commit dc6079821bd2868f20ab89638ec9026357d8a331 Author: wehub-resource-sync Date: Mon Jul 13 13:24:47 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000..ec5222b --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,34 @@ +version: "2" +exclude_patterns: + - rasa/core/utils.py # codeclimate has some encoding issues with this files because of emojis + - .* + - .github/ + - CHANGELOG.mdx + - CODEOWNERS + - CODE_OF_CONDUCT.md + - Dockerfile + - LICENSE.txt + - Makefile + - NOTICE + - PRONCIPLES.md + - README.md + - binder/ + - changelog/ + - data/ + - docs/ + - examples/ + - poetry.lock + - pyproject.toml + - tests/ + - stubs/ + - scripts/ + - security.txt + - secrets.tar.enc +checks: + argument-count: + config: + threshold: 10 + file-lines: + enabled: false + method-count: + enabled: false diff --git a/.deepsource.toml b/.deepsource.toml new file mode 100644 index 0000000..306150a --- /dev/null +++ b/.deepsource.toml @@ -0,0 +1,12 @@ +version = 1 + +test_patterns = ["tests/**"] + +exclude_patterns = ["docs/**"] + +[[analyzers]] +name = "python" +enabled = true + + [analyzers.meta] + runtime_version = "3.x.x" diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..b49b2d9 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,27 @@ +# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster +ARG VARIANT=3-bullseye +FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} + +ENV PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=off \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=100 + +# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 +ARG NODE_VERSION="none" +RUN if [ "${NODE_VERSION}" != "nne" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi + +# [Optional] If your requirements rarely change, uncomment this section to add them to the image. +# COPY requirements.txt /tmp/pip-tmp/ +# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ +# && rm -rf /tmp/pip-tmp +RUN pip install poetry==1.1.10 pre-commit +COPY ../poetry.lock ../pyproject.toml /tmp/pip-tmp/rasa/ +RUN cd /tmp/pip-tmp/rasa && poetry config virtualenvs.create false \ + && poetry install --no-interaction --no-ansi --no-root + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..ce3ffbb --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,50 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.233.0/containers/python-3-postgres +// Update the VARIANT arg in docker-compose.yml to pick a Python version +{ + "name": "Rasa Open Source", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/rasa", + // Set *default* container specific settings.json values on container create. + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", + "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", + "python.linting.ruffPath": "/usr/local/py-utils/bin/ruff", + "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", + "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", + "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", + "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", + "python.testing.pytestPath": "/usr/local/py-utils/bin/pytest" + }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance" + ], + // memory is required for frontend build...fails for machines with less than 10g + // "hostRequirements": { + // "memory": "12gb" + // }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // This can be used to network with other containers or the host. + "forwardPorts": [ + 5005 + ], + // Use 'postCreateCommand' to run commands after the container is created. + "updateContentCommand": "make install && make install-docs && cd / && mkdir example && rasa init --no-prompt --init-dir example", + // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + //"remoteUser": "vscode", + "features": { + "docker-in-docker": "20.10", + "docker-from-docker": "20.10", + "git": "os-provided", + "github-cli": "latest", + "sshd": "latest" + } +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..6224abc --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,57 @@ +version: '3.8' + +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + args: + # Update 'VARIANT' to pick a version of Python: 3, 3.10, 3.9, 3.8, 3.7, 3.6 + # Append -bullseye or -buster to pin to an OS version. + # Use -bullseye variants on local arm64/Apple Silicon. + VARIANT: "3.8" + # Optional Node.js version to install + NODE_VERSION: "16" + environment: + DB_DRIVER: "postgresql" + DB_USER: "admin" + DB_PASSWORD: "postgres" + + volumes: + - ..:/workspaces/rasa:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:db + # Uncomment the next line to use a non-root user for all processes. + # user: vscode + + db: + image: "bitnamilegacy/postgresql:11.15.0" + restart: unless-stopped + volumes: + - postgres-data:/bitnami/postgresql + environment: + POSTGRESQL_USERNAME: admin + POSTGRESQL_DATABASE: rasa + POSTGRESQL_PASSWORD: postgres + + duckling: + restart: unless-stopped + image: "rasa/duckling:0.2.0.2" + expose: + - "8000" + command: ["duckling-example-exe", "--no-access-log", "--no-error-log"] + + redis: + restart: unless-stopped + image: "bitnamilegacy/redis:6.2.7" + environment: + REDIS_PASSWORD: "redis" + expose: + - "6379" + +volumes: + postgres-data: null diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6afcdc0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +docker* +docs +.git* +**/*.pyc +**/__pycache__ +!docker/configs +rasa/tests +rasa/scripts +data/ +examples/ +docker-data/* diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8f12c43 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* -text +# Reclassifies `Dockerfile*` files as Dockerfile: +Dockerfile.* linguist-language=Dockerfile diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..2ce6e44 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,2 @@ + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..2683d11 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Bug Report + url: https://rasa-open-source.atlassian.net/browse/OSS + about: Create a report to help us improve https://rasa-open-source.atlassian.net/browse/OSS + - name: Feature request + url: https://rasa-open-source.atlassian.net/browse/OSS + about: Suggest an idea on how to improve Rasa https://rasa-open-source.atlassian.net/browse/OSS + - name: Ask a question + url: https://forum.rasa.com/ + about: If you have a "How do I?" question please ask in the forum https://forum.rasa.com diff --git a/.github/PULL_REQUEST_AUTOMATIC_TEMPLATE.md b/.github/PULL_REQUEST_AUTOMATIC_TEMPLATE.md new file mode 100644 index 0000000..e527415 --- /dev/null +++ b/.github/PULL_REQUEST_AUTOMATIC_TEMPLATE.md @@ -0,0 +1,10 @@ +:bulb: This pull request was created automatically to merge a release branch back into the `main` branch. + +The changes you see here should have already been reviewed by someone, and shouldn't need an extra +review. Nonetheless, if you notice something that needs to be addressed, please reach out to the person +responsible for the original changes. In case additional changes need to be made, they need to target the release branch +(not this pull request nor `main`). + +:auto_rickshaw: This PR should be merged automatically once it has been approved. If it doesn't happen: +- [ ] Handle merge conflicts +- [ ] Fix build errors diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..230ee60 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +**Proposed changes**: +- ... + +**Status (please check what you already did)**: +- [ ] added some tests for the functionality +- [ ] updated the documentation +- [ ] updated the changelog (please check [changelog](https://github.com/RasaHQ/rasa/tree/main/changelog) for instructions) +- [ ] reformat files using `black` (please check [Readme](https://github.com/RasaHQ/rasa#code-style) for instructions) diff --git a/.github/change_filters.yml b/.github/change_filters.yml new file mode 100644 index 0000000..05b5184 --- /dev/null +++ b/.github/change_filters.yml @@ -0,0 +1,28 @@ +backend: + - 'pyproject.toml' + - 'poetry.lock' + - 'rasa/**/*' + - 'tests/**/*' + - 'data/**/*' + - 'examples/**/*' + - 'Makefile' + - '.github/workflows/continous-integration.yml' + - '.github/workflows/security-scans.yml' + +docker: + - 'pyproject.toml' + - 'poetry.lock' + - 'rasa/**/*' + - 'docker/**/*' + - 'Makefile' + +docs: + - 'docs/**/*' + - 'changelog/*' + - 'CHANGELOG.mdx' + - 'tests/docs/*' + - 'data/**/*' + - 'examples/**/*' + - 'Makefile' + - '.github/workflows/documentation.yml' + - '.github/workflows/ci-docs-tests.yml' diff --git a/.github/configs/mr-test-example.yaml b/.github/configs/mr-test-example.yaml new file mode 100644 index 0000000..a8b5207 --- /dev/null +++ b/.github/configs/mr-test-example.yaml @@ -0,0 +1,49 @@ +## Example configuration +#################### syntax ################# +## include: +## - dataset: [""] +## config: [""] +# +## Example: +## include: +## - dataset: ["Carbon Bot"] +## config: ["Sparse + DIET(bow) + ResponseSelector(bow)"] +# +## Shortcut: +## You can use the "all" shortcut to include all available configurations or datasets +# +## Example: Use the "Sparse + EmbeddingIntent + ResponseSelector(bow)" configuration +## for all available datasets +## include: +## - dataset: ["all"] +## config: ["Sparse + DIET(bow) + ResponseSelector(bow)"] +# +## Example: Use all available configurations for the "Carbon Bot" and "Sara" datasets +## and for the "Hermit" dataset use the "Sparse + DIET + ResponseSelector(T2T)" and +## "BERT + DIET + ResponseSelector(T2T)" configurations: +## include: +## - dataset: ["Carbon Bot", "Sara"] +## config: ["all"] +## - dataset: ["Hermit"] +## config: ["Sparse + DIET(seq) + ResponseSelector(t2t)", "BERT + DIET(seq) + ResponseSelector(t2t)"] +# +## Example: Define a branch name to check-out for a dataset repository. Default branch is 'main' +## dataset_branch: "test-branch" +## include: +## - dataset: ["Carbon Bot", "Sara"] +## config: ["all"] +# +## Example: Define number of repetitions. This will inform how often to repeat all runs defined in the include section. Default is 1 +## num_repetitions: 2 +## include: +## - dataset: ["Carbon Bot", "Sara"] +## config: ["Sparse + DIET(seq) + ResponseSelector(t2t)"] +## +## Shortcuts: +## You can use the "all" shortcut to include all available configurations or datasets. +## You can use the "all-nlu" shortcut to include all available NLU configurations or datasets. +## You can use the "all-core" shortcut to include all available core configurations or datasets. + +include: + - dataset: ["Carbon Bot"] + config: ["Sparse + DIET(bow) + ResponseSelector(bow)"] diff --git a/.github/configs/mr-test-schedule.json b/.github/configs/mr-test-schedule.json new file mode 100644 index 0000000..ab4ae70 --- /dev/null +++ b/.github/configs/mr-test-schedule.json @@ -0,0 +1,3 @@ +{ + "body": "```yml\r\ninclude:\r\n - dataset: [\"all\"]\r\n config: [\"all\"]\r\n```" +} diff --git a/.github/configs/tf-cuda.json b/.github/configs/tf-cuda.json new file mode 100644 index 0000000..96b3119 --- /dev/null +++ b/.github/configs/tf-cuda.json @@ -0,0 +1,25 @@ +{ + "default_image_tag": "latest", + "config": [ + { + "TF": "2.3", + "IMAGE_TAG": "cuda-10.1-cudnn7" + }, + { + "TF": "2.5", + "IMAGE_TAG": "cuda-11.2.0-cudnn8" + }, + { + "TF": "2.6", + "IMAGE_TAG": "cuda-11.2.0-cudnn8" + }, + { + "TF": "2.7", + "IMAGE_TAG": "cuda-11.2.0-cudnn8" + }, + { + "TF": "2.11", + "IMAGE_TAG": "cuda-11.2.0-cudnn8" + } + ] +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7fe51d4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + time: "13:00" + pull-request-branch-name: + separator: "-" + open-pull-requests-limit: 10 + labels: + - type:dependencies + - release:main + ignore: + - dependency-name: prompt-toolkit + versions: + - "> 2.0.10" + - dependency-name: pytest-asyncio + versions: + - "> 0.10.0" + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + time: "12:00" + pull-request-branch-name: + separator: "-" + open-pull-requests-limit: 10 + reviewers: + - RasaHQ/infrastructure-squad + labels: + - type:dependencies diff --git a/.github/matchers/flake8-error-matcher.json b/.github/matchers/flake8-error-matcher.json new file mode 100644 index 0000000..af1442a --- /dev/null +++ b/.github/matchers/flake8-error-matcher.json @@ -0,0 +1,17 @@ +{ + "problemMatcher": [ + { + "owner": "flake8-error", + "severity": "error", + "pattern": [ + { + "regexp": "^([^:]+):(\\d+):(\\d+):\\s+([DCFNWE]\\d+\\s+.+)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + } + ] + } + ] +} diff --git a/.github/no-response.yml b/.github/no-response.yml new file mode 100644 index 0000000..baba728 --- /dev/null +++ b/.github/no-response.yml @@ -0,0 +1,12 @@ +# Configuration for probot-no-response - https://github.com/probot/no-response + +# Number of days of inactivity before an Issue is closed for lack of response +daysUntilClose: 14 +# Label requiring a response +responseRequiredLabel: status:more-details-needed +# Comment to post when closing an Issue for lack of response. Set to `false` to disable +closeComment: > + This issue has been automatically closed because there has been no response + to our request for more information from the original author. Without this, + we don't have enough information to help you. Please comment below with the + requested information if you still need help. diff --git a/.github/poetry_version.txt b/.github/poetry_version.txt new file mode 100644 index 0000000..271f9b9 --- /dev/null +++ b/.github/poetry_version.txt @@ -0,0 +1,2 @@ +# The poetry version is stored in a separate file due to the https://github.com/python-poetry/poetry/issues/3316 +poetry-version=1.8.2 diff --git a/.github/runner/github-runner-deployment.yaml.tmpl b/.github/runner/github-runner-deployment.yaml.tmpl new file mode 100644 index 0000000..d39dbac --- /dev/null +++ b/.github/runner/github-runner-deployment.yaml.tmpl @@ -0,0 +1,74 @@ +# GitHub Runner deployment - uses to deploy a github runner +# which is used by the CI for model regression tests +apiVersion: apps/v1 +kind: Deployment +metadata: + name: github-runner-{{getenv "GITHUB_RUN_ID"}} + namespace: github-runner + labels: + app: github-runner + pod: github-runner-{{getenv "GITHUB_RUN_ID"}} +spec: + replicas: {{getenv "NUM_REPLICAS" "1"}} + selector: + matchLabels: + app: github-runner + pod: github-runner-{{getenv "GITHUB_RUN_ID"}} + template: + metadata: + labels: + app: github-runner + pod: github-runner-{{getenv "GITHUB_RUN_ID"}} + spec: + priorityClassName: high-priority + automountServiceAccountToken: false + terminationGracePeriodSeconds: 720 + containers: + - name: github-runner + image: {{getenv "GH_RUNNER_IMAGE"}}:{{getenv "GH_RUNNER_IMAGE_TAG" "latest"}} + imagePullPolicy: Always + livenessProbe: + initialDelaySeconds: 30 + periodSeconds: 15 + failureThreshold: 3 + exec: + command: + - /bin/bash + - -c + - "if [[ `curl -sX GET -H \"Authorization: token ${GITHUB_PAT}\" \ + https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPOSITORY}/actions/runners | \ + jq -r '.runners[] | select(.name == \"'${POD_NAME}'\") | .status'` == \"offline\" ]]; then \ + echo \"The GitHub API returns offline status for the ${POD_NAME} runner\" && exit 1; fi" + resources: + limits: + nvidia.com/gpu: 1 + requests: + nvidia.com/gpu: 1 + memory: 10G + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + # RUNNER_LABELS - defines labels + # with which a github-runner will be registered + - name: RUNNER_LABELS + value: "self-hosted,gpu,kubernetes,{{getenv "GITHUB_RUN_ID"}}" + # GITHUB_OWNER - a name of the repository owner + - name: GITHUB_OWNER + valueFrom: + secretKeyRef: + name: github-rasa + key: owner + # GITHUB_REPOSITORY - a name of the repository + - name: GITHUB_REPOSITORY + valueFrom: + secretKeyRef: + name: github-rasa + key: repository + # GITHUB_PAT - Personal Access Token + - name: GITHUB_PAT + valueFrom: + secretKeyRef: + name: github-rasa + key: pat diff --git a/.github/scripts/download_pretrained.py b/.github/scripts/download_pretrained.py new file mode 100644 index 0000000..1ba712f --- /dev/null +++ b/.github/scripts/download_pretrained.py @@ -0,0 +1,120 @@ +import argparse +import logging +import time +from typing import List, NamedTuple, Optional, Text + +from transformers import AutoTokenizer, TFAutoModel + +import rasa.shared.utils.io +from rasa.nlu.utils.hugging_face.registry import ( + model_weights_defaults, + model_class_dict, +) + +logger = logging.getLogger(__name__) + +COMP_NAME = "LanguageModelFeaturizer" +DEFAULT_MODEL_NAME = "bert" + + +class LmfSpec(NamedTuple): + """Holds information about the LanguageModelFeaturizer.""" + + model_name: Text + model_weights: Text + cache_dir: Optional[Text] = None + + +def get_model_name_and_weights_from_config( + config_path: str, +) -> List[LmfSpec]: + config = rasa.shared.utils.io.read_config_file(config_path) + logger.info(config) + steps = config.get("pipeline", []) + + # Look for LanguageModelFeaturizer steps + steps = list(filter(lambda x: x["name"] == COMP_NAME, steps)) + + lmf_specs = [] + for lmfeat_step in steps: + if "model_name" not in lmfeat_step: + if "model_weights" in lmfeat_step: + model_weights = lmfeat_step["model_weights"] + raise KeyError( + "When model_name is not given, then model_weights cannot be set. " + f"Here, model_weigths is set to {model_weights}" + ) + model_name = DEFAULT_MODEL_NAME + model_weights = model_weights_defaults[DEFAULT_MODEL_NAME] + else: + model_name = lmfeat_step["model_name"] + + if model_name not in model_class_dict: + raise KeyError( + f"'{model_name}' not a valid model name. Choose from " + f"{str(list(model_class_dict.keys()))} or create" + f"a new class inheriting from this class to support your model." + ) + + model_weights = lmfeat_step.get("model_weights") + if not model_weights: + logger.info( + f"Model weights not specified. Will choose default model " + f"weights: {model_weights_defaults[model_name]}" + ) + model_weights = model_weights_defaults[model_name] + cache_dir = lmfeat_step.get("cache_dir", None) + lmf_specs.append(LmfSpec(model_name, model_weights, cache_dir)) + + return lmf_specs + + +def instantiate_to_download(comp: LmfSpec) -> None: + """Instantiates Auto class instances, but only to download.""" + + _ = AutoTokenizer.from_pretrained(comp.model_weights, cache_dir=comp.cache_dir) + logger.info("Done with AutoTokenizer, now doing TFAutoModel") + _ = TFAutoModel.from_pretrained(comp.model_weights, cache_dir=comp.cache_dir) + + +def download(config_path: str): + lmf_specs = get_model_name_and_weights_from_config(config_path) + + if not lmf_specs: + logger.info(f"No {COMP_NAME} found, therefore, skipping download") + return + + for lmf_spec in lmf_specs: + logger.info( + f"model_name: {lmf_spec.model_name}, " + f"model_weights: {lmf_spec.model_weights}, " + f"cache_dir: {lmf_spec.cache_dir}" + ) + start = time.time() + + instantiate_to_download(lmf_spec) + + duration_in_sec = time.time() - start + logger.info(f"Instantiating Auto classes takes {duration_in_sec:.2f}seconds") + + +def create_argument_parser() -> argparse.ArgumentParser: + """Downloads pretrained models, i.e., Huggingface weights.""" + parser = argparse.ArgumentParser( + description="Downloads pretrained models, i.e., Huggingface weights, " + "e.g. path to bert_diet_responset2t.yml" + ) + parser.add_argument( + "-c", + "--config", + type=str, + required=True, + help="The path to the config yaml file.", + ) + return parser + + +if __name__ == "__main__": + arg_parser = create_argument_parser() + cmdline_args = arg_parser.parse_args() + download(cmdline_args.config) diff --git a/.github/scripts/mr_generate_summary.py b/.github/scripts/mr_generate_summary.py new file mode 100644 index 0000000..4d4b428 --- /dev/null +++ b/.github/scripts/mr_generate_summary.py @@ -0,0 +1,55 @@ +# Collect the results of the various model test runs which are done as part of +# the model regression CI pipeline and dump them as a single file artifact. +# This artifact will the then be published at the end of the tests. +from collections import defaultdict +import json +import os +from pathlib import Path +from typing import Dict, List + + +def combine_result( + result1: Dict[str, dict], result2: Dict[str, Dict[str, Dict]] +) -> Dict[str, Dict[str, List]]: + """Combines 2 result dicts to accumulated dict of the same format. + + Args: + result1: dict of key: dataset, value: (dict of key: config, value: list of res) + Example: { + "Carbon Bot": { + "Sparse + DIET(bow) + ResponseSelector(bow)": [{ + "Entity Prediction": { + "macro avg": { + "f1-score": 0.88, + } + }, + "test_run_time": "47s", + }] + } + } + result2: dict of key: dataset, value: (dict of key: config, value: list of res) + + Returns: + dict of key: dataset, and value: (dict of key: config value: list of results) + """ + combined_dict = defaultdict(lambda: defaultdict(list)) + for new_dict in [result1, result2]: + for dataset, results_for_dataset in new_dict.items(): + for config, res in results_for_dataset.items(): + for res_dict in res: + combined_dict[dataset][config].append(res_dict) + return combined_dict + + +if __name__ == "__main__": + data = {} + reports_dir = Path(os.environ["REPORTS_DIR"]) + reports_paths = list(reports_dir.glob("*/report.json")) + + for report_path in reports_paths: + report_dict = json.load(open(report_path)) + data = combine_result(data, report_dict) + + summary_file = os.environ["SUMMARY_FILE"] + with open(summary_file, "w") as f: + json.dump(data, f, sort_keys=True, indent=2) diff --git a/.github/scripts/mr_publish_results.py b/.github/scripts/mr_publish_results.py new file mode 100644 index 0000000..a71d743 --- /dev/null +++ b/.github/scripts/mr_publish_results.py @@ -0,0 +1,293 @@ +# Send model regression test results to Datadog +# with a summary of all test results. +# Also write them into a report file. +import copy +import datetime +import json +import os +from typing import Any, Dict, List, Text, Tuple + +from datadog_api_client.v1 import ApiClient, Configuration +from datadog_api_client.v1.api.metrics_api import MetricsApi +from datadog_api_client.v1.model.metrics_payload import MetricsPayload +from datadog_api_client.v1.model.point import Point +from datadog_api_client.v1.model.series import Series + +DD_ENV = "rasa-regression-tests" +DD_SERVICE = "rasa" +METRIC_RUNTIME_PREFIX = "rasa.perf.benchmark." +METRIC_ML_PREFIX = "rasa.perf.ml." +CONFIG_REPOSITORY = "training-data" + +TASK_MAPPING = { + "intent_report.json": "intent_classification", + "CRFEntityExtractor_report.json": "entity_prediction", + "DIETClassifier_report.json": "entity_prediction", + "response_selection_report.json": "response_selection", + "story_report.json": "story_prediction", +} + +METRICS = { + "test_run_time": "TEST_RUN_TIME", + "train_run_time": "TRAIN_RUN_TIME", + "total_run_time": "TOTAL_RUN_TIME", +} + +MAIN_TAGS = { + "config": "CONFIG", + "dataset": "DATASET_NAME", +} + +OTHER_TAGS = { + "config_repository_branch": "DATASET_REPOSITORY_BRANCH", + "dataset_commit": "DATASET_COMMIT", + "accelerator_type": "ACCELERATOR_TYPE", + "type": "TYPE", + "index_repetition": "INDEX_REPETITION", + "host_name": "HOST_NAME", +} + +GIT_RELATED_TAGS = { + "pr_id": "PR_ID", + "pr_url": "PR_URL", + "github_event": "GITHUB_EVENT_NAME", + "github_run_id": "GITHUB_RUN_ID", + "github_sha": "GITHUB_SHA", + "workflow": "GITHUB_WORKFLOW", +} + + +def create_dict_of_env(name_to_env: Dict[Text, Text]) -> Dict[Text, Text]: + return {name: os.environ[env_var] for name, env_var in name_to_env.items()} + + +def _get_is_external_and_dataset_repository_branch() -> Tuple[bool, Text]: + is_external = os.environ["IS_EXTERNAL"] + dataset_repository_branch = os.environ["DATASET_REPOSITORY_BRANCH"] + if is_external.lower() in ("yes", "true", "t", "1"): + is_external_flag = True + dataset_repository_branch = os.environ["EXTERNAL_DATASET_REPOSITORY_BRANCH"] + else: + is_external_flag = False + return is_external_flag, dataset_repository_branch + + +def prepare_datasetrepo_and_external_tags() -> Dict[Text, Any]: + is_external, dataset_repo_branch = _get_is_external_and_dataset_repository_branch() + return { + "dataset_repository_branch": dataset_repo_branch, + "external_dataset_repository": is_external, + } + + +def prepare_dsrepo_and_external_tags_as_str() -> Dict[Text, Text]: + return { + "dataset_repository_branch": os.environ["DATASET_REPOSITORY_BRANCH"], + "external_dataset_repository": os.environ["IS_EXTERNAL"], + } + + +def transform_to_seconds(duration: Text) -> float: + """Transform string (with hours, minutes, and seconds) to seconds. + + Args: + duration: Examples: '1m27s', '1m27.3s', '27s', '1h27s', '1h1m27s' + + Raises: + Exception: If the input is not supported. + + Returns: + Duration converted in seconds. + """ + h_split = duration.split("h") + if len(h_split) == 1: + rest = h_split[0] + hours = 0 + else: + hours = int(h_split[0]) + rest = h_split[1] + m_split = rest.split("m") + if len(m_split) == 2: + minutes = int(m_split[0]) + seconds = float(m_split[1].rstrip("s")) + elif len(m_split) == 1: + minutes = 0 + seconds = float(m_split[0].rstrip("s")) + else: + raise Exception(f"Unsupported duration: {duration}") + overall_seconds = hours * 60 * 60 + minutes * 60 + seconds + return overall_seconds + + +def prepare_ml_metric(result: Dict[Text, Any]) -> Dict[Text, float]: + """Converts a nested result dict into a list of metrics. + + Args: + result: Example + {'accuracy': 1.0, + 'weighted avg': { + 'precision': 1.0, 'recall': 1.0, 'f1-score': 1.0, 'support': 28 + } + } + + Returns: + Dict of metric name and metric value + """ + metrics_ml = {} + result = copy.deepcopy(result) + result.pop("file_name", None) + task = result.pop("task", None) + + for metric_name, metric_value in result.items(): + if isinstance(metric_value, float): + metric_full_name = f"{task}.{metric_name}" + metrics_ml[metric_full_name] = float(metric_value) + elif isinstance(metric_value, dict): + for mname, mval in metric_value.items(): + metric_full_name = f"{task}.{metric_name}.{mname}" + metrics_ml[metric_full_name] = float(mval) + else: + raise Exception( + f"metric_value {metric_value} has", + f"unexpected type {type(metric_value)}", + ) + return metrics_ml + + +def prepare_ml_metrics(results: List[Dict[Text, Any]]) -> Dict[Text, float]: + metrics_ml = {} + for result in results: + new_metrics_ml = prepare_ml_metric(result) + metrics_ml.update(new_metrics_ml) + + return metrics_ml + + +def prepare_datadog_tags() -> List[Text]: + tags = { + "env": DD_ENV, + "service": DD_SERVICE, + "branch": os.environ["BRANCH"], + "config_repository": CONFIG_REPOSITORY, + **prepare_dsrepo_and_external_tags_as_str(), + **create_dict_of_env(MAIN_TAGS), + **create_dict_of_env(OTHER_TAGS), + **create_dict_of_env(GIT_RELATED_TAGS), + } + tags_list = [f"{k}:{v}" for k, v in tags.items()] + return tags_list + + +def send_to_datadog(results: List[Dict[Text, Any]]) -> None: + """Sends metrics to datadog.""" + # Prepare + tags_list = prepare_datadog_tags() + timestamp = datetime.datetime.now().timestamp() + series = [] + + # Send metrics about runtime + metrics_runtime = create_dict_of_env(METRICS) + for metric_name, metric_value in metrics_runtime.items(): + overall_seconds = transform_to_seconds(metric_value) + series.append( + Series( + metric=f"{METRIC_RUNTIME_PREFIX}{metric_name}.gauge", + type="gauge", + points=[Point([timestamp, overall_seconds])], + tags=tags_list, + ) + ) + + # Send metrics about ML model performance + metrics_ml = prepare_ml_metrics(results) + for metric_name, metric_value in metrics_ml.items(): + series.append( + Series( + metric=f"{METRIC_ML_PREFIX}{metric_name}.gauge", + type="gauge", + points=[Point([timestamp, float(metric_value)])], + tags=tags_list, + ) + ) + + body = MetricsPayload(series=series) + with ApiClient(Configuration()) as api_client: + api_instance = MetricsApi(api_client) + response = api_instance.submit_metrics(body=body) + if response.get("status") != "ok": + print(response) + + +def read_results(file: Text) -> Dict[Text, Any]: + with open(file) as json_file: + data = json.load(json_file) + + keys = [ + "accuracy", + "weighted avg", + "macro avg", + "micro avg", + "conversation_accuracy", + ] + result = {key: data[key] for key in keys if key in data} + + return result + + +def get_result(file_name: Text, file: Text) -> Dict[Text, Any]: + result = read_results(file) + result["file_name"] = file_name + result["task"] = TASK_MAPPING[file_name] + return result + + +def send_all_to_datadog() -> None: + results = [] + for dirpath, dirnames, files in os.walk(os.environ["RESULT_DIR"]): + for f in files: + if any(f.endswith(valid_name) for valid_name in TASK_MAPPING.keys()): + result = get_result(f, os.path.join(dirpath, f)) + results.append(result) + send_to_datadog(results) + + +def generate_json(file: Text, task: Text, data: dict) -> dict: + config = os.environ["CONFIG"] + dataset = os.environ["DATASET_NAME"] + + if dataset not in data: + data = {dataset: {config: []}, **data} + elif config not in data[dataset]: + data[dataset] = {config: [], **data[dataset]} + + assert len(data[dataset][config]) <= 1 + + data[dataset][config] = [ + { + "config_repository": CONFIG_REPOSITORY, + **prepare_datasetrepo_and_external_tags(), + **create_dict_of_env(METRICS), + **create_dict_of_env(OTHER_TAGS), + **(data[dataset][config][0] if data[dataset][config] else {}), + task: read_results(file), + } + ] + return data + + +def create_report_file() -> None: + data = {} + for dirpath, dirnames, files in os.walk(os.environ["RESULT_DIR"]): + for f in files: + if f not in TASK_MAPPING.keys(): + continue + + data = generate_json(os.path.join(dirpath, f), TASK_MAPPING[f], data) + + with open(os.environ["SUMMARY_FILE"], "w") as f: + json.dump(data, f, sort_keys=True, indent=2) + + +if __name__ == "__main__": + send_all_to_datadog() + create_report_file() diff --git a/.github/scripts/start_dd_agent.sh b/.github/scripts/start_dd_agent.sh new file mode 100755 index 0000000..8d2442c --- /dev/null +++ b/.github/scripts/start_dd_agent.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +DD_API_KEY=$1 +ACCELERATOR_TYPE=$2 +NVML_INTERVAL_IN_SEC=${3:-15} # 15 seconds are the default interval + +# Install Datadog system agent +DD_AGENT_MAJOR_VERSION=7 DD_API_KEY=$DD_API_KEY DD_SITE="datadoghq.eu" bash -c "$(curl -L https://s3.amazonaws.com/dd-agent/scripts/install_script.sh)" +DATADOG_YAML_PATH=/etc/datadog-agent/datadog.yaml +sudo chmod 666 $DATADOG_YAML_PATH + +# Associate metrics with tags and env +{ + echo "env: rasa-regression-tests" + echo "tags:" + echo "- service:rasa" + echo "- accelerator_type:${ACCELERATOR_TYPE}" + echo "- dataset:${DATASET_NAME}" + echo "- config:${CONFIG}" + echo "- dataset_commit:${DATASET_COMMIT}" + echo "- branch:${BRANCH}" + echo "- github_sha:${GITHUB_SHA}" + echo "- pr_id:${PR_ID:-schedule}" + echo "- pr_url:${PR_URL:-schedule}" + echo "- type:${TYPE}" + echo "- dataset_repository_branch:${DATASET_REPOSITORY_BRANCH}" + echo "- external_dataset_repository:${IS_EXTERNAL:-none}" + echo "- config_repository:training-data" + echo "- config_repository_branch:${DATASET_REPOSITORY_BRANCH}" + echo "- workflow:${GITHUB_WORKFLOW:-none}" + echo "- github_run_id:${GITHUB_RUN_ID:-none}" + echo "- github_event:${GITHUB_EVENT_NAME:-none}" + echo "- index_repetition:${INDEX_REPETITION}" + echo "- host_name:${HOST_NAME}" + echo "" + echo "apm_config:" + echo " enabled: true" + echo "process_config:" + echo " enabled: false" + echo "use_dogstatsd: true" +} >> $DATADOG_YAML_PATH + +# Enable system_core integration +sudo mv /etc/datadog-agent/conf.d/system_core.d/conf.yaml.example /etc/datadog-agent/conf.d/system_core.d/conf.yaml + +if [[ "${ACCELERATOR_TYPE}" == "GPU" ]]; then + # Install and enable NVML integration + sudo datadog-agent integration --allow-root install -t datadog-nvml==1.0.1 + sudo -u dd-agent -H /opt/datadog-agent/embedded/bin/pip3 install grpcio pynvml + NVML_CONF_FPATH="/etc/datadog-agent/conf.d/nvml.d/conf.yaml" + sudo mv "${NVML_CONF_FPATH}.example" ${NVML_CONF_FPATH} + if [[ "${NVML_INTERVAL_IN_SEC}" != 15 ]]; then + # Append a line to the NVML config file + sudo echo " min_collection_interval: ${NVML_INTERVAL_IN_SEC}" | sudo tee -a ${NVML_CONF_FPATH} > /dev/null + fi +fi + +# Apply changes +sudo service datadog-agent stop + +# Restart agent (such that GPU/NVML metrics are collected) +# Adusted code from /etc/init/datadog-agent.conf +INSTALL_DIR="/opt/datadog-agent" +AGENTPATH="$INSTALL_DIR/bin/agent/agent" +PIDFILE="$INSTALL_DIR/run/agent.pid" +AGENT_USER="dd-agent" +LD_LIBRARY_PATH="/usr/local/cuda/extras/CUPTI/lib64:/usr/local/cuda/lib64:/usr/local/nvidia/lib:/usr/local/nvidia/lib64" +sudo -E start-stop-daemon --start --background --quiet --chuid $AGENT_USER --pidfile $PIDFILE --user $AGENT_USER --startas /bin/bash -- -c "LD_LIBRARY_PATH=$LD_LIBRARY_PATH $AGENTPATH run -p $PIDFILE" + +# Adusted code from /etc/init/datadog-agent-process.conf +TRACE_AGENTPATH="$INSTALL_DIR/embedded/bin/trace-agent" +TRACE_PIDFILE="$INSTALL_DIR/run/trace-agent.pid" +sudo -E start-stop-daemon --start --background --quiet --chuid $AGENT_USER --pidfile $TRACE_PIDFILE --user $AGENT_USER --startas /bin/bash -- -c "LD_LIBRARY_PATH=$LD_LIBRARY_PATH $TRACE_AGENTPATH --config $DATADOG_YAML_PATH --pid $TRACE_PIDFILE" + +# Adusted code from /etc/init/datadog-agent-trace.conf +PROCESS_AGENTPATH="$INSTALL_DIR/embedded/bin/process-agent" +PROCESS_PIDFILE="$INSTALL_DIR/run/process-agent.pid" +SYSTEM_PROBE_YAML="/etc/datadog-agent/system-probe.yaml" +sudo -E start-stop-daemon --start --background --quiet --chuid $AGENT_USER --pidfile $PROCESS_PIDFILE --user $AGENT_USER --startas /bin/bash -- -c "LD_LIBRARY_PATH=$LD_LIBRARY_PATH $PROCESS_AGENTPATH --config=$DATADOG_YAML_PATH --sysprobe-config=$SYSTEM_PROBE_YAML --pid=$PROCESS_PIDFILE" diff --git a/.github/scripts/validate_cpu.py b/.github/scripts/validate_cpu.py new file mode 100644 index 0000000..b9c8ec3 --- /dev/null +++ b/.github/scripts/validate_cpu.py @@ -0,0 +1,14 @@ +import sys + +import tensorflow as tf + + +def check_gpu_not_available(): + num_gpus = len(tf.config.list_physical_devices("GPU")) + print(f"Num GPUs Available: {num_gpus}") + if num_gpus > 0: + sys.exit(1) + + +if __name__ == "__main__": + check_gpu_not_available() diff --git a/.github/scripts/validate_gpus.py b/.github/scripts/validate_gpus.py new file mode 100644 index 0000000..6ee4e72 --- /dev/null +++ b/.github/scripts/validate_gpus.py @@ -0,0 +1,14 @@ +import sys + +import tensorflow as tf + + +def check_gpu_available(): + num_gpus = len(tf.config.list_physical_devices("GPU")) + print(f"Num GPUs Available: {num_gpus}") + if num_gpus <= 0: + sys.exit(1) + + +if __name__ == "__main__": + check_gpu_available() diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..46299ba --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,18 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 90 +# Label to use when marking an issue as stale +staleLabel: stale + +pulls: + # Give more time before closing PRs + daysUntilClose: 21 + # Comment to post when marking a PR as stale. Set to `false` to disable + markComment: > + This PR has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. + # Comment to post when closing a stale PR. Set to `false` to disable + closeComment: > + This PR has been automatically closed due to inactivity. Please reopen + this PR or a new one if you plan to follow-up on it. Thank you for your + contributions. diff --git a/.github/templates/README.md b/.github/templates/README.md new file mode 100644 index 0000000..7b01404 --- /dev/null +++ b/.github/templates/README.md @@ -0,0 +1,63 @@ +# gomplate templates for GitHub Actions + +This document describes gomplate templates use for GitHub Actions. + +## Requirements + +You have to have installed [gomplate](https://docs.gomplate.ca/installing/) tool in order to render a template file. + +> gomplate is a template renderer which supports a growing list of datastores, such as: JSON (including EJSON - encrypted JSON), YAML, AWS EC2 metadata, BoltDB, Hashicorp Consul and Hashicorp Vault secrets. + +## Templates + +Below you can find a list of templates with their description and the commands to render them. + +### `configuration_variables.tmpl` + +The template maps dataset name and configuration name for the model regression tests into paths where files are located. As a result, the template returns two environment variables `DATASET` and `CONFIG` which contain paths to file/directory. + +#### How to run locally + +```shell +gomplate -d mapping= -f .github/templates/configuration_variables.tmpl +``` + +### `model_regression_test_config_comment.tmpl` + +The template returns a comment message which is used as a help description in a PR. The template reads the `.github/configs/mr-test-example.yaml` file and include it as example content. + +The help message is triggered by adding `status:model-regression-tests` label. +Comment with a help message is added if a PR doesn't contain a comment with a configuration for the model regression tests. + +#### How to run locally + +```shell +gomplate -f .github/templates/model_regression_test_config_comment.tmpl +``` + +The template uses the `GITHUB_ACTOR` environment variable, you have to export the variable before executing the command. + +### `model_regression_test_config_to_json.tmpl` + +The template reads an issue/a PR comment and transforms a YAML code block into JSON. + +#### How to run locally + +```shell +gomplate -d github=https://api.github.com/repos/${{ github.repository }}/issues/comments/${{ comment-id }} -H 'github=Authorization:token ${{ secrets.GITHUB_TOKEN }}' -f .github/templates/model_regression_test_config_to_json.tmpl +``` + +### `model_regression_test_results.tmpl` + +The template reads a file with a report (the report file is available as an artifact in the model regression tests workflow) and returns markdown table with a summary of tests. + +#### How to run locally + +```shell +gomplate -d data=report.json -d results_main=report_main.json -f .github/templates/model_regression_test_results.tmpl +``` + +In order to be able to use the `.github/templates/model_regression_test_results.tmpl` template you need the following files: + +- `report.json` - the file with a report generated by the `CI - Model Regression` workflow run in a PR. The report is available to download as an artifact in the workflow related to the PR. +- `report_main.json` - the file with a report generated by the `CI - Model Regression` workflow that is triggered on schedule event. A list of the workflows that you can download an artifact from, can be found [here](https://github.com/RasaHQ/rasa/actions?query=workflow%3A%22CI+-+Model+Regression%22+event%3Aschedule). diff --git a/.github/templates/configuration_variables.tmpl b/.github/templates/configuration_variables.tmpl new file mode 100644 index 0000000..d2b5a90 --- /dev/null +++ b/.github/templates/configuration_variables.tmpl @@ -0,0 +1,43 @@ +{{- /* + +The template maps dataset name and configuration name for the model +regression tests into paths where files are located. As a result, +the template returns two environment variables `DATASET` and `CONFIG` +which contain paths to file/directory. + + */ -}} +{{- $mapping := (datasource "mapping") -}} +{{- $dataset := (index $mapping.datasets (getenv "DATASET_NAME")) -}} +{{- $config := $mapping.configurations -}} +{{- if has $dataset "repository" }} +export DATASET="{{ $dataset.repository }}" +export IS_EXTERNAL="true" +echo "::add-mask::{{ $dataset.repository }}" +{{ if has $dataset "repository_branch" }} +export EXTERNAL_DATASET_REPOSITORY_BRANCH="{{ $dataset.repository_branch }}" +{{ else }} +export EXTERNAL_DATASET_REPOSITORY_BRANCH="main" +{{ end }} +{{- else if has $dataset "path" }} +export DATASET="{{ $dataset.path }}" +export IS_EXTERNAL="false" +echo "::add-mask::{{ $dataset.path }}" +{{ end }} + +{{- if has $dataset "train" }} +export TRAIN_DIR="{{ $dataset.train }}" +{{ end }} +{{- if has $dataset "test" }} +export TEST_DIR="{{ $dataset.test }}" +{{ end }} +{{- if has $dataset "domain" }} +export DOMAIN_FILE="{{ $dataset.domain }}" +{{ end }} + +{{- if (has $config.nlu (getenv "CONFIG_NAME")) }} +export CONFIG="{{ $dataset.language }}/nlu/{{ index $config.nlu (getenv "CONFIG_NAME") }}" +echo "::add-mask::{{ $dataset.language }}/nlu/{{ index $config.nlu (getenv "CONFIG_NAME") }}" +{{ else if (has $config.core (getenv "CONFIG_NAME")) }} +export CONFIG="{{ $dataset.language }}/core/{{ index $config.core (getenv "CONFIG_NAME") }}" +echo "::add-mask::{{ $dataset.language }}/core/{{ index $config.core (getenv "CONFIG_NAME") }}" +{{ end -}} diff --git a/.github/templates/model_regression_test_config_comment.tmpl b/.github/templates/model_regression_test_config_comment.tmpl new file mode 100644 index 0000000..866dd57 --- /dev/null +++ b/.github/templates/model_regression_test_config_comment.tmpl @@ -0,0 +1,45 @@ +{{- /* + +The template returns a comment message which is used as a help description +in a PR. The template reads the `.github/configs/mr-test-example.yaml` file +and include it as example content. + +The help message is triggered by adding `status:model-regression-tests` label. +Comment with a help message is added if a PR doesn't contain a comment +with a configuration for the model regression tests. + + */ -}} +{{ define "check_available_configuration" -}} +NLU +{{- if has .dataset "domain" -}} +, Core +{{- end -}} +{{- end -}} +Hey @{{ .Env.GITHUB_ACTOR }}! :wave: To run model regression tests, comment with the `/modeltest` command and a configuration. + +_Tips :bulb:: The model regression test will be run on `push` events. You can re-run the tests by re-add `status:model-regression-tests` label or use a `Re-run jobs` button in Github Actions workflow._ + +_Tips :bulb:: Every time when you want to change a configuration you should edit the comment with the previous configuration._ + +You can copy this in your comment and customize: + +> /modeltest +> ~~~yml +>```yml +>########## +>## Available datasets +>########## +{{range (coll.Keys (datasource "mapping").datasets)}}># - "{{ . }}" ({{ template "check_available_configuration" (dict "dataset" (index (datasource "mapping").datasets .)) }}){{"\n"}}{{ end -}} +> +>########## +>## Available NLU configurations +>########## +{{range (coll.Keys (datasource "mapping").configurations.nlu)}}># - "{{.}}"{{"\n"}}{{ end -}} +> +>########## +>## Available Core configurations +>########## +{{range (coll.Keys (datasource "mapping").configurations.core)}}># - "{{.}}"{{"\n"}}{{ end -}} +> +{{range split (file.Read ".github/configs/mr-test-example.yaml") "\n"}}>{{.}}{{"\n"}}{{ end -}} +>``` diff --git a/.github/templates/model_regression_test_config_to_json.tmpl b/.github/templates/model_regression_test_config_to_json.tmpl new file mode 100644 index 0000000..d057b85 --- /dev/null +++ b/.github/templates/model_regression_test_config_to_json.tmpl @@ -0,0 +1,71 @@ +{{- /* + +The template reads an issue/a PR comment and transforms a YAML code block into JSON. + +*/ -}} +{{ define "check_config_type" -}} +{{- if has (datasource "mapping").configurations.nlu . -}} +nlu +{{- else if has (datasource "mapping").configurations.core . -}} +core +{{- end -}} +{{- end -}} +{{- $config := ((datasource "github").body | regexp.Find "```(?s)(.*)```" | regexp.ReplaceLiteral "```.*|\r" "" | yaml | toJSON | json) -}} +{{- $num_repetitions := 1 -}} +{{- if has $config "num_repetitions" -}} +{{- $num_repetitions = $config.num_repetitions -}} +{{- end -}} +{"include":[ +{{- $inc := coll.Slice -}} +{{- $dataset := coll.Slice -}} +{{- range $pair := $config.include -}} +{{- /* use all available datasets if value is equal to all */ -}} +{{- if eq (index $pair.dataset 0) "all" -}} +{{ $dataset = (coll.Keys (datasource "mapping").datasets) }} +{{- else if eq (index $pair.dataset 0) "all-core" -}} +{{- range $dataset_name, $dataset_spec := (datasource "mapping").datasets -}} +{{- if has $dataset_spec "domain" -}} +{{ $dataset = (coll.Append $dataset_name $dataset) -}} +{{- end -}} +{{- end -}} +{{- else if eq (index $pair.dataset 0) "all-nlu" -}} +{{- range $dataset_name, $dataset_spec := (datasource "mapping").datasets -}} +{{- if not (has $dataset_spec "domain") -}} +{{ $dataset = (coll.Append $dataset_name $dataset) -}} +{{- end -}} +{{- end -}} +{{- else -}} +{{- $dataset = $pair.dataset -}} +{{- end -}} +{{- range $index_dataset, $value_dataset := $dataset -}} +{{- range $index_config, $value_config := $pair.config -}} +{{ range $index_repetition, $element := (strings.Repeat $num_repetitions "x " | strings.Trim " " | strings.Split " ") }} +{{- /* use all available configurations if value is equal to all */ -}} +{{- if eq $value_config "all" -}} +{{- range $config_type := (coll.Keys (datasource "mapping").configurations) -}} +{{- range $config_name, $config_file := (index (datasource "mapping").configurations $config_type ) -}} + +{{ $inc = (coll.Append (dict "index_repetition" $index_repetition "dataset" $value_dataset "config" $config_name "type" $config_type | toJSON) $inc) -}} +{{- end -}} +{{- end -}} +{{- else if eq $value_config "all-core" -}} +{{- range $config_name, $config_file := (datasource "mapping").configurations.core -}} +{{ $inc = (coll.Append (dict "index_repetition" $index_repetition "dataset" $value_dataset "config" $config_name "type" "core" | toJSON) $inc) -}} +{{- end -}} +{{- else if eq $value_config "all-nlu" -}} +{{- range $config_name, $config_file := (datasource "mapping").configurations.nlu -}} +{{ $inc = (coll.Append (dict "index_repetition" $index_repetition "dataset" $value_dataset "config" $config_name "type" "nlu" | toJSON) $inc) -}} +{{- end -}} +{{- else -}} +{{- if has (datasource "mapping").configurations.nlu $value_config -}} +{{ $inc = (coll.Append (dict "index_repetition" $index_repetition "dataset" $value_dataset "config" $value_config "type" "nlu" | toJSON) $inc) -}} +{{- else if has (datasource "mapping").configurations.core $value_config -}} +{{ $inc = (coll.Append (dict "index_repetition" $index_repetition "dataset" $value_dataset "config" $value_config "type" "core" | toJSON) $inc) -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- join $inc "," -}} +]} diff --git a/.github/templates/model_regression_test_read_dataset_branch.tmpl b/.github/templates/model_regression_test_read_dataset_branch.tmpl new file mode 100644 index 0000000..2f6e2e9 --- /dev/null +++ b/.github/templates/model_regression_test_read_dataset_branch.tmpl @@ -0,0 +1,13 @@ +{{- /* + +The template reads a PR comment and gets the dataset branch for the training-data +repository. + + */ -}} +{{- $config := ((datasource "github").body | regexp.Find "```(?s)(.*)```" | regexp.ReplaceLiteral "```.*|\r" "" | yaml | toJSON | json) -}} +{{- $dataset_branch := "main" -}} +{{- /* if a branch name for dataset repository is not defined use the main branch */ -}} +{{- if has $config "dataset_branch" -}} +{{- $dataset_branch = $config.dataset_branch -}} +{{- end -}} +export DATASET_BRANCH="{{ $dataset_branch }}" diff --git a/.github/templates/model_regression_test_results.tmpl b/.github/templates/model_regression_test_results.tmpl new file mode 100644 index 0000000..5bfd846 --- /dev/null +++ b/.github/templates/model_regression_test_results.tmpl @@ -0,0 +1,159 @@ +{{- /* + +The template reads a file with a report (the report file is available +as an artifact in the model regression tests workflow) and returns +a markdown table with a summary of the tests. + +*/ -}} +{{- /* + +The print_result_nlu template returns data depends on available fields. + +*/ -}} +{{ define "print_result_nlu" -}} +{{- if and (has (index .branch "micro avg") "f1-score") (has (index .main "micro avg") "f1-score") -}} +{{ printf "%.4f" (index (index .branch "micro avg") "f1-score") }} ({{ printf "%.2f" ((index (index .main "micro avg") "f1-score") | math.Sub (index (index .branch "micro avg") "f1-score")) }}) +{{- else if and (has .branch "accuracy") (has .main "accuracy") -}} +{{ printf "%.4f" .branch.accuracy }} ({{ printf "%.2f" (.main.accuracy | math.Sub .branch.accuracy) }}) +{{- else if and (has .branch "accuracy") (has (index .main "micro avg") "f1-score") -}} +{{ printf "%.4f" .branch.accuracy }} ({{ printf "%.2f" ((index (index .main "micro avg") "f1-score") | math.Sub .branch.accuracy) }}) +{{- else if and (has (index .branch "micro avg") "f1-score") (has .main "accuracy") -}} +{{ printf "%.4f" (index (index .branch "micro avg") "f1-score") }} ({{ printf "%.2f" (.main.accuracy | math.Sub (index (index .branch "micro avg") "f1-score")) }}) +{{- else if (has .branch "accuracy") -}} +{{ printf "%.4f" .branch.accuracy }} (`no data`) +{{- else if has (index .branch "micro avg") "f1-score" -}} +{{ printf "%.4f" (index (index .branch "micro avg") "f1-score") }} (`no data`) +{{- else -}} +`no data` +{{- end -}} +{{- end -}} +{{- /* + +The print_result_core template returns data depends on available fields. + +*/ -}} +{{ define "print_result_core_micro_avg" -}} +{{- if and (has (index .branch "micro avg") "f1-score") (has (index .main "micro avg") "f1-score") -}} +{{ printf "%.4f" (index (index .branch "micro avg") "f1-score") }} ({{ printf "%.2f" ((index (index .main "micro avg") "f1-score") | math.Sub (index (index .branch "micro avg") "f1-score")) }}) +{{- else if and (has .branch "accuracy") (has .main "accuracy") -}} +{{ printf "%.4f" .branch.accuracy }} ({{ printf "%.2f" (.main.accuracy | math.Sub .branch.accuracy) }}) +{{- else if and (has .branch "accuracy") (has (index .main "micro avg") "f1-score") -}} +{{ printf "%.4f" .branch.accuracy }} ({{ printf "%.2f" ((index (index .main "micro avg") "f1-score") | math.Sub .branch.accuracy) }}) +{{- else if and (has (index .branch "micro avg") "f1-score") (has .main "accuracy") -}} +{{ printf "%.4f" (index (index .branch "micro avg") "f1-score") }} ({{ printf "%.2f" (.main.accuracy | math.Sub (index (index .branch "micro avg") "f1-score")) }}) +{{- else if (has .branch "accuracy") -}} +{{ printf "%.4f" .branch.accuracy }} (`no data`) +{{- else if has (index .branch "micro avg") "f1-score" -}} +{{ printf "%.4f" (index (index .branch "micro avg") "f1-score") }} (`no data`) +{{- else -}} +`no data` +{{- end -}} +{{- end -}} + +{{ define "print_result_core_conversation_accuracy" -}} +{{- if and (has (index .branch "conversation_accuracy") "accuracy") (has (index .main "conversation_accuracy") "accuracy") -}} +{{ printf "%.4f" (index (index .branch "conversation_accuracy") "accuracy") }} ({{ printf "%.2f" ((index (index .main "conversation_accuracy") "accuracy") | math.Sub (index (index .branch "conversation_accuracy") "accuracy")) }}) +{{- else if has (index .branch "conversation_accuracy") "accuracy" -}} +{{ printf "%.4f" (index (index .branch "conversation_accuracy") "accuracy") }} (`no data`) +{{- else -}} +`no data` +{{- end -}} +{{- end -}} + +{{ define "print_table_nlu" }} +{{- $available_types := (index .results_for_dataset | jsonpath `@..type`) -}} +{{- if isKind "string" $available_types }}{{- $available_types = (index .results_for_dataset | jsonpath `@..type` | slice) -}}{{- end -}} +{{- if has $available_types "nlu" -}} +| Configuration | Intent Classification Micro F1 | Entity Recognition Micro F1 | Response Selection Micro F1 | +|---------------|-----------------|-----------------|-------------------| +{{ range $config_name, $config_data_array := .results_for_dataset -}} +{{ range $config_data := $config_data_array }} +{{- if eq $config_data.type "nlu" -}} +| `{{ $config_name }}`
test: `{{ $config_data.test_run_time }}`, train: `{{ $config_data.train_run_time }}`, total: `{{ $config_data.total_run_time }}`| +{{- if has $config_data "intent_classification" -}} +{{- $intent_class_main := dict -}} +{{- if has $.results_for_dataset_main $config_name -}} +{{- $intent_class_main = (index (index $.results_for_dataset_main $config_name) 0).intent_classification -}} +{{- end -}} +{{- $intent_class := $config_data.intent_classification -}} +{{ template "print_result_nlu" (dict "branch" $intent_class "main" $intent_class_main) }}| +{{- else -}} +`no data`| +{{- end -}} +{{- if has $config_data "entity_prediction" -}} +{{- $entity_class_main := dict -}} +{{- if has $.results_for_dataset_main $config_name -}} +{{- $entity_class_main = (index (index $.results_for_dataset_main $config_name) 0).entity_prediction -}} +{{- end -}} +{{- $entity_class := $config_data.entity_prediction -}} +{{ template "print_result_nlu" (dict "branch" $entity_class "main" $entity_class_main) }}| +{{- else -}} +`no data`| +{{- end -}} +{{- if has $config_data "response_selection" -}} +{{- $response_class_main := dict -}} +{{- if has $.results_for_dataset_main $config_name -}} +{{- $response_class_main = (index (index $.results_for_dataset_main $config_name) 0).response_selection -}} +{{- end -}} +{{- $response_class := $config_data.response_selection -}} +{{ template "print_result_nlu" (dict "branch" $response_class "main" $response_class_main) }}| +{{- else -}} +`no data`| +{{- end }} +{{end}} +{{- end}} +{{- end}} +{{- end -}} +{{- end -}} + +{{- define "print_table_core" -}} +{{- $available_types := (index .results_for_dataset | jsonpath `@..type`) -}} +{{- if isKind "string" $available_types }}{{- $available_types = (index .results_for_dataset | jsonpath `@..type` | slice) -}}{{- end -}} +{{- if has $available_types "core" -}} +| Dialog Policy Configuration | Action Level Micro Avg. F1 | Conversation Level Accuracy | Run Time Train | Run Time Test | +|---------------|-----------------|-----------------|-------------------|-------------------| +{{ range $config_name, $config_data_array := .results_for_dataset -}} +{{ range $config_data := $config_data_array }} +{{- if eq $config_data.type "core" -}} +| `{{ $config_name }}` | +{{- if has $config_data "story_prediction" -}} +{{- $story_prediction_main := dict -}} +{{- if has $.results_for_dataset_main $config_name -}} +{{- $story_prediction_main = (index (index $.results_for_dataset_main $config_name) 0).story_prediction -}} +{{- end -}} +{{- $story_prediction := $config_data.story_prediction -}} +{{ template "print_result_core_micro_avg" (dict "branch" $story_prediction "main" $story_prediction_main) }}| +{{- else -}} +`no data`| +{{- end -}} +{{- if has $config_data "story_prediction" -}} +{{- $story_prediction_main := dict -}} +{{- if has $.results_for_dataset_main $config_name -}} +{{- $story_prediction_main = (index (index $.results_for_dataset_main $config_name) 0).story_prediction -}} +{{- end -}} +{{- $story_prediction := index $config_data.story_prediction -}} +{{ template "print_result_core_conversation_accuracy" (dict "branch" $story_prediction "main" $story_prediction_main) }}| +{{- else -}} +`no data`| +{{- end -}} +`{{ $config_data.train_run_time }}`| `{{ $config_data.test_run_time }}`| +{{ end }} +{{- end}} +{{- end}} +{{- end -}} +{{- end -}} + +{{- $results_main := (datasource "results_main") -}} +{{ range $dataset, $results_for_dataset := (datasource "data")}} +{{ $results_for_dataset_main := (index $results_main $dataset) -}} +{{ $content_dicts := index $results_for_dataset (index (keys $results_for_dataset) 0) -}} +{{ $one_content_dict := index $content_dicts 0 -}} +{{- if ($one_content_dict).external_dataset_repository -}} +Dataset: `{{$dataset}}`, Dataset repository branch: `{{ ($one_content_dict).dataset_repository_branch }}` (external repository), commit: `{{ ($one_content_dict).dataset_commit }}` +Configuration repository branch: `{{ ($one_content_dict).config_repository_branch }}` +{{ else -}} +Dataset: `{{$dataset}}`, Dataset repository branch: `{{ ($one_content_dict).dataset_repository_branch }}`, commit: `{{ ($one_content_dict).dataset_commit }}` +{{ end -}} +{{ template "print_table_nlu" (dict "results_for_dataset" $results_for_dataset "results_for_dataset_main" $results_for_dataset_main) }} +{{ template "print_table_core" (dict "results_for_dataset" $results_for_dataset "results_for_dataset_main" $results_for_dataset_main) }} +{{- end }} diff --git a/.github/tests/test_data/bert_diet_response2t.yml b/.github/tests/test_data/bert_diet_response2t.yml new file mode 100644 index 0000000..85dc4f8 --- /dev/null +++ b/.github/tests/test_data/bert_diet_response2t.yml @@ -0,0 +1,23 @@ +# Configuration for Rasa NLU. +# https://rasa.com/docs/rasa/nlu/components/ +language: en +pipeline: + - name: WhitespaceTokenizer + - name: LanguageModelFeaturizer + alias: "lmf" + - name: RegexFeaturizer + alias: "rf" + - name: LexicalSyntacticFeaturizer + alias: "lsf" + - name: DIETClassifier + epochs: 50 + random_seed: 42 + - name: ResponseSelector + epochs: 100 + num_transformer_layers: 2 + transformer_size: 256 + hidden_layers_size: + text: [] + label: [] + random_seed: 42 + featurizers: ["lmf"] \ No newline at end of file diff --git a/.github/tests/test_data/comment_body.json b/.github/tests/test_data/comment_body.json new file mode 100644 index 0000000..4806318 --- /dev/null +++ b/.github/tests/test_data/comment_body.json @@ -0,0 +1,3 @@ +{ + "body": "/modeltest\r\n\r\n```yml\r\ndataset_branch: \"test_dataset_branch\"\r\ninclude:\r\n - dataset: [\"financial-demo\"]\r\n config: [\"TEST\"]\r\n ```\r\n\r\n" +} diff --git a/.github/tests/test_data/comment_body_no_dataset_branch.json b/.github/tests/test_data/comment_body_no_dataset_branch.json new file mode 100644 index 0000000..8780c56 --- /dev/null +++ b/.github/tests/test_data/comment_body_no_dataset_branch.json @@ -0,0 +1,3 @@ +{ + "body": "/modeltest\r\n\r\n```yml\r\ninclude:\r\n - dataset: [\"financial-demo\"]\r\n config: [\"TEST\"]\r\n ```\r\n\r\n" +} diff --git a/.github/tests/test_data/intent_report.json b/.github/tests/test_data/intent_report.json new file mode 100644 index 0000000..eb9361f --- /dev/null +++ b/.github/tests/test_data/intent_report.json @@ -0,0 +1,120 @@ +{ + "search_transactions": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1, + "confused_with": {} + }, + "greet": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 2, + "confused_with": {} + }, + "out_of_scope": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1, + "confused_with": {} + }, + "thankyou": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1, + "confused_with": {} + }, + "help": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 2, + "confused_with": {} + }, + "inform": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1, + "confused_with": {} + }, + "goodbye": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1, + "confused_with": {} + }, + "affirm": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 3, + "confused_with": {} + }, + "pay_cc": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 2, + "confused_with": {} + }, + "check_balance": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 5, + "confused_with": {} + }, + "deny": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1, + "confused_with": {} + }, + "ask_transfer_charge": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1, + "confused_with": {} + }, + "transfer_money": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 3, + "confused_with": {} + }, + "check_recipients": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 2, + "confused_with": {} + }, + "check_earnings": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 2, + "confused_with": {} + }, + "accuracy": 1.0, + "macro avg": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 28 + }, + "weighted avg": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 28 + } +} diff --git a/.github/tests/test_data/report-on-schedule-2022-02-02.json b/.github/tests/test_data/report-on-schedule-2022-02-02.json new file mode 100644 index 0000000..5f8d8b2 --- /dev/null +++ b/.github/tests/test_data/report-on-schedule-2022-02-02.json @@ -0,0 +1,303 @@ +{ + "RasaHQ/financial-demo": { + "BERT + DIET(bow) + ResponseSelector(bow)": [{ + "accelerator_type": "GPU", + "config_repository": "training-data", + "config_repository_branch": "main", + "dataset_commit": "52a3ad3eb5292d56542687e23b06703431f15ead", + "dataset_repository_branch": "fix-model-regression-tests", + "entity_prediction": { + "macro avg": { + "f1-score": 0.7333333333333333, + "precision": 0.8, + "recall": 0.7, + "support": 14 + }, + "micro avg": { + "f1-score": 0.8333333333333333, + "precision": 1.0, + "recall": 0.7142857142857143, + "support": 14 + }, + "weighted avg": { + "f1-score": 0.738095238095238, + "precision": 0.7857142857142857, + "recall": 0.7142857142857143, + "support": 14 + } + }, + "external_dataset_repository": true, + "intent_classification": { + "accuracy": 1.0, + "macro avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 28 + }, + "weighted avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 28 + } + }, + "test_run_time": "35s", + "total_run_time": "2m2s", + "train_run_time": "1m28s", + "type": "nlu" + }], + "BERT + DIET(seq) + ResponseSelector(t2t)": [{ + "accelerator_type": "GPU", + "config_repository": "training-data", + "config_repository_branch": "main", + "dataset_commit": "52a3ad3eb5292d56542687e23b06703431f15ead", + "dataset_repository_branch": "fix-model-regression-tests", + "entity_prediction": { + "macro avg": { + "f1-score": 0.7333333333333333, + "precision": 0.8, + "recall": 0.7, + "support": 14 + }, + "micro avg": { + "f1-score": 0.8333333333333333, + "precision": 1.0, + "recall": 0.7142857142857143, + "support": 14 + }, + "weighted avg": { + "f1-score": 0.738095238095238, + "precision": 0.7857142857142857, + "recall": 0.7142857142857143, + "support": 14 + } + }, + "external_dataset_repository": true, + "intent_classification": { + "accuracy": 1.0, + "macro avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 28 + }, + "weighted avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 28 + } + }, + "test_run_time": "55s", + "total_run_time": "2m8s", + "train_run_time": "1m14s", + "type": "nlu" + }], + "Rules + Memo + TED": [{ + "accelerator_type": "GPU", + "config_repository": "training-data", + "config_repository_branch": "main", + "dataset_commit": "52a3ad3eb5292d56542687e23b06703431f15ead", + "dataset_repository_branch": "fix-model-regression-tests", + "external_dataset_repository": true, + "story_prediction": { + "accuracy": 1.0, + "conversation_accuracy": { + "accuracy": 1.0, + "correct": 48, + "total": 48, + "with_warnings": 0 + }, + "macro avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 317 + }, + "weighted avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 317 + } + }, + "test_run_time": "51s", + "total_run_time": "8m15s", + "train_run_time": "7m24s", + "type": "core" + }] + }, + "RasaHQ/retail-demo": { + "BERT + DIET(bow) + ResponseSelector(bow)": [{ + "accelerator_type": "GPU", + "config_repository": "training-data", + "config_repository_branch": "main", + "dataset_commit": "8226b51b4312aa4d3723098cf6d4028feea040b4", + "dataset_repository_branch": "fix-model-regression-tests", + "entity_prediction": { + "macro avg": { + "f1-score": 0.25, + "precision": 0.25, + "recall": 0.25, + "support": 6 + }, + "micro avg": { + "f1-score": 0.2857142857142857, + "precision": 1.0, + "recall": 0.16666666666666666, + "support": 6 + }, + "weighted avg": { + "f1-score": 0.16666666666666666, + "precision": 0.16666666666666666, + "recall": 0.16666666666666666, + "support": 6 + } + }, + "external_dataset_repository": true, + "intent_classification": { + "macro avg": { + "f1-score": 0.8, + "precision": 0.8, + "recall": 0.85, + "support": 16 + }, + "micro avg": { + "f1-score": 0.8387096774193549, + "precision": 0.8666666666666667, + "recall": 0.8125, + "support": 16 + }, + "weighted avg": { + "f1-score": 0.8125, + "precision": 0.875, + "recall": 0.8125, + "support": 16 + } + }, + "test_run_time": "29s", + "total_run_time": "1m16s", + "train_run_time": "47s", + "type": "nlu" + }], + "BERT + DIET(seq) + ResponseSelector(t2t)": [{ + "accelerator_type": "GPU", + "config_repository": "training-data", + "config_repository_branch": "main", + "dataset_commit": "8226b51b4312aa4d3723098cf6d4028feea040b4", + "dataset_repository_branch": "fix-model-regression-tests", + "entity_prediction": { + "macro avg": { + "f1-score": 0.25, + "precision": 0.25, + "recall": 0.25, + "support": 6 + }, + "micro avg": { + "f1-score": 0.2857142857142857, + "precision": 1.0, + "recall": 0.16666666666666666, + "support": 6 + }, + "weighted avg": { + "f1-score": 0.16666666666666666, + "precision": 0.16666666666666666, + "recall": 0.16666666666666666, + "support": 6 + } + }, + "external_dataset_repository": true, + "intent_classification": { + "accuracy": 0.875, + "macro avg": { + "f1-score": 0.8300000000000001, + "precision": 0.8166666666666667, + "recall": 0.85, + "support": 16 + }, + "weighted avg": { + "f1-score": 0.85, + "precision": 0.8333333333333333, + "recall": 0.875, + "support": 16 + } + }, + "test_run_time": "56s", + "total_run_time": "2m2s", + "train_run_time": "1m6s", + "type": "nlu" + }], + "Rules + Memo": [{ + "accelerator_type": "GPU", + "config_repository": "training-data", + "config_repository_branch": "main", + "dataset_commit": "8226b51b4312aa4d3723098cf6d4028feea040b4", + "dataset_repository_branch": "fix-model-regression-tests", + "external_dataset_repository": true, + "story_prediction": { + "conversation_accuracy": { + "accuracy": 0.8888888888888888, + "correct": 8, + "total": 9, + "with_warnings": 0 + }, + "macro avg": { + "f1-score": 0.9663698541747322, + "precision": 1.0, + "recall": 0.946007696007696, + "support": 67 + }, + "micro avg": { + "f1-score": 0.9692307692307692, + "precision": 1.0, + "recall": 0.9402985074626866, + "support": 67 + }, + "weighted avg": { + "f1-score": 0.9656317714563074, + "precision": 1.0, + "recall": 0.9402985074626866, + "support": 67 + } + }, + "test_run_time": "10s", + "total_run_time": "19s", + "train_run_time": "10s", + "type": "core" + }], + "Rules + Memo + TED": [{ + "accelerator_type": "GPU", + "config_repository": "training-data", + "config_repository_branch": "main", + "dataset_commit": "8226b51b4312aa4d3723098cf6d4028feea040b4", + "dataset_repository_branch": "fix-model-regression-tests", + "external_dataset_repository": true, + "story_prediction": { + "accuracy": 1.0, + "conversation_accuracy": { + "accuracy": 1.0, + "correct": 9, + "total": 9, + "with_warnings": 0 + }, + "macro avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 67 + }, + "weighted avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 67 + } + }, + "test_run_time": "31s", + "total_run_time": "4m57s", + "train_run_time": "4m27s", + "type": "core" + }] + } +} \ No newline at end of file diff --git a/.github/tests/test_data/report_listformat_core.json b/.github/tests/test_data/report_listformat_core.json new file mode 100644 index 0000000..a24046b --- /dev/null +++ b/.github/tests/test_data/report_listformat_core.json @@ -0,0 +1,70 @@ +{ + "RasaHQ/retail-demo": { + "Rules + Memo + TED": [{ + "accelerator_type": "GPU", + "config_repository": "training-data", + "config_repository_branch": "main", + "dataset_commit": "8226b51b4312aa4d3723098cf6d4028feea040b4", + "dataset_repository_branch": "fix-model-regression-tests", + "external_dataset_repository": true, + "story_prediction": { + "accuracy": 1.0, + "conversation_accuracy": { + "accuracy": 1.0, + "correct": 9, + "total": 9, + "with_warnings": 0 + }, + "macro avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 67 + }, + "weighted avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 67 + } + }, + "test_run_time": "31s", + "total_run_time": "4m57s", + "train_run_time": "4m27s", + "type": "core" + }, + { + "accelerator_type": "GPU", + "config_repository": "training-data", + "config_repository_branch": "main", + "dataset_commit": "8226b51b4312aa4d3723098cf6d4028feea040b4", + "dataset_repository_branch": "fix-model-regression-tests", + "external_dataset_repository": true, + "story_prediction": { + "accuracy": 1.0, + "conversation_accuracy": { + "accuracy": 1.0, + "correct": 9, + "total": 9, + "with_warnings": 0 + }, + "macro avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 67 + }, + "weighted avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 67 + } + }, + "test_run_time": "41s", + "total_run_time": "5m57s", + "train_run_time": "5m27s", + "type": "core" + }] + } +} \ No newline at end of file diff --git a/.github/tests/test_data/report_listformat_nlu.json b/.github/tests/test_data/report_listformat_nlu.json new file mode 100644 index 0000000..dc398b3 --- /dev/null +++ b/.github/tests/test_data/report_listformat_nlu.json @@ -0,0 +1,98 @@ +{ + "RasaHQ/financial-demo": { + "BERT + DIET(seq) + ResponseSelector(t2t)": [{ + "accelerator_type": "CPU", + "config_repository": "training-data", + "config_repository_branch": "main", + "dataset_commit": "52a3ad3eb5292d56542687e23b06703431f15ead", + "dataset_repository_branch": "fix-model-regression-tests", + "entity_prediction": { + "macro avg": { + "f1-score": 0.7333333333333333, + "precision": 0.8, + "recall": 0.7, + "support": 14 + }, + "micro avg": { + "f1-score": 0.8333333333333333, + "precision": 1.0, + "recall": 0.7142857142857143, + "support": 14 + }, + "weighted avg": { + "f1-score": 0.738095238095238, + "precision": 0.7857142857142857, + "recall": 0.7142857142857143, + "support": 14 + } + }, + "external_dataset_repository": true, + "intent_classification": { + "accuracy": 1.0, + "macro avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 28 + }, + "weighted avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 28 + } + }, + "test_run_time": "1m29s", + "total_run_time": "4m24s", + "train_run_time": "2m55s", + "type": "nlu" + }, + { + "accelerator_type": "CPU", + "config_repository": "training-data", + "config_repository_branch": "main", + "dataset_commit": "52a3ad3eb5292d56542687e23b06703431f15ead", + "dataset_repository_branch": "fix-model-regression-tests", + "entity_prediction": { + "macro avg": { + "f1-score": 0.7333333333333333, + "precision": 0.8, + "recall": 0.7, + "support": 14 + }, + "micro avg": { + "f1-score": 0.8333333333333333, + "precision": 1.0, + "recall": 0.7142857142857143, + "support": 14 + }, + "weighted avg": { + "f1-score": 0.738095238095238, + "precision": 0.7857142857142857, + "recall": 0.7142857142857143, + "support": 14 + } + }, + "external_dataset_repository": true, + "intent_classification": { + "accuracy": 1.0, + "macro avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 28 + }, + "weighted avg": { + "f1-score": 1.0, + "precision": 1.0, + "recall": 1.0, + "support": 28 + } + }, + "test_run_time": "2m29s", + "total_run_time": "5m24s", + "train_run_time": "3m55s", + "type": "nlu" + }] + } +} \ No newline at end of file diff --git a/.github/tests/test_download_pretrained.py b/.github/tests/test_download_pretrained.py new file mode 100644 index 0000000..dd583dd --- /dev/null +++ b/.github/tests/test_download_pretrained.py @@ -0,0 +1,101 @@ +from copy import deepcopy +import sys +import tempfile +from pathlib import Path + +import pytest +from ruamel.yaml import YAML + +sys.path.append(".github/scripts") +import download_pretrained # noqa: E402 + +CONFIG_FPATH = Path(__file__).parent / "test_data" / "bert_diet_response2t.yml" + + +def test_download_pretrained_lmf_exists_no_params(): + lmf_specs = download_pretrained.get_model_name_and_weights_from_config(CONFIG_FPATH) + assert lmf_specs[0].model_name == "bert" + assert lmf_specs[0].model_weights == "rasa/LaBSE" + + +def test_download_pretrained_lmf_exists_with_model_name(): + yaml = YAML(typ="safe") + config = yaml.load(CONFIG_FPATH) + + steps = config.get("pipeline", []) + step = list(filter(lambda x: x["name"] == download_pretrained.COMP_NAME, steps))[0] + step["model_name"] = "roberta" + step["cache_dir"] = "/this/dir" + + with tempfile.NamedTemporaryFile("w+") as fp: + yaml.dump(config, fp) + fp.seek(0) + lmf_specs = download_pretrained.get_model_name_and_weights_from_config(fp.name) + assert lmf_specs[0].model_name == "roberta" + assert lmf_specs[0].model_weights == "roberta-base" + assert lmf_specs[0].cache_dir == "/this/dir" + + +def test_download_pretrained_unknown_model_name(): + yaml = YAML(typ="safe") + config = yaml.load(CONFIG_FPATH) + + steps = config.get("pipeline", []) + step = list(filter(lambda x: x["name"] == download_pretrained.COMP_NAME, steps))[0] + step["model_name"] = "unknown" + + with tempfile.NamedTemporaryFile("w+") as fp: + yaml.dump(config, fp) + fp.seek(0) + with pytest.raises(KeyError): + download_pretrained.get_model_name_and_weights_from_config(fp.name) + + +def test_download_pretrained_multiple_model_names(): + yaml = YAML(typ="safe") + config = yaml.load(CONFIG_FPATH) + + steps = config.get("pipeline", []) + step = list(filter(lambda x: x["name"] == download_pretrained.COMP_NAME, steps))[0] + step_new = deepcopy(step) + step_new["model_name"] = "roberta" + steps.append(step_new) + + with tempfile.NamedTemporaryFile("w+") as fp: + yaml.dump(config, fp) + fp.seek(0) + lmf_specs = download_pretrained.get_model_name_and_weights_from_config(fp.name) + assert len(lmf_specs) == 2 + assert lmf_specs[1].model_name == "roberta" + + +def test_download_pretrained_with_model_name_and_nondefault_weight(): + yaml = YAML(typ="safe") + config = yaml.load(CONFIG_FPATH) + + steps = config.get("pipeline", []) + step = list(filter(lambda x: x["name"] == download_pretrained.COMP_NAME, steps))[0] + step["model_name"] = "bert" + step["model_weights"] = "bert-base-uncased" + + with tempfile.NamedTemporaryFile("w+") as fp: + yaml.dump(config, fp) + fp.seek(0) + lmf_specs = download_pretrained.get_model_name_and_weights_from_config(fp.name) + assert lmf_specs[0].model_name == "bert" + assert lmf_specs[0].model_weights == "bert-base-uncased" + + +def test_download_pretrained_lmf_doesnt_exists(): + yaml = YAML(typ="safe") + config = yaml.load(CONFIG_FPATH) + + steps = config.get("pipeline", []) + step = list(filter(lambda x: x["name"] == download_pretrained.COMP_NAME, steps))[0] + steps.remove(step) + + with tempfile.NamedTemporaryFile("w+") as fp: + yaml.dump(config, fp) + fp.seek(0) + lmf_specs = download_pretrained.get_model_name_and_weights_from_config(fp.name) + assert len(lmf_specs) == 0 diff --git a/.github/tests/test_model_regression_test_read_dataset_branch_tmpl.py b/.github/tests/test_model_regression_test_read_dataset_branch_tmpl.py new file mode 100644 index 0000000..c6dc357 --- /dev/null +++ b/.github/tests/test_model_regression_test_read_dataset_branch_tmpl.py @@ -0,0 +1,27 @@ +import pathlib +import subprocess +import pytest +from typing import Text + +TEMPLATE_FPATH = ".github/templates/model_regression_test_read_dataset_branch.tmpl" +REPO_DIR = pathlib.Path("").absolute() +TEST_DATA_DIR = str(pathlib.Path(__file__).parent / "test_data") +DEFAULT_DATASET_BRANCH = "main" + + +@pytest.mark.parametrize( + "comment_body_file,expected_dataset_branch", + [ + ("comment_body.json", "test_dataset_branch"), + ("comment_body_no_dataset_branch.json", DEFAULT_DATASET_BRANCH), + ], +) +def test_read_dataset_branch(comment_body_file: Text, expected_dataset_branch: Text): + cmd = ( + "gomplate " + f"-d github={TEST_DATA_DIR}/{comment_body_file} " + f"-f {TEMPLATE_FPATH}" + ) + output = subprocess.check_output(cmd.split(" "), cwd=REPO_DIR) + output = output.decode("utf-8").strip() + assert output == f'export DATASET_BRANCH="{expected_dataset_branch}"' diff --git a/.github/tests/test_model_regression_test_results_tmpl.py b/.github/tests/test_model_regression_test_results_tmpl.py new file mode 100644 index 0000000..8d4ba92 --- /dev/null +++ b/.github/tests/test_model_regression_test_results_tmpl.py @@ -0,0 +1,50 @@ +import pathlib +import subprocess + +TEMPLATE_FPATH = ".github/templates/model_regression_test_results.tmpl" +REPO_DIR = pathlib.Path("").absolute() +TEST_DATA_DIR = str(pathlib.Path(__file__).parent / "test_data") + + +def test_comment_nlu(): + cmd = ( + "gomplate " + f"-d data={TEST_DATA_DIR}/report_listformat_nlu.json " + f"-d results_main={TEST_DATA_DIR}/report-on-schedule-2022-02-02.json " + f"-f {TEMPLATE_FPATH}" + ) + output = subprocess.check_output(cmd.split(" "), cwd=REPO_DIR) + output = output.decode("utf-8") + expected_output = """ +Dataset: `RasaHQ/financial-demo`, Dataset repository branch: `fix-model-regression-tests` (external repository), commit: `52a3ad3eb5292d56542687e23b06703431f15ead` +Configuration repository branch: `main` +| Configuration | Intent Classification Micro F1 | Entity Recognition Micro F1 | Response Selection Micro F1 | +|---------------|-----------------|-----------------|-------------------| +| `BERT + DIET(seq) + ResponseSelector(t2t)`
test: `1m29s`, train: `2m55s`, total: `4m24s`|1.0000 (0.00)|0.8333 (0.00)|`no data`| +| `BERT + DIET(seq) + ResponseSelector(t2t)`
test: `2m29s`, train: `3m55s`, total: `5m24s`|1.0000 (0.00)|0.8333 (0.00)|`no data`| + + +""" # noqa E501 + assert output == expected_output + + +def test_comment_core(): + cmd = ( + "gomplate " + f"-d data={TEST_DATA_DIR}/report_listformat_core.json " + f"-d results_main={TEST_DATA_DIR}/report-on-schedule-2022-02-02.json " + f"-f {TEMPLATE_FPATH}" + ) + output = subprocess.check_output(cmd.split(" "), cwd=REPO_DIR) + output = output.decode("utf-8") + expected_output = """ +Dataset: `RasaHQ/retail-demo`, Dataset repository branch: `fix-model-regression-tests` (external repository), commit: `8226b51b4312aa4d3723098cf6d4028feea040b4` +Configuration repository branch: `main` + +| Dialog Policy Configuration | Action Level Micro Avg. F1 | Conversation Level Accuracy | Run Time Train | Run Time Test | +|---------------|-----------------|-----------------|-------------------|-------------------| +| `Rules + Memo + TED` |1.0000 (0.00)|1.0000 (0.00)|`4m27s`| `31s`| +| `Rules + Memo + TED` |1.0000 (0.00)|1.0000 (0.00)|`5m27s`| `41s`| + +""" # noqa E501 + assert output == expected_output diff --git a/.github/tests/test_mr_generate_summary.py b/.github/tests/test_mr_generate_summary.py new file mode 100644 index 0000000..2019fa0 --- /dev/null +++ b/.github/tests/test_mr_generate_summary.py @@ -0,0 +1,208 @@ +import sys + +sys.path.append(".github/scripts") +from mr_generate_summary import combine_result # noqa: E402 + + +RESULT1 = { + "financial-demo": { + "BERT + DIET(bow) + ResponseSelector(bow)": [ + { + "Entity Prediction": { + "macro avg": { + "f1-score": 0.7333333333333333, + } + }, + "test_run_time": "47s", + } + ] + } +} + + +def test_same_ds_different_config(): + result2 = { + "financial-demo": { + "Sparse + DIET(bow) + ResponseSelector(bow)": [ + { + "Entity Prediction": { + "macro avg": { + "f1-score": 0.88, + } + }, + "test_run_time": "47s", + } + ] + } + } + expected_combined = { + "financial-demo": { + "BERT + DIET(bow) + ResponseSelector(bow)": [ + { + "Entity Prediction": { + "macro avg": { + "f1-score": 0.7333333333333333, + } + }, + "test_run_time": "47s", + } + ], + "Sparse + DIET(bow) + ResponseSelector(bow)": [ + { + "Entity Prediction": { + "macro avg": { + "f1-score": 0.88, + } + }, + "test_run_time": "47s", + } + ], + } + } + + actual_combined = combine_result(RESULT1, result2) + assert actual_combined == expected_combined + + actual_combined = combine_result(result2, RESULT1) + assert actual_combined == expected_combined + + +def test_different_ds_same_config(): + result2 = { + "Carbon Bot": { + "Sparse + DIET(bow) + ResponseSelector(bow)": [ + { + "Entity Prediction": { + "macro avg": { + "f1-score": 0.88, + } + }, + "test_run_time": "47s", + } + ] + } + } + expected_combined = { + "financial-demo": { + "BERT + DIET(bow) + ResponseSelector(bow)": [ + { + "Entity Prediction": { + "macro avg": { + "f1-score": 0.7333333333333333, + } + }, + "test_run_time": "47s", + } + ], + }, + "Carbon Bot": { + "Sparse + DIET(bow) + ResponseSelector(bow)": [ + { + "Entity Prediction": { + "macro avg": { + "f1-score": 0.88, + } + }, + "test_run_time": "47s", + } + ] + }, + } + + actual_combined = combine_result(RESULT1, result2) + assert actual_combined == expected_combined + + actual_combined = combine_result(result2, RESULT1) + assert actual_combined == expected_combined + + +def test_start_empty(): + result2 = {} + expected_combined = { + "financial-demo": { + "BERT + DIET(bow) + ResponseSelector(bow)": [ + { + "Entity Prediction": { + "macro avg": { + "f1-score": 0.7333333333333333, + } + }, + "test_run_time": "47s", + } + ] + } + } + + actual_combined = combine_result(RESULT1, result2) + assert actual_combined == expected_combined + + actual_combined = combine_result(result2, RESULT1) + assert actual_combined == expected_combined + + +def test_combine_result_repetition(): + expected_combined = { + "financial-demo": { + "BERT + DIET(bow) + ResponseSelector(bow)": [ + { + "Entity Prediction": { + "macro avg": { + "f1-score": 0.7333333333333333, + } + }, + "test_run_time": "47s", + }, + { + "Entity Prediction": { + "macro avg": { + "f1-score": 0.7333333333333333, + } + }, + "test_run_time": "47s", + }, + ] + } + } + + actual_combined = combine_result(RESULT1, RESULT1) + assert actual_combined == expected_combined + + +def test_combine_result_repetition_3times(): + expected_combined = { + "financial-demo": { + "BERT + DIET(bow) + ResponseSelector(bow)": [ + { + "Entity Prediction": { + "macro avg": { + "f1-score": 0.7333333333333333, + } + }, + "test_run_time": "47s", + }, + { + "Entity Prediction": { + "macro avg": { + "f1-score": 0.7333333333333333, + } + }, + "test_run_time": "47s", + }, + { + "Entity Prediction": { + "macro avg": { + "f1-score": 0.7333333333333333, + } + }, + "test_run_time": "47s", + }, + ] + } + } + + tmp_combined = combine_result(RESULT1, RESULT1) + actual_combined = combine_result(tmp_combined, RESULT1) + assert actual_combined == expected_combined + + actual_combined = combine_result(RESULT1, tmp_combined) + assert actual_combined == expected_combined diff --git a/.github/tests/test_mr_publish_results.py b/.github/tests/test_mr_publish_results.py new file mode 100644 index 0000000..c1c05cd --- /dev/null +++ b/.github/tests/test_mr_publish_results.py @@ -0,0 +1,132 @@ +import os +from pathlib import Path +import sys +from unittest import mock + +sys.path.append(".github/scripts") +from mr_publish_results import ( # noqa: E402 + prepare_ml_metric, + prepare_ml_metrics, + transform_to_seconds, + generate_json, + prepare_datadog_tags, +) + +EXAMPLE_CONFIG = "Sparse + BERT + DIET(seq) + ResponseSelector(t2t)" +EXAMPLE_DATASET_NAME = "financial-demo" + +ENV_VARS = { + "BRANCH": "my-branch", + "PR_ID": "10927", + "PR_URL": "https://github.com/RasaHQ/rasa/pull/10856/", + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_RUN_ID": "1882718340", + "GITHUB_SHA": "abc", + "GITHUB_WORKFLOW": "CI - Model Regression", + "IS_EXTERNAL": "false", + "DATASET_REPOSITORY_BRANCH": "main", + "CONFIG": EXAMPLE_CONFIG, + "DATASET_NAME": EXAMPLE_DATASET_NAME, + "CONFIG_REPOSITORY_BRANCH": "main", + "DATASET_COMMIT": "52a3ad3eb5292d56542687e23b06703431f15ead", + "ACCELERATOR_TYPE": "CPU", + "TEST_RUN_TIME": "1m54s", + "TRAIN_RUN_TIME": "4m4s", + "TOTAL_RUN_TIME": "5m58s", + "TYPE": "nlu", + "INDEX_REPETITION": "0", + "HOST_NAME": "github-runner-2223039222-22df222fcd-2cn7d", +} + + +@mock.patch.dict(os.environ, ENV_VARS, clear=True) +def test_generate_json(): + f = Path(__file__).parent / "test_data" / "intent_report.json" + result = generate_json(f, task="intent_classification", data={}) + assert isinstance(result[EXAMPLE_DATASET_NAME][EXAMPLE_CONFIG], list) + + actual = result[EXAMPLE_DATASET_NAME][EXAMPLE_CONFIG][0]["intent_classification"] + expected = { + "accuracy": 1.0, + "weighted avg": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 28, + }, + "macro avg": {"precision": 1.0, "recall": 1.0, "f1-score": 1.0, "support": 28}, + } + assert expected == actual + + +def test_transform_to_seconds(): + assert 87.0 == transform_to_seconds("1m27s") + assert 87.3 == transform_to_seconds("1m27.3s") + assert 27.0 == transform_to_seconds("27s") + assert 3627.0 == transform_to_seconds("1h27s") + assert 3687.0 == transform_to_seconds("1h1m27s") + + +def test_prepare_ml_model_perf_metrics(): + results = [ + { + "macro avg": { + "precision": 0.8, + "recall": 0.8, + "f1-score": 0.8, + "support": 14, + }, + "micro avg": { + "precision": 1.0, + "recall": 0.7857142857142857, + "f1-score": 0.88, + "support": 14, + }, + "file_name": "DIETClassifier_report.json", + "task": "Entity Prediction", + }, + { + "accuracy": 1.0, + "weighted avg": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 28, + }, + "macro avg": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 28, + }, + "file_name": "intent_report.json", + "task": "Intent Classification", + }, + ] + metrics_ml = prepare_ml_metrics(results) + assert len(metrics_ml) == 17 + + +def test_prepare_ml_model_perf_metrics_simple(): + result = { + "accuracy": 1.0, + "weighted avg": {"precision": 1, "recall": 1.0, "f1-score": 1, "support": 28}, + "task": "Intent Classification", + } + metrics_ml = prepare_ml_metric(result) + assert len(metrics_ml) == 5 + + for _, v in metrics_ml.items(): + assert isinstance(v, float) + + key, value = "Intent Classification.accuracy", 1.0 + assert key in metrics_ml and value == metrics_ml[key] + + key, value = "Intent Classification.weighted avg.f1-score", 1.0 + assert key in metrics_ml and value == metrics_ml[key] + + +@mock.patch.dict(os.environ, ENV_VARS, clear=True) +def test_prepare_datadog_tags(): + tags_list = prepare_datadog_tags() + assert "dataset:financial-demo" in tags_list diff --git a/.github/tests/test_validate_gpus.py b/.github/tests/test_validate_gpus.py new file mode 100644 index 0000000..336335e --- /dev/null +++ b/.github/tests/test_validate_gpus.py @@ -0,0 +1,27 @@ +import os +import sys +from unittest import mock + +import pytest + +sys.path.append(".github/scripts") +import validate_cpu # noqa: E402 +import validate_gpus # noqa: E402 + +ENV_VARS = { + "CUDA_VISIBLE_DEVICES": "-1", +} + + +@mock.patch.dict(os.environ, ENV_VARS, clear=True) +def test_validate_cpu_succeeds_when_there_are_no_gpus(): + validate_cpu.check_gpu_not_available() + + +@mock.patch.dict(os.environ, ENV_VARS, clear=True) +def test_validate_gpus_exits_when_there_are_no_gpus(): + # This unit test assumes that unit tests are run on a CPU + with pytest.raises(SystemExit) as pytest_wrapped_e: + validate_gpus.check_gpu_available() + assert pytest_wrapped_e.type == SystemExit + assert pytest_wrapped_e.value.code == 1 diff --git a/.github/workflows/automatic-pr-update.yml b/.github/workflows/automatic-pr-update.yml new file mode 100644 index 0000000..6621d59 --- /dev/null +++ b/.github/workflows/automatic-pr-update.yml @@ -0,0 +1,22 @@ +name: Automatic PR Merger + +on: + push: {} # update PR when base branch is updated + +jobs: + # thats's all. single step is needed - if PR is mergeable according to + # branch protection rules it will be merged automatically + mergepal: + runs-on: ubuntu-24.04 + if: github.repository == 'RasaHQ/rasa' + + steps: + - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + - uses: rasahq/update-pr-branch@f7012036a6d5659cfbc37f180716963511e81f95 + with: + token: ${{ secrets.UPDATE_BRANCH_PAT }} + # required parameter by original action - + # check is already done through protected branches so not needed for us + required_approval_count: 0 + # update branch despite failing check runs + require_passed_checks: false diff --git a/.github/workflows/ci-docs-tests.yml b/.github/workflows/ci-docs-tests.yml new file mode 100644 index 0000000..ba6ee10 --- /dev/null +++ b/.github/workflows/ci-docs-tests.yml @@ -0,0 +1,164 @@ +name: Docs Tests +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, labeled] + +concurrency: + group: ci-docs-tests-${{ github.ref }} # branch name + cancel-in-progress: true + +env: + DEFAULT_PYTHON_VERSION: "3.10" + +jobs: + changes: + name: Check for file changes + runs-on: ubuntu-24.04 + outputs: + docs: ${{ steps.filter.outputs.docs }} + steps: + - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + - uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 + id: filter + with: + token: ${{ secrets.GITHUB_TOKEN }} + filters: .github/change_filters.yml + + test_documentation: + name: Test Documentation + runs-on: ubuntu-24.04 + needs: [changes] + if: needs.changes.outputs.docs == 'true' && false # disabled as docs are moved out in new versions + + steps: + - name: Checkout git repository 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Python ${{ env.DEFAULT_PYTHON_VERSION }} 🐍 + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + + - name: Set up Node 12.x 🦙 + uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c + with: + node-version: "12.x" + + - name: Read Poetry Version 🔢 + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry 🦄 + uses: Gr1N/setup-poetry@15821dc8a61bc630db542ae4baf6a7c19a994844 # v8 + with: + poetry-version: ${{ env.POETRY_VERSION }} + + - name: Load Poetry Cached Libraries ⬇ + id: cache-poetry + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .venv + key: ${{ runner.os }}-poetry-${{ env.POETRY_VERSION }}-${{ env.DEFAULT_PYTHON_VERSION }}-${{ hashFiles('**/poetry.lock') }}-${{ secrets.POETRY_CACHE_VERSION }} + restore-keys: ${{ runner.os }}-poetry-${{ env.DEFAULT_PYTHON_VERSION }} + + - name: Clear Poetry cache + if: steps.cache-poetry.outputs.cache-hit == 'true' && contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-docs-tests') + run: rm -r .venv + + - name: Create virtual environment + if: (steps.cache-poetry.outputs.cache-hit != 'true' || contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-docs-tests')) + run: python -m venv create .venv + + - name: Set up virtual environment + if: needs.changes.outputs.docs == 'true' + run: poetry config virtualenvs.in-project true + + - name: Load Yarn Cached Packages ⬇ + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: docs/node_modules + key: ${{ runner.os }}-yarn-12.x-${{ hashFiles('docs/yarn.lock') }} + restore-keys: ${{ runner.os }}-yarn-12.x + + - name: Install Dependencies 📦 + run: | + sudo apt-get -y install libpq-dev + make install-full install-docs + + - name: Run Swagger 🕵️‍♀️ + run: | + npm install -g swagger-cli + swagger-cli validate docs/static/spec/action-server.yml + swagger-cli validate docs/static/spec/rasa.yml + + - name: Test Docs 🕸 + run: make test-docs + + documentation_lint: + name: Documentation Linting Checks + runs-on: ubuntu-24.04 + needs: [changes] + if: needs.changes.outputs.docs == 'true' && false # disabled as docs are moved out in new versions + + steps: + - name: Checkout git repository 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Python ${{ env.DEFAULT_PYTHON_VERSION }} 🐍 + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + + - name: Set up Node 12.x 🦙 + uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c + with: + node-version: "12.x" + + - name: Read Poetry Version 🔢 + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry 🦄 + uses: Gr1N/setup-poetry@15821dc8a61bc630db542ae4baf6a7c19a994844 # v8 + with: + poetry-version: ${{ env.POETRY_VERSION }} + + - name: Load Poetry Cached Libraries ⬇ + id: cache-poetry + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .venv + key: ${{ runner.os }}-poetry-${{ env.POETRY_VERSION }}-${{ env.DEFAULT_PYTHON_VERSION }}-${{ hashFiles('**/poetry.lock') }}-${{ secrets.POETRY_CACHE_VERSION }} + restore-keys: ${{ runner.os }}-poetry-${{ env.DEFAULT_PYTHON_VERSION }} + + - name: Clear Poetry cache + if: steps.cache-poetry.outputs.cache-hit == 'true' && contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-docs-tests') + run: rm -r .venv + + - name: Create virtual environment + if: (steps.cache-poetry.outputs.cache-hit != 'true' || contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-docs-tests')) + run: python -m venv create .venv + + - name: Set up virtual environment + if: needs.changes.outputs.docs == 'true' + run: poetry config virtualenvs.in-project true + + - name: Load Yarn Cached Packages ⬇ + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: docs/node_modules + key: ${{ runner.os }}-yarn-12.x-${{ hashFiles('docs/yarn.lock') }} + restore-keys: ${{ runner.os }}-yarn-12.x + + - name: Install Dependencies 📦 + run: | + sudo apt-get -y install libpq-dev + make install-full install-docs + + - name: Docs Linting Checks 🕸 + run: make lint-docs diff --git a/.github/workflows/ci-github-actions.yml b/.github/workflows/ci-github-actions.yml new file mode 100644 index 0000000..2883f6f --- /dev/null +++ b/.github/workflows/ci-github-actions.yml @@ -0,0 +1,76 @@ +name: CI Github Actions + +on: + push: + branches: + - main + tags: + - "*" + pull_request: + +env: + DEFAULT_PYTHON_VERSION: "3.10" + +jobs: + test: + name: Run Tests + runs-on: ubuntu-24.04 + #missing matrix + strategy: + fail-fast: false + + steps: + - name: Checkout git repository 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Download gomplate + run: |- + sudo curl -o /usr/local/bin/gomplate -sSL https://github.com/hairyhenderson/gomplate/releases/download/v3.9.0/gomplate_linux-amd64 + sudo chmod +x /usr/local/bin/gomplate + + - name: Set up Python ${{ env.DEFAULT_PYTHON_VERSION }} 🐍 + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + + - name: Read Poetry Version 🔢 + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry 🦄 + uses: Gr1N/setup-poetry@15821dc8a61bc630db542ae4baf6a7c19a994844 # v8 + with: + poetry-version: ${{ env.POETRY_VERSION }} + + - name: Load Poetry Cached Libraries ⬇ + id: cache-poetry + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .venv + key: ${{ runner.os }}-poetry-${{ env.POETRY_VERSION }}-${{ env.DEFAULT_PYTHON_VERSION }}-${{ hashFiles('**/poetry.lock') }}-${{ secrets.POETRY_CACHE_VERSION }} + restore-keys: ${{ runner.os }}-poetry-${{ env.DEFAULT_PYTHON_VERSION }} + + - name: Clear Poetry cache + if: steps.cache-poetry.outputs.cache-hit == 'true' && contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-unit-tests') + run: rm -r .venv + + - name: Create virtual environment + if: (steps.cache-poetry.outputs.cache-hit != 'true' || contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-unit-tests')) + run: python -m venv create .venv + + - name: Set up virtual environment + run: poetry config virtualenvs.in-project true + + - name: Install Dependencies 📦 + run: | + make install-full + + - name: Lint Code 🎎 + run: | + poetry run ruff check .github --extend-ignore D + poetry run black --check .github + + - name: Test Code 🔍 + run: | + make test-gh-actions diff --git a/.github/workflows/ci-model-regression-on-schedule.yml b/.github/workflows/ci-model-regression-on-schedule.yml new file mode 100644 index 0000000..37247cb --- /dev/null +++ b/.github/workflows/ci-model-regression-on-schedule.yml @@ -0,0 +1,724 @@ +# The docs: https://www.notion.so/rasa/The-CI-for-model-regression-tests-92af7185e08e4fb2a0c764770a8e9095 +name: CI - Model Regression on schedule + +on: + schedule: + # Run once a week + - cron: "1 23 * * */7" + +env: + GKE_ZONE: us-central1 + GCLOUD_VERSION: "318.0.0" + TF_FORCE_GPU_ALLOW_GROWTH: true + GITHUB_ISSUE_LABELS: '["type:bug :bug:", "tool:model-regression-tests"]' + PERFORMANCE_DROP_THRESHOLD: -0.05 + NVML_INTERVAL_IN_SEC: 1 + +jobs: + read_test_configuration: + name: Reads tests configuration + runs-on: ubuntu-24.04 + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + matrix_length: ${{ steps.set-matrix.outputs.matrix_length }} + steps: + - name: Checkout main + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Checkout dataset + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + with: + repository: ${{ secrets.DATASET_REPOSITORY }} + token: ${{ secrets.ML_TEST_SA_PAT }} + path: "dataset" + ref: "main" + + - name: Download gomplate + run: |- + sudo curl -o /usr/local/bin/gomplate -sSL https://github.com/hairyhenderson/gomplate/releases/download/v3.9.0/gomplate_linux-amd64 + sudo chmod +x /usr/local/bin/gomplate + + - name: Check if a configuration file exists + run: test -f .github/configs/mr-test-schedule.json + + - name: Set matrix values + id: set-matrix + shell: bash + run: |- + matrix=$(gomplate -d mapping=./dataset/dataset_config_mapping.json -d github=.github/configs/mr-test-schedule.json -f .github/templates/model_regression_test_config_to_json.tmpl) + matrix_length=$(echo $matrix | jq '.[] | length') + echo "matrix_length=$matrix_length" >> $GITHUB_OUTPUT + echo "matrix=$matrix" >> $GITHUB_OUTPUT + + deploy_runner_gpu: + name: Deploy Github Runner - GPU + needs: read_test_configuration + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Setup Python + id: python + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: '3.8' + + - name: Export CLOUDSDK_PYTHON env variable + run: | + echo "CLOUDSDK_PYTHON=${{ steps.python.outputs.python-path }}" >> $GITHUB_OUTPUT + export CLOUDSDK_PYTHON=${{ steps.python.outputs.python-path }} + + - name: Download gomplate + run: |- + curl -o gomplate -sSL https://github.com/hairyhenderson/gomplate/releases/download/v3.9.0/gomplate_linux-amd64 + chmod 755 gomplate + + - name: Get TensorFlow version + run: |- + # Read TF version from poetry.lock file + pip install toml + TF_VERSION=$(scripts/read_tensorflow_version.sh) + echo "TensorFlow version: $TF_VERSION" + echo TF_VERSION=$TF_VERSION >> $GITHUB_ENV + + # Use compatible CUDA/cuDNN with the given TF version + - name: Prepare GitHub runner image tag + run: |- + GH_RUNNER_IMAGE_TAG=$(jq -r 'if (.config | any(.TF == "${{ env.TF_VERSION }}" )) then (.config[] | select(.TF == "${{ env.TF_VERSION }}") | .IMAGE_TAG) else .default_image_tag end' .github/configs/tf-cuda.json) + echo "GitHub runner image tag for TensorFlow ${{ env.TF_VERSION }} is ${GH_RUNNER_IMAGE_TAG}" + echo GH_RUNNER_IMAGE_TAG=$GH_RUNNER_IMAGE_TAG >> $GITHUB_ENV + + num_max_replicas=3 + matrix_length=${{ needs.read_test_configuration.outputs.matrix_length }} + if [[ $matrix_length -gt $num_max_replicas ]]; then + NUM_REPLICAS=$num_max_replicas + else + NUM_REPLICAS=$matrix_length + fi + echo NUM_REPLICAS=$NUM_REPLICAS >> $GITHUB_ENV + + - name: Send warning if the current TF version does not have CUDA image tags configured + if: env.GH_RUNNER_IMAGE_TAG == 'latest' + env: + TF_CUDA_FILE: ./github/config/tf-cuda.json + run: |- + echo "::warning file=${TF_CUDA_FILE},line=3,col=1,endColumn=3::Missing cuda config for tf ${{ env.TF_VERSION }}. If you are not sure how to config CUDA, please reach out to infrastructure." + + - name: Notify slack on tf-cuda config updates + if: env.GH_RUNNER_IMAGE_TAG == 'latest' + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + uses: voxmedia/github-action-slack-notify-build@3665186a8c1a022b28a1dbe0954e73aa9081ea9e + with: + channel_id: ${{ secrets.SLACK_ALERTS_CHANNEL_ID }} + status: WARNING + color: warning + + - name: Render deployment template + run: |- + export GH_RUNNER_IMAGE_TAG=${{ env.GH_RUNNER_IMAGE_TAG }} + export GH_RUNNER_IMAGE=${{ secrets.GH_RUNNER_IMAGE }} + ./gomplate -f .github/runner/github-runner-deployment.yaml.tmpl -o runner_deployment.yaml + + # Setup gcloud auth + - uses: google-github-actions/auth@e8df18b60c5dd38ba618c121b779307266153fbf + with: + service_account: ${{ secrets.GKE_RASA_CI_GPU_SA_NAME_RASA_CI_CD }} + credentials_json: ${{ secrets.GKE_SA_RASA_CI_CD_GPU_RASA_CI_CD }} + + # Get the GKE credentials for the cluster + - name: Get GKE Cluster Credentials + uses: google-github-actions/get-gke-credentials@894c221960ab1bc16a69902f29f090638cca753f + with: + cluster_name: ${{ secrets.GKE_GPU_CLUSTER_RASA_CI_CD }} + location: ${{ env.GKE_ZONE }} + project_id: ${{ secrets.GKE_SA_RASA_CI_GPU_PROJECT_RASA_CI_CD }} + + - name: Deploy Github Runner + run: |- + kubectl apply -f runner_deployment.yaml + kubectl -n github-runner rollout status --timeout=15m deployment/github-runner-$GITHUB_RUN_ID + + - name: Notify slack on failure + if: failure() + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + uses: voxmedia/github-action-slack-notify-build@3665186a8c1a022b28a1dbe0954e73aa9081ea9e + with: + channel_id: ${{ secrets.SLACK_ALERTS_CHANNEL_ID }} + status: FAILED + color: danger + + model_regression_test_gpu: + name: Model Regression Tests - GPU + continue-on-error: true + needs: + - deploy_runner_gpu + - read_test_configuration + env: + # Determine where CUDA and Nvidia libraries are located. TensorFlow looks for libraries in the given paths + LD_LIBRARY_PATH: "/usr/local/cuda/extras/CUPTI/lib64:/usr/local/cuda/lib64:/usr/local/nvidia/lib:/usr/local/nvidia/lib64" + ACCELERATOR_TYPE: "GPU" + GITHUB_ISSUE_TITLE: "Scheduled Model Regression Test Failed" + runs-on: [self-hosted, gpu, "${{ github.run_id }}"] + strategy: + # max-parallel: By default, GitHub will maximize the number of jobs run in parallel depending on the available runners on GitHub-hosted virtual machines. + matrix: ${{fromJson(needs.read_test_configuration.outputs.matrix)}} + + steps: + - name: Checkout + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Checkout dataset + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + with: + repository: ${{ secrets.DATASET_REPOSITORY }} + token: ${{ secrets.ML_TEST_SA_PAT }} + path: "dataset" + ref: "main" + + - name: Set env variables + id: set_dataset_config_vars + env: + DATASET_NAME: "${{ matrix.dataset }}" + CONFIG_NAME: "${{ matrix.config }}" + run: |- + # determine extra environment variables + # - CONFIG + # - DATASET + # - IS_EXTERNAL + # - EXTERNAL_DATASET_REPOSITORY_BRANCH + # - TRAIN_DIR + # - TEST_DIR + # - DOMAIN_FILE + source <(gomplate -d mapping=./dataset/dataset_config_mapping.json -f .github/templates/configuration_variables.tmpl) + + # Not all configurations are available for all datasets. + # The job will fail and the workflow continues, if the configuration file doesn't exist + # for a given dataset + + echo "is_dataset_exists=true" >> $GITHUB_OUTPUT + echo "is_config_exists=true" >> $GITHUB_OUTPUT + echo "is_external=${IS_EXTERNAL}" >> $GITHUB_OUTPUT + + if [[ "${IS_EXTERNAL}" == "true" ]]; then + echo "DATASET_DIR=dataset_external" >> $GITHUB_ENV + else + echo "DATASET_DIR=dataset" >> $GITHUB_ENV + test -d dataset/$DATASET || (echo "::warning::The ${{ matrix.dataset }} dataset doesn't exist. Skipping the job." \ + && echo "is_config_exists=false" >> $GITHUB_OUTPUT && exit 0) + fi + + # Skip job if dataset is Hermit and config is BERT + DIET(seq) + ResponseSelector(t2t) or Sparse + BERT + DIET(seq) + ResponseSelector(t2t) + if [[ "${{ matrix.dataset }}" == "Hermit" && "${{ matrix.config }}" =~ "BERT + DIET(seq) + ResponseSelector(t2t)" ]]; then + echo "::warning::This ${{ matrix.dataset }} dataset / ${{ matrix.config }} config is currently being skipped due to OOM associated with the upgrade to TF 2.6." \ + && echo "is_config_exists=false" >> $GITHUB_OUTPUT && exit 0 + fi + + # Skip job if a given type is not available for a given dataset + if [[ -z "${DOMAIN_FILE}" && "${{ matrix.type }}" == "core" ]]; then + echo "::warning::The ${{ matrix.dataset }} dataset doesn't include core type. Skipping the job." \ + && echo "is_config_exists=false" >> $GITHUB_OUTPUT && exit 0 + fi + + test -f dataset/configs/$CONFIG || (echo "::warning::The ${{ matrix.config }} configuration file doesn't exist. Skipping the job." \ + && echo "is_dataset_exists=false" >> $GITHUB_OUTPUT && exit 0) + + echo "DATASET=${DATASET}" >> $GITHUB_ENV + echo "CONFIG=${CONFIG}" >> $GITHUB_ENV + echo "DOMAIN_FILE=${DOMAIN_FILE}" >> $GITHUB_ENV + echo "EXTERNAL_DATASET_REPOSITORY_BRANCH=${EXTERNAL_DATASET_REPOSITORY_BRANCH}" >> $GITHUB_ENV + echo "IS_EXTERNAL=${IS_EXTERNAL}" >> $GITHUB_ENV + + if [[ -z "${TRAIN_DIR}" ]]; then + echo "TRAIN_DIR=train" >> $GITHUB_ENV + else + echo "TRAIN_DIR=${TRAIN_DIR}" >> $GITHUB_ENV + fi + + if [[ -z "${TEST_DIR}" ]]; then + echo "TEST_DIR=test" >> $GITHUB_ENV + else + echo "TEST_DIR=${TEST_DIR}" >> $GITHUB_ENV + fi + + HOST_NAME=`hostname` + echo "HOST_NAME=${HOST_NAME}" >> $GITHUB_ENV + + - name: Checkout dataset - external + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + if: steps.set_dataset_config_vars.outputs.is_external == 'true' + with: + repository: ${{ env.DATASET }} + token: ${{ secrets.ML_TEST_SA_PAT }} + path: "dataset_external" + ref: ${{ env.EXTERNAL_DATASET_REPOSITORY_BRANCH }} + + - name: Set dataset commit + id: set-dataset-commit + working-directory: ${{ env.DATASET_DIR }} + run: | + DATASET_COMMIT=$(git rev-parse HEAD) + echo $DATASET_COMMIT + echo "dataset_commit=$DATASET_COMMIT" >> $GITHUB_OUTPUT + + - name: Start Datadog Agent + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + env: + DATASET_NAME: "${{ matrix.dataset }}" + CONFIG: "${{ matrix.config }}" + DATASET_COMMIT: "${{ steps.set-dataset-commit.outputs.dataset_commit }}" + BRANCH: ${{ github.ref }} + GITHUB_SHA: "${{ github.sha }}" + TYPE: "${{ matrix.type }}" + DATASET_REPOSITORY_BRANCH: "main" + INDEX_REPETITION: "${{ matrix.index_repetition }}" + run: | + .github/scripts/start_dd_agent.sh "${{ secrets.DD_API_KEY }}" "${{ env.ACCELERATOR_TYPE }}" ${{ env.NVML_INTERVAL_IN_SEC }} + + - name: Set up Python 3.10 🐍 + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + with: + python-version: '3.10' + + - name: Read Poetry Version 🔢 + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry 🦄 + uses: Gr1N/setup-poetry@15821dc8a61bc630db542ae4baf6a7c19a994844 # v8 + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + with: + poetry-version: ${{ env.POETRY_VERSION }} + + - name: Load Poetry Cached Libraries ⬇ + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + with: + path: ~/.cache/pypoetry/virtualenvs + key: ${{ runner.os }}-poetry-${{ env.POETRY_VERSION }}-3.9-${{ hashFiles('**/poetry.lock') }}-${{ secrets.POETRY_CACHE_VERSION }} + + - name: Install Dependencies 📦 + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: | + make install-full + poetry run python -m spacy download de_core_news_md + + - name: Install datadog-api-client + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: poetry run pip install -U datadog-api-client + + - name: Validate that GPUs are working + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: |- + poetry run python .github/scripts/validate_gpus.py + + - name: Download pretrained models 💪 + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: |- + poetry run python .github/scripts/download_pretrained.py --config dataset/configs/${CONFIG} + + - name: Run test + id: run_test + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + env: + TFHUB_CACHE_DIR: ~/.tfhub_cache/ + OMP_NUM_THREADS: 1 + run: |- + poetry run rasa --version + + export NOW_TRAIN=$(gomplate -i '{{ (time.Now).Format time.RFC3339}}'); + cd ${{ github.workspace }} + + if [[ "${{ steps.set_dataset_config_vars.outputs.is_external }}" == "true" ]]; then + export DATASET=. + fi + + if [[ "${{ matrix.type }}" == "nlu" ]]; then + poetry run rasa train nlu --quiet -u "${DATASET_DIR}/${DATASET}/${TRAIN_DIR}" -c "dataset/configs/${CONFIG}" --out "${DATASET_DIR}/models/${DATASET}/${CONFIG}" + echo "train_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TRAIN") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + + export NOW_TEST=$(gomplate -i '{{ (time.Now).Format time.RFC3339}}'); + poetry run rasa test nlu --quiet -u "${DATASET_DIR}/$DATASET/${TEST_DIR}" -m "${DATASET_DIR}/models/$DATASET/$CONFIG" --out "${{ github.workspace }}/results/$DATASET/$CONFIG" + + echo "test_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TEST") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + echo "total_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TRAIN") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + + elif [[ "${{ matrix.type }}" == "core" ]]; then + poetry run rasa train core --quiet -s ${DATASET_DIR}/$DATASET/$TRAIN_DIR -c dataset/configs/$CONFIG -d ${DATASET_DIR}/${DATASET}/${DOMAIN_FILE} + echo "train_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TRAIN") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + + export NOW_TEST=$(gomplate -i '{{ (time.Now).Format time.RFC3339}}'); + poetry run rasa test core -s "${DATASET_DIR}/${DATASET}/${TEST_DIR}" --out "${{ github.workspace }}/results/${{ matrix.dataset }}/${{ matrix.config }}" + + echo "test_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TEST") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + echo "total_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TRAIN") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + fi + + - name: Generate a JSON file with a report / Publish results to Datadog + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + env: + SUMMARY_FILE: "./report.json" + DATASET_NAME: ${{ matrix.dataset }} + RESULT_DIR: "${{ github.workspace }}/results" + CONFIG: ${{ matrix.config }} + TEST_RUN_TIME: ${{ steps.run_test.outputs.test_run_time }} + TRAIN_RUN_TIME: ${{ steps.run_test.outputs.train_run_time }} + TOTAL_RUN_TIME: ${{ steps.run_test.outputs.total_run_time }} + DATASET_REPOSITORY_BRANCH: "main" + TYPE: ${{ matrix.type }} + INDEX_REPETITION: ${{ matrix.index_repetition }} + DATASET_COMMIT: ${{ steps.set-dataset-commit.outputs.dataset_commit }} + BRANCH: ${{ github.ref }} + PR_ID: "${{ github.event.number }}" + PR_URL: "" + DD_APP_KEY: ${{ secrets.DD_APP_KEY_PERF_TEST }} + DD_API_KEY: ${{ secrets.DD_API_KEY }} + DD_SITE: datadoghq.eu + run: |- + poetry run pip install analytics-python + poetry run python .github/scripts/mr_publish_results.py + cat $SUMMARY_FILE + + - name: Upload an artifact with the report + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce + with: + name: report-${{ matrix.dataset }}-${{ matrix.config }}-${{ matrix.index_repetition }} + path: report.json + + # Prepare diagnostic data for the configs which evaluate dialog policy performance + - name: Prepare diagnostic data + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' && matrix.type == 'core' + run: | + # Create dummy files to preserve directory structure when uploading artifacts + # See: https://github.com/actions/upload-artifact/issues/174 + touch "results/${{ matrix.dataset }}/.keep" + touch "results/${{ matrix.dataset }}/${{ matrix.config }}/.keep" + + - name: Upload an artifact with diagnostic data + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' && matrix.type == 'core' + uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce + with: + name: diagnostic_data + path: | + results/**/.keep + results/${{ matrix.dataset }}/${{ matrix.config }}/failed_test_stories.yml + results/${{ matrix.dataset }}/${{ matrix.config }}/story_confusion_matrix.png + + - name: Stop Datadog Agent + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: | + sudo service datadog-agent stop + + - name: Check duplicate issue + if: failure() && github.event_name == 'schedule' + uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 + id: issue-exists + with: + result-encoding: string + github-token: ${{ github.token }} + script: | + // Get all open issues + const opts = await github.issues.listForRepo.endpoint.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: ${{ env.GITHUB_ISSUE_LABELS }} + }); + const issues = await github.paginate(opts) + + // Check if issue exist by comparing title and body + for (const issue of issues) { + if (issue.title.includes('${{ env.GITHUB_ISSUE_TITLE }}') && + issue.body.includes('${{ matrix.dataset }}') && + issue.body.includes('${{ matrix.config }}')) { + console.log(`Found an exist issue \#${issue.number}. Skip the following steps.`); + return 'true' + } + } + return 'false' + + - name: Create GitHub Issue 📬 + id: create-issue + if: failure() && steps.issue-exists.outputs.result == 'false' && github.event_name == 'schedule' + uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 + with: + # do not use GITHUB_TOKEN here because it wouldn't trigger subsequent workflows + github-token: ${{ secrets.RASABOT_GITHUB_TOKEN }} + script: | + var issue = await github.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: '${{ env.GITHUB_ISSUE_TITLE }}', + labels: ${{ env.GITHUB_ISSUE_LABELS }}, + body: '*This PR is automatically created by the Scheduled Model Regression Test workflow. Checkout the Github Action Run [here](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).*
---
**Description of Problem:**
Scheduled Model Regression Test failed.
**Configuration**: `${{ matrix.config }}`
**Dataset**: `${{ matrix.dataset}}`' + }) + return issue.data.number + + - name: Notify Slack of Failure 😱 + if: failure() && steps.issue-exists.outputs.result == 'false' && github.event_name == 'schedule' + uses: 8398a7/action-slack@fbd6aa58ba854a740e11a35d0df80cb5d12101d8 # v3 + with: + status: custom + fields: workflow,job,commit,repo,ref,author,took + custom_payload: | + { + attachments: [{ + fallback: 'fallback', + color: '${{ job.status }}' === 'success' ? 'good' : '${{ job.status }}' === 'failure' ? 'danger' : 'warning', + title: `${process.env.AS_WORKFLOW}`, + text: 'Scheduled model regression test failed :no_entry:️', + fields: [{ + title: 'Configuration', + value: '${{ matrix.config }}', + short: false + }, + { + title: 'Dataset', + value: '${{ matrix.dataset }}', + short: false + }, + { + title: 'GitHub Issue', + value: `https://github.com/${{ github.repository }}/issues/${{ steps.create-issue.outputs.result }}`, + short: false + }, + { + title: 'GitHub Action', + value: `https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}`, + short: false + }], + actions: [{ + }] + }] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CI_MODEL_REGRESSION_TEST }} + + combine_reports: + name: Combine reports + runs-on: ubuntu-24.04 + needs: + - model_regression_test_gpu + if: always() && needs.model_regression_test_gpu.result == 'success' + steps: + - name: Checkout git repository 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Python 3.10 🐍 + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: '3.10' + + - name: Get reports + uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a + with: + path: reports/ + + - name: Display structure of downloaded files + continue-on-error: true + run: ls -R + working-directory: reports/ + + - name: Merge all reports + env: + SUMMARY_FILE: "./report.json" + REPORTS_DIR: "reports/" + run: | + python .github/scripts/mr_generate_summary.py + cat $SUMMARY_FILE + + - name: Upload an artifact with the overall report + uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce + with: + name: report.json + path: ./report.json + + analyse_performance: + name: Analyse Performance + runs-on: ubuntu-24.04 + if: always() && github.event_name == 'schedule' + needs: + - model_regression_test_gpu + - combine_reports + env: + GITHUB_ISSUE_TITLE: "Scheduled Model Regression Test Performance Drops" + + steps: + - name: Checkout + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Download report from last on-schedule regression test + run: | + # Get ID of last on-schedule workflow + SCHEDULE_ID=$(curl -X GET -s -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${{ github.repository }}/actions/workflows" \ + | jq -r '.workflows[] | select(.name == "${{ github.workflow }}") | select(.path | test("schedule")) | .id') + + ARTIFACT_URL=$(curl -s -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${{ github.repository }}/actions/workflows/${SCHEDULE_ID}/runs?event=schedule&status=completed&branch=main&per_page=1" | jq -r .workflow_runs[0].artifacts_url) + + DOWNLOAD_URL=$(curl -s -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' -H "Accept: application/vnd.github.v3+json" "${ARTIFACT_URL}" \ + | jq -r '.artifacts[] | select(.name == "report.json") | .archive_download_url') + + # Download the artifact + curl -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' -LJO -H "Accept: application/vnd.github.v3+json" $DOWNLOAD_URL + + # Unzip and change name + unzip report.json.zip && mv report.json report_main.json + + - name: Download the report + uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a + with: + name: report.json + + - name: Download gomplate + run: |- + sudo curl -o /usr/local/bin/gomplate -sSL https://github.com/hairyhenderson/gomplate/releases/download/v3.9.0/gomplate_linux-amd64 + sudo chmod +x /usr/local/bin/gomplate + + - name: Analyse Performance 🔍 + id: performance + run: | + OUTPUT="$(gomplate -d data=report.json -d results_main=report_main.json -f .github/templates/model_regression_test_results.tmpl)" + OUTPUT="${OUTPUT//$'\n'/'%0A'}" + OUTPUT="${OUTPUT//$'\r'/'%0D'}" + OUTPUT="$(echo $OUTPUT | sed 's|`|\\`|g')" + echo "report_description=${OUTPUT}" >> $GITHUB_OUTPUT + + IS_DROPPED=false + + # Loop through all negative values within parentheses + # Set IS_DROPPED to true if there is any value lower + # than the threshold + for x in $(grep -o '\(-[0-9.]\+\)' <<< $OUTPUT); do + if (( $(bc -l <<< "${{ env.PERFORMANCE_DROP_THRESHOLD }} > $x") )); then + IS_DROPPED=true + echo "The decrease of some test performance is > ${{ env.PERFORMANCE_DROP_THRESHOLD }}. Executing the following steps..." + break + fi + done + + echo "is_dropped=$IS_DROPPED" >> $GITHUB_OUTPUT + + - name: Check duplicate issue + if: steps.performance.outputs.is_dropped == 'true' + uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 + id: issue-exists + with: + result-encoding: string + github-token: ${{ github.token }} + script: | + // Get all open issues based on labels + const opts = await github.issues.listForRepo.endpoint.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: ${{ env.GITHUB_ISSUE_LABELS }} + }); + const issues = await github.paginate(opts) + + // Check if issue exist by comparing title + for (const issue of issues) { + if (issue.title.includes('${{ env.GITHUB_ISSUE_TITLE }}') ) { + console.log(`Found an exist issue \#${issue.number}. Skip the following steps.`); + return 'true' + } + } + return 'false' + + - name: Create GitHub Issue 📬 + id: create-issue + if: steps.performance.outputs.is_dropped == 'true' && steps.issue-exists.outputs.result == 'false' + uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 + with: + # do not use GITHUB_TOKEN here because it wouldn't trigger subsequent workflows + github-token: ${{ secrets.RASABOT_GITHUB_TOKEN }} + script: | + // Prepare issue body + let issue_body = '*This PR is automatically created by the Scheduled Model Regression Test workflow. Checkout the Github Action Run [here](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).*
---
**Description of Problem:**
Some test performance scores **decreased**. Please look at the following table for more details.
' + issue_body += `${{ steps.performance.outputs.report_description }}` + + // Open issue + var issue = await github.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: '${{ env.GITHUB_ISSUE_TITLE }}', + labels: ${{ env.GITHUB_ISSUE_LABELS }}, + body: issue_body + }) + return issue.data.number + + - name: Notify Slack when Performance Drops 💬 + if: steps.performance.outputs.is_dropped == 'true' && steps.issue-exists.outputs.result == 'false' + uses: 8398a7/action-slack@fbd6aa58ba854a740e11a35d0df80cb5d12101d8 #v3 + with: + status: custom + fields: workflow,job,commit,repo,ref,author,took + custom_payload: | + { + attachments: [{ + fallback: 'fallback', + color: 'danger', + title: `${process.env.AS_WORKFLOW}`, + text: 'Scheduled model regression test performance drops :chart_with_downwards_trend:', + fields: [{ + title: 'GitHub Issue', + value: `https://github.com/${{ github.repository }}/issues/${{ steps.create-issue.outputs.result }}`, + short: false + }, + { + title: 'GitHub Action', + value: `https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}`, + short: false + }], + actions: [{ + }] + }] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CI_MODEL_REGRESSION_TEST }} + + remove_runner_gpu: + name: Delete Github Runner - GPU + if: always() + needs: + - deploy_runner_gpu + - model_regression_test_gpu + runs-on: ubuntu-24.04 + + steps: + + - name: Setup Python + id: python + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: '3.8' + + - name: Export CLOUDSDK_PYTHON env variable + run: | + echo "CLOUDSDK_PYTHON=${{ steps.python.outputs.python-path }}" >> $GITHUB_OUTPUT + export CLOUDSDK_PYTHON=${{ steps.python.outputs.python-path }} + + # Setup gcloud auth + - uses: google-github-actions/auth@e8df18b60c5dd38ba618c121b779307266153fbf + with: + service_account: ${{ secrets.GKE_RASA_CI_GPU_SA_NAME_RASA_CI_CD }} + credentials_json: ${{ secrets.GKE_SA_RASA_CI_CD_GPU_RASA_CI_CD }} + + # Get the GKE credentials for the cluster + - name: Get GKE Cluster Credentials + uses: google-github-actions/get-gke-credentials@894c221960ab1bc16a69902f29f090638cca753f + with: + cluster_name: ${{ secrets.GKE_GPU_CLUSTER_RASA_CI_CD }} + location: ${{ env.GKE_ZONE }} + project_id: ${{ secrets.GKE_SA_RASA_CI_GPU_PROJECT_RASA_CI_CD }} + + - name: Remove Github Runner + run: kubectl -n github-runner delete deployments github-runner-${GITHUB_RUN_ID} --grace-period=30 diff --git a/.github/workflows/ci-model-regression.yml b/.github/workflows/ci-model-regression.yml new file mode 100644 index 0000000..04f6674 --- /dev/null +++ b/.github/workflows/ci-model-regression.yml @@ -0,0 +1,923 @@ +# The docs: +# - https://www.notion.so/rasa/The-CI-for-model-regression-tests-aa579d5524a544af992f97d132bcc2de +# - https://www.notion.so/rasa/Datadog-Usage-Documentation-422099c9a3a24f5a99d92d904537dd0b +name: CI - Model Regression + +on: + push: + branches: + - "[0-9]+.[0-9]+.x" + tags: + - "**" + pull_request: + types: [opened, synchronize, labeled] + +concurrency: + group: ci-model-regression-${{ github.ref }} # branch or tag name + cancel-in-progress: true + +env: + GKE_ZONE: us-central1 + GCLOUD_VERSION: "318.0.0" + DD_PROFILING_ENABLED: false + TF_FORCE_GPU_ALLOW_GROWTH: true + NVML_INTERVAL_IN_SEC: 1 + +jobs: + read_test_configuration: + name: Reads tests configuration + if: ${{ github.repository == 'RasaHQ/rasa' && contains(github.event.pull_request.labels.*.name, 'status:model-regression-tests') }} + runs-on: ubuntu-24.04 + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + matrix_length: ${{ steps.set-matrix.outputs.matrix_length }} + configuration_id: ${{ steps.fc_config.outputs.comment-id }} + dataset_branch: ${{ steps.set-dataset-branch.outputs.dataset_branch }} + + steps: + - name: Checkout main + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Download gomplate + run: |- + sudo curl -o /usr/local/bin/gomplate -sSL https://github.com/hairyhenderson/gomplate/releases/download/v3.9.0/gomplate_linux-amd64 + sudo chmod +x /usr/local/bin/gomplate + + - name: Find a comment with configuration + uses: tczekajlo/find-comment@16228d0f2100e06ea9bf8c0e7fe7287b7c6b531d + id: fc_config + with: + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.number }} + body-includes: "^/modeltest" + + - run: echo ${{ steps.fc_config.outputs.comment-id }} + + # This step has to happen before the other configuration details are read from + # the same PR comment, because we need to check out the correct branch to feed the + # dataset mapping and configs into the 'Read configuration from a PR comment' step + # which creates the experiments matrix + - name: Read dataset branch from a PR comment + if: steps.fc_config.outputs.comment-id != '' + id: set-dataset-branch + run: |- + source <(gomplate -d github=https://api.github.com/repos/${{ github.repository }}/issues/comments/${{ steps.fc_config.outputs.comment-id }} -H 'github=Authorization:token ${{ secrets.GITHUB_TOKEN }}' -f .github/templates/model_regression_test_read_dataset_branch.tmpl) + echo "dataset_branch=${DATASET_BRANCH}" >> $GITHUB_OUTPUT + + - name: Checkout dataset + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + with: + repository: ${{ secrets.DATASET_REPOSITORY }} + token: ${{ secrets.ML_TEST_SA_PAT }} + path: "dataset" + ref: ${{ steps.set-dataset-branch.outputs.dataset_branch }} + + - name: Render help description from template + id: get_help_description + run: | + OUTPUT=$(gomplate -d mapping=./dataset/dataset_config_mapping.json -f .github/templates/model_regression_test_config_comment.tmpl) + OUTPUT="${OUTPUT//$'\n'/'%0A'}" + OUTPUT="${OUTPUT//$'\r'/'%0D'}" + echo "help_description=$OUTPUT" >> $GITHUB_OUTPUT + + - name: Create a comment with help description + uses: RasaHQ/create-comment@da7b2ec20116674919493bb5894eea70fdaa6486 + with: + mode: "delete-previous" + id: comment_help_description + github-token: ${{ secrets.GITHUB_TOKEN }} + body: | + ${{ steps.get_help_description.outputs.help_description }} + + - if: steps.fc_config.outputs.comment-id == '' + run: echo "::error::Cannot find a comment with the configuration" + name: Log a warning message if a configuration cannot be found + + - name: Read configuration from a PR comment + if: steps.fc_config.outputs.comment-id != '' + id: set-matrix + run: |- + matrix=$(gomplate -d mapping=./dataset/dataset_config_mapping.json -d github=https://api.github.com/repos/${{ github.repository }}/issues/comments/${{ steps.fc_config.outputs.comment-id }} -H 'github=Authorization:token ${{ secrets.GITHUB_TOKEN }}' -f .github/templates/model_regression_test_config_to_json.tmpl) + + if [ $? -ne 0 ]; then + echo "::error::Cannot read config from PR. Please double check your config." + exit 1 + fi + + matrix_length=$(echo $matrix | jq '.[] | length') + echo "matrix_length=$matrix_length" >> $GITHUB_OUTPUT + echo "matrix=$matrix" >> $GITHUB_OUTPUT + + - name: Update the comment with the configuration + uses: peter-evans/create-or-update-comment@3383acd359705b10cb1eeef05c0e88c056ea4666 + if: steps.fc_config.outputs.comment-id != '' + with: + comment-id: ${{ steps.fc_config.outputs.comment-id }} + body: | + + reactions: eyes + + - name: Re-create the comment with the configuration + uses: RasaHQ/create-comment@da7b2ec20116674919493bb5894eea70fdaa6486 + if: steps.fc_config.outputs.comment-id != '' && steps.fc_config.outputs.comment-body != '' + with: + mode: "delete-previous" + id: comment_configuration + github-token: ${{ secrets.GITHUB_TOKEN }} + body: ${{ steps.fc_config.outputs.comment-body }} + + - name: Find a comment with configuration - update + uses: tczekajlo/find-comment@16228d0f2100e06ea9bf8c0e7fe7287b7c6b531d + id: fc_config_update + with: + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.number }} + body-includes: "^/modeltest" + + - name: Add reaction + uses: peter-evans/create-or-update-comment@3383acd359705b10cb1eeef05c0e88c056ea4666 + if: steps.fc_config_update.outputs.comment-id != '' + with: + edit-mode: "replace" + comment-id: ${{ steps.fc_config_update.outputs.comment-id }} + reactions: heart, hooray, rocket + + - name: Add a comment that the tests are in progress + uses: RasaHQ/create-comment@da7b2ec20116674919493bb5894eea70fdaa6486 + if: steps.fc_config_update.outputs.comment-id != '' + with: + mode: "delete-previous" + id: comment_tests_in_progress + github-token: ${{ secrets.GITHUB_TOKEN }} + body: | + The model regression tests have started. It might take a while, please be patient. + As soon as results are ready you'll see a new comment with the results. + + Used configuration can be found in [the comment.](https://github.com/${{ github.repository }}/pull/${{ github.event.number}}#issuecomment-${{ steps.fc_config_update.outputs.comment-id }}) + + deploy_runner_gpu: + name: Deploy Github Runner - GPU + needs: read_test_configuration + runs-on: ubuntu-24.04 + if: ${{ contains(github.event.pull_request.labels.*.name, 'runner:gpu') && github.repository == 'RasaHQ/rasa' && contains(github.event.pull_request.labels.*.name, 'status:model-regression-tests') && needs.read_test_configuration.outputs.configuration_id != '' }} + + steps: + - name: Checkout + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Download gomplate + run: |- + sudo curl -o /usr/local/bin/gomplate -sSL https://github.com/hairyhenderson/gomplate/releases/download/v3.9.0/gomplate_linux-amd64 + sudo chmod +x /usr/local/bin/gomplate + + - name: Get TensorFlow version + run: |- + # Read TF version from poetry.lock file + pip install toml + TF_VERSION=$(scripts/read_tensorflow_version.sh) + # Keep the first 3 characters, e.g. we keep 2.3 if TF_VERSION is 2.3.4 + TF_VERSION=${TF_VERSION::3} + echo "TensorFlow version: $TF_VERSION" + echo TF_VERSION=$TF_VERSION >> $GITHUB_ENV + + # Use compatible CUDA/cuDNN with the given TF version + - name: Prepare GitHub runner image tag + run: |- + GH_RUNNER_IMAGE_TAG=$(jq -r 'if (.config | any(.TF == "${{ env.TF_VERSION }}" )) then (.config[] | select(.TF == "${{ env.TF_VERSION }}") | .IMAGE_TAG) else .default_image_tag end' .github/configs/tf-cuda.json) + echo "GitHub runner image tag for TensorFlow ${{ env.TF_VERSION }} is ${GH_RUNNER_IMAGE_TAG}" + echo GH_RUNNER_IMAGE_TAG=$GH_RUNNER_IMAGE_TAG >> $GITHUB_ENV + + num_max_replicas=3 + matrix_length=${{ needs.read_test_configuration.outputs.matrix_length }} + if [[ $matrix_length -gt $num_max_replicas ]]; then + NUM_REPLICAS=$num_max_replicas + else + NUM_REPLICAS=$matrix_length + fi + echo NUM_REPLICAS=$NUM_REPLICAS >> $GITHUB_ENV + + - name: Send warning if the current TF version does not have CUDA image tags configured + if: env.GH_RUNNER_IMAGE_TAG == 'latest' + env: + TF_CUDA_FILE: ./github/config/tf-cuda.json + run: |- + echo "::warning file=${TF_CUDA_FILE},line=3,col=1,endColumn=3::Missing cuda config for tf ${{ env.TF_VERSION }}. If you are not sure how to config CUDA, please reach out to infrastructure." + + - name: Notify slack on tf-cuda config updates + if: env.GH_RUNNER_IMAGE_TAG == 'latest' + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + uses: voxmedia/github-action-slack-notify-build@3665186a8c1a022b28a1dbe0954e73aa9081ea9e + with: + channel_id: ${{ secrets.SLACK_ALERTS_CHANNEL_ID }} + status: WARNING + color: warning + + - name: Render deployment template + run: |- + export GH_RUNNER_IMAGE_TAG=${{ env.GH_RUNNER_IMAGE_TAG }} + export GH_RUNNER_IMAGE=${{ secrets.GH_RUNNER_IMAGE }} + gomplate -f .github/runner/github-runner-deployment.yaml.tmpl -o runner_deployment.yaml + + # Setup gcloud auth + - uses: google-github-actions/auth@e8df18b60c5dd38ba618c121b779307266153fbf + with: + service_account: ${{ secrets.GKE_RASA_CI_GPU_SA_NAME_RASA_CI_CD }} + credentials_json: ${{ secrets.GKE_SA_RASA_CI_CD_GPU_RASA_CI_CD }} + + # Get the GKE credentials for the cluster + - name: Get GKE Cluster Credentials + uses: google-github-actions/get-gke-credentials@894c221960ab1bc16a69902f29f090638cca753f + with: + cluster_name: ${{ secrets.GKE_GPU_CLUSTER_RASA_CI_CD }} + location: ${{ env.GKE_ZONE }} + project_id: ${{ secrets.GKE_SA_RASA_CI_GPU_PROJECT_RASA_CI_CD }} + + - name: Deploy Github Runner + run: |- + kubectl apply -f runner_deployment.yaml + kubectl -n github-runner rollout status --timeout=15m deployment/github-runner-$GITHUB_RUN_ID + + model_regression_test_gpu: + name: Model Regression Tests - GPU + needs: + - deploy_runner_gpu + - read_test_configuration + env: + # Determine where CUDA and Nvidia libraries are located. TensorFlow looks for libraries in the given paths + LD_LIBRARY_PATH: "/usr/local/cuda/extras/CUPTI/lib64:/usr/local/cuda/lib64:/usr/local/nvidia/lib:/usr/local/nvidia/lib64" + ACCELERATOR_TYPE: "GPU" + runs-on: [self-hosted, gpu, "${{ github.run_id }}"] + strategy: + # max-parallel: By default, GitHub will maximize the number of jobs run in parallel depending on the available runners on GitHub-hosted virtual machines. + matrix: ${{fromJson(needs.read_test_configuration.outputs.matrix)}} + fail-fast: false + if: ${{ contains(github.event.pull_request.labels.*.name, 'runner:gpu') && github.repository == 'RasaHQ/rasa' && contains(github.event.pull_request.labels.*.name, 'status:model-regression-tests') && needs.read_test_configuration.outputs.configuration_id != '' }} + + steps: + - name: Checkout + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Checkout dataset + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + with: + repository: ${{ secrets.DATASET_REPOSITORY }} + token: ${{ secrets.ML_TEST_SA_PAT }} + path: "dataset" + ref: ${{ needs.read_test_configuration.outputs.dataset_branch }} + + - name: Set env variables + id: set_dataset_config_vars + env: + DATASET_NAME: "${{ matrix.dataset }}" + CONFIG_NAME: "${{ matrix.config }}" + run: |- + # determine extra environment variables + # - CONFIG + # - DATASET + # - IS_EXTERNAL + # - EXTERNAL_DATASET_REPOSITORY_BRANCH + # - TRAIN_DIR + # - TEST_DIR + # - DOMAIN_FILE + source <(gomplate -d mapping=./dataset/dataset_config_mapping.json -f .github/templates/configuration_variables.tmpl) + + # Not all configurations are available for all datasets. + # The job will fail and the workflow continues, if the configuration file doesn't exist + # for a given dataset + + echo "is_dataset_exists=true" >> $GITHUB_OUTPUT + echo "is_config_exists=true" >> $GITHUB_OUTPUT + echo "is_external=${IS_EXTERNAL}" >> $GITHUB_OUTPUT + + # Warn about job if dataset is Hermit and config is BERT + DIET(seq) + ResponseSelector(t2t) or Sparse + BERT + DIET(seq) + ResponseSelector(t2t) + if [[ "${{ matrix.dataset }}" == "Hermit" && "${{ matrix.config }}" =~ "BERT + DIET(seq) + ResponseSelector(t2t)" ]]; then + echo "::warning::This ${{ matrix.dataset }} dataset / ${{ matrix.config }} config is currently being skipped on scheduled tests due to OOM associated with the upgrade to TF 2.6. You may see OOM here." + fi + + if [[ "${IS_EXTERNAL}" == "true" ]]; then + echo "DATASET_DIR=dataset_external" >> $GITHUB_ENV + else + echo "DATASET_DIR=dataset" >> $GITHUB_ENV + test -d dataset/$DATASET || (echo "::warning::The ${{ matrix.dataset }} dataset doesn't exist. Skipping the job." \ + && echo "is_config_exists=false" >> $GITHUB_OUTPUT && exit 0) + fi + + # Skip job if a given type is not available for a given dataset + if [[ -z "${DOMAIN_FILE}" && "${{ matrix.type }}" == "core" ]]; then + echo "::warning::The ${{ matrix.dataset }} dataset doesn't include core type. Skipping the job." \ + && echo "is_config_exists=false" >> $GITHUB_OUTPUT && exit 0 + fi + + test -f dataset/configs/$CONFIG || (echo "::warning::The ${{ matrix.config }} configuration file doesn't exist. Skipping the job." \ + && echo "is_dataset_exists=false" >> $GITHUB_OUTPUT && exit 0) + + echo "DATASET=${DATASET}" >> $GITHUB_ENV + echo "CONFIG=${CONFIG}" >> $GITHUB_ENV + echo "DOMAIN_FILE=${DOMAIN_FILE}" >> $GITHUB_ENV + echo "EXTERNAL_DATASET_REPOSITORY_BRANCH=${EXTERNAL_DATASET_REPOSITORY_BRANCH}" >> $GITHUB_ENV + echo "IS_EXTERNAL=${IS_EXTERNAL}" >> $GITHUB_ENV + + if [[ -z "${TRAIN_DIR}" ]]; then + echo "TRAIN_DIR=train" >> $GITHUB_ENV + else + echo "TRAIN_DIR=${TRAIN_DIR}" >> $GITHUB_ENV + fi + + if [[ -z "${TEST_DIR}" ]]; then + echo "TEST_DIR=test" >> $GITHUB_ENV + else + echo "TEST_DIR=${TEST_DIR}" >> $GITHUB_ENV + fi + + HOST_NAME=`hostname` + echo "HOST_NAME=${HOST_NAME}" >> $GITHUB_ENV + + - name: Checkout dataset - external + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + if: steps.set_dataset_config_vars.outputs.is_external == 'true' + with: + repository: ${{ env.DATASET }} + token: ${{ secrets.ML_TEST_SA_PAT }} + path: "dataset_external" + ref: ${{ env.EXTERNAL_DATASET_REPOSITORY_BRANCH }} + + - name: Set dataset commit + id: set-dataset-commit + working-directory: ${{ env.DATASET_DIR }} + run: | + DATASET_COMMIT=$(git rev-parse HEAD) + echo $DATASET_COMMIT + echo "dataset_commit=$DATASET_COMMIT" >> $GITHUB_OUTPUT + + - name: Start Datadog Agent + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + env: + DATASET_NAME: "${{ matrix.dataset }}" + CONFIG: "${{ matrix.config }}" + DATASET_COMMIT: "${{ steps.set-dataset-commit.outputs.dataset_commit }}" + BRANCH: ${{ github.head_ref }} + GITHUB_SHA: "${{ github.sha }}" + PR_ID: "${{ github.event.number }}" + TYPE: "${{ matrix.type }}" + DATASET_REPOSITORY_BRANCH: ${{ needs.read_test_configuration.outputs.dataset_branch }} + INDEX_REPETITION: "${{ matrix.index_repetition }}" + run: | + export PR_URL="https://github.com/${GITHUB_REPOSITORY}/pull/${{ github.event.number }}" + .github/scripts/start_dd_agent.sh "${{ secrets.DD_API_KEY }}" "${{ env.ACCELERATOR_TYPE }}" ${{ env.NVML_INTERVAL_IN_SEC }} + + - name: Set up Python 3.10 🐍 + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + with: + python-version: '3.10' + + - name: Read Poetry Version 🔢 + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry 🦄 + uses: Gr1N/setup-poetry@15821dc8a61bc630db542ae4baf6a7c19a994844 # v8 + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + with: + poetry-version: ${{ env.POETRY_VERSION }} + + - name: Load Poetry Cached Libraries ⬇ + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + with: + path: ~/.cache/pypoetry/virtualenvs + key: ${{ runner.os }}-poetry-${{ env.POETRY_VERSION }}-3.9-${{ hashFiles('**/poetry.lock') }}-${{ secrets.POETRY_CACHE_VERSION }} + + - name: Install Dependencies 📦 + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: | + make install-full + poetry run python -m spacy download de_core_news_md + + - name: Install datadog dependencies + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: poetry run pip install -U datadog-api-client ddtrace + + - name: Validate that GPUs are working + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: |- + poetry run python .github/scripts/validate_gpus.py + + - name: Download pretrained models 💪 + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: |- + poetry run python .github/scripts/download_pretrained.py --config dataset/configs/${CONFIG} + + - name: Run test + id: run_test + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + env: + TFHUB_CACHE_DIR: ~/.tfhub_cache/ + OMP_NUM_THREADS: 1 + run: |- + poetry run rasa --version + + export NOW_TRAIN=$(gomplate -i '{{ (time.Now).Format time.RFC3339}}'); + cd ${{ github.workspace }} + + if [[ "${{ steps.set_dataset_config_vars.outputs.is_external }}" == "true" ]]; then + export DATASET=. + fi + + if [[ "${{ matrix.type }}" == "nlu" ]]; then + poetry run ddtrace-run rasa train nlu --quiet -u ${DATASET_DIR}/${DATASET}/${TRAIN_DIR} -c dataset/configs/${CONFIG} --out ${DATASET_DIR}/models/${DATASET}/${CONFIG} + echo "train_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TRAIN") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + + export NOW_TEST=$(gomplate -i '{{ (time.Now).Format time.RFC3339}}'); + poetry run ddtrace-run rasa test nlu --quiet -u ${DATASET_DIR}/$DATASET/${TEST_DIR} -m ${DATASET_DIR}/models/$DATASET/$CONFIG --out ${{ github.workspace }}/results/$DATASET/$CONFIG + + echo "test_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TEST") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + echo "total_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TRAIN") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + + elif [[ "${{ matrix.type }}" == "core" ]]; then + poetry run ddtrace-run rasa train core --quiet -s ${DATASET_DIR}/$DATASET/$TRAIN_DIR -c dataset/configs/$CONFIG -d ${DATASET_DIR}/${DATASET}/${DOMAIN_FILE} + echo "train_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TRAIN") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + + export NOW_TEST=$(gomplate -i '{{ (time.Now).Format time.RFC3339}}'); + poetry run ddtrace-run rasa test core -s ${DATASET_DIR}/${DATASET}/${TEST_DIR} --out ${{ github.workspace }}/results/${{ matrix.dataset }}/${CONFIG} + + echo "test_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TEST") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + echo "total_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TRAIN") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + fi + + - name: Generate a JSON file with a report / Publish results to Datadog + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + env: + SUMMARY_FILE: "./report.json" + DATASET_NAME: ${{ matrix.dataset }} + RESULT_DIR: "${{ github.workspace }}/results" + CONFIG: ${{ matrix.config }} + TEST_RUN_TIME: ${{ steps.run_test.outputs.test_run_time }} + TRAIN_RUN_TIME: ${{ steps.run_test.outputs.train_run_time }} + TOTAL_RUN_TIME: ${{ steps.run_test.outputs.total_run_time }} + DATASET_REPOSITORY_BRANCH: ${{ needs.read_test_configuration.outputs.dataset_branch }} + TYPE: ${{ matrix.type }} + INDEX_REPETITION: ${{ matrix.index_repetition }} + DATASET_COMMIT: ${{ steps.set-dataset-commit.outputs.dataset_commit }} + BRANCH: ${{ github.head_ref }} + GITHUB_SHA: "${{ github.sha }}" + PR_ID: "${{ github.event.number }}" + DD_APP_KEY: ${{ secrets.DD_APP_KEY_PERF_TEST }} + DD_API_KEY: ${{ secrets.DD_API_KEY }} + DD_SITE: datadoghq.eu + run: |- + export PR_URL="https://github.com/${GITHUB_REPOSITORY}/pull/${{ github.event.number }}" + poetry run pip install analytics-python + poetry run python .github/scripts/mr_publish_results.py + cat $SUMMARY_FILE + + - name: Upload an artifact with the report + uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + with: + name: report-${{ matrix.dataset }}-${{ matrix.config }}-${{ matrix.index_repetition }} + path: report.json + + - name: Stop Datadog Agent + if: ${{ always() && steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' }} + run: | + sudo service datadog-agent stop + + model_regression_test_cpu: + name: Model Regression Tests - CPU + needs: + - read_test_configuration + env: + ACCELERATOR_TYPE: "CPU" + runs-on: ubuntu-24.04 + strategy: + max-parallel: 3 + matrix: ${{fromJson(needs.read_test_configuration.outputs.matrix)}} + fail-fast: false + if: ${{ !contains(github.event.pull_request.labels.*.name, 'runner:gpu') && github.repository == 'RasaHQ/rasa' && contains(github.event.pull_request.labels.*.name, 'status:model-regression-tests') && needs.read_test_configuration.outputs.configuration_id != '' }} + + steps: + - name: Checkout + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Checkout dataset + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + with: + repository: ${{ secrets.DATASET_REPOSITORY }} + token: ${{ secrets.ML_TEST_SA_PAT }} + path: "dataset" + ref: ${{ needs.read_test_configuration.outputs.dataset_branch }} + + - name: Download gomplate + run: |- + sudo curl -o /usr/local/bin/gomplate -sSL https://github.com/hairyhenderson/gomplate/releases/download/v3.9.0/gomplate_linux-amd64 + sudo chmod +x /usr/local/bin/gomplate + + - name: Set env variables + id: set_dataset_config_vars + env: + DATASET_NAME: "${{ matrix.dataset }}" + CONFIG_NAME: "${{ matrix.config }}" + run: |- + # determine extra environment variables + # - CONFIG + # - DATASET + # - IS_EXTERNAL + # - EXTERNAL_DATASET_REPOSITORY_BRANCH + # - TRAIN_DIR + # - TEST_DIR + # - DOMAIN_FILE + source <(gomplate -d mapping=./dataset/dataset_config_mapping.json -f .github/templates/configuration_variables.tmpl) + + # Not all configurations are available for all datasets. + # The job will fail and the workflow continues, if the configuration file doesn't exist + # for a given dataset + + echo "is_dataset_exists=true" >> $GITHUB_OUTPUT + echo "is_config_exists=true" >> $GITHUB_OUTPUT + echo "is_external=${IS_EXTERNAL}" >> $GITHUB_OUTPUT + + if [[ "${IS_EXTERNAL}" == "true" ]]; then + echo "DATASET_DIR=dataset_external" >> $GITHUB_ENV + else + echo "DATASET_DIR=dataset" >> $GITHUB_ENV + test -d dataset/$DATASET || (echo "::warning::The ${{ matrix.dataset }} dataset doesn't exist. Skipping the job." \ + && echo "is_config_exists=false" >> $GITHUB_OUTPUT && exit 0) + fi + + # Skip job if a given type is not available for a given dataset + if [[ -z "${DOMAIN_FILE}" && "${{ matrix.type }}" == "core" ]]; then + echo "::warning::The ${{ matrix.dataset }} dataset doesn't include core type. Skipping the job." \ + && echo "is_config_exists=false" >> $GITHUB_OUTPUT && exit 0 + fi + + test -f dataset/configs/$CONFIG || (echo "::warning::The ${{ matrix.config }} configuration file doesn't exist. Skipping the job." \ + && echo "is_dataset_exists=false" >> $GITHUB_OUTPUT && exit 0) + + echo "DATASET=${DATASET}" >> $GITHUB_ENV + echo "CONFIG=${CONFIG}" >> $GITHUB_ENV + echo "DOMAIN_FILE=${DOMAIN_FILE}" >> $GITHUB_ENV + echo "EXTERNAL_DATASET_REPOSITORY_BRANCH=${EXTERNAL_DATASET_REPOSITORY_BRANCH}" >> $GITHUB_ENV + echo "IS_EXTERNAL=${IS_EXTERNAL}" >> $GITHUB_ENV + + if [[ -z "${TRAIN_DIR}" ]]; then + echo "TRAIN_DIR=train" >> $GITHUB_ENV + else + echo "TRAIN_DIR=${TRAIN_DIR}" >> $GITHUB_ENV + fi + + if [[ -z "${TEST_DIR}" ]]; then + echo "TEST_DIR=test" >> $GITHUB_ENV + else + echo "TEST_DIR=${TEST_DIR}" >> $GITHUB_ENV + fi + + HOST_NAME=`hostname` + echo "HOST_NAME=${HOST_NAME}" >> $GITHUB_ENV + + - name: Checkout dataset - external + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + if: steps.set_dataset_config_vars.outputs.is_external == 'true' + with: + repository: ${{ env.DATASET }} + token: ${{ secrets.ML_TEST_SA_PAT }} + path: "dataset_external" + ref: ${{ env.EXTERNAL_DATASET_REPOSITORY_BRANCH }} + + - name: Set dataset commit + id: set-dataset-commit + working-directory: ${{ env.DATASET_DIR }} + run: | + DATASET_COMMIT=$(git rev-parse HEAD) + echo $DATASET_COMMIT + echo "dataset_commit=$DATASET_COMMIT" >> $GITHUB_OUTPUT + + - name: Start Datadog Agent + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + env: + DATASET_NAME: "${{ matrix.dataset }}" + CONFIG: "${{ matrix.config }}" + DATASET_COMMIT: "${{ steps.set-dataset-commit.outputs.dataset_commit }}" + BRANCH: ${{ github.head_ref }} + GITHUB_SHA: "${{ github.sha }}" + PR_ID: "${{ github.event.number }}" + TYPE: "${{ matrix.type }}" + DATASET_REPOSITORY_BRANCH: ${{ matrix.dataset_branch }} + INDEX_REPETITION: "${{ matrix.index_repetition }}" + run: | + export PR_URL="https://github.com/${GITHUB_REPOSITORY}/pull/${{ github.event.number }}" + .github/scripts/start_dd_agent.sh "${{ secrets.DD_API_KEY }}" "${{ env.ACCELERATOR_TYPE }}" ${{ env.NVML_INTERVAL_IN_SEC }} + + - name: Set up Python 3.10 🐍 + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + with: + python-version: '3.10' + + - name: Read Poetry Version 🔢 + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry 🦄 + uses: Gr1N/setup-poetry@15821dc8a61bc630db542ae4baf6a7c19a994844 # v8 + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + with: + poetry-version: ${{ env.POETRY_VERSION }} + + - name: Load Poetry Cached Libraries ⬇ + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + with: + path: ~/.cache/pypoetry/virtualenvs + key: ${{ runner.os }}-poetry-${{ env.POETRY_VERSION }}-3.9-${{ hashFiles('**/poetry.lock') }}-${{ secrets.POETRY_CACHE_VERSION }} + + - name: Install Dependencies 📦 + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: | + make install-full + poetry run python -m spacy download de_core_news_md + + - name: Install datadog dependencies + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: poetry run pip install -U datadog-api-client ddtrace + + - name: CPU run - Validate that no GPUs are available + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: |- + poetry run python .github/scripts/validate_cpu.py + + - name: Download pretrained models 💪 + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + run: |- + poetry run python .github/scripts/download_pretrained.py --config dataset/configs/${CONFIG} + + - name: Run test + id: run_test + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + env: + TFHUB_CACHE_DIR: ~/.tfhub_cache/ + OMP_NUM_THREADS: 1 + run: |- + poetry run rasa --version + + export NOW_TRAIN=$(gomplate -i '{{ (time.Now).Format time.RFC3339}}'); + cd ${{ github.workspace }} + + if [[ "${{ steps.set_dataset_config_vars.outputs.is_external }}" == "true" ]]; then + export DATASET=. + fi + + if [[ "${{ matrix.type }}" == "nlu" ]]; then + poetry run ddtrace-run rasa train nlu --quiet -u ${DATASET_DIR}/${DATASET}/${TRAIN_DIR} -c dataset/configs/${CONFIG} --out ${DATASET_DIR}/models/${DATASET}/${CONFIG} + echo "train_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TRAIN") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + + export NOW_TEST=$(gomplate -i '{{ (time.Now).Format time.RFC3339}}'); + poetry run ddtrace-run rasa test nlu --quiet -u ${DATASET_DIR}/$DATASET/${TEST_DIR} -m ${DATASET_DIR}/models/$DATASET/$CONFIG --out ${{ github.workspace }}/results/$DATASET/$CONFIG + + echo "test_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TEST") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + echo "total_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TRAIN") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + + elif [[ "${{ matrix.type }}" == "core" ]]; then + poetry run ddtrace-run rasa train core --quiet -s ${DATASET_DIR}/$DATASET/$TRAIN_DIR -c dataset/configs/$CONFIG -d ${DATASET_DIR}/${DATASET}/${DOMAIN_FILE} + echo "train_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TRAIN") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + + export NOW_TEST=$(gomplate -i '{{ (time.Now).Format time.RFC3339}}'); + poetry run ddtrace-run rasa test core -s ${DATASET_DIR}/${DATASET}/${TEST_DIR} --out ${{ github.workspace }}/results/${{ matrix.dataset }}/${CONFIG} + + echo "test_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TEST") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + echo "total_run_time=$(gomplate -i '{{ $t := time.Parse time.RFC3339 (getenv "NOW_TRAIN") }}{{ (time.Since $t).Round (time.Second 1) }}')" >> $GITHUB_OUTPUT + fi + + - name: Generate a JSON file with a report / Publish results to Datadog + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + env: + SUMMARY_FILE: "./report.json" + DATASET_NAME: ${{ matrix.dataset }} + RESULT_DIR: "${{ github.workspace }}/results" + CONFIG: ${{ matrix.config }} + TEST_RUN_TIME: ${{ steps.run_test.outputs.test_run_time }} + TRAIN_RUN_TIME: ${{ steps.run_test.outputs.train_run_time }} + TOTAL_RUN_TIME: ${{ steps.run_test.outputs.total_run_time }} + DATASET_REPOSITORY_BRANCH: ${{ needs.read_test_configuration.outputs.dataset_branch }} + TYPE: ${{ matrix.type }} + INDEX_REPETITION: ${{ matrix.index_repetition }} + DATASET_COMMIT: ${{ steps.set-dataset-commit.outputs.dataset_commit }} + BRANCH: ${{ github.head_ref }} + GITHUB_SHA: "${{ github.sha }}" + PR_ID: "${{ github.event.number }}" + DD_APP_KEY: ${{ secrets.DD_APP_KEY_PERF_TEST }} + DD_API_KEY: ${{ secrets.DD_API_KEY }} + DD_SITE: datadoghq.eu + run: |- + export PR_URL="https://github.com/${GITHUB_REPOSITORY}/pull/${{ github.event.number }}" + poetry run pip install analytics-python + poetry run python .github/scripts/mr_publish_results.py + cat $SUMMARY_FILE + + - name: Upload an artifact with the report + uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce + if: steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' + with: + name: report-${{ matrix.dataset }}-${{ matrix.config }}-${{ matrix.index_repetition }} + path: report.json + + - name: Stop Datadog Agent + if: ${{ always() && steps.set_dataset_config_vars.outputs.is_dataset_exists == 'true' && steps.set_dataset_config_vars.outputs.is_config_exists == 'true' }} + run: | + sudo service datadog-agent stop + + combine_reports: + name: Combine reports + runs-on: ubuntu-24.04 + needs: + - model_regression_test_cpu + - model_regression_test_gpu + if: ${{ always() && ((needs.model_regression_test_cpu.result != 'skipped') != (needs.model_regression_test_gpu.result != 'skipped')) }} + outputs: + success_status: ${{ steps.set-success-status.outputs.success_status }} + + steps: + - name: Set success status + id: set-success-status + run: |- + succeeded=${{ needs.model_regression_test_cpu.result == 'success' || needs.model_regression_test_gpu.result == 'success' }} + if [[ $succeeded == "false" ]]; then + success_status="Failed" + elif [[ $succeeded == "true" ]]; then + success_status="Succeeded" + else + success_status="Unknown" + fi + echo $success_status + echo "success_status=$success_status" >> $GITHUB_OUTPUT + + - name: Checkout git repository 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Python 3.10 🐍 + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: '3.10' + + - name: Get reports + uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a + with: + path: reports/ + + - name: Display structure of downloaded files + continue-on-error: true + run: ls -R + working-directory: reports/ + + - name: Merge all reports + env: + SUMMARY_FILE: "./report.json" + REPORTS_DIR: "reports/" + run: | + python .github/scripts/mr_generate_summary.py + cat $SUMMARY_FILE + + - name: Upload an artifact with the overall report + uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce + with: + name: report.json + path: ./report.json + + set_job_success_status: + name: Set job success status + runs-on: ubuntu-24.04 + needs: + - combine_reports + if: ${{ always() && needs.combine_reports.result == 'success' }} + steps: + - name: Set return code + run: | + success_status=${{ needs.combine_reports.outputs.success_status }} + echo "Status: $success_status" + if [[ $success_status == "Succeeded" ]]; then + exit 0 + else + exit 1 + fi + + add_comment_results: + name: Add a comment with the results + runs-on: ubuntu-24.04 + needs: + - combine_reports + if: ${{ always() && needs.combine_reports.result == 'success' }} + + steps: + - name: Checkout + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Download report from last on-schedule regression test + run: | + # Get ID of last on-schedule workflow + SCHEDULE_ID=$(curl -X GET -s -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${{ github.repository }}/actions/workflows" \ + | jq -r '.workflows[] | select(.name == "CI - Model Regression on schedule") | select(.path | test("schedule")) | .id') + + ARTIFACT_URL=$(curl -s -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${{ github.repository }}/actions/workflows/${SCHEDULE_ID}/runs?event=schedule&status=completed&branch=main&per_page=1" | jq -r .workflow_runs[0].artifacts_url) + + DOWNLOAD_URL=$(curl -s -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' -H "Accept: application/vnd.github.v3+json" "${ARTIFACT_URL}" \ + | jq -r '.artifacts[] | select(.name == "report.json") | .archive_download_url') + + # Download the artifact + curl -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' -LJO -H "Accept: application/vnd.github.v3+json" $DOWNLOAD_URL + + # Unzip and change name + unzip report.json.zip && mv report.json report_main.json + + - name: Download the report + uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a + with: + name: report.json + + - name: Download gomplate + run: |- + sudo curl -o /usr/local/bin/gomplate -sSL https://github.com/hairyhenderson/gomplate/releases/download/v3.9.0/gomplate_linux-amd64 + sudo chmod +x /usr/local/bin/gomplate + + - name: Render a comment to add + id: get_results + run: | + OUTPUT="$(gomplate -d data=report.json -d results_main=report_main.json -f .github/templates/model_regression_test_results.tmpl)" + OUTPUT="${OUTPUT//$'\n'/'%0A'}" + OUTPUT="${OUTPUT//$'\r'/'%0D'}" + echo "result=$OUTPUT" >> $GITHUB_OUTPUT + + # Get time of current commit as start time + TIME_ISO_COMMIT=$(gomplate -d github=https://api.github.com/repos/rasaHQ/rasa/commits/${{ github.sha }} -H 'github=Authorization:token ${{ secrets.GITHUB_TOKEN }}' -i '{{ (ds "github").commit.author.date }}') # Example "2022-02-17T14:06:38Z" + TIME_UNIX_COMMIT=$(date -d "${TIME_ISO_COMMIT}" +%s%3N) # Example: "1645106798" + + # Get current time + TIME_ISO_NOW=$(gomplate -i '{{ (time.Now).UTC.Format time.RFC3339}}') # Example: "2022-02-17T14:50:54Z%" + TIME_UNIX_NOW=$(date -d "${TIME_ISO_NOW}" +%s%3N) # Example: "1645118083" + + echo "from_ts=$TIME_UNIX_COMMIT" >> $GITHUB_OUTPUT + echo "to_ts=$TIME_UNIX_NOW" >> $GITHUB_OUTPUT + + - name: Publish results as a PR comment + uses: marocchino/sticky-pull-request-comment@f61b6cf21ef2fcc468f4345cdfcc9bda741d2343 # v2.6.2 + if: ${{ always() }} + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + header: ${{ github.run_id }} + append: true + message: |- + Status of the run: ${{ needs.combine_reports.outputs.success_status }} + + Commit: ${{ github.sha }}, [The full report is available as an artifact.](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) + + [Datadog dashboard link](https://app.datadoghq.eu/dashboard/mf4-2hu-x84?tpl_var_branch_baseline=${{ github.head_ref }}&from_ts=${{ steps.get_results.outputs.from_ts }}&to_ts=${{ steps.get_results.outputs.to_ts }}&live=false) + + ${{ steps.get_results.outputs.result }} + + - name: Remove 'status:model-regression-tests' label + continue-on-error: true + uses: buildsville/add-remove-label@6008d7bd99d3baeb7c04033584e68f8ec80b198b # v1.0 + with: + token: ${{secrets.GITHUB_TOKEN}} + label: "status:model-regression-tests" + type: remove + + - name: Remove 'runner:gpu' label + continue-on-error: true + uses: buildsville/add-remove-label@6008d7bd99d3baeb7c04033584e68f8ec80b198b # v1.0 + with: + token: ${{secrets.GITHUB_TOKEN}} + label: "runner:gpu" + type: remove + + remove_runner_gpu: + name: Delete Github Runner - GPU + needs: + - deploy_runner_gpu + - model_regression_test_gpu + runs-on: ubuntu-24.04 + if: ${{ always() && needs.deploy_runner_gpu.result != 'skipped' && contains(github.event.pull_request.labels.*.name, 'runner:gpu') && contains(github.event.pull_request.labels.*.name, 'status:model-regression-tests') }} + + steps: + # Setup gcloud auth + - uses: google-github-actions/auth@e8df18b60c5dd38ba618c121b779307266153fbf + with: + service_account: ${{ secrets.GKE_RASA_CI_GPU_SA_NAME_RASA_CI_CD }} + credentials_json: ${{ secrets.GKE_SA_RASA_CI_CD_GPU_RASA_CI_CD }} + + # Get the GKE credentials for the cluster + - name: Get GKE Cluster Credentials + uses: google-github-actions/get-gke-credentials@894c221960ab1bc16a69902f29f090638cca753f + with: + cluster_name: ${{ secrets.GKE_GPU_CLUSTER_RASA_CI_CD }} + location: ${{ env.GKE_ZONE }} + project_id: ${{ secrets.GKE_SA_RASA_CI_GPU_PROJECT_RASA_CI_CD }} + + - name: Remove Github Runner + run: kubectl -n github-runner delete deployments github-runner-${GITHUB_RUN_ID} --grace-period=30 diff --git a/.github/workflows/continous-integration.yml b/.github/workflows/continous-integration.yml new file mode 100644 index 0000000..c5cf83f --- /dev/null +++ b/.github/workflows/continous-integration.yml @@ -0,0 +1,1326 @@ +name: Continuous Integration + +on: + push: + branches: + - main + tags: + - "*" + pull_request: + +concurrency: + group: continous-integration-${{ github.ref }} # branch or tag name + cancel-in-progress: true + +# SECRETS +# - GH_RELEASE_NOTES_TOKEN: personal access token of `rasabot` github account +# (login for account in 1pw) +# - SLACK_WEBHOOK_TOKEN: token to post to RasaHQ slack account (in 1password) +# - PYPI_TOKEN: publishing token for amn41 account, needs to be maintainer of +# RasaHQ/rasa on pypi (account credentials in 1password) +# - DOCKERHUB_PASSWORD: password for an account with write access to the rasa +# repo on hub.docker.com. used to pull and upload containers +# - RASA_OSS_TELEMETRY_WRITE_KEY: key to write to segment. Used to report telemetry. +# The key will be added to the distributions +# - RASA_OSS_EXCEPTION_WRITE_KEY: key to write to sentry. Used to report exceptions. +# The key will be added to the distributions. +# Key can be found at https://sentry.io/settings/rasahq/projects/rasa-open-source/install/python/ +# - SENTRY_AUTH_TOKEN: authentication used to tell Sentry about any new releases +# created at https://sentry.io/settings/account/api/auth-tokens/ + +env: + # needed to fix issues with boto during testing: + # https://github.com/travis-ci/travis-ci/issues/7940 + BOTO_CONFIG: /dev/null + + IS_TAG_BUILD: ${{ startsWith(github.event.ref, 'refs/tags') }} + DOCKERHUB_USERNAME: tmbo + DEFAULT_PYTHON_VERSION: "3.10" + + # for wait_for_xx jobs + WAIT_TIMEOUT_SECS: 3000 + WAIT_INTERVAL_SECS: 60 + +jobs: + changes: + name: Check for file changes + runs-on: ubuntu-24.04 + outputs: + # Both of the outputs below are strings but only one exists at any given time + backend: ${{ steps.changed-files.outputs.backend || steps.run-all.outputs.backend }} + docker: ${{ steps.changed-files.outputs.docker || steps.run-all.outputs.docker }} + docs: ${{ steps.changed-files.outputs.docs || steps.run-all.outputs.docs }} + is_pre_release_version: ${{ steps.rasa_check_version_type.outputs.is_pre_release_version }} + steps: + - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + - uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 + # Run the normal filters if the all-tests-required label is not set + id: changed-files + if: contains(github.event.pull_request.labels.*.name, 'status:all-tests-required') == false && github.event_name == 'pull_request' + with: + token: ${{ secrets.GITHUB_TOKEN }} + filters: .github/change_filters.yml + - name: Set all filters to true if all tests are required + # Set all filters to true if the all-tests-required label is set or if we are not in a PR + # Bypasses all the change filters in change_filters.yml and forces all outputs to true + id: run-all + if: contains(github.event.pull_request.labels.*.name, 'status:all-tests-required') || github.event_name != 'pull_request' + run: | + echo "backend=true" >> $GITHUB_OUTPUT + echo "docker=true" >> $GITHUB_OUTPUT + echo "docs=true" >> $GITHUB_OUTPUT + + - name: Check if tag version is a pre release version + id: rasa_check_version_type + if: env.IS_TAG_BUILD == 'true' + run: | + # Get current tagged Rasa version + CURRENT_TAG=${GITHUB_REF#refs/tags/} + if [[ "$CURRENT_TAG" =~ ^[0-9.]+$ ]]; then + echo "is_pre_release_version=false" >> $GITHUB_OUTPUT + else + echo "is_pre_release_version=true" >> $GITHUB_OUTPUT + fi + + wait_for_docs_tests: + # Looks for doc test workflows and waits for it to complete successfully + # Runs on pushes to main exclusively + name: Wait for docs tests + if: github.ref_type != 'tag' + runs-on: ubuntu-24.04 + needs: [changes] + + steps: + - name: Wait for doc tests + uses: fountainhead/action-wait-for-check@297be350cf8393728ea4d4b39435c7d7ae167c93 + id: wait-for-doc-tests + with: + token: ${{ secrets.GITHUB_TOKEN }} + checkName: Test Documentation + ref: ${{ github.event.pull_request.head.sha || github.sha }} + timeoutSeconds: ${{ env.WAIT_TIMEOUT_SECS }} + intervalSeconds: ${{ env.WAIT_INTERVAL_SECS }} + + - name: Fail the step if the doc tests run could not be found + if: ${{ steps.wait-for-doc-tests.outputs.conclusion == 'timed_out' }} + run: | + echo "Could not find the doc tests run." + exit 1 + + quality: + name: Code Quality + if: github.ref_type != 'tag' + runs-on: ubuntu-24.04 + needs: [changes] + + steps: + - name: Checkout git repository 🕝 + if: needs.changes.outputs.backend == 'true' + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Python ${{ env.DEFAULT_PYTHON_VERSION }} 🐍 + if: needs.changes.outputs.backend == 'true' + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + + - name: Read Poetry Version 🔢 + if: needs.changes.outputs.backend == 'true' + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry 🦄 + if: needs.changes.outputs.backend == 'true' + uses: snok/install-poetry@93ada01c735cc8a383ce0ce2ae205a21c415379b + with: + version: ${{ env.POETRY_VERSION }} + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Load Poetry Cached Libraries ⬇ + id: cache-poetry + if: needs.changes.outputs.backend == 'true' + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .venv + key: ${{ runner.os }}-poetry-${{ env.POETRY_VERSION }}-${{ env.DEFAULT_PYTHON_VERSION }}-${{ hashFiles('**/poetry.lock') }}-${{ secrets.POETRY_CACHE_VERSION }} + restore-keys: ${{ runner.os }}-poetry-${{ env.DEFAULT_PYTHON_VERSION }} + + - name: Clear Poetry cache + if: steps.cache-poetry.outputs.cache-hit == 'true' && needs.changes.outputs.backend == 'true' && contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-unit-tests') + run: rm -r .venv + + - name: Create virtual environment + if: (steps.cache-poetry.outputs.cache-hit != 'true' || contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-unit-tests')) && needs.changes.outputs.backend == 'true' + run: python -m venv create .venv + + - name: Set up virtual environment + if: needs.changes.outputs.backend == 'true' + run: poetry config virtualenvs.in-project true + + - name: Install Dependencies 📦 + if: needs.changes.outputs.backend == 'true' + # Poetry intermittently fails to install dependency if it is not PEP 517 compliant + # This is a workaround for that issue + run: | + sudo apt-get -y install libpq-dev + make install-full + + - name: Checkout target branch to be able to diff + if: needs.changes.outputs.backend == 'true' && github.event_name == 'pull_request' + run: | + git fetch origin ${{ github.base_ref }} + echo "DOCSTRING_DIFF_BRANCH=origin/${{ github.base_ref }}" >> $GITHUB_ENV + + # Fetch entire history for current branch so that `make lint-docstrings` + # can calculate the proper diff between the branches + git fetch --unshallow origin "${{ github.ref }}" + + - name: Add github workflow problem matchers + if: needs.changes.outputs.backend == 'true' + run: | + echo "::add-matcher::.github/matchers/flake8-error-matcher.json" + + - name: Lint Code 🎎 + if: needs.changes.outputs.backend == 'true' + run: | + # If it's not a pull request, $DOCSTRING_DIFF_BRANCH is unset. + # This will result in an empty diff, which effictively means that + # make lint-docstrings will be skipped for other events than `pull_request` + make lint BRANCH=$DOCSTRING_DIFF_BRANCH + + - name: Check Types 📚 + if: needs.changes.outputs.backend == 'true' + run: make types + + - name: Lint Changelog Filenames 📝 + if: needs.changes.outputs.backend == 'true' && github.event_name == 'pull_request' + run: make lint-changelog + + - name: Test CLI 🖥 + if: needs.changes.outputs.backend == 'true' + # makes sure we catch any dependency error early. they will create strange + # errors during the docs build, so easier to catch them early on by + # trying to run the `rasa` command once before the docs build. + run: poetry run rasa --help + + changelog: + name: Check for changelog + runs-on: ubuntu-24.04 + + steps: + - name: Checkout git repository 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Assert release includes all changelog entries + # check changelog folder only when we create pull request preparing release + if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'prepare-release') && needs.changes.outputs.is_pre_release_version == 'false' + working-directory: changelog + run: | + # List all unexpected files in changelog/ + UNEXPECTED_FILES=$(ls -A --ignore={"README.md",".gitignore","_template.md.jinja2"}) + + # Exit with error if found any unexpected files + [[ "$UNEXPECTED_FILES" ]] && \ + echo "Found the following unexpected files in changelogs/" && \ + echo "$UNEXPECTED_FILES" && \ + exit 1 || \ + echo "Release includes all changelog entries." + + test: + name: Run Tests + if: github.ref_type != 'tag' + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + needs: [changes] + strategy: + fail-fast: false + matrix: + test: + - test-cli + - test-core-featurizers + - test-policies + - test-nlu-featurizers + - test-nlu-predictors + - test-full-model-training + - test-other-unit-tests + - test-performance + os: [ubuntu-24.04, windows-2022] + python-version: [3.8, 3.9, "3.10"] + + steps: + - name: Run DataDog Agent + if: needs.changes.outputs.backend == 'true' && (matrix.os != 'windows-2022' || contains(github.event.pull_request.labels.*.name, 'tools:datadog-windows')) + run: | + docker run --name dd_agent -p 8126:8126 -d -e "DD_API_KEY=${{ secrets.DD_API_KEY }}" -e "DD_INSIDE_CI=true" -e "DD_HOSTNAME=none" -e "DD_SITE=datadoghq.eu" -e GITHUB_ACTIONS=true -e CI=true datadog/agent:latest + docker ps --all --filter name=dd_agent --filter status=running --no-trunc --format "{{.ID}} {{.Status}}" + docker port dd_agent + + - name: Checkout git repository 🕝 + if: needs.changes.outputs.backend == 'true' + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Python ${{ matrix.python-version }} 🐍 + if: needs.changes.outputs.backend == 'true' + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: ${{ matrix.python-version }} + + - name: Read Poetry Version 🔢 + if: needs.changes.outputs.backend == 'true' + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry (Ubuntu) 🦄 + if: needs.changes.outputs.backend == 'true' && matrix.os == 'ubuntu-24.04' + uses: snok/install-poetry@93ada01c735cc8a383ce0ce2ae205a21c415379b + with: + version: ${{ env.POETRY_VERSION }} + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Install poetry (Windows) 🦄 + if: needs.changes.outputs.backend == 'true' && matrix.os == 'windows-2022' + uses: Gr1N/setup-poetry@48b0f77c8c1b1b19cb962f0f00dff7b4be8f81ec #v9 + with: + poetry-version: ${{ env.POETRY_VERSION }} + + - name: Prevent race condition in poetry build + # More context about race condition during poetry build can be found here: + # https://github.com/python-poetry/poetry/issues/7611#issuecomment-1747836233 + if: needs.changes.outputs.backend == 'true' + run: | + poetry config installer.max-workers 1 + + - name: Load Poetry Cached Libraries ⬇ + id: cache-poetry + if: needs.changes.outputs.backend == 'true' + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .venv + key: ${{ runner.os }}-poetry-${{ env.POETRY_VERSION }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}-venv-${{ secrets.POETRY_CACHE_VERSION }}-${{ env.pythonLocation }} + + - name: Clear Poetry cache + if: steps.cache-poetry.outputs.cache-hit == 'true' && needs.changes.outputs.backend == 'true' && contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-unit-tests') + run: rm -r .venv + + # Poetry >= 1.1.0b uses virtualenv to create a virtual environment. + # The virtualenv simply doesn't work on Windows with our setup, + # that's why we use venv to create virtual environment + - name: Create virtual environment + if: (steps.cache-poetry.outputs.cache-hit != 'true' || contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-unit-tests')) && needs.changes.outputs.backend == 'true' + run: python -m venv create .venv + + - name: Set up virtual environment + if: needs.changes.outputs.backend == 'true' + # Poetry on Windows cannot pick up the virtual environments directory properly, + # and it creates a new one every time the pipeline runs. + # This step solves this problem — it tells poetry to always use `.venv` directory inside + # the project itself, which also makes it easier for us to determine the correct directory + # that needs to be cached. + run: poetry config virtualenvs.in-project true + + - name: Install Dependencies (Linux) 📦 + if: needs.changes.outputs.backend == 'true' && matrix.os == 'ubuntu-24.04' + # Poetry intermittently fails to install dependency if it is not PEP 517 compliant + # This is a workaround for that issue + run: | + sudo apt-get -y install libpq-dev + make install-full | tee .output + if grep 'The lock file is not up to date' .output; then exit 1; fi + make prepare-tests-ubuntu + + - name: Install Dependencies (Windows) 📦 + if: needs.changes.outputs.backend == 'true' && matrix.os == 'windows-2022' + # Restoring cache doesn't work properly on Windows due to symlinks. + # We create symlinks for spacy models, that's why we need to clean them up + # before caching the dependencies directory. + # More information: https://github.com/actions/cache/issues/120 + # Poetry intermittently fails to install dependency if it is not PEP 517 compliant + # This is a workaround for that issue + run: | + $spacy_data_dir = ".venv\lib\site-packages\spacy\data" + if (Test-Path $spacy_data_dir) { + Get-ChildItem -Force -ErrorAction Stop $spacy_data_dir | Where-Object { if($_.Attributes -match "ReparsePoint"){$_.Delete()} } + Remove-Item -Force -Recurse $spacy_data_dir + New-Item -Path $spacy_data_dir -Type Directory + } + make install-full + make prepare-tests-windows-gha + + - name: Add github workflow problem matchers + if: needs.changes.outputs.backend == 'true' && matrix.python-version == 3.7 && matrix.os == 'ubuntu-24.04' + # only annotate based on test runs on ubuntu: otherwise + # all errors will be duplicated for each python / os combination + # therefore, we only enable for the one where most tests are run + # (tests will still run in other envs, they will just not create annotations) + run: pip install pytest-github-actions-annotate-failures + + - name: Disable "LongPathsEnabled" option on Windows + if: matrix.os == 'windows-2022' + # On Windows laptops, a default preset prevents path names from being longer than + # 260 characters. Some of our users can't enable this setting due to company policies. + # We implemented a fix for model storage. The Windows container in GitHub + # comes with the setting enabled, so we disable it here in order to ensure our tests + # are running in an environment where long path names are prevented. + run: | + (Get-ItemProperty "HKLM:System\CurrentControlSet\Control\FileSystem").LongPathsEnabled + Set-ItemProperty 'HKLM:\System\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -value 0 + + - name: Install ddtrace on Linux + if: needs.changes.outputs.backend == 'true' && matrix.os == 'ubuntu-24.04' + run: poetry run pip install -U 'ddtrace<2.0.0' + + - name: Install ddtrace on Windows + if: needs.changes.outputs.backend == 'true' && matrix.os == 'windows-2022' + run: | + .\.venv\Scripts\activate + py -m pip install -U 'ddtrace<2.0.0' + + - name: Test Code 🔍 (multi-process) + if: needs.changes.outputs.backend == 'true' + env: + JOBS: 2 + PYTHONIOENCODING: "utf-8" + DD_ENV: ${{ matrix.test }} + DD_SERVICE: rasa + DD_ARGS: --ddtrace --ddtrace-patch-all + run: | + make ${{ matrix.test }} + shell: bash # bash shell is a way to make code run for both Linux and Windows + + - name: Move coverage report + if: needs.changes.outputs.backend == 'true' && matrix.os == 'ubuntu-24.04' + run: | + mv .coverage ${{ github.workspace }}/.coverage.${{ matrix.test }} + + - name: Store coverage reports + if: needs.changes.outputs.backend == 'true' && matrix.os == 'ubuntu-24.04' + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 #v4.4.3 + with: + name: ${{ matrix.test }}-${{ matrix.python-version }}-${{ matrix.os }}-coverage + include-hidden-files: true + path: | + ${{ github.workspace }}/.coverage.${{ matrix.test }} + + test-flaky: + name: Run Flaky Tests + if: github.ref_type != 'tag' + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + needs: [changes] + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2022] + python-version: [3.8, 3.9, "3.10"] + + steps: + - name: Run DataDog Agent + if: needs.changes.outputs.backend == 'true' && (matrix.os != 'windows-2022' || contains(github.event.pull_request.labels.*.name, 'tools:datadog-windows')) + run: | + docker run --name dd_agent -p 8126:8126 -d -e "DD_API_KEY=${{ secrets.DD_API_KEY }}" -e "DD_INSIDE_CI=true" -e "DD_HOSTNAME=none" -e "DD_SITE=datadoghq.eu" -e GITHUB_ACTIONS=true -e CI=true datadog/agent:latest + docker ps --all --filter name=dd_agent --filter status=running --no-trunc --format "{{.ID}} {{.Status}}" + docker port dd_agent + + - name: Checkout git repository 🕝 + if: needs.changes.outputs.backend == 'true' + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Python ${{ matrix.python-version }} 🐍 + if: needs.changes.outputs.backend == 'true' + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: ${{ matrix.python-version }} + + - name: Read Poetry Version 🔢 + if: needs.changes.outputs.backend == 'true' + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry (Ubuntu) 🦄 + if: needs.changes.outputs.backend == 'true' && matrix.os == 'ubuntu-24.04' + uses: snok/install-poetry@93ada01c735cc8a383ce0ce2ae205a21c415379b + with: + version: ${{ env.POETRY_VERSION }} + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Install poetry (Windows) 🦄 + if: needs.changes.outputs.backend == 'true' && matrix.os == 'windows-2022' + uses: Gr1N/setup-poetry@48b0f77c8c1b1b19cb962f0f00dff7b4be8f81ec #v9 + with: + poetry-version: ${{ env.POETRY_VERSION }} + + - name: Load Poetry Cached Libraries ⬇ + id: cache-poetry + if: needs.changes.outputs.backend == 'true' + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .venv + key: ${{ runner.os }}-poetry-${{ env.POETRY_VERSION }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}-venv-${{ secrets.POETRY_CACHE_VERSION }}-${{ env.pythonLocation }} + + - name: Clear Poetry cache + if: steps.cache-poetry.outputs.cache-hit == 'true' && needs.changes.outputs.backend == 'true' && contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-unit-tests') + run: rm -r .venv + + # Poetry >= 1.1.0b uses virtualenv to create a virtual environment. + # The virtualenv simply doesn't work on Windows with our setup, + # that's why we use venv to create virtual environment + - name: Create virtual environment + if: (steps.cache-poetry.outputs.cache-hit != 'true' || contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-unit-tests')) && needs.changes.outputs.backend == 'true' + run: python -m venv create .venv + + - name: Set up virtual environment + if: needs.changes.outputs.backend == 'true' + # Poetry on Windows cannot pick up the virtual environments directory properly, + # and it creates a new one every time the pipeline runs. + # This step solves this problem — it tells poetry to always use `.venv` directory inside + # the project itself, which also makes it easier for us to determine the correct directory + # that needs to be cached. + run: poetry config virtualenvs.in-project true + + - name: Install Dependencies (Linux) 📦 + if: needs.changes.outputs.backend == 'true' && matrix.os == 'ubuntu-24.04' + run: | + sudo apt-get -y install libpq-dev + make install-full | tee .output + if grep 'The lock file is not up to date' .output; then exit 1; fi + make prepare-tests-ubuntu + + - name: Install Dependencies (Windows) 📦 + if: needs.changes.outputs.backend == 'true' && matrix.os == 'windows-2022' + # Restoring cache doesn't work properly on Windows due to symlinks. + # We create symlinks for spacy models, that's why we need to clean them up + # before caching the dependencies' directory. + # More information: https://github.com/actions/cache/issues/120 + run: | + $spacy_data_dir = ".venv\lib\site-packages\spacy\data" + if (Test-Path $spacy_data_dir) { + Get-ChildItem -Force -ErrorAction Stop $spacy_data_dir | Where-Object { if($_.Attributes -match "ReparsePoint"){$_.Delete()} } + Remove-Item -Force -Recurse $spacy_data_dir + New-Item -Path $spacy_data_dir -Type Directory + } + make install-full + make prepare-tests-windows-gha + + - name: Add github workflow problem matchers + if: needs.changes.outputs.backend == 'true' && matrix.python-version == 3.7 && matrix.os == 'ubuntu-24.04' + # only annotate based on test runs on ubuntu: otherwise + # all errors will be duplicated for each python / os combination + # therefore, we only enable for the one where most tests are run + # (tests will still run in other envs, they will just not create annotations) + run: pip install pytest-github-actions-annotate-failures + + - name: Disable "LongPathsEnabled" option on Windows + if: matrix.os == 'windows-2022' + # On Windows laptops, a default preset prevents path names from being longer than + # 260 characters. Some of our users can't enable this setting due to company policies. + # We implemented a fix for model storage. The Windows container in GitHub + # comes with the setting enabled, so we disable it here in order to ensure our tests + # are running in an environment where long path names are prevented. + run: | + (Get-ItemProperty "HKLM:System\CurrentControlSet\Control\FileSystem").LongPathsEnabled + Set-ItemProperty 'HKLM:\System\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -value 0 + + - name: Install ddtrace on Linux + if: needs.changes.outputs.backend == 'true' && matrix.os == 'ubuntu-24.04' + run: poetry run pip install -U 'ddtrace<2.0.0' + + - name: Install ddtrace on Windows + if: needs.changes.outputs.backend == 'true' && matrix.os == 'windows-2022' + run: | + .\.venv\Scripts\activate + py -m pip install -U 'ddtrace<2.0.0' + + - name: Test Code 🔍 (multi-process) + if: needs.changes.outputs.backend == 'true' + env: + JOBS: 2 + PYTHONIOENCODING: "utf-8" + DD_ENV: test-flaky + DD_SERVICE: rasa + DD_ARGS: --ddtrace --ddtrace-patch-all + run: | + make test-flaky + shell: bash # bash shell is a way to make code run for both Linux and Windows + + - name: Move coverage report + if: needs.changes.outputs.backend == 'true' && matrix.os == 'ubuntu-24.04' + run: | + mv .coverage ${{ github.workspace }}/.coverage.test_flaky_coverage + + - name: Store coverage reports + if: needs.changes.outputs.backend == 'true' && matrix.os == 'ubuntu-24.04' + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.test }}-${{ matrix.python-version }}-${{ matrix.os }}-coverage + include-hidden-files: true + path: | + ${{ github.workspace }}/.coverage.test_flaky_coverage + + upload_coverage_reports: + name: Upload coverage reports to codeclimate + if: github.ref_type != 'tag' + runs-on: ubuntu-24.04 + # Always upload results even if tests failed + needs: + - test + - test-flaky + - changes + + steps: + - name: Checkout git repository 🕝 + if: needs.changes.outputs.backend == 'true' + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Python 3.10 🐍 + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: "3.10" + + - name: Get backend coverage reports + if: needs.changes.outputs.backend == 'true' + uses: actions/download-artifact@v4 #v4.1.8 + with: + path: ${{ github.workspace }}/tests_coverage + pattern: "*-coverage" + merge-multiple: true + + - name: List all coverages + if: needs.changes.outputs.backend == 'true' + run: | + ls -l ${{ github.workspace }} + ls -l ${{ github.workspace }}/tests_coverage + + - name: Merge all reports + if: needs.changes.outputs.backend == 'true' + run: | + pip install coverage + coverage combine ${{ github.workspace }}/tests_coverage/ + coverage xml + + - name: Upload reports to codeclimate + if: needs.changes.outputs.backend == 'true' + uses: paambaati/codeclimate-action@b649ad206d2e83dafb9ed130deba698aa1b41d78 + env: + CC_TEST_REPORTER_ID: ${{ secrets.CODECLIMATE_REPORTER_ID }} + with: + coverageLocations: | + ${{ github.workspace }}/coverage.xml:coverage.py + debug: true + + integration_test: + name: Run Non-Sequential Integration Tests + if: github.ref_type != 'tag' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + needs: [changes] + env: + REDIS_HOST: localhost + REDIS_PORT: 6379 + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + + services: + redis: + image: redis:6 + # Set health checks to wait until redis has started + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + # FIXME: cannot use ${{ env.REDIS_PORT }} here + # mapping container ports to the host + - 6379:6379 + + postgres: + image: postgres:13 + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + # postgres image requires password to be set + POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + ports: + # FIXME: cannot use ${{ env.POSTGRES_PORT }} here + # mapping container ports to the host + - 5432:5432 + + mongodb: + image: mongodb/mongodb-community-server:6.0.4-ubuntu2204 + options: >- + --health-cmd "echo 'db.runCommand("ping").ok' | mongosh --quiet" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 27017:27017 + + steps: + - name: Checkout git repository 🕝 + if: needs.changes.outputs.backend == 'true' + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Python ${{ env.DEFAULT_PYTHON_VERSION }} 🐍 + if: needs.changes.outputs.backend == 'true' + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + + - name: Read Poetry Version 🔢 + if: needs.changes.outputs.backend == 'true' + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry 🦄 + if: needs.changes.outputs.backend == 'true' + uses: snok/install-poetry@93ada01c735cc8a383ce0ce2ae205a21c415379b + with: + version: ${{ env.POETRY_VERSION }} + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Load Poetry Cached Libraries ⬇ + id: cache-poetry + if: needs.changes.outputs.backend == 'true' + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .venv + key: ${{ runner.os }}-poetry-${{ env.POETRY_VERSION }}-${{ env.DEFAULT_PYTHON_VERSION }}-${{ hashFiles('**/poetry.lock') }}-venv-${{ secrets.POETRY_CACHE_VERSION }}-${{ env.pythonLocation }} + + - name: Clear Poetry cache + if: steps.cache-poetry.outputs.cache-hit == 'true' && needs.changes.outputs.backend == 'true' && contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-unit-tests') + run: rm -r .venv + + # Poetry >= 1.1.0b uses virtualenv to create a virtual environment. + # The virtualenv simply doesn't work on Windows with our setup, + # that's why we use venv to create virtual environment + - name: Create virtual environment + if: (steps.cache-poetry.outputs.cache-hit != 'true' || contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-unit-tests')) && needs.changes.outputs.backend == 'true' + run: python -m venv create .venv + + - name: Set up virtual environment + if: needs.changes.outputs.backend == 'true' + # Poetry on Windows cannot pick up the virtual environments directory properly, + # and it creates a new one every time the pipeline runs. + # This step solves this problem — it tells poetry to always use `.venv` directory inside + # the project itself, which also makes it easier for us to determine the correct directory + # that needs to be cached. + run: poetry config virtualenvs.in-project true + + - name: Install Dependencies (Linux) 📦 + if: needs.changes.outputs.backend == 'true' + run: | + sudo apt-get -y install libpq-dev + make install-full | tee .output + if grep 'The lock file is not up to date' .output; then exit 1; fi + make prepare-tests-ubuntu + + - name: Test Code with Services 🩺 + if: needs.changes.outputs.backend == 'true' + env: + JOBS: 2 + INTEGRATION_TEST_PYTEST_MARKERS: '"(not sequential) and (not broker)"' + PYTHONIOENCODING: "utf-8" + run: | + make test-integration + + broker_integration_test: + name: Run Broker Integration Tests + if: github.ref_type != 'tag' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + needs: [changes] + env: + RABBITMQ_HOST: localhost + RABBITMQ_PORT: 5672 + RABBITMQ_USER: guest + RABBITMQ_PASSWORD: guest + + services: + rabbitmq: + # see https://github.com/docker-library/healthcheck/blob/master/rabbitmq/docker-healthcheck + image: healthcheck/rabbitmq + ports: + - 5672:5672 + + steps: + - name: Checkout git repository 🕝 + if: needs.changes.outputs.backend == 'true' + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Python ${{ env.DEFAULT_PYTHON_VERSION }} 🐍 + if: needs.changes.outputs.backend == 'true' + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + + - name: Read Poetry Version 🔢 + if: needs.changes.outputs.backend == 'true' + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry 🦄 + if: needs.changes.outputs.backend == 'true' + uses: snok/install-poetry@93ada01c735cc8a383ce0ce2ae205a21c415379b + with: + version: ${{ env.POETRY_VERSION }} + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Load Poetry Cached Libraries ⬇ + id: cache-poetry + if: needs.changes.outputs.backend == 'true' + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .venv + key: ${{ runner.os }}-poetry-${{ env.POETRY_VERSION }}-${{ env.DEFAULT_PYTHON_VERSION }}-${{ hashFiles('**/poetry.lock') }}-venv-${{ secrets.POETRY_CACHE_VERSION }}-${{ env.pythonLocation }} + + - name: Clear Poetry cache + if: steps.cache-poetry.outputs.cache-hit == 'true' && needs.changes.outputs.backend == 'true' && contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-unit-tests') + run: rm -r .venv + + # Poetry >= 1.1.0b uses virtualenv to create a virtual environment. + # The virtualenv simply doesn't work on Windows with our setup, + # that's why we use venv to create virtual environment + - name: Create virtual environment + if: (steps.cache-poetry.outputs.cache-hit != 'true' || contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-unit-tests')) && needs.changes.outputs.backend == 'true' + run: python -m venv create .venv + + - name: Set up virtual environment + if: needs.changes.outputs.backend == 'true' + # Poetry on Windows cannot pick up the virtual environments directory properly, + # and it creates a new one every time the pipeline runs. + # This step solves this problem — it tells poetry to always use `.venv` directory inside + # the project itself, which also makes it easier for us to determine the correct directory + # that needs to be cached. + run: poetry config virtualenvs.in-project true + + - name: Install Dependencies (Linux) 📦 + if: needs.changes.outputs.backend == 'true' + run: | + sudo apt-get -y install libpq-dev + make install-full | tee .output + if grep 'The lock file is not up to date' .output; then exit 1; fi + make prepare-tests-ubuntu + make prepare-spacy + + - name: Run kafka and zookeeper containers for integration testing + if: needs.changes.outputs.backend == 'true' + run: | + docker compose -f tests_deployment/docker-compose.kafka.yml up -d + + - name: Test Code with Services 🩺 + if: needs.changes.outputs.backend == 'true' + env: + JOBS: 2 + INTEGRATION_TEST_PYTEST_MARKERS: "broker" + PYTHONIOENCODING: "utf-8" + run: | + make test-integration + + - name: Stop kafka and zookeeper containers for integration testing + if: needs.changes.outputs.backend == 'true' + run: | + docker compose -f tests_deployment/docker-compose.kafka.yml down + + sequential_integration_test: + name: Run Sequential Integration Tests + if: github.ref_type != 'tag' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + needs: [changes] + env: + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + + services: + postgres: + image: postgres:13 + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + # postgres image requires password to be set + POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + ports: + # FIXME: cannot use ${{ env.POSTGRES_PORT }} here + # mapping container ports to the host + - 5432:5432 + + steps: + - name: Checkout git repository 🕝 + if: needs.changes.outputs.backend == 'true' + uses: actions/checkout@v3 + + - name: Set up Python ${{ env.DEFAULT_PYTHON_VERSION }} 🐍 + if: needs.changes.outputs.backend == 'true' + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + + - name: Read Poetry Version 🔢 + if: needs.changes.outputs.backend == 'true' + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry 🦄 + if: needs.changes.outputs.backend == 'true' + uses: snok/install-poetry@93ada01c735cc8a383ce0ce2ae205a21c415379b + with: + version: ${{ env.POETRY_VERSION }} + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Load Poetry Cached Libraries ⬇ + id: cache-poetry + if: needs.changes.outputs.backend == 'true' + uses: actions/cache@v3 + with: + path: .venv + key: ${{ runner.os }}-poetry-${{ env.POETRY_VERSION }}-${{ env.DEFAULT_PYTHON_VERSION }}-${{ hashFiles('**/poetry.lock') }}-venv-${{ secrets.POETRY_CACHE_VERSION }}-${{ env.pythonLocation }} + + - name: Clear Poetry cache + if: steps.cache-poetry.outputs.cache-hit == 'true' && needs.changes.outputs.backend == 'true' && contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-unit-tests') + run: rm -r .venv + + # Poetry >= 1.1.0b uses virtualenv to create a virtual environment. + # The virtualenv simply doesn't work on Windows with our setup, + # that's why we use venv to create virtual environment + - name: Create virtual environment + if: (steps.cache-poetry.outputs.cache-hit != 'true' || contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-unit-tests')) && needs.changes.outputs.backend == 'true' + run: python -m venv create .venv + + - name: Set up virtual environment + if: needs.changes.outputs.backend == 'true' + # Poetry on Windows cannot pick up the virtual environments directory properly, + # and it creates a new one every time the pipeline runs. + # This step solves this problem — it tells poetry to always use `.venv` directory inside + # the project itself, which also makes it easier for us to determine the correct directory + # that needs to be cached. + run: poetry config virtualenvs.in-project true + + - name: Install Dependencies (Linux) 📦 + if: needs.changes.outputs.backend == 'true' + run: | + sudo apt-get -y install libpq-dev + make install-full | tee .output + if grep 'The lock file is not up to date' .output; then exit 1; fi + make prepare-tests-ubuntu + + # these integration tests need to be ran in a sequential fashion, + # due to environment constraints, so we're running them in a single process. + - name: Test Code with Services 🩺 (sequential) + if: needs.changes.outputs.backend == 'true' + env: + JOBS: 1 + INTEGRATION_TEST_PYTEST_MARKERS: "sequential" + PYTHONIOENCODING: "utf-8" + run: | + make test-integration + + build_docker_base_images_and_set_env: + name: Build Docker base images and setup environment + runs-on: ubuntu-24.04 + outputs: + base_image_hash: ${{ steps.check_image.outputs.base_image_hash }} + base_mitie_image_hash: ${{ steps.check_image.outputs.base_mitie_image_hash }} + base_builder_image_hash: ${{ steps.check_image.outputs.base_builder_image_hash }} + # Tag name used for images created during Docker image builds, e.g. 3886 - a PR number + image_tag: ${{ steps.set_output.outputs.image_tag }} + # Return 'true' if tag version is equal or higher than the latest tagged Rasa version + is_newest_version: ${{ steps.rasa_get_version.outputs.is_newest_version }} + + steps: + # Due to an issue with checking out a wrong commit, we make sure + # to checkout HEAD commit for a pull request. + # More details: https://github.com/actions/checkout/issues/299 + - name: Checkout pull request HEAD commit instead of merge commit 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + if: github.event_name == 'pull_request' + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Checkout git repository 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + if: github.event_name != 'pull_request' + + - name: Set up QEMU + uses: docker/setup-qemu-action@2b82ce82d56a2a04d2637cd93a637ae1b359c0a7 # v2.2.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@ecf95283f03858871ff00b787d79c419715afc34 # v2.7.0 + + - name: Read Poetry Version 🔢 + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Login to DockerHub Registry 🔢 + run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u ${{ env.DOCKERHUB_USERNAME }} --password-stdin || true + + - name: Check if tag version is equal or higher than the latest tagged Rasa version + id: rasa_get_version + if: env.IS_TAG_BUILD == 'true' + run: | + # Get latest tagged Rasa version + git fetch --depth=1 origin "+refs/tags/*:refs/tags/*" + # Fetch branch history + git fetch --prune --unshallow + LATEST_TAGGED_NON_ALPHA_RASA_VERSION=$(git tag | sort -r -V | grep -E "^[0-9.]+$" | head -n1) + CURRENT_TAG=${GITHUB_REF#refs/tags/} + # Return 'true' if tag version is equal or higher than the latest tagged Rasa version + IS_NEWEST_VERSION=$((printf '%s\n%s\n' "${LATEST_TAGGED_NON_ALPHA_RASA_VERSION}" "$CURRENT_TAG" \ + | sort -V -C && echo true || echo false) || true) + # Avoid that the script gets released for alphas or release candidates + if [[ "${IS_NEWEST_VERSION}" == "true" && "$CURRENT_TAG" =~ ^[0-9.]+$ ]]; then + echo "is_newest_version=true" >> $GITHUB_OUTPUT + else + echo "is_newest_version=false" >> $GITHUB_OUTPUT + fi + + - name: Check if a base image exists + id: check_image + env: + DOCKER_CLI_EXPERIMENTAL: enabled + run: | + # Base image + BASE_IMAGE_HASH=${{ hashFiles('docker/Dockerfile.base') }} + echo "base_image_hash=${BASE_IMAGE_HASH}" >> $GITHUB_OUTPUT + + BASE_IMAGE_EXISTS=$((docker manifest inspect rasa/rasa:base-${BASE_IMAGE_HASH} &> /dev/null && echo true || echo false) || true) + echo "base_exists=${BASE_IMAGE_EXISTS}" >> $GITHUB_OUTPUT + + # Base MITIE image + BASE_MITIE_IMAGE_HASH=${{ hashFiles('docker/Dockerfile.base-mitie') }} + MAKEFILE_MITIE_HASH=${{ hashFiles('Makefile') }} + echo "base_mitie_image_hash=${BASE_MITIE_IMAGE_HASH:0:50}-${MAKEFILE_MITIE_HASH:0:50}" >> $GITHUB_OUTPUT + + BASE_IMAGE_MITIE_EXISTS=$((docker manifest inspect rasa/rasa:base-mitie-${BASE_MITIE_IMAGE_HASH:0:50}-${MAKEFILE_MITIE_HASH:0:50} &> /dev/null && echo true || echo false) || true) + echo "base_mitie_exists=${BASE_IMAGE_MITIE_EXISTS}" >> $GITHUB_OUTPUT + + # Base poetry image + BASE_IMAGE_POETRY_EXISTS=$((docker manifest inspect rasa/rasa:base-poetry-${{ env.POETRY_VERSION }} &> /dev/null && echo true || echo false) || true) + echo "base_poetry_exists=${BASE_IMAGE_POETRY_EXISTS}" >> $GITHUB_OUTPUT + + # Base builder image + BASE_IMAGE_BUILDER_HASH=${{ hashFiles('docker/Dockerfile.base-builder') }}-poetry-${{ env.POETRY_VERSION }} + echo "base_builder_image_hash=${BASE_IMAGE_BUILDER_HASH}" >> $GITHUB_OUTPUT + + BASE_IMAGE_BUILDER_EXISTS=$((docker manifest inspect rasa/rasa:base-builder-${BASE_IMAGE_BUILDER_HASH} &> /dev/null && echo true || echo false) || true) + echo "base_builder_exists=${BASE_IMAGE_BUILDER_EXISTS}" >> $GITHUB_OUTPUT + + - name: Build Docker base image 🛠 + if: steps.check_image.outputs.base_exists == 'false' || env.IS_TAG_BUILD == 'true' + run: | + export IMAGE_TAG=${{ steps.check_image.outputs.base_image_hash }} + docker buildx bake --set *.platform=linux/amd64,linux/arm64 -f docker/docker-bake.hcl base + + - name: Push Docker base image if it's not building from a fork ⬆ + if: (steps.check_image.outputs.base_exists == 'false' || env.IS_TAG_BUILD == 'true') && github.event.pull_request.head.repo.owner.login == 'RasaHQ' + run: | + export IMAGE_TAG=${{ steps.check_image.outputs.base_image_hash }} + docker buildx bake --set *.platform=linux/amd64,linux/arm64 -f docker/docker-bake.hcl base --push + + - name: Build Docker mitie base image 🛠 + if: steps.check_image.outputs.base_mitie_exists == 'false' || steps.check_image.outputs.base_exists == 'false' + run: | + export IMAGE_TAG=${{ steps.check_image.outputs.base_mitie_image_hash }} + docker buildx bake --set *.platform=linux/amd64,linux/arm64 -f docker/docker-bake.hcl base-mitie + + - name: Push Docker mitie base image if it's not building from a fork ⬆ + if: (steps.check_image.outputs.base_mitie_exists == 'false' || steps.check_image.outputs.base_exists == 'false') && github.event.pull_request.head.repo.owner.login == 'RasaHQ' + run: | + export IMAGE_TAG=${{ steps.check_image.outputs.base_mitie_image_hash }} + docker buildx bake --set *.platform=linux/amd64,linux/arm64 -f docker/docker-bake.hcl base-mitie --push + + - name: Build Docker poetry base image 🛠 + if: steps.check_image.outputs.base_poetry_exists == 'false' || steps.check_image.outputs.base_exists == 'false' + run: | + export IMAGE_TAG=${{ env.POETRY_VERSION }} + export BASE_IMAGE_HASH=${{ steps.check_image.outputs.base_image_hash }} + docker buildx bake --set *.platform=linux/amd64,linux/arm64 -f docker/docker-bake.hcl base-poetry + + - name: Push Docker poetry base image if it's not building from a fork ⬆ + if: (steps.check_image.outputs.base_poetry_exists == 'false' || steps.check_image.outputs.base_exists == 'false') && github.event.pull_request.head.repo.owner.login == 'RasaHQ' + run: | + export IMAGE_TAG=${{ env.POETRY_VERSION }} + export BASE_IMAGE_HASH=${{ steps.check_image.outputs.base_image_hash }} + docker buildx bake --set *.platform=linux/amd64,linux/arm64 -f docker/docker-bake.hcl base-poetry --push + + - name: Build Docker builder base image 🛠 + if: steps.check_image.outputs.base_builder_exists == 'false' || steps.check_image.outputs.base_exists == 'false' + run: | + export IMAGE_TAG=${{ steps.check_image.outputs.base_builder_image_hash }} + docker buildx bake --set *.platform=linux/amd64,linux/arm64 -f docker/docker-bake.hcl base-builder + + - name: Push Docker builder base image if it's not building from a fork ⬆ + if: (steps.check_image.outputs.base_builder_exists == 'false' || steps.check_image.outputs.base_exists == 'false') && github.event.pull_request.head.repo.owner.login == 'RasaHQ' + run: | + export IMAGE_TAG=${{ steps.check_image.outputs.base_builder_image_hash }} + docker buildx bake --set *.platform=linux/amd64,linux/arm64 -f docker/docker-bake.hcl base-builder --push + + # Set environment variables for a pull request + # + # In this scenario, we've created a PR #1234 + # + # Example output: + # IMAGE_TAG=1234 + - name: Set environment variables - pull_request + if: github.event_name == 'pull_request' && env.IS_TAG_BUILD == 'false' + run: | + echo "IMAGE_TAG=${{ github.event.number }}" >> $GITHUB_ENV + + # Set environment variables for a tag + # + # In this scenario, we've pushed the '2.0.6' tag + # + # Example output: + # TAG_NAME=2.0.6 + # IMAGE_TAG=2.0.6 + - name: Set environment variables - push - tag + if: github.event_name == 'push' && env.IS_TAG_BUILD == 'true' + run: | + TAG_NAME=${GITHUB_REF#refs/tags/} + echo "IMAGE_TAG=${TAG_NAME}" >> $GITHUB_ENV + + # Set environment variables for a branch + # + # In this scenario, we've pushed changes into the main branch + # + # Example output: + # IMAGE_TAG=main + - name: Set environment variables - push - branch + if: github.event_name == 'push' && env.IS_TAG_BUILD == 'false' + run: | + BRANCH_NAME=${GITHUB_REF#refs/heads/} + SAFE_BRANCH_NAME="$(echo ${GITHUB_REF#refs/heads/} | sed 's/[\\*+.$\#\-\/]/-/g')" + echo "IMAGE_TAG=${SAFE_BRANCH_NAME}" >> $GITHUB_ENV + + - name: Set output + id: set_output + run: | + echo "image_tag=${{ env.IMAGE_TAG }}" >> $GITHUB_OUTPUT + + docker: + name: Build Docker + runs-on: ubuntu-24.04 + needs: [changes, build_docker_base_images_and_set_env] + env: + IMAGE_TAG: ${{ needs.build_docker_base_images_and_set_env.outputs.image_tag }} + BASE_IMAGE_HASH: ${{ needs.build_docker_base_images_and_set_env.outputs.base_image_hash }} + BASE_MITIE_IMAGE_HASH: ${{ needs.build_docker_base_images_and_set_env.outputs.base_mitie_image_hash }} + BASE_BUILDER_IMAGE_HASH: ${{ needs.build_docker_base_images_and_set_env.outputs.base_builder_image_hash }} + + strategy: + matrix: + image: [default, full, mitie-en, spacy-de, spacy-it, spacy-en] + + steps: + # Due to an issue with checking out a wrong commit, we make sure + # to checkout HEAD commit for a pull request. + # More details: https://github.com/actions/checkout/issues/299 + - name: Checkout pull request HEAD commit instead of merge commit 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + if: github.event_name == 'pull_request' + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Checkout git repository 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + if: github.event_name != 'pull_request' + + - name: Set up QEMU + uses: docker/setup-qemu-action@2b82ce82d56a2a04d2637cd93a637ae1b359c0a7 # v2.2.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@ecf95283f03858871ff00b787d79c419715afc34 # v2.7.0 + + - name: Free disk space + if: needs.changes.outputs.docker == 'true' + # tries to make sure we do not run out of disk space, see + # https://github.community/t5/GitHub-Actions/BUG-Strange-quot-No-space-left-on-device-quot-IOExceptions-on/td-p/46101 + run: | + sudo swapoff -a + sudo rm -f /swapfile + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + sudo apt clean + docker image prune -a -f + docker volume prune -f + docker container prune -f + df -h + + - name: Read Poetry Version 🔢 + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Echo Available platforms + run: echo ${{ steps.buildx.outputs.platforms }} + + - name: Login to DockerHub Registry 🔢 + if: needs.changes.outputs.docker == 'true' + run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u ${{ env.DOCKERHUB_USERNAME }} --password-stdin || true + + - name: Copy Segment write key to the package + if: needs.changes.outputs.docker == 'true' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags') && github.repository == 'RasaHQ/rasa' + env: + RASA_TELEMETRY_WRITE_KEY: ${{ secrets.RASA_OSS_TELEMETRY_WRITE_KEY }} + RASA_EXCEPTION_WRITE_KEY: ${{ secrets.RASA_OSS_EXCEPTION_WRITE_KEY }} + run: | + ./scripts/write_keys_file.sh + + - name: Build Docker image + if: needs.changes.outputs.docker == 'true' + run: | + docker buildx bake --set *.platform=linux/amd64,linux/arm64 -f docker/docker-bake.hcl ${{ matrix.image }} + + - name: Check how much space is left after Docker build + run: df -h + + - name: Push image with main tag 📦 + if: needs.changes.outputs.docker == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'RasaHQ/rasa' + run: | + docker buildx bake --set *.platform=linux/amd64,linux/arm64 -f docker/docker-bake.hcl ${{ matrix.image }} --push + + - name: Push image with ${{github.ref}} tag 📦 + if: needs.changes.outputs.docker == 'true' && github.event_name == 'push' && env.IS_TAG_BUILD == 'true' && github.repository == 'RasaHQ/rasa' + run: | + IS_NEWEST_VERSION=${{ needs.build_docker_base_images_and_set_env.outputs.is_newest_version }} + + docker buildx bake --set *.platform=linux/amd64,linux/arm64 -f docker/docker-bake.hcl ${{ matrix.image }} --push + +# # Tag the image as latest +# if [[ "${IS_NEWEST_VERSION}" == "true" ]]; then +# if [[ "${{ matrix.image }}" == "default" ]]; then +# RELEASE_TAG="${IMAGE_TAG}" +# else +# RELEASE_TAG="${IMAGE_TAG}-${{ matrix.image }}" +# fi +# +# LATEST_TAG=$(echo $RELEASE_TAG | sed 's/'$IMAGE_TAG'/latest/g') +# +# docker tag rasa/rasa:${RELEASE_TAG} rasa/rasa:${LATEST_TAG} +# docker push rasa/rasa:${LATEST_TAG} +# fi + + deploy: + name: Deploy to PyPI + runs-on: ubuntu-24.04 + + # deploy will only be run when there is a tag available + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') && github.repository == 'RasaHQ/rasa' + needs: [docker] # only run after the docker build stage succeeds + + steps: + - name: Checkout git repository 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Python 3.9 🐍 + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: 3.9 + + - name: Read Poetry Version 🔢 + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry 🦄 + uses: snok/install-poetry@93ada01c735cc8a383ce0ce2ae205a21c415379b + with: + version: ${{ env.POETRY_VERSION }} + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Copy Segment write key to the package + env: + RASA_TELEMETRY_WRITE_KEY: ${{ secrets.RASA_OSS_TELEMETRY_WRITE_KEY }} + RASA_EXCEPTION_WRITE_KEY: ${{ secrets.RASA_OSS_EXCEPTION_WRITE_KEY }} + run: | + ./scripts/write_keys_file.sh + + - name: Build ⚒️ Distributions + run: poetry build + + - name: Publish to PyPI 📦 + uses: pypa/gh-action-pypi-publish@c7f29f7adef1a245bd91520e94867e5c6eedddcc + with: + user: __token__ + password: ${{ secrets.PYPI_TOKEN }} + + - name: Notify Sentry about the release + env: + GITHUB_TAG: ${{ github.ref }} + SENTRY_ORG: rasahq + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + run: | + curl -sL https://sentry.io/get-cli/ | bash + GITHUB_TAG=${GITHUB_TAG/refs\/tags\//} + sentry-cli releases new -p rasa-open-source "rasa-$GITHUB_TAG" + sentry-cli releases set-commits --auto "rasa-$GITHUB_TAG" + sentry-cli releases finalize "rasa-$GITHUB_TAG" + + - name: Publish Release Notes 🗞 + env: + GH_RELEASE_NOTES_TOKEN: ${{ secrets.GH_RELEASE_NOTES_TOKEN }} + GITHUB_TAG: ${{ github.ref }} + GITHUB_REPO_SLUG: ${{ github.repository }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + GITHUB_TAG=${GITHUB_TAG/refs\/tags\//} + pip install -U github3.py pep440-version-utils + python3 scripts/publish_gh_release_notes.py + + - name: Notify Slack of successful release + # notification will be sent to the #product channel on slack, webhook url is added as repository secret + if: success() + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_RELEASE_ASSISTANT_RELEASE_WEBHOOK }} + uses: Ilshidur/action-slack@689ad44a9c9092315abd286d0e3a9a74d31ab78a + with: + args: "💥 New *Rasa Open Source * version `${{ github.ref_name }}` has been released!" + + send_slack_notification_for_release_on_failure: + name: Notify Slack & Publish Release Notes + runs-on: ubuntu-24.04 + # run this job when the workflow is triggered by a tag push + if: always() && github.repository == 'RasaHQ/rasa' && github.ref_type == 'tag' + needs: + - deploy + + steps: + - name: Notify Slack of failure ⛔️ + # send notification if 'deploy' is skipped (previous needed job failed) or failed + if: needs.deploy.result != 'success' + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_RELEASE_ASSISTANT_DEV_TRIBE_WEBHOOK }} + uses: Ilshidur/action-slack@689ad44a9c9092315abd286d0e3a9a74d31ab78a + with: + args: "⛔️ *Rasa Open Source* version `${{ github.ref_name }}` could not be released 😱! Please check out GitHub Actions: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/dependabot-batch-updater.yml b/.github/workflows/dependabot-batch-updater.yml new file mode 100644 index 0000000..dd08a4c --- /dev/null +++ b/.github/workflows/dependabot-batch-updater.yml @@ -0,0 +1,18 @@ +name: Check if dependencies can be updated and create a pull request with updated ones. +on: + schedule: + # Run at 05:00 on Wednesday + - cron: '0 5 * * 3' + +jobs: + update_dependencies: + runs-on: ubuntu-24.04 + name: Update dependencies + steps: + - uses: RasaHQ/dependabot-batch-updater@f049cbb0bbd3754bcb5ab154a79f00cd780fc633 # v1.0 + with: + repo-token: ${{ secrets.RASABOT_GITHUB_TOKEN }} + repository: RasaHQ/rasa + directory: / + package-manager: pip + batch-size: 5 diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..04c5369 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,325 @@ +name: Publish Documentation + +on: + push: + branches: + - "main" + - "documentation" + tags: + - "**" + pull_request: + +concurrency: + # group workflow runs based on the branch or the tag ref + group: documentation-${{ github.ref }} + cancel-in-progress: true + +# SECRETS +# - GH_DOCS_WRITE_KEY: generated locally, added to github repo (public key) +# `ssh-keygen -t rsa -b 4096 -C "Github CI Docs Key" -N "" -f key` +# - GITHUB_TOKEN: (default, from github actions) +# - NETLIFY_AUTH_TOKEN: an access token to use when authenticating commands on Netlify +# - NETLIFY_SITE_ID: the API ID of the Netlify site for the docs + +env: + DOCS_FOLDER: docs + DOCS_BRANCH: documentation + IS_TAG_BUILD: ${{ startsWith(github.event.ref, 'refs/tags') }} + IS_MAIN_BRANCH: ${{ github.ref == 'refs/heads/main' }} + +jobs: + changes: + name: Check for file changes + runs-on: ubuntu-24.04 + # don't run this for pull requests of forks + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'RasaHQ/rasa' + outputs: + # Both of the outputs below are strings but only one exists at any given time + backend: ${{ steps.changed-files.outputs.backend || steps.run-all.outputs.backend }} + docker: ${{ steps.changed-files.outputs.docker || steps.run-all.outputs.docker }} + docs: ${{ steps.changed-files.outputs.docs || steps.run-all.outputs.docs }} + steps: + - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + - uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 + # Run the normal filters if the all-tests-required label is not set + id: changed-files + if: contains(github.event.pull_request.labels.*.name, 'status:all-tests-required') == false + with: + token: ${{ secrets.GITHUB_TOKEN }} + filters: .github/change_filters.yml + - name: Set all filters to true if all tests are required + # Set all filters to true if the all-tests-required label is set + # Bypasses all the change filters in change_filters.yml and forces all outputs to true + id: run-all + if: contains(github.event.pull_request.labels.*.name, 'status:all-tests-required') + run: | + echo "backend=true" >> $GITHUB_OUTPUT + echo "docker=true" >> $GITHUB_OUTPUT + echo "docs=true" >> $GITHUB_OUTPUT + + evaluate_release_tag: + name: Evaluate release tag + runs-on: ubuntu-24.04 + # don't run this for main branches of forks and on documentation branch + if: github.repository == 'RasaHQ/rasa' && github.ref != 'refs/heads/documentation' && github.event_name != 'pull_request' + outputs: + build_docs: ${{ steps.check_tag.outputs.build_docs }} + + steps: + - name: Checkout git repository 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Install version library + run: | + python3 -m pip install pep440_version_utils + + - name: Check if tag version is equal or higher than the latest tagged Rasa version + id: check_tag + if: env.IS_TAG_BUILD == 'true' || env.IS_MAIN_BRANCH == 'true' + run: | + if [[ "${IS_MAIN_BRANCH}" == "true" ]]; then + echo "Main branch: setting build_docs to true." + echo "build_docs=true" >> $GITHUB_OUTPUT + else + # Get latest tagged Rasa version + git describe --tags --match="3.6.[0-9]*" --abbrev=0 HEAD + # Fetch branch history + TAG_NAME=${GITHUB_REF#refs/tags/} + git fetch --prune --unshallow + python scripts/evaluate_release_tag.py $TAG_NAME + exit_status=$? + + if [[ ${exit_status} -eq 0 ]]; then + echo "Setting build_docs to true." + echo "build_docs=true" >> $GITHUB_OUTPUT + else + echo "Setting build_docs to false." + echo "build_docs=false" >> $GITHUB_OUTPUT + fi + fi + + prebuild_docs: + name: Prebuild Docs + runs-on: ubuntu-24.04 + needs: [evaluate_release_tag] + # don't run this for main branches of forks, would fail anyways + if: github.repository == 'RasaHQ/rasa' && needs.evaluate_release_tag.outputs.build_docs == 'true' && github.ref != 'refs/heads/documentation' && github.event_name != 'pull_request' + + steps: + - name: Checkout git repository 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Python 3.10 🐍 + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: '3.10' + + - name: Set up Node 12.x 🦙 + uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c + with: + node-version: "12.x" + + - name: Read Poetry Version 🔢 + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry 🦄 + uses: Gr1N/setup-poetry@15821dc8a61bc630db542ae4baf6a7c19a994844 # v8 + with: + poetry-version: ${{ env.POETRY_VERSION }} + + - name: Load Poetry Cached Libraries ⬇ + id: cache-poetry + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .venv + key: ${{ runner.os }}-poetry-${{ env.POETRY_VERSION }}-3.9-non-full-${{ hashFiles('**/poetry.lock') }}-${{ secrets.POETRY_CACHE_VERSION }} + restore-keys: ${{ runner.os }}-poetry-3.9-non-full + + - name: Clear Poetry cache + if: steps.cache-poetry.outputs.cache-hit == 'true' + run: rm -r .venv + + - name: Create virtual environment + if: steps.cache-poetry.outputs.cache-hit != 'true' + run: python -m venv create .venv + + - name: Set up virtual environment + run: poetry config virtualenvs.in-project true + + - name: Load Yarn Cached Packages ⬇ + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: docs/node_modules + key: ${{ runner.os }}-yarn-12.x-${{ hashFiles('docs/yarn.lock') }} + restore-keys: ${{ runner.os }}-yarn-12.x + + - name: Install Dependencies 📦 + run: make install install-docs + + - name: Pre-build Docs 🧶 + run: make prepare-docs + + - name: Push docs to documentation branch 🏃‍♀️ + env: + GH_DOCS_WRITE_KEY: ${{ secrets.GH_DOCS_WRITE_KEY }} + TMP_DOCS_FOLDER: /tmp/documentation-${{ github.run_id }} + TMP_SSH_KEY_PATH: /tmp/docs_key + run: | + eval "$(ssh-agent -s)"; touch $TMP_SSH_KEY_PATH; chmod 0600 $TMP_SSH_KEY_PATH + echo "$GH_DOCS_WRITE_KEY" > $TMP_SSH_KEY_PATH + ssh-add $TMP_SSH_KEY_PATH + + git config --global user.email "builds@github-ci.com" + git config --global user.name "GitHub CI" + git remote set-url --push origin "git@github.com:${{github.repository}}" + + ./scripts/push_docs_to_branch.sh + + - name: Notify slack on failure + if: failure() + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + uses: voxmedia/github-action-slack-notify-build@3665186a8c1a022b28a1dbe0954e73aa9081ea9e # v1.6.0 + with: + channel_id: ${{ secrets.SLACK_ALERTS_CHANNEL_ID }} + status: FAILED + color: warning + + preview_docs: + name: Preview Docs + runs-on: ubuntu-24.04 + needs: [changes] + # don't run this for pull requests from forks + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'RasaHQ/rasa' + + steps: + - name: Checkout git repository 🕝 + if: needs.changes.outputs.docs == 'true' + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Python 3.10 🐍 + if: needs.changes.outputs.docs == 'true' + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: '3.10' + + - name: Set up Node 12.x 🦙 + if: needs.changes.outputs.docs == 'true' + uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c + with: + node-version: "12.x" + + - name: Read Poetry Version 🔢 + if: needs.changes.outputs.docs == 'true' + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry 🦄 + if: needs.changes.outputs.docs == 'true' + uses: Gr1N/setup-poetry@15821dc8a61bc630db542ae4baf6a7c19a994844 # v8 + with: + poetry-version: ${{ env.POETRY_VERSION }} + + - name: Load Poetry Cached Libraries ⬇ + id: cache-poetry + if: needs.changes.outputs.docs == 'true' + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .venv + key: ${{ runner.os }}-poetry-${{ env.POETRY_VERSION }}-3.9-non-full-${{ hashFiles('**/poetry.lock') }}-${{ secrets.POETRY_CACHE_VERSION }} + restore-keys: ${{ runner.os }}-poetry-3.9-non-full + + - name: Clear Poetry cache + if: steps.cache-poetry.outputs.cache-hit == 'true' && needs.changes.outputs.docs == 'true' && contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-preview-docs') + run: rm -r .venv + + - name: Create virtual environment + if: (steps.cache-poetry.outputs.cache-hit != 'true' || contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-preview-docs')) && needs.changes.outputs.docs == 'true' + run: python -m venv create .venv + + - name: Set up virtual environment + if: needs.changes.outputs.docs == 'true' + run: poetry config virtualenvs.in-project true + + - name: Load Yarn Cached Packages ⬇ + if: needs.changes.outputs.docs == 'true' + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: docs/node_modules + key: ${{ runner.os }}-yarn-12.x-${{ hashFiles('docs/yarn.lock') }} + restore-keys: ${{ runner.os }}-yarn-12.x + + - name: Install Dependencies 📦 + if: needs.changes.outputs.docs == 'true' + run: make install install-docs + + - name: Pre-build Docs 🧶 + if: needs.changes.outputs.docs == 'true' + run: make prepare-docs + + - name: Preview draft build 🔬 + if: needs.changes.outputs.docs == 'true' + id: preview_draft_build + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + DOCS_SITE_BASE_URL: /docs/rasa + PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + run: | + make preview-docs + DEPLOY_URL="https://$PULL_REQUEST_NUMBER--rasahq-docs-rasa-v2.netlify.app${DOCS_SITE_BASE_URL}" + echo "preview_url=$DEPLOY_URL" >> $GITHUB_OUTPUT + + - name: Create a comment with help description + if: needs.changes.outputs.docs == 'true' + uses: RasaHQ/create-comment@da7b2ec20116674919493bb5894eea70fdaa6486 + with: + mode: "delete-previous" + id: comment_docs_previews + github-token: ${{ secrets.GITHUB_TOKEN }} + body: | + 🚀 A preview of the docs have been deployed at the following URL: ${{ steps.preview_draft_build.outputs.preview_url }} + + publish_docs: + name: Publish Docs + runs-on: ubuntu-24.04 + # don't run this for main branches of forks; only run on documentation branch + if: github.repository == 'RasaHQ/rasa' && github.ref == 'refs/heads/documentation' + + steps: + - name: Checkout git repository 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Node 12.x 🦙 + uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c + with: + node-version: "12.x" + + - name: Load Yarn Cached Packages ⬇ + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: docs/node_modules + key: ${{ runner.os }}-yarn-12.x-${{ hashFiles('docs/yarn.lock') }} + restore-keys: ${{ runner.os }}-yarn-12.x + + - name: Install Dependencies 📦 + run: make install-docs + + - name: Publish production build ✅ + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + run: make publish-docs + + - name: Notify slack on failure + if: failure() + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + uses: voxmedia/github-action-slack-notify-build@3665186a8c1a022b28a1dbe0954e73aa9081ea9e # v1.6.0 + with: + channel_id: ${{ secrets.SLACK_ALERTS_CHANNEL_ID }} + status: FAILED + color: warning diff --git a/.github/workflows/nightly_release.yml b/.github/workflows/nightly_release.yml new file mode 100644 index 0000000..772f06a --- /dev/null +++ b/.github/workflows/nightly_release.yml @@ -0,0 +1,178 @@ +name: Nightly Builds +on: + schedule: + # Runs every weekday at 1am + - cron: 0 1 * * 1-5 + workflow_dispatch: + + +jobs: + run_script_and_tag_nightly_release: + name: Run release script and tag a new nightly release + runs-on: ubuntu-24.04 + outputs: + tag_name: ${{ steps.set_tagname.outputs.tag_name }} + + steps: + - name: Checkout git repository 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Python module + run: | + python3 -m pip install pluggy + python3 -m pip install ruamel.yaml + + - name: Compose tag name + id: set_tagname + run: | + DATE=$(date +'%Y%m%d') + + # Find latest rasa-oss version + echo "Trying to find the latest rasa-oss version..." + LATEST_RASA_MINOR=$(python -c "import sys; import os; sys.path.append('${{ github.workspace }}/rasa'); from rasa.version import __version__; print(__version__)") + echo "Current RASA version: ${LATEST_RASA_MINOR}" + + LATEST_NIGHTLY_VERSION=$(echo ${LATEST_RASA_MINOR}) + + echo "Composing nightly build tag name..." + GH_TAG=${LATEST_NIGHTLY_VERSION}.dev${DATE} + echo "New nightly release version: ${GH_TAG}" + echo "tag_name=${GH_TAG}" >> $GITHUB_OUTPUT + + - name: Tag latest main commit as nightly + run: | + git config user.name github-actions + git config user.email github-actions@github.com + + git tag -a ${{ steps.set_tagname.outputs.tag_name }} -m "This is an internal development build" + git push origin ${{ steps.set_tagname.outputs.tag_name }} --tags + + + deploy: + name: Deploy to PyPI + runs-on: ubuntu-24.04 + + # deploy will only be run when there is a tag available + needs: run_script_and_tag_nightly_release # only run after all other stages succeeded + + steps: + - name: Checkout git repository 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Python 3.9 🐍 + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: 3.9 + + - name: Read Poetry Version 🔢 + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry 🦄 + uses: Gr1N/setup-poetry@15821dc8a61bc630db542ae4baf6a7c19a994844 # v8 + with: + poetry-version: ${{ env.POETRY_VERSION }} + + - name: Copy Segment write key to the package + env: + RASA_TELEMETRY_WRITE_KEY: ${{ secrets.RASA_OSS_TELEMETRY_WRITE_KEY }} + RASA_EXCEPTION_WRITE_KEY: ${{ secrets.RASA_OSS_EXCEPTION_WRITE_KEY }} + run: | + ./scripts/write_keys_file.sh + + - name: Update version (nightly releases) 🚀 + run: | + poetry run pip install toml pep440_version_utils + poetry run python ./scripts/prepare_nightly_release.py --next_version "${{ needs.run_script_and_tag_nightly_release.outputs.tag_name }}" + + - name: Build ⚒️ Distributions + run: | + poetry build + + # Authenticate and push to the release registry + - id: 'auth-release' + name: Authenticate with gcloud for release registry 🎫 + uses: 'google-github-actions/auth@e8df18b60c5dd38ba618c121b779307266153fbf' + with: + credentials_json: '${{ secrets.RASA_OSS_RELEASE_ACCOUNT_KEY }}' + + - name: 'Set up Cloud SDK' + uses: 'google-github-actions/setup-gcloud@62d4898025f6041e16b1068643bfc5a696863587' + + - name: Release via GCP Artifact Registry + run: | + pip install keyring + pip install keyrings.google-artifactregistry-auth + pip install twine + gcloud artifacts print-settings python --project=rasa-releases --repository=rasa --location=europe-west3 > ~/.pypirc + twine upload --verbose --repository-url https://europe-west3-python.pkg.dev/rasa-releases/rasa/ ${{ format('{0}/dist/*', github.workspace) }} + + docker: + name: Build Docker + runs-on: ubuntu-24.04 + needs: run_script_and_tag_nightly_release + env: + GCLOUD_VERSION: "297.0.1" + # Registry used to store Docker images used for release purposes + DEV_REGISTRY: "europe-west3-docker.pkg.dev/rasa-ci-cd/rasa" + IMAGE_TAG: ${{ needs.run_script_and_tag_nightly_release.outputs.tag_name }} + + steps: + - name: Checkout git repository 🕝 + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Free disk space + # tries to make sure we do not run out of disk space, see + # https://github.community/t5/GitHub-Actions/BUG-Strange-quot-No-space-left-on-device-quot-IOExceptions-on/td-p/46101 + run: | + sudo swapoff -a + sudo rm -f /swapfile + sudo apt clean + docker rmi $(docker image ls -aq) + df -h + + - name: Read Poetry Version 🔢 + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4b4e9c3e2d4531116a6f8ba8e71fc6e2cb6e6c8c + id: buildx + with: + version: v0.5.1 + driver: docker + + - name: Copy Segment write key to the package + env: + RASA_TELEMETRY_WRITE_KEY: ${{ secrets.RASA_OSS_TELEMETRY_WRITE_KEY }} + RASA_EXCEPTION_WRITE_KEY: ${{ secrets.RASA_OSS_EXCEPTION_WRITE_KEY }} + run: | + ./scripts/write_keys_file.sh + + - name: Build Docker image + run: | + docker build . -t rasa/rasa:base-localdev -f docker/Dockerfile.base + docker build . -t rasa/rasa:base-builder-localdev -f docker/Dockerfile.base-builder --build-arg IMAGE_BASE_NAME=rasa/rasa --build-arg POETRY_VERSION=${{ env.POETRY_VERSION }} + docker build . -t rasa/rasa:base-poetry -f docker/Dockerfile.base-poetry --build-arg IMAGE_BASE_NAME=rasa/rasa --build-arg BASE_IMAGE_HASH=localdev + docker build . -t rasa/rasa:${IMAGE_TAG} -f Dockerfile --build-arg IMAGE_BASE_NAME=rasa/rasa --build-arg BASE_IMAGE_HASH=localdev --build-arg BASE_BUILDER_IMAGE_HASH=localdev + docker tag rasa/rasa:${IMAGE_TAG} ${{env.DEV_REGISTRY}}/rasa:${IMAGE_TAG} + + # Authenticate and push to the release registry + - id: 'auth-dev' + name: Authenticate with gcloud for dev registry 🎫 + uses: 'google-github-actions/auth@e8df18b60c5dd38ba618c121b779307266153fbf' + with: + credentials_json: '${{ secrets.RASA_OSS_RELEASE_ACCOUNT_KEY }}' + + - name: Authenticate docker for dev registry 🎫 + run: | + # Set up docker to authenticate via gcloud command-line tool. + gcloud auth configure-docker europe-west3-docker.pkg.dev + + - name: Push image to release registry + run: | + docker push ${{env.DEV_REGISTRY}}/rasa:${IMAGE_TAG} diff --git a/.github/workflows/rasa-install-cron-check.yml b/.github/workflows/rasa-install-cron-check.yml new file mode 100644 index 0000000..2cc143c --- /dev/null +++ b/.github/workflows/rasa-install-cron-check.yml @@ -0,0 +1,48 @@ +# This is a installation test that we run daily. We attempt to install Rasa +# on Ubuntu, Mac and Windows and try to run `rasa init`. The goal here is to +# catch installation issues in an automated way. + +name: Cron Test to Check Pip Installation + +on: + schedule: + - cron: "0 0 * * *" + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + [ubuntu-24.04, ubuntu-18.04, macos-latest, windows-2019, windows-2022] + python-version: [3.8, 3.9, '3.10'] + fail-fast: false + + steps: + - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: ${{ matrix.python-version }} + - name: Try to install Rasa. + run: | + python -m pip install rasa + - name: Try to install Rasa[full]. + run: | + python -m pip install rasa[full] + - name: Try to run Rasa. + run: | + python -m rasa --version + # Must create a folder first + mkdir rasa-demo + # Next we try to init. + python -m rasa init --init-dir rasa-demo --no-prompt + - name: Notify slack on failure + if: failure() + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + uses: voxmedia/github-action-slack-notify-build@3665186a8c1a022b28a1dbe0954e73aa9081ea9e + with: + channel_id: ${{ secrets.SLACK_ALERTS_CHANNEL_ID }} + status: FAILED + color: danger diff --git a/.github/workflows/security-scans.yml b/.github/workflows/security-scans.yml new file mode 100644 index 0000000..612518e --- /dev/null +++ b/.github/workflows/security-scans.yml @@ -0,0 +1,131 @@ +name: Security Scans + +on: + pull_request: + types: [opened, synchronize, labeled] + +concurrency: + group: security-scans-${{ github.head_ref }} # head branch name + cancel-in-progress: true + +jobs: + changes: + name: Check for file changes + runs-on: ubuntu-24.04 + outputs: + backend: ${{ steps.filter.outputs.backend }} + docker: ${{ steps.filter.outputs.docker }} + docs: ${{ steps.filter.outputs.docs }} + steps: + - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + - uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 + id: filter + with: + token: ${{ secrets.GITHUB_TOKEN }} + filters: .github/change_filters.yml + + trivy: + name: Detecting hardcoded secrets + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + with: + # Fetch all history for all tags and branches + fetch-depth: '0' + - name: Run Trivy vulnerability scanner + id: trivy + uses: aquasecurity/trivy-action@e5f43133f6e8736992c9f3c1b3296e24b37e17f2 + continue-on-error: true + with: + format: 'table' + scan-type: 'fs' + exit-code: '1' + scanners: 'secret' + - name: Alert on secret finding + if: steps.trivy.outcome == 'failure' + uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844 + with: + payload: | + { + "text": "*A secret was detected in a GitHub commit in the repo ${{ github.repository }}.*\n${{ github.event.pull_request.html_url || github.event.head_commit.url }}", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*A secret was detected in a GitHub commit in the repo ${{ github.repository }}.*\n${{ github.event.pull_request.html_url || github.event.head_commit.url }}" + } + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CODESECURITY_WEBHOOK_URL }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK + - name: Fail build if a secret is found + if: steps.trivy.outcome == 'failure' + run: | + echo "==========================================================" + echo "| This build has failed because Trivy detected a secret. |" + echo "==========================================================" + echo "1. Check the step 'Run Trivy vulnerability scanner' for output to help you find the secret." + echo "2. If the finding is a false positive, add it as an entry to trivy-secret.yaml in the root of the repo to suppress the finding." + echo "3. If the finding is valid, the security team can help advise your next steps." + exit 1 + + + bandit: + name: Detect python security issues + runs-on: ubuntu-24.04 + needs: [changes] + + steps: + - name: Checkout git repository 🕝 + if: needs.changes.outputs.backend == 'true' + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c + + - name: Set up Python 3.10 🐍 + if: needs.changes.outputs.backend == 'true' + uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b + with: + python-version: '3.10' + + - name: Read Poetry Version 🔢 + if: needs.changes.outputs.backend == 'true' + run: | + echo "POETRY_VERSION=$(scripts/poetry-version.sh)" >> $GITHUB_ENV + shell: bash + + - name: Install poetry 🦄 + if: needs.changes.outputs.backend == 'true' + uses: Gr1N/setup-poetry@15821dc8a61bc630db542ae4baf6a7c19a994844 # v8 + with: + poetry-version: ${{ env.POETRY_VERSION }} + + - name: Load Poetry Cached Libraries ⬇ + id: cache-poetry + if: needs.changes.outputs.backend == 'true' + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .venv + key: ${{ runner.os }}-poetry-${{ env.POETRY_VERSION }}-3.9-${{ hashFiles('**/poetry.lock') }}-${{ secrets.POETRY_CACHE_VERSION }} + restore-keys: ${{ runner.os }}-poetry-3.9 + + - name: Clear Poetry cache + if: steps.cache-poetry.outputs.cache-hit == 'true' && needs.changes.outputs.backend == 'true' && contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-security-scans') + run: rm -r .venv + + - name: Create virtual environment + if: (steps.cache-poetry.outputs.cache-hit != 'true' || contains(github.event.pull_request.labels.*.name, 'tools:clear-poetry-cache-security-scans')) && needs.changes.outputs.backend == 'true' + run: python -m venv create .venv + + - name: Set up virtual environment + if: needs.changes.outputs.backend == 'true' + run: poetry config virtualenvs.in-project true + + - name: Install Dependencies (Linux) 📦 + if: needs.changes.outputs.backend == 'true' + run: make install + + - name: Run Bandit 🔪 + if: needs.changes.outputs.backend == 'true' + run: make lint-security diff --git a/.github/workflows/semgrep-check.yml b/.github/workflows/semgrep-check.yml new file mode 100644 index 0000000..0e66657 --- /dev/null +++ b/.github/workflows/semgrep-check.yml @@ -0,0 +1,33 @@ +# Name of this GitHub Actions workflow. +name: Semgrep + +on: + # Scan mainline branches and report all findings: + push: + branches: + - main + # Scan changed files in PRs (diff-aware scanning): + pull_request: + +jobs: + semgrep: + # User-definable name of this GitHub Actions job: + name: Semgrep Workflow Security Scan + # If you are self-hosting, change the following `runs-on` value: + runs-on: ubuntu-24.04 + + container: + # A Docker image with Semgrep installed. Do not change this. + image: returntocorp/semgrep@sha256:37736e4992c539f760e36e14d48924bd9fa70d0abbde39a6d86d93f66a1affd4 + + # To skip any PR created by dependabot to avoid permission issues: + if: (github.actor != 'dependabot[bot]') + + steps: + # Fetch project source with GitHub Actions Checkout. + - uses: actions/checkout@v3 + # Run the "semgrep ci" command on the command line of the docker image. + - run: semgrep ci + env: + # Add the rules that Semgrep uses by setting the SEMGREP_RULES environment variable. + SEMGREP_RULES: p/github-actions diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml new file mode 100644 index 0000000..3359884 --- /dev/null +++ b/.github/workflows/spellcheck.yml @@ -0,0 +1,21 @@ +name: Typo CI + +on: + push: + branches-ignore: + - main +jobs: + spellcheck: + name: Typo CI (GitHub Action) + runs-on: ubuntu-24.04 + timeout-minutes: 4 + if: "!contains(github.event.head_commit.message, '[ci skip]')" + steps: + - name: TypoCheck + uses: typoci/spellcheck-action@a63b1430c8f0ceed9fd0bd0f3ad8f7466e6c695d # v1.1.0 + # with: + # A license can be purchased via: + # https://gumroad.com/l/MvvBE + # typo_ci_license_key: ${{ secrets.TYPO_CI_LICENSE_KEY }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17ba5ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,91 @@ +*~ +*pyc +*.DS_Store +*.egg +*.eggs +*.egg-info +*.log +*.pyc +*.sass-cache +*build/ +*dat +.env +venv +.pytest_cache/ +.ipynb_checkpoints +.ruby-version +.tox +.mypy_cache/ +dist/ +pip-wheel-metadata +server/ +scala/ +mongodb/ +.cache/ +build/ +*.egg-info/ +jnk/ +logs/ +tmp/ +profile.* +*.sqlite +lastmile_ai/learn/plots/ +*npy +*# +/config.json +*log.json +.coverage +.coveralls.yml +.idea/ +.venv/ +*.iml +out/ +.vscode/ +tmp_training_data.json +.DS_Store +models/ +.mypy_cache/ +*.tar.gz +secrets.tar +.pytest_cache +test_download.zip +bower_components/ +build/lib/ +/models/ +node_modules/ +npm-debug.log +examples/moodbot/py3/ +.python-version +tokens.dat +graph.png +graph.html +story_graph.html +story_graph.dot +debug.md +examples/moodbot/*.png +examples/moodbot/errors.json +examples/formbot/models* +examples/concertbot/models* +examples/moodbot/models* +examples/e2ebot/results/* +failed_stories.md +errors.json +pip-wheel-metadata/* +events.db +events.db-shm +events.db-wal +rasa.db +rasa.db-shm +rasa.db-wal +*.swp +*.coverage* +env +.dir-locals.el +.history +rasa/segment_key +rasa/keys +.rasa +/results/ +# Local Netlify folder +.netlify +rasa.code-workspace diff --git a/.mergepal.yml b/.mergepal.yml new file mode 100644 index 0000000..f3da236 --- /dev/null +++ b/.mergepal.yml @@ -0,0 +1,3 @@ +whitelist: + - status:ready-to-merge +method: merge diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..62a0aa4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: + - repo: https://github.com/ambv/black + rev: 22.10.0 + hooks: + - id: black + args: [--line-length=88] + - repo: https://github.com/thlorenz/doctoc + rev: v2.1.0 + hooks: + - id: doctoc + files: "CONTRIBUTING.md" + - repo: local + hooks: + - id: docstring-check + name: docstring-check + entry: bash -c 'make lint-docstrings' + language: system + types: [python] + pass_filenames: false diff --git a/.typo-ci.yml b/.typo-ci.yml new file mode 100644 index 0000000..2cd32e3 --- /dev/null +++ b/.typo-ci.yml @@ -0,0 +1,209 @@ +# This is a sample .typo-ci.yml file, it's used to configure how Typo CI will behave. +# Add it to the root of your project and push it to github. +--- + +# What language dictionaries should it use? Currently Typo CI supports: +# de +# en +# en_GB +# es +# fr +# it +# pt +# pt_BR +dictionaries: + - en + +# # Any files/folders we should ignore? +excluded_files: + - "*.py" + - "*.css" + - "*.scss" + - "*.yml" + - "*.yaml" + - "*.html" + - "*.json" + - "*.lock" + - "*.js" + - "*.jsx" + - "*.ts" + - "*.tsx" + - "*.md" + - "CHANGELOG.mdx" + - "CODE_OF_CONDUCT.md" + - "CONTRIBUTING.md" + - "Dockerfile" + - "LICENSE.txt" + - "Makefile" + - "NOTICE" + - "README.md" + - "cloudbuild.yaml" + - "pyproject.toml" + - "secrets.tar.enc" + - "setup.cfg" + - ".codeclimate.yml" + - ".coveragerc" + - ".deepsource.toml" + - ".dockerignore" + - ".env" + - ".git" + - ".gitattributes" + - ".gitignore" + - ".pre-commit-config.yaml" + - ".typo-ci.yml" + - ".github/**/*" + - "binder/**/*" + - "data/**/*" + - "docker/**/*" + - "examples/**/*" + - "rasa/**/*" + - "scripts/**/*" + - "tests/**/*" + +# # Any typos we should ignore? +excluded_words: + - analytics + - asyncio + - bot + - bot's + - cdd + - CDD + - cmdline + - conveRT + - ConveRTFeaturizer + - ConveRTTokenizer + - crfsuite + - custom-nlg-service + - daksh + - db's + - deque + - docusaurus + - non-latin + - deduplicate + - deduplication + - donath + - matplotlib + - extractor + - fbmessenger + - featurization + - featurize + - featurized + - featurizer + - featurizers + - featurizes + - featurizing + - forni + - gzip + - gzipped + - hftransformersnlp + - initializer + - instaclient + - jwt + - jwt's + - jupyter + - jupyterhub + - karpathy + - keras + - knowledgebase + - knowledgebasebot + - linenos + - luis + - matmul + - mattermost + - memoization + - miniconda + - mitie + - mitiefeaturizer + - mitie's + - mitienlp + - dataset + - mongod + - mrkdown + - mrkdwn + - myio + - mymodelname + - myuser + - numpy + - networkx + - nlu + - nlu's + - perceptron + - pika + - pika's + - jieba + - pretrained + - prototyper + - pycodestyle + - pykwalify + - pymessenger + - pyobject + - python-engineio + - pre + - customizable + - quickstart + - rasa + - rasa's + - readthedocs + - regularizer + - repo + - rst + - sanic + - sanitization + - scipy + - sklearn + - socketio + - spacy + - spacyfeaturizer + - spacynlp + - ish + - spaCy + - spaCy's + - README + - crf + - backends + - whitespaced + - ngram + - subsampled + - testagent + - tokenize + - tokenized + - tokenization + - tokenizer + - tokenizers + - tokenizing + - typoci + - unfeaturized + - unschedule + - wsgi + - ruamel + - prototyper + - hallo + - crypto + - regexes + - walkthroughs + - webexteams + - venv + - regexfeaturizer + - crfentityextractor + - Comerica + - entitysynonymmapper + - memoizationpolicy + - NLG + - nlg + - Juste + - Tanja + - Vova + - rustup + - rustup-init + - rustc + - conftest + - winpty + - pii-management + - anonymization + - anonymized + - dslim + - pluggy + - HookimplMarker + - hookimpl + +spellcheck_filenames: false diff --git a/CHANGELOG.mdx b/CHANGELOG.mdx new file mode 100644 index 0000000..d1290c7 --- /dev/null +++ b/CHANGELOG.mdx @@ -0,0 +1,5641 @@ +--- +id: changelog +sidebar_label: Rasa Open Source Change Log +title: Rasa Open Source Change Log +--- + +All notable changes to this project will be documented in this file. +This project adheres to [Semantic Versioning](https://semver.org/) starting with version 1.0. + + + + + +## [3.6.21] - 2025-01-13 + +Rasa 3.6.21 (2025-01-13) +### Bugfixes +- [#1424](https://github.com/rasahq/rasa/issues/1424): Replace `pickle` and `joblib` with safer alternatives, e.g. `json`, `safetensors`, and `skops`, for + serializing components. + + **Note**: This is a model breaking change. Please retrain your model. + + If you have a custom component that inherits from one of the components listed below and modified the `persist` or + `load` method, make sure to update your code. Please contact us in case you encounter any problems. + + Affected components: + + - `CountVectorFeaturizer` + - `LexicalSyntacticFeaturizer` + - `LogisticRegressionClassifier` + - `SklearnIntentClassifier` + - `DIETClassifier` + - `CRFEntityExtractor` + - `TrackerFeaturizer` + - `TEDPolicy` + - `UnexpectedIntentTEDPolicy` + + +## [3.6.20] - 2024-04-18 + +Rasa 3.6.20 (2024-04-18) +### Miscellaneous internal changes +- [#13030](https://github.com/rasahq/rasa/issues/13030), [#13031](https://github.com/rasahq/rasa/issues/13031) + + +## [3.6.19] - 2024-03-04 + +Rasa 3.6.19 (2024-03-04) +### Bugfixes +- [#13019](https://github.com/rasahq/rasa/issues/13019): Changed the ordering of returned events to order by ID (previously timestamp) in SQL Tracker Store + + +## [3.6.18] - 2024-02-23 + +Rasa 3.6.18 (2024-02-23) +### Bugfixes +- [#13017](https://github.com/rasahq/rasa/issues/13017): Flush messages when Kafka producer is closed. This is to ensure that all messages in the producer's internal queue are sent to the broker. + + +## [3.6.17] - 2024-02-13 + +Rasa 3.6.17 (2024-02-13) +### Miscellaneous internal changes +- [#13007](https://github.com/rasahq/rasa/issues/13007) + + +## [3.6.16] - 2024-01-19 + +Rasa 3.6.16 (2024-01-19) +### Bugfixes +- [#12983](https://github.com/rasahq/rasa/issues/12983): Upgrade Cryptography to fix improper certificate validation. +- [#12998](https://github.com/rasahq/rasa/issues/12998): Fixes a bug that caused the `full_retrieval_intent_name` key to be missing in the published event. Rasa Analytics makes use of this key to get the Retrieval Intent Name + +### Miscellaneous internal changes +- [#712](https://github.com/rasahq/rasa/issues/712) + + +## [3.6.15] - 2023-11-30 + +Rasa 3.6.15 (2023-11-30) +### Bugfixes +- [#12965](https://github.com/rasahq/rasa/issues/12965): Fixed connection timeout to action server by setting KEEP_ALIVE_TIMEOUT to 120, and reverting changes introduced in #12886. + + +## [3.6.14] - 2023-11-17 + +Rasa 3.6.14 (2023-11-17) +### Bugfixes +- [#12948](https://github.com/rasahq/rasa/issues/12948): Fixed UnexpecTEDIntentlessPolicy training errors that resulted from a change to batching behavior. Changed the batching behavior back to the original for all components. Made the changed batching behavior accessible in DietClassifier using `drop_small_last_batch: True`. + + +## [3.6.13] - 2023-10-23 + +Rasa 3.6.13 (2023-10-23) +### Bugfixes +- [#12927](https://github.com/rasahq/rasa/issues/12927): Fix wrong conflicts that occur when rasa validate stories is run with slots that have active_loop set to null in mapping conditions. + + +## [3.6.12] - 2023-10-10 + +Rasa 3.6.12 (2023-10-10) +### Bugfixes +- [#12904](https://github.com/rasahq/rasa/issues/12904): Refresh headers used in requests (e.g. action server requests) made by `EndpointConfig` using its `headers` attribute. +- [#12906](https://github.com/rasahq/rasa/issues/12906): Upgrade `pillow` to `10.0.1` to address security vulnerability CVE-2023-4863 found in `10.0.0` version. + + +## [3.6.11] - 2023-10-05 + +Rasa 3.6.11 (2023-10-05) +### Bugfixes +- [#12722](https://github.com/rasahq/rasa/issues/12722): Intent names will not be falsely abbreviated in interactive training (fixes OSS-413). + + This will also fix a bug where forced user utterances (using the regex matcher) will + be reverted even though they are present in the domain. +- [#12886](https://github.com/rasahq/rasa/issues/12886): Cache `EndpointConfig` session object using `cached_property` decorator instead of recreating this object on every request. + Initialize these connection pools for action server and model server endpoints as part of the Sanic `after_server_start` listener. + Also close connection pools during Sanic `after_server_stop` listener. + + +## [3.6.10] - 2023-09-26 + +Rasa 3.6.10 (2023-09-26) +### Improvements +- [#12827](https://github.com/rasahq/rasa/issues/12827): Improved handling of last batch during DIET and TED training. The last batch is discarded if it contains less than half a batch size of data. +- [#12852](https://github.com/rasahq/rasa/issues/12852): Added `username` to the connection parameters for `RedisLockStore` and `RedisTrackerStore` +- [#1493](https://github.com/rasahq/rasa/issues/1493): Telemetry data is only send for licensed users. + +### Improved Documentation +- [#12868](https://github.com/rasahq/rasa/issues/12868): Remove the Playground from docs. + + +## [3.6.9] - 2023-09-15 + +Rasa 3.6.9 (2023-09-15) +### Improvements +- [#12778](https://github.com/rasahq/rasa/issues/12778): Added additional method `fingerprint_addon` to the `GraphComponent` interface to allow inclusion of external data into the fingerprint calculation of a component + +### Bugfixes +- [#12790](https://github.com/rasahq/rasa/issues/12790): Fixed `KeyError` which resulted when `domain_responses` doesn't exist as a keyword argument while using a custom action dispatcher with nlg server. + + +## [3.6.8] - 2023-08-30 + +Rasa 3.6.8 (2023-08-30) + +No significant changes. + + +## [3.6.7] - 2023-08-29 + +Rasa 3.6.7 (2023-08-29) +### Bugfixes +- [#12768](https://github.com/rasahq/rasa/issues/12768): Updated certifi, cryptography, and scipy packages to address security vulnerabilities. + + +## [3.6.6] - 2023-08-23 + +Rasa 3.6.6 (2023-08-23) +### Bugfixes +- [#12755](https://github.com/rasahq/rasa/issues/12755): Updated setuptools and wheel to address security vulnerabilities. + + +## [3.6.5] - 2023-08-17 + +Rasa 3.6.5 (2023-08-17) +### Improvements +- [#12696](https://github.com/rasahq/rasa/issues/12696): Use the same session across requests in `RasaNLUHttpInterpreter` + +### Bugfixes +- [#12737](https://github.com/rasahq/rasa/issues/12737): Resolve dependency incompatibility: Pin version of `dnspython` to ==2.3.0. + +### Improved Documentation +- [#12712](https://github.com/rasahq/rasa/issues/12712): Updated PII docs with new section on how to use Rasa X/Enterprise with PII management solution, and a new note on debug + logs being displayed after the bot message with `rasa shell`. + + +## [3.6.4] - 2023-07-21 + +Rasa 3.6.4 (2023-07-21) +### Bugfixes +- [#12575](https://github.com/rasahq/rasa/issues/12575): Extract conditional response variation and channel variation filtering logic into a separate component. + Enable usage of this component in the NaturalLanguageGenerator subclasses (e.g. CallbackNaturalLanguageGenerator, TemplatedNaturalLanguageGenerator). + Amend nlg_request_format to include a single response ID string field, instead of a list of IDs. + +### Improved Documentation +- [#12663](https://github.com/rasahq/rasa/issues/12663): Updated commands with square brackets e.g (`pip install rasa[spacy]`) to use quotes (`pip install 'rasa[spacy]'`) for compatibility with zsh in docs. + + +## [3.6.3] - 2023-07-20 + +Rasa 3.6.3 (2023-07-20) +### Improvements +- [#12637](https://github.com/rasahq/rasa/issues/12637): Added a human readable component to structlog using the `event_info` key and made it the default rendered key if present. + +### Bugfixes +- [#12638](https://github.com/rasahq/rasa/issues/12638): Fix the issue with the most recent model not being selected if the owner or permissions where modified on the model file. +- [#12661](https://github.com/rasahq/rasa/issues/12661): Fixed `BlockingIOError` which occured as a result of too large data passed to strulogs. + +### Improved Documentation +- [#12659](https://github.com/rasahq/rasa/issues/12659): Update action server documentation with new capability to extend Sanic features by using plugins. + Update rasa-sdk dependency to version 3.6.1. +- [#12663](https://github.com/rasahq/rasa/issues/12663): Updated commands with square brackets e.g (`pip install rasa[spacy]`) to use quotes (`pip install 'rasa[spacy]'`) for compatibility with zsh in docs. + + +## [3.6.2] - 2023-07-06 + +Rasa 3.6.2 (2023-07-06) +### Bugfixes +- [#12602](https://github.com/rasahq/rasa/issues/12602): Resolves the issue of importing TensorFlow on Docker for ARM64 architecture. + + +## [3.6.1] - 2023-07-03 + +Rasa 3.6.1 (2023-07-03) +### Improvements +- [#12533](https://github.com/rasahq/rasa/issues/12533): Add building multi-platform Docker image (amd64/arm64) +- [#12543](https://github.com/rasahq/rasa/issues/12543): Switch struct log to `FilteringBoundLogger` in order to retain log level set in the config. +- [#12560](https://github.com/rasahq/rasa/issues/12560): Add new anonymizable structlog keys. + +### Bugfixes +- [#12516](https://github.com/rasahq/rasa/issues/12516): Add `rasa_events` to the list of anonymizable structlog keys and rename structlog keys. +- [#12521](https://github.com/rasahq/rasa/issues/12521): Introduce a validation step in `rasa data validate` and `rasa train` commands to identify non-existent paths and empty domains. +- [#12556](https://github.com/rasahq/rasa/issues/12556): Rich responses containing buttons with parentheses characters are now correctly parsed. + Previously any characters found between the first identified pair of `()` in response button took precedence. + +### Improved Documentation +- [#12371](https://github.com/rasahq/rasa/issues/12371): Update wording in Rasa Pro installation page. +- [#12505](https://github.com/rasahq/rasa/issues/12505): Document new PII Management section. +- [#12527](https://github.com/rasahq/rasa/issues/12527): Added Documentation for Realtime Markers Section. +- [#12579](https://github.com/rasahq/rasa/issues/12579): Add "Rasa Pro Change Log" to documentation. +- [#12581](https://github.com/rasahq/rasa/issues/12581): Document new Load Testing Guidelines section. +- [#12597](https://github.com/rasahq/rasa/issues/12597): Changes the formatting of realtime markers documentation page + + +## [3.6.0] - 2023-06-14 + +Rasa 3.6.0 (2023-06-14) +### Deprecations and Removals +- [#12355](https://github.com/rasahq/rasa/issues/12355): Removed Python 3.7 support as [it reaches its end of life in June 2023](https://devguide.python.org/versions/) + +### Improvements +- [#11222](https://github.com/rasahq/rasa/issues/11222): Add optional property `ids` to the nlg server request body. + IDs will be transmitted to the NLG server and can be used to identify the response variation that should be used. +- [#11775](https://github.com/rasahq/rasa/issues/11775): Add building Docker container for arm64 (e.g. to allow running Rasa inside docker on M1/M2). +- [#12162](https://github.com/rasahq/rasa/issues/12162): Add support for Location data from Whatsapp on Twilio Channel +- [#12324](https://github.com/rasahq/rasa/issues/12324): Add validation to `rasa train` to align validation expectations with `rasa data validate`. + Add `--skip-validation` flag to disable validation and `--fail-on-validation-warnings`, `--validation-max-history` to `rasa train` to have the same options as `rasa data validate`. +- [#12453](https://github.com/rasahq/rasa/issues/12453): Updated tensorflow to version 2.11.1 for all platforms except Apple Silicon which stays on 2.11.0 as 2.11.1 is not available yet +- [#12454](https://github.com/rasahq/rasa/issues/12454): Slot mapping conditions accept `active_loop` specified as `null` in those cases when slots with this mapping condition + should be filled only outside form contexts. +- [#12487](https://github.com/rasahq/rasa/issues/12487): Add an optional `description` key to the Markers Configuration format. This can be used to add documentation and context about marker's usage. For example, a `markers.yml` can look like + + ``` yaml + marker_name_provided: + description: “Name slot has been set” + slot_was_set: name + + marker_mood_expressed: + description: “Unhappy or Great Mood was expressed” + or: + - intent: mood_unhappy + - intent: mood_great + ``` + +### Bugfixes +- [#12467](https://github.com/rasahq/rasa/issues/12467): Fix running custom form validation to update required slots at form activation when prefilled slots consist only of slots + that are not requested by the form. + +### Improved Documentation +- [#12145](https://github.com/rasahq/rasa/issues/12145): Explicitly set Node.js version to 12.x in order to run Docusaurus. +- [#12160](https://github.com/rasahq/rasa/issues/12160): Update obselete commands in docs README. +- [#12168](https://github.com/rasahq/rasa/issues/12168): Correct docker image name for `deploy-rasa-pro-services` in docs. +- [#12169](https://github.com/rasahq/rasa/issues/12169): Update Compatibility Matrix. +- [#12266](https://github.com/rasahq/rasa/issues/12266): Implement `rasa data split stories` to split stories data into train/test parts. +- [#12362](https://github.com/rasahq/rasa/issues/12362): Updated [knowledge base action docs](./action-server/knowledge-base-actions.mdx) to reflect the improvements made in `knowledge base actions` in Rasa 3.6 version. This enhancement now allows users to query for the `object` attribute without the need for users to request a list of `objects` of a particular `object type` beforehand. The docs update mentions this under `:::info New in 3.6` section. +- [#12504](https://github.com/rasahq/rasa/issues/12504): Fix dead link in Analytics documentation. + +### Miscellaneous internal changes +- [#12291](https://github.com/rasahq/rasa/issues/12291), [#12329](https://github.com/rasahq/rasa/issues/12329), [#12332](https://github.com/rasahq/rasa/issues/12332), [#12365](https://github.com/rasahq/rasa/issues/12365), [#12372](https://github.com/rasahq/rasa/issues/12372), [#12386](https://github.com/rasahq/rasa/issues/12386), [#12492](https://github.com/rasahq/rasa/issues/12492) + +## [3.5.17] - 2023-12-05 + +Rasa 3.5.17 (2023-12-05) +### Improvements +- [#12851](https://github.com/rasahq/rasa/issues/12851): Added `username` to the connection parameters for `RedisLockStore` and `RedisTrackerStore` +- [#1493](https://github.com/rasahq/rasa/issues/1493): Telemetry data is only send for licensed users. + + +## [3.5.16] - 2023-08-30 + +Rasa 3.5.16 (2023-08-30) + +No significant changes. + + +## [3.5.15] - 2023-07-21 + +Rasa 3.5.15 (2023-07-21) + +No significant changes. + + +## [3.5.14] - 2023-07-12 + +Rasa 3.5.14 (2023-07-12) +### Bugfixes +- [#12639](https://github.com/rasahq/rasa/issues/12639): Fix the issue with the most recent model not being selected if the owner or permissions where modified on the model file. + +### Miscellaneous internal changes +- [#12649](https://github.com/rasahq/rasa/issues/12649) + + +## [3.5.13] - 2023-07-05 + +Rasa 3.5.13 (2023-07-05) +### Bugfixes +- [#12549](https://github.com/rasahq/rasa/issues/12549): Introduce a validation step in `rasa data validate` command to identify non-existent paths and empty domains. + + +## [3.5.12] - 2023-06-23 + +Rasa 3.5.12 (2023-06-23) +### Bugfixes +- [#12534](https://github.com/rasahq/rasa/issues/12534): Rich responses containing buttons with parentheses characters are now correctly parsed. + Previously any characters found between the first identified pair of `()` in response button took precedence. + +### Miscellaneous internal changes +- [#12512](https://github.com/rasahq/rasa/issues/12512) + + +## [3.5.11] - 2023-06-08 + +Rasa 3.5.11 (2023-06-08) +### Bugfixes +- [#12467](https://github.com/rasahq/rasa/issues/12467): Fix running custom form validation to update required slots at form activation when prefilled slots consist only of slots + that are not requested by the form. + + +## [3.5.10] - 2023-05-23 + +Rasa 3.5.10 (2023-05-23) +### Improved Documentation +- [#12437](https://github.com/rasahq/rasa/issues/12437): Added documentation for spaces alpha + + +## [3.5.9] - 2023-05-19 + +Rasa 3.5.9 (2023-05-19) + +No significant changes. + + +## [3.5.8] - 2023-05-12 + +Rasa 3.5.8 (2023-05-12) +### Bugfixes +- [#12361](https://github.com/rasahq/rasa/issues/12361): Explicitly handled `BufferError exception - Local: Queue full` in Kafka producer. + + +## [3.5.7] - 2023-05-09 + +Rasa 3.5.7 (2023-05-09) +### Bugfixes +- [#12314](https://github.com/rasahq/rasa/issues/12314): `SlotSet` events will be emitted when the value set by the custom action is the same as the existing value of the slot. This was fixed for `AugmentedMemoizationPolicy` to work properly with truncated trackers. + + To restore the previous behaviour, the custom action can return a SlotSet only if the slot value has changed. For example, + + ``` + class CustomAction(Action): + def name(self) -> Text: + return "custom_action" + + def run(self, dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any]) -> List[Dict[Text, Any]]: + # current value of the slot + slot_value = tracker.get_slot('my_slot') + + # value of the entity + # this is parsed from the user utterance + entity_value = next(tracker.get_latest_entity_values("entity_name"), None) + + if slot_value != entity_value: + return[SlotSet("my_slot", entity_value)] + ``` + + +## [3.5.6] - 2023-04-28 + +Rasa 3.5.6 (2023-04-28) +### Bugfixes +- [#12280](https://github.com/rasahq/rasa/issues/12280): Addresses Regular Expression Denial of Service vulnerability in slack connector (https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS) +- [#12325](https://github.com/rasahq/rasa/issues/12325): Fix parsing of RabbitMQ URL provided in `endpoints.yml` file to include vhost path and query parameters. + Re-allows inclusion of credentials in the URL as a regression fix (this was supported in 2.x). + + +## [3.5.5] - 2023-04-20 + +Rasa 3.5.5 (2023-04-20) +### Bugfixes +- [#12268](https://github.com/rasahq/rasa/issues/12268): Allow slot mapping parameter `intent` to accept a list of intent names (as strings), in addition to accepting an intent name as a single string. +- [#12271](https://github.com/rasahq/rasa/issues/12271): Fix `BlockingIOError` when running `rasa shell` on utterances with more than 5KB of text. +- [#12286](https://github.com/rasahq/rasa/issues/12286): Use `ruamel.yaml` round-trip loader in order to preserve all comments after appending `assistant_id` to `config.yml`. +- [#12295](https://github.com/rasahq/rasa/issues/12295): Fix `AttributeError: 'NoneType' object has no attribute 'send_response'` caused by retrieving tracker via `GET /conversations/{conversation_id}/tracker` endpoint when `action_session_start` is customized in a custom action. + This was addressed by passing an instance of `CollectingOutputChannel` to the method retrieving the tracker from the `MessageProcessor`. + +### Improved Documentation +- [#12272](https://github.com/rasahq/rasa/issues/12272): Updated AWS model loading documentation to indicate what should `AWS_ENDPOINT_URL` environment variable be set to. + Added integration test for AWS model loading. +- [#12279](https://github.com/rasahq/rasa/issues/12279): Updated Rasa Pro Services documentation to add `KAFKA_SSL_CA_LOCATION` environment variable. Allows connections over SSL to Kafka +- [#12290](https://github.com/rasahq/rasa/issues/12290): Added note to CLI documentation to address encoding and color issues on certain Windows terminals + +### Miscellaneous internal changes +- [#12267](https://github.com/rasahq/rasa/issues/12267) + + +## [3.5.4] - 2023-04-05 + +Rasa 3.5.4 (2023-04-05) +### Bugfixes +- [#12226](https://github.com/rasahq/rasa/issues/12226): Fix issue with failures while publishing events to RabbitMQ after a RabbitMQ restart. + The fix consists of pinning `aio-pika` dependency to `8.2.3`, since this issue was introduced in `aio-pika` v`8.2.4`. +- [#12232](https://github.com/rasahq/rasa/issues/12232): Patch redis Race Conditiion vulnerability. + +### Miscellaneous internal changes +- [#12230](https://github.com/rasahq/rasa/issues/12230), [#12238](https://github.com/rasahq/rasa/issues/12238) + + +## [3.5.3] - 2023-03-30 + +Rasa 3.5.3 (2023-03-30) +### Improved Documentation +- [#12209](https://github.com/rasahq/rasa/issues/12209): Add new Rasa Pro page in docs, together with minimal content changes. + + +## [3.5.2] - 2023-03-30 + +Rasa 3.5.2 (2023-03-30) +### Improvements +- [#12144](https://github.com/rasahq/rasa/issues/12144): Add a self-reference of the synonym in the EntitySynonymMapper to handle entities extracted in a casing different to synonym case. (For example if a synonym `austria` is added, entities extracted with any alternate casing of the synonym will also be mapped to `austria`). It addresses ATO-616 + +### Bugfixes +- [#12189](https://github.com/rasahq/rasa/issues/12189): Make custom actions inheriting from rasa-sdk `FormValidationAction` parent class an exception of the `selective_domain` rule and always send them domain. +- [#12193](https://github.com/rasahq/rasa/issues/12193): Fix 2 issues detected with the HTTP API: + - The `GET /conversations/{conversation_id}/tracker` endpoint was not returning the tracker with all sessions when `include_events` query parameter was set to `ALL`. + The fix constituted in using `TrackerStore.retrieve_full_tracker` method instead of `TrackerStore.retrieve` method in the function handling the `GET /conversations/{conversation_id}/tracker` endpoint. + Implemented or updated this method across all tracker store subclasses. + - The `GET /conversations/{conversation_id}/story` endpoint was not returning all the stories for all sessions when `all_sessions` query parameter was set to `true`. + The fix constituted in using all events of the tracker to be converted in stories instead of only the `applied_events`. + +### Improved Documentation +- [#12110](https://github.com/rasahq/rasa/issues/12110): Add documentation for secrets managers. + + +## [3.5.1] - 2023-03-24 + +Rasa 3.5.1 (2023-03-24) +### Bugfixes +- [#12174](https://github.com/rasahq/rasa/issues/12174): Fixes training `DIETCLassifier` on the GPU. + + A deterministic GPU implementation of SparseTensorDenseMatmulOp is not currently available + +### Improved Documentation +- [#12127](https://github.com/rasahq/rasa/issues/12127): Updated `Test your assistant` section to describe the new end-to-end testing feature. + Also updated CLI and telemetry reference docs. +- [#12169](https://github.com/rasahq/rasa/issues/12169): Update Compatibility Matrix. + + +## [3.5.0] - 2023-03-21 + +Rasa 3.5.0 (2023-03-21) +### Features +- [#12053](https://github.com/rasahq/rasa/issues/12053): Add a new required key (`assistant_id`) to `config.yml` to uniquely identify assistants in deployment. + The assistant identifier is extracted from the model metadata and added to the metadata of all dialogue events. + Re-training will be required to include the assistant id in the event metadata. + + If the assistant identifier is missing from the `config.yml` or the default identifier value is not replaced, a random + identifier is generated during each training. + + An assistant running without an identifier will issue a warning that dialogue events without identifier metadata will be + streamed to the event broker. + +### Improvements +- [#11998](https://github.com/rasahq/rasa/issues/11998): Add capability to send compressed body in HTTP request to action server. + Use COMPRESS_ACTION_SERVER_REQUEST=True to turn the feature on. + +### Bugfixes +- [#12136](https://github.com/rasahq/rasa/issues/12136): Address potentially missing events with Pika consumer due to weak references on asynchronous tasks, + as specifcied in [Python official documentation](https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task). +- [#12155](https://github.com/rasahq/rasa/issues/12155): Sets a global seed for numpy, TensorFlow, keras, Python and CuDNN, to ensure consistent random number generation. + +### Improved Documentation +- [#11893](https://github.com/rasahq/rasa/issues/11893): Clarify in the docs, how rules are designed and how to use this behaviour to abort a rule + +### Miscellaneous internal changes +- [#11924](https://github.com/rasahq/rasa/issues/11924), [#11926](https://github.com/rasahq/rasa/issues/11926), [#12006](https://github.com/rasahq/rasa/issues/12006), [#12022](https://github.com/rasahq/rasa/issues/12022), [#12092](https://github.com/rasahq/rasa/issues/12092), [#12135](https://github.com/rasahq/rasa/issues/12135), [#12137](https://github.com/rasahq/rasa/issues/12137), [#12140](https://github.com/rasahq/rasa/issues/12140), [#12154](https://github.com/rasahq/rasa/issues/12154) + + +## [3.4.14] - 2023-06-08 + +Rasa 3.4.14 (2023-06-08) +### Bugfixes +- [#12467](https://github.com/rasahq/rasa/issues/12467): Fix running custom form validation to update required slots at form activation when prefilled slots consist only of slots + that are not requested by the form. + + +## [3.4.13] - 2023-05-19 + +Rasa 3.4.13 (2023-05-19) + +No significant changes. + + +## [3.4.12] - 2023-05-12 + +Rasa 3.4.12 (2023-05-12) +### Bugfixes +- [#12361](https://github.com/rasahq/rasa/issues/12361): Explicitly handled `BufferError exception - Local: Queue full` in Kafka producer. + + +## [3.4.11] - 2023-05-09 + +Rasa 3.4.11 (2023-05-09) +### Bugfixes +- [#12325](https://github.com/rasahq/rasa/issues/12325): Fix parsing of RabbitMQ URL provided in `endpoints.yml` file to include vhost path and query parameters. + Re-allows inclusion of credentials in the URL as a regression fix (this was supported in 2.x). +- [#12364](https://github.com/rasahq/rasa/issues/12364): `SlotSet` events will be emitted when the value set by the custom action is the same as the existing value of the slot. This was fixed for `AugmentedMemoizationPolicy` to work properly with truncated trackers. + + To restore the previous behaviour, the custom action can return a SlotSet only if the slot value has changed. For example, + + ``` + class CustomAction(Action): + def name(self) -> Text: + return "custom_action" + + def run(self, dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any]) -> List[Dict[Text, Any]]: + # current value of the slot + slot_value = tracker.get_slot('my_slot') + + # value of the entity + # this is parsed from the user utterance + entity_value = next(tracker.get_latest_entity_values("entity_name"), None) + + if slot_value != entity_value: + return[SlotSet("my_slot", entity_value)] + ``` + +### Miscellaneous internal changes +- [#12267](https://github.com/rasahq/rasa/issues/12267) + + +## [3.4.10] - 2023-04-17 + +Rasa 3.4.10 (2023-04-17) +### Miscellaneous internal changes +- [#12255](https://github.com/rasahq/rasa/issues/12255) + + +## [3.4.9] - 2023-04-05 +### Miscellaneous internal changes +- [#12234](https://github.com/rasahq/rasa/issues/12234) + + +## [3.4.8] - 2023-04-03 + +Rasa 3.4.8 (2023-04-03) +### Bugfixes +- [#12186](https://github.com/rasahq/rasa/issues/12186): Fix issue with failures while publishing events to RabbitMQ after a RabbitMQ restart. + The fix consists of pinning `aio-pika` dependency to `8.2.3`, since this issue was introduced in `aio-pika` v`8.2.4`. + + +## [3.4.7] - 2023-03-30 + +Rasa 3.4.7 (2023-03-30) +### Improvements +- [#12144](https://github.com/rasahq/rasa/issues/12144): Add a self-reference of the synonym in the EntitySynonymMapper to handle entities extracted in a casing different to synonym case. (For example if a synonym `austria` is added, entities extracted with any alternate casing of the synonym will also be mapped to `austria`). It addresses ATO-616 + +### Bugfixes +- [#12139](https://github.com/rasahq/rasa/issues/12139): Fix 2 issues detected with the HTTP API: + - The `GET /conversations/{conversation_id}/tracker` endpoint was not returning the tracker with all sessions when `include_events` query parameter was set to `ALL`. + The fix constituted in using `TrackerStore.retrieve_full_tracker` method instead of `TrackerStore.retrieve` method in the function handling the `GET /conversations/{conversation_id}/tracker` endpoint. + Implemented or updated this method across all tracker store subclasses. + - The `GET /conversations/{conversation_id}/story` endpoint was not returning all the stories for all sessions when `all_sessions` query parameter was set to `true`. + The fix constituted in using all events of the tracker to be converted in stories instead of only the `applied_events`. +- [#12189](https://github.com/rasahq/rasa/issues/12189): Make custom actions inheriting from rasa-sdk `FormValidationAction` parent class an exception of the `selective_domain` rule and always send them domain. + + +## [3.4.6] - 2023-03-16 + +Rasa 3.4.6 (2023-03-16) +### Bugfixes +- [#12098](https://github.com/rasahq/rasa/issues/12098): Fixes CountVectorFeaturizer to train when min_df != 1. + + +## [3.4.5] - 2023-03-09 + +Rasa 3.4.5 (2023-03-09) +### Bugfixes +- [#12059](https://github.com/rasahq/rasa/issues/12059): Check unresolved slots before initiating model training. +- [#12096](https://github.com/rasahq/rasa/issues/12096): Fixes the bug when a slot (with `from_intent` mapping which contains no input for `intent` parameter) will no longer fill for any intent that is not under the `not_intent` parameter. +- [#12099](https://github.com/rasahq/rasa/issues/12099): Fix validation metrics calculation when batch_size is dynamic. + +### Improved Documentation +- [#12062](https://github.com/rasahq/rasa/issues/12062): Add a link to an existing docs section on how to test the audio channel on `localhost`. + + +## [3.4.4] - 2023-02-17 + +Rasa 3.4.4 (2023-02-17) +### Improvements +- [#11997](https://github.com/rasahq/rasa/issues/11997): Add capability to send compressed body in HTTP request to action server. + Use COMPRESS_ACTION_SERVER_REQUEST=True to turn the feature on. + +### Bugfixes +- [#12052](https://github.com/rasahq/rasa/issues/12052): Fix the error which resulted during merging multiple domain files where at least one of them contains custom actions that explicitly need `send_domain` set as True in the domain. + + +## [3.4.3] - 2023-02-14 + +Rasa 3.4.3 (2023-02-14) +### Improvements +- [#12001](https://github.com/rasahq/rasa/issues/12001): Add support for custom RulePolicy. +- [#12013](https://github.com/rasahq/rasa/issues/12013): Add capability to select which custom actions should receive domain when they are invoked. + +### Bugfixes +- [#11942](https://github.com/rasahq/rasa/issues/11942): Fix calling the form validation action twice for the same user message triggering a form. +- [#12033](https://github.com/rasahq/rasa/issues/12033): Fix conditional response does not check other conditions if first condition matches. + +### Improved Documentation +- [#11976](https://github.com/rasahq/rasa/issues/11976): Add section in tracker store docs to document the fallback tracker store mechanism. + + +## [3.4.2] - 2023-01-27 + +Rasa 3.4.2 (2023-01-27) +### Bugfixes +- [#11926](https://github.com/rasahq/rasa/issues/11926): Decision to publish docs should not consider next major and minor alpha release versions. +- [#11968](https://github.com/rasahq/rasa/issues/11968): Exit training/running Rasa model when SpaCy runtime version is not compatible with the specified SpaCy model version. +- [#11971](https://github.com/rasahq/rasa/issues/11971): The new custom logging feature was not working due to small syntax issue in the argparse level. + + Previously, the file name passed using the argument --logging-config-file was never retrieved so it never creates the new custom config file with the desired formatting. + +### Miscellaneous internal changes +- [#11924](https://github.com/rasahq/rasa/issues/11924) + + +## [3.4.1] - 2023-01-19 + +Rasa 3.4.1 (2023-01-19) +### Bugfixes +- [#11923](https://github.com/rasahq/rasa/issues/11923): Changed categorical slot comparison to be case insensitive. +- [#11929](https://github.com/rasahq/rasa/issues/11929): Exit training when transformer_size is not divisible by the number_of_attention_heads parameter and update the transformer documentations. + +### Improved Documentation +- [#11899](https://github.com/rasahq/rasa/issues/11899): Update compatibility matrix between Rasa-plus and Rasa Pro services. + + +## [3.4.0] - 2022-12-14 + +Rasa 3.4.0 (2022-12-14) +### Features +- [#11087](https://github.com/rasahq/rasa/issues/11087): Add metadata to Websocket channel. Messages can now include a `metadata` object which will be included as metadata to Rasa. The metadata can be supplied on a user configurable key with the `metadata_key` setting in the `socketio` section of the `credentials.yml`. + +### Improvements +- [#11517](https://github.com/rasahq/rasa/issues/11517): Added `./docker/Dockerfile_pretrained_embeddings_spacy_it` to include Spacy's Italian pre-trained model `it_core_news_md`. +- [#11765](https://github.com/rasahq/rasa/issues/11765): Replace `kafka-python` dependency with `confluent-kafka` async Producer API. +- [#11773](https://github.com/rasahq/rasa/issues/11773): Add support for Python 3.10 version. +- [#2546](https://github.com/rasahq/rasa/issues/2546): Added CLI option `--logging-config-file` to enable configuration of custom logs formatting. + +### Bugfixes +- [#11869](https://github.com/rasahq/rasa/issues/11869): Implements a new CLI option `--jwt-private-key` required to have complete support for asymmetric algorithms as specified + originally in the docs. + + +### Improved Documentation +- [#11766](https://github.com/rasahq/rasa/issues/11766): Clarify in the documentation how to write testing stories if a user presses a button with payload. +- [#11801](https://github.com/rasahq/rasa/issues/11801): Clarify prioritisation of used slot asking option in forms in documentation. + +### Miscellaneous internal changes +- [#11742](https://github.com/rasahq/rasa/issues/11742), [#11800](https://github.com/rasahq/rasa/issues/11800), [#11817](https://github.com/rasahq/rasa/issues/11817), [#11877](https://github.com/rasahq/rasa/issues/11877) + +## [3.3.8] - 2023-04-06 +### Miscellaneous internal changes +- [#12235](https://github.com/rasahq/rasa/issues/12235) + + +## [3.3.7] - 2023-03-31 +### Improvements +- [#12144](https://github.com/rasahq/rasa/issues/12144): Add a self-reference of the synonym in the EntitySynonymMapper to handle entities extracted in a casing different to synonym case. (For example if a synonym `austria` is added, entities extracted with any alternate casing of the synonym will also be mapped to `austria`). It addresses ATO-616 + +### Bugfixes +- [#12187](https://github.com/rasahq/rasa/issues/12187): Fix issue with failures while publishing events to RabbitMQ after a RabbitMQ restart. + The fix consists of pinning `aio-pika` dependency to `8.2.3`, since this issue was introduced in `aio-pika` v`8.2.4`. +- [#12192](https://github.com/rasahq/rasa/issues/12192): Fix 2 issues detected with the HTTP API: + - The `GET /conversations/{conversation_id}/tracker` endpoint was not returning the tracker with all sessions when `include_events` query parameter was set to `ALL`. + The fix constituted in using `TrackerStore.retrieve_full_tracker` method instead of `TrackerStore.retrieve` method in the function handling the `GET /conversations/{conversation_id}/tracker` endpoint. + Implemented or updated this method across all tracker store subclasses. + - The `GET /conversations/{conversation_id}/story` endpoint was not returning all the stories for all sessions when `all_sessions` query parameter was set to `true`. + The fix constituted in using all events of the tracker to be converted in stories instead of only the `applied_events`. + +## [3.3.6] - 2023-03-09 + +Rasa 3.3.6 (2023-03-09) +### Bugfixes +- [#12103](https://github.com/rasahq/rasa/issues/12103): Fixes the bug when a slot (with `from_intent` mapping which contains no input for `intent` parameter) will no longer fill for any intent that is not under the `not_intent` parameter. +- [#12117](https://github.com/rasahq/rasa/issues/12117): Fix validation metrics calculation when batch_size is dynamic. + + +## [3.3.5] - 2023-02-21 + +No significant changes. + + +## [3.3.4] - 2023-02-14 + +Rasa 3.3.4 (2023-02-14) +### Improvements +- [#11996](https://github.com/rasahq/rasa/issues/11996): Add capability to send compressed body in HTTP request to action server. +Use COMPRESS_ACTION_SERVER_REQUEST=True to turn the feature on. +- [#12001](https://github.com/rasahq/rasa/issues/12001): Add support for custom RulePolicy. + +## [3.3.3] - 2022-12-01 + +### Bugfixes +- [#11792](https://github.com/rasahq/rasa/issues/11792): Bypass Windows path length restrictions upon saving and reading a model archive in `rasa.engine.storage.LocalModelStorage`. + +### Improvements +- [#11819](https://github.com/rasahq/rasa/issues/11819): Updated tensorflow to 2.8.4. + +## [3.3.2] - 2022-11-30 +### Improvements +- [#11762](https://github.com/rasahq/rasa/issues/11762): Added support for camembert french bert model + +### Bugfixes +- [#11818](https://github.com/rasahq/rasa/issues/11818): Fixes `RuntimeWarning: coroutine 'Bot.set_webhook' was never awaited` issue encountered when starting the rasa server, + which caused the Telegram bot to be unresponsive. + +### Improved Documentation +- [#11768](https://github.com/rasahq/rasa/issues/11768): The documentation was updated for Buttons using messages that start with '/'. + Previously, it wrongly stated that messages with '/' bypass NLU, which is not the case. +- [#11799](https://github.com/rasahq/rasa/issues/11799): Add documentation for Rasa Pro Services upgrades. + + +## [3.3.1] - 2022-11-09 +### Improved Documentation +- [#11721](https://github.com/rasahq/rasa/issues/11721): Add docs on how to set up additional data lakes for Rasa Pro analytics pipeline. +- [#11724](https://github.com/rasahq/rasa/issues/11724): Update migration guide and form docs with prescriptive recommendation on how to implement dynamic forms with custom slot mappings. + +### Improvements +- [#11732](https://github.com/rasahq/rasa/issues/11732): Updated numpy and scikit learn version to fix vulnerabilities of those dependencies + +## [3.3.0] - 2022-10-24 +### Improvements +- [#11561](https://github.com/rasahq/rasa/issues/11561): Added option `--offset-timestamps-by-seconds` to offset the timestamp of events when using `rasa export` +- [#11578](https://github.com/rasahq/rasa/issues/11578): Rasa supports native installations on Apple Silicon (M1 / M2). Please + follow the installation instructions and take a look at the limitations. +- [#11596](https://github.com/rasahq/rasa/issues/11596): Caching `Message` and `Features` fingerprints unless they are altered, saving up to 2/3 of fingerprinting time in our tests. +- [#11598](https://github.com/rasahq/rasa/issues/11598): Added package versions of component dependencies as an additional part of fingerprinting calculation. Upgrading an dependency will thus lead to a retraining of the component in the future. Also, by changing fingerprint calculation, the next training after this change will be a complete retraining. +- [#11623](https://github.com/rasahq/rasa/issues/11623): Export events continuously rather than loading all events in memory first when + using `rasa export`. Events will be streamed right from the start rather + than loading all events first and pushing them to the broker afterwards. +- [#11568](https://github.com/rasahq/rasa/issues/11568): Adds new dependency pluggy, with which it was possible to implement new plugin functionality. + This plugin manager enables the extension and/or enhancement of the Rasa command line interface with functionality made available in the rasa-plus package. + +### Bugfixes +- [#11575](https://github.com/rasahq/rasa/issues/11575): Fix `BlockingIOError` when running `rasa interactive`, after the upgrade of `prompt-toolkit` dependency. +- [#11395](https://github.com/rasahq/rasa/issues/11395): Fixes a bug that lead to initial slot values being incorporated into all rules by default, thus breaking most rules when the slot value changed from its initial value +- [#11429](https://github.com/rasahq/rasa/issues/11429): Made logistic regression classifier output a proper intent ranking and made ranking length configurable + +### Deprecations and Removals +- [#11190](https://github.com/rasahq/rasa/issues/11190): Remove code related to Rasa X local mode as it is deprecated and scheduled for removal. + +### Miscellaneous internal changes +- [#11548](https://github.com/rasahq/rasa/issues/11548) + + +## [3.2.13] - 2023-03-09 + +Rasa 3.2.13 (2023-03-09) +### Bugfixes +- [#12099](https://github.com/rasahq/rasa/issues/12099): Fix validation metrics calculation when batch_size is dynamic. +- [#12102](https://github.com/rasahq/rasa/issues/12102): Fixes the bug when a slot (with `from_intent` mapping which contains no input for `intent` parameter) will no longer fill for any intent that is not under the `not_intent` parameter. + + +## [3.2.12] - 2023-02-21 +### Features +- [#11087](https://github.com/rasahq/rasa/issues/11087): Add metadata to Websocket channel. Messages can now include a `metadata` object which will be included as metadata to Rasa. The metadata can be supplied on a user configurable key with the `metadata_key` setting in the `socketio` section of the `credentials.yml`. + +### Improvements +- [#11995](https://github.com/rasahq/rasa/issues/11995): Add capability to send compressed body in HTTP request to action server. +Use COMPRESS_ACTION_SERVER_REQUEST=True to turn the feature on. + +### Bugfixes +- [#11926](https://github.com/rasahq/rasa/issues/11926): Decision to publish docs should not consider next major and minor alpha release versions. + + +## [3.2.11] - 2022-12-05 +### Improvements +- [#11596](https://github.com/rasahq/rasa/issues/11596): Caching `Message` and `Features` fingerprints unless they are altered, saving up to 2/3 of fingerprinting time in our tests. + +### Bugfixes +- [#11829](https://github.com/rasahq/rasa/issues/11829): Implements a new CLI option `--jwt-private-key` required to have complete support for asymmetric algorithms as specified + originally in the docs. + + +## [3.2.10] - 2022-09-29 +### Bugfixes +- [#11588](https://github.com/rasahq/rasa/issues/11588): Fixes scenarios in which a slot with `from_trigger_intent` mapping that specifies an `active_loop` condition was being filled despite that `active_loop` not being activated. + In addition, fixes scenario in which a slot with `from_trigger_intent` mapping without a specified `active_loop` mapping condition is only filled if the form gets activated. + Removes unnecessary validation warning that a slot with `from_trigger_intent` and a mapping condition should be included in the form's required_slots. +- [#ato-161](https://github.com/rasahq/rasa/issues/ato-161): Fixed a bug where `DIETClassier` crashed during training + when both masked language modelling and evaluation + during training were used. + +### Improved Documentation +- [#11571](https://github.com/rasahq/rasa/issues/11571): Rasa SDK documentation lives now in Rasa Open Source documentation under the _Rasa SDK_ category. + +### Miscellaneous internal changes +- [#11552](https://github.com/rasahq/rasa/issues/11552) + + +## [3.2.9] - 2022-09-09 + +Yanked. + + +## [3.2.8] - 2022-09-08 +### Bugfixes +- [#11540](https://github.com/rasahq/rasa/issues/11540): Fix bug where `KeywordIntentClassifier` overrides preceding intent classifiers' predictions + although the `KeyWordIntentClassifier` was not matching any keywords. + + +## [3.2.7] - 2022-08-31 +### Improvements +- [#11448](https://github.com/rasahq/rasa/issues/11448): Improve `rasa data validate` command so that it uses custom importers when they are defined in config file. + +### Bugfixes +- [#11311](https://github.com/rasahq/rasa/issues/11311): Re-instates the REST channel metadata feature. Metadata can be provided on the `metadata` key. + + +## [3.2.6] - 2022-08-12 +### Bugfixes +- [#11433](https://github.com/rasahq/rasa/issues/11433): This fix makes sure that when a Domain object is loaded from multiple files where one file specifies a custom session config and the rest do not, the default session configuration does not override the custom session config. + +### Miscellaneous internal changes +- [#11426](https://github.com/rasahq/rasa/issues/11426), [#11434](https://github.com/rasahq/rasa/issues/11434) + + +## [3.2.5] - 2022-08-05 +### Bugfixes +- [#11294](https://github.com/rasahq/rasa/issues/11294): Fix `KeyError` which resulted when `action_two_stage_fallback` got executed in a project whose domain contained slot mappings. +- [#11390](https://github.com/rasahq/rasa/issues/11390): Fixes regression in which slot mappings were prioritized according to reverse order as they were listed in the domain, instead of in order from first to last, as was implicitly expected in `2.x`. + Clarifies this implicit priority order in the docs. +- [#11394](https://github.com/rasahq/rasa/issues/11394): Enables the dispatching of bot messages returned as events by slot validation actions. + + +## [3.2.4] - 2022-07-21 +### Bugfixes +- [#11061](https://github.com/rasahq/rasa/issues/11061): Added session_config key as valid domain key during domain loading from directory containing a separate domain file with session configuration. +- [#11362](https://github.com/rasahq/rasa/issues/11362): Run default action `action_extract_slots` after a custom action returns a `UserUttered` event to fill any applicable slots. +- [#11368](https://github.com/rasahq/rasa/issues/11368): Handle the case when an `EndpointConfig` object is given as parameter to the `AwaitableTrackerStore.create()` method. + + +## [3.2.3] - 2022-07-18 +### Bugfixes +- [#11305](https://github.com/rasahq/rasa/issues/11305): - Fixed error in creating response when slack sends retry messages. Assigning `None` to `response.text` caused `TypeError: Bad body type. Expected str, got NoneType`. + - Fixed Slack triggering timeout after 3 seconds if the action execution is too slow. Running `on_new_message` as an asyncio background task instead of a blocking await fixes this by immediately returning a response with code 200. +- [#11326](https://github.com/rasahq/rasa/issues/11326): Revert change in #10295 that removed running the form validation action on activation of the form before the loop is active. +- [#11333](https://github.com/rasahq/rasa/issues/11333): `SlotSet` events will be emitted when the value set by the current user turn is the same as the existing value. + + Previously, `ActionExtractSlots` would not emit any `SlotSet` events if the new value was the same as the existing + one. This caused the augmented memoization policy to lose these slot values when truncating the tracker. + + +## [3.2.2] - 2022-07-05 +### Improved Documentation +- [#11207](https://github.com/rasahq/rasa/issues/11207): Update documentation for customizable classes such as tracker stores, event brokers and lock stores. + +### Miscellaneous internal changes +- [#11296](https://github.com/rasahq/rasa/issues/11296) + + +## [3.2.1] - 2022-06-17 +### Bugfixes +- [#10430](https://github.com/rasahq/rasa/issues/10430): Fix failed check in `rasa data validate` that verifies forms in rules or stories are consistent with the domain when the rule or story contains a default action as `active_loop` step. + + +## [3.2.0] - 2022-06-14 +### Deprecations and Removals +- [#10989](https://github.com/rasahq/rasa/issues/10989): [NLU training data](nlu-training-data.mdx) in JSON format is deprecated and will be + removed in Rasa Open Source 4.0. + Please use `rasa data convert nlu -f yaml --data ` to convert your + NLU JSON data to YAML format before support for NLU JSON data is removed. + +### Improvements +- [#10696](https://github.com/rasahq/rasa/issues/10696): Make `TrackerStore` interface methods asynchronous and supply an `AwaitableTrackerstore` wrapper for custom tracker stores which do not implement the methods as asynchronous. +- [#10897](https://github.com/rasahq/rasa/issues/10897): Added flag `use_gpu` to `TEDPolicy` and `UnexpecTEDIntentPolicy` that can be used to enable training on CPU even when a GPU is available. +- [#11102](https://github.com/rasahq/rasa/issues/11102): Add `--endpoints` command line parameter to `rasa train` parser. + +### Bugfixes +- [#11129](https://github.com/rasahq/rasa/issues/11129): The azure botframework channel now validates the incoming JSON Web Tokens (including signature). + + Previously, JWTs were not validated at all. +- [#9386](https://github.com/rasahq/rasa/issues/9386): `rasa shell` now outputs custom json unicode characters instead of `\uxxxx` codes + +### Improved Documentation +- [#11130](https://github.com/rasahq/rasa/issues/11130): Clarify aspects of the API spec + GET /status endpoint: Correct response schema for model_id - a string, not an object. + + GET /conversations/{conversation_id}/tracker: Describe each of the enum options for include_events query parameter + + POST & PUT /conversations/{conversation_id}/tracker/eventss: Events schema added for each event type + + GET /conversations/{conversation_id}/story: Clarified the all_sessions query parameter and default behaviour. + + POST /model/test/intents : Remove JSON payload option since it is not supported + + POST /model/parse: Explain what emulation_mode is and how it affects response results + +### Miscellaneous internal changes +- [#11088](https://github.com/rasahq/rasa/issues/11088), [#11123](https://github.com/rasahq/rasa/issues/11123), [#9093](https://github.com/rasahq/rasa/issues/9093), [#9099](https://github.com/rasahq/rasa/issues/9099) + +## [3.1.7] - 2022-08-30 +### Miscellaneous internal changes +- [#11522](https://github.com/rasahq/rasa/issues/11522) + + +## [3.1.6] - 2022-07-20 +### Bugfixes +- [#11362](https://github.com/rasahq/rasa/issues/11362): Run default action `action_extract_slots` after a custom action returns a `UserUttered` event to fill any applicable slots. + + +## [3.1.5] - 2022-07-15 +### Bugfixes +- [#11333](https://github.com/rasahq/rasa/issues/11333): `SlotSet` events will be emitted when the value set by the current user turn is the same as the existing value. + + Previously, `ActionExtractSlots` would not emit any `SlotSet` events if the new value was the same as the existing + one. This caused the augmented memoization policy to lose these slot values when truncating the tracker. + + +## [3.1.4] - 2022-06-21 No significant changes. +Upgrade dependent libraries with security vulnerabilities (Pillow, TensorFlow, ujson). + +## [3.1.3] - 2022-06-17 +### Bugfixes +- [#11129](https://github.com/rasahq/rasa/issues/11129): The azure botframework channel now validates the incoming JSON Web Tokens (including signature). + + Previously, JWTs were not validated at all. +- [#11197](https://github.com/rasahq/rasa/issues/11197): Backports fix for failed check in `rasa data validate` that verifies forms in rules or stories are consistent with the domain when the rule or story contains a default action as `active_loop` step. + + +## [3.1.2] - 2022-06-08 +### Miscellaneous internal changes +- [#11156](https://github.com/rasahq/rasa/issues/11156), [#11173](https://github.com/rasahq/rasa/issues/11173) + + +## [3.1.1] - 2022-06-03 +### Bugfixes +- [#10480](https://github.com/rasahq/rasa/issues/10480): Remove warning for Rasa X localmode not being supported when the `--production` flag is present. +- [#10908](https://github.com/rasahq/rasa/issues/10908): Pin requirement for `scipy<1.8.0` since `scipy>=1.8.0` is not backward compatible with `scipy<1.8.0` and additionally requires Python>=3.8, while Rasa supports Python 3.7 as well. +- [#11149](https://github.com/rasahq/rasa/issues/11149): Fix the extraction of values for slots with mapping conditions from trigger intents that activate a form, which was possible in `2.x`. + + +## [3.1.0] - 2022-03-25 +### Features +- [#10203](https://github.com/rasahq/rasa/issues/10203): Add configuration options (via env variables) for library logging. +- [#10473](https://github.com/rasahq/rasa/issues/10473): Support other recipe types. + + This pull request also adds support for graph recipes, see details at + https://rasa.com/docs/rasa/model-configuration and check Graph Recipe page. + + Graph recipe is a raw format for specifying executed graph directly. This is + useful if you need a more powerful way to specify your model creation. +- [#10545](https://github.com/rasahq/rasa/issues/10545): Added optional `ssl_keyfile`, `ssl_certfile`, and `ssl_ca_certs` parameters to the Redis tracker store. +- [#10650](https://github.com/rasahq/rasa/issues/10650): Added `LogisticRegressionClassifier` to the NLU classifiers. + + This model is lightweight and might help in early prototyping. The training times typically decrease substantially, but the accuracy might be a bit lower too. +- [#8762](https://github.com/rasahq/rasa/issues/8762): Added support for Python 3.9. + +### Improvements +- [#10378](https://github.com/rasahq/rasa/issues/10378): Bump TensorFlow version to 2.7. + + :::caution + We can't guarantee the exact same output and hence model performance if your + configuration uses `LanguageModelFeaturizer`. This applies to the case where the + model is re-trained with the new rasa open source version without changing the + configuration, random seeds, and data as well as to the case where a model trained with + a previous version of rasa open source is loaded with this new version for inference. + + We suggest training a new model if you are upgrading to this version of Rasa Open Source. +- [#10444](https://github.com/rasahq/rasa/issues/10444): Make `rasa data validate` check for duplicated intents, forms, responses + and slots when using domains split between multiple files. +- [#10899](https://github.com/rasahq/rasa/issues/10899): Add an `influence_conversation` flag to entites to provide a shorthand for ignoring an entity for all intents. +- [#9789](https://github.com/rasahq/rasa/issues/9789): Add `--request-timeout` command line argument to `rasa shell`, allowing users to configure the time a request can take before it's terminated. + +### Bugfixes +- [#10376](https://github.com/rasahq/rasa/issues/10376): Validate regular expressions in nlu training data configuration. +- [#10409](https://github.com/rasahq/rasa/issues/10409): Unset the default values for `num_threads` and `finetuning_epoch_fraction` to `None` in order + to fix cases when CLI defaults override the data from config. +- [#10447](https://github.com/rasahq/rasa/issues/10447): Update `rasa data validate` to not fail when `active_loop` is `null` +- [#10570](https://github.com/rasahq/rasa/issues/10570): Fixes Domain loading when domain config uses multiple yml files. + + Previously not all configures attributes were necessarily known when merging Domains, and in the case of `entities` were not being properly assigned to `intents`. +- [#10606](https://github.com/rasahq/rasa/issues/10606): Fix `max_history` truncation in `AugmentedMemoizationPolicy` to preserve the most recent `UserUttered` event. + Previously, `AugmentedMemoizationPolicy` failed to predict next action after long sequences of actions (longer than `max_history`) because the policy did not have access to the most recent user message. +- [#10634](https://github.com/rasahq/rasa/issues/10634): Add `RASA_ENVIRONMENT` header in Kafka only if the environmental variable is set. +- [#10767](https://github.com/rasahq/rasa/issues/10767): Merge domain entities as lists of dicts, not lists of lists to support entity roles and groups across multiple domains. +- [#9351](https://github.com/rasahq/rasa/issues/9351): Add an option to specify `--domain` for `rasa test nlu` CLI command. + +### Improved Documentation +- [#10553](https://github.com/rasahq/rasa/issues/10553): Fixed an over-indent in the Tokenizers section of the Components page of the docs. + +### Miscellaneous internal changes +- [#10143](https://github.com/rasahq/rasa/issues/10143), [#10507](https://github.com/rasahq/rasa/issues/10507), [#10568](https://github.com/rasahq/rasa/issues/10568), [#10601](https://github.com/rasahq/rasa/issues/10601), [#10658](https://github.com/rasahq/rasa/issues/10658), [#10746](https://github.com/rasahq/rasa/issues/10746), [#10807](https://github.com/rasahq/rasa/issues/10807), [#9094](https://github.com/rasahq/rasa/issues/9094), [#9096](https://github.com/rasahq/rasa/issues/9096), [#9097](https://github.com/rasahq/rasa/issues/9097), [#9098](https://github.com/rasahq/rasa/issues/9098) + + +## [3.0.10] - 2022-03-15## [3.0.10] - 2022-03-15 +### Bugfixes +- [#10675](https://github.com/rasahq/rasa/issues/10675): Fix broken conversion from Rasa JSON NLU data to Rasa YAML NLU data. + + +## [3.0.9] - 2022-03-11 +### Bugfixes +- [#10412](https://github.com/rasahq/rasa/issues/10412): Fix Socket IO connection issues by upgrading sanic to v21.12. + + The bug is caused by [an invalid function signature](https://github.com/sanic-org/sanic/issues/2272) and is fixed in [v21.12](https://sanic.readthedocs.io/en/v21.12.1/sanic/changelog.html#version-21-12-0). + + This update brings some deprecations in `sanic`: + + - Sanic and Blueprint may no longer have arbitrary properties attached to them + - Fixed this by moving user defined properties to the `instance.ctx` object + - Sanic and Blueprint forced to have compliant names + - Fixed this by using string literal names instead of the module's name via _\_name\_\_ + - `sanic.exceptions.abort` is Deprecated + - Fixed by replacing it with `sanic.exceptions.SanicException` + - `sanic.response.StreamingHTTPResponse` is deprecated + - Fixed by replacing it with sanic.response.ResponseStream +- [#10447](https://github.com/rasahq/rasa/issues/10447): Update `rasa data validate` to not fail when `active_loop` is `null` + +### Improved Documentation +- [#10798](https://github.com/rasahq/rasa/issues/10798): Updated the `model_confidence` parameter in `TEDPolicy` and `DIETClassifier`. The `linear_norm` is removed + as it is no longer supported. +- [#10940](https://github.com/rasahq/rasa/issues/10940): Added an additional step to `Receiving Messages` section in slack.mdx documentation. After a slack update this + additional step is needed to allow direct messages to the bot. +- [#10957](https://github.com/rasahq/rasa/issues/10957): Backport the updated deployment docs to 3.0.x. + + +## [3.0.8] - 2022-02-11 +### Improvements +- [#10394](https://github.com/rasahq/rasa/issues/10394): Allow single tokens in rasa end-to-end test files to be annotated with multiple entities. + + Some entity extractors (s.a. `RegexEntityExtractor`) can generate multiple entities from a single expression. Before this change, `rasa test` would fail in this case, because test stories could not be annotated correctly. + New annotation option is + ```YAML + stories: + - story: Some story + steps: + - user: | + cancel my [iphone][{"entity":"iphone", "value":"iphone"},{"entity":"smartphone", "value":"true"}{"entity":"mobile_service", "value":"true"}] + intent: cancel_contract + ``` + +### Bugfixes +- [#10865](https://github.com/rasahq/rasa/issues/10865): Fixed a bug where the `POST /conversations//tracker/events` endpoint repeated + session start events when appending events to a new tracker. + + +## [3.0.7] - 2022-02-09 +### Bugfixes +- [#10516](https://github.com/rasahq/rasa/issues/10516): Checkpoint weights were never loaded before. Implements overwriting checkpoint weights to the final model weights after training of `DIETClassifier`, `ResponseSelector` and `TEDPolicy`. +- [#10782](https://github.com/rasahq/rasa/issues/10782): Allow arbitrary keys under each slot in the domain to allow for custom slot types. +- [#10840](https://github.com/rasahq/rasa/issues/10840): Fix issue with missing running event loop in `MainThread` when starting Rasa Open + Source for Rasa X with JWT secrets. + + +## [3.0.6] - 2022-01-28 +### Deprecations and Removals +- [#10590](https://github.com/rasahq/rasa/issues/10590): Removed CompositionView. + +### Bugfixes +- [#10504](https://github.com/rasahq/rasa/issues/10504): Fixes a bug which was caused by `DIETClassifier` (`ResponseSelector`, `SklearnIntentClassifier` and `CRFEntityExtractor` have the same issue) trying to process message which didn't have required features. Implements removing unfeaturized messages for the above-mentioned components before training and prediction. +- [#10540](https://github.com/rasahq/rasa/issues/10540): Enable slots with `from_entity` mapping that are not part of a form's required slots to be set during active loop. +- [#10673](https://github.com/rasahq/rasa/issues/10673): Catch `ValueError` for any port values that cannot be cast to integer and re-raise as `RasaException` during the initialisation of `SQLTrackerStore`. +- [#10728](https://github.com/rasahq/rasa/issues/10728): Use `tf.function` for model prediction to improve inference speed. +- [#10761](https://github.com/rasahq/rasa/issues/10761): Tie prompt-toolkit to ^2.0 to fix `rasa-shell`. + +### Improved Documentation +- [#10536](https://github.com/rasahq/rasa/issues/10536): Update dynamic form behaviour docs section with an example on how to override `required_slots` in case of removal of a form required slot. + + +## [3.0.5] - 2022-01-19 +### Bugfixes +- [#10519](https://github.com/rasahq/rasa/issues/10519): Corrects `transformer_size` parameter value (`None` by default) with a default size during loading in case `ResponseSelector` contains transformer layers. + +### Miscellaneous internal changes +- [#10385](https://github.com/rasahq/rasa/issues/10385), [#592](https://github.com/rasahq/rasa/issues/592) + + +## [3.0.4] - 2021-12-22 + + +### Miscellaneous internal changes +- [#10572](https://github.com/rasahq/rasa/issues/10572) + + +## [3.0.3] - 2021-12-16 +### Bugfixes +- [#10448](https://github.com/rasahq/rasa/issues/10448): Copy lookup tables to train and test folds in cross validation. Before, the generated folds did not have a copy of + the lookup tables from the original NLU data, so that `RegexEntityExtractor` could not recognize any entities during + the evaluation. +- [#7645](https://github.com/rasahq/rasa/issues/7645): Do not print warning when subintent actions have response. + +### Miscellaneous internal changes +- [#9945](https://github.com/rasahq/rasa/issues/9945) + + +## [3.0.2] - 2021-12-09 +### Bugfixes +- [#10374](https://github.com/rasahq/rasa/issues/10374): Update SQLAlchemy version to a compatible one in case other dependencies force + a lower version. +- [#10391](https://github.com/rasahq/rasa/issues/10391): Fix overriding of default config with custom config containing nested dictionaries. Before, + the keys of a nested dictionary in the default config that were not specified in the + custom config got lost. +- [#10401](https://github.com/rasahq/rasa/issues/10401): Add `UserWarning` to alert users trying to run `rasa x` CLI command with rasa version 3.0 or higher that rasa-x currently doesn't support rasa 3.x. + +### Improved Documentation +- [#10291](https://github.com/rasahq/rasa/issues/10291): Added note to the slot mappings section of the migration guide to recommend checking dynamic form behavior on migrated assistants. + + +## [3.0.1] - 2021-12-02 +### Bugfixes +- [#10235](https://github.com/rasahq/rasa/issues/10235): Fix previous slots getting filled after a restart. Previously events were + searched from oldest to newest which meant we would find first occurrence of a + message and use slots from thereafter. Now we use the last utterance or the + restart event. + +### Miscellaneous internal changes +- [#10405](https://github.com/rasahq/rasa/issues/10405) + + +## [3.0.0] - 2021-11-23 +### Deprecations and Removals +- [#6487](https://github.com/rasahq/rasa/issues/6487): Remove backwards compatibility code with Rasa Open Source 1.x, Rasa Enterprise 0.35, and other outdated + backwards compatibility code in `rasa.cli.x`, `rasa.core.utils`, `rasa.model_testing`, `rasa.model_training` + and `rasa.shared.core.events`. +- [#8569](https://github.com/rasahq/rasa/issues/8569): Removed Python 3.6 support as [it reaches its end of life in December 2021](https://www.python.org/dev/peps/pep-0494/#lifespan). +- [#8862](https://github.com/rasahq/rasa/issues/8862): Follow through on removing deprecation warnings for synchronous `EventBroker` methods. +- [#8864](https://github.com/rasahq/rasa/issues/8864): Follow through on deprecation warnings for policies and policy ensembles. +- [#8867](https://github.com/rasahq/rasa/issues/8867): Follow through on deprecation warnings for `rasa.shared.data`. +- [#8868](https://github.com/rasahq/rasa/issues/8868): Follow through on deprecation warnings for the `Domain`. Most importantly this will + enforce the schema of the [`forms` section](forms.mdx) in the domain file. + This further includes the removal of the `UnfeaturizedSlot` type. +- [#8869](https://github.com/rasahq/rasa/issues/8869): Remove deprecated `change_form_to` and `set_form_validation` methods from `DialogueStateTracker`. +- [#8870](https://github.com/rasahq/rasa/issues/8870): Remove the support of Markdown training data format. This includes: + - reading and writing of story files in Markdown format + - reading and writing of NLU data in Markdown format + - reading and writing of retrieval intent data in Markdown format + - all the Markdown examples and tests that use Markdown +- [#8871](https://github.com/rasahq/rasa/issues/8871): Removed automatic renaming of deprecated action + `action_deactivate_form` to `action_deactivate_loop`. + `action_deactivate_form` will just be treated like other + non-existing actions from now on. +- [#8872](https://github.com/rasahq/rasa/issues/8872): Remove deprecated `sorted_intent_examples` method from `TrainingData`. +- [#8873](https://github.com/rasahq/rasa/issues/8873): Raising `RasaException` instead of deprecation warning when using + `class_from_module_path` for loading types other than classes. +- [#8874](https://github.com/rasahq/rasa/issues/8874): Specifying the `retrieve_events_from_previous_conversation_sessions` kwarg for the any `TrackerStore` was deprecated and has now been removed. + Please use the `retrieve_full_tracker()` method instead. + + Deserialization of pickled trackers was deprecated and has now been removed. + Rasa will perform any future save operations of trackers using json serialisation. + + Removed catch for missing (deprecated) `session_date` when saving trackers in `DynamoTrackerStore`. +- [#8879](https://github.com/rasahq/rasa/issues/8879): Removed the deprecated dialogue policy state featurizers: `BinarySingleStateFeature` and `LabelTokenizerSingleStateFeaturizer`. + + Removed the deprecated method `encode_all_actions` of `SingleStateFeaturizer`. Use `encode_all_labels` instead. +- [#8880](https://github.com/rasahq/rasa/issues/8880): Follow through with removing deprecated policies: `FormPolicy`, `MappingPolicy`, `FallbackPolicy`, `TwoStageFallbackPolicy`, and `SklearnPolicy`. + + Remove warning about default value of `max_history` in MemoizationPolicy. The default value is now `None`. +- [#8881](https://github.com/rasahq/rasa/issues/8881): Follow through on deprecation warnings and remove code, tests, and docs for `ConveRTTokenizer`, `LanguageModelTokenizer` and `HFTransformersNLP`. +- [#8883](https://github.com/rasahq/rasa/issues/8883): `rasa.shared.nlu.training_data.message.Message` method `get_combined_intent_response_key` has been removed. `get_full_intent` should now be used in its place. +- [#8974](https://github.com/rasahq/rasa/issues/8974): Intent IDs sent with events (to kafka and elsewhere) have been removed, intent + names can be used instead (or if numerical values are needed for backwards + compatibility, one can also hash the names to get previous ID values, ie. + `hash(intent_name)` is the old ID values). Intent IDs have been removed because + they were providing no extra value and integers that large were problematic for + some event broker implementations. +- [#9236](https://github.com/rasahq/rasa/issues/9236): Remove `loop` argument from `train` method in `rasa`. + This argument became redundant when Python 3.6 support was dropped as `asyncio.run` became available in Python 3.7. +- [#9390](https://github.com/rasahq/rasa/issues/9390): Remove `template_variables` and `e2e` arguments from `get_stories` method of `TrainingDataImporter`. + This argument was used in Markdown data format and became redundant once Markdown was removed. +- [#9399](https://github.com/rasahq/rasa/issues/9399): `weight_sparsity` has been removed. Developers should replace it with `connection_density` in the following way: `connection_density` = 1-`weight_sparsity`. + + `softmax` is not available as a `loss_type` anymore. + + The `linear_norm` option has been removed as possible value for `model_confidence`. Please, use `softmax` instead. + + `minibatch` has been removed as a value for `tensorboard_log_level`, use `batch` instead. + + Removed deprecation warnings related to the removed component config values. +- [#9404](https://github.com/rasahq/rasa/issues/9404): Follow through on removing deprecation warnings raised in these modules: + + - `rasa/server.py` + + - `rasa/core/agent.py` + + - `rasa/core/actions/action.py` + + - `rasa/core/channels/mattermost.py` + + - `rasa/core/nlg/generator.py` + + - `rasa/nlu/registry.py` +- [#9432](https://github.com/rasahq/rasa/issues/9432): Remove deprecation warnings associated with the `"number_additional_patterns"` parameter of + `rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer`. + This parameter is no longer needed for incremental training. + + Remove deprecation warnings associated with the `"additional_vocabulary_size"` parameter of + `rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer`. + This parameter is no longer needed for incremental training. + + Remove deprecated functions `training_states_actions_and_entities` and + `training_states_and_actions` from `rasa.core.featurizers.tracker_featurizers.TrackerFeaturizer`. + Use `training_states_labels_and_entities` and `training_states_and_labels` instead. +- [#9455](https://github.com/rasahq/rasa/issues/9455): Follow through on deprecation warning for `NGramFeaturizer` +- [#9598](https://github.com/rasahq/rasa/issues/9598): The CLI commands `rasa data convert config` and `rasa data convert responses` which + converted from the Rasa Open Source 1 to the Rasa Open Source 2 formats were removed. + Please use a Rasa Open Source 2 installation to convert your training data before + moving to Rasa Open Source 3. +- [#9766](https://github.com/rasahq/rasa/issues/9766): `rasa.core.agent.Agent.visualize` was removed. Please use `rasa visualize` or + `rasa.core.visualize.visualize` instead. +- [#9972](https://github.com/rasahq/rasa/issues/9972): Removed slot auto-fill functionality, making the key invalid to use in the domain file. + The `auto_fill` parameter was also removed from the constructor of the `Slot` class. + In order to continue filling slots with entities of the same name, you now have to define a `from_entity` mapping in the `slots` section of the domain. + To learn more about how to migrate your 2.0 assistant, please read the migration guide. + +### Features +- [#10150](https://github.com/rasahq/rasa/issues/10150): Training data version upgraded from `2.0` to `3.0` due to breaking changes to format in Rasa Open Source 3.0 +- [#10170](https://github.com/rasahq/rasa/issues/10170): A new experimental feature called `Markers` has been added. + `Markers` allow you to define points of interest in conversations as a set of conditions that need to be met. + A new command `rasa evaluate markers` allows you to apply these conditions to your existing tracker stores + and outputs the points at which the conditions were satisfied. +- [#9803](https://github.com/rasahq/rasa/issues/9803): Rasa Open Source now uses the [model configuration](model-configuration.mdx) to build a + + [directed acyclic graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph). + This graph describes the dependencies between the items in your model configuration and + how data flows between them. This has two major benefits: + + - Rasa Open Source can use the computational graph to optimize the execution of your + model. Examples for this are efficient caching of training steps or executing + independent steps in parallel. + - Rasa Open Source can represent different model architectures flexibly. As long as the + graph remains acyclic Rasa Open Source can in theory pass any data to any graph + component based on the model configuration without having to tie the underlying + software architecture to the used model architecture. + + This change required changes to custom policies and custom NLU components. See the + documentation for a detailed + [migration guide](migration-guide.mdx#custom-policies-and-custom-components). +- [#9972](https://github.com/rasahq/rasa/issues/9972): Added explicit mechanism for slot filling that allows slots to be set and/or updated throughout the conversation. + This mechanism is enabled by defining global slot mappings in the `slots` section of the domain file. + + In order to support this new functionality, implemented a new default action: `action_extract_slots`. This new action runs after each user turn and checks if any slots can be filled with information extracted from the last user message based on defined slot mappings. + + Since slot mappings were moved away from the `forms` section of the domain file, converted the form's `required_slots` to a list of slot names. + In order to restrict certain mappings to a form, you can now use the `conditions` key in the mapping to define the applicable `active_loop`, like so: + ```yaml + slots: + location: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: city + conditions: + - active_loop: booking_form + ``` + To learn more about how to migrate your 2.0 assistant, please read the migration guide. + +### Improvements +- [#10189](https://github.com/rasahq/rasa/issues/10189): Updated the `/status` endpoint response payload, and relevant documentation, to return/reflect the updated 3.0 keys/values. +- [#7619](https://github.com/rasahq/rasa/issues/7619): Bump TensorFlow version to 2.6. + + This update brings some security benefits (see TensorFlow + [release notes](https://github.com/tensorflow/tensorflow/releases/tag/v2.6.0) + for details). However, internal experiments suggest that it is also associated with + increased train and inference time, as well as increased memory usage. + + You can read more about why we decided to update TensorFlow, and what the expected + impact is [here](https://rasa.com/blog/let-s-talk-about-tensorflow-2-6/). + + If you experience a significant increase in train time, inference time, and/or memory + usage, please let us know in the [forum](https://forum.rasa.com/t/feedback-upgrading-to-tensorflow-2-6/48331). + + Users can no longer set `TF_DETERMINISTIC_OPS=1` if they are using GPU(s) because a + `tf.errors.UnimplementedError` will be thrown by TensorFlow (read more + [here](https://github.com/tensorflow/tensorflow/releases/tag/v2.6.0)). + + :::caution + This **breaks backward compatibility of previously trained models**. + It is not possible to load models trained with previous versions of Rasa Open Source. Please re-train + your assistant before trying to use this version. +- [#8057](https://github.com/rasahq/rasa/issues/8057): Added authentication support for connecting to external RabbitMQ servers. + Currently user has to hardcode a username and a password in a URL in order to connect to an external RabbitMQ server. +- [#8459](https://github.com/rasahq/rasa/issues/8459): 1) Failed test stories will display full retrieval intents. + + 2) Retrieval intents will be extracted during action prediction in test stories so that we won't have unnecessary mismatches anymore. + + Let's take this example story: + ```yaml + - story: test story + steps: + - user: | + what is your name? + intent: chitchat/ask_name + - action: utter_chitchat/ask_name + - intent: bye + - action: utter_bye + ``` + + Before: + ```yaml + steps: + - intent: chitchat # 1) intent is not displayed in it's original form + - action: utter_chitchat/ask_name # predicted: utter_chitchat + # 2) retrieval intent is not extracted during action prediction and we have a mismatch + + - intent: bye # some other fail + - action: utter_bye # some other fail + ``` + + Both 1) and 2) problems are solved. + + Now: + ```yaml + steps: + - intent: chitchat/ask_name + - action: utter_chitchat/ask_name + - intent: bye # some other fail + - action: utter_bye # some other fail + ``` +- [#8469](https://github.com/rasahq/rasa/issues/8469): Added `-i` command line option to make RASA listen on a specific ip-address instead of any network interface +- [#8760](https://github.com/rasahq/rasa/issues/8760): `rasa data validate` now checks that forms referenced in `active_loop` directives are defined in the domain +- [#8914](https://github.com/rasahq/rasa/issues/8914): Every conversation event now includes in its metadata the ID of the model which loaded at the time it was created. +- [#8924](https://github.com/rasahq/rasa/issues/8924): Send indices of user message tokens along with the `UserUttered` event through the event broker to Rasa X. +- [#8929](https://github.com/rasahq/rasa/issues/8929): Added optional flag to convert intent ID hashes from integer to string in the `KafkaEventBroker`. +- [#8933](https://github.com/rasahq/rasa/issues/8933): Make it possible to use `or` functionality for `slot_was_set` events. +- [#9068](https://github.com/rasahq/rasa/issues/9068): Upgraded the spaCy dependency from version 3.0 to 3.1. +- [#9133](https://github.com/rasahq/rasa/issues/9133): Implemented `fingerprint` methods in these classes: + + - `Event` + - `Slot` + - `DialogueStateTracker` +- [#9193](https://github.com/rasahq/rasa/issues/9193): Added debug message that logs when a response condition is used. +- [#9485](https://github.com/rasahq/rasa/issues/9485): The naming scheme for trained models was changed. Unless you provide a + `--fixed-model-name` to `rasa train`, Rasa Open Source will now generate a new model + name using the schema `-.tar.gz`, e.g. + - `20211018-094821-composite-pita.tar.gz` (for a model containing a trained NLU and dialogue model) + - `nlu-20211018-094821-composite-pita.tar.gz` (for a model containing only a trained NLU model but not a dialogue model) + - `core-20211018-094821-composite-pita.tar.gz` (for a model containing only a trained dialogue model but no NLU model) +- [#9604](https://github.com/rasahq/rasa/issues/9604): Due to changes in the model architecture the behavior of `rasa train --dry-run` changed. + The exit codes now have the following meaning: + + * `0` means that the model does not require an expensive retraining. However, the + responses might still require updating by running `rasa train` + * `1` means that one or multiple components require to be retrained. + * `8` means that the `--force` flag was used and hence any cached results are ignored + and the entire model is retrained. +- [#9642](https://github.com/rasahq/rasa/issues/9642): Machine learning components like `DIETClassifier`, `ResponseSelector` and `TEDPolicy` using a `ranking_length` parameter will no longer report renormalised + confidences for the top predictions by default. + + A new parameter `renormalize_confidences` is added to these components which if set to `True`, renormalizes the confidences of top `ranking_length` number of predictions to sum up to 1. The default value is `False`, which means no renormalization will be applied by default. It is advised to leave it to `False` but if you are trying to reproduce the results from previous versions of Rasa Open Source, you can set it to `True`. + + Renormalization will only be applied if `model_confidence=softmax` is used. + +### Bugfixes +- [#10079](https://github.com/rasahq/rasa/issues/10079): Fixed validation behavior and logging output around unused intents and utterances. +- [#8614](https://github.com/rasahq/rasa/issues/8614): `rasa test nlu --cross-validation` uses autoconfiguration when no pipeline is defined instead of failing +- [#9195](https://github.com/rasahq/rasa/issues/9195): Update DynamoDb tracker store to correctly retrieve all `sender_ids` from a DynamoDb table. +- [#9629](https://github.com/rasahq/rasa/issues/9629): Fix for `failed_test_stories.yml` not printing the correct message when the extracted entity + specified in a test story is incorrect. +- [#9852](https://github.com/rasahq/rasa/issues/9852): Fix CVE-2021-41127 + +### Improved Documentation +- [#10095](https://github.com/rasahq/rasa/issues/10095): Added new docs for Markers. +- [#10230](https://github.com/rasahq/rasa/issues/10230): Update pip in same command which installs rasa and clarify supported version in + docs. +- [#8568](https://github.com/rasahq/rasa/issues/8568): Update `pika` consumer code in Event Brokers documentation. +- [#8930](https://github.com/rasahq/rasa/issues/8930): Adds documentation on how to use `CRFEntityExtractor` with features from a dense featurizer (e.g. `LanguageModelFeaturizer`). +- [#9366](https://github.com/rasahq/rasa/issues/9366): Updated docs (Domain, Forms, Default Actions, Migration Guide, CLI) to provide more detail over the new slot mappings changes. +- [#9711](https://github.com/rasahq/rasa/issues/9711): Updated documentation publishing mechanisms to build one version of [the documentation](https://rasa.com/docs/rasa) + for each major version of Rasa Open Source, starting from 2.x upwards. Previously, we were building one + version of the documentation for each minor version of Rasa Open Source, resulting in a poor user + experience and high maintenance costs. + +### Miscellaneous internal changes +- [#10065](https://github.com/rasahq/rasa/issues/10065), [#10084](https://github.com/rasahq/rasa/issues/10084), [#10086](https://github.com/rasahq/rasa/issues/10086), [#10131](https://github.com/rasahq/rasa/issues/10131), [#9078](https://github.com/rasahq/rasa/issues/9078), [#9131](https://github.com/rasahq/rasa/issues/9131), [#9135](https://github.com/rasahq/rasa/issues/9135), [#9557](https://github.com/rasahq/rasa/issues/9557) + + +## [2.8.16] - 2021-12-09 +### Improvements +- [#10413](https://github.com/rasahq/rasa/issues/10413): The value of the `RASA_ENVIRONMENT` environmental variable is sent as a header in messages logged by `KafkaEventBroker`. + This value was previously only made available by `PikaEventConsumer`. + +### Bugfixes +- [#10458](https://github.com/rasahq/rasa/issues/10458): Make `action_metadata` json serializable and make it available on the tracker. This is a backport of a fix in 3.0.0. + + +## [2.8.15] - 2021-11-25 +### Bugfixes +- [#10381](https://github.com/rasahq/rasa/issues/10381): Validate regular + expressions in nlu training data configuration. + +## [2.8.14] - 2021-11-18 +### Bugfixes +- [#10241](https://github.com/rasahq/rasa/issues/10241): Bump TensorFlow version to 2.6.2. _We have plans to port this + change to 3.x (see [this issue](https://github.com/RasaHQ/rasa/issues/10378))_. +- [#10257](https://github.com/rasahq/rasa/issues/10257): Downgrade google-auth to <2. + +## [2.8.13] - 2021-11-11 +### Bugfixes +- [#9949](https://github.com/rasahq/rasa/issues/9949): Fixed new intent creation in `rasa interactive` command. Previously, this failed with 500 + from the server due to `UnexpecTEDIntentPolicy` trying to predict with the new intent not in + domain. +- [#9982](https://github.com/rasahq/rasa/issues/9982): Install mitie library when preparing test runs. This step was missing before + and tests were thus failing as we have many tests which rely on mitie library. + Previously, `make install-full` was required. + +### Miscellaneous internal changes +- [#10146](https://github.com/rasahq/rasa/issues/10146), [#9989](https://github.com/rasahq/rasa/issues/9989) + + +## [2.8.12] - 2021-10-21 +### Bugfixes +- [#9771](https://github.com/rasahq/rasa/issues/9771): Fixed a bug where `rasa test --fail-on-prediction-errors` would raise a + `WrongPredictionException` for entities which were actually predicted correctly. + + This happened in two ways: + 1. if for a user message some entities were extracted multiple times (by multiple entity + extractors) but listed only once in the test story, + 2. if the order in which entities from a message were extracted didn't match the order + in which they were listed in the test story. + +### Improved Documentation +- [#9691](https://github.com/rasahq/rasa/issues/9691): Improve the documentation for training `TEDPolicy` with data augmentation. + + +## [2.8.11] - 2021-10-20 +### Bugfixes +- [#9858](https://github.com/rasahq/rasa/issues/9858): Updates dependency on `sanic-jwt` (1.5.0 -> ">=1.6.0, <1.7.0") + + This removes the need to pin the version of `pyjwt` as the newer version of `sanic-jwt` + manages this properly. + + +## [2.8.10] - 2021-10-14 +### Bugfixes +- [#5657](https://github.com/rasahq/rasa/issues/5657): Add List handling in the `send_custom_json` method on `channels/facebook.py`. + Bellow are some examples that could cause en error before. + + Example 1: when the whole json is a List + ``` + [ + { + "blocks": { + "type": "progression_bar", + "text": {"text": "progression 1", "level": "1"}, + } + }, + {"sender": {"id": "example_id"}}, + ] + ``` + + Example 2: instead of being a Dict, *blocks* is a List when there are 2 *type* + keys under it + ``` + { + "blocks": [ + {"type": "title", "text": {"text": "Conversation progress"}}, + { + "type": "progression_bar", + "text": {"text": "Look how far we are...", "level": "1"}, + }, + ] + } + ``` +- [#7676](https://github.com/rasahq/rasa/issues/7676): Fixed bug when using wit.ai training data to train. + Training failed with an error similarly to this: + + ```bash + File "./venv/lib/python3.8/site-packages/rasa/nlu/classifiers/diet_classifier.py", line 803, in train + self.check_correct_entity_annotations(training_data) + File "./venv/lib/python3.8/site-packages/rasa/nlu/extractors/extractor.py", line 418, in check_correct_entity_annotations + entities_repr = [ + File "./venv/lib/python3.8/site-packages/rasa/nlu/extractors/extractor.py", line 422, in + entity[ENTITY_ATTRIBUTE_VALUE], + KeyError: 'value' + ``` +- [#9851](https://github.com/rasahq/rasa/issues/9851): Fix CVE-2021-41127 + + +## [2.8.9] - 2021-10-08 +### Improvements +- [#7619](https://github.com/rasahq/rasa/issues/7619): Bump TensorFlow version to 2.6. + + This update brings some security benefits (see TensorFlow + [release notes](https://github.com/tensorflow/tensorflow/releases/tag/v2.6.0) + for details). However, internal experiments suggest that it is also associated with + increased train and inference time, as well as increased memory usage. + + You can read more about why we decided to update TensorFlow, and what the expected + impact is [here](https://rasa.com/blog/let-s-talk-about-tensorflow-2-6/). + + If you experience a significant increase in train time, inference time, and/or memory + usage, please let us know in the [forum](https://forum.rasa.com/t/feedback-upgrading-to-tensorflow-2-6/48331). + + Users can no longer set `TF_DETERMINISTIC_OPS=1` if they are using GPU(s) because a + `tf.errors.UnimplementedError` will be thrown by TensorFlow (read more + [here](https://github.com/tensorflow/tensorflow/releases/tag/v2.6.0)). + + :::caution + This **breaks backward compatibility of previously trained models**. + It is not possible to load models trained with previous versions of Rasa Open Source. Please re-train + your assistant before trying to use this version. + + ::: +## [2.8.8] - 2021-10-06 + + +### Improvements +- [#7250](https://github.com/rasahq/rasa/issues/7250): Added a function to display the actual text of a Token when inspecting + a Message in a pipeline, making it easier to debug. + +### Improved Documentation +- [#9780](https://github.com/rasahq/rasa/issues/9780): Removing the experimental feature warning for `conditional response variations` from the Rasa docs. + The behaviour of the feature remains unchanged. +- [#9782](https://github.com/rasahq/rasa/issues/9782): Updates [quick install documentation](./installation/installing-rasa-open-source.mdx) with optional venv step, better pip install instructions, & M1 warning + + +## [2.8.7] - 2021-09-20 +### Bugfixes +- [#9678](https://github.com/rasahq/rasa/issues/9678): Explicitly set the upper limit for currently compatible TensorFlow versions. + + +## [2.8.6] - 2021-09-09 +### Bugfixes +- [#9302](https://github.com/rasahq/rasa/issues/9302): Fix rules not being applied when a featurised categorical slot has as one of its allowed + values `none`, `NoNe`, `None` or a similar value. + + +## [2.8.5] - 2021-09-06 +### Bugfixes +- [#9476](https://github.com/rasahq/rasa/issues/9476): AugmentedMemoizationPolicy is accelerated for large trackers +- [#9542](https://github.com/rasahq/rasa/issues/9542): Bump tensorflow to 2.3.4 to address security vulnerabilities + + +## [2.8.4] - 2021-09-02 +### Improvements +- [#5546](https://github.com/rasahq/rasa/issues/5546): Increase speed of augmented lookup for `AugmentedMemoizationPolicy` + +### Bugfixes +- [#7362](https://github.com/rasahq/rasa/issues/7362): Fix `--data` being treated as if non-optional on sub-commands of `rasa data convert` +- [#9490](https://github.com/rasahq/rasa/issues/9490): Fixes bug where `hide_rule_turn` was defaulting to `None` when ActionExecuted was deserialised. + +### Miscellaneous internal changes +- [#8682](https://github.com/rasahq/rasa/issues/8682) + + +## [2.8.3] - 2021-08-19 +### Bugfixes +- [#7695](https://github.com/rasahq/rasa/issues/7695): Ignore checking that intent is in domain for E2E story utterances when running `rasa data validate`. Previously data validation would fail on E2E stories. + + +## [2.8.2] - 2021-08-04 +### Bugfixes +- [#9203](https://github.com/rasahq/rasa/issues/9203): Fixes a bug which caused training of `UnexpecTEDIntentPolicy` to crash when end-to-end training stories were included in the training data. + + Stories with end-to-end training data will now be skipped for the training of `UnexpecTEDIntentPolicy`. + +### Improved Documentation +- [#8024](https://github.com/rasahq/rasa/issues/8024): Removing the experimental feature warning for the `story validation` tool from the rasa docs. + The behaviour of the feature remains unchanged. +- [#8791](https://github.com/rasahq/rasa/issues/8791): Removing the experimental feature warning for `entity roles and groups` from the rasa docs, + and from the code where it previously appeared as a print statement. + The behaviour of the feature remains otherwise unchanged. + + +## [2.8.1] - 2021-07-22 +### Improvements +- [#9085](https://github.com/rasahq/rasa/issues/9085): Add support for `cafile` parameter in `endpoints.yaml`. + This will load a custom local certificate file and use it when making requests to that endpoint. + + For example: + + ```yaml + action_endpoint: + url: https://localhost:5055/webhook + cafile: ./cert.pem + ``` + + This means that requests to the action server `localhost:5055` will use the certificate `cert.pem` located in the current working directory. + +### Bugfixes +- [#9182](https://github.com/rasahq/rasa/issues/9182): Fixes wrong overriding of `epochs` parameter when `TEDPolicy` or `UnexpecTEDIntentPolicy` is not loaded in finetune mode. + + +## [2.8.0] - 2021-07-12 +### Deprecations and Removals +- [#9045](https://github.com/rasahq/rasa/issues/9045): The option `model_confidence=linear_norm` is deprecated and will be removed in Rasa Open Source `3.0.0`. + + Rasa Open Source `2.3.0` introduced `linear_norm` as a possible value for `model_confidence` + parameter in machine learning components such as `DIETClassifier`, `ResponseSelector` and `TEDPolicy`. + Based on user feedback, we have identified multiple problems with this option. + Therefore, `model_confidence=linear_norm` is now deprecated and + will be removed in Rasa Open Source `3.0.0`. If you were using `model_confidence=linear_norm` for any of the mentioned components, + we recommend to revert it back to `model_confidence=softmax` and re-train the assistant. After re-training, + we also recommend to [re-tune the thresholds for fallback components](./fallback-handoff.mdx#fallbacks). +- [#9091](https://github.com/rasahq/rasa/issues/9091): The fallback mechanism for spaCy models has now been removed in Rasa `3.0.0`. + + Rasa Open Source `2.5.0` introduced support for spaCy 3.0. This introduced a + breaking feature because models would no longer be manually linked. To make + the transition smooth Rasa would rely on the `language` parameter in the + `config.yml` to fallback to a medium spaCy model if no model was configured + for the `SpacyNLP` component. In Rasa Open Source `3.0.0` and onwards the + `SpacyNLP` component will require the model name (like `"en_core_web_md"`) + to be passed explicitly. + +### Features +- [#8724](https://github.com/rasahq/rasa/issues/8724): Added `sasl_mechanism` as an optional configurable parameters for the [Kafka Producer](event-brokers.mdx#kafka-event-broker). +- [#8913](https://github.com/rasahq/rasa/issues/8913): Introduces a new policy called [`UnexpecTEDIntentPolicy`](./policies.mdx#unexpected-intent-policy). + + `UnexpecTEDIntentPolicy` helps you [review conversations](./conversation-driven-development.mdx#review) + and also allows your bot to react to unexpected user turns in conversations. + It is an auxiliary policy that should only be used in conjunction with + at least one other policy, as the only action that it can trigger + is the special and newly introduced + [`action_unlikely_intent`](./default-actions.mdx#action_unlikely_intent) action. + + The auto-configuration will include `UnexpecTEDIntentPolicy` in your + configuration automatically, but you can also include it yourself + in the `policies` section of the configuration: + + ``` + policies: + - name: UnexpecTEDIntentPolicy + epochs: 200 + max_history: 5 + ``` + + As part of the feature, it also introduces: + + - [`IntentMaxHistoryTrackerFeaturizer`](./policies.mdx#3-intent-max-history) + to featurize the trackers for `UnexpecTEDIntentPolicy`. + - `MultiLabelDotProductLoss` to support `UnexpecTEDIntentPolicy`'s multi-label training objective. + - A new default action called [`action_unlikely_intent`](./default-actions.mdx#action_unlikely_intent). + + + `rasa test` command has also been adapted to `UnexpecTEDIntentPolicy`: + + - If a test story contains `action_unlikely_intent` and the policy ensemble does not trigger it, this leads to + a test error (wrongly predicted action) and the corresponding story will be logged in `failed_test_stories.yml`. + - If the story does not contain `action_unlikely_intent` and Rasa Open Source does predict it then + the prediction of `action_unlikely_intent` will be ignored for the evaluation (and hence not lead + to a prediction error) but the story will be logged in a file called `stories_with_warnings.yml`. + + + The `rasa data validate` command will warn if `action_unlikely_intent` is + included in the training stories. Accordingly, `YAMLStoryWriter` and `MarkdownStoryWriter` have been updated to not dump `action_unlikely_intent` when writing stories to a file. + + :::caution + The introduction of a new default action **breaks backward compatibility of previously trained models**. + It is not possible to load models trained with previous versions of Rasa Open Source. Please re-train + your assistant before trying to use this version. + + ::: + +### Improvements +- [#8127](https://github.com/rasahq/rasa/issues/8127): Added detailed json schema validation for `UserUttered`, `SlotSet`, `ActionExecuted` and `EntitiesAdded` events both sent and received from the action server, as well as covered at high-level the validation of the rest of the 20 events. + In case the events are invalid, a `ValidationError` will be raised. +- [#8232](https://github.com/rasahq/rasa/issues/8232): Users don't need to specify an additional buffer size for sparse featurizers anymore during incremental training. + + Space for new sparse features are created dynamically inside the downstream machine learning + models - `DIETClassifier`, `ResponseSelector`. In other words, no extra buffer is created in + advance for additional vocabulary items and space will be dynamically allocated for them inside the model. + + This means there's no need to specify `additional_vocabulary_size` for [`CountVectorsFeaturizer`](./components.mdx#countvectorsfeaturizer) or + `number_additional_patterns` for [`RegexFeaturizer`](./components.mdx#regexfeaturizer). These parameters are now deprecated. + + **Before** + ```yaml + pipeline: + - name: "WhitespaceTokenizer" + - name: "RegexFeaturizer" + number_additional_patterns: 100 + - name: "CountVectorsFeaturizer" + additional_vocabulary_size: {text: 100, response: 20} + ``` + + **Now** + ```yaml + pipeline: + - name: "WhitespaceTokenizer" + - name: "RegexFeaturizer" + - name: "CountVectorsFeaturizer" + ``` + + Also, all custom layers specifically built for machine learning models - `RasaSequenceLayer`, `RasaFeatureCombiningLayer` + and `ConcatenateSparseDenseFeatures` now inherit from `RasaCustomLayer` so that they support flexible incremental training out of the box. +- [#8295](https://github.com/rasahq/rasa/issues/8295): Speed up the contradiction check of the [`RulePolicy`](policies.mdx#rule-policy) + by a factor of 3. +- [#8801](https://github.com/rasahq/rasa/issues/8801): Change the confidence score assigned by [`FallbackClassifier`](components.mdx#fallbackclassifier) to fallback intent to be the same as the fallback threshold. +- [#8926](https://github.com/rasahq/rasa/issues/8926): Issue a UserWarning if a specified **domain folder** contains files that look like YML files but cannot be parsed successfully. + Only invoked if user specifies a folder path in `--domain` paramater. Previously those invalid files in the specified folder were silently ignored. + **Does not apply** to individually specified domain YAML files, e.g. `--domain /some/path/domain.yml`, those being invalid will still raise an exception. + +### Bugfixes +- [#8711](https://github.com/rasahq/rasa/issues/8711): Fix for unnecessary retrain and duplication of folders in the model + +### Miscellaneous internal changes +- [#8241](https://github.com/rasahq/rasa/issues/8241), [#8525](https://github.com/rasahq/rasa/issues/8525), [#8694](https://github.com/rasahq/rasa/issues/8694), [#8704](https://github.com/rasahq/rasa/issues/8704) + + +## [2.7.2] - 2021-08-09 +### Bugfixes +- [#7695](https://github.com/rasahq/rasa/issues/7695): Ignore checking that intent is in domain for E2E story utterances when running `rasa data validate`. Previously data validation would fail on E2E stories. +- [#8711](https://github.com/rasahq/rasa/issues/8711): Fix for unnecessary retrain and duplication of folders in the model + + +## [2.7.1] - 2021-06-16 + + +### Bugfixes +- [#7286](https://github.com/rasahq/rasa/issues/7286): Best model checkpoint allows for metrics to be equal to previous best if at least one + metric improves, rather than strict improvement for each metric. +- [#8200](https://github.com/rasahq/rasa/issues/8200): Fixes a bug where multiple plots overlap each other and are rendered incorrectly when comparing performance across multiple NLU pipelines. +- [#8812](https://github.com/rasahq/rasa/issues/8812): Don't evaluate entities if no entities present in test data. + + Also, catch exception in `plot_paired_histogram` when data is empty. + + +## [2.7.0] - 2021-06-03 + + +### Improvements +- [#7691](https://github.com/rasahq/rasa/issues/7691): Changed the default config to train the `RulePolicy` before the `TEDPolicy`. + This means that conflicting rule/stories will be identified before a potentially slow training of the `TEDPolicy`. +- [#7799](https://github.com/rasahq/rasa/issues/7799): Updated validator used by `rasa data validate` to verify that actions used in stories and rules are present in the domain and that form slots match domain slots. +- [#7912](https://github.com/rasahq/rasa/issues/7912): Rename `plot_histogram` to `plot_paired_histogram` and fix missing bars in the plot. +- [#8225](https://github.com/rasahq/rasa/issues/8225): Changed --data option type in the ``rasa data validate``` command to allow more than one path to be passed. + +### Bugfixes +- [#8152](https://github.com/rasahq/rasa/issues/8152): The file `failed_test_stories.yml` (generated by `rasa test`) now also includes the wrongly predicted entity as a comment next to the entity of a user utterance. + Additionally, the comment printed next to the intent of a user utterance is printed only if the intent was wrongly predicted (irrelevantly if there was a wrongly predicted entity or not in the specific user utterance). +- [#8309](https://github.com/rasahq/rasa/issues/8309): Added check in PikaEventBroker constructor: if port cannot be cast to integer, raise RasaException +- [#8388](https://github.com/rasahq/rasa/issues/8388): Fixed bug where missing intent warnings appear when running `rasa test` +- [#8611](https://github.com/rasahq/rasa/issues/8611): Update `should_retrain` function to return the correct fingerprint comparison result + even when there is a problem with model unpacking. +- [#8719](https://github.com/rasahq/rasa/issues/8719): Handle correctly Telegram edited message. + +### Miscellaneous internal changes +- [#8591](https://github.com/rasahq/rasa/issues/8591), [#8641](https://github.com/rasahq/rasa/issues/8641), [#8654](https://github.com/rasahq/rasa/issues/8654), [#8658](https://github.com/rasahq/rasa/issues/8658), [#8802](https://github.com/rasahq/rasa/issues/8802) + + +## [2.6.3] - 2021-05-28 + + +### Bugfixes +- [#8046](https://github.com/rasahq/rasa/issues/8046): `ResponseSelector` can now be trained with the transformer enabled (i.e. when a positive + `number_of_transformer_layers` is provided) even if one doesn't specify the transformer's + size. Previously, not specifying `transformer_size` led to an error. +- [#8386](https://github.com/rasahq/rasa/issues/8386): Return `EntityEvaluationResult` during evaluation of test stories only if `parsed_message` is not `None`. +- [#8546](https://github.com/rasahq/rasa/issues/8546): Ignore `OSError` in Sentry reporting. +- [#8547](https://github.com/rasahq/rasa/issues/8547): Replaced `ValueError` with `RasaException` in TED model `_check_data` method. +- [#8639](https://github.com/rasahq/rasa/issues/8639): Changed import to fix agent creation in Jupyter. + +### Miscellaneous internal changes +- [#7906](https://github.com/rasahq/rasa/issues/7906), [#8544](https://github.com/rasahq/rasa/issues/8544), [#8725](https://github.com/rasahq/rasa/issues/8725), [#8726](https://github.com/rasahq/rasa/issues/8726), [#8727](https://github.com/rasahq/rasa/issues/8727), [#8728](https://github.com/rasahq/rasa/issues/8728) + + +## [2.6.2] - 2021-05-18 + + +### Bugfixes +- [#8364](https://github.com/rasahq/rasa/issues/8364): Fixed a bug where [`ListSlot`](domain.mdx#list-slot)s were filled with single items + in case only one matching entity was extracted for this slot. + + Values applied to [`ListSlot`](domain.mdx#list-slot)s will be converted to a `List` + in case they aren't one. +- [#8581](https://github.com/rasahq/rasa/issues/8581): Fix bug with false rule conflicts + + This essentially reverts [PR 8446](https://github.com/RasaHQ/rasa/pull/8446/files), except for the tests. + The PR is redundant due to [PR 8646](https://github.com/RasaHQ/rasa/pull/8646/files). +- [#8590](https://github.com/rasahq/rasa/issues/8590): Handle `AttributeError ` thrown by empty slot mappings in domain form through refactoring. +- [#8631](https://github.com/rasahq/rasa/issues/8631): Fixed incorrect `The action 'utter_' is used in the stories, but is not a valid utterance action` + error when running `rasa data validate` with response selector responses in the domain file. + +### Improved Documentation +- [#8079](https://github.com/rasahq/rasa/issues/8079): Added a note to clarify best practice for resetting all slots after form deactivation. + +### Miscellaneous internal changes +- [#8587](https://github.com/rasahq/rasa/issues/8587) + + +## [2.6.1] - 2021-05-11 + + +### Bugfixes +- [#7908](https://github.com/rasahq/rasa/issues/7908): Made `SchemaError` message available to validator so that the reason why reason schema validation fails during `rasa data validate` is displayed when response `text` value is `null`. + Added warning message when deprecated MappingPolicy format is used in the domain. +- [#8623](https://github.com/rasahq/rasa/issues/8623): When there are multiple entities in a user message, they will get sorted when creating a + representation of the current dialogue state. + + Previously, the ordering was random, leading to inconsistent state representations. This + would sometimes lead to memoization policies failing to recall a memorised action. + + +## [2.6.0] - 2021-05-06 + + +### Deprecations and Removals +- [#261](https://github.com/rasahq/rasa/issues/261): In forms, the keyword `required_slots` should always precede the definition of slot mappings and the lack of it is deprecated. + Please see the [migration guide](migration-guide.mdx) for more information. +- [#8428](https://github.com/rasahq/rasa/issues/8428): `rasa.data.get_test_directory`, `rasa.data.get_core_nlu_directories`, and + `rasa.shared.nlu.training_data.training_data.TrainingData::get_core_nlu_directories` + are deprecated and will be removed in Rasa Open Source 3.0.0. +- [#8498](https://github.com/rasahq/rasa/issues/8498): Update the minimum compatible model version to "2.6.0". + This means all models trained with an earlier version will have to be retrained. + +### Features +- [#8103](https://github.com/rasahq/rasa/issues/8103): Feature enhancement enabling JWT authentication for the Socket.IO channel. Users can define `jwt_key` and `jwt_method` as parameters in their credentials file for authentication. +- [#8180](https://github.com/rasahq/rasa/issues/8180): Allows a Rasa bot to be connected to a Twilio Voice channel. More details in the [Twilio Voice docs](connectors/twilio-voice.mdx) +- [#8532](https://github.com/rasahq/rasa/issues/8532): Conditional response variations are supported in the `domain.yml` without requiring users to write custom actions code. + + A condition can be a list of slot-value mapping constraints. + +### Improvements +- [#261](https://github.com/rasahq/rasa/issues/261): Added an optional `ignored_intents` parameter in forms. + + - To use it, add the `ignored_intents` parameter in your `domain.yml` file after the forms name and provide a list of intents to ignore. Please see [Forms](forms.mdx) for more information. + - This can be used in case the user never wants to fill any slots of a form with the specified intent, e.g. chitchat. +- [#5786](https://github.com/rasahq/rasa/issues/5786): Add function to carry `max_history` to featurizer +- [#7589](https://github.com/rasahq/rasa/issues/7589): Improved the machine learning models' codebase by factoring out shared feature-processing + logic into three custom layer classes: + - `ConcatenateSparseDenseFeatures` combines multiple sparse and dense feature tensors + into one. + - `RasaFeatureCombiningLayer` additionally combines sequence-level and sentence-level + features. + - `RasaSequenceLayer` is used for attributes with sequence-level features; it + additionally embeds the combined features with a transformer and facilitates masked + language modeling. +- [#7685](https://github.com/rasahq/rasa/issues/7685): Added the following usability improvements with respect to entities getting extracted multiple times: + * Added warnings for competing entity extractors at training time and for overlapping entities at inference time + * Improved docs to help users handle overlapping entity problems. +- [#7999](https://github.com/rasahq/rasa/issues/7999): Replace `weight_sparsity` with `connection_density` in all transformer-based models and add guarantees about internal layers. + + We rename `DenseWithSparseWeights` into `RandomlyConnectedDense`, and guarantee that even at density zero the output is dense and every input is connected to at least one output. The former `weight_sparsity` parameter of DIET, TED, and the ResponseSelector, is now roughly equivalent to `1 - connection_density`, except at very low densities (high sparsities). + + All layers and components that used to have a `sparsity` argument (`Ffnn`, `TransformerRasaModel`, `MultiHeadAttention`, `TransformerEncoderLayer`, `TransformerEncoder`) now have a `density` argument instead. +- [#8074](https://github.com/rasahq/rasa/issues/8074): Rasa test now prints a warning if the test stories contain bot utterances that are not part of the domain. +- [#8263](https://github.com/rasahq/rasa/issues/8263): Updated `asyncio.Task.all_tasks` to `asyncio.all_tasks`, with a fallback for python 3.6, which raises an AttributeError for `asyncio.all_tasks`. This removes the deprecation warning for the `Task.all_tasks` usage. +- [#8461](https://github.com/rasahq/rasa/issues/8461): Change variable name from `i` to `array_2D` +- [#8560](https://github.com/rasahq/rasa/issues/8560): Implement a new interface `run_inference` inside `RasaModel` which performs batch inferencing through tensorflow models. + + `rasa_predict` inside `RasaModel` has been made a private method now by changing it to `_rasa_predict`. + +### Bugfixes +- [#7005](https://github.com/rasahq/rasa/issues/7005): Fixed a bug for plotting trackers with non-ascii texts during interactive training by enforcing utf-8 encoding +- [#7589](https://github.com/rasahq/rasa/issues/7589): Fix masked language modeling in DIET to only apply masking to token-level + (sequence-level) features. Previously, masking was applied to both token-level and + sentence-level features. +- [#8300](https://github.com/rasahq/rasa/issues/8300): Make it possible to use `null` entities in stories. +- [#8333](https://github.com/rasahq/rasa/issues/8333): Introduce a `skip_validation` flag in order to speed up reading YAML files that were already validated. +- [#8341](https://github.com/rasahq/rasa/issues/8341): Fixed a bug in interactive training that + lead to crashes for long Chinese, Japanese, + or Korean user or bot utterances. + + +## [2.5.2] - 2021-06-16 + + +### Features +- [#8892](https://github.com/rasahq/rasa/issues/8892): Added `sasl_mechanism` as an optional configurable parameters for the [Kafka Producer](event-brokers.mdx#kafka-event-broker). + + +## [2.5.1] - 2021-04-28 + + +### Bugfixes +- [#8446](https://github.com/rasahq/rasa/issues/8446): Fixed prediction for rules with multiple entities. +- [#8545](https://github.com/rasahq/rasa/issues/8545): Mitigated Matplotlib backend issue using lazy configuration + and added a more explicit error message to guide users. + + +## [2.5.0] - 2021-04-12 + + +### Deprecations and Removals +- [#8141](https://github.com/rasahq/rasa/issues/8141): The following import abbreviations were removed: + * `rasa.core.train`: Please use `rasa.core.train.train` instead. + * `rasa.core.visualize`: Please use `rasa.core.visualize.visualize` instead. + * `rasa.nlu.train`: Please use `rasa.nlu.train.train` instead. + * `rasa.nlu.test`: Please use `rasa.nlu.test.run_evaluation` instead. + * `rasa.nlu.cross_validate`: Please use `rasa.nlu.test.cross_validate` instead. + +### Features +- [#7869](https://github.com/rasahq/rasa/issues/7869): Upgraded Rasa to be compatible with spaCy 3.0. + + This means that we can support more features for more languages but there are also a few changes. + + SpaCy 3.0 deprecated the `spacy link ` command so that means that from now on [the + full model name](https://spacy.io/models) needs to be used in the `config.yml` file. + + **Before** + + Before you could run `spacy link en en_core_web_md` and then we would be able + to pick up the correct model from the `language` parameter. + + ```yaml + language: en + + pipeline: + - name: SpacyNLP + ``` + + **Now** + + This behavior will be deprecated and instead you'll want to be explicit in `config.yml`. + + ```yaml + language: en + + pipeline: + - name: SpacyNLP + model: en_core_web_md + ``` + + **Fallback** + + To make the transition easier, Rasa will try to fall back to a medium spaCy model when-ever + a compatible language is configured for the entire pipeline in `config.yml` even if you don't + specify a `model`. This fallback behavior is temporary and will be deprecated in Rasa 3.0.0. + + We've updated our docs to reflect these changes. All examples now show a direct link to the + correct spaCy model. We've also added a warning to the [SpaCyNLP](components.mdx#spacynlp) + docs that explains the fallback behavior. + +### Improvements +- [#4280](https://github.com/rasahq/rasa/issues/4280): Improved CLI startup time. +- [#4596](https://github.com/rasahq/rasa/issues/4596): Add `augmentation` and `num_threads` arguments to API `POST /model/train` + + Fix boolean casting issue for `force_training` and `save_to_default_model_directory` arguments +- [#7477](https://github.com/rasahq/rasa/issues/7477): Add minimum compatible version to --version command +- [#7660](https://github.com/rasahq/rasa/issues/7660): Updated warning for unexpected slot events during prediction time to Rasa Open Source + 2.0 YAML training data format. +- [#7701](https://github.com/rasahq/rasa/issues/7701): Hide dialogue turns predicted by `RulePolicy` in the tracker states + for ML-only policies like `TEDPolicy` + if those dialogue turns only appear as rules in the training data and do not appear in stories. + + Add `set_shared_policy_states(...)` method to all policies. + This method sets `_rule_only_data` dict with keys: + - `rule_only_slots`: Slot names, which only occur in rules but not in stories. + - `rule_only_loops`: Loop names, which only occur in rules but not in stories. + + This information is needed for correct featurization to hide dialogue turns that appear only in rules. +- [#8208](https://github.com/rasahq/rasa/issues/8208): Faster reading of YAML NLU training data files. +- [#8335](https://github.com/rasahq/rasa/issues/8335): Added partition_by_sender flag to [Kafka Producer](event-brokers.mdx#kafka-event-broker) to optionally associate events with Kafka partition based on sender_id. + +### Bugfixes +- [#7260](https://github.com/rasahq/rasa/issues/7260): Fixed the 'loading model' message which was logged twice when using `rasa run`. +- [#7379](https://github.com/rasahq/rasa/issues/7379): Change training data validation to only count nlu training examples. +- [#7450](https://github.com/rasahq/rasa/issues/7450): Rule tracker states no longer include the initial value of slots. + Rules now only require slot values when explicitly stated in the rule. +- [#7640](https://github.com/rasahq/rasa/issues/7640): `rasa test`, `rasa test core` and `rasa test nlu` no longer show temporary paths + in case there are issues in the test files. +- [#7690](https://github.com/rasahq/rasa/issues/7690): Resolved memory problems with dense features and `CRFEntityExtractor` +- [#7916](https://github.com/rasahq/rasa/issues/7916): Handle empty intent and entity mapping in the `domain`. + + There is now an InvalidDomain exception raised if in the `domain.yml` file there are empty intent or entity mappings. + An example of empty intent and entity mappings is the following : + ```yaml-rasa + intents: + - greet: + - goodbye: + + entities: + - cuisine: + - number: + ``` +- [#8102](https://github.com/rasahq/rasa/issues/8102): Fixed a bug in a form where slot mapping doesn't work if the predicted intent name is substring for another intent name. +- [#8114](https://github.com/rasahq/rasa/issues/8114): Fixes bug where stories could not be retrieved if entities had no start or end. +- [#8178](https://github.com/rasahq/rasa/issues/8178): Catch ChannelNotFoundEntity exception coming from the pika broker and raise as ConnectionException. +- [#8337](https://github.com/rasahq/rasa/issues/8337): Fix bug with NoReturn throwing an exception in Python 3.7.0 when running `rasa train` +- [#8382](https://github.com/rasahq/rasa/issues/8382): Throw `RasaException` instead of `ValueError` in situations when environment variables + specified in YAML cannot be expanded. +- [#8343](https://github.com/rasahq/rasa/issues/8343): Updated python-engineio version for compatibility with python-socketio + +### Miscellaneous internal changes +- [#6511](https://github.com/rasahq/rasa/issues/6511), [#7640](https://github.com/rasahq/rasa/issues/7640), [#7827](https://github.com/rasahq/rasa/issues/7827), [#8056](https://github.com/rasahq/rasa/issues/8056), [#8117](https://github.com/rasahq/rasa/issues/8117), [#8141](https://github.com/rasahq/rasa/issues/8141), [#8240](https://github.com/rasahq/rasa/issues/8240) + + +## [2.4.3] - 2021-03-26 + + +### Bugfixes +- [#8114](https://github.com/rasahq/rasa/issues/8114): Fixes bug where stories could not be retrieved if entities had no start or end. + + +## [2.4.2] - 2021-03-25 + + +### Bugfixes +- [#7835](https://github.com/rasahq/rasa/issues/7835): Fix `UnicodeException` in `is_key_in_yaml`. +- [#8258](https://github.com/rasahq/rasa/issues/8258): Fixed the bug that events from previous conversation sessions would be re-saved in the [`SQLTrackerStore`](tracker-stores.mdx#sqltrackerstore) or [`MongoTrackerStore`](tracker-stores.mdx#mongotrackerstore) when `retrieve_events_from_previous_conversation_sessions` was true. + + +## [2.4.1] - 2021-03-23 + + +### Bugfixes +- [#8194](https://github.com/rasahq/rasa/issues/8194): Fix `TEDPolicy` training e2e entities when no entities are present in the stories + but there are entities in the domain. +- [#8198](https://github.com/rasahq/rasa/issues/8198): Fixed missing model configuration file validation. +- [#8223](https://github.com/rasahq/rasa/issues/8223): In Rasa 2.4.0, support for using `template` in `utter_message` when handling a custom action was wrongly deprecated. Both `template` and `response` are now supported, though note that `template` will be deprecated at Rasa 3.0.0. + + +## [2.4.0] - 2021-03-11 + + +### Deprecations and Removals +- [#6484](https://github.com/rasahq/rasa/issues/6484): NLG Server + - Changed request format to send `response` as well as `template` as a field. The `template` field will be removed in Rasa Open Source 3.0.0. + + `rasa.core.agent` + - The terminology `template` is deprecated and replaced by `response`. Support for `template` from the NLG response will be removed in Rasa Open Source 3.0.0. Please see [here](nlg.mdx) for more details. + + `rasa.core.nlg.generator` + - `generate()` now takes in `utter_action` as a parameter. + - The terminology `template` is deprecated and replaced by `response`. Support for `template` in the `NaturalLanguageGenerator` will be removed in Rasa Open Source 3.0.0. + + `rasa.shared.core.domain` + - The property `templates` is deprecated. Use `responses` instead. It will be removed in Rasa Open Source 3.0.0. + - `retrieval_intent_templates` will be removed in Rasa Open Source 3.0.0. Please use `retrieval_intent_responses` instead. + - `is_retrieval_intent_template` will be removed in Rasa Open Source 3.0.0. Please use `is_retrieval_intent_response` instead. + - `check_missing_templates` will be removed in Rasa Open Source 3.0.0. Please use `check_missing_responses` instead. + + Response Selector + - The field `template_name` will be deprecated in Rasa Open Source 3.0.0. Please use `utter_action` instead. Please see [here](components.mdx#selectors) for more details. + - The field `response_templates` will be deprecated in Rasa Open Source 3.0.0. Please use `responses` instead. Please see [here](components.mdx#selectors) for more details. + +### Improvements +- [#7022](https://github.com/rasahq/rasa/issues/7022): The following endpoints now require the existence of the conversation for the specified conversation ID, raising an exception and returning a 404 status code. + + * `GET /conversations//story` + + * `POST /conversations//execute` + + * `POST /conversations//predict` +- [#7438](https://github.com/rasahq/rasa/issues/7438): Simplify our training by overwriting `train_step` instead of `fit` for our custom models. + + This allows us to use the build-in callbacks from Keras, such as the + [Tensorboard Callback](https://www.tensorflow.org/api_docs/python/tf/keras/callbacks/TensorBoard), + which offers more functionality compared to what we had before. + + :::warning + If you want to use Tensorboard for `DIETClassifier`, `ResponseSelector`, or `TEDPolicy` and log metrics after + every (mini)batch, please use 'batch' instead of 'minibatch' as 'tensorboard_log_level'. +- [#7578](https://github.com/rasahq/rasa/issues/7578): When `TED` is configured to extract entities `rasa test` now evaluates them against the labels in the test stories. Results are saved in `/results` along with the results for the NLU components that extract entities. +- [#7680](https://github.com/rasahq/rasa/issues/7680): We're now running integration tests for Rasa Open Source, with initial coverage for `SQLTrackerStore` (with PostgreSQL), + `RedisLockStore` (with Redis) and `PikaEventBroker` (with RabbitMQ). The integration tests are now part of our + CI, and can also be ran locally using `make test-integration` + (see [Rasa Open Source README](https://github.com/RasaHQ/rasa#running-the-integration-tests) for more information). +- [#7763](https://github.com/rasahq/rasa/issues/7763): Allow tests to be located anywhere, not just in `tests` directory. +- [#7893](https://github.com/rasahq/rasa/issues/7893): Model configuration files are now validated whether they match the expected schema. +- [#7952](https://github.com/rasahq/rasa/issues/7952): Speed up `YAMLStoryReader.is_key_in_yaml` function by making it to check if key is in YAML without + actually parsing the text file. +- [#7953](https://github.com/rasahq/rasa/issues/7953): Speed up YAML parsing by reusing parsers, making the process of environment variable interpolation optional, + and by not adding duplicating implicit resolvers and YAML constructors to `ruamel.yaml` +- [#7955](https://github.com/rasahq/rasa/issues/7955): Drastically improved finger printing time for large story graphs +- [#8000](https://github.com/rasahq/rasa/issues/8000): Remove console logging of conversation level F1-score and precision since these calculations were not meaningful. + + Add conversation level accuracy to core policy results logged to file in `story_report.json` after running `rasa test core` or `rasa test`. +- [#8100](https://github.com/rasahq/rasa/issues/8100): Improved the [lock store](lock-stores.mdx) debug log message when the process has to + queue because other messages have to be processed before this item. + +### Bugfixes +- [#4612](https://github.com/rasahq/rasa/issues/4612): Fixed the bug that OR statements in stories would break the check whether a model needs to be retrained +- [#7063](https://github.com/rasahq/rasa/issues/7063): Update the spec of `POST /model/test/intents` and add tests for cases when JSON is provided. + + Fix the incorrect temporary file extension for the data that gets extracted from the payload provided + in the body of `POST /model/test/intents` request. +- [#7113](https://github.com/rasahq/rasa/issues/7113): Fix for the cli command `rasa data convert config` when migrating Mapping Policy and no rules. + + Making `rasa data convert config` migrate correctly the Mapping Policy when no rules are available. It updates the `config.yml` file by removing the `MappingPolicy` and adding the `RulePolicy` instead. Also, it creates the `data/rules.yml` file even if empty in the case of no available rules. +- [#7470](https://github.com/rasahq/rasa/issues/7470): Allow to have slots with values that result to a dictionary under the key `slot_was_set` (in `stories.yml` file). + + An example would be to have the following story step in `stories.yml`: + ```yaml + - slot_was_set: + - some_slot: + some_key: 'some_value' + other_key: 'other_value' + ``` + This would be allowed if the `some_slot` is also set accordingly in the `domain.yml` with type `any`. +- [#7662](https://github.com/rasahq/rasa/issues/7662): Update the fingerprinting function to recognize changes in lookup files. +- [#7932](https://github.com/rasahq/rasa/issues/7932): Fixed a bug when interpolating environment variables in YAML files which included `$` in their value. + This led to the following stack trace: + + ``` + ValueError: Error when trying to expand the environment variables in '${PASSWORD}'. Please make sure to also set these environment variables: '['$qwerty']'. + (13 additional frame(s) were not displayed) + ... + File "rasa/utils/endpoints.py", line 26, in read_endpoint_config + content = rasa.shared.utils.io.read_config_file(filename) + File "rasa/shared/utils/io.py", line 527, in read_config_file + content = read_yaml_file(filename) + File "rasa/shared/utils/io.py", line 368, in read_yaml_file + return read_yaml(read_file(filename, DEFAULT_ENCODING)) + File "rasa/shared/utils/io.py", line 349, in read_yaml + return yaml_parser.load(content) or {} + File "rasa/shared/utils/io.py", line 314, in env_var_constructor + " variables: '{}'.".format(value, not_expanded) + ``` +- [#7949](https://github.com/rasahq/rasa/issues/7949): The REQUESTED_SLOT always belongs to the currently active form. + + Previously it was possible that after form switching, the REQUESTED_SLOT was for the previous form. +- [#96](https://github.com/rasahq/rasa/issues/96): Update the `LanguageModelFeaturizer` tests to reflect new default model weights for `bert`, and skip all `bert` tests + with default model weights on CI, run `bert` tests with `bert-base-uncased` on CI instead. + +### Improved Documentation +- [#8080](https://github.com/rasahq/rasa/issues/8080): Update links to Sanic docs in the documentation. +- [#8109](https://github.com/rasahq/rasa/issues/8109): Update Rasa Playground to correctly use `tracking_id` when calling API methods. + +### Miscellaneous internal changes +- [#6484](https://github.com/rasahq/rasa/issues/6484), [#7737](https://github.com/rasahq/rasa/issues/7737), [#7879](https://github.com/rasahq/rasa/issues/7879), [#8016](https://github.com/rasahq/rasa/issues/8016) + + +## [2.3.5] - 2021-06-16 + + +### Features +- [#8860](https://github.com/rasahq/rasa/issues/8860): Added `sasl_mechanism` as an optional configurable parameters for the [Kafka Producer](event-brokers.mdx#kafka-event-broker). + +### Improvements +- [#7955](https://github.com/rasahq/rasa/issues/7955): Drastically improved finger printing time for large story graphs +- [#8100](https://github.com/rasahq/rasa/issues/8100): Improved the [lock store](lock-stores.mdx) debug log message when the process has to + queue because other messages have to be processed before this item. + +### Bugfixes +- [#4612](https://github.com/rasahq/rasa/issues/4612): Fixed the bug that OR statements in stories would break the check whether a model needs to be retrained +- [#8649](https://github.com/rasahq/rasa/issues/8649): Updated `python-engineio` dependency version for compatibility with `python-socketio`. + +### Improved Documentation +- [#8080](https://github.com/rasahq/rasa/issues/8080): Update links to Sanic docs in the documentation. + + +## [2.3.4] - 2021-02-26 + + +### Bugfixes +- [#8014](https://github.com/rasahq/rasa/issues/8014): Setting `model_confidence=cosine` in `DIETClassifier`, `ResponseSelector` and `TEDPolicy` is deprecated and will no longer be available. This was introduced in Rasa Open Source version `2.3.0` but post-release experiments suggest that using cosine similarity as model's confidences can change the ranking of predicted labels which is wrong. + + `model_confidence=inner` is deprecated and is replaced by `model_confidence=linear_norm` as the former produced an unbounded range of confidences which broke the logic of assistants in various other places. + + We encourage you to try `model_confidence=linear_norm` which will produce a linearly normalized version of dot product similarities with each value in the range `[0,1]`. This can be done with the following config: + ```yaml + - name: DIETClassifier + model_confidence: linear_norm + constrain_similarities: True + ``` + This should ease up [tuning fallback thresholds](./fallback-handoff.mdx#fallbacks) as confidences for wrong predictions are better distributed across the range `[0, 1]`. + + If you trained a model with `model_confidence=cosine` or `model_confidence=inner` setting using previous versions of Rasa Open Source, please re-train by either removing the `model_confidence` option from the configuration or setting it to `linear_norm`. + + `model_confidence=cosine` is removed from the configuration generated by [auto-configuration](model-configuration.mdx#suggested-config). + + +## [2.3.3] - 2021-02-25 + + +### Bugfixes +- [#8001](https://github.com/rasahq/rasa/issues/8001): Fixed bug where the conversation does not lock before handling a reminder event. + + +## [2.3.2] - 2021-02-22 + + +### Bugfixes +- [#7972](https://github.com/rasahq/rasa/issues/7972): Fix a bug where, if a user injects an intent using the HTTP API, slot auto-filling is not performed on the entities provided. + + +## [2.3.1] - 2021-02-17 + + +### Bugfixes +- [#7970](https://github.com/rasahq/rasa/issues/7970): Fixed a YAML validation error which happened when executing multiple validations + concurrently. This could e.g. happen when sending concurrent requests to server + endpoints which process YAML training data. + + +## [2.3.0] - 2021-02-11 + + +### Improvements +- [#5673](https://github.com/rasahq/rasa/issues/5673): Expose diagnostic data for action and NLU predictions. + + Add `diagnostic_data` field to the [Message](./reference/rasa/shared/nlu/training_data/message.md#message-objects) + and [Prediction](./reference/rasa/core/policies/policy.md#policyprediction-objects) objects, which contain + information about attention weights and other intermediate results of the inference computation. + This information can be used for debugging and fine-tuning, e.g. with [RasaLit](https://github.com/RasaHQ/rasalit). + + For examples of how to access the diagnostic data, see [here](https://gist.github.com/JEM-Mosig/c6e15b81ee70561cb72e361aff310d7e). +- [#5986](https://github.com/rasahq/rasa/issues/5986): Using the `TrainingDataImporter` interface to load the data in `rasa test core`. + + Failed test stories are now referenced by their absolute path instead of the relative path. +- [#7292](https://github.com/rasahq/rasa/issues/7292): Improve error handling and Sentry tracking: + - Raise `MarkdownException` when training data in Markdown format cannot be read. + - Raise `InvalidEntityFormatException` error instead of `json.JSONDecodeError` when entity format is in valid + in training data. + - Gracefully handle empty sections in endpoint config files. + - Introduce `ConnectionException` error and raise it when `TrackerStore` and `EventBroker` + cannot connect to 3rd party services, instead of raising exceptions from 3rd party libraries. + - Improve `rasa.shared.utils.common.class_from_module_path` function by making sure it always returns a class. + The function currently raises a deprecation warning if it detects an anomaly. + - Ignore `MemoryError` and `asyncio.CancelledError` in Sentry. + - `rasa.shared.utils.validation.validate_training_data` now raises a `SchemaValidationError` when validation fails + (this error inherits `jsonschema.ValidationError`, ensuring backwards compatibility). +- [#7303](https://github.com/rasahq/rasa/issues/7303): Allow `PolicyEnsemble` in cases where calling individual policy's `load` method returns `None`. +- [#7420](https://github.com/rasahq/rasa/issues/7420): User message metadata can now be accessed via the default slot + `session_started_metadata` during the execution of a + [custom `action_session_start`](default-actions.mdx#customization). + + ```python + from typing import Any, Text, Dict, List + from rasa_sdk import Action, Tracker + from rasa_sdk.events import SlotSet, SessionStarted, ActionExecuted, EventType + + class ActionSessionStart(Action): + def name(self) -> Text: + return "action_session_start" + + async def run( + self, dispatcher, tracker: Tracker, domain: Dict[Text, Any] + ) -> List[Dict[Text, Any]]: + metadata = tracker.get_slot("session_started_metadata") + + # Do something with the metadata + print(metadata) + + # the session should begin with a `session_started` event and an `action_listen` + # as a user message follows + return [SessionStarted(), ActionExecuted("action_listen")] + ``` +- [#7579](https://github.com/rasahq/rasa/issues/7579): Add BILOU tagging schema for entity extraction in end-to-end TEDPolicy. +- [#7616](https://github.com/rasahq/rasa/issues/7616): Added two new parameters `constrain_similarities` and `model_confidence` to machine learning (ML) components - [DIETClassifier](components.mdx#dietclassifier), [ResponseSelector](components.mdx#dietclassifier) and [TEDPolicy](policies.mdx#ted-policy). + + Setting `constrain_similarities=True` adds a sigmoid cross-entropy loss on all similarity values to restrict them to an approximate range in `DotProductLoss`. This should help the models to perform better on real world test sets. + By default, the parameter is set to `False` to preserve the old behaviour, but users are encouraged to set it to `True` and re-train their assistants as it will be set to `True` by default from Rasa Open Source 3.0.0 onwards. + + Parameter `model_confidence` affects how model's confidence for each label is computed during inference. It can take three values: + 1. `softmax` - Similarities between input and label embeddings are post-processed with a softmax function, as a result of which confidence for all labels sum up to 1. + 2. `cosine` - Cosine similarity between input label embeddings. Confidence for each label will be in the range `[-1,1]`. + 3. `inner` - Dot product similarity between input and label embeddings. Confidence for each label will be in an unbounded range. + + Setting `model_confidence=cosine` should help users tune the fallback thresholds of their assistant better. The default value is `softmax` to preserve the old behaviour, but we recommend using `cosine` as that will be the new default value from Rasa Open Source 3.0.0 onwards. The value of this option does not affect how confidences are computed for entity predictions in `DIETClassifier` and `TEDPolicy`. + + With both the above recommendations, users should configure their ML component, e.g. `DIETClassifier`, as + ```yaml + - name: DIETClassifier + model_confidence: cosine + constrain_similarities: True + ... + ``` + Once the assistant is re-trained with the above configuration, users should also tune fallback confidence thresholds. + + Configuration option `loss_type=softmax` is now deprecated and will be removed in Rasa Open Source 3.0.0 . Use `loss_type=cross_entropy` instead. + + The default [auto-configuration](model-configuration.mdx#suggested-config) is changed to use `constrain_similarities=True` and `model_confidence=cosine` in ML components so that new users start with the recommended configuration. + + **EDIT**: Some post-release experiments revealed that using `model_confidence=cosine` is wrong as it can change the order of predicted labels. That's why this option was removed in Rasa Open Source version `2.3.3`. `model_confidence=inner` is deprecated as it produces an unbounded range of confidences which can break the logic of assistants in various other places. Please use `model_confidence=linear_norm` which will produce a linearly normalized version of dot product similarities with each value in the range `[0,1]`. Please read more about this change under the notes for release `2.3.4`. + +- [#7817](https://github.com/rasahq/rasa/issues/7817): Use simple random uniform distribution of integers in negative sampling, because + negative sampling with `tf.while_loop` and random shuffle inside creates a memory leak. +- [#7848](https://github.com/rasahq/rasa/issues/7848): Added support to configure `exchange_name` for [pika event broker](event-brokers.mdx#pika-event-broker). +- [#7867](https://github.com/rasahq/rasa/issues/7867): If `MaxHistoryTrackerFeaturizer` is used, invert the dialogue sequence before passing + it to the transformer so that the last dialogue input becomes the first one and + therefore always have the same positional encoding. + +### Bugfixes +- [#7420](https://github.com/rasahq/rasa/issues/7420): Fixed an error when using the endpoint `GET /conversations//story` + with a tracker which contained slots. +- [#7707](https://github.com/rasahq/rasa/issues/7707): Add the option to configure whether extracted entities should be split by comma (`","`) or not to TEDPolicy. Fixes + crash when this parameter is accessed during extraction. +- [#7710](https://github.com/rasahq/rasa/issues/7710): When switching forms, the next form will always correctly ask for the first required slot. + + Before, the next form did not ask for the slot if it was the same slot as the requested slot of the previous form. +- [#7749](https://github.com/rasahq/rasa/issues/7749): Fix the bug when `RulePolicy` handling loop predictions are overwritten by e2e `TEDPolicy`. +- [#7751](https://github.com/rasahq/rasa/issues/7751): When switching forms, the next form is cleanly activated. + + Before, the next form was correctly activated, but the previous form had wrongly uttered + the response that asked for the requested slot when slot validation for that slot + had failed. +- [#7829](https://github.com/rasahq/rasa/issues/7829): Fix a bug in incremental training when passing a specific model path with the `--finetune` argument. +- [#7867](https://github.com/rasahq/rasa/issues/7867): Fix the role of `unidirectional_encoder` in TED. This parameter is only applied to + transformers for `text`, `action_text` and `label_action_text`. + +### Miscellaneous internal changes +- [#7420](https://github.com/rasahq/rasa/issues/7420), [#7515](https://github.com/rasahq/rasa/issues/7515), [#7574](https://github.com/rasahq/rasa/issues/7574), [#7601](https://github.com/rasahq/rasa/issues/7601) + + +## [2.2.10] - 2021-02-08 + + +### Improvements +- [#7069](https://github.com/rasahq/rasa/issues/7069): Updated error message when using incompatible model versions. + +### Bugfixes +- [#7885](https://github.com/rasahq/rasa/issues/7885): Limit `numpy` version to `< 1.2` as `tensorflow` is not compatible with `numpy` + versions `>= 1.2`. `pip` versions `<= 20.2` don't resolve dependencies conflicts + correctly which could result in an incompatible `numpy` version and the following + error: + + ```bash + NotImplementedError: Cannot convert a symbolic Tensor (strided_slice_6:0) to a numpy array. This error may indicate that you're trying to pass a Tensor to a NumPy call, which is not supported + ``` + + +## [2.2.9] - 2021-02-02 + + +### Bugfixes +- [#7861](https://github.com/rasahq/rasa/issues/7861): Correctly include the `confused_with` field in the test report for the + [`POST /model/test/intents`](/pages/http-api#operation/testModelIntent) endpoint. + + +## [2.2.8] - 2021-01-28 + + +### Bugfixes +- [#7764](https://github.com/rasahq/rasa/issues/7764): Fixes a bug in [forms](forms.mdx) where the next slot asked was not consistent after returning to a form from an unhappy path. + + +## [2.2.7] - 2021-01-25 + + +### Improvements +- [#7731](https://github.com/rasahq/rasa/issues/7731): Add support for in `RasaYAMLWriter` for writing intent and example metadata back + into NLU YAML files. + +### Bugfixes +- [#4311](https://github.com/rasahq/rasa/issues/4311): Fixed a bug with `Domain.is_domain_file()` that could raise an Exception in case the potential domain file is not a valid YAML. + + +## [2.2.6] - 2021-01-21 + + +### Bugfixes +- [#7717](https://github.com/rasahq/rasa/issues/7717): Fix wrong warning `The method 'EventBroker.close' was changed to be asynchronous` when + the `EventBroker.close` was actually asynchronous. +- [#7720](https://github.com/rasahq/rasa/issues/7720): Fix incremental training for cases when training data does not contain entities but `DIETClassifier` is configured to perform entity recognition also. + + Now, the instance of `RasaModelData` inside `DIETClassifier` does not contain `entities` as a feature for training if there is no training data present for entity recognition. + + +## [2.2.5] - 2021-01-12 + + +### Bugfixes +- [#7603](https://github.com/rasahq/rasa/issues/7603): Fixed key-error bug on `rasa data validate stories`. + +### Miscellaneous internal changes +- [#7711](https://github.com/rasahq/rasa/issues/7711) + + +## [2.2.4] - 2021-01-08 + + +### Improvements +- [#7520](https://github.com/rasahq/rasa/issues/7520): Improve the warning in case the [RulePolicy](policies.mdx#rule-policy) or the deprecated + [MappingPolicy](https://rasa.com/docs/rasa/2.x/policies#mapping-policy) are missing + from the model's `policies` configuration. Changed the info log to a warning as one + of this policies should be added to the model configuration. + +### Bugfixes +- [#7692](https://github.com/rasahq/rasa/issues/7692): Explicitly specify the `crypto` extra dependency of `pyjwt` to ensure that the + `cryptography` dependency is installed. `cryptography` is strictly required to be able + to be able to verify JWT tokens. + + +## [2.2.3] - 2021-01-06 + + +### Bugfixes +- [#7622](https://github.com/rasahq/rasa/issues/7622): Correctly retrieve intent ranking from `UserUttered` even during default affirmation + action implementation. +- [#7684](https://github.com/rasahq/rasa/issues/7684): Fixed a problem when using the `POST /model/test/intents` endpoint together with a + [model server](model-storage.mdx#load-model-from-server). The error looked as follows: + + ``` + ERROR rasa.core.agent:agent.py:327 Could not load model due to Detected inconsistent loop usage. Trying to schedule a task on a new event loop, but scheduler was created with a different event loop. Make sure there is only one event loop in use and that the scheduler is running on that one. + ``` + + This also fixes a problem where testing a model from a model server would change the + production model. + + +## [2.2.2] - 2020-12-21 + + +### Bugfixes +- [#7592](https://github.com/rasahq/rasa/issues/7592): Fixed incompatibility between Rasa Open Source 2.2.x and Rasa X < 0.35. + + +## [2.2.1] - 2020-12-17 + + +### Bugfixes +- [#7557](https://github.com/rasahq/rasa/issues/7557): Fixed a problem where a [form](forms.mdx) wouldn't reject when the + `FormValidationAction` re-implemented `required_slots`. +- [#7585](https://github.com/rasahq/rasa/issues/7585): Fixed an error when using the [SQLTrackerStore](tracker-stores.mdx#sqltrackerstore) + with a Postgres database and the parameter `login_db` specified. + + The error was: + + ```bash + psycopg2.errors.SyntaxError: syntax error at end of input + rasa-production_1 | LINE 1: SELECT 1 FROM pg_catalog.pg_database WHERE datname = ? + ``` + + +## [2.2.0] - 2020-12-16 + + +### Deprecations and Removals +- [#6410](https://github.com/rasahq/rasa/issues/6410): `Domain.random_template_for` is deprecated and will be removed in Rasa Open Source + 3.0.0. You can alternatively use the `TemplatedNaturalLanguageGenerator`. + + `Domain.action_names` is deprecated and will be removed in Rasa Open Source + 3.0.0. Please use `Domain.action_names_or_texts` instead. +- [#7458](https://github.com/rasahq/rasa/issues/7458): Interfaces for `Policy.__init__` and `Policy.load` have changed. + See [migration guide](./migration-guide.mdx#rasa-21-to-rasa-22) for details. +- [#7495](https://github.com/rasahq/rasa/issues/7495): Deprecate training and test data in Markdown format. This includes: + - reading and writing of story files in Markdown format + - reading and writing of NLU data in Markdown format + - reading and writing of retrieval intent data in Markdown format + + Support for Markdown data will be removed entirely in Rasa Open Source 3.0.0. + + Please convert your existing Markdown data by using the commands + from the [migration guide](./migration-guide.mdx#rasa-21-to-rasa-22): + + ```bash + rasa data convert nlu -f yaml --data={SOURCE_DIR} --out={TARGET_DIR} + rasa data convert nlg -f yaml --data={SOURCE_DIR} --out={TARGET_DIR} + rasa data convert core -f yaml --data={SOURCE_DIR} --out={TARGET_DIR} + ``` +- [#7529](https://github.com/rasahq/rasa/issues/7529): `Domain.add_categorical_slot_default_value`, `Domain.add_requested_slot` + and `Domain.add_knowledge_base_slots` are deprecated and will be removed in Rasa Open + Source 3.0.0. Their internal versions are now called during the Domain creation. + Calling them manually is no longer required. + +### Features +- [#6971](https://github.com/rasahq/rasa/issues/6971): Incremental training of models in a pipeline is now supported. + + If you have added new NLU training examples or new stories/rules for + dialogue manager, you don't need to train the pipeline from scratch. + Instead, you can initialize the pipeline with a previously trained model + and continue finetuning the model on the complete dataset consisting of + new training examples. To do so, use `rasa train --finetune`. For more + detailed explanation of the command, check out the docs on [incremental + training](./command-line-interface.mdx#incremental-training). + + Added a configuration parameter `additional_vocabulary_size` to + [`CountVectorsFeaturizer`](./components.mdx#countvectorsfeaturizer) + and `number_additional_patterns` to [`RegexFeaturizer`](./components.mdx#regexfeaturizer). + These parameters are useful to configure when using incremental training for your pipelines. +- [#7408](https://github.com/rasahq/rasa/issues/7408): Add the option to use cross-validation to the + [`POST /model/test/intents`](/pages/http-api#operation/testModelIntent) endpoint. + To use cross-validation specify the query parameter `cross_validation_folds` in addition + to the training data in YAML format. + + Add option to run NLU evaluation + ([`POST /model/test/intents`](/pages/http-api#operation/testModelIntent)) and + model training ([`POST /model/train`](/pages/http-api#operation/trainModel)) + asynchronously. + To trigger asynchronous processing specify + a callback URL in the query parameter `callback_url` which Rasa Open Source should send + the results to. This URL will also be called in case of errors. +- [#7496](https://github.com/rasahq/rasa/issues/7496): Make [TED Policy](./policies.mdx#ted-policy) an end-to-end policy. Namely, make it possible to train TED on stories that contain + intent and entities or user text and bot actions or bot text. + If you don't have text in your stories, TED will behave the same way as before. + Add possibility to predict entities using TED. + + Here's an example of a dialogue in the Rasa story format: + + ```rasa-yaml + stories: + - story: collect restaurant booking info # name of the story - just for debugging + steps: + - intent: greet # user message with no entities + - action: utter_ask_howcanhelp # action that the bot should execute + - intent: inform # user message with entities + entities: + - location: "rome" + - price: "cheap" + - bot: On it # actual text that bot can output + - action: utter_ask_cuisine + - user: I would like [spanish](cuisine). # actual text that user input + - action: utter_ask_num_people + ``` + + Some model options for `TEDPolicy` got renamed. + Please update your configuration files using the following mapping: + + | Old model option | New model option | + |-----------------------------|--------------------------------------------------------| + |transformer_size |dictionary “transformer_size” with keys | + | |“text”, “action_text”, “label_action_text”, “dialogue” | + |number_of_transformer_layers |dictionary “number_of_transformer_layers” with keys | + | |“text”, “action_text”, “label_action_text”, “dialogue” | + |dense_dimension |dictionary “dense_dimension” with keys | + | |“text”, “action_text”, “label_action_text”, “intent”, | + | |“action_name”, “label_action_name”, “entities”, “slots”,| + | |“active_loop” | + +### Improvements +- [#3998](https://github.com/rasahq/rasa/issues/3998): Added a message showing the location where the failed stories file was saved. +- [#7232](https://github.com/rasahq/rasa/issues/7232): Add support for the top-level response keys `quick_replies`, `attachment` and `elements` refered to in `rasa.core.channels.OutputChannel.send_reponse`, as well as `metadata`. +- [#7257](https://github.com/rasahq/rasa/issues/7257): Changed the format of the histogram of confidence values for both correct and incorrect predictions produced by running `rasa test`. +- [#7284](https://github.com/rasahq/rasa/issues/7284): Run [`bandit`](https://bandit.readthedocs.io/en/latest/) checks on pull requests. + Introduce `make static-checks` command to run all static checks locally. +- [#7397](https://github.com/rasahq/rasa/issues/7397): Add `rasa train --dry-run` command that allows to check if training needs to be performed + and what exactly needs to be retrained. +- [#7408](https://github.com/rasahq/rasa/issues/7408): [`POST /model/test/intents`](/pages/http-api#operation/testModelIntent) now returns + the `report` field for `intent_evaluation`, `entity_evaluation` and + `response_selection_evaluation` as machine-readable JSON payload instead of string. +- [#7436](https://github.com/rasahq/rasa/issues/7436): Make `rasa data validate stories` work for end-to-end. + + The `rasa data validate stories` function now considers the tokenized user text instead of the plain text that is part of a state. + This is closer to what Rasa Core actually uses to distinguish states and thus captures more story structure problems. + +### Bugfixes +- [#6804](https://github.com/rasahq/rasa/issues/6804): Rename `language_list` to `supported_language_list` for `JiebaTokenizer`. +- [#7244](https://github.com/rasahq/rasa/issues/7244): A `float` slot returns unambiguous values - `[1.0, ]` if successfully converted, `[0.0, 0.0]` if not. + This makes it possible to distinguish an empty float slot from a slot set to `0.0`. + :::caution + This change is model-breaking. Please retrain your models. + ::: +- [#7306](https://github.com/rasahq/rasa/issues/7306): Fix an erroneous attribute for Redis key prefix in `rasa.core.tracker_store.RedisTrackerStore`: 'RedisTrackerStore' object has no attribute 'prefix'. +- [#7407](https://github.com/rasahq/rasa/issues/7407): Remove token when its text (for example, whitespace) can't be tokenized by LM tokenizer (from `LanguageModelFeaturizer`). +- [#7408](https://github.com/rasahq/rasa/issues/7408): Temporary directories which were created during requests to the [HTTP API](http-api.mdx) + are now cleaned up correctly once the request was processed. +- [#7422](https://github.com/rasahq/rasa/issues/7422): Add option `use_word_boundaries` for `RegexFeaturizer` and `RegexEntityExtractor`. To correctly process languages such as Chinese that don't use whitespace for word separation, the user needs to add the `use_word_boundaries: False` option to those two components. +- [#7529](https://github.com/rasahq/rasa/issues/7529): Correctly fingerprint the default domain slots. Previously this led to the issue + that `rasa train core` would always retrain the model even if the training data hasn't + changed. + +### Improved Documentation +- [#7313](https://github.com/rasahq/rasa/issues/7313): Return the "Migrate from" entry to the docs sidebar. + +### Miscellaneous internal changes +- [#7167](https://github.com/rasahq/rasa/issues/7167) + + +## [2.1.3] - 2020-12-04 + + +### Improvements +- [#7426](https://github.com/rasahq/rasa/issues/7426): Removed `multidict` from the project dependencies. `multidict` continues to be a second + order dependency of Rasa Open Source but will be determined by the dependencies which + use it instead of by Rasa Open Source directly. + + This resolves issues like the following: + + ```bash + sanic 20.9.1 has requirement multidict==5.0.0, but you'll have multidict 4.6.0 which is incompatible. + ``` + +### Bugfixes +- [#7316](https://github.com/rasahq/rasa/issues/7316): `SingleStateFeaturizer` checks whether it was trained with `RegexInterpreter` as + nlu interpreter. If that is the case, `RegexInterpreter` is used during prediction. +- [#7390](https://github.com/rasahq/rasa/issues/7390): Make sure the `responses` are synced between NLU training data and the Domain even if there're no retrieval intents in the NLU training data. +- [#7417](https://github.com/rasahq/rasa/issues/7417): Categorical slots will have a default value set when just updating nlg data in the domain. + + Previously this resulted in `InvalidDomain` being thrown. +- [#7418](https://github.com/rasahq/rasa/issues/7418): - Preserve `domain` slot ordering while dumping it back to the file. + - Preserve multiline `text` examples of `responses` defined in `domain` and `NLU` training data. + + +## [2.1.2] - 2020-11-27 + + +### Bugfixes +- [#7235](https://github.com/rasahq/rasa/issues/7235): Slots that use `initial_value` won't cause rule contradiction errors when `conversation_start: true` is used. Previously, two rules that differed only in their use of `conversation_start` would be flagged as contradicting when a slot used `initial_value`. + + In checking for incomplete rules, an action will be required to have set _only_ those slots that the same action has set in another rule. Previously, an action was expected to have set also slots which, despite being present after this action in another rule, were not actually set by this action. +- [#7345](https://github.com/rasahq/rasa/issues/7345): Fixed Rasa Open Source not being able to fetch models from certain URLs. + + +## [2.1.1] - 2020-11-23 + + +### Bugfixes +- [#7338](https://github.com/rasahq/rasa/issues/7338): Sender ID is correctly set when copying the tracker and sending it to the action server (instead of sending the `default` value). This fixes a problem where the action server would only retrieve trackers with a `sender_id` `default`. + + +## [2.1.0] - 2020-11-17 + + +### Deprecations and Removals +- [#7136](https://github.com/rasahq/rasa/issues/7136): The [`Policy`](policies.mdx) interface was changed to return a `PolicyPrediction` object when + `predict_action_probabilities` is called. Returning a list of probabilities directly + is deprecated and support for this will be removed in Rasa Open Source 3.0. + + You can adapt your custom policy by wrapping your probabilities in a `PolicyPrediction` + object: + + ```python + from rasa.core.policies.policy import Policy, PolicyPrediction + # ... other imports + + def predict_action_probabilities( + self, + tracker: DialogueStateTracker, + domain: Domain, + interpreter: NaturalLanguageInterpreter, + **kwargs: Any, + ) -> PolicyPrediction: + probabilities = ... # an action prediction of your policy + return PolicyPrediction(probabilities, "policy_name", policy_priority=self.priority) + ``` + + The same change was applied to the `PolicyEnsemble` interface. Instead of returning + a tuple of action probabilities and policy name, it is now returning a + `PolicyPrediction` object. Support for the old `PolicyEnsemble` interface will be + removed in Rasa Open Source 3.0. + + :::caution + This change is model-breaking. Please retrain your models. + + ::: +- [#7263](https://github.com/rasahq/rasa/issues/7263): The [Pika Event Broker](event-brokers.mdx#pika-event-broker) no longer supports + the environment variables `RABBITMQ_SSL_CA_FILE` and `RABBITMQ_SSL_KEY_PASSWORD`. + You can alternatively specify `RABBITMQ_SSL_CA_FILE` in the RabbitMQ connection URL as + described in the + [RabbitMQ documentation](https://www.rabbitmq.com/uri-query-parameters.html). + + ```yaml-rasa title="endpoints.yml + event_broker: + type: pika + url: "amqps://user:password@host?cacertfile=path_to_ca_cert&password=private_key_password" + queues: + - my_queue + + ``` + + Support for `RABBITMQ_SSL_KEY_PASSWORD` was removed entirely. + + The method [`Event Broker.close`](event-brokers.mdx) was changed to be asynchronous. + Support for synchronous implementations will be removed in Rasa Open Source 3.0.0. + To adapt your implementation add the `async` keyword: + + ```python + from rasa.core.brokers.broker import EventBroker + + class MyEventBroker(EventBroker): + + async def close(self) -> None: + # clean up event broker resources + ``` + +### Features +- [#7136](https://github.com/rasahq/rasa/issues/7136): [Policies](policies.mdx) can now return obligatory and optional events as part of their + prediction. Obligatory events are always applied to the current conversation tracker. + Optional events are only applied to the conversation tracker in case the policy wins. + +### Improvements +- [#4341](https://github.com/rasahq/rasa/issues/4341): Changed `Agent.load` method to support `pathlib` paths. +- [#5715](https://github.com/rasahq/rasa/issues/5715): If you are using the feature [Entity Roles and Groups](nlu-training-data.mdx#entities-roles-and-groups), you should now also list the roles and groups + in your domain file if you want roles and groups to influence your conversations. For example: + ```yaml-rasa + entities: + - city: + roles: + - from + - to + - name + - topping: + groups: + - 1 + - 2 + - size: + groups: + - 1 + - 2 + ``` + + Entity roles and groups can now influence dialogue predictions. For more information see the section + [Entity Roles and Groups influencing dialogue predictions](nlu-training-data.mdx#entity-roles-and-groups-influencing-dialogue-predictions). +- [#6285](https://github.com/rasahq/rasa/issues/6285): Predictions of the [`FallbackClassifier`](components.mdx#fallbackclassifier) are + ignored when + [evaluating the NLU model](testing-your-assistant.mdx#evaluating-an-nlu-model) + Note that the `FallbackClassifier` predictions still apply to + [test stories](testing-your-assistant.mdx#writing-test-stories). +- [#6474](https://github.com/rasahq/rasa/issues/6474): Adapt the training data reader and emulator for wit.ai to their latest format. + Update the instructions in the + [migrate from wit.ai documentation](migrate-from/facebook-wit-ai-to-rasa.mdx) + to run Rasa Open Source in wit.ai emulation mode. +- [#6498](https://github.com/rasahq/rasa/issues/6498): Adding configurable prefixes to Redis [Tracker](./tracker-stores.mdx) and [Lock Stores](./lock-stores.mdx) so that a single Redis instance (and logical DB) can support multiple conversation trackers and locks. + By default, conversations will be prefixed with `tracker:...` and all locks prefixed with `lock:...`. Additionally, you can add an alphanumeric-only `prefix: value` in `endpoints.yml` such that keys in redis will take the form `value:tracker:...` and `value:lock:...` respectively. +- [#6571](https://github.com/rasahq/rasa/issues/6571): Log the model's relative path when using CLI commands. +- [#6852](https://github.com/rasahq/rasa/issues/6852): Adds the option to configure whether extracted entities should be split by comma (`","`) or not. The default behaviour is `True` - i.e. split any list of extracted entities by comma. This makes sense for a list of ingredients in a recipie, for example `"avocado, tofu, cauliflower"`, however doesn't make sense for an address such as `"Schönhauser Allee 175, 10119 Berlin, Germany"`. + + In the latter case, add a new option to your config, e.g. if you are using the `DIETClassifier` this becomes: + + ```yaml + ... + - name: DIETClassifier + split_entities_by_comma: False + ... + ``` + + in which case, none of the extracted entities will be split by comma. To switch it on/off for specific entity types you can use: + + ```yaml + ... + - name: DIETClassifier + split_entities_by_comma: + address: True + ingredient: False + ... + ``` + + where both `address` and `ingredient` are two entity types. + + This feature is also available for `CRFEntityExtractor`. +- [#6860](https://github.com/rasahq/rasa/issues/6860): Fetching test stories from the HTTP API endpoint + `GET /conversations//story` no longer triggers an update + of the + [conversation session](./domain.mdx#session-configuration). + + Added a new boolean query parameter `all_sessions` (default: `false`) to the + [HTTP API](./http-api.mdx) endpoint for fetching test stories + (`GET /conversations//story`). + + When setting `?all_sessions=true`, the endpoint returns test stories for all + conversation sessions for `conversation_id`. + When setting `?all_sessions=all_sessions`, or when omitting the `all_sessions` + parameter, a single test story is returned for `conversation_id`. In cases where + multiple conversation sessions exist, only the last story is returned. + + Specifying the `retrieve_events_from_previous_conversation_sessions` + kwarg for the [Tracker Store](./tracker-stores.mdx) class is deprecated and will be + removed in Rasa Open Source 3.0. Please use the `retrieve_full_tracker()` method + instead. +- [#6865](https://github.com/rasahq/rasa/issues/6865): Improve the `rasa data convert nlg` command and introduce the `rasa data convert responses` command + to simplify the migration from pre-2.0 response selector format to the new format. +- [#6966](https://github.com/rasahq/rasa/issues/6966): Added warning for when an option is provided for a [component](components.mdx) that is not listed as a key in the defaults for that component. +- [#6977](https://github.com/rasahq/rasa/issues/6977): [Forms](forms.mdx) no longer reject their execution before a potential custom + action for validating / extracting slots was executed. + Forms continue to reject in two cases automatically: + - A slot was requested to be filled, but no slot mapping applied to the latest user + message and there was no custom action for potentially extracting other slots. + - A slot was requested to be filled, but the custom action for validating / extracting + slots didn't return any slot event. + + Additionally you can also reject the form execution manually by returning a + `ActionExecutionRejected` event within your custom action for validating / extracting + slots. +- [#7027](https://github.com/rasahq/rasa/issues/7027): Remove dependency between `ConveRTTokenizer` and `ConveRTFeaturizer`. The `ConveRTTokenizer` is now deprecated, and the + `ConveRTFeaturizer` can be used with any other `Tokenizer`. + + Remove dependency between `HFTransformersNLP`, `LanguageModelTokenizer`, and `LanguageModelFeaturizer`. Both + `HFTransformersNLP` and `LanguageModelTokenizer` are now deprecated. `LanguageModelFeaturizer` implements the behavior + of the stack and can be used with any other `Tokenizer`. +- [#7061](https://github.com/rasahq/rasa/issues/7061): Gray out "Download" button in Rasa Playground when the project is not yet ready to be downloaded. +- [#7068](https://github.com/rasahq/rasa/issues/7068): Slot mappings for [Forms](forms.mdx) in the domain are now optional. If you do not + provide any slot mappings as part of the domain, you need to provide + [custom slot mappings](forms.mdx#custom-slot-mappings) through a custom action. + A form without slot mappings is specified as follows: + + ```rasa-yaml + forms: + my_form: + # no mappings + ``` + + The action for [forms](forms.mdx) can now be overridden by defining a custom action + with the same name as the form. This can be used to keep using the deprecated + Rasa Open Source `FormAction` which is implemented within the Rasa SDK. Note that it is + **not** recommended to override the form action for anything else than using the + deprecated Rasa SDK `FormAction`. +- [#7102](https://github.com/rasahq/rasa/issues/7102): Changed the default model weights loaded for `HFTransformersNLP` component. + + Use a [language agnostic sentence embedding model](https://tfhub.dev/google/LaBSE/1) + as the default model. These model weights should help improve performance on + intent classification and response selection. +- [#7122](https://github.com/rasahq/rasa/issues/7122): Add validations for [slot mappings](forms.mdx#slot-mappings). + If a slot mapping is not valid, an `InvalidDomain` error is raised. +- [#7132](https://github.com/rasahq/rasa/issues/7132): Adapt the training data reader and emulator for LUIS to + [their latest format](https://westcentralus.dev.cognitive.microsoft.com/docs/services/luis-endpoint-api-v3-0/) + and add support for roles. + Update the instructions in the + ["Migrate from LUIS" documentation page](migrate-from/microsoft-luis-to-rasa.mdx) + to reflect the recent changes made to the UI of LUIS. +- [#7160](https://github.com/rasahq/rasa/issues/7160): Adapt the [training data reader and emulator for DialogFlow](migrate-from/google-dialogflow-to-rasa.mdx) to + [their latest format](https://cloud.google.com/dialogflow/es/docs/reference/rest/v2/DetectIntentResponse) + and add support for regex entities. +- [#7263](https://github.com/rasahq/rasa/issues/7263): The [Pika Event Broker](event-brokers.mdx#pika-event-broker) was reimplemented with + the `[aio-pika` library[(https://aio-pika.readthedocs.io/en/latest/). Messages will + now be published to RabbitMQ asynchronously which improves the prediction performance. +- [#7278](https://github.com/rasahq/rasa/issues/7278): The confidence of the [`FallbackClassifier`](components.mdx#fallbackclassifier) + predictions is set to `1 - top intent confidence`. + +### Bugfixes +- [#5974](https://github.com/rasahq/rasa/issues/5974): `ActionRestart` will now trigger `ActionSessionStart` as a followup action. +- [#6582](https://github.com/rasahq/rasa/issues/6582): Fixed a bug with `rasa data split nlu` which caused the resulting train / test ratio to sometimes differ from the ratio specified by the user or by default. + + The splitting algorithm ensures that every intent and response class appears in both the training and the test set. This means that each split must contain at least as many examples as there are classes, which for small datasets can contradict the requested training fraction. When this happens, the command issues a warning to the user that the requested training fraction can't be satisfied. +- [#6721](https://github.com/rasahq/rasa/issues/6721): Fixed bug where slots with `influence_conversation=false` affected the action + prediction if they were set manually using the + `POST /conversations/ + ``` +- [#7108](https://github.com/rasahq/rasa/issues/7108): Update example formbot to use `FormValidationAction` for slot validation + + +## [2.0.2] - 2020-10-22 + + +### Bugfixes +- [#6691](https://github.com/rasahq/rasa/issues/6691): Fix description of previous event in output of `rasa data validate stories` +- [#7053](https://github.com/rasahq/rasa/issues/7053): Fixed command line coloring for windows command lines running an encoding other than `utf-8`. + +### Miscellaneous internal changes +- [#7057](https://github.com/rasahq/rasa/issues/7057) + + +## [2.0.1] - 2020-10-20 + + +### Bugfixes +- [#7018](https://github.com/rasahq/rasa/issues/7018): Create correct `KafkaProducer` for `PLAINTEXT` and `SASL_SSL` security protocols. +- [#7033](https://github.com/rasahq/rasa/issues/7033): - Fix `YAMLStoryReader` not being able to represent [`OR` statements](stories.mdx#or-statements) in conversion mode. + - Fix `MarkdownStoryWriter` not being able to write stories with `OR` statements (when loaded in conversion mode). + + +## [2.0.0] - 2020-10-07 + + +### Deprecations and Removals +- [#5757](https://github.com/rasahq/rasa/issues/5757): Removed previously deprecated packages `rasa_nlu` and `rasa_core`. + + Use imports from `rasa.core` and `rasa.nlu` instead. +- [#5758](https://github.com/rasahq/rasa/issues/5758): Removed previously deprecated classes: + - event brokers (`EventChannel` and `FileProducer`, `KafkaProducer`, + `PikaProducer`, `SQLProducer`) + - intent classifier `EmbeddingIntentClassifier` + - policy `KerasPolicy` + + Removed previously deprecated methods: + - `Agent.handle_channels` + - `TrackerStore.create_tracker_store` + + Removed support for pipeline templates in `config.yml` + + Removed deprecated training data keys `entity_examples` and `intent_examples` from + json training data format. +- [#5834](https://github.com/rasahq/rasa/issues/5834): Removed `restaurantbot` example as it was confusing and not a great way to build a bot. +- [#6296](https://github.com/rasahq/rasa/issues/6296): `LabelTokenizerSingleStateFeaturizer` is deprecated. To replicate `LabelTokenizerSingleStateFeaturizer` functionality, + add a `Tokenizer` with `intent_tokenization_flag: True` and `CountVectorsFeaturizer` to the NLU pipeline. + An example of elements to be added to the pipeline is shown in the improvement changelog 6296`. + + `BinarySingleStateFeaturizer` is deprecated and will be removed in the future. We recommend to switch to `SingleStateFeaturizer`. +- [#6354](https://github.com/rasahq/rasa/issues/6354): Specifying the parameters `force` and `save_to_default_model_directory` as part of the + JSON payload when training a model using `POST /model/train` is now deprecated. + Please use the query parameters `force_training` and `save_to_default_model_directory` + instead. See the [API documentation](/pages/http-api) for more information. +- [#6409](https://github.com/rasahq/rasa/issues/6409): The conversation event `form` was renamed to `active_loop`. Rasa Open Source + will continue to be able to read and process old `form` events. Note that + serialized trackers will no longer have the `active_form` field. Instead the + `active_loop` field will contain the same information. Story representations + in Markdown and YAML will use `active_loop` instead of `form` to represent the + event. +- [#6453](https://github.com/rasahq/rasa/issues/6453): Removed support for `queue` argument in `PikaEventBroker` (use `queues` instead). + + Domain file: + - Removed support for `templates` key (use `responses` instead). + - Removed support for string `responses` (use dictionaries instead). + + NLU `Component`: + - Removed support for `provides` attribute, it's not needed anymore. + - Removed support for `requires` attribute (use `required_components()` instead). + + Removed `_guess_format()` utils method from `rasa.nlu.training_data.loading` (use `guess_format` instead). + + Removed several config options for [TED Policy](./policies.mdx#ted-policy), [DIETClassifier](./components.mdx#dietclassifier) and [ResponseSelector](./components.mdx#responseselector): + - `hidden_layers_sizes_pre_dial` + - `hidden_layers_sizes_bot` + - `droprate` + - `droprate_a` + - `droprate_b` + - `hidden_layers_sizes_a` + - `hidden_layers_sizes_b` + - `num_transformer_layers` + - `num_heads` + - `dense_dim` + - `embed_dim` + - `num_neg` + - `mu_pos` + - `mu_neg` + - `use_max_sim_neg` + - `C2` + - `C_emb` + - `evaluate_every_num_epochs` + - `evaluate_on_num_examples` + + Please check the documentation for more information. +- [#6463](https://github.com/rasahq/rasa/issues/6463): The conversation event `form_validation` was renamed to `loop_interrupted`. + Rasa Open Source will continue to be able to read and process old `form_validation` + events. +- [#6658](https://github.com/rasahq/rasa/issues/6658): `SklearnPolicy` was deprecated. `TEDPolicy` is the preferred machine-learning policy for dialogue models. +- [#6809](https://github.com/rasahq/rasa/issues/6809): [Slots](domain.mdx#slots) of type `unfeaturized` are + now deprecated and will be removed in Rasa Open Source 3.0. Instead you should use + the property `influence_conversation: false` for every slot type as described in the + [migration guide](migration-guide.mdx#unfeaturized-slots). +- [#6934](https://github.com/rasahq/rasa/issues/6934): [Conversation sessions](domain.mdx#session-configuration) are now enabled by default + if your [Domain](domain.mdx) does not contain a session configuration. + Previously a missing session configuration was treated as if conversation sessions + were disabled. You can explicitly disable conversation sessions using the following + snippet: + + ```yaml-rasa title="domain.yml" + session_config: + # A session expiration time of `0` + # disables conversation sessions + session_expiration_time: 0 + ``` +- [#6952](https://github.com/rasahq/rasa/issues/6952): Using the [default action](default-actions.mdx) `action_deactivate_form` to deactivate + the currently active loop / [Form](forms.mdx) is deprecated. + Please use `action_deactivate_loop` instead. + +### Features +- [#4745](https://github.com/rasahq/rasa/issues/4745): Added template name to the metadata of bot utterance events. + + `BotUttered` event contains a `template_name` property in its metadata for any + new bot message. +- [#5086](https://github.com/rasahq/rasa/issues/5086): Added a `--num-threads` CLI argument that can be passed to `rasa train` + and will be used to train NLU components. +- [#5510](https://github.com/rasahq/rasa/issues/5510): You can now define what kind of features should be used by what component + (see [Choosing a Pipeline](./tuning-your-model.mdx)). + + You can set an alias via the option `alias` for every featurizer in your pipeline. + The `alias` can be anything, by default it is set to the full featurizer class name. + You can then specify, for example, on the + [DIETClassifier](./components.mdx#dietclassifier) what features from which + featurizers should be used. + If you don't set the option `featurizers` all available features will be used. + This is also the default behavior. + Check components to see what components have the option + `featurizers` available. + + Here is an example pipeline that shows the new option. + We define an alias for all featurizers in the pipeline. + All features will be used in the `DIETClassifier`. + However, the `ResponseSelector` only takes the features from the + `ConveRTFeaturizer` and the `CountVectorsFeaturizer` (word level). + + ``` + pipeline: + - name: ConveRTTokenizer + - name: ConveRTFeaturizer + alias: "convert" + - name: CountVectorsFeaturizer + alias: "cvf_word" + - name: CountVectorsFeaturizer + alias: "cvf_char" + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + - name: RegexFeaturizer + alias: "regex" + - name: LexicalSyntacticFeaturizer + alias: "lsf" + - name: DIETClassifier: + - name: ResponseSelector + epochs: 50 + featurizers: ["convert", "cvf_word"] + - name: EntitySynonymMapper + ``` + + :::caution + This change is model-breaking. Please retrain your models. + + ::: +- [#5837](https://github.com/rasahq/rasa/issues/5837): Added `--port` commandline argument to the interactive learning mode to allow + changing the port for the Rasa server running in the background. +- [#5957](https://github.com/rasahq/rasa/issues/5957): Add new entity extractor `RegexEntityExtractor`. The entity extractor extracts entities using the lookup tables + and regexes defined in the training data. For more information see [RegexEntityExtractor](./components.mdx#regexentityextractor). +- [#5996](https://github.com/rasahq/rasa/issues/5996): Introduced a new `YAML` format for Core training data and implemented a parser + for it. Rasa Open Source can now read stories in both `Markdown` and `YAML` format. +- [#6020](https://github.com/rasahq/rasa/issues/6020): You can now enable threaded message responses from Rasa through the Slack connector. + This option is enabled using an optional configuration in the credentials.yml file + + ```yaml + slack: + slack_token: + slack_channel: + use_threads: True + ``` + + Button support has also been added in the Slack connector. +- [#6065](https://github.com/rasahq/rasa/issues/6065): Add support for [rules](./rules.mdx) data and [forms](./forms.mdx) in YAML + format. +- [#6066](https://github.com/rasahq/rasa/issues/6066): The NLU `interpreter` is now passed to the [Policies](./policies.mdx) during training and + inference time. Note that this requires an additional parameter `interpreter` in the + method `predict_action_probabilities` of the `Policy` interface. In case a + custom `Policy` implementation doesn't provide this parameter Rasa Open Source + will print a warning and omit passing the `interpreter`. +- [#6088](https://github.com/rasahq/rasa/issues/6088): Added the new dialogue policy RulePolicy which will replace the old “rule-like” + policies [Mapping Policy](https://rasa.com/docs/rasa/2.x/policies#mapping-policy), + [Fallback Policy](https://rasa.com/docs/rasa/2.x/policies#fallback-policy), + [Two-Stage Fallback Policy](https://rasa.com/docs/rasa/2.x/policies#two-stage-fallback-policy), and + [Form Policy](https://rasa.com/docs/rasa/2.x/policies#form-policy). These policies are now + deprecated and will be removed in the future. Please see the + [rules documentation](./rules.mdx) for more information. + + Added new NLU component [FallbackClassifier](./components.mdx#fallbackclassifier) + which predicts an intent `nlu_fallback` in case the confidence was below a given + threshold. The intent `nlu_fallback` may + then be used to write stories / rules to handle the fallback in case of low NLU + confidence. + + ```yaml-rasa + pipeline: + - # Other NLU components ... + - name: FallbackClassifier + # If the highest ranked intent has a confidence lower than the threshold then + # the NLU pipeline predicts an intent `nlu_fallback` which you can then be used in + # stories / rules to implement an appropriate fallback. + threshold: 0.5 + ``` +- [#6132](https://github.com/rasahq/rasa/issues/6132): Added possibility to split the domain into separate files. All YAML files + under the path specified with `--domain` will be scanned for domain + information (e.g. intents, actions, etc) and then combined into a single domain. + + The default value for `--domain` is still `domain.yml`. +- [#6275](https://github.com/rasahq/rasa/issues/6275): Add optional metadata argument to `NaturalLanguageInterpreter`'s parse method. +- [#6354](https://github.com/rasahq/rasa/issues/6354): The Rasa Open Source API endpoint `POST /model/train` now supports training data in YAML + format. Please specify the header `Content-Type: application/yaml` when + training a model using YAML training data. + See the [API documentation](/pages/http-api) for more information. +- [#6374](https://github.com/rasahq/rasa/issues/6374): Added a YAML schema and a writer for 2.0 Training Core data. +- [#6404](https://github.com/rasahq/rasa/issues/6404): Users can now use the ``rasa data convert {nlu|core} -f yaml`` command to convert training data from Markdown format to YAML format. +- [#6536](https://github.com/rasahq/rasa/issues/6536): Add option `use_lemma` to `CountVectorsFeaturizer`. By default it is set to `True`. + + `use_lemma` indicates whether the featurizer should use the lemma of a word for counting (if available) or not. + If this option is set to `False` it will use the word as it is. + +### Improvements +- [#4536](https://github.com/rasahq/rasa/issues/4536): Add support for Python 3.8. +- [#5368](https://github.com/rasahq/rasa/issues/5368): Changed the project structure for Rasa projects initialized with the + [CLI](./command-line-interface.mdx) (using the `rasa init` command): + `actions.py` -> `actions/actions.py`. `actions` is now a Python package (it contains + a file `actions/__init__.py`). In addition, the `__init__.py` at the + root of the project has been removed. +- [#5481](https://github.com/rasahq/rasa/issues/5481): `DIETClassifier` now also assigns a confidence value to entity predictions. +- [#5637](https://github.com/rasahq/rasa/issues/5637): Added behavior to the `rasa --version` command. It will now also list information + about the operating system, python version and `rasa-sdk`. This will make it easier + for users to file bug reports. +- [#5743](https://github.com/rasahq/rasa/issues/5743): Support for additional training metadata. + + Training data messages now to support kwargs and the Rasa JSON data reader + includes all fields when instantiating a training data instance. +- [#5748](https://github.com/rasahq/rasa/issues/5748): Standardize testing output. The following test output can be produced for intents, + responses, entities and stories: + - report: a detailed report with testing metrics per label (e.g. precision, + recall, accuracy, etc.) + - errors: a file that contains incorrect predictions + - successes: a file that contains correct predictions + - confusion matrix: plot of confusion matrix + - histogram: plot of confidence distribution (not available for stories) +- [#5756](https://github.com/rasahq/rasa/issues/5756): To avoid the problem of our entity extractors predicting entity labels for + just a part of the words, we introduced a cleaning method after the prediction + was done. We should avoid the incorrect prediction in the first place. + To achieve this we will not tokenize words into sub-words anymore. + We take the mean feature vectors of the sub-words as the feature vector of the word. + + :::caution + This change is model breaking. Please, retrain your models. + + ::: +- [#5759](https://github.com/rasahq/rasa/issues/5759): Move option `case_sensitive` from the tokenizers to the featurizers. + - Remove the option from the `WhitespaceTokenizer` and `ConveRTTokenizer`. + - Add option `case_sensitive` to the `RegexFeaturizer`. +- [#5766](https://github.com/rasahq/rasa/issues/5766): If a user sends a voice message to the bot using Facebook, users messages was set to the attachments URL. The same is now also done for the rest of attachment types (image, video, and file). +- [#5794](https://github.com/rasahq/rasa/issues/5794): Creating a `Domain` using `Domain.fromDict` can no longer alter the input dictionary. + Previously, there could be problems when the input dictionary was re-used for other + things after creating the `Domain` from it. +- [#5805](https://github.com/rasahq/rasa/issues/5805): The debug-level logs when instantiating an + [SQLTrackerStore](./tracker-stores.mdx#sqltrackerstore) + no longer show the password in plain text. Now, the URL is displayed with the password + hidden, e.g. `postgresql://username:***@localhost:5432`. +- [#5855](https://github.com/rasahq/rasa/issues/5855): Shorten the information in tqdm during training ML algorithms based on the log + level. If you train your model in debug mode, all available metrics will be + shown during training, otherwise, the information is shorten. +- [#5913](https://github.com/rasahq/rasa/issues/5913): Ignore conversation test directory `tests/` when importing a project + using `MultiProjectImporter` and `use_e2e` is `False`. + Previously, any story data found in a project subdirectory would be imported + as training data. +- [#5985](https://github.com/rasahq/rasa/issues/5985): Implemented model checkpointing for DIET (including the response selector) and TED. The best model during training will be stored instead of just the last model. The model is evaluated on the basis of `evaluate_every_number_of_epochs` and `evaluate_on_number_of_examples`. + + Checkpointing is enabled iff the following is set for the models in the `config.yml` file: + * `checkpoint_model: True` + * `evaluate_on_number_of_examples > 0` + + The model is stored to whatever location has been specified with the `--out` parameter when calling `rasa train nlu/core ...`. +- [#6024](https://github.com/rasahq/rasa/issues/6024): `rasa data split nlu` now makes sure that there is at least one example per + intent and response in the test data. +- [#6039](https://github.com/rasahq/rasa/issues/6039): The method `ensure_consistent_bilou_tagging` now also considers the confidence values of the predicted tags + when updating the BILOU tags. +- [#6045](https://github.com/rasahq/rasa/issues/6045): We updated the way how we save and use features in our NLU pipeline. + + The message object now has a dedicated field, called `features`, to store the + features that are generated in the NLU pipeline. We adapted all our featurizers in a + way that sequence and sentence features are stored independently. This allows us to + keep different kind of features for the sequence and the sentence. For example, the + `LexicalSyntacticFeaturizer` does not produce any sentence features anymore as our + experiments showed that those did not bring any performance gain just quite a lot of + additional values to store. + + We also modified the DIET architecture to process the sequence and sentence + features independently at first. The features are concatenated just before + the transformer. + + We also removed the `__CLS__` token again. Our Tokenizers will not + add this token anymore. + + :::caution + This change is model-breaking. Please retrain your models. + + ::: +- [#6052](https://github.com/rasahq/rasa/issues/6052): Add endpoint kwarg to `rasa.jupyter.chat` to enable using a custom action server while chatting with a model in a jupyter notebook. +- [#6055](https://github.com/rasahq/rasa/issues/6055): Support for rasa conversation id with special characters on the server side - necessary for some channels (e.g. Viber) +- [#6123](https://github.com/rasahq/rasa/issues/6123): Add support for proxy use in [slack](./connectors/slack.mdx) input channel. +- [#6134](https://github.com/rasahq/rasa/issues/6134): Log the number of examples per intent during training. Logging can be enabled using `rasa train --debug`. +- [#6237](https://github.com/rasahq/rasa/issues/6237): Support for other remote storages can be achieved by using an external library. +- [#6273](https://github.com/rasahq/rasa/issues/6273): Add `output_channel` query param to `/conversations//tracker/events` route, along with boolean `execute_side_effects` to optionally schedule/cancel reminders, and forward bot messages to output channel. +- [#6276](https://github.com/rasahq/rasa/issues/6276): Allow Rasa to boot when model loading exception occurs. Forward HTTP Error responses to standard log output. +- [#6294](https://github.com/rasahq/rasa/issues/6294): Rename `DucklingHTTPExtractor` to `DucklingEntityExtractor`. +- [#6296](https://github.com/rasahq/rasa/issues/6296): * Modified functionality of `SingleStateFeaturizer`. + + `SingleStateFeaturizer` uses trained NLU `Interpreter` to featurize intents and action names. + This modified `SingleStateFeaturizer` can replicate `LabelTokenizerSingleStateFeaturizer` functionality. + This component is deprecated from now on. + To replicate `LabelTokenizerSingleStateFeaturizer` functionality, + add a `Tokenizer` with `intent_tokenization_flag: True` and `CountVectorsFeaturizer` to the NLU pipeline. + Please update your configuration file. + + For example: + ```yaml + language: en + pipeline: + - name: WhitespaceTokenizer + intent_tokenization_flag: True + - name: CountVectorsFeaturizer + ``` + + Please train both NLU and Core (using `rasa train`) to use a trained tokenizer and featurizer for core featurization. + + The new `SingleStateFeaturizer` stores slots, entities and forms in sparse features for more lightweight storage. + + `BinarySingleStateFeaturizer` is deprecated and will be removed in the future. + We recommend to switch to `SingleStateFeaturizer`. + + * Modified `TEDPolicy` to handle sparse features. As a result, `TEDPolicy` may require more epochs than before to converge. + + * Default TEDPolicy featurizer changed to `MaxHistoryTrackerFeaturizer` with infinite max history (takes all dialogue turns into account). + * Default batch size for TED increased from [8,32] to [64, 256] +- [#6323](https://github.com/rasahq/rasa/issues/6323): [Response selector templates](./components.mdx#responseselector) now support all features that + domain utterances do. They use the yaml format instead of markdown now. + This means you can now use buttons, images, ... in your FAQ or chitchat responses + (assuming they are using the response selector). + + As a consequence, training data form in markdown has to have the file + suffix `.md` from now on to allow proper file type detection- +- [#6457](https://github.com/rasahq/rasa/issues/6457): Support for test stories written in yaml format. +- [#6466](https://github.com/rasahq/rasa/issues/6466): [Response Selectors](./components.mdx#responseselector) are now trained on retrieval intent labels by default instead of the actual response text. For most models, this should improve training time and accuracy of the `ResponseSelector`. + + If you want to revert to the pre-2.0 default behavior, add the `use_text_as_label=true` parameter to your `ResponseSelector` component. + + You can now also have multiple response templates for a single sub-intent of a retrieval intent. The first response template + containing the text attribute is picked for training(if `use_text_as_label=True`) and a random template is picked for bot's utterance just as how other `utter_` templates are picked. + + All response selector related evaluation artifacts - `report.json, successes.json, errors.json, confusion_matrix.png` now use the sub-intent of the retrieval intent as the target and predicted labels instead of the actual response text. + + The output schema of `ResponseSelector` has changed - `full_retrieval_intent` and `name` have been deprecated in favour + of `intent_response_key` and `response_templates` respectively. Additionally a key `all_retrieval_intents` + is added to the response selector output which will hold a list of all retrieval intents(faq,chitchat, etc.) + that are present in the training data.An example output looks like this - + ``` + "response_selector": { + "all_retrieval_intents": ["faq"], + "default": { + "response": { + "id": 1388783286124361986, "confidence": 1.0, "intent_response_key": "faq/is_legit", + "response_templates": [ + { + "text": "absolutely", + "image": "https://i.imgur.com/nGF1K8f.jpg" + }, + { + "text": "I think so." + } + ], + }, + "ranking": [ + { + "id": 1388783286124361986, + "confidence": 1.0, + "intent_response_key": "faq/is_legit" + }, + ] + ``` + + An example bot demonstrating how to use the `ResponseSelector` is added to the `examples` folder. +- [#6472](https://github.com/rasahq/rasa/issues/6472): Do not modify conversation tracker's ``latest_input_channel`` property when using ``POST /trigger_intent`` or ``ReminderScheduled``. +- [#6555](https://github.com/rasahq/rasa/issues/6555): Do not set the output dimension of the `sparse-to-dense` layers to the same dimension as the dense features. + + Update default value of `dense_dimension` and `concat_dimension` for `text` in `DIETClassifier` to 128. +- [#6591](https://github.com/rasahq/rasa/issues/6591): Retrieval actions with `respond_` prefix are now replaced with usual utterance actions with `utter_` prefix. + + If you were using retrieval actions before, rename all of them to start with `utter_` prefix. For example, `respond_chitchat` becomes `utter_chitchat`. + Also, in order to keep the response templates more consistent, you should now add the `utter_` prefix to all response templates defined for retrieval intents. For example, a response template `chitchat/ask_name` becomes `utter_chitchat/ask_name`. Note that the NLU examples for this will still be under `chitchat/ask_name` intent. + The example `responseselectorbot` should help clarify these changes further. +- [#6613](https://github.com/rasahq/rasa/issues/6613): Added telemetry reporting. Rasa uses telemetry to report anonymous usage information. + This information is essential to help improve Rasa Open Source for all users. + Reporting will be opt-out. More information can be found in our + [telemetry documentation](./telemetry/telemetry.mdx). +- [#6757](https://github.com/rasahq/rasa/issues/6757): Update `extract_other_slots` method inside `FormAction` to fill a slot from an entity + with a different name if corresponding slot mapping of `from_entity` type is unique. +- [#6809](https://github.com/rasahq/rasa/issues/6809): [Slots](domain.mdx#slots) of any type can now be ignored during a conversation. + To do so, specify the property `influence_conversation: false` for the slot. + + ```yaml + slot: + a_slot: + type: text + influence_conversation: false + ``` + + The property `influence_conversation` is set to `true` by default. See the + [documentation for slots](domain.mdx#slots) for more information. + + A new slot type [`any`](domain.mdx#any-slot) was added. Slots of this type can store + any value. Slots of type `any` are always ignored during conversations. +- [#6856](https://github.com/rasahq/rasa/issues/6856): Improved exception handling within Rasa Open Source. + + All exceptions that are somewhat expected (e.g. errors in file formats like + configurations or training data) will share a common base class + `RasaException`. + + ::warning Backwards Incompatibility + Base class for the exception raised when an action can not be found has been changed + from a `NameError` to a `ValueError`. + :: + + Some other exceptions have also slightly changed: + - raise `YamlSyntaxException` instead of YAMLError (from ruamel) when + failing to load a yaml file with information about the line where loading failed + - introduced `MissingDependencyException` as an exception raised if packages + need to be installed +- [#6900](https://github.com/rasahq/rasa/issues/6900): Debug logs from `matplotlib` libraries are now hidden by default and are configurable with the `LOG_LEVEL_LIBRARIES` environment variable. +- [#6943](https://github.com/rasahq/rasa/issues/6943): Update `KafkaEventBroker` to support `SASL_SSL` and `PLAINTEXT` protocols. + +### Bugfixes +- [#3597](https://github.com/rasahq/rasa/issues/3597): Fixed issue where temporary model directories were not removed after pulling from a model server. + + If the model pulled from the server was invalid, this could lead to large amounts of local storage usage. +- [#5038](https://github.com/rasahq/rasa/issues/5038): Fixed a bug in the `CountVectorsFeaturizer` which resulted in the very first + message after loading a model to be processed incorrectly due to the vocabulary + not being loaded yet. +- [#5135](https://github.com/rasahq/rasa/issues/5135): Fixed Rasa shell skipping button messages if buttons are attached to + a message previous to the latest. +- [#5385](https://github.com/rasahq/rasa/issues/5385): Stack level for `FutureWarning` updated to level 2. +- [#5453](https://github.com/rasahq/rasa/issues/5453): If custom utter message contains no value or integer value, then it fails + returning custom utter message. Fixed by converting the template to type string. +- [#5617](https://github.com/rasahq/rasa/issues/5617): Don't create TensorBoard log files during prediction. +- [#5638](https://github.com/rasahq/rasa/issues/5638): Fixed DIET breaking with empty spaCy model. +- [#5737](https://github.com/rasahq/rasa/issues/5737): Pinned the library version for the Azure + [Cloud Storage](./model-storage.mdx#load-model-from-cloud) to 2.1.0 since the + persistor is currently not compatible with later versions of the azure-storage-blob + library. +- [#5755](https://github.com/rasahq/rasa/issues/5755): Remove `clean_up_entities` from extractors that extract pre-defined entities. + Just keep the clean up method for entity extractors that extract custom entities. +- [#5792](https://github.com/rasahq/rasa/issues/5792): Fixed issue where the `DucklingHTTPExtractor` component would + not work if its `url` contained a trailing slash. +- [#5808](https://github.com/rasahq/rasa/issues/5808): Changed to variable `CERT_URI` in `hangouts.py` to a string type +- [#5850](https://github.com/rasahq/rasa/issues/5850): Slots will be correctly interpolated for `button` responses. + + Previously this resulted in no interpolation due to a bug. +- [#5905](https://github.com/rasahq/rasa/issues/5905): Remove option `token_pattern` from `CountVectorsFeaturizer`. + Instead all tokenizers now have the option `token_pattern`. + If a regular expression is set, the tokenizer will apply the token pattern. +- [#5921](https://github.com/rasahq/rasa/issues/5921): Allow user to retry failed file exports in interactive training. +- [#5964](https://github.com/rasahq/rasa/issues/5964): Fixed a bug when custom metadata passed with the utterance always restarted the session. +- [#5998](https://github.com/rasahq/rasa/issues/5998): `WhitespaceTokenizer` does not remove vowel signs in Hindi anymore. +- [#6042](https://github.com/rasahq/rasa/issues/6042): Convert entity values coming from `DucklingHTTPExtractor` to string + during evaluation to avoid mismatches due to different types. +- [#6053](https://github.com/rasahq/rasa/issues/6053): Update `FeatureSignature` to store just the feature dimension instead of the + complete shape. This change fixes the usage of the option `share_hidden_layers` + in the `DIETClassifier`. +- [#6087](https://github.com/rasahq/rasa/issues/6087): Unescape the `\n, \t, \r, \f, \b` tokens on reading nlu data from markdown files. + + On converting json files into markdown, the tokens mentioned above are espaced. These tokens need to be unescaped on loading the data from markdown to ensure that the data is treated in the same way. +- [#6120](https://github.com/rasahq/rasa/issues/6120): Fix the way training data is generated in rasa test nlu when using the `-P` flag. + Each percentage of the training dataset used to be formed as a part of the last + sampled training dataset and not as a sample from the original training dataset. +- [#6143](https://github.com/rasahq/rasa/issues/6143): Prevent `WhitespaceTokenizer` from outputting empty list of tokens. +- [#6198](https://github.com/rasahq/rasa/issues/6198): Add `EntityExtractor` as a required component for `EntitySynonymMapper` in a pipeline. +- [#6222](https://github.com/rasahq/rasa/issues/6222): Better handling of input sequences longer than the maximum sequence length that the `HFTransformersNLP` models can handle. + + During training, messages with longer sequence length should result in an error, whereas during inference they are + gracefully handled but a debug message is logged. Ideally, passing messages longer than the acceptable maximum sequence + lengths of each model should be avoided. +- [#6231](https://github.com/rasahq/rasa/issues/6231): When using the `DynamoTrackerStore`, if there are more than 100 DynamoDB tables, the tracker could attempt to re-create an existing table if that table was not among the first 100 listed by the dynamo API. +- [#6282](https://github.com/rasahq/rasa/issues/6282): Fixed a deprication warning that pops up due to changes in numpy +- [#6291](https://github.com/rasahq/rasa/issues/6291): Update `rasabaster` to fix an issue with syntax highlighting on "Prototype an Assistant" page. + + Update default stories and rules on "Prototype an Assistant" page. +- [#6419](https://github.com/rasahq/rasa/issues/6419): Fixed a bug in the `serialise` method of the `EvaluationStore` class which resulted in a wrong end-to-end evaluation of the predicted entities. +- [#6535](https://github.com/rasahq/rasa/issues/6535): [Forms](./forms.mdx) with slot mappings defined in `domain.yml` must now be a + dictionary (with form names as keys). The previous syntax where `forms` was simply a + list of form names is still supported. +- [#6577](https://github.com/rasahq/rasa/issues/6577): Remove BILOU tag prefix from role and group labels when creating entities. +- [#6601](https://github.com/rasahq/rasa/issues/6601): Fixed a bug in the featurization of the boolean slot type. Previously, to set a slot value to "true", + you had to set it to "1", which is in conflict with the documentation. In older versions `true` + (without quotes) was also possible, but now raised an error during yaml validation. +- [#6603](https://github.com/rasahq/rasa/issues/6603): Fixed a bug in rasa interactive. Now it exports the stories and nlu training data as yml file. +- [#6711](https://github.com/rasahq/rasa/issues/6711): Fixed slots not being featurized before first user utterance. + + Fixed AugmentedMemoizationPolicy to forget the first action on the first going back +- [#6741](https://github.com/rasahq/rasa/issues/6741): Fixed the remote URL of ConveRT model as it was recently updated by its authors. +- [#6755](https://github.com/rasahq/rasa/issues/6755): Treat the length of OOV token as 1 to fix token align issue when OOV occurred. +- [#6757](https://github.com/rasahq/rasa/issues/6757): Fixed the bug when entity was extracted even + if it had a role or group but roles or groups were not expected. +- [#6803](https://github.com/rasahq/rasa/issues/6803): Fixed the bug that caused `supported_language_list` of `Component` to not work correctly. + + To avoid confusion, only one of `supported_language_list` and `not_supported_language_list` can be set to not `None` now +- [#6897](https://github.com/rasahq/rasa/issues/6897): Fixed issue where responses including `text: ""` and no `custom` key would incorrectly fail domain validation. +- [#6898](https://github.com/rasahq/rasa/issues/6898): Fixed issue where extra keys other than `title` and `payload` inside of `buttons` made a response fail domain validation. +- [#6919](https://github.com/rasahq/rasa/issues/6919): Do not filter training data in model.py but on component side. +- [#6929](https://github.com/rasahq/rasa/issues/6929): Check if a model was provided when executing `rasa test core`. + If not, print a useful error message and stop. +- [#6805](https://github.com/rasahq/rasa/issues/6805): Transfer only response templates for retrieval intents from domain to NLU Training Data. + + This avoids retraining the NLU model if one of the non retrieval intent response templates are edited. + +### Improved Documentation +- [#4441](https://github.com/rasahq/rasa/issues/4441): Added documentation on `ambiguity_threshold` parameter in Fallback Actions page. +- [#4605](https://github.com/rasahq/rasa/issues/4605): Remove outdated whitespace tokenizer warning in Testing Your Assistant documentation. +- [#5640](https://github.com/rasahq/rasa/issues/5640): Updated Facebook Messenger channel docs with supported attachment information +- [#5675](https://github.com/rasahq/rasa/issues/5675): Update `rasa shell` documentation to explain how to recreate external + channel session behavior. +- [#5811](https://github.com/rasahq/rasa/issues/5811): Event brokers documentation should say `url` instead of `host`. +- [#5952](https://github.com/rasahq/rasa/issues/5952): Update `rasa init` documentation to include `tests/conversation_tests.md` + in the resulting directory tree. +- [#6819](https://github.com/rasahq/rasa/issues/6819): Update ["Validating Form Input" section](./forms.mdx#validating-form-input) to include details about + how `FormValidationAction` class makes it easier to validate form slots in custom actions and how to use it. +- [#6823](https://github.com/rasahq/rasa/issues/6823): Update the examples in the API docs to use YAML instead of Markdown + +### Miscellaneous internal changes +- [#5784](https://github.com/rasahq/rasa/issues/5784), [#5788](https://github.com/rasahq/rasa/issues/5788), [#6199](https://github.com/rasahq/rasa/issues/6199), [#6403](https://github.com/rasahq/rasa/issues/6403), [#6735](https://github.com/rasahq/rasa/issues/6735) + + +## [1.10.26] - 2021-06-17 + +### Features +- [#8876](https://github.com/rasahq/rasa/issues/8876): Added `sasl_mechanism` as an optional configurable parameter for the [Kafka Producer](event-brokers.mdx#kafka-event-broker). + + +## [1.10.25] - 2021-04-14 + +### Features +- [#8429](https://github.com/rasahq/rasa/issues/8429): Added `partition_by_sender` flag to Kafka Producer to optionally associate events with Kafka partition based on sender_id. + +### Improvements +- [#8345](https://github.com/rasahq/rasa/issues/8345): Improved the [lock store](lock-stores.mdx) debug log message when the process has to queue because other messages have to be processed before this item. + + +## [1.10.24] - 2021-03-29 + +### Bugfixes +- [#8019](https://github.com/rasahq/rasa/issues/8019): Added `group_id` parameter back to `KafkaEventBroker` to fix error when instantiating event broker with a config containing the `group_id` parameter which is only relevant to the event consumer + + +## [1.10.23] - 2021-02-22 + +### Bugfixes +- [#7895](https://github.com/rasahq/rasa/issues/7895): Fixed bug where the conversation does not lock before handling a reminder event. + + +## [1.10.22] - 2021-02-05 + +### Bugfixes +- [#7772](https://github.com/rasahq/rasa/issues/7754): Backported the Rasa Open Source 2 `PikaEventBroker` implementation to address + problems when using it with multiple Sanic workers. + +## [1.10.21] - 2021-02-01 + +### Improvements +- [#7439](https://github.com/rasahq/rasa/issues/7439): The `url` option now supports a list of servers `url: ['10.0.0.158:32803','10.0.0.158:32804']`. + Removed `group_id` because it is not a valid Kafka producer parameter. + +### Bugfixes +- [#7638](https://github.com/rasahq/rasa/issues/7638): Fixed a bug that occurred when setting multiple Sanic workers in combination with a custom [Lock Store](lock-stores.mdx). Previously, if the number was set higher than 1 and you were using a custom lock store, it would reject because of a strict check to use a [Redis Lock Store](lock-stores.mdx#redislockstore). +- [#7722](https://github.com/rasahq/rasa/issues/7722): Fix a bug where, if a user injects an intent using the HTTP API, slot auto-filling is not performed on the entities provided. + + +## [1.10.20] - 2020-12-18 + +### Bugfixes + +- [#7575](https://github.com/rasahq/rasa/issues/7575): Fix scikit-learn crashing during evaluation of `ResponseSelector` predictions. + + +## [1.10.19] - 2020-12-17 + +### Improvements + +- [#6251](https://github.com/rasahq/rasa/issues/6251): Kafka Producer connection now remains active across sends. Added support for group and client id. + The Kafka producer also adds support for the `PLAINTEXT` and `SASL_SSL` protocols. + + DynamoDB table exists check fixed bug when more than 100 tables exist. + +- [#6814](https://github.com/rasahq/rasa/issues/6814): Replace use of `python-telegram-bot` package with `pyTelegramBotAPI` +- [#7423](https://github.com/rasahq/rasa/issues/7423): Use response selector keys (sub-intents) as labels for plotting the confusion matrix during NLU evaluation to improve readability. + + +## [1.10.18] - 2020-11-26 + +### Bugfixes +- [#7340](https://github.com/rasahq/rasa/issues/7340>): Fixed an issues with the DynamoDB TrackerStore creating a new table entry/object for each TrackerStore update. The column ``session_date`` has been deprecated and should be removed manually in existing DynamoDB tables. + + +## [1.10.17] - 2020-11-12 + +### Bugfixes +- [#7219](https://github.com/rasahq/rasa/issues/7219): Prevent the message handling process in ``PikaEventBroker`` from being terminated. + + +## [1.10.16] - 2020-10-15 + +### Bugfixes +- [#6703](https://github.com/rasahq/rasa/issues/6703): Update Pika event broker to be a separate process and make it use a + ``multiprocessing.Queue`` to send and process messages. This change should help + avoid situations when events stop being sent after a while. + +## [1.10.15] - 2020-10-09 + +### Bugfixes + +- [#3597](https://github.com/rasahq/rasa/issues/3597): Fixed issue where temporary model directories were not removed after pulling from a model server. If the model pulled from the server was invalid, this could lead to large amounts of local storage usage. +- [#6755](https://github.com/rasahq/rasa/issues/6755): Treat the length of OOV token as 1 to fix token align issue when OOV occurred. +- [#6899](https://github.com/rasahq/rasa/issues/6899): Fixed ``MappingPolicy`` not predicting ``action_listen`` after the mapped action while running ``rasa test``. + + +### Improvements + +- [#6900](https://github.com/rasahq/rasa/issues/6900): Debug logs from ``matplotlib`` libraries are now hidden by default and are configurable with the ``LOG_LEVEL_LIBRARIES`` environment variable. + +## [1.10.14] - 2020-09-23 + +### Bugfixes + +- [#6741](https://github.com/rasahq/rasa/issues/6741): Fixed the remote URL of ConveRT model as it was recently updated by its authors. Also made the remote URL configurable at runtime in the corresponding tokenizer's and featurizer's configuration. + +## [1.10.13] - 2020-09-22 + +### Bugfixes + +- [#6577](https://github.com/rasahq/rasa/issues/6577): Remove BILOU tag prefix from role and group labels when creating entities. + +## [1.10.12] - 2020-09-03 + +### Bugfixes + +- [#6549](https://github.com/rasahq/rasa/issues/6549): Fix slow training of `CRFEntityExtractor` when using Entity Roles and Groups. + +## [1.10.11] - 2020-08-21 + +### Improvements + +- [#6044](https://github.com/rasahq/rasa/issues/6044): Do not deepcopy slots when instantiating trackers. This leads to a significant + speedup when training on domains with a large number of slots. +- [#6226](https://github.com/rasahq/rasa/issues/6226): Added more debugging logs to the [Lock Stores](./lock-stores.mdx) to simplify debugging in case of + + connection problems. + + Added a new parameter `socket_timeout` to the `RedisLockStore`. If Redis doesn't + answer within `socket_timeout` seconds to requests from Rasa Open Source, an error + is raised. This avoids seemingly infinitely blocking connections and exposes connection + problems early. + +### Bugfixes + +- [#5182](https://github.com/rasahq/rasa/issues/5182): Fixed a bug where domain fields such as `store_entities_as_slots` were overridden + with defaults and therefore ignored. +- [#6191](https://github.com/rasahq/rasa/issues/6191): If two entities are separated by a comma (or any other symbol), extract them as two separate entities. +- [#6340](https://github.com/rasahq/rasa/issues/6340): If two entities are separated by a single space and uses BILOU tagging, + extract them as two separate entities based on their BILOU tags. + + +## [1.10.10] - 2020-08-04 + +### Bugfixes + +- [#6280](https://github.com/rasahq/rasa/issues/6280): Fixed `TypeError: expected string or bytes-like object` + issue caused by integer, boolean, and null values in templates. + + +## [1.10.9] - 2020-07-29 + +### Improvements + +- [#6255](https://github.com/rasahq/rasa/issues/6255): Rasa Open Source will no longer add `responses` to the `actions` section of the + domain when persisting the domain as a file. This addresses related problems in Rasa X + when Integrated Version Control introduced big diffs due to the added utterances + in the `actions` section. + +### Bugfixes + +- [#6160](https://github.com/rasahq/rasa/issues/6160): Consider entity roles/groups during interactive learning. + + +## [1.10.8] - 2020-07-15 + +### Bugfixes + +* [#6075](https://github.com/rasahq/rasa/issues/6075): Add 'Access-Control-Expose-Headers' for 'filename' header +* [#6137](https://github.com/rasahq/rasa/issues/6137): Fixed a bug where an invalid language variable prevents rasa from finding training examples when importing Dialogflow data. + + +## [1.10.7] - 2020-07-07 + +### Features + +* [#6150](https://github.com/rasahq/rasa/issues/6150): Add `not_supported_language_list` to component to be able to define languages that a component can NOT handle. + + `WhitespaceTokenizer` is not able to process languages which are not separated by whitespace. `WhitespaceTokenizer` + will throw an error if it is used with Chinese, Japanese, and Thai. + +### Bugfixes + +* [#6150](https://github.com/rasahq/rasa/issues/6150): `WhitespaceTokenizer` only removes emoji if complete token matches emoji regex. + + +## [1.10.6] - 2020-07-06 + +### Bugfixes + +* [#6143](https://github.com/rasahq/rasa/issues/6143): Prevent `WhitespaceTokenizer` from outputting empty list of tokens. + +## [1.10.5] - 2020-07-02 + +### Bugfixes + +* [#6119](https://github.com/rasahq/rasa/issues/6119): Explicitly remove all emojis which appear as unicode characters from the output of `regex.sub` inside `WhitespaceTokenizer`. + +## [1.10.4] - 2020-07-01 + +### Bugfixes + +* [#5998](https://github.com/rasahq/rasa/issues/5998): `WhitespaceTokenizer` does not remove vowel signs in Hindi anymore. + +* [#6031](https://github.com/rasahq/rasa/issues/6031): Previously, specifying a lock store in the endpoint configuration with a type other than `redis` or `in_memory` + would lead to an `AttributeError: 'str' object has no attribute 'type'`. This bug is fixed now. + +* [#6032](https://github.com/rasahq/rasa/issues/6032): Fix `Interpreter parsed an intent ...` warning when using the `/model/parse` + endpoint with an NLU-only model. + +* [#6042](https://github.com/rasahq/rasa/issues/6042): Convert entity values coming from any entity extractor to string during evaluation to avoid mismatches due to + different types. + +* [#6078](https://github.com/rasahq/rasa/issues/6078): The assistant will respond through the webex channel to any user (room) communicating to it. Before the bot responded only to a fixed `roomId` set in the `credentials.yml` config file. + +## [1.10.3] - 2020-06-12 + +### Improvements + +* [#3900](https://github.com/rasahq/rasa/issues/3900): Reduced duplicate logs and warnings when running `rasa train`. + +### Bugfixes + +* [#5972](https://github.com/rasahq/rasa/issues/5972): Remove the `clean_up_entities` method from the `DIETClassifier` and `CRFEntityExtractor` as it let to incorrect + entity predictions. + +* [#5976](https://github.com/rasahq/rasa/issues/5976): Fix server crashes that occurred when Rasa Open Source pulls a model from a + [model server](./model-storage.mdx#load-model-from-server) and an exception was thrown during + model loading (such as a domain with invalid YAML). + +## [1.10.2] - 2020-06-03 + +### Bugfixes + +* [#5521](https://github.com/rasahq/rasa/issues/5521): Responses used in ResponseSelector now support new lines with explicitly adding `\\n` between them. + +* [#5758](https://github.com/rasahq/rasa/issues/5758): Fixed a bug in [`rasa export`](./command-line-interface.mdx#rasa-export)) which caused Rasa Open Source to only migrate conversation events from the last [Session configuration](./domain.mdx#session-configuration). + +## [1.10.1] - 2020-05-15 + +### Improvements + +* [#5794](https://github.com/rasahq/rasa/issues/5794): Creating a `Domain` using `Domain.fromDict` can no longer alter the input dictionary. + Previously, there could be problems when the input dictionary was re-used for other + things after creating the `Domain` from it. + +### Bugfixes + +* [#5617](https://github.com/rasahq/rasa/issues/5617): Don't create TensorBoard log files during prediction. + +* [#5638](https://github.com/rasahq/rasa/issues/5638): Fix: DIET breaks with empty spaCy model + +* [#5755](https://github.com/rasahq/rasa/issues/5755): Remove `clean_up_entities` from extractors that extract pre-defined entities. + Just keep the clean up method for entity extractors that extract custom entities. + +* [#5792](https://github.com/rasahq/rasa/issues/5792): Fixed issue where the `DucklingHTTPExtractor` component would + not work if its url contained a trailing slash. + +* [#5825](https://github.com/rasahq/rasa/issues/5825): Fix list index out of range error in `ensure_consistent_bilou_tagging`. + +### Miscellaneous internal changes + +* #5788 + +## [1.10.0] - 2020-04-28 + +### Features + +* [#3765](https://github.com/rasahq/rasa/issues/3765): Add support for entities with roles and grouping of entities in Rasa NLU. + + You can now define a role and/or group label in addition to the entity type for entities. + Use the role label if an entity can play different roles in your assistant. + For example, a city can be a destination or a departure city. + The group label can be used to group multiple entities together. + For example, you could group different pizza orders, so that you know what toppings goes with which pizza and + what size which pizza has. + For more details see [Entities Roles and Groups](./nlu-training-data.mdx#entities-roles-and-groups). + + To fill slots from entities with a specific role/group, you need to either use forms or use a custom action. + We updated the tracker method `get_latest_entity_values` to take an optional role/group label. + If you want to use a form, you can add the specific role/group label of interest to the slot mapping function + `from_entity` (see [Forms](./forms.mdx)). + + :::note + Composite entities are currently just supported by the [DIETClassifier](./components.mdx#dietclassifier) and [CRFEntityExtractor](./components.mdx#crfentityextractor). + + ::: + +* [#5465](https://github.com/rasahq/rasa/issues/5465): Update training data format for NLU to support entities with a role or group label. + + You can now specify synonyms, roles, and groups of entities using the following data format: + Markdown: + + ``` + [LA]{"entity": "location", "role": "city", "group": "CA", "value": "Los Angeles"} + ``` + + JSON: + + ``` + "entities": [ + { + "start": 10, + "end": 12, + "value": "Los Angeles", + "entity": "location", + "role": "city", + "group": "CA", + } + ] + ``` + + The markdown format `[LA](location:Los Angeles)` is deprecated. To update your training data file just + execute the following command on the terminal of your choice: + `sed -i -E 's/\\[([^)]+)\\]\\(([^)]+):([^)]+)\\)/[\\1]{"entity": "\\2", "value": "\\3"}/g' nlu.md` + + For more information about the new data format see [Training Data Format](./training-data-format.mdx). + +### Improvements + +* [#2224](https://github.com/rasahq/rasa/issues/2224): Suppressed `pika` logs when establishing the connection. These log messages + mostly happened when Rasa X and RabbitMQ were started at the same time. Since RabbitMQ + can take a few seconds to initialize, Rasa X has to re-try until the connection is + established. + In case you suspect a different problem (such as failing authentication) you can + re-enable the `pika` logs by setting the log level to `DEBUG`. To run Rasa Open + Source in debug mode, use the `--debug` flag. To run Rasa X in debug mode, set the + environment variable `DEBUG_MODE` to `true`. + +* [#3419](https://github.com/rasahq/rasa/issues/3419): Include the source filename of a story in the failed stories + + Include the source filename of a story in the failed stories to make it easier to identify the file which contains the failed story. + +* [#5544](https://github.com/rasahq/rasa/issues/5544): Add confusion matrix and “confused_with” to response selection evaluation + + If you are using ResponseSelectors, they now produce similiar outputs during NLU evaluation. Misclassfied responses are listed in a “confused_with” attribute in the evaluation report. Similiarily, a confusion matrix of all responses is plotted. + +* [#5578](https://github.com/rasahq/rasa/issues/5578): Added `socketio` to the compatible channels for [Reminders and External Events](./reaching-out-to-user.mdx). + +* [#5595](https://github.com/rasahq/rasa/issues/5595): Update `POST /model/train` endpoint to accept retrieval action responses + at the `responses` key of the JSON payload. + +* [#5627](https://github.com/rasahq/rasa/issues/5627): All Rasa Open Source images are now using Python 3.7 instead of Python 3.6. + +* [#5635](https://github.com/rasahq/rasa/issues/5635): Update dependencies based on the `dependabot` check. + +* [#5636](https://github.com/rasahq/rasa/issues/5636): Add dropout between `FFNN` and `DenseForSparse` layers in `DIETClassifier`, + `ResponseSelector` and `EmbeddingIntentClassifier` controlled by `use_dense_input_dropout` config parameter. + +* [#5646](https://github.com/rasahq/rasa/issues/5646): `DIETClassifier` only counts as extractor in `rasa test` if it was actually trained for entity recognition. + +* [#5669](https://github.com/rasahq/rasa/issues/5669): Remove regularization gradient for variables that don't have prediction gradient. + +* [#5672](https://github.com/rasahq/rasa/issues/5672): Raise a warning in `CRFEntityExtractor` and `DIETClassifier` if entities are not correctly annotated in the + training data, e.g. their start and end values do not match any start and end values of tokens. + +* [#5690](https://github.com/rasahq/rasa/issues/5690): Add `full_retrieval_intent` property to `ResponseSelector` rankings + +* [#5717](https://github.com/rasahq/rasa/issues/5717): Change default values for hyper-parameters in `EmbeddingIntentClassifier` and `DIETClassifier` + + Use `scale_loss=False` in `DIETClassifier`. Reduce the number of dense dimensions for sparse features of text from 512 to 256 in `EmbeddingIntentClassifier`. + +### Bugfixes + +* [#5230](https://github.com/rasahq/rasa/issues/5230): Fixed issue where posting to certain callback channel URLs would return a 500 error on successful posts due to invalid response format. + +* [#5475](https://github.com/rasahq/rasa/issues/5475): One word can just have one entity label. + + If you are using, for example, `ConveRTTokenizer` words can be split into multiple tokens. + Our entity extractors assign entity labels per token. So, it might happen, that a word, that was split into two tokens, + got assigned two different entity labels. This is now fixed. One word can just have one entity label at a time. + +* [#5509](https://github.com/rasahq/rasa/issues/5509): An entity label should always cover a complete word. + + If you are using, for example, `ConveRTTokenizer` words can be split into multiple tokens. + Our entity extractors assign entity labels per token. So, it might happen, that just a part of a word has + an entity label. This is now fixed. An entity label always covers a complete word. + +* [#5574](https://github.com/rasahq/rasa/issues/5574): Fixed an issue that happened when metadata is passed in a new session. + + Now the metadata is correctly passed to the ActionSessionStart. + +* [#5672](https://github.com/rasahq/rasa/issues/5672): Updated Python dependency `ruamel.yaml` to `>=0.16`. We recommend to use at least + `0.16.10` due to the security issue + [CVE-2019-20478](https://nvd.nist.gov/vuln/detail/CVE-2019-20478) which is present in + in prior versions. + +### Miscellaneous internal changes + +* #5556, #5587, #5614, #5631, #5633 + +## [1.9.7] - 2020-04-23 + +### Improvements + +* [#4606](https://github.com/rasahq/rasa/issues/4606): The stream reading timeout for `rasa shell\` is now configurable by using the + environment variable \`\`RASA_SHELL_STREAM_READING_TIMEOUT_IN_SECONDS`. + This can help to fix problems when using `rasa shell` with custom actions which run + 10 seconds or longer. + +### Bugfixes + +* [#5709](https://github.com/rasahq/rasa/issues/5709): Reverted changes in 1.9.6 that led to model incompatibility. Upgrade to 1.9.7 to fix + `self.sequence_lengths_for(tf_batch_data[TEXT_SEQ_LENGTH][0]) IndexError: list index out of range` + error without needing to retrain earlier 1.9 models. + + Therefore, all 1.9 models except for 1.9.6 will be compatible; a model trained on 1.9.6 will need + to be retrained on 1.9.7. + +## [1.9.6] - 2020-04-15 + +### Bugfixes + +* [#5426](https://github.com/rasahq/rasa/issues/5426): Fix rasa test nlu plotting when using multiple runs. + +* [#5489](https://github.com/rasahq/rasa/issues/5489): Fixed issue where `max_number_of_predictions` was not considered when running end-to-end testing. + +### Miscellaneous internal changes + +* #5626 + +## [1.9.5] - 2020-04-01 + +### Improvements + +* [#5533](https://github.com/rasahq/rasa/issues/5533): Support for + [PostgreSQL schemas](https://www.postgresql.org/docs/11/ddl-schemas.html) in + [SQLTrackerStore](./tracker-stores.mdx#sqltrackerstore). The `SQLTrackerStore` + accesses schemas defined by the `POSTGRESQL_SCHEMA` environment variable if + connected to a PostgreSQL database. + + The schema is added to the connection string option's `-csearch_path` key, e.g. + `-options=-csearch_path=` (see the + [PostgreSQL docs](https://www.postgresql.org/docs/11/contrib-dblink-connect.html) for more details). + As before, if no `POSTGRESQL_SCHEMA` is defined, Rasa uses the database's default + schema (`public`). + + The schema has to exist in the database before connecting, i.e. it needs to have been + created with + + ```postgresql + CREATE SCHEMA schema_name; + ``` + +### Bugfixes + +* [#5547](https://github.com/rasahq/rasa/issues/5547): Fixed ambiguous logging in `DIETClassifier` by adding the name of the calling class to the log message. + +## [1.9.4] - 2020-03-30 + +### Bugfixes + +* [#5529](https://github.com/rasahq/rasa/issues/5529): Fix memory leak problem on increasing number of calls to `/model/parse` endpoint. + +## [1.9.3] - 2020-03-27 + +### Bugfixes + +* [#5505](https://github.com/rasahq/rasa/issues/5505): Set default value for `weight_sparsity` in `ResponseSelector` to `0`. + This fixes a bug in the default behavior of `ResponseSelector` which was accidentally introduced in `rasa==1.8.0`. + Users should update to this version and re-train their models if `ResponseSelector` was used in their pipeline. + +## [1.9.2] - 2020-03-26 + +### Improved Documentation + +* [#5497](https://github.com/RasaHQ/rasa/pull/5497): Fix documentation to bring back Sara. + +## [1.9.1] - 2020-03-25 + +### Bugfixes + +* [#5492](https://github.com/rasahq/rasa/issues/5492): Fix an issue where the deprecated `queue` parameter for the [Pika Event Broker](./event-brokers.mdx#pika-event-broker) + was ignored and Rasa Open Source published the events to the `rasa_core_events` + queue instead. Note that this does not change the fact that the `queue` argument + is deprecated in favor of the `queues` argument. + +## [1.9.0] - 2020-03-24 + +### Features + +* [#5006](https://github.com/rasahq/rasa/issues/5006): Channel `hangouts` for Rasa integration with Google Hangouts Chat is now supported out-of-the-box. + +* [#5389](https://github.com/rasahq/rasa/issues/5389): Add an optional path to a specific directory to download and cache the pre-trained model weights for [HFTransformersNLP](https://rasa.com/docs/rasa/2.x/components#hftransformersnlp). + +* [#5422](https://github.com/rasahq/rasa/issues/5422): Add options `tensorboard_log_directory` and `tensorboard_log_level` to `EmbeddingIntentClassifier`, + `DIETClasifier`, `ResponseSelector`, `EmbeddingPolicy` and `TEDPolicy`. + + By default `tensorboard_log_directory` is `None`. If a valid directory is provided, + metrics are written during training. After the model is trained you can take a look + at the training metrics in tensorboard. Execute `tensorboard --logdir `. + + Metrics can either be written after every epoch (default) or for every training step. + You can specify when to write metrics using the variable `tensorboard_log_level`. + Valid values are 'epoch' and 'minibatch'. + + We also write down a model summary, i.e. layers with inputs and types, to the given directory. + +### Improvements + +* [#4756](https://github.com/rasahq/rasa/issues/4756): Make response timeout configurable. + `rasa run`, `rasa shell` and `rasa x` can now be started with + `--response-timeout ` to configure a response timeout of `` seconds. + +* [#4826](https://github.com/rasahq/rasa/issues/4826): Add full retrieval intent name to message data + `ResponseSelector` will now add the full retrieval intent name + e.g. `faq/which_version` to the prediction, making it accessible + from the tracker. + +* [#5258](https://github.com/rasahq/rasa/issues/5258): Added `PikaEventBroker` ([Pika Event Broker](./event-brokers.mdx#pika-event-broker)) support for publishing to + multiple queues. Messages are now published to a `fanout` exchange with name + `rasa-exchange` (see + [exchange-fanout](https://www.rabbitmq.com/tutorials/amqp-concepts.html#exchange-fanout) + for more information on `fanout` exchanges). + + The former `queue` key is deprecated. Queues should now be + specified as a list in the `endpoints.yml` event broker config under a new key + `queues`. Example config: + + ```yaml-rasa + event_broker: + type: pika + url: localhost + username: username + password: password + queues: + - queue-1 + - queue-2 + - queue-3 + ``` + +* [#5416](https://github.com/rasahq/rasa/issues/5416): Change `rasa init` to include `tests/conversation_tests.md` file by default. + +* [#5446](https://github.com/rasahq/rasa/issues/5446): The endpoint `PUT /conversations//tracker/events` no longer + adds session start events (to learn more about conversation sessions, please + see [Session configuration](./domain.mdx#session-configuration)) in addition to the events which were sent in the request + payload. To achieve the old behavior send a + `GET /conversations//tracker` + request before appending events. + +* [#5482](https://github.com/rasahq/rasa/issues/5482): Make `scale_loss` for intents behave the same way as in versions below `1.8`, but + only scale if some of the examples in a batch has probability of the golden label more than `0.5`. + Introduce `scale_loss` for entities in `DIETClassifier`. + +### Bugfixes + +* [#5205](https://github.com/rasahq/rasa/issues/5205): Fixed the bug when FormPolicy was overwriting MappingPolicy prediction (e.g. `/restart`). + Priorities for [Mapping Policy](https://rasa.com/docs/rasa/2.x/policies#mapping-policy) and [Form Policy](https://rasa.com/docs/rasa/2.x/policies#form-policy) are no longer linear: + `FormPolicy` priority is 5, but its prediction is ignored if `MappingPolicy` is used for prediction. + +* [#5215](https://github.com/rasahq/rasa/issues/5215): Fixed issue related to storing Python `float` values as `decimal.Decimal` objects + in DynamoDB tracker stores. All `decimal.Decimal` objects are now converted to + `float` on tracker retrieval. + + Added a new docs section on [DynamoTrackerStore](./tracker-stores.mdx#dynamotrackerstore). + +* [#5356](https://github.com/rasahq/rasa/issues/5356): Fixed bug where `FallbackPolicy` would always fall back if the fallback action is + `action_listen`. + +* [#5361](https://github.com/rasahq/rasa/issues/5361): Fixed bug where starting or ending a response with `\\n\\n` led to one of the responses returned being empty. + +* [#5405](https://github.com/rasahq/rasa/issues/5405): Fixes issue where model always gets retrained if multiple NLU/story files are in a + directory, by sorting the list of files. + +* [#5444](https://github.com/rasahq/rasa/issues/5444): Fixed ambiguous logging in DIETClassifier by adding the name of the calling class to the log message. + +### Improved Documentation + +* [#2237](https://github.com/rasahq/rasa/issues/2237): Restructure the “Evaluating models” documentation page and rename this page to [Testing Your Assistant](./testing-your-assistant.mdx). + +* [#5302](https://github.com/rasahq/rasa/issues/5302): Improved documentation on how to build and deploy an action server image for use on other servers such as Rasa X deployments. + +### Miscellaneous internal changes + +* #5340 + +## [1.8.3] - 2020-03-27 + +### Bugfixes + +* [#5405](https://github.com/rasahq/rasa/issues/5405): Fixes issue where model always gets retrained if multiple NLU/story files are in a + directory, by sorting the list of files. + +* [#5444](https://github.com/rasahq/rasa/issues/5444): Fixed ambiguous logging in DIETClassifier by adding the name of the calling class to the log message. + +* [#5506](https://github.com/rasahq/rasa/issues/5506): Set default value for `weight_sparsity` in `ResponseSelector` to `0`. + This fixes a bug in the default behavior of `ResponseSelector` which was accidentally introduced in `rasa==1.8.0`. + Users should update to this version or `rasa>=1.9.3` and re-train their models if `ResponseSelector` was used in their pipeline. + +### Improved Documentation + +* [#5302](https://github.com/rasahq/rasa/issues/5302): Improved documentation on how to build and deploy an action server image for use on other servers such as Rasa X deployments. + +## [1.8.2] - 2020-03-19 + +### Bugfixes + +* [#5438](https://github.com/rasahq/rasa/issues/5438): Fixed bug when installing rasa with `poetry`. + +* [#5413](https://github.com/RasaHQ/rasa/issues/5413): Fixed bug with `EmbeddingIntentClassifier`, where results + weren't the same as in 1.7.x. Fixed by setting weight sparsity to 0. + +### Improved Documentation + +* [#5404](https://github.com/rasahq/rasa/issues/5404): Explain how to run commands as `root` user in Rasa SDK Docker images since version + `1.8.0`. Since version `1.8.0` the Rasa SDK Docker images does not longer run as + `root` user by default. For commands which require `root` user usage, you have to + switch back to the `root` user in your Docker image as described in + [Building an Action Server Image](./deploy/deploy-action-server.mdx#building-an-action-server-image). + +* [#5402](https://github.com/RasaHQ/rasa/issues/5402): Made improvements to Building Assistants tutorial + +## [1.8.1] - 2020-03-06 + +### Bugfixes + +* [#5354](https://github.com/rasahq/rasa/issues/5354): Fixed issue with using language models like `xlnet` along with `entity_recognition` set to `True` inside + `DIETClassifier`. + +### Miscellaneous internal changes + +* #5330, #5348 + +## [1.8.0] - 2020-02-26 + +### Deprecations and Removals + +* [#4991](https://github.com/rasahq/rasa/issues/4991): Removed `Agent.continue_training` and the `dump_flattened_stories` parameter + from `Agent.persist`. + +* [#5266](https://github.com/rasahq/rasa/issues/5266): Properties `Component.provides` and `Component.requires` are deprecated. + Use `Component.required_components()` instead. + +### Features + +* [#2674](https://github.com/rasahq/rasa/issues/2674): Add default value `__other__` to `values` of a `CategoricalSlot`. + + All values not mentioned in the list of values of a `CategoricalSlot` + will be mapped to `__other__` for featurization. + +* [#4088](https://github.com/rasahq/rasa/issues/4088): Add story structure validation functionality (e.g. rasa data validate stories –max-history 5). + +* [#5065](https://github.com/rasahq/rasa/issues/5065): Add [LexicalSyntacticFeaturizer](./components.mdx#lexicalsyntacticfeaturizer) to sparse featurizers. + + `LexicalSyntacticFeaturizer` does the same featurization as the `CRFEntityExtractor`. We extracted the + featurization into a separate component so that the features can be reused and featurization is independent from the + entity extraction. + +* [#5187](https://github.com/rasahq/rasa/issues/5187): Integrate language models from HuggingFace's [Transformers](https://github.com/huggingface/transformers) Library. + + Add a new NLP component [HFTransformersNLP](https://rasa.com/docs/rasa/2.x/components#hftransformersnlp) which tokenizes and featurizes incoming messages using a specified + pre-trained model with the Transformers library as the backend. + Add [LanguageModelTokenizer](https://rasa.com/docs/rasa/2.x/components#languagemodeltokenizer) and [LanguageModelFeaturizer](./components.mdx#languagemodelfeaturizer) which use the information from + [HFTransformersNLP](https://rasa.com/docs/rasa/2.x/components#hftransformersnlp) and sets them correctly for message object. + Language models currently supported: BERT, OpenAIGPT, GPT-2, XLNet, DistilBert, RoBERTa. + +* [#5225](https://github.com/rasahq/rasa/issues/5225): Added a new CLI command `rasa export` to publish tracker events from a persistent + tracker store using an event broker. See [Export Conversations to an Event Broker](./command-line-interface.mdx#rasa-export), [Tracker Stores](./tracker-stores.mdx) + and [Event Brokers](./event-brokers.mdx) for more details. + +* [#5230](https://github.com/rasahq/rasa/issues/5230): Refactor how GPU and CPU environments are configured for TensorFlow 2.0. + + Please refer to the documentation on [Configuring TensorFlow](./tuning-your-model.mdx#configuring-tensorflow) to understand + which environment variables to set in what scenarios. A couple of examples are shown below as well: + + ```python + # This specifies to use 1024 MB of memory from GPU with logical ID 0 and 2048 MB of memory from GPU with logical ID 1 + TF_GPU_MEMORY_ALLOC="0:1024, 1:2048" + + # Specifies that at most 3 CPU threads can be used to parallelize multiple non-blocking operations + TF_INTER_OP_PARALLELISM_THREADS="3" + + # Specifies that at most 2 CPU threads can be used to parallelize a particular operation. + TF_INTRA_OP_PARALLELISM_THREADS="2" + ``` + +* [#5266](https://github.com/rasahq/rasa/issues/5266): Added a new NLU component [DIETClassifier](./components.mdx#dietclassifier) and a new policy [TEDPolicy](./policies.mdx#ted-policy). + + DIET (Dual Intent and Entity Transformer) is a multi-task architecture for intent classification and entity + recognition. You can read more about this component in the [DIETClassifier](./components.mdx#dietclassifier) documentation. + The new component will replace the `EmbeddingIntentClassifier` and the + [CRFEntityExtractor](./components.mdx#crfentityextractor) in the future. + Those two components are deprecated from now on. + See [migration guide](./migration-guide.mdx#rasa-17-to-rasa-18) for details on how to + switch to the new component. + + [TEDPolicy](./policies.mdx#ted-policy) is the new name for EmbeddingPolicy. + `EmbeddingPolicy` is deprecated from now on. + The functionality of `TEDPolicy` and `EmbeddingPolicy` is the same. + Please update your configuration file to use the new name for the policy. + +* [#663](https://github.com/rasahq/rasa/issues/663): The sentence vector of the `SpacyFeaturizer` and `MitieFeaturizer` can be calculated using max or mean pooling. + + To specify the pooling operation, set the option `pooling` for the `SpacyFeaturizer` or the `MitieFeaturizer` + in your configuration file. The default pooling operation is `mean`. The mean pooling operation also does not take + into account words, that do not have a word vector. + +### Improvements + +* [#3975](https://github.com/rasahq/rasa/issues/3975): Added command line argument `--conversation-id` to `rasa interactive`. + If the argument is not given, `conversation_id` defaults to a random uuid. + +* [#4653](https://github.com/rasahq/rasa/issues/4653): Added a new command-line argument `--init-dir` to command `rasa init` to specify + the directory in which the project is initialised. + +* [#4682](https://github.com/rasahq/rasa/issues/4682): Added support to send images with the twilio output channel. + +* [#4817](https://github.com/rasahq/rasa/issues/4817): Part of Slack sanitization: + Multiple garbled URL's in a string coming from slack will be converted into actual strings. + `Example: health check of and to health check of + eemdb.net and eemdb1.net` + +* [#5117](https://github.com/rasahq/rasa/issues/5117): New command-line argument –conversation-id will be added and wiil give the ability to + set specific conversation ID for each shell session, if not passed will be random. + +* [#5211](https://github.com/rasahq/rasa/issues/5211): Messages sent to the [Pika Event Broker](./event-brokers.mdx#pika-event-broker) are now persisted. This guarantees + the RabbitMQ will re-send previously received messages after a crash. Note that this + does not help for the case where messages are sent to an unavailable RabbitMQ instance. + +* [#5250](https://github.com/rasahq/rasa/issues/5250): Added support for mattermost connector to use bot accounts. + +* [#5266](https://github.com/rasahq/rasa/issues/5266): We updated our code to TensorFlow 2. + +* [#5317](https://github.com/rasahq/rasa/issues/5317): Events exported using `rasa export` receive a message header if published through a + `PikaEventBroker`. The header is added to the message's `BasicProperties.headers` + under the `rasa-export-process-id` key + (`rasa.core.constants.RASA_EXPORT_PROCESS_ID_HEADER_NAME`). The value is a + UUID4 generated at each call of `rasa export`. The resulting header is a key-value + pair that looks as follows: + + ```text + 'rasa-export-process-id': 'd3b3d3ffe2bd4f379ccf21214ccfb261' + ``` + +* [#5292](https://github.com/rasahq/rasa/issues/5292): Added `followlinks=True` to os.walk calls, to allow the use of symlinks in training, NLU and domain data. + +* [#4811](https://github.com/rasahq/rasa/issues/4811): Support invoking a `SlackBot` by direct messaging or `@` mentions. + +### Bugfixes + +* [#4006](https://github.com/rasahq/rasa/issues/4006): Fixed timestamp parsing warning when using DucklingHTTPExtractor + +* [#4601](https://github.com/rasahq/rasa/issues/4601): Fixed issue with `action_restart` getting overridden by `action_listen` when the [Mapping Policy](https://rasa.com/docs/rasa/2.x/policies#mapping-policy) and the + [Two-Stage Fallback Policy](https://rasa.com/docs/rasa/2.x/policies#two-stage-fallback-policy) are used together. + +* [#5201](https://github.com/rasahq/rasa/issues/5201): Fixed incorrectly raised Error encountered in pipelines with a `ResponseSelector` and NLG. + + When NLU training data is split before NLU pipeline comparison, + NLG responses were not also persisted and therefore training for a pipeline including the `ResponseSelector` would fail. + + NLG responses are now persisted along with NLU data to a `/train` directory in the `run_x/xx%_exclusion` folder. + +* [#5277](https://github.com/rasahq/rasa/issues/5277): Fixed sending custom json with Twilio channel + +### Improved Documentation + +* [#5174](https://github.com/rasahq/rasa/issues/5174): Updated the documentation to properly suggest not to explicitly add utterance actions to the domain. + +* [#5189](https://github.com/rasahq/rasa/issues/5189): Added user guide for reminders and external events, including `reminderbot` demo. + +### Miscellaneous internal changes + +* #3923, #4597, #4903, #5180, #5189, #5266, #699 + +## [1.7.4] - 2020-02-24 + +### Bugfixes + +* [#5068](https://github.com/rasahq/rasa/issues/5068): Tracker stores supporting conversation sessions (`SQLTrackerStore` and + `MongoTrackerStore`) do not save the tracker state to database immediately after + starting a new conversation session. This leads to the number of events being saved + in addition to the already-existing ones to be calculated correctly. + + This fixes `action_listen` events being saved twice at the beginning of + conversation sessions. + +## [1.7.3] - 2020-02-21 + +### Bugfixes + +* [#5231](https://github.com/rasahq/rasa/issues/5231): Fix segmentation fault when running `rasa train` or `rasa shell`. + +### Improved Documentation + +* [#5286](https://github.com/rasahq/rasa/issues/5286): Fix doc links on “Deploying your Assistant” page + +## [1.7.2] - 2020-02-13 + +### Bugfixes + +* [#5197](https://github.com/rasahq/rasa/issues/5197): Fixed incompatibility of Oracle with the [SQLTrackerStore](./tracker-stores.mdx#sqltrackerstore), by using a `Sequence` + for the primary key columns. This does not change anything for SQL databases other than Oracle. + If you are using Oracle, please create a sequence with the instructions in the [SQLTrackerStore](./tracker-stores.mdx#sqltrackerstore) docs. + +### Improved Documentation + +* [#5197](https://github.com/rasahq/rasa/issues/5197): Added section on setting up the SQLTrackerStore with Oracle + +* [#5210](https://github.com/rasahq/rasa/issues/5210): Renamed “Running the Server” page to “Configuring the HTTP API” + +## [1.7.1] - 2020-02-11 + +### Bugfixes + +* [#5106](https://github.com/rasahq/rasa/issues/5106): Fixed file loading of non proper UTF-8 story files, failing properly when checking for + story files. + +* [#5162](https://github.com/rasahq/rasa/issues/5162): Fix problem with multi-intents. + Training with multi-intents using the `CountVectorsFeaturizer` together with `EmbeddingIntentClassifier` is + working again. + +* [#5171](https://github.com/rasahq/rasa/issues/5171): Fix bug `ValueError: Cannot concatenate sparse features as sequence dimension does not match`. + + When training a Rasa model that contains responses for just some of the intents, training was failing. + Fixed the featurizers to return a consistent feature vector in case no response was given for a specific message. + +* [#5199](https://github.com/rasahq/rasa/issues/5199): If no text features are present in `EmbeddingIntentClassifier` return the intent `None`. + +* [#5216](https://github.com/rasahq/rasa/issues/5216): Resolve version conflicts: Pin version of cloudpickle to ~=1.2.0. + +## [1.7.0] - 2020-01-29 + +### Deprecations and Removals + +* [#4964](https://github.com/rasahq/rasa/issues/4964): The endpoint `/conversations//execute` is now deprecated. Instead, users should use + the `/conversations//trigger_intent` endpoint and thus trigger intents instead of actions. + +* [#4978](https://github.com/rasahq/rasa/issues/4978): Remove option `use_cls_token` from tokenizers and option `return_sequence` from featurizers. + + By default all tokenizer add a special token (`__CLS__`) to the end of the list of tokens. + This token will be used to capture the features of the whole utterance. + + The featurizers will return a matrix of size (number-of-tokens x feature-dimension) by default. + This allows to train sequence models. + However, the feature vector of the `__CLS__` token can be used to train non-sequence models. + The corresponding classifier can decide what kind of features to use. + +### Features + +* [#400](https://github.com/rasahq/rasa/issues/400): Rename `templates` key in domain to `responses`. + + `templates` key will still work for backwards compatibility but will raise a future warning. + +* [#4902](https://github.com/rasahq/rasa/issues/4902): Added a new configuration parameter, `ranking_length` to the `EmbeddingPolicy`, `EmbeddingIntentClassifier`, + and `ResponseSelector` classes. + +* [#4964](https://github.com/rasahq/rasa/issues/4964): External events and reminders now trigger intents (and entities) instead of actions. + + Add new endpoint `/conversations//trigger_intent`, which lets the user specify an intent and a + list of entities that is injected into the conversation in place of a user message. The bot then predicts and + executes a response action. + +* [#4978](https://github.com/rasahq/rasa/issues/4978): Add `ConveRTTokenizer`. + + The tokenizer should be used whenever the `ConveRTFeaturizer` is used. + + Every tokenizer now supports the following configuration options: + `intent_tokenization_flag`: Flag to check whether to split intents (default `False`). + `intent_split_symbol`: Symbol on which intent should be split (default `_`) + +### Improvements + +* [#1988](https://github.com/rasahq/rasa/issues/1988): Remove the need of specifying utter actions in the `actions` section explicitly if these actions are already + listed in the `templates` section. + +* [#4877](https://github.com/rasahq/rasa/issues/4877): Entity examples that have been extracted using an external extractor are excluded + from Markdown dumping in `MarkdownWriter.dumps()`. The excluded external extractors + are `DucklingHTTPExtractor` and `SpacyEntityExtractor`. + +* [#4902](https://github.com/rasahq/rasa/issues/4902): The `EmbeddingPolicy`, `EmbeddingIntentClassifier`, and `ResponseSelector` now by default normalize confidence + levels over the top 10 results. See [Rasa 1.6 to Rasa 1.7](./migration-guide.mdx#rasa-16-to-rasa-17) for more details. + +* [#4964](https://github.com/rasahq/rasa/issues/4964): `ReminderCancelled` can now cancel multiple reminders if no name is given. It still cancels a single + reminder if the reminder's name is specified. + +### Bugfixes + +* [#4774](https://github.com/rasahq/rasa/issues/4774): Requests to `/model/train` do not longer block other requests to the Rasa server. + +* [#4896](https://github.com/rasahq/rasa/issues/4896): Fixed default behavior of `rasa test core --evaluate-model-directory` when called without `--model`. Previously, the latest model file was used as `--model`. Now the default model directory is used instead. + + New behavior of `rasa test core --evaluate-model-directory` when given an existing file as argument for `--model`: Previously, this led to an error. Now a warning is displayed and the directory containing the given file is used as `--model`. + +* [#5040](https://github.com/rasahq/rasa/issues/5040): Updated the dependency `networkx` from 2.3.0 to 2.4.0. The old version created incompatibilities when using pip. + + There is an imcompatibility between Rasa dependecy requests 2.22.0 and the own depedency from Rasa for networkx raising errors upon pip install. There is also a bug corrected in `requirements.txt` which used `~=` instead of `==`. All of these are fixed using networkx 2.4.0. + +* [#5057](https://github.com/rasahq/rasa/issues/5057): Fixed compatibility issue with Microsoft Bot Framework Emulator if `service_url` lacked a trailing `/`. + +* [#5092](https://github.com/rasahq/rasa/issues/5092): DynamoDB tracker store decimal values will now be rounded on save. Previously values exceeding 38 digits caused an unhandled error. + +### Miscellaneous internal changes + +* #4458, #4664, #4780, #5029 + +## [1.6.2] - 2020-01-28 + +### Improvements + +* [#4994](https://github.com/rasahq/rasa/issues/4994): Switching back to a TensorFlow release which only includes CPU support to reduce the + size of the dependencies. If you want to use the TensorFlow package with GPU support, + please run `pip install tensorflow-gpu==1.15.0`. + +### Bugfixes + +* [#5111](https://github.com/rasahq/rasa/issues/5111): Fixes `Exception 'Loop' object has no attribute '_ready'` error when running + `rasa init`. + +* [#5126](https://github.com/rasahq/rasa/issues/5126): Updated the end-to-end ValueError you recieve when you have a invalid story format to point + to the updated doc link. + +## [1.6.1] - 2020-01-07 + +### Bugfixes + +* [#4989](https://github.com/rasahq/rasa/issues/4989): Use an empty domain in case a model is loaded which has no domain + (avoids errors when accessing `agent.doman.`). + +* [#4995](https://github.com/rasahq/rasa/issues/4995): Replace error message with warning in tokenizers and featurizers if default parameter not set. + +* [#5019](https://github.com/rasahq/rasa/issues/5019): Pin sanic patch version instead of minor version. Fixes sanic `_run_request_middleware()` error. + +* [#5032](https://github.com/rasahq/rasa/issues/5032): Fix wrong calculation of additional conversation events when saving the conversation. + This led to conversation events not being saved. + +* [#5032](https://github.com/rasahq/rasa/issues/5032): Fix wrong order of conversation events when pushing events to conversations via + `POST /conversations//tracker/events`. + +## [1.6.0] - 2019-12-18 + +### Deprecations and Removals + +* [#4935](https://github.com/rasahq/rasa/issues/4935): Removed `ner_features` as a feature name from `CRFEntityExtractor`, use `text_dense_features` instead. + + The following settings match the previous `NGramFeaturizer`: + + ```yaml-rasa + pipeline: + - name: 'CountVectorsFeaturizer' + analyzer: 'char_wb' + min_ngram: 3 + max_ngram: 17 + max_features: 10 + min_df: 5 + ``` + +* [#4957](https://github.com/rasahq/rasa/issues/4957): To [use custom features in the `CRFEntityExtractor`](./components.mdx#crfentityextractor) + use `text_dense_features` instead of `ner_features`. If + `text_dense_features` are present in the feature set, the `CRFEntityExtractor` will automatically make use of + them. Just make sure to add a dense featurizer in front of the `CRFEntityExtractor` in your pipeline and set the + flag `return_sequence` to `True` for that featurizer. + +* [#4990](https://github.com/rasahq/rasa/issues/4990): Deprecated `Agent.continue_training`. Instead, a model should be retrained. + +* [#684](https://github.com/rasahq/rasa/issues/684): Specifying lookup tables directly in the NLU file is now deprecated. Please specify + them in an external file. + +### Features + +* [#4795](https://github.com/rasahq/rasa/issues/4795): Replaced the warnings about missing templates, intents etc. in validator.py by debug messages. + +* [#4830](https://github.com/rasahq/rasa/issues/4830): Added conversation sessions to trackers. + + A conversation session represents the dialog between the assistant and a user. + Conversation sessions can begin in three ways: 1. the user begins the conversation + with the assistant, 2. the user sends their first message after a configurable period + of inactivity, or 3. a manual session start is triggered with the `/session_start` + intent message. The period of inactivity after which a new conversation session is + triggered is defined in the domain using the `session_expiration_time` key in the + `session_config` section. The introduction of conversation sessions comprises the + following changes: + + * Added a new event `SessionStarted` that marks the beginning of a new conversation + session. + + * Added a new default action `ActionSessionStart`. This action takes all + `SlotSet` events from the previous session and applies it to the next session. + + * Added a new default intent `session_start` which triggers the start of a new + conversation session. + + * `SQLTrackerStore` and `MongoTrackerStore` only retrieve + events from the last session from the database. + + :::note + The session behavior is disabled for existing projects, i.e. existing domains + without session config section. + + ::: + +* [#4935](https://github.com/rasahq/rasa/issues/4935): Preparation for an upcoming change in the `EmbeddingIntentClassifier`: + + Add option `use_cls_token` to all tokenizers. If it is set to `True`, the token `__CLS__` will be added to + the end of the list of tokens. Default is set to `False`. No need to change the default value for now. + + Add option `return_sequence` to all featurizers. By default all featurizers return a matrix of size + (1 x feature-dimension). If the option `return_sequence` is set to `True`, the corresponding featurizer will return + a matrix of size (token-length x feature-dimension). See [Text Featurizers](./components.mdx#featurizers). + Default value is set to `False`. However, you might want to set it to `True` if you want to use custom features + in the `CRFEntityExtractor`. + See [passing custom features to the `CRFEntityExtractor`](./components.mdx#crfentityextractor) + + Changed some featurizers to use sparse features, which should reduce memory usage with large amounts of training data significantly. + Read more: [Text Featurizers](./components.mdx#featurizers) . + + :::caution + These changes break model compatibility. You will need to retrain your old models! + + ::: + +### Improvements + +* [#3549](https://github.com/rasahq/rasa/issues/3549): Added `--no-plot` option for `rasa test` command, which disables rendering of confusion matrix and histogram. By default plots will be rendered. + +* [#4086](https://github.com/rasahq/rasa/issues/4086): If matplotlib couldn't set up a default backend, it will be set automatically to TkAgg/Agg one + +* [#4647](https://github.com/rasahq/rasa/issues/4647): Add the option `\`random_seed\`` to the `\`rasa data split nlu\`` command to generate + reproducible train/test splits. + +* [#4734](https://github.com/rasahq/rasa/issues/4734): Changed `url` `__init__()` arguments for custom tracker stores to `host` to reflect the `__init__` arguments of + currently supported tracker stores. Note that in `endpoints.yml`, these are still declared as `url`. + +* [#4751](https://github.com/rasahq/rasa/issues/4751): The `kafka-python` dependency has become as an “extra” dependency. To use the + `KafkaEventConsumer`, `rasa` has to be installed with the `[kafka]` option, i.e. + + ```bash + $ pip install rasa[kafka] + ``` + +* [#4801](https://github.com/rasahq/rasa/issues/4801): Allow creation of natural language interpreter and generator by classname reference + in `endpoints.yml`. + +* [#4834](https://github.com/rasahq/rasa/issues/4834): Made it explicit that interactive learning does not work with NLU-only models. + + Interactive learning no longer trains NLU-only models if no model is provided + and no core data is provided. + +* [#4899](https://github.com/rasahq/rasa/issues/4899): The `intent_report.json` created by `rasa test` now creates an extra field + `confused_with` for each intent. This is a dictionary containing the names of + the most common false positives when this intent should be predicted, and the + number of such false positives. + +* [#4976](https://github.com/rasahq/rasa/issues/4976): `rasa test nlu --cross-validation` now also includes an evaluation of the response selector. + As a result, the train and test F1-score, accuracy and precision is logged for the response selector. + A report is also generated in the `results` folder by the name `response_selection_report.json` + +### Bugfixes + +* [#4635](https://github.com/rasahq/rasa/issues/4635): If a `wait_time_between_pulls` is configured for the model server in `endpoints.yml`, + this will be used instead of the default one when running Rasa X. + +* [#4759](https://github.com/rasahq/rasa/issues/4759): Training Luis data with `luis_schema_version` higher than 4.x.x will show a warning instead of throwing an exception. + +* [#4799](https://github.com/rasahq/rasa/issues/4799): Running `rasa interactive` with no NLU data now works, with the functionality of `rasa interactive core`. + +* [#4917](https://github.com/rasahq/rasa/issues/4917): When loading models from S3, namespaces (folders within a bucket) are now respected. + Previously, this would result in an error upon loading the model. + +* [#4925](https://github.com/rasahq/rasa/issues/4925): “rasa init” will ask if user wants to train a model + +* [#4942](https://github.com/rasahq/rasa/issues/4942): Pin `multidict` dependency to 4.6.1 to prevent sanic from breaking, + see [the Sanic GitHub issue](https://github.com/huge-success/sanic/issues/1729 "Sanic Github Issue #1729 about Multidict update breaking Sanic") for more info. + +* [#4985](https://github.com/rasahq/rasa/issues/4985): Fix errors during training and testing of `ResponseSelector`. + +## [1.5.3] - 2019-12-11 + +### Improvements + +* [#4933](https://github.com/rasahq/rasa/issues/4933): Improved error message that appears when an incorrect parameter is passed to a policy. + +### Bugfixes + +* [#4914](https://github.com/rasahq/rasa/issues/4914): Added `rasa/nlu/schemas/config.yml` to wheel package + +* [#4942](https://github.com/rasahq/rasa/issues/4942): Pin `multidict` dependency to 4.6.1 to prevent sanic from breaking, + see [the Sanic GitHub issue](https://github.com/huge-success/sanic/issues/1729 "Sanic Github Issue #1729 about Multidict update breaking Sanic") + +## [1.5.2] - 2019-12-09 + +### Improvements + +* [#3684](https://github.com/rasahq/rasa/issues/3684): `rasa interactive` will skip the story visualization of training stories in case + there are more than 200 stories. Stories created during interactive learning will be + visualized as before. + +* [#4792](https://github.com/rasahq/rasa/issues/4792): The log level for SocketIO loggers, including `websockets.protocol`, `engineio.server`, + and `socketio.server`, is now handled by the `LOG_LEVEL_LIBRARIES` environment variable, + where the default log level is `ERROR`. + +* [#4873](https://github.com/rasahq/rasa/issues/4873): Updated all example bots and documentation to use the updated `dispatcher.utter_message()` method from rasa-sdk==1.5.0. + +### Bugfixes + +* [#3684](https://github.com/rasahq/rasa/issues/3684): `rasa interactive` will not load training stories in case the visualization is + skipped. + +* [#4789](https://github.com/rasahq/rasa/issues/4789): Fixed error where spacy models where not found in the docker images. + +* [#4802](https://github.com/rasahq/rasa/issues/4802): Fixed unnecessary `kwargs` unpacking in `rasa.test.test_core` call in `rasa.test.test` function. + +* [#4898](https://github.com/rasahq/rasa/issues/4898): Training data files now get loaded in the same order (especially relevant to subdirectories) each time to ensure training consistency when using a random seed. + +* [#4918](https://github.com/rasahq/rasa/issues/4918): Locks for tickets in `LockStore` are immediately issued without a redundant + check for their availability. + +### Improved Documentation + +* [#4844](https://github.com/rasahq/rasa/issues/4844): Added `towncrier` to automatically collect changelog entries. + +* [#4869](https://github.com/rasahq/rasa/issues/4869): Document the pipeline for `pretrained_embeddings_convert` in the pre-configured pipelines section. + +* [#4894](https://github.com/rasahq/rasa/issues/4894): `Proactively Reaching Out to the User Using Actions` now correctly links to the + endpoint specification. + +## [1.5.1] - 2019-11-27 + +### Improvements + +* When NLU training data is dumped as Markdown file the intents are not longer ordered + alphabetically, but in the original order of given training data + +### Bugfixes + +* End to end stories now support literal payloads which specify entities, e.g. + `greet: /greet{"name": "John"}` + +* Slots will be correctly interpolated if there are lists in custom response templates. + +* Fixed compatibility issues with `rasa-sdk` `1.5` + +* Updated `/status` endpoint to show correct path to model archive + +## [1.5.0] - 2019-11-26 + +### Features + +* Added data validator that checks if domain object returned is empty. If so, exit early + from the command `rasa data validate`. + +* Added the KeywordIntentClassifier. + +* Added documentation for `AugmentedMemoizationPolicy`. + +* Fall back to `InMemoryTrackerStore` in case there is any problem with the current + tracker store. + +* Arbitrary metadata can now be attached to any `Event` subclass. The data must be + stored under the `metadata` key when reading the event from a JSON object or + dictionary. + +* Add command line argument `rasa x --config CONFIG`, to specify path to the policy + and NLU pipeline configuration of your bot (default: `config.yml`). + +* Added a new NLU featurizer - `ConveRTFeaturizer` based on [ConveRT](https://github.com/PolyAI-LDN/polyai-models) model released by PolyAI. + +* Added a new preconfigured pipeline - `pretrained_embeddings_convert`. + +### Improvements + +* Do not retrain the entire Core model if only the `templates` section of the domain + is changed. + +* Upgraded `jsonschema` version. + +### Deprecations and Removals + +* Remove duplicate messages when creating training data (issues/1446). + +### Bugfixes + +* `MultiProjectImporter` now imports files in the order of the import statements + +* Fixed server hanging forever on leaving `rasa shell` before first message + +* Fixed rasa init showing traceback error when user does Keyboard Interrupt before choosing a project path + +* `CountVectorsFeaturizer` featurizes intents only if its analyzer is set to `word` + +* Fixed bug where facebooks generic template was not rendered when buttons were `None` + +* Fixed default intents unnecessarily raising undefined parsing error + +## [1.4.6] - 2019-11-22 + +### Bugfixes + +* Fixed Rasa X not working when any tracker store was configured for Rasa. + +* Use the matplotlib backend `agg` in case the `tkinter` package is not installed. + +## [1.4.5] - 2019-11-14 + +### Bugfixes + +* NLU-only models no longer throw warnings about parsing features not defined in the domain + +* Fixed bug that stopped Dockerfiles from building version 1.4.4. + +* Fixed format guessing for e2e stories with intent restated as `/intent` + +## [1.4.4] - 2019-11-13 + +### Features + +* `PikaEventProducer` adds the RabbitMQ `App ID` message property to published + messages with the value of the `RASA_ENVIRONMENT` environment variable. The + message property will not be assigned if this environment variable isn't set. + +### Improvements + +* Updated Mattermost connector documentation to be more clear. + +* Updated format strings to f-strings where appropriate. + +* Updated tensorflow requirement to `1.15.0` + +* Dump domain using UTF-8 (to avoid `\\UXXXX` sequences in the dumped files) + +### Bugfixes + +* Fixed exporting NLU training data in `json` format from `rasa interactive` + +* Fixed numpy deprecation warnings + +## [1.4.3] - 2019-10-29 + +### Bugfixes + +* Fixed `Connection reset by peer` errors and bot response delays when using the + RabbitMQ event broker. + +## [1.4.2] - 2019-10-28 + +### Deprecations and Removals + +* TensorFlow deprecation warnings are no longer shown when running `rasa x` + +### Bugfixes + +* Fixed `'Namespace' object has no attribute 'persist_nlu_data'` error during + interactive learning + +* Pinned networkx~=2.3.0 to fix visualization in rasa interactive and Rasa X + +* Fixed `No model found` error when using `rasa run actions` with “actions” + as a directory. + +## [1.4.1] - 2019-10-22 + +Regression: changes from `1.2.12` were missing from `1.4.0`, readded them + +## [1.4.0] - 2019-10-19 + +### Features + +* add flag to CLI to persist NLU training data if needed + +* log a warning if the `Interpreter` picks up an intent or an entity that does not + exist in the domain file. + +* added `DynamoTrackerStore` to support persistence of agents running on AWS + +* added docstrings for `TrackerStore` classes + +* added buttons and images to mattermost. + +* `CRFEntityExtractor` updated to accept arbitrary token-level features like word + vectors (issues/4214) + +* `SpacyFeaturizer` updated to add `ner_features` for `CRFEntityExtractor` + +* Sanitizing incoming messages from slack to remove slack formatting like `` + or `` and substitute it with original content + +* Added the ability to configure the number of Sanic worker processes in the HTTP + server (`rasa.server`) and input channel server + (`rasa.core.agent.handle_channels()`). The number of workers can be set using the + environment variable `SANIC_WORKERS` (default: 1). A value of >1 is allowed only in + combination with `RedisLockStore` as the lock store. + +* Botframework channel can handle uploaded files in `UserMessage` metadata. + +* Added data validator that checks there is no duplicated example data across multiples intents + +### Improvements + +* Unknown sections in markdown format (NLU data) are not ignored anymore, but instead an error is raised. + +* It is now easier to add metadata to a `UserMessage` in existing channels. + You can do so by overwriting the method `get_metadata`. The return value of this + method will be passed to the `UserMessage` object. + +* Tests can now be run in parallel + +* Serialise `DialogueStateTracker` as json instead of pickle. **DEPRECATION warning**: + Deserialisation of pickled trackers will be deprecated in version 2.0. For now, + trackers are still loaded from pickle but will be dumped as json in any subsequent + save operations. + +* Event brokers are now also passed to custom tracker stores (using the `event_broker` parameter) + +* Don't run the Rasa Docker image as `root`. + +* Use multi-stage builds to reduce the size of the Rasa Docker image. + +* Updated the `/status` api route to use the actual model file location instead of the `tmp` location. + +### Deprecations and Removals + +* **Removed Python 3.5 support** + +### Bugfixes + +* fixed missing `tkinter` dependency for running tests on Ubuntu + +* fixed issue with `conversation` JSON serialization + +* fixed the hanging HTTP call with `ner_duckling_http` pipeline + +* fixed Interactive Learning intent payload messages saving in nlu files + +* fixed DucklingHTTPExtractor dimensions by actually applying to the request + +## [1.3.10] - 2019-10-18 + +### Features + +* Can now pass a package as an argument to the `--actions` parameter of the + `rasa run actions` command. + +### Bugfixes + +* Fixed visualization of stories with entities which led to a failing + visualization in Rasa X + +## [1.3.9] - 2019-10-10 + +### Features + +* Port of 1.2.10 (support for RabbitMQ TLS authentication and `port` key in + event broker endpoint config). + +* Port of 1.2.11 (support for passing a CA file for SSL certificate verification via the + –ssl-ca-file flag). + +### Bugfixes + +* Fixed the hanging HTTP call with `ner_duckling_http` pipeline. + +* Fixed text processing of `intent` attribute inside `CountVectorFeaturizer`. + +* Fixed `argument of type 'NoneType' is not iterable` when using `rasa shell`, + `rasa interactive` / `rasa run` + +## [1.3.8] - 2019-10-08 + +### Improvements + +* Policies now only get imported if they are actually used. This removes + TensorFlow warnings when starting Rasa X + +### Bugfixes + +* Fixed error `Object of type 'MaxHistoryTrackerFeaturizer' is not JSON serializable` + when running `rasa train core` + +* Default channel `send_` methods no longer support kwargs as they caused issues in incompatible channels + +## [1.3.7] - 2019-09-27 + +### Bugfixes + +* re-added TLS, SRV dependencies for PyMongo + +* socketio can now be run without turning on the `--enable-api` flag + +* MappingPolicy no longer fails when the latest action doesn't have a policy + +## [1.3.6] - 2019-09-21 + +### Features + +* Added the ability for users to specify a conversation id to send a message to when + using the `RasaChat` input channel. + +## [1.3.5] - 2019-09-20 + +### Bugfixes + +* Fixed issue where `rasa init` would fail without spaCy being installed + +## [1.3.4] - 2019-09-20 + +### Features + +* Added the ability to set the `backlog` parameter in Sanics `run()` method using + the `SANIC_BACKLOG` environment variable. This parameter sets the + number of unaccepted connections the server allows before refusing new + connections. A default value of 100 is used if the variable is not set. + +* Status endpoint (`/status`) now also returns the number of training processes currently running + +### Bugfixes + +* Added the ability to properly deal with spaCy `Doc`-objects created on + empty strings as discussed in + [issue #4445](https://github.com/RasaHQ/rasa/issues/4445 "Rasa issue #4445: Handling spaCy objects on empty strings"). + Only training samples that actually bear content are sent to `self.nlp.pipe` + for every given attribute. Non-content-bearing samples are converted to empty + `Doc`-objects. The resulting lists are merged with their preserved order and + properly returned. + +* asyncio warnings are now only printed if the callback takes more than 100ms + (up from 1ms). + +* `agent.load_model_from_server` no longer affects logging. + +### Improvements + +* The endpoint `POST /model/train` no longer supports specifying an output directory + for the trained model using the field `out`. Instead you can choose whether you + want to save the trained model in the default model directory (`models`) + (default behavior) or in a temporary directory by specifying the + `save_to_default_model_directory` field in the training request. + +## [1.3.3] - 2019-09-13 + +### Bugfixes + +* Added a check to avoid training `CountVectorizer` for a particular + attribute of a message if no text is provided for that attribute across + the training data. + +* Default one-hot representation for label featurization inside `EmbeddingIntentClassifier` if label features don't exist. + +* Policy ensemble no longer incorrectly wrings “missing mapping policy” when + mapping policy is present. + +* “text” from `utter_custom_json` now correctly saved to tracker when using telegram channel + +### Deprecations and Removals + +* Removed computation of `intent_spacy_doc`. As a result, none of the spacy components process intents now. + +## [1.3.2] - 2019-09-10 + +### Bugfixes + +* SQL tracker events are retrieved ordered by timestamps. This fixes interactive + learning events being shown in the wrong order. + +## [1.3.1] - 2019-09-09 + +### Improvements + +* Pin gast to == 0.2.2 + +## [1.3.0] - 2019-09-05 + +### Features + +* Added option to persist nlu training data (default: False) + +* option to save stories in e2e format for interactive learning + +* bot messages contain the `timestamp` of the `BotUttered` event, which can be used in channels + +* `FallbackPolicy` can now be configured to trigger when the difference between confidences of two predicted intents is too narrow + +* experimental training data importer which supports training with data of multiple + sub bots. Please see the + [docs](./training-data-importers.mdx) for more + information. + +* throw error during training when triggers are defined in the domain without + `MappingPolicy` being present in the policy ensemble + +* The tracker is now available within the interpreter's `parse` method, giving the + ability to create interpreter classes that use the tracker state (eg. slot values) + during the parsing of the message. More details on motivation of this change see + issues/3015. + +* add example bot `knowledgebasebot` to showcase the usage of `ActionQueryKnowledgeBase` + +* `softmax` starspace loss for both `EmbeddingPolicy` and `EmbeddingIntentClassifier` + +* `balanced` batching strategy for both `EmbeddingPolicy` and `EmbeddingIntentClassifier` + +* `max_history` parameter for `EmbeddingPolicy` + +* Successful predictions of the NER are written to a file if `--successes` is set when running `rasa test nlu` + +* Incorrect predictions of the NER are written to a file by default. You can disable it via `--no-errors`. + +* New NLU component `ResponseSelector` added for the task of response selection + +* Message data attribute can contain two more keys - `response_key`, `response` depending on the training data + +* New action type implemented by `ActionRetrieveResponse` class and identified with `response_` prefix + +* Vocabulary sharing inside `CountVectorsFeaturizer` with `use_shared_vocab` flag. If set to True, vocabulary of corpus is shared between text, intent and response attributes of message + +* Added an option to share the hidden layer weights of text input and label input inside `EmbeddingIntentClassifier` using the flag `share_hidden_layers` + +* New type of training data file in NLU which stores response phrases for response selection task. + +* Add flag `intent_split_symbol` and `intent_tokenization_flag` to all `WhitespaceTokenizer`, `JiebaTokenizer` and `SpacyTokenizer` + +* Added evaluation for response selector. Creates a report `response_selection_report.json` inside `--out` directory. + +* argument `--config-endpoint` to specify the URL from which `rasa x` pulls + the runtime configuration (endpoints and credentials) + +* `LockStore` class storing instances of `TicketLock` for every `conversation_id` + +* environment variables `SQL_POOL_SIZE` (default: 50) and `SQL_MAX_OVERFLOW` + (default: 100) can be set to control the pool size and maximum pool overflow for + `SQLTrackerStore` when used with the `postgresql` dialect + +* Add a bot_challenge intent and a utter_iamabot action to all example projects and the rasa init bot. + +* Allow sending attachments when using the socketio channel + +* `rasa data validate` will fail with a non-zero exit code if validation fails + +### Improvements + +* added character-level `CountVectorsFeaturizer` with empirically found parameters + into the `supervised_embeddings` NLU pipeline template + +* NLU evaluations now also stores its output in the output directory like the core evaluation + +* show warning in case a default path is used instead of a provided, invalid path + +* compare mode of `rasa train core` allows the whole core config comparison, + naming style of models trained for comparison is changed (this is a breaking change) + +* pika keeps a single connection open, instead of open and closing on each incoming event + +* `RasaChatInput` fetches the public key from the Rasa X API. The key is used to + decode the bearer token containing the conversation ID. This requires + `rasa-x>=0.20.2`. + +* more specific exception message when loading custom components depending on whether component's path or + class name is invalid or can't be found in the global namespace + +* change priorities so that the `MemoizationPolicy` has higher priority than the `MappingPolicy` + +* substitute LSTM with Transformer in `EmbeddingPolicy` + +* `EmbeddingPolicy` can now use `MaxHistoryTrackerFeaturizer` + +* non zero `evaluate_on_num_examples` in `EmbeddingPolicy` + and `EmbeddingIntentClassifier` is the size of + hold out validation set that is excluded from training data + +* defaults parameters and architectures for both `EmbeddingPolicy` and + `EmbeddingIntentClassifier` are changed (this is a breaking change) + +* evaluation of NER does not include 'no-entity' anymore + +* `--successes` for `rasa test nlu` is now boolean values. If set incorrect/successful predictions + are saved in a file. + +* `--errors` is renamed to `--no-errors` and is now a boolean value. By default incorrect predictions are saved + in a file. If `--no-errors` is set predictions are not written to a file. + +* Remove `label_tokenization_flag` and `label_split_symbol` from `EmbeddingIntentClassifier`. Instead move these parameters to `Tokenizers`. + +* Process features of all attributes of a message, i.e. - text, intent and response inside the respective component itself. For e.g. - intent of a message is now tokenized inside the tokenizer itself. + +* Deprecate `as_markdown` and `as_json` in favour of `nlu_as_markdown` and `nlu_as_json` respectively. + +* pin python-engineio >= 3.9.3 + +* update python-socketio req to >= 4.3.1 + +### Bugfixes + +* `rasa test nlu` with a folder of configuration files + +* `MappingPolicy` standard featurizer is set to `None` + +* Removed `text` parameter from send_attachment function in slack.py to avoid duplication of text output to slackbot + +* server `/status` endpoint reports status when an NLU-only model is loaded + +### Deprecations and Removals + +* Removed `--report` argument from `rasa test nlu`. All output files are stored in the `--out` directory. + +## [1.2.12] - 2019-10-16 + +### Features + +* Support for transit encryption with Redis via `use_ssl: True` in the tracker store config in endpoints.yml + +## [1.2.11] - 2019-10-09 + +### Features + +* Support for passing a CA file for SSL certificate verification via the + –ssl-ca-file flag + +## [1.2.10] - 2019-10-08 + +### Features + +* Added support for RabbitMQ TLS authentication. The following environment variables + need to be set: + `RABBITMQ_SSL_CLIENT_CERTIFICATE` - path to the SSL client certificate (required) + `RABBITMQ_SSL_CLIENT_KEY` - path to the SSL client key (required) + `RABBITMQ_SSL_CA_FILE` - path to the SSL CA file (optional, for certificate + verification) + `RABBITMQ_SSL_KEY_PASSWORD` - SSL private key password (optional) + +* Added ability to define the RabbitMQ port using the `port` key in the + `event_broker` endpoint config. + +## [1.2.9] - 2019-09-17 + +### Bugfixes + +* Correctly pass SSL flag values to x CLI command (backport of + +## [1.2.8] - 2019-09-10 + +### Bugfixes + +* SQL tracker events are retrieved ordered by timestamps. This fixes interactive + learning events being shown in the wrong order. Backport of `1.3.2` patch + (PR #4427). + +## [1.2.7] - 2019-09-02 + +### Bugfixes + +* Added `query` dictionary argument to `SQLTrackerStore` which will be appended + to the SQL connection URL as query parameters. + +## [1.2.6] - 2019-09-02 + +### Bugfixes + +* fixed bug that occurred when sending template `elements` through a channel that doesn't support them + +## [1.2.5] - 2019-08-26 + +### Features + +* SSL support for `rasa run` command. Certificate can be specified using + `--ssl-certificate` and `--ssl-keyfile`. + +### Bugfixes + +* made default augmentation value consistent across repo + +* `'/restart'` will now also restart the bot if the tracker is paused + +## [1.2.4] - 2019-08-23 + +### Bugfixes + +* the `SocketIO` input channel now allows accesses from other origins + (fixes `SocketIO` channel on Rasa X) + +## [1.2.3] - 2019-08-15 + +### Improvements + +* messages with multiple entities are now handled properly with e2e evaluation + +* `data/test_evaluations/end_to_end_story.md` was re-written in the + restaurantbot domain + +## [1.2.3] - 2019-08-15 + +### Improvements + +* messages with multiple entities are now handled properly with e2e evaluation + +* `data/test_evaluations/end_to_end_story.md` was re-written in the restaurantbot domain + +### Bugfixes + +* Free text input was not allowed in the Rasa shell when the response template + contained buttons, which has now been fixed. + +## [1.2.2] - 2019-08-07 + +### Bugfixes + +* `UserUttered` events always got the same timestamp + +## [1.2.1] - 2019-08-06 + +### Features + +* Docs now have an `EDIT THIS PAGE` button + +### Bugfixes + +* `Flood control exceeded` error in Telegram connector which happened because the + webhook was set twice + +## [1.2.0] - 2019-08-01 + +### Features + +* add root route to server started without `--enable-api` parameter + +* add `--evaluate-model-directory` to `rasa test core` to evaluate models + from `rasa train core -c ` + +* option to send messages to the user by calling + `POST /conversations/{conversation_id}/execute` + +### Improvements + +* `Agent.update_model()` and `Agent.handle_message()` now work without needing to set a domain + or a policy ensemble + +* Update pytype to `2019.7.11` + +* new event broker class: `SQLProducer`. This event broker is now used when running locally with + Rasa X + +* API requests are not longer logged to `rasa_core.log` by default in order to avoid + problems when running on OpenShift (use `--log-file rasa_core.log` to retain the + old behavior) + +* `metadata` attribute added to `UserMessage` + +### Bugfixes + +* `rasa test core` can handle compressed model files + +* rasa can handle story files containing multi line comments + +* template will retain { if escaped with {. e.g. {{“foo”: {bar}}} will result in {“foo”: “replaced value”} + +## [1.1.8] - 2019-07-25 + +### Features + +* `TrainingFileImporter` interface to support customizing the process of loading + training data + +* fill slots for custom templates + +### Improvements + +* `Agent.update_model()` and `Agent.handle_message()` now work without needing to set a domain + or a policy ensemble + +* update pytype to `2019.7.11` + +### Bugfixes + +* interactive learning bug where reverted user utterances were dumped to training data + +* added timeout to terminal input channel to avoid freezing input in case of server + errors + +* fill slots for image, buttons, quick_replies and attachments in templates + +* `rasa train core` in comparison mode stores the model files compressed (`tar.gz` files) + +* slot setting in interactive learning with the TwoStageFallbackPolicy + +## [1.1.7] - 2019-07-18 + +### Features + +* added optional pymongo dependencies `[tls, srv]` to `requirements.txt` for better mongodb support + +* `case_sensitive` option added to `WhiteSpaceTokenizer` with `true` as default. + +### Bugfixes + +* validation no longer throws an error during interactive learning + +* fixed wrong cleaning of `use_entities` in case it was a list and not `True` + +* updated the server endpoint `/model/parse` to handle also messages with the intent prefix + +* fixed bug where “No model found” message appeared after successfully running the bot + +* debug logs now print to `rasa_core.log` when running `rasa x -vv` or `rasa run -vv` + +## [1.1.6] - 2019-07-12 + +### Features + +* rest channel supports setting a message's input_channel through a field + `input_channel` in the request body + +### Improvements + +* recommended syntax for empty `use_entities` and `ignore_entities` in the domain file + has been updated from `False` or `None` to an empty list (`[]`) + +### Bugfixes + +* `rasa run` without `--enable-api` does not require a local model anymore + +* using `rasa run` with `--enable-api` to run a server now prints + “running Rasa server” instead of “running Rasa Core server” + +* actions, intents, and utterances created in `rasa interactive` can no longer be empty + +## [1.1.5] - 2019-07-10 + +### Features + +* debug logging now tells you which tracker store is connected + +* the response of `/model/train` now includes a response header for the trained model filename + +* `Validator` class to help developing by checking if the files have any errors + +* project's code is now linted using flake8 + +* `info` log when credentials were provided for multiple channels and channel in + `--connector` argument was specified at the same time + +* validate export paths in interactive learning + +### Improvements + +* deprecate `rasa.core.agent.handle_channels(...)\`. Please use \`\`rasa.run(...)` + or `rasa.core.run.configure_app` instead. + +* `Agent.load()` also accepts `tar.gz` model file + +### Deprecations and Removals + +* revert the stripping of trailing slashes in endpoint URLs since this can lead to + problems in case the trailing slash is actually wanted + +* starter packs were removed from Github and are therefore no longer tested by Travis script + +### Bugfixes + +* all temporal model files are now deleted after stopping the Rasa server + +* `rasa shell nlu` now outputs unicode characters instead of `\\uxxxx` codes + +* fixed PUT /model with model_server by deserializing the model_server to + EndpointConfig. + +* `x in AnySlotDict` is now `True` for any `x`, which fixes empty slot warnings in + interactive learning + +* `rasa train` now also includes NLU files in other formats than the Rasa format + +* `rasa train core` no longer crashes without a `--domain` arg + +* `rasa interactive` now looks for endpoints in `endpoints.yml` if no `--endpoints` arg is passed + +* custom files, e.g. custom components and channels, load correctly when using + the command line interface + +* `MappingPolicy` now works correctly when used as part of a PolicyEnsemble + +## [1.1.4] - 2019-06-18 + +### Features + +* unfeaturize single entities + +* added agent readiness check to the `/status` resource + +### Improvements + +* removed leading underscore from name of '_create_initial_project' function. + +### Bugfixes + +* fixed bug where facebook quick replies were not rendering + +* take FB quick reply payload rather than text as input + +* fixed bug where training_data path in metadata.json was an absolute path + +## [1.1.3] - 2019-06-14 + +### Bugfixes + +* fixed any inconsistent type annotations in code and some bugs revealed by + type checker + +## [1.1.2] - 2019-06-13 + +### Bugfixes + +* fixed duplicate events appearing in tracker when using a PostgreSQL tracker store + +## [1.1.1] - 2019-06-13 + +### Bugfixes + +* fixed compatibility with Rasa SDK + +* bot responses can contain `custom` messages besides other message types + +## [1.1.0] - 2019-06-13 + +### Features + +* nlu configs can now be directly compared for performance on a dataset + in `rasa test nlu` + +### Improvements + +* update the tracker in interactive learning through reverting and appending events + instead of replacing the tracker + +* `POST /conversations/{conversation_id}/tracker/events` supports a list of events + +### Bugfixes + +* fixed creation of `RasaNLUHttpInterpreter` + +* form actions are included in domain warnings + +* default actions, which are overriden by custom actions and are listed in the + domain are excluded from domain warnings + +* SQL `data` column type to `Text` for compatibility with MySQL + +* non-featurizer training parameters don't break SklearnPolicy anymore + +## [1.0.9] - 2019-06-10 + +### Improvements + +* revert PR #3739 (as this is a breaking change): set `PikaProducer` and + `KafkaProducer` default queues back to `rasa_core_events` + +## [1.0.8] - 2019-06-10 + +### Features + +* support for specifying full database urls in the `SQLTrackerStore` configuration + +* maximum number of predictions can be set via the environment variable + `MAX_NUMBER_OF_PREDICTIONS` (default is 10) + +### Improvements + +* default `PikaProducer` and `KafkaProducer` queues to `rasa_production_events` + +* exclude unfeaturized slots from domain warnings + +### Bugfixes + +* loading of additional training data with the `SkillSelector` + +* strip trailing slashes in endpoint URLs + +## [1.0.7] - 2019-06-06 + +### Features + +* added argument `--rasa-x-port` to specify the port of Rasa X when running Rasa X locally via `rasa x` + +### Bugfixes + +* slack notifications from bots correctly render text + +* fixed usage of `--log-file` argument for `rasa run` and `rasa shell` + +* check if correct tracker store is configured in local mode + +## [1.0.6] - 2019-06-03 + +### Bugfixes + +* fixed backwards incompatible utils changes + +## [1.0.5] - 2019-06-03 + +### Bugfixes + +* fixed spacy being a required dependency (regression) + +## [1.0.4] - 2019-06-03 + +### Features + +* automatic creation of index on the `sender_id` column when using an SQL + tracker store. If you have an existing data and you are running into performance + issues, please make sure to add an index manually using + `CREATE INDEX event_idx_sender_id ON events (sender_id);`. + +### Improvements + +* NLU evaluation in cross-validation mode now also provides intent/entity reports, + confusion matrix, etc. + +## [1.0.3] - 2019-05-30 + +### Bugfixes + +* non-ascii characters render correctly in stories generated from interactive learning + +* validate domain file before usage, e.g. print proper error messages if domain file + is invalid instead of raising errors + +## [1.0.2] - 2019-05-29 + +### Features + +* added `domain_warnings()` method to `Domain` which returns a dict containing the + diff between supplied {actions, intents, entities, slots} and what's contained in the + domain + +### Bugfixes + +* fix lookup table files failed to load issues/3622 + +* buttons can now be properly selected during cmdline chat or when in interactive learning + +* set slots correctly when events are added through the API + +* mapping policy no longer ignores NLU threshold + +* mapping policy priority is correctly persisted + +## [1.0.1] - 2019-05-21 + +### Bugfixes + +* updated installation command in docs for Rasa X + +## [1.0.0] - 2019-05-21 + +### Features + +* added arguments to set the file paths for interactive training + +* added quick reply representation for command-line output + +* added option to specify custom button type for Facebook buttons + +* added tracker store persisting trackers into a SQL database + (`SQLTrackerStore`) + +* added rasa command line interface and API + +* Rasa HTTP training endpoint at `POST /jobs`. This endpoint + will train a combined Rasa Core and NLU model + +* `ReminderCancelled(action_name)` event to cancel given action_name reminder + for current user + +* Rasa HTTP intent evaluation endpoint at `POST /intentEvaluation`. + This endpoints performs an intent evaluation of a Rasa model + +* option to create template for new utterance action in `interactive learning` + +* you can now choose actions previously created in the same session + in `interactive learning` + +* add formatter 'black' + +* channel-specific utterances via the `- "channel":` key in utterance templates + +* arbitrary json messages via the `- "custom":` key in utterance templates and + via `utter_custom_json()` method in custom actions + +* support to load sub skills (domain, stories, nlu data) + +* support to select which sub skills to load through `import` section in + `config.yml` + +* support for spaCy 2.1 + +* a model for an agent can now also be loaded from a remote storage + +* log level can be set via environment variable `LOG_LEVEL` + +* add `--store-uncompressed` to train command to not compress Rasa model + +* log level of libraries, such as tensorflow, can be set via environment variable `LOG_LEVEL_LIBRARIES` + +* if no spaCy model is linked upon building a spaCy pipeline, an appropriate error message + is now raised with instructions for linking one + +### Improvements + +* renamed all CLI parameters containing any `_` to use dashes `-` instead (GNU standard) + +* renamed `rasa_core` package to `rasa.core` + +* for interactive learning only include manually annotated and ner_crf entities in nlu export + +* made `message_id` an additional argument to `interpreter.parse` + +* changed removing punctuation logic in `WhitespaceTokenizer` + +* `training_processes` in the Rasa NLU data router have been renamed to `worker_processes` + +* created a common utils package `rasa.utils` for nlu and core, common methods like `read_yaml` moved there + +* removed `--num_threads` from run command (server will be asynchronous but + running in a single thread) + +* the `_check_token()` method in `RasaChat` now authenticates against `/auth/verify` instead of `/user` + +* removed `--pre_load` from run command (Rasa NLU server will just have a maximum of one model and that model will be + loaded by default) + +* changed file format of a stored trained model from the Rasa NLU server to `tar.gz` + +* train command uses fallback config if an invalid config is given + +* test command now compares multiple models if a list of model files is provided for the argument `--model` + +* Merged rasa.core and rasa.nlu server into a single server. See swagger file in `docs/_static/spec/server.yaml` for + available endpoints. + +* `utter_custom_message()` method in rasa_core_sdk has been renamed to `utter_elements()` + +* updated dependencies. as part of this, models for spacy need to be reinstalled + for 2.1 (from 2.0) + +* make sure all command line arguments for `rasa test` and `rasa interactive` are actually used, removed arguments + that were not used at all (e.g. `--core` for `rasa test`) + +### Deprecations and Removals + +* removed possibility to execute `python -m rasa_core.train` etc. (e.g. scripts in `rasa.core` and `rasa.nlu`). + Use the CLI for rasa instead, e.g. `rasa train core`. + +* removed `_sklearn_numpy_warning_fix` from the `SklearnIntentClassifier` + +* removed `Dispatcher` class from core + +* removed projects: the Rasa NLU server now has a maximum of one model at a time loaded. + +### Bugfixes + +* evaluating core stories with two stage fallback gave an error, trying to handle None for a policy + +* the `/evaluate` route for the Rasa NLU server now runs evaluation + in a parallel process, which prevents the currently loaded model unloading + +* added missing implementation of the `keys()` function for the Redis Tracker + Store + +* in interactive learning: only updates entity values if user changes annotation + +* log options from the command line interface are applied (they overwrite the environment variable) + +* all message arguments (kwargs in dispatcher.utter methods, as well as template args) are now sent through to output channels + +* utterance templates defined in actions are checked for existence upon training a new agent, and a warning + is thrown before training if one is missing diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..65113bd --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,3 @@ +/docs/docs/prototype-an-assistant.mdx @RasaHQ/atom-squad +/.github/workflows/ @RasaHQ/infrastructure-squad +/rasa/ @RasaHQ/dev-tribe-engineers diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..f04715b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at tom@rasa.ai or alan@rasa.ai. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..81e7328 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,203 @@ +
+ + + +- [How to open a GitHub issue & file a bug report](#how-to-open-a-github-issue--file-a-bug-report) + - [Working on a new feature or filing a bug report](#working-on-a-new-feature-or-filing-a-bug-report) + - [Working on an existing feature](#working-on-an-existing-feature) +- [How to open a GitHub Pull Request](#how-to-open-a-github-pull-request) + - [What is a Pull Request (PR)?](#what-is-a-pull-request-pr) + - [What to know before opening a PR](#what-to-know-before-opening-a-pr) + - [Opening issues before PRs](#opening-issues-before-prs) + - [Draft PRs](#draft-prs) + - [PRs should be a reasonable length](#prs-should-be-a-reasonable-length) + - [Code style](#code-style) + - [Formatting and Type Checking](#formatting-and-type-checking) +- [How to open a PR and contribute code to Rasa Open Source](#how-to-open-a-pr-and-contribute-code-to-rasa-open-source) + - [1. Forking the Rasa Repository](#1-forking-the-rasa-repository) + - [2. Cloning the Forked Repository Locally](#2-cloning-the-forked-repository-locally) + - [3. Update your Forked Repository](#3-update-your-forked-repository) + - [4. Implement your code contribution](#4-implement-your-code-contribution) + - [5. Push changes to your forked repository on GitHub](#5-push-changes-to-your-forked-repository-on-github) + - [6. Opening the Pull Request on Rasa Open Source](#6-opening-the-pull-request-on-rasa-open-source) + - [7. Signing the Contributor Licence Agreement (CLA)](#7-signing-the-contributor-licence-agreement-cla) + - [8. Merging your PR and the final steps of your contribution](#8-merging-your-pr-and-the-final-steps-of-your-contribution) + - [9. Share your contributions with the world!](#9-share-your-contributions-with-the-world) + - [10. Non-code contributions](#10-non-code-contributions) + + +
+ +--- + +## How to open a GitHub issue & file a bug report + +### Working on a new feature or fixing a bug + +If you would like to add a new feature or fix an existing bug, we prefer that you open a new issue on the Rasa repository before creating a pull request. + +It’s important to note that when opening an issue, you should first do a quick search of existing issues to make sure your suggestion hasn’t already been added as an issue. +If your issue doesn’t already exist, and you’re ready to create a new one, make sure to state what you would like to implement, improve or bugfix. We have provided templates to make this process easier for you. + +**To open a Github issue, go to the RasaHQ repository, select “Issues”, “New Issue” then “Feature Request” or “Bug Report” and fill out the template.** + +![](https://www.rasa.com/assets/img/contributor-guidelines/opening-new-issue.png) + +The Rasa team will then get in touch with you to discuss if the proposed feature aligns with the company's roadmap, and we will guide you along the way in shaping the proposed feature so that it could be merged to the Rasa codebase. + +### Working on an existing feature + +If you want to contribute code, but don't know what to work on, check out the Rasa contributors board to find existing open issues. + +The issues are handpicked by the Rasa team to have labels which correspond to the difficulty/estimated time needed to resolve the issue. + +**To work on an existing issue, go to the contributor project board, add a comment stating you would like to work on it and include any solutions you may already have in mind.** + +![](https://www.rasa.com/assets/img/contributor-guidelines/exiting-issue-sara.png) + +Someone from Rasa will then assign that issue to you and help you along the way. + +--- + +## How to open a GitHub Pull Request + +### What is a Pull Request (PR)? + +This is how the GitHub team defines a PR: + +> “Pull requests let you tell others about changes you’ve pushed to a branch in a repository on GitHub. Once a pull request is opened, you can discuss and review the potential changes with collaborators and add follow-up commits before your changes are merged into the base branch.” + +This process is used by both Rasa team members and Rasa contributors to make changes and improvements to Rasa Open Source. + +### What to know before opening a PR + +#### Opening issues before PRs + +We usually recommend opening an issue before a pull request if there isn’t already an issue for the problem you’d like to solve. This helps facilitate a discussion before deciding on an implementation. See How to open a GitHub issue & file a bug report. + +#### Draft PRs + +If you're ready to get some quick initial feedback from the Rasa team, you can create a draft pull request. + +#### PRs should be a reasonable length + +If your PR is greater than 500 lines, please consider splitting it into multiple smaller contributions. + +#### Code style + +To ensure a standardized code style we recommend using formatter black. To ensure our type annotations are correct we also suggest using the type checker `mypy`. + +#### Formatting and Type Checking + +If you want to automatically format your code on every commit, you can use pre-commit. Just install it via `pip install pre-commit` and execute `pre-commit install` in the root folder. This will add a hook to the repository, which reformats files on every commit. + +If you want to set it up manually, install black via `pip install -r requirements-dev.txt.` To reformat files execute `make formatter`. + +If you want to check types on the codebase, install `mypy` using `poetry install`. To check the types execute `make types`. + +The CI/CD tests that we run can be found in the [continous-integration.yml](https://github.com/RasaHQ/rasa/blob/main/.github/workflows/continous-integration.yml) file. + +--- + +## How to open a PR and contribute code to Rasa Open Source + +#### 1. Forking the Rasa Repository + +Head to Rasa repository and click ‘Fork’. Forking a repository creates you a copy of the project which you can edit and use to propose changes to the original project. + +![](https://www.rasa.com/assets/img/contributor-guidelines/fork.png) + +Once you fork it, a copy of the Rasa repository will appear inside your GitHub repository list. + +#### 2. Cloning the Forked Repository Locally + +To make changes to your copy of the Rasa repository, clone the repository on your local machine. To do that, run the following command in your terminal: + +``` +git clone https://github.com/your_github_username/rasa.git +``` + +The link to the repository can be found after clicking Clone or download button as shown in the image below: + +![](https://www.rasa.com/assets/img/contributor-guidelines/clone.png) + +Note: this assumes you have git installed on your local machine. If not, check out the [following guide](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) to learn how to install it. + +#### 3. Update your Forked Repository + +Before you make any changes to your cloned repository, make sure you have the latest version of the original Rasa repository. To do that, run the following commands in your terminal: + +``` +cd rasa +git remote add upstream git://github.com/RasaHQ/rasa.git +git pull upstream main +``` + +This will update the local copy of the Rasa repository to the latest version. + +#### 4. Implement your code contribution + +At this point, you are good to make changes to the files in the local directory of your project. + +Alternatively, you can create a new branch which will contain the implementation of your contribution. To do that, run: + +``` +git checkout -b name-of-your-new-branch +``` + +#### 5. Push changes to your forked repository on GitHub + +Once you are happy with the changes you made in the local files, push them to the forked repository on GitHub. To do that, run the following commands: + +``` +git add . +git commit -m ‘fixed a bug’ +git push origin name-of-your-new-branch +``` + +This will create a new branch on your forked Rasa repository, and now you’re ready to create a Pull Request with your proposed changes! + +#### 6. Opening the Pull Request on Rasa Open Source + +Head to the forked repository and click on a _Compare & pull_ request button. + +![](https://www.rasa.com/assets/img/contributor-guidelines/openpr-1.png) + +This will open a window where you can choose the repository and branch you would like to propose your changes to, as well as specific details of your contribution. In the top panel menu choose the following details: + +- Base repository: `RasaHQ/rasa` +- Base branch: `main` +- Head repository: `your-github-username/rasa` +- Head branch: `name-of-your-new-branch` + +![](https://www.rasa.com/assets/img/contributor-guidelines/openpr-2.png) + +Next, make sure to update the pull request card with as many details about your contribution as possible. _Proposed changes_ section should contain the details of what has been fixed/implemented, and Status should reflect the status of your contributions. Any reasonable change (not like a typo) should include a changelog entry, a bug fix should have a test, a new feature should have documentation, etc. + +If you are ready to get feedback on your contribution from the Rasa team, tick the _made PR ready for code review_ and _allow edits from maintainers_ box. + +Once you are happy with everything, click the _Create pull request_ button. This will create a Pull Request with your proposed changes. + +![](https://www.rasa.com/assets/img/contributor-guidelines/openpr-3.png) + +#### 7. Signing the Contributor Licence Agreement (CLA) + +To merge your contributions to the Rasa codebase, you will have to sign a Contributor License Agreement (CLA). + +It is necessary for us to know that you agree for your code to be included into the Rasa codebase and allow us to use it in our later releases. You can find a detailed Rasa Contributor Licence Agreement [here](https://cla-assistant.io/RasaHQ/rasa). + +#### 8. Merging your PR and the final steps of your contribution + +Once you sign the CLA, a member from the Rasa team will get in touch with you with the feedback on your contribution. In some cases, contributions are accepted right away, but often, you may be asked to make some edits/improvements. Don’t worry if you are asked to change something - it’s a completely normal part of software development. + +If you have been requested to make changes to your contribution, head back to the local copy of your repository on your machine, implement the changes and push them to your contribution branch by repeating instructions from step 5. Your pull request will automatically be updated with the changes you pushed. Once you've implemented all of the suggested changes, tag the person who first reviewed your contribution by mentioning them in the comments of your PR to ask them to take another look. +Finally, if your contribution is accepted, the Rasa team member will merge it to the Rasa codebase. + +#### 9. Share your contributions with the world! + +Contributing to open source can take a lot of time and effort, so you should be proud of the great work you have done! +Let the world know that you have become a contributor to the Rasa open source project by posting about it on your social media (make sure to tag @RasaHQ as well), mention the contribution on your CV and get ready to get some really cool [Rasa contributor swag](https://blog.rasa.com/announcing-the-rasa-contributor-program/)! + +#### 10. Non-code contributions + +Contributing doesn’t start and end with code. You can support the project by planning community events, creating tutorials, helping fellow community members find answers to their questions or translating documentation and news. Every contribution matters! You can find more details [on our website](https://rasa.com/community/contribute/). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..188ae5f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# The default Docker image +ARG IMAGE_BASE_NAME +ARG BASE_IMAGE_HASH +ARG BASE_BUILDER_IMAGE_HASH + +FROM ${IMAGE_BASE_NAME}:base-builder-${BASE_BUILDER_IMAGE_HASH} as builder +# copy files +COPY . /build/ + +# change working directory +WORKDIR /build + +# install dependencies +RUN python -m venv /opt/venv && \ + . /opt/venv/bin/activate && \ + pip install --no-cache-dir -U "pip==22.*" -U "wheel>0.38.0" && \ + poetry install --no-dev --no-root --no-interaction && \ + poetry build -f wheel -n && \ + pip install --no-deps dist/*.whl && \ + rm -rf dist *.egg-info + +# start a new build stage +FROM ${IMAGE_BASE_NAME}:base-${BASE_IMAGE_HASH} as runner + +# copy everything from /opt +COPY --from=builder /opt/venv /opt/venv + +# make sure we use the virtualenv +ENV PATH="/opt/venv/bin:$PATH" + +# set HOME environment variable +ENV HOME=/app + +# update permissions & change user to not run as root +WORKDIR /app +RUN chgrp -R 0 /app && chmod -R g=u /app && chmod o+wr /app +USER 1001 + +# create a volume for temporary data +VOLUME /tmp + +# change shell +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# the entry point +EXPOSE 5005 +ENTRYPOINT ["rasa"] +CMD ["--help"] diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..0975a64 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Rasa Technologies GmbH + + 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a58eda4 --- /dev/null +++ b/Makefile @@ -0,0 +1,290 @@ +.PHONY: clean test lint init docs format formatter build-docker build-docker-full build-docker-mitie-en build-docker-spacy-en build-docker-spacy-de + +JOBS ?= 1 +INTEGRATION_TEST_FOLDER = tests/integration_tests/ +INTEGRATION_TEST_PYTEST_MARKERS ?= "sequential or broker or ((not sequential) and (not broker))" +PLATFORM ?= "linux/amd64" + +help: + @echo "make" + @echo " clean" + @echo " Remove Python/build artifacts." + @echo " install" + @echo " Install rasa." + @echo " install-full" + @echo " Install rasa with all extras (transformers, tensorflow_text, spacy, jieba)." + @echo " formatter" + @echo " Apply black formatting to code." + @echo " lint" + @echo " Lint code with ruff, and check if black formatter should be applied." + @echo " lint-docstrings" + @echo " Check docstring conventions in changed files." + @echo " types" + @echo " Check for type errors using mypy." + @echo " static-checks" + @echo " Run all python static checks." + @echo " prepare-tests-ubuntu" + @echo " Install system requirements for running tests on Ubuntu and Debian based systems." + @echo " prepare-tests-macos" + @echo " Install system requirements for running tests on macOS." + @echo " prepare-tests-windows" + @echo " Install system requirements for running tests on Windows." + @echo " prepare-spacy" + @echo " Download models needed for spacy tests." + @echo " prepare-mitie" + @echo " Download the standard english mitie model." + @echo " prepare-transformers" + @echo " Download all models needed for testing LanguageModelFeaturizer." + @echo " test" + @echo " Run pytest on tests/." + @echo " Use the JOBS environment variable to configure number of workers (default: 1)." + @echo " test-integration" + @echo " Run integration tests using pytest." + @echo " Use the JOBS environment variable to configure number of workers (default: 1)." + @echo " livedocs" + @echo " Build the docs locally." + @echo " release" + @echo " Prepare a release." + @echo " build-docker" + @echo " Build Rasa Open Source Docker image." + @echo " run-integration-containers" + @echo " Run the integration test containers." + @echo " stop-integration-containers" + @echo " Stop the integration test containers." + +clean: + find . -name '*.pyc' -exec rm -f {} + + find . -name '*.pyo' -exec rm -f {} + + find . -name '*~' -exec rm -f {} + + rm -rf build/ + rm -rf .mypy_cache/ + rm -rf dist/ + rm -rf docs/build + rm -rf docs/.docusaurus + +install: + poetry run python -m pip install -U pip + poetry install + +install-mitie: + poetry run python -m pip install -U pip + poetry run python -m pip install -U git+https://github.com/tmbo/MITIE.git#egg=mitie + +install-full: install-mitie + poetry install -E full + +install-docs: + cd docs/ && yarn install + +formatter: + poetry run black rasa tests + +format: formatter + +lint: + # Ignore docstring errors when running on the entire project + poetry run ruff check rasa tests --ignore D + poetry run black --check rasa tests + make lint-docstrings + +# Compare against `main` if no branch was provided +BRANCH ?= main +lint-docstrings: + ./scripts/lint_python_docstrings.sh $(BRANCH) + +lint-changelog: + ./scripts/lint_changelog_files.sh + +lint-security: + poetry run bandit -ll -ii -r --config pyproject.toml rasa/* + +types: + poetry run mypy rasa + +static-checks: lint lint-security types + +prepare-spacy: + poetry run python -m spacy download en_core_web_md + poetry run python -m spacy download de_core_news_sm + +prepare-mitie: + wget --progress=dot:giga -N -P data/ https://github.com/mit-nlp/MITIE/releases/download/v0.4/MITIE-models-v0.2.tar.bz2 +ifeq ($(OS),Windows_NT) + 7z x data/MITIE-models-v0.2.tar.bz2 -bb3 + 7z x MITIE-models-v0.2.tar -bb3 + cp MITIE-models/english/total_word_feature_extractor.dat data/ + rm -r MITIE-models + rm MITIE-models-v0.2.tar +else + tar -xvjf data/MITIE-models-v0.2.tar.bz2 --strip-components 2 -C data/ MITIE-models/english/total_word_feature_extractor.dat +endif + rm data/MITIE*.bz2 + +prepare-transformers: + while read -r MODEL; do poetry run python scripts/download_transformer_model.py $$MODEL ; done < data/test/hf_transformers_models.txt + if ! [ $(CI) ]; then poetry run python scripts/download_transformer_model.py rasa/LaBSE; fi +prepare-tests-macos: + brew install wget graphviz || true + +prepare-tests-ubuntu: + sudo apt-get update && sudo apt-get -y install graphviz graphviz-dev python3-tk + +prepare-tests-windows: + choco install wget graphviz + +# GitHub Action has pre-installed a helper function for installing Chocolatey packages +# It will retry the installation 5 times if it fails +# See: https://github.com/actions/virtual-environments/blob/main/images/win/scripts/ImageHelpers/ChocoHelpers.ps1 +prepare-tests-windows-gha: + powershell -command "Install-ChocoPackage wget graphviz" + +test: clean + # OMP_NUM_THREADS can improve overall performance using one thread by process (on tensorflow), avoiding overload + # TF_CPP_MIN_LOG_LEVEL=2 sets C code log level for tensorflow to error suppressing lower log events + OMP_NUM_THREADS=1 TF_CPP_MIN_LOG_LEVEL=2 poetry run pytest tests -n $(JOBS) --dist loadscope --cov rasa --ignore $(INTEGRATION_TEST_FOLDER) + +test-integration: + # OMP_NUM_THREADS can improve overall performance using one thread by process (on tensorflow), avoiding overload + # TF_CPP_MIN_LOG_LEVEL=2 sets C code log level for tensorflow to error suppressing lower log events +ifeq (,$(wildcard tests_deployment/.env)) + OMP_NUM_THREADS=1 TF_CPP_MIN_LOG_LEVEL=2 poetry run pytest $(INTEGRATION_TEST_FOLDER) -n $(JOBS) -m $(INTEGRATION_TEST_PYTEST_MARKERS) --dist loadgroup +else + set -o allexport; source tests_deployment/.env && OMP_NUM_THREADS=1 TF_CPP_MIN_LOG_LEVEL=2 poetry run pytest $(INTEGRATION_TEST_FOLDER) -n $(JOBS) -m $(INTEGRATION_TEST_PYTEST_MARKERS) --dist loadgroup && set +o allexport +endif + +test-cli: PYTEST_MARKER=category_cli and (not flaky) +test-cli: DD_ARGS := $(or $(DD_ARGS),) +test-cli: test-marker + +test-core-featurizers: PYTEST_MARKER=category_core_featurizers and (not flaky) +test-core-featurizers: DD_ARGS := $(or $(DD_ARGS),) +test-core-featurizers: test-marker + +test-policies: PYTEST_MARKER=category_policies and (not flaky) +test-policies: DD_ARGS := $(or $(DD_ARGS),) +test-policies: test-marker + +test-nlu-featurizers: PYTEST_MARKER=category_nlu_featurizers and (not flaky) +test-nlu-featurizers: DD_ARGS := $(or $(DD_ARGS),) +test-nlu-featurizers: prepare-spacy prepare-mitie prepare-transformers test-marker + +test-nlu-predictors: PYTEST_MARKER=category_nlu_predictors and (not flaky) +test-nlu-predictors: DD_ARGS := $(or $(DD_ARGS),) +test-nlu-predictors: prepare-spacy prepare-mitie test-marker + +test-full-model-training: PYTEST_MARKER=category_full_model_training and (not flaky) +test-full-model-training: DD_ARGS := $(or $(DD_ARGS),) +test-full-model-training: prepare-spacy prepare-mitie prepare-transformers test-marker + +test-other-unit-tests: PYTEST_MARKER=category_other_unit_tests and (not flaky) +test-other-unit-tests: DD_ARGS := $(or $(DD_ARGS),) +test-other-unit-tests: prepare-spacy prepare-mitie test-marker + +test-performance: PYTEST_MARKER=category_performance and (not flaky) +test-performance: DD_ARGS := $(or $(DD_ARGS),) +test-performance: test-marker + +test-flaky: PYTEST_MARKER=flaky +test-flaky: DD_ARGS := $(or $(DD_ARGS),) +test-flaky: prepare-spacy prepare-mitie test-marker + +test-gh-actions: + OMP_NUM_THREADS=1 TF_CPP_MIN_LOG_LEVEL=2 poetry run pytest .github/tests --cov .github/scripts + +test-marker: clean + # OMP_NUM_THREADS can improve overall performance using one thread by process (on tensorflow), avoiding overload + # TF_CPP_MIN_LOG_LEVEL=2 sets C code log level for tensorflow to error suppressing lower log events + TRANSFORMERS_OFFLINE=1 OMP_NUM_THREADS=1 TF_CPP_MIN_LOG_LEVEL=2 poetry run pytest tests -n $(JOBS) --dist loadscope -m "$(PYTEST_MARKER)" --cov rasa --ignore $(INTEGRATION_TEST_FOLDER) $(DD_ARGS) + +generate-pending-changelog: + poetry run python -c "from scripts import release; release.generate_changelog('major.minor.patch')" + +cleanup-generated-changelog: + # this is a helper to cleanup your git status locally after running "make test-docs" + # it's not run on CI at the moment + git status --porcelain | sed -n '/^D */s///p' | xargs git reset HEAD + git reset HEAD CHANGELOG.mdx + git ls-files --deleted | xargs git checkout + git checkout CHANGELOG.mdx + +test-docs: generate-pending-changelog docs + poetry run pytest tests/docs/* + +lint-docs: generate-pending-changelog docs + cd docs/ && yarn mdx-lint + +prepare-docs: + cd docs/ && poetry run yarn pre-build + +docs: prepare-docs + cd docs/ && yarn build + +livedocs: + cd docs/ && poetry run yarn start + +preview-docs: + cd docs/ && yarn build && yarn deploy-preview --alias=${PULL_REQUEST_NUMBER} --message="Preview for Pull Request #${PULL_REQUEST_NUMBER}" + +publish-docs: + cd docs/ && yarn build && yarn deploy + +release: + poetry run python scripts/release.py + +build-docker: + export IMAGE_NAME=rasa && \ + docker buildx use default && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base-poetry && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base-builder && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl default + +build-docker-full: + export IMAGE_NAME=rasa && \ + docker buildx use default && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base-images && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base-builder && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl full + +build-docker-mitie-en: + export IMAGE_NAME=rasa && \ + docker buildx use default && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base-images && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base-builder && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl mitie-en + +build-docker-spacy-en: + export IMAGE_NAME=rasa && \ + docker buildx use default && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base-poetry && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base-builder && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl spacy-en + +build-docker-spacy-de: + export IMAGE_NAME=rasa && \ + docker buildx use default && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base-poetry && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base-builder && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl spacy-de + +build-docker-spacy-it: + export IMAGE_NAME=rasa && \ + docker buildx use default && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base-poetry && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl base-builder && \ + docker buildx bake --set default.platform=${PLATFORM} -f docker/docker-bake.hcl spacy-it + +build-tests-deployment-env: ## Create environment files (.env) for docker-compose. + cd tests_deployment && \ + test -f .env || cat .env.example >> .env + +run-integration-containers: build-tests-deployment-env ## Run the integration test containers. + cd tests_deployment && \ + docker-compose -f docker-compose.integration.yml up & + +stop-integration-containers: ## Stop the integration test containers. + cd tests_deployment && \ + docker-compose -f docker-compose.integration.yml down diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..b03765f --- /dev/null +++ b/NOTICE @@ -0,0 +1,5 @@ +Rasa Technologies GmbH +Copyright 2016-2022 Rasa Technologies GmbH + +This product includes software from spaCy (https://github.com/explosion/spaCy), +under the MIT License (see: rasa.nlu.extractors.crf_entity_extractor). diff --git a/PRINCIPLES.md b/PRINCIPLES.md new file mode 100644 index 0000000..d603101 --- /dev/null +++ b/PRINCIPLES.md @@ -0,0 +1,28 @@ + + +When you create a conversational assistant, you are responsible for its impact on the people that it talks to. +Therefore, you should consider how users might perceive the assistant’s statements, and how a conversation might affect their lives. +This is not always straightforward, as you typically have little to no knowledge about the background of your users. +Thus, we created this guide to help you avoid the worst outcomes. + +It is in the best interest of all conversational assistant creators that the public perceives these assistants as helpful and friendly. +Beyond this, it is also in the best interest of all members of society (including creators) that conversational assistants are not used for harassment or manipulation. Aside from being unethical, such use cases would create a lasting reluctance of users to engage with conversational assistants. + +The following four key points should help you use this technology wisely. Please note, however, that these guidelines are only a first step, and you should use your own judgement as well. + + +## 1. **A conversational assistant should not cause users harm**. + +Even though a conversational assistant only exists in the digital world, it can still inflict harm on users simply by communicating with them in a certain way. For example, assistants are often used as information sources or decision guides. If the information that the assistant provides is inaccurate or misleading, users may end up making poor (or even dangerous) decisions based on their interaction with your assistant. + +## 2. **A conversational assistant should not encourage or normalize harmful behaviour from users**. + +Although users have complete freedom in what they can communicate to a conversational assistants, these assistants are designed to only follow pre-defined stories. In doing so, a conversational assistant should not try to provoke the user into engaging in harmful behaviour. If for any reason the user decides to engage in this behaviour anyway, the assistant should politely refuse to participate. In other words, treating such behaviour as normal or acceptable should be avoided. Trying to argue with the user will very rarely lead to useful results. + +## 3. **A conversational assistant should always identify itself as one**. + +When asked questions such as “Are you a bot?” or “Are you a human?”, a bot should always inform the user that it is indeed an assistant, and not a human. Impostor bots (algorithms that pose as humans) are a major piece of platform manipulation techniques, and this creates a lot of mistrust. Instead of misleading users, we should build assistants that truly support them, thereby enabling a larger fraction of work to be done by conversational assistants in the long-term (as users become more accustomed to them). This does not mean that conversational assistants can’t be human-like. + +## 4. **A conversational assistant should provide users a way to prove its identity**. + +When an assistant is designed to communicate with users while representing a company, organization, etc., it is important to allow users to verify that this representation has been previously authorized. It’s possible to use already existing technologies to do this: for example, by integrating a conversational assistant to a website served using HTTPS, the content of the site (and therefore the assistant itself) will be guaranteed to be legitimate by a trusted certificate authority. Another example would be to have the conversational assistant use a “verified” social media account. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c535027 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +

Rasa Open Source

+ +
+ +[![Join the Agent Engineering Community](https://img.shields.io/badge/Community-Join%20the%20Discussion-blueviolet)](https://info.rasa.com/community?utm_source=github&utm_medium=website&utm_campaign=) +[![Try Hello Rasa](https://img.shields.io/badge/Playground-Try%20Hello%20Rasa-ff69b4)](https://hello.rasa.ai/?utm_source=github&utm_medium=website&utm_campaign=) +[![PyPI version](https://badge.fury.io/py/rasa.svg)](https://badge.fury.io/py/rasa) +[![Supported Python Versions](https://img.shields.io/pypi/pyversions/rasa.svg)](https://pypi.python.org/pypi/rasa) +[![Build Status](https://github.com/RasaHQ/rasa/workflows/Continuous%20Integration/badge.svg)](https://github.com/RasaHQ/rasa/actions) +[![Documentation Status](https://img.shields.io/badge/docs-stable-brightgreen.svg)](https://rasa.com/docs) + +
+ +
+ +
+

🚧 Note: Maintenance Mode 🚧

+

+ Rasa Open Source is currently in maintenance mode. +
+ The future of building AI agents with Rasa is Hello Rasa and CALM. +

+
+ +
+ +## 🚀 The Future of Rasa: Hello Rasa + +**Building reliable AI agents just got easier.** + +[**Hello Rasa**](https://hello.rasa.ai/?utm_source=github&utm_medium=website&utm_campaign=) is our new interactive playground for prototyping AI agents. It combines LLM fluency with the reliability of business logic using our **CALM** (Conversational AI with Language Models) engine. + +### Why switch to Hello Rasa? + +* **No setup required:** Open the playground, pick a template (Banking, Telecom, Support), and start building in your browser. +* **No NLU training:** We have moved beyond intents. The LLM handles dialogue understanding while you define the business flows. +* **Built-in copilot:** A specialized AI assistant helps you generate code, debug flows, and expand your agent instantly. +* **Production ready:** Hello Rasa is not just a toy. Export your agent to the Rasa Platform when you are ready to scale. + +### Core concepts + +* **CALM:** Combines LLM flexibility with strict business logic. The LLM understands the user; the code enforces the rules. +* **Flows:** Describe logical steps (e.g., collect money, transfer funds) rather than rigid dialogue trees. +* **Inspector:** See real-time decision-making. No black boxes. + +👉 **[Start building for free at Hello Rasa](https://hello.rasa.ai/?utm_source=github&utm_medium=website&utm_campaign=)** + +--- + +## 🧠 Join the Agent Engineering Community + +We are building a home for people shipping real-world AI agents. + +Agent Engineering is evolving faster than any single framework. This is a vendor-neutral space to discuss architectures, memory, orchestration, and safety with builders across the industry. + +### What you get: +* **Network:** Meet engineers building production agents +* **Learn:** Discuss practical patterns, not just theory +* **Access:** Direct influence on the Hello Rasa roadmap and early access to features + +| Channel | Purpose | +| :--- | :--- | +| **#agent-design** | Architectures, reasoning, memory, testing | +| **#showcase** | Show your builds, demos, and repos | +| **#ask-anything** | Debugging and workflow questions | + +👉 **[Join the Community](https://info.rasa.com/community?utm_source=github&utm_medium=website&utm_campaign=)** + +--- + +
+
+ +# Rasa Open Source (Legacy) + +> **Note:** The documentation and installation instructions below apply to the classic Rasa Open Source framework. For the latest CALM-based experience, see the [Hello Rasa](#-the-future-of-rasa-hello-rasa) section above. + +Rasa is an open source machine learning framework for automating text and voice-based conversations. With Rasa, you can build contextual assistants on: + +- Facebook Messenger +- Slack +- Google Hangouts +- Webex Teams +- Microsoft Bot Framework +- Rocket.Chat +- Mattermost +- Telegram +- Twilio +- Your own custom conversational channels + +Rasa helps you build contextual assistants that can handle layered conversations with lots of back-and-forth. + +### 📚 Resources +- 🤓 [Read the docs](https://rasa.com/docs/rasa/) +- 😁 [Install Rasa](https://rasa.com/docs/rasa/installation/environment-set-up) +- 🚀 [Learn all about Conversational AI](https://learning.rasa.com/) +- 🏢 [Explore the enterprise platform](https://rasa.com/product/rasa-platform/) + +## Development Internals & Contributing + +We are happy to receive contributions. Please review our [Contribution Guidelines](CONTRIBUTING.md) before getting started. + +### Installation for Development +Rasa uses **Poetry** for packaging and dependency management. + +1. **Install Poetry**: Follow the [official guide](https://python-poetry.org/docs/#installation). +2. **Build from source**: + ```bash + make install + ``` + *Note for macOS users*: If you run into compiler issues, try `export SYSTEM_VERSION_COMPAT=1` before installation. + +### Running Tests +Make sure you have development requirements installed: + +```bash +make prepare-tests-ubuntu # Ubuntu/Debian +make prepare-tests-macos # macOS +make test # Run tests +``` + +### Releases + +Rasa follows Semantic Versioning. + + * **Major**: Incompatible API changes + * **Minor**: Backward-compatible functionality + * **Patch**: Backward-compatible bug fixes + +For full details on our release cadence and maintenance policy, visit our [Product Release and Maintenance Policy](https://rasa.com/rasa-product-release-and-maintenance-policy/). + +## License + +Licensed under the Apache License, Version 2.0. Copyright 2022 Rasa Technologies GmbH. [Copy of the license](https://www.google.com/search?q=LICENSE.txt). diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..0cba492 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`RasaHQ/rasa` +- 原始仓库:https://github.com/RasaHQ/rasa +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/binder/postBuild b/binder/postBuild new file mode 100644 index 0000000..a5ddc2f --- /dev/null +++ b/binder/postBuild @@ -0,0 +1 @@ +poetry run python -m spacy download en diff --git a/changelog/README.md b/changelog/README.md new file mode 100644 index 0000000..21a093c --- /dev/null +++ b/changelog/README.md @@ -0,0 +1,36 @@ +This directory contains "newsfragments" which are short files that contain a small +**Markdown**-formatted text that will be added to the next `CHANGELOG`. + +The `CHANGELOG` will be read by **users**, so this description should be aimed +to Rasa OSS users. + +Make sure to use full sentences in the **past or present tense** and use +punctuation, examples: + + Slots will be correctly interpolated if there are lists in custom response templates. + + Previously this resulted in no interpolation. + +Each file should be named like `..md`, where +`` is an issue / PR number, and `` is one of: + +* `feature`: new user facing features, like new command-line options and new behavior. +* `improvement`: improvement of existing functionality, usually without requiring user intervention. +* `bugfix`: fixes a reported bug or security vulnerability. +* `doc`: documentation improvement, like rewording an entire section or adding missing docs. +* `removal`: feature deprecation or feature removal. +* `misc`: fixing a small typo or internal change, will not be included in the changelog. + +So for example: `123.feature.md`, `456.bugfix.md`. + +If your change fixes an issue, use the issue number here. If there is no issue, +then after you submit the PR with your changes and get the PR number, you can add a +changelog using that PR number instead. + +If you are not sure what issue type to use, don't hesitate to ask in your PR. + +`towncrier` preserves multiple paragraphs and formatting (code blocks, lists, +and so on), but for entries other than `features` it is usually better to stick +to a single paragraph to keep it concise. You can install `towncrier` and then +run `towncrier --draft` if you want to get a preview of how your change will look +in the final release notes. diff --git a/changelog/_template.md.jinja2 b/changelog/_template.md.jinja2 new file mode 100644 index 0000000..0fa670c --- /dev/null +++ b/changelog/_template.md.jinja2 @@ -0,0 +1,12 @@ +{# Based on https://github.com/hawkowl/towncrier/blob/master/src/towncrier/templates/default.rst #} +{% if top_line %}{{ top_line }} {{ top_underline * ((top_line)|length)}} {% elif versiondata.name %}{{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.name + versiondata.version + versiondata.date)|length + 4)}}{% else %}{{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}}{% endif %}{% for section in sections %}{% if section %}{{section}}{% endif %}{% if sections[section] %}{% for category, val in definitions.items() if category in sections[section] %} + +{{ "### " + definitions[category]['name'] }} +{% if definitions[category]['showcontent'] %}{% for text, values in sections[section][category]|dictsort(by='value') %}{% set issue_joiner = joiner(', ') %}- {% for value in values|sort %}{{ issue_joiner() }}{{ value }}{% endfor %}: {{ text }} +{% endfor %}{% else %}- {{ sections[section][category]['']|sort|join(', ') }}{% endif %}{% if sections[section][category]|length == 0 %} No significant changes. + +{% else %}{% endif %}{% endfor %}{% else %} + +No significant changes. + +{% endif %}{% endfor %} diff --git a/cloudbuild.yaml b/cloudbuild.yaml new file mode 100644 index 0000000..2e5d2d5 --- /dev/null +++ b/cloudbuild.yaml @@ -0,0 +1,6 @@ +timeout: "20m" +steps: +- name: 'gcr.io/cloud-builders/docker' + id: 'docker-build' + args: ['build', '--file', './docker/Dockerfile_full', '-t', '$_IMAGE_REPO:$TAG_NAME', '.'] +images: [ '$_IMAGE_REPO:$TAG_NAME' ] diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..3b5ceca --- /dev/null +++ b/data/README.md @@ -0,0 +1,12 @@ +These are some example training data files for a simple bot in the restaurant domain. +They are in the format of the services rasa NLU can emulate, e.g. when you download an export +of your app from one of these services it should look like one of these files. + + +[examples/rasa](examples/rasa): examples in the native rasa NLU format + +[examples/luis](examples/luis): in LUIS format + +[examples/wit](examples/wit): in wit format + +[examples/api](examples/api): this is a dir and in Dialogflow format diff --git a/data/configs_for_docs/config_featurizers.yml b/data/configs_for_docs/config_featurizers.yml new file mode 100644 index 0000000..1f0049e --- /dev/null +++ b/data/configs_for_docs/config_featurizers.yml @@ -0,0 +1,24 @@ +assistant_id: example_featurizers_bot +language: "en" + +pipeline: + - name: ConveRTTokenizer + - name: ConveRTFeaturizer + alias: "convert" + - name: RegexFeaturizer + alias: "regex" + - name: LexicalSyntacticFeaturizer + alias: "lexical-syntactic" + - name: CountVectorsFeaturizer + alias: "cvf-word" + - name: CountVectorsFeaturizer + alias: "cvf-char" + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 100 + - name: EntitySynonymMapper + - name: ResponseSelector + featurizers: ["convert", "cvf-word"] + epochs: 100 diff --git a/data/configs_for_docs/default_config.yml b/data/configs_for_docs/default_config.yml new file mode 100644 index 0000000..ee65e52 --- /dev/null +++ b/data/configs_for_docs/default_config.yml @@ -0,0 +1,17 @@ +assistant_id: default_config_bot +language: "fr" # your two-letter language code + +pipeline: + - name: WhitespaceTokenizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 100 + - name: EntitySynonymMapper + - name: ResponseSelector + epochs: 100 \ No newline at end of file diff --git a/data/configs_for_docs/default_english_config.yml b/data/configs_for_docs/default_english_config.yml new file mode 100644 index 0000000..6fe323f --- /dev/null +++ b/data/configs_for_docs/default_english_config.yml @@ -0,0 +1,18 @@ +assistant_id: default_en_bot +language: "en" + +pipeline: + - name: ConveRTTokenizer + - name: ConveRTFeaturizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 100 + - name: EntitySynonymMapper + - name: ResponseSelector + epochs: 100 diff --git a/data/configs_for_docs/default_spacy_config.yml b/data/configs_for_docs/default_spacy_config.yml new file mode 100644 index 0000000..b0100da --- /dev/null +++ b/data/configs_for_docs/default_spacy_config.yml @@ -0,0 +1,19 @@ +assistant_id: default_spacy_bot +language: "fr" # your two-letter language code + +pipeline: + - name: SpacyNLP + - name: SpacyTokenizer + - name: SpacyFeaturizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 100 + - name: EntitySynonymMapper + - name: ResponseSelector + epochs: 100 diff --git a/data/configs_for_docs/example_for_suggested_config.yml b/data/configs_for_docs/example_for_suggested_config.yml new file mode 100644 index 0000000..6ffa1e0 --- /dev/null +++ b/data/configs_for_docs/example_for_suggested_config.yml @@ -0,0 +1,12 @@ +recipe: default.v1 +assistant_id: example_bot +language: en + +pipeline: +# will be selected by the Suggested Config feature + +policies: +- name: MemoizationPolicy +- name: TEDPolicy + max_history: 5 + epochs: 10 diff --git a/data/configs_for_docs/example_for_suggested_config_after_train.yml b/data/configs_for_docs/example_for_suggested_config_after_train.yml new file mode 100644 index 0000000..ffed30d --- /dev/null +++ b/data/configs_for_docs/example_for_suggested_config_after_train.yml @@ -0,0 +1,30 @@ +recipe: default.v1 +assistant_id: example_bot +language: en + +pipeline: +# # No configuration for the NLU pipeline was provided. The following default pipeline was used to train your model. +# # If you'd like to customize it, uncomment and adjust the pipeline. +# # See https://rasa.com/docs/rasa/tuning-your-model for more information. +# - name: WhitespaceTokenizer +# - name: RegexFeaturizer +# - name: LexicalSyntacticFeaturizer +# - name: CountVectorsFeaturizer +# - name: CountVectorsFeaturizer +# analyzer: char_wb +# min_ngram: 1 +# max_ngram: 4 +# - name: DIETClassifier +# epochs: 100 +# - name: EntitySynonymMapper +# - name: ResponseSelector +# epochs: 100 +# - name: FallbackClassifier +# threshold: 0.3 +# ambiguity_threshold: 0.1 + +policies: +- name: MemoizationPolicy +- name: TEDPolicy + max_history: 5 + epochs: 10 diff --git a/data/configs_for_docs/pretrained_embeddings_convert_config.yml b/data/configs_for_docs/pretrained_embeddings_convert_config.yml new file mode 100644 index 0000000..07a713f --- /dev/null +++ b/data/configs_for_docs/pretrained_embeddings_convert_config.yml @@ -0,0 +1,7 @@ +assistant_id: example_convert_bot +language: "en" + +pipeline: +- name: "ConveRTTokenizer" +- name: "ConveRTFeaturizer" +- name: "DIETClassifier" diff --git a/data/configs_for_docs/pretrained_embeddings_mitie_config_1.yml b/data/configs_for_docs/pretrained_embeddings_mitie_config_1.yml new file mode 100644 index 0000000..1f3fc6b --- /dev/null +++ b/data/configs_for_docs/pretrained_embeddings_mitie_config_1.yml @@ -0,0 +1,12 @@ +assistant_id: example_mitie_bot +language: "en" + +pipeline: +- name: "MitieNLP" + model: "data/total_word_feature_extractor.dat" +- name: "MitieTokenizer" +- name: "MitieEntityExtractor" +- name: "EntitySynonymMapper" +- name: "RegexFeaturizer" +- name: "MitieFeaturizer" +- name: "SklearnIntentClassifier" diff --git a/data/configs_for_docs/pretrained_embeddings_mitie_config_2.yml b/data/configs_for_docs/pretrained_embeddings_mitie_config_2.yml new file mode 100644 index 0000000..e6ff0b5 --- /dev/null +++ b/data/configs_for_docs/pretrained_embeddings_mitie_config_2.yml @@ -0,0 +1,11 @@ +assistant_id: example_mitie_bot +language: "en" + +pipeline: +- name: "MitieNLP" + model: "data/total_word_feature_extractor.dat" +- name: "MitieTokenizer" +- name: "MitieEntityExtractor" +- name: "EntitySynonymMapper" +- name: "RegexFeaturizer" +- name: "MitieIntentClassifier" diff --git a/data/configs_for_docs/pretrained_embeddings_spacy_config.yml b/data/configs_for_docs/pretrained_embeddings_spacy_config.yml new file mode 100644 index 0000000..d92dd5b --- /dev/null +++ b/data/configs_for_docs/pretrained_embeddings_spacy_config.yml @@ -0,0 +1,13 @@ +assistant_id: example_spacy_bot +language: "en" + +pipeline: +- name: "SpacyNLP" + model: "en_core_web_md" +- name: "SpacyTokenizer" +- name: "SpacyFeaturizer" +- name: "RegexFeaturizer" +- name: "CRFEntityExtractor" +- name: "EntitySynonymMapper" + +- name: "SklearnIntentClassifier" diff --git a/data/configs_for_docs/supervised_embeddings_config.yml b/data/configs_for_docs/supervised_embeddings_config.yml new file mode 100644 index 0000000..035633b --- /dev/null +++ b/data/configs_for_docs/supervised_embeddings_config.yml @@ -0,0 +1,14 @@ +assistant_id: example_supervised_bot +language: "en" + +pipeline: +- name: "WhitespaceTokenizer" +- name: "RegexFeaturizer" +- name: "CRFEntityExtractor" +- name: "EntitySynonymMapper" +- name: "CountVectorsFeaturizer" +- name: "CountVectorsFeaturizer" + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 +- name: "DIETClassifier" diff --git a/data/examples/dialogflow/agent.json b/data/examples/dialogflow/agent.json new file mode 100644 index 0000000..c4e89bc --- /dev/null +++ b/data/examples/dialogflow/agent.json @@ -0,0 +1,54 @@ +{ + "description": "", + "language": "en", + "shortDescription": "", + "examples": "", + "linkToDocs": "", + "disableInteractionLogs": false, + "disableStackdriverLogs": true, + "googleAssistant": { + "googleAssistantCompatible": false, + "project": "rasanlu-development", + "welcomeIntentSignInRequired": false, + "startIntents": [], + "systemIntents": [], + "endIntentIds": [], + "oAuthLinking": { + "required": false, + "providerId": "", + "authorizationUrl": "", + "tokenUrl": "", + "scopes": "", + "privacyPolicyUrl": "", + "grantType": "AUTH_CODE_GRANT" + }, + "voiceType": "MALE_1", + "capabilities": [], + "env": "", + "protocolVersion": "V2", + "autoPreviewEnabled": false, + "isDeviceAgent": false + }, + "defaultTimezone": "Asia/Hong_Kong", + "webhook": { + "url": "", + "username": "", + "headers": {}, + "available": false, + "useForDomains": false, + "cloudFunctionsEnabled": false, + "cloudFunctionsInitialized": false + }, + "isPrivate": true, + "mlMinConfidence": 0.3, + "supportedLanguages": [ + "es" + ], + "enableOnePlatformApi": true, + "onePlatformApiVersion": "v2", + "analyzeQueryTextSentiment": false, + "enabledKnowledgeBaseNames": [], + "knowledgeServiceConfidenceAdjustment": 0.0, + "dialogBuilderMode": false, + "baseActionPackagesUrl": "" +} diff --git a/data/examples/dialogflow/entities/cuisine.json b/data/examples/dialogflow/entities/cuisine.json new file mode 100644 index 0000000..866f5da --- /dev/null +++ b/data/examples/dialogflow/entities/cuisine.json @@ -0,0 +1,9 @@ +{ + "id": "11c77228-4a02-4db8-a398-b286fe8098d2", + "name": "cuisine", + "isOverridable": true, + "isEnum": false, + "isRegexp": false, + "automatedExpansion": false, + "allowFuzzyExtraction": false +} diff --git a/data/examples/dialogflow/entities/cuisine_entries_en.json b/data/examples/dialogflow/entities/cuisine_entries_en.json new file mode 100644 index 0000000..eecec9d --- /dev/null +++ b/data/examples/dialogflow/entities/cuisine_entries_en.json @@ -0,0 +1,23 @@ +[ + { + "value": "mexican", + "synonyms": [ + "mexican", + "mexico" + ] + }, + { + "value": "chinese", + "synonyms": [ + "chinese", + "china" + ] + }, + { + "value": "indian", + "synonyms": [ + "indian", + "india" + ] + } +] diff --git a/data/examples/dialogflow/entities/cuisine_entries_es.json b/data/examples/dialogflow/entities/cuisine_entries_es.json new file mode 100644 index 0000000..1c6256f --- /dev/null +++ b/data/examples/dialogflow/entities/cuisine_entries_es.json @@ -0,0 +1,25 @@ +[ + { + "value": "mexicano", + "synonyms": [ + "mexicano", + "mexicana", + "méxico" + ] + }, + { + "value": "chino", + "synonyms": [ + "chino", + "china", + "chinos" + ] + }, + { + "value": "indio", + "synonyms": [ + "indio", + "india" + ] + } +] diff --git a/data/examples/dialogflow/entities/flightNumber.json b/data/examples/dialogflow/entities/flightNumber.json new file mode 100644 index 0000000..d261f1d --- /dev/null +++ b/data/examples/dialogflow/entities/flightNumber.json @@ -0,0 +1,9 @@ +{ + "id": "fc93d510-e240-4b71-bafe-7dd7a3303e79", + "name": "flightNumber", + "isOverridable": true, + "isEnum": false, + "isRegexp": true, + "automatedExpansion": false, + "allowFuzzyExtraction": false +} diff --git a/data/examples/dialogflow/entities/flightNumber_entries_en.json b/data/examples/dialogflow/entities/flightNumber_entries_en.json new file mode 100644 index 0000000..9cefd8a --- /dev/null +++ b/data/examples/dialogflow/entities/flightNumber_entries_en.json @@ -0,0 +1,8 @@ +[ + { + "value": "flight [A-Z]{2} [0-9]{4}", + "synonyms": [ + "flight [A-Z]{2} [0-9]{4}" + ] + } +] diff --git a/data/examples/dialogflow/entities/location.json b/data/examples/dialogflow/entities/location.json new file mode 100644 index 0000000..d9036d7 --- /dev/null +++ b/data/examples/dialogflow/entities/location.json @@ -0,0 +1,9 @@ +{ + "id": "8ee88034-01d3-49d4-bb58-531a705b963b", + "name": "location", + "isOverridable": true, + "isEnum": false, + "isRegexp": false, + "automatedExpansion": false, + "allowFuzzyExtraction": false +} diff --git a/data/examples/dialogflow/entities/location_entries_en.json b/data/examples/dialogflow/entities/location_entries_en.json new file mode 100644 index 0000000..98d05da --- /dev/null +++ b/data/examples/dialogflow/entities/location_entries_en.json @@ -0,0 +1,26 @@ +[ + { + "value": "centre", + "synonyms": [ + "centre" + ] + }, + { + "value": "west", + "synonyms": [ + "west" + ] + }, + { + "value": "central", + "synonyms": [ + "central" + ] + }, + { + "value": "north", + "synonyms": [ + "north" + ] + } +] diff --git a/data/examples/dialogflow/entities/location_entries_es.json b/data/examples/dialogflow/entities/location_entries_es.json new file mode 100644 index 0000000..e30f8b5 --- /dev/null +++ b/data/examples/dialogflow/entities/location_entries_es.json @@ -0,0 +1,29 @@ +[ + { + "value": "centro", + "synonyms": [ + "centro", + "centrar" + ] + }, + { + "value": "oeste", + "synonyms": [ + "oeste", + "occidente" + ] + }, + { + "value": "central", + "synonyms": [ + "central", + "céntrico" + ] + }, + { + "value": "norte", + "synonyms": [ + "norte" + ] + } +] diff --git a/data/examples/dialogflow/intents/Default Fallback Intent.json b/data/examples/dialogflow/intents/Default Fallback Intent.json new file mode 100644 index 0000000..2b4ecd2 --- /dev/null +++ b/data/examples/dialogflow/intents/Default Fallback Intent.json @@ -0,0 +1,60 @@ +{ + "id": "27b800fb-3b69-4723-932d-ca53eb849138", + "name": "Default Fallback Intent", + "auto": true, + "contexts": [], + "responses": [ + { + "resetContexts": false, + "action": "input.unknown", + "affectedContexts": [], + "parameters": [], + "messages": [ + { + "type": "0", + "title": "", + "textToSpeech": "", + "lang": "es", + "speech": [ + "Ups, no he entendido a que te refieres.", + "¿Podrías repetirlo, por favor?", + "¿Disculpa?", + "¿Decías?", + "¿Cómo?" + ], + "condition": "" + }, + { + "type": "0", + "title": "", + "textToSpeech": "", + "lang": "en", + "speech": [ + "I didn\u0027t get that. Can you say it again?", + "I missed what you said. Say it again?", + "Sorry, could you say that again?", + "Sorry, can you say that again?", + "Can you say that again?", + "Sorry, I didn\u0027t get that.", + "Sorry, what was that?", + "One more time?", + "What was that?", + "Say that again?", + "I didn\u0027t get that.", + "I missed that." + ], + "condition": "" + } + ], + "speech": [] + } + ], + "priority": 500000, + "webhookUsed": false, + "webhookForSlotFilling": false, + "fallbackIntent": true, + "events": [], + "conditionalResponses": [], + "condition": "", + "conditionalFollowupEvents": [] +} diff --git a/data/examples/dialogflow/intents/affirm.json b/data/examples/dialogflow/intents/affirm.json new file mode 100644 index 0000000..879bd0e --- /dev/null +++ b/data/examples/dialogflow/intents/affirm.json @@ -0,0 +1,45 @@ +{ + "id": "c2e82a05-3980-4f74-b0d5-7ee0e1297284", + "name": "affirm", + "auto": true, + "contexts": [], + "responses": [ + { + "resetContexts": false, + "action": "", + "affectedContexts": [], + "parameters": [], + "messages": [ + { + "type": "0", + "title": "", + "textToSpeech": "", + "lang": "es", + "speech": [ + "Me alegro de ayudarte, compañero :)" + ], + "condition": "" + }, + { + "type": "0", + "title": "", + "textToSpeech": "", + "lang": "en", + "speech": [ + "Glad I help you, mate :)" + ], + "condition": "" + } + ], + "speech": [] + } + ], + "priority": 500000, + "webhookUsed": false, + "webhookForSlotFilling": false, + "fallbackIntent": false, + "events": [], + "conditionalResponses": [], + "condition": "", + "conditionalFollowupEvents": [] +} diff --git a/data/examples/dialogflow/intents/affirm_usersays_en.json b/data/examples/dialogflow/intents/affirm_usersays_en.json new file mode 100644 index 0000000..554e48d --- /dev/null +++ b/data/examples/dialogflow/intents/affirm_usersays_en.json @@ -0,0 +1,93 @@ +[ + { + "id": "74dc9ae2-335c-448e-8e02-f37225051102", + "data": [ + { + "text": "yes", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "425eacad-7c28-471a-8a4b-58f5079ec1c6", + "data": [ + { + "text": "yep", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "034520c5-bc84-4f09-bd74-625d10fa6499", + "data": [ + { + "text": "yeah", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "ee97d4c8-1d44-4f8c-a657-33a2c4c1c869", + "data": [ + { + "text": "indeed", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "6d5b05c4-5cd6-43b5-af52-764b3b1259e7", + "data": [ + { + "text": "that\u0027s right", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "0ef60526-405a-40ec-955f-3961600ae7dd", + "data": [ + { + "text": "ok", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "b15de01a-db24-4516-bab0-88eda8de1c16", + "data": [ + { + "text": "great", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + } +] diff --git a/data/examples/dialogflow/intents/affirm_usersays_es.json b/data/examples/dialogflow/intents/affirm_usersays_es.json new file mode 100644 index 0000000..46ccf1a --- /dev/null +++ b/data/examples/dialogflow/intents/affirm_usersays_es.json @@ -0,0 +1,93 @@ +[ + { + "id": "9dc52d2a-dbf2-44e7-bc55-0eee5a7a8a14", + "data": [ + { + "text": "sí", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "9dg52d2a-dbf2-44e7-bc55-0eee5a6a8a14", + "data": [ + { + "text": "si", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "9dg52d2a-dbf2-44e7-bc55-0eee5a6a8a24", + "data": [ + { + "text": "Sí", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "7a11df78-3b06-48c3-9aa4-9f779c23fb0b", + "data": [ + { + "text": "de verdad", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "e6238a3e-3dcd-4932-9034-1a05f037d4e3", + "data": [ + { + "text": "está bien", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "64cc393a-f9c9-4521-9052-543f99fcd97e", + "data": [ + { + "text": "muy bien", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "0f4a932a-c929-47d4-8cb2-5095882f40a0", + "data": [ + { + "text": "estupendo", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + } +] diff --git a/data/examples/dialogflow/intents/goodbye.json b/data/examples/dialogflow/intents/goodbye.json new file mode 100644 index 0000000..a0369f8 --- /dev/null +++ b/data/examples/dialogflow/intents/goodbye.json @@ -0,0 +1,45 @@ +{ + "id": "a90df8dd-f5bd-45dd-b8cf-12cc4b5cb800", + "name": "goodbye", + "auto": true, + "contexts": [], + "responses": [ + { + "resetContexts": false, + "action": "", + "affectedContexts": [], + "parameters": [], + "messages": [ + { + "type": "0", + "title": "", + "textToSpeech": "", + "lang": "es", + "speech": [ + "¡Nos vemos! Disfrutar" + ], + "condition": "" + }, + { + "type": "0", + "title": "", + "textToSpeech": "", + "lang": "en", + "speech": [ + "See ya! Enjoy" + ], + "condition": "" + } + ], + "speech": [] + } + ], + "priority": 500000, + "webhookUsed": false, + "webhookForSlotFilling": false, + "fallbackIntent": false, + "events": [], + "conditionalResponses": [], + "condition": "", + "conditionalFollowupEvents": [] +} diff --git a/data/examples/dialogflow/intents/goodbye_usersays_en.json b/data/examples/dialogflow/intents/goodbye_usersays_en.json new file mode 100644 index 0000000..8e179d9 --- /dev/null +++ b/data/examples/dialogflow/intents/goodbye_usersays_en.json @@ -0,0 +1,67 @@ +[ + { + "id": "1f094fbb-199a-40cd-af8f-3978fcebc027", + "data": [ + { + "text": "bye", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "b25f004f-425e-4ff3-b4db-36fa46772fc9", + "data": [ + { + "text": "goodbye", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "f4e435f4-c88a-4998-b2c0-ef94565327ae", + "data": [ + { + "text": "good bye", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "328a011a-08ba-4bd0-beea-6ab08859dd89", + "data": [ + { + "text": "stop", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "b8d9e693-6383-4be9-a98f-38a60890fa7a", + "data": [ + { + "text": "end", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + } +] diff --git a/data/examples/dialogflow/intents/goodbye_usersays_es.json b/data/examples/dialogflow/intents/goodbye_usersays_es.json new file mode 100644 index 0000000..390c284 --- /dev/null +++ b/data/examples/dialogflow/intents/goodbye_usersays_es.json @@ -0,0 +1,67 @@ +[ + { + "id": "dde37345-fa41-4310-9a7f-74f74ca7a925", + "data": [ + { + "text": "a usted adiós", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "86811b69-cee1-4ae0-8944-ace8eb4badc2", + "data": [ + { + "text": "despedida", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "78bbd720-b50b-4cb9-9dce-eee0fef5aa74", + "data": [ + { + "text": "adiós", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "74597c31-b649-49cc-bd3b-9a4ed3487070", + "data": [ + { + "text": "suspender", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "7b42db30-d815-46fd-a7f5-79b9180c7c6b", + "data": [ + { + "text": "fin", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + } +] diff --git a/data/examples/dialogflow/intents/hi.json b/data/examples/dialogflow/intents/hi.json new file mode 100644 index 0000000..767ae35 --- /dev/null +++ b/data/examples/dialogflow/intents/hi.json @@ -0,0 +1,35 @@ +{ + "id": "3e7ea801-9d08-479c-ada2-27ce467ca326", + "name": "hi", + "auto": true, + "contexts": [], + "responses": [ + { + "resetContexts": false, + "action": "greet", + "affectedContexts": [], + "parameters": [], + "messages": [ + { + "type": "0", + "title": "", + "textToSpeech": "", + "lang": "en", + "speech": [ + "hey! how can I help you?" + ], + "condition": "" + } + ], + "speech": [] + } + ], + "priority": 500000, + "webhookUsed": false, + "webhookForSlotFilling": false, + "fallbackIntent": false, + "events": [], + "conditionalResponses": [], + "condition": "", + "conditionalFollowupEvents": [] +} diff --git a/data/examples/dialogflow/intents/hi_usersays_en.json b/data/examples/dialogflow/intents/hi_usersays_en.json new file mode 100644 index 0000000..b8dde45 --- /dev/null +++ b/data/examples/dialogflow/intents/hi_usersays_en.json @@ -0,0 +1,67 @@ +[ + { + "id": "462fb0f5-d97a-4a95-96ab-91f49f289676", + "data": [ + { + "text": "hey", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "651c6730-61e8-467b-a174-aca4e0ed66af", + "data": [ + { + "text": "howdy", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "bab1998d-d54f-4a5f-9ed2-c7e8b24f37fc", + "data": [ + { + "text": "hey there", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "9dfc509d-4f0e-4723-af4a-10ceef2fbf91", + "data": [ + { + "text": "hello", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "a2271775-488e-4223-9ff4-458cfe4d2ba8", + "data": [ + { + "text": "hi", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + } +] diff --git a/data/examples/dialogflow/intents/hi_usersays_es.json b/data/examples/dialogflow/intents/hi_usersays_es.json new file mode 100644 index 0000000..14da515 --- /dev/null +++ b/data/examples/dialogflow/intents/hi_usersays_es.json @@ -0,0 +1,67 @@ +[ + { + "id": "40c60a12-1079-4e2c-a7c3-3498ab00de30", + "data": [ + { + "text": "hello", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "96019dea-5810-4ce0-9f69-16c2ce012603", + "data": [ + { + "text": "Hola amigo", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "298a54d1-c80c-4542-b41f-2cb549db814c", + "data": [ + { + "text": "Bueno", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "0f490459-85df-4865-abd0-70f87f62890e", + "data": [ + { + "text": "Caramba", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "1bf90b9b-fe34-42ae-96ea-21beaa3fca4f", + "data": [ + { + "text": "Hola", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + } +] diff --git a/data/examples/dialogflow/intents/inform.json b/data/examples/dialogflow/intents/inform.json new file mode 100644 index 0000000..fcfc6c8 --- /dev/null +++ b/data/examples/dialogflow/intents/inform.json @@ -0,0 +1,62 @@ +{ + "id": "fd566317-b15d-4685-9158-63028b6fb5bf", + "name": "inform", + "auto": true, + "contexts": [], + "responses": [ + { + "resetContexts": false, + "action": "", + "affectedContexts": [], + "parameters": [ + { + "name": "location", + "required": false, + "dataType": "@location", + "value": "$location", + "defaultValue": "", + "isList": true, + "prompts": [], + "promptMessages": [], + "noMatchPromptMessages": [], + "noInputPromptMessages": [], + "outputDialogContexts": [] + }, + { + "name": "cuisine", + "required": false, + "dataType": "@cuisine", + "value": "$cuisine", + "defaultValue": "", + "isList": false, + "prompts": [], + "promptMessages": [], + "noMatchPromptMessages": [], + "noInputPromptMessages": [], + "outputDialogContexts": [] + } + ], + "messages": [ + { + "type": "0", + "title": "", + "textToSpeech": "", + "lang": "en", + "speech": [ + "Here is a great spot I am sure you\u0027ll like, pal!" + ], + "condition": "" + } + ], + "speech": [] + } + ], + "priority": 500000, + "webhookUsed": false, + "webhookForSlotFilling": false, + "fallbackIntent": false, + "events": [], + "conditionalResponses": [], + "condition": "", + "conditionalFollowupEvents": [] +} diff --git a/data/examples/dialogflow/intents/inform_usersays_en.json b/data/examples/dialogflow/intents/inform_usersays_en.json new file mode 100644 index 0000000..778974a --- /dev/null +++ b/data/examples/dialogflow/intents/inform_usersays_en.json @@ -0,0 +1,145 @@ +[ + { + "id": "e623ff79-8f24-40bf-a6ed-5a885d9af6c8", + "data": [ + { + "text": "i\u0027m looking for a place to eat", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "a3e1901a-6c84-402c-a5a4-e2ec05307aa9", + "data": [ + { + "text": "i\u0027m looking for a place in the ", + "userDefined": false + }, + { + "text": "north", + "meta": "@location", + "alias": "location", + "userDefined": false + }, + { + "text": " of", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "fbf1e1b3-da86-4bbf-98b4-85e09f14c7b2", + "data": [ + { + "text": "show me ", + "userDefined": false + }, + { + "text": "chinese", + "meta": "@cuisine", + "alias": "cuisine", + "userDefined": false + }, + { + "text": " restaurants", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "860619cb-6c78-41b9-882d-d105a51e4377", + "data": [ + { + "text": "i am looking for an ", + "userDefined": false + }, + { + "text": "indian", + "meta": "@cuisine", + "alias": "cuisine", + "userDefined": false + }, + { + "text": " spot", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "a8b9fa75-19db-49ef-963b-50d316a14aa2", + "data": [ + { + "text": "search for restaurants", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "c91a0223-e109-4d32-aab0-4008fb0a9f35", + "data": [ + { + "text": "anywhere in the ", + "userDefined": false + }, + { + "text": "west", + "meta": "@location", + "alias": "location", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + }, + { + "id": "34c28215-f492-44d6-88a9-779ff59cb301", + "data": [ + { + "text": "central", + "meta": "@location", + "alias": "location", + "userDefined": false + }, + { + "text": " ", + "userDefined": false + }, + { + "text": "indian", + "meta": "@cuisine", + "alias": "cuisine", + "userDefined": false + }, + { + "text": " restaurant", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "en", + "updated": 0 + } +] diff --git a/data/examples/dialogflow/intents/inform_usersays_es.json b/data/examples/dialogflow/intents/inform_usersays_es.json new file mode 100644 index 0000000..38347ee --- /dev/null +++ b/data/examples/dialogflow/intents/inform_usersays_es.json @@ -0,0 +1,133 @@ +[ + { + "id": "b3ecd39d-4eec-435d-a0fe-6cfd29412ad7", + "data": [ + { + "text": "estoy buscando un lugar para comer", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "5a25e429-a00a-4418-a48b-7d019eca3ac6", + "data": [ + { + "text": "busco un lugar en el ", + "userDefined": false + }, + { + "text": "norte", + "meta": "@location", + "alias": "location", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "8b6fcd21-8e95-46ff-a841-54c59c650571", + "data": [ + { + "text": "muéstrame los restaurantes ", + "userDefined": false + }, + { + "text": "chinos", + "meta": "@cuisine", + "alias": "cuisine", + "userDefined": true + } + ], + "isTemplate": false, + "count": 1, + "lang": "es", + "updated": 0 + }, + { + "id": "86992625-d848-47de-8220-f9a7d5ddf63b", + "data": [ + { + "text": "estoy buscando un lugar ", + "userDefined": false + }, + { + "text": "indio", + "meta": "@cuisine", + "alias": "cuisine", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "2c5ce034-f89b-4570-a7eb-7bee714902df", + "data": [ + { + "text": "buscar restaurantes", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "93ce3697-b53f-4e96-bea1-9de96b098ec4", + "data": [ + { + "text": "en cualquier parte del ", + "userDefined": false + }, + { + "text": "oeste", + "meta": "@location", + "alias": "location", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + }, + { + "id": "6190aae9-a23f-4d8a-9a50-84a9d971d3f0", + "data": [ + { + "text": "restaurante ", + "userDefined": false + }, + { + "text": "central", + "meta": "@location", + "alias": "location", + "userDefined": false + }, + { + "text": " de la ", + "userDefined": false + }, + { + "text": "India", + "meta": "@cuisine", + "alias": "cuisine", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "lang": "es", + "updated": 0 + } +] diff --git a/data/examples/dialogflow/package.json b/data/examples/dialogflow/package.json new file mode 100644 index 0000000..1587a66 --- /dev/null +++ b/data/examples/dialogflow/package.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/data/examples/luis/demo-restaurants_v7.json b/data/examples/luis/demo-restaurants_v7.json new file mode 100644 index 0000000..a7117a7 --- /dev/null +++ b/data/examples/luis/demo-restaurants_v7.json @@ -0,0 +1,274 @@ +{ + "luis_schema_version": "7.0.0", + "versionId": "0.1", + "name": "demo-restaurants", + "desc": "", + "culture": "en-us", + "tokenizerVersion": "1.0.0", + "patternAnyEntities": [], + "regex_entities": [ + { + "name": "flightNumber", + "regexPattern": "flight [A-Z]{2} [0-9]{4}", + "roles": [] + } + ], + "phraselists": [], + "regex_features": [], + "patterns": [], + "settings": [], + "intents": [ + { + "name": "affirm", + "features": [] + }, + { + "name": "goodbye", + "features": [] + }, + { + "name": "greet", + "features": [] + }, + { + "name": "inform", + "features": [] + }, + { + "name": "None", + "features": [] + } + ], + "entities": [ + { + "name": "cuisine", + "children": [], + "roles": [], + "features": [] + }, + { + "name": "location", + "children": [], + "roles": [ + "to", + "from" + ], + "features": [] + } + ], + "hierarchicals": [], + "composites": [], + "closedLists": [], + "prebuiltEntities": [], + "utterances": [ + { + "text": "hello", + "intent": "greet", + "entities": [] + }, + { + "text": "hey", + "intent": "greet", + "entities": [] + }, + { + "text": "hi", + "intent": "greet", + "entities": [] + }, + { + "text": "hey there", + "intent": "greet", + "entities": [] + }, + { + "text": "howdy", + "intent": "greet", + "entities": [] + }, + { + "text": "i'm looking for a place to eat", + "intent": "inform", + "entities": [] + }, + { + "text": "i'm looking for a place in the north of town", + "intent": "inform", + "entities": [ + { + "entity": "location", + "role": "to", + "startPos": 31, + "endPos": 35, + "children": [] + } + ] + }, + { + "text": "show me chinese restaurants", + "intent": "greet", + "entities": [ + { + "entity": "cuisine", + "startPos": 8, + "endPos": 14, + "children": [] + } + ] + }, + { + "text": "yes", + "intent": "affirm", + "entities": [] + }, + { + "text": "yep", + "intent": "affirm", + "entities": [] + }, + { + "text": "yeah", + "intent": "affirm", + "entities": [] + }, + { + "text": "show me a mexican place in the centre", + "intent": "inform", + "entities": [ + { + "entity": "cuisine", + "startPos": 10, + "endPos": 16, + "children": [] + }, + { + "entity": "location", + "startPos": 31, + "endPos": 36, + "children": [] + } + ] + }, + { + "text": "bye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "goodbye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "good bye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "stop", + "intent": "goodbye", + "entities": [] + }, + { + "text": "end", + "intent": "goodbye", + "entities": [] + }, + { + "text": "i am looking for an indian spot", + "intent": "inform", + "entities": [] + }, + { + "text": "search for restaurants", + "intent": "inform", + "entities": [] + }, + { + "text": "anywhere in the west", + "intent": "inform", + "entities": [ + { + "entity": "location", + "startPos": 16, + "endPos": 19, + "children": [] + } + ] + }, + { + "text": "central indian restaurant", + "intent": "greet", + "entities": [ + { + "entity": "cuisine", + "startPos": 8, + "endPos": 13, + "children": [] + }, + { + "entity": "location", + "startPos": 0, + "endPos": 6, + "children": [] + } + ] + }, + { + "text": "indeed", + "intent": "affirm", + "entities": [] + }, + { + "text": "that's right", + "intent": "affirm", + "entities": [] + }, + { + "text": "ok", + "intent": "affirm", + "entities": [] + }, + { + "text": "great", + "intent": "affirm", + "entities": [] + }, + { + "text": "do you know any good vietnamese places?", + "intent": "inform", + "entities": [ + { + "entity": "cuisine", + "startPos": 21, + "endPos": 30, + "children": [] + } + ] + }, + { + "text": "i want some russian food", + "intent": "inform", + "entities": [ + { + "entity": "cuisine", + "startPos": 12, + "endPos": 18, + "children": [] + } + ] + }, + { + "text": "any indonesian places?", + "intent": "inform", + "entities": [ + { + "entity": "cuisine", + "startPos": 4, + "endPos": 13, + "children": [] + } + ] + } + ] +} diff --git a/data/examples/rasa/demo-rasa-multi-intent.yml b/data/examples/rasa/demo-rasa-multi-intent.yml new file mode 100644 index 0000000..8040408 --- /dev/null +++ b/data/examples/rasa/demo-rasa-multi-intent.yml @@ -0,0 +1,75 @@ +version: "3.1" +nlu: +- intent: affirm + examples: | + - yes + - yep + - yeah + - indeed + - that's right + - ok + - great + - right, thank you + - correct + - great choice + - sounds really good +- intent: goodbye + examples: | + - bye + - goodbye + - good bye + - stop + - end + - farewell + - Bye bye + - have a good one +- intent: greet + examples: | + - hey + - howdy + - hey there + - hello + - hi + - good morning + - good evening + - dear sir +- intent: chitchat+ask_name + examples: | + - What's your name? + - What can I call you? +- intent: chitchat+ask_weather + examples: | + - How's the weather? + - Is it too hot outside? +- intent: restaurant_search + examples: | + - i'm looking for a place to eat + - I want to grab lunch + - I am searching for a dinner spot + - i'm looking for a place in the [north](location) of town + - show me [chinese](cuisine) restaurants + - show me [chines]{"entity": "cuisine", "value": "chinese"} restaurants in the [north](location) + - show me a [mexican](cuisine) place in the [centre](location) + - i am looking for an [indian](cuisine) spot called olaolaolaolaolaola + - search for restaurants + - anywhere in the [west](location) + - anywhere near [18328](location) + - I am looking for [asian fusion](cuisine) food + - I am looking a restaurant in [29432](location) + - I am looking for [mexican indian fusion](cuisine) + - [central](location) [indian](cuisine) restaurant +- synonym: chinese + examples: | + - chines + - Chines + - Chinese +- synonym: vegetarian + examples: | + - vegg + - veggie +- regex: greet + examples: | + - hey[^\s]* +- regex: zipcode + examples: | + - [0-9]{5} diff --git a/data/examples/rasa/demo-rasa-responses.yml b/data/examples/rasa/demo-rasa-responses.yml new file mode 100644 index 0000000..c54ac1c --- /dev/null +++ b/data/examples/rasa/demo-rasa-responses.yml @@ -0,0 +1,6 @@ +responses: + utter_chitchat/ask_weather: + - text: It's sunny where I live + + utter_chitchat/ask_name: + - text: I am Mr. Bot diff --git a/data/examples/rasa/demo-rasa.json b/data/examples/rasa/demo-rasa.json new file mode 100644 index 0000000..ae644ad --- /dev/null +++ b/data/examples/rasa/demo-rasa.json @@ -0,0 +1,350 @@ +{ + "rasa_nlu_data": { + "regex_features": [ + { + "name": "zipcode", + "pattern": "[0-9]{5}" + }, + { + "name": "greet", + "pattern": "hey[^\\s]*" + } + ], + "entity_synonyms": [ + { + "value": "chinese", + "synonyms": ["Chinese", "Chines", "chines"] + }, + { + "value": "vegetarian", + "synonyms": ["veggie", "vegg"] + } + ], + "common_examples": [ + { + "text": "hey", + "intent": "greet", + "entities": [] + }, + { + "text": "howdy", + "intent": "greet", + "entities": [] + }, + { + "text": "hey there", + "intent": "greet", + "entities": [] + }, + { + "text": "hello", + "intent": "greet", + "entities": [] + }, + { + "text": "hi", + "intent": "greet", + "entities": [] + }, + { + "text": "good morning", + "intent": "greet", + "entities": [] + }, + { + "text": "good evening", + "intent": "greet", + "entities": [] + }, + { + "text": "dear sir", + "intent": "greet", + "entities": [] + }, + { + "text": "yes", + "intent": "affirm", + "entities": [] + }, + { + "text": "yep", + "intent": "affirm", + "entities": [] + }, + { + "text": "yeah", + "intent": "affirm", + "entities": [] + }, + { + "text": "indeed", + "intent": "affirm", + "entities": [] + }, + { + "text": "that's right", + "intent": "affirm", + "entities": [] + }, + { + "text": "ok", + "intent": "affirm", + "entities": [] + }, + { + "text": "great", + "intent": "affirm", + "entities": [] + }, + { + "text": "right, thank you", + "intent": "affirm", + "entities": [] + }, + { + "text": "correct", + "intent": "affirm", + "entities": [] + }, + { + "text": "great choice", + "intent": "affirm", + "entities": [] + }, + { + "text": "sounds really good", + "intent": "affirm", + "entities": [] + }, + { + "text": "What's your name?", + "intent": "chitchat/ask_name", + "entitites": [] + }, + { + "text": "What can I call you?", + "intent": "chitchat/ask_name", + "entitites": [] + }, + { + "text": "How's the weather?", + "intent": "chitchat/ask_weather", + "entitites": [] + }, + { + "text": "Is it too hot outside?", + "intent": "chitchat/ask_weather", + "entitites": [] + }, + { + "text": "i'm looking for a place to eat", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "I want to grab lunch", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "I am searching for a dinner spot", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "i'm looking for a place in the north of town", + "intent": "restaurant_search", + "entities": [ + { + "start": 31, + "end": 36, + "value": "north", + "entity": "location" + } + ] + }, + { + "text": "show me chinese restaurants", + "intent": "restaurant_search", + "entities": [ + { + "start": 8, + "end": 15, + "value": "chinese", + "entity": "cuisine" + } + ] + }, + { + "text": "show me chines restaurants in the north", + "intent": "restaurant_search", + "entities": [ + { + "start": 8, + "end": 14, + "value": "chinese", + "entity": "cuisine" + }, + { + "start": 34, + "end": 39, + "value": "north", + "entity": "location" + } + ] + }, + { + "text": "show me a mexican place in the centre", + "intent": "restaurant_search", + "entities": [ + { + "start": 31, + "end": 37, + "value": "centre", + "entity": "location" + }, + { + "start": 10, + "end": 17, + "value": "mexican", + "entity": "cuisine" + } + ] + }, + { + "text": "i am looking for an indian spot called olaolaolaolaolaola", + "intent": "restaurant_search", + "entities": [ + { + "start": 20, + "end": 26, + "value": "indian", + "entity": "cuisine" + } + ] + }, { + "text": "search for restaurants", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "anywhere in the west", + "intent": "restaurant_search", + "entities": [ + { + "start": 16, + "end": 20, + "value": "west", + "entity": "location" + } + ] + }, + { + "text": "anywhere near 18328", + "intent": "restaurant_search", + "entities": [ + { + "start": 14, + "end": 19, + "value": "18328", + "entity": "location" + } + ] + }, + { + "text": "I am looking for asian fusion food", + "intent": "restaurant_search", + "entities": [ + { + "start": 17, + "end": 29, + "value": "asian fusion", + "entity": "cuisine" + } + ] + }, + { + "text": "I am looking a restaurant in 29432", + "intent": "restaurant_search", + "entities": [ + { + "start": 29, + "end": 34, + "value": "29432", + "entity": "location" + } + ] + }, + { + "text": "I am looking for mexican indian fusion", + "intent": "restaurant_search", + "entities": [ + { + "start": 17, + "end": 38, + "value": "mexican indian fusion", + "entity": "cuisine" + } + ] + }, + { + "text": "central indian restaurant", + "intent": "restaurant_search", + "entities": [ + { + "start": 0, + "end": 7, + "value": "central", + "entity": "location" + }, + { + "start": 8, + "end": 14, + "value": "indian", + "entity": "cuisine" + } + ] + }, + { + "text": "bye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "goodbye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "good bye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "stop", + "intent": "goodbye", + "entities": [] + }, + { + "text": "end", + "intent": "goodbye", + "entities": [] + }, + { + "text": "farewell", + "intent": "goodbye", + "entities": [] + }, + { + "text": "Bye bye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "have a good one", + "intent": "goodbye", + "entities": [] + } + ] + } +} diff --git a/data/examples/rasa/demo-rasa.yml b/data/examples/rasa/demo-rasa.yml new file mode 100644 index 0000000..7b33099 --- /dev/null +++ b/data/examples/rasa/demo-rasa.yml @@ -0,0 +1,85 @@ +version: "3.1" +nlu: +- intent: affirm + examples: | + - yes + - yep + - yeah + - indeed + - that's right + - ok + - great + - right, thank you + - correct + - great choice + - sounds really good +- intent: goodbye + examples: | + - bye + - goodbye + - good bye + - stop + - end + - farewell + - Bye bye + - have a good one +- intent: greet + examples: | + - hey + - howdy + - hey there + - hello + - hi + - good morning + - good evening + - dear sir +- intent: chitchat/ask_name + examples: | + - What's your name? + - What can I call you? +- intent: chitchat/ask_weather + examples: | + - How's the weather? + - Is it too hot outside? +- intent: restaurant_search + examples: | + - i'm looking for a place to eat + - I want to grab lunch + - I am searching for a dinner spot + - i'm looking for a place in the [north](location) of town + - show me [chinese](cuisine) restaurants + - show me [chines]{"entity": "cuisine", "value": "chinese"} restaurants in the [north](location) + - show me a [mexican](cuisine) place in the [centre](location) + - i am looking for an [indian](cuisine) spot called olaolaolaolaolaola + - search for restaurants + - anywhere in the [west](location) + - anywhere near [18328](location) + - I am looking for [asian fusion](cuisine) food + - I am looking a restaurant in [29432](location) + - I am looking for [mexican indian fusion](cuisine) + - [central](location) [indian](cuisine) restaurant +- synonym: chinese + examples: | + - chines + - Chines + - Chinese +- synonym: vegetarian + examples: | + - vegg + - veggie +- regex: greet + examples: | + - hey[^\s]* +- regex: zipcode + examples: | + - [0-9]{5} + +responses: + utter_chitchat/ask_name: + - image: "https://i.imgur.com/zTvA58i.jpeg" + text: Hello, my name is Retrieval Bot. + - text: I am called Retrieval Bot! + utter_chitchat/ask_weather: + - text: Oh, it does look sunny right now in Berlin. + image: "https://i.imgur.com/vwv7aHN.png" + - text: I am not sure of the whole week but I can see the sun is out today. diff --git a/data/examples/wit/demo-flights.json b/data/examples/wit/demo-flights.json new file mode 100755 index 0000000..7f583de --- /dev/null +++ b/data/examples/wit/demo-flights.json @@ -0,0 +1,86 @@ +{ + "utterances": [ + { + "text": "I want pizza", + "entities": [], + "traits": [] + }, + { + "text": "i want to fly from london", + "entities": [ + { + "entity": "location:from", + "start": 19, + "end": 25, + "body": "london", + "entities": [] + } + ], + "traits": [], + "intent": "flight_booking" + }, + { + "text": "i want to fly to berlin", + "entities": [ + { + "entity": "location:to", + "start": 17, + "end": 23, + "body": "berlin", + "entities": [] + } + ], + "traits": [], + "intent": "flight_booking" + }, + { + "text": "i want to go from berlin to tokyo tomorrow", + "entities": [ + { + "entity": "location:from", + "start": 18, + "end": 24, + "body": "berlin", + "entities": [] + }, + { + "entity": "location:to", + "start": 28, + "end": 33, + "body": "tokyo", + "entities": [] + } + ], + "traits": [], + "intent": "flight_booking" + }, + { + "text": "i'm looking for a flight from london to amsterdam next monday", + "entities": [ + { + "entity": "location:from", + "start": 30, + "end": 36, + "body": "london", + "entities": [] + }, + { + "entity": "wit$datetime:datetime", + "start": 50, + "end": 61, + "body": "next monday", + "entities": [] + }, + { + "entity": "location:to", + "start": 40, + "end": 49, + "body": "amsterdam", + "entities": [] + } + ], + "traits": [], + "intent": "flight_booking" + } + ] +} diff --git a/data/graph_schemas/config_pretrained_embeddings_mitie_predict_schema.yml b/data/graph_schemas/config_pretrained_embeddings_mitie_predict_schema.yml new file mode 100644 index 0000000..67ecfdf --- /dev/null +++ b/data/graph_schemas/config_pretrained_embeddings_mitie_predict_schema.yml @@ -0,0 +1,106 @@ +nodes: + nlu_message_converter: + needs: + messages: __message__ + uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter + constructor_name: load + fn: convert_user_message + config: {} + eager: true + is_target: false + is_input: false + resource: null + provide_MitieNLP0: + needs: {} + uses: rasa.nlu.utils.mitie_utils.MitieNLP + constructor_name: load + fn: provide + config: + model: data/total_word_feature_extractor.dat + eager: true + is_target: false + is_input: false + resource: null + run_MitieTokenizer1: + needs: + messages: nlu_message_converter + uses: rasa.nlu.tokenizers.mitie_tokenizer.MitieTokenizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_MitieEntityExtractor2: + needs: + messages: run_MitieTokenizer1 + model: provide_MitieNLP0 + uses: rasa.nlu.extractors.mitie_entity_extractor.MitieEntityExtractor + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_MitieEntityExtractor2 + run_EntitySynonymMapper3: + needs: + messages: run_MitieEntityExtractor2 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_EntitySynonymMapper3 + run_RegexFeaturizer4: + needs: + messages: run_EntitySynonymMapper3 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RegexFeaturizer4 + run_MitieFeaturizer5: + needs: + messages: run_RegexFeaturizer4 + model: provide_MitieNLP0 + uses: rasa.nlu.featurizers.dense_featurizer.mitie_featurizer.MitieFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_SklearnIntentClassifier6: + needs: + messages: run_MitieFeaturizer5 + uses: rasa.nlu.classifiers.sklearn_intent_classifier.SklearnIntentClassifier + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_SklearnIntentClassifier6 + run_RegexMessageHandler: + needs: + messages: run_SklearnIntentClassifier6 + uses: rasa.nlu.classifiers.regex_message_handler.RegexMessageHandler + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null diff --git a/data/graph_schemas/config_pretrained_embeddings_mitie_train_schema.yml b/data/graph_schemas/config_pretrained_embeddings_mitie_train_schema.yml new file mode 100644 index 0000000..5414f18 --- /dev/null +++ b/data/graph_schemas/config_pretrained_embeddings_mitie_train_schema.yml @@ -0,0 +1,129 @@ +nodes: + schema_validator: + needs: + importer: __importer__ + uses: rasa.graph_components.validators.default_recipe_validator.DefaultV1RecipeValidator + constructor_name: create + fn: validate + config: {} + eager: false + is_target: false + is_input: true + resource: null + finetuning_validator: + needs: + importer: schema_validator + uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator + constructor_name: create + fn: validate + config: + validate_core: false + validate_nlu: true + eager: false + is_target: false + is_input: true + resource: null + nlu_training_data_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.nlu_training_data_provider.NLUTrainingDataProvider + constructor_name: create + fn: provide + config: + language: en + persist: false + eager: false + is_target: false + is_input: true + resource: null + provide_MitieNLP0: + needs: {} + uses: rasa.nlu.utils.mitie_utils.MitieNLP + constructor_name: create + fn: provide + config: + model: data/total_word_feature_extractor.dat + eager: false + is_target: false + is_input: false + resource: null + run_MitieTokenizer1: + needs: + training_data: nlu_training_data_provider + uses: rasa.nlu.tokenizers.mitie_tokenizer.MitieTokenizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_MitieEntityExtractor2: + needs: + training_data: run_MitieTokenizer1 + model: provide_MitieNLP0 + uses: rasa.nlu.extractors.mitie_entity_extractor.MitieEntityExtractor + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_EntitySynonymMapper3: + needs: + training_data: run_MitieTokenizer1 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_RegexFeaturizer4: + needs: + training_data: run_MitieTokenizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_RegexFeaturizer4: + needs: + training_data: run_MitieTokenizer1 + resource: train_RegexFeaturizer4 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + run_MitieFeaturizer5: + needs: + training_data: run_RegexFeaturizer4 + model: provide_MitieNLP0 + uses: rasa.nlu.featurizers.dense_featurizer.mitie_featurizer.MitieFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_SklearnIntentClassifier6: + needs: + training_data: run_MitieFeaturizer5 + uses: rasa.nlu.classifiers.sklearn_intent_classifier.SklearnIntentClassifier + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null diff --git a/data/graph_schemas/config_pretrained_embeddings_mitie_zh_predict_schema.yml b/data/graph_schemas/config_pretrained_embeddings_mitie_zh_predict_schema.yml new file mode 100644 index 0000000..07c5199 --- /dev/null +++ b/data/graph_schemas/config_pretrained_embeddings_mitie_zh_predict_schema.yml @@ -0,0 +1,107 @@ +nodes: + nlu_message_converter: + needs: + messages: __message__ + uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter + constructor_name: load + fn: convert_user_message + config: {} + eager: true + is_target: false + is_input: false + resource: null + provide_MitieNLP0: + needs: {} + uses: rasa.nlu.utils.mitie_utils.MitieNLP + constructor_name: load + fn: provide + config: + model: data/total_word_feature_extractor_zh.dat + eager: true + is_target: false + is_input: false + resource: null + run_JiebaTokenizer1: + needs: + messages: nlu_message_converter + uses: rasa.nlu.tokenizers.jieba_tokenizer.JiebaTokenizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_JiebaTokenizer1 + run_MitieEntityExtractor2: + needs: + messages: run_JiebaTokenizer1 + model: provide_MitieNLP0 + uses: rasa.nlu.extractors.mitie_entity_extractor.MitieEntityExtractor + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_MitieEntityExtractor2 + run_EntitySynonymMapper3: + needs: + messages: run_MitieEntityExtractor2 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_EntitySynonymMapper3 + run_RegexFeaturizer4: + needs: + messages: run_EntitySynonymMapper3 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RegexFeaturizer4 + run_MitieFeaturizer5: + needs: + messages: run_RegexFeaturizer4 + model: provide_MitieNLP0 + uses: rasa.nlu.featurizers.dense_featurizer.mitie_featurizer.MitieFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_SklearnIntentClassifier6: + needs: + messages: run_MitieFeaturizer5 + uses: rasa.nlu.classifiers.sklearn_intent_classifier.SklearnIntentClassifier + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_SklearnIntentClassifier6 + run_RegexMessageHandler: + needs: + messages: run_SklearnIntentClassifier6 + uses: rasa.nlu.classifiers.regex_message_handler.RegexMessageHandler + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null diff --git a/data/graph_schemas/config_pretrained_embeddings_mitie_zh_train_schema.yml b/data/graph_schemas/config_pretrained_embeddings_mitie_zh_train_schema.yml new file mode 100644 index 0000000..4d2db74 --- /dev/null +++ b/data/graph_schemas/config_pretrained_embeddings_mitie_zh_train_schema.yml @@ -0,0 +1,141 @@ +nodes: + schema_validator: + needs: + importer: __importer__ + uses: rasa.graph_components.validators.default_recipe_validator.DefaultV1RecipeValidator + constructor_name: create + fn: validate + config: {} + eager: false + is_target: false + is_input: true + resource: null + finetuning_validator: + needs: + importer: schema_validator + uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator + constructor_name: create + fn: validate + config: + validate_core: false + validate_nlu: true + eager: false + is_target: false + is_input: true + resource: null + nlu_training_data_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.nlu_training_data_provider.NLUTrainingDataProvider + constructor_name: create + fn: provide + config: + language: zh + persist: false + eager: false + is_target: false + is_input: true + resource: null + provide_MitieNLP0: + needs: {} + uses: rasa.nlu.utils.mitie_utils.MitieNLP + constructor_name: create + fn: provide + config: + model: data/total_word_feature_extractor_zh.dat + eager: false + is_target: false + is_input: false + resource: null + train_JiebaTokenizer1: + needs: + training_data: nlu_training_data_provider + uses: rasa.nlu.tokenizers.jieba_tokenizer.JiebaTokenizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_JiebaTokenizer1: + needs: + training_data: nlu_training_data_provider + resource: train_JiebaTokenizer1 + uses: rasa.nlu.tokenizers.jieba_tokenizer.JiebaTokenizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_MitieEntityExtractor2: + needs: + training_data: run_JiebaTokenizer1 + model: provide_MitieNLP0 + uses: rasa.nlu.extractors.mitie_entity_extractor.MitieEntityExtractor + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_EntitySynonymMapper3: + needs: + training_data: run_JiebaTokenizer1 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_RegexFeaturizer4: + needs: + training_data: run_JiebaTokenizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_RegexFeaturizer4: + needs: + training_data: run_JiebaTokenizer1 + resource: train_RegexFeaturizer4 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + run_MitieFeaturizer5: + needs: + training_data: run_RegexFeaturizer4 + model: provide_MitieNLP0 + uses: rasa.nlu.featurizers.dense_featurizer.mitie_featurizer.MitieFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_SklearnIntentClassifier6: + needs: + training_data: run_MitieFeaturizer5 + uses: rasa.nlu.classifiers.sklearn_intent_classifier.SklearnIntentClassifier + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null diff --git a/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_predict_schema.yml b/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_predict_schema.yml new file mode 100644 index 0000000..3d142a7 --- /dev/null +++ b/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_predict_schema.yml @@ -0,0 +1,129 @@ +nodes: + nlu_message_converter: + needs: + messages: __message__ + uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter + constructor_name: load + fn: convert_user_message + config: {} + eager: true + is_target: false + is_input: false + resource: null + provide_SpacyNLP0: + needs: {} + uses: rasa.nlu.utils.spacy_utils.SpacyNLP + constructor_name: load + fn: provide + config: + model: en_core_web_md + eager: true + is_target: false + is_input: false + resource: null + run_SpacyNLP0: + needs: + messages: nlu_message_converter + model: provide_SpacyNLP0 + uses: rasa.nlu.utils.spacy_utils.SpacyNLP + constructor_name: load + fn: process + config: + model: en_core_web_md + eager: true + is_target: false + is_input: false + resource: null + run_SpacyTokenizer1: + needs: + messages: run_SpacyNLP0 + uses: rasa.nlu.tokenizers.spacy_tokenizer.SpacyTokenizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_RegexFeaturizer2: + needs: + messages: run_SpacyTokenizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RegexFeaturizer2 + run_SpacyFeaturizer3: + needs: + messages: run_RegexFeaturizer2 + uses: rasa.nlu.featurizers.dense_featurizer.spacy_featurizer.SpacyFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_CRFEntityExtractor4: + needs: + messages: run_SpacyFeaturizer3 + uses: rasa.nlu.extractors.crf_entity_extractor.CRFEntityExtractor + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_CRFEntityExtractor4 + run_EntitySynonymMapper5: + needs: + messages: run_CRFEntityExtractor4 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_EntitySynonymMapper5 + run_SklearnIntentClassifier6: + needs: + messages: run_EntitySynonymMapper5 + uses: rasa.nlu.classifiers.sklearn_intent_classifier.SklearnIntentClassifier + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_SklearnIntentClassifier6 + run_DucklingEntityExtractor7: + needs: + messages: run_SklearnIntentClassifier6 + uses: rasa.nlu.extractors.duckling_entity_extractor.DucklingEntityExtractor + constructor_name: load + fn: process + config: + url: http://duckling:8000 + eager: true + is_target: false + is_input: false + resource: null + run_RegexMessageHandler: + needs: + messages: run_DucklingEntityExtractor7 + uses: rasa.nlu.classifiers.regex_message_handler.RegexMessageHandler + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null diff --git a/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_train_schema.yml b/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_train_schema.yml new file mode 100644 index 0000000..e938314 --- /dev/null +++ b/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_train_schema.yml @@ -0,0 +1,140 @@ +nodes: + schema_validator: + needs: + importer: __importer__ + uses: rasa.graph_components.validators.default_recipe_validator.DefaultV1RecipeValidator + constructor_name: create + fn: validate + config: {} + eager: false + is_target: false + is_input: true + resource: null + finetuning_validator: + needs: + importer: schema_validator + uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator + constructor_name: create + fn: validate + config: + validate_core: false + validate_nlu: true + eager: false + is_target: false + is_input: true + resource: null + nlu_training_data_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.nlu_training_data_provider.NLUTrainingDataProvider + constructor_name: create + fn: provide + config: + language: en + persist: false + eager: false + is_target: false + is_input: true + resource: null + provide_SpacyNLP0: + needs: {} + uses: rasa.nlu.utils.spacy_utils.SpacyNLP + constructor_name: create + fn: provide + config: + model: en_core_web_md + eager: false + is_target: false + is_input: false + resource: null + run_SpacyNLP0: + needs: + training_data: nlu_training_data_provider + model: provide_SpacyNLP0 + uses: rasa.nlu.utils.spacy_utils.SpacyNLP + constructor_name: load + fn: process_training_data + config: + model: en_core_web_md + eager: false + is_target: false + is_input: false + resource: null + run_SpacyTokenizer1: + needs: + training_data: run_SpacyNLP0 + uses: rasa.nlu.tokenizers.spacy_tokenizer.SpacyTokenizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_RegexFeaturizer2: + needs: + training_data: run_SpacyTokenizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_RegexFeaturizer2: + needs: + training_data: run_SpacyTokenizer1 + resource: train_RegexFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + run_SpacyFeaturizer3: + needs: + training_data: run_RegexFeaturizer2 + uses: rasa.nlu.featurizers.dense_featurizer.spacy_featurizer.SpacyFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_CRFEntityExtractor4: + needs: + training_data: run_SpacyFeaturizer3 + uses: rasa.nlu.extractors.crf_entity_extractor.CRFEntityExtractor + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_EntitySynonymMapper5: + needs: + training_data: run_SpacyFeaturizer3 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_SklearnIntentClassifier6: + needs: + training_data: run_SpacyFeaturizer3 + uses: rasa.nlu.classifiers.sklearn_intent_classifier.SklearnIntentClassifier + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null diff --git a/data/graph_schemas/default_config_core_predict_schema.yml b/data/graph_schemas/default_config_core_predict_schema.yml new file mode 100644 index 0000000..a3c5550 --- /dev/null +++ b/data/graph_schemas/default_config_core_predict_schema.yml @@ -0,0 +1,123 @@ +nodes: + domain_provider: + needs: {} + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: domain_provider + run_MemoizationPolicy0: + needs: + domain: domain_provider + rule_only_data: rule_only_data_provider + tracker: __tracker__ + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: load + fn: predict_action_probabilities + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_MemoizationPolicy0 + run_RulePolicy1: + needs: + domain: domain_provider + rule_only_data: rule_only_data_provider + tracker: __tracker__ + uses: rasa.core.policies.rule_policy.RulePolicy + constructor_name: load + fn: predict_action_probabilities + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RulePolicy1 + run_UnexpecTEDIntentPolicy2: + needs: + domain: domain_provider + rule_only_data: rule_only_data_provider + tracker: __tracker__ + uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicy + constructor_name: load + fn: predict_action_probabilities + config: + epochs: 100 + max_history: 5 + eager: true + is_target: false + is_input: false + resource: + name: train_UnexpecTEDIntentPolicy2 + run_TEDPolicy3: + needs: + domain: domain_provider + rule_only_data: rule_only_data_provider + tracker: __tracker__ + uses: rasa.core.policies.ted_policy.TEDPolicy + constructor_name: load + fn: predict_action_probabilities + config: + epochs: 100 + max_history: 5 + constrain_similarities: true + eager: true + is_target: false + is_input: false + resource: + name: train_TEDPolicy3 + rule_only_data_provider: + needs: {} + uses: rasa.graph_components.providers.rule_only_provider.RuleOnlyDataProvider + constructor_name: load + fn: provide + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RulePolicy1 + select_prediction: + needs: + policy0: run_MemoizationPolicy0 + policy1: run_RulePolicy1 + policy2: run_UnexpecTEDIntentPolicy2 + policy3: run_TEDPolicy3 + domain: domain_provider + tracker: __tracker__ + uses: rasa.core.policies.ensemble.DefaultPolicyPredictionEnsemble + constructor_name: load + fn: combine_predictions_from_kwargs + config: {} + eager: true + is_target: false + is_input: false + resource: null + nlu_message_converter: + needs: + messages: __message__ + uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter + constructor_name: load + fn: convert_user_message + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_RegexMessageHandler: + needs: + messages: nlu_message_converter + domain: domain_provider + uses: rasa.nlu.classifiers.regex_message_handler.RegexMessageHandler + constructor_name: load + fn: process + config: {} + eager: True + is_target: False + is_input: False + resource: null diff --git a/data/graph_schemas/default_config_core_train_schema.yml b/data/graph_schemas/default_config_core_train_schema.yml new file mode 100644 index 0000000..da7ca36 --- /dev/null +++ b/data/graph_schemas/default_config_core_train_schema.yml @@ -0,0 +1,146 @@ +nodes: + schema_validator: + needs: + importer: __importer__ + uses: rasa.graph_components.validators.default_recipe_validator.DefaultV1RecipeValidator + constructor_name: create + fn: validate + config: {} + eager: false + is_target: false + is_input: true + resource: null + finetuning_validator: + needs: + importer: schema_validator + uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator + constructor_name: create + fn: validate + config: + validate_core: true + validate_nlu: false + eager: false + is_target: false + is_input: true + resource: null + domain_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null + domain_for_core_training_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.domain_for_core_training_provider.DomainForCoreTrainingProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + forms_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.forms_provider.FormsProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + responses_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.responses_provider.ResponsesProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + story_graph_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.story_graph_provider.StoryGraphProvider + constructor_name: create + fn: provide + config: + exclusion_percentage: null + eager: false + is_target: false + is_input: true + resource: null + training_tracker_provider: + needs: + story_graph: story_graph_provider + domain: domain_for_core_training_provider + uses: rasa.graph_components.providers.training_tracker_provider.TrainingTrackerProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_MemoizationPolicy0: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_RulePolicy1: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.rule_policy.RulePolicy + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_UnexpecTEDIntentPolicy2: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicy + constructor_name: create + fn: train + config: + max_history: 5 + epochs: 100 + eager: false + is_target: true + is_input: false + resource: null + train_TEDPolicy3: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.ted_policy.TEDPolicy + constructor_name: create + fn: train + config: + max_history: 5 + epochs: 100 + constrain_similarities: true + eager: false + is_target: true + is_input: false + resource: null diff --git a/data/graph_schemas/default_config_e2e_predict_schema.yml b/data/graph_schemas/default_config_e2e_predict_schema.yml new file mode 100644 index 0000000..fe1cfee --- /dev/null +++ b/data/graph_schemas/default_config_e2e_predict_schema.yml @@ -0,0 +1,324 @@ +nodes: + nlu_message_converter: + needs: + messages: __message__ + uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter + constructor_name: load + fn: convert_user_message + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_WhitespaceTokenizer0: + needs: + messages: nlu_message_converter + uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_RegexFeaturizer1: + needs: + messages: run_WhitespaceTokenizer0 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RegexFeaturizer1 + run_LexicalSyntacticFeaturizer2: + needs: + messages: run_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_LexicalSyntacticFeaturizer2 + run_CountVectorsFeaturizer3: + needs: + messages: run_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_CountVectorsFeaturizer3 + run_CountVectorsFeaturizer4: + needs: + messages: run_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: true + is_target: false + is_input: false + resource: + name: train_CountVectorsFeaturizer4 + run_DIETClassifier5: + needs: + messages: run_CountVectorsFeaturizer4 + uses: rasa.nlu.classifiers.diet_classifier.DIETClassifier + constructor_name: load + fn: process + config: + epochs: 100 + constrain_similarities: true + eager: true + is_target: false + is_input: false + resource: + name: train_DIETClassifier5 + run_EntitySynonymMapper6: + needs: + messages: run_DIETClassifier5 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_EntitySynonymMapper6 + run_ResponseSelector7: + needs: + messages: run_EntitySynonymMapper6 + uses: rasa.nlu.selectors.response_selector.ResponseSelector + constructor_name: load + fn: process + config: + epochs: 100 + constrain_similarities: true + eager: true + is_target: false + is_input: false + resource: + name: train_ResponseSelector7 + run_FallbackClassifier8: + needs: + messages: run_ResponseSelector7 + uses: rasa.nlu.classifiers.fallback_classifier.FallbackClassifier + constructor_name: load + fn: process + config: + threshold: 0.3 + ambiguity_threshold: 0.1 + eager: true + is_target: false + is_input: false + resource: null + run_RegexMessageHandler: + needs: + messages: run_FallbackClassifier8 + domain: domain_provider + uses: rasa.nlu.classifiers.regex_message_handler.RegexMessageHandler + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null + domain_provider: + needs: {} + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: domain_provider + tracker_to_message_converter: + needs: + tracker: __tracker__ + uses: rasa.core.featurizers.precomputation.CoreFeaturizationInputConverter + constructor_name: load + fn: convert_for_inference + config: {} + eager: true + is_target: false + is_input: false + resource: null + e2e_run_WhitespaceTokenizer0: + needs: + messages: tracker_to_message_converter + uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null + e2e_run_RegexFeaturizer1: + needs: + messages: e2e_run_WhitespaceTokenizer0 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RegexFeaturizer1 + e2e_run_LexicalSyntacticFeaturizer2: + needs: + messages: e2e_run_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_LexicalSyntacticFeaturizer2 + e2e_run_CountVectorsFeaturizer3: + needs: + messages: e2e_run_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_CountVectorsFeaturizer3 + e2e_run_CountVectorsFeaturizer4: + needs: + messages: e2e_run_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: true + is_target: false + is_input: false + resource: + name: train_CountVectorsFeaturizer4 + end_to_end_features_provider: + needs: + messages: e2e_run_CountVectorsFeaturizer4 + uses: rasa.core.featurizers.precomputation.CoreFeaturizationCollector + constructor_name: load + fn: collect + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_MemoizationPolicy0: + needs: + tracker: __tracker__ + domain: domain_provider + rule_only_data: rule_only_data_provider + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: load + fn: predict_action_probabilities + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_MemoizationPolicy0 + run_RulePolicy1: + needs: + tracker: __tracker__ + domain: domain_provider + rule_only_data: rule_only_data_provider + uses: rasa.core.policies.rule_policy.RulePolicy + constructor_name: load + fn: predict_action_probabilities + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RulePolicy1 + run_UnexpecTEDIntentPolicy2: + needs: + tracker: __tracker__ + domain: domain_provider + precomputations: end_to_end_features_provider + rule_only_data: rule_only_data_provider + uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicy + constructor_name: load + fn: predict_action_probabilities + config: + epochs: 100 + max_history: 5 + eager: true + is_target: false + is_input: false + resource: + name: train_UnexpecTEDIntentPolicy2 + run_TEDPolicy3: + needs: + tracker: __tracker__ + domain: domain_provider + precomputations: end_to_end_features_provider + rule_only_data: rule_only_data_provider + uses: rasa.core.policies.ted_policy.TEDPolicy + constructor_name: load + fn: predict_action_probabilities + config: + epochs: 100 + max_history: 5 + constrain_similarities: true + eager: true + is_target: false + is_input: false + resource: + name: train_TEDPolicy3 + rule_only_data_provider: + needs: {} + uses: rasa.graph_components.providers.rule_only_provider.RuleOnlyDataProvider + constructor_name: load + fn: provide + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RulePolicy1 + select_prediction: + needs: + policy0: run_MemoizationPolicy0 + policy1: run_RulePolicy1 + policy2: run_UnexpecTEDIntentPolicy2 + policy3: run_TEDPolicy3 + domain: domain_provider + tracker: __tracker__ + uses: rasa.core.policies.ensemble.DefaultPolicyPredictionEnsemble + constructor_name: load + fn: combine_predictions_from_kwargs + config: {} + eager: true + is_target: false + is_input: false + resource: null diff --git a/data/graph_schemas/default_config_e2e_train_schema.yml b/data/graph_schemas/default_config_e2e_train_schema.yml new file mode 100644 index 0000000..bc8261e --- /dev/null +++ b/data/graph_schemas/default_config_e2e_train_schema.yml @@ -0,0 +1,392 @@ +nodes: + schema_validator: + needs: + importer: __importer__ + uses: rasa.graph_components.validators.default_recipe_validator.DefaultV1RecipeValidator + constructor_name: create + fn: validate + config: {} + eager: false + is_target: false + is_input: true + resource: null + finetuning_validator: + needs: + importer: schema_validator + uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator + constructor_name: create + fn: validate + config: + validate_core: true + validate_nlu: true + eager: false + is_target: false + is_input: true + resource: null + nlu_training_data_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.nlu_training_data_provider.NLUTrainingDataProvider + constructor_name: create + fn: provide + config: + language: en + persist: false + eager: false + is_target: false + is_input: true + resource: null + run_WhitespaceTokenizer0: + needs: + training_data: nlu_training_data_provider + uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_RegexFeaturizer1: + needs: + training_data: run_WhitespaceTokenizer0 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_RegexFeaturizer1: + needs: + training_data: run_WhitespaceTokenizer0 + resource: train_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_LexicalSyntacticFeaturizer2: + needs: + training_data: run_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_LexicalSyntacticFeaturizer2: + needs: + training_data: run_RegexFeaturizer1 + resource: train_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_CountVectorsFeaturizer3: + needs: + training_data: run_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_CountVectorsFeaturizer3: + needs: + training_data: run_LexicalSyntacticFeaturizer2 + resource: train_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_CountVectorsFeaturizer4: + needs: + training_data: run_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: create + fn: train + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: false + is_target: true + is_input: false + resource: null + run_CountVectorsFeaturizer4: + needs: + training_data: run_CountVectorsFeaturizer3 + resource: train_CountVectorsFeaturizer4 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process_training_data + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: false + is_target: false + is_input: false + resource: null + train_DIETClassifier5: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.classifiers.diet_classifier.DIETClassifier + constructor_name: create + fn: train + config: + epochs: 100 + constrain_similarities: true + eager: false + is_target: true + is_input: false + resource: null + train_EntitySynonymMapper6: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_ResponseSelector7: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.selectors.response_selector.ResponseSelector + constructor_name: create + fn: train + config: + epochs: 100 + constrain_similarities: true + eager: false + is_target: true + is_input: false + resource: null + domain_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null + domain_for_core_training_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.domain_for_core_training_provider.DomainForCoreTrainingProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + forms_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.forms_provider.FormsProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + responses_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.responses_provider.ResponsesProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + story_graph_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.story_graph_provider.StoryGraphProvider + constructor_name: create + fn: provide + config: + exclusion_percentage: null + eager: false + is_target: false + is_input: true + resource: null + training_tracker_provider: + needs: + story_graph: story_graph_provider + domain: domain_for_core_training_provider + uses: rasa.graph_components.providers.training_tracker_provider.TrainingTrackerProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: false + resource: null + story_to_nlu_training_data_converter: + needs: + story_graph: story_graph_provider + domain: domain_for_core_training_provider + uses: rasa.core.featurizers.precomputation.CoreFeaturizationInputConverter + constructor_name: create + fn: convert_for_training + config: {} + eager: false + is_target: false + is_input: true + resource: null + e2e_run_WhitespaceTokenizer0: + needs: + training_data: story_to_nlu_training_data_converter + uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + e2e_run_RegexFeaturizer1: + needs: + training_data: e2e_run_WhitespaceTokenizer0 + resource: train_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + e2e_run_LexicalSyntacticFeaturizer2: + needs: + training_data: e2e_run_RegexFeaturizer1 + resource: train_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + e2e_run_CountVectorsFeaturizer3: + needs: + training_data: e2e_run_LexicalSyntacticFeaturizer2 + resource: train_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + e2e_run_CountVectorsFeaturizer4: + needs: + training_data: e2e_run_CountVectorsFeaturizer3 + resource: train_CountVectorsFeaturizer4 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process_training_data + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: false + is_target: false + is_input: false + resource: null + end_to_end_features_provider: + needs: + messages: e2e_run_CountVectorsFeaturizer4 + uses: rasa.core.featurizers.precomputation.CoreFeaturizationCollector + constructor_name: create + fn: collect + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_MemoizationPolicy0: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_RulePolicy1: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.rule_policy.RulePolicy + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_UnexpecTEDIntentPolicy2: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + precomputations: end_to_end_features_provider + uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicy + constructor_name: create + fn: train + config: + max_history: 5 + epochs: 100 + eager: false + is_target: true + is_input: false + resource: null + train_TEDPolicy3: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + precomputations: end_to_end_features_provider + uses: rasa.core.policies.ted_policy.TEDPolicy + constructor_name: create + fn: train + config: + max_history: 5 + epochs: 100 + constrain_similarities: true + eager: false + is_target: true + is_input: false + resource: null diff --git a/data/graph_schemas/default_config_finetune_epoch_fraction_schema.yml b/data/graph_schemas/default_config_finetune_epoch_fraction_schema.yml new file mode 100644 index 0000000..d783a19 --- /dev/null +++ b/data/graph_schemas/default_config_finetune_epoch_fraction_schema.yml @@ -0,0 +1,309 @@ +nodes: + schema_validator: + needs: + importer: __importer__ + uses: rasa.graph_components.validators.default_recipe_validator.DefaultV1RecipeValidator + constructor_name: create + fn: validate + config: {} + eager: false + is_target: false + is_input: true + resource: null + finetuning_validator: + needs: + importer: schema_validator + uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator + constructor_name: load + fn: validate + config: + validate_core: true + validate_nlu: true + eager: false + is_target: false + is_input: true + resource: null + nlu_training_data_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.nlu_training_data_provider.NLUTrainingDataProvider + constructor_name: create + fn: provide + config: + language: en + persist: false + eager: false + is_target: false + is_input: true + resource: null + run_WhitespaceTokenizer0: + needs: + training_data: nlu_training_data_provider + uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_RegexFeaturizer1: + needs: + training_data: run_WhitespaceTokenizer0 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_RegexFeaturizer1: + needs: + training_data: run_WhitespaceTokenizer0 + resource: train_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_LexicalSyntacticFeaturizer2: + needs: + training_data: run_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: load + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_LexicalSyntacticFeaturizer2: + needs: + training_data: run_RegexFeaturizer1 + resource: train_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_CountVectorsFeaturizer3: + needs: + training_data: run_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_CountVectorsFeaturizer3: + needs: + training_data: run_LexicalSyntacticFeaturizer2 + resource: train_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_CountVectorsFeaturizer4: + needs: + training_data: run_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: train + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: false + is_target: true + is_input: false + resource: null + run_CountVectorsFeaturizer4: + needs: + training_data: run_CountVectorsFeaturizer3 + resource: train_CountVectorsFeaturizer4 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process_training_data + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: false + is_target: false + is_input: false + resource: null + train_DIETClassifier5: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.classifiers.diet_classifier.DIETClassifier + constructor_name: load + fn: train + config: + epochs: 50 + constrain_similarities: true + finetuning_epoch_fraction: 0.5 + eager: false + is_target: true + is_input: false + resource: null + train_EntitySynonymMapper6: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: load + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_ResponseSelector7: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.selectors.response_selector.ResponseSelector + constructor_name: load + fn: train + config: + epochs: 50 + constrain_similarities: true + finetuning_epoch_fraction: 0.5 + eager: false + is_target: true + is_input: false + resource: null + domain_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null + domain_for_core_training_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.domain_for_core_training_provider.DomainForCoreTrainingProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + forms_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.forms_provider.FormsProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + responses_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.responses_provider.ResponsesProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + story_graph_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.story_graph_provider.StoryGraphProvider + constructor_name: create + fn: provide + config: + exclusion_percentage: null + eager: false + is_target: false + is_input: true + resource: null + training_tracker_provider: + needs: + story_graph: story_graph_provider + domain: domain_for_core_training_provider + uses: rasa.graph_components.providers.training_tracker_provider.TrainingTrackerProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_MemoizationPolicy0: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: load + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_RulePolicy1: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.rule_policy.RulePolicy + constructor_name: load + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_UnexpecTEDIntentPolicy2: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicy + constructor_name: load + fn: train + config: + max_history: 5 + epochs: 50 + finetuning_epoch_fraction: 0.5 + eager: false + is_target: true + is_input: false + resource: null + train_TEDPolicy3: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.ted_policy.TEDPolicy + constructor_name: load + fn: train + config: + max_history: 5 + epochs: 50 + constrain_similarities: true + finetuning_epoch_fraction: 0.5 + eager: false + is_target: true + is_input: false + resource: null diff --git a/data/graph_schemas/default_config_finetune_schema.yml b/data/graph_schemas/default_config_finetune_schema.yml new file mode 100644 index 0000000..5e36e5d --- /dev/null +++ b/data/graph_schemas/default_config_finetune_schema.yml @@ -0,0 +1,305 @@ +nodes: + schema_validator: + needs: + importer: __importer__ + uses: rasa.graph_components.validators.default_recipe_validator.DefaultV1RecipeValidator + constructor_name: create + fn: validate + config: {} + eager: false + is_target: false + is_input: true + resource: null + finetuning_validator: + needs: + importer: schema_validator + uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator + constructor_name: load + fn: validate + config: + validate_core: true + validate_nlu: true + eager: false + is_target: false + is_input: true + resource: null + nlu_training_data_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.nlu_training_data_provider.NLUTrainingDataProvider + constructor_name: create + fn: provide + config: + language: en + persist: false + eager: false + is_target: false + is_input: true + resource: null + run_WhitespaceTokenizer0: + needs: + training_data: nlu_training_data_provider + uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_RegexFeaturizer1: + needs: + training_data: run_WhitespaceTokenizer0 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_RegexFeaturizer1: + needs: + training_data: run_WhitespaceTokenizer0 + resource: train_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_LexicalSyntacticFeaturizer2: + needs: + training_data: run_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: load + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_LexicalSyntacticFeaturizer2: + needs: + training_data: run_RegexFeaturizer1 + resource: train_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_CountVectorsFeaturizer3: + needs: + training_data: run_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_CountVectorsFeaturizer3: + needs: + training_data: run_LexicalSyntacticFeaturizer2 + resource: train_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_CountVectorsFeaturizer4: + needs: + training_data: run_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: train + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: false + is_target: true + is_input: false + resource: null + run_CountVectorsFeaturizer4: + needs: + training_data: run_CountVectorsFeaturizer3 + resource: train_CountVectorsFeaturizer4 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process_training_data + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: false + is_target: false + is_input: false + resource: null + train_DIETClassifier5: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.classifiers.diet_classifier.DIETClassifier + constructor_name: load + fn: train + config: + epochs: 100 + constrain_similarities: true + eager: false + is_target: true + is_input: false + resource: null + train_EntitySynonymMapper6: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: load + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_ResponseSelector7: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.selectors.response_selector.ResponseSelector + constructor_name: load + fn: train + config: + epochs: 100 + constrain_similarities: true + eager: false + is_target: true + is_input: false + resource: null + domain_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null + domain_for_core_training_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.domain_for_core_training_provider.DomainForCoreTrainingProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + forms_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.forms_provider.FormsProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + responses_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.responses_provider.ResponsesProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + story_graph_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.story_graph_provider.StoryGraphProvider + constructor_name: create + fn: provide + config: + exclusion_percentage: null + eager: false + is_target: false + is_input: true + resource: null + training_tracker_provider: + needs: + story_graph: story_graph_provider + domain: domain_for_core_training_provider + uses: rasa.graph_components.providers.training_tracker_provider.TrainingTrackerProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_MemoizationPolicy0: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: load + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_RulePolicy1: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.rule_policy.RulePolicy + constructor_name: load + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_UnexpecTEDIntentPolicy2: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicy + constructor_name: load + fn: train + config: + max_history: 5 + epochs: 100 + eager: false + is_target: true + is_input: false + resource: null + train_TEDPolicy3: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.ted_policy.TEDPolicy + constructor_name: load + fn: train + config: + max_history: 5 + epochs: 100 + constrain_similarities: true + eager: false + is_target: true + is_input: false + resource: null diff --git a/data/graph_schemas/default_config_nlu_predict_schema.yml b/data/graph_schemas/default_config_nlu_predict_schema.yml new file mode 100644 index 0000000..d1948cf --- /dev/null +++ b/data/graph_schemas/default_config_nlu_predict_schema.yml @@ -0,0 +1,138 @@ +nodes: + nlu_message_converter: + needs: + messages: __message__ + uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter + constructor_name: load + fn: convert_user_message + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_WhitespaceTokenizer0: + needs: + messages: nlu_message_converter + uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_RegexFeaturizer1: + needs: + messages: run_WhitespaceTokenizer0 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RegexFeaturizer1 + run_LexicalSyntacticFeaturizer2: + needs: + messages: run_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_LexicalSyntacticFeaturizer2 + run_CountVectorsFeaturizer3: + needs: + messages: run_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_CountVectorsFeaturizer3 + run_CountVectorsFeaturizer4: + needs: + messages: run_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: true + is_target: false + is_input: false + resource: + name: train_CountVectorsFeaturizer4 + run_DIETClassifier5: + needs: + messages: run_CountVectorsFeaturizer4 + uses: rasa.nlu.classifiers.diet_classifier.DIETClassifier + constructor_name: load + fn: process + config: + epochs: 100 + constrain_similarities: true + eager: true + is_target: false + is_input: false + resource: + name: train_DIETClassifier5 + run_EntitySynonymMapper6: + needs: + messages: run_DIETClassifier5 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_EntitySynonymMapper6 + run_ResponseSelector7: + needs: + messages: run_EntitySynonymMapper6 + uses: rasa.nlu.selectors.response_selector.ResponseSelector + constructor_name: load + fn: process + config: + epochs: 100 + constrain_similarities: true + eager: true + is_target: false + is_input: false + resource: + name: train_ResponseSelector7 + run_FallbackClassifier8: + needs: + messages: run_ResponseSelector7 + uses: rasa.nlu.classifiers.fallback_classifier.FallbackClassifier + constructor_name: load + fn: process + config: + threshold: 0.3 + ambiguity_threshold: 0.1 + eager: true + is_target: false + is_input: false + resource: null + run_RegexMessageHandler: + needs: + messages: run_FallbackClassifier8 + uses: rasa.nlu.classifiers.regex_message_handler.RegexMessageHandler + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null diff --git a/data/graph_schemas/default_config_nlu_train_schema.yml b/data/graph_schemas/default_config_nlu_train_schema.yml new file mode 100644 index 0000000..2e39f1f --- /dev/null +++ b/data/graph_schemas/default_config_nlu_train_schema.yml @@ -0,0 +1,184 @@ +nodes: + schema_validator: + needs: + importer: __importer__ + uses: rasa.graph_components.validators.default_recipe_validator.DefaultV1RecipeValidator + constructor_name: create + fn: validate + config: {} + eager: false + is_target: false + is_input: true + resource: null + finetuning_validator: + needs: + importer: schema_validator + uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator + constructor_name: create + fn: validate + config: + validate_core: false + validate_nlu: true + eager: false + is_target: false + is_input: true + resource: null + nlu_training_data_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.nlu_training_data_provider.NLUTrainingDataProvider + constructor_name: create + fn: provide + config: + language: en + persist: false + eager: false + is_target: false + is_input: true + resource: null + run_WhitespaceTokenizer0: + needs: + training_data: nlu_training_data_provider + uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_RegexFeaturizer1: + needs: + training_data: run_WhitespaceTokenizer0 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_RegexFeaturizer1: + needs: + training_data: run_WhitespaceTokenizer0 + resource: train_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_LexicalSyntacticFeaturizer2: + needs: + training_data: run_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_LexicalSyntacticFeaturizer2: + needs: + training_data: run_RegexFeaturizer1 + resource: train_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_CountVectorsFeaturizer3: + needs: + training_data: run_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_CountVectorsFeaturizer3: + needs: + training_data: run_LexicalSyntacticFeaturizer2 + resource: train_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_CountVectorsFeaturizer4: + needs: + training_data: run_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: create + fn: train + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: false + is_target: true + is_input: false + resource: null + run_CountVectorsFeaturizer4: + needs: + training_data: run_CountVectorsFeaturizer3 + resource: train_CountVectorsFeaturizer4 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process_training_data + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: false + is_target: false + is_input: false + resource: null + train_DIETClassifier5: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.classifiers.diet_classifier.DIETClassifier + constructor_name: create + fn: train + config: + epochs: 100 + constrain_similarities: true + eager: false + is_target: true + is_input: false + resource: null + train_EntitySynonymMapper6: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_ResponseSelector7: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.selectors.response_selector.ResponseSelector + constructor_name: create + fn: train + config: + epochs: 100 + constrain_similarities: true + eager: false + is_target: true + is_input: false + resource: null diff --git a/data/graph_schemas/default_config_predict_schema.yml b/data/graph_schemas/default_config_predict_schema.yml new file mode 100644 index 0000000..12ab1e8 --- /dev/null +++ b/data/graph_schemas/default_config_predict_schema.yml @@ -0,0 +1,238 @@ +nodes: + nlu_message_converter: + needs: + messages: __message__ + uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter + constructor_name: load + fn: convert_user_message + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_WhitespaceTokenizer0: + needs: + messages: nlu_message_converter + uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_RegexFeaturizer1: + needs: + messages: run_WhitespaceTokenizer0 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RegexFeaturizer1 + run_LexicalSyntacticFeaturizer2: + needs: + messages: run_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_LexicalSyntacticFeaturizer2 + run_CountVectorsFeaturizer3: + needs: + messages: run_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_CountVectorsFeaturizer3 + run_CountVectorsFeaturizer4: + needs: + messages: run_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: true + is_target: false + is_input: false + resource: + name: train_CountVectorsFeaturizer4 + run_DIETClassifier5: + needs: + messages: run_CountVectorsFeaturizer4 + uses: rasa.nlu.classifiers.diet_classifier.DIETClassifier + constructor_name: load + fn: process + config: + epochs: 100 + constrain_similarities: true + eager: true + is_target: false + is_input: false + resource: + name: train_DIETClassifier5 + run_EntitySynonymMapper6: + needs: + messages: run_DIETClassifier5 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_EntitySynonymMapper6 + run_ResponseSelector7: + needs: + messages: run_EntitySynonymMapper6 + uses: rasa.nlu.selectors.response_selector.ResponseSelector + constructor_name: load + fn: process + config: + epochs: 100 + constrain_similarities: true + eager: true + is_target: false + is_input: false + resource: + name: train_ResponseSelector7 + run_FallbackClassifier8: + needs: + messages: run_ResponseSelector7 + uses: rasa.nlu.classifiers.fallback_classifier.FallbackClassifier + constructor_name: load + fn: process + config: + threshold: 0.3 + ambiguity_threshold: 0.1 + eager: true + is_target: false + is_input: false + resource: null + run_RegexMessageHandler: + needs: + messages: run_FallbackClassifier8 + domain: domain_provider + uses: rasa.nlu.classifiers.regex_message_handler.RegexMessageHandler + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null + domain_provider: + needs: {} + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: domain_provider + run_MemoizationPolicy0: + needs: + tracker: __tracker__ + domain: domain_provider + rule_only_data: rule_only_data_provider + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: load + fn: predict_action_probabilities + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_MemoizationPolicy0 + run_RulePolicy1: + needs: + tracker: __tracker__ + domain: domain_provider + rule_only_data: rule_only_data_provider + uses: rasa.core.policies.rule_policy.RulePolicy + constructor_name: load + fn: predict_action_probabilities + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RulePolicy1 + run_UnexpecTEDIntentPolicy2: + needs: + tracker: __tracker__ + domain: domain_provider + rule_only_data: rule_only_data_provider + uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicy + constructor_name: load + fn: predict_action_probabilities + config: + epochs: 100 + max_history: 5 + eager: true + is_target: false + is_input: false + resource: + name: train_UnexpecTEDIntentPolicy2 + run_TEDPolicy3: + needs: + tracker: __tracker__ + domain: domain_provider + rule_only_data: rule_only_data_provider + uses: rasa.core.policies.ted_policy.TEDPolicy + constructor_name: load + fn: predict_action_probabilities + config: + epochs: 100 + max_history: 5 + constrain_similarities: true + eager: true + is_target: false + is_input: false + resource: + name: train_TEDPolicy3 + rule_only_data_provider: + needs: {} + uses: rasa.graph_components.providers.rule_only_provider.RuleOnlyDataProvider + constructor_name: load + fn: provide + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RulePolicy1 + select_prediction: + needs: + policy0: run_MemoizationPolicy0 + policy1: run_RulePolicy1 + policy2: run_UnexpecTEDIntentPolicy2 + policy3: run_TEDPolicy3 + domain: domain_provider + tracker: __tracker__ + uses: rasa.core.policies.ensemble.DefaultPolicyPredictionEnsemble + constructor_name: load + fn: combine_predictions_from_kwargs + config: {} + eager: true + is_target: false + is_input: false + resource: null diff --git a/data/graph_schemas/default_config_train_schema.yml b/data/graph_schemas/default_config_train_schema.yml new file mode 100644 index 0000000..a3b4288 --- /dev/null +++ b/data/graph_schemas/default_config_train_schema.yml @@ -0,0 +1,305 @@ +nodes: + schema_validator: + needs: + importer: __importer__ + uses: rasa.graph_components.validators.default_recipe_validator.DefaultV1RecipeValidator + constructor_name: create + fn: validate + config: {} + eager: false + is_target: false + is_input: true + resource: null + finetuning_validator: + needs: + importer: schema_validator + uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator + constructor_name: create + fn: validate + config: + validate_core: true + validate_nlu: true + eager: false + is_target: false + is_input: true + resource: null + nlu_training_data_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.nlu_training_data_provider.NLUTrainingDataProvider + constructor_name: create + fn: provide + config: + language: en + persist: false + eager: false + is_target: false + is_input: true + resource: null + run_WhitespaceTokenizer0: + needs: + training_data: nlu_training_data_provider + uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_RegexFeaturizer1: + needs: + training_data: run_WhitespaceTokenizer0 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_RegexFeaturizer1: + needs: + training_data: run_WhitespaceTokenizer0 + resource: train_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_LexicalSyntacticFeaturizer2: + needs: + training_data: run_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_LexicalSyntacticFeaturizer2: + needs: + training_data: run_RegexFeaturizer1 + resource: train_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_CountVectorsFeaturizer3: + needs: + training_data: run_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_CountVectorsFeaturizer3: + needs: + training_data: run_LexicalSyntacticFeaturizer2 + resource: train_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_CountVectorsFeaturizer4: + needs: + training_data: run_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: create + fn: train + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: false + is_target: true + is_input: false + resource: null + run_CountVectorsFeaturizer4: + needs: + training_data: run_CountVectorsFeaturizer3 + resource: train_CountVectorsFeaturizer4 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process_training_data + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: false + is_target: false + is_input: false + resource: null + train_DIETClassifier5: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.classifiers.diet_classifier.DIETClassifier + constructor_name: create + fn: train + config: + epochs: 100 + constrain_similarities: true + eager: false + is_target: true + is_input: false + resource: null + train_EntitySynonymMapper6: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_ResponseSelector7: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.selectors.response_selector.ResponseSelector + constructor_name: create + fn: train + config: + epochs: 100 + constrain_similarities: true + eager: false + is_target: true + is_input: false + resource: null + domain_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null + domain_for_core_training_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.domain_for_core_training_provider.DomainForCoreTrainingProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + forms_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.forms_provider.FormsProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + responses_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.responses_provider.ResponsesProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + story_graph_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.story_graph_provider.StoryGraphProvider + constructor_name: create + fn: provide + config: + exclusion_percentage: null + eager: false + is_target: false + is_input: true + resource: null + training_tracker_provider: + needs: + story_graph: story_graph_provider + domain: domain_for_core_training_provider + uses: rasa.graph_components.providers.training_tracker_provider.TrainingTrackerProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_MemoizationPolicy0: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_RulePolicy1: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.rule_policy.RulePolicy + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_UnexpecTEDIntentPolicy2: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicy + constructor_name: create + fn: train + config: + max_history: 5 + epochs: 100 + eager: false + is_target: true + is_input: false + resource: null + train_TEDPolicy3: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.ted_policy.TEDPolicy + constructor_name: create + fn: train + config: + max_history: 5 + epochs: 100 + constrain_similarities: true + eager: false + is_target: true + is_input: false + resource: null diff --git a/data/graph_schemas/graph_config_short_predict_schema.yml b/data/graph_schemas/graph_config_short_predict_schema.yml new file mode 100644 index 0000000..eeeffb0 --- /dev/null +++ b/data/graph_schemas/graph_config_short_predict_schema.yml @@ -0,0 +1,73 @@ +nodes: + nlu_message_converter: + needs: + messages: __message__ + uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter + constructor_name: load + fn: convert_user_message + config: {} + eager: true + is_target: false + is_input: false + resource: null + custom_nlu_target: + needs: + messages: nlu_message_converter + domain: domain_provider + uses: rasa.nlu.classifiers.regex_message_handler.RegexMessageHandler + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null + domain_provider: + needs: {} + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: domain_provider + run_MemoizationPolicy0: + needs: + domain: domain_provider + tracker: __tracker__ + rule_only_data: rule_only_data_provider + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: load + fn: predict_action_probabilities + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_MemoizationPolicy0 + rule_only_data_provider: + needs: {} + uses: rasa.graph_components.providers.rule_only_provider.RuleOnlyDataProvider + constructor_name: load + fn: provide + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RulePolicy1 + custom_core_target: + needs: + policy0: run_MemoizationPolicy0 + domain: domain_provider + tracker: __tracker__ + uses: rasa.core.policies.ensemble.DefaultPolicyPredictionEnsemble + constructor_name: load + fn: combine_predictions_from_kwargs + config: {} + eager: true + is_target: false + is_input: false + resource: null diff --git a/data/graph_schemas/graph_config_short_train_schema.yml b/data/graph_schemas/graph_config_short_train_schema.yml new file mode 100644 index 0000000..9a3de23 --- /dev/null +++ b/data/graph_schemas/graph_config_short_train_schema.yml @@ -0,0 +1,85 @@ +nodes: + finetuning_validator: + needs: + importer: __importer__ + uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator + constructor_name: create + fn: validate + config: + validate_core: true + validate_nlu: true + eager: false + is_target: false + is_input: true + resource: null + nlu_training_data_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.nlu_training_data_provider.NLUTrainingDataProvider + constructor_name: create + fn: provide + config: + language: en + persist: false + eager: false + is_target: false + is_input: true + resource: null + domain_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null + domain_for_core_training_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.domain_for_core_training_provider.DomainForCoreTrainingProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + story_graph_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.story_graph_provider.StoryGraphProvider + constructor_name: create + fn: provide + config: + exclusion_percentage: null + eager: false + is_target: false + is_input: true + resource: null + training_tracker_provider: + needs: + story_graph: story_graph_provider + domain: domain_for_core_training_provider + uses: rasa.graph_components.providers.training_tracker_provider.TrainingTrackerProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_MemoizationPolicy0: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null diff --git a/data/graph_schemas/keyword_classifier_config_predict_schema.yml b/data/graph_schemas/keyword_classifier_config_predict_schema.yml new file mode 100644 index 0000000..efbe223 --- /dev/null +++ b/data/graph_schemas/keyword_classifier_config_predict_schema.yml @@ -0,0 +1,35 @@ +nodes: + nlu_message_converter: + needs: + messages: __message__ + uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter + constructor_name: load + fn: convert_user_message + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_KeywordIntentClassifier0: + needs: + messages: nlu_message_converter + uses: rasa.nlu.classifiers.keyword_intent_classifier.KeywordIntentClassifier + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_KeywordIntentClassifier0 + run_RegexMessageHandler: + needs: + messages: run_KeywordIntentClassifier0 + uses: rasa.nlu.classifiers.regex_message_handler.RegexMessageHandler + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null diff --git a/data/graph_schemas/keyword_classifier_config_train_schema.yml b/data/graph_schemas/keyword_classifier_config_train_schema.yml new file mode 100644 index 0000000..328a316 --- /dev/null +++ b/data/graph_schemas/keyword_classifier_config_train_schema.yml @@ -0,0 +1,49 @@ +nodes: + schema_validator: + needs: + importer: __importer__ + uses: rasa.graph_components.validators.default_recipe_validator.DefaultV1RecipeValidator + constructor_name: create + fn: validate + config: {} + eager: false + is_target: false + is_input: true + resource: null + finetuning_validator: + needs: + importer: schema_validator + uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator + constructor_name: create + fn: validate + config: + validate_core: false + validate_nlu: true + eager: false + is_target: false + is_input: true + resource: null + nlu_training_data_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.nlu_training_data_provider.NLUTrainingDataProvider + constructor_name: create + fn: provide + config: + language: null + persist: false + eager: false + is_target: false + is_input: true + resource: null + train_KeywordIntentClassifier0: + needs: + training_data: nlu_training_data_provider + uses: rasa.nlu.classifiers.keyword_intent_classifier.KeywordIntentClassifier + constructor_name: create + fn: train + config: {} + eager: False + is_target: True + is_input: False + resource: null diff --git a/data/graph_schemas/max_hist_config_predict_schema.yml b/data/graph_schemas/max_hist_config_predict_schema.yml new file mode 100644 index 0000000..484bfa0 --- /dev/null +++ b/data/graph_schemas/max_hist_config_predict_schema.yml @@ -0,0 +1,105 @@ +nodes: + domain_provider: + needs: {} + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: domain_provider + run_MemoizationPolicy0: + needs: + domain: domain_provider + rule_only_data: rule_only_data_provider + tracker: __tracker__ + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: load + fn: predict_action_probabilities + config: + max_history: 5 + eager: true + is_target: false + is_input: false + resource: + name: train_MemoizationPolicy0 + run_RulePolicy1: + needs: + domain: domain_provider + rule_only_data: rule_only_data_provider + tracker: __tracker__ + uses: rasa.core.policies.rule_policy.RulePolicy + constructor_name: load + fn: predict_action_probabilities + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RulePolicy1 + run_TEDPolicy2: + needs: + domain: domain_provider + rule_only_data: rule_only_data_provider + tracker: __tracker__ + uses: rasa.core.policies.ted_policy.TEDPolicy + constructor_name: load + fn: predict_action_probabilities + config: + max_history: 5 + eager: true + is_target: false + is_input: false + resource: + name: train_TEDPolicy2 + rule_only_data_provider: + needs: {} + uses: rasa.graph_components.providers.rule_only_provider.RuleOnlyDataProvider + constructor_name: load + fn: provide + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RulePolicy1 + select_prediction: + needs: + policy0: run_MemoizationPolicy0 + policy1: run_RulePolicy1 + policy2: run_TEDPolicy2 + domain: domain_provider + tracker: __tracker__ + uses: rasa.core.policies.ensemble.DefaultPolicyPredictionEnsemble + constructor_name: load + fn: combine_predictions_from_kwargs + config: {} + eager: true + is_target: false + is_input: false + resource: null + nlu_message_converter: + needs: + messages: __message__ + uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter + constructor_name: load + fn: convert_user_message + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_RegexMessageHandler: + needs: + messages: nlu_message_converter + domain: domain_provider + uses: rasa.nlu.classifiers.regex_message_handler.RegexMessageHandler + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null diff --git a/data/graph_schemas/max_hist_config_train_schema.yml b/data/graph_schemas/max_hist_config_train_schema.yml new file mode 100644 index 0000000..416e4cc --- /dev/null +++ b/data/graph_schemas/max_hist_config_train_schema.yml @@ -0,0 +1,131 @@ +nodes: + schema_validator: + needs: + importer: __importer__ + uses: rasa.graph_components.validators.default_recipe_validator.DefaultV1RecipeValidator + constructor_name: create + fn: validate + config: {} + eager: false + is_target: false + is_input: true + resource: null + finetuning_validator: + needs: + importer: schema_validator + uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator + constructor_name: create + fn: validate + config: + validate_core: true + validate_nlu: false + eager: false + is_target: false + is_input: true + resource: null + domain_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null + domain_for_core_training_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.domain_for_core_training_provider.DomainForCoreTrainingProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + forms_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.forms_provider.FormsProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + responses_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.responses_provider.ResponsesProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + story_graph_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.story_graph_provider.StoryGraphProvider + constructor_name: create + fn: provide + config: + exclusion_percentage: null + eager: false + is_target: false + is_input: true + resource: null + training_tracker_provider: + needs: + story_graph: story_graph_provider + domain: domain_for_core_training_provider + uses: rasa.graph_components.providers.training_tracker_provider.TrainingTrackerProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_MemoizationPolicy0: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: create + fn: train + config: + max_history: 5 + eager: false + is_target: true + is_input: false + resource: null + train_RulePolicy1: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.rule_policy.RulePolicy + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_TEDPolicy2: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.ted_policy.TEDPolicy + constructor_name: create + fn: train + config: + max_history: 5 + eager: false + is_target: true + is_input: false + resource: null diff --git a/data/rasa_yaml_examples/nlu.yml b/data/rasa_yaml_examples/nlu.yml new file mode 100644 index 0000000..25d1826 --- /dev/null +++ b/data/rasa_yaml_examples/nlu.yml @@ -0,0 +1,10 @@ +nlu: +- intent: estimate_emissions + # Arbitrary metadata + metadata: + author: Some example metadata! + key: value + # Multiline examples, each line is a separate training example. + examples: | + how much CO2 will that use? + how much carbon will a one way flight from [new york]{"entity": "city", "role": "from"} to california produce? diff --git a/data/test/config_embedding_test.yml b/data/test/config_embedding_test.yml new file mode 100644 index 0000000..01837c1 --- /dev/null +++ b/data/test/config_embedding_test.yml @@ -0,0 +1,7 @@ +recipe: default.v1 +language: en +pipeline: +- name: "CountVectorsFeaturizer" + max_ngram: 3 +- name: "DIETClassifier" + epochs: 10 diff --git a/data/test/demo-rasa-composite-entities.yml b/data/test/demo-rasa-composite-entities.yml new file mode 100644 index 0000000..83bb16e --- /dev/null +++ b/data/test/demo-rasa-composite-entities.yml @@ -0,0 +1,42 @@ +version: "3.1" +nlu: +- intent: affirm + examples: | + - yes + - yep + - yeah + - indeed + - that's right + - ok + - great + - right, thank you + - correct + - great choice + - sounds really good +- intent: chitchat + examples: | + - What's your name? + - What can I call you? + - How's the weather? + - Is it too hot outside? +- intent: book_flight + examples: | + - i'm looking for a flight + - I want to book a flight + - i'm looking for a flight to [Berlin]{"entity": "location", "role": "to"} + - show me flights from [Amsterdam]{"entity": "location", "role": "from"} + - show me flights to [London]{"entity": "location", "role": "to"} + - i am looking for a flight from [SF]{"entity": "location", "role": "from", "value": "San Fransisco"} to [New York]{"entity": "location", "role": "to"} + - search for flights + - from [Madrid]{"entity": "location", "role": "from"} to [Munich]{"entity": "location", "role": "to"} + - any flight to [Liverpool]{"entity": "location", "role": "to"} +- intent: order_pizza + examples: | + - i want a [large]{"entity": "size", "group": "1"} pizza with [tomato]{"entity": "topping", "group": "1"} and a [small]{"entity": "size", "group": "2"} pizza with [bacon]{"entity": "topping", "group": "2"} + - one [large]{"entity": "size", "group": "1"} with [pepperoni]{"entity": "topping", "group": "1"} and a [medium]{"entity": "size", "group": "2"} with [mushrooms]{"entity": "topping", "group": "2"} + - I would like a [medium]{"entity": "size", "group": "1"} standard pizza and a [medium]{"entity": "size", "group": "2"} pizza with [extra cheese]{"entity": "topping", "group": "2"} + - [large]{"entity": "size", "group": "1"} with [onions]{"entity": "topping", "group": "1"} and [small]{"entity": "size", "group": "2"} with [olives]{"entity": "topping", "group": "2"} + - a pizza with [onions]{"entity": "topping", "group": "1"} in [medium]{"entity": "size", "group": "1"} and one with [mushrooms]{"entity": "topping", "group": "2"} in [small]{"entity": "size", "group": "2"} please +- synonym: San Fransisco + examples: | + - SF diff --git a/data/test/demo-rasa-lookup-ents.yml b/data/test/demo-rasa-lookup-ents.yml new file mode 100644 index 0000000..bb31a79 --- /dev/null +++ b/data/test/demo-rasa-lookup-ents.yml @@ -0,0 +1,27 @@ +version: "3.1" +nlu: +- intent: affirm + examples: | + - yes + - yep + - yeah + - indeed + - that's right + - ok + - great + - right, thank you + - correct + - great choice + - sounds really good +- intent: buy_groceries + examples: | + - I'd like to buy some [milk](product) + - One pack of [butter](product) please + - A loaf of [bread](product) + - I want to make some [pasta](product) tonight +- lookup: product + examples: | + - milk + - bread + - butter + - pasta diff --git a/data/test/demo-rasa-more-ents-and-multiplied.yml b/data/test/demo-rasa-more-ents-and-multiplied.yml new file mode 100644 index 0000000..b4468fc --- /dev/null +++ b/data/test/demo-rasa-more-ents-and-multiplied.yml @@ -0,0 +1,111 @@ +version: "3.1" +nlu: +- intent: affirm + examples: | + - yes + - yep + - yeah + - indeed + - that's right + - ok + - great + - right, thank you + - correct + - great choice + - sounds really good +- intent: goodbye + examples: | + - bye + - goodbye + - good bye + - stop + - end + - farewell + - Bye bye + - have a good one +- intent: greet + examples: | + - hey + - howdy + - hey there + - hello + - hi + - good morning + - good evening + - dear sir +- intent: chitchat/ask_name + examples: | + - What's your name? + - What can I call you? +- intent: chitchat/ask_weather + examples: | + - How's the weather? + - Is it too hot outside? +- intent: restaurant_search + examples: | + - i'm looking for a place in the [north](location) of town + - show me [chinese](cuisine) restaurants + - show me [chines]{"entity": "cuisine", "value": "chinese"} restaurants in the [north](location) + - show me a [mexican](cuisine) place in the [centre](location) + - i am looking for an [indian](cuisine) spot called olaolaolaolaolaola + - anywhere in the [west](location) + - anywhere near [18328](location) + - I am looking for [asian fusion](cuisine) food + - I am looking a restaurant in [29432](location) + - I am looking for [mexican indian fusion](cuisine) + - [central](location) [indian](cuisine) restaurant + - [mexican](cuisine) restaurant in [Berlin](location) + - are there any [indian](cuisine) restaurants in the [centre](location) + - show me [chinese](cuisine) restaurants near the [city centre](location) + - I am looking for [mexican](cuisine) food + - I want [chinese](cuisine) food + - Do you know of any [indian](cuisine) places near [18328](location)? + - i'm looking for a place in the [north](location) of town 1 + - show me [chinese](cuisine) restaurants 1 + - show me [chines]{"entity": "cuisine", "value": "chinese"} restaurants in the [north](location) 1 + - show me a [mexican](cuisine) place in the [centre](location) 1 + - i am looking for an [indian](cuisine) spot called olaolaolaolaolaola 1 + - anywhere in the [west](location) 1 + - anywhere near [18328](location) 1 + - I am looking for [asian fusion](cuisine) food 1 + - I am looking a restaurant in [29432](location) 1 + - I am looking for [mexican indian fusion](cuisine) 1 + - [central](location) [indian](cuisine) restaurant 1 + - [mexican](cuisine) restaurant in [Berlin](location) 1 + - are there any [indian](cuisine) restaurants in the [centre](location) 1 + - show me [chinese](cuisine) restaurants near the [city centre](location) 1 + - I am looking for [mexican](cuisine) food 1 + - I want [chinese](cuisine) food 1 + - Do you know of any [indian](cuisine) places near [18328](location)? 1 + - i'm looking for a place in the [north](location) of town 2 + - show me [chinese](cuisine) restaurants 2 + - show me [chines]{"entity": "cuisine", "value": "chinese"} restaurants in the [north](location) 2 + - show me a [mexican](cuisine) place in the [centre](location) 2 + - i am looking for an [indian](cuisine) spot called olaolaolaolaolaola 2 + - anywhere in the [west](location) 2 + - anywhere near [18328](location) 2 + - I am looking for [asian fusion](cuisine) food 2 + - I am looking a restaurant in [29432](location) 2 + - I am looking for [mexican indian fusion](cuisine) 2 + - [central](location) [indian](cuisine) restaurant 2 + - [mexican](cuisine) restaurant in [Berlin](location) 2 + - are there any [indian](cuisine) restaurants in the [centre](location) 2 + - show me [chinese](cuisine) restaurants near the [city centre](location) 2 + - I am looking for [mexican](cuisine) food 2 + - I want [chinese](cuisine) food 2 + - Do you know of any [indian](cuisine) places near [18328](location)? 2 +- synonym: chinese + examples: | + - chines + - Chines + - Chinese +- synonym: vegetarian + examples: | + - vegg + - veggie +- regex: greet + examples: | + - hey[^\s]* +- regex: zipcode + examples: | + - [0-9]{5} diff --git a/data/test/demo-rasa-no-ents.yml b/data/test/demo-rasa-no-ents.yml new file mode 100644 index 0000000..3499ed8 --- /dev/null +++ b/data/test/demo-rasa-no-ents.yml @@ -0,0 +1,77 @@ +version: "3.1" +nlu: +- intent: affirm + examples: | + - yes + - yep + - yeah + - indeed + - that's right + - ok + - great + - right, thank you + - correct + - great choice + - sounds really good +- intent: goodbye + examples: | + - bye + - goodbye + - good bye + - stop + - end + - farewell + - Bye bye + - have a good one +- intent: greet + examples: | + - hey + - howdy + - hey there + - hello + - hi + - good morning + - good evening + - dear sir +- intent: chitchat/ask_name + examples: | + - What's your name? + - What can I call you? +- intent: chitchat/ask_weather + examples: | + - How's the weather? + - Is it too hot outside? +- intent: restaurant_search + examples: | + - i'm looking for a place in the north of town + - show me chinese restaurants + - show me chines restaurants in the north + - show me a mexican place in the centre + - i am looking for an indian spot called olaolaolaolaolaola + - anywhere in the west + - anywhere near 18328 + - I am looking for asian fusion food + - I am looking a restaurant in 29432 + - I am looking for mexican indian fusion + - central indian restaurant + - mexican restaurant in Berlin + - are there any indian restaurants in the centre + - show me chinese restaurants near the city centre + - I am looking for mexican food + - I want chinese food + - Do you know of any indian places near 18328? +- synonym: chinese + examples: | + - chines + - Chines + - Chinese +- synonym: vegetarian + examples: | + - vegg + - veggie +- regex: greet + examples: | + - hey[^\s]* +- regex: zipcode + examples: | + - [0-9]{5} diff --git a/data/test/demo-rasa-noents.json b/data/test/demo-rasa-noents.json new file mode 100644 index 0000000..65d0a56 --- /dev/null +++ b/data/test/demo-rasa-noents.json @@ -0,0 +1,233 @@ +{ + "rasa_nlu_data": { + "entity_examples": [ + { + "text": "hey", + "intent": "greet", + "entities": [] + }, + { + "text": "howdy", + "intent": "greet", + "entities": [] + }, + { + "text": "hey there", + "intent": "greet", + "entities": [] + }, + { + "text": "hello", + "intent": "greet", + "entities": [] + }, + { + "text": "hi", + "intent": "greet", + "entities": [] + }, + { + "text": "i'm looking for a place to eat", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "i'm looking for a place in the north of town", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "show me chinese restaurants", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "yes", + "intent": "affirm", + "entities": [] + }, + { + "text": "yep", + "intent": "affirm", + "entities": [] + }, + { + "text": "yeah", + "intent": "affirm", + "entities": [] + }, + { + "text": "show me a mexican place in the centre", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "bye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "goodbye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "good bye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "stop", + "intent": "goodbye", + "entities": [] + }, + { + "text": "end", + "intent": "goodbye", + "entities": [] + }, + { + "text": "i am looking for an indian spot", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "search for restaurants", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "anywhere in the west", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "central indian restaurant", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "indeed", + "intent": "affirm", + "entities": [] + }, + { + "text": "that's right", + "intent": "affirm", + "entities": [] + }, + { + "text": "ok", + "intent": "affirm", + "entities": [] + }, + { + "text": "great", + "intent": "affirm", + "entities": [] + } + ], + "intent_examples": [ + { + "text": "hey", + "intent": "greet" + }, + { + "text": "howdy", + "intent": "greet" + }, + { + "text": "hey there", + "intent": "greet" + }, + { + "text": "hello", + "intent": "greet" + }, + { + "text": "hi", + "intent": "greet" + }, + { + "text": "i'm looking for a place to eat", + "intent": "restaurant_search" + }, + { + "text": "i'm looking for a place in the north of town", + "intent": "restaurant_search" + }, + { + "text": "show me chinese restaurants", + "intent": "restaurant_search" + }, + { + "text": "yes", + "intent": "affirm" + }, + { + "text": "yep", + "intent": "affirm" + }, + { + "text": "yeah", + "intent": "affirm" + }, + { + "text": "show me a mexican place in the centre", + "intent": "restaurant_search" + }, + { + "text": "bye", + "intent": "goodbye" + }, + { + "text": "goodbye", + "intent": "goodbye" + }, + { + "text": "good bye", + "intent": "goodbye" + }, + { + "text": "stop", + "intent": "goodbye" + }, + { + "text": "end", + "intent": "goodbye" + }, + { + "text": "i am looking for an indian spot", + "intent": "restaurant_search" + }, + { + "text": "search for restaurants", + "intent": "restaurant_search" + }, + { + "text": "anywhere in the west", + "intent": "restaurant_search" + }, + { + "text": "central indian restaurant", + "intent": "restaurant_search" + }, + { + "text": "indeed", + "intent": "affirm" + }, + { + "text": "that's right", + "intent": "affirm" + }, + { + "text": "ok", + "intent": "affirm" + }, + { + "text": "great", + "intent": "affirm" + } + ] + } +} \ No newline at end of file diff --git a/data/test/demo-rasa-small.json b/data/test/demo-rasa-small.json new file mode 100644 index 0000000..9c2db30 --- /dev/null +++ b/data/test/demo-rasa-small.json @@ -0,0 +1,51 @@ +{ + "rasa_nlu_data": { + "common_examples": [ + { + "text": "hey", + "intent": "greet", + "entities": [] + }, + { + "text": "dear sir", + "intent": "greet", + "entities": [] + }, + { + "text": "i'm looking for a place to eat", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "i'm looking for a place in the north of town", + "intent": "restaurant_search", + "entities": [ + { + "start": 31, + "end": 36, + "value": "north", + "entity": "location" + } + ] + }, + { + "text": "show me a mexican place in the centre", + "intent": "restaurant_search", + "entities": [ + { + "start": 31, + "end": 37, + "value": "centre", + "entity": "location" + }, + { + "start": 10, + "end": 17, + "value": "mexican", + "entity": "cuisine" + } + ] + } + ] + } +} diff --git a/data/test/demo-rasa-zh.json b/data/test/demo-rasa-zh.json new file mode 100644 index 0000000..ff75ffd --- /dev/null +++ b/data/test/demo-rasa-zh.json @@ -0,0 +1,279 @@ +{ + "rasa_nlu_data": { + "common_examples": [ + { + "text": "你好", + "intent": "greet", + "entities": [] + }, + { + "text": "你好啊", + "intent": "greet", + "entities": [] + }, + { + "text": "你好吗", + "intent": "greet", + "entities": [] + }, + { + "text": "hello", + "intent": "greet", + "entities": [] + }, + { + "text": "hi", + "intent": "greet", + "entities": [] + }, + { + "text": "早上好", + "intent": "greet", + "entities": [] + }, + { + "text": "晚上好", + "intent": "greet", + "entities": [] + }, + { + "text": "嗨", + "intent": "greet", + "entities": [] + }, + { + "text": "是的", + "intent": "affirm", + "entities": [] + }, + { + "text": "是", + "intent": "affirm", + "entities": [] + }, + { + "text": "对的", + "intent": "affirm", + "entities": [] + }, + { + "text": "确实", + "intent": "affirm", + "entities": [] + }, + { + "text": "好", + "intent": "affirm", + "entities": [] + }, + { + "text": "ok", + "intent": "affirm", + "entities": [] + }, + { + "text": "好的", + "intent": "affirm", + "entities": [] + }, + { + "text": "好的,谢谢你", + "intent": "affirm", + "entities": [] + }, + { + "text": "对的", + "intent": "affirm", + "entities": [] + }, + { + "text": "好滴", + "intent": "affirm", + "entities": [] + }, + { + "text": "好啊", + "intent": "affirm", + "entities": [] + }, + { + "text": "我想找地方吃饭", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "我想吃火锅啊", + "intent": "restaurant_search", + "entities": [ + { + "start": 2, + "end": 5, + "value": "吃火锅", + "entity": "food" + } + ] + }, + { + "text": "找个吃拉面的店", + "intent": "restaurant_search", + "entities": [ + { + "start": 3, + "end": 5, + "value": "拉面", + "entity": "food" + } + ] + }, + { + "text": "这附近哪里有吃麻辣烫的地方", + "intent": "restaurant_search", + "entities": [ + { + "start": 7, + "end": 10, + "value": "麻辣烫", + "entity": "food" + } + ] + }, + { + "text": "附近有什么好吃的地方吗", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "肚子饿了,推荐一家吃放的地儿呗", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "带老婆孩子去哪里吃饭比较好", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "想去一家有情调的餐厅", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "bye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "再见", + "intent": "goodbye", + "entities": [] + }, + { + "text": "886", + "intent": "goodbye", + "entities": [] + }, + { + "text": "拜拜", + "intent": "goodbye", + "entities": [] + }, + { + "text": "下次见", + "intent": "goodbye", + "entities": [] + }, + { + "text": "感冒了怎么办", + "intent": "medical", + "entities": [ + { + "start": 0, + "end": 2, + "value": "感冒", + "entity": "disease" + } + ] + }, + { + "text": "我便秘了,该吃什么药", + "intent": "medical", + "entities": [ + { + "start": 1, + "end": 3, + "value": "便秘", + "entity": "disease" + } + ] + }, + { + "text": "我胃痛,该吃什么药?", + "intent": "medical", + "entities": [ + { + "start": 1, + "end": 3, + "value": "胃痛", + "entity": "disease" + } + ] + }, + { + "text": "一直打喷嚏怎么办", + "intent": "medical", + "entities": [ + { + "start": 2, + "end": 5, + "value": "打喷嚏", + "entity": "disease" + } + ] + }, + { + "text": "父母都有高血压,我会遗传吗", + "intent": "medical", + "entities": [ + { + "start": 4, + "end": 7, + "value": "高血压", + "entity": "disease" + } + ] + }, + { + "text": "我生病了", + "intent": "medical", + "entities": [] + }, + { + "text": "头上烫烫的,感觉发烧了", + "intent": "medical", + "entities": [ + { + "start": 8, + "end": 10, + "value": "发烧", + "entity": "disease" + } + ] + }, + { + "text": "头很疼该怎么办", + "intent": "medical", + "entities": [] + }, + { + "text": "减肥有什么好方法吗?", + "intent": "medical", + "entities": [] + }, + { + "text": "怎样良好的生活习惯才能预防生病呢", + "intent": "medical", + "entities": [] + } + ] + } +} \ No newline at end of file diff --git a/data/test/dialogflow_en_converted_to_rasa.json b/data/test/dialogflow_en_converted_to_rasa.json new file mode 100644 index 0000000..65b9d30 --- /dev/null +++ b/data/test/dialogflow_en_converted_to_rasa.json @@ -0,0 +1,181 @@ +{ + "rasa_nlu_data": { + "entity_synonyms": [ + { + "value": "mexican", + "synonyms": ["mexico"] + }, + { + "value": "chinese", + "synonyms": ["china"] + }, + { + "value": "indian", + "synonyms": ["india"] + } + ], + "common_examples": [ + { + "text": "central indian restaurant", + "intent": "inform", + "entities": [ + { + "start": 0, + "end": 7, + "value": "central", + "entity": "location" + }, + { + "start": 8, + "end": 14, + "value": "indian", + "entity": "cuisine" + } + ] + }, + { + "text": "anywhere in the west", + "intent": "inform", + "entities": [ + { + "start": 16, + "end": 20, + "value": "west", + "entity": "location" + } + ] + }, + { + "text": "i am looking for an indian spot", + "intent": "inform", + "entities": [ + { + "start": 20, + "end": 26, + "value": "indian", + "entity": "cuisine" + } + ] + }, + { + "text": "show me chinese restaurants", + "intent": "inform", + "entities": [ + { + "start": 8, + "end": 15, + "value": "chinese", + "entity": "cuisine" + } + ] + }, + { + "text": "i'm looking for a place in the north of ", + "intent": "inform", + "entities": [ + { + "start": 31, + "end": 36, + "value": "north", + "entity": "location" + } + ] + }, + { + "text": "great", + "intent": "affirm", + "entities": [] + }, + { + "text": "ok", + "intent": "affirm", + "entities": [] + }, + { + "text": "that's right", + "intent": "affirm", + "entities": [] + }, + { + "text": "indeed", + "intent": "affirm", + "entities": [] + }, + { + "text": "yeah", + "intent": "affirm", + "entities": [] + }, + { + "text": "yep", + "intent": "affirm", + "entities": [] + }, + { + "text": "yes", + "intent": "affirm", + "entities": [] + }, + { + "text": "end", + "intent": "goodbye", + "entities": [] + }, + { + "text": "stop", + "intent": "goodbye", + "entities": [] + }, + { + "text": "good bye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "goodbye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "bye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "hi", + "intent": "hi", + "entities": [] + }, + { + "text": "hello", + "intent": "hi", + "entities": [] + }, + { + "text": "hey there", + "intent": "hi", + "entities": [] + }, + { + "text": "howdy", + "intent": "hi", + "entities": [] + }, + { + "text": "hey", + "intent": "hi", + "entities": [] + }, + { + "text": "search for restaurants", + "intent": "inform", + "entities": [] + }, + { + "text": "i'm looking for a place to eat", + "intent": "inform", + "entities": [] + } + ] + } +} \ No newline at end of file diff --git a/data/test/dialogflow_es_converted_to_rasa.json b/data/test/dialogflow_es_converted_to_rasa.json new file mode 100644 index 0000000..c1a434b --- /dev/null +++ b/data/test/dialogflow_es_converted_to_rasa.json @@ -0,0 +1,207 @@ +{ + "rasa_nlu_data": { + "entity_synonyms": [ + { + "value": "mexicano", + "synonyms": [ + "mexicana", + "méxico" + ] + }, + { + "value": "chino", + "synonyms": [ + "china", + "chinos" + ] + }, + { + "value": "indio", + "synonyms": [ + "india" + ] + }, + { + "value": "centro", + "synonyms": [ + "centrar" + ] + }, + { + "value": "oeste", + "synonyms": [ + "occidente" + ] + }, + { + "value": "central", + "synonyms": [ + "céntrico" + ] + } + ], + "common_examples": [ + { + "text": "restaurante central de la India", + "intent": "inform", + "entities": [ + { + "start": 12, + "end": 19, + "value": "central", + "entity": "location" + }, + { + "start": 27, + "end": 33, + "value": "India", + "entity": "cuisine" + } + ] + }, + { + "text": "en cualquier parte del oeste", + "intent": "inform", + "entities": [ + { + "start": 23, + "end": 28, + "value": "oeste", + "entity": "location" + } + ] + }, + { + "text": "estoy buscando un lugar indio", + "intent": "inform", + "entities": [ + { + "start": 24, + "end": 30, + "value": "indio", + "entity": "cuisine" + } + ] + }, + { + "text": "muéstrame los restaurantes chinos", + "intent": "inform", + "entities": [ + { + "start": 27, + "end": 33, + "value": "chinos", + "entity": "cuisine" + } + ] + }, + { + "text": "busco un lugar en el norte", + "intent": "inform", + "entities": [ + { + "start": 21, + "end": 26, + "value": "norte", + "entity": "location" + } + ] + }, + { + "text": "sí", + "intent": "affirm", + "entities": [] + }, + { + "text": "si", + "intent": "affirm", + "entities": [] + }, + { + "text": "Sí", + "intent": "affirm", + "entities": [] + }, + { + "text": "de verdad", + "intent": "affirm", + "entities": [] + }, + { + "text": "está bien", + "intent": "affirm", + "entities": [] + }, + { + "text": "muy bien", + "intent": "affirm", + "entities": [] + }, + { + "text": "estupendo", + "intent": "affirm", + "entities": [] + }, + { + "text": "a usted adiós", + "intent": "goodbye", + "entities": [] + }, + { + "text": "despedida", + "intent": "goodbye", + "entities": [] + }, + { + "text": "adiós", + "intent": "goodbye", + "entities": [] + }, + { + "text": "suspender", + "intent": "goodbye", + "entities": [] + }, + { + "text": "fin", + "intent": "goodbye", + "entities": [] + }, + { + "text": "hello", + "intent": "hi", + "entities": [] + }, + { + "text": "Hola amigo", + "intent": "hi", + "entities": [] + }, + { + "text": "Bueno", + "intent": "hi", + "entities": [] + }, + { + "text": "Caramba", + "intent": "hi", + "entities": [] + }, + { + "text": "Hola", + "intent": "hi", + "entities": [] + }, + { + "text": "buscar restaurantes", + "intent": "inform", + "entities": [] + }, + { + "text": "estoy buscando un lugar para comer", + "intent": "inform", + "entities": [] + } + ] + } +} \ No newline at end of file diff --git a/data/test/duplicate_intents_yaml/demo-rasa-intents-1.yml b/data/test/duplicate_intents_yaml/demo-rasa-intents-1.yml new file mode 100644 index 0000000..541427d --- /dev/null +++ b/data/test/duplicate_intents_yaml/demo-rasa-intents-1.yml @@ -0,0 +1,32 @@ +version: "3.1" +nlu: +- intent: affirm + examples: | + - yes + - yep + - yeah + - indeed + - that's right + - ok + - great + - right, thank you + - correct + - great choice + - sounds really good +- intent: goodbye + examples: | + - bye + - goodbye + - good bye + - stop + - end +- intent: greet + examples: | + - hey + - howdy + - hey there + - hello + - hi + - good morning + - good evening + - dear sir diff --git a/data/test/duplicate_intents_yaml/demo-rasa-intents-2.yml b/data/test/duplicate_intents_yaml/demo-rasa-intents-2.yml new file mode 100644 index 0000000..a7410ae --- /dev/null +++ b/data/test/duplicate_intents_yaml/demo-rasa-intents-2.yml @@ -0,0 +1,52 @@ +version: "3.1" +nlu: +- intent: affirm + examples: | + - yes + - yep + - yeah + - indeed + - that's right + - ok + - great + - right, thank you + - correct + - great choice + - sounds really good +- intent: goodbye + examples: | + - farewell + - Bye bye + - have a good one +- intent: restaurant_search + examples: | + - i'm looking for a place to eat + - I want to grab lunch + - I am searching for a dinner spot + - i'm looking for a place in the [north](location) of town + - show me [chinese](cuisine) restaurants + - show me [chines]{"entity": "cuisine", "value": "chinese"} restaurants in the [north](location) + - show me a [mexican](cuisine) place in the [centre](location) + - i am looking for an [indian](cuisine) spot + - search for restaurants + - anywhere in the [west](location) + - anywhere near [18328](location) + - I am looking for [asian fusion](cuisine) food + - I am looking a restaurant in [29432](location) + - I am looking for [mexican indian fusion](cuisine) + - [central](location) [indian](cuisine) restaurant +- synonym: chinese + examples: | + - chines + - Chines + - Chinese +- synonym: vegetarian + examples: | + - vegg + - veggie +- regex: greet + examples: | + - hey[^\s]* +- regex: zipcode + examples: | + - [0-9]{5} diff --git a/data/test/duplicate_intents_yaml/demo-rasa-intents-3.yml b/data/test/duplicate_intents_yaml/demo-rasa-intents-3.yml new file mode 100644 index 0000000..a92fc90 --- /dev/null +++ b/data/test/duplicate_intents_yaml/demo-rasa-intents-3.yml @@ -0,0 +1,23 @@ +version: "3.1" +nlu: +- intent: affirm + examples: | + - yes + - yep + - yeah + - indeed + - that's right + - ok + - great + - right, thank you + - correct + - great choice + - sounds really good +- intent: chitchat/ask_name + examples: | + - What's your name? + - What can I call you? +- intent: chitchat/ask_weather + examples: | + - How's the weather? + - Is it too hot outside? diff --git a/data/test/hf_transformers_models.txt b/data/test/hf_transformers_models.txt new file mode 100644 index 0000000..ab931e0 --- /dev/null +++ b/data/test/hf_transformers_models.txt @@ -0,0 +1,9 @@ +bert-base-chinese +openai-gpt +gpt2 +xlnet-base-cased +bert-base-uncased +roberta-base +distilbert-base-uncased +camembert-base +sentence-transformers/all-MiniLM-L6-v2 diff --git a/data/test/incorrect_nlu_format.yml b/data/test/incorrect_nlu_format.yml new file mode 100644 index 0000000..2afbc46 --- /dev/null +++ b/data/test/incorrect_nlu_format.yml @@ -0,0 +1,2 @@ +version: "3.1" +nlu: diff --git a/data/test/lookup_tables/lookup_table.json b/data/test/lookup_tables/lookup_table.json new file mode 100644 index 0000000..fefba02 --- /dev/null +++ b/data/test/lookup_tables/lookup_table.json @@ -0,0 +1,17 @@ +{ + "rasa_nlu_data": { + "lookup_tables": [ + { + "name": "plates", + "elements": "data/test/lookup_tables/plates.txt" + } + ], + "common_examples": [ + { + "text": "hey", + "intent": "greet", + "entities": [] + } + ] + } +} diff --git a/data/test/lookup_tables/lookup_table.yml b/data/test/lookup_tables/lookup_table.yml new file mode 100644 index 0000000..32136d5 --- /dev/null +++ b/data/test/lookup_tables/lookup_table.yml @@ -0,0 +1,15 @@ +version: "3.1" +nlu: +- intent: restaurant_search + examples: | + - i'm looking for a [sushi](food) place to eat + - I want to grab [tacos](food) + - I am searching for a [pizza](food) spot + - I would like to drink [sweet berry wine](beverage) with my meal +- lookup: plates + examples: | + - tacos + - beef + - mapo tofu + - burrito + - lettuce wrap diff --git a/data/test/lookup_tables/plates.txt b/data/test/lookup_tables/plates.txt new file mode 100644 index 0000000..7e1006c --- /dev/null +++ b/data/test/lookup_tables/plates.txt @@ -0,0 +1,5 @@ +tacos +beef +mapo tofu +burrito +lettuce wrap \ No newline at end of file diff --git a/data/test/luis_converted_to_rasa.json b/data/test/luis_converted_to_rasa.json new file mode 100644 index 0000000..423dd0d --- /dev/null +++ b/data/test/luis_converted_to_rasa.json @@ -0,0 +1,215 @@ +{ + "rasa_nlu_data": { + "common_examples": [ + { + "text": "i'm looking for a place in the north of town", + "intent": "inform", + "entities": [ + { + "start": 31, + "end": 36, + "role": "to", + "value": "north", + "entity": "location" + } + ] + }, + { + "text": "show me chinese restaurants", + "intent": "greet", + "entities": [ + { + "start": 8, + "end": 15, + "value": "chinese", + "entity": "cuisine" + } + ] + }, + { + "text": "show me a mexican place in the centre", + "intent": "inform", + "entities": [ + { + "start": 10, + "end": 17, + "value": "mexican", + "entity": "cuisine" + }, + { + "start": 31, + "end": 37, + "value": "centre", + "entity": "location" + } + ] + }, + { + "text": "anywhere in the west", + "intent": "inform", + "entities": [ + { + "start": 16, + "end": 20, + "value": "west", + "entity": "location" + } + ] + }, + { + "text": "central indian restaurant", + "intent": "greet", + "entities": [ + { + "start": 8, + "end": 14, + "value": "indian", + "entity": "cuisine" + }, + { + "start": 0, + "end": 7, + "value": "central", + "entity": "location" + } + ] + }, + { + "text": "do you know any good vietnamese places?", + "intent": "inform", + "entities": [ + { + "start": 21, + "end": 31, + "value": "vietnamese", + "entity": "cuisine" + } + ] + }, + { + "text": "i want some russian food", + "intent": "inform", + "entities": [ + { + "start": 12, + "end": 19, + "value": "russian", + "entity": "cuisine" + } + ] + }, + { + "text": "any indonesian places?", + "intent": "inform", + "entities": [ + { + "start": 4, + "end": 14, + "value": "indonesian", + "entity": "cuisine" + } + ] + }, + { + "text": "hello", + "intent": "greet", + "entities": [] + }, + { + "text": "hey", + "intent": "greet", + "entities": [] + }, + { + "text": "hi", + "intent": "greet", + "entities": [] + }, + { + "text": "hey there", + "intent": "greet", + "entities": [] + }, + { + "text": "howdy", + "intent": "greet", + "entities": [] + }, + { + "text": "i'm looking for a place to eat", + "intent": "inform", + "entities": [] + }, + { + "text": "yes", + "intent": "affirm", + "entities": [] + }, + { + "text": "yep", + "intent": "affirm", + "entities": [] + }, + { + "text": "yeah", + "intent": "affirm", + "entities": [] + }, + { + "text": "bye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "goodbye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "good bye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "stop", + "intent": "goodbye", + "entities": [] + }, + { + "text": "end", + "intent": "goodbye", + "entities": [] + }, + { + "text": "i am looking for an indian spot", + "intent": "inform", + "entities": [] + }, + { + "text": "search for restaurants", + "intent": "inform", + "entities": [] + }, + { + "text": "indeed", + "intent": "affirm", + "entities": [] + }, + { + "text": "that's right", + "intent": "affirm", + "entities": [] + }, + { + "text": "ok", + "intent": "affirm", + "entities": [] + }, + { + "text": "great", + "intent": "affirm", + "entities": [] + } + ] + } +} diff --git a/data/test/many_intents.yml b/data/test/many_intents.yml new file mode 100644 index 0000000..1d6f9f6 --- /dev/null +++ b/data/test/many_intents.yml @@ -0,0 +1,67 @@ +version: "3.1" +nlu: +- intent: handleinsult + examples: | + - you are an idiot + - You lack understanding. +- intent: grett + examples: | + - Hello + - Hi + - Welcome +- intent: thank + examples: | + - Thanks + - Thank you +- intent: telljoke + examples: | + - Tell me something that you think will make me laugh. + - Entertain me with a joke. +- intent: signup_newsletter + examples: | + - I wanna sign up for the newsletter. + - I want to sign up for the newsletter. +- intent: react_positive + examples: | + - you are funny + - thats funny +- intent: react_negative + examples: | + - i am sad + - bad +- intent: how_to_get_started + examples: | + - how do I get started with rasa + - how do I use rasa +- intent: technical_question + examples: | + - what is duckling + - where to train intents in rasa? +- intent: source_code + examples: | + - how it works? + - where can i find this code +- intent: pipeline_recommendation + examples: | + - what pipeline should I start with? + - what is the right pipeline to choose? +- intent: rasa_cost + examples: | + - is rasa free + - are you really free +- intent: nicetomeeyou + examples: | + - It’s great connecting with you. + - Hi, nice to meet you! +- intent: nlu_generation_tool_recommendation + examples: | + - which tools can I use to create nlu data + - how can I get nlu data +- intent: install_rasa + examples: | + - I want to install Rasa Stack + - How to install Rasa? +- intent: ask_which_events + examples: | + - Which community events do you have + - Where can I meet Rasas diff --git a/data/test/md_converted_to_json.json b/data/test/md_converted_to_json.json new file mode 100644 index 0000000..1bf9fe4 --- /dev/null +++ b/data/test/md_converted_to_json.json @@ -0,0 +1,308 @@ +{ + "rasa_nlu_data": { + "common_examples": [ + { + "text": "yes", + "intent": "affirm" + }, + { + "text": "yep", + "intent": "affirm" + }, + { + "text": "yeah", + "intent": "affirm" + }, + { + "text": "indeed", + "intent": "affirm" + }, + { + "text": "that's right", + "intent": "affirm" + }, + { + "text": "ok", + "intent": "affirm" + }, + { + "text": "great", + "intent": "affirm" + }, + { + "text": "right, thank you", + "intent": "affirm" + }, + { + "text": "correct", + "intent": "affirm" + }, + { + "text": "great choice", + "intent": "affirm" + }, + { + "text": "sounds really good", + "intent": "affirm" + }, + { + "text": "bye", + "intent": "goodbye" + }, + { + "text": "goodbye", + "intent": "goodbye" + }, + { + "text": "good bye", + "intent": "goodbye" + }, + { + "text": "stop", + "intent": "goodbye" + }, + { + "text": "end", + "intent": "goodbye" + }, + { + "text": "farewell", + "intent": "goodbye" + }, + { + "text": "Bye bye", + "intent": "goodbye" + }, + { + "text": "have a good one", + "intent": "goodbye" + }, + { + "text": "hey", + "intent": "greet" + }, + { + "text": "howdy", + "intent": "greet" + }, + { + "text": "hey there", + "intent": "greet" + }, + { + "text": "hello", + "intent": "greet" + }, + { + "text": "hi", + "intent": "greet" + }, + { + "text": "good morning", + "intent": "greet" + }, + { + "text": "good evening", + "intent": "greet" + }, + { + "text": "dear sir", + "intent": "greet" + }, + { + "text": "What's your name?", + "intent": "chitchat/ask_name" + }, + { + "text": "What can I call you?", + "intent": "chitchat/ask_name" + }, + { + "text": "How's the weather?", + "intent": "chitchat/ask_weather" + }, + { + "text": "Is it too hot outside?", + "intent": "chitchat/ask_weather" + }, + { + "text": "i'm looking for a place to eat", + "intent": "restaurant_search" + }, + { + "text": "I want to grab lunch", + "intent": "restaurant_search" + }, + { + "text": "I am searching for a dinner spot", + "intent": "restaurant_search" + }, + { + "entities": [ + { + "start": 31, + "end": 36, + "value": "north", + "entity": "location" + } + ], + "intent": "restaurant_search", + "text": "i'm looking for a place in the north of town" + }, + { + "entities": [ + { + "start": 8, + "end": 15, + "value": "chinese", + "entity": "cuisine" + } + ], + "intent": "restaurant_search", + "text": "show me chinese restaurants" + }, + { + "entities": [ + { + "start": 8, + "end": 14, + "value": "chinese", + "entity": "cuisine" + } + ], + "intent": "restaurant_search", + "text": "show me chines restaurants" + }, + { + "entities": [ + { + "start": 10, + "end": 17, + "value": "mexican", + "entity": "cuisine" + }, + { + "start": 31, + "end": 37, + "value": "centre", + "entity": "location" + } + ], + "intent": "restaurant_search", + "text": "show me a mexican place in the centre" + }, + { + "entities": [ + { + "start": 20, + "end": 26, + "value": "indian", + "entity": "cuisine" + } + ], + "intent": "restaurant_search", + "text": "i am looking for an indian spot called olaolaolaolaolaola" + }, + { + "text": "search for restaurants", + "intent": "restaurant_search" + }, + { + "entities": [ + { + "start": 16, + "end": 20, + "value": "west", + "entity": "location" + } + ], + "intent": "restaurant_search", + "text": "anywhere in the west" + }, + { + "entities": [ + { + "start": 14, + "end": 19, + "value": "18328", + "entity": "location" + } + ], + "intent": "restaurant_search", + "text": "anywhere near 18328" + }, + { + "entities": [ + { + "start": 17, + "end": 29, + "value": "asian fusion", + "entity": "cuisine" + } + ], + "intent": "restaurant_search", + "text": "I am looking for asian fusion food" + }, + { + "entities": [ + { + "start": 29, + "end": 34, + "value": "29432", + "entity": "location" + } + ], + "intent": "restaurant_search", + "text": "I am looking a restaurant in 29432" + }, + { + "entities": [ + { + "start": 17, + "end": 38, + "value": "mexican indian fusion", + "entity": "cuisine" + } + ], + "intent": "restaurant_search", + "text": "I am looking for mexican indian fusion" + }, + { + "entities": [ + { + "start": 0, + "end": 7, + "value": "central", + "entity": "location" + }, + { + "start": 8, + "end": 14, + "value": "indian", + "entity": "cuisine" + } + ], + "intent": "restaurant_search", + "text": "central indian restaurant" + } + ], + "entity_synonyms": [ + { + "synonyms": [ + "Chines", + "chines", + "Chinese" + ], + "value": "chinese" + }, + { + "synonyms": [ + "veggie", + "vegg" + ], + "value": "vegetarian" + } + ], + "regex_features": [] + } +} \ No newline at end of file diff --git a/data/test/multiple_files_json/demo-rasa-affirm.json b/data/test/multiple_files_json/demo-rasa-affirm.json new file mode 100644 index 0000000..57fbe9a --- /dev/null +++ b/data/test/multiple_files_json/demo-rasa-affirm.json @@ -0,0 +1,61 @@ +{ + "rasa_nlu_data": { + "common_examples": [ + { + "text": "yes", + "intent": "affirm", + "entities": [] + }, + { + "text": "yep", + "intent": "affirm", + "entities": [] + }, + { + "text": "yeah", + "intent": "affirm", + "entities": [] + }, + { + "text": "indeed", + "intent": "affirm", + "entities": [] + }, + { + "text": "that's right", + "intent": "affirm", + "entities": [] + }, + { + "text": "ok", + "intent": "affirm", + "entities": [] + }, + { + "text": "great", + "intent": "affirm", + "entities": [] + }, + { + "text": "right, thank you", + "intent": "affirm", + "entities": [] + }, + { + "text": "correct", + "intent": "affirm", + "entities": [] + }, + { + "text": "great choice", + "intent": "affirm", + "entities": [] + }, + { + "text": "sounds really good", + "intent": "affirm", + "entities": [] + } + ] + } +} diff --git a/data/test/multiple_files_json/demo-rasa-chitchat.json b/data/test/multiple_files_json/demo-rasa-chitchat.json new file mode 100644 index 0000000..5f3def4 --- /dev/null +++ b/data/test/multiple_files_json/demo-rasa-chitchat.json @@ -0,0 +1,26 @@ +{ + "rasa_nlu_data": { + "common_examples": [ + { + "text": "What's your name?", + "intent": "chitchat/ask_name", + "entitites": [] + }, + { + "text": "What can I call you?", + "intent": "chitchat/ask_name", + "entitites": [] + }, + { + "text": "How's the weather?", + "intent": "chitchat/ask_weather", + "entitites": [] + }, + { + "text": "Is it too hot outside?", + "intent": "chitchat/ask_weather", + "entitites": [] + } + ] + } +} diff --git a/data/test/multiple_files_json/demo-rasa-goodbye.json b/data/test/multiple_files_json/demo-rasa-goodbye.json new file mode 100644 index 0000000..f40f443 --- /dev/null +++ b/data/test/multiple_files_json/demo-rasa-goodbye.json @@ -0,0 +1,46 @@ +{ + "rasa_nlu_data": { + "common_examples": [ + { + "text": "bye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "goodbye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "good bye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "stop", + "intent": "goodbye", + "entities": [] + }, + { + "text": "end", + "intent": "goodbye", + "entities": [] + }, + { + "text": "farewell", + "intent": "goodbye", + "entities": [] + }, + { + "text": "Bye bye", + "intent": "goodbye", + "entities": [] + }, + { + "text": "have a good one", + "intent": "goodbye", + "entities": [] + } + ] + } +} diff --git a/data/test/multiple_files_json/demo-rasa-greet.json b/data/test/multiple_files_json/demo-rasa-greet.json new file mode 100644 index 0000000..9c5de85 --- /dev/null +++ b/data/test/multiple_files_json/demo-rasa-greet.json @@ -0,0 +1,51 @@ +{ + "rasa_nlu_data": { + "regex_features": [ + { + "name": "zipcode", + "pattern": "[0-9]{5}" + }], + "common_examples": [ + { + "text": "hey", + "intent": "greet", + "entities": [] + }, + { + "text": "howdy", + "intent": "greet", + "entities": [] + }, + { + "text": "hey there", + "intent": "greet", + "entities": [] + }, + { + "text": "hello", + "intent": "greet", + "entities": [] + }, + { + "text": "hi", + "intent": "greet", + "entities": [] + }, + { + "text": "good morning", + "intent": "greet", + "entities": [] + }, + { + "text": "good evening", + "intent": "greet", + "entities": [] + }, + { + "text": "dear sir", + "intent": "greet", + "entities": [] + } + ] + } +} diff --git a/data/test/multiple_files_json/demo-rasa-restaurant_search.json b/data/test/multiple_files_json/demo-rasa-restaurant_search.json new file mode 100644 index 0000000..321fa57 --- /dev/null +++ b/data/test/multiple_files_json/demo-rasa-restaurant_search.json @@ -0,0 +1,191 @@ +{ + "rasa_nlu_data": { + "regex_features": [ + { + "name": "greet", + "pattern": "hey[^\\s]*" + } + ], + "entity_synonyms": [ + { + "value": "chinese", + "synonyms": ["Chinese", "Chines", "chines"] + }, + { + "value": "vegetarian", + "synonyms": ["veggie", "vegg"] + } + ], + "common_examples": [ + { + "text": "i'm looking for a place to eat", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "I want to grab lunch", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "I am searching for a dinner spot", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "i'm looking for a place in the north of town", + "intent": "restaurant_search", + "entities": [ + { + "start": 31, + "end": 36, + "value": "north", + "entity": "location" + } + ] + }, + { + "text": "show me chinese restaurants", + "intent": "restaurant_search", + "entities": [ + { + "start": 8, + "end": 15, + "value": "chinese", + "entity": "cuisine" + } + ] + }, + { + "text": "show me chines restaurants in the north", + "intent": "restaurant_search", + "entities": [ + { + "start": 8, + "end": 14, + "value": "chinese", + "entity": "cuisine" + }, + { + "start": 34, + "end": 39, + "value": "north", + "entity": "location" + } + ] + }, + { + "text": "show me a mexican place in the centre", + "intent": "restaurant_search", + "entities": [ + { + "start": 31, + "end": 37, + "value": "centre", + "entity": "location" + }, + { + "start": 10, + "end": 17, + "value": "mexican", + "entity": "cuisine" + } + ] + }, + { + "text": "i am looking for an indian spot called olaolaolaolaolaola", + "intent": "restaurant_search", + "entities": [ + { + "start": 20, + "end": 26, + "value": "indian", + "entity": "cuisine" + } + ] + }, { + "text": "search for restaurants", + "intent": "restaurant_search", + "entities": [] + }, + { + "text": "anywhere in the west", + "intent": "restaurant_search", + "entities": [ + { + "start": 16, + "end": 20, + "value": "west", + "entity": "location" + } + ] + }, + { + "text": "anywhere near 18328", + "intent": "restaurant_search", + "entities": [ + { + "start": 14, + "end": 19, + "value": "18328", + "entity": "location" + } + ] + }, + { + "text": "I am looking for asian fusion food", + "intent": "restaurant_search", + "entities": [ + { + "start": 17, + "end": 29, + "value": "asian fusion", + "entity": "cuisine" + } + ] + }, + { + "text": "I am looking a restaurant in 29432", + "intent": "restaurant_search", + "entities": [ + { + "start": 29, + "end": 34, + "value": "29432", + "entity": "location" + } + ] + }, + { + "text": "I am looking for mexican indian fusion", + "intent": "restaurant_search", + "entities": [ + { + "start": 17, + "end": 38, + "value": "mexican indian fusion", + "entity": "cuisine" + } + ] + }, + { + "text": "central indian restaurant", + "intent": "restaurant_search", + "entities": [ + { + "start": 0, + "end": 7, + "value": "central", + "entity": "location" + }, + { + "start": 8, + "end": 14, + "value": "indian", + "entity": "cuisine" + } + ] + } + ] + } +} diff --git a/data/test/overlapping_regex_entities.yml b/data/test/overlapping_regex_entities.yml new file mode 100644 index 0000000..6f3ca86 --- /dev/null +++ b/data/test/overlapping_regex_entities.yml @@ -0,0 +1,14 @@ + +version: "3.1" +nlu: +- intent: restaurant_search + examples: | + - What [pizza](meal) places have been [buzzing](zz-words) lately? + - What [pizza](meal) places have been [trendy](zz-words) lately? +- regex: zz-words + examples: | + - \w*zz\w* +- lookup: meal + examples: | + - pizza + - pasta diff --git a/data/test/simple_retrieval_intent_nlu.yml b/data/test/simple_retrieval_intent_nlu.yml new file mode 100644 index 0000000..dab9c67 --- /dev/null +++ b/data/test/simple_retrieval_intent_nlu.yml @@ -0,0 +1,10 @@ +version: "3.1" +nlu: +- intent: chitchat/hello + examples: | + - hey + - hello + - hi + - good morning + - good evening + - hey there diff --git a/data/test/stories_default_retrieval_intents.yml b/data/test/stories_default_retrieval_intents.yml new file mode 100644 index 0000000..5deba27 --- /dev/null +++ b/data/test/stories_default_retrieval_intents.yml @@ -0,0 +1,68 @@ +version: "3.1" +nlu: +- intent: greet + examples: | + - hey + - hello + - hi + - good morning + - good evening + - hey there +- intent: goodbye + examples: | + - bye + - goodbye + - see you around + - see you later +- intent: affirm + examples: | + - yes + - indeed + - of course + - that sounds good + - correct +- intent: deny + examples: | + - no + - never + - I don't think so + - don't like that + - no way + - not really +- intent: mood_great + examples: | + - perfect + - very good + - great + - amazing + - wonderful + - I am feeling very good + - I am great + - I'm good +- intent: mood_unhappy + examples: | + - sad + - very sad + - unhappy + - bad + - very bad + - awful + - terrible + - not very good + - extremely sad + - so sad +- intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? +- intent: chitchat/ask_name + examples: | + - what's your name + - who are you? + - what are you called? +- intent: chitchat/ask_weather + examples: | + - how's weather? + - is it sunny where you are? diff --git a/data/test/synonyms_only.yml b/data/test/synonyms_only.yml new file mode 100644 index 0000000..60a0087 --- /dev/null +++ b/data/test/synonyms_only.yml @@ -0,0 +1,6 @@ +version: "3.1" +nlu: +- synonym: chinese + examples: | + - Chines + - Chinese diff --git a/data/test/test_integration/data/nlu.yml b/data/test/test_integration/data/nlu.yml new file mode 100644 index 0000000..ecb72d6 --- /dev/null +++ b/data/test/test_integration/data/nlu.yml @@ -0,0 +1,98 @@ +version: "3.1" + +nlu: + - intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon + + - intent: goodbye + examples: | + - cu + - good by + - cee you later + - good night + - bye + - goodbye + - have a nice day + - see you around + - bye bye + - see you later + + - intent: affirm + examples: | + - yes + - y + - indeed + - of course + - that sounds good + - correct + + - intent: deny + examples: | + - no + - n + - never + - I don't think so + - don't like that + - no way + - not really + + - intent: mood_great + examples: | + - perfect + - great + - amazing + - feeling like a king + - wonderful + - I am feeling very good + - I am great + - I am amazing + - I am going to save the world + - super stoked + - extremely good + - so so perfect + - so good + - so perfect + + - intent: mood_unhappy + examples: | + - my day was horrible + - I am sad + - I don't feel very well + - I am disappointed + - super sad + - I'm so sad + - sad + - very sad + - unhappy + - not good + - not very good + - extremly sad + - so saad + - so sad + + - intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? + + - intent: block + examples: | + - block my [checking](account_type) account + - i want to block my [savings](account_type) account + - i need to block an account + - i need to block my [credit](account_type) card diff --git a/data/test/test_integration/data/rules.yml b/data/test/test_integration/data/rules.yml new file mode 100644 index 0000000..f727a19 --- /dev/null +++ b/data/test/test_integration/data/rules.yml @@ -0,0 +1,13 @@ +version: "3.1" + +rules: + + - rule: Say goodbye anytime the user says goodbye + steps: + - intent: goodbye + - action: utter_goodbye + + - rule: Say 'I am a bot' anytime the user challenges + steps: + - intent: bot_challenge + - action: utter_iamabot diff --git a/data/test/test_integration/data/stories.yml b/data/test/test_integration/data/stories.yml new file mode 100644 index 0000000..227609c --- /dev/null +++ b/data/test/test_integration/data/stories.yml @@ -0,0 +1,56 @@ +version: "3.1" + +stories: + + - story: happy path + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy + + - story: sad path 1 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + + - story: sad path 2 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: deny + - action: utter_goodbye + + - story: block savings + steps: + - intent: block + entities: + - account_type: savings + - action: utter_block_account + + - story: block checking + steps: + - intent: block + entities: + - account_type: checking + - action: utter_checking + + - story: block credit + steps: + - intent: block + entities: + - account_type: credit + - action: utter_credit + + - story: block no entities + steps: + - intent: block + - action: utter_nothing diff --git a/data/test/test_integration/domain.yml b/data/test/test_integration/domain.yml new file mode 100644 index 0000000..2118bb6 --- /dev/null +++ b/data/test/test_integration/domain.yml @@ -0,0 +1,64 @@ +version: "3.1" + +intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + - block + +responses: + utter_greet: + - text: "Hey! How are you?" + + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" + + utter_did_that_help: + - text: "Did that help you?" + + utter_happy: + - text: "Great, carry on!" + + utter_goodbye: + - text: "Bye" + + utter_iamabot: + - text: "I am a bot, powered by Rasa." + + utter_block_account: + - text: "your account has been blocked" + + utter_checking: + - text: "checking account" + + utter_credit: + - text: "credit account" + + utter_nothing: + - text: "no account type was specified" + +entities: + - account_type + +slots: + account_type: + type: categorical + influence_conversation: true + values: + - savings + - checking + - credit + mappings: + - type: from_entity + entity: account_type + conditions: + - active_loop: null + +session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true diff --git a/data/test/test_integration_err/data/nlu.yml b/data/test/test_integration_err/data/nlu.yml new file mode 100644 index 0000000..0d804f0 --- /dev/null +++ b/data/test/test_integration_err/data/nlu.yml @@ -0,0 +1,91 @@ +version: "3.1" + +nlu: + - intent: greet hello + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon + + - intent: goodbye + examples: | + - cu + - good by + - cee you later + - good night + - bye + - goodbye + - have a nice day + - see you around + - bye bye + - see you later + + - intent: affirm + examples: | + - yes + - y + - indeed + - of course + - that sounds good + - correct + + - intent: deny + examples: | + - no + - n + - never + - I don't think so + - don't like that + - no way + - not really + + - intent: mood_great + examples: | + - perfect + - great + - amazing + - feeling like a king + - wonderful + - I am feeling very good + - I am great + - I am amazing + - I am going to save the world + - super stoked + - extremely good + - so so perfect + - so good + - so perfect + + - intent: mood_unhappy + examples: | + - my day was horrible + - I am sad + - I don't feel very well + - I am disappointed + - super sad + - I'm so sad + - sad + - very sad + - unhappy + - not good + - not very good + - extremly sad + - so saad + - so sad + + - intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? diff --git a/data/test/test_integration_err/data/rules.yml b/data/test/test_integration_err/data/rules.yml new file mode 100644 index 0000000..f727a19 --- /dev/null +++ b/data/test/test_integration_err/data/rules.yml @@ -0,0 +1,13 @@ +version: "3.1" + +rules: + + - rule: Say goodbye anytime the user says goodbye + steps: + - intent: goodbye + - action: utter_goodbye + + - rule: Say 'I am a bot' anytime the user challenges + steps: + - intent: bot_challenge + - action: utter_iamabot diff --git a/data/test/test_integration_err/data/stories.yml b/data/test/test_integration_err/data/stories.yml new file mode 100644 index 0000000..8d67b01 --- /dev/null +++ b/data/test/test_integration_err/data/stories.yml @@ -0,0 +1,30 @@ +version: "3.1" + +stories: + + - story: happy path + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy + + - story: sad path 1 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + + - story: sad path 2 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: deny + - action: utter_goodbye diff --git a/data/test/test_integration_err/domain.yml b/data/test/test_integration_err/domain.yml new file mode 100644 index 0000000..d72dc53 --- /dev/null +++ b/data/test/test_integration_err/domain.yml @@ -0,0 +1,34 @@ +version: "3.1" + +intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + +responses: + utter_greet: + - text: "Hey! How are you?" + + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" + + utter_did_that_help: + - text: "Did that help you?" + + utter_happy: + - text: "Great, carry on!" + + utter_goodbye: + - text: "Bye" + + utter_iamabot: + - text: "I am a bot, powered by Rasa." + +session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true diff --git a/data/test/training_data_containing_special_chars.json b/data/test/training_data_containing_special_chars.json new file mode 100644 index 0000000..e16261c --- /dev/null +++ b/data/test/training_data_containing_special_chars.json @@ -0,0 +1,33 @@ +{ + "rasa_nlu_data": { + "common_examples": [ + { + "text": "yes\n", + "intent": "affirm" + }, + { + "entities": [ + { + "start": 31, + "end": 38, + "value": "n\north\n", + "entity": "location" + } + ], + "intent": "restaurant_search\t", + "text": "i'm looking for a place in the n\north\n of town" + } + ], + "entity_synonyms": [ + { + "synonyms": [ + "C\thi\rne\bs", + "chines", + "Chinese" + ], + "value": "chinese" + } + ], + "regex_features": [] + } +} \ No newline at end of file diff --git a/data/test/wit_converted_to_rasa.json b/data/test/wit_converted_to_rasa.json new file mode 100644 index 0000000..a2949d4 --- /dev/null +++ b/data/test/wit_converted_to_rasa.json @@ -0,0 +1,89 @@ +{ + "rasa_nlu_data": { + "common_examples": [ + { + "text": "i want pizza", + "intent": "out_of_scope" + }, + { + "text": "i want to go from berlin to tokyo tomorrow", + "intent": "flight_booking", + "entities": [ + { + "start": 18, + "role": "from", + "end": 24, + "value": "berlin", + "entity": "location" + }, + { + "start": 28, + "role": "to", + "end": 33, + "value": "tokyo", + "entity": "location" + }, + { + "start": 34, + "end": 42, + "value": "2016-05-29T00:00:00.000-07:00", + "entity": "datetime" + } + ] + }, + { + "text": "i'm looking for a flight from london to amsterdam next monday", + "intent": "flight_booking", + "entities": [ + { + "start": 30, + "role": "from", + "end": 36, + "value": "london", + "entity": "location" + }, + { + "start": 40, + "role": "to", + "end": 49, + "value": "amsterdam", + "entity": "location" + }, + { + "start": 50, + "end": 61, + "value": "2016-05-30T00:00:00.000-07:00", + "entity": "datetime", + "role": "datetime" + } + ] + }, + { + "text": "i want to fly to berlin", + "intent": "flight_booking", + "entities": [ + { + "start": 17, + "role": "from", + "end": 23, + "value": "berlin", + "entity": "location" + } + ] + }, + { + "text": "i want to fly from london", + "intent": "flight_booking", + "entities": [ + { + "start": 19, + "role": "from", + "end": 25, + "value": "london", + "entity": "location" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/data/test_action_extract_slots_11333/config.yml b/data/test_action_extract_slots_11333/config.yml new file mode 100644 index 0000000..3bebec9 --- /dev/null +++ b/data/test_action_extract_slots_11333/config.yml @@ -0,0 +1,49 @@ +# The config recipe. +# https://rasa.com/docs/rasa/model-configuration/ +recipe: default.v1 + +assistant_id: placeholder_default + +# Configuration for Rasa NLU. +# https://rasa.com/docs/rasa/nlu/components/ +language: en + +pipeline: +# No configuration for the NLU pipeline was provided. The following default pipeline was used to train your model. +# If you'd like to customize it, uncomment and adjust the pipeline. +# See https://rasa.com/docs/rasa/tuning-your-model for more information. + - name: WhitespaceTokenizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 100 + constrain_similarities: true + run_eagerly: true + - name: EntitySynonymMapper + # - name: ResponseSelector + # epochs: 100 + # constrain_similarities: true + # - name: FallbackClassifier + # threshold: 0.3 + # ambiguity_threshold: 0.1 + +# Configuration for Rasa Core. +# https://rasa.com/docs/rasa/core/policies/ +policies: +# No configuration for policies was provided. The following default policies were used to train your model. +# If you'd like to customize them, uncomment and adjust the policies. +# See https://rasa.com/docs/rasa/policies for more information. + - name: AugmentedMemoizationPolicy + - name: RulePolicy + # - name: UnexpecTEDIntentPolicy + # max_history: 5 + # epochs: 100 + # - name: TEDPolicy + # max_history: 5 + # epochs: 100 + # constrain_similarities: true diff --git a/data/test_action_extract_slots_11333/data/nlu.yml b/data/test_action_extract_slots_11333/data/nlu.yml new file mode 100644 index 0000000..42dc0ed --- /dev/null +++ b/data/test_action_extract_slots_11333/data/nlu.yml @@ -0,0 +1,98 @@ +version: "3.1" + +nlu: +- intent: block + examples: | + - block my [checking](account_type) account + - i want to block my [savings](account_type) account + - i need to block an account + - i need to block my [credit](account_type) card + +- intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon + +- intent: goodbye + examples: | + - cu + - good by + - cee you later + - good night + - bye + - goodbye + - have a nice day + - see you around + - bye bye + - see you later + +- intent: affirm + examples: | + - yes + - y + - indeed + - of course + - that sounds good + - correct + +- intent: deny + examples: | + - no + - n + - never + - I don't think so + - don't like that + - no way + - not really + +- intent: mood_great + examples: | + - perfect + - great + - amazing + - feeling like a king + - wonderful + - I am feeling very good + - I am great + - I am amazing + - I am going to save the world + - super stoked + - extremely good + - so so perfect + - so good + - so perfect + +- intent: mood_unhappy + examples: | + - my day was horrible + - I am sad + - I don't feel very well + - I am disappointed + - super sad + - I'm so sad + - sad + - very sad + - unhappy + - not good + - not very good + - extremly sad + - so saad + - so sad + +- intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? diff --git a/data/test_action_extract_slots_11333/data/stories.yml b/data/test_action_extract_slots_11333/data/stories.yml new file mode 100644 index 0000000..eed978e --- /dev/null +++ b/data/test_action_extract_slots_11333/data/stories.yml @@ -0,0 +1,63 @@ +version: "3.1" + +stories: + +- story: happy path + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy + +- story: sad path 1 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + +- story: sad path 2 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: deny + - action: utter_goodbye + +- story: block savings + steps: + - intent: block + entities: + - account_type: savings + - slot_was_set: + - account_type: savings + - action: utter_block_account + +- story: block checking + steps: + - intent: block + entities: + - account_type: checking + - slot_was_set: + - account_type: checking + - action: utter_checking + +- story: block credit + steps: + - intent: block + entities: + - account_type: credit + - slot_was_set: + - account_type: credit + - action: utter_credit + +- story: block checking + steps: + - intent: block + - action: utter_nothing + diff --git a/data/test_action_extract_slots_11333/domain.yml b/data/test_action_extract_slots_11333/domain.yml new file mode 100644 index 0000000..1504926 --- /dev/null +++ b/data/test_action_extract_slots_11333/domain.yml @@ -0,0 +1,61 @@ +version: "3.1" + +intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + - block + +entities: + - account_type + +slots: + account_type: + type: categorical + influence_conversation: true + values: + - savings + - checking + - credit + mappings: + - type: from_entity + entity: account_type + +responses: + utter_block_account: + - text: "your account has been blocked" + + utter_checking: + - text: "checking account" + + utter_credit: + - text: "credit account" + + utter_nothing: + - text: "no account type was specified" + + utter_goodbye: + - text: "Bye" + + utter_iamabot: + - text: "I am a bot, powered by Rasa." + + utter_greet: + - text: "Hey! How are you?" + + utter_cheer_up: + - text: 'Here is something to cheer you up' + + utter_did_that_help: + - text: Did that help you? + + utter_happy: + - text: Great, carry on! + +session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true diff --git a/data/test_action_extract_slots_11333/tests/test_stories.yml b/data/test_action_extract_slots_11333/tests/test_stories.yml new file mode 100644 index 0000000..d46e39b --- /dev/null +++ b/data/test_action_extract_slots_11333/tests/test_stories.yml @@ -0,0 +1,91 @@ +#### This file contains tests to evaluate that your bot behaves as expected. +#### If you want to learn more, please see the docs: https://rasa.com/docs/rasa/testing-your-assistant + +stories: +- story: happy path 1 + steps: + - user: | + hello there! + intent: greet + - action: utter_greet + - user: | + amazing + intent: mood_great + - action: utter_happy + +- story: happy path 2 + steps: + - user: | + hello there! + intent: greet + - action: utter_greet + - user: | + amazing + intent: mood_great + - action: utter_happy + - user: | + bye-bye! + intent: goodbye + - action: utter_goodbye + +- story: sad path 1 + steps: + - user: | + hello + intent: greet + - action: utter_greet + - user: | + not good + intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - user: | + yes + intent: affirm + - action: utter_happy + +- story: sad path 2 + steps: + - user: | + hello + intent: greet + - action: utter_greet + - user: | + not good + intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - user: | + not really + intent: deny + - action: utter_goodbye + +- story: sad path 3 + steps: + - user: | + hi + intent: greet + - action: utter_greet + - user: | + very terrible + intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - user: | + no + intent: deny + - action: utter_goodbye + +- story: say goodbye + steps: + - user: | + bye-bye! + intent: goodbye + - action: utter_goodbye + +- story: bot challenge + steps: + - user: | + are you a bot? + intent: bot_challenge + - action: utter_iamabot diff --git a/data/test_classes/custom_graph_components/nlu_dense.py b/data/test_classes/custom_graph_components/nlu_dense.py new file mode 100644 index 0000000..55fc9cf --- /dev/null +++ b/data/test_classes/custom_graph_components/nlu_dense.py @@ -0,0 +1,148 @@ +import numpy as np +import logging +from bpemb import BPEmb +from typing import Any, Text, Dict, List, Type + +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.featurizers.dense_featurizer.dense_featurizer import DenseFeaturizer +from rasa.nlu.tokenizers.tokenizer import Tokenizer +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.features import Features +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.constants import ( + DENSE_FEATURIZABLE_ATTRIBUTES, + FEATURIZER_CLASS_ALIAS, +) +from rasa.shared.nlu.constants import ( + TEXT, + TEXT_TOKENS, + FEATURE_TYPE_SENTENCE, + FEATURE_TYPE_SEQUENCE, +) + + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_FEATURIZER, is_trainable=False +) +class BytePairFeaturizer(DenseFeaturizer, GraphComponent): + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [Tokenizer] + + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return ["bpemb"] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns the component's default config.""" + return { + **DenseFeaturizer.get_default_config(), + # specifies the language of the subword segmentation model + "lang": None, + # specifies the dimension of the subword embeddings + "dim": None, + # specifies the vocabulary size of the segmentation model + "vs": None, + # if set to True and the given vocabulary size can't be loaded for the given + # model, the closest size is chosen + "vs_fallback": True, + } + + def __init__( + self, + config: Dict[Text, Any], + name: Text, + ) -> None: + """Constructs a new byte pair vectorizer.""" + super().__init__(name, config) + # The configuration dictionary is saved in `self._config` for reference. + self.model = BPEmb( + lang=self._config["lang"], + dim=self._config["dim"], + vs=self._config["vs"], + vs_fallback=self._config["vs_fallback"], + ) + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + """Creates a new component (see parent class for full docstring).""" + return cls(config, execution_context.node_name) + + def process(self, messages: List[Message]) -> List[Message]: + """Processes incoming messages and computes and sets features.""" + for message in messages: + for attribute in DENSE_FEATURIZABLE_ATTRIBUTES: + self._set_features(message, attribute) + return messages + + def process_training_data(self, training_data: TrainingData) -> TrainingData: + """Processes the training examples in the given training data in-place.""" + self.process(training_data.training_examples) + return training_data + + def _create_word_vector(self, document: Text) -> np.ndarray: + """Creates a word vector from a text. Utility method.""" + encoded_ids = self.model.encode_ids(document) + if encoded_ids: + return self.model.vectors[encoded_ids[0]] + + return np.zeros((self.component_config["dim"],), dtype=np.float32) + + def _set_features(self, message: Message, attribute: Text = TEXT) -> None: + """Sets the features on a single message. Utility method.""" + tokens = message.get(TEXT_TOKENS) + + # If the message doesn't have tokens, we can't create features. + if not tokens: + return None + + # We need to reshape here such that the shape is equivalent to that of sparsely + # generated features. Without it, it'd be a 1D tensor. We need 2D (n_utterance, n_dim). + text_vector = self._create_word_vector(document=message.get(TEXT)).reshape( + 1, -1 + ) + word_vectors = np.array( + [self._create_word_vector(document=t.text) for t in tokens] + ) + + final_sequence_features = Features( + word_vectors, + FEATURE_TYPE_SEQUENCE, + attribute, + self._config[FEATURIZER_CLASS_ALIAS], + ) + message.add_features(final_sequence_features) + final_sentence_features = Features( + text_vector, + FEATURE_TYPE_SENTENCE, + attribute, + self._config[FEATURIZER_CLASS_ALIAS], + ) + message.add_features(final_sentence_features) + + @classmethod + def validate_config(cls, config: Dict[Text, Any]) -> None: + """Validates that the component is configured properly.""" + if not config["lang"]: + raise ValueError("BytePairFeaturizer needs language setting via `lang`.") + if not config["dim"]: + raise ValueError( + "BytePairFeaturizer needs dimensionality setting via `dim`." + ) + if not config["vs"]: + raise ValueError("BytePairFeaturizer needs a vector size setting via `vs`.") diff --git a/data/test_classes/custom_graph_components/nlu_meta_fallback.py b/data/test_classes/custom_graph_components/nlu_meta_fallback.py new file mode 100644 index 0000000..8b1592d --- /dev/null +++ b/data/test_classes/custom_graph_components/nlu_meta_fallback.py @@ -0,0 +1,42 @@ +from typing import Dict, Text, Any, List + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.nlu.classifiers.fallback_classifier import FallbackClassifier + + +@DefaultV1Recipe.register( + [DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER], is_trainable=True +) +class MetaFallback(FallbackClassifier): + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> None: + super().__init__(config) + + self._model_storage = model_storage + self._resource = resource + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> FallbackClassifier: + """Creates a new untrained component (see parent class for full docstring).""" + return cls(config, model_storage, resource, execution_context) + + def train(self, training_data: TrainingData) -> Resource: + # Do something here with the messages + return self._resource diff --git a/data/test_classes/custom_graph_components/nlu_meta_intent_featurizer.py b/data/test_classes/custom_graph_components/nlu_meta_intent_featurizer.py new file mode 100644 index 0000000..d9629aa --- /dev/null +++ b/data/test_classes/custom_graph_components/nlu_meta_intent_featurizer.py @@ -0,0 +1,15 @@ +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.nlu.classifiers.diet_classifier import DIETClassifier + + +@DefaultV1Recipe.register( + [DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER, + DefaultV1Recipe.ComponentType.ENTITY_EXTRACTOR, + DefaultV1Recipe.ComponentType.MESSAGE_FEATURIZER], is_trainable=True +) +class DIETFeaturizer(DIETClassifier): + + def process_training_data(self, training_data: TrainingData) -> TrainingData: + # classify and add the attributes to the messages on the training data + return training_data diff --git a/data/test_classes/custom_graph_components/nlu_sparse.py b/data/test_classes/custom_graph_components/nlu_sparse.py new file mode 100644 index 0000000..6342a7a --- /dev/null +++ b/data/test_classes/custom_graph_components/nlu_sparse.py @@ -0,0 +1,164 @@ +import logging +from typing import Any, Text, Dict, List, Type + +from sklearn.feature_extraction.text import TfidfVectorizer +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.featurizers.sparse_featurizer.sparse_featurizer import SparseFeaturizer +from rasa.nlu.tokenizers.tokenizer import Tokenizer +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.features import Features +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.constants import ( + DENSE_FEATURIZABLE_ATTRIBUTES, + FEATURIZER_CLASS_ALIAS, +) +from joblib import dump, load +from rasa.shared.nlu.constants import ( + TEXT, + TEXT_TOKENS, + FEATURE_TYPE_SENTENCE, + FEATURE_TYPE_SEQUENCE, +) + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_FEATURIZER, is_trainable=True +) +class TfIdfFeaturizer(SparseFeaturizer, GraphComponent): + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [Tokenizer] + + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return ["sklearn"] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns the component's default config.""" + return { + **SparseFeaturizer.get_default_config(), + "analyzer": "word", + "min_ngram": 1, + "max_ngram": 1, + } + + def __init__( + self, + config: Dict[Text, Any], + name: Text, + model_storage: ModelStorage, + resource: Resource, + ) -> None: + """Constructs a new tf/idf vectorizer using the sklearn framework.""" + super().__init__(name, config) + # Initialize the tfidf sklearn component + self.tfm = TfidfVectorizer( + analyzer=config["analyzer"], + ngram_range=(config["min_ngram"], config["max_ngram"]), + ) + + # We need to use these later when saving the trained component. + self._model_storage = model_storage + self._resource = resource + + def train(self, training_data: TrainingData) -> Resource: + """Trains the component from training data.""" + texts = [e.get(TEXT) for e in training_data.training_examples if e.get(TEXT)] + self.tfm.fit(texts) + self.persist() + return self._resource + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + """Creates a new untrained component (see parent class for full docstring).""" + return cls(config, execution_context.node_name, model_storage, resource) + + def _set_features(self, message: Message, attribute: Text = TEXT) -> None: + """Sets the features on a single message. Utility method.""" + tokens = message.get(TEXT_TOKENS) + + # If the message doesn't have tokens, we can't create features. + if not tokens: + return None + + # Make distinction between sentence and sequence features + text_vector = self.tfm.transform([message.get(TEXT)]) + word_vectors = self.tfm.transform([t.text for t in tokens]) + + final_sequence_features = Features( + word_vectors, + FEATURE_TYPE_SEQUENCE, + attribute, + self._config[FEATURIZER_CLASS_ALIAS], + ) + message.add_features(final_sequence_features) + final_sentence_features = Features( + text_vector, + FEATURE_TYPE_SENTENCE, + attribute, + self._config[FEATURIZER_CLASS_ALIAS], + ) + message.add_features(final_sentence_features) + + def process(self, messages: List[Message]) -> List[Message]: + """Processes incoming message and compute and set features.""" + for message in messages: + for attribute in DENSE_FEATURIZABLE_ATTRIBUTES: + self._set_features(message, attribute) + return messages + + def process_training_data(self, training_data: TrainingData) -> TrainingData: + """Processes the training examples in the given training data in-place.""" + self.process(training_data.training_examples) + return training_data + + def persist(self) -> None: + """ + Persist this model into the passed directory. + + Returns the metadata necessary to load the model again. In this case; `None`. + """ + with self._model_storage.write_to(self._resource) as model_dir: + dump(self.tfm, model_dir / "tfidfvectorizer.joblib") + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + """Loads trained component from disk.""" + try: + with model_storage.read_from(resource) as model_dir: + tfidfvectorizer = load(model_dir / "tfidfvectorizer.joblib") + component = cls( + config, execution_context.node_name, model_storage, resource + ) + component.tfm = tfidfvectorizer + except (ValueError, FileNotFoundError): + logger.debug( + f"Couldn't load metadata for component '{cls.__name__}' as the persisted " + f"model data couldn't be loaded." + ) + return component + + @classmethod + def validate_config(cls, config: Dict[Text, Any]) -> None: + """Validates that the component is configured properly.""" + pass diff --git a/data/test_classes/custom_slots.py b/data/test_classes/custom_slots.py new file mode 100644 index 0000000..464261d --- /dev/null +++ b/data/test_classes/custom_slots.py @@ -0,0 +1,55 @@ +import logging +from typing import Any, Dict, List, Text, Optional + +from rasa.shared.core.slots import Slot + +logger = logging.getLogger(__name__) + + +class LimitSlot(Slot): + """ + A slot for featurizing an amount as greater than or equal to vs. less than a given value. + + Example of configuration in the domain.yml file: + slots: + my_slot: + type: custom.slots.LimitSlot + limit: 100 + """ + + type_name = "limit" + + def __init__( + self, + name: Text, + limit: int, + mappings: List[Dict[Text, Any]], + initial_value: Any = None, + value_reset_delay: Optional[int] = None, + influence_conversation: bool = True, + ) -> None: + + super().__init__( + name=name, + initial_value=initial_value, + mappings=mappings, + value_reset_delay=value_reset_delay, + influence_conversation=influence_conversation, + ) + self.limit = limit + + def _as_feature(self) -> List[float]: + try: + greater_than_limit = float(self.value >= self.limit) + return [1.0, greater_than_limit] + except (TypeError, ValueError): + return [0.0, 0.0] + + def persistence_info(self) -> Dict[Text, Any]: + """Returns relevant information to persist this slot.""" + d = super().persistence_info() + d["limit"] = self.limit + return d + + def _feature_dimensionality(self) -> int: + return len(self.as_feature()) diff --git a/data/test_classes/graph_component_interface.py b/data/test_classes/graph_component_interface.py new file mode 100644 index 0000000..90beddd --- /dev/null +++ b/data/test_classes/graph_component_interface.py @@ -0,0 +1,110 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import List, Type, Dict, Text, Any, Optional + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage + + +class GraphComponent(ABC): + """Interface for any component which will run in a graph.""" + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [] + + @classmethod + @abstractmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + """Creates a new `GraphComponent`. + + Args: + config: This config overrides the `default_config`. + model_storage: Storage which graph components can use to persist and load + themselves. + resource: Resource locator for this component which can be used to persist + and load itself from the `model_storage`. + execution_context: Information about the current graph run. + + Returns: An instantiated `GraphComponent`. + """ + ... + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> GraphComponent: + """Creates a component using a persisted version of itself. + + If not overridden this method merely calls `create`. + + Args: + config: The config for this graph component. This is the default config of + the component merged with config specified by the user. + model_storage: Storage which graph components can use to persist and load + themselves. + resource: Resource locator for this component which can be used to persist + and load itself from the `model_storage`. + execution_context: Information about the current graph run. + kwargs: Output values from previous nodes might be passed in as `kwargs`. + + Returns: + An instantiated, loaded `GraphComponent`. + """ + return cls.create(config, model_storage, resource, execution_context) + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns the component's default config. + + Default config and user config are merged by the `GraphNode` before the + config is passed to the `create` and `load` method of the component. + + Returns: + The default config of the component. + """ + return {} + + @staticmethod + def supported_languages() -> Optional[List[Text]]: + """Determines which languages this component can work with. + + Returns: A list of supported languages, or `None` to signify all are supported. + """ + return None + + @staticmethod + def not_supported_languages() -> Optional[List[Text]]: + """Determines which languages this component cannot work with. + + Returns: A list of not supported languages, or + `None` to signify all are supported. + """ + return None + + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return [] + + @classmethod + def fingerprint_addon(cls, config: Dict[str, Any]) -> Optional[str]: + """Adds additional data to the fingerprint calculation. + + This is useful if a component uses external data that is not provided + by the graph. + """ + return None diff --git a/data/test_classes/nlu_component_skeleton.py b/data/test_classes/nlu_component_skeleton.py new file mode 100644 index 0000000..ef5ae14 --- /dev/null +++ b/data/test_classes/nlu_component_skeleton.py @@ -0,0 +1,41 @@ +from typing import Dict, Text, Any, List + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData + +# TODO: Correctly register your component with its type +@DefaultV1Recipe.register( + [DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER], is_trainable=True +) +class CustomNLUComponent(GraphComponent): + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + # TODO: Implement this + ... + + def train(self, training_data: TrainingData) -> Resource: + # TODO: Implement this if your component requires training + ... + + def process_training_data(self, training_data: TrainingData) -> TrainingData: + # TODO: Implement this if your component augments the training data with + # tokens or message features which are used by other components + # during training. + ... + + return training_data + + def process(self, messages: List[Message]) -> List[Message]: + # TODO: This is the method which Rasa Open Source will call during inference. + ... + return messages diff --git a/data/test_classes/registered_component.py b/data/test_classes/registered_component.py new file mode 100644 index 0000000..300dad4 --- /dev/null +++ b/data/test_classes/registered_component.py @@ -0,0 +1,11 @@ +from rasa.engine.graph import GraphComponent +from rasa.engine.recipes.default_recipe import DefaultV1Recipe + + +@DefaultV1Recipe.register( + component_types=[DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER], + is_trainable=True, + model_from="SpacyNLP", +) +class MyComponent(GraphComponent): + ... diff --git a/data/test_config/config_crf_custom_features.yml b/data/test_config/config_crf_custom_features.yml new file mode 100644 index 0000000..7536105 --- /dev/null +++ b/data/test_config/config_crf_custom_features.yml @@ -0,0 +1,26 @@ +language: "en" + +pipeline: +- name: "SpacyNLP" + model: "en_core_web_md" +- name: "SpacyTokenizer" +- name: "SpacyEntityExtractor" +- name: "DucklingEntityExtractor" + url: "http://duckling:8000" + dimensions: + - "NUMBER" +- name: "CRFEntityExtractor" + BILOU_flag: true + features: + # features for word before token + - ["low", "title", "upper", "pos", "pos2"] + # features of token itself + - ["bias", "low", "word3", "word2", "upper", "title", "digit", "pos", "pos2", "pattern"] + # features for word after the token we want to tag + - ["low", "title", "upper", "pos", "pos2"] + max_iterations: 50 + L1_c: 1 + L2_c: 1e-3 +- name: "SklearnIntentClassifier" + C: [1, 2, 5, 10, 20, 100] + kernel: "linear" \ No newline at end of file diff --git a/data/test_config/config_crf_no_pattern_feature.yml b/data/test_config/config_crf_no_pattern_feature.yml new file mode 100644 index 0000000..ee673b1 --- /dev/null +++ b/data/test_config/config_crf_no_pattern_feature.yml @@ -0,0 +1,8 @@ +recipe: default.v1 +language: en +pipeline: + - name: "WhitespaceTokenizer" + - name: "RegexFeaturizer" + - name: "CRFEntityExtractor" + features: [['low', 'title', 'upper'],['bias', 'low', 'digit'],['low', 'title', 'upper']] + - name: "EntitySynonymMapper" diff --git a/data/test_config/config_crf_no_regex.yml b/data/test_config/config_crf_no_regex.yml new file mode 100644 index 0000000..14c198d --- /dev/null +++ b/data/test_config/config_crf_no_regex.yml @@ -0,0 +1,6 @@ +recipe: default.v1 +language: en +pipeline: + - name: "WhitespaceTokenizer" + - name: "CRFEntityExtractor" + - name: "EntitySynonymMapper" diff --git a/data/test_config/config_crf_no_synonyms.yml b/data/test_config/config_crf_no_synonyms.yml new file mode 100644 index 0000000..5bc65bd --- /dev/null +++ b/data/test_config/config_crf_no_synonyms.yml @@ -0,0 +1,5 @@ +recipe: default.v1 +language: en +pipeline: + - name: "WhitespaceTokenizer" + - name: "CRFEntityExtractor" diff --git a/data/test_config/config_default_assistant_id_value.yml b/data/test_config/config_default_assistant_id_value.yml new file mode 100644 index 0000000..b37052d --- /dev/null +++ b/data/test_config/config_default_assistant_id_value.yml @@ -0,0 +1,6 @@ +recipe: default.v1 +assistant_id: placeholder_default +language: en +pipeline: [] +data: null +policies: null diff --git a/data/test_config/config_defaults.yml b/data/test_config/config_defaults.yml new file mode 100644 index 0000000..9244ca0 --- /dev/null +++ b/data/test_config/config_defaults.yml @@ -0,0 +1,40 @@ +recipe: default.v1 +assistant_id: placeholder_default +language: en +pipeline: [] +# # No configuration for the NLU pipeline was provided. The following default pipeline was used to train your model. +# # If you'd like to customize it, uncomment and adjust the pipeline. +# # See https://rasa.com/docs/rasa/tuning-your-model for more information. +# - name: WhitespaceTokenizer +# - name: RegexFeaturizer +# - name: LexicalSyntacticFeaturizer +# - name: CountVectorsFeaturizer +# - name: CountVectorsFeaturizer +# analyzer: char_wb +# min_ngram: 1 +# max_ngram: 4 +# - name: DIETClassifier +# epochs: 100 +# constrain_similarities: true +# - name: EntitySynonymMapper +# - name: ResponseSelector +# epochs: 100 +# constrain_similarities: true +# - name: FallbackClassifier +# threshold: 0.3 +# ambiguity_threshold: 0.1 + +data: +policies: +# # No configuration for policies was provided. The following default policies were used to train your model. +# # If you'd like to customize them, uncomment and adjust the policies. +# # See https://rasa.com/docs/rasa/policies for more information. +# - name: MemoizationPolicy +# - name: RulePolicy +# - name: UnexpecTEDIntentPolicy +# max_history: 5 +# epochs: 100 +# - name: TEDPolicy +# max_history: 5 +# epochs: 100 +# constrain_similarities: true diff --git a/data/test_config/config_embedding_intent_response_selector.yml b/data/test_config/config_embedding_intent_response_selector.yml new file mode 100644 index 0000000..b2be558 --- /dev/null +++ b/data/test_config/config_embedding_intent_response_selector.yml @@ -0,0 +1,9 @@ +language: "en" + +pipeline: + - name: "WhitespaceTokenizer" + - name: "CountVectorsFeaturizer" + - name: "DIETClassifier" + epochs: 2 + - name: "ResponseSelector" + epochs: 2 diff --git a/data/test_config/config_empty_en.yml b/data/test_config/config_empty_en.yml new file mode 100644 index 0000000..8dd2705 --- /dev/null +++ b/data/test_config/config_empty_en.yml @@ -0,0 +1,5 @@ +recipe: default.v1 +assistant_id: placeholder_default +language: en +pipeline: +policies: diff --git a/data/test_config/config_empty_en_after_dumping.yml b/data/test_config/config_empty_en_after_dumping.yml new file mode 100644 index 0000000..58f81c1 --- /dev/null +++ b/data/test_config/config_empty_en_after_dumping.yml @@ -0,0 +1,38 @@ +recipe: default.v1 +assistant_id: placeholder_default +language: en +pipeline: +# # No configuration for the NLU pipeline was provided. The following default pipeline was used to train your model. +# # If you'd like to customize it, uncomment and adjust the pipeline. +# # See https://rasa.com/docs/rasa/tuning-your-model for more information. +# - name: WhitespaceTokenizer +# - name: RegexFeaturizer +# - name: LexicalSyntacticFeaturizer +# - name: CountVectorsFeaturizer +# - name: CountVectorsFeaturizer +# analyzer: char_wb +# min_ngram: 1 +# max_ngram: 4 +# - name: DIETClassifier +# epochs: 100 +# constrain_similarities: true +# - name: EntitySynonymMapper +# - name: ResponseSelector +# epochs: 100 +# constrain_similarities: true +# - name: FallbackClassifier +# threshold: 0.3 +# ambiguity_threshold: 0.1 +policies: +# # No configuration for policies was provided. The following default policies were used to train your model. +# # If you'd like to customize them, uncomment and adjust the policies. +# # See https://rasa.com/docs/rasa/policies for more information. +# - name: MemoizationPolicy +# - name: RulePolicy +# - name: UnexpecTEDIntentPolicy +# max_history: 5 +# epochs: 100 +# - name: TEDPolicy +# max_history: 5 +# epochs: 100 +# constrain_similarities: true diff --git a/data/test_config/config_empty_en_after_dumping_core.yml b/data/test_config/config_empty_en_after_dumping_core.yml new file mode 100644 index 0000000..12d5833 --- /dev/null +++ b/data/test_config/config_empty_en_after_dumping_core.yml @@ -0,0 +1,17 @@ +recipe: default.v1 +assistant_id: placeholder_default +language: en +pipeline: +policies: +# # No configuration for policies was provided. The following default policies were used to train your model. +# # If you'd like to customize them, uncomment and adjust the policies. +# # See https://rasa.com/docs/rasa/policies for more information. +# - name: MemoizationPolicy +# - name: RulePolicy +# - name: UnexpecTEDIntentPolicy +# max_history: 5 +# epochs: 100 +# - name: TEDPolicy +# max_history: 5 +# epochs: 100 +# constrain_similarities: true diff --git a/data/test_config/config_empty_en_after_dumping_nlu.yml b/data/test_config/config_empty_en_after_dumping_nlu.yml new file mode 100644 index 0000000..a2ce0eb --- /dev/null +++ b/data/test_config/config_empty_en_after_dumping_nlu.yml @@ -0,0 +1,26 @@ +recipe: default.v1 +assistant_id: placeholder_default +language: en +pipeline: +# # No configuration for the NLU pipeline was provided. The following default pipeline was used to train your model. +# # If you'd like to customize it, uncomment and adjust the pipeline. +# # See https://rasa.com/docs/rasa/tuning-your-model for more information. +# - name: WhitespaceTokenizer +# - name: RegexFeaturizer +# - name: LexicalSyntacticFeaturizer +# - name: CountVectorsFeaturizer +# - name: CountVectorsFeaturizer +# analyzer: char_wb +# min_ngram: 1 +# max_ngram: 4 +# - name: DIETClassifier +# epochs: 100 +# constrain_similarities: true +# - name: EntitySynonymMapper +# - name: ResponseSelector +# epochs: 100 +# constrain_similarities: true +# - name: FallbackClassifier +# threshold: 0.3 +# ambiguity_threshold: 0.1 +policies: diff --git a/data/test_config/config_empty_fr.yml b/data/test_config/config_empty_fr.yml new file mode 100644 index 0000000..d5de97e --- /dev/null +++ b/data/test_config/config_empty_fr.yml @@ -0,0 +1,4 @@ +language: fr +assistant_id: placeholder_default +pipeline: +policies: diff --git a/data/test_config/config_empty_fr_after_dumping.yml b/data/test_config/config_empty_fr_after_dumping.yml new file mode 100644 index 0000000..7f39917 --- /dev/null +++ b/data/test_config/config_empty_fr_after_dumping.yml @@ -0,0 +1,37 @@ +language: fr +assistant_id: placeholder_default +pipeline: +# # No configuration for the NLU pipeline was provided. The following default pipeline was used to train your model. +# # If you'd like to customize it, uncomment and adjust the pipeline. +# # See https://rasa.com/docs/rasa/tuning-your-model for more information. +# - name: WhitespaceTokenizer +# - name: RegexFeaturizer +# - name: LexicalSyntacticFeaturizer +# - name: CountVectorsFeaturizer +# - name: CountVectorsFeaturizer +# analyzer: char_wb +# min_ngram: 1 +# max_ngram: 4 +# - name: DIETClassifier +# epochs: 100 +# constrain_similarities: true +# - name: EntitySynonymMapper +# - name: ResponseSelector +# epochs: 100 +# constrain_similarities: true +# - name: FallbackClassifier +# threshold: 0.3 +# ambiguity_threshold: 0.1 +policies: +# # No configuration for policies was provided. The following default policies were used to train your model. +# # If you'd like to customize them, uncomment and adjust the policies. +# # See https://rasa.com/docs/rasa/policies for more information. +# - name: MemoizationPolicy +# - name: RulePolicy +# - name: UnexpecTEDIntentPolicy +# max_history: 5 +# epochs: 100 +# - name: TEDPolicy +# max_history: 5 +# epochs: 100 +# constrain_similarities: true diff --git a/data/test_config/config_language_only.yml b/data/test_config/config_language_only.yml new file mode 100644 index 0000000..72d709f --- /dev/null +++ b/data/test_config/config_language_only.yml @@ -0,0 +1,2 @@ +recipe: default.v1 +language: en diff --git a/data/test_config/config_no_assistant_id.yml b/data/test_config/config_no_assistant_id.yml new file mode 100644 index 0000000..dacbbd1 --- /dev/null +++ b/data/test_config/config_no_assistant_id.yml @@ -0,0 +1,5 @@ +recipe: default.v1 +language: en +pipeline: [] +data: null +policies: null diff --git a/data/test_config/config_no_assistant_id_with_comments.yml b/data/test_config/config_no_assistant_id_with_comments.yml new file mode 100644 index 0000000..2f88b7e --- /dev/null +++ b/data/test_config/config_no_assistant_id_with_comments.yml @@ -0,0 +1,8 @@ +# Random comments line 1 +recipe: default.v1 # Random comments line 2 +# Random comments line 3 +language: en # Random comments line 4 +pipeline: [] +data: null +policies: null +# Random comments line 5 diff --git a/data/test_config/config_pipeline_empty.yml b/data/test_config/config_pipeline_empty.yml new file mode 100644 index 0000000..d7810a8 --- /dev/null +++ b/data/test_config/config_pipeline_empty.yml @@ -0,0 +1,19 @@ +recipe: default.v1 +assistant_id: placeholder_default +language: en + +pipeline: + +policies: + - name: "KerasPolicy" + featurizer: + - name: MaxHistoryTrackerFeaturizer + max_history: 5 + state_featurizer: + - name: SingleStateFeaturizer + - name: "MemoizationPolicy" + max_history: 5 + - name: "FallbackPolicy" + nlu_threshold: 0.4 + core_threshold: 0.3 + fallback_action_name: "my_fallback_action" diff --git a/data/test_config/config_pipeline_missing.yml b/data/test_config/config_pipeline_missing.yml new file mode 100644 index 0000000..e088aa3 --- /dev/null +++ b/data/test_config/config_pipeline_missing.yml @@ -0,0 +1,17 @@ +recipe: default.v1 +assistant_id: placeholder_default +language: en + +policies: + - name: "KerasPolicy" + featurizer: + - name: MaxHistoryTrackerFeaturizer + max_history: 5 + state_featurizer: + - name: SingleStateFeaturizer + - name: "MemoizationPolicy" + max_history: 5 + - name: "FallbackPolicy" + nlu_threshold: 0.4 + core_threshold: 0.3 + fallback_action_name: "my_fallback_action" diff --git a/data/test_config/config_policies_empty.yml b/data/test_config/config_policies_empty.yml new file mode 100644 index 0000000..9c95e11 --- /dev/null +++ b/data/test_config/config_policies_empty.yml @@ -0,0 +1,21 @@ +recipe: default.v1 +assistant_id: placeholder_default +language: en + +pipeline: + - name: ConveRTTokenizer + - name: ConveRTFeaturizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 100 + - name: EntitySynonymMapper + - name: ResponseSelector + epochs: 100 + +policies: diff --git a/data/test_config/config_policies_missing.yml b/data/test_config/config_policies_missing.yml new file mode 100644 index 0000000..079bb6d --- /dev/null +++ b/data/test_config/config_policies_missing.yml @@ -0,0 +1,19 @@ +recipe: default.v1 +assistant_id: placeholder_default +language: en + +pipeline: + - name: ConveRTTokenizer + - name: ConveRTFeaturizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 100 + - name: EntitySynonymMapper + - name: ResponseSelector + epochs: 100 diff --git a/data/test_config/config_pretrained_embeddings_convert.yml b/data/test_config/config_pretrained_embeddings_convert.yml new file mode 100644 index 0000000..7f5fb5f --- /dev/null +++ b/data/test_config/config_pretrained_embeddings_convert.yml @@ -0,0 +1,6 @@ +language: "en" + +pipeline: + - name: ConveRTTokenizer + - name: ConveRTFeaturizer + - name: DIETClassifier diff --git a/data/test_config/config_pretrained_embeddings_mitie.yml b/data/test_config/config_pretrained_embeddings_mitie.yml new file mode 100644 index 0000000..46da09d --- /dev/null +++ b/data/test_config/config_pretrained_embeddings_mitie.yml @@ -0,0 +1,12 @@ +language: "en" + +pipeline: +- name: "MitieNLP" + model: "data/total_word_feature_extractor.dat" +- name: "MitieTokenizer" +- name: "MitieEntityExtractor" +- name: "EntitySynonymMapper" +- name: "RegexFeaturizer" +- name: "MitieFeaturizer" +- name: "SklearnIntentClassifier" + diff --git a/data/test_config/config_pretrained_embeddings_mitie_2.yml b/data/test_config/config_pretrained_embeddings_mitie_2.yml new file mode 100644 index 0000000..356eb89 --- /dev/null +++ b/data/test_config/config_pretrained_embeddings_mitie_2.yml @@ -0,0 +1,10 @@ +language: "en" + +pipeline: +- name: "MitieNLP" + model: "data/total_word_feature_extractor.dat" +- name: "MitieTokenizer" +- name: "MitieEntityExtractor" +- name: "EntitySynonymMapper" +- name: "RegexFeaturizer" +- name: "MitieIntentClassifier" diff --git a/data/test_config/config_pretrained_embeddings_mitie_diet.yml b/data/test_config/config_pretrained_embeddings_mitie_diet.yml new file mode 100644 index 0000000..ec0575a --- /dev/null +++ b/data/test_config/config_pretrained_embeddings_mitie_diet.yml @@ -0,0 +1,18 @@ +language: "en" + +pipeline: +- name: "MitieNLP" + model: "data/total_word_feature_extractor.dat" +- name: "MitieTokenizer" +- name: "MitieEntityExtractor" +- name: "EntitySynonymMapper" +- name: "RegexFeaturizer" +- name: "MitieFeaturizer" +- name: "MitieIntentClassifier" + num_threads: 200000 + finetuning_epoch_fraction: 0.75 +- name: "DIETClassifier" + num_threads: 200000 + finetuning_epoch_fraction: 0.75 +- name: "SklearnIntentClassifier" + diff --git a/data/test_config/config_pretrained_embeddings_mitie_zh.yml b/data/test_config/config_pretrained_embeddings_mitie_zh.yml new file mode 100644 index 0000000..7b0ca27 --- /dev/null +++ b/data/test_config/config_pretrained_embeddings_mitie_zh.yml @@ -0,0 +1,11 @@ +language: "zh" + +pipeline: +- name: "MitieNLP" + model: "data/total_word_feature_extractor_zh.dat" +- name: "JiebaTokenizer" +- name: "MitieEntityExtractor" +- name: "EntitySynonymMapper" +- name: "RegexFeaturizer" +- name: "MitieFeaturizer" +- name: "SklearnIntentClassifier" diff --git a/data/test_config/config_pretrained_embeddings_spacy.yml b/data/test_config/config_pretrained_embeddings_spacy.yml new file mode 100644 index 0000000..c538032 --- /dev/null +++ b/data/test_config/config_pretrained_embeddings_spacy.yml @@ -0,0 +1,10 @@ +language: "en" + +pipeline: + - name: SpacyNLP + - name: SpacyTokenizer + - name: SpacyFeaturizer + - name: RegexFeaturizer + - name: CRFEntityExtractor + - name: EntitySynonymMapper + - name: SklearnIntentClassifier diff --git a/data/test_config/config_pretrained_embeddings_spacy_de.yml b/data/test_config/config_pretrained_embeddings_spacy_de.yml new file mode 100644 index 0000000..7f5b2fa --- /dev/null +++ b/data/test_config/config_pretrained_embeddings_spacy_de.yml @@ -0,0 +1,11 @@ +language: "de" + +pipeline: + - name: SpacyNLP + model: "de_core_news_sm" + - name: SpacyTokenizer + - name: SpacyFeaturizer + - name: RegexFeaturizer + - name: CRFEntityExtractor + - name: EntitySynonymMapper + - name: SklearnIntentClassifier diff --git a/data/test_config/config_pretrained_embeddings_spacy_duckling.yml b/data/test_config/config_pretrained_embeddings_spacy_duckling.yml new file mode 100644 index 0000000..faedb63 --- /dev/null +++ b/data/test_config/config_pretrained_embeddings_spacy_duckling.yml @@ -0,0 +1,14 @@ +language: "en" + +pipeline: +- name: "SpacyNLP" + model: "en_core_web_md" +- name: "SpacyTokenizer" +- name: "RegexFeaturizer" +- name: "SpacyFeaturizer" +- name: "CRFEntityExtractor" +- name: "EntitySynonymMapper" +- name: "SklearnIntentClassifier" +- name: "DucklingEntityExtractor" + url: "http://duckling:8000" + \ No newline at end of file diff --git a/data/test_config/config_response_selector_minimal.yml b/data/test_config/config_response_selector_minimal.yml new file mode 100644 index 0000000..1d33762 --- /dev/null +++ b/data/test_config/config_response_selector_minimal.yml @@ -0,0 +1,8 @@ +recipe: default.v1 +assistant_id: placeholder_default +language: en +pipeline: + - name: WhitespaceTokenizer + - name: CountVectorsFeaturizer + - name: ResponseSelector + epochs: 1 diff --git a/data/test_config/config_spacy_entity_extractor.yml b/data/test_config/config_spacy_entity_extractor.yml new file mode 100644 index 0000000..0843360 --- /dev/null +++ b/data/test_config/config_spacy_entity_extractor.yml @@ -0,0 +1,10 @@ +recipe: default.v1 +language: en +pipeline: + - name: "SpacyNLP" + model: "en_core_web_md" + - name: "SpacyTokenizer" + - name: "SpacyFeaturizer" + - name: "RegexFeaturizer" + - name: "SpacyEntityExtractor" + - name: "EntitySynonymMapper" diff --git a/data/test_config/config_supervised_embeddings.yml b/data/test_config/config_supervised_embeddings.yml new file mode 100644 index 0000000..c3bd3a2 --- /dev/null +++ b/data/test_config/config_supervised_embeddings.yml @@ -0,0 +1,13 @@ +language: "en" + +pipeline: + - name: WhitespaceTokenizer + - name: RegexFeaturizer + - name: CRFEntityExtractor + - name: EntitySynonymMapper + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier diff --git a/data/test_config/config_supervised_embeddings_duckling.yml b/data/test_config/config_supervised_embeddings_duckling.yml new file mode 100644 index 0000000..f6475a8 --- /dev/null +++ b/data/test_config/config_supervised_embeddings_duckling.yml @@ -0,0 +1,7 @@ +language: "en" + +pipeline: +- name: "CountVectorsFeaturizer" +- name: "LogisticRegressionClassifier" +- name: "DucklingEntityExtractor" + url: "http://duckling:8000" diff --git a/data/test_config/config_ted_policy_model_checkpointing.yml b/data/test_config/config_ted_policy_model_checkpointing.yml new file mode 100644 index 0000000..6174588 --- /dev/null +++ b/data/test_config/config_ted_policy_model_checkpointing.yml @@ -0,0 +1,8 @@ +language: "en" +assistant_id: placeholder_default +policies: + - name: TEDPolicy + epochs: 2 + evaluate_on_number_of_examples: 14 + evaluate_every_number_of_epochs: 1 + checkpoint_model: True diff --git a/data/test_config/config_ted_policy_model_checkpointing_zero_eval_num_examples.yml b/data/test_config/config_ted_policy_model_checkpointing_zero_eval_num_examples.yml new file mode 100644 index 0000000..3f71c48 --- /dev/null +++ b/data/test_config/config_ted_policy_model_checkpointing_zero_eval_num_examples.yml @@ -0,0 +1,8 @@ +language: "en" +assistant_id: placeholder_default +policies: + - name: TEDPolicy + epochs: 1 + evaluate_on_number_of_examples: 0 + evaluate_every_number_of_epochs: 1 + checkpoint_model: True diff --git a/data/test_config/config_ted_policy_model_checkpointing_zero_every_num_epochs.yml b/data/test_config/config_ted_policy_model_checkpointing_zero_every_num_epochs.yml new file mode 100644 index 0000000..6ad7605 --- /dev/null +++ b/data/test_config/config_ted_policy_model_checkpointing_zero_every_num_epochs.yml @@ -0,0 +1,8 @@ +language: "en" +assistant_id: placeholder_default +policies: + - name: TEDPolicy + epochs: 1 + evaluate_on_number_of_examples: 14 + evaluate_every_number_of_epochs: 0 + checkpoint_model: True diff --git a/data/test_config/config_ted_policy_no_model_checkpointing.yml b/data/test_config/config_ted_policy_no_model_checkpointing.yml new file mode 100644 index 0000000..608b053 --- /dev/null +++ b/data/test_config/config_ted_policy_no_model_checkpointing.yml @@ -0,0 +1,6 @@ +language: "en" +assistant_id: placeholder_default +policies: + - name: TEDPolicy + epochs: 2 + checkpoint_model: False diff --git a/data/test_config/config_train_server_json.yml b/data/test_config/config_train_server_json.yml new file mode 100644 index 0000000..2579862 --- /dev/null +++ b/data/test_config/config_train_server_json.yml @@ -0,0 +1,23 @@ +language: "en" + +pipeline: + - name: SpacyNLP + - name: SpacyTokenizer + - name: SpacyFeaturizer + - name: RegexFeaturizer + - name: CRFEntityExtractor + - name: EntitySynonymMapper + - name: SklearnIntentClassifier + +# data contains the same json, as described in the training data section +data: { + "rasa_nlu_data": { + "common_examples": [ + { + "text": "hey", + "intent": "greet", + "entities": [] + } + ] + } +} diff --git a/data/test_config/config_train_server_md.yml b/data/test_config/config_train_server_md.yml new file mode 100644 index 0000000..d440f4e --- /dev/null +++ b/data/test_config/config_train_server_md.yml @@ -0,0 +1,20 @@ +language: "en" + +pipeline: + - name: SpacyNLP + - name: SpacyTokenizer + - name: SpacyFeaturizer + - name: RegexFeaturizer + - name: CRFEntityExtractor + - name: EntitySynonymMapper + - name: SklearnIntentClassifier + +# data contains the same md, as described in the training data section +data: | + ## intent:affirm + - yes + - yep + + ## intent:goodbye + - bye + - goodbye diff --git a/data/test_config/config_unique_assistant_id.yml b/data/test_config/config_unique_assistant_id.yml new file mode 100644 index 0000000..da42cef --- /dev/null +++ b/data/test_config/config_unique_assistant_id.yml @@ -0,0 +1,40 @@ +recipe: default.v1 +assistant_id: my_unique_assistant_name +language: en +pipeline: [] +# # No configuration for the NLU pipeline was provided. The following default pipeline was used to train your model. +# # If you'd like to customize it, uncomment and adjust the pipeline. +# # See https://rasa.com/docs/rasa/tuning-your-model for more information. +# - name: WhitespaceTokenizer +# - name: RegexFeaturizer +# - name: LexicalSyntacticFeaturizer +# - name: CountVectorsFeaturizer +# - name: CountVectorsFeaturizer +# analyzer: char_wb +# min_ngram: 1 +# max_ngram: 4 +# - name: DIETClassifier +# epochs: 100 +# constrain_similarities: true +# - name: EntitySynonymMapper +# - name: ResponseSelector +# epochs: 100 +# constrain_similarities: true +# - name: FallbackClassifier +# threshold: 0.3 +# ambiguity_threshold: 0.1 + +data: +policies: +# # No configuration for policies was provided. The following default policies were used to train your model. +# # If you'd like to customize them, uncomment and adjust the policies. +# # See https://rasa.com/docs/rasa/policies for more information. +# - name: MemoizationPolicy +# - name: RulePolicy +# - name: UnexpecTEDIntentPolicy +# max_history: 5 +# epochs: 100 +# - name: TEDPolicy +# max_history: 5 +# epochs: 100 +# constrain_similarities: true diff --git a/data/test_config/config_with_comment_between_suggestions.yml b/data/test_config/config_with_comment_between_suggestions.yml new file mode 100644 index 0000000..fdbde79 --- /dev/null +++ b/data/test_config/config_with_comment_between_suggestions.yml @@ -0,0 +1,36 @@ +recipe: default.v1 +assistant_id: placeholder_default +language: en +pipeline: +# # No configuration for the NLU pipeline was provided. The following default pipeline was used to train your model. +# # If you'd like to customize it, uncomment and adjust the pipeline. +# # See https://rasa.com/docs/rasa/tuning-your-model for more information. +# - name: WhitespaceTokenizer +# - name: RegexFeaturizer +# - name: LexicalSyntacticFeaturizer +# - name: CountVectorsFeaturizer +# - name: CountVectorsFeaturizer +# analyzer: char_wb +# min_ngram: 1 +# max_ngram: 4 +# - name: DIETClassifier +# epochs: 100 +# - name: EntitySynonymMapper +# - name: ResponseSelector +# epochs: 100 +# - name: FallbackClassifier +# threshold: 0.3 +# ambiguity_threshold: 0.1 +# some other comment in here +policies: +# # No configuration for policies was provided. The following default policies were used to train your model. +# # If you'd like to customize them, uncomment and adjust the policies. +# # See https://rasa.com/docs/rasa/policies for more information. +# - name: MemoizationPolicy +# - name: UnexpecTEDIntentPolicy +# max_history: 5 +# epochs: 100 +# - name: TEDPolicy +# max_history: 5 +# epochs: 100 +# - name: RulePolicy diff --git a/data/test_config/config_with_comments.yml b/data/test_config/config_with_comments.yml new file mode 100644 index 0000000..4b5d7cd --- /dev/null +++ b/data/test_config/config_with_comments.yml @@ -0,0 +1,27 @@ +# here is some comment +recipe: default.v1 +assistant_id: placeholder_default +language: en + +# another comment +pipeline: + - name: ConveRTTokenizer + - name: ConveRTFeaturizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 100 + - name: EntitySynonymMapper + - name: ResponseSelector + epochs: 100 +# all the comments +# so many +policies: # even here +# this one will disappear + +# comments everywhere diff --git a/data/test_config/config_with_comments_after_dumping.yml b/data/test_config/config_with_comments_after_dumping.yml new file mode 100644 index 0000000..969dd14 --- /dev/null +++ b/data/test_config/config_with_comments_after_dumping.yml @@ -0,0 +1,38 @@ +# here is some comment +recipe: default.v1 +assistant_id: placeholder_default +language: en + +# another comment +pipeline: + - name: ConveRTTokenizer + - name: ConveRTFeaturizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 100 + - name: EntitySynonymMapper + - name: ResponseSelector + epochs: 100 +# all the comments +# so many +policies: # even here +# # No configuration for policies was provided. The following default policies were used to train your model. +# # If you'd like to customize them, uncomment and adjust the policies. +# # See https://rasa.com/docs/rasa/policies for more information. +# - name: MemoizationPolicy +# - name: RulePolicy +# - name: UnexpecTEDIntentPolicy +# max_history: 5 +# epochs: 100 +# - name: TEDPolicy +# max_history: 5 +# epochs: 100 +# constrain_similarities: true + +# comments everywhere diff --git a/data/test_config/embedding_random_seed.yaml b/data/test_config/embedding_random_seed.yaml new file mode 100644 index 0000000..53ee829 --- /dev/null +++ b/data/test_config/embedding_random_seed.yaml @@ -0,0 +1,4 @@ +policies: +- name: TEDPolicy + random_seed: 42 + epochs: 2 diff --git a/data/test_config/example_config.yaml b/data/test_config/example_config.yaml new file mode 100644 index 0000000..5cf15f9 --- /dev/null +++ b/data/test_config/example_config.yaml @@ -0,0 +1,5 @@ +policies: + - name: MemoizationPolicy + max_history: 5 + - name: tests.core.test_config.ExamplePolicy + example_arg: 10 diff --git a/data/test_config/graph_config.yml b/data/test_config/graph_config.yml new file mode 100644 index 0000000..e24aff3 --- /dev/null +++ b/data/test_config/graph_config.yml @@ -0,0 +1,558 @@ +# The config recipe. +# https://rasa.com/docs/rasa/model-configuration/ +recipe: graph.v1 + +assistant_id: placeholder_default + +language: en + +nlu_target: run_RegexMessageHandler + +core_target: select_prediction + +train_schema: + nodes: + schema_validator: + needs: + importer: __importer__ + uses: rasa.graph_components.validators.default_recipe_validator.DefaultV1RecipeValidator + constructor_name: create + fn: validate + config: {} + eager: false + is_target: false + is_input: true + resource: null + finetuning_validator: + needs: + importer: schema_validator + uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator + constructor_name: create + fn: validate + config: + validate_core: true + validate_nlu: true + eager: false + is_target: false + is_input: true + resource: null + nlu_training_data_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.nlu_training_data_provider.NLUTrainingDataProvider + constructor_name: create + fn: provide + config: + language: en + persist: false + eager: false + is_target: false + is_input: true + resource: null + run_WhitespaceTokenizer0: + needs: + training_data: nlu_training_data_provider + uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_RegexFeaturizer1: + needs: + training_data: run_WhitespaceTokenizer0 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_RegexFeaturizer1: + needs: + training_data: run_WhitespaceTokenizer0 + resource: train_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_LexicalSyntacticFeaturizer2: + needs: + training_data: run_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_LexicalSyntacticFeaturizer2: + needs: + training_data: run_RegexFeaturizer1 + resource: train_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_CountVectorsFeaturizer3: + needs: + training_data: run_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + run_CountVectorsFeaturizer3: + needs: + training_data: run_LexicalSyntacticFeaturizer2 + resource: train_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process_training_data + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_CountVectorsFeaturizer4: + needs: + training_data: run_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: create + fn: train + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: false + is_target: true + is_input: false + resource: null + run_CountVectorsFeaturizer4: + needs: + training_data: run_CountVectorsFeaturizer3 + resource: train_CountVectorsFeaturizer4 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process_training_data + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: false + is_target: false + is_input: false + resource: null + train_DIETClassifier5: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.classifiers.diet_classifier.DIETClassifier + constructor_name: create + fn: train + config: + epochs: 100 + constrain_similarities: true + eager: false + is_target: true + is_input: false + resource: null + train_EntitySynonymMapper6: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_ResponseSelector7: + needs: + training_data: run_CountVectorsFeaturizer4 + uses: rasa.nlu.selectors.response_selector.ResponseSelector + constructor_name: create + fn: train + config: + epochs: 100 + constrain_similarities: true + eager: false + is_target: true + is_input: false + resource: null + domain_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null + domain_for_core_training_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.domain_for_core_training_provider.DomainForCoreTrainingProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + forms_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.forms_provider.FormsProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + responses_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.responses_provider.ResponsesProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: true + resource: null + story_graph_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.story_graph_provider.StoryGraphProvider + constructor_name: create + fn: provide + config: + exclusion_percentage: null + eager: false + is_target: false + is_input: true + resource: null + training_tracker_provider: + needs: + story_graph: story_graph_provider + domain: domain_for_core_training_provider + uses: rasa.graph_components.providers.training_tracker_provider.TrainingTrackerProvider + constructor_name: create + fn: provide + config: {} + eager: false + is_target: false + is_input: false + resource: null + train_MemoizationPolicy0: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_RulePolicy1: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.rule_policy.RulePolicy + constructor_name: create + fn: train + config: {} + eager: false + is_target: true + is_input: false + resource: null + train_UnexpecTEDIntentPolicy2: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicy + constructor_name: create + fn: train + config: + max_history: 5 + epochs: 100 + eager: false + is_target: true + is_input: false + resource: null + train_TEDPolicy3: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.ted_policy.TEDPolicy + constructor_name: create + fn: train + config: + max_history: 5 + epochs: 100 + constrain_similarities: true + eager: false + is_target: true + is_input: false + resource: null + +predict_schema: + nodes: + nlu_message_converter: + needs: + messages: __message__ + uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter + constructor_name: load + fn: convert_user_message + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_WhitespaceTokenizer0: + needs: + messages: nlu_message_converter + uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null + run_RegexFeaturizer1: + needs: + messages: run_WhitespaceTokenizer0 + uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RegexFeaturizer1 + run_LexicalSyntacticFeaturizer2: + needs: + messages: run_RegexFeaturizer1 + uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_LexicalSyntacticFeaturizer2 + run_CountVectorsFeaturizer3: + needs: + messages: run_LexicalSyntacticFeaturizer2 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_CountVectorsFeaturizer3 + run_CountVectorsFeaturizer4: + needs: + messages: run_CountVectorsFeaturizer3 + uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizer + constructor_name: load + fn: process + config: + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + eager: true + is_target: false + is_input: false + resource: + name: train_CountVectorsFeaturizer4 + run_DIETClassifier5: + needs: + messages: run_CountVectorsFeaturizer4 + uses: rasa.nlu.classifiers.diet_classifier.DIETClassifier + constructor_name: load + fn: process + config: + epochs: 100 + constrain_similarities: true + eager: true + is_target: false + is_input: false + resource: + name: train_DIETClassifier5 + run_EntitySynonymMapper6: + needs: + messages: run_DIETClassifier5 + uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapper + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_EntitySynonymMapper6 + run_ResponseSelector7: + needs: + messages: run_EntitySynonymMapper6 + uses: rasa.nlu.selectors.response_selector.ResponseSelector + constructor_name: load + fn: process + config: + epochs: 100 + constrain_similarities: true + eager: true + is_target: false + is_input: false + resource: + name: train_ResponseSelector7 + run_FallbackClassifier8: + needs: + messages: run_ResponseSelector7 + uses: rasa.nlu.classifiers.fallback_classifier.FallbackClassifier + constructor_name: load + fn: process + config: + threshold: 0.3 + ambiguity_threshold: 0.1 + eager: true + is_target: false + is_input: false + resource: null + run_RegexMessageHandler: + needs: + messages: run_FallbackClassifier8 + domain: domain_provider + uses: rasa.nlu.classifiers.regex_message_handler.RegexMessageHandler + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null + domain_provider: + needs: {} + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: domain_provider + run_MemoizationPolicy0: + needs: + domain: domain_provider + tracker: __tracker__ + rule_only_data: rule_only_data_provider + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: load + fn: predict_action_probabilities + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_MemoizationPolicy0 + run_RulePolicy1: + needs: + domain: domain_provider + tracker: __tracker__ + rule_only_data: rule_only_data_provider + uses: rasa.core.policies.rule_policy.RulePolicy + constructor_name: load + fn: predict_action_probabilities + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RulePolicy1 + run_UnexpecTEDIntentPolicy2: + needs: + domain: domain_provider + tracker: __tracker__ + rule_only_data: rule_only_data_provider + uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicy + constructor_name: load + fn: predict_action_probabilities + config: + max_history: 5 + epochs: 100 + eager: true + is_target: false + is_input: false + resource: + name: train_UnexpecTEDIntentPolicy2 + run_TEDPolicy3: + needs: + domain: domain_provider + tracker: __tracker__ + rule_only_data: rule_only_data_provider + uses: rasa.core.policies.ted_policy.TEDPolicy + constructor_name: load + fn: predict_action_probabilities + config: + max_history: 5 + epochs: 100 + constrain_similarities: true + eager: true + is_target: false + is_input: false + resource: + name: train_TEDPolicy3 + rule_only_data_provider: + needs: {} + uses: rasa.graph_components.providers.rule_only_provider.RuleOnlyDataProvider + constructor_name: load + fn: provide + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RulePolicy1 + select_prediction: + needs: + policy0: run_MemoizationPolicy0 + policy1: run_RulePolicy1 + policy2: run_UnexpecTEDIntentPolicy2 + policy3: run_TEDPolicy3 + domain: domain_provider + tracker: __tracker__ + uses: rasa.core.policies.ensemble.DefaultPolicyPredictionEnsemble + constructor_name: load + fn: combine_predictions_from_kwargs + config: {} + eager: true + is_target: false + is_input: false + resource: null diff --git a/data/test_config/graph_config_short.yml b/data/test_config/graph_config_short.yml new file mode 100644 index 0000000..179dc71 --- /dev/null +++ b/data/test_config/graph_config_short.yml @@ -0,0 +1,173 @@ +# The config recipe. +# https://rasa.com/docs/rasa/model-configuration/ +recipe: graph.v1 + +language: en + +core_target: custom_core_target + +nlu_target: custom_nlu_target + +train_schema: + nodes: + # We skip schema_validator node (we only have this for DefaultV1Recipe + # since we don't do validation for the GraphV1Recipe) + finetuning_validator: + needs: + importer: __importer__ + uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator + constructor_name: create + fn: validate + config: + validate_core: true + validate_nlu: true + eager: false + is_target: false + is_input: true + resource: null + nlu_training_data_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.nlu_training_data_provider.NLUTrainingDataProvider + constructor_name: create + fn: provide + config: + language: en + persist: false + eager: false + is_target: false + is_input: true + resource: null + domain_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: create + fn: provide_train + config: { } + eager: false + is_target: true + is_input: true + resource: null + domain_for_core_training_provider: + needs: + domain: domain_provider + uses: rasa.graph_components.providers.domain_for_core_training_provider.DomainForCoreTrainingProvider + constructor_name: create + fn: provide + config: { } + eager: false + is_target: false + is_input: true + resource: null + story_graph_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.story_graph_provider.StoryGraphProvider + constructor_name: create + fn: provide + config: + exclusion_percentage: null + eager: false + is_target: false + is_input: true + resource: null + training_tracker_provider: + needs: + story_graph: story_graph_provider + domain: domain_for_core_training_provider + uses: rasa.graph_components.providers.training_tracker_provider.TrainingTrackerProvider + constructor_name: create + fn: provide + config: { } + eager: false + is_target: false + is_input: false + resource: null + train_MemoizationPolicy0: + needs: + training_trackers: training_tracker_provider + domain: domain_for_core_training_provider + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: create + fn: train + config: { } + eager: false + is_target: true + is_input: false + resource: null + +predict_schema: + nodes: + nlu_message_converter: + needs: + messages: __message__ + uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter + constructor_name: load + fn: convert_user_message + config: {} + eager: true + is_target: false + is_input: false + resource: null + custom_nlu_target: + needs: + messages: nlu_message_converter + domain: domain_provider + uses: rasa.nlu.classifiers.regex_message_handler.RegexMessageHandler + constructor_name: load + fn: process + config: {} + eager: true + is_target: false + is_input: false + resource: null + domain_provider: + needs: {} + uses: rasa.graph_components.providers.domain_provider.DomainProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: domain_provider + run_MemoizationPolicy0: + needs: + domain: domain_provider + tracker: __tracker__ + rule_only_data: rule_only_data_provider + uses: rasa.core.policies.memoization.MemoizationPolicy + constructor_name: load + fn: predict_action_probabilities + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_MemoizationPolicy0 + rule_only_data_provider: + needs: {} + uses: rasa.graph_components.providers.rule_only_provider.RuleOnlyDataProvider + constructor_name: load + fn: provide + config: {} + eager: true + is_target: false + is_input: false + resource: + name: train_RulePolicy1 + custom_core_target: + needs: + policy0: run_MemoizationPolicy0 + domain: domain_provider + tracker: __tracker__ + uses: rasa.core.policies.ensemble.DefaultPolicyPredictionEnsemble + constructor_name: load + fn: combine_predictions_from_kwargs + config: {} + eager: true + is_target: false + is_input: false + resource: null diff --git a/data/test_config/keyword_classifier_config.yml b/data/test_config/keyword_classifier_config.yml new file mode 100644 index 0000000..c6a6eca --- /dev/null +++ b/data/test_config/keyword_classifier_config.yml @@ -0,0 +1,2 @@ +pipeline: + - name: "KeywordIntentClassifier" diff --git a/data/test_config/max_hist_config.yml b/data/test_config/max_hist_config.yml new file mode 100644 index 0000000..a627b6d --- /dev/null +++ b/data/test_config/max_hist_config.yml @@ -0,0 +1,7 @@ +assistant_id: placeholder_default +policies: + - name: MemoizationPolicy + max_history: 5 + - name: RulePolicy + - name: TEDPolicy + max_history: 5 diff --git a/data/test_config/no_max_hist_config.yml b/data/test_config/no_max_hist_config.yml new file mode 100644 index 0000000..faa6057 --- /dev/null +++ b/data/test_config/no_max_hist_config.yml @@ -0,0 +1,3 @@ +policies: + - name: MemoizationPolicy + - name: RulePolicy diff --git a/data/test_config/stack_config.yml b/data/test_config/stack_config.yml new file mode 100644 index 0000000..600f726 --- /dev/null +++ b/data/test_config/stack_config.yml @@ -0,0 +1,15 @@ +# Configuration for Rasa NLU. +# https://rasa.com/docs/rasa/components +recipe: default.v1 +# The assistant project unique identifier +assistant_id: unique_stack_assistant_test_name +language: en +pipeline: + - name: KeywordIntentClassifier + - name: RegexEntityExtractor + +# Configuration for Rasa Core. +# https://rasa.com/docs/rasa/policies +policies: + - name: MemoizationPolicy + - name: RulePolicy diff --git a/data/test_config/test_moodbot_config_no_assistant_id.yml b/data/test_config/test_moodbot_config_no_assistant_id.yml new file mode 100644 index 0000000..82d98be --- /dev/null +++ b/data/test_config/test_moodbot_config_no_assistant_id.yml @@ -0,0 +1,21 @@ +recipe: default.v1 +language: en + +pipeline: + - name: WhitespaceTokenizer + intent_tokenization_flag: True + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + - name: LogisticRegressionClassifier + +policies: +# - name: TEDPolicy +# max_history: 5 +# epochs: 1 + - name: MemoizationPolicy + - name: RulePolicy diff --git a/data/test_custom_action_triggers_action_extract_slots/config.yml b/data/test_custom_action_triggers_action_extract_slots/config.yml new file mode 100644 index 0000000..3e1a1f5 --- /dev/null +++ b/data/test_custom_action_triggers_action_extract_slots/config.yml @@ -0,0 +1,27 @@ +version: "3.1" + +assistant_id: placeholder_default + +pipeline: + - name: WhitespaceTokenizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 15 + constrain_similarities: true + run_eagerly: true + - name: EntitySynonymMapper + +policies: + - name: AugmentedMemoizationPolicy + max_history: 6 + - name: RulePolicy + core_fallback_threshold: 0.4 + core_fallback_action_name: "action_fallback_universal_search" + enable_fallback_prediction: True + check_for_contradictions: True diff --git a/data/test_custom_action_triggers_action_extract_slots/domain.yml b/data/test_custom_action_triggers_action_extract_slots/domain.yml new file mode 100644 index 0000000..08e8e3f --- /dev/null +++ b/data/test_custom_action_triggers_action_extract_slots/domain.yml @@ -0,0 +1,24 @@ +version: "3.1" + +intents: + - activate_flow + - mood_great + +entities: + - mood + +slots: + mood_slot: + type: text + influence_conversation: true + mappings: + - type: from_entity + entity: mood + +responses: + utter_happy: + - text: Great, carry on! + +actions: + - action_force_next_utter + - action_fallback_universal_search diff --git a/data/test_custom_action_triggers_action_extract_slots/nlu.yml b/data/test_custom_action_triggers_action_extract_slots/nlu.yml new file mode 100644 index 0000000..531ed0b --- /dev/null +++ b/data/test_custom_action_triggers_action_extract_slots/nlu.yml @@ -0,0 +1,12 @@ +version: "3.1" + +nlu: +- intent: activate_flow + examples: | + - Trigger custom action. + - Activate custom action. + +- intent: mood_great + examples: | + - I am so [happy](mood). + - Feeling [sad](mood). diff --git a/data/test_custom_action_triggers_action_extract_slots/stories.yml b/data/test_custom_action_triggers_action_extract_slots/stories.yml new file mode 100644 index 0000000..732ee3d --- /dev/null +++ b/data/test_custom_action_triggers_action_extract_slots/stories.yml @@ -0,0 +1,15 @@ +version: "3.1" + +stories: + +- story: happy path + steps: + - intent: activate_flow + - action: action_force_next_utter + +- story: test next action + steps: + - intent: mood_great + entities: + - mood: happy + - action: utter_happy diff --git a/data/test_domains/conditional_response_variations.yml b/data/test_domains/conditional_response_variations.yml new file mode 100644 index 0000000..262e5fa --- /dev/null +++ b/data/test_domains/conditional_response_variations.yml @@ -0,0 +1,54 @@ +version: "3.1" + +intents: + - greet + - withdraw + - check_balance + - goodbye + +slots: + account_type: + type: categorical + values: + - primary + - secondary + mappings: + - type: custom + can_withdraw: + type: bool + mappings: + - type: custom + +responses: + utter_withdraw: + - text: "You are not allowed to withdraw any amounts. Please check permission." + condition: + - type: slot + name: can_withdraw + value: False + - text: "Withdrawal has been approved." + condition: + - type: slot + name: can_withdraw + value: True + - type: slot + name: account_type + value: primary + - text: "Withdrawal was sent for approval to primary account holder." + condition: + - type: slot + name: account_type + value: secondary + utter_check_balance: + - text: "As a primary account holder, you can now set-up your access on mobile app too." + condition: + - type: slot + name: account_type + value: primary + channel: os + - text: "Welcome to your app account overview." + condition: + - type: slot + name: account_type + value: primary + channel: app diff --git a/data/test_domains/custom_slot_domain.yml b/data/test_domains/custom_slot_domain.yml new file mode 100644 index 0000000..f9bb1c4 --- /dev/null +++ b/data/test_domains/custom_slot_domain.yml @@ -0,0 +1,18 @@ +intents: + - inform + - greet + +entities: + - limit + +slots: + limit: + type: data.test_classes.custom_slots.LimitSlot + limit: 1000 + mappings: + - type: from_entity + entity: limit + +actions: +- action_greet + diff --git a/data/test_domains/default.yml b/data/test_domains/default.yml new file mode 100644 index 0000000..298f056 --- /dev/null +++ b/data/test_domains/default.yml @@ -0,0 +1,29 @@ +intents: + - greet + - default + - goodbye + +slots: + cuisine: + type: text + mappings: + - type: from_entity + entity: cuisine + location: + type: text + mappings: + - type: from_entity + entity: location + +entities: + - name + - cuisine + - location + +responses: + utter_greet: + - text: hey there! + utter_goodbye: + - text: goodbye :( + utter_default: + - text: default message diff --git a/data/test_domains/default_retrieval_intents.yml b/data/test_domains/default_retrieval_intents.yml new file mode 100644 index 0000000..2e15a39 --- /dev/null +++ b/data/test_domains/default_retrieval_intents.yml @@ -0,0 +1,24 @@ +intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + - chitchat + +responses: + utter_greet: + - text: Hey! How are you? + utter_cheer_up: + - text: 'Here is something to cheer you up:' + image: https://i.imgur.com/nGF1K8f.jpg + utter_did_that_help: + - text: Did that help you? + utter_happy: + - text: Great, carry on! + utter_goodbye: + - text: Bye + utter_iamabot: + - text: I am a bot, powered by Rasa. diff --git a/data/test_domains/default_unfeaturized_entities.yml b/data/test_domains/default_unfeaturized_entities.yml new file mode 100644 index 0000000..2c9baaf --- /dev/null +++ b/data/test_domains/default_unfeaturized_entities.yml @@ -0,0 +1,25 @@ +intents: + - greet: {use_entities: [name, used_entity]} + - default: {ignore_entities : [unrelated_recognized_entity]} + - goodbye: {use_entities: null} + - thank: {use_entities: False} + - ask: {use_entities: True} + - why: {use_entities: []} + - pure_intent + +entities: + - name + - unrelated_recognized_entity + - other + - used_entity: + influence_conversation: true + - unused_entity: + influence_conversation: false + +responses: + utter_greet: + - text: hey there! + utter_goodbye: + - text: goodbye :( + utter_default: + - text: default message diff --git a/data/test_domains/default_with_mapping.yml b/data/test_domains/default_with_mapping.yml new file mode 100644 index 0000000..efbec36 --- /dev/null +++ b/data/test_domains/default_with_mapping.yml @@ -0,0 +1,31 @@ +intents: + - greet: + triggers: utter_greet + - default: + triggers: utter_default + - goodbye + +slots: + cuisine: + type: text + mappings: + - type: from_entity + entity: cuisine + location: + type: text + mappings: + - type: from_entity + entity: location + +entities: + - name + - cuisine + - location + +responses: + utter_greet: + - text: hey there! + utter_goodbye: + - text: goodbye :( + utter_default: + - text: default message diff --git a/data/test_domains/default_with_slots.yml b/data/test_domains/default_with_slots.yml new file mode 100644 index 0000000..0c8beaf --- /dev/null +++ b/data/test_domains/default_with_slots.yml @@ -0,0 +1,40 @@ +# all hashtags are comments :) +intents: + - greet + - default + - goodbye + - affirm + - thank_you + - change_bank_details + - simple + - hello + - why + - next_intent + +entities: + - name + +slots: + name: + type: text + mappings: + - type: from_entity + entity: name + +responses: + utter_greet: + - text: "hey there {name}!" # {name} will be filled by slot (same name) or by custom action + utter_channel: + - text: "this is a default channel" + - text: "you're talking to me on slack!" # if you define channel-specific utterances, the bot will pick + channel: "slack" # from those when talking on that specific channel + utter_goodbye: + - text: "goodbye 😢" # multiple responses - bot will randomly pick one of them + - text: "bye bye 😢" + utter_default: # utterance sent by action_default_fallback + - text: "sorry, I didn't get that, can you rephrase it?" + +forms: + some_form: + required_slots: + - name diff --git a/data/test_domains/domain_with_categorical_slot.yml b/data/test_domains/domain_with_categorical_slot.yml new file mode 100644 index 0000000..5880813 --- /dev/null +++ b/data/test_domains/domain_with_categorical_slot.yml @@ -0,0 +1,12 @@ +slots: + category_slot: + type: categorical + values: + - value_one + - value_two + mappings: + - type: custom + +responses: + utter_greet: + - text: "hey there!" diff --git a/data/test_domains/duplicate_actions.yml b/data/test_domains/duplicate_actions.yml new file mode 100644 index 0000000..f1aa55b --- /dev/null +++ b/data/test_domains/duplicate_actions.yml @@ -0,0 +1,35 @@ +intents: + - greet + - default + - goodbye + +slots: + cuisine: + type: text + mappings: + - type: from_entity + entity: cuisine + location: + type: text + mappings: + - type: from_entity + entity: location + +entities: + - name + - cuisine + - location + +responses: + utter_greet: + - text: hey there! + utter_goodbye: + - text: goodbye :( + utter_default: + - text: default message + +actions: + - utter_default + - utter_greet + - utter_goodbye + - utter_default diff --git a/data/test_domains/duplicate_entities.yml b/data/test_domains/duplicate_entities.yml new file mode 100644 index 0000000..c3fcf1f --- /dev/null +++ b/data/test_domains/duplicate_entities.yml @@ -0,0 +1,31 @@ +intents: + - greet + - default + - goodbye + +slots: + cuisine: + type: text + mappings: + - type: from_entity + entity: cuisine + location: + type: text + mappings: + - type: from_entity + entity: location + +entities: + - name + - name + - cuisine + - location + +responses: + utter_greet: + - text: hey there! + utter_goodbye: + - text: goodbye :( + utter_default: + - text: default message + diff --git a/data/test_domains/duplicate_intents.yml b/data/test_domains/duplicate_intents.yml new file mode 100644 index 0000000..84e7330 --- /dev/null +++ b/data/test_domains/duplicate_intents.yml @@ -0,0 +1,35 @@ +intents: + - greet + - default + - goodbye + - default + - goodbye + +slots: + cuisine: + type: text + mappings: + - type: from_entity + entity: cuisine + location: + type: text + mappings: + - type: from_entity + entity: cuisine + +entities: + - name + - cuisine + - location + +responses: + utter_greet: + - text: hey there! + utter_goodbye: + - text: goodbye :( + utter_default: + - text: default message + +session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true diff --git a/data/test_domains/duplicate_responses.yml b/data/test_domains/duplicate_responses.yml new file mode 100644 index 0000000..3d0fbe1 --- /dev/null +++ b/data/test_domains/duplicate_responses.yml @@ -0,0 +1,23 @@ +intents: + - greet + - default + - goodbye + +slots: + cuisine: + type: text + location: + type: text + +entities: + - name + +responses: + utter_greet: + - text: hey there! + utter_goodbye: + - text: goodbye :( + utter_default: + - text: default message + utter_greet: + - text: hey there! diff --git a/data/test_domains/empty_response_format.yml b/data/test_domains/empty_response_format.yml new file mode 100644 index 0000000..f843a23 --- /dev/null +++ b/data/test_domains/empty_response_format.yml @@ -0,0 +1,25 @@ +intents: + - greet + - default + - goodbye + +slots: + cuisine: + type: text + location: + type: text + +entities: + - name + +responses: + utter_greet: + utter_goodbye: + - text: goodbye :( + utter_default: + - text: default message + +actions: + - utter_default + - utter_greet + - utter_goodbye diff --git a/data/test_domains/form.yml b/data/test_domains/form.yml new file mode 100644 index 0000000..999beaa --- /dev/null +++ b/data/test_domains/form.yml @@ -0,0 +1,31 @@ +intents: + - greet + - default + - goodbye + - start_form + - stop + - affirm + - deny + - inform + +slots: + cuisine: + type: text + location: + type: text + +entities: + - name + +responses: + utter_greet: + - text: hey there! + utter_goodbye: + - text: goodbye :( + utter_default: + - text: default message + utter_ask_continue: + - text: should I continue? + +forms: + some_form: {} diff --git a/data/test_domains/initial_slot_values_greet_and_goodbye.yml b/data/test_domains/initial_slot_values_greet_and_goodbye.yml new file mode 100644 index 0000000..0944276 --- /dev/null +++ b/data/test_domains/initial_slot_values_greet_and_goodbye.yml @@ -0,0 +1,25 @@ +version: "3.1" + +intents: + - greet + - goodbye + +slots: + has_said_hi: + type: bool + initial_value: false + mappings: + - type: from_intent + value: true + intent: greet + +responses: + utter_greet: + - text: "Hey! How are you?" + + utter_goodbye: + - text: "Bye" + +session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true diff --git a/data/test_domains/invalid_format.yml b/data/test_domains/invalid_format.yml new file mode 100644 index 0000000..3f84616 --- /dev/null +++ b/data/test_domains/invalid_format.yml @@ -0,0 +1,26 @@ +intents: + - greet + - default + - goodbye + +slots: + cuisine: + type: text + location: + type: text + +entities + - name + +responses: + utter_greet: + - text: hey there! + utter_goodbye: + - text: goodbye :( + utter_default: + - text: default message + +actions: +utter_default + - utter_greet + - utter_goodbye diff --git a/data/test_domains/minimal_domain_validate_files_with_active_loop_null.yml b/data/test_domains/minimal_domain_validate_files_with_active_loop_null.yml new file mode 100644 index 0000000..b950fed --- /dev/null +++ b/data/test_domains/minimal_domain_validate_files_with_active_loop_null.yml @@ -0,0 +1,35 @@ +version: "3.1" + +intents: + - request_restaurant + +entities: +- number +- cuisine + +slots: + cuisine: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: cuisine + people: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: number + conditions: + - active_loop: restaurant_form + requested_slot: people + +actions: + - action_search_restaurants + + +forms: + restaurant_form: + required_slots: + - cuisine + - people diff --git a/data/test_domains/missing_chitchat_response.yml b/data/test_domains/missing_chitchat_response.yml new file mode 100644 index 0000000..ce9e744 --- /dev/null +++ b/data/test_domains/missing_chitchat_response.yml @@ -0,0 +1,5 @@ +version: '3.0' +intents: ['does_not_matter'] +responses: + greet: + - text: hey there! diff --git a/data/test_domains/missing_text_for_templates.yml b/data/test_domains/missing_text_for_templates.yml new file mode 100644 index 0000000..0d47817 --- /dev/null +++ b/data/test_domains/missing_text_for_templates.yml @@ -0,0 +1,12 @@ +intents: + - greet + - default + - goodbye + +responses: + utter_greet: + - hey there! + utter_goodbye: + - goodbye :( + utter_default: + - default message diff --git a/data/test_domains/mixed_retrieval_intents.yml b/data/test_domains/mixed_retrieval_intents.yml new file mode 100644 index 0000000..50e624e --- /dev/null +++ b/data/test_domains/mixed_retrieval_intents.yml @@ -0,0 +1,37 @@ +intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + - chitchat + +responses: + utter_greet: + - text: Hey! How are you? + utter_cheer_up: + - text: 'Here is something to cheer you up:' + image: https://i.imgur.com/nGF1K8f.jpg + utter_did_that_help: + - text: Did that help you? + utter_happy: + - text: Great, carry on! + utter_goodbye: + - text: Bye + utter_iamabot: + - text: I am a bot, powered by Rasa. + utter_chitchat/ask_weather: + - text: It does look sunny today + utter_chitchat/ask_name: + - text: Would it be cool if you didn't know that? :) + +actions: + - utter_chitchat + - utter_greet + - utter_cheer_up + - utter_did_that_help + - utter_happy + - utter_goodbye + - utter_iamabot diff --git a/data/test_domains/people_form.yml b/data/test_domains/people_form.yml new file mode 100644 index 0000000..0e273b1 --- /dev/null +++ b/data/test_domains/people_form.yml @@ -0,0 +1,18 @@ +# all hashtags are comments :) +intents: + - inform + - enter_name + +slots: + person_name: + type: text + requested_slot: + type: text + influence_conversation: false + +responses: + utter_ask_person_name: + - text: "what's the name of the person you're looking for?" + +actions: + - action_search_people diff --git a/data/test_domains/query_form.yml b/data/test_domains/query_form.yml new file mode 100644 index 0000000..601e5cc --- /dev/null +++ b/data/test_domains/query_form.yml @@ -0,0 +1,21 @@ +# all hashtags are comments :) +intents: + - inform + +slots: + username: + type: text + query: + type: text + requested_slot: + type: text + influence_conversation: false + +responses: + utter_ask_username: + - text: "what is your name?" + utter_ask_query: + - text: "hello {username}, what can I help you with?" + +actions: + - action_perform_query diff --git a/data/test_domains/response_selector_responses_in_domain.yml b/data/test_domains/response_selector_responses_in_domain.yml new file mode 100644 index 0000000..18b82f8 --- /dev/null +++ b/data/test_domains/response_selector_responses_in_domain.yml @@ -0,0 +1,35 @@ +# This test domain is the same as `default_retrieval_intents.yml`, but the responses +# for the retrieval intents live in the domain file here + +intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + - chitchat + +responses: + utter_greet: + - text: Hey! How are you? + utter_cheer_up: + - text: 'Here is something to cheer you up:' + image: https://i.imgur.com/nGF1K8f.jpg + utter_did_that_help: + - text: Did that help you? + utter_happy: + - text: Great, carry on! + utter_goodbye: + - text: Bye + utter_iamabot: + - text: I am a bot, powered by Rasa. + utter_chitchat/ask_name: + - image: "https://i.imgur.com/zTvA58i.jpeg" + text: hello, my name is retrieval bot. + - text: Oh yeah, I am called the retrieval bot. + utter_chitchat/ask_weather: + - text: Oh, it does look sunny right now in Berlin. + image: "https://i.imgur.com/vwv7aHN.png" + - text: I am not sure of the whole week but I can see the sun is out today. diff --git a/data/test_domains/restaurant_form.yml b/data/test_domains/restaurant_form.yml new file mode 100644 index 0000000..dfce865 --- /dev/null +++ b/data/test_domains/restaurant_form.yml @@ -0,0 +1,40 @@ +# all hashtags are comments :) +intents: + - inform + - request_restaurant + +entities: +- number +- cuisine + +slots: + cuisine: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: cuisine + people: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: number + conditions: + - active_loop: restaurant_form + requested_slot: people +responses: + utter_ask_people: + - text: "For how many people?" + utter_ask_cuisine: + - text: "What cuisine would you like?" + +actions: + - action_search_restaurants + + +forms: + restaurant_form: + required_slots: + - cuisine + - people diff --git a/data/test_domains/selectors.yml b/data/test_domains/selectors.yml new file mode 100644 index 0000000..d4ef46e --- /dev/null +++ b/data/test_domains/selectors.yml @@ -0,0 +1,2 @@ +intents: + - faq diff --git a/data/test_domains/simple_retrieval_intent.yml b/data/test_domains/simple_retrieval_intent.yml new file mode 100644 index 0000000..f3e31e0 --- /dev/null +++ b/data/test_domains/simple_retrieval_intent.yml @@ -0,0 +1,5 @@ +version: '3.0' +intents: ['does_not_matter'] +responses: + utter_chitchat/hello: + - text: hey there! diff --git a/data/test_domains/test_domain_files_with_no_session_config_and_custom_session_config/config.yml b/data/test_domains/test_domain_files_with_no_session_config_and_custom_session_config/config.yml new file mode 100644 index 0000000..6e91955 --- /dev/null +++ b/data/test_domains/test_domain_files_with_no_session_config_and_custom_session_config/config.yml @@ -0,0 +1,39 @@ +recipe: default.v1 +language: en +pipeline: [] +# # No configuration for the NLU pipeline was provided. The following default pipeline was used to train your model. +# # If you'd like to customize it, uncomment and adjust the pipeline. +# # See https://rasa.com/docs/rasa/tuning-your-model for more information. +# - name: WhitespaceTokenizer +# - name: RegexFeaturizer +# - name: LexicalSyntacticFeaturizer +# - name: CountVectorsFeaturizer +# - name: CountVectorsFeaturizer +# analyzer: char_wb +# min_ngram: 1 +# max_ngram: 4 +# - name: DIETClassifier +# epochs: 100 +# constrain_similarities: true +# - name: EntitySynonymMapper +# - name: ResponseSelector +# epochs: 100 +# constrain_similarities: true +# - name: FallbackClassifier +# threshold: 0.3 +# ambiguity_threshold: 0.1 + +data: +policies: +# # No configuration for policies was provided. The following default policies were used to train your model. +# # If you'd like to customize them, uncomment and adjust the policies. +# # See https://rasa.com/docs/rasa/policies for more information. +# - name: MemoizationPolicy +# - name: RulePolicy +# - name: UnexpecTEDIntentPolicy +# max_history: 5 +# epochs: 100 +# - name: TEDPolicy +# max_history: 5 +# epochs: 100 +# constrain_similarities: true diff --git a/data/test_domains/test_domain_files_with_no_session_config_and_custom_session_config/data/nlu.yml b/data/test_domains/test_domain_files_with_no_session_config_and_custom_session_config/data/nlu.yml new file mode 100644 index 0000000..2f6c3f8 --- /dev/null +++ b/data/test_domains/test_domain_files_with_no_session_config_and_custom_session_config/data/nlu.yml @@ -0,0 +1,91 @@ +version: "3.1" + +nlu: +- intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon + +- intent: goodbye + examples: | + - cu + - good by + - cee you later + - good night + - bye + - goodbye + - have a nice day + - see you around + - bye bye + - see you later + +- intent: affirm + examples: | + - yes + - y + - indeed + - of course + - that sounds good + - correct + +- intent: deny + examples: | + - no + - n + - never + - I don't think so + - don't like that + - no way + - not really + +- intent: mood_great + examples: | + - perfect + - great + - amazing + - feeling like a king + - wonderful + - I am feeling very good + - I am great + - I am amazing + - I am going to save the world + - super stoked + - extremely good + - so so perfect + - so good + - so perfect + +- intent: mood_unhappy + examples: | + - my day was horrible + - I am sad + - I don't feel very well + - I am disappointed + - super sad + - I'm so sad + - sad + - very sad + - unhappy + - not good + - not very good + - extremly sad + - so saad + - so sad + +- intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? diff --git a/data/test_domains/test_domain_files_with_no_session_config_and_custom_session_config/data/responses.yml b/data/test_domains/test_domain_files_with_no_session_config_and_custom_session_config/data/responses.yml new file mode 100644 index 0000000..03929b3 --- /dev/null +++ b/data/test_domains/test_domain_files_with_no_session_config_and_custom_session_config/data/responses.yml @@ -0,0 +1,6 @@ +version: "3.1" + + +responses: + utter_test: + - text: "Hey! How are you?" diff --git a/data/test_domains/test_domain_files_with_no_session_config_and_custom_session_config/data/stories.yml b/data/test_domains/test_domain_files_with_no_session_config_and_custom_session_config/data/stories.yml new file mode 100644 index 0000000..6ff78ee --- /dev/null +++ b/data/test_domains/test_domain_files_with_no_session_config_and_custom_session_config/data/stories.yml @@ -0,0 +1,30 @@ +version: "3.1" + +stories: + +- story: happy path + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy + +- story: sad path 1 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + +- story: sad path 2 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: deny + - action: utter_goodbye diff --git a/data/test_domains/test_domain_files_with_no_session_config_and_custom_session_config/domain.yml b/data/test_domains/test_domain_files_with_no_session_config_and_custom_session_config/domain.yml new file mode 100644 index 0000000..7513806 --- /dev/null +++ b/data/test_domains/test_domain_files_with_no_session_config_and_custom_session_config/domain.yml @@ -0,0 +1,34 @@ +version: "3.1" + +intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + +responses: + utter_greet: + - text: "Hey! How are you?" + + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" + + utter_did_that_help: + - text: "Did that help you?" + + utter_happy: + - text: "Great, carry on!" + + utter_goodbye: + - text: "Bye" + + utter_iamabot: + - text: "I am a bot, powered by Rasa." + +session_config: + session_expiration_time: 0 + carry_over_slots_to_new_session: false diff --git a/data/test_domains/test_domain_from_directory/domain_invalid.yml b/data/test_domains/test_domain_from_directory/domain_invalid.yml new file mode 100644 index 0000000..9cb61ba --- /dev/null +++ b/data/test_domains/test_domain_from_directory/domain_invalid.yml @@ -0,0 +1,4 @@ +# invalid yml file should create warning +# contains duplicate key +version: "3.1" +version: "3.1" diff --git a/data/test_domains/test_domain_from_directory/domain_valid.yml b/data/test_domains/test_domain_from_directory/domain_valid.yml new file mode 100644 index 0000000..4945f18 --- /dev/null +++ b/data/test_domains/test_domain_from_directory/domain_valid.yml @@ -0,0 +1,30 @@ +version: "3.1" +intents: + - greet + - default + - goodbye + +slots: + cuisine: + type: text + mappings: + - type: from_entity + entity: cuisine + location: + type: text + mappings: + - type: from_entity + entity: location + +entities: + - name + - cuisine + - location + +responses: + utter_greet: + - text: hey there! + utter_goodbye: + - text: goodbye :( + utter_default: + - text: default message diff --git a/data/test_domains/test_domain_from_directory_for_entities/game_1.yml b/data/test_domains/test_domain_from_directory_for_entities/game_1.yml new file mode 100644 index 0000000..adbdff9 --- /dev/null +++ b/data/test_domains/test_domain_from_directory_for_entities/game_1.yml @@ -0,0 +1,13 @@ +version: "3.1" + +intents: + - play: + use_entities: + - ball + - chess + - stow_away: + use_entities: true + +entities: + - ball + - chess diff --git a/data/test_domains/test_domain_from_directory_for_entities/game_2.yml b/data/test_domains/test_domain_from_directory_for_entities/game_2.yml new file mode 100644 index 0000000..9692aba --- /dev/null +++ b/data/test_domains/test_domain_from_directory_for_entities/game_2.yml @@ -0,0 +1,17 @@ +version: "3.1" + +intents: + - support_encouraging: + use_entities: + - automatic_cupcakes + - anti_freeze_blankets + - certify: + use_entities: true + - vacationing: + ignore_entities: + - tornadoes + +entities: + - automatic_cupcakes + - anti_freeze_blankets + - tornadoes diff --git a/data/test_domains/test_domain_from_directory_for_entities/game_3.yml b/data/test_domains/test_domain_from_directory_for_entities/game_3.yml new file mode 100644 index 0000000..8b53a00 --- /dev/null +++ b/data/test_domains/test_domain_from_directory_for_entities/game_3.yml @@ -0,0 +1,4 @@ +version: "3.1" + +intents: + - question diff --git a/data/test_domains/test_domain_from_directory_tree/domain_pt1.yml b/data/test_domains/test_domain_from_directory_tree/domain_pt1.yml new file mode 100644 index 0000000..6e5b6a4 --- /dev/null +++ b/data/test_domains/test_domain_from_directory_tree/domain_pt1.yml @@ -0,0 +1,8 @@ +version: "3.1" + +intents: + - utter_root + - utter_root2 + +entities: + - pandemic diff --git a/data/test_domains/test_domain_from_directory_tree/skill_1_domain/domain_pt2.yml b/data/test_domains/test_domain_from_directory_tree/skill_1_domain/domain_pt2.yml new file mode 100644 index 0000000..7aed55a --- /dev/null +++ b/data/test_domains/test_domain_from_directory_tree/skill_1_domain/domain_pt2.yml @@ -0,0 +1,17 @@ +version: "3.1" + +intents: + - utter_skill_1 + +entities: + - ball + - chess + +responses: + utter_greet: + - text: "Hey! How are you?" + + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" + diff --git a/data/test_domains/test_domain_from_directory_tree/skill_2_domain/domain_pt3.yml b/data/test_domains/test_domain_from_directory_tree/skill_2_domain/domain_pt3.yml new file mode 100644 index 0000000..eb7b48b --- /dev/null +++ b/data/test_domains/test_domain_from_directory_tree/skill_2_domain/domain_pt3.yml @@ -0,0 +1,7 @@ +version: "3.1" + +intents: + - utter_skill_2 + +utter_did_that_help: + - text: "Did that help you?" diff --git a/data/test_domains/test_domain_from_directory_tree/skill_2_domain/skill_2_subdirectory/domain_pt4.yml b/data/test_domains/test_domain_from_directory_tree/skill_2_domain/skill_2_subdirectory/domain_pt4.yml new file mode 100644 index 0000000..1e67624 --- /dev/null +++ b/data/test_domains/test_domain_from_directory_tree/skill_2_domain/skill_2_subdirectory/domain_pt4.yml @@ -0,0 +1,18 @@ +version: "3.1" + +intents: + - utter_subskill + - utter_subroot + +entities: + - cluedo + - monopoly + + +responses: + utter_greet: + - text: "Hey! How are you?" + + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" diff --git a/data/test_domains/test_domain_from_multiple_files/drum.yml b/data/test_domains/test_domain_from_multiple_files/drum.yml new file mode 100644 index 0000000..c4d17e9 --- /dev/null +++ b/data/test_domains/test_domain_from_multiple_files/drum.yml @@ -0,0 +1,136 @@ +version: "3.1" + +intents: +- drum_robot: + use_entities: ["pistachio_robot"] +- drum_robot_chocolate: + ignore_entities: + - caramel_robot + - strawberry_robot + - rum_and_raisin_robot + - vanilla_robot + - other_robot +- drum_wallets: + use_entities: + - caramel_robot + ignore_entities: + - chocolate_robot +- drum_soups: + use_entities: True +- drum_clocks: + use_entities: False +- drum_lampshades: + use_entities: False +- view_offers: + use_entities: True +- delay: + use_entities: ["chocolate_robot", "strawberry_robot"] + +entities: + - chocolate_robot + - other_robot + - strawberry_robot + - vanilla_robot + +slots: + drumChocolateWallets: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_bot + drumStrawberryWallets: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_bot + drumOtherWallets: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_bot + drumSnareWallets: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_bot + drumClockCovers: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_bot + drumClockAdapters: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_bot + drumMindspace: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_bot + drumAllLampshades: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_bot + drumSoupChocolate: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_bot + drumSoupStrawberry: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_bot + drumAllSoups: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_bot + drumClocksChocolate: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_bot + drumClocksStrawberry: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_bot + drumAllClocks: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_bot + offers: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_bot + +actions: +- action_utter_drum_menu +- action_increase_15 +- utter_anythingelse_menu +- utter_std_drum_menu +- utter_drumwallets +- utter_drumlampshades +- utter_drumsoups +- utter_drumclocks +- action_utter_robot_menu diff --git a/data/test_domains/test_domain_from_multiple_files/last_month.yml b/data/test_domains/test_domain_from_multiple_files/last_month.yml new file mode 100644 index 0000000..0152709 --- /dev/null +++ b/data/test_domains/test_domain_from_multiple_files/last_month.yml @@ -0,0 +1,32 @@ +version: "3.1" +session_config: + session_expiration_time: 360 + carry_over_slots_to_new_session: True + +intents: +- run_finish +- run_finish_recent +- run_finished + +actions: +- utter_run_finish +- utter_run_finished +- utter_run_finish_recent + +entities: +- pistachio_robot +- caramel_robot: + roles: + - sauce + - chips +- rum_and_raisin_robot: + groups: + - rum + - raisin + + +forms: + robot_form: + required_slots: + - propose_simulation + - display_cure_method diff --git a/data/test_domains/test_domain_from_multiple_files/main_menu.yml b/data/test_domains/test_domain_from_multiple_files/main_menu.yml new file mode 100644 index 0000000..d22c761 --- /dev/null +++ b/data/test_domains/test_domain_from_multiple_files/main_menu.yml @@ -0,0 +1,16 @@ +version: "3.1" + +intents: +- main_menu +- self_selection + +actions: +- action_utter_main_menu + +responses: + utter_greet: + - text: hey there! + utter_goodbye: + - text: goodbye :( + utter_default: + - text: default message diff --git a/data/test_domains/test_domain_from_multiple_files/selection.yml b/data/test_domains/test_domain_from_multiple_files/selection.yml new file mode 100644 index 0000000..7dc452c --- /dev/null +++ b/data/test_domains/test_domain_from_multiple_files/selection.yml @@ -0,0 +1,42 @@ +version: "3.1" + +intents: +- finish_selection +- humble_selection +- selection_troubleshooting +- device_selection_scaffold +- humble_selection_scaffold +- unsure_selection_scaffold +- finish_humble_selection +- finish_selection_line + + +slots: + humbleSelection: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: vanilla_robot + humbleSelectionManagement: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: some_slot + humbleSelectionStatus: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: some_slot + + +actions: +- utter_humble_selection +- utter_selection_issues +- utter_horn_selection_scaffold +- utter_humble_selection_scaffold +- utter_finish_selection +- utter_finish_humble_selection +- utter_finish_selection_line diff --git a/data/test_domains/test_domain_from_multiple_files/small_talk.yml b/data/test_domains/test_domain_from_multiple_files/small_talk.yml new file mode 100644 index 0000000..ef89731 --- /dev/null +++ b/data/test_domains/test_domain_from_multiple_files/small_talk.yml @@ -0,0 +1,25 @@ +version: "3.1" + +intents: +- bot_challenge +- endless_love +- thanks +- are_you_there +- greeting +- profanity +- affirm +- deny + +actions: +- action_utter_previous_message +- action_utter_smalltalk_greeting +- utter_thanks_response +- utter_profanity +- utter_tmo_love +- utter_bot_challenge +- utter_im_here +- utter_smalltalk_greeting + +responses: + utter_amazement: + - text: awesomness! diff --git a/data/test_domains/test_domain_from_multiple_files/tomorrow.yml b/data/test_domains/test_domain_from_multiple_files/tomorrow.yml new file mode 100644 index 0000000..1c33d2f --- /dev/null +++ b/data/test_domains/test_domain_from_multiple_files/tomorrow.yml @@ -0,0 +1,31 @@ +version: "3.1" + +intents: +- cure_network + +slots: + display_method_artwork: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: other_robot + display_cure_method: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: vanilla_robot + display_drum_cure_horns: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: vanilla_robot + +actions: +- utter_cure_standard +- utter_cure_specific +- utter_non_standard +- action_utter_cure_standard + diff --git a/data/test_domains/test_domain_from_multiple_files/wallets.yml b/data/test_domains/test_domain_from_multiple_files/wallets.yml new file mode 100644 index 0000000..95b9caf --- /dev/null +++ b/data/test_domains/test_domain_from_multiple_files/wallets.yml @@ -0,0 +1,30 @@ +version: "3.1" + +intents: +- open_wallet +- exchange_wallet + +slots: + greenOrGrey: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: strawberry_robot + activate_simulation: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: strawberry_robot + activate_double_simulation: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: strawberry_robot + +actions: +- utter_open_wallet_options +- utter_greengrey_wallet +- action_prevent_20 diff --git a/data/test_domains/test_domain_with_duplicates/domain1.yml b/data/test_domains/test_domain_with_duplicates/domain1.yml new file mode 100644 index 0000000..25e4cd1 --- /dev/null +++ b/data/test_domains/test_domain_with_duplicates/domain1.yml @@ -0,0 +1,38 @@ +version: "3.1" + +intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + +responses: + utter_greet: + - text: "Hey! How are you?" + + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" + + utter_did_that_help: + - text: "Did that help you?" + + utter_goodbye: + - text: "Bye" + + utter_iamabot: + - text: "I am a bot, powered by Rasa." + +slots: + mood: + type: bool + mappings: + - type: from_entity + entity: some_slot + +session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true diff --git a/data/test_domains/test_domain_with_duplicates/domain2.yml b/data/test_domains/test_domain_with_duplicates/domain2.yml new file mode 100644 index 0000000..1008672 --- /dev/null +++ b/data/test_domains/test_domain_with_duplicates/domain2.yml @@ -0,0 +1,22 @@ +version: "3.1" + +intents: + - greet + - test + +responses: + utter_greet: + - text: "Hey! How are you?" + + utter_did_that_help: + - text: "Did that help you?" + + utter_happy: + - text: "Great, carry on!" + +slots: + mood: + type: bool + mappings: + - type: from_entity + entity: some_slot diff --git a/data/test_domains/test_domain_with_separate_session_config/configuration.yml b/data/test_domains/test_domain_with_separate_session_config/configuration.yml new file mode 100644 index 0000000..85cf91e --- /dev/null +++ b/data/test_domains/test_domain_with_separate_session_config/configuration.yml @@ -0,0 +1,5 @@ +version: "3.1" + +session_config: + session_expiration_time: 1 + carry_over_slots_to_new_session: true diff --git a/data/test_domains/test_domain_with_separate_session_config/intents.yml b/data/test_domains/test_domain_with_separate_session_config/intents.yml new file mode 100644 index 0000000..567c232 --- /dev/null +++ b/data/test_domains/test_domain_with_separate_session_config/intents.yml @@ -0,0 +1,10 @@ +version: "3.1" + +intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge diff --git a/data/test_domains/test_domain_with_separate_session_config/responses.yml b/data/test_domains/test_domain_with_separate_session_config/responses.yml new file mode 100644 index 0000000..9761da2 --- /dev/null +++ b/data/test_domains/test_domain_with_separate_session_config/responses.yml @@ -0,0 +1,21 @@ +version: "3.1" + +responses: + utter_greet: + - text: "Hey! How are you?" + + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" + + utter_did_that_help: + - text: "Did that help you?" + + utter_happy: + - text: "Great, carry on!" + + utter_goodbye: + - text: "Bye" + + utter_iamabot: + - text: "I am a bot, powered by Rasa." diff --git a/data/test_domains/test_domain_without_duplicates/domain1.yml b/data/test_domains/test_domain_without_duplicates/domain1.yml new file mode 100644 index 0000000..ad60570 --- /dev/null +++ b/data/test_domains/test_domain_without_duplicates/domain1.yml @@ -0,0 +1,31 @@ +version: "3.1" + +intents: + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + +responses: + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" + + utter_goodbye: + - text: "Bye" + + utter_iamabot: + - text: "I am a bot, powered by Rasa." + +slots: + mood: + type: bool + mappings: + - type: from_entity + entity: some_slot + +session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true diff --git a/data/test_domains/test_domain_without_duplicates/domain2.yml b/data/test_domains/test_domain_without_duplicates/domain2.yml new file mode 100644 index 0000000..2525b7f --- /dev/null +++ b/data/test_domains/test_domain_without_duplicates/domain2.yml @@ -0,0 +1,15 @@ +version: "3.1" + +intents: + - greet + - test + +responses: + utter_greet: + - text: "Hey! How are you?" + + utter_did_that_help: + - text: "Did that help you?" + + utter_happy: + - text: "Great, carry on!" diff --git a/data/test_domains/travel_form.yml b/data/test_domains/travel_form.yml new file mode 100644 index 0000000..f4998f3 --- /dev/null +++ b/data/test_domains/travel_form.yml @@ -0,0 +1,43 @@ +# all hashtags are comments :) +intents: + - inform: + use_entities: + - GPE + - greet: + ignore_entities: + - GPE + +entities: + - GPE: + roles: + - destination + - origin + - name + +slots: + GPE_origin: + type: text + mappings: + - type: from_entity + entity: GPE + role: origin + GPE_destination: + type: text + mappings: + - type: from_entity + entity: GPE + role: destination + requested_slot: + type: text + influence_conversation: false + mappings: + - type: custom + +responses: + utter_ask_GPE_origin: + - text: "where are you leaving from?" + utter_ask_GPE_destination: + - text: "where are you going to?" + +actions: + - action_search_travel diff --git a/data/test_domains/valid_actions.yml b/data/test_domains/valid_actions.yml new file mode 100644 index 0000000..267fe05 --- /dev/null +++ b/data/test_domains/valid_actions.yml @@ -0,0 +1,36 @@ +intents: + - greet + - default + - goodbye + +slots: + cuisine: + type: text + mappings: + - type: from_entity + entity: cuisine + location: + type: text + mappings: + - type: from_entity + entity: location + +entities: + - name + - cuisine + - location + +responses: + utter_greet: + - text: hey there! + utter_goodbye: + - text: goodbye :( + utter_default: + - text: default message + +actions: + - utter_default: {send_domain: True} + - action_send_mail: {send_domain: False} + - action_send_mail2: {send_domain: true} + - action_send_mail3: {send_domain: false} + - action_hello diff --git a/data/test_domains/wrong_custom_response_format.yml b/data/test_domains/wrong_custom_response_format.yml new file mode 100644 index 0000000..37a1521 --- /dev/null +++ b/data/test_domains/wrong_custom_response_format.yml @@ -0,0 +1,26 @@ +intents: + - greet + - default + - goodbye + +slots: + cuisine: + type: text + location: + type: text + +entities: + - name + +responses: + utter_greet: + - super: cool + utter_goodbye: + - text: goodbye :( + utter_default: + - text: default message + +actions: + - utter_default + - utter_greet + - utter_goodbye diff --git a/data/test_domains/wrong_response_format.yml b/data/test_domains/wrong_response_format.yml new file mode 100644 index 0000000..d9419df --- /dev/null +++ b/data/test_domains/wrong_response_format.yml @@ -0,0 +1,26 @@ +intents: + - greet + - default + - goodbye + +slots: + cuisine: + type: text + location: + type: text + +entities: + - name + +responses: + utter_greet: + - hey there! + utter_goodbye: + - goodbye :( + utter_default: + - stuff: default message + +actions: + - utter_default + - utter_greet + - utter_goodbye diff --git a/data/test_e2ebot/config.yml b/data/test_e2ebot/config.yml new file mode 100644 index 0000000..be5c782 --- /dev/null +++ b/data/test_e2ebot/config.yml @@ -0,0 +1,21 @@ +recipe: default.v1 +assistant_id: test_e2e_bot +language: en +pipeline: + - name: WhitespaceTokenizer + intent_tokenization_flag: True + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 1 + run_eagerly: True +policies: +- name: TEDPolicy + random_seed: 42 + epochs: 5 +- name: RulePolicy diff --git a/data/test_e2ebot/data/nlu.yml b/data/test_e2ebot/data/nlu.yml new file mode 100644 index 0000000..2a14c9e --- /dev/null +++ b/data/test_e2ebot/data/nlu.yml @@ -0,0 +1,18 @@ +version: "3.1" + +nlu: +- intent: tell_name + examples: | + - my name is [joe](name) + - my name is [bob](name) + - I am [bob](name) + - I am [jim](name) + - I am called [bill](name) + +- intent: tell_mood + examples: | + - I am feeling [happy](mood) + - I am quite [happy](mood) + - I feel [sad](mood) + - I feel [happy](mood) + - I am so [sad](mood) diff --git a/data/test_e2ebot/data/stories.yml b/data/test_e2ebot/data/stories.yml new file mode 100644 index 0000000..565aa76 --- /dev/null +++ b/data/test_e2ebot/data/stories.yml @@ -0,0 +1,16 @@ +version: "3.1" + +stories: +- story: Tell name and mood + steps: + - user: my name is [joe](name) + - bot: hi joe! + - user: I am feeling [happy](mood) + - bot: I'm glad + +- story: Tell name and mood 2 + steps: + - user: my name is [bob](name) + - bot: hi bob! + - user: I am feeling [sad](mood) + - bot: oh dear... diff --git a/data/test_e2ebot/domain.yml b/data/test_e2ebot/domain.yml new file mode 100644 index 0000000..88d3af5 --- /dev/null +++ b/data/test_e2ebot/domain.yml @@ -0,0 +1,27 @@ +version: "3.1" + +intents: + - tell_name + - tell_mood + +entities: + - name + - mood + +slots: + name: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: name + mood: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: mood + +responses: + utter_greet: + - text: "hi!" diff --git a/data/test_e2ebot/tests/test_stories.yml b/data/test_e2ebot/tests/test_stories.yml new file mode 100644 index 0000000..cc1821b --- /dev/null +++ b/data/test_e2ebot/tests/test_stories.yml @@ -0,0 +1,14 @@ +version: "3.1" + +stories: +- story: Test with two easy entities + steps: + - user: my name is [joe](name) + - bot: hi joe! + - user: I am feeling [happy](mood) + - bot: I'm glad + +- story: Test with a hard entity + steps: + - user: today I was very [cranky](mood) + - bot: I'm sorry! diff --git a/data/test_e2ebot/tests/test_stories_with_unknown_bot_utterances.yml b/data/test_e2ebot/tests/test_stories_with_unknown_bot_utterances.yml new file mode 100644 index 0000000..ef8fff7 --- /dev/null +++ b/data/test_e2ebot/tests/test_stories_with_unknown_bot_utterances.yml @@ -0,0 +1,14 @@ +version: "3.1" + +stories: +- story: Test with two easy entities + steps: + - user: my name is [joe](name) + - bot: hi joe! + - user: I am feeling [happy](mood) + - bot: This is an unknown bot utterance + +- story: Test with a hard entity + steps: + - user: today I was very [cranky](mood) + - bot: I'm sorry! diff --git a/data/test_endpoints/__init__.py b/data/test_endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/data/test_endpoints/cert.pem b/data/test_endpoints/cert.pem new file mode 100644 index 0000000..cd25e05 --- /dev/null +++ b/data/test_endpoints/cert.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFiDCCA3ACCQCgeYtdshQ3bjANBgkqhkiG9w0BAQsFADCBhTELMAkGA1UEBhMC +ZGUxDzANBgNVBAgMBmJlcmxpbjEPMA0GA1UEBwwGYmVybGluMQ0wCwYDVQQKDARy +YXNhMQ0wCwYDVQQLDARyYXNhMRYwFAYDVQQDDA1teWFjdGlvbnMuZGV2MR4wHAYJ +KoZIhvcNAQkBFg9qLmp1emxAcmFzYS5jb20wHhcNMjEwNzEzMTAwNTI5WhcNMjIw +NzEzMTAwNTI5WjCBhTELMAkGA1UEBhMCZGUxDzANBgNVBAgMBmJlcmxpbjEPMA0G +A1UEBwwGYmVybGluMQ0wCwYDVQQKDARyYXNhMQ0wCwYDVQQLDARyYXNhMRYwFAYD +VQQDDA1teWFjdGlvbnMuZGV2MR4wHAYJKoZIhvcNAQkBFg9qLmp1emxAcmFzYS5j +b20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDH4D74AQ0vlk7McR+a +syG/38maXyqFM1XKybxxjUnBqooay4v8+wiByn3LK3IjVdnUBeuJLe8jAhbbDXgK +okbjuQb8U7o3T/rW4rrFKoT3bpF+VOfULmzW51jREYB/mtd+TvhBLhMlxytxPVzU +Rq3xT5NV6UILaTuWUFrQNP/CcwzjCPJXfPHwQaY8G5Ua31PqOBe2rNxHInIcctbg +DSwUxtcCCroIOIC5THoiYDzMMwGryZIabtdRbp/XAvcnrDxqXZGLxNEKKM7xOLTG +HfJfqqlLhCMn7KOie8OC0NGX36rIDUJHV+JVenDqgFdu7h4sRNFal/w0dstcZQBy +68A5GcQuuyggLv2XvTWThc1WycDAQ/gCoOsLTnuDW2KS1v/W1Rckyvm0R45FwIv/ +BomnAEDWpUao8Jy4k8/b10xaX1kDyikz3uIeM0AtDY1cxnBowk+82yL1uC0UIcRp +8bCxp9U9y+uk80+1+OHnb8CIOjR3jzVSdLnnMeZyld+5g2qJ8WbPvXJmzo5sh9yb +PKMx8zwODM3YyJm6WgRkZXNRCEytIeNMFBr4gfa+iBvCn2PBERMDrG5gzOV7VSY8 +JJTCpbmAkq9DnxLLMO1PqK+l+yD3xtE5xIx4lF4vMoy29w1K046pyORcRBDAOHyN +WA3YrlWVghZPCPH3eMaZ8H+gPQIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQBGAKk5 +0Rts2Dob9DBh0/lVTSeIa9tmjlpeBf1ZsqUVCkmAs5nhE+3ydGeNYTw1zoJ7hiHn +27IiB6yq+zu1qUoHFLv7W0xrZ2cW2M2df0mYVG1bFH9pmSeCQt4/ekDjnztZLgNb +fQiKpVWk7GLrAKhAhdoYE/t5uKT5Ns5vp9g5AsIIlMNPZqQAYdhX7/6zI755xuHp +2Dp232zKpmh+6bYLYeuI6bLDQ2HLSBhxmwEf4YZCi3R1LwxjnomXT9yTyd2PDN/B +WrV7G6zrPRcYq/QeLVKHDCNbUtZrwko5dR2Siz4ds8vLN1pX4v5y88WkIniZox+Q +9GyYvQ/m11rB+0kp9LKfCswW/F2AIznLgnQxfgXgrw/DUiUkQ0hQ/S5trwSQPHpO +Uf4g4yXh1MZg38kadSslqmkwMg0lI4MYdyCNjsVbH+ESqc5FkfFfR6TOS1wuevIQ +OFbF6BfkA/b4Faq9Uf+pxA1+9bt6EI5No2SnZ0InGvmbqnPkU78SCa7qTmq2UHZ1 +fvLejsbOTHZpu72lj/mXVZAgrnXzcuj+M0uc+k/vAWp1sx7fAapsMlO+8Unn6QGq +/BE3rSj1OnYfb6c7w+yF/8mRx2t0OWyJinkNrOAScFdDWalnprc9woattsRunhvF +xdPTIWctCZpcmgDvh7XJMP47CGvOHCSFtgsEIg== +-----END CERTIFICATE----- diff --git a/data/test_endpoints/custom_tracker_endpoints.yml b/data/test_endpoints/custom_tracker_endpoints.yml new file mode 100644 index 0000000..8cc638a --- /dev/null +++ b/data/test_endpoints/custom_tracker_endpoints.yml @@ -0,0 +1,7 @@ +tracker_store: + type: tests.core.test_tracker_stores.ExampleTrackerStore + url: localhost + port: 6379 + db: 0 + password: password + record_exp: 30000 diff --git a/data/test_endpoints/endpoints_redis.yml b/data/test_endpoints/endpoints_redis.yml new file mode 100644 index 0000000..7d2a899 --- /dev/null +++ b/data/test_endpoints/endpoints_redis.yml @@ -0,0 +1,5 @@ +tracker_store: + type: redis + url: localhost + port: 6379 + db: 0 diff --git a/data/test_endpoints/endpoints_sql.yml b/data/test_endpoints/endpoints_sql.yml new file mode 100644 index 0000000..6455a22 --- /dev/null +++ b/data/test_endpoints/endpoints_sql.yml @@ -0,0 +1,5 @@ +tracker_store: + type: SQL + dialect: "postgresql" + url: "localhost" + db: "rasa" diff --git a/data/test_endpoints/event_brokers/kafka_invalid_sasl_mechanism.yml b/data/test_endpoints/event_brokers/kafka_invalid_sasl_mechanism.yml new file mode 100644 index 0000000..fe72766 --- /dev/null +++ b/data/test_endpoints/event_brokers/kafka_invalid_sasl_mechanism.yml @@ -0,0 +1,12 @@ +event_broker: + type: kafka + security_protocol: SASL_SSL + topic: topic + url: localhost + sasl_username: username + sasl_password: password + sasl_mechanism: SOMETHING + ssl_cafile: CARoot.pem + ssl_certfile: certificate.pem + ssl_keyfile: key.pem + ssl_check_hostname: True diff --git a/data/test_endpoints/event_brokers/kafka_invalid_security_protocol.yml b/data/test_endpoints/event_brokers/kafka_invalid_security_protocol.yml new file mode 100644 index 0000000..74c07e1 --- /dev/null +++ b/data/test_endpoints/event_brokers/kafka_invalid_security_protocol.yml @@ -0,0 +1,3 @@ +event_broker: + security_protocol: SOMETHING + type: kafka diff --git a/data/test_endpoints/event_brokers/kafka_plaintext_endpoint.yml b/data/test_endpoints/event_brokers/kafka_plaintext_endpoint.yml new file mode 100644 index 0000000..fd07b57 --- /dev/null +++ b/data/test_endpoints/event_brokers/kafka_plaintext_endpoint.yml @@ -0,0 +1,6 @@ +event_broker: + type: kafka + security_protocol: PLAINTEXT + topic: topic + url: localhost + client_id: kafka-python-rasa diff --git a/data/test_endpoints/event_brokers/kafka_plaintext_endpoint_no_url.yml b/data/test_endpoints/event_brokers/kafka_plaintext_endpoint_no_url.yml new file mode 100644 index 0000000..eb71a07 --- /dev/null +++ b/data/test_endpoints/event_brokers/kafka_plaintext_endpoint_no_url.yml @@ -0,0 +1,4 @@ +event_broker: + client_id: kafka-python-rasa + security_protocol: PLAINTEXT + type: kafka diff --git a/data/test_endpoints/event_brokers/kafka_sasl_plaintext_endpoint.yml b/data/test_endpoints/event_brokers/kafka_sasl_plaintext_endpoint.yml new file mode 100644 index 0000000..7b9cb32 --- /dev/null +++ b/data/test_endpoints/event_brokers/kafka_sasl_plaintext_endpoint.yml @@ -0,0 +1,9 @@ +event_broker: + type: kafka + security_protocol: SASL_PLAINTEXT + topic: topic + url: localhost + partition_by_sender: True + sasl_username: username + sasl_password: password + sasl_mechanism: PLAIN diff --git a/data/test_endpoints/event_brokers/kafka_sasl_ssl_endpoint.yml b/data/test_endpoints/event_brokers/kafka_sasl_ssl_endpoint.yml new file mode 100644 index 0000000..1d2890b --- /dev/null +++ b/data/test_endpoints/event_brokers/kafka_sasl_ssl_endpoint.yml @@ -0,0 +1,12 @@ +event_broker: + type: kafka + security_protocol: SASL_SSL + topic: topic + url: localhost + sasl_username: username + sasl_password: password + sasl_mechanism: PLAIN + ssl_cafile: CARoot.pem + ssl_certfile: certificate.pem + ssl_keyfile: key.pem + ssl_check_hostname: True \ No newline at end of file diff --git a/data/test_endpoints/event_brokers/kafka_ssl_endpoint.yml b/data/test_endpoints/event_brokers/kafka_ssl_endpoint.yml new file mode 100644 index 0000000..01d3835 --- /dev/null +++ b/data/test_endpoints/event_brokers/kafka_ssl_endpoint.yml @@ -0,0 +1,9 @@ +event_broker: + type: kafka + security_protocol: SSL + topic: topic + url: localhost + ssl_cafile: CARoot.pem + ssl_certfile: certificate.pem + ssl_keyfile: key.pem + ssl_check_hostname: True \ No newline at end of file diff --git a/data/test_endpoints/event_brokers/pika_endpoint.yml b/data/test_endpoints/event_brokers/pika_endpoint.yml new file mode 100644 index 0000000..9fbb8bb --- /dev/null +++ b/data/test_endpoints/event_brokers/pika_endpoint.yml @@ -0,0 +1,11 @@ +event_broker: + type: pika + url: localhost + username: username + password: password + queues: + - queue-1 +# you may supply more than one queue to publish to +# - queue-2 +# - queue-3 + exchange_name: exchange diff --git a/data/test_endpoints/event_brokers/sql_endpoint.yml b/data/test_endpoints/event_brokers/sql_endpoint.yml new file mode 100644 index 0000000..f2d93a5 --- /dev/null +++ b/data/test_endpoints/event_brokers/sql_endpoint.yml @@ -0,0 +1,4 @@ +event_broker: + db: ":memory:" + dialect: "sqlite" + type: "sql" diff --git a/data/test_endpoints/example_endpoints.yml b/data/test_endpoints/example_endpoints.yml new file mode 100644 index 0000000..2e07554 --- /dev/null +++ b/data/test_endpoints/example_endpoints.yml @@ -0,0 +1,47 @@ +nlg: + url: http://localhost:5055/nlg # url of the nlg endpoint + # you can also specify additional parameters, if you need them: + # headers: + # my-custom-header: value + # token: "my_authentication_token" # will be passed as a get parameter + # basic_auth: + # username: user + # password: pass +# example of redis external tracker store config +tracker_store: + type: redis + url: localhost + port: 6379 + db: 0 + username: username + password: password + key_prefix: conversation + record_exp: 30000 + use_ssl: True + ssl_keyfile: "keyfile.key" + ssl_certfile: "certfile.crt" + ssl_ca_certs: "my-bundle.ca-bundle" +# example of redis external lock store config +lock_store: + type: redis + url: localhost + port: 6379 + db: 0 + username: username + password: password + key_prefix: lock + use_ssl: True + ssl_keyfile: "keyfile.key" + ssl_certfile: "certfile.crt" + ssl_ca_certs: "my-bundle.ca-bundle" +# example of mongoDB external tracker store config +#tracker_store: + #type: mongod + #url: mongodb://localhost:27017 + #db: rasa + #user: username + #password: password +action_endpoint: + url: http://localhost:5055/webhook + cafile: ./some_test_file +empty: diff --git a/data/test_endpoints/model_endpoint.yml b/data/test_endpoints/model_endpoint.yml new file mode 100644 index 0000000..c378ba2 --- /dev/null +++ b/data/test_endpoints/model_endpoint.yml @@ -0,0 +1,3 @@ +model: + url: localhost + wait_time_between_pulls: 10 diff --git a/data/test_evaluations/test_end_to_end_story.yml b/data/test_evaluations/test_end_to_end_story.yml new file mode 100644 index 0000000..9a2c71b --- /dev/null +++ b/data/test_evaluations/test_end_to_end_story.yml @@ -0,0 +1,29 @@ +stories: +- story: simple_story_with_only_start + steps: + - intent: greet + user: /greet + - action: utter_greet + +- story: simple_story_with_multiple_turns + steps: + - intent: greet + user: /greet + - action: utter_greet + - intent: default + user: /default + - action: utter_default + - intent: goodbye + user: /goodbye + - action: utter_goodbye + +- story: story_with_multiple_entities_correction_and_search + steps: + - intent: greet + user: | + /greet{"name": "Max"} + - action: utter_greet + - intent: default + user: /default + - action: utter_default + diff --git a/data/test_evaluations/test_end_to_end_trips_circuit_breaker.yml b/data/test_evaluations/test_end_to_end_trips_circuit_breaker.yml new file mode 100644 index 0000000..042996e --- /dev/null +++ b/data/test_evaluations/test_end_to_end_trips_circuit_breaker.yml @@ -0,0 +1,16 @@ +stories: +- story: story_trips_circuit_breaker + steps: + - intent: greet + user: /greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet diff --git a/data/test_evaluations/test_form_end_to_end_stories.yml b/data/test_evaluations/test_form_end_to_end_stories.yml new file mode 100644 index 0000000..ea656dc --- /dev/null +++ b/data/test_evaluations/test_form_end_to_end_stories.yml @@ -0,0 +1,25 @@ +version: "3.1" +stories: + - story: stop form + continue + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - intent: stop + - action: utter_ask_continue + - intent: affirm + - action: restaurant_form + - active_loop: null + - action: utter_submit + - action: utter_slots_values + + - story: stop form + stop + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - intent: stop + - action: utter_ask_continue + - intent: deny + - action: action_deactivate_loop + - active_loop: null diff --git a/data/test_evaluations/test_stories_trip_circuit_breaker.yml b/data/test_evaluations/test_stories_trip_circuit_breaker.yml new file mode 100644 index 0000000..24173e0 --- /dev/null +++ b/data/test_evaluations/test_stories_trip_circuit_breaker.yml @@ -0,0 +1,15 @@ +stories: +- story: story_trips_circuit_breaker + steps: + - intent: greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet + - action: utter_greet diff --git a/data/test_evaluations/test_story_unknown_entity.yml b/data/test_evaluations/test_story_unknown_entity.yml new file mode 100644 index 0000000..bfcbf7f --- /dev/null +++ b/data/test_evaluations/test_story_unknown_entity.yml @@ -0,0 +1,6 @@ +stories: +- story: simple_story_with_unknown_entity + steps: + - user: | + I am [rasa](name) + intent: utter_greet diff --git a/data/test_from_trigger_intent_with_mapping_conditions/config.yml b/data/test_from_trigger_intent_with_mapping_conditions/config.yml new file mode 100644 index 0000000..761d52c --- /dev/null +++ b/data/test_from_trigger_intent_with_mapping_conditions/config.yml @@ -0,0 +1,50 @@ +# The config recipe. +# https://rasa.com/docs/rasa/model-configuration/ +recipe: default.v1 + +assistant_id: placeholder_default + +# Configuration for Rasa NLU. +# https://rasa.com/docs/rasa/nlu/components/ +language: en + +pipeline: +# # No configuration for the NLU pipeline was provided. The following default pipeline was used to train your model. +# # If you'd like to customize it, uncomment and adjust the pipeline. + # See https://rasa.com/docs/rasa/tuning-your-model for more information. + - name: WhitespaceTokenizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 1 + constrain_similarities: true + run_eagerly: true + - name: EntitySynonymMapper + - name: ResponseSelector + epochs: 1 + constrain_similarities: true + run_eagerly: true + - name: FallbackClassifier + threshold: 0.3 + ambiguity_threshold: 0.1 + +# Configuration for Rasa Core. +# https://rasa.com/docs/rasa/core/policies/ +policies: +# # No configuration for policies was provided. The following default policies were used to train your model. +# # If you'd like to customize them, uncomment and adjust the policies. +# # See https://rasa.com/docs/rasa/policies for more information. + - name: MemoizationPolicy + - name: RulePolicy +# - name: UnexpecTEDIntentPolicy +# max_history: 5 +# epochs: 1 +# - name: TEDPolicy +# max_history: 5 +# epochs: 1 +# constrain_similarities: true diff --git a/data/test_from_trigger_intent_with_mapping_conditions/domain.yml b/data/test_from_trigger_intent_with_mapping_conditions/domain.yml new file mode 100644 index 0000000..7128afa --- /dev/null +++ b/data/test_from_trigger_intent_with_mapping_conditions/domain.yml @@ -0,0 +1,71 @@ +version: '3.1' + +intents: +- affirm +- bot_challenge +- deny +- goodbye +- greet +- mood_great +- mood_unhappy +- switch_another_form +slots: + test_trigger: + type: text + influence_conversation: false + mappings: + - type: from_trigger_intent + value: testing123 + intent: mood_great + conditions: + - active_loop: test_form + question1: + type: text + influence_conversation: false + mappings: + - type: from_text + conditions: + - active_loop: test_form + requested_slot: question1 + q2: + type: text + influence_conversation: false + mappings: + - type: from_text + conditions: + - active_loop: another_form + requested_slot: q2 +forms: + test_form: + required_slots: + - question1 + another_form: + required_slots: + - q2 +responses: + utter_greet: + - text: Hey! How are you? + utter_cheer_up: + - text: 'Here is something to cheer you up:' + image: https://i.imgur.com/nGF1K8f.jpg + utter_did_that_help: + - text: Did that help you? + utter_happy: + - text: Great, carry on! + utter_test_trigger: + - text: "The value of test_trigger slot is: {test_trigger}" + utter_goodbye: + - text: Bye + utter_activate_another: + - text: Ok. going to another_form flow. sound good? + utter_iamabot: + - text: I am a bot, powered by Rasa. + utter_ask_test_form_question1: + - text: test form - question 1 + utter_ask_another_form_q2: + - text: another form - q2 + utter_submit_test_form: + - text: Submit test form +session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true diff --git a/data/test_from_trigger_intent_with_mapping_conditions/nlu.yml b/data/test_from_trigger_intent_with_mapping_conditions/nlu.yml new file mode 100644 index 0000000..e561817 --- /dev/null +++ b/data/test_from_trigger_intent_with_mapping_conditions/nlu.yml @@ -0,0 +1,90 @@ +version: "3.1" +nlu: +- intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon +- intent: goodbye + examples: | + - cu + - good by + - cee you later + - good night + - bye + - goodbye + - have a nice day + - see you around + - bye bye + - see you later +- intent: affirm + examples: | + - yes + - y + - indeed + - of course + - that sounds good + - correct +- intent: deny + examples: | + - no + - n + - never + - I don't think so + - don't like that + - no way + - not really +- intent: mood_great + examples: | + - perfect + - great + - amazing + - feeling like a king + - wonderful + - I am feeling very good + - I am great + - I am amazing + - I am going to save the world + - super stoked + - extremely good + - so so perfect + - so good + - so perfect +- intent: mood_unhappy + examples: | + - my day was horrible + - I am sad + - I don't feel very well + - I am disappointed + - super sad + - I'm so sad + - sad + - very sad + - unhappy + - not good + - not very good + - extremely sad + - so saad + - so sad +- intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? + +- intent: switch_another_form + examples: | + - switch to another form + - switch forms + - activate another form diff --git a/data/test_from_trigger_intent_with_mapping_conditions/stories.yml b/data/test_from_trigger_intent_with_mapping_conditions/stories.yml new file mode 100644 index 0000000..9a02eb2 --- /dev/null +++ b/data/test_from_trigger_intent_with_mapping_conditions/stories.yml @@ -0,0 +1,50 @@ +version: "3.1" + +stories: + +- story: happy path + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy + - action: utter_test_trigger + +- story: sad path 1 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + +- story: sad path 2 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: deny + - action: utter_goodbye + +- story: activate test form + steps: + - intent: mood_great + - action: test_form + - active_loop: test_form + - active_loop: null + - action: utter_submit_test_form + - action: utter_test_trigger + +- story: activate another form + steps: + - intent: switch_another_form + - action: utter_activate_another + - intent: mood_great + - action: another_form + - active_loop: another_form + - active_loop: null + - action: utter_test_trigger diff --git a/data/test_from_trigger_intent_with_no_mapping_conditions/config.yml b/data/test_from_trigger_intent_with_no_mapping_conditions/config.yml new file mode 100644 index 0000000..761d52c --- /dev/null +++ b/data/test_from_trigger_intent_with_no_mapping_conditions/config.yml @@ -0,0 +1,50 @@ +# The config recipe. +# https://rasa.com/docs/rasa/model-configuration/ +recipe: default.v1 + +assistant_id: placeholder_default + +# Configuration for Rasa NLU. +# https://rasa.com/docs/rasa/nlu/components/ +language: en + +pipeline: +# # No configuration for the NLU pipeline was provided. The following default pipeline was used to train your model. +# # If you'd like to customize it, uncomment and adjust the pipeline. + # See https://rasa.com/docs/rasa/tuning-your-model for more information. + - name: WhitespaceTokenizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 1 + constrain_similarities: true + run_eagerly: true + - name: EntitySynonymMapper + - name: ResponseSelector + epochs: 1 + constrain_similarities: true + run_eagerly: true + - name: FallbackClassifier + threshold: 0.3 + ambiguity_threshold: 0.1 + +# Configuration for Rasa Core. +# https://rasa.com/docs/rasa/core/policies/ +policies: +# # No configuration for policies was provided. The following default policies were used to train your model. +# # If you'd like to customize them, uncomment and adjust the policies. +# # See https://rasa.com/docs/rasa/policies for more information. + - name: MemoizationPolicy + - name: RulePolicy +# - name: UnexpecTEDIntentPolicy +# max_history: 5 +# epochs: 1 +# - name: TEDPolicy +# max_history: 5 +# epochs: 1 +# constrain_similarities: true diff --git a/data/test_from_trigger_intent_with_no_mapping_conditions/domain.yml b/data/test_from_trigger_intent_with_no_mapping_conditions/domain.yml new file mode 100644 index 0000000..d681815 --- /dev/null +++ b/data/test_from_trigger_intent_with_no_mapping_conditions/domain.yml @@ -0,0 +1,64 @@ +version: '3.1' + +intents: +- affirm +- bot_challenge +- deny +- goodbye +- greet +- mood_great +- mood_unhappy +slots: + test_trigger: + type: text + influence_conversation: false + mappings: + - type: from_trigger_intent + value: testing123 + intent: mood_great + question1: + type: text + influence_conversation: false + mappings: + - type: from_text + conditions: + - active_loop: test_form + requested_slot: question1 + q2: + type: text + influence_conversation: false + mappings: + - type: from_text + conditions: + - active_loop: another_form + requested_slot: q2 +forms: + test_form: + required_slots: + - question1 + another_form: + required_slots: + - q2 +responses: + utter_greet: + - text: Hey! How are you? + utter_cheer_up: + - text: 'Here is something to cheer you up:' + image: https://i.imgur.com/nGF1K8f.jpg + utter_did_that_help: + - text: Did that help you? + utter_happy: + - text: Great, carry on! + utter_test_trigger: + - text: "The value of test_trigger slot is: {test_trigger}" + utter_goodbye: + - text: Bye + utter_iamabot: + - text: I am a bot, powered by Rasa. + utter_ask_test_form_question1: + - text: test form - question 1 + utter_submit_test_form: + - text: Submit test form +session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true diff --git a/data/test_from_trigger_intent_with_no_mapping_conditions/nlu.yml b/data/test_from_trigger_intent_with_no_mapping_conditions/nlu.yml new file mode 100644 index 0000000..5166a47 --- /dev/null +++ b/data/test_from_trigger_intent_with_no_mapping_conditions/nlu.yml @@ -0,0 +1,84 @@ +version: "3.1" +nlu: +- intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon +- intent: goodbye + examples: | + - cu + - good by + - cee you later + - good night + - bye + - goodbye + - have a nice day + - see you around + - bye bye + - see you later +- intent: affirm + examples: | + - yes + - y + - indeed + - of course + - that sounds good + - correct +- intent: deny + examples: | + - no + - n + - never + - I don't think so + - don't like that + - no way + - not really +- intent: mood_great + examples: | + - perfect + - great + - amazing + - feeling like a king + - wonderful + - I am feeling very good + - I am great + - I am amazing + - I am going to save the world + - super stoked + - extremely good + - so so perfect + - so good + - so perfect +- intent: mood_unhappy + examples: | + - my day was horrible + - I am sad + - I don't feel very well + - I am disappointed + - super sad + - I'm so sad + - sad + - very sad + - unhappy + - not good + - not very good + - extremely sad + - so saad + - so sad +- intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? diff --git a/data/test_from_trigger_intent_with_no_mapping_conditions/stories.yml b/data/test_from_trigger_intent_with_no_mapping_conditions/stories.yml new file mode 100644 index 0000000..f53428e --- /dev/null +++ b/data/test_from_trigger_intent_with_no_mapping_conditions/stories.yml @@ -0,0 +1,40 @@ +version: "3.1" + +stories: + +- story: happy path + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy + - action: utter_test_trigger + +- story: sad path 1 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + +- story: sad path 2 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: deny + - action: utter_goodbye + +- story: activate test form + steps: + - intent: mood_great + - action: test_form + - active_loop: test_form + - active_loop: null + - action: utter_submit_test_form + - action: utter_test_trigger diff --git a/data/test_incremental_training/iter1/nlg_training_data.yml b/data/test_incremental_training/iter1/nlg_training_data.yml new file mode 100644 index 0000000..5f46b76 --- /dev/null +++ b/data/test_incremental_training/iter1/nlg_training_data.yml @@ -0,0 +1,6 @@ +version: "3.1" +responses: + utter_mood/great: + - text: some response to mood/great + utter_mood/unhappy: + - text: some response to mood/unhappy diff --git a/data/test_incremental_training/iter1/training_data.yml b/data/test_incremental_training/iter1/training_data.yml new file mode 100644 index 0000000..604fb99 --- /dev/null +++ b/data/test_incremental_training/iter1/training_data.yml @@ -0,0 +1,73 @@ +version: "3.1" +nlu: +- intent: bot_challenge + examples: | + - are you a bot? + - am I talking to a bot? + - are you a human? +- intent: affirm + examples: | + - y + - that sounds good + - correct + - yes + - of course +- intent: deny + examples: | + - n + - I don't think so + - don't like that + - no way +- intent: goodbye + examples: | + - cu + - cee you later + - have a nice day + - bye bye + - good night + - see you around + - see you later + - good by + - goodbye +- intent: greet + examples: | + - hi + - let's go + - goodmorning + - hey + - hello + - good morning + - goodevening + - good evening + - hey dude + - moin + - hello there +- intent: mood/great + examples: | + - I am feeling very good + - extremely good + - I am amazing + - so perfect + - I am great + - great + - so so perfect + - feeling like a king + - so good + - wonderful + - I am going to save the world +- intent: mood/unhappy + examples: | + - very sad + - so saad + - sad + - I don't feel very well + - my day was horrible + - not good + - super sad + - I am disappointed + - so sad + - I am sad + - I'm so sad +- regex: greet + examples: | + - hey[^\s]* diff --git a/data/test_incremental_training/iter2/training_data.yml b/data/test_incremental_training/iter2/training_data.yml new file mode 100644 index 0000000..0f26883 --- /dev/null +++ b/data/test_incremental_training/iter2/training_data.yml @@ -0,0 +1,13 @@ +version: "3.1" +nlu: +- intent: affirm + examples: | + - indeed + - that's right 12345 +- intent: mood/unhappy + examples: | + - I'm a disappointment + - I feel lonely +- regex: zipcode + examples: | + - [0-9]{5} diff --git a/data/test_logging_config_files/test_invalid_format_value_in_config.yml b/data/test_logging_config_files/test_invalid_format_value_in_config.yml new file mode 100644 index 0000000..f4a3ba5 --- /dev/null +++ b/data/test_logging_config_files/test_invalid_format_value_in_config.yml @@ -0,0 +1,20 @@ +version: 1 +disable_existing_loggers: false +formatters: + normalFormatter: + # invalid value + format: None +handlers: + test_handler: + level: INFO + formatter: normalFormatter + class: logging.FileHandler + filename: "logging_test.log" +loggers: + root: + handlers: [test_handler] + level: INFO + rasa: + handlers: [test_handler] + level: INFO + propagate: 0 diff --git a/data/test_logging_config_files/test_invalid_handler_key_in_config.yml b/data/test_logging_config_files/test_invalid_handler_key_in_config.yml new file mode 100644 index 0000000..e73c17e --- /dev/null +++ b/data/test_logging_config_files/test_invalid_handler_key_in_config.yml @@ -0,0 +1,21 @@ +version: 1 +disable_existing_loggers: false +formatters: + normalFormatter: + format: "{\"time\": \"%(asctime)s\", \"name\": \"[%(name)s]\", \"levelname\": \"%(levelname)s\", \"message\": \"%(message)s\"}" +handlers: + test_handler: + level: INFO + formatter: normalFormatter + class: logging.FileHandler + filename: "logging_test.log" + # invalid unknown key + extra_key: "hello world" +loggers: + root: + handlers: [test_handler] + level: INFO + rasa: + handlers: [test_handler] + level: INFO + propagate: 0 diff --git a/data/test_logging_config_files/test_invalid_value_for_level_in_config.yml b/data/test_logging_config_files/test_invalid_value_for_level_in_config.yml new file mode 100644 index 0000000..59e263c --- /dev/null +++ b/data/test_logging_config_files/test_invalid_value_for_level_in_config.yml @@ -0,0 +1,20 @@ +version: 1 +disable_existing_loggers: false +formatters: + normalFormatter: + format: "{\"time\": \"%(asctime)s\", \"name\": \"[%(name)s]\", \"levelname\": \"%(levelname)s\", \"message\": \"%(message)s\"}" +handlers: + test_handler: + # invalid value for level + level: HIGH + formatter: normalFormatter + class: logging.FileHandler + filename: "logging_test.log" +loggers: + root: + handlers: [test_handler] + level: INFO + rasa: + handlers: [test_handler] + level: INFO + propagate: 0 diff --git a/data/test_logging_config_files/test_logging_config.yml b/data/test_logging_config_files/test_logging_config.yml new file mode 100644 index 0000000..49590b5 --- /dev/null +++ b/data/test_logging_config_files/test_logging_config.yml @@ -0,0 +1,19 @@ +version: 1 +disable_existing_loggers: false +formatters: + normalFormatter: + format: "{\"time\": \"%(asctime)s\", \"name\": \"[%(name)s]\", \"levelname\": \"%(levelname)s\", \"message\": \"%(message)s\"}" +handlers: + test_handler: + level: INFO + formatter: normalFormatter + class: logging.FileHandler + filename: "logging_test.log" +loggers: + root: + handlers: [test_handler] + level: INFO + rasa: + handlers: [test_handler] + level: INFO + propagate: 0 diff --git a/data/test_logging_config_files/test_missing_required_key_invalid_config.yml b/data/test_logging_config_files/test_missing_required_key_invalid_config.yml new file mode 100644 index 0000000..5b197ad --- /dev/null +++ b/data/test_logging_config_files/test_missing_required_key_invalid_config.yml @@ -0,0 +1,20 @@ +# missing mandatory key +# version: 1 +disable_existing_loggers: false +formatters: + normalFormatter: + format: "{\"time\": \"%(asctime)s\", \"name\": \"[%(name)s]\", \"levelname\": \"%(levelname)s\", \"message\": \"%(message)s\"}" +handlers: + test_handler: + level: INFO + formatter: normalFormatter + class: logging.FileHandler + filename: "logging_test.log" +loggers: + root: + handlers: [test_handler] + level: INFO + rasa: + handlers: [test_handler] + level: INFO + propagate: 0 diff --git a/data/test_logging_config_files/test_non_existent_handler_id.yml b/data/test_logging_config_files/test_non_existent_handler_id.yml new file mode 100644 index 0000000..19f617e --- /dev/null +++ b/data/test_logging_config_files/test_non_existent_handler_id.yml @@ -0,0 +1,20 @@ +version: 1 +disable_existing_loggers: false +formatters: + normalFormatter: + format: "{\"time\": \"%(asctime)s\", \"name\": \"[%(name)s]\", \"levelname\": \"%(levelname)s\", \"message\": \"%(message)s\"}" +handlers: + test_handler: + level: INFO + formatter: normalFormatter + class: logging.FileHandler + filename: "logging_test.log" +loggers: + root: + # a non-existent handler id + handlers: [some_handler] + level: INFO + rasa: + handlers: [test_handler] + level: INFO + propagate: 0 diff --git a/data/test_mixed_yaml_training_data/training_data.yml b/data/test_mixed_yaml_training_data/training_data.yml new file mode 100644 index 0000000..070080f --- /dev/null +++ b/data/test_mixed_yaml_training_data/training_data.yml @@ -0,0 +1,24 @@ +version: "3.1" +nlu: +- intent: intent_name + examples: | + - how much CO2 will that use? + - how much carbon will a one way flight from [new york]{"entity": "city", "role": "from"} to california produce? + +stories: +- story: simple_story_with_only_start + steps: + - checkpoint: check_greet + - intent: simple + - action: utter_default + +- rule: Say 'I am a bot' anytime the user challenges + steps: + - intent: bot_challenge + - action: utter_iamabot + +responses: + chitchat/ask_name: + - image: "https://i.imgur.com/zTvA58i.jpeg" + text: hello, my name is retrieval bot. + - text: Oh yeah, I am called the retrieval bot. diff --git a/data/test_moodbot/config.yml b/data/test_moodbot/config.yml new file mode 100644 index 0000000..192cfa7 --- /dev/null +++ b/data/test_moodbot/config.yml @@ -0,0 +1,25 @@ +recipe: default.v1 +assistant_id: test_moodbot +language: en + +pipeline: + - name: WhitespaceTokenizer + intent_tokenization_flag: True + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + - name: "DIETClassifier" + entity_recognition: False + epochs: 1 + run_eagerly: True + +policies: + - name: TEDPolicy + max_history: 5 + epochs: 1 + - name: MemoizationPolicy + - name: RulePolicy diff --git a/data/test_moodbot/credentials.yml b/data/test_moodbot/credentials.yml new file mode 100644 index 0000000..8f7fe95 --- /dev/null +++ b/data/test_moodbot/credentials.yml @@ -0,0 +1,33 @@ +twilio: + account_sid: "ACbc2dxxxxxxxxxxxx19d54bdcd6e41186" + auth_token: "e231c197493a7122d475b4xxxxxxxxxx" + twilio_number: "+440123456789" + +slack: + slack_token: "xoxb-xxx" + slack_channel: "#my_channel" # leave out this param to post to DMs with bot app + slack_signing_secret: "123456701ea89032asdebfe5a74518" + +telegram: + access_token: "490161424:AAGlRxinBRtKGb21_rlOEMtDFZMXBl6EC0o" + verify: "your_bot" + webhook_url: "your_url.com/webhook" + +mattermost: + url: "https://chat.example.com/api/v4" + token: "YOUR-TOKEN" + +facebook: + verify: "rasa-bot" + secret: "3e34709d01ea89032asdebfe5a74518" + page-access-token: "EAAbHPa7H9rEBAAuFk4Q3gPKbDedQnx4djJJ1JmQ7CAqO4iJKrQcNT0wtD" + +webexteams: + access_token: "ADD-YOUR-BOT-ACCESS-TOKEN" + room: "YOUR-WEBEXTEAMS-ROOM-ID" + +rocketchat: + user: "your_bot_user" + password: "you_bot_pass" + server_url: "localhost:3000" + ssl: false diff --git a/data/test_moodbot/data/nlu.yml b/data/test_moodbot/data/nlu.yml new file mode 100644 index 0000000..8524916 --- /dev/null +++ b/data/test_moodbot/data/nlu.yml @@ -0,0 +1,90 @@ +version: "3.1" +nlu: +- intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon + +- intent: goodbye + examples: | + - good afternoon + - cu + - good by + - cee you later + - good night + - bye + - goodbye + - have a nice day + - see you around + - bye bye + - see you later + +- intent: affirm + examples: | + - yes + - y + - indeed + - of course + - that sounds good + - correct + +- intent: deny + examples: | + - no + - n + - never + - I don't think so + - don't like that + - no way + +- intent: mood_great + examples: | + - perfect + - great + - amazing + - feeling like a king + - wonderful + - I am feeling very good + - I am great + - I am amazing + - I am going to save the world + - super stoked + - extremely good + - so so perfect + - so good + - so perfect + +- intent: mood_unhappy + examples: | + - my day was horrible + - I am sad + - I don't feel very well + - I am disappointed + - super sad + - I'm so sad + - sad + - very sad + - unhappy + - not good + - not very good + - extremly sad + - so saad + - so sad + +- intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? diff --git a/data/test_moodbot/data/rules.yml b/data/test_moodbot/data/rules.yml new file mode 100644 index 0000000..7da88a7 --- /dev/null +++ b/data/test_moodbot/data/rules.yml @@ -0,0 +1,11 @@ +version: "3.1" +rules: +- rule: Say goodbye anytime the user says goodbye + steps: + - intent: goodbye + - action: utter_goodbye + +- rule: Say 'I am a bot' anytime the user challenges + steps: + - intent: bot_challenge + - action: utter_iamabot diff --git a/data/test_moodbot/data/stories.yml b/data/test_moodbot/data/stories.yml new file mode 100644 index 0000000..5ed5834 --- /dev/null +++ b/data/test_moodbot/data/stories.yml @@ -0,0 +1,29 @@ +version: "3.1" +stories: +- story: happy path + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy + +- story: sad path 1 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + +- story: sad path 2 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: deny + - action: utter_goodbye + diff --git a/data/test_moodbot/domain.yml b/data/test_moodbot/domain.yml new file mode 100644 index 0000000..de67a50 --- /dev/null +++ b/data/test_moodbot/domain.yml @@ -0,0 +1,39 @@ +version: "3.1" + +intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + +responses: + utter_greet: + - text: "Hey! How are you?" + buttons: + - title: "great" + payload: "/mood_great" + - title: "super sad" + payload: "/mood_unhappy" + + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" + + utter_did_that_help: + - text: "Did that help you?" + + utter_happy: + - text: "Great, carry on!" + + utter_goodbye: + - text: "Bye" + + utter_iamabot: + - text: "I am a bot, powered by Rasa." + +session_config: + session_expiration_time: 60 # value in minutes + carry_over_slots_to_new_session: true diff --git a/data/test_moodbot/unexpected_intent_policy_config.yml b/data/test_moodbot/unexpected_intent_policy_config.yml new file mode 100644 index 0000000..d74d96c --- /dev/null +++ b/data/test_moodbot/unexpected_intent_policy_config.yml @@ -0,0 +1,15 @@ +recipe: default.v1 +language: en + +pipeline: + +policies: + - name: MemoizationPolicy + - name: UnexpecTEDIntentPolicy + max_history: 5 + epochs: 1 + - name: TEDPolicy + max_history: 5 + epochs: 1 + constrain_similarities: true + - name: RulePolicy diff --git a/data/test_multi_domain/config.yml b/data/test_multi_domain/config.yml new file mode 100644 index 0000000..ed2d2d4 --- /dev/null +++ b/data/test_multi_domain/config.yml @@ -0,0 +1,15 @@ +recipe: default.v1 +assistant_id: placeholder_default +language: en + +pipeline: + - name: "KeywordIntentClassifier" + +policies: + - name: MemoizationPolicy + +importers: + - name: MultiProjectImporter + +imports: + - data/MoodBot diff --git a/data/test_multi_domain/data/GreetBot/data/nlu.yml b/data/test_multi_domain/data/GreetBot/data/nlu.yml new file mode 100644 index 0000000..f7e6259 --- /dev/null +++ b/data/test_multi_domain/data/GreetBot/data/nlu.yml @@ -0,0 +1,17 @@ +version: "3.1" +nlu: +- intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon diff --git a/data/test_multi_domain/data/GreetBot/data/stories.yml b/data/test_multi_domain/data/GreetBot/data/stories.yml new file mode 100644 index 0000000..c488408 --- /dev/null +++ b/data/test_multi_domain/data/GreetBot/data/stories.yml @@ -0,0 +1,6 @@ +version: "3.1" +stories: +- story: say hello + steps: + - intent: greet + - action: utter_greet diff --git a/data/test_multi_domain/data/GreetBot/domain.yml b/data/test_multi_domain/data/GreetBot/domain.yml new file mode 100644 index 0000000..c683c9c --- /dev/null +++ b/data/test_multi_domain/data/GreetBot/domain.yml @@ -0,0 +1,15 @@ +intents: + - greet + - goodbye + +responses: + utter_greet: + - text: "Hey! How are you?" + buttons: + - title: "great" + payload: "/mood_great" + - title: "super sad" + payload: "/mood_unhappy" + + utter_goodbye: + - text: "Bye" diff --git a/data/test_multi_domain/data/MoodBot/config.yml b/data/test_multi_domain/data/MoodBot/config.yml new file mode 100644 index 0000000..4b81482 --- /dev/null +++ b/data/test_multi_domain/data/MoodBot/config.yml @@ -0,0 +1,11 @@ +recipe: default.v1 +language: en + +pipeline: "pretrained_embeddings_spacy" + +policies: + - name: MemoizationPolicy + - name: TEDPolicy + +imports: +- ../GreetBot diff --git a/data/test_multi_domain/data/MoodBot/data/nlu.yml b/data/test_multi_domain/data/MoodBot/data/nlu.yml new file mode 100644 index 0000000..753b34e --- /dev/null +++ b/data/test_multi_domain/data/MoodBot/data/nlu.yml @@ -0,0 +1,53 @@ +version: "3.1" +nlu: +- intent: affirm + examples: | + - yes + - y + - indeed + - of course + - that sounds good + - correct + +- intent: deny + examples: | + - no + - n + - never + - I don't think so + - don't like that + - no way + +- intent: mood_great + examples: | + - perfect + - great + - amazing + - feeling like a king + - wonderful + - I am feeling very good + - I am great + - I am amazing + - I am going to save the world + - super stoked + - extremely good + - so so perfect + - so good + - so perfect + +- intent: mood_unhappy + examples: | + - my day was horrible + - I am sad + - I don't feel very well + - I am disappointed + - super sad + - I'm so sad + - sad + - very sad + - unhappy + - not good + - not very good + - extremly sad + - so saad + - so sad diff --git a/data/test_multi_domain/data/MoodBot/data/stories.yml b/data/test_multi_domain/data/MoodBot/data/stories.yml new file mode 100644 index 0000000..5ed5834 --- /dev/null +++ b/data/test_multi_domain/data/MoodBot/data/stories.yml @@ -0,0 +1,29 @@ +version: "3.1" +stories: +- story: happy path + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy + +- story: sad path 1 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + +- story: sad path 2 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: deny + - action: utter_goodbye + diff --git a/data/test_multi_domain/data/MoodBot/domain.yml b/data/test_multi_domain/data/MoodBot/domain.yml new file mode 100644 index 0000000..ff849ac --- /dev/null +++ b/data/test_multi_domain/data/MoodBot/domain.yml @@ -0,0 +1,16 @@ +intents: + - affirm + - deny + - mood_great + - mood_unhappy + +responses: + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" + + utter_did_that_help: + - text: "Did that help you?" + + utter_happy: + - text: "Great carry on!" \ No newline at end of file diff --git a/data/test_multi_domain/data/nlu.yml b/data/test_multi_domain/data/nlu.yml new file mode 100644 index 0000000..077ddd7 --- /dev/null +++ b/data/test_multi_domain/data/nlu.yml @@ -0,0 +1,15 @@ +version: "3.1" +nlu: +- intent: goodbye + examples: | + - cu + - good by + - cee you later + - good night + - good afternoon + - bye + - goodbye + - have a nice day + - see you around + - bye bye + - see you later diff --git a/data/test_multi_domain/data/stories.yml b/data/test_multi_domain/data/stories.yml new file mode 100644 index 0000000..59e8726 --- /dev/null +++ b/data/test_multi_domain/data/stories.yml @@ -0,0 +1,6 @@ +version: "3.1" +stories: +- story: say goodbye + steps: + - intent: goodbye + - action: utter_goodbye diff --git a/data/test_multi_domain/domain.yml b/data/test_multi_domain/domain.yml new file mode 100644 index 0000000..93a3518 --- /dev/null +++ b/data/test_multi_domain/domain.yml @@ -0,0 +1,6 @@ +intents: + - goodbye + +responses: + utter_goodbye: + - text: "Bye" diff --git a/data/test_multifile_yaml_stories/stories_part_1.yml b/data/test_multifile_yaml_stories/stories_part_1.yml new file mode 100644 index 0000000..15bff95 --- /dev/null +++ b/data/test_multifile_yaml_stories/stories_part_1.yml @@ -0,0 +1,20 @@ +stories: +- story: simple_story_without_checkpoint + steps: + - intent: simple + - action: utter_default + - action: utter_greet + +- story: simple_story_with_only_start + steps: + - checkpoint: check_greet # checkpoints at the start define entry points + - intent: simple + - action: utter_default + +- story: simple_story_with_only_end + steps: + - intent: hello + - action: utter_greet + - slot_was_set: + - name: peter + - checkpoint: check_greet # checkpoint defining the end of this turn \ No newline at end of file diff --git a/data/test_multifile_yaml_stories/stories_part_2.yml b/data/test_multifile_yaml_stories/stories_part_2.yml new file mode 100644 index 0000000..f8f7a3f --- /dev/null +++ b/data/test_multifile_yaml_stories/stories_part_2.yml @@ -0,0 +1,27 @@ +stories: +- story: simple_story_with_multiple_turns + steps: + - or: + - intent: affirm + - intent: thank_you + - action: utter_default + - intent: goodbye + - action: utter_goodbye + - checkpoint: check_goodbye + +- story: why does the user want to leave? + steps: + - checkpoint: check_goodbye + - intent: why + - action: utter_default + - checkpoint: check_greet + +- story: show_it_all + steps: + - checkpoint: check_greet + - checkpoint: check_hello # allows multiple entry points + - intent: next_intent + - action: utter_greet # actions taken by the bot + - checkpoint: check_intermediate # allows intermediate checkpoints + - intent: change_bank_details + - action: utter_default # allows to end without checkpoints diff --git a/data/test_multiline_intent_examples_yaml/nlu.yml b/data/test_multiline_intent_examples_yaml/nlu.yml new file mode 100644 index 0000000..67cb8c4 --- /dev/null +++ b/data/test_multiline_intent_examples_yaml/nlu.yml @@ -0,0 +1,14 @@ +version: "3.1" +nlu: +- intent: greet + examples: + - text: | + Hello, + My name is Christof + - text: | + Hello, + You can call me Christof +- intent: bye + examples: | + - bye, my friend + - cya diff --git a/data/test_multiproject/config.yml b/data/test_multiproject/config.yml new file mode 100644 index 0000000..9737969 --- /dev/null +++ b/data/test_multiproject/config.yml @@ -0,0 +1,25 @@ +recipe: default.v1 +language: en + +pipeline: + - name: "SpacyNLP" + model: "en_core_web_md" + - name: "SpacyTokenizer" + - name: "SpacyFeaturizer" + - name: "DIETClassifier" + entity_recognition: False + epochs: 50 + +policies: + - name: TEDPolicy + max_history: 5 + epochs: 100 + - name: MemoizationPolicy + - name: RulePolicy + +importers: +- name: MultiProjectImporter + +imports: + - projects/ChitchatBot + - projects/GreetBot diff --git a/data/test_multiproject/projects/ChitchatBot/data/nlu.yml b/data/test_multiproject/projects/ChitchatBot/data/nlu.yml new file mode 100644 index 0000000..e872f4c --- /dev/null +++ b/data/test_multiproject/projects/ChitchatBot/data/nlu.yml @@ -0,0 +1,14 @@ +version: "3.1" +nlu: +- intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? +- intent: tell_joke + examples: | + - tell me joke + - tell me sth funny + - can you tell me a joke? + - what's your best joke? diff --git a/data/test_multiproject/projects/ChitchatBot/data/rules.yml b/data/test_multiproject/projects/ChitchatBot/data/rules.yml new file mode 100644 index 0000000..dbdb5f3 --- /dev/null +++ b/data/test_multiproject/projects/ChitchatBot/data/rules.yml @@ -0,0 +1,11 @@ +version: "3.1" +rules: +- rule: Tell a joke when the user asks for one + steps: + - intent: tell_joke + - action: utter_joke + +- rule: Say 'I am a bot' anytime the user challenges + steps: + - intent: bot_challenge + - action: utter_iamabot diff --git a/data/test_multiproject/projects/ChitchatBot/domain.yml b/data/test_multiproject/projects/ChitchatBot/domain.yml new file mode 100644 index 0000000..5efe8d4 --- /dev/null +++ b/data/test_multiproject/projects/ChitchatBot/domain.yml @@ -0,0 +1,16 @@ +version: "3.1" + +intents: + - tell_joke + - bot_challenge + +responses: + utter_joke: + - text: "Ups, mein Ball ist umgekippt." + + utter_iamabot: + - text: "I am a bot, powered by Rasa." + +session_config: + session_expiration_time: 60 # value in minutes + carry_over_slots_to_new_session: true diff --git a/data/test_multiproject/projects/GreetBot/data/nlu.yml b/data/test_multiproject/projects/GreetBot/data/nlu.yml new file mode 100644 index 0000000..542be4d --- /dev/null +++ b/data/test_multiproject/projects/GreetBot/data/nlu.yml @@ -0,0 +1,31 @@ +version: "3.1" +nlu: +- intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon + +- intent: goodbye + examples: | + - good afternoon + - cu + - good by + - cee you later + - good night + - bye + - goodbye + - have a nice day + - see you around + - bye bye + - see you later diff --git a/data/test_multiproject/projects/GreetBot/data/rules.yml b/data/test_multiproject/projects/GreetBot/data/rules.yml new file mode 100644 index 0000000..7d6a413 --- /dev/null +++ b/data/test_multiproject/projects/GreetBot/data/rules.yml @@ -0,0 +1,11 @@ +version: "3.1" +rules: +- rule: respond to greeting + steps: + - intent: greet + - action: utter_greet + +- rule: respond to goodbye + steps: + - intent: goodbye + - action: utter_goodbye diff --git a/data/test_multiproject/projects/GreetBot/domain.yml b/data/test_multiproject/projects/GreetBot/domain.yml new file mode 100644 index 0000000..2a45cd4 --- /dev/null +++ b/data/test_multiproject/projects/GreetBot/domain.yml @@ -0,0 +1,16 @@ +version: "3.1" + +intents: + - greet + - goodbye + +responses: + utter_greet: + - text: "Hey! How are you?" + + utter_goodbye: + - text: "Bye" + +session_config: + session_expiration_time: 60 # value in minutes + carry_over_slots_to_new_session: true diff --git a/data/test_nlg/domain_with_response_ids.yml b/data/test_nlg/domain_with_response_ids.yml new file mode 100644 index 0000000..d77fbf7 --- /dev/null +++ b/data/test_nlg/domain_with_response_ids.yml @@ -0,0 +1,63 @@ +version: "3.1" + +intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + +entities: + - name + + +slots: + name: + type: text + mappings: + - type: from_entity + entity: name + logged_in: + type: bool + influence_conversation: false + mappings: + - type: custom + +responses: + utter_greet: + - text: "Hey! How are you?" + buttons: + - title: "great" + payload: "/mood_great" + - title: "super sad" + payload: "/mood_unhappy" + id: "ID_1" + + - text: "Welcome back {name}! How are you?" + id: "ID_2" + condition: + - type: slot + name: logged_in + value: true + + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" + + utter_did_that_help: + - text: "Did that help you?" + + utter_happy: + - text: "Great, carry on!" + + utter_goodbye: + - text: "Bye" + + utter_iamabot: + - text: "I am a bot, powered by Rasa." + +session_config: + session_expiration_time: 60 # value in minutes + carry_over_slots_to_new_session: true diff --git a/data/test_nlu/test_nlu_validate_files_with_active_loop_null.yml b/data/test_nlu/test_nlu_validate_files_with_active_loop_null.yml new file mode 100644 index 0000000..b1c629c --- /dev/null +++ b/data/test_nlu/test_nlu_validate_files_with_active_loop_null.yml @@ -0,0 +1,34 @@ +version: "3.1" +nlu: +- intent: request_restaurant + examples: | + - how about [indian](cuisine) type of cuisine + - today is [greek](cuisine) food day + - i am looking for [indian](cuisine) cuisine + - im looking for [greek](cuisine) cuisine for [4](number) people + - look for [italian](cuisine) for [4](number) persons + - looking for [greek](cuisine) food in the center of Athens + - [greek](cuisine) food for [4](number) people + - i will order some [greek](cuisine) feta + - we could eat [andalusian](cuisine) tapas and some cerveza + - i want [vegetarian](cuisine) food + - i am up for [portuguese](cuisine) food + - i would like to eat [greek](cuisine) delicacies + - are there any [greek](cuisine) tapas around here + - [indonesian](cuisine) food is the best + - im in for [greek](cuisine) cuisine + - i am hungry for [french](cuisine) fries + - [outside](seating) + - prefer sitting [outside](seating) + - prefer sitting [indoors](seating) + - I would like to seat [inside](seating) please + - I prefer sitting [outside](seating) + - i want to seat [outside](seating) + - i would like to seat [inside](seating) + - kean on sitting [outside](seating) + - i prefer to seat [outside](seating) + - i want to seat in the [outside](seating) space + - let's go [outside](seating) + - [inside](seating) + - with [outside](seating) seats + - to seat [outside](seating) is a great idea diff --git a/data/test_nlu_no_responses/domain_with_only_responses.yml b/data/test_nlu_no_responses/domain_with_only_responses.yml new file mode 100644 index 0000000..d366ae4 --- /dev/null +++ b/data/test_nlu_no_responses/domain_with_only_responses.yml @@ -0,0 +1,10 @@ +responses: + utter_chitchat/ask_name: + - image: "https://i.imgur.com/zTvA58i.jpeg" + text: hello, my name is retrieval bot. + - text: Oh yeah, I am called the retrieval bot. + + utter_chitchat/ask_weather: + - text: Oh, it does look sunny right now in Berlin. + image: "https://i.imgur.com/vwv7aHN.png" + - text: I am not sure of the whole week but I can see the sun is out today. diff --git a/data/test_nlu_no_responses/nlu_no_responses.yml b/data/test_nlu_no_responses/nlu_no_responses.yml new file mode 100644 index 0000000..ace2b50 --- /dev/null +++ b/data/test_nlu_no_responses/nlu_no_responses.yml @@ -0,0 +1,16 @@ +version: "3.1" + +nlu: +- intent: chitchat/ask_name + examples: | + - What is your name? + - May I know your name? + - What do people call you? + - Do you have a name for yourself? + +- intent: chitchat/ask_weather + examples: | + - What's the weather like today? + - Does it look sunny outside today? + - Oh, do you mind checking the weather for me please? + - I like sunny days in Berlin. diff --git a/data/test_nlu_no_responses/nlu_with_unicode.yml b/data/test_nlu_no_responses/nlu_with_unicode.yml new file mode 100644 index 0000000..563f076 --- /dev/null +++ b/data/test_nlu_no_responses/nlu_with_unicode.yml @@ -0,0 +1,163 @@ +version: "3.1" + +nlu: +- intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon + + +- intent: goodbye + examples: | + - good afternoon + - cu + - good by + - cee you later + - good night + - bye + - goodbye + - have a nice day + - see you around + - bye bye + - see you later + +- intent: affirm + examples: | + - yes + - y + - indeed + - of course + - that sounds good + - correct + +- intent: deny + examples: | + - no + - n + - never + - I don't think so + - don't like that + - no way + - not really + +- intent: mood_great + examples: | + - perfect + - great + - amazing + - feeling like a king + - wonderful + - I am feeling very good + - I am great + - I am amazing + - I am going to save the world + - super stoked + - extremely good + - so so perfect + - so good + - so perfect + +- intent: mood_unhappy + examples: | + - my day was horrible + - I am sad + - I don't feel very well + - I am disappointed + - super sad + - I'm so sad + - sad + - very sad + - unhappy + - not good + - not very good + - extremly sad + - so saad + - so sad + +- intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? + +- intent: internet_problem + examples: | + - my internet is slow + - i have very slow wifi + - my internet is very bad + - internet is down + - router not working + - modem not giving internet + - modem problem + - i cant use the internet + - i cant go to google + - google is not working + - ma connection est lente + - j'ai un wifi très lent + - mon internet est très mauvais + - Internet est en panne + - le routeur ne marche pas + - le modem ne donne pas Internet + - problème de modem + - je ne peux pas utiliser Internet + - je ne peux pas aller sur google + - google ne fonctionne pas + - mafi internet + - ma 3ande wifi + - mech meche el internet + - ma fie fout 3al internet + - ma fie esta3mil el wifi + - el connextion manna mechye + - internet khara + - internet 3atil + - el router mano meche + - mech edir fout 3ala el facebook + - الانترنت لدي بطيء + - لدي wifi بطيء جدًا + - الإنترنت الخاص بي سيء للغاية + - الإنترنت معطل + - جهاز التوجيه لا يعمل + - مودم لا يعطي الإنترنت + - مشكلة المودم + - لا أستطيع استخدام الإنترنت + - لا أستطيع الذهاب إلى جوجل + - جوجل لا يعمل + - իմ ինտերնետը դանդաղ է + - ես շատ դանդաղ wifi ունեմ + - իմ ինտերնետը շատ վատն է + - ինտերնետն անջատված է + - երթուղիչը չի աշխատում + - մոդեմ, որը ինտերնետ չի տալիս + - մոդեմի խնդիր + - Ես չեմ կարող օգտվել ինտերնետից + - չեմ կարող մտնել google + - google- ը չի աշխատում + - my connection is down + - my internet is bad + - my wifi is slow + - my interent is not working + - i have internet problems + - i have bad wifi + - i have called ogero on 1515 and they have resolved the noise issue + - my connection is still down + - i reached to ogero on 1515 and they solved the noise + - check my connection + - I contacted ogero + - i don't have internet + - wifi khara + - internet us slow + - My internet is slow + - BAD INTERNET + - my internt is rlly shitty diff --git a/data/test_nlu_no_responses/sara_nlu_data.yml b/data/test_nlu_no_responses/sara_nlu_data.yml new file mode 100644 index 0000000..9d83ce3 --- /dev/null +++ b/data/test_nlu_no_responses/sara_nlu_data.yml @@ -0,0 +1,2377 @@ +version: "3.1" +nlu: +- intent: why_rasa + examples: | + - why should i use rasa instead of google [dialogflow](current_api) + - why rasa + - why should I use rasa? + - why should I switch to rasa? + - what's so great about using Rasa? + - how is rasa's NLU better than [watson](current_api) 's + - why should i use rasa instead of IBM [watson](current_api) ? + - Is it better to use rasa or [luis](current_api)? + - Is rasa better than google [dialogflow](current_api)? + - what sets rasa apart? + - give me a reason to use Rasa + - why should I switch to rasa from [dialogflow](current_api) + - why should I switch from [luis](current_api) + - give me a reason to switch to Rasa from [luis](current_api) + - Why switch to Rasa? + - why switch from [dialogflow](current_api)? + - why not use [watson](current_api)? + - why would you opt for rasa + - why use rasa + - why should I switch + - I meant why you over competitors ? + - why to use RASA + - why is Rasa useful + - why is rasa better? + - why RASA? + - why should i choose rasa + - why is rasa so good? + - why is rasa good + - why do I need rasa + - why should I use Rasa + - why only rasa + - why to use rasa + - why should I migrate to rasa? + - why migrate? + - why switch? + - why not use ibm watson + - why people go for Rasa chatbot? + - why to use rasa over other available platform + - i want to know more about nlu and why is it better than watson or luis + - why is rasa a good nlp libarary + - and why i should not use Tenserflow? + - why would i use your product + - why is rasa interesting + - Why rasa? + - Why choose rasa? +- intent: ask_how_contribute + examples: | + - Can I assist? + - Can I help improve your code at all? + - How can I add code to Rasa + - How can I assist the cause? + - How can I be a contributor? + - How can I be helpful? + - How can I be more involved? + - How can I contribute to your code + - How can I help with the code? + - How can I help you? + - How can be of assistance? + - How can one contribute to this cause? + - How can one contribute? + - How does one go about making their contribution? + - I want to help improve Rasa + - I want to help the cause. + - I want to make Rasa better + - I want to offer assistance + - I want to put some of my effort in. + - I would like to contribute. + - I'm ready to contribute. + - I'm ready to help. + - In what manner can one contribute? + - In what way can I contribute. + - In what ways can I help? + - Is there a way I can assist? + - Is there a way to contribute? + - Is there any way I can contribute? + - Is there some way I can help improve your code? + - Is there some way I can help? + - Tell me how I can contribute + - There must be a way I can put forth my ideas to the situation. + - What can I do to help? + - What could I do to be helpful? + - What could I do to contribute? + - What should I do fo this project? + - What should I work on? + - What ways are there to contribute? + - What ways can one make a contribution? + - are there ways I can contribute? + - how can I contribute? + - how can I help improve your code + - how can I help improve your code? + - how can I help? + - how can I improve Rasa + - how can i contribute to Rasa + - how can i contribute to it + - how contribute to Rasa + - how do I contribute? + - how help Rasa + - how to improve Rasa + - in what ways can I help out? + - what can I do? +- intent: ask_question_in_forum + examples: | + - Can we stop at the forum so I can ask a question + - Help me to find the forum. + - Hey I want to ask a question in the forum + - How do I ask a question on the forum? + - How do I create a thread on the forum? + - How do I find the forum? + - How do I post my question on the forum? + - How do I post on the forum? + - How do i write a forum question? + - How to I post a question on the forum? + - I have an inquiry for the forum + - I have something to ask about at the forum. + - I need someone in the forum to help me + - I need to ask a question in the forum. + - I need to ask something of the forum + - I need to ask the forum something + - I need to get information from the forum + - I want to ask a question in the forum + - I want to ask the forum for an answer + - I want to get help in the forum + - I want to make a forum post. + - I wonder if the forum can answer my question. + - Is the forum the right place to ask questions? + - Lets go to the forum so I can ask my question. + - Take me to the forum help section. + - Where can I ask a question on the forum? + - Where can I find the forum + - Where can I post on the forum? + - Where do I ask questions? + - Where do I post my question? + - Where do I post questions in the forum? + - Where is the forum + - Where should I ask my question on the forum? + - Will the forum take my question? + - can someone show me the forum? + - how can I get help in the forum + - how can I leave a query in the forum? + - how can I post a question in the forum? + - how do I access the forum? + - i need information from posters in the forum + - ok send me to the forum + - to the forum + - need help on chatbot +- intent: ask_which_events + examples: | + - Any other event like rasa meetup in future? + - Does Rasa host any events? + - Can you tell me what kinds of events you have? + - Could you please list the kinds of events that you have? + - Do you have a user group + - Give me the events you have. + - I want to meet Rasa + - Is there a Rasa meetup + - Is there a meetup + - Is there any Rasa meetups? + - Tell me all of the events you have. + - Tell me the events you have + - What are all of the events you have? + - What are the events available? + - What are the events now? + - What are the events that you have? + - What do you know about rasa meetups? + - What events are scheduled for today? + - What events are there? + - What is on the calendar for this month? + - What kinds of events are happening here? + - What kinds of events are on your calendar? + - What kinds of events are on your schedule? + - What kinds of events are scheduled? + - What kinds of events do you host here? + - What types of events are planned? + - Where can I meet Rasas + - Which community events do you have + - Which events are available? + - Which events do you have? + - Which events you got? + - You have rasa meetups? + - can you explain what the events are? + - can you tell all of the events? + - does the community have meet ups? + - how can I meet eh community? + - so what events are there? + - so what exactly are these events? + - what are the events? + - what are the names of all the events? + - what events are there going to be? + - what events will there be? + - what kind of events are there? + - what kind of events will be held? + - what sort of social events are we throwing? + - where can I see a calendar of community events? + - What are the events in [Detroit](location)? + - When are the events in [Paris](location)? + - what are the events in [Berlin](location)? + - What are the events in [Switzerland](location)? + - When are the events in [paris](location)? + - what are the events in [berlin](location)? + - What are the events for [Detroit](location)? + - When are the events for [Paris](location)? + - what are the events for [China](location)? + - What are the events for [New York](location)? + - When are the events for [paris](location)? + - what are the events for [berlin](location)? + - Assuming that there is an upcoming event, when is that event? + - At what time is the next event scheduled? + - At which date the next community event will take place? + - By chance do you know the date of next community event? + - Do you know the exact date for the next community event? + - Do you know when is the next event in [Montreal](location)? + - If there is an upcoming event when is it? + - Is next community event held in 2019? + - Is there a Rasa event in [San Francisco](location) + - Is there an event in [Montreal](location)? + - Is there any special in next community event? + - On what day is the next event scheduled? + - Tell me when the next community event is happening; + - What and when is the next event? + - What even is coming up next and when is it please? + - What is the date of the next community event? + - What's the next community event + - What's the next rasa event + - When does the upcoming event occur? + - When is it planned the next event in [Montreal](location)? + - When is it scheduled the next community event? + - When is it that the next event occurs? + - When is the next community event? + - When is the next event in [Berlin](location) + - When is the next event scheduled? + - When is the next user group meetup + - When will the next event occur in the community? + - Where is next community event held? + - do you have an event in [Berlin](location) + - is there an event in [Montreal](location) + - what community events are there? + - what date is the next community event? + - when is our next group event going to take place? + - when is the event within the community gonna happen? + - when is the next community event gonna be? + - when is the next event? + - when is the next group event going to be? + - when will our next group event be? + - when will the community event take place? + - when will the next community event be? + - will there be an event in my city? + - When is the next event in [california](location)? + - What is the next event in [Paris](location)? + - When is the next event in [detroit](location)? + - What is the next event in [san francisco](location)? + - When is the next event for [Detroit](location)? + - What is the next event for [Seattle](location)? + - When is the next event for [India](location)? + - What is the next event for [paris](location)? +- intent: ask_why_contribute + examples: | + - What can I bring to help your code + - What is the benefit of contributing to your code + - Why add to your business? + - Why aid your opportunity? + - Why be a part of your mission? + - Why contribute to Rasa? + - Why do I want to help with your code + - Why help Rasa's organization? + - Why should I add to your code + - Why should I contribute + - Why should I contribute to Rasa + - Why should I contribute to your code? + - Why should I devote effort to working on your code + - Why should I devote time to your code + - Why should I help to improve Rasa + - what are the benefits of helping + - what do I get if I contribute + - what will i get for the contribution? + - why help out? + - why offer my assistance? + - why should I help? +- intent: contact_sales + examples: | + - But I wanted a sales call + - Can I have a call tomorrow at 3pm? + - Can I have a call? + - HOW CAN I BOOK A SALES CALL ? + - How can i contact the team ? + - I also want to book a sales call + - I wanna have a subscription for your product + - I wanna talk to your sales guy + - I wanna talk to your sales people. + - I want a sales call + - I want an offer for your platform + - I want to book a call + - I want to book a call with your sales team + - I want to buy the rasa platform + - I want to contact sales + - I want to contact the sales team + - I want to get in touch with your sales guys + - I want to learn more about your pricing + - I want to speak with sales + - I want to talk to sales + - I want to talk to someone about your pricing system + - I want to talk to your sales guys + - I want to talk to your sales people + - I want to talk to your sales team + - I want to talk with sales about our project + - I would like to book a call + - I would like to book a sales call + - I would like to contact your sales team please + - I would like to have a call with sales team + - I would like to have a demo scheduled + - I would like to talk to someone from your sales team + - i want to buy the enterprise edition + - i would like rasa enterprise + - purchase rasa enterprise + - buy rasa enterprise + - Ok I want to talk to your sales team immediately + - Please connect me to someone from sales + - Please schedule a sales call + - Sales + - a call + - a sales call with [Rufus Gardner](name) would be nice + - are there different packages customers can book? + - book + - book a call + - book a sale call + - book a sales + - book a sales call + - book an appointment + - book call + - book sales call + - booking a sales call + - booking a sall + - booking sales ca;; + - booking sales call + - but I want a sales call + - call + - call sales + - call with sales team + - can i get a ssales call + - can i speak to the sales team + - can i talk to a sales representative + - can i talk to your disagreeable sales man? + - can someone call me please? + - can you connect me to sales + - connect me to your sales department + - connect to sales + - connect to the sales team + - contact + - contact any sales person + - contact call with sales + - contact sales + - contact sales for me + - contact to sales + - get me the sales team + - give me someone who can explain your business model + - have a call + - have a call with the sales team + - hm ok then i want to talk to the sales dude + - hmmm sales + - how does your pricing work? + - how to book a sales call + - how to book a sales call> + - how to book a sales call? + - i choose the call + - i choose the call with sales + - i need a call from sales + - i need the call request + - i need to know how i can book support + - i need to speak to your sales team + - i need to talk to sales + - i need your data source + - i think I want to talk to your sales folks + - i want one platform please + - i want a sales call + - i want someone to call me + - i want ti booking about booking sales + - i want to be connected to your sales team + - i want to be in touch with sales + - i want to book a appointment + - i want to book a sales call + - i want to bookk a sales call + - i want to buy the rasa platform + - i want to call + - i want to chat to sales + - i want to connect to sales + - i want to connect to your sales team + - i want to connect your sales + - i want to contact sales + - i want to contact sales now + - i want to contact sales support + - i want to contact your sales team + - i want to have a call with sales + - i want to talk to sales + - i would like a call with [Mr Hughes](name) + - i would like to talk to sales please + - i'd like to call [Johnnie Essig](name) + - i'd like to talk to a sales person + - i'd like to talk to sales + - id like a call please + - id like to talk to someone who can explain me what i can do with rasa + - iwant booking sales + - just gimme a call + - let me call the sales team + - let me contact sales + - let me have the call + - let me talk sales + - let me talk to sales + - let me talk to your sales guys + - let me talk to your sales people + - let me talk to your sales people! + - let' contact sales now + - lets talk to sales + - next the sales contact + - obviously talk to your awesome sales team + - ok i want to talk to your sales guys + - ok sales + - ok, well, then a sales call with the fabulous [Theodora Estrada](name) + - please can you book call for me + - please connect me to sales + - request a call + - request call + - request call with sales team + - request sales call + - request sales team + - sales + - sales call + - sales contact + - sales department + - sales pl + - sales please + - sales sales sales + - sales team + - sales team connection + - sign me up, my email is Elizabeth@yahoo.com + - talk to sales team + - we started working with rasa but now we need support + - Can i have a deno + - bookin + - Is there a live demo of rasa somewhere ? +- intent: enter_data + examples: | + - $1 + - 1 euro + - 10000 dollars + - 10000000 + - 100000k + - 10000k + - 100k + - 150,000 USD + - 150,000$/ year + - 2 euro + - 20k + - 240k/year + - 400 trilion + - 5 bucks + - 5 euros + - 5 mln + - 50,000 dollar + - 50,000,000 INR + - 50k + - 60 million INR + - 75000-150000 euro + - A customer service bot + - A wolf bot + - ACME bank + - ACME brands + - Able to integrate with paypal, wordpress, facebook andd twilio + - An ice cube bot + - Country names + - Evaluate Rasa :-) + - Flexible, but looking for low-cost alternative to proof of concept + - I am [Christina Sullivan](name) + - I am [Hattie Rice](name) + - I am [Robert Starks](name) + - I am a [Data Scientist](job_function) + - I am a [data scientist](job_function) + - I am a [head of business intelligence](job_function) + - I am a driver + - I am responsible for our [innovation department](job_function) + - I book a bus ticket + - I do not know yet + - I spend money + - I wanna build a bot that sends the people cute animal pictures based on their favorite color + - I wanna build a super bot to send me cute animal pictures + - I want a bot that sales my product that [Catherine Rodriguez](name) finally can focus on important stuff + - I want to build a bot that can substitute our entire workforce + - I want to build a cool bot + - I want to build a health insurance bot + - I want to build a kick ass bot + - I want to build a lot of different bots + - I want to build a sales bot + - I want to build an FAQ bot + - I want to use pip + - I work as a [frontend dev](job_function) + - I work as a [project manager](job_function) + - I work at [EXAMPLE insurance group](company) as [Head of Innovation](job_function) + - I work at the [NYT](company) + - I work for [Bayer](company) + - I work for [Stanford University](company) + - I work for the AI research group of the turing centre of the [UBC](company), Vancouver, Canada + - I work for the [New York Times](company) + - I work for the [new york times](company) + - I work in [innovation](job_function) + - I work in [project management](job_function) + - I would like to build an ice cube dispenser bot + - I wrote it in [chinese](language) + - I wrote it in [dutch](language) + - I wrote it in [english](language) + - I wrote it in [french](language) + - I wrote it in [german](language) + - I wrote it in [italian](language) + - I wrote it in [mandarin](language) + - I wrote it in [portuguese](language) + - I wrote it in [spanish](language) + - I'm [Gladys Bynum](name) + - I'm [Harvey Cordano](name) + - I'm [Jeanine Hwang](name) + - I'm [Virginia Mason](name) + - I'm [project manager](job_function) + - I'm a [bot developer](job_function) + - I'm a [business woman](job_function) + - I'm a [construction worker](job_function) + - I'm a [conversation designer](job_function) + - I'm a [developer](job_function) + - I'm a [full stack developer](job_function) + - I'm a [janitor](job_function) + - I'm a [machine learning engineer](job_function) + - I'm a [product manager](job_function) + - I'm a [project manager](job_function) + - I'm a [python developer](job_function) + - I'm a [software engineer](job_function) + - I'm a [student](job_function) + - I'm a bot [developer](job_function) + - I'm a real good [engineer](job_function) + - I'm an [AI researcher](job_function) + - I'm an [engineer](job_function) + - I'm in [business](job_function) + - I'm in [project mgmt](job_function) + - I'm the [boss](job_function) + - I'm the [developer](job_function) + - I'm the [lead engineer](job_function) + - Im a [full stack developer](job_function) + - It is Drew@Mccarthy.com + - I’ve trained it in [chinese](language) + - I’ve trained it in [dutch](language) + - I’ve trained it in [english](language) + - I’ve trained it in [french](language) + - I’ve trained it in [german](language) + - I’ve trained it in [italian](language) + - I’ve trained it in [mandarin](language) + - I’ve trained it in [portuguese](language) + - I’ve trained it in [spanish](language) + - My budget is oov + - My email is Richard@Simmons.com + - My name is [Ashleigh Mees](name) + - My name is [chelsea Parker](name) + - My name is [jessie maglione](name) + - My name is [Kenneth Sherman](name) + - My name is [Lee George](name) + - My name is [Louise Caudill](name) + - My name is [Richard smith](name) + - My name is [Shane Goodyear](name) + - My name is [Sondra Boyd](name) + - My name si [tom Harbin](name) + - No job + - None + - Rasa bot + - SCALABLE MINDS + - The master of desaster + - The name of the company is [Daimler](company) + - This is [Kim Vanderveen](name) + - This is [Norma Taylor](name) + - We plan to build a sales bot to increase our sales by 500%. + - Whatever it costs + - $0.00 + - $1000 + - 0 + - 1 million + - 1 million big ones + - 10 m + - 1000000 + - 100000 + - 1000 + - 100 + - 10 + - 10k + - 120000 + - 1231 + - 12 + - 2 million + - 200 bucks + - 200000000 + - 20000 + - 20000k + - 2000k + - 200k + - 25,000 + - 300 rupees + - 300000 + - 300k + - 3 + - 500 million + - 500000 + - 5000 + - 500k + - 50 p + - 5 quid + - 6000000 + - 900 dollars + - 90k + - 99 + - [ACME Mops](company) + - [AI engieer](job_function) + - [AI researcher](job_function) + - [Al Capone](name) + - [Allianz](company) + - [Angel Robinson company](company) + - [BBC](company) + - [BCBSM](company) + - [BCG brazil](company) + - [BCG digital ventures](company) + - [BigBotsInc](company) + - [BigBots](company) + - [Bosch](company) + - [CEO](job_function) + - [COO](job_function) + - [CSI](company) + - [CTO](job_function) + - Carolyn.Eisenhauer@Watkins.com + - [Club Mate](company) + - [David Carter](name) + - [Denise Armstrong's company](company) + - [Developer Advocate](job_function) + - [Developer](job_function) + - Ebony@gmail.com + - [Elise](name) + - [Founder](job_function) + - [Full Stack](job_function) + - [Full stack](job_function). + - [Helvetia](company) + - [I am a freelancer](company) + - [IBM](company) + - [IT manager](job_function) + - [Im Phyllis Howard](name) + - J_Herrera@gmail.com + - [Jamie Moore](name) + - [Jane Baines](name) + - [John Strickland](name) + - K_Claytor@yahoo.com + - K_Rainey@Yochum.net + - [Keith Donnell](name) PhD + - Kristin@yahoo.com + - [Linda Mchone](name) + - [Lithuanian](language) + - [McKinsey Germany](company) + - [Michele Perry](name) + - [Michelle Garcia](name) + - [Michelle Vinion](name) + - [Mr. Sweney](name) + - [N26](company) + - None? + - [Owner](job_function) + - [Product Manager](job_function) + - [Rasa](company) + - Robert.Sparks@gmail.com + - [SAP](company) + - [Scalable Minds](company) + - [Software engineer](job_function). + - [Steven Carter](name)'s company + - Ten + - [Terri Cline](name) + - [William Zelkind](name) + - Willie@gmail.com + - [Zendesk](company) + - a@b.com + - abhbose3k@gmail.com + - [accenture](company) + - [allianz](company) + - [amounts](entity) + - [arabic](language) + - [assistant to the CEO](job_function) + - [bayer](company) + - [botium](company) + - [brand manager](job_function) + - [ceo](job_function) + - [chinese](language) + - [chinese](language) is the language of my bot + - [chinese](language) is the only language but I want to add more + - [clue](company) + - [companies](entity) + - [custom entity](entity) + - [data analyst](job_function) + - [data science engineer](job_function) + - [data scientist](job_function) + - [designer](job_function) + - [dev](job_function) + - [developer advocate](job_function) + - [developer](job_function) + - [distances](entity) + - [dutch](language) + - [dutch](language) is the language of my bot + - [dutch](language) is the only language but I want to add more + - [engineer](job_function) + - [english](language) + - [english](language) is the language of my bot + - [english](language) is the only language but I want to add more + - [french](language) + - [french](language) is the language of my bot + - [french](language) is the only language but I want to add more + - [full stack](job_function) + - [fullstack](job_function) + - [german](language) + - [german](language) is the language of my bot + - [german](language) is the only language but I want to add more + - [google enginer](job_function) + - [growth manager](job_function) + - [hindi](language) + - [how long](entity) + - [intel](company) + - [italian](language) + - [italian](language) is the language of my bot + - [italian](language) is the only language but I want to add more + - [italina](language) + - [klara health](company) + - [manager](job_function) + - [mandarin](language) + - [mandarin](language) is the language of my bot + - [mandarin](language) is the only language but I want to add more + - [marketing](job_function) + - [microsoft](company) + - [ml researcher](job_function) + - [no job](job_function) + - none + - none i will build it from scraps + - [numbers](entity) + - one billion + - one + - one that flatters me every morning + - one that will get me promoted + - one which asks me loads about myself + - [portuguese](language) + - [portuguese](language) is the language of my bot + - [portuguese](language) is the only language but I want to add more + - [product manager](job_function) + - [project manager](job_function) + - [reddit](company) + - [saler](job_function) + - [sales manager](job_function) + - [software developer](job_function) + - [software engineer](job_function) + - [spanish](language) + - [spanish](language) is the language of my bot + - [spanish](language) is the only language but I want to add more + - [susi ai](company) + - [t-mobile US](company) + - [time](entity) + - [ubisoft](company) + - [vodafone](company) + - [wurst co kg](company) + - [xyz](company) + - a big ol transformer + - a bot + - a bot to get a promotion + - a bot which sends cute shiba pictures + - a chatbot for mops - mopbot + - a chatbot for our company + - a chocolate bot + - a cool bot + - a cool boy + - a customer service support system + - a good one? + - a great one + - a health bot + - a insurance tool that consults potential customers on the best life insurance to choose. + - a killer bot + - a pizza bot + - a sales bot + - a sentient robot + - a shitty bot + - a turtle + - a voice bot + - about 10 k + - all the training data was in [chinese](language) + - all the training data was in [dutch](language) + - all the training data was in [english](language) + - all the training data was in [french](language) + - all the training data was in [german](language) + - all the training data was in [italian](language) + - all the training data was in [mandarin](language) + - all the training data was in [portuguese](language) + - all the training data was in [spanish](language) + - amounts of [money](entity) + - an ice cream bot + - around $500,000 per year + - around 200k + - around one millon euros + - badass bot tester + - between 100 to 200.000 + - big old bot + - boo + - bout 4,000,000 INR + - can you try E_Conder@gmail.com instead? + - chatbot + - chief lemonade officer + - chief nerd at rasa technologies + - company: [uber](company) + - conversational + - customer service + - customer service automation + - customer service automation bot + - don't have one + - driver + - eisenkleber limited co kg + - email = Patti.Salazar@gmail.com + - extracting [durations](entity) + - faq + - funny bot + - get [dates](entity) from messages + - how to extract [relationship](entity) + - got it + - half a million + - have no idea + - head of biz deve + - health care + - how much [money](entity) + - i am [self emplayed](job_function) + - i am a [projject manager](job_function) + - i am interested in [ordinals](entity) + - i don't have one + - i don't have it + - i don't know + - i have none + - i have about 200 bucks in my savings account + - i need a bot for customer service automation + - i sell turtles + - i ues [chinese](language) + - i use [chinese](language) + - i use anaconda + - i wanna build all the bots + - i want a great bot to impress my boss + - i want to build a bot + - i want to build a bot about me + - i want to build a health bot + - i want to build all the bots + - i want to build an insurance bot + - i want to build bots + - i want to built a [Eric Jones](name) bot + - i want to extract [names](entity) + - i work in [biz dev](job_function) + - i'd like to build a transformer + - i'd like to build sentient glibber or glitter + - i'm [Herbert Ball](name) + - i'm [head of sales](job_function) + - i'm a [dev](job_function) + - i'm a [developer](job_function) + - i'm a [product manager](job_function) + - i'm a [race car driver](job_function) + - i'm a [solutions architect](job_function) + - i'm a developer + - i'm a glibber and glitter salesman + - i'm in [customer success](job_function) + - i'm in [marketing](job_function) + - i'm in [sales](job_function) + - im a [dev](job_function) + - im a [developer](job_function) + - im a [freelancer](job_function) + - im in [marketing](job_function) + - im lonely + - in health care domain + - it is in [chinese](language) + - it is in [dutch](language) + - it is in [english](language) + - it is in [french](language) + - it is in [german](language) + - it is in [italian](language) + - it is in [mandarin](language) + - it is in [portuguese](language) + - it is in [spanish](language) + - it speaks [chinese](language) + - it speaks [dutch](language) + - it speaks [english](language) + - it speaks [french](language) + - it speaks [german](language) + - it speaks [italian](language) + - it speaks [mandarin](language) + - it speaks [portuguese](language) + - it speaks [spanish](language) + - it's 500000000 + - it's [Katie Betz](name) + - it's R_Iuliucci@yahoo.com + - it's [Robert Weir](name) + - it's Shannon.Adelman@Hurt.com + - it's a small company from the US, the name is [Microsoft](company) + - it's a tech company, [apple](company) + - it's the [moabit yoga studio](company) + - its an [chinese](language) bot + - its an [dutch](language) bot + - its an [english](language) bot + - its an [french](language) bot + - its an [german](language) bot + - its an [italian](language) bot + - its an [mandarin](language) bot + - its an [portuguese](language) bot + - its an [spanish](language) bot + - it’s an [chinese](language) bot + - it’s an [dutch](language) bot + - it’s an [english](language) bot + - it’s an [french](language) bot + - it’s an [german](language) bot + - it’s an [italian](language) bot + - it’s an [mandarin](language) bot + - it’s an [portuguese](language) bot + - it’s an [spanish](language) bot + - it’s available in [chinese](language) + - it’s available in [dutch](language) + - it’s available in [english](language) + - it’s available in [french](language) + - it’s available in [german](language) + - it’s available in [italian](language) + - it’s available in [mandarin](language) + - it’s available in [portuguese](language) + - it’s available in [spanish](language) + - it’s in [chinese](language) + - it’s in [dutch](language) + - it’s in [english](language) + - it’s in [french](language) + - it’s in [german](language) + - it’s in [italian](language) + - it’s in [mandarin](language) + - it’s in [portuguese](language) + - it’s in [spanish](language) + - it’s only in [chinese](language) but I plan to train it in other languages + - it’s only in [dutch](language) but I plan to train it in other languages + - it’s only in [english](language) but I plan to train it in other languages + - it’s only in [french](language) but I plan to train it in other languages + - it’s only in [german](language) but I plan to train it in other languages + - it’s only in [italian](language) but I plan to train it in other languages + - it’s only in [mandarin](language) but I plan to train it in other languages + - it’s only in [portuguese](language) but I plan to train it in other languages + - it’s only in [spanish](language) but I plan to train it in other languages + - it’s trained in [chinese](language) + - it’s trained in [dutch](language) + - it’s trained in [english](language) + - it’s trained in [french](language) + - it’s trained in [german](language) + - it’s trained in [italian](language) + - it’s trained in [mandarin](language) + - it’s trained in [portuguese](language) + - it’s trained in [spanish](language) + - it’s trained only in [chinese](language) + - it’s trained only in [dutch](language) + - it’s trained only in [english](language) + - it’s trained only in [french](language) + - it’s trained only in [german](language) + - it’s trained only in [italian](language) + - it’s trained only in [mandarin](language) + - it’s trained only in [portuguese](language) + - it’s trained only in [spanish](language) + - language = [chinese](language) + - language = [dutch](language) + - language = [english](language) + - language = [french](language) + - language = [german](language) + - language = [italian](language) + - language = [mandarin](language) + - language = [portuguese](language) + - language = [spanish](language) + - language: [chinese](language) + - language: [dutch](language) + - language: [english](language) + - language: [french](language) + - language: [german](language) + - language: [italian](language) + - language: [mandarin](language) + - language: [portuguese](language) + - language: [spanish](language) + - lead generation + - like 60 quid + - mail: Geneva.Favors@yahoo.com + - maybe then instead James@Anast.com + - mi name is [Kathy Wright](name) + - my bot is in [chinese](language) + - my bot is in [dutch](language) + - my bot is in [english](language) + - my bot is in [french](language) + - my bot is in [german](language) + - my bot is in [italian](language) + - my bot is in [mandarin](language) + - my bot is in [portuguese](language) + - my bot is in [spanish](language) + - my business mail is s_Dibenedetto@Simpson.net + - my email is Carole@Hart.com + - my email is Elinor_Stock@Higgenbotham.com + - my email is K_Spivey@yahoo.com + - my email is Kelly@Coulter.net + - my email is M_Jones@Luna.com + - my email is Mia_Gainey@gmail.com + - my email is S_Calderon@Cofield.com + - my email is Virginia@Brown.com + - my emayl is V_Comley@Nelson.com + - my function is [to serve butter](job_function) + - my job function is [developer](job_function) + - my name is [Alexander Kohn](name) + - my name is [betty mclendon](name) + - my name is [Brian Leung](name) + - my name is [Claude Ake](name) + - my name is [Earl Ring](name) + - my name is [Felicia Cosby](name) + - my name is [Frances Kunkle](name) + - my name is [Greg King](name) + - my name is [james culpit](name) + - my name is [Jermaine Mccleery](name) + - my name is [John Evers](name) + - my name is [Joseph Parson](name) + - my name is [Monica Ser](name) + - my name is [Nigel Partida](name) + - my name is [Staci Simpson](name) + - my name is [susan crandall](name) + - my name is [Tabitha Schoenthal](name) + - my name's [Michael Peppers](name) + - my own + - n/a + - no idea + - not sure yet + - not sure yet, we plan with 50 thousand euro at the moment + - nothing + - ok I'm actually an [engineer](job_function) + - ok it's Hee@yahoo.com + - ok its P_Simpkins@Suehs.com + - on that will get me promoted + - one bot + - one trillion dollar + - oov + - oov per year + - operations + - our estimation is 10k + - picking my nose + - pip + - pip is fine + - pip please + - please conda + - please give me instructions for pip + - problem solving + - sales assitant + - sales bot + - sales guy + - so far it only speaks [chinese](language) + - so far it only speaks [dutch](language) + - so far it only speaks [english](language) + - so far it only speaks [french](language) + - so far it only speaks [german](language) + - so far it only speaks [italian](language) + - so far it only speaks [mandarin](language) + - so far it only speaks [portuguese](language) + - so far it only speaks [spanish](language) + - something to talk to my friends while I'm busy working + - the [New York Times](company) + - the [ice cream factory](company) is the company + - the assistant is in [chinese](language) + - the assistant is in [dutch](language) + - the assistant is in [english](language) + - the assistant is in [french](language) + - the assistant is in [german](language) + - the assistant is in [italian](language) + - the assistant is in [mandarin](language) + - the assistant is in [portuguese](language) + - the assistant is in [spanish](language) + - the assistant speaks [chinese](language) + - the assistant speaks [dutch](language) + - the assistant speaks [english](language) + - the assistant speaks [french](language) + - the assistant speaks [german](language) + - the assistant speaks [italian](language) + - the assistant speaks [mandarin](language) + - the assistant speaks [portuguese](language) + - the assistant speaks [spanish](language) + - the bot like you + - the bot should help with HR stuff + - the bot speaks [chinese](language) + - the bot speaks [dutch](language) + - the bot speaks [english](language) + - the bot speaks [french](language) + - the bot speaks [german](language) + - the bot speaks [italian](language) + - the bot speaks [mandarin](language) + - the bot speaks [portuguese](language) + - the bot speaks [spanish](language) + - the bot that helps you choose insurance for the car ;) + - the language is [chinese](language) + - the language is [dutch](language) + - the language is [english](language) + - the language is [french](language) + - the language is [german](language) + - the language is [italian](language) + - the language is [mandarin](language) + - the language is [portuguese](language) + - the language is [spanish](language) + - the language of the ai assistant is [chinese](language) + - the language of the ai assistant is [dutch](language) + - the language of the ai assistant is [english](language) + - the language of the ai assistant is [french](language) + - the language of the ai assistant is [german](language) + - the language of the ai assistant is [italian](language) + - the language of the ai assistant is [mandarin](language) + - the language of the ai assistant is [portuguese](language) + - the language of the ai assistant is [spanish](language) + - the people speak [chinese](language) + - the people speak [dutch](language) + - the people speak [english](language) + - the people speak [french](language) + - the people speak [german](language) + - the people speak [italian](language) + - the people speak [mandarin](language) + - the people speak [portuguese](language) + - the people speak [spanish](language) + - there is no budget + - until now it’s only in [chinese](language) + - until now it’s only in [dutch](language) + - until now it’s only in [english](language) + - until now it’s only in [french](language) + - until now it’s only in [german](language) + - until now it’s only in [italian](language) + - until now it’s only in [mandarin](language) + - until now it’s only in [portuguese](language) + - until now it’s only in [spanish](language) + - user can communicate with the bot in [chinese](language) + - user can communicate with the bot in [dutch](language) + - user can communicate with the bot in [english](language) + - user can communicate with the bot in [french](language) + - user can communicate with the bot in [german](language) + - user can communicate with the bot in [italian](language) + - user can communicate with the bot in [mandarin](language) + - user can communicate with the bot in [portuguese](language) + - user can communicate with the bot in [spanish](language) + - user can talk to my bot in [chinese](language) + - user can talk to my bot in [dutch](language) + - user can talk to my bot in [english](language) + - user can talk to my bot in [french](language) + - user can talk to my bot in [german](language) + - user can talk to my bot in [italian](language) + - user can talk to my bot in [mandarin](language) + - user can talk to my bot in [portuguese](language) + - user can talk to my bot in [spanish](language) + - we are a covert government organisation + - we don't have one + - we plan to build a sales bot to increase our revenue by 100%. + - we plan with 250.000 euro for one year + - we think 4 million INR/ year + - we're building a conversational assistant for our employees to book meeting rooms. + - £50k + - [botonic](company) + - [wordpress](company) + - slack + - e commerce bot + - My name is [manuel](name) + - I am [Aniket](name) + - general and sales + - [rasa](product) + - [botpress](current_api) + - simple bpt + - education bot + - contexual + - Sorry it's not [suleman](name) is [Shehzad](name) + - [x](product) + - [dialogueflow](current_api) + - T10 + - the company is called t10 +- intent: how_to_get_started + examples: | + - can you help me to build a bot + - can you tell me how to build a bot? + - About [Core](product) + - Can I build a FAQ robot with Rasa? + - Can you shw me some information about intallation? + - I wanted to build a bot my product customer support + - Can you tell me more about [NLU](product)? + - Get started + - HOW CAN i connect to rasa + - Help me get started + - Hi, how can i get started with [rasa x](product) + - Hot to get started with rasa + - How can I develop a bot? + - How can I get started with Rasa + - How can I get started with Rasa? + - How can I get started with rasa? + - How can I get started? + - How can I start with RASA on a legacy windows without Python? + - How can I try out Rasa? + - How can i launch a bot? + - How do I build a bot + - How do I download RASA + - How do I download rasa ? + - How do I get started + - How do I get started with Rasa? + - How do I get started? + - How do I start + - How do i get started + - How get started? + - How to build a bot in rasa + - How to download? + - How to get start + - How to get started + - How to get started with Rasa + - How to get started with Rasa [core](product)? + - How to get started with Rasa? + - How to get started? + - How to get starter? + - How to make a bot + - How to start using Rasa + - I am [new](user_type) + - I am [new](user_type) to Rasa + - I like to build a bot + - I need to know if I can use Rasa to build an application? + - I want know about Rasa [Core](product) + - I want to build a bot + - I want to build a chatbot + - I want to create chatbot using Rasa + - I want to implement rasa + - I want to know how to get started with rasa + - I want to know more about [NLU](product) + - I want to know more about [core](product) + - I want to learn more about rasa + - I would like to know more about RASA [NLU](product) + - I'm [new](user_type) + - I'm [new](user_type) in Rasa, help me! + - I'm [new](user_type) to Rasa + - I'm [new](user_type) to rasa + - Im [new](user_type) + - Lets start with the basics + - More about [NLU](product) + - Only [NLU](product) + - Please tell me how I can start? + - Please tell me how to get started + - Please tell me how to get started with rasa + - RASA [NLU](product) + - Rasa [Core](product) + - Rasa [NLU](product) + - Rasa [core](product) + - Show me learning resources about Rasa + - Sure, give me the basics + - Tell me how to get started + - Tell me how to get started with Rasa + - Tell me more about Get started with Rasa + - The components of Rasa? + - What are Rasa's components? + - What are the components of Rasa? + - What are the components of rasa? + - What are the prerequisites for installing RASA + - What do I Need for Rasa implementation? + - Yeah please help me out + - Yes I am [new](user_type) + - Yes, I want to know more about [NLU](product) + - [Core](product) + - [NLU](product) + - [NLU](product) and [Core](product) + - [both](product) + - [both](product) please + - [core](product) + - [core](product) please + - [new](user_type) + - [nlu](product) + - [nlu](product) part + - about [both](product) + - and rasa [nlu](product)? + - can give tell me about components of Rosa + - can i just test features without having to deal with your predefined conversation + - can i try it out + - can u teach me + - can you explain how can i make chatbot like you + - can you explain rasa [core](product) + - can you guide me know to create knowledge base chatbot + - can you help me build a chatbot + - can you help me get started? + - can you help me with the rasa [core](product) ? + - compnnent of rasa + - components in rasa + - components of Rasa? + - components of rasa + - rasa components + - different parts of Rasa + - do you know how to set up a chatbot? + - find out how to get started with Rasa + - from where I should start? + - get starte + - get started + - get started pls + - get started with Rasa + - get started with rasa + - get strarted with rasa + - give me some information on [nlu](product) + - having trouble with rasa installation + - help me build a bot + - help me get started + - help with rasa + - hi can you help e build a chatbot + - hi, can you help in understanding NLU + - how about [nlu](product) + - how about building chatbot + - how can I build a chatbot + - how can I get started + - how can I get started? + - how can I learn rasa + - how can i build a chatbot + - how can i get started with Rasa? + - how can i get started with rasa + - how can i get started? + - how can i start + - how can i start with rasa [core](product)? + - how cna i get started with rasa + - how do I build a bot? + - how do I get rasa [core](product) + - how do I get started + - how do I get started with Rasa + - how do I get started with rasa + - how do I get started? + - how do I start + - how do I use rasa + - how do i build a bot + - how do i build a bot? + - how do i build a chatbot? + - how do i build a rasa chatbot? + - how do i get rasa [core](product) + - how do i get rasa [nlu](product) + - how do i get startd? + - how do i get started + - how do i get started with [NLU](product) + - how do i get started with [nlu](product) + - how do i get started with rasa + - how do i get started with rasa [nlu](product) + - how do i get started with rasa myself? + - how do i get started? + - how do i learn rasa [core](product) + - how do i set up a chatbot? + - how do i sstart + - how do i start + - how do i train rasa [core](product) + - how do i train rasa [nlu](product) + - how do you build a bot + - how easy is it to use rasa? + - how start + - how this Rasa works + - how to build a bot + - how to build a bot? + - how to build a chatbot + - how to build chatbot using rasa + - how to create a basic chat bot + - how to get sarted + - how to get start + - how to get start with Rasa + - how to get started + - how to get started with + - how to get started with Rasa + - how to get started with Rasa? + - how to get started with [nlu](product) + - how to get started with rasa + - how to get started with rasa ? + - how to get started with rasa [nlu](product) + - how to get started with rassa + - how to get started? + - how to get strated + - how to get strated? + - how to learn RASA + - how to learn rasa [core](product) + - how to setup rasa + - how to start + - how to start RASA + - how to start rasa + - how to start with it + - how to start with rasa + - how to start with rasa? + - how to use + - how toget strated? + - how t oget started + - hw to get started with Rasa + - i am [new](user_type) + - i am [new](user_type) to rasa + - i am a [new](user_type) + - i am a [new](user_type) user + - i m [new](user_type) + - i need help with rasa + - i need more info for rasa [core](product) + - i wanna build a bot + - i wanna get started + - i wanna try rasa [nlu](product) + - i want to built a chatbot please help me + - i want to develop a chatbot + - i want to get started + - i want to get started with rasa + - i want to know about RASA [Nlu](product) + - i want to know about the parts of rasa + - i want to know how can buld my own bot + - i want to know how to start with Rasa + - i want to know more about [NLU](product) + - i want to learn about [nlu](product) + - i want to learn about rasa [core](product) + - i want to learn about rasa [nlu](product) + - i want to make intelligence chatbot + - i want to use [nlu](product) + - i want to use rasa to build my chatbot + - i would like more explanation on RASA [Core](product) + - i would like to know how to get started with Rasa + - i'd like to get started with rasa + - i'm [new](user_type) + - i'm [new](user_type) to rasa + - im a [new](user_type) to rasa + - information about rasa + - just Rasa [NLU](product) + - just [NLU](product) + - just rasa [nlu](product) + - let's start + - lets get started + - more about [NLU](product) + - more about [NLU](product) please + - more about [nlu](product) + - more info on components + - more info on components of rasa pls + - more information on components in rasa + - more information on rasa + - need help in finding information about rasa + - ok i am [new](user_type) to Rasa + - ow to get started with Rasa + - parts of rasa + - please teach me + - pls explain how to get started + - rasa [core](product) + - rasa [core](product) quickstart + - rasa [nlu](product) + - so, how do I use rasa? + - start rasa + - tell me about Rasa [Core](product) + - tell me about Rasa [NLU](product) + - tell me about [core](product) + - tell me about [core](product) please + - tell me about [nlu](product) + - tell me about the components + - tell me about the different parts of rasa + - tell me how i can get started with rasa + - tell me how to get started with [core](product) + - tell me how to start + - tell me more about [NLU](product) + - tell me more about how to use rasa + - tell me more about rasa [nlu](product) + - tell me something about [core](product) + - the components of Rasa + - the components of rasa + - want to build a chatbot + - whar are the components of rasa? + - what [rasa_nlu](product) + - what about [nlu](product)? + - what are Rasa's components + - what are the components of RASA + - what are the components of Rasa + - what are the components? + - what are your features ? + - what is [nlu](product) + - what is a component in rasa? + - what is componenbts + - what is components + - what is rasa [nlu](product)? + - what is the best place to get started? + - what is this [nlu](product) thing? + - what's rasa [core](product)? + - what's rasa [nlu](product) + - where can I learn more about rasa + - where can i learn to build a chatbot + - where do i find instructions + - where do i start? + - where should i start from + - where to start the development of rasa + - where to start? + - yes i wanna know more about rasa [nlu](product) + - I am trying to build a bot using rasa + - i am [new](user_type) but so how can i start + - where can i find rasa document + - more about [Rasa open source](product) + - tell me about [rasa open source](product) + - i want to know about [rasa Open source](product) + - get started with [Rasa Open Source](product) + - [Rasa open source](product) + - [rasa open source](product) + - [rasa Open source](product) + - [Rasa Open Source](product) + - [rasa open source](product) then lets talk about [rasa x](product) + - [rasa open source](product) please and thank you + - [rasa open source](product) to begin with + - [open source rasa](product) + - the [rasa open source](product) + - Tell me about [rasa open source](product) + - Rada [open source](product) + - how to train [rasa open source](product) + - More about [Rasa open source](product) + - Rosa [open source](product) + - [rasa open source](product) first + - rasa basics + - how can i get stared + - how to build assistant? + - how to build assistant with rasa? + - how to using you + - What are the coponent + - create chatbot steps + - can you tell me how to create a new rasa project + - can you help me build my bot? + - start + - how to build bot with rasa x +- intent: human_handoff + examples: | + - Can I speak to anyone who can really help me? + - Could I talk to [Tyrone King](name)? + - I don't wanna talk to a bot + - I dont like to talk to a machine + - I want to talk to a human + - I want to talk to the founders + - are there also humans working for your company? + - can I speak to a person? + - can i please speak to a human? + - can you forward me to your team + - can you please connect me to a real rasa employee? + - can you put me in touch with a human? + - do you have human support ? + - gimme a proper human + - give me a human + - give me a human now + - human handoff + - i dont wanna talk to a bot + - i want to speak to a [manager](job_function) + - i want to speak to a real person + - i want to speak to customer service + - i want to talk to a human + - i want to talk to a person + - i want to talk to human + - i want to talk to someone at rasa + - i want to talk to someone else + - i want to talk to someone who is smarter than you + - i would like to speak to a person + - i'd rather speak with a real rasa employee + - id like to talk to a real rasa employee + - let me speak with a real person please + - let me talk to a human + - let me talk to a real person + - please give me a human + - service agent + - someone from customer care + - speak to a real person + - talking to a bot is stupid + - that's annoying I'd like to speak to someone real + - thats not helping, can i talk to human? + - wrong i want to speak to a human + - can i speak to human + - can i speak to your human + - i want to chat with human + - How do I talk to a human + - talk with a human + - Can i talk to a human instead + - nevermind.... you're not human ... I need to talk to a live person + - Can you get a human to assist me? + - Can i talk to a human? + - Can I talk to a human + - Can I speak to a human? + - can i speak to a human + - no, i want to talk to human + - can you hand a conversation over to a human? + - can I talk to human? + - can I talk to human + - talk to human + - i want human :( + - can i talk to human + - i want to talk to a human \ + - i want to speak to human + - can i talk to a real person? + - connect me to a real person + - I need a real person + - can i took with a real person + - let me speak to a real person + - let me speak to a real person please + - i want to talk to a real person +- intent: install_rasa + examples: | + - Can you get me Rasa [Core](product)? + - Can you help me to install Rasa? + - Do you mind helping me install Rasa? + - Help me get Rasa [Core](product). + - Help me install Rasa + - How do I install Rasa Stack? + - How i install + - How to install Rasa + - How to install Rasa Stack + - How to install Rasa [Core](product)? + - How to install Rasa? + - How to install rasa + - How to install rasa stack + - I have chosen Rasa Stack + - I have decided on Rasa Stack + - I need Rasa Stack + - I need Rasa Stack. + - I need assistance in getting Rasa Stack. + - I need to get Rasa Stack up and running. + - I need to install Rasa + - I need to install Rasa [Core](product). + - I need to install Rasa [NLU](product). + - I require Rasa Stack? + - I think I want to install Rasa Stack + - I want info on installing Rasa + - I want to do a Rasa Stack installation + - I want to install Rasa Stack + - I want to install Rasa [Core](product) + - I want to install rasa + - I want to use Rasa Stack + - I'd like to install Rasa [Core](product) + - I'd like to install Rasa [NLU](product) + - I'd like to perform an installation of Rasa Stack + - I'm getting Rasa Stack + - I'm going to install Rasa Stack + - I'm installing Rasa Stack + - Installing Rasa Stack will be extremely helpful to me. + - Installing rasa + - Its urgent for me to install Rasa. + - Just install Rasa Stack + - Let me install Rasa Stack. + - Please assist me with installing Rasa Stack. + - Please help me install Rasa Stack + - Please install Rasa Stack + - Please, I need Rasa [Core](product). + - Rasa Stack is what I will be installing + - Thank you in advance for suggesting I install Rasa [NLU](product). + - Where to get Rasa Stack? + - Yes, I do need Rasa Stack. + - am struck with installation + - am struck with installation of rasa [nlu](product) and [core](product) in my mac book + - can i run rasa on my computer? + - can you help me with installation + - can you help me with installation of rasa [nlu](product) and train my first bot + - download + - dude, i want install rasa + - having some problems with installation + - help me wih the installation + - hi i am not able install rasa demo in my machine + - how can I install RASA + - how can i install python + - how can i install rasa + - how do I install Rasa + - how do I install it? + - how do I install rasa in windows + - how do I install rasa? + - how do I run rasa on windows + - how do i install + - how do i install rasa? + - how to install + - how to install on window + - how to install rasa [core](product)? + - how to install rasa in my system + - how to install rasa on windows? + - how to install rasa stack + - how to install rasa? + - how to install rasa_nlu + - how to install sara in my server + - how to install the rasa stack + - i need help setting up + - i need to download rasa + - i want to install + - i want to install rasa + - i want to use pip to install sara + - install + - install Rasa [NLU](product) + - install Rasa on Linux + - install Rasa on Mac + - install rasa stack + - please tell steps for installing chatbot + - what do I need to install Rasa + - what sould i do to install rasa + - installation steps of rasa + - how can I install [rasa open source](product)? + - How to download rasa + - where do i download rasa + - I have a specific question regarding installation +- intent: nlu_generation_tool_recommendation + examples: | + - are there simpler ways to create [nlu](product) data + - are there tools to create [nlu](product) data + - how can I get [nlu](product) data + - how to work with [nlu](product) data + - i have to less [nlu](product) data + - i need more [nlu](product) data + - i require more [nlu](product) data + - need more data for [nlu](product) + - recommend me some [nlu](product) tools + - where can i get data for the [nlu](product) + - which tools can I use to create [nlu](product) data + - How to generate [NLU](product) using frontend. + - [NLU](product) data generation + - i want a recommendation for an [nlu](product) data generation tool +- intent: nlu_info + examples: | + - I checked the documentation on [entity recognition](nlu_part) but I still don’t understand it + - I checked the documentation on [intent classification](nlu_part) but I still don’t understand it + - I don’t understand [entity recognition](nlu_part) + - I don’t understand [intent classification](nlu_part) + - I don’t understand how you handle [entity recognition](nlu_part) at Rasa + - I don’t understand how you handle [intent classification](nlu_part) at Rasa + - I still don’t get how [entity recognition](nlu_part) works + - I still don’t get how [intent classification](nlu_part) works + - I want to know if rasa works with [duckling](nlu_part) + - I want to learn about [entity recognition](nlu_part) + - I want to learn about [intent classification](nlu_part) + - I was looking for [Duckling](nlu_part) integration + - Rasa NLu + - Tell me about the [entity extraction](nlu_part) + - Tell me about the [entity recognition](nlu_part) + - [Entity recognition](nlu_part) + - [NER](nlu_part) + - [duckling](nlu_part) + - [entity recognition](nlu_part) + - [entity recognition](nlu_part) - what is that? + - [intent classification](nlu_part) + - [intent classification](nlu_part) - what is that? + - [intent classificaton](nlu_part) + - [intent recognition](nlu_part) + - [intent](nlu_part) + - [intent](nlu_part) please + - [intents](nlu_part) + - [recognition](nlu_part) + - an explanation of how [entity recognition](nlu_part) work would help + - an explanation of how [intent classification](nlu_part) work would help + - can you explain me what [intents](nlu_part) are ? + - can you explain to me how [entity recognition](nlu_part) works? + - can you explain to me how [intent classification](nlu_part) works? + - custom [ner](nlu_part) + - do you use [duckling](nlu_part) + - does rasa work with [duckling](nlu_part)? + - hm [intents](nlu_part)? + - how do you integrate [duckling](nlu_part) + - how does [entity recognition](nlu_part) work? + - how does [intent classification](nlu_part) work? + - how is [entity recognition](nlu_part) managed in rasa? + - how is [intent classification](nlu_part) managed in rasa? + - is [duckling](nlu_part) part of rasa? + - it would be helpful to learn more about [entity recognition](nlu_part) + - it would be helpful to learn more about [intent classification](nlu_part) + - sorry its [ner](nlu_part) + - tell me about [entity recognition](nlu_part) + - tell me about [intent classification](nlu_part) + - the [intent](nlu_part) + - what are [intents](nlu_part) ? + - what are [intents](nlu_part)? + - what does [nlu](product) stands for + - what is [duckling](nlu_part) + - what is [duckling](nlu_part)? + - what is [entity recognition](nlu_part)? + - what is [intent classification](nlu_part)? + - what is [intent recognition](nlu_part)? + - what is a [intent](nlu_part)? + - where to [intents](nlu_part)? + - where to train [intents](nlu_part) in rasa? + - what does NLU server do? + - how enttity extrcation works +- intent: pipeline_recommendation + examples: | + - I don’t know which pipeline to use + - can you help me with the pipeline? + - give me a recommendation + - pipeline + - pipeline recommendation + - recommend pipeline + - should I better start with the tensorflow pipeline or spacy? + - spacy or tensorflow, what is better to start? + - what I a good pipeline to start with? + - what [nlu](product) pipeline would you recommend? + - what is the right pipeline to choose? + - what pipeline is better for what i want? + - what pipeline is better? + - what pipeline should I start with? + - what pipeline should i use? + - which pipeline is better? + - nlu pipeline + - how to build a pipelin + - how to build a pipeline for the bot +- intent: signup_newsletter + examples: | + - I also want to subscribe + - I wanna sign up for the newsletter. + - I want the newsletter + - I want to sign up for the newsletter + - I want to sign up for the newsletter. + - I want to subscribe to your newsletter + - I want to subscribing to the Rasa newsletter + - I would like to sign up for the newsletter. + - I would love to subscribe to a newsletter! + - I'd like to subscribe + - I'll subscribe to the newsletter + - Newsletter + - Newsletter please. + - Sign me up for the newsletter. + - Sign up. + - Subscribe + - Subscribe me to the newsletter please! + - Subscribe me to you newsletter + - Subscribe newsletter + - Subscribe to Rasa newsletter + - add me as your subscriber + - add me to the newsletter + - add me to the newsletter list + - add me to the subscription list + - but please sign me up for the newsletter! + - can i get emails from you + - can i sign up to the newsletter too? + - can i subscribe to news letter + - can you add Edward@Paul.com to the newsletter list? + - can you pelase subscribe me to the newsletter + - can you sign me up for the newsletter + - can you subscribe me to the newsletter + - do the newsletter then + - first lets sign up for the newsletter + - get a subscription + - get newsletter + - get the latest news from Rasa + - get the newsletter + - gimme the newsletter + - hello sara, can you subscribe me to the newsletter? + - how about the newsletter + - how about the newsletter signup + - how to subdcribe? + - i also want to sign up for the newsletter + - i go for the newsletter + - i just want to signup for our newsletter + - i just want to signup for your newsletter + - i need the newsletter + - i need this dope newsletter + - i need to be on the newsletter list + - i prefer to get the newsletter + - i want newsletter + - i want on that dope newsletter + - i want on this dope newsletter - my email is R_Grove@gmail.com + - i want subscribe + - i want the newsletter + - i want to suscribe + - i want to be part of the newsletter + - i want to get the newsletter + - i want to join the newsletter list + - i want to join the newsletter mails + - i want to receive the newsletter emails + - i want to receive the newsletter from now on + - i want to receive your nl + - i want to sign up for the newsletter + - i want to signup + - i want to signup for the newsletter + - i want to signup for the nl + - i want to signup for your newsletter + - i want to subscribe + - i want to subscribe to the newsletter with Joseph_Pyles@yahoo.com + - i want to subscribe to your newsletter + - i want to subsribe to the newsletter + - i would like to join the newsletter + - i would like to subscribe + - i would like to subscribe to your newsletter + - i would love to get the newsletter + - i would love to receive the rasa newsletter + - i'd like your newspaper please + - i'm craving the newsletter + - id like to receive the newsletter + - id like to subscribe + - join that newsletter + - let's make a subscribtion + - lets do the newsletter signup + - lets try the newsletter registration + - lets try the newsletter signup + - may i receive the newsletter from now on + - newletter + - news + - newsletter + - newsletter - my email is Mabel@Brown.com + - newsletter it is + - newsletter please + - newsletter please my email is M_Moore@yahoo.com + - newsletter pls + - newsletter registration + - newsletter registration first + - newsletter subscription + - newsletter, here is my email: Marcus.Miller@yahoo.com + - newslettwr + - nl + - now i want to signup for the newsletter + - oh actually i want to get the newsletter + - please send me the newsletter + - please send newsletter to Robert@yahoo.com + - please sign me up for the newsletter + - please subscribe me to the newsletter gregory_lilley@yahoo.com + - please subscribe me to your newsletter + - register me for the newsletter + - send me the newsletter + - sign me up for that newsletter + - sign me up for the newsletter - my email is Carolyn_Caskey@yahoo.com + - sign me up for the rasa newsletter + - sign up + - sign up for newsletter + - sign up for the NL + - sign up newsletter + - sign up to the newsletter + - subcribe + - subscribe + - subscribe Bruce_harryman@Olsen.com to the newsletter + - subscribe Denise@gmail.com to the newsletter + - subscribe me to newsletter + - subscribe me to the newsletter + - subscribe my email Evan@Palmer.net to the newsletter + - subscribe to newsletter + - subscribe to our newsletter + - subscribe to the newsletter + - subscribe to your newsletter + - subscribing to our newsletter + - subscrime me + - subscription newsletter + - subsribing to our newsletter + - to make a subscribtion + - what about signing up for the newsletter? + - yeaaah lets go for the newsletter + - yeah how about the newsletter + - yes I would like to subscribe + - yes subscribe me + - yes.I.would.like.to.subscrbe +- intent: source_code + examples: | + - Can u tell where is ur code + - Where can I find your source code? + - Where can i find the source code + - can i know your source code ? + - can i look at your source code + - can i see your code + - demo bot source code + - do u give me the code + - hey can you provide me the code of yours + - how can i get the code for the demo bot? + - how do u work? + - how it works? + - how to get the source code + - i need source code + - i need the source code to this bot + - i want to use your source code + - is your code available? + - source code + - we want to have full code of rasa chatbot + - what is your source code + - what's your source code? + - where can I download the source code? + - where can I find the rasa source code? + - where can i find this code + - where is the source code? + - where is your source code + - what is your github link + - yes with your source code + - your code + - your code please + - i need nlu.md file + - i need smalltalk.md file + - source + - github link? +- intent: switch + examples: | + - How to migrate from [DialogFlwo](current_api) + - Do you have any tutorials how to migrate from [dialogflow](current_api)? + - How to migrate a bot from [DialogFlow](current_api) to Rasa? + - How to migrate from [DialogFlow](current_api) to Rasa? + - How to migrate from [DialogFlow](current_api)? + - How to migrate from [Luis](current_api)? + - How to migrate to [DialogFlow](current_api)? + - I am using [dialogflow](current_api) - how can I migrate + - I currently use [LUIS](current_api) + - I currently use [dialog flow](current_api) + - I use [DialogFlow](current_api) + - I use [luis](current_api) + - I use [wit.ai](current_api) + - I want to change from [dialogflow](current_api) to rasa + - I want to convert my [dialog flow](current_api) bot to rasa + - I want to move from [LUIS.ai](current_api) to Rasa + - I want to switch from [dialog flow](current_api) + - I want to switch from [dialogflow](current_api) to rasa + - Migration please + - Yes I want to switch from [LUIS](current_api) to rasa + - [DialogFlow](current_api) + - [LUIS](current_api) + - [bot framework](current_api) + - [chatfuel](current_api) + - [luis.ai](current_api) + - [luis](current_api) + - [luis](current_api) bot can migrate to raza bot ? + - [wit](current_api) + - can i migrate my [luis](current_api) bot to raza + - can i switch from [luis](current_api) to rasa? + - how can i migrate from [dialogflow](current_api)? + - how do you switch from [dialogflow](current_api) + - how to export [dialogflow](current_api) data to rasa + - i am switching from [luis](current_api) + - i can migrate microsoft [luis](current_api) bot to raza? + - i want to switch from [luis](current_api) to rasa + - i'm migrating from [LUIS](current_api) + - im migrating from [dialogflow](current_api) + - im moving [luis](current_api) + - migration from [LUIS](current_api) + - migration from [dialogflow](current_api) + - switching + - switching from [DialogFlow](current_api) + - migrate to rasa + - how to migrate my bot to rasa + - switch to rasa from another platform + - migrate to rasa from another tool + - switch to rasa + - from which tools can I migrate to rasa? + - can I migrate to rasa from another tool? + - [dialogflow](current_api) and implementation from scratch + - how to migrate to [dialogueflow](current_api) + - switch from [dilogueflow](current_api) + - how do i migrate from [dialogflow](current_api) +- intent: technical_question + examples: | + - how to restart the rasa server + - i don't want to run rasa, i want to restart it + - action_restart in rasa + - i want to know restart action + - how can i restart conversation on chatbot + - how to restart story if am drooping in between + - how to restart rasa + - how to restart the rasa + - how to restart rasax + - docker is restarting + - restart server + - I am not able to restart action in some action + - how to restart rasa? + - how do you restart a story? + - Create ecommerce bot + - How can I visualise conversation flow? + - How many languages does Spacy support? + - and your REST API doesn't work + - can i makae rest calls + - can we use regex is rasa code + - does it support AI + - how do you retrieve previous messages + - how many words can you handle? + - how to use flask? + - how to visualise dialogue flow + - is this test compatible with linux? + - what database rasa use + - what is significance of domain.yml file + - what is the policy + - will this work on windows server + - work with buttons? + - can I install this on a mac? + - database rasa is using + - what is the difference between you and LUIS + - what is the latest version of rasa? + - Can I use Rasa for E-Mail + - Can you send messages based on events? + - How did rasa works? + - How do I use ngrok with [rasa x](product)? + - I want to know more about tracker + - What is the difference between entities and slots? + - Where can I get the source code of Rasa? + - Which other tools can be used to create chatbots? + - an I use Rasa for e-mail applications + - any open source GUI rasa have? + - any other tools to create chatbots? + - can i install on may mac + - can rasa run standalone + - db processing + - do you have docker image for rasa? + - does rasa support prestashop? + - hey can i run this onpremise + - how about interactive learning + - how can I get a docker image + - how can I train data + - how can i deploy my server on production? + - how i deploy my bot on production server? + - how to get the metadata file + - how to integrate RASA with customer data? + - how to use formaction + - how to use form actions + - how to use forms + - interactive learning? + - is rasa core able to run standalone? + - ok quick question here do i download this api + - tensorflow 1.10.0 has requirement numpy<=1.14.5,>=1.13.3, but you'll have numpy 1.16.0 which is incompatible. + - training model? + - what are the features does rasa have? + - what does on-premise mean? + - what infrastructure is required to run a bot? + - what is a synonym called? + - what is endpoint + - what is pip? + - what is the last version of rasa [core](product)? + - what technologies did u use to create more mature chatbot? + - what's pip + - yes what if i have to code open end responses into some categories + - best policies to be used + - Deploy to a Server + - Hello, Sara. How can I configure [etnity extraction](nlu_part) for [russian](language) lnguage? + - can I use Rasa with my Raspberry Pi + - Can I run Rasa on a raspberry pi ? + - can I run rasa on a raspberry pi + - Can I run rasa on a raspberry pi ? + - Is it possible to integrate Rasa with Android to run on mobile devices + - What is the min requirements to run rasa + - TypeError: 'module' object is not callable + - deploy rasa chat bot in flask + - How can i automate retraining of my rasa models + - can i user rasa for my text classification problem? + - how to initialize a new project? + - how to train model + - what are the policy available + - i need help with policies + - how to connect mongodb + - what is fallback policy in rasa + - what is a webhook + - how to handle sending scheduled message to custom webhooks + - actions + - actions on rasa + - how to add dropdowns? + - replace default nlu with custom component + - which technology is used to create you + - rasa-core error + - I am getting some error + - I have a problem + - I need help with a problem + - can you help me with this problem + - can you help with some documentation + - conda threw some weird error + - conda throws some unexpected error + - getting some error + - i am having trouble setting this up + - technical side of things? + - there is error + - why do I get errors using rasa? + - there are some python incompatibilities + - can this be integrated with mongo db + - Can you help me with forms + - can tell me about rasa_sdk + - rasa sdk + - where is rasa sdk? + - Do you have a python sdk? + - RASA sdk + - do RASA has sdk to develop bot + - python sdk + - how to run sdk endpoint in background + - sdk + - where can i find api documentation for rasa x + - where is the api for rasa x + - Hello, where can I find the paper about DIET? + - Is there a technical paper about DIET? + - domain + - How do I get yes / no answer buttons + - I am searching the changlog + - rasa templates + - how to add in my website + - php + - php code + - what is the difference between slot and entity + - is slot teh same as entity + - what should I do when I want to use a binary slot + - How d I use a boolean slot + - what are values of a boolean slot + - fallback + - stories files + - use of stories files + - installation error + - bash: poetry: command not found + - Can i use NLU on its own + - do we need to write training data nlu.md + - For training data, to we need to exclusively write the file in MD format? + - How can i talk to RASA through the url + - what is action server + - is it a best practice to connect an external cms + - can you show me buttons + - I'd like to handle chitchat + - Can Rasa be incorporated into iOS apps? + - how to implement buttons + - buttons + - implement buttons + - I want to build RASA DIET in google colab + - tensorflow-text + - installation of tensorflow-text + - ConveRTFeaturizer + - whatsapp bro + - What does the NLU pipeline do + - what does the nlu pipeline do + - What is DIET + - how can create multilingual chatbor + - websocket + - hosting + - host models + - What is COnvert? + - Hi the command rasa init doesn't do anything in windows + - what is the knowledge base server + - testing chatbot + - what is knowledge base + - knowledge base action + - multipass issue + - embeddings + - how to write stories to train rasa + - tell me about the nlu training data format + - how can i get data from a database and use them as a response to a question? + - how to add a database? + - I want to integrate a database and look up values based on an entity the user gave me. How is this possible? + - what is default fall back + - fallback actions + - what is custom actions + - I am facing some issues with LMS + - my nlu cant detect entities + - how do i detect entities + - how to evaluate model + - Can I have multiple .md data files? + - response selector? + - does mongodb works for rasax + - tensorflow + - credentials.yml + - credentials + - how can I use transformers + - how long to train + - what model do you use + - technical question + - rasa core agent information + - rasa shell + - what is an intemt + - Rasa provides me recall and precision? + - i want to run rasa x in ibm cloud + - how to build stories + - testing + - whats tensorflow + - what is a custom action? + - which python libraries are used + - Should I run the 'rasa init' command in the anaconda prompt ? + - Where to run rasa init command ? + - how to set threshold ? + - how to you exit the server + - is Rasa works with Unity3d? + - rasa uses deep learning ? + - i want to extract predefined entity from user query +- intent: restart + examples: | + - yep you can restart + - Please restart this chat/ + - ok restart please + - please restart the bot + - restart this conversation + - i want to restart + - why don't you restart???? + - hey, i said restart + - restart session pls + - restart ps +- synonym: english + examples: | + - English + - en + - eng + - Eng + - ENGLISH +- synonym: ' duration' + examples: | + - durations + - how long +- synonym: USA + examples: | + - US + - United States + - U.S. + - usa +- synonym: United Kingdom + examples: | + - UK + - U.K. +- synonym: all + examples: | + - both +- synonym: core + examples: | + - CORE + - Core +- synonym: custom actions + examples: | + - Actions + - Custom actions + - actions +- synonym: date + examples: | + - dates +- synonym: dialogflow + examples: | + - dialog flow + - DialogFlwo + - DialogFlow + - google + - dialogueflow + - dilogueflow +- synonym: distance + examples: | + - distances +- synonym: entity recognition + examples: | + - entities + - ner + - entity extraction + - Entity recognition + - NER + - etnity extraction + - recognition +- synonym: full stack developer + examples: | + - full stack develope +- synonym: helvetia + examples: | + - Helvetia +- synonym: intent classification + examples: | + - intent classificaton + - intent + - intent recognition + - intents +- synonym: italian + examples: | + - italina +- synonym: luis + examples: | + - LUIS + - luis.ai + - LUIS.ai + - Luis +- synonym: money + examples: | + - amounts +- synonym: name + examples: | + - names +- synonym: nlu + examples: | + - Natural Language Understanding + - Nlu + - rasa_nlu + - NLU +- synonym: organisation + examples: | + - companies +- synonym: rasa + examples: | + - Rasa + - rasa open source + - Rasa Open Source + - Rasa open source + - rasa Open Source + - open source + - open source rasa + - RASA open source +- synonym: stack + examples: | + - fullstack + - full + - Full stack + - full staclk + - full framework + - full stack + - Full + - Full Stack +- synonym: wit.ai + examples: | + - wit +- synonym: x + examples: | + - Rasax + - RASAX + - Rasa X + - rasa x + - RASA X + - X + - rasax +- regex: greet + examples: | + - hey[^\s]* +- regex: zipcode + examples: | + - [0-9]{5} diff --git a/data/test_number_nlu_examples/nlu.yml b/data/test_number_nlu_examples/nlu.yml new file mode 100644 index 0000000..36b64da --- /dev/null +++ b/data/test_number_nlu_examples/nlu.yml @@ -0,0 +1,13 @@ +version: "3.1" + +nlu: +- intent: greet + examples: | + - Hello! + - Howdy! + +- intent: ask_weather + examples: | + - What's the weather like today? + - Does it look sunny outside today? + - Oh, do you mind checking the weather for me please? diff --git a/data/test_number_nlu_examples/rules.yml b/data/test_number_nlu_examples/rules.yml new file mode 100644 index 0000000..cd55903 --- /dev/null +++ b/data/test_number_nlu_examples/rules.yml @@ -0,0 +1,11 @@ + +rules: + - rule: provide_weather + steps: + - intent: ask_weather + - action: utter_weather + + - rule: greet + steps: + - intent: greet + - action: utter_greet diff --git a/data/test_number_nlu_examples/stories.yml b/data/test_number_nlu_examples/stories.yml new file mode 100644 index 0000000..c5f438a --- /dev/null +++ b/data/test_number_nlu_examples/stories.yml @@ -0,0 +1,8 @@ + +stories: + - story: simple_story + steps: + - intent: greet + - action: utter_greet + - intent: ask_weather + - action: utter_weather diff --git a/data/test_response_selector_bot/__init__.py b/data/test_response_selector_bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/data/test_response_selector_bot/config.yml b/data/test_response_selector_bot/config.yml new file mode 100644 index 0000000..28d4de3 --- /dev/null +++ b/data/test_response_selector_bot/config.yml @@ -0,0 +1,15 @@ +recipe: default.v1 +assistant_id: test_response_selector_bot +language: en + +pipeline: + - name: "WhitespaceTokenizer" + - name: "CountVectorsFeaturizer" + - name: KeywordIntentClassifier + - name: ResponseSelector + epochs: 5 + learning_rate: 0.01 + random_seed: 42 + +policies: + - name: RulePolicy diff --git a/data/test_response_selector_bot/data/nlu.yml b/data/test_response_selector_bot/data/nlu.yml new file mode 100644 index 0000000..42748ad --- /dev/null +++ b/data/test_response_selector_bot/data/nlu.yml @@ -0,0 +1,116 @@ +version: "3.1" + +nlu: +- intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon + +- intent: goodbye + examples: | + - good afternoon + - cu + - good by + - cee you later + - good night + - bye + - goodbye + - have a nice day + - see you around + - bye bye + - see you later + +- intent: affirm + examples: | + - yes + - y + - indeed + - of course + - that sounds good + - correct + +- intent: deny + examples: | + - no + - n + - never + - I don't think so + - don't like that + - no way + +- intent: mood_great + examples: | + - perfect + - great + - amazing + - feeling like a king + - wonderful + - I am feeling very good + - I am great + - I am amazing + - I am going to save the world + - super stoked + - extremely good + - so so perfect + - so good + - so perfect + +- intent: mood_unhappy + examples: | + - my day was horrible + - I am sad + - I don't feel very well + - I am disappointed + - super sad + - I'm so sad + - sad + - very sad + - unhappy + - not good + - not very good + - extremly sad + - so saad + - so sad + +- intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? + +- intent: chitchat/ask_name + examples: | + - What is your name? + - May I know your name? + - What do people call you? + - Do you have a name for yourself? + +- intent: chitchat/ask_weather + examples: | + - What's the weather like today? + - Does it look sunny outside today? + - Oh, do you mind checking the weather for me please? + - I like sunny days in Berlin. + +responses: + utter_chitchat/ask_name: + - image: "https://i.imgur.com/zTvA58i.jpeg" + text: hello, my name is retrieval bot. + - text: Oh yeah, I am called the retrieval bot. + + utter_chitchat/ask_weather: + - text: Oh, it does look sunny right now in Berlin. + image: "https://i.imgur.com/vwv7aHN.png" + - text: I am not sure of the whole week but I can see the sun is out today. diff --git a/data/test_response_selector_bot/data/rules.yml b/data/test_response_selector_bot/data/rules.yml new file mode 100644 index 0000000..9f696f4 --- /dev/null +++ b/data/test_response_selector_bot/data/rules.yml @@ -0,0 +1,18 @@ +version: "3.1" + +rules: + +- rule: Say goodbye anytime the user says goodbye + steps: + - intent: goodbye + - action: utter_goodbye + +- rule: Say 'I am a bot' anytime the user challenges + steps: + - intent: bot_challenge + - action: utter_iamabot + +- rule: Response with a chitchat utterance whenever user indulges in some chitchat + steps: + - intent: chitchat + - action: utter_chitchat diff --git a/data/test_response_selector_bot/domain.yml b/data/test_response_selector_bot/domain.yml new file mode 100644 index 0000000..b944ba5 --- /dev/null +++ b/data/test_response_selector_bot/domain.yml @@ -0,0 +1,40 @@ +version: "3.1" + +intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + - chitchat + +responses: + utter_greet: + - text: "Hey! How are you?" + buttons: + - title: "great" + payload: "/mood_great" + - title: "super sad" + payload: "/mood_unhappy" + + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" + + utter_did_that_help: + - text: "Did that help you?" + + utter_happy: + - text: "Great, carry on!" + + utter_goodbye: + - text: "Bye" + + utter_iamabot: + - text: "I am a bot, powered by Rasa." + +session_config: + session_expiration_time: 60 # value in minutes + carry_over_slots_to_new_session: true diff --git a/data/test_response_selector_bot/tests/test_stories.yml b/data/test_response_selector_bot/tests/test_stories.yml new file mode 100644 index 0000000..299f954 --- /dev/null +++ b/data/test_response_selector_bot/tests/test_stories.yml @@ -0,0 +1,30 @@ +version: "3.1" + +stories: +- story: test 0 - both predictions should be the same in the test stories + steps: + - user: | + What is your name? + intent: chitchat/ask_name + - action: utter_chitchat/ask_name + +- story: test 1 - test retrieving full utter action when predicted wrongly + steps: + - user: | + What is your name? + intent: chitchat/ask_name + - action: utter_chitchat/ask_weather + +- story: test 2 - test that fails correctly without retrieval intents + steps: + - user: | + What is your name? + intent: chitchat + - action: utter_goodbye + +- story: test 3 - test that works well without retrieval intents + steps: + - user: | + What is your name? + intent: chitchat + - action: utter_chitchat diff --git a/data/test_responses/default.yml b/data/test_responses/default.yml new file mode 100644 index 0000000..b1a8191 --- /dev/null +++ b/data/test_responses/default.yml @@ -0,0 +1,5 @@ +responses: + utter_chitchat/ask_weather: + - text: It's sunny where I live + utter_chitchat/ask_name: + - text: I am Mr. Bot diff --git a/data/test_responses/responses_utter_rasa.yml b/data/test_responses/responses_utter_rasa.yml new file mode 100644 index 0000000..2a91f86 --- /dev/null +++ b/data/test_responses/responses_utter_rasa.yml @@ -0,0 +1,3 @@ +responses: + utter_rasa: + - text: this is utter_rasa! diff --git a/data/test_restaurantbot/config.yml b/data/test_restaurantbot/config.yml new file mode 100644 index 0000000..6af08af --- /dev/null +++ b/data/test_restaurantbot/config.yml @@ -0,0 +1,13 @@ +recipe: default.v1 +assistant_id: test_restaurant_bot +language: en + +pipeline: + - name: WhitespaceTokenizer + - name: CountVectorsFeaturizer + - name: DIETClassifier + epochs: 30 + random_seed: 2021 + +policies: + - name: RulePolicy diff --git a/data/test_restaurantbot/data/nlu.yml b/data/test_restaurantbot/data/nlu.yml new file mode 100644 index 0000000..b0a8684 --- /dev/null +++ b/data/test_restaurantbot/data/nlu.yml @@ -0,0 +1,39 @@ +version: "3.1" +nlu: +- intent: request_restaurant + examples: | + - how about [indian](cuisine) type of cuisine + - today is [greek](cuisine) food day + - i am looking for [indian](cuisine) cuisine + - im looking for [greek](cuisine) cuisine for [4](number) people + - look for [italian](cuisine) for [4](number) persons + - looking for [greek](cuisine) food in the center of Athens + - [greek](cuisine) food for [4](number) people + - i will order some [greek](cuisine) feta + - we could eat [andalusian](cuisine) tapas and some cerveza + - i want [vegetarian](cuisine) food + - i am up for [portuguese](cuisine) food + - i would like to eat [greek](cuisine) delicacies + - are there any [greek](cuisine) tapas around here + - [indonesian](cuisine) food is the best + - im in for [greek](cuisine) cuisine + - i am hungry for [french](cuisine) fries + - [outside](seating) + - prefer sitting [outside](seating) + - prefer sitting [indoors](seating) + - I would like to seat [inside](seating) please + - I prefer sitting [outside](seating) + - i want to seat [outside](seating) + - i would like to seat [inside](seating) + - kean on sitting [outside](seating) + - i prefer to seat [outside](seating) + - i want to seat in the [outside](seating) space + - let's go [outside](seating) + - [inside](seating) + - with [outside](seating) seats + - to seat [outside](seating) is a great idea + +- intent: inform + examples: | + - looking forward to eat some [greek](cuisine) souvlaki + diff --git a/data/test_restaurantbot/data/rules.yml b/data/test_restaurantbot/data/rules.yml new file mode 100644 index 0000000..10b6021 --- /dev/null +++ b/data/test_restaurantbot/data/rules.yml @@ -0,0 +1,5 @@ +version: "3.1" +rules: + - rule: request restaurant + steps: + - intent: request_restaurant diff --git a/data/test_restaurantbot/data/stories.yml b/data/test_restaurantbot/data/stories.yml new file mode 100644 index 0000000..4766886 --- /dev/null +++ b/data/test_restaurantbot/data/stories.yml @@ -0,0 +1,5 @@ +version: "3.1" +stories: + - story: inform + steps: + - intent: inform diff --git a/data/test_restaurantbot/domain.yml b/data/test_restaurantbot/domain.yml new file mode 100644 index 0000000..ef578b5 --- /dev/null +++ b/data/test_restaurantbot/domain.yml @@ -0,0 +1,17 @@ +version: "3.1" + +intents: + - request_restaurant: + use_entities: + - cuisine + - seating + - inform + +entities: + - cuisine + - feedback + - seating + +session_config: + session_expiration_time: 60 # value in minutes + carry_over_slots_to_new_session: true diff --git a/data/test_selectors/nlu.yml b/data/test_selectors/nlu.yml new file mode 100644 index 0000000..2810ebc --- /dev/null +++ b/data/test_selectors/nlu.yml @@ -0,0 +1,246 @@ +version: "3.1" + +nlu: +- intent: faq/meta_inquire-ask_bot-challenge + examples: | + - What is your name? + - Are you a rasa bot? + - Am I chatting with a human? + - Am I talking to a bot? + - Are you human? + - So you can't tell if you're doing well? + - What are you? + - Who am I talking to? + - Who are you? + - Who made you? + - You're a human, right? + - are you a bot? + - who do you work for? + - What's your name? + - who are you ? + - What is Carbon bot + - I'm obviously talking to a computer + - am i taking to bot + - bot? + +- intent: faq/ask_howdoing + examples: | + - how are you? + - how is it going? + - how's it going? + - how are things? + - are you well? + - what's up? + - wassssup + - wassup + - hey what is up + - how's you + - how ya going? + - how you doing? + - how are you doing + - sup + - 'sup + - how you doing + - Hello how are you + - Whats up? + - are you okay? + - How are you + - how are you + - whats'up + +- intent: faq/meta_inquire_capabilities + examples: | + - Hmm... I don't know. What can you do? + - What is this + - what questions can you answer + - so, what can you do? + - what kinds of tasks can you help with? + - alright anything else? + - is that it? + - alright is that it? + - so what can you do for me + - Ok. What can you do then? + - So what can you help me with? + - Tell me what you can do + - Well... I thought you could tell me how I could offset my emissions! + - What can you do? + - What can you help me with? + - What lies within the realm of you abilities, dear bot? + - can you do anything? + - help me + - how can you help me? + - how can you help? + - what can this bot do? + - what can you actually do? + - what can you do? + - what could you do for me? + - What else can you tell me? + - how can you help with carbon offsets + - what can i ask you + - how can i help? + - Thanks anything else that you can do + - what csn you do for me + - What do you mean by this if I have to go on website why should I have you in first place? + - what do you do? + - what you can do + - anything else ? + +- intent: faq/oos-future_inquire-ask + examples: | + - How do we know that airplanes emit CO2? + - do you have anymore information? + - Is CO2 harmful? + - what else should I know? + - what are you doing + - why is this side effect "happy"? + - Can you debate the most common misconceptions about climate change? + - Can you give me some things to read? + - Could you give me a QR code, so I can share you? + - Could you give me a link, so I can share you? + - Hi. Do you know about climate change? + - How do you reduce emissions? + - How does climate change work? + - What are greenhouse gas emissions? + - What are the most common misconceptions about climate change? + - What are the most important sources of greenhouse gas emissions? + - What emits CO2? + - What is climate change? + - What is so bad about greenhouse gas? + - Why did you say this? + - Why does climate change matter? + - Why is it called "greenhouse"? + +- intent: faq/oos_inform + examples: | + - Chuck Norris can stop climate change with one hand. + - It's cold outside. + - it's warm outside + - Bacor + - Need a ride from 75 Beekman st [Plattsburgh](city) ny to + - i read in information + - tell me more, please + - that was rude + - Nothing + - Hello, I think you have an interesting profile, I wonder if you would like to participate in production of content with me. I do interviews, skits, commercials, pranks, fashion and music videos. To see examples what I do please see my yt I want to grow my audience and find more projects with a budget. more fb pages on various subjects with backlink purposes to reach different markets. I also do remote interviews. + - piefgwngpiVNP + - Problem making or receiving payment through PayPal + - there are way too many flights + - Buying carbon oddsets for a Christmas oresent + - I dont really trust the UN + - buy a flight ticket for me + - I will consider purchasing offsets next time I fly. + - Tell him to meet me in marietta ohio come with me please + - L o st themdink frving + +- intent: faq/oos_inquire + examples: | + - Great! What is the weather like in [Berlin](city)? + - What do you know about Chuck Norris? + - What is the answer to life, the universe, and everything? + - do you have any thanksgiving plans + - can you suggest some? + - how fast the bot move? + - oh, ok well can you give me more info about my flight then + - What are we doing here buddy? + - What is christmas? + - Can you come with me please + - Yes can you come with me please meet me in [marietta](city) Ohio + - Hi his me miranda I love tom cruise can you make it happen + - Can you hep fix it + - what is the weather ? + - Are you sending me things Roy + +- intent: faq/oos_request + examples: | + - make me coffee + - play the lottery + - I want to know name of the flight? + - Can you get me the specific flight information? + - Can I give you feedback? + - I want to pay the money from paypal + - can I pay with credit card + - book flight + - Deliver a pizza + - how to implement faq + - What would you suggest where to go for a romance places + - Can you fyling him here please i love him so much + - Can you fyling him here please + - Can you ask him please to fyling here do you watch underworld movie he in it + - Can you till him that to fyling here to meet me please + - Please stop message him to flying here please + - Carolina tell shane brolly that please + +- intent: faq/placeholder + examples: | + - and + - so what do I do? + - what do i do + - . + - ok so same + - then + - next + - attitude + - talk to me + - i + - something thing + - Hmm + - Hmm... + - I see + - hmm + +- intent: faq/takeacut + examples: | + - do you get paid for offsets + - so do you get a cut when I buy these? + - what do you get out of this + - what are you getting from this? + - what you get out of it? + - how does this benefit you? + - what's the catch? + - is there a catch? + - there must be a catch + - what's the business model here? + - are you gonna take a cut? + - do you make money off this + - do you make money if I buy these offsets? + - who benefits from this? + - do you get some of that? + - who is taking a cut + - do you get a cut + - are you non-profit? + - Does the company take any of the money I pay for offsets? + +responses: + utter_faq/meta_inquire-ask_bot-challenge: + - text: Yes, I am a bot! And I'm part of a research project. + + utter_faq/ask_howdoing: + - text: I'm great! Thanks for asking. + + utter_faq/meta_inquire_capabilities: + - text: I can help you calculate and buy carbon offsets for your flights. + + utter_faq/oos-future_inquire-ask: + - text: That sounds like a relevant question, but I don't know the answer. + + utter_faq/oos_inform: + - text: Good to know + + utter_faq/oos_inquire: + - text: I'm afraid I have no idea + + utter_faq/oos_request: + - text: that's not something I can help with + + utter_faq/placeholder: + - text: so + + utter_faq/takeacut: + - text: No, my creators don't get a cut from the offsets you buy. I'm just here to help! + + +stories: +- story: FAQ + steps: + - intent: faq + - action: utter_faq diff --git a/data/test_spacybot/config.yml b/data/test_spacybot/config.yml new file mode 100644 index 0000000..46fa131 --- /dev/null +++ b/data/test_spacybot/config.yml @@ -0,0 +1,17 @@ +recipe: default.v1 +assistant_id: test_spacy_bot +language: en + +pipeline: + - name: "SpacyNLP" + model: "en_core_web_md" + - name: "SpacyTokenizer" + - name: "SpacyFeaturizer" + - name: "DIETClassifier" + entity_recognition: False + epochs: 1 + +policies: + - name: TEDPolicy + max_history: 5 + epochs: 1 diff --git a/data/test_spacybot/data/nlu.yml b/data/test_spacybot/data/nlu.yml new file mode 100644 index 0000000..d3febba --- /dev/null +++ b/data/test_spacybot/data/nlu.yml @@ -0,0 +1,13 @@ +version: "3.1" +nlu: +- intent: greet + examples: | + - hey + - hello + - hi + +- intent: mood_great + examples: | + - perfect + - great + - amazing diff --git a/data/test_spacybot/data/stories.yml b/data/test_spacybot/data/stories.yml new file mode 100644 index 0000000..ff787be --- /dev/null +++ b/data/test_spacybot/data/stories.yml @@ -0,0 +1,8 @@ +version: "3.1" +stories: +- story: happy path + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy diff --git a/data/test_spacybot/domain.yml b/data/test_spacybot/domain.yml new file mode 100644 index 0000000..9a47626 --- /dev/null +++ b/data/test_spacybot/domain.yml @@ -0,0 +1,12 @@ +version: "3.1" + +intents: + - greet + - mood_great + +responses: + utter_greet: + - text: "Hey! How are you?" + + utter_happy: + - text: "Great, carry on!" diff --git a/data/test_tokenizers/naughty_strings.json b/data/test_tokenizers/naughty_strings.json new file mode 100644 index 0000000..56290bf --- /dev/null +++ b/data/test_tokenizers/naughty_strings.json @@ -0,0 +1,517 @@ +[ + "", + "undefined", + "undef", + "null", + "NULL", + "(null)", + "nil", + "NIL", + "true", + "false", + "True", + "False", + "TRUE", + "FALSE", + "None", + "hasOwnProperty", + "then", + "\\", + "\\\\", + "0", + "1", + "1.00", + "$1.00", + "1/2", + "1E2", + "1E02", + "1E+02", + "-1", + "-1.00", + "-$1.00", + "-1/2", + "-1E2", + "-1E02", + "-1E+02", + "1/0", + "0/0", + "-2147483648/-1", + "-9223372036854775808/-1", + "-0", + "-0.0", + "+0", + "+0.0", + "0.00", + "0..0", + ".", + "0.0.0", + "0,00", + "0,,0", + ",", + "0,0,0", + "0.0/0", + "1.0/0.0", + "0.0/0.0", + "1,0/0,0", + "0,0/0,0", + "--1", + "-", + "-.", + "-,", + "999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999", + "NaN", + "Infinity", + "-Infinity", + "INF", + "1#INF", + "-1#IND", + "1#QNAN", + "1#SNAN", + "1#IND", + "0x0", + "0xffffffff", + "0xffffffffffffffff", + "0xabad1dea", + "123456789012345678901234567890123456789", + "1,000.00", + "1 000.00", + "1'000.00", + "1,000,000.00", + "1 000 000.00", + "1'000'000.00", + "1.000,00", + "1 000,00", + "1'000,00", + "1.000.000,00", + "1 000 000,00", + "1'000'000,00", + "01000", + "08", + "09", + "2.2250738585072011e-308", + ",./;'[]\\-=", + "<>?:\"{}|_+", + "!@#$%^&*()`~", + "\u0001\u0002\u0003\u0004\u0005\u0006\u0007\b\u000e\u000f\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001a\u001b\u001c\u001d\u001e\u001f", + "€‚ƒ„†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ", + "\t\u000b\f …             ​

   ", + "­؀؁؂؃؄؅؜۝܏᠎​‌‍‎‏‪‫‬‭‮⁠⁡⁢⁣⁤⁦⁧⁨⁩𑂽𛲠𛲡𛲢𛲣𝅳𝅴𝅵𝅶𝅷𝅸𝅹𝅺󠀁󠀠󠀡󠀢󠀣󠀤󠀥󠀦󠀧󠀨󠀩󠀪󠀫󠀬󠀭󠀮󠀯󠀰󠀱󠀲󠀳󠀴󠀵󠀶󠀷󠀸󠀹󠀺󠀻󠀼󠀽󠀾󠀿󠁀󠁁󠁂󠁃󠁄󠁅󠁆󠁇󠁈󠁉󠁊󠁋󠁌󠁍󠁎󠁏󠁐󠁑󠁒󠁓󠁔󠁕󠁖󠁗󠁘󠁙󠁚󠁛󠁜󠁝󠁞󠁟󠁠󠁡󠁢󠁣󠁤󠁥󠁦󠁧󠁨󠁩󠁪󠁫󠁬󠁭󠁮󠁯󠁰󠁱󠁲󠁳󠁴󠁵󠁶󠁷󠁸󠁹󠁺󠁻󠁼󠁽󠁾󠁿", + "", + "￾", + "Ω≈ç√∫˜µ≤≥÷", + "åß∂ƒ©˙∆˚¬…æ", + "œ∑´®†¥¨ˆøπ“‘", + "¡™£¢∞§¶•ªº–≠", + "¸˛Ç◊ı˜Â¯˘¿", + "ÅÍÎÏ˝ÓÔÒÚÆ☃", + "Œ„´‰ˇÁ¨ˆØ∏”’", + "`⁄€‹›fifl‡°·‚—±", + "⅛⅜⅝⅞", + "ЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюя", + "٠١٢٣٤٥٦٧٨٩", + "⁰⁴⁵", + "₀₁₂", + "⁰⁴⁵₀₁₂", + "ด้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็ ด้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็ ด้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็", + "'", + "\"", + "''", + "\"\"", + "'\"'", + "\"''''\"'\"", + "\"'\"'\"''''\"", + "", + "", + "", + "", + "田中さんにあげて下さい", + "パーティーへ行かないか", + "和製漢語", + "部落格", + "사회과학원 어학연구소", + "찦차를 타고 온 펲시맨과 쑛다리 똠방각하", + "社會科學院語學研究所", + "울란바토르", + "𠜎𠜱𠝹𠱓𠱸𠲖𠳏", + "𐐜 𐐔𐐇𐐝𐐀𐐡𐐇𐐓 𐐙𐐊𐐡𐐝𐐓/𐐝𐐇𐐗𐐊𐐤𐐔 𐐒𐐋𐐗 𐐒𐐌 𐐜 𐐡𐐀𐐖𐐇𐐤𐐓𐐝 𐐱𐑂 𐑄 𐐔𐐇𐐝𐐀𐐡𐐇𐐓 𐐏𐐆𐐅𐐤𐐆𐐚𐐊𐐡𐐝𐐆𐐓𐐆", + "表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀", + "Ⱥ", + "Ⱦ", + "ヽ༼ຈل͜ຈ༽ノ ヽ༼ຈل͜ຈ༽ノ", + "(。◕ ∀ ◕。)", + "`ィ(´∀`∩", + "__ロ(,_,*)", + "・( ̄∀ ̄)・:*:", + "゚・✿ヾ╲(。◕‿◕。)╱✿・゚", + ",。・:*:・゜’( ☻ ω ☻ )。・:*:・゜’", + "(╯°□°)╯︵ ┻━┻)", + "(ノಥ益ಥ)ノ ┻━┻", + "┬─┬ノ( º _ ºノ)", + "( ͡° ͜ʖ ͡°)", + "¯\\_(ツ)_/¯", + "😍", + "👩🏽", + "👨‍🦰 👨🏿‍🦰 👨‍🦱 👨🏿‍🦱 🦹🏿‍♂️", + "👾 🙇 💁 🙅 🙆 🙋 🙎 🙍", + "🐵 🙈 🙉 🙊", + "❤️ 💔 💌 💕 💞 💓 💗 💖 💘 💝 💟 💜 💛 💚 💙", + "✋🏿 💪🏿 👐🏿 🙌🏿 👏🏿 🙏🏿", + "👨‍👩‍👦 👨‍👩‍👧‍👦 👨‍👨‍👦 👩‍👩‍👧 👨‍👦 👨‍👧‍👦 👩‍👦 👩‍👧‍👦", + "🚾 🆒 🆓 🆕 🆖 🆗 🆙 🏧", + "0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟", + "🇺🇸🇷🇺🇸 🇦🇫🇦🇲🇸", + "🇺🇸🇷🇺🇸🇦🇫🇦🇲", + "🇺🇸🇷🇺🇸🇦", + "123", + "١٢٣", + "ثم نفس سقطت وبالتحديد،, جزيرتي باستخدام أن دنو. إذ هنا؟ الستار وتنصيب كان. أهّل ايطاليا، بريطانيا-فرنسا قد أخذ. سليمان، إتفاقية بين ما, يذكر الحدود أي بعد, معاملة بولندا، الإطلاق عل إيو.", + "בְּרֵאשִׁית, בָּרָא אֱלֹהִים, אֵת הַשָּׁמַיִם, וְאֵת הָאָרֶץ", + "הָיְתָהtestالصفحات التّحول", + "﷽", + "ﷺ", + "مُنَاقَشَةُ سُبُلِ اِسْتِخْدَامِ اللُّغَةِ فِي النُّظُمِ الْقَائِمَةِ وَفِيم يَخُصَّ التَّطْبِيقَاتُ الْحاسُوبِيَّةُ، ", + "᚛ᚄᚓᚐᚋᚒᚄ ᚑᚄᚂᚑᚏᚅ᚜‪‪‪", + "‪‪᚛                 ᚜‪", + "‪‪test‪", + "‫test‫", + "
test
", + "test⁠test‫", + "⁦test⁧", + "Ṱ̺̺̕o͞ ̷i̲̬͇̪͙n̝̗͕v̟̜̘̦͟o̶̙̰̠kè͚̮̺̪̹̱̤ ̖t̝͕̳̣̻̪͞h̼͓̲̦̳̘̲e͇̣̰̦̬͎ ̢̼̻̱̘h͚͎͙̜̣̲ͅi̦̲̣̰̤v̻͍e̺̭̳̪̰-m̢iͅn̖̺̞̲̯̰d̵̼̟͙̩̼̘̳ ̞̥̱̳̭r̛̗̘e͙p͠r̼̞̻̭̗e̺̠̣͟s̘͇̳͍̝͉e͉̥̯̞̲͚̬͜ǹ̬͎͎̟̖͇̤t͍̬̤͓̼̭͘ͅi̪̱n͠g̴͉ ͏͉ͅc̬̟h͡a̫̻̯͘o̫̟̖͍̙̝͉s̗̦̲.̨̹͈̣", + "̡͓̞ͅI̗̘̦͝n͇͇͙v̮̫ok̲̫̙͈i̖͙̭̹̠̞n̡̻̮̣̺g̲͈͙̭͙̬͎ ̰t͔̦h̞̲e̢̤ ͍̬̲͖f̴̘͕̣è͖ẹ̥̩l͖͔͚i͓͚̦͠n͖͍̗͓̳̮g͍ ̨o͚̪͡f̘̣̬ ̖̘͖̟͙̮c҉͔̫͖͓͇͖ͅh̵̤̣͚͔á̗̼͕ͅo̼̣̥s̱͈̺̖̦̻͢.̛̖̞̠̫̰", + "̗̺͖̹̯͓Ṯ̤͍̥͇͈h̲́e͏͓̼̗̙̼̣͔ ͇̜̱̠͓͍ͅN͕͠e̗̱z̘̝̜̺͙p̤̺̹͍̯͚e̠̻̠͜r̨̤͍̺̖͔̖̖d̠̟̭̬̝͟i̦͖̩͓͔̤a̠̗̬͉̙n͚͜ ̻̞̰͚ͅh̵͉i̳̞v̢͇ḙ͎͟-҉̭̩̼͔m̤̭̫i͕͇̝̦n̗͙ḍ̟ ̯̲͕͞ǫ̟̯̰̲͙̻̝f ̪̰̰̗̖̭̘͘c̦͍̲̞͍̩̙ḥ͚a̮͎̟̙͜ơ̩̹͎s̤.̝̝ ҉Z̡̖̜͖̰̣͉̜a͖̰͙̬͡l̲̫̳͍̩g̡̟̼̱͚̞̬ͅo̗͜.̟", + "̦H̬̤̗̤͝e͜ ̜̥̝̻͍̟́w̕h̖̯͓o̝͙̖͎̱̮ ҉̺̙̞̟͈W̷̼̭a̺̪͍į͈͕̭͙̯̜t̶̼̮s̘͙͖̕ ̠̫̠B̻͍͙͉̳ͅe̵h̵̬͇̫͙i̹͓̳̳̮͎̫̕n͟d̴̪̜̖ ̰͉̩͇͙̲͞ͅT͖̼͓̪͢h͏͓̮̻e̬̝̟ͅ ̤̹̝W͙̞̝͔͇͝ͅa͏͓͔̹̼̣l̴͔̰̤̟͔ḽ̫.͕", + "Z̮̞̠͙͔ͅḀ̗̞͈̻̗Ḷ͙͎̯̹̞͓G̻O̭̗̮", + "˙ɐnbᴉlɐ ɐuƃɐɯ ǝɹolop ʇǝ ǝɹoqɐl ʇn ʇunpᴉpᴉɔuᴉ ɹodɯǝʇ poɯsnᴉǝ op pǝs 'ʇᴉlǝ ƃuᴉɔsᴉdᴉpɐ ɹnʇǝʇɔǝsuoɔ 'ʇǝɯɐ ʇᴉs ɹolop ɯnsdᴉ ɯǝɹo˥", + "00˙Ɩ$-", + "The quick brown fox jumps over the lazy dog", + "𝐓𝐡𝐞 𝐪𝐮𝐢𝐜𝐤 𝐛𝐫𝐨𝐰𝐧 𝐟𝐨𝐱 𝐣𝐮𝐦𝐩𝐬 𝐨𝐯𝐞𝐫 𝐭𝐡𝐞 𝐥𝐚𝐳𝐲 𝐝𝐨𝐠", + "𝕿𝖍𝖊 𝖖𝖚𝖎𝖈𝖐 𝖇𝖗𝖔𝖜𝖓 𝖋𝖔𝖝 𝖏𝖚𝖒𝖕𝖘 𝖔𝖛𝖊𝖗 𝖙𝖍𝖊 𝖑𝖆𝖟𝖞 𝖉𝖔𝖌", + "𝑻𝒉𝒆 𝒒𝒖𝒊𝒄𝒌 𝒃𝒓𝒐𝒘𝒏 𝒇𝒐𝒙 𝒋𝒖𝒎𝒑𝒔 𝒐𝒗𝒆𝒓 𝒕𝒉𝒆 𝒍𝒂𝒛𝒚 𝒅𝒐𝒈", + "𝓣𝓱𝓮 𝓺𝓾𝓲𝓬𝓴 𝓫𝓻𝓸𝔀𝓷 𝓯𝓸𝔁 𝓳𝓾𝓶𝓹𝓼 𝓸𝓿𝓮𝓻 𝓽𝓱𝓮 𝓵𝓪𝔃𝔂 𝓭𝓸𝓰", + "𝕋𝕙𝕖 𝕢𝕦𝕚𝕔𝕜 𝕓𝕣𝕠𝕨𝕟 𝕗𝕠𝕩 𝕛𝕦𝕞𝕡𝕤 𝕠𝕧𝕖𝕣 𝕥𝕙𝕖 𝕝𝕒𝕫𝕪 𝕕𝕠𝕘", + "𝚃𝚑𝚎 𝚚𝚞𝚒𝚌𝚔 𝚋𝚛𝚘𝚠𝚗 𝚏𝚘𝚡 𝚓𝚞𝚖𝚙𝚜 𝚘𝚟𝚎𝚛 𝚝𝚑𝚎 𝚕𝚊𝚣𝚢 𝚍𝚘𝚐", + "⒯⒣⒠ ⒬⒰⒤⒞⒦ ⒝⒭⒪⒲⒩ ⒡⒪⒳ ⒥⒰⒨⒫⒮ ⒪⒱⒠⒭ ⒯⒣⒠ ⒧⒜⒵⒴ ⒟⒪⒢", + "", + "<script>alert('123');</script>", + "", + "", + "\">", + "'>", + ">", + "", + "< / script >< script >alert(123)< / script >", + " onfocus=JaVaSCript:alert(123) autofocus", + "\" onfocus=JaVaSCript:alert(123) autofocus", + "' onfocus=JaVaSCript:alert(123) autofocus", + "<script>alert(123)</script>", + "ript>alert(123)ript>", + "-->", + "\";alert(123);t=\"", + "';alert(123);t='", + "JavaSCript:alert(123)", + ";alert(123);", + "src=JaVaSCript:prompt(132)", + "\"><\\x3Cscript>javascript:alert(1)", + "'`\"><\\x00script>javascript:alert(1)", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "ABC
DEF", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "test", + "`\"'>", + "`\"'>", + "`\"'>", + "`\"'>", + "`\"'>", + "`\"'>", + "`\"'>", + "`\"'>", + "`\"'>", + "`\"'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "\"`'>", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "XXX", + "javascript:alert(1)\"` `>", + "", + "", + "<a href=http://foo.bar/#x=`y></a><img alt=\"`><img src=x:x onerror=javascript:alert(1)></a>\">", + "<!--[if]><script>javascript:alert(1)</script -->", + "<!--[if<img src=x onerror=javascript:alert(1)//]> -->", + "<script src=\"/\\%(jscript)s\"></script>", + "<script src=\"\\\\%(jscript)s\"></script>", + "<IMG \"\"\"><SCRIPT>alert(\"XSS\")</SCRIPT>\">", + "<IMG SRC=javascript:alert(String.fromCharCode(88,83,83))>", + "<IMG SRC=# onmouseover=\"alert('xxs')\">", + "<IMG SRC= onmouseover=\"alert('xxs')\">", + "<IMG onmouseover=\"alert('xxs')\">", + "<IMG SRC=javascript:alert('XSS')>", + "<IMG SRC=javascript:alert('XSS')>", + "<IMG SRC=javascript:alert('XSS')>", + "<IMG SRC=\"jav ascript:alert('XSS');\">", + "<IMG SRC=\"jav ascript:alert('XSS');\">", + "<IMG SRC=\"jav ascript:alert('XSS');\">", + "<IMG SRC=\"jav ascript:alert('XSS');\">", + "perl -e 'print \"<IMG SRC=java\\0script:alert(\\\"XSS\\\")>\";' > out", + "<IMG SRC=\"  javascript:alert('XSS');\">", + "<SCRIPT/XSS SRC=\"http://ha.ckers.org/xss.js\"></SCRIPT>", + "<BODY onload!#$%&()*~+-_.,:;?@[/|\\]^`=alert(\"XSS\")>", + "<SCRIPT/SRC=\"http://ha.ckers.org/xss.js\"></SCRIPT>", + "<<SCRIPT>alert(\"XSS\");//<</SCRIPT>", + "<SCRIPT SRC=http://ha.ckers.org/xss.js?< B >", + "<SCRIPT SRC=//ha.ckers.org/.j>", + "<IMG SRC=\"javascript:alert('XSS')\"", + "<iframe src=http://ha.ckers.org/scriptlet.html <", + "\\\";alert('XSS');//", + "<u oncopy=alert()> Copy me</u>", + "<i onwheel=alert(1)> Scroll over me </i>", + "<plaintext>", + "http://a/%%30%30", + "</textarea><script>alert(123)</script>", + "1;DROP TABLE users", + "1'; DROP TABLE users-- 1", + "' OR 1=1 -- 1", + "' OR '1'='1", + "'; EXEC sp_MSForEachTable 'DROP TABLE ?'; --", + " ", + "%", + "_", + "-", + "--", + "--version", + "--help", + "$USER", + "/dev/null; touch /tmp/blns.fail ; echo", + "`touch /tmp/blns.fail`", + "$(touch /tmp/blns.fail)", + "@{[system \"touch /tmp/blns.fail\"]}", + "eval(\"puts 'hello world'\")", + "System(\"ls -al /\")", + "`ls -al /`", + "Kernel.exec(\"ls -al /\")", + "Kernel.exit(1)", + "%x('ls -al /')", + "<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><!DOCTYPE foo [ <!ELEMENT foo ANY ><!ENTITY xxe SYSTEM \"file:///etc/passwd\" >]><foo>&xxe;</foo>", + "$HOME", + "$ENV{'HOME'}", + "%d", + "%s%s%s%s%s", + "{0}", + "%*.*s", + "%@", + "%n", + "File:///", + "../../../../../../../../../../../etc/passwd%00", + "../../../../../../../../../../../etc/hosts", + "() { 0; }; touch /tmp/blns.shellshock1.fail;", + "() { _; } >_[$($())] { touch /tmp/blns.shellshock2.fail; }", + "<<< %s(un='%s') = %u", + "+++ATH0", + "CON", + "PRN", + "AUX", + "CLOCK$", + "NUL", + "A:", + "ZZ:", + "COM1", + "LPT1", + "LPT2", + "LPT3", + "COM2", + "COM3", + "COM4", + "DCC SEND STARTKEYLOGGER 0 0 0", + "Scunthorpe General Hospital", + "Penistone Community Church", + "Lightwater Country Park", + "Jimmy Clitheroe", + "Horniman Museum", + "shitake mushrooms", + "RomansInSussex.co.uk", + "http://www.cum.qc.ca/", + "Craig Cockburn, Software Specialist", + "Linda Callahan", + "Dr. Herman I. Libshitz", + "magna cum laude", + "Super Bowl XXX", + "medieval erection of parapets", + "evaluate", + "mocha", + "expression", + "Arsenal canal", + "classic", + "Tyson Gay", + "Dick Van Dyke", + "basement", + "If you're reading this, you've been in a coma for almost 20 years now. We're trying a new technique. We don't know where this message will end up in your dream, but we hope it works. Please wake up, we miss you.", + "Roses are \u001b[0;31mred\u001b[0m, violets are \u001b[0;34mblue. Hope you enjoy terminal hue", + "But now...\u001b[20Cfor my greatest trick...\u001b[8m", + "The quic\b\b\b\b\b\bk brown fo\u0007\u0007\u0007\u0007\u0007\u0007\u0007\u0007\u0007\u0007\u0007x... [Beeeep]", + "Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗", + "🏳0🌈️", + "జ్ఞ‌ా", + "گچپژ", + "{% print 'x' * 64 * 1024**3 %}", + "{{ \"\".__class__.__mro__[2].__subclasses__()[40](\"/etc/passwd\").read() }}" +] \ No newline at end of file diff --git a/data/test_trackers/tracker_moodbot.json b/data/test_trackers/tracker_moodbot.json new file mode 100644 index 0000000..6e206bd --- /dev/null +++ b/data/test_trackers/tracker_moodbot.json @@ -0,0 +1,144 @@ +{ + "latest_message": { + "entities": [], + "intent": { + "confidence": 0.60, + "name": "mood_great" + }, + "message_id": null, + "metadata": {}, + "text": "/mood_great", + "intent_ranking": [ + { + "confidence": 0.60, + "name": "mood_great" + }, + { + "confidence": 0.20, + "name": "greet" + }, + { + "confidence": 0.20, + "name": "goodbye" + } + ] + }, + "sender_id": "mysender", + "latest_action": {"action_name": "action_listen"}, + "latest_action_name": "action_listen", + "active_loop": {}, + "paused": false, + "latest_event_time": 1517821726.211042, + "followup_action": null, + "slots": {"name": null, "requested_slot": null, "session_started_metadata": null}, + "latest_input_channel": null, + "events": [ + { + "timestamp": 1517821726.211031, + "event": "action", + "name": "action_listen", + "action_text": null, + "policy": null, + "confidence": null, + "hide_rule_turn": false + }, + { + "timestamp": 1517821726.200036, + "metadata": {}, + "parse_data": { + "entities": [], + "intent": { + "confidence": 0.54, + "name": "greet" + }, + "message_id": null, + "metadata": {}, + "text": "/greet", + "intent_ranking": [ + { + "confidence": 0.54, + "name": "greet" + }, + { + "confidence": 0.31, + "name": "goodbye" + }, + { + "confidence": 0.15, + "name": "default" + } + ] + }, + "event": "user", + "text": "/greet", + "input_channel": null, "message_id": null, "metadata": {} + }, + { + "timestamp": 1517821726.200373, + "event": "action", + "name": "utter_greet", + "action_text": null, + "policy": null, + "confidence": null, + "hide_rule_turn": false + }, + { + "timestamp": 1517821726.211038, + "event": "action", + "name": "action_listen", + "action_text": null, + "policy": null, + "confidence": null, + "hide_rule_turn": false + }, + { + "timestamp": 1517821726.209836, + "metadata": {}, + "parse_data": { + "entities": [], + "intent": { + "confidence": 0.60, + "name": "mood_great" + }, + "message_id": null, + "metadata": {}, + "text": "/mood_great", + "intent_ranking": [ + { + "confidence": 0.60, + "name": "mood_great" + }, + { + "confidence": 0.20, + "name": "greet" + }, + { + "confidence": 0.20, + "name": "goodbye" + } + ] + }, + "event": "user", + "text": "/mood_great", + "input_channel": null, "message_id": null, "metadata": {} + }, + { + "timestamp": 1517821726.209908, + "event": "action", + "name": "utter_happy", + "action_text": null, + "policy": "policy_1_TEDPolicy", + "confidence": 0.8, + "hide_rule_turn": false + }, + { + "timestamp": 1517821726.211042, + "event": "action", + "name": "action_listen", + "action_text": null, + "policy": "policy_2_MemoizationPolicy", + "confidence": 1.0, + "hide_rule_turn": false + } + ] +} diff --git a/data/test_trackers/tracker_moodbot_with_new_utterances.json b/data/test_trackers/tracker_moodbot_with_new_utterances.json new file mode 100644 index 0000000..08f521b --- /dev/null +++ b/data/test_trackers/tracker_moodbot_with_new_utterances.json @@ -0,0 +1,186 @@ +{ + "latest_message": { + "entities": [], + "intent": { + "confidence": 0.60, + "name": "mood_great" + }, + "message_id": null, + "metadata": {}, + "text": "/mood_great", + "intent_ranking": [ + { + "confidence": 0.60, + "name": "mood_great" + }, + { + "confidence": 0.20, + "name": "greet" + }, + { + "confidence": 0.20, + "name": "goodbye" + } + ] + }, + "sender_id": "mysender", + "latest_action": {"action_name": "action_listen"}, + "latest_action_name": "action_listen", + "active_loop": {}, + "paused": false, + "latest_event_time": 1517821726.211042, + "followup_action": null, + "slots": {"name": null}, + "latest_input_channel": null, + "events": [ + { + "timestamp": 1517821726.211031, + "event": "action", + "name": "action_listen", + "policy": null, + "confidence": null, + "hide_rule_turn": false + }, + { + "timestamp": 1517821726.200036, + "metadata": {}, + "parse_data": { + "entities": [], + "intent": { + "confidence": 0.54, + "name": "greet" + }, + "message_id": null, + "metadata": {}, + "text": "/greet", + "intent_ranking": [ + { + "confidence": 0.54, + "name": "greet" + }, + { + "confidence": 0.31, + "name": "goodbye" + }, + { + "confidence": 0.15, + "name": "default" + } + ] + }, + "event": "user", + "text": "/greet", + "input_channel": null, "message_id": null, "metadata": {} + }, + { + "timestamp": 1517821726.200373, + "event": "action", + "name": "utter_greet", + "policy": null, + "confidence": null, + "hide_rule_turn": false + }, + { + "timestamp": 1517821726.211038, + "event": "action", + "name": "action_listen", + "policy": null, + "confidence": null, + "hide_rule_turn": false + }, + { + "timestamp": 1517821726.209836, + "metadata": {}, + "parse_data": { + "entities": [], + "intent": { + "confidence": 0.60, + "name": "mood_great" + }, + "message_id": null, + "metadata": {}, + "text": "/mood_great", + "intent_ranking": [ + { + "confidence": 0.60, + "name": "mood_great" + }, + { + "confidence": 0.20, + "name": "greet" + }, + { + "confidence": 0.20, + "name": "goodbye" + } + ] + }, + "event": "user", + "text": "/mood_great", + "input_channel": null, "message_id": null, "metadata": {} + }, + { + "timestamp": 1517821726.209908, + "event": "action", + "name": "utter_happy", + "policy": "policy_1_TEDPolicy", + "confidence": 0.8, + "hide_rule_turn": false + }, + { + "timestamp": 1517821726.211042, + "event": "action", + "name": "action_listen", + "policy": "policy_2_MemoizationPolicy", + "confidence": 1.0, + "hide_rule_turn": false + }, + { + "timestamp": 1517821730.209836, + "metadata": {}, + "parse_data": { + "entities": [], + "intent": { + "confidence": 0.60, + "name": "mood_great" + }, + "message_id": null, + "metadata": {}, + "text": "my mood is so great!", + "intent_ranking": [ + { + "confidence": 0.60, + "name": "mood_great" + }, + { + "confidence": 0.20, + "name": "greet" + }, + { + "confidence": 0.20, + "name": "goodbye" + } + ] + }, + "event": "user", + "text": "/mood_great", + "input_channel": null, "message_id": null, "metadata": {} + }, + { + "timestamp": 1517821740.209908, + "event": "action", + "name": "utter_happy", + "policy": "policy_1_TEDPolicy", + "confidence": 0.8, + "hide_rule_turn": false + }, + { + "timestamp": 1517821750.211042, + "event": "action", + "name": "action_listen", + "policy": "policy_2_MemoizationPolicy", + "confidence": 1.0, + "hide_rule_turn": false + } + ] +} diff --git a/data/test_validation/data/nlu.yml b/data/test_validation/data/nlu.yml new file mode 100644 index 0000000..0f66f3f --- /dev/null +++ b/data/test_validation/data/nlu.yml @@ -0,0 +1,5 @@ +version: "2.0" +nlu: +- intent: greet + examples: | + - hey diff --git a/data/test_validation/data/stories.yml b/data/test_validation/data/stories.yml new file mode 100644 index 0000000..235ba52 --- /dev/null +++ b/data/test_validation/data/stories.yml @@ -0,0 +1,6 @@ +version: "2.0" +stories: +- story: happy path + steps: + - intent: greet + - action: utter_greet diff --git a/data/test_validation/domain.yml b/data/test_validation/domain.yml new file mode 100644 index 0000000..e72f350 --- /dev/null +++ b/data/test_validation/domain.yml @@ -0,0 +1,12 @@ +version: "2.0" + +intents: + - greet + - goodbye + +responses: + utter_greet: + - text: "Hey! How are you?" + + utter_chatter: + - text: "Lovely weather today, isn't it?" diff --git a/data/test_wrong_yaml_stories/intent_with_leading_slash.yml b/data/test_wrong_yaml_stories/intent_with_leading_slash.yml new file mode 100644 index 0000000..5b9a51f --- /dev/null +++ b/data/test_wrong_yaml_stories/intent_with_leading_slash.yml @@ -0,0 +1,6 @@ +stories: +- story: simple_story_without_checkpoint + steps: + - intent: /simple + - action: utter_default + - action: utter_greet diff --git a/data/test_wrong_yaml_stories/wrong_yaml.yml b/data/test_wrong_yaml_stories/wrong_yaml.yml new file mode 100644 index 0000000..26951bc --- /dev/null +++ b/data/test_wrong_yaml_stories/wrong_yaml.yml @@ -0,0 +1 @@ +[dasdassd, diff --git a/data/test_yaml_stories/non_test_full_retrieval_intent_story.yml b/data/test_yaml_stories/non_test_full_retrieval_intent_story.yml new file mode 100644 index 0000000..c4b8784 --- /dev/null +++ b/data/test_yaml_stories/non_test_full_retrieval_intent_story.yml @@ -0,0 +1,5 @@ +stories: +- story: chitchat name + steps: + - intent: chitchat/ask_name + - action: utter_chitchat diff --git a/data/test_yaml_stories/rules_greet_and_goodbye.yml b/data/test_yaml_stories/rules_greet_and_goodbye.yml new file mode 100644 index 0000000..7e67678 --- /dev/null +++ b/data/test_yaml_stories/rules_greet_and_goodbye.yml @@ -0,0 +1,13 @@ +version: "3.1" + +rules: + +- rule: Say hi! + steps: + - intent: greet + - action: utter_greet + +- rule: Say goodbye anytime the user says goodbye + steps: + - intent: goodbye + - action: utter_goodbye diff --git a/data/test_yaml_stories/rules_missing_intent.yml b/data/test_yaml_stories/rules_missing_intent.yml new file mode 100644 index 0000000..e05bf23 --- /dev/null +++ b/data/test_yaml_stories/rules_missing_intent.yml @@ -0,0 +1,7 @@ +version: "3.1" + +rules: + - rule: greet user + steps: + - intent: + - action: utter_greet diff --git a/data/test_yaml_stories/rules_with_stories_sorted.yaml b/data/test_yaml_stories/rules_with_stories_sorted.yaml new file mode 100644 index 0000000..be0c0a8 --- /dev/null +++ b/data/test_yaml_stories/rules_with_stories_sorted.yaml @@ -0,0 +1,45 @@ +stories: +- story: simple_story_without_checkpoint + steps: + - intent: simple + - action: utter_default + - action: utter_greet +- story: simple_story_with_only_start + steps: + - checkpoint: check_greet + - intent: simple + - action: utter_default +- story: simple_story_with_only_end + steps: + - intent: hello + - action: utter_greet + - slot_was_set: + - name: peter + - checkpoint: check_greet +rules: +- rule: Rule with condition + condition: + - active_loop: loop_q_form + - slot_was_set: + - requested_slot: some_slot + steps: + - intent: inform + entities: + - some_slot: bla + - action: loop_q_form +- rule: Rule without condition + steps: + - intent: explain + - action: utter_explain_some_slot + - action: loop_q_form + - active_loop: loop_q_form +- rule: Rule after which another action should be predicted + steps: + - intent: explain + - action: utter_explain_some_slot + wait_for_user_input: false +- rule: Rule which only applies to conversation start + conversation_start: true + steps: + - intent: explain + - action: utter_explain_some_slot diff --git a/data/test_yaml_stories/rules_without_stories.yml b/data/test_yaml_stories/rules_without_stories.yml new file mode 100644 index 0000000..f5faefb --- /dev/null +++ b/data/test_yaml_stories/rules_without_stories.yml @@ -0,0 +1,45 @@ +rules: +- rule: Rule with condition + condition: + - active_loop: loop_q_form + - slot_was_set: + - requested_slot: some_slot + steps: + - intent: inform + entities: + - some_slot: bla + - action: loop_q_form + +- rule: Rule without condition + steps: + - intent: explain + - action: utter_explain_some_slot + - action: loop_q_form + - active_loop: loop_q_form + +- rule: Rule which explicitly waits for user input when finished + steps: + - intent: explain + - action: utter_explain_some_slot + wait_for_user_input: True + +- rule: Rule after which another action should be predicted + steps: + - intent: explain + - action: utter_explain_some_slot + wait_for_user_input: False + +- rule: Rule which only applies to conversation start + conversation_start: True + steps: + - intent: explain + - action: utter_explain_some_slot + +- rule: start optional preferences + steps: + - action: utter_request_optional + - or: + - intent: deny + - intent: inform + - action: form_plane_search_preference + wait_for_user_input: false diff --git a/data/test_yaml_stories/rules_without_stories_and_wrong_names.yml b/data/test_yaml_stories/rules_without_stories_and_wrong_names.yml new file mode 100644 index 0000000..1b614ff --- /dev/null +++ b/data/test_yaml_stories/rules_without_stories_and_wrong_names.yml @@ -0,0 +1,33 @@ +rules: +- rule: rule 1 + condition: + - active_loop: loop_q_form + - slot_was_set: + - requested_slot: some_slot + steps: + - intent: some_intent_that_doesnt_exist + entities: + - some_slot: bla + - action: loop_q_form + +- rule: rule 2 + condition: + - active_loop: loop_q_form + - slot_was_set: + - requested_slot: some_slot + steps: + - intent: explain + - action: utter_some_action_that_doesnt_exist + - action: loop_q_form + - active_loop: loop_q_form + +- rule: rule 3 + condition: + - active_loop: loop_q_form + - slot_was_set: + - requested_slot: some_slot + steps: + - intent: explain + - action: utter_some_action_that_doesnt_exist + - action: loop_q_form + - active_loop: loop_q_form diff --git a/data/test_yaml_stories/simple_story_with_only_end.yml b/data/test_yaml_stories/simple_story_with_only_end.yml new file mode 100644 index 0000000..08e3316 --- /dev/null +++ b/data/test_yaml_stories/simple_story_with_only_end.yml @@ -0,0 +1,7 @@ +stories: +- story: simple_story_with_only_end + steps: + - intent: hello + - action: utter_greet + - slot_was_set: + - name: peter diff --git a/data/test_yaml_stories/stories.yml b/data/test_yaml_stories/stories.yml new file mode 100644 index 0000000..695b827 --- /dev/null +++ b/data/test_yaml_stories/stories.yml @@ -0,0 +1,47 @@ +stories: +- story: simple_story_without_checkpoint + steps: + - intent: simple + - action: utter_default + - action: utter_greet + +- story: simple_story_with_only_start + steps: + - checkpoint: check_greet # checkpoints at the start define entry points + - intent: simple + - action: utter_default + +- story: simple_story_with_only_end + steps: + - intent: hello + - action: utter_greet + - slot_was_set: + - name: peter + - checkpoint: check_greet # checkpoint defining the end of this turn + +- story: simple_story_with_multiple_turns + steps: + - or: + - intent: affirm + - intent: thank_you + - action: utter_default + - intent: goodbye + - action: utter_goodbye + - checkpoint: check_goodbye + +- story: why does the user want to leave? + steps: + - checkpoint: check_goodbye + - intent: why + - action: utter_default + - checkpoint: check_greet + +- story: show_it_all + steps: + - checkpoint: check_greet + - checkpoint: check_hello # allows multiple entry points + - intent: next_intent + - action: utter_greet # actions taken by the bot + - checkpoint: check_intermediate # allows intermediate checkpoints + - intent: change_bank_details + - action: utter_default # allows to end without checkpoints diff --git a/data/test_yaml_stories/stories_and_rules.yml b/data/test_yaml_stories/stories_and_rules.yml new file mode 100644 index 0000000..8a21d9b --- /dev/null +++ b/data/test_yaml_stories/stories_and_rules.yml @@ -0,0 +1,49 @@ +rules: +- rule: rule 1 + condition: + - active_loop: loop_q_form + - slot_was_set: + - requested_slot: some_slot + steps: + - intent: inform + entities: + - some_slot: bla + - action: loop_q_form + +- rule: rule 2 + condition: + - active_loop: loop_q_form + - slot_was_set: + - requested_slot: some_slot + steps: + - intent: explain + - action: utter_explain_some_slot + - action: loop_q_form + - active_loop: loop_q_form + +- rule: rule 3 + steps: + - action: loop_q_form + - active_loop: null + - action: stop_q_form + +stories: +- story: simple_story_without_checkpoint + steps: + - intent: simple + - action: utter_default + - action: utter_greet + +- story: simple_story_with_only_start + steps: + - checkpoint: check_greet # checkpoints at the start define entry points + - intent: simple + - action: utter_default + +- story: simple_story_with_only_end + steps: + - intent: hello + - action: utter_greet + - slot_was_set: + - name: peter + - checkpoint: check_greet # checkpoint defining the end of this turn diff --git a/data/test_yaml_stories/stories_checkpoint_after_or.yml b/data/test_yaml_stories/stories_checkpoint_after_or.yml new file mode 100644 index 0000000..68b661d --- /dev/null +++ b/data/test_yaml_stories/stories_checkpoint_after_or.yml @@ -0,0 +1,14 @@ +stories: +- story: story with checkpoint after or + steps: + - or: + - intent: affirm + - intent: thank_you + - checkpoint: check_after_or + +- story: story to continue checkpoint + steps: + - checkpoint: check_after_or + - action: utter_default + - intent: goodbye + - action: utter_goodbye diff --git a/data/test_yaml_stories/stories_conflicting_1.yml b/data/test_yaml_stories/stories_conflicting_1.yml new file mode 100644 index 0000000..f519870 --- /dev/null +++ b/data/test_yaml_stories/stories_conflicting_1.yml @@ -0,0 +1,18 @@ +stories: +- story: "story 1" + steps: + - intent: greet + - action: utter_greet + - intent: greet + - action: utter_greet + - intent: greet + - action: utter_greet + +- story: "story 2" + steps: + - intent: default + - action: utter_greet + - intent: greet + - action: utter_greet + - intent: greet + - action: utter_default diff --git a/data/test_yaml_stories/stories_conflicting_2.yml b/data/test_yaml_stories/stories_conflicting_2.yml new file mode 100644 index 0000000..6ebed5c --- /dev/null +++ b/data/test_yaml_stories/stories_conflicting_2.yml @@ -0,0 +1,18 @@ +stories: +- story: greetings + steps: + - intent: greet + - action: utter_greet + - checkpoint: check_greet + +- story: "happy path" + steps: + - checkpoint: check_greet + - intent: default + - action: utter_default + +- story: problem + steps: + - checkpoint: check_greet + - intent: default + - action: utter_goodbye diff --git a/data/test_yaml_stories/stories_conflicting_3.yml b/data/test_yaml_stories/stories_conflicting_3.yml new file mode 100644 index 0000000..8201e9a --- /dev/null +++ b/data/test_yaml_stories/stories_conflicting_3.yml @@ -0,0 +1,20 @@ +stories: +- story: greetings + steps: + - intent: greet + - action: utter_greet + - checkpoint: check_greet + +- story: "happy path" + steps: + - checkpoint: check_greet + - or: + - intent: default + - intent: greet + - action: utter_default + +- story: problem + steps: + - checkpoint: check_greet + - intent: default + - action: utter_goodbye diff --git a/data/test_yaml_stories/stories_conflicting_4.yml b/data/test_yaml_stories/stories_conflicting_4.yml new file mode 100644 index 0000000..da7d457 --- /dev/null +++ b/data/test_yaml_stories/stories_conflicting_4.yml @@ -0,0 +1,22 @@ +stories: +- story: "story 1" + steps: + - intent: greet + - action: utter_greet + - intent: greet + entities: + - cuisine: German + - action: utter_greet + - intent: greet + - action: utter_greet + +- story: "story 2" + steps: + - intent: greet + - action: utter_greet + - intent: greet + entities: + - cuisine: German + - action: utter_greet + - intent: greet + - action: utter_default diff --git a/data/test_yaml_stories/stories_conflicting_5.yml b/data/test_yaml_stories/stories_conflicting_5.yml new file mode 100644 index 0000000..1976a62 --- /dev/null +++ b/data/test_yaml_stories/stories_conflicting_5.yml @@ -0,0 +1,20 @@ +stories: +- story: "story 1" + steps: + - intent: greet + - action: utter_greet + - intent: greet + entities: + - cuisine: German + - action: utter_greet + - intent: greet + - action: utter_greet + +- story: "story 2" + steps: + - intent: greet + - action: utter_greet + - intent: greet + - action: utter_greet + - intent: greet + - action: utter_default diff --git a/data/test_yaml_stories/stories_conflicting_6.yml b/data/test_yaml_stories/stories_conflicting_6.yml new file mode 100644 index 0000000..3e79111 --- /dev/null +++ b/data/test_yaml_stories/stories_conflicting_6.yml @@ -0,0 +1,28 @@ +stories: +- story: "story 1" + steps: + - intent: greet + - action: utter_greet + +- story: "story 2" + steps: + - intent: greet + - action: utter_default + +- story: "story 3" + steps: + - intent: greet + - action: utter_default + - intent: greet + +- story: "story 4" + steps: + - intent: greet + - action: utter_default + - intent: default + +- story: "story 5" + steps: + - intent: greet + - action: utter_default + - intent: goodbye \ No newline at end of file diff --git a/data/test_yaml_stories/stories_conflicting_at_1.yml b/data/test_yaml_stories/stories_conflicting_at_1.yml new file mode 100644 index 0000000..da4d400 --- /dev/null +++ b/data/test_yaml_stories/stories_conflicting_at_1.yml @@ -0,0 +1,18 @@ +stories: +- story: "story 1" + steps: + - intent: greet + - action: utter_greet + - intent: default + - action: utter_default + - intent: greet + - action: utter_greet + +- story: "story 2" + steps: + - intent: default + - action: utter_default + - intent: greet + - action: utter_greet + - intent: greet + - action: utter_default \ No newline at end of file diff --git a/data/test_yaml_stories/stories_defaultdomain.yml b/data/test_yaml_stories/stories_defaultdomain.yml new file mode 100644 index 0000000..86eecf6 --- /dev/null +++ b/data/test_yaml_stories/stories_defaultdomain.yml @@ -0,0 +1,30 @@ +stories: +- story: simple_story_with_only_start + steps: + - checkpoint: check_greet # checkpoints at the start define entry points + - intent: default + - action: utter_default + +- story: simple_story_with_only_end + steps: + - or: + - intent: greet + - intent: greet + entities: + - name: Peter + - action: utter_greet + - checkpoint: check_greet # checkpoint defining the end of this turn + +- story: simple_story_with_multiple_turns + steps: + - intent: greet + - action: utter_greet + - intent: default + - action: utter_default + - intent: goodbye + - action: utter_goodbye + +- story: simple_story_for_goodbye + steps: + - intent: goodbye + - action: utter_goodbye diff --git a/data/test_yaml_stories/stories_e2e.yml b/data/test_yaml_stories/stories_e2e.yml new file mode 100644 index 0000000..bc62cf3 --- /dev/null +++ b/data/test_yaml_stories/stories_e2e.yml @@ -0,0 +1,30 @@ +version: "3.1" + +stories: +- story: happy path (intent to action) + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy + +- story: sad path (text to text) + steps: + - user: "[Hello](bla)" + - bot: "Welcome to moodbot. How are you feeling today?" + - user: "Horrible" + - bot: "Oh no! Here is a kitten photo. Did it help?" + - user: "Yes" + - bot: "Perfect" + +- story: sad path 2 (mixed) + steps: + - intent: greet + - action: utter_greet + - user: "I'm not happy" + - action: utter_cheer_up + - action: utter_did_that_help + - user: "Not at all" + - bot: "Goodbye!" + + diff --git a/data/test_yaml_stories/stories_form.yml b/data/test_yaml_stories/stories_form.yml new file mode 100644 index 0000000..1d62e03 --- /dev/null +++ b/data/test_yaml_stories/stories_form.yml @@ -0,0 +1,83 @@ +stories: +- story: simple_story_with_multiple_turns + steps: + - intent: greet + - action: utter_greet + - intent: default + - action: utter_default + - intent: goodbye + - action: utter_goodbye + +- story: simple_story_with_form_happy + steps: + - intent: greet + - action: utter_greet + - intent: start_form + - action: some_form + - active_loop: some_form + - intent: "form: inform" + - action: "form: some_form" + - active_loop: null + - intent: default + - action: utter_default + - intent: goodbye + - action: utter_goodbye + +- story: simple_story_with_form_unhappy + steps: + - intent: greet + - action: utter_greet + - intent: start_form + - action: some_form + - active_loop: some_form + - intent: default + - action: utter_default + - action: some_form + - active_loop: null + - intent: goodbye + - action: utter_goodbye + +- story: simple_story_with_form_stop_continue + steps: + - intent: greet + - action: utter_greet + - intent: start_form + - action: some_form + - active_loop: some_form + - intent: stop + - action: utter_ask_continue + - intent: affirm + - action: some_form + - active_loop: null + - intent: goodbye + - action: utter_goodbye + +- story: simple_story_with_form_stop_inform + steps: + - intent: greet + - action: utter_greet + - intent: start_form + - action: some_form + - active_loop: some_form + - intent: stop + - action: utter_ask_continue + - action: action_listen + - intent: "form: inform" + - action: "form: some_form" + - active_loop: null + - intent: goodbye + - action: utter_goodbye + +- story: simple_story_with_form_stop_deactivate + steps: + - intent: greet + - action: utter_greet + - intent: start_form + - action: some_form + - active_loop: some_form + - intent: stop + - action: utter_ask_continue + - intent: deny + - action: action_deactivate_loop + - active_loop: null + - action: utter_goodbye diff --git a/data/test_yaml_stories/stories_hybrid_e2e.yml b/data/test_yaml_stories/stories_hybrid_e2e.yml new file mode 100644 index 0000000..bdedd78 --- /dev/null +++ b/data/test_yaml_stories/stories_hybrid_e2e.yml @@ -0,0 +1,15 @@ +stories: +- story: My hybrid End-to-End story + steps: + # Regular story with labels + - intent: simple + - action: utter_greet + # Actual messages are given instead of labels + - user: "I am looking for a [Kenyan](cuisine) restaurant" + - bot: "good for you" + # Regular labeled events + - intent: goodbye + - action: utter_goodbye + # Actual messages are given instead of labels + - user: One more thing + - bot: What? diff --git a/data/test_yaml_stories/stories_missing_intent.yml b/data/test_yaml_stories/stories_missing_intent.yml new file mode 100644 index 0000000..ca624b6 --- /dev/null +++ b/data/test_yaml_stories/stories_missing_intent.yml @@ -0,0 +1,7 @@ +version: "3.1" + +stories: + - story: greet user + steps: + - intent: + - action: utter_greet diff --git a/data/test_yaml_stories/stories_restart.yml b/data/test_yaml_stories/stories_restart.yml new file mode 100644 index 0000000..c48e49c --- /dev/null +++ b/data/test_yaml_stories/stories_restart.yml @@ -0,0 +1,8 @@ +stories: +- story: simple_story_with_restart + steps: + - intent: greet + - action: utter_greet + - intent: goodbye + - action: action_restart + - action: restart diff --git a/data/test_yaml_stories/stories_retrieval_intents.yml b/data/test_yaml_stories/stories_retrieval_intents.yml new file mode 100644 index 0000000..ed7eeea --- /dev/null +++ b/data/test_yaml_stories/stories_retrieval_intents.yml @@ -0,0 +1,44 @@ +version: "3.1" + +stories: + - story: happy path + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy + + - story: sad path 1 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + + - story: sad path 2 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: deny + - action: utter_goodbye + + - story: say goodbye + steps: + - intent: goodbye + - action: utter_goodbye + + - story: bot challenge + steps: + - intent: bot_challenge + - action: utter_iamabot + + - story: chitchat + steps: + - intent: chitchat + - action: respond_chitchat diff --git a/data/test_yaml_stories/stories_simple.yml b/data/test_yaml_stories/stories_simple.yml new file mode 100644 index 0000000..8ded95b --- /dev/null +++ b/data/test_yaml_stories/stories_simple.yml @@ -0,0 +1,9 @@ +version: "3.1" + +stories: +- story: simple_story + steps: + - intent: greet + - action: utter_greet + - intent: goodbye + - action: utter_goodbye diff --git a/data/test_yaml_stories/stories_unexpected_intent_unlearnable.yml b/data/test_yaml_stories/stories_unexpected_intent_unlearnable.yml new file mode 100644 index 0000000..efe1968 --- /dev/null +++ b/data/test_yaml_stories/stories_unexpected_intent_unlearnable.yml @@ -0,0 +1,12 @@ +version: "3.1" +stories: +- story: "story 1" + steps: + - intent: greet + - action: action_unlikely_intent + +- story: "story 2" + steps: + - intent: greet + - action: utter_greet + - action: action_unlikely_intent diff --git a/data/test_yaml_stories/stories_unfeaturized_entities.yml b/data/test_yaml_stories/stories_unfeaturized_entities.yml new file mode 100644 index 0000000..c5bb38a --- /dev/null +++ b/data/test_yaml_stories/stories_unfeaturized_entities.yml @@ -0,0 +1,50 @@ +stories: +- story: simple_story + steps: + - intent: greet + entities: + - name: Peter + - unrelated_recognized_entity: whatever + - unused_entity: whatever + - other: foo + - action: utter_greet + +- story: simple_story_with_multiple_turns + steps: + - intent: greet + - action: utter_greet + - intent: default + entities: + - name: Peter + - unrelated_recognized_entity: whatever + - unused_entity: whatever + - other: foo + - action: utter_default + - intent: goodbye + entities: + - name: Peter + - unrelated_recognized_entity: whatever + - unused_entity: whatever + - other: foo + - action: utter_goodbye + - intent: thank + entities: + - name: Peter + - unrelated_recognized_entity: whatever + - unused_entity: whatever + - other: foo + - action: utter_default + - intent: ask + entities: + - name: Peter + - unrelated_recognized_entity: whatever + - unused_entity: whatever + - other: foo + - action: utter_default + - intent: why + entities: + - name: Peter + - unrelated_recognized_entity: whatever + - unused_entity: whatever + - other: foo + - action: utter_default diff --git a/data/test_yaml_stories/stories_unused_checkpoints.yml b/data/test_yaml_stories/stories_unused_checkpoints.yml new file mode 100644 index 0000000..5260347 --- /dev/null +++ b/data/test_yaml_stories/stories_unused_checkpoints.yml @@ -0,0 +1,23 @@ +stories: +- story: simple_story_with_end_checkpoint_1 + steps: + - intent: simple + - action: utter_default + - action: utter_greet + - checkpoint: check_end_1 + +- story: simple_story_with_end_checkpoint_2 + steps: + - intent: hello + - action: utter_greet + - slot_was_set: + - name: peter + - checkpoint: check_end_2 + +- story: simple_story_with_start + steps: + - checkpoint: check_start + - intent: hello + - action: utter_greet + - slot_was_set: + - name: peter diff --git a/data/test_yaml_stories/stories_with_cycle.yml b/data/test_yaml_stories/stories_with_cycle.yml new file mode 100644 index 0000000..1063d66 --- /dev/null +++ b/data/test_yaml_stories/stories_with_cycle.yml @@ -0,0 +1,38 @@ +stories: +- story: utter greet + steps: + - intent: greet + - action: utter_greet + - checkpoint: get_name + +- story: user no name + steps: + - checkpoint: get_name + - intent: default + entities: + - name: null + - checkpoint: process_name + +- story: user sends name + steps: + - checkpoint: get_name + - intent: default + entities: + - name: "Josh" + - checkpoint: process_name + +- story: goodbye + steps: + - checkpoint: process_name + slot_was_set: + - name: "Josh" + - action: utter_goodbye + - action: action_restart + +- story: utter default + steps: + - checkpoint: process_name + slot_was_set: + - name: null + - action: utter_default + - checkpoint: get_name diff --git a/data/test_yaml_stories/stories_with_rules_conflicting.yml b/data/test_yaml_stories/stories_with_rules_conflicting.yml new file mode 100644 index 0000000..17750bc --- /dev/null +++ b/data/test_yaml_stories/stories_with_rules_conflicting.yml @@ -0,0 +1,15 @@ +rules: +- rule: rule_1 + steps: + - intent: greet + - action: utter_noworries + - active_loop: null + - action: stop_q_form + +stories: +- story: ml_story_1 + steps: + - intent: greet + - action: utter_greet + - intent: thankyou + - action: utter_noworries diff --git a/data/test_yaml_stories/story_slot_different_types.yml b/data/test_yaml_stories/story_slot_different_types.yml new file mode 100644 index 0000000..cafb80a --- /dev/null +++ b/data/test_yaml_stories/story_slot_different_types.yml @@ -0,0 +1,9 @@ +stories: +- story: Story with all different slot types + steps: + - intent: card_lost + - action: check_transactions + - slot_was_set: + - list_slot: ["value1", "value2"] + - bool_slot: true + - text_slot: "some_text" diff --git a/data/test_yaml_stories/story_with_or_and_entities.yml b/data/test_yaml_stories/story_with_or_and_entities.yml new file mode 100644 index 0000000..e4e286f --- /dev/null +++ b/data/test_yaml_stories/story_with_or_and_entities.yml @@ -0,0 +1,10 @@ +stories: +- story: story with or and entities + steps: + - or: + - intent: greet + - intent: greet + entities: + # slot with the same name was autofilled + - name: peter + - action: utter_greet diff --git a/data/test_yaml_stories/story_with_or_and_entities_with_no_value.yml b/data/test_yaml_stories/story_with_or_and_entities_with_no_value.yml new file mode 100644 index 0000000..20ddfea --- /dev/null +++ b/data/test_yaml_stories/story_with_or_and_entities_with_no_value.yml @@ -0,0 +1,8 @@ +stories: +- story: story with or and entities with no value + steps: + - intent: greet + entities: + # slot with the same name was NOT autofilled + - name + - action: utter_greet diff --git a/data/test_yaml_stories/story_with_or_slot_was_set.yml b/data/test_yaml_stories/story_with_or_slot_was_set.yml new file mode 100644 index 0000000..afe1400 --- /dev/null +++ b/data/test_yaml_stories/story_with_or_slot_was_set.yml @@ -0,0 +1,11 @@ +stories: + - story: simple_story_with_or_slot_was_set + steps: + - intent: hello + - action: utter_greet + - or: + - slot_was_set: + - name: peter + - slot_was_set: + - name: bob + - checkpoint: check_greet diff --git a/data/test_yaml_stories/story_with_slot_was_set.yml b/data/test_yaml_stories/story_with_slot_was_set.yml new file mode 100644 index 0000000..a641b43 --- /dev/null +++ b/data/test_yaml_stories/story_with_slot_was_set.yml @@ -0,0 +1,5 @@ +stories: +- story: story with slot + steps: + - slot_was_set: + - name \ No newline at end of file diff --git a/data/test_yaml_stories/story_with_two_equal_or_statements.yml b/data/test_yaml_stories/story_with_two_equal_or_statements.yml new file mode 100644 index 0000000..622f35c --- /dev/null +++ b/data/test_yaml_stories/story_with_two_equal_or_statements.yml @@ -0,0 +1,25 @@ +stories: +- story: story_with_two_equal_or_statements + steps: + - intent: simple + - action: utter_default + - or: + - intent: affirm + - intent: thank_you + - action: utter_greet + - action: utter_default + - or: + - intent: affirm + - intent: thank_you + - action: utter_goodbye +# same name again, same events, same intents, but with entities +- story: story_with_two_equal_or_statements + steps: + - intent: simple + - action: utter_default + - or: + - intent: affirm + entities: + - name: peter + - intent: thank_you + - action: utter_greet diff --git a/data/test_yaml_stories/test_base_retrieval_intent_story.yml b/data/test_yaml_stories/test_base_retrieval_intent_story.yml new file mode 100644 index 0000000..a342b91 --- /dev/null +++ b/data/test_yaml_stories/test_base_retrieval_intent_story.yml @@ -0,0 +1,7 @@ +stories: +- story: chitchat name + steps: + - user: | + What's the weather like today? + intent: chitchat + - action: utter_chitchat diff --git a/data/test_yaml_stories/test_base_retrieval_intent_wrong_prediction.yml b/data/test_yaml_stories/test_base_retrieval_intent_wrong_prediction.yml new file mode 100644 index 0000000..46bf7d4 --- /dev/null +++ b/data/test_yaml_stories/test_base_retrieval_intent_wrong_prediction.yml @@ -0,0 +1,7 @@ +stories: +- story: chitchat name + steps: + - user: | + What is your name? + intent: affirm + - action: utter_chitchat diff --git a/data/test_yaml_stories/test_failed_entity_extraction_comment.yml b/data/test_yaml_stories/test_failed_entity_extraction_comment.yml new file mode 100644 index 0000000..d1690df --- /dev/null +++ b/data/test_yaml_stories/test_failed_entity_extraction_comment.yml @@ -0,0 +1,6 @@ +stories: +- story: test story with correct intent and wrong entity + steps: + - user: | + i am [looking for greek](feedback) food + intent: request_restaurant diff --git a/data/test_yaml_stories/test_full_retrieval_intent_story.yml b/data/test_yaml_stories/test_full_retrieval_intent_story.yml new file mode 100644 index 0000000..f7fc9da --- /dev/null +++ b/data/test_yaml_stories/test_full_retrieval_intent_story.yml @@ -0,0 +1,7 @@ +stories: +- story: chitchat name + steps: + - user: | + What is your name? + intent: chitchat/ask_name + - action: utter_chitchat diff --git a/data/test_yaml_stories/test_full_retrieval_intent_wrong_prediction.yml b/data/test_yaml_stories/test_full_retrieval_intent_wrong_prediction.yml new file mode 100644 index 0000000..9d416df --- /dev/null +++ b/data/test_yaml_stories/test_full_retrieval_intent_wrong_prediction.yml @@ -0,0 +1,7 @@ +stories: +- story: chitchat name + steps: + - user: | + What is your name? + intent: chitchat/ask_weather + - action: utter_chitchat diff --git a/data/test_yaml_stories/test_multiple_action_unlikely_intent_warnings.yml b/data/test_yaml_stories/test_multiple_action_unlikely_intent_warnings.yml new file mode 100644 index 0000000..300cbf8 --- /dev/null +++ b/data/test_yaml_stories/test_multiple_action_unlikely_intent_warnings.yml @@ -0,0 +1,28 @@ +version: "3.1" +stories: + - story: path 1 + steps: + - user: | + hello there! + intent: greet + - action: utter_greet + - user: | + amazing + intent: mood_great + - action: utter_happy + + - story: path 2 + steps: + - user: | + hello there! + intent: greet + - action: utter_greet + - user: | + very sad + intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - user: | + yes + intent: affirm + - action: utter_happy diff --git a/data/test_yaml_stories/test_prediction_with_correct_intent_wrong_entity.yml b/data/test_yaml_stories/test_prediction_with_correct_intent_wrong_entity.yml new file mode 100644 index 0000000..79ecb99 --- /dev/null +++ b/data/test_yaml_stories/test_prediction_with_correct_intent_wrong_entity.yml @@ -0,0 +1,6 @@ +stories: +- story: test story with correct intent and wrong entity + steps: + - user: | + i am looking for [greek](feedback) food and to seat [outside](seating) + intent: request_restaurant diff --git a/data/test_yaml_stories/test_prediction_with_wrong_intent_correct_entity.yml b/data/test_yaml_stories/test_prediction_with_wrong_intent_correct_entity.yml new file mode 100644 index 0000000..4fb3033 --- /dev/null +++ b/data/test_yaml_stories/test_prediction_with_wrong_intent_correct_entity.yml @@ -0,0 +1,6 @@ +stories: +- story: test story with wrong intent and correct entity + steps: + - user: | + i am looking for [greek](cuisine) food and to seat [outside](seating) + intent: greet diff --git a/data/test_yaml_stories/test_prediction_with_wrong_intent_wrong_entity.yml b/data/test_yaml_stories/test_prediction_with_wrong_intent_wrong_entity.yml new file mode 100644 index 0000000..1f07cad --- /dev/null +++ b/data/test_yaml_stories/test_prediction_with_wrong_intent_wrong_entity.yml @@ -0,0 +1,6 @@ +stories: +- story: test story with wrong intent and wrong entity + steps: + - user: | + i am looking for [greek](feedback) food and to seat [outside](seating) + intent: greet diff --git a/data/test_yaml_stories/test_stories_entity_annotations.yml b/data/test_yaml_stories/test_stories_entity_annotations.yml new file mode 100644 index 0000000..49f95cc --- /dev/null +++ b/data/test_yaml_stories/test_stories_entity_annotations.yml @@ -0,0 +1,9 @@ +version: "3.1" + +stories: + - story: Test with entity annotation + steps: + - intent: greet + user: |- + Hi, I live in [London](city). + - bot: hey there! diff --git a/docker/.dockerignore b/docker/.dockerignore new file mode 100644 index 0000000..012f564 --- /dev/null +++ b/docker/.dockerignore @@ -0,0 +1 @@ +docker-data/* diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base new file mode 100644 index 0000000..fa6620b --- /dev/null +++ b/docker/Dockerfile.base @@ -0,0 +1,28 @@ +# The base image used for all images +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND="noninteractive" + +RUN apt-get update -qq && \ + apt-get install -y --no-install-recommends \ + python3 \ + python3-venv \ + python3-pip \ + python3-dev \ + # required by psycopg2 at build and runtime + libpq-dev \ + # required for health check + curl \ + # required for some Python package builds with Ubuntu 22.04 + cargo \ + rustc \ + && apt-get autoremove -y + +# Make sure that all security updates are installed +RUN apt-get update && apt-get dist-upgrade -y --no-install-recommends + +RUN update-alternatives --install /usr/bin/python python /usr/bin/python3 100 \ + && update-alternatives --install /usr/bin/pip pip /usr/bin/pip3 100 + +# Create rasa user and group +RUN useradd -rm -d /app -s /sbin/nologin -g root -u 1001 rasa && groupadd -g 1001 rasa \ No newline at end of file diff --git a/docker/Dockerfile.base-builder b/docker/Dockerfile.base-builder new file mode 100644 index 0000000..6aa9b1b --- /dev/null +++ b/docker/Dockerfile.base-builder @@ -0,0 +1,33 @@ +# The base builder image used for all images +ARG IMAGE_BASE_NAME +ARG POETRY_VERSION + +FROM ${IMAGE_BASE_NAME}:base-poetry-${POETRY_VERSION} + +ARG BUILDPLATFORM + +RUN apt-get update -qq && \ + apt-get install -y --no-install-recommends \ + build-essential \ + wget \ + openssh-client \ + graphviz-dev \ + pkg-config \ + git-core \ + openssl \ + libssl-dev \ + libffi7 \ + libffi-dev \ + libpng-dev \ + && apt-get autoremove -y + +RUN \ +if [ "$BUILDPLATFORM" = "linux/arm64" ]; \ +then wget --progress=dot:giga -N -P librdkafka/ https://github.com/confluentinc/librdkafka/archive/refs/tags/v2.0.2.tar.gz && \ + tar -xvf librdkafka/v2.0.2.tar.gz -C librdkafka/ && \ + cd librdkafka/librdkafka-2.0.2 && ./configure --prefix=/usr && make && make install ; \ +fi + +# Make sure that all security updates are installed +RUN apt-get update && apt-get dist-upgrade -y --no-install-recommends + diff --git a/docker/Dockerfile.base-mitie b/docker/Dockerfile.base-mitie new file mode 100644 index 0000000..3995622 --- /dev/null +++ b/docker/Dockerfile.base-mitie @@ -0,0 +1,9 @@ +# The base image used for all images that require a MITIE model +FROM alpine:latest + +RUN apk add --update make wget + +# download mitie model +WORKDIR /build +COPY ./Makefile . +RUN make prepare-mitie diff --git a/docker/Dockerfile.base-poetry b/docker/Dockerfile.base-poetry new file mode 100644 index 0000000..150d934 --- /dev/null +++ b/docker/Dockerfile.base-poetry @@ -0,0 +1,11 @@ +ARG IMAGE_BASE_NAME +ARG BASE_IMAGE_HASH + +FROM ${IMAGE_BASE_NAME}:base-${BASE_IMAGE_HASH} + +ARG POETRY_VERSION + +# install poetry +ENV POETRY_VERSION ${POETRY_VERSION} +RUN curl -sSL https://install.python-poetry.org | python3 - +ENV PATH "/root/.local/bin:${PATH}" diff --git a/docker/Dockerfile.full b/docker/Dockerfile.full new file mode 100644 index 0000000..b865a5b --- /dev/null +++ b/docker/Dockerfile.full @@ -0,0 +1,67 @@ +# The image tagged with the 'full' suffix +ARG IMAGE_BASE_NAME +ARG BASE_IMAGE_HASH +ARG BASE_MITIE_IMAGE_HASH +ARG BASE_BUILDER_IMAGE_HASH + +FROM ${IMAGE_BASE_NAME}:base-mitie-${BASE_MITIE_IMAGE_HASH} as mitie + +FROM ${IMAGE_BASE_NAME}:base-builder-${BASE_BUILDER_IMAGE_HASH} as builder + +COPY --from=mitie /build/data /build/data + +# copy files +COPY . /build/ +COPY docker/configs/config_pretrained_embeddings_spacy_en_duckling.yml /build/config.yml + +# change working directory +WORKDIR /build + +# install dependencies +RUN python -m venv /opt/venv && \ + . /opt/venv/bin/activate && \ + pip install --no-cache-dir -U "pip==22.*" -U "wheel>0.38.0" +RUN . /opt/venv/bin/activate && poetry install --extras full --no-dev --no-root --no-interaction +RUN . /opt/venv/bin/activate && make install-mitie && \ + poetry build -f wheel -n && \ + pip install --no-deps dist/*.whl && \ + rm -rf dist *.egg-info + +# make sure we use the virtualenv +ENV PATH="/opt/venv/bin:$PATH" + +# spacy link +RUN python -m spacy download en_core_web_md && \ + python -m spacy download de_core_news_sm && \ + python -m spacy download it_core_news_md && \ + python -m spacy link en_core_web_md en && \ + python -m spacy link it_core_news_md it && \ + python -m spacy link de_core_news_sm de + +# start a new build stage +FROM ${IMAGE_BASE_NAME}:base-${BASE_IMAGE_HASH} as runner + +# copy everything from /opt +COPY --from=builder /opt/venv /opt/venv + +# make sure we use the virtualenv +ENV PATH="/opt/venv/bin:$PATH" + +# set HOME environment variable +ENV HOME=/app + +# update permissions & change user to not run as root +WORKDIR /app +RUN chgrp -R 0 /app && chmod -R g=u /app && chmod o+wr /app +USER 1001 + +# Create a volume for temporary data +VOLUME /tmp + +# change shell +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# the entry point +EXPOSE 5005 +ENTRYPOINT ["rasa"] +CMD ["--help"] diff --git a/docker/Dockerfile.pretrained_embeddings_mitie_en b/docker/Dockerfile.pretrained_embeddings_mitie_en new file mode 100644 index 0000000..af81efe --- /dev/null +++ b/docker/Dockerfile.pretrained_embeddings_mitie_en @@ -0,0 +1,55 @@ +# The image tagged with the 'mitie-en' suffix +ARG IMAGE_BASE_NAME +ARG BASE_IMAGE_HASH +ARG BASE_MITIE_IMAGE_HASH +ARG BASE_BUILDER_IMAGE_HASH + +FROM ${IMAGE_BASE_NAME}:base-mitie-${BASE_MITIE_IMAGE_HASH} as mitie + +FROM ${IMAGE_BASE_NAME}:base-builder-${BASE_BUILDER_IMAGE_HASH} as builder + +COPY --from=mitie /build/data /build/data + +# copy files +COPY . /build/ +COPY docker/configs/config_pretrained_embeddings_mitie.yml /build/config.yml + +# change working directory +WORKDIR /build + +# install dependencies +RUN python -m venv /opt/venv && \ + . /opt/venv/bin/activate && pip install --no-cache-dir -U "pip==22.*" -U "wheel>0.38.0" +RUN . /opt/venv/bin/activate && poetry install --no-dev --no-root --no-interaction +RUN . /opt/venv/bin/activate && make install-mitie && \ + poetry build -f wheel -n && \ + pip install --no-deps dist/*.whl && \ + rm -rf dist *.egg-info + +# start a new build stage +FROM ${IMAGE_BASE_NAME}:base-${BASE_IMAGE_HASH} as runner + +# copy everything from /opt +COPY --from=builder /opt/venv /opt/venv + +# make sure we use the virtualenv +ENV PATH="/opt/venv/bin:$PATH" + +# set HOME environment variable +ENV HOME=/app + +# update permissions & change user to not run as root +WORKDIR /app +RUN chgrp -R 0 /app && chmod -R g=u /app && chmod o+wr /app +USER 1001 + +# create a volume for temporary data +VOLUME /tmp + +# change shell +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# the entry point +EXPOSE 5005 +ENTRYPOINT ["rasa"] +CMD ["--help"] diff --git a/docker/Dockerfile.pretrained_embeddings_spacy_de b/docker/Dockerfile.pretrained_embeddings_spacy_de new file mode 100644 index 0000000..f804f87 --- /dev/null +++ b/docker/Dockerfile.pretrained_embeddings_spacy_de @@ -0,0 +1,54 @@ +# The image tagged with the 'spacy-de' suffix +ARG IMAGE_BASE_NAME +ARG BASE_IMAGE_HASH +ARG BASE_BUILDER_IMAGE_HASH + +FROM ${IMAGE_BASE_NAME}:base-builder-${BASE_BUILDER_IMAGE_HASH} as builder + +# copy files +COPY . /build/ +COPY docker/configs/config_pretrained_embeddings_spacy_de.yml /build/config.yml + +# change working directory +WORKDIR /build + +# install dependencies +RUN python -m venv /opt/venv && \ + . /opt/venv/bin/activate && pip install --no-cache-dir -U "pip==22.*" -U "wheel>0.38.0" +RUN . /opt/venv/bin/activate && poetry install --extras spacy --no-dev --no-root --no-interaction +RUN . /opt/venv/bin/activate && poetry build -f wheel -n && \ + pip install --no-deps dist/*.whl && \ + rm -rf dist *.egg-info + +# make sure we use the virtualenv +ENV PATH="/opt/venv/bin:$PATH" + +RUN python -m spacy download de_core_news_sm + +# start a new build stage +FROM ${IMAGE_BASE_NAME}:base-${BASE_IMAGE_HASH} as runner + +# copy everything from /opt +COPY --from=builder /opt/venv /opt/venv + +# make sure we use the virtualenv +ENV PATH="/opt/venv/bin:$PATH" + +# set HOME environment variable +ENV HOME=/app + +# update permissions & change user to not run as root +WORKDIR /app +RUN chgrp -R 0 /app && chmod -R g=u /app && chmod o+wr /app +USER 1001 + +# Create a volume for temporary data +VOLUME /tmp + +# change shell +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# the entry point +EXPOSE 5005 +ENTRYPOINT ["rasa"] +CMD ["--help"] diff --git a/docker/Dockerfile.pretrained_embeddings_spacy_en b/docker/Dockerfile.pretrained_embeddings_spacy_en new file mode 100644 index 0000000..509d23a --- /dev/null +++ b/docker/Dockerfile.pretrained_embeddings_spacy_en @@ -0,0 +1,54 @@ +# The image tagged with the 'spacy-en' suffix +ARG IMAGE_BASE_NAME +ARG BASE_IMAGE_HASH +ARG BASE_BUILDER_IMAGE_HASH + +FROM ${IMAGE_BASE_NAME}:base-builder-${BASE_BUILDER_IMAGE_HASH} as builder + +# copy files +COPY . /build/ +COPY docker/configs/config_pretrained_embeddings_spacy_en.yml /build/config.yml + +# change working directory +WORKDIR /build + +# install dependencies +RUN python -m venv /opt/venv && \ + . /opt/venv/bin/activate && pip install --no-cache-dir -U "pip==22.*" -U "wheel>0.38.0" +RUN . /opt/venv/bin/activate && poetry install --extras spacy --no-dev --no-root --no-interaction +RUN . /opt/venv/bin/activate && poetry build -f wheel -n && \ + pip install --no-deps dist/*.whl && \ + rm -rf dist *.egg-info + +# make sure we use the virtualenv +ENV PATH="/opt/venv/bin:$PATH" + +RUN python -m spacy download en_core_web_md + +# start a new build stage +FROM ${IMAGE_BASE_NAME}:base-${BASE_IMAGE_HASH} as runner + +# copy everything from /opt +COPY --from=builder /opt/venv /opt/venv + +# make sure we use the virtualenv +ENV PATH="/opt/venv/bin:$PATH" + +# set HOME environment variable +ENV HOME=/app + +# update permissions & change user to not run as root +WORKDIR /app +RUN chgrp -R 0 /app && chmod -R g=u /app && chmod o+wr /app +USER 1001 + +# Create a volume for temporary data +VOLUME /tmp + +# change shell +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# the entry point +EXPOSE 5005 +ENTRYPOINT ["rasa"] +CMD ["--help"] diff --git a/docker/Dockerfile.pretrained_embeddings_spacy_it b/docker/Dockerfile.pretrained_embeddings_spacy_it new file mode 100644 index 0000000..0e58c1f --- /dev/null +++ b/docker/Dockerfile.pretrained_embeddings_spacy_it @@ -0,0 +1,54 @@ +# The image tagged with the 'spacy-it' suffix +ARG IMAGE_BASE_NAME +ARG BASE_IMAGE_HASH +ARG BASE_BUILDER_IMAGE_HASH + +FROM ${IMAGE_BASE_NAME}:base-builder-${BASE_BUILDER_IMAGE_HASH} as builder + +# copy files +COPY . /build/ +COPY docker/configs/config_pretrained_embeddings_spacy_it.yml /build/config.yml + +# change working directory +WORKDIR /build + +# install dependencies +RUN python -m venv /opt/venv && \ + . /opt/venv/bin/activate && pip install --no-cache-dir -U "pip==22.*" -U "wheel>0.38.0" +RUN . /opt/venv/bin/activate && poetry install --extras spacy --no-dev --no-root --no-interaction +RUN . /opt/venv/bin/activate && poetry build -f wheel -n && \ + pip install --no-deps dist/*.whl && \ + rm -rf dist *.egg-info + +# make sure we use the virtualenv +ENV PATH="/opt/venv/bin:$PATH" + +RUN python -m spacy download it_core_news_md + +# start a new build stage +FROM ${IMAGE_BASE_NAME}:base-${BASE_IMAGE_HASH} as runner + +# copy everything from /opt +COPY --from=builder /opt/venv /opt/venv + +# make sure we use the virtualenv +ENV PATH="/opt/venv/bin:$PATH" + +# set HOME environment variable +ENV HOME=/app + +# update permissions & change user to not run as root +WORKDIR /app +RUN chgrp -R 0 /app && chmod -R g=u /app && chmod o+wr /app +USER 1001 + +# Create a volume for temporary data +VOLUME /tmp + +# change shell +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# the entry point +EXPOSE 5005 +ENTRYPOINT ["rasa"] +CMD ["--help"] diff --git a/docker/configs/config_pretrained_embeddings_mitie.yml b/docker/configs/config_pretrained_embeddings_mitie.yml new file mode 100644 index 0000000..1ff8997 --- /dev/null +++ b/docker/configs/config_pretrained_embeddings_mitie.yml @@ -0,0 +1,11 @@ +language: "en" + +pipeline: + - name: MitieNLP + model: "data/total_word_feature_extractor.dat" + - name: MitieTokenizer + - name: MitieEntityExtractor + - name: EntitySynonymMapper + - name: RegexFeaturizer + - name: MitieFeaturizer + - name: SklearnIntentClassifier diff --git a/docker/configs/config_pretrained_embeddings_spacy_de.yml b/docker/configs/config_pretrained_embeddings_spacy_de.yml new file mode 100644 index 0000000..2348d4a --- /dev/null +++ b/docker/configs/config_pretrained_embeddings_spacy_de.yml @@ -0,0 +1,17 @@ +language: "de" + +pipeline: + - name: SpacyNLP + model: "de_core_news_sm" + - name: SpacyTokenizer + - name: SpacyFeaturizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + - name: EntitySynonymMapper + - name: ResponseSelector diff --git a/docker/configs/config_pretrained_embeddings_spacy_en.yml b/docker/configs/config_pretrained_embeddings_spacy_en.yml new file mode 100644 index 0000000..b6bffd5 --- /dev/null +++ b/docker/configs/config_pretrained_embeddings_spacy_en.yml @@ -0,0 +1,17 @@ +language: "en" + +pipeline: + - name: SpacyNLP + model: "en_core_web_md" + - name: SpacyTokenizer + - name: SpacyFeaturizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + - name: EntitySynonymMapper + - name: ResponseSelector diff --git a/docker/configs/config_pretrained_embeddings_spacy_en_duckling.yml b/docker/configs/config_pretrained_embeddings_spacy_en_duckling.yml new file mode 100644 index 0000000..950f7ee --- /dev/null +++ b/docker/configs/config_pretrained_embeddings_spacy_en_duckling.yml @@ -0,0 +1,19 @@ +language: "en" + +pipeline: + - name: SpacyNLP + model: "en_core_web_md" + - name: SpacyTokenizer + - name: SpacyFeaturizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + - name: EntitySynonymMapper + - name: ResponseSelector + - name: DucklingEntityExtractor + url: "http://duckling:8000" diff --git a/docker/configs/config_pretrained_embeddings_spacy_it.yml b/docker/configs/config_pretrained_embeddings_spacy_it.yml new file mode 100644 index 0000000..29fe0f1 --- /dev/null +++ b/docker/configs/config_pretrained_embeddings_spacy_it.yml @@ -0,0 +1,17 @@ +language: "it" + +pipeline: + - name: SpacyNLP + model: "it_core_news_md" + - name: SpacyTokenizer + - name: SpacyFeaturizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + - name: EntitySynonymMapper + - name: ResponseSelector diff --git a/docker/docker-bake.hcl b/docker/docker-bake.hcl new file mode 100644 index 0000000..89934a8 --- /dev/null +++ b/docker/docker-bake.hcl @@ -0,0 +1,187 @@ +variable "IMAGE_NAME" { + default = "rasa/rasa" +} + +variable "IMAGE_TAG" { + default = "localdev" +} + +variable "BASE_IMAGE_HASH" { + default = "localdev" +} + +variable "BASE_MITIE_IMAGE_HASH" { + default = "localdev" +} + +variable "BASE_BUILDER_IMAGE_HASH" { + default = "localdev" +} + +# keep this in sync with the version in .github/poetry_version.txt +# the variable is set automatically for builds in CI +variable "POETRY_VERSION" { + default = "1.8.2" +} + +group "base-images" { + targets = ["base", "base-poetry", "base-mitie"] +} + +target "base" { + dockerfile = "docker/Dockerfile.base" + tags = ["${IMAGE_NAME}:base-${IMAGE_TAG}"] + cache-to = ["type=inline"] +} + +target "base-mitie" { + dockerfile = "docker/Dockerfile.base-mitie" + tags = ["${IMAGE_NAME}:base-mitie-${IMAGE_TAG}"] + cache-to = ["type=inline"] +} + +target "base-poetry" { + dockerfile = "docker/Dockerfile.base-poetry" + tags = ["${IMAGE_NAME}:base-poetry-${POETRY_VERSION}"] + + args = { + IMAGE_BASE_NAME = "${IMAGE_NAME}" + BASE_IMAGE_HASH = "${BASE_IMAGE_HASH}" + POETRY_VERSION = "${POETRY_VERSION}" + } + + cache-to = ["type=inline"] + + cache-from = [ + "type=registry,ref=${IMAGE_NAME}:base-poetry-${POETRY_VERSION}", + ] +} + +target "base-builder" { + dockerfile = "docker/Dockerfile.base-builder" + tags = ["${IMAGE_NAME}:base-builder-${IMAGE_TAG}"] + + args = { + IMAGE_BASE_NAME = "${IMAGE_NAME}" + POETRY_VERSION = "${POETRY_VERSION}" + } + + cache-to = ["type=inline"] +} + +target "default" { + dockerfile = "Dockerfile" + tags = ["${IMAGE_NAME}:${IMAGE_TAG}"] + + args = { + IMAGE_BASE_NAME = "${IMAGE_NAME}" + BASE_IMAGE_HASH = "${BASE_IMAGE_HASH}" + BASE_BUILDER_IMAGE_HASH = "${BASE_BUILDER_IMAGE_HASH}" + } + + cache-to = ["type=inline"] + + cache-from = [ + "type=registry,ref=${IMAGE_NAME}:base-${BASE_IMAGE_HASH}", + "type=registry,ref=${IMAGE_NAME}:base-builder-${BASE_BUILDER_IMAGE_HASH}", + "type=registry,ref=${IMAGE_NAME}:latest", + ] +} + +target "full" { + dockerfile = "docker/Dockerfile.full" + tags = ["${IMAGE_NAME}:${IMAGE_TAG}-full"] + + args = { + IMAGE_BASE_NAME = "${IMAGE_NAME}" + BASE_IMAGE_HASH = "${BASE_IMAGE_HASH}" + BASE_MITIE_IMAGE_HASH = "${BASE_MITIE_IMAGE_HASH}" + BASE_BUILDER_IMAGE_HASH = "${BASE_BUILDER_IMAGE_HASH}" + } + + cache-to = ["type=inline"] + + cache-from = [ + "type=registry,ref=${IMAGE_NAME}:base-${BASE_IMAGE_HASH}", + "type=registry,ref=${IMAGE_NAME}:base-builder-${BASE_BUILDER_IMAGE_HASH}", + "type=registry,ref=${IMAGE_NAME}:latest-full", + ] +} + +target "mitie-en" { + dockerfile = "docker/Dockerfile.pretrained_embeddings_mitie_en" + tags = ["${IMAGE_NAME}:${IMAGE_TAG}-mitie-en"] + + args = { + IMAGE_BASE_NAME = "${IMAGE_NAME}" + BASE_IMAGE_HASH = "${BASE_IMAGE_HASH}" + BASE_MITIE_IMAGE_HASH = "${BASE_MITIE_IMAGE_HASH}" + BASE_BUILDER_IMAGE_HASH = "${BASE_BUILDER_IMAGE_HASH}" + } + + cache-to = ["type=inline"] + + cache-from = [ + "type=registry,ref=${IMAGE_NAME}:base-${BASE_IMAGE_HASH}", + "type=registry,ref=${IMAGE_NAME}:base-mitie-${BASE_MITIE_IMAGE_HASH}", + "type=registry,ref=${IMAGE_NAME}:base-builder-${BASE_BUILDER_IMAGE_HASH}", + "type=registry,ref=${IMAGE_NAME}:latest-mitie-en", + ] +} + +target "spacy-de" { + dockerfile = "docker/Dockerfile.pretrained_embeddings_spacy_de" + tags = ["${IMAGE_NAME}:${IMAGE_TAG}-spacy-de"] + + args = { + IMAGE_BASE_NAME = "${IMAGE_NAME}" + BASE_IMAGE_HASH = "${BASE_IMAGE_HASH}" + BASE_BUILDER_IMAGE_HASH = "${BASE_BUILDER_IMAGE_HASH}" + } + + cache-to = ["type=inline"] + + cache-from = [ + "type=registry,ref=${IMAGE_NAME}:base-${BASE_IMAGE_HASH}", + "type=registry,ref=${IMAGE_NAME}:base-builder-${BASE_BUILDER_IMAGE_HASH}", + "type=registry,ref=${IMAGE_NAME}:latest-spacy-de", + ] +} + +target "spacy-it" { + dockerfile = "docker/Dockerfile.pretrained_embeddings_spacy_it" + tags = ["${IMAGE_NAME}:${IMAGE_TAG}-spacy-it"] + + args = { + IMAGE_BASE_NAME = "${IMAGE_NAME}" + BASE_IMAGE_HASH = "${BASE_IMAGE_HASH}" + BASE_BUILDER_IMAGE_HASH = "${BASE_BUILDER_IMAGE_HASH}" + } + + cache-to = ["type=inline"] + + cache-from = [ + "type=registry,ref=${IMAGE_NAME}:base-${BASE_IMAGE_HASH}", + "type=registry,ref=${IMAGE_NAME}:base-builder-${BASE_BUILDER_IMAGE_HASH}", + "type=registry,ref=${IMAGE_NAME}:latest-spacy-it", + ] +} + +target "spacy-en" { + dockerfile = "docker/Dockerfile.pretrained_embeddings_spacy_en" + tags = ["${IMAGE_NAME}:${IMAGE_TAG}-spacy-en"] + + args = { + IMAGE_BASE_NAME = "${IMAGE_NAME}" + BASE_IMAGE_HASH = "${BASE_IMAGE_HASH}" + BASE_BUILDER_IMAGE_HASH = "${BASE_BUILDER_IMAGE_HASH}" + } + + cache-to = ["type=inline"] + + cache-from = [ + "type=registry,ref=${IMAGE_NAME}:base-${BASE_IMAGE_HASH}", + "type=registry,ref=${IMAGE_NAME}:base-builder-${BASE_BUILDER_IMAGE_HASH}", + "type=registry,ref=${IMAGE_NAME}:latest-spacy-en", + ] +} diff --git a/docker/docker-cloud.yml b/docker/docker-cloud.yml new file mode 100644 index 0000000..07d1929 --- /dev/null +++ b/docker/docker-cloud.yml @@ -0,0 +1,8 @@ +rasa: + image: rasa/rasa:latest-spacy-en + ports: + - "5000:5000" + volumes: + - "/rasa-app-data/projects:/app/projects" + - "/rasa-app-data/logs:/app/logs" + - "./rasa-app-data/data:/app/data" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..efc735c --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,28 @@ +version: '3.0' + +services: + rasa: + image: rasa/rasa:latest-full + networks: ['rasa-network'] + ports: + - "5005:5005" + volumes: + - "./rasa-app-data/:/app/" + command: + - run + + action_server: + image: rasa/rasa-sdk:latest + networks: ['rasa-network'] + ports: + - "5055:5055" + volumes: + - "./rasa-app-data/actions:/app/actions" + + duckling: + image: rasa/duckling:latest + networks: ['rasa-network'] + ports: + - "8000:8000" + +networks: {rasa-network: {}} diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..66d4f68 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,7 @@ +.netlify +.docusaurus +docs/variables.json +docs/sources +docs/reference +docs/changelog.mdx +docs/telemetry/reference.mdx diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..f0e477b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,68 @@ +# Docs + +The docs are built using [Docusaurus 2](https://v2.docusaurus.io/). +To run Docusaurus, install `Node.js 12.x`. + +## Useful commands + +### Installation +Firstly, install python dependencies for Rasa: + +``` +$ make install +``` + +Then, install doc dependencies: + +``` +$ make install-docs +``` + +### Local Development +In order to build the docs, run: + +``` +$ make docs +``` + +Then, start doc server in watch mode: + +``` +$ make livedocs +``` + +This command starts a local development server and open up a browser window. Most changes are reflected live without having to restart the server. + +### Build + +``` +$ yarn build +``` + +This command generates static content into the `build` directory and can be served using any static contents hosting service. + +### Deployment + +Deployment is handled by Netlify: it is setup for listening to changes on the `documentation` branch. + + +## Manual steps after a new version + +When a new docs version has been released, we'll need to do the following manual steps: +- Remove all the callouts from previous versions, with the exception of experimental features. You can find + those using `:::info` or `:::caution` in all the docs files. +- Update the wording of the top banner, configured in `docusaurus.config.js` in `announcementBar`: update the Rasa versions + that are mentioned and link to the now previous major version documentation. +- Update Netlify redirects in `netlify.toml`, under `# Redirects for latest version permalinks`, by adjusting the + version number to the now new major version. + + +## Handling deadlinks after removal of deprecated features + +When removing deprecated features, it will happen that some links become dead because they now link to +parts of the docs that no longer exist. This usually happens in the CHANGELOG or migration links, +and thankfully we do have CI checks that alert for dead links. + +The trick here is to make these links point to _previous_ versions of the docs. For instance, if the feature +you removed was documented at `./policies#mapping-policy` and the current latest version for the docs is `2.x` +(this also means that the next version is `3.x`), then you can update the link to `https://rasa.com/docs/rasa/2.x/policies#mapping-policy`. diff --git a/docs/babel.config.js b/docs/babel.config.js new file mode 100644 index 0000000..e00595d --- /dev/null +++ b/docs/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: [require.resolve('@docusaurus/core/lib/babel/preset')], +}; diff --git a/docs/docs/action-server/actions.mdx b/docs/docs/action-server/actions.mdx new file mode 100644 index 0000000..5bb280e --- /dev/null +++ b/docs/docs/action-server/actions.mdx @@ -0,0 +1,330 @@ +--- +id: actions +sidebar_label: Actions +title: Actions +--- + +When a Rasa assistant calls a custom action, it sends a request to the action server. +Rasa only knows about whatever events and responses come back in the request response; +it's up to the action server to call the correct code based on the action name that Rasa provides. + +To better understand what happens when Rasa calls a custom action, consider +the following example: + +You have deployed a weather bot to both Facebook and Slack. The user +can ask for the weather with the intent `ask_weather`. There is a slot `location` +which will be filled if the user has specified a location. +The action `action_tell_weather` will use an API to get the weather forecast , +using a default location if the user doesn't specify one. +The action will set the `temperature` to the maximum temperature of the weather forecast. +The message returned will differ according to the channel they are using. + + +:::info Compressed body in HTTP requests +Rasa has the capability to compress the HTTP request body for custom actions. +By default, this option is off to keep backward compatibility with older versions of +custom action servers which did not receive the compressed body in the HTTP request for custom actions. +To enable this option, set environment variable COMPRESS_ACTION_SERVER_REQUEST to `True`. + +Rasa SDK versions `3.2.2`, `3.3.1`, `3.4.1` and upwards, +support both compressed and non-compressed body in the HTTP request for +running custom action server. There is no additional setup required. +::: + +## Custom Action Input + +Your action server receives the following payload from the Rasa server: + +```json +{ + "next_action": "action_tell_weather", + "sender_id": "2687378567977106", + "tracker": { + "sender_id": "2687378567977106", + "slots": { + "location": null, + "temperature": null + }, + "latest_message": { + "text": "/ask_weather", + "intent": { + "name": "ask_weather", + "confidence": 1 + }, + "intent_ranking": [ + { + "name": "ask_weather", + "confidence": 1 + } + ], + "entities": [] + }, + "latest_event_time": 1599850576.655345, + "followup_action": null, + "paused": false, + "events": [ + { + "event": "action", + "timestamp": 1599850576.654908, + "name": "action_session_start", + "policy": null, + "confidence": null + }, + { + "event": "session_started", + "timestamp": 1599850576.654916 + }, + { + "event": "action", + "timestamp": 1599850576.654928, + "name": "action_listen", + "policy": null, + "confidence": null + }, + { + "event": "user", + "timestamp": 1599850576.655345, + "text": "/ask_weather", + "parse_data": { + "text": "/ask_weather", + "intent": { + "name": "ask_weather", + "confidence": 1 + }, + "intent_ranking": [ + { + "name": "ask_weather", + "confidence": 1 + } + ], + "entities": [] + }, + "input_channel": "facebook", + "message_id": "3f2f2317dada4908b7a841fd3eab6bf9", + "metadata": {} + } + ], + "latest_input_channel": "facebook", + "active_form": {}, + "latest_action_name": "action_listen" + }, + "domain": { + "config": { + "store_entities_as_slots": true + }, + "session_config": { + "session_expiration_time": 60, + "carry_over_slots_to_new_session": true + }, + "intents": [ + { + "greet": { + "use_entities": true + } + }, + { + "ask_weather": { + "use_entities": true + } + } + ], + "entities": [], + "slots": { + "location": { + "type": "rasa.core.slots.UnfeaturizedSlot", + "initial_value": null, + "auto_fill": true + }, + "temperature": { + "type": "rasa.core.slots.UnfeaturizedSlot", + "initial_value": null, + "auto_fill": true + } + }, + "responses": { + "utter_greet": [ + { + "text": "Hey! How are you?" + } + ] + }, + "actions": [ + "action_tell_weather", + "utter_greet" + ], + "forms": [] + }, + "version": "2.0.0" +} +``` + + +### `next_action` + +The `next_action` field tells your action server what action to run. +Your actions don't have to be implemented as classes, but they do have +to be callable by name. + +In the example case, your action server should run the action `action_tell_weather`. + +### `sender_id` + +The `sender_id` tells you the unique ID of the +user having the conversation. Its format varies according to the input channel. +What it tells you about the user also depends on the input channel and how +the user is identified by the channel. + +In the example case, the `sender_id` is not used for anything. + +### `tracker` + +The `tracker` contains information about the conversation, including a history of events +and a record of all slots: + +- `sender_id`: The same `sender_id` as is available in the top level of the payload +- `slots`: Each slot in your bot's domain and its value at the current time +- `latest_message`: The attributes of the latest message +- `latest_event_time`: The timestamp at which the last event was added to the tracker +- `followup_action`: The action called was a forced follow up action +- `paused`: Whether the conversation is currently paused +- `events`: A list of all previous [events](./events.mdx) +- `latest_input_channel`: The input channel from which the last user message was received +- `active_form`: The name of the currently active form, if any +- `latest_action_name`: The name of the last action the bot executed + +In the example case, your custom action uses the value of the `location` slot (if it is set) +to get the weather forecast. It also checks the `latest_input_channel` property +and formats the message payload so that it will display correctly in Facebook Messenger. + +### `domain` + +The `domain` is a json representation of your `domain.yaml` file. +It is unlikely that a custom action +will refer to its contents, as they are static and do not indicate the state +of the conversation. + +You can control if an action should receive a domain or not. +Visit [selective-domain](../domain.mdx#select-which-actions-should-receive-domain) + +### `version` + +This is the version of the Rasa server. A custom action +is also unlikely to refer to this, although you might use it in a +verification step if your action server +is only compatible with certain Rasa versions. + + +## Custom Action Output + +The Rasa server expects a dictionary of `events` and `responses` as a response +to a custom action call. + +### `events` + +[Events](./events.mdx) are how your action server can influence the conversation. +In the example case, your custom action should store the maximum temperature +in the `temperature` slot, so it needs to return a [`slot` event](./events.mdx#slot). To set the +slot and do nothing else, your response payload would look like this: + +```json + { + "events": [ + { + "event": "slot", + "timestamp": null, + "name": "temperature", + "value": "30" + } + ], + "responses": [] + } +``` + +Note that events will be applied to the tracker in the order you list them; with `slot` +events, the order won't matter, but with other event types it can. + +### `responses` + +A response can be of any of the response types described in the +[documentation on rich responses](../responses.mdx#rich-responses). +See the response sample of the [API spec](/pages/action-server-api) for the expected formats. + +In the example case, you want to send the user a message with the weather forecast. +To send a regular text message, the response payload would look like this: + +```json + { + "events": [ + { + "event": "slot", + "timestamp": null, + "name": "temperature", + "value": "30" + } + ], + "responses": [ + { + "text": "This is your weather forecast!" + } + ] + } +``` + + + +However, you want to make use of your channels' specific capabilities. Since +the `latest_input_channel` was Facebook, you add a response with +a custom payload that will be rendered as a media message according to Facebook's API spec. +Your response payload then looks like this: + + +```json + { + "events": [ + { + "event": "slot", + "timestamp": null, + "name": "temperature", + "value": "30" + } + ], + "responses": [ + { + "text": "This is your weather forecast!" + }, + { + "attachment": { + "type": "template", + "payload": { + "template_type": "media", + "elements": [ + { + "media_type": "weather_forcast.gif", + "attachment_id": "<id from facebook upload endpoint>" + } + ] + } + } + } + ] + } +``` + +When this response is sent back to +the Rasa server, Rasa will apply the `slot` event and two responses to the tracker, +and return both messages to the user. + +## Special Action Types + +There are special action types that are automatically triggered under certain circumstances, namely [default actions](../default-actions.mdx) +and [slot validation actions](../slot-validation-actions.mdx). +These special action types have predefined naming conventions that must be followed to maintain the automatic triggering behavior. + +You can customize a default action by implementing a custom action with exactly the same name. +Please see the [docs on default actions](../default-actions.mdx) for the expected behavior of each action. + +Slot validation actions are run on every user turn, depending on whether a form is active or not. +A slot validation action that should run when a form is not active must be called `action_validate_slot_mappings`. +A slot validation action that should run when a form is active must be called `validate_<form name>`. +These actions are expected to return `SlotSet` events only and to behave like the Rasa SDK [`ValidationAction` class](./validation-action.mdx#validationaction-class-implementation) +and [`FormValidationAction` class](./validation-action.mdx#formvalidationaction-class-implementation) respectively. diff --git a/docs/docs/action-server/events.mdx b/docs/docs/action-server/events.mdx new file mode 100644 index 0000000..fb95580 --- /dev/null +++ b/docs/docs/action-server/events.mdx @@ -0,0 +1,352 @@ +--- +id: events +sidebar_label: Events +title: Events +--- + + + +Conversations in Rasa are represented as a sequence of events. Custom actions can +influence the course of a conversation by returning events in the response to the action server request. + +Not all events are typically returned by custom actions since +they are tracked automatically by Rasa (e.g. user messages). +Others events can only be tracked if they are returned by a custom action. + +## Event Types + + +### `slot` + +Sets a slot on the tracker. It can set a slot to a value, +or reset a slot by setting its value to `null`. + +**Automatic Tracking**: + +* When a slot is filled by an entity of the same name. + +A custom action is needed +to set any slot not auto-filled by an entity. + + +**JSON**: + +```json +{ + "event": "slot", + "name": "departure_airport", + "value": "BER" +} +``` + + +**Parameters**: + +* `name`: Name of the slot to set +* `value`: Value to set the slot to. The datatype must match the [type](../domain.mdx#slot-types) + of the slot + +**Rasa Class**: `rasa.core.events.SlotSet` + + +### `reset_slots` + +Resets all slots on the tracker to `null`. + +**Automatic Tracking**: Never + +**JSON**: + +```json +{ + "event": "reset_slots" +} +``` + +**Rasa Class**: `rasa.core.events.AllSlotsReset` + + +### `reminder` + +Schedules an intent to be triggered at a certain time in the future. + +**Automatic Tracking**: Never + +**JSON**: + +```json +{ + "event": "reminder", + "intent": "my_intent", + "entities": {"entity1": "value1", "entity2": "value2"}, + "date_time": "2018-09-03T11:41:10.128172", + "name": "my_reminder", + "kill_on_user_msg": true, +} +``` + +**Parameters**: + +* `intent`: Intent which the reminder will trigger +* `entities`: Entities to send with the intent +* `date_time`: Date at which the execution of the action should be triggered. This should either be in UTC or include a timezone. +* `name`: ID of the reminder. If there are multiple reminders with the same id only the last will be run. +* `kill_on_user_msg`: Whether a user message before the trigger time will abort the reminder + +**Rasa Class**: `rasa.core.events.ReminderScheduled` + + +### `cancel_reminder` + +Cancels a scheduled reminder or reminders. +All reminders which match the supplied parameters will be cancelled. + +**Automatic Tracking**: Never + +**JSON**: + +```json +{ + "event": "cancel_reminder", + "name": "my_reminder", + "intent": "my_intent", + "entities": [ + {"entity": "entity1", "value": "value1"}, + {"entity": "entity2", "value": "value2"}, + ], + "date_time": "2018-09-03T11:41:10.128172", +} +``` + +**Parameters**: + + +* `intent`: Intent which the reminder will trigger +* `entities`: Entities to send with the intent +* `date_time`: Date at which the execution of the action should be triggered. This should either be in UTC or include a timezone. +* `name`: ID of the reminder. + + +**Rasa Class**: `rasa.core.events.ReminderCancelled` + +### `pause` + +Stops the bot from responding to user messages. The conversation will remain paused and no actions will be predicted until the conversation is explicitly [resumed](#resume). + +**Automatic Tracking**: Never + +**JSON**: + +```json +{ + "event": "pause" +} +``` + +**Rasa Class**: `rasa.core.events.ConversationPaused` + +### `resume` + +Resume a previously paused conversation. Once this event is added to the tracker the bot will start predicting actions again. It will not predict actions for user messages received while the conversation was paused. + +**Automatic Tracking**: Never + +**JSON**: + +```json +{ + "event": "resume" +} +``` + + +**Rasa Class**: `rasa.core.events.ConversationResumed` + +### `followup` + +Force a follow up action, bypassing action prediction. + +**Automatic Tracking**: Never + +**JSON**: + +```json +{ + "event": "followup", + "name": "my_action" +} +``` + +**Parameters**: + +* `name`: The name of the follow up action that will be executed. + + +**Rasa Class**: `rasa.core.events.FollowupAction` + +### `rewind` + +Reverts all side effects of the last user message and removes the last `user` event from the tracker. + +**Automatic Tracking**: + +**JSON**: + +```json +{ + "event": "rewind" +} +``` + +**Rasa Class**: `rasa.core.events.UserUtteranceReverted` + + +### `undo` + +Undoes all side effects of the last bot action and removes the last bot action from the tracker. + +**Automatic Tracking**: + +**JSON**: + +```json +{ + "event": "undo" +} +``` + +**Rasa Class**: `rasa.core.events.ActionReverted` + + + +### `restart` + +Resets the tracker. After a `restart` event, there will be no conversation history and no record of the restart. + +**Automatic Tracking**: + +* When the `/restart` default intent is triggered. + + +**JSON**: + +```json +{ + "event": "restart" +} +``` + + +**Rasa Class**: `rasa.core.events.Restarted` + + +### `session_started` + +Starts a new conversation by resetting the tracker and running the default action `ActionSessionStart`. This action will by default carry over existing `SlotSet` events to a new conversation session. You can configure this behaviour in your domain file under `session_config`. + +**Automatic Tracking**: + +* Whenever a user starts a conversation with the bot for the first time. +* Whenever a session expires (after `session_expiration_time` specified in the domain), and the user resumes their conversation + +Restarting a conversation with [`restart`](#restart) event **does not** automatically cause a `session_started` event. + +**JSON**: + +```json +{ + "event": "session_started" +} +``` + +**Rasa Class**: `rasa.core.events.SessionStarted` + + +### `user` + +The user sent a message to the bot. + +**Automatic Tracking**: + +* When the user sends a message to the bot. + +This event is not usually returned by a custom action. + +**JSON**: + +```json +{ + "event": "user", + "text": "Hey", + "parse_data": { + "intent": { + "name": "greet", + "confidence": 0.9 + }, + "entities": [] + }, + "metadata": {}, +} +``` + +**Parameters**: + +* `text`: Text of the user message +* `parse_data`: Parsed data of user message. This is ordinarily filled by NLU. +* `metadata`: Arbitrary metadata that comes with the user message + +**Rasa Class**: `rasa.core.events.UserUttered` + +### `bot` + +The bot sent a message to the user. + + +**Automatic Tracking**: + +* Whenever `responses` are returned by a custom action +* Whenever responses are sent to the user directly without being returned by a custom action (e.g. `utter_` actions) + +This event is not usually returned explicitly by a custom action; `responses` would be returned instead. + +**JSON**: + +```json +{ + "event": "bot", + "text": "Hey there!", + "data": {} +} +``` + +**Parameters**: + +* `text`: The text the bot sends to the user +* `data`: Any non-text elements of the bot response. The structure of `data` matches that of `responses` given in the [API spec](/pages/action-server-api). + +**Rasa Class**: `rasa.core.events.BotUttered` + + +### `action` + +Logs an action called by the bot. Only the action itself is logged; the events that the action creates are logged separately when they are applied. + +**Automatic Tracking**: + +* Any action (including custom actions and responses) that is called, even if the action does not execute successfully. + +This event is not usually returned explicitly by a custom action. + +**JSON**: +```json +{ + "event": "action", + "name": "my_action" +} +``` +**Parameters**: + +* `name`: Name of the action that was called + +**Rasa Class**: `rasa.core.events.ActionExecuted` + diff --git a/docs/docs/action-server/index.mdx b/docs/docs/action-server/index.mdx new file mode 100644 index 0000000..1879d4d --- /dev/null +++ b/docs/docs/action-server/index.mdx @@ -0,0 +1,46 @@ +--- +slug: /action-server +sidebar_label: Introduction +title: Introduction to Rasa Action Server +--- + +A Rasa action server runs [custom actions](../custom-actions.mdx) for a Rasa Open Source +conversational assistant. + +## How it works + +When your assistant predicts a custom action, the Rasa server sends +a `POST` request to the action server with a json payload including +the name of the predicted action, +the conversation ID, the contents of the tracker and the contents of the domain. + +When the action server finishes running a custom action, it returns a json payload +of [responses](../responses.mdx) and [events](./events.mdx). +See the [API spec](/pages/action-server-api) for details about the request and response payloads. + +The Rasa server then returns the responses to the user and adds the events +to the conversation tracker. + + +## SDKs for Custom Actions + +You can use an action server written in any language to run your custom actions, as long as it implements +the [required APIs](/pages/action-server-api). + +### Rasa SDK (Python) + +Rasa SDK is a Python SDK for running custom actions. Besides implementing +the required APIs, it offers methods for interacting with the conversation tracker +and composing events and responses. +If you don't yet have an action server and don't need it +to be in a language other than Python, using the Rasa SDK will be the easiest way +to get started. + +### Other Action Servers + +If you have legacy code or existing business logic in another language, +you may not want to use the Rasa SDK. In this case you can write +your own action server in any language you want. The only requirement +for the action server is that it provide a `/webhook` endpoint which accepts HTTP `POST` requests +from the Rasa server and returns a payload of [events](./events.mdx) and responses. +See the [API spec](/pages/action-server-api) for details about the required `/webhook` endpoint. diff --git a/docs/docs/action-server/knowledge-base-actions.mdx b/docs/docs/action-server/knowledge-base-actions.mdx new file mode 100644 index 0000000..538c79c --- /dev/null +++ b/docs/docs/action-server/knowledge-base-actions.mdx @@ -0,0 +1,589 @@ +--- +id: knowledge-bases +sidebar_label: Knowledge Base Actions +title: Knowledge Base Actions +abstract: Leverage information from knowledge bases inside conversations using `ActionQueryKnowledgeBase` in open source bot framework Rasa. +--- +import useBaseUrl from '@docusaurus/useBaseUrl'; + +:::caution +This feature is experimental. +We introduce experimental features to get feedback from our community, so we encourage you to try it out! +However, the functionality might be changed or removed in the future. +If you have feedback (positive or negative) please share it with us on the [forum](https://forum.rasa.com). + +::: + +Knowledge base actions enable you to handle the following kind of conversations: + + + +<img alt="image" src={useBaseUrl("/img/knowledge-base-example.png")} /> + +A common problem in conversational AI is that users do not only refer to certain objects by their names, +but also use reference terms such as “the first one” or “it”. +We need to keep track of the information that was presented to resolve these mentions to +the correct object. + +In addition, users may want to obtain detailed information about objects during a conversation – +for example, whether a restaurant has outside seating, or how expensive it is. +In order to respond to those user requests, knowledge about the restaurant domain is needed. +Since the information is subject to change, hard-coding the information isn't the solution. + +To handle the above challenges, Rasa can be integrated with knowledge bases. To use this integration, you can create a +custom action that inherits from `ActionQueryKnowledgeBase`, a pre-written custom action that contains +the logic to query a knowledge base for objects and their attributes. + +You can find a complete example in `examples/knowledgebasebot` +([knowledge base bot](https://github.com/RasaHQ/rasa/tree/main/examples/knowledgebasebot/)), as well as instructions +for implementing this custom action below. + +## Using `ActionQueryKnowledgeBase` + +<a aria-hidden="true" tabIndex="-1" className="anchor enhancedAnchor" id="create-knowledge-base"></a> + +### Create a Knowledge Base + +The data used to answer the user's requests will be stored in a knowledge base. +A knowledge base can be used to store complex data structures. +We suggest you get started by using the `InMemoryKnowledgeBase`. +Once you want to start working with a large amount of data, you can switch to a custom knowledge base +(see [Creating Your Own Knowledge Base](./knowledge-base-actions.mdx#create-a-knowledge-base)). + +To initialize an `InMemoryKnowledgeBase`, you need to provide the data in a json file. +The following example contains data about restaurants and hotels. +The json structure should contain a key for every object type, i.e. `"restaurant"` and `"hotel"`. +Every object type maps to a list of objects – here we have a list of 3 restaurants and a list of 3 hotels. + +```json +{ + "restaurant": [ + { + "id": 0, + "name": "Donath", + "cuisine": "Italian", + "outside-seating": true, + "price-range": "mid-range" + }, + { + "id": 1, + "name": "Berlin Burrito Company", + "cuisine": "Mexican", + "outside-seating": false, + "price-range": "cheap" + }, + { + "id": 2, + "name": "I due forni", + "cuisine": "Italian", + "outside-seating": true, + "price-range": "mid-range" + } + ], + "hotel": [ + { + "id": 0, + "name": "Hilton", + "price-range": "expensive", + "breakfast-included": true, + "city": "Berlin", + "free-wifi": true, + "star-rating": 5, + "swimming-pool": true + }, + { + "id": 1, + "name": "Hilton", + "price-range": "expensive", + "breakfast-included": true, + "city": "Frankfurt am Main", + "free-wifi": true, + "star-rating": 4, + "swimming-pool": false + }, + { + "id": 2, + "name": "B&B", + "price-range": "mid-range", + "breakfast-included": false, + "city": "Berlin", + "free-wifi": false, + "star-rating": 1, + "swimming-pool": false + }, + ] +} +``` + +Once the data is defined in a json file, called, for example, `data.json`, you will be able use the this data file to create your +`InMemoryKnowledgeBase`, which will be passed to the action that queries the knowledge base. + +Every object in your knowledge base should have at least the `"name"` and `"id"` fields to use the default implementation. +If it doesn't, you'll have to [customize your InMemoryKnowledgeBase](./knowledge-base-actions.mdx#customizing-the-inmemoryknowledgebase). + +### Define the NLU Data + +In this section: + +* we will introduce a new intent, `query_knowledge_base` + +* we will annotate `mention` entities so that our model detects indirect mentions of objects like “the + first one” + +* we will use [synonyms](../training-data-format.mdx#synonyms) extensively + +For the bot to understand that the user wants to retrieve information from the knowledge base, you need to define +a new intent. We will call it `query_knowledge_base`. + +We can split requests that `ActionQueryKnowledgeBase` can handle into two categories: +(1) the user wants to obtain a list of objects of a specific type, or (2) the user wants to know about a certain +attribute of an object. The intent should contain lots of variations of both of these requests: + +```yaml-rasa +nlu: +- intent: query_knowledge_base + examples: | + - what [restaurants]{"entity": "object_type", "value": "restaurant"} can you recommend? + - list some [restaurants]{"entity": "object_type", "value": "restaurant"} + - can you name some [restaurants]{"entity": "object_type", "value": "restaurant"} please? + - can you show me some [restaurants]{"entity": "object_type", "value": "restaurant"} options + - list [German](cuisine) [restaurants]{"entity": "object_type", "value": "restaurant"} + - do you have any [mexican](cuisine) [restaurants]{"entity": "object_type", "value": "restaurant"}? + - do you know the [price range]{"entity": "attribute", "value": "price-range"} of [that one](mention)? + - what [cuisine](attribute) is [it](mention)? + - do you know what [cuisine](attribute) the [last one]{"entity": "mention", "value": "LAST"} has? + - does the [first one]{"entity": "mention", "value": "1"} have [outside seating]{"entity": "attribute", "value": "outside-seating"}? + - what is the [price range]{"entity": "attribute", "value": "price-range"} of [Berlin Burrito Company](restaurant)? + - what about [I due forni](restaurant)? + - can you tell me the [price range](attribute) of [that restaurant](mention)? + - what [cuisine](attribute) do [they](mention) have? +``` + +The above example just shows examples related to the restaurant domain. +You should add examples for every object type that exists in your knowledge base to the same `query_knowledge_base` intent. + +In addition to adding a variety of training examples for each query type, +you need to specify and annotate the following entities in your training examples: + +* `object_type`: Whenever a training example references a specific object type from your knowledge base, the object type should + be marked as an entity. Use [synonyms](../training-data-format.mdx#synonyms) to map e.g. `restaurants` to `restaurant`, the correct + object type listed as a key in the knowledge base. + +* `mention`: If the user refers to an object via “the first one”, “that one”, or “it”, you should mark those terms + as `mention`. We also use synonyms to map some of the mentions to symbols. You can learn about that + in [resolving mentions](./knowledge-base-actions.mdx#resolve-mentions). + +* `attribute`: All attribute names defined in your knowledge base should be identified as `attribute` in the + NLU data. Again, use synonyms to map variations of an attribute name to the one used in the + knowledge base. + +Remember to add those entities to your domain file (as entities and slots): + +```yaml-rasa +entities: + - object_type + - mention + - attribute + +slots: + object_type: + type: any + influence_conversation: false + mappings: + - type: from_entity + entity: object_type + mention: + type: any + influence_conversation: false + mappings: + - type: from_entity + entity: mention + attribute: + type: any + influence_conversation: false + mappings: + - type: from_entity + entity: attribute +``` + +<a aria-hidden="true" tabIndex="-1" className="anchor enhancedAnchor" id="create-action-query-knowledge-base"></a> + +### Create an Action to Query your Knowledge Base + +To create your own knowledge base action, you need to inherit `ActionQueryKnowledgeBase` and pass the knowledge +base to the constructor of `ActionQueryKnowledgeBase`. + +```python +from rasa_sdk.knowledge_base.storage import InMemoryKnowledgeBase +from rasa_sdk.knowledge_base.actions import ActionQueryKnowledgeBase + +class MyKnowledgeBaseAction(ActionQueryKnowledgeBase): + def __init__(self): + knowledge_base = InMemoryKnowledgeBase("data.json") + super().__init__(knowledge_base) +``` + +Whenever you create an `ActionQueryKnowledgeBase`, you need to pass a `KnowledgeBase` to the constructor. +It can be either an `InMemoryKnowledgeBase` or your own implementation of a `KnowledgeBase` +(see [Creating Your Own Knowledge Base](./knowledge-base-actions.mdx#create-a-knowledge-base)). +You can only pull information from one knowledge base, as the usage of multiple knowledge bases at the same time is not supported. + +This is the entirety of the code for this action! The name of the action is `action_query_knowledge_base`. +Don't forget to add it to your domain file: + +```yaml-rasa +actions: +- action_query_knowledge_base +``` + +:::note +If you overwrite the default action name `action_query_knowledge_base`, you need to add the following three +unfeaturized slots to your domain file: `knowledge_base_objects`, `knowledge_base_last_object`, and +`knowledge_base_last_object_type`. +The slots are used internally by `ActionQueryKnowledgeBase`. +If you keep the default action name, those slots will be automatically added for you. + +::: + +You also need to make sure to add a story to your stories file that includes the intent `query_knowledge_base` and +the action `action_query_knowledge_base`. For example: + +```yaml-rasa +stories: +- story: knowledge base happy path + steps: + - intent: greet + - action: utter_greet + - intent: query_knowledge_base + - action: action_query_knowledge_base + - intent: goodbye + - action: utter_goodbye +``` + +The last thing you need to do is to define the response `utter_ask_rephrase` in your domain file. +If the action doesn't know how to handle the user's request, it will use this response to ask the user to rephrase. +For example, add the following responses to your domain file: + +```yaml-rasa +responses: + utter_ask_rephrase: + - text: "Sorry, I'm not sure I understand. Could you rephrase it?" + - text: "Could you please rephrase your message? I didn't quite get that." +``` + +After adding all the relevant pieces, the action is now able to query the knowledge base. + +## How It Works + +`ActionQueryKnowledgeBase` looks at both the entities that were picked up in the request as well as the +previously set slots to decide what to query for. + +### Query the Knowledge Base for Objects + +In order to query the knowledge base for any kind of object, the user's request needs to include the object type. +Let's look at an example: + +Can you please name some restaurants? + +This question includes the object type of interest: “restaurant.” +The bot needs to pick up on this entity in order to formulate a query – otherwise the action would not know what objects the user is interested in. + +When the user says something like: + +What Italian restaurant options in Berlin do I have? + +The user wants to obtain a list of restaurants that (1) have Italian cuisine and (2) are located in +Berlin. If the NER detects those attributes in the request of the user, the action will use those to filter the +restaurants found in the knowledge base. + +In order for the bot to detect these attributes, you need to mark “Italian” and “Berlin” as entities in the NLU data: + +```yaml-rasa +intents: +- intent: query_knowledge_base + examples: | + - What [Italian](cuisine) [restaurant](object_type) options in [Berlin](city) do I have?. +``` + +The names of the attributes, “cuisine” and “city,” should be equal to the ones used in the knowledge base. +You also need to add those as entities and slots to the domain file. + +### Query the Knowledge Base for an Attribute of an Object + +If the user wants to obtain specific information about an object, the request should include both the object and +attribute of interest. + +:::info New in 3.6 +The user is not required to query the knowledge base to list any kind of object prior to this. +The `ActionQueryKnowledgeBase` will extract the object type from the user's request and query the knowledge base for an attribute of the object. +::: + +For example, if the user asks something like: + +What is the cuisine of Berlin Burrito Company? + +The user wants to obtain the “cuisine” (attribute of interest) for the restaurant “Berlin Burrito Company” (object of +interest). + +The attribute and object of interest should be marked as entities in the NLU training data: + +```yaml-rasa +intents: +- intent: query_knowledge_base + examples: | + - What is the [cuisine](attribute) of [Berlin Burrito Company](restaurant)? +``` + +Make sure to add the object type, “restaurant,” to the domain file as entity and slot. This will support `ActionQueryKnowledgeBase` +to extract the object type of the object the user is interested in. + +<a aria-hidden="true" tabIndex="-1" className="anchor enhancedAnchor" id="resolve-mentions"></a> + +### Resolve Mentions + +Following along from the above example, users may not always refer to restaurants by their names. +Users can either refer to the object of interest by its name, e.g. “Berlin Burrito Company” (representation string +of the object), or they may refer to a previously listed object via a mention, for example: + +What is the cuisine of the second restaurant you mentioned? + +Our action is able to resolve these mentions to the actual object in the knowledge base. +More specifically, it can resolve two mention types: (1) ordinal mentions, such as “the first one”, and (2) +mentions such as “it” or “that one”. + +**Ordinal Mentions** + +When a user refers to an object by its position in a list, it is called an ordinal mention. Here's an example: + +* User: What restaurants in Berlin do you know? + +* Bot: Found the following objects of type 'restaurant': 1: I due forni 2: PastaBar 3: Berlin Burrito Company + +* User: Does the first one have outside seating? + +The user referred to “I due forni” by the term “the first one”. +Other ordinal mentions might include “the second one,” “the last one,” “any,” or “3”. + +Ordinal mentions are typically used when a list of objects was presented to the user. +To resolve those mentions to the actual object, we use an ordinal mention mapping which is set in the +`KnowledgeBase` class. +The default mapping looks like: + +```python +{ + "1": lambda l: l[0], + "2": lambda l: l[1], + "3": lambda l: l[2], + "4": lambda l: l[3], + "5": lambda l: l[4], + "6": lambda l: l[5], + "7": lambda l: l[6], + "8": lambda l: l[7], + "9": lambda l: l[8], + "10": lambda l: l[9], + "ANY": lambda l: random.choice(l), + "LAST": lambda l: l[-1], +} +``` + +The ordinal mention mapping maps a string, such as “1”, to the object in a list, e.g. `lambda l: l[0]`, meaning the +object at index `0`. + +As the ordinal mention mapping does not, for example, include an entry for “the first one”, +it is important that you use [Entity Synonyms](../training-data-format.mdx#synonyms) to map “the first one” in your NLU data to “1”: + +```yaml-rasa +intents: +- intent: query_knowledge_base + examples: | + - Does the [first one]{entity: "mention", value": 1} have [outside seating]{entity: "attribute", value": "outside-seating"} +``` + +The NER detects “first one” as a `mention` entity, but puts “1” into the `mention` slot. +Thus, our action can take the `mention` slot together with the ordinal mention mapping to resolve “first one” to +the actual object “I due forni”. + +You can overwrite the ordinal mention mapping by calling the function `set_ordinal_mention_mapping()` on your +`KnowledgeBase` implementation (see [Customizing the InMemoryKnowledgeBase](./knowledge-base-actions.mdx#customizing-the-inmemoryknowledgebase)). + +**Other Mentions** + +Take a look at the following conversation: + +* User: What is the cuisine of PastaBar? + +* Bot: PastaBar has an Italian cuisine. + +* User: Does it have wifi? + +* Bot: Yes. + +* User: Can you give me an address? + +In the question “Does it have wifi?”, the user refers to “PastaBar” by the word “it”. +If the NER detected “it” as the entity `mention`, the knowledge base action would resolve it to the last mentioned +object in the conversation, “PastaBar”. + +In the next input, the user refers indirectly to the object “PastaBar” instead of mentioning it explicitly. +The knowledge base action would detect that the user wants to obtain the value of a specific attribute, in this case, the address. +If no mention or object was detected by the NER, the action assumes the user is referring to the most recently +mentioned object, “PastaBar”. + +You can disable this behavior by setting `use_last_object_mention` to `False` when initializing the action. + +## Customization + +### Customizing `ActionQueryKnowledgeBase` + +You can overwrite the following two functions of `ActionQueryKnowledgeBase` if you'd like to customize what the bot +says to the user: + +* `utter_objects()` + +* `utter_attribute_value()` + +`utter_objects()` is used when the user has requested a list of objects. +Once the bot has retrieved the objects from the knowledge base, it will respond to the user by default with a message, formatted like: + +Found the following objects of type 'restaurant': +1: I due forni +2: PastaBar +3: Berlin Burrito Company + +Or, if no objects are found, + +I could not find any objects of type 'restaurant'. + +If you want to change the utterance format, you can overwrite the method `utter_objects()` in your action. + +The function `utter_attribute_value()` determines what the bot utters when the user is asking for specific information about +an object. + +If the attribute of interest was found in the knowledge base, the bot will respond with the following utterance: + +'Berlin Burrito Company' has the value 'Mexican' for attribute 'cuisine'. + +If no value for the requested attribute was found, the bot will respond with + +Did not find a valid value for attribute 'cuisine' for object 'Berlin Burrito Company'. + +If you want to change the bot utterance, you can overwrite the method `utter_attribute_value()`. + +:::note +There is a [tutorial](https://blog.rasa.com/integrating-rasa-with-knowledge-bases/) on our blog about +how to use knowledge bases in custom actions. The tutorial explains the implementation behind +`ActionQueryKnowledgeBase` in detail. + +::: + +### Creating Your Own Knowledge Base Actions + +`ActionQueryKnowledgeBase` should allow you to easily get started with integrating knowledge bases into your actions. +However, the action can only handle two kind of user requests: + +* the user wants to get a list of objects from the knowledge base + +* the user wants to get the value of an attribute for a specific object + +The action is not able to compare objects or consider relations between objects in your knowledge base. +Furthermore, resolving any mention to the last mentioned object in the conversation might not always be optimal. + +If you want to tackle more complex use cases, you can write your own custom action. +We added some helper functions to `rasa_sdk.knowledge_base.utils` +([link to code](https://github.com/RasaHQ/rasa-sdk/tree/main/rasa_sdk/knowledge_base/) ) +to help you when implement your own solution. +We recommend using `KnowledgeBase` interface so that you can still use the `ActionQueryKnowledgeBase` +alongside your new custom action. + +If you write a knowledge base action that tackles one of the above use cases or a new one, be sure to tell us about +it on the [forum](https://forum.rasa.com)! + +<a aria-hidden="true" tabIndex="-1" className="anchor enhancedAnchor" id="customizing-the-inmemoryknowledgebase"></a> + +### Customizing the `InMemoryKnowledgeBase` + +The class `InMemoryKnowledgeBase` inherits `KnowledgeBase`. +You can customize your `InMemoryKnowledgeBase` by overwriting the following functions: + +* `get_key_attribute_of_object()`: To keep track of what object the user was talking about last, we store the value + of the key attribute in a specific slot. Every object should have a key attribute that is unique, + similar to the primary key in a relational database. By default, the name of the key attribute for every object type + is set to `id`. You can overwrite the name of the key attribute for a specific object type by calling + `set_key_attribute_of_object()`. + +* `get_representation_function_of_object()`: Let's focus on the following restaurant: + + ```json + { + "id": 0, + "name": "Donath", + "cuisine": "Italian", + "outside-seating": true, + "price-range": "mid-range" + } + ``` + + When the user asks the bot to list any Italian restaurant, it doesn't need all of the details of the restaurant. + Instead, you want to provide a meaningful name that identifies the restaurant – in most cases, the name of the object will do. + The function `get_representation_function_of_object()` returns a lambda function that maps the + above restaurant object to its name. + + ```python + lambda obj: obj["name"] + ``` + + This function is used whenever the bot is talking about a specific object, so that the user is presented a meaningful + name for the object. + + By default, the lambda function returns the value of the `"name"` attribute of the object. + If your object does not have a `"name"` attribute , or the `"name"` of an object is + ambiguous, you should set a new lambda function for that object type by calling + `set_representation_function_of_object()`. + +* `set_ordinal_mention_mapping()`: The ordinal mention mapping is needed to resolve an ordinal mention, such as + “second one,” to an object in a list. By default, the ordinal mention mapping looks like this: + + ```python + { + "1": lambda l: l[0], + "2": lambda l: l[1], + "3": lambda l: l[2], + "4": lambda l: l[3], + "5": lambda l: l[4], + "6": lambda l: l[5], + "7": lambda l: l[6], + "8": lambda l: l[7], + "9": lambda l: l[8], + "10": lambda l: l[9], + "ANY": lambda l: random.choice(l), + "LAST": lambda l: l[-1], + } + ``` + + You can overwrite it by calling the function `set_ordinal_mention_mapping()`. + If you want to learn more about how this mapping is used, check out [Resolve Mentions](./knowledge-base-actions.mdx#resolve-mentions). + +See the [example bot](https://github.com/RasaHQ/rasa/blob/main/examples/knowledgebasebot/actions/actions.py) for an +example implementation of an `InMemoryKnowledgeBase` that uses the method `set_representation_function_of_object()` +to overwrite the default representation of the object type “hotel.” +The implementation of the `InMemoryKnowledgeBase` itself can be found in the +[rasa-sdk](https://github.com/RasaHQ/rasa-sdk/tree/main/rasa_sdk/knowledge_base/) package. + +<a aria-hidden="true" tabIndex="-1" className="anchor enhancedAnchor" id="custom-knowledge-base"></a> + +### Creating Your Own Knowledge Base + +If you have more data or if you want to use a more complex data structure that, for example, involves relations between +different objects, you can create your own knowledge base implementation. +Just inherit `KnowledgeBase` and implement the methods `get_objects()`, `get_object()`, `get_object_types()` and +`get_attributes_of_object()`. The [knowledge base code](https://github.com/RasaHQ/rasa-sdk/tree/main/rasa_sdk/knowledge_base/) +provides more information on what those methods should do. + +You can also customize your knowledge base further, by adapting the methods mentioned in the section +[Customizing the InMemoryKnowledgeBase](./knowledge-base-actions.mdx#customizing-the-inmemoryknowledgebase). + +:::note +We wrote a [blog post](https://blog.rasa.com/set-up-a-knowledge-base-to-encode-domain-knowledge-for-rasa/) +that explains how you can set up your own knowledge base. + +::: diff --git a/docs/docs/action-server/running-action-server.mdx b/docs/docs/action-server/running-action-server.mdx new file mode 100644 index 0000000..2ebdccf --- /dev/null +++ b/docs/docs/action-server/running-action-server.mdx @@ -0,0 +1,45 @@ +--- +id: running-action-server +sidebar_label: Running a Rasa SDK Server +title: Running a Rasa SDK Action Server +--- + +There are two ways to run the action server, depending on whether you +are using an environment with +`rasa` installed or not: + +If `rasa` is installed, you can run the action server using a `rasa` command: + +```bash +rasa run actions +``` + +Alternatively you can make your assistant listen on a specific address using the `SANIC_HOST` environment +variable: + +```bash +SANIC_HOST=192.168.69.150 rasa run actions +``` + +If `rasa` is not installed, you can run the action server directly as a python module: + +```bash +python -m rasa_sdk --actions actions +``` + +Running the action server directly as a python module allows for `SANIC_HOST` too: + +```bash +SANIC_HOST=192.168.69.150 python -m rasa_sdk --actions actions +``` + +Using the command above, `rasa_sdk` will expect to find your actions +in a file called `actions.py` +or in a package directory called `actions`. +You can specify a different actions module or package with the +`--actions` flag. + +The full list of options for running the action server with either command is: + +```text [python -m rasa_sdk --help] +``` diff --git a/docs/docs/action-server/sanic-extensions.mdx b/docs/docs/action-server/sanic-extensions.mdx new file mode 100644 index 0000000..0192d55 --- /dev/null +++ b/docs/docs/action-server/sanic-extensions.mdx @@ -0,0 +1,67 @@ +--- +id: sanic-extensions +sidebar_label: Sanic Extensions +title: Sanic Extensions +--- + +:::info New in 3.6 + +You can now extend Sanic features such as middlewares, listeners, background tasks +and additional routes. + +::: + + +You can now create additional Sanic extensions by accessing the app object created by the action server. The hook implemented +in the plugin package provides you access to the Sanic app object created by `rasa-sdk` when starting the action server. + +## Step-by-step guide on creating your own Sanic extension in rasa_sdk +This example will show you how to create a Sanic listener using plugins. + +### Create the rasa_sdk_plugins package +Create a package in your action server project which you must name `rasa_sdk_plugins`. Rasa SDK will try to instantiate this package in your project to start plugins. +If no plugins are found, it will print a debug log that there are no plugins in your project. + +### Register modules containing the hooks +Create the package `rasa_sdk_plugins` and initialize the hooks by creating an `__init__.py` file where the plugin manager will look for the module where the hooks are implemented: + +``` +def init_hooks(manager: pluggy.PluginManager) -> None: + """Initialise hooks into rasa sdk.""" + import sys + import rasa_sdk_plugins.your_module + + logger.info("Finding hooks") + manager.register(sys.modules["rasa_sdk_plugins.your_module"]) +``` +### Implement your hook +Implement the hook `attach_sanic_app_extensions`. This hook forwards the app object created by Sanic in the `rasa_sdk` and allows you to create additional routes, middlewares, listeners and background tasks. Here's an example of this implementation that creates a listener. + +In your `rasa_sdk_plugins.your_module.py`: + +``` +from __future__ import annotations + +import logging +import pluggy + +from asyncio import AbstractEventLoop +from functools import partial + + +logger = logging.getLogger(__name__) +hookimpl = pluggy.HookimplMarker("rasa_sdk") + + +@hookimpl # type: ignore[misc] +def attach_sanic_app_extensions(app: Sanic) -> None: + logger.info("hook called") + app.register_listener( + partial(before_server_start), + "before_server_start", + ) + + +async def before_server_start(app: Sanic, loop: AbstractEventLoop): + logger.info("BEFORE SERVER START") +``` diff --git a/docs/docs/action-server/sdk-actions.mdx b/docs/docs/action-server/sdk-actions.mdx new file mode 100644 index 0000000..16e7db0 --- /dev/null +++ b/docs/docs/action-server/sdk-actions.mdx @@ -0,0 +1,110 @@ +--- +id: sdk-actions +sidebar_label: Actions +title: Actions +--- + + +The `Action` class is the base class for any custom action. To +define a custom action, create a subclass of the `Action` class +and overwrite the two required methods, `name` and `run`. The +action server will call an action according to the return value +of its `name` method when it receives a request to run an action. + +A skeleton custom action looks like this: + +```python +class MyCustomAction(Action): + + def name(self) -> Text: + + return "action_name" + + async def run( + self, dispatcher, tracker: Tracker, domain: Dict[Text, Any], + ) -> List[Dict[Text, Any]]: + + return [] +``` + +## Methods + +### Action.name + +Defines the action's name. The name returned by this method is the one used in your bot's domain. + +* **Returns**: + + Name of action + +* **Return type**: + + `str` + +### Action.run + +```python +async Action.run(dispatcher, tracker, domain) +``` + +The `run` method executes the side effects of the action. + + + +#### **Parameters** + + * **dispatcher** – the dispatcher which is used to + send messages back to the user. Use + `dispatcher.utter_message()` or any other + `rasa_sdk.executor.CollectingDispatcher` + method. See the [documentation for the dispatcher](sdk-dispatcher.mdx) + + * **tracker** – the state tracker for the current + user. You can access slot values using + `tracker.get_slot(slot_name)`, the most recent user message + is `tracker.latest_message.text` and any other + `rasa_sdk.Tracker` property. See the [documentation for the tracker](sdk-tracker.mdx). + + * **domain** – the bot's domain + + + +#### **Returns** + + A list of `rasa_sdk.events.Event` instances. See the [documentation for events](sdk-events.mdx). + +#### **Return type** + + `List`[`Dict`[`str`, `Any`]] + + +## Example + +In a restaurant bot, if the user says “show me a Mexican restaurant”, +your bot could execute the action `ActionCheckRestaurants`, +which might look like this: + +```python +from typing import Text, Dict, Any, List +from rasa_sdk import Action +from rasa_sdk.events import SlotSet + +class ActionCheckRestaurants(Action): + def name(self) -> Text: + return "action_check_restaurants" + + def run(self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any]) -> List[Dict[Text, Any]]: + + cuisine = tracker.get_slot('cuisine') + q = "select * from restaurants where cuisine='{0}' limit 1".format(cuisine) + result = db.query(q) + + return [SlotSet("matches", result if result is not None else [])] +``` + +This action queries a database to find restaurants matching +the requested cuisine, and uses the list of restaurants found +to set the value of the `matches` slot. diff --git a/docs/docs/action-server/sdk-dispatcher.mdx b/docs/docs/action-server/sdk-dispatcher.mdx new file mode 100644 index 0000000..3e5b24f --- /dev/null +++ b/docs/docs/action-server/sdk-dispatcher.mdx @@ -0,0 +1,135 @@ +--- +id: sdk-dispatcher +sidebar_label: Dispatcher +title: Dispatcher +--- + +A dispatcher is an instance of the `CollectingDispatcher` class used to generate responses to send back to the user. + +## CollectingDispatcher + +`CollectingDispatcher` has one method, `utter_message`, and one attribute, `messages`. +It is used in an action's `run` method to add responses to the payload returned to the Rasa server. +The Rasa server will in turn add `BotUttered` events to the tracker for each response. Responses +added using the dispatcher should therefore not be returned +explicitly as [events](events.mdx). For example, the +following custom action returns no events explicitly but will return the response, "Hi, User!" to the user: + +```python +class ActionGreetUser(Action): + def name(self) -> Text: + return "action_greet_user" + + async def run( + self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> List[EventType]: + + dispatcher.utter_message(text = "Hi, User!") + + return [] +``` + + +### CollectingDispatcher.utter_message + +The `utter_message` method can be used to return any type +of response to the user. + +#### **Parameters** + +The `utter_message` method takes the following optional arguments. +Passing no arguments will result in an empty message being returned to the user. +Passing multiple arguments will result in a rich response (e.g. text and buttons) being returned to the user. + + - `text`: The text to return to the user. + + ```python + dispatcher.utter_message(text = "Hey there") + ``` + + - `image`: An image URL or file path that will be used to display an image to the user. + + ```python + dispatcher.utter_message(image = "https://i.imgur.com/nGF1K8f.jpg") + ``` + + - `json_message`: A custom json payload as a dictionary. It can be used to send [channel specific responses](../responses.mdx). + The following example would return a date picker in Slack: + + ```python + date_picker = { + "blocks":[ + { + "type": "section", + "text":{ + "text": "Make a bet on when the world will end:", + "type": "mrkdwn" + }, + "accessory": + { + "type": "datepicker", + "initial_date": "2019-05-21", + "placeholder": + { + "type": "plain_text", + "text": "Select a date" + } + } + } + ] + } + dispatcher.utter_message(json_message = date_picker) + ``` + + - `response`: The name of a response to return to the user. This response should + be specified in your assistants [domain](../domain.mdx). + + ```python + dispatcher.utter_message(response = "utter_greet") + ``` + + - `attachment`: A URL or file path of an attachment to return to the user. + + ```python + dispatcher.utter_message(attachment = "") + ``` + + - `buttons`: A list of buttons to return to the user. + Each button is a dictionary and should have a `title` and a `payload` key. + A button can include other keys, but these will only be used if + a specific channel looks for them. + The button's `payload` will be sent as a user message if the user + clicks the button. + + ``` + dispatcher.utter_message(buttons = [ + {"payload": "/affirm", "title": "Yes"}, + {"payload": "/deny", "title": "No"}, + ]) + ``` + + - `elements`: These are specific to using Facebook as a messaging channel. For details + of expected format see [Facebook's documentation](https://developers.facebook.com/docs/messenger-platform/send-messages/template/generic/) + + - `**kwargs`: arbitrary keyword arguments, which can be + used to specify values for [variable interpolation in response variations](../responses.mdx). For example, + given the following response: + + ```yaml + responses: + utter_greet_name: + - text: Hi {name}! + ``` + + You could specify the name with: + + ```python + dispatcher.utter_message(response = "utter_greet_name", name = "Aimee") + ``` + +#### **Return type** + +`None` diff --git a/docs/docs/action-server/sdk-events.mdx b/docs/docs/action-server/sdk-events.mdx new file mode 100644 index 0000000..2577d92 --- /dev/null +++ b/docs/docs/action-server/sdk-events.mdx @@ -0,0 +1,360 @@ +--- +id: sdk-events +sidebar_label: Events +title: Events +--- + +Internally, Rasa conversations are represented as +a list of [events](events.mdx). Rasa SDK provides classes +for each event, and takes care of turning instances of event classes into properly formatted +event payloads. + +This page is about the event classes in `rasa_sdk`. +The side effects of events and their underlying +payloads are identical regardless of whether you use `rasa_sdk` or another action +server. +For details about the side effects of an event, its underlying + payload and the class in Rasa it is translated to +see the [documentation for events for all action servers](events.mdx) +(also linked to in each section). + +:::tip Importing events +All events written in a Rasa SDK action server need to be imported from `rasa_sdk.events`. +::: + + +## Event Classes + +### SlotSet + +```python +rasa_sdk.events.SlotSet( + key: Text, + value: Any = None, + timestamp: Optional[float] = None +) +``` + +**Underlying event**: [`slot`](events.mdx#slot) + +**Parameters**: + +* `key`: Name of the slot to set +* `value`: Value to set the slot to. The datatype must match the [type](../domain.mdx#slot-types) + of the slot +* `timestamp`: Optional timestamp of the event + +**Example** + +```python +evt = SlotSet(key = "name", value = "Mary") +``` + + +### AllSlotsReset + +```python +rasa_sdk.events.AllSlotsReset(timestamp: Optional[float] = None) +``` + +**Underlying event**: [`reset_slots`](events.mdx#reset_slots) + +**Parameters**: + +* `timestamp`: Optional timestamp of the event + +**Example**: + +```python +evt = AllSlotsReset() +``` + + +### ReminderScheduled + +```python +rasa_sdk.events.ReminderScheduled( + intent_name: Text, + trigger_date_time: datetime.datetime, + entities: Optional[Union[List[Dict[Text, Any]], Dict[Text, Text]]] = None, + name: Optional[Text] = None, + kill_on_user_message: bool = True, + timestamp: Optional[float] = None, +) +``` + +**Underlying event**: [`reminder`](events.mdx#reminder) + + +**Parameters**: + +* `intent_name`: Intent which the reminder will trigger +* `trigger_date_time`: Datetime at which the execution of the action should be triggered. +* `entities`: Entities to send with the intent +* `name`: ID of the reminder. If there are multiple reminders with the same id only the last will be run. +* `kill_on_user_message`: Whether a user message before the trigger time will abort the reminder +* `timestamp`: Optional timestamp of the event + + +**Example**: + +```python +from datetime import datetime + +evt = ReminderScheduled( + intent_name = "EXTERNAL_dry_plant", + trigger_date_time = datetime(2020, 9, 15, 0, 36, 0, 851609), + entities = [{"name": "plant","value":"orchid"}], + name = "remind_water_plants", +) +``` + + +### ReminderCancelled + +```python +ReminderCancelled( + name: Optional[Text] = None, + intent_name: Optional[Text] = None, + entities: Optional[Union[List[Dict[Text, Any]], Dict[Text, Text]]] = None, + timestamp: Optional[float] = None, +) +``` + +**Underlying event**: [`cancel_reminder`](events.mdx#cancel_reminder) + +**Parameters**: + +* `name`: ID of the reminder. +* `intent_name`: Intent which the reminder triggers +* `entities`: Entities sent with the intent +* `timestamp`: Optional timestamp of the event + +**Example**: + +```python +evt = ReminderCancelled(name = "remind_water_plants") +``` + + + +### ConversationPaused + +```python +ConversationPaused(timestamp: Optional[float] = None) +``` +**Underlying event**: [`pause`](events.mdx#slot) + +**Parameters**: + +* `timestamp`: Optional timestamp of the event + +**Example**: + +```python +evt = ConversationPaused() +``` + + + +### ConversationResumed + +```python +ConversationResumed(timestamp: Optional[float] = None) +``` + +**Underlying event**: [`resume`](events.mdx#resume) + +**Parameters**: + +* `timestamp`: Optional timestamp of the event + +**Example**: + +```python +evt = ConversationResumed() +``` + + + +### FollowupAction + +```python +FollowupAction( + name: Text, + timestamp: Optional[float] = None +) +``` + +**Underlying event**: [`followup`](events.mdx#followup) + +**Parameters**: + +* `name`: The name of the follow up action that will be executed. +* `timestamp`: Optional timestamp of the event + +**Example**: + +```python +evt = FollowupAction(name = "action_say_goodbye") +``` + + + +### UserUtteranceReverted + +```python +UserUtteranceReverted(timestamp: Optional[float] = None) +``` +**Underlying event**: [`rewind`](events.mdx#rewind) + +**Parameters**: + +* `timestamp`: Optional timestamp of the event + +**Example**: + +```python +evt = UserUtteranceReverted() +``` + + + +### ActionReverted + +```python +ActionReverted(timestamp: Optional[float] = None) +``` + +**Underlying event**: [`undo`](events.mdx#undo) + +**Parameters**: + +* `timestamp`: Optional timestamp of the event + +**Example**: + +```python +evt = ActionReverted() +``` + + + +### Restarted + +```python +Restarted(timestamp: Optional[float] = None) +``` + +**Underlying event**: [`restart`](events.mdx#restart) + +**Parameters**: + +* `timestamp`: Optional timestamp of the event + +**Example**: + +```python +evt = Restarted() +``` + + + +### SessionStarted + +```python +SessionStarted(timestamp: Optional[float] = None) +``` + +**Underlying event**: [`session_started`](events.mdx#session_started) + +**Parameters**: + +* `timestamp`: Optional timestamp of the event + +**Example**: + +```python +evt = SessionStarted() +``` + + + +### UserUttered + +```python +UserUttered( + text: Optional[Text], + parse_data: Optional[Dict[Text, Any]] = None, + timestamp: Optional[float] = None, + input_channel: Optional[Text] = None, +) +``` + +**Underlying event**: [`user`](events.mdx#user) + +**Parameters**: + +* `text`: Text of the user message +* `parse_data`: Parsed data of user message. This is ordinarily filled by NLU. +* `input_channel`: The channel on which the message was received +* `timestamp`: Optional timestamp of the event + +**Example**: + +```python +evt = UserUttered(text = "Hallo bot") +``` + + +### BotUttered + +```python +BotUttered( + text: Optional[Text] = None, + data: Optional[Dict[Text, Any]] = None, + metadata: Optional[Dict[Text, Any]] = None, + timestamp: Optional[float] = None, +) +``` + +**Underlying event**: [`bot`](events.mdx#bot) + +**Parameters**: + +* `text`: The text the bot sends to the user +* `data`: Any non-text elements of the bot response. The structure of `data` matches that of `responses` given in the [API spec](/pages/action-server-api). +* `metadata`: Arbitrary key-value metadata +* `timestamp`: Optional timestamp of the event + +**Example**: + +```python +evt = BotUttered(text = "Hallo user") +``` + + +### ActionExecuted + +```python +ActionExecuted( + action_name, + policy=None, + confidence: Optional[float] = None, + timestamp: Optional[float] = None, +) +``` +**Underlying event**: [`action`](events.mdx#action) + +**Parameters**: + +* `action_name`: Name of the action that was called +* `policy`: The policy used to predict the action +* `confidence`: The confidence with which the action was predicted +* `timestamp`: Optional timestamp of the event + +**Example**: + +```python +evt = ActionExecuted("action_greet_user") +``` diff --git a/docs/docs/action-server/sdk-tracker.mdx b/docs/docs/action-server/sdk-tracker.mdx new file mode 100644 index 0000000..1f0d597 --- /dev/null +++ b/docs/docs/action-server/sdk-tracker.mdx @@ -0,0 +1,139 @@ +--- +id: sdk-tracker +sidebar_label: Tracker +title: Tracker +--- + +The `Tracker` class represents a Rasa conversation tracker. +It lets you access your bot's memory in your custom +actions. You can get information about past events and the current state of the +conversation through `Tracker` attributes and methods. + + +## Attributes + +The following are available as attributes of a `Tracker` object: + +* `sender_id` - The unique ID of person talking to the bot. + +* `slots` - The list of slots that can be filled as defined in the + “ref”domains. + +* `latest_message` - A dictionary containing the attributes of the latest + message: `intent`, `entities` and `text`. + +* `events` - A list of all previous events. + +* `active_loop` - The name of the currently active loop. + +* `latest_action_name` - The name of the last action the bot executed. + +## Methods + +The available methods from the `Tracker` are: + +### Tracker.current_state + +Return the current tracker state as an object. + + * **Return type** + + `Dict[str, Any]` + + +### Tracker.is_paused + +State whether the tracker is currently paused. + + * **Return type** + + `bool` + + +### Tracker.get_latest_entity_values + +Get entity values found for the passed entity type and optional role and +group in latest message. +If you are only interested in the first entity of a given type use: + +```python +next(tracker.get_latest_entity_values(“my_entity_name”), None) +``` + +If no entity is found, then `None` is the default result. + + + * **Parameters** + + * `entity_type` – the entity type of interest + + * `entity_role` – optional entity role of interest + + * `entity_group` – optional entity group of interest + + + + * **Returns** + + List of entity values. + + + + * **Return type** + + `Iterator[str]` + + + +### Tracker.get_latest_input_channel + +Get the name of the input_channel of the latest UserUttered event + + + * **Return type** + + `Optional[str]` + + + +### Tracker.events_after_latest_restart + +Return a list of events after the most recent restart. + + + * **Return type** + + `List[Dict]` + + + +### Tracker.get_slot + + Retrieves the value of a slot. + + * **Parameters** + + * `key` – the name of the slot of which to retrieve the value + + + * **Return type** + + `Optional[Any]` + +### Tracker.get_intent_of_latest_message + + Retrieves the user's latest intent. + + * **Parameters** + + * `skip_fallback_intent` (default: `True`) – Optionally skip the `nlu_fallback` intent and return the next highest ranked. + + + * **Returns** + + The intent of the latest message if available. + + + * **Return type** + + `Optional[Text]` diff --git a/docs/docs/action-server/validation-action.mdx b/docs/docs/action-server/validation-action.mdx new file mode 100644 index 0000000..20552b2 --- /dev/null +++ b/docs/docs/action-server/validation-action.mdx @@ -0,0 +1,251 @@ +--- +id: validation-action +sidebar_label: Slot Validation Actions +title: Slot Validation Actions +abstract: Learn how `ValidationAction` class is implemented in the Rasa SDK. +--- + +There are two helper classes in Rasa SDK with the role of executing custom slot extraction and validation: +- `ValidationAction`: the base class for custom actions extracting and validating slots that can be set or updated +outside of a form context. +- `FormValidationAction`: the base class for custom actions extracting and validating slots that are set only within +the context of a form. + +In order to implement custom slot extraction and validation logic, you have the option of subclassing either +`ValidationAction` or `FormValidationAction` class, depending on the context in which you want to set or update slots. + +## `ValidationAction` class + +You can extend the `ValidationAction` class in the Rasa SDK to define custom extraction and / or validation of slots that +can be set or updated outside of a form context. + +:::note +`ValidationAction` is intended for extracting slots **outside** the context of a form. +It will ignore extraction and validation methods for any slots that have a form specified under the +[slot mapping's `conditions`](../domain.mdx#mapping-conditions). +It will not run these methods when the specified form is active nor when no form is active. +Please extend the [`FormValidationAction`](./validation-action.mdx#formvalidationaction-class) class to apply +custom slot mappings only within the context of a form. +::: + +### How to subclass `ValidationAction` + +Firstly, you must add the name of this action, `action_validate_slot_mappings`, to the domain `actions` list. +Note that you do not need to implement the `name` method in the custom action extending `ValidationAction`, since this +is already implemented. If you override the `name` method, the custom validation action will not run because the +original name is hardcoded in the call that the default action `action_extract_slots` makes to the action server. + +You should create only one subclass of `ValidationAction` which should contain all extraction and validation +methods for different slots according to your use-case. + +With this option, you do not need to specify the `action` key in the [custom slot mapping](../domain.mdx#custom-slot-mappings), +since the default action [`action_extract_slots`](../default-actions.mdx#action_extract_slots) runs `action_validate_slot_mappings` +automatically if present in the `actions` section of the domain. + +#### Validation of Slots with Predefined Mappings + +To validate slots with a predefined mapping, you must write functions named `validate_<slot_name>`. + +In the following example, the value for slot `location` is capitalized only if the extracted value is of type string: + +```python +from typing import Text, Any, Dict + +from rasa_sdk import Tracker, ValidationAction +from rasa_sdk.executor import CollectingDispatcher +from rasa_sdk.types import DomainDict + + +class ValidatePredefinedSlots(ValidationAction): + def validate_location( + self, + slot_value: Any, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: DomainDict, + ) -> Dict[Text, Any]: + """Validate location value.""" + if isinstance(slot_value, str): + # validation succeeded, capitalize the value of the "location" slot + return {"location": slot_value.capitalize()} + else: + # validation failed, set this slot to None + return {"location": None} +``` + +#### Extraction of Custom Slot Mappings + +To define custom extraction code, write an `extract_<slot_name>` method for every slot with a +custom slot mapping. + +The following example shows the implementation of a custom action that extracts the slot `count_of_insults` to keep +track of the user's attitude. + +```python +from typing import Dict, Text, Any + +from rasa_sdk import Tracker +from rasa_sdk.executor import CollectingDispatcher +from rasa_sdk.forms import ValidationAction + + +class ValidateCustomSlotMappings(ValidationAction): + async def extract_count_of_insults( + self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict + ) -> Dict[Text, Any]: + intent_of_last_user_message = tracker.get_intent_of_latest_message() + current_count_of_insults = tracker.get_slot("count_of_insults") + if intent_of_last_user_message == "insult": + current_count_of_insults += 1 + + return {"count_of_insults": current_count_of_insults} +``` + +### `ValidationAction` class implementation + +`ValidationAction` is a subclass of the `Action` Rasa SDK class and the abstract Python `ABC` class. +Therefore the class implements the `name` and `run` methods inherited from `Action`. +In addition, `ValidationAction` implements more specialized methods that will be called in the `run` method: +- `get_extraction_events`: Extracts custom slots using available `extract_<slot name>` methods +- `get_validation_events`: Validates slots by calling available `validate_<slot name>` methods for each slot +- `required_slots`: Returns slots which the validation action should fill + +#### Methods + +##### ValidationAction.name + +Defines the action's name: this must be hardcoded as `action_validate_slot_mappings`. + +* **Returns**: + + Name of action + +* **Return type**: + + `str` + +##### ValidationAction.run + +```python +async ValidationAction.run(dispatcher, tracker, domain) +``` + +The `run` method executes the custom extraction code defined in `extract_<slot name>` methods by calling the +`get_extraction_events` method, then updates the tracker with the returned events. +The `run` method will also execute custom validation code defined in `validate_<slot name>` methods via the +`get_validation_events` method and add the returned events to the tracker. + +###### **Parameters** + + * **dispatcher** – the dispatcher which is used to + send messages back to the user. Use + `dispatcher.utter_message()` or any other + `rasa_sdk.executor.CollectingDispatcher` + method. See the [documentation for the dispatcher](./sdk-dispatcher.mdx) + + * **tracker** – the state tracker for the current + user. You can access slot values using + `tracker.get_slot(slot_name)`, the most recent user message + is `tracker.latest_message.text` and any other + `rasa_sdk.Tracker` property. See the [documentation for the tracker](./sdk-tracker.mdx). + + * **domain** – the bot's domain + +###### **Returns** + + A list of `rasa_sdk.events.Event` instances. See the [documentation for events](./sdk-events.mdx). + +###### **Return type** + + `List`[`Dict`[`str`, `Any`]] + +##### ValidationAction.required_slots + +```python +async ValidationAction.required_slots(domain_slots, dispatcher, tracker, domain) +``` + +The `required_slots` method will return the `domain_slots` which is a list of all slot names mapped in the domain that +do not include any slot mapping with conditions. `domain_slots` is returned by the `domain_slots` method, which only takes +`Domain` as an argument. + +###### **Returns** + + A list of slot names of type `Text`. + +##### ValidationAction.get_extraction_events + +```python +async ValidationAction.get_extraction_events(dispatcher, tracker, domain) +``` + +The `get_extraction_events` method will gather the list of slot names via `required_slots` method call and then loop +through every slot name to run the `extract_<slot name>` method if available. + +##### **Returns** + + A list of `rasa_sdk.events.SlotSet` instances. See the [documentation for SlotSet events](./sdk-events.mdx#slotset). + +##### ValidationAction.get_validation_events + +```python +async ValidationAction.get_validation_events(dispatcher, tracker, domain) +``` + +The `get_validation_events` method will gather the list of slot names to validate via `required_slots` method call. +Then it will get a mapping of slots which were recently set and their values via `tracker.slots_to_validate` call. +Looping through this mapping of recently extracted slots, it will check if the slot is in `required_slots` then run the +`validate_<slot name>` method if available for that slot. + +###### **Returns** + + A list of `rasa_sdk.events.SlotSet` instances. See the [documentation for SlotSet events](./sdk-events.mdx#slotset). + +## `FormValidationAction` class + +A `FormValidationAction` custom action will only run if the form it specifies in its name is activated. +If certain custom slot mappings should only be extracted and / or validated within the context of a form, +the custom action should inherit from `FormValidationAction` rather than from `ValidationAction`. + +Since a custom action extending `FormValidationAction` runs on every user turn only as long as the form is active, it is +not required to use mapping conditions in this case. + +To learn more about how to implement this class, see Forms [Advanced Usage](../forms.mdx#advanced-usage). + +### `FormValidationAction` class implementation + +`FormValidationAction` is a subclass of the `ValidationAction` Rasa SDK class and the abstract Python `ABC` class. +`FormValidationAction` inherits most methods from `ValidationAction` class, however it overrides the `name` and +`domain_slots` methods, implements a new method `next_requested_slot` and extends the implementation of `run` method. + +#### Methods + +##### FormValidationAction.name + +The method `name` will raise a `NotImplementedError` exception if the bot custom action subclassing `FormValidationAction` +does not return a custom name which follows this naming convention: `validate_<form name>`. + +##### FormValidationAction.required_slots + +The method `required_slots` will return the `domain_slots` which is a list of all slot names included in the form's +`required_slots`. `domain_slots` is returned by the `domain_slots` method, which only takes +`Domain` as an argument. + +##### FormValidationAction.next_requested_slot + +The method `next_requested_slot` will set the value of `REQUESTED_SLOT` to the next unset slot only if the +`required_slots` method was overridden by the custom action subclassing `FormValidationAction`. + +If users didn't override `required_slots` then we'll let the `FormAction` within Rasa Open +Source request the next slot, and the method will return `None`. + +The parameters the method requires are: +- [dispatcher](./sdk-dispatcher.mdx) +- [tracker](./sdk-tracker.mdx) +- the bot's domain + +##### FormValidationAction.run + +The original implementation of the `ValidationAction.run` method is extended to add a call to the `next_requested_slot` +method. The output of the `next_requested_slot` method call (if not `None`) is added to the list of events that `run` +method returns. diff --git a/docs/docs/actions.mdx b/docs/docs/actions.mdx new file mode 100644 index 0000000..f18d227 --- /dev/null +++ b/docs/docs/actions.mdx @@ -0,0 +1,29 @@ +--- +id: actions +sidebar_label: Overview +title: Actions +abstract: After each user message, the model will predict an action that the assistant should perform next. This page gives you an overview of the different types of actions you can use. +--- + +## Responses +A [response](./responses.mdx) is a message the assistant will send back to the user. This is +the action you will use most often, when you want the assistant to send text, images, buttons +or similar to the user. + +## Custom Actions +A [custom action](./custom-actions.mdx) is an action that can run any code you want. This can be used to make an +API call, or to query a database for example. + +## Forms +[Forms](./forms.mdx) are a special type of custom action, designed to handle business logic. If you have +any conversation designs where you expect the assistant to ask for a specific set of +information, you should use forms. + +## Default Actions +[Default actions](./default-actions.mdx) are actions that are built into the dialogue manager by default. Most of +these are automatically predicted based on certain conversation situations. You may want to +customize these to personalize your assistant. + +## Slot Validation Actions +A [slot validation action](./slot-validation-actions.mdx) is a special type of custom action, designed to handle custom extraction and/or validation of slot values. +This can be used to validate slots with predefined mappings or extract slots with custom mappings. diff --git a/docs/docs/arch-overview.mdx b/docs/docs/arch-overview.mdx new file mode 100644 index 0000000..7c1cabd --- /dev/null +++ b/docs/docs/arch-overview.mdx @@ -0,0 +1,19 @@ +--- +id: arch-overview +sidebar_label: Overview +title: Rasa Architecture Overview +abstract: Rasa has a scalable architecture. Read about the key components of the Rasa architecture. +--- +import useBaseUrl from '@docusaurus/useBaseUrl'; + + +The diagram below provides an overview of the Rasa architecture. The two primary +components are Natural Language Understanding (NLU) and dialogue management. + +NLU is the part that handles intent classification, entity extraction, and response retrieval. +It's shown below as the *NLU Pipeline* because it processes +user utterances using an NLU model that is generated by the trained pipeline. + +The dialogue management component decides the next action in a conversation based on the +context. This is displayed as the *Dialogue Policies* in the diagram. +<img alt="image" src={useBaseUrl("/img/architecture.png")} /> diff --git a/docs/docs/architecture-img.png b/docs/docs/architecture-img.png new file mode 100644 index 0000000..387783b Binary files /dev/null and b/docs/docs/architecture-img.png differ diff --git a/docs/docs/architecture.mdx b/docs/docs/architecture.mdx new file mode 100644 index 0000000..9377bfe --- /dev/null +++ b/docs/docs/architecture.mdx @@ -0,0 +1,33 @@ +--- +id: architecture +sidebar_label: Rasa Architecture +title: Rasa Architecture +description: Check the architecture to understand how Rasa uses machine learning, context and state of the conversation to predict the next action of the AI Assistant. +--- +import useBaseUrl from '@docusaurus/useBaseUrl'; + +## Message Handling + +This diagram shows the basic steps of how an assistant built with Rasa +responds to a message: + +import archImage from './architecture-img.png'; + +<Image img={archImage} caption="Rasa architecture" alt="A visual representation of the steps that follow."/> + +The steps are: + +1. The message is received and passed to an `Interpreter`, which + converts it into a dictionary including the original text, the intent, + and any entities that were found. This part is handled by NLU. + +2. The message is passed from the `Interpreter` to the `Tracker`. + The `Tracker` is the object which keeps track of conversation state. + +3. The current state of the tracker is sent to each policy. + +4. Each policy chooses which action to take next. + +5. The chosen action is logged by the tracker. + +6. A response is sent to the user. diff --git a/docs/docs/business-logic.mdx b/docs/docs/business-logic.mdx new file mode 100644 index 0000000..24e4a9b --- /dev/null +++ b/docs/docs/business-logic.mdx @@ -0,0 +1,314 @@ +--- +id: business-logic +sidebar_label: Handling Business Logic +title: Handling Business Logic +abstract: Conversational assistants often need to ask users for information in order to help them. You can use Forms to collect the required user information and fulfill a request. +--- + +Conversational assistants often support user goals that involve collecting required information +from the user before doing something for them. For example, a restaurant search bot would need to gather a few pieces of information +about the user's preferences to find them a suitable restaurant: + +<Chat caption="Finding a restaurant"> +<ChatUserText>Help me find a restaurant</ChatUserText> +<ChatBotText>What cuisine?</ChatBotText> +<ChatUserText>I'm looking for Tuscan food</ChatUserText> +<ChatBotText>How many people?</ChatBotText> +<ChatUserText>5</ChatUserText> +<ChatBotText>Do you want to sit outside?</ChatBotText> +<ChatUserText>Yes</ChatUserText> +<ChatBotText>All done!</ChatBotText> +<ChatBotText>I am going to run a restaurant search using the following parameters:</ChatBotText> +<ChatBotText><p>- cuisine: Tuscan</p> +<p>- num_people: 5</p> +<p>- outdoor_seating: True</p></ChatBotText> +</Chat> + + +This page is a guide on handling the **business logic** of collecting user information to fulfill a request. +In the example above, business logic includes needing to know the user's preferred cuisine, party size, and seating preference. +The examples on this page come from the [formbot example bot](https://github.com/RasaHQ/rasa/tree/main/examples/formbot). + +## Step-by-step Guide on Using Forms to Handle Business Logic + +[Forms](forms.mdx) work by prompting the user for information until it has gathered all required information. +The information is stored in [slots](domain.mdx#slots). Once all the required slots are filled, +the bot fulfills the user's original request. + + +### 1. Defining the form + +To define a form, you will need to define: + +* Slot mappings: The required info to collect +* Responses: How your bot should ask for each piece of information + +#### Slot Mappings + +For the restaurant search example, we want to collect the following information +from the user: + +- cuisine +- number of people +- whether they want to sit outside or not + + +You define a form in your domain by specifying a list of required slots under the form name: + +```yaml-rasa title="domain.yml" +forms: + restaurant_form: + required_slots: + - cuisine + - num_people + - outdoor_seating +``` + +These slots need to be added to your domain's `slots` section, along with [slot mappings](domain.mdx#slot-mappings) which define how the slots can be filled. For any slot filled `from_entity`, the entity also needs to be added +to the domain. Slots filled by forms should usually not influence +the conversation, so set `influence_conversation` to `false: + +```yaml-rasa title="domain.yml" +entities: + - cuisine + - number +slots: + cuisine: + type: text + mappings: + - type: from_entity + entity: cuisine + num_people: + type: float + mappings: + - type: from_entity + entity: number + outdoor_seating: + type: bool + mappings: + - type: from_intent + intent: affirm + value: true + conditions: + - active_loop: restaurant_form + requested_slot: outdoor_seating + - type: from_intent + intent: deny + value: false + conditions: + - active_loop: restaurant_form + requested_slot: outdoor_seating +``` + +The `number` slot is filled from an entity. Entities like numbers +can be extracted by [DucklingEntityExtractor](./components.mdx#ducklingentityextractor). To use it, add DucklingEntityExtractor +to your NLU pipeline: + +```yaml-rasa title="config.yml" +language: en +pipeline: +# other components +- name: DucklingEntityExtractor + dimensions: ["number"] +``` + +##### Slot Mappings with Conditions + +The `outdoor_seating` slot is +filled based on the user's intent: If it is `affirm`, it'll be `true`, if it is +`deny`, it'll be `false`. + +However, the slot should only be set to `true` or `false` if the user was responding to the question, `Do you want to sit outside?`. +To enforce this condition, the `conditions` for the `outdoor_seating` slot requires that `restaurant_form` is active and that the requested slot is `outdoor_seating`. +If there were no conditions and the user had sent a message with the `affirm` or `deny` intent earlier in the conversation, +the `outdoor_seating` slot would already be filled when the form was activated. Therefore the form would not prompt the user for their outdoor seating preference. See [mapping conditions](./domain.mdx#mapping-conditions) for more information. + + +#### Validating Slots + +Often, you'll want to validate the user's input before accepting it, +for example by checking if the given cuisine is in your assistant's database +of available cuisines. +See the docs on [validating form input](forms.mdx#validating-form-input) for more information +about validation actions. + +#### Requesting Slots + +To specify how the bot should ask for the required information, +you define [`responses`](domain.mdx#responses) called `utter_ask_{slotname}` in your domain: + +```yaml-rasa title="domain.yml" +responses: + utter_ask_cuisine: + - text: "What cuisine?" + utter_ask_num_people: + - text: "How many people?" + utter_ask_outdoor_seating: + - text: "Do you want to sit outside?" +``` + + +### 2. Updating the configuration + +A form's [happy path](glossary.mdx#happy--unhappy-paths) should be defined as a [rule](rules.mdx) which means you'll need to add the [RulePolicy](policies.mdx#rule-policy) +to your policies: + +```yaml-rasa title="config.yml" +policies: + - name: RulePolicy +``` + +### 3. Creating rules + +The form itself takes care of the logic around asking the user for all +the required information, so +you need only two rules for a form's happy path: +One that defines when it starts, and one that defines what happens when it has been filled. +For the restaurant search example, in real life the assistant would look up +a restaurant based on the user's preferences. +In this case, the bot will utter a response with the details +that would be used for a search. + + +```yaml-rasa title="rules.yml" +rules: + - rule: activate restaurant form + steps: + - intent: request_restaurant # intent that triggers form activation + - action: restaurant_form # run the form + - active_loop: restaurant_form # this form is active + + - rule: submit form + condition: + - active_loop: restaurant_form # this form must be active + steps: + - action: restaurant_form # run the form + - active_loop: null # the form is no longer active because it has been filled + - action: utter_submit # action to take after the form is complete + - action: utter_slots_values # action to take after the form is complete +``` + +By splitting up the activation and submission of the form, +the rules will still apply if the user provides +[unexpected input](unexpected-input.mdx) or interrupts +the form with [chitchat](chitchat-faqs.mdx). + + +### 4. Updating the NLU training data + +You'll need to add examples for the intent that should activate the form, as well as +examples for how the user will provide the required information. + +#### Form Activation Intent(s) + +You need to provide training examples for the +intent(s) that should activate the form. +Add examples for the intent `request_restaurant`: + +```yaml-rasa title="nlu.yml" +nlu: +- intent: request_restaurant + examples: | + - im looking for a restaurant + - can i get [swedish](cuisine) food in any area + - a restaurant that serves [caribbean](cuisine) food + - id like a restaurant + - im looking for a restaurant that serves [mediterranean](cuisine) food + - can i find a restaurant that serves [chinese](cuisine) +``` + +Slots filled with `from_entity` can by default be filled by any user utterance, regardless of the intent, as +long as the correct entity is extracted. That means that if the user provides the `cuisine` entity as part of +their first message, the slot will be filled at the beginning of the form and the bot will not +ask them for the cuisine again. + +#### Form Filling Intent(s) + +While the form is filling slots, it will not pay attention to which intent was predicted +unless a slot mapping explicitly requires or excludes an intent. + +For the restaurant search example, the `outdoor_seating` slot is mapped to two intents, +so you need to add training data for these intents. + +For the `cuisine` and `number` slots, no intent is specified, so you can add examples to a generic `inform` intent. You need +to annotate the `cuisine` entity so that DIETClassifier can +learn to extract it. You don't need to annotate the `number` entity since DucklingEntityExtractor is a rule-based extractors +that isn't trained on your training data. Only a few examples are shown for each intent; +for your bot to work well, you should add more training data than is shown here: + + +```yaml-rasa title="nlu.yml" +nlu: +- intent: affirm + examples: | + - Yes + - yes, please + - yup +- intent: deny + examples: | + - no don't + - no + - no I don't want that + +- intent: inform + examples: | + - [afghan](cuisine) food + - how bout [asian oriental](cuisine) + - what about [indian](cuisine) food + - uh how about [turkish](cuisine) type of food + - um [english](cuisine) + - im looking for [tuscan](cuisine) food + - id like [moroccan](cuisine) food + - for ten people + - 2 people + - for three people + - just one person + - book for seven people + - 2 please + - nine people +``` + +Update your domain to include these intents: + +```yaml-rasa title="domain.yml" +intents: + - request_restaurant + - affirm + - deny + - inform +``` + +### 5. Defining the responses + +Add the responses that are sent after the form has been submitted: + +```yaml-rasa title="domain.yml" +responses: + utter_submit: + - text: "All done!" + utter_slots_values: + - text: "I am going to run a restaurant search using the following parameters:\n + - cuisine: {cuisine}\n + - num_people: {num_people}\n + - outdoor_seating: {outdoor_seating}" +``` + +## Summary + +Forms can simplify the logic of collecting user +information. To define a minimal form like the restaurant search +example above, this is a summary of what you'll need to do: + +- [ ] Add the RulePolicy to `config.yml` +- [ ] Define the form with required slots in the domain +- [ ] Add slot mappings for all required slots in the domain +- [ ] Add rules for activating and submitting the form +- [ ] Add examples for the intent(s) to activate your form +- [ ] Add examples for the intent(s) to fill the required slots +- [ ] Define an action or response for the bot to take when the form is completed +- [ ] Update your domain with new intents and actions you've defined + +To try out your newly defined form, retrain the bot's model by running `rasa train` and start `rasa shell`. +Because the DucklingEntityExtractor is being used to extract +entities, you'll need to start Duckling in the background as well +(see the [instructions for running Duckling](components.mdx#DucklingEntityExtractor)). diff --git a/docs/docs/chitchat-faqs.mdx b/docs/docs/chitchat-faqs.mdx new file mode 100644 index 0000000..c48555a --- /dev/null +++ b/docs/docs/chitchat-faqs.mdx @@ -0,0 +1,173 @@ +--- +id: chitchat-faqs +sidebar_label: Chitchat and FAQs +title: Chitchat and FAQs +abstract: FAQ assistants are the simplest assistants to build and typically the first kind of assistant anyone builds. This page is a guide to the concepts and training data you need to handle non-contextual questions like FAQs and chitchat. +--- + + +[FAQs](./glossary.mdx#faqs) and [chitchat](./glossary.mdx#chitchat) are two cases where the conversational assistant responds with a fixed set of messages, and the assistant should always answer the same way, +no matter what has happened previously in the conversation. +For example, in the following conversation, every question can be asked at any point in the conversation, +with the answer being independent of anything the user has said previously. + +<Chat caption="Chitchat"> +<ChatUserText>What's your name</ChatUserText> +<ChatBotText>My name is Sara!</ChatBotText> +<ChatUserText>Which languages can I build assistants in?</ChatUserText> +<ChatBotText>You can use Rasa to build assistants in any language you want!</ChatBotText> +<ChatUserText>What’s Rasa X/Enterprise?</ChatUserText> +<ChatBotText>Rasa X/Enterprise is a tool to learn from real conversations and improve your assistant.</ChatBotText> +</Chat> + + +## Step-by-step Guide on Using Response Selector for FAQs and Chitchat + +To handle FAQs and chitchat you'll need a rule-based dialogue management policy +(the [RulePolicy](./policies.mdx#rule-policy)) and an easy way +to return the appropriate response for a question (the [ResponseSelector](./components.mdx#responseselector)). + +### 1. Updating the configuration + +For FAQs and chitchat, you always want the assistant to respond the same way every time +the same type of question is asked. [Rules](rules.mdx) allow you to do exactly that. +To use rules, the you need to add the [RulePolicy](./policies.mdx#rule-policy) to your policies in your configuration file: + +```yaml-rasa title="config.yml" +policies: +# other policies +- name: RulePolicy +``` + +Next, include the ResponseSelector in your NLU pipeline in your configuration file. +The ResponseSelector requires a featurizer and intent classifier to work, so +it should come after these components in your pipeline, for example: + +```yaml-rasa title="config.yml" +pipeline: + - name: WhitespaceTokenizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 100 + - name: EntitySynonymMapper + - name: ResponseSelector + epochs: 100 +``` + +By default, the ResponseSelector will build a single retrieval model for all retrieval intents. +To retrieve responses for FAQs and chitchat separately, use multiple ResponseSelector components +and specify the `retrieval_intent` key: + +```yaml-rasa title="config.yml" +pipeline: +# Other components +- name: ResponseSelector + epochs: 100 + retrieval_intent: faq +- name: ResponseSelector + epochs: 100 + retrieval_intent: chitchat +``` + + + +### 2. Defining Retrieval Intents and the ResponseSelector + +Consider an example where you have 20 different FAQs. Although each question is represented as an individual intent, all FAQ intents are handled the same way in the dialogue. For each FAQ intent, the assistant **retrieves** the proper response depending on which question has been asked. + +Instead of writing 20 rules, you can use a single action, e.g. `utter_faq` to handle all FAQs with a single rule by grouping them together under a single [retrieval intent](glossary.mdx#retrieval-intent) +called e.g. `faq`. + +The single action uses the output of the +[ResponseSelector](./components.mdx#responseselector) to return +the correct response for the specific FAQ that the user asked. + + +### 3. Creating rules + +You need to write only one rule for each retrieval intent. All intents +grouped under that retrieval intent will then be handled the same way. +The action name starts with `utter_` and ends with the retrieval intent's name. +Write rules for responding to FAQs and chitchat: + +```yaml-rasa title="rules.yml" +rules: + - rule: respond to FAQs + steps: + - intent: faq + - action: utter_faq + - rule: respond to chitchat + steps: + - intent: chitchat + - action: utter_chitchat +``` + +The actions `utter_faq` and `utter_chitchat` will use the ResponseSelector's prediction to return the actual response message. + +### 4. Updating the NLU Training Data + +NLU training examples for the ResponseSelector look the same as +regular training examples, except that their names must refer to the retrieval +intent they are grouped under: + +```yaml-rasa title="nlu.yml" +nlu: + - intent: chitchat/ask_name + examples: | + - What is your name? + - May I know your name? + - What do people call you? + - Do you have a name for yourself? + - intent: chitchat/ask_weather + examples: | + - What's the weather like today? + - Does it look sunny outside today? + - Oh, do you mind checking the weather for me please? + - I like sunny days in Berlin. +``` + +Be sure to update your domain file to include the added `chitchat` intent: + +```yaml-rasa title="domain.yml" +intents: +# other intents +- chitchat +``` + + +### 5. Defining the responses + +Responses for the ResponseSelector follow the same naming convention as +retrieval intents. Besides this, they can have all the characteristics of +normal bot [response](domain.mdx#responses). For the chitchat intents +listed above, our responses could look like: + +```yaml-rasa title="domain.yml" +responses: + utter_chitchat/ask_name: + - image: "https://i.imgur.com/zTvA58i.jpeg" + text: Hello, my name is Retrieval Bot. + - text: I am called Retrieval Bot! + utter_chitchat/ask_weather: + - text: Oh, it does look sunny right now in Berlin. + image: "https://i.imgur.com/vwv7aHN.png" + - text: I am not sure of the whole week but I can see the sun is out today. +``` + +## Summary + +Once you've done the following, you can train your bot and try it out! + +- [ ] Add RulePolicy to your policies and ResponseSelector to your pipeline in `config.yml` +- [ ] Add at least one rule for responding to FAQs/chitchat +- [ ] Add examples for your FAQs/chitchat intents +- [ ] Add responses for your FAQs/chitchat intents +- [ ] Update the intents in your domain + +Now, your assistant should be able to respond correctly and consistently to FAQs or chitchat, even if these interjections happen while your assistant is helping the user with another task. diff --git a/docs/docs/command-line-interface.mdx b/docs/docs/command-line-interface.mdx new file mode 100644 index 0000000..48e057e --- /dev/null +++ b/docs/docs/command-line-interface.mdx @@ -0,0 +1,761 @@ +--- +id: command-line-interface +sidebar_label: Command Line Interface +title: Command Line Interface +description: Command line interface for open source chatbot framework Rasa. Learn how to train, test and run your machine learning-based conversational AI assistants +abstract: The command line interface (CLI) gives you easy-to-remember commands for common tasks. This page describes the behavior of the commands and the parameters you can pass to them. +--- +import RasaProLabel from "@theme/RasaProLabel"; + +import RasaProBanner from "@theme/RasaProBanner"; + +## Cheat Sheet + + +| Command | Effect | +|--------------------------|------------------------------------------------------------------------------------------------------------------------------------------| +|`rasa init` |Creates a new project with example training data, actions, and config files. | +|`rasa train` |Trains a model using your NLU data and stories, saves trained model in `./models`. | +|`rasa interactive` |Starts an interactive learning session to create new training data by chatting to your assistant. | +|`rasa shell` |Loads your trained model and lets you talk to your assistant on the command line. | +|`rasa run` |Starts a server with your trained model. | +|`rasa run actions` |Starts an action server using the Rasa SDK. | +|`rasa visualize` |Generates a visual representation of your stories. | +|`rasa test` |Tests a trained Rasa model on any files starting with `test_`. | +|`rasa test e2e` |Runs end-to-end testing fully integrated with the action server that serves as acceptance testing. | +|`rasa data split nlu` |Performs a 80/20 split of your NLU training data. | +|`rasa data split stories` |Do the same as `rasa data split nlu`, but for your stories data. | +|`rasa data convert` |Converts training data between different formats. | +|`rasa data migrate` |Migrates 2.0 domain to 3.0 format. | +|`rasa data validate` |Checks the domain, NLU and conversation data for inconsistencies. | +|`rasa export` |Exports conversations from a tracker store to an event broker. | +|`rasa evaluate markers` |Extracts markers from an existing tracker store. | +|`rasa marker upload` |Upload marker configurations to Analytics Data Pipeline | +|`rasa license` |Display licensing information. | +|`rasa -h` |Shows all available commands. | + +:::note +If you run into character encoding issues on Windows like: `UnicodeEncodeError: 'charmap' codec can't encode character ...` or +the terminal is not displaying colored messages properly, prepend `winpty` to the command you would like to run. +For example `winpty rasa init` instead of `rasa init` +::: + +## Log Level + +Rasa produces log messages at several different levels (eg. warning, info, error and so on). You can control which level of logs you would like to see with `--verbose` (same as `-v`) or `--debug` (same as `-vv`) as optional command line arguments. See each command below for more explanation on what these arguments mean. + +In addition to CLI arguments, several environment variables allow you to control log output in a more granular way. With these environment variables, you can configure log levels for messages created by external libraries such as Matplotlib, Pika, and Kafka. These variables follow [standard logging level in Python](https://docs.python.org/3/library/logging.html#logging-levels). Currently, following environment variables are supported: + +1. LOG_LEVEL_LIBRARIES: This is the general environment variable to configure log level for the main libraries Rasa uses. It covers Tensorflow, `asyncio`, APScheduler, SocketIO, Matplotlib, RabbitMQ, Kafka. +2. LOG_LEVEL_MATPLOTLIB: This is the specialized environment variable to configure log level only for Matplotlib. +3. LOG_LEVEL_RABBITMQ: This is the specialized environment variable to configure log level only for AMQP libraries, at the moment it handles log levels from `aio_pika` and `aiormq`. +4. LOG_LEVEL_KAFKA: This is the specialized environment variable to configure log level only for kafka. +5. LOG_LEVEL_PRESIDIO: This is the specialized environment variable to configure log level only for Presidio, at the moment it handles log levels from `presidio_analyzer` and `presidio_anonymizer`. +6. LOG_LEVEL_FAKER: This is the specialized environment variable to configure log level only for Faker. + +General configuration (`LOG_LEVEL_LIBRARIES`) has less priority than library level specific configuration (`LOG_LEVEL_MATPLOTLIB`, `LOG_LEVEL_RABBITMQ` etc); and CLI parameter sets the lowest level log messages which will be handled. This means variables can be used together with a predictable result. As an example: + +```bash +LOG_LEVEL_LIBRARIES=ERROR LOG_LEVEL_MATPLOTLIB=WARNING LOG_LEVEL_KAFKA=DEBUG rasa shell --debug +``` + +The above command run will result in showing: +- messages with `DEBUG` level and higher by default (due to `--debug`) +- messages with `WARNING` level and higher for Matplotlib +- messages with `DEBUG` level and higher for kafka +- messages with `ERROR` level and higher for other libraries not configured + +Note that CLI config sets the lowest level log messages to be handled, hence the following command will set the log level to `INFO` (due to `--verbose`) and no debug messages will be seen (library level configuration will not have any effect): + +```bash +LOG_LEVEL_LIBRARIES=DEBUG LOG_LEVEL_MATPLOTLIB=DEBUG rasa shell --verbose +``` + +As an aside, CLI log level sets the level at the root logger (which has the important handler - `coloredlogs` handler); this means even if an environment variable sets a library logger to a lower level, the root logger will reject messages from that library. If not specified, the CLI log level is set to `INFO`. + +## Custom logging configuration + +:::info New in 3.4 + +The Rasa CLI now includes a new argument `--logging-config-file` which accepts a YAML file as value. + +::: + +You can now configure any logging formatters or handlers in a separate YAML file. +The logging config YAML file must follow the [Python built-in dictionary schema](https://docs.python.org/3/library/logging.config.html#dictionary-schema-details), otherwise it will fail validation. +You can pass this file as argument to the `--logging-config-file` CLI option and use it with any of the rasa commands. + +## rasa init + +This command sets up a complete assistant for you with some example training data: + +```bash +rasa init +``` + +It creates the following files: + +```bash +. +├── actions +│ ├── __init__.py +│ └── actions.py +├── config.yml +├── credentials.yml +├── data +│ ├── nlu.yml +│ └── stories.yml +├── domain.yml +├── endpoints.yml +├── models +│ └── <timestamp>.tar.gz +└── tests + └── test_stories.yml +``` + +It will ask you if you want to train an initial model using this data. +If you answer no, the `models` directory will be empty. + +Any of the default CLI commands will expect this project setup, so this is the +best way to get started. You can run `rasa train`, `rasa shell` and `rasa test` +without any additional configuration. + +## rasa train + +The following command trains a Rasa model: + +```bash +rasa train +``` +If you have existing models in your directory (under `models/` by default), only +the parts of your model that have changed will be re-trained. For example, if you edit +your NLU training data and nothing else, only the NLU part will be trained. + +If you want to train an NLU or dialogue model individually, you can run +`rasa train nlu` or `rasa train core`. If you provide training data only for one one of +these, `rasa train` will fall back to one of these commands by default. + +`rasa train` will store the trained model in the directory defined by `--out`, `models/` by default. +The name of the model by default is `<timestamp>.tar.gz`. If you want to name your model differently, +you can specify the name using the `--fixed-model-name` flag. + +By default validation is run before training the model. If you want to skip validation, you can use the `--skip-validation` flag. +If you want to fail on validation warnings, you can use the `--fail-on-validation-warnings` flag. +The `--validation-max-history` is analogous to the `--max-history` argument of `rasa data validate`. + +The following arguments can be used to configure the training process: + +```text [rasa train --help] +``` + +See the section on [data augmentation](./policies.mdx#data-augmentation) for info on how data augmentation works +and how to choose a value for the flag. Note that `TEDPolicy` is the only policy affected by data augmentation. + +See the following section on [incremental training](#incremental-training) for more information about the `--epoch-fraction` argument. + + +### Incremental training + +:::info New in 2.2 +This feature is experimental. +We introduce experimental features to get feedback from our community, so we encourage you to try it out! +However, the functionality might be changed or removed in the future. +If you have feedback (positive or negative) please share it with us on the [Rasa Forum](https://forum.rasa.com). + +::: + +In order to improve the performance of an assistant, it's helpful to practice [CDD](./conversation-driven-development.mdx) +and add new training examples based on how your users have talked to your assistant. You can use `rasa train --finetune` +to initialize the pipeline with an already trained model and further finetune it on the +new training dataset that includes the additional training examples. This will help reduce the +training time of the new model. + +By default, the command picks up the latest model in the `models/` directory. If you have a specific model +which you want to improve, you may specify the path to this by +running `rasa train --finetune <path to model to finetune>`. Finetuning a model usually +requires fewer epochs to train machine learning components like `DIETClassifier`, `ResponseSelector` and `TEDPolicy` compared to training from scratch. +Either use a model configuration for finetuning +which defines fewer epochs than before or use the flag +`--epoch-fraction`. `--epoch-fraction` will use a fraction of the epochs specified for each machine learning component +in the model configuration file. For example, if `DIETClassifier` is configured to use 100 epochs, +specifying `--epoch-fraction 0.5` will only use 50 epochs for finetuning. + +You can also finetune an NLU-only or dialogue management-only model by using +`rasa train nlu --finetune` and `rasa train core --finetune` respectively. + +To be able to fine tune a model, the following conditions must be met: + +1. The configuration supplied should be exactly the same as the +configuration used to train the model which is being finetuned. +The only parameter that you can change is `epochs` for the individual machine learning components and policies. + +2. The set of labels(intents, actions, entities and slots) for which the base model is trained +should be exactly the same as the ones present in the training data used for finetuning. This +means that you cannot add new intent, action, entity or slot labels to your training data +during incremental training. You can still add new training examples for each of the existing +labels. If you have added/removed labels in the training data, the pipeline needs to be trained +from scratch. + +3. The model to be finetuned is trained with `MINIMUM_COMPATIBLE_VERSION` of the currently installed rasa version. + +## rasa interactive + +You can start an interactive learning session by running: + +```bash +rasa interactive +``` + +This will first train a model and then start an interactive shell session. +You can then correct your assistants predictions as you talk to it. +If [`UnexpecTEDIntentPolicy`](./policies.mdx#unexpected-intent-policy) is +included in the pipeline, [`action_unlikely_intent`](./default-actions.mdx#action_unlikely_intent) +can be triggered at any conversation turn. Subsequently, the following message will be displayed: + +``` + The bot wants to run 'action_unlikely_intent' to indicate that the last user message was unexpected + at this point in the conversation. Check out UnexpecTEDIntentPolicy docs to learn more. +``` + +As the message states, this is an indication that you have explored a conversation path +which is unexpected according to the current set of training stories and hence adding this +path to training stories is recommended. Like other bot actions, you can choose to confirm +or deny running this action. + + +If you provide a trained model using the `--model` argument, training is skipped +and that model will be loaded instead. + +During interactive learning, Rasa will plot the current conversation +and a few similar conversations from the training data to help you +keep track of where you are. You can view the visualization +at http://localhost:5005/visualization.html +as soon as the session has started. This diagram can take some time to generate. +To skip the visualization, run `rasa interactive --skip-visualization`. + +:::info Add the `assistant_id` key introduced in 3.5 + +Running interactive learning with a pre-trained model whose metadata does not include the `assistant_id` +will exit with an error. If this happens, add the required key with a unique identifier value in `config.yml` +and re-run training. + +::: + + +The following arguments can be used to configure the interactive learning session: + +```text [rasa interactive --help] +``` + +## rasa shell + +You can start a chat session by running: + +```bash +rasa shell +``` + +By default, this will load up the latest trained model. +You can specify a different model to be loaded by using the `--model` flag. + +If you start the shell with an NLU-only model, `rasa shell` will output the +intents and entities predicted for any message you enter. + +If you have trained a combined Rasa model but only want to see what your model +extracts as intents and entities from text, you can use the command `rasa shell nlu`. + +To increase the logging level for debugging, run: + +```bash +rasa shell --debug +``` + +:::note +In order to see the typical greetings and/or session start behavior you might see +in an external channel, you will need to explicitly send `/session_start` +as the first message. Otherwise, the session start behavior will begin as described in +[Session configuration](./domain.mdx#session-configuration). +::: + +The following arguments can be used to configure the command. +Most arguments overlap with `rasa run`; see the [following section](#rasa-run) for more info on those arguments. + +Note that the `--connector` argument will always be set to `cmdline` when running `rasa shell`. +This means all credentials in your credentials file will be ignored, +and if you provide your own value for the `--connector` argument it will also be ignored. + + +```text [rasa shell --help] +``` + + +## rasa run + +To start a server running your trained model, run: + +```bash +rasa run +``` + +By default the Rasa server uses HTTP for its communication. To secure the communication with +SSL and run the server on HTTPS, you need to provide a valid certificate and the corresponding +private key file. You can specify these files as part of the `rasa run` command. +If you encrypted your keyfile with a password during creation, +you need to add the `--ssl-password` as well. + +```bash +rasa run --ssl-certificate myssl.crt --ssl-keyfile myssl.key --ssl-password mypassword +``` + +Rasa by default listens on each available network interface. You can limit this to a specific +network interface using the `-i` command line option. + +```bash +rasa run -i 192.168.69.150 +``` + +Rasa will by default connect to all channels specified in your credentials file. +To connect to a single channel and ignore all other channels in your credentials file, +specify the name of the channel in the `--connector` argument. + +```bash +rasa run --connector rest +``` + +The name of the channel should match the name you specify in your credentials file. +For supported channels see [the page about messaging and voice channels](./messaging-and-voice-channels.mdx). + +The following arguments can be used to configure your Rasa server: + +```text [rasa run --help] +``` + +For more information on the additional parameters, see [Model Storage](./model-storage.mdx). +See the Rasa [HTTP API](./http-api.mdx) page for detailed documentation of all the endpoints. + +## rasa run actions + +To start an action server with the Rasa SDK, run: + +```bash +rasa run actions +``` + +The following arguments can be used to adapt the server settings: + +```text [rasa run actions --help] +``` + +## rasa visualize + +To generate a graph of your stories in the browser, run: + +```bash +rasa visualize +``` + +If your stories are located somewhere other than the default location `data/`, +you can specify their location with the `--stories` flag. + +The following arguments can be used to configure this command: + +```text [rasa visualize --help] +``` + +## rasa test + +To evaluate a model on your test data, run: + +```bash +rasa test +``` + +This will test your latest trained model on any end-to-end stories you have +defined in files with the `test_` prefix. +If you want to use a different model, you can specify it using the `--model` flag. + +To evaluate the dialogue and NLU +models separately, use the commands below: + +```bash +rasa test core +``` +and + +```bash +rasa test nlu +``` + +You can find more details on specific arguments for each testing type in +[Evaluating an NLU Model](./testing-your-assistant.mdx#evaluating-an-nlu-model) and +[Evaluating a Dialogue Management Model](./testing-your-assistant.mdx#evaluating-a-dialogue-model). + +The following arguments are available for `rasa test`: + +```text [rasa test --help] +``` + +## rasa test e2e + +<RasaProLabel /> + +<RasaProBanner /> + +:::info New in 3.5 + +You can now use end-to-end testing to test your assistant as a whole, including dialogue management and custom actions. + +::: + +To run [end-to-end testing](./testing-your-assistant.mdx#end-to-end-testing) on your trained model, run: + +```bash +rasa test e2e +``` + +This will test your latest trained model on any end-to-end test cases you have. +If you want to use a different model, you can specify it using the `--model` flag. + +The following arguments are available for `rasa test e2e`: + +```text +usage: rasa test e2e [-h] [-v] [-vv] [--quiet] [--logging-config-file LOGGING_CONFIG_FILE] [--fail-fast] [-o] [--remote-storage REMOTE_STORAGE] [-m MODEL] [--endpoints ENDPOINTS] [path-to-test-cases] + +Runs end-to-end testing. + +optional arguments: + -h, --help show this help message and exit + -o, --e2e-results Results file containing end-to-end testing summary. (default: None) + --remote-storage REMOTE_STORAGE + Set the remote location where your Rasa model is stored, e.g. on AWS. (default: None) + -m MODEL, --model MODEL + Path to a trained Rasa model. If a directory is specified, it will use the latest model in this directory. (default: models) + --endpoints ENDPOINTS + Configuration file for the model server and the connectors as a yml file. (default: endpoints.yml) + +Python Logging Options: + You can control level of log messages printed. In addition to these arguments, a more fine grained configuration can be achieved with environment variables. See online documentation for more info. + + -v, --verbose Be verbose. Sets logging level to INFO. (default: None) + -vv, --debug Print lots of debugging statements. Sets logging level to DEBUG. (default: None) + --quiet Be quiet! Sets logging level to WARNING. (default: None) + --logging-config-file LOGGING_CONFIG_FILE + If set, the name of the logging configuration file will be set to the given name. (default: None) + +Testing Settings: + path-to-test-cases Input file or folder containing end-to-end test cases. (default: tests/e2e_test_cases.yml) + --fail-fast Fail the test suite as soon as a unit test fails. (default: False) +``` + +## rasa data split + +To create a train-test split of your NLU training data, run: + +```bash +rasa data split nlu +``` + +This will create a 80/20 split of train/test data by default. +You can specify the training data, the fraction, and the output directory using +the following arguments: + +```text [rasa data split nlu --help] +``` + +If you have NLG data for retrieval actions, this will be saved to separate files: + +```bash +ls train_test_split + + nlg_test_data.yml test_data.yml + nlg_training_data.yml training_data.yml +``` + +To split your stories, you can use the following command: + +```bash +rasa data split stories +``` + +It has the same arguments as `split nlu` command, but loads yaml files with stories and perform random splitting. +Directory `train_test_split` will contain all yaml files processed with prefixes `train_` or `test_` containing +train and test parts. + +## rasa data convert nlu + +You can convert NLU data from +- LUIS data format, +- WIT data format, +- Dialogflow data format, or +- JSON + +to +- YAML or +- JSON + +You can start the converter by running: + +```bash +rasa data convert nlu +``` + +You can specify the input file or directory, output file or directory, and the output format with the following arguments: + +```text [rasa data convert nlu --help] +``` + +## rasa data migrate + +The domain is the only data file whose format changed between 2.0 and 3.0. +You can automatically migrate a 2.0 domain to the 3.0 format. + +You can start the migration by running: + +```bash +rasa data migrate +``` + +You can specify the input file or directory and the output file or directory with the following arguments: + +```bash +rasa data migrate -d DOMAIN --out OUT_PATH +``` + +If no arguments are specified, the default domain path (`domain.yml`) will be used for both input and output files. + +This command will also back-up your 2.0 domain file(s) into a different `original_domain.yml` file or +directory labeled `original_domain`. + +Note that the slots in the migrated domain will contain [mapping conditions](./domain.mdx#mapping-conditions) if these +slots are part of a form's `required_slots`. + +:::caution +Exceptions will be raised and the migration process terminated if invalid domain files are provided or if they are +already in the 3.0 format, if slots or forms are missing from your original files or if the slots or forms sections +are spread across multiple domain files. +This is done to avoid duplication of migrated sections in your domain files. +Please make sure all your slots' or forms' definitions are grouped into a single file. + +::: + +You can learn more about this command by running: + +```bash +rasa data migrate --help +``` + +## rasa data validate + +You can check your domain, NLU data, or story data for mistakes and inconsistencies. +To validate your data, run this command: + +```bash +rasa data validate +``` + +The validator searches for errors in the data, e.g. two intents that have some +identical training examples. +The validator also checks if you have any stories where different assistant actions follow from the same +dialogue history. Conflicts between stories will prevent a model from learning the correct +pattern for a dialogue. + +:::info Searching for the `assistant_id` key introduced in 3.5 + +The validator will check whether the `assistant_id` key is present in the config file and will issue a warning if this +key is missing or if the default value has not been changed. + +::: + +If you pass a `max_history` value to one or more policies in your `config.yml` file, provide the +smallest of those values in the validator command using the `--max-history <max_history>` flag. + +You can also validate only the story structure by running this command: + +```bash +rasa data validate stories +``` + +:::note +Running `rasa data validate` does **not** test if your [rules](./rules.mdx) are consistent with your stories. +However, during training, the `RulePolicy` checks for conflicts between rules and stories. Any such conflict will abort training. + +Also, if you use end-to-end stories, then this might not capture all conflicts. Specifically, if two user inputs +result in different tokens yet exactly the same featurization, then conflicting actions after these inputs +may exist but will not be reported by the tool. +::: + +To interrupt validation even for minor issues such as unused intents or responses, use the `--fail-on-warnings` flag. + +:::caution check your story names +The `rasa data validate stories` command assumes that all your story names are unique! +::: + +You can use `rasa data validate` with additional arguments, e.g. to specify the location of your data and +domain files: + +```text [rasa data validate --help] +``` + +## rasa export + +To export events from a tracker store using an event broker, run: + +```bash +rasa export +``` + +You can specify the location of the environments file, the minimum and maximum +timestamps of events that should be published, as well as the conversation IDs that +should be published: + +```text [rasa export --help] +``` + +:::tip Import conversations into Rasa X/Enterprise +This command is most commonly used to import old conversations into Rasa X/Enterprise to annotate +them. Read more about [importing conversations into Rasa X/Enterprise](https://rasa.com/docs/rasa-enterprise/installation-and-setup/deploy#1-import-existing-conversations-from-rasa-open-source). +::: + +## rasa evaluate markers + +:::caution + +This feature is currently experimental and might change or be removed in the future. Share your feedback in the forum to help us make it production-ready. + +::: + +The following command applies the [markers](./markers.mdx) you defined in your marker configuration file, +to pre-existing dialogues stored in your [tracker store](./tracker-stores.mdx), and produces `.csv` files containing +the extracted markers and summary statistics: + +```bash +rasa evaluate markers all extracted_markers.csv +``` + +Use the following arguments to configure the marker extraction process: + +``` +usage: rasa evaluate markers [-h] [-v] [-vv] [--quiet] [--config CONFIG] [--no-stats | --stats-file-prefix [STATS_FILE_PREFIX]] [--endpoints ENDPOINTS] [-d DOMAIN] output_filename {first_n,sample,all} ... + +positional arguments: + output_filename The filename to write the extracted markers to (CSV format). + {first_n,sample,all} + first_n Select trackers sequentially until N are taken. + sample Select trackers by sampling N. + all Select all trackers. + +optional arguments: + -h, --help show this help message and exit + --config CONFIG The config file(s) containing marker definitions. This can be a single YAML file, or a directory that contains several files with marker definitions in it. The content of these files will be read and + merged together. (default: markers.yml) + --no-stats Do not compute summary statistics. (default: True) + --stats-file-prefix [STATS_FILE_PREFIX] + The common file prefix of the files where we write out the compute statistics. More precisely, the file prefix must consist of a common path plus a common file prefix, to which suffixes `-overall.csv` and + `-per-session.csv` will be added automatically. (default: stats) + --endpoints ENDPOINTS + Configuration file for the tracker store as a yml file. (default: endpoints.yml) + -d DOMAIN, --domain DOMAIN + Domain specification. This can be a single YAML file, or a directory that contains several files with domain specifications in it. The content of these files will be read and merged together. (default: + domain.yml) + +Python Logging Options: + -v, --verbose Be verbose. Sets logging level to INFO. (default: None) + -vv, --debug Print lots of debugging statements. Sets logging level to DEBUG. (default: None) + --quiet Be quiet! Sets logging level to WARNING. (default: None) +``` + +## rasa markers upload + +<RasaProLabel /> + +<RasaProBanner /> + +:::info New in 3.6 + +This command is available from Rasa Pro 3.6.0 and requires [Rasa Analytics Data Pipeline](./monitoring/analytics/getting-started-with-analytics.mdx) +::: + +This command applies to [markers](./markers.mdx) and their [real-time processing](./monitoring/analytics/realtime-markers.mdx). +Running this command validates the marker configuration file against the domain file and uploads the configuration to Analytics Data Pipeline + +``` +usage: rasa markers upload [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] + [--config CONFIG] + [--rasa-pro-services-url RASA_PRO_SERVICES_URL] + [-d DOMAIN] + +optional arguments: + -h, --help show this help message and exit + --config CONFIG The marker configuration file(s) containing marker + definitions. This can be a single YAML file, or a + directory that contains several files with marker + definitions in it. The content of these files will be + read and merged together. (default: markers.yml) + --rasa-pro-services-url RASA_PRO_SERVICES_URL + The URL of the Rasa Pro Services instance to upload + markers to.Specified URL should not contain a trailing + slash. (default: ) + -d DOMAIN, --domain DOMAIN + Domain specification. This can be a single YAML file, + or a directory that contains several files with domain + specifications in it. The content of these files will + be read and merged together. (default: domain.yml) + +Python Logging Options: + You can control level of log messages printed. In addition to these + arguments, a more fine grained configuration can be achieved with + environment variables. See online documentation for more info. + + -v, --verbose Be verbose. Sets logging level to INFO. (default: + None) + -vv, --debug Print lots of debugging statements. Sets logging level + to DEBUG. (default: None) + --quiet Be quiet! Sets logging level to WARNING. (default: + None) + --logging-config-file LOGGING_CONFIG_FILE + If set, the name of the logging configuration file + will be set to the given name. (default: None) + +Description: + The `rasa markers upload` command allows you to upload markers to the Rasa Pro Services. Markers are custom conversational events that provide additional context for analysis and insights generation. By uploading markers, you can enable real-time analysis and enhance the performance of your Rasa Assistant. + +Examples: + Upload Markers to Rasa Pro Services: + rasa markers upload --config markers.yml --rasa-pro-services-url https://example.com/rasa-pro -d domain.yml + +``` + +## rasa license + +<RasaProLabel /> + +<RasaProBanner /> + +:::info New in 3.3 + +This command was introduced. +::: + +Use `rasa license` to display information about licensing in Rasa Pro, especially information about +3rd party dependencies licenses. + +Here is the list of all possible arguments: + +``` +usage: rasa license [-h] [-v] [-vv] [--quiet] [--logging-config-file LOGGING_CONFIG_FILE] + +Display licensing information. + +options: + -h, --help show this help message and exit + +Python Logging Options: + You can control level of log messages printed. In addition to these arguments, a more fine grained configuration can be achieved with environment variables. See online documentation for more info. + + -v, --verbose Be verbose. Sets logging level to INFO. (default: None) + -vv, --debug Print lots of debugging statements. Sets logging level to DEBUG. (default: None) + --quiet Be quiet! Sets logging level to WARNING. (default: None) + --logging-config-file LOGGING_CONFIG_FILE + If set, the name of the logging configuration file will be set to the given name. (default: None) +``` diff --git a/docs/docs/compatibility-matrix.mdx b/docs/docs/compatibility-matrix.mdx new file mode 100644 index 0000000..afb5191 --- /dev/null +++ b/docs/docs/compatibility-matrix.mdx @@ -0,0 +1,19 @@ +--- +id: compatibility-matrix +sidebar_label: Compatibility Matrix +title: Compatibility Matrix +description: | + Information about compatibility between Rasa Pro Services and Rasa Plus. +--- + + +When choosing a version of Rasa Pro Services to [deploy](./deploy/deploy-rasa-pro-services.mdx), you can refer to the +table below to see which one is compatible with your installation of Rasa Plus. + +Rasa Pro Services version is independent of Rasa Plus version, except that they share the same major version number. + + +| Rasa Pro Services | Rasa Plus | +|------------------:|--------------------:| +| 3.0.x | 3.3.x, 3.4.x, 3.5.x | +| 3.1.x | 3.6.x | diff --git a/docs/docs/component-lifecycle-img.png b/docs/docs/component-lifecycle-img.png new file mode 100644 index 0000000..5f73eec Binary files /dev/null and b/docs/docs/component-lifecycle-img.png differ diff --git a/docs/docs/components.mdx b/docs/docs/components.mdx new file mode 100644 index 0000000..67d7e45 --- /dev/null +++ b/docs/docs/components.mdx @@ -0,0 +1,2910 @@ +--- +id: components +sidebar_label: Pipeline Components +title: Components +abstract: Components make up your NLU pipeline and work sequentially to process user input + into structured output. There are components for entity extraction, for intent classification, response selection, + pre-processing, and more. +--- + + +## Language Models + +The following components load pre-trained models that are needed if you want to use pre-trained +word vectors in your pipeline. + + +### MitieNLP + + +* **Short** + + MITIE initializer + + + +* **Outputs** + + Nothing + + + +* **Requires** + + Nothing + + + +* **Description** + + Initializes MITIE structures. Every MITIE component relies on this, + hence this should be put at the beginning + of every pipeline that uses any MITIE components. + + + +* **Configuration** + + The MITIE library needs a language model file, that **must** be specified in + the configuration: + + ```yaml-rasa + pipeline: + - name: "MitieNLP" + # language model to load + model: "data/total_word_feature_extractor.dat" + ``` + + For more information where to get that file from, head over to + [installing MITIE](./installation/installing-rasa-open-source.mdx#dependencies-for-mitie). + + + You can also pre-train your own word vectors from a language corpus using MITIE. To do so: + + 1. Get a clean language corpus (a Wikipedia dump works) as a set of text files. + + 2. Build and run [MITIE Wordrep Tool](https://github.com/mit-nlp/MITIE/tree/master/tools/wordrep) on your corpus. + This can take several hours/days depending on your dataset and your workstation. + You'll need something like 128GB of RAM for wordrep to run – yes, that's a lot: try to extend your swap. + + 3. Set the path of your new `total_word_feature_extractor.dat` as the `model` parameter to the `MitieNLP` component in your + [configuration](./model-configuration.mdx) file. + + For a full example of how to train MITIE word vectors, check out + [用Rasa NLU构建自己的中文NLU系统](http://www.crownpku.com/2017/07/27/%E7%94%A8Rasa_NLU%E6%9E%84%E5%BB%BA%E8%87%AA%E5%B7%B1%E7%9A%84%E4%B8%AD%E6%96%87NLU%E7%B3%BB%E7%BB%9F.html), + a blogpost that goes through creating a MITIE model from a Chinese Wikipedia dump. + + + +### SpacyNLP + + +* **Short** + + spaCy language initializer + + + +* **Outputs** + + Nothing + + + +* **Requires** + + Nothing + + + +* **Description** + + Initializes spaCy structures. Every spaCy component relies on this, hence this should be put at the beginning + of every pipeline that uses any spaCy components. + + + +* **Configuration** + + You need to specify the language model to use. The name will be passed to `spacy.load(name)`. + You can find more information on the available models on the [spaCy documentation](https://spacy.io/usage/models). + + ```yaml-rasa + pipeline: + - name: "SpacyNLP" + # language model to load + model: "en_core_web_md" + + # when retrieving word vectors, this will decide if the casing + # of the word is relevant. E.g. `hello` and `Hello` will + # retrieve the same vector, if set to `False`. For some + # applications and models it makes sense to differentiate + # between these two words, therefore setting this to `True`. + case_sensitive: False + ``` + + For more information on how to download the spaCy models, head over to + [installing SpaCy](./installation/installing-rasa-open-source.mdx#dependencies-for-spacy). + + In addition to SpaCy's pretrained language models, you can also use this component to + attach spaCy models that you've trained yourself. + +## Tokenizers + +Tokenizers split text into tokens. +If you want to split intents into multiple labels, e.g. for predicting multiple intents or for +modeling hierarchical intent structure, use the following flags with any tokenizer: + +* `intent_tokenization_flag` indicates whether to tokenize intent labels or not. Set it to `True`, so that intent + labels are tokenized. + +* `intent_split_symbol` sets the delimiter string to split the intent labels, default is underscore + (`_`). + + +### WhitespaceTokenizer + + +* **Short** + + Tokenizer using whitespaces as a separator + + + +* **Outputs** + + `tokens` for user messages, responses (if present), and intents (if specified) + + + +* **Requires** + + Nothing + + + +* **Description** + + Creates a token for every whitespace separated character sequence. + + Any character not in: `a-zA-Z0-9_#@&` will be substituted with whitespace before + splitting on whitespace if the character fulfills any of the following conditions: + - the character follows a whitespace: `" !word"` → `"word"` + - the character precedes a whitespace: `"word! "` → `"word"` + - the character is at the beginning of the string: `"!word"` → `"word"` + - the character is at the end of the string: `"word!"` → `"word"` + + Note that: + - `"wo!rd"` → `"wo!rd"` + + In addition, any character not in: `a-zA-Z0-9_#@&.~:\/?[]()!$*+,;=-` will be + substituted with whitespace before splitting on whitespace if the character is not + between numbers: + - `"twenty{one"` → `"twenty"`, `"one"` ("{"` is not between numbers) + - `"20{1"` → `"20{1"` ("{"` *is* between numbers) + + Note that: + - `"name@example.com"` → `"name@example.com"` + - `"10,000.1"` → `"10,000.1"` + - `"1 - 2"` → `"1"`,`"2"` + + +* **Configuration** + + ```yaml-rasa + pipeline: + - name: "WhitespaceTokenizer" + # Flag to check whether to split intents + "intent_tokenization_flag": False + # Symbol on which intent should be split + "intent_split_symbol": "_" + # Regular expression to detect tokens + "token_pattern": None + ``` + + +### JiebaTokenizer + + +* **Short** + + Tokenizer using Jieba for Chinese language + + + +* **Outputs** + + `tokens` for user messages, responses (if present), and intents (if specified) + + + +* **Requires** + + Nothing + + + +* **Description** + + Creates tokens using the Jieba tokenizer specifically for Chinese + language. It will only work for the Chinese language. + + :::note + To use `JiebaTokenizer` you need to install Jieba with `pip3 install jieba`. + + ::: + + + +* **Configuration** + + User's custom dictionary files can be auto loaded by specifying the files' directory path via `dictionary_path`. + If the `dictionary_path` is `None` (the default), then no custom dictionary will be used. + + ```yaml-rasa + pipeline: + - name: "JiebaTokenizer" + dictionary_path: "path/to/custom/dictionary/dir" + # Flag to check whether to split intents + "intent_tokenization_flag": False + # Symbol on which intent should be split + "intent_split_symbol": "_" + # Regular expression to detect tokens + "token_pattern": None + ``` + + +### MitieTokenizer + + +* **Short** + + Tokenizer using MITIE + + + +* **Outputs** + + `tokens` for user messages, responses (if present), and intents (if specified) + + + +* **Requires** + + [MitieNLP](./components.mdx#mitienlp) + + + +* **Description** + + Creates tokens using the MITIE tokenizer. + + + +* **Configuration** + + ```yaml-rasa + pipeline: + - name: "MitieTokenizer" + # Flag to check whether to split intents + "intent_tokenization_flag": False + # Symbol on which intent should be split + "intent_split_symbol": "_" + # Regular expression to detect tokens + "token_pattern": None + ``` + + +### SpacyTokenizer + + +* **Short** + + Tokenizer using spaCy + + + +* **Outputs** + + `tokens` for user messages, responses (if present), and intents (if specified) + + + +* **Requires** + + [SpacyNLP](./components.mdx#spacynlp) + + + +* **Description** + + Creates tokens using the spaCy tokenizer. + + + +* **Configuration** + + ```yaml-rasa + pipeline: + - name: "SpacyTokenizer" + # Flag to check whether to split intents + "intent_tokenization_flag": False + # Symbol on which intent should be split + "intent_split_symbol": "_" + # Regular expression to detect tokens + "token_pattern": None + ``` + + +## Featurizers + +Text featurizers are divided into two different categories: sparse featurizers and dense featurizers. +Sparse featurizers are featurizers that return feature vectors with a lot of missing values, e.g. zeros. +As those feature vectors would normally take up a lot of memory, we store them as sparse features. +Sparse features only store the values that are non zero and their positions in the vector. +Thus, we save a lot of memory and are able to train on larger datasets. + +All featurizers can return two different kind of features: sequence features and sentence features. +The sequence features are a matrix of size `(number-of-tokens x feature-dimension)`. +The matrix contains a feature vector for every token in the sequence. +This allows us to train sequence models. +The sentence features are represented by a matrix of size `(1 x feature-dimension)`. +It contains the feature vector for the complete utterance. +The sentence features can be used in any bag-of-words model. +The corresponding classifier can therefore decide what kind of features to use. +Note: The `feature-dimension` for sequence and sentence features does not have to be the same. + +### MitieFeaturizer + + +* **Short** + + Creates a vector representation of user message and response (if specified) using the MITIE featurizer. + + + +* **Outputs** + + `dense_features` for user messages and responses + + + +* **Requires** + + [MitieNLP](./components.mdx#mitienlp) + + + +* **Type** + + Dense featurizer + + + +* **Description** + + Creates features for entity extraction, intent classification, and response classification using the MITIE + featurizer. + + :::note + NOT used by the `MitieIntentClassifier` component. But can be used by any component later in the pipeline + that makes use of `dense_features`. + + ::: + + + +* **Configuration** + + The sentence vector, i.e. the vector of the complete utterance, can be calculated in two different ways, either via + mean or via max pooling. You can specify the pooling method in your configuration file with the option `pooling`. + The default pooling method is set to `mean`. + + ```yaml-rasa + pipeline: + - name: "MitieFeaturizer" + # Specify what pooling operation should be used to calculate the vector of + # the complete utterance. Available options: 'mean' and 'max'. + "pooling": "mean" + ``` + + +### SpacyFeaturizer + + +* **Short** + + Creates a vector representation of user message and response (if specified) using the spaCy featurizer. + + + +* **Outputs** + + `dense_features` for user messages and responses + + + +* **Requires** + + [SpacyNLP](./components.mdx#spacynlp) + + + +* **Type** + + Dense featurizer + + + +* **Description** + + Creates features for entity extraction, intent classification, and response classification using the spaCy + featurizer. + + + +* **Configuration** + + The sentence vector, i.e. the vector of the complete utterance, can be calculated in two different ways, either via + mean or via max pooling. You can specify the pooling method in your configuration file with the option `pooling`. + The default pooling method is set to `mean`. + + ```yaml-rasa + pipeline: + - name: "SpacyFeaturizer" + # Specify what pooling operation should be used to calculate the vector of + # the complete utterance. Available options: 'mean' and 'max'. + "pooling": "mean" + ``` + + +### ConveRTFeaturizer + + +* **Short** + + Creates a vector representation of user message and response (if specified) using + [ConveRT](https://github.com/PolyAI-LDN/polyai-models) model. + + + +* **Outputs** + + `dense_features` for user messages and responses + + + +* **Type** + + Dense featurizer + + + +* **Description** + + Creates features for entity extraction, intent classification, and response selection. + It uses the [default signature](https://github.com/PolyAI-LDN/polyai-models#tfhub-signatures) to compute vector + representations of input text. + + :::note + Since `ConveRT` model is trained only on an English corpus of conversations, this featurizer should only + be used if your training data is in English language. + + ::: + + :::note + Note that this component cannot currently run on MacOS using M1 / M2 architecture. + More information on this limitation is available [here](./installation/environment-set-up.mdx#m1--m2-apple-silicon-limitations). + + ::: + + + +* **Configuration** + + ```yaml-rasa + pipeline: + - name: "ConveRTFeaturizer" + # Remote URL/Local directory of model files(Required) + "model_url": None + ``` + + :::caution + Since the public URL of the ConveRT model was taken offline recently, it is now mandatory + to set the parameter `model_url` to a community/self-hosted URL or path to a local directory containing model files. + + ::: + + ::: + +### LanguageModelFeaturizer + + +* **Short** + + Creates a vector representation of user message and response (if specified) using a pre-trained language model. + + + +* **Outputs** + + `dense_features` for user messages and responses + + +* **Type** + + Dense featurizer + + + +* **Description** + + Creates features for entity extraction, intent classification, and response selection. + Uses a pre-trained language model to compute vector representations of input text. + + :::note + Please make sure that you use a language model which is pre-trained on the same language corpus as that of your + training data. + + ::: + + + +* **Configuration** + + Include a [Tokenizer](./components.mdx#tokenizers) component before this component. + + You should specify what language model to load via the parameter `model_name`. See the below table for the + currently supported language models. The weights to be loaded can be specified by the additional parameter + `model_weights`. If left empty, it uses the default model weights listed in the table. + + ``` + +----------------+--------------+-------------------------+ + | Language Model | Parameter | Default value for | + | | "model_name" | "model_weights" | + +----------------+--------------+-------------------------+ + | BERT | bert | rasa/LaBSE | + +----------------+--------------+-------------------------+ + | GPT | gpt | openai-gpt | + +----------------+--------------+-------------------------+ + | GPT-2 | gpt2 | gpt2 | + +----------------+--------------+-------------------------+ + | XLNet | xlnet | xlnet-base-cased | + +----------------+--------------+-------------------------+ + | DistilBERT | distilbert | distilbert-base-uncased | + +----------------+--------------+-------------------------+ + | RoBERTa | roberta | roberta-base | + +----------------+--------------+-------------------------+ + | camemBERT | camembert | camembert-base | + +----------------+--------------+-------------------------+ + ``` + + Apart from the default pretrained model weights, further models can be used from + [HuggingFace models](https://huggingface.co/models) provided the following conditions are met (the mentioned + files can be found in the "Files and versions" section of the model website): + + * The model architecture is one of the supported language models (check that the `model_type` in `config.json` is + listed in the table's column `model_name`) + * The model has pretrained Tensorflow weights (check that the file `tf_model.h5` exists) + * The model uses the default tokenizer (`config.json` should not contain a custom `tokenizer_class` setting) + + :::note + The `LaBSE` weights that are loaded as default for the `bert` architecture provide a multi-lingual model trained on + 112 languages (see our [tutorial](https://www.youtube.com/watch?v=7tAWk_Coj-s) and the original + [paper](https://arxiv.org/pdf/2007.01852.pdf)). We strongly encourage using this as a baseline and testing your bot + end-to-end before trying to optimize this component with other weights/architectures. + ::: + + + The following configuration loads the language model BERT with `rasa/LaBSE` weights, which can be found + [here](https://huggingface.co/rasa/LaBSE/tree/main): + + ```yaml-rasa + pipeline: + - name: LanguageModelFeaturizer + # Name of the language model to use + model_name: "bert" + # Pre-Trained weights to be loaded + model_weights: "rasa/LaBSE" + + # An optional path to a directory from which + # to load pre-trained model weights. + # If the requested model is not found in the + # directory, it will be downloaded and + # cached in this directory for future use. + # The default value of `cache_dir` can be + # set using the environment variable + # `TRANSFORMERS_CACHE`, as per the + # Transformers library. + cache_dir: null + ``` + +### RegexFeaturizer + + +* **Short** + + Creates a vector representation of user message using regular expressions. + + + +* **Outputs** + + `sparse_features` for user messages and `tokens.pattern` + + + +* **Requires** + + `tokens` + + + +* **Type** + + Sparse featurizer + + + +* **Description** + + Creates features for entity extraction and intent classification. + During training the `RegexFeaturizer` creates a list of regular expressions defined in the training + data format. + For each regex, a feature will be set marking whether this expression was found in the user message or not. + All features will later be fed into an intent classifier / entity extractor to simplify classification (assuming + the classifier has learned during the training phase, that this set feature indicates a certain intent / entity). + Regex features for entity extraction are currently only supported by the [CRFEntityExtractor](./components.mdx#crfentityextractor) and the + [DIETClassifier](./components.mdx#dietclassifier) components! + + + +* **Configuration** + + Make the featurizer case insensitive by adding the `case_sensitive: False` option, the default being + `case_sensitive: True`. + + To correctly process languages such as Chinese that don't use whitespace for word separation, + the user needs to add the `use_word_boundaries: False` option, the default being `use_word_boundaries: True`. + + ```yaml-rasa + pipeline: + - name: "RegexFeaturizer" + # Text will be processed with case sensitive as default + "case_sensitive": True + # use match word boundaries for lookup table + "use_word_boundaries": True + ``` + + **Configuring for incremental training** + + To ensure that `sparse_features` are of fixed size during + [incremental training](./command-line-interface.mdx#incremental-training), the + component should be configured to account for additional patterns that may be + added to the training data in future. To do so, configure the `number_additional_patterns` + parameter while training the base model from scratch: + + ```yaml-rasa {3} + pipeline: + - name: RegexFeaturizer + number_additional_patterns: 10 + ``` + + If not configured by the user, the component will use twice the number of + patterns currently present in the training data (including lookup tables and regex patterns) + as the default value for `number_additional_patterns`. + This number is kept at a minimum of 10 in order to avoid running out of additional + slots for new patterns too frequently during incremental training. + Once the component runs out of additional pattern slots, the new patterns are dropped + and not considered during featurization. At this point, it is advisable + to retrain a new model from scratch. + + +### CountVectorsFeaturizer + + +* **Short** + + Creates bag-of-words representation of user messages, intents, and responses. + + + +* **Outputs** + + `sparse_features` for user messages, intents, and responses + + + +* **Requires** + + `tokens` + + + +* **Type** + + Sparse featurizer + + + +* **Description** + + Creates features for intent classification and response selection. + Creates bag-of-words representation of user message, intent, and response using + [sklearn's CountVectorizer](https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.CountVectorizer.html). + All tokens which consist only of digits (e.g. 123 and 99 but not a123d) will be assigned to the same feature. + + + +* **Configuration** + + See [sklearn's CountVectorizer docs](https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.CountVectorizer.html) + for detailed description of the configuration parameters. + + This featurizer can be configured to use word or character n-grams, using the `analyzer` configuration parameter. + By default `analyzer` is set to `word` so word token counts are used as features. + If you want to use character n-grams, set `analyzer` to `char` or `char_wb`. + The lower and upper boundaries of the n-grams can be configured via the parameters `min_ngram` and `max_ngram`. + By default both of them are set to `1`. + By default the featurizer takes the lemma of a word instead of the word directly if it is available. The lemma + of a word is currently only set by the [SpacyTokenizer](./components.mdx#spacytokenizer). You can + disable this behavior by setting `use_lemma` to `False`. + + :::note + Option `char_wb` creates character n-grams only from text inside word boundaries; + n-grams at the edges of words are padded with space. + This option can be used to create [Subword Semantic Hashing](https://arxiv.org/abs/1810.07150). + + ::: + + :::note + For character n-grams do not forget to increase `min_ngram` and `max_ngram` parameters. + Otherwise the vocabulary will contain only single letters. + + ::: + + Handling Out-Of-Vocabulary (OOV) words: + + :::note + Enabled only if `analyzer` is `word`. + + ::: + + Since the training is performed on limited vocabulary data, it cannot be guaranteed that during prediction + an algorithm will not encounter an unknown word (a word that were not seen during training). + In order to teach an algorithm how to treat unknown words, some words in training data can be substituted + by generic word `OOV_token`. + In this case during prediction all unknown words will be treated as this generic word `OOV_token`. + + For example, one might create separate intent `outofscope` in the training data containing messages of + different number of `OOV_token` s and maybe some additional general words. + Then an algorithm will likely classify a message with unknown words as this intent `outofscope`. + + You can either set the `OOV_token` or a list of words `OOV_words`: + + * `OOV_token` set a keyword for unseen words; if training data contains `OOV_token` as words in some + messages, during prediction the words that were not seen during training will be substituted with + provided `OOV_token`; if `OOV_token=None` (default behavior) words that were not seen during + training will be ignored during prediction time; + + * `OOV_words` set a list of words to be treated as `OOV_token` during training; if a list of words + that should be treated as Out-Of-Vocabulary is known, it can be set to `OOV_words` instead of manually + changing it in training data or using custom preprocessor. + + :::note + This featurizer creates a bag-of-words representation by **counting** words, + so the number of `OOV_token` in the sentence might be important. + + ::: + + :::note + Providing `OOV_words` is optional, training data can contain `OOV_token` input manually or by custom + additional preprocessor. + Unseen words will be substituted with `OOV_token` **only** if this token is present in the training + data or `OOV_words` list is provided. + + ::: + + If you want to share the vocabulary between user messages and intents, you need to set the option + `use_shared_vocab` to `True`. In that case a common vocabulary set between tokens in intents and user messages + is build. + + ```yaml-rasa + pipeline: + - name: "CountVectorsFeaturizer" + # Analyzer to use, either 'word', 'char', or 'char_wb' + "analyzer": "word" + # Set the lower and upper boundaries for the n-grams + "min_ngram": 1 + "max_ngram": 1 + # Set the out-of-vocabulary token + "OOV_token": "_oov_" + # Whether to use a shared vocab + "use_shared_vocab": False + ``` + + **Configuring for incremental training** + + + To ensure that `sparse_features` are of fixed size during + [incremental training](./command-line-interface.mdx#incremental-training), the + component should be configured to account for additional vocabulary tokens + that may be added as part of new training examples in the future. + To do so, configure the `additional_vocabulary_size` parameter while training the base model from scratch: + + ```yaml-rasa {3-6} + pipeline: + - name: CountVectorsFeaturizer + additional_vocabulary_size: + text: 1000 + response: 1000 + action_text: 1000 + ``` + + As in the above example, you can define additional vocabulary size for each of + `text` (user messages), `response` (bot responses used by `ResponseSelector`) and + `action_text` (bot responses not used by `ResponseSelector`). If you are building a shared + vocabulary (`use_shared_vocab=True`), you only need to define a value for the `text` attribute. + If any of the attribute is not configured by the user, the component takes half of the current + vocabulary size as the default value for the attribute's `additional_vocabulary_size`. + This number is kept at a minimum of 1000 in order to avoid running out of additional vocabulary + slots too frequently during incremental training. Once the component runs out of additional vocabulary slots, + the new vocabulary tokens are dropped and not considered during featurization. At this point, + it is advisable to retrain a new model from scratch. + + +The above configuration parameters are the ones you should configure to fit your model to your data. +However, additional parameters exist that can be adapted. + +<details><summary>More configurable parameters</summary> + +``` ++---------------------------+-------------------------+--------------------------------------------------------------+ +| Parameter | Default Value | Description | ++===========================+=========================+==============================================================+ +| use_shared_vocab | False | If set to 'True' a common vocabulary is used for labels | +| | | and user message. | ++---------------------------+-------------------------+--------------------------------------------------------------+ +| analyzer | word | Whether the features should be made of word n-gram or | +| | | character n-grams. Option 'char_wb' creates character | +| | | n-grams only from text inside word boundaries; | +| | | n-grams at the edges of words are padded with space. | +| | | Valid values: 'word', 'char', 'char_wb'. | ++---------------------------+-------------------------+--------------------------------------------------------------+ +| strip_accents | None | Remove accents during the pre-processing step. | +| | | Valid values: 'ascii', 'unicode', 'None'. | ++---------------------------+-------------------------+--------------------------------------------------------------+ +| stop_words | None | A list of stop words to use. | +| | | Valid values: 'english' (uses an internal list of | +| | | English stop words), a list of custom stop words, or | +| | | 'None'. | ++---------------------------+-------------------------+--------------------------------------------------------------+ +| min_df | 1 | When building the vocabulary ignore terms that have a | +| | | document frequency strictly lower than the given threshold. | ++---------------------------+-------------------------+--------------------------------------------------------------+ +| max_df | 1 | When building the vocabulary ignore terms that have a | +| | | document frequency strictly higher than the given threshold | +| | | (corpus-specific stop words). | ++---------------------------+-------------------------+--------------------------------------------------------------+ +| min_ngram | 1 | The lower boundary of the range of n-values for different | +| | | word n-grams or char n-grams to be extracted. | ++---------------------------+-------------------------+--------------------------------------------------------------+ +| max_ngram | 1 | The upper boundary of the range of n-values for different | +| | | word n-grams or char n-grams to be extracted. | ++---------------------------+-------------------------+--------------------------------------------------------------+ +| max_features | None | If not 'None', build a vocabulary that only consider the top | +| | | max_features ordered by term frequency across the corpus. | ++---------------------------+-------------------------+--------------------------------------------------------------+ +| lowercase | True | Convert all characters to lowercase before tokenizing. | ++---------------------------+-------------------------+--------------------------------------------------------------+ +| OOV_token | None | Keyword for unseen words. | ++---------------------------+-------------------------+--------------------------------------------------------------+ +| OOV_words | [] | List of words to be treated as 'OOV_token' during training. | ++---------------------------+-------------------------+--------------------------------------------------------------+ +| alias | CountVectorFeaturizer | Alias name of featurizer. | ++---------------------------+-------------------------+--------------------------------------------------------------+ +| use_lemma | True | Use the lemma of words for featurization. | ++---------------------------+-------------------------+--------------------------------------------------------------+ +| additional_vocabulary_size| text: 1000 | Size of additional vocabulary to account for incremental | +| | response: 1000 | training while training a model from scratch | +| | action_text: 1000 | | ++---------------------------+-------------------------+--------------------------------------------------------------+ +``` + +</details> + + +### LexicalSyntacticFeaturizer + + +* **Short** + + Creates lexical and syntactic features for a user message to support entity extraction. + + + +* **Outputs** + + `sparse_features` for user messages + + + +* **Requires** + + `tokens` + + + +* **Type** + + Sparse featurizer + + + +* **Description** + + Creates features for entity extraction. + Moves with a sliding window over every token in the user message and creates features according to the + configuration (see below). As a default configuration is present, you don't need to specify a configuration. + + + +* **Configuration** + + You can configure what kind of lexical and syntactic features the featurizer should extract. + The following features are available: + + ``` + ============== ========================================================================================== + Feature Name Description + ============== ========================================================================================== + BOS Checks if the token is at the beginning of the sentence. + EOS Checks if the token is at the end of the sentence. + low Checks if the token is lower case. + upper Checks if the token is upper case. + title Checks if the token starts with an uppercase character and all remaining characters are + lowercased. + digit Checks if the token contains just digits. + prefix5 Take the first five characters of the token. + prefix2 Take the first two characters of the token. + suffix5 Take the last five characters of the token. + suffix3 Take the last three characters of the token. + suffix2 Take the last two characters of the token. + suffix1 Take the last character of the token. + pos Take the Part-of-Speech tag of the token (``SpacyTokenizer`` required). + pos2 Take the first two characters of the Part-of-Speech tag of the token + (``SpacyTokenizer`` required). + ============== ========================================================================================== + ``` + + As the featurizer is moving over the tokens in a user message with a sliding window, you can define features for + previous tokens, the current token, and the next tokens in the sliding window. + You define the features as a [before, token, after] array. + If you want to define features for the token before, the current token, and the token after, + your features configuration would look like this: + + ```yaml-rasa + pipeline: + - name: LexicalSyntacticFeaturizer + "features": [ + ["low", "title", "upper"], + ["BOS", "EOS", "low", "upper", "title", "digit"], + ["low", "title", "upper"], + ] + ``` + + This configuration is also the default configuration. + + :::note + If you want to make use of `pos` or `pos2` you need to add `SpacyTokenizer` to your pipeline. + + ::: + +## Intent Classifiers + +Intent classifiers assign one of the intents defined in the domain file to incoming user messages. + +### MitieIntentClassifier + + +* **Short** + + MITIE intent classifier (using a + [text categorizer](https://github.com/mit-nlp/MITIE/blob/master/examples/python/text_categorizer_pure_model.py)) + + + +* **Outputs** + + `intent` + + + +* **Requires** + + `tokens` for user message and [MitieNLP](./components.mdx#mitienlp) + + + +* **Output-Example** + + ```json + { + "intent": {"name": "greet", "confidence": 0.98343} + } + ``` + + + +* **Description** + + This classifier uses MITIE to perform intent classification. The underlying classifier + is using a multi-class linear SVM with a sparse linear kernel (see `train_text_categorizer_classifier` function at the + [MITIE trainer code](https://github.com/mit-nlp/MITIE/blob/master/mitielib/src/text_categorizer_trainer.cpp)). + + :::note + This classifier does not rely on any featurizer as it extracts features on its own. + + ::: + + + +* **Configuration** + + ```yaml-rasa + pipeline: + - name: "MitieIntentClassifier" + ``` + + +### LogisticRegressionClassifier + +* **Short** + + Logistic regression intent classifier, using the [scikit-learn implementation](https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html). + + + +* **Outputs** + + `intent` and `intent_ranking` + + + +* **Requires** + + Either `sparse_features` or `dense_features` need to be present. + + + +* **Output-Example** + +```json +{ + "intent": {"name": "greet", "confidence": 0.780}, + "intent_ranking": [ + { + "confidence": 0.780, + "name": "greet" + }, + { + "confidence": 0.140, + "name": "goodbye" + }, + { + "confidence": 0.080, + "name": "restaurant_search" + } + ] +} +``` + + +* **Description** + + This classifier uses scikit-learn's logistic regression implementation to perform intent classification. + It's able to use only sparse features, but will also pick up any dense features that are present. In general, + DIET should yield higher accuracy results, but this classifier should train faster and may be used as + a lightweight benchmark. Our implementation uses the base settings from scikit-learn, with the exception + of the `class_weight` parameter where we assume the `"balanced"` setting. + + + +* **Configuration** + +An example configuration with all the defaults can be found below. + +```yaml +pipeline: +- name: LogisticRegressionClassifier + max_iter: 100 + solver: lbfgs + tol: 0.0001 + random_state: 42 + ranking_length: 10 +``` + +There configuration parameters are briefly explained below. + +- `max_iter`: Maximum number of iterations taken for the solvers to converge. +- `solver`: Solver to be used. For very small datasets you might consider `liblinear`. +- `tol`: Tolerance for stopping criteria of the optimizer. +- `random_state`: Used to shuffle the data before training. +- `ranking_length`: Number of top intents to report. Set to 0 to report all intents + +More details on the parameters can be found on the [scikit-learn documentation page](https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html). + + + +### SklearnIntentClassifier + + +* **Short** + + Sklearn intent classifier + + + +* **Outputs** + + `intent` and `intent_ranking` + + + +* **Requires** + + `dense_features` for user messages + + + +* **Output-Example** + + ```json + { + "intent": {"name": "greet", "confidence": 0.7800}, + "intent_ranking": [ + { + "confidence": 0.7800, + "name": "greet" + }, + { + "confidence": 0.1400, + "name": "goodbye" + }, + { + "confidence": 0.0800, + "name": "restaurant_search" + } + ] + } + ``` + + + +* **Description** + + The sklearn intent classifier trains a linear SVM which gets optimized using a grid search. It also provides + rankings of the labels that did not “win”. The `SklearnIntentClassifier` needs to be preceded by a dense + featurizer in the pipeline. This dense featurizer creates the features used for the classification. + For more information about the algorithm itself, take a look at the + [GridSearchCV](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.GridSearchCV.html) + documentation. + + + +* **Configuration** + + During the training of the SVM a hyperparameter search is run to find the best parameter set. + In the configuration you can specify the parameters that will get tried. + + ```yaml-rasa + pipeline: + - name: "SklearnIntentClassifier" + # Specifies the list of regularization values to + # cross-validate over for C-SVM. + # This is used with the ``kernel`` hyperparameter in GridSearchCV. + C: [1, 2, 5, 10, 20, 100] + # Specifies the kernel to use with C-SVM. + # This is used with the ``C`` hyperparameter in GridSearchCV. + kernels: ["linear"] + # Gamma parameter of the C-SVM. + "gamma": [0.1] + # We try to find a good number of cross folds to use during + # intent training, this specifies the max number of folds. + "max_cross_validation_folds": 5 + # Scoring function used for evaluating the hyper parameters. + # This can be a name or a function. + "scoring_function": "f1_weighted" + ``` + + +### KeywordIntentClassifier + + +* **Short** + + Simple keyword matching intent classifier, intended for small, short-term projects. + + + +* **Outputs** + + `intent` + + + +* **Requires** + + Nothing + + + +* **Output-Example** + + ```json + { + "intent": {"name": "greet", "confidence": 1.0} + } + ``` + + + +* **Description** + + This classifier works by searching a message for keywords. + The matching is case sensitive by default and searches only for exact matches of the keyword-string in the user + message. + The keywords for an intent are the examples of that intent in the NLU training data. + This means the entire example is the keyword, not the individual words in the example. + + :::note + This classifier is intended only for small projects or to get started. If + you have few NLU training data, you can take a look at the recommended pipelines in + [Tuning Your Model](./tuning-your-model.mdx). + + ::: + + + +* **Configuration** + + ```yaml-rasa + pipeline: + - name: "KeywordIntentClassifier" + case_sensitive: True + ``` + + +### DIETClassifier + + +* **Short** + + Dual Intent Entity Transformer (DIET) used for intent classification and entity extraction + + + +* **Outputs** + + `entities`, `intent` and `intent_ranking` + + + +* **Requires** + + `dense_features` and/or `sparse_features` for user message and optionally the intent + + + +* **Output-Example** + + ```json + { + "intent": {"name": "greet", "confidence": 0.7800}, + "intent_ranking": [ + { + "confidence": 0.7800, + "name": "greet" + }, + { + "confidence": 0.1400, + "name": "goodbye" + }, + { + "confidence": 0.0800, + "name": "restaurant_search" + } + ], + "entities": [{ + "end": 53, + "entity": "time", + "start": 48, + "value": "2017-04-10T00:00:00.000+02:00", + "confidence": 1.0, + "extractor": "DIETClassifier" + }] + } + ``` + + + +* **Description** + + DIET (Dual Intent and Entity Transformer) is a multi-task architecture for intent classification and entity + recognition. The architecture is based on a transformer which is shared for both tasks. + A sequence of entity labels is predicted through a Conditional Random Field (CRF) tagging layer on top of the + transformer output sequence corresponding to the input sequence of tokens. + For the intent labels the transformer output for the complete utterance and intent labels are embedded into a + single semantic vector space. We use the dot-product loss to maximize the similarity with the target label and + minimize similarities with negative samples. + + DIET does not provide pre-trained word embeddings or pre-trained language models but it is able to use these features if + they are added to the pipeline. If you want to learn more about the model, check out the + [Algorithm Whiteboard](https://www.youtube.com/playlist?list=PL75e0qA87dlG-za8eLI6t0_Pbxafk-cxb) series on YouTube, + where we explain the model architecture in detail. + + :::note + If during prediction time a message contains **only** words unseen during training + and no Out-Of-Vocabulary preprocessor was used, an empty intent `None` is predicted with confidence + `0.0`. This might happen if you only use the [CountVectorsFeaturizer](./components.mdx#countvectorsfeaturizer) with a `word` analyzer + as featurizer. If you use the `char_wb` analyzer, you should always get an intent with a confidence + value `> 0.0`. + + ::: + +* **Configuration** + + If you want to use the `DIETClassifier` just for intent classification, set `entity_recognition` to `False`. + If you want to do only entity recognition, set `intent_classification` to `False`. + By default `DIETClassifier` does both, i.e. `entity_recognition` and `intent_classification` are set to + `True`. + + You can define a number of hyperparameters to adapt the model. + If you want to adapt your model, start by modifying the following parameters: + + * `epochs`: + This parameter sets the number of times the algorithm will see the training data (default: `300`). + One `epoch` is equals to one forward pass and one backward pass of all the training examples. + Sometimes the model needs more epochs to properly learn. + Sometimes more epochs don't influence the performance. + The lower the number of epochs the faster the model is trained. + + * `hidden_layers_sizes`: + This parameter allows you to define the number of feed forward layers and their output + dimensions for user messages and intents (default: `text: [], label: []`). + Every entry in the list corresponds to a feed forward layer. + For example, if you set `text: [256, 128]`, we will add two feed forward layers in front of + the transformer. The vectors of the input tokens (coming from the user message) will be passed on to those + layers. The first layer will have an output dimension of 256 and the second layer will have an output + dimension of 128. If an empty list is used (default behavior), no feed forward layer will be + added. + Make sure to use only positive integer values. Usually, numbers of power of two are used. + Also, it is usual practice to have decreasing values in the list: next value is smaller or equal to the + value before. + + * `embedding_dimension`: + This parameter defines the output dimension of the embedding layers used inside the model (default: `20`). + We are using multiple embeddings layers inside the model architecture. + For example, the vector of the complete utterance and the intent is passed on to an embedding layer before + they are compared and the loss is calculated. + + * `number_of_transformer_layers`: + This parameter sets the number of transformer layers to use (default: `2`). + The number of transformer layers corresponds to the transformer blocks to use for the model. + + * `transformer_size`: + This parameter sets the number of units in the transformer (default: `256`). + The vectors coming out of the transformers will have the given `transformer_size`. + The `transformer_size` should be a multiple of the `number_of_attention_heads` parameter, + the training exits with an error otherwise. + + * `connection_density`: + This parameter defines the fraction of kernel weights that are set to non zero values for all feed forward + layers in the model (default: `0.2`). The value should be between 0 and 1. If you set `connection_density` + to 1, no kernel weights will be set to 0, the layer acts as a standard feed forward layer. You should not + set `connection_density` to 0 as this would result in all kernel weights being 0, i.e. the model is not able + to learn. + + * `constrain_similarities`: + This parameter when set to `True` applies a sigmoid cross entropy loss over all similarity terms. + This helps in keeping similarities between input and negative labels to smaller values. + This should help in better generalization of the model to real world test sets. + + * `model_confidence`: + This parameter allows the user to configure how confidences are computed during inference. It can take only + one value as input which is `softmax`[^1]. In `softmax`, confidences are in the range `[0, 1]`. The computed + similarities are normalized with the `softmax` activation function. + +[^1]: Note that `model_confidence: cosine`is deprecated in 2.3.4 (see [changelog](./changelog.mdx#234---2021-02-26)) + and cannot be specified in the config, however `model_confidence: cosine` will be used regardless of the config + if `loss_type: margin` is specified. + +The above configuration parameters are the ones you should configure to fit your model to your data. +However, additional parameters exist that can be adapted. + +<details><summary>More configurable parameters</summary> + +``` ++---------------------------------+------------------+--------------------------------------------------------------+ +| Parameter | Default Value | Description | ++=================================+==================+==============================================================+ +| hidden_layers_sizes | text: [] | Hidden layer sizes for layers before the embedding layers | +| | label: [] | for user messages and labels. The number of hidden layers is | +| | | equal to the length of the corresponding list. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| share_hidden_layers | False | Whether to share the hidden layer weights between user | +| | | messages and labels. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| transformer_size | 256 | Number of units in transformer. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| number_of_transformer_layers | 2 | Number of transformer layers. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| number_of_attention_heads | 4 | Number of attention heads in transformer. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| use_key_relative_attention | False | If 'True' use key relative embeddings in attention. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| use_value_relative_attention | False | If 'True' use value relative embeddings in attention. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| max_relative_position | None | Maximum position for relative embeddings. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| unidirectional_encoder | False | Use a unidirectional or bidirectional encoder. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| batch_size | [64, 256] | Initial and final value for batch sizes. | +| | | Batch size will be linearly increased for each epoch. | +| | | If constant `batch_size` is required, pass an int, e.g. `8`. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| batch_strategy | "balanced" | Strategy used when creating batches. | +| | | Can be either 'sequence' or 'balanced'. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| epochs | 300 | Number of epochs to train. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| random_seed | None | Set random seed to any 'int' to get reproducible results. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| learning_rate | 0.001 | Initial learning rate for the optimizer. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| embedding_dimension | 20 | Dimension size of embedding vectors. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| dense_dimension | text: 128 | Dense dimension for sparse features to use. | +| | label: 20 | | ++---------------------------------+------------------+--------------------------------------------------------------+ +| concat_dimension | text: 128 | Concat dimension for sequence and sentence features. | +| | label: 20 | | ++---------------------------------+------------------+--------------------------------------------------------------+ +| number_of_negative_examples | 20 | The number of incorrect labels. The algorithm will minimize | +| | | their similarity to the user input during training. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| similarity_type | "auto" | Type of similarity measure to use, either 'auto' or 'cosine' | +| | | or 'inner'. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| loss_type | "cross_entropy" | The type of the loss function, either 'cross_entropy' | +| | | or 'margin'. If type 'margin' is specified, | +| | | "model_confidence=cosine" will be used which is deprecated | +| | | as of 2.3.4. See footnote (1). | ++---------------------------------+------------------+--------------------------------------------------------------+ +| ranking_length | 10 | Number of top intents to report. Set to 0 to report all | +| | | intents. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| renormalize_confidences | False | Normalize the reported top intents. Applicable only with loss| +| | | type 'cross_entropy' and 'softmax' confidences. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| maximum_positive_similarity | 0.8 | Indicates how similar the algorithm should try to make | +| | | embedding vectors for correct labels. | +| | | Should be 0.0 < ... < 1.0 for 'cosine' similarity type. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| maximum_negative_similarity | -0.4 | Maximum negative similarity for incorrect labels. | +| | | Should be -1.0 < ... < 1.0 for 'cosine' similarity type. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| use_maximum_negative_similarity | True | If 'True' the algorithm only minimizes maximum similarity | +| | | over incorrect intent labels, used only if 'loss_type' is | +| | | set to 'margin'. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| scale_loss | False | Scale loss inverse proportionally to confidence of correct | +| | | prediction. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| regularization_constant | 0.002 | The scale of regularization. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| negative_margin_scale | 0.8 | The scale of how important it is to minimize the maximum | +| | | similarity between embeddings of different labels. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| connection_density | 0.2 | Connection density of the weights in dense layers. | +| | | Value should be between 0 and 1. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| drop_rate | 0.2 | Dropout rate for encoder. Value should be between 0 and 1. | +| | | The higher the value the higher the regularization effect. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| drop_rate_attention | 0.0 | Dropout rate for attention. Value should be between 0 and 1. | +| | | The higher the value the higher the regularization effect. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| use_sparse_input_dropout | True | If 'True' apply dropout to sparse input tensors. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| use_dense_input_dropout | True | If 'True' apply dropout to dense input tensors. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| evaluate_every_number_of_epochs | 20 | How often to calculate validation accuracy. | +| | | Set to '-1' to evaluate just once at the end of training. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| evaluate_on_number_of_examples | 0 | How many examples to use for hold out validation set. | +| | | Large values may hurt performance, e.g. model accuracy. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| intent_classification | True | If 'True' intent classification is trained and intents are | +| | | predicted. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| entity_recognition | True | If 'True' entity recognition is trained and entities are | +| | | extracted. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| use_masked_language_model | False | If 'True' random tokens of the input message will be masked | +| | | and the model has to predict those tokens. It acts like a | +| | | regularizer and should help to learn a better contextual | +| | | representation of the input. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| tensorboard_log_directory | None | If you want to use tensorboard to visualize training | +| | | metrics, set this option to a valid output directory. You | +| | | can view the training metrics after training in tensorboard | +| | | via 'tensorboard --logdir <path-to-given-directory>'. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| tensorboard_log_level | "epoch" | Define when training metrics for tensorboard should be | +| | | logged. Either after every epoch ('epoch') or for every | +| | | training step ('batch'). | ++---------------------------------+------------------+--------------------------------------------------------------+ +| featurizers | [] | List of featurizer names (alias names). Only features | +| | | coming from the listed names are used. If list is empty | +| | | all available features are used. | ++---------------------------------+------------------+--------------------------------------------------------------+ +| checkpoint_model | False | Save the best performing model during training. Models are | +| | | stored to the location specified by `--out`. Only the one | +| | | best model will be saved. | +| | | Requires `evaluate_on_number_of_examples > 0` and | +| | | `evaluate_every_number_of_epochs > 0` | ++---------------------------------+------------------+--------------------------------------------------------------+ +| split_entities_by_comma | True | Splits a list of extracted entities by comma to treat each | +| | | one of them as a single entity. Can either be `True`/`False` | +| | | globally, or set per entity type, such as: | +| | | ``` | +| | | ... | +| | | - name: DIETClassifier | +| | | split_entities_by_comma: | +| | | address: True | +| | | ... | +| | | ... | +| | | ``` | ++---------------------------------+------------------+--------------------------------------------------------------+ +| constrain_similarities | False | If `True`, applies sigmoid on all similarity terms and adds | +| | | it to the loss function to ensure that similarity values are | +| | | approximately bounded. Used only if `loss_type=cross_entropy`| ++---------------------------------+------------------+--------------------------------------------------------------+ +| model_confidence | "softmax" | Affects how model's confidence for each intent | +| | | is computed. Currently, only one value is supported: | +| | | 1. `softmax` - Similarities between input and intent | +| | | embeddings are post-processed with a softmax function, | +| | | as a result of which confidence for all intents sum up to 1. | +| | | This parameter does not affect the confidence for entity | +| | | prediction. | ++---------------------------------+------------------+--------------------------------------------------------------+ +``` + +:::note +Parameter `maximum_negative_similarity` is set to a negative value to mimic the original +starspace algorithm in the case `maximum_negative_similarity = maximum_positive_similarity` +and `use_maximum_negative_similarity = False`. +See [starspace paper](https://arxiv.org/abs/1709.03856) for details. + +::: + +</details> + +### FallbackClassifier + +* **Short** + + Classifies a message with the intent `nlu_fallback` if the NLU intent classification + scores are ambiguous. The confidence is set to be the same as the `fallback threshold`. + +* **Outputs** + + `entities`, `intent` and `intent_ranking` + +* **Requires** + + `intent` and `intent_ranking` output from a previous intent classifier + +* **Output-Example** + + ```json + + { + "intent": {"name": "nlu_fallback", "confidence": 0.7183846840434321}, + "intent_ranking": [ + { + "confidence": 0.7183846840434321, + "name": "nlu_fallback" + }, + { + "confidence": 0.28161531595656784, + "name": "restaurant_search" + } + ], + "entities": [{ + "end": 53, + "entity": "time", + "start": 48, + "value": "2017-04-10T00:00:00.000+02:00", + "confidence": 1.0, + "extractor": "DIETClassifier" + }] + } + ``` + +* **Description** + + The `FallbackClassifier` classifies a user message with the intent `nlu_fallback` + in case the previous intent classifier wasn't + able to classify an intent with a confidence greater or equal than the `threshold` + of the `FallbackClassifier`. It can also predict the fallback intent in the + case when the confidence scores of the two top ranked intents are closer than the the + `ambiguity_threshold`. + + You can use the `FallbackClassifier` to implement a + [Fallback Action](./fallback-handoff.mdx#fallbacks) which handles message with uncertain + NLU predictions. + + ```yaml-rasa + rules: + + - rule: Ask the user to rephrase in case of low NLU confidence + steps: + - intent: nlu_fallback + - action: utter_please_rephrase + ``` +* **Configuration** + + The `FallbackClassifier` will only add its prediction for the `nlu_fallback` + intent in case no other intent was predicted with a confidence greater or equal + than `threshold`. + + - `threshold`: + This parameter sets the threshold for predicting the `nlu_fallback` intent. + If no intent predicted by a previous + intent classifier has a confidence + level greater or equal than `threshold` the `FallbackClassifier` will add + a prediction of the `nlu_fallback` intent with a confidence `1.0`. + - `ambiguity_threshold`: If you configure an `ambiguity_threshold`, the + `FallbackClassifier` will also predict the `nlu_fallback` intent in case + the difference of the confidence scores for the two highest ranked intents is + smaller than the `ambiguity_threshold`. + + +## Entity Extractors + +Entity extractors extract entities, such as person names or locations, from the user message. + +:::note + If you use multiple entity extractors, we advise that each extractor targets an exclusive + set of entity types. For example, use [Duckling](components.mdx#ducklingentityextractor) to extract dates and times, and + [DIETClassifier](components.mdx#dietclassifier-1) to extract person names. Otherwise, if multiple extractors + target the same entity types, it is very likely that entities will be extracted multiple times. + + For example, if you use two or more general purpose extractors like [MitieEntityExtractor](components.mdx#mitieentityextractor), + [DIETClassifier](components.mdx#dietclassifier-1), or [CRFEntityExtractor](components.mdx#crfentityextractor), + the entity types in your training data will be found and + extracted by all of them. If the [slots](domain.mdx#slots) you are filling with your entity types are of type `text`, + then the last extractor in your pipeline will win. If the slot is of type `list`, then all results + will be added to the list, including duplicates. + + Another, less obvious case of duplicate/overlapping extraction can happen even if extractors focus on different + entity types. Imagine a food delivery bot and a user message like `I would like to order the Monday special`. + Hypothetically, if your time extractor's performance isn't very good, it might extract `Monday` here as a time for the order, + and your other extractor might extract `Monday special` as the meal. + If you struggle with overlapping entities of this sort, it might make sense to add additional training data + to improve your extractor. If that does not suffice, you can add a + [custom component](components.mdx#custom-components) that resolves conflicts in entity + extraction according to your own logic. +::: +### MitieEntityExtractor + + +* **Short** + + MITIE entity extraction (using a [MITIE NER trainer](https://github.com/mit-nlp/MITIE/blob/master/mitielib/src/ner_trainer.cpp)) + + + +* **Outputs** + + `entities` + + + +* **Requires** + + [MitieNLP](./components.mdx#mitienlp) and `tokens` + + + +* **Output-Example** + + ```json + { + "entities": [{ + "value": "New York City", + "start": 20, + "end": 33, + "confidence": null, + "entity": "city", + "extractor": "MitieEntityExtractor" + }] + } + ``` + + + +* **Description** + + `MitieEntityExtractor` uses the MITIE entity extraction to find entities in a message. The underlying classifier + is using a multi class linear SVM with a sparse linear kernel and custom features. + The MITIE component does not provide entity confidence values. + + :::note + This entity extractor does not rely on any featurizer as it extracts features on its own. + + ::: + + + +* **Configuration** + + ```yaml-rasa + pipeline: + - name: "MitieEntityExtractor" + ``` + + +### SpacyEntityExtractor + + +* **Short** + + spaCy entity extraction + + + +* **Outputs** + + `entities` + + + +* **Requires** + + [SpacyNLP](./components.mdx#spacynlp) + + + +* **Output-Example** + + ```json + { + "entities": [{ + "value": "New York City", + "start": 20, + "end": 33, + "confidence": null, + "entity": "city", + "extractor": "SpacyEntityExtractor" + }] + } + ``` + + + +* **Description** + + Using spaCy this component predicts the entities of a message. spaCy uses a statistical BILOU transition model. + As of now, this component can only use the spaCy builtin entity extraction models and can not be retrained. + This extractor does not provide any confidence scores. + + You can test out spaCy's entity extraction models in this [interactive demo](https://explosion.ai/demos/displacy-ent). + Note that some spaCy models are highly case-sensitive. + +:::note +The `SpacyEntityExtractor` extractor does not provide a `confidence` level and will always return `null`. + +::: + +* **Configuration** + + Configure which dimensions, i.e. entity types, the spaCy component + should extract. A full list of available dimensions can be found in + the [spaCy documentation](https://spacy.io/api/annotation#section-named-entities). + Leaving the dimensions option unspecified will extract all available dimensions. + + ```yaml-rasa + pipeline: + - name: "SpacyEntityExtractor" + # dimensions to extract + dimensions: ["PERSON", "LOC", "ORG", "PRODUCT"] + ``` + + +### CRFEntityExtractor + + +* **Short** + + Conditional random field (CRF) entity extraction + + + +* **Outputs** + + `entities` + + + +* **Requires** + + `tokens` and `dense_features` (optional) + + + +* **Output-Example** + + ```json + { + "entities": [{ + "value": "New York City", + "start": 20, + "end": 33, + "entity": "city", + "confidence": 0.874, + "extractor": "CRFEntityExtractor" + }] + } + ``` + + + +* **Description** + + This component implements a conditional random fields (CRF) to do named entity recognition. + CRFs can be thought of as an undirected Markov chain where the time steps are words + and the states are entity classes. Features of the words (capitalization, POS tagging, + etc.) give probabilities to certain entity classes, as are transitions between + neighbouring entity tags: the most likely set of tags is then calculated and returned. + + + If you want to pass custom features, such as pre-trained word embeddings, to `CRFEntityExtractor`, you can + add any dense featurizer to the pipeline before the `CRFEntityExtractor` and subsequently configure + `CRFEntityExtractor` to make use of the dense features by adding `"text_dense_feature"` to its feature configuration. + `CRFEntityExtractor` automatically finds the additional dense features and checks if the dense features are an + iterable of `len(tokens)`, where each entry is a vector. + A warning will be shown in case the check fails. + However, `CRFEntityExtractor` will continue to train just without the additional custom features. + In case dense features are present, `CRFEntityExtractor` will pass the dense features to `sklearn_crfsuite` + and use them for training. + + + +* **Configuration** + + `CRFEntityExtractor` has a list of default features to use. + However, you can overwrite the default configuration. + The following features are available: + + ``` + =================== ========================================================================================== + Feature Name Description + =================== ========================================================================================== + low word identity - use the lower-cased token as a feature. + upper Checks if the token is upper case. + title Checks if the token starts with an uppercase character and all remaining characters are + lowercased. + digit Checks if the token contains just digits. + prefix5 Take the first five characters of the token. + prefix2 Take the first two characters of the token. + suffix5 Take the last five characters of the token. + suffix3 Take the last three characters of the token. + suffix2 Take the last two characters of the token. + suffix1 Take the last character of the token. + pos Take the Part-of-Speech tag of the token (``SpacyTokenizer`` required). + pos2 Take the first two characters of the Part-of-Speech tag of the token + (``SpacyTokenizer`` required). + pattern Take the patterns defined by ``RegexFeaturizer``. + bias Add an additional "bias" feature to the list of features. + text_dense_features Adds additional features from a dense featurizer. + =================== ========================================================================================== + ``` + + As the featurizer is moving over the tokens in a user message with a sliding window, you can define features for + previous tokens, the current token, and the next tokens in the sliding window. + You define the features as [before, token, after] array. + + Additional you can set a flag to determine whether to use the BILOU tagging schema or not. + + * `BILOU_flag` determines whether to use BILOU tagging or not. Default `True`. + + ```yaml-rasa + pipeline: + - name: "CRFEntityExtractor" + # BILOU_flag determines whether to use BILOU tagging or not. + "BILOU_flag": True + # features to extract in the sliding window + "features": [ + ["low", "title", "upper"], + [ + "bias", + "low", + "prefix5", + "prefix2", + "suffix5", + "suffix3", + "suffix2", + "upper", + "title", + "digit", + "pattern", + "text_dense_features" + ], + ["low", "title", "upper"], + ] + # The maximum number of iterations for optimization algorithms. + "max_iterations": 50 + # weight of the L1 regularization + "L1_c": 0.1 + # weight of the L2 regularization + "L2_c": 0.1 + # Name of dense featurizers to use. + # If list is empty all available dense features are used. + "featurizers": [] + # Indicated whether a list of extracted entities should be split into individual entities for a given entity type + "split_entities_by_comma": + address: False + email: True + ``` + + :::note + If POS features are used (`pos` or `pos2`), you need to have `SpacyTokenizer` in your pipeline. + + ::: + + :::note + If `pattern` features are used, you need to have `RegexFeaturizer` in your pipeline. + + ::: + + :::note + If `text_dense_features` features are used, you need to have a dense featurizer (e.g. `LanguageModelFeaturizer`) in + your pipeline. + + ::: + +### DucklingEntityExtractor + + +* **Short** + + Duckling lets you extract common entities like dates, + amounts of money, distances, and others in a number of languages. + + + +* **Outputs** + + `entities` + + + +* **Requires** + + Nothing + + + +* **Output-Example** + + ```json + { + "entities": [{ + "end": 53, + "entity": "time", + "start": 48, + "value": "2017-04-10T00:00:00.000+02:00", + "confidence": 1.0, + "extractor": "DucklingEntityExtractor" + }] + } + ``` + + + +* **Description** + + To use this component you need to run a duckling server. The easiest + option is to spin up a docker container using + `docker run -p 8000:8000 rasa/duckling`. + + Alternatively, you can [install duckling directly on your + machine](https://github.com/facebook/duckling#quickstart) and start the server. + + Duckling allows to recognize dates, numbers, distances and other structured entities + and normalizes them. + Please be aware that duckling tries to extract as many entity types as possible without + providing a ranking. For example, if you specify both `number` and `time` as dimensions + for the duckling component, the component will extract two entities: `10` as a number and + `in 10 minutes` as a time from the text `I will be there in 10 minutes`. In such a + situation, your application would have to decide which entity type is be the correct one. + The extractor will always return 1.0 as a confidence, as it is a rule + based system. + + The list of supported languages can be found in the + [Duckling GitHub repository](https://github.com/facebook/duckling/tree/master/Duckling/Dimensions). + + + +* **Configuration** + + Configure which dimensions, i.e. entity types, the duckling component + should extract. A full list of available dimensions can be found in + the [duckling project readme](https://github.com/facebook/duckling). + Leaving the dimensions option unspecified will extract all available dimensions. + + ```yaml-rasa + pipeline: + - name: "DucklingEntityExtractor" + # url of the running duckling server + url: "http://localhost:8000" + # dimensions to extract + dimensions: ["time", "number", "amount-of-money", "distance"] + # allows you to configure the locale, by default the language is + # used + locale: "de_DE" + # if not set the default timezone of Duckling is going to be used + # needed to calculate dates from relative expressions like "tomorrow" + timezone: "Europe/Berlin" + # Timeout for receiving response from http url of the running duckling server + # if not set the default timeout of duckling http url is set to 3 seconds. + timeout : 3 + ``` + + +### DIETClassifier + + +* **Short** + + Dual Intent Entity Transformer (DIET) used for intent classification and entity extraction + + + +* **Description** + + You can find the detailed description of the [DIETClassifier](./components.mdx#dietclassifier) under the section + Intent Classifiers. + + +### RegexEntityExtractor + +* **Short** + + Extracts entities using the lookup tables and/or regexes defined in the training data + + +* **Outputs** + + `entities` + + +* **Requires** + + Nothing + + +* **Description** + + This component extract entities using the [lookup tables](nlu-training-data.mdx#lookup-tables) and [regexes](nlu-training-data.mdx#regular-expressions-for-entity-extraction) defined in the training data. + The component checks if the user message contains an entry of one of the lookup tables or matches one of the + regexes. If a match is found, the value is extracted as entity. + + This component only uses those regex features that have a name equal to one of the entities defined in the + training data. Make sure to annotate at least one example per entity. + + :::note + When you use this extractor in combination with [MitieEntityExtractor](components.mdx#mitieentityextractor), + [CRFEntityExtractor](components.mdx#crfentityextractor), or [DIETClassifier](components.mdx#dietclassifier-1) it can + lead to multiple extraction of entities. Especially if many training sentences have entity annotations for + the entity types for which you also have defined regexes. See the big info box at the start of the + [entity extractor section](components.mdx#entity-extractors) for more info on multiple extraction. + + In the case where you seem to need both this RegexEntityExtractor and another of the aforementioned + statistical extractors, we advise you to consider one of the following two options. + + Option 1 is advisable when you have exclusive entity types for each type of extractor. To make the + sure the extractors don't interfere with one another annotate only one example sentence for each + regex/lookup entity type, but not more. + + Option 2 is useful when you want to use regexes matches as additional signal for your statistical extractor, + but you don't have separate entity types. In this case you will want to 1) add the + [RegexFeaturizer](components.mdx#regexfeaturizer) before the extractors in your pipeline 2) + annotate all your entity examples in the training data and 3) remove the RegexEntityExtractor from your pipeline. + This way, your statistical extractors will receive additional signal about the presence of regex matches + and will be able to statistically determine when to rely on these matches and when not to. + ::: + + +* **Configuration** + + Make the entity extractor case sensitive by adding the `case_sensitive: True` option, the default being + `case_sensitive: False`. + + To correctly process languages such as Chinese that don't use whitespace for word separation, + the user needs to add the `use_word_boundaries: False` option, the default being `use_word_boundaries: True`. + + ```yaml-rasa + pipeline: + - name: RegexEntityExtractor + # text will be processed with case insensitive as default + case_sensitive: False + # use lookup tables to extract entities + use_lookup_tables: True + # use regexes to extract entities + use_regexes: True + # use match word boundaries for lookup table + use_word_boundaries: True + ``` + + +### EntitySynonymMapper + + +* **Short** + + Maps synonymous entity values to the same value. + + + +* **Outputs** + + Modifies existing entities that previous entity extraction components found. + + + +* **Requires** + + An extractor from [Entity Extractors](./components.mdx) + + + +* **Description** + + If the training data contains defined synonyms, this component will make sure that detected entity values will + be mapped to the same value. For example, if your training data contains the following examples: + + ```json + [ + { + "text": "I moved to New York City", + "intent": "inform_relocation", + "entities": [{ + "value": "nyc", + "start": 11, + "end": 24, + "entity": "city", + }] + }, + { + "text": "I got a new flat in NYC.", + "intent": "inform_relocation", + "entities": [{ + "value": "nyc", + "start": 20, + "end": 23, + "entity": "city", + }] + } + ] + ``` + + This component will allow you to map the entities `New York City` and `NYC` to `nyc`. The entity + extraction will return `nyc` even though the message contains `NYC`. When this component changes an + existing entity, it appends itself to the processor list of this entity. + + + +* **Configuration** + + ```yaml-rasa + pipeline: + - name: "EntitySynonymMapper" + ``` + + :::note + When using the `EntitySynonymMapper` as part of an NLU pipeline, it will need to be placed + below any entity extractors in the configuration file. + + ::: + + + ## Combined Intent Classifiers and Entity Extractors + + ### DIETClassifier + + + * **Short** + + Dual Intent Entity Transformer (DIET) used for intent classification and entity extraction + + + + * **Outputs** + + `entities`, `intent` and `intent_ranking` + + + + * **Requires** + + `dense_features` and/or `sparse_features` for user message and optionally the intent + + + + * **Output-Example** + + ```json + { + "intent": {"name": "greet", "confidence": 0.7800}, + "intent_ranking": [ + { + "confidence": 0.7800, + "name": "greet" + }, + { + "confidence": 0.1400, + "name": "goodbye" + }, + { + "confidence": 0.0800, + "name": "restaurant_search" + } + ], + "entities": [{ + "end": 53, + "entity": "time", + "start": 48, + "value": "2017-04-10T00:00:00.000+02:00", + "confidence": 1.0, + "extractor": "DIETClassifier" + }] + } + ``` + + + + * **Description** + + DIET (Dual Intent and Entity Transformer) is a multi-task architecture for intent classification and entity + recognition. The architecture is based on a transformer which is shared for both tasks. + A sequence of entity labels is predicted through a Conditional Random Field (CRF) tagging layer on top of the + transformer output sequence corresponding to the input sequence of tokens. + For the intent labels the transformer output for the complete utterance and intent labels are embedded into a + single semantic vector space. We use the dot-product loss to maximize the similarity with the target label and + minimize similarities with negative samples. + + If you want to learn more about the model, check out the + [Algorithm Whiteboard](https://www.youtube.com/playlist?list=PL75e0qA87dlG-za8eLI6t0_Pbxafk-cxb) series on YouTube, + where we explain the model architecture in detail. + + :::note + If during prediction time a message contains **only** words unseen during training + and no Out-Of-Vocabulary preprocessor was used, an empty intent `None` is predicted with confidence + `0.0`. This might happen if you only use the [CountVectorsFeaturizer](./components.mdx#countvectorsfeaturizer) with a `word` analyzer + as featurizer. If you use the `char_wb` analyzer, you should always get an intent with a confidence + value `> 0.0`. + + ::: + + + + * **Configuration** + + If you want to use the `DIETClassifier` just for intent classification, set `entity_recognition` to `False`. + If you want to do only entity recognition, set `intent_classification` to `False`. + By default `DIETClassifier` does both, i.e. `entity_recognition` and `intent_classification` are set to + `True`. + + You can define a number of hyperparameters to adapt the model. + If you want to adapt your model, start by modifying the following parameters: + + * `epochs`: + This parameter sets the number of times the algorithm will see the training data (default: `300`). + One `epoch` is equals to one forward pass and one backward pass of all the training examples. + Sometimes the model needs more epochs to properly learn. + Sometimes more epochs don't influence the performance. + The lower the number of epochs the faster the model is trained. + + * `hidden_layers_sizes`: + This parameter allows you to define the number of feed forward layers and their output + dimensions for user messages and intents (default: `text: [], label: []`). + Every entry in the list corresponds to a feed forward layer. + For example, if you set `text: [256, 128]`, we will add two feed forward layers in front of + the transformer. The vectors of the input tokens (coming from the user message) will be passed on to those + layers. The first layer will have an output dimension of 256 and the second layer will have an output + dimension of 128. If an empty list is used (default behavior), no feed forward layer will be + added. + Make sure to use only positive integer values. Usually, numbers of power of two are used. + Also, it is usual practice to have decreasing values in the list: next value is smaller or equal to the + value before. + + * `embedding_dimension`: + This parameter defines the output dimension of the embedding layers used inside the model (default: `20`). + We are using multiple embeddings layers inside the model architecture. + For example, the vector of the complete utterance and the intent is passed on to an embedding layer before + they are compared and the loss is calculated. + + * `number_of_transformer_layers`: + This parameter sets the number of transformer layers to use (default: `2`). + The number of transformer layers corresponds to the transformer blocks to use for the model. + + * `transformer_size`: + This parameter sets the number of units in the transformer (default: `256`). + The vectors coming out of the transformers will have the given `transformer_size`. + The `transformer_size` should be a multiple of the `number_of_attention_heads` parameter, + the training exits with an error otherwise. + + * `connection_density`: + This parameter defines the fraction of kernel weights that are set to non zero values for all feed forward + layers in the model (default: `0.2`). The value should be between 0 and 1. If you set `connection_density` + to 1, no kernel weights will be set to 0, the layer acts as a standard feed forward layer. You should not + set `connection_density` to 0 as this would result in all kernel weights being 0, i.e. the model is not able + to learn. + + * `BILOU_flag`: + This parameter determines whether to use BILOU tagging or not. Default `True`. + + The above configuration parameters are the ones you should configure to fit your model to your data. + However, additional parameters exist that can be adapted. + + <details><summary>More configurable parameters</summary> + + ``` + +---------------------------------+------------------+--------------------------------------------------------------+ + | Parameter | Default Value | Description | + +=================================+==================+==============================================================+ + | hidden_layers_sizes | text: [] | Hidden layer sizes for layers before the embedding layers | + | | label: [] | for user messages and labels. The number of hidden layers is | + | | | equal to the length of the corresponding. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | share_hidden_layers | False | Whether to share the hidden layer weights between user | + | | | messages and labels. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | transformer_size | 256 | Number of units in transformer. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | number_of_transformer_layers | 2 | Number of transformer layers. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | number_of_attention_heads | 4 | Number of attention heads in transformer. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | use_key_relative_attention | False | If 'True' use key relative embeddings in attention. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | use_value_relative_attention | False | If 'True' use value relative embeddings in attention. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | max_relative_position | None | Maximum position for relative embeddings. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | unidirectional_encoder | False | Use a unidirectional or bidirectional encoder. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | batch_size | [64, 256] | Initial and final value for batch sizes. | + | | | Batch size will be linearly increased for each epoch. | + | | | If constant `batch_size` is required, pass an int, e.g. `8`. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | batch_strategy | "balanced" | Strategy used when creating batches. | + | | | Can be either 'sequence' or 'balanced'. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | epochs | 300 | Number of epochs to train. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | random_seed | None | Set random seed to any 'int' to get reproducible results. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | learning_rate | 0.001 | Initial learning rate for the optimizer. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | embedding_dimension | 20 | Dimension size of embedding vectors. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | dense_dimension | text: 128 | Dense dimension for sparse features to use if no dense | + | | label: 20 | features are present. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | concat_dimension | text: 128 | Concat dimension for sequence and sentence features. | + | | label: 20 | | + +---------------------------------+------------------+--------------------------------------------------------------+ + | number_of_negative_examples | 20 | The number of incorrect labels. The algorithm will minimize | + | | | their similarity to the user input during training. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | similarity_type | "auto" | Type of similarity measure to use, either 'auto' or 'cosine' | + | | | or 'inner'. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | loss_type | "cross_entropy" | The type of the loss function, either 'cross_entropy' | + | | | or 'margin'. Type 'margin' is only compatible with | + | | | "model_confidence=cosine", | + | | | which is deprecated (see changelog for 2.3.4). | + +---------------------------------+------------------+--------------------------------------------------------------+ + | ranking_length | 10 | Number of top intents to report. Set to 0 to report all | + | | | intents. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | renormalize_confidences | False | Normalize the reported top intents. Applicable only with loss| + | | | type 'cross_entropy' and 'softmax' confidences. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | maximum_positive_similarity | 0.8 | Indicates how similar the algorithm should try to make | + | | | embedding vectors for correct labels. | + | | | Should be 0.0 < ... < 1.0 for 'cosine' similarity type. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | maximum_negative_similarity | -0.4 | Maximum negative similarity for incorrect labels. | + | | | Should be -1.0 < ... < 1.0 for 'cosine' similarity type. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | use_maximum_negative_similarity | True | If 'True' the algorithm only minimizes maximum similarity | + | | | over incorrect intent labels, used only if 'loss_type' is | + | | | set to 'margin'. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | scale_loss | False | Scale loss inverse proportionally to confidence of correct | + | | | prediction. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | regularization_constant | 0.002 | The scale of regularization. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | negative_margin_scale | 0.8 | The scale of how important it is to minimize the maximum | + | | | similarity between embeddings of different labels. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | connection_density | 0.2 | Connection density of the weights in dense layers. | + | | | Value should be between 0 and 1. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | drop_rate | 0.2 | Dropout rate for encoder. Value should be between 0 and 1. | + | | | The higher the value the higher the regularization effect. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | drop_rate_attention | 0.0 | Dropout rate for attention. Value should be between 0 and 1. | + | | | The higher the value the higher the regularization effect. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | use_sparse_input_dropout | True | If 'True' apply dropout to sparse input tensors. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | use_dense_input_dropout | True | If 'True' apply dropout to dense input tensors. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | evaluate_every_number_of_epochs | 20 | How often to calculate validation accuracy. | + | | | Set to '-1' to evaluate just once at the end of training. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | evaluate_on_number_of_examples | 0 | How many examples to use for hold out validation set. | + | | | Large values may hurt performance, e.g. model accuracy. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | intent_classification | True | If 'True' intent classification is trained and intents are | + | | | predicted. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | entity_recognition | True | If 'True' entity recognition is trained and entities are | + | | | extracted. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | use_masked_language_model | False | If 'True' random tokens of the input message will be masked | + | | | and the model has to predict those tokens. It acts like a | + | | | regularizer and should help to learn a better contextual | + | | | representation of the input. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | BILOU_flag | True | If 'True', additional BILOU tags are added to entity labels. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | tensorboard_log_directory | None | If you want to use tensorboard to visualize training | + | | | metrics, set this option to a valid output directory. You | + | | | can view the training metrics after training in tensorboard | + | | | via 'tensorboard --logdir <path-to-given-directory>'. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | tensorboard_log_level | "epoch" | Define when training metrics for tensorboard should be | + | | | logged. Either after every epoch ('epoch') or for every | + | | | training step ('batch'). | + +---------------------------------+------------------+--------------------------------------------------------------+ + | featurizers | [] | List of featurizer names (alias names). Only features | + | | | coming from the listed names are used. If list is empty | + | | | all available features are used. | + +---------------------------------+------------------+--------------------------------------------------------------+ + | checkpoint_model | False | Save the best performing model during training. Models are | + | | | stored to the location specified by `--out`. Only the one | + | | | best model will be saved. | + | | | Requires `evaluate_on_number_of_examples > 0` and | + | | | `evaluate_every_number_of_epochs > 0` | + +---------------------------------+------------------+--------------------------------------------------------------+ + ``` + + :::note + Parameter `maximum_negative_similarity` is set to a negative value to mimic the original + starspace algorithm in the case `maximum_negative_similarity = maximum_positive_similarity` + and `use_maximum_negative_similarity = False`. + See [starspace paper](https://arxiv.org/abs/1709.03856) for details. + + ::: + + </details> + +## Selectors + +Selectors predict a bot response from a set of candidate responses. + + +### ResponseSelector + + +* **Short** + + Response Selector + + + +* **Outputs** + + A dictionary with the key as the retrieval intent of the response selector + and value containing predicted responses, confidence and the response key under the retrieval intent + + + +* **Requires** + + `dense_features` and/or `sparse_features` for user messages and response + + + +* **Output-Example** + + The parsed output from NLU will have a property named `response_selector` + containing the output for each response selector component. Each response selector is + identified by `retrieval_intent` parameter of that response selector + and stores two properties: + + * `response`: The predicted response key under the corresponding retrieval intent, + prediction's confidence and the associated responses. + + * `ranking`: Ranking with confidences of top 10 candidate response keys. + + Example result: + + ```json + { + "response_selector": { + "faq": { + "response": { + "id": 1388783286124361986, + "confidence": 0.7, + "intent_response_key": "chitchat/ask_weather", + "responses": [ + { + "text": "It's sunny in Berlin today", + "image": "https://i.imgur.com/nGF1K8f.jpg" + }, + { + "text": "I think it's about to rain." + } + ], + "utter_action": "utter_chitchat/ask_weather" + }, + "ranking": [ + { + "id": 1388783286124361986, + "confidence": 0.7, + "intent_response_key": "chitchat/ask_weather" + }, + { + "id": 1388783286124361986, + "confidence": 0.3, + "intent_response_key": "chitchat/ask_name" + } + ] + } + } + } + ``` + + If the `retrieval_intent` parameter of a particular response selector was left to its default value, + the corresponding response selector will be identified as `default` in the returned output. + + ```json {3} + { + "response_selector": { + "default": { + "response": { + "id": 1388783286124361986, + "confidence": 0.7, + "intent_response_key": "chitchat/ask_weather", + "responses": [ + { + "text": "It's sunny in Berlin today", + "image": "https://i.imgur.com/nGF1K8f.jpg" + }, + { + "text": "I think it's about to rain." + } + ], + "utter_action": "utter_chitchat/ask_weather" + }, + "ranking": [ + { + "id": 1388783286124361986, + "confidence": 0.7, + "intent_response_key": "chitchat/ask_weather" + }, + { + "id": 1388783286124361986, + "confidence": 0.3, + "intent_response_key": "chitchat/ask_name" + } + ] + } + } + } + ``` + +* **Description** + + Response Selector component can be used to build a response retrieval model to directly predict a bot response from + a set of candidate responses. The prediction of this model is used by the dialogue manager to utter the predicted responses. + It embeds user inputs and response labels into the same space and follows the exact same + neural network architecture and optimization as the [DIETClassifier](./components.mdx#dietclassifier). + + To use this component, your training data should contain [retrieval intents](./glossary.mdx#retrieval-intent). To define these, + checkout [documentation on NLU training examples](./training-data-format.mdx#training-examples) and + [documentation on defining response utterances for retrieval intents](./responses.mdx#defining-responses). + + :::note + If during prediction time a message contains **only** words unseen during training + and no Out-Of-Vocabulary preprocessor was used, an empty response `None` is predicted with confidence + `0.0`. This might happen if you only use the [CountVectorsFeaturizer](./components.mdx#countvectorsfeaturizer) with a `word` analyzer + as featurizer. If you use the `char_wb` analyzer, you should always get a response with a confidence + value `> 0.0`. + + ::: + + + +* **Configuration** + + The algorithm includes almost all the hyperparameters that [DIETClassifier](./components.mdx#dietclassifier) uses. + If you want to adapt your model, start by modifying the following parameters: + + * `epochs`: + This parameter sets the number of times the algorithm will see the training data (default: `300`). + One `epoch` is equals to one forward pass and one backward pass of all the training examples. + Sometimes the model needs more epochs to properly learn. + Sometimes more epochs don't influence the performance. + The lower the number of epochs the faster the model is trained. + + * `hidden_layers_sizes`: + This parameter allows you to define the number of feed forward layers and their output + dimensions for user messages and intents (default: `text: [256, 128], label: [256, 128]`). + Every entry in the list corresponds to a feed forward layer. + For example, if you set `text: [256, 128]`, we will add two feed forward layers in front of + the transformer. The vectors of the input tokens (coming from the user message) will be passed on to those + layers. The first layer will have an output dimension of 256 and the second layer will have an output + dimension of 128. If an empty list is used (default behavior), no feed forward layer will be + added. + Make sure to use only positive integer values. Usually, numbers of power of two are used. + Also, it is usual practice to have decreasing values in the list: next value is smaller or equal to the + value before. + + * `embedding_dimension`: + This parameter defines the output dimension of the embedding layers used inside the model (default: `20`). + We are using multiple embeddings layers inside the model architecture. + For example, the vector of the complete utterance and the intent is passed on to an embedding layer before + they are compared and the loss is calculated. + + * `number_of_transformer_layers`: + This parameter sets the number of transformer layers to use (default: `0`). + The number of transformer layers corresponds to the transformer blocks to use for the model. + + * `transformer_size`: + This parameter sets the number of units in the transformer (default: `None`). + The vectors coming out of the transformers will have the given `transformer_size`. + The `transformer_size` should be a multiple of the `number_of_attention_heads` parameter, + the training exits with an error otherwise. + + * `connection_density`: + This parameter defines the fraction of kernel weights that are set to non zero values for all feed forward + layers in the model (default: `0.2`). The value should be between 0 and 1. If you set `connection_density` + to 1, no kernel weights will be set to 0, the layer acts as a standard feed forward layer. You should not + set `connection_density` to 0 as this would result in all kernel weights being 0, i.e. the model is not able + to learn. + + * `constrain_similarities`: + This parameter when set to `True` applies a sigmoid cross entropy loss over all similarity terms. + This helps in keeping similarities between input and negative labels to smaller values. + This should help in better generalization of the model to real world test sets. + + * `model_confidence`: + This parameter allows the user to configure how confidences are computed during inference. It can take only + one value as input which is `softmax`. In `softmax`, confidences are in the range `[0, 1]`. The computed + similarities are normalized with the `softmax` activation function. + + The component can also be configured to train a response selector for a particular retrieval intent. + The parameter `retrieval_intent` sets the name of the retrieval intent for which this response selector model is trained. + Default is `None`, i.e. the model is trained for all retrieval intents. + + In its default configuration, the component uses the retrieval intent with the response key(e.g. `faq/ask_name`) as the label for training. + Alternatively, it can also be configured to use the text of the responses as the training label + by switching `use_text_as_label` to `True`. In this mode, the component will use the first available response which has a text attribute for training. If none are found, it falls back to using the retrieval intent + combined with the response key as the label. + + :::note examples and tutorials + Check out the [responseselectorbot](https://github.com/RasaHQ/rasa/tree/main/examples/responseselectorbot) for an example of + how you can use the `ResponseSelector` component in your assistant. Additionally, you will find this tutorial on + [handling FAQs](./chitchat-faqs.mdx) using a `ResponseSelector` useful as well. + ::: + + +The above configuration parameters are the ones you should configure to fit your model to your data. +However, additional parameters exist that can be adapted. + +<details><summary>More configurable parameters</summary> + +```text ++---------------------------------+-------------------+--------------------------------------------------------------+ +| Parameter | Default Value | Description | ++=================================+===================+==============================================================+ +| hidden_layers_sizes | text: [256, 128] | Hidden layer sizes for layers before the embedding layers | +| | label: [256, 128] | for user messages and labels. The number of hidden layers is | +| | | equal to the length of the corresponding list. We recommend | +| | | disabling the hidden layers (by providing empty lists) when | +| | | the transformer is enabled. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| share_hidden_layers | False | Whether to share the hidden layer weights between user | +| | | messages and labels. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| transformer_size | None | Number of units in the transformer. When a positive value is | +| | | provided for `number_of_transformer_layers`, the default size| +| | | becomes `256`. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| number_of_transformer_layers | 0 | Number of transformer layers; positive values enable the | +| | | transformer. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| number_of_attention_heads | 4 | Number of attention heads in transformer. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| use_key_relative_attention | False | If 'True' use key relative embeddings in attention. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| use_value_relative_attention | False | If 'True' use value relative embeddings in attention. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| max_relative_position | None | Maximum position for relative embeddings. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| unidirectional_encoder | False | Use a unidirectional or bidirectional encoder. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| batch_size | [64, 256] | Initial and final value for batch sizes. | +| | | Batch size will be linearly increased for each epoch. | +| | | If constant `batch_size` is required, pass an int, e.g. `8`. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| batch_strategy | "balanced" | Strategy used when creating batches. | +| | | Can be either 'sequence' or 'balanced'. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| epochs | 300 | Number of epochs to train. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| random_seed | None | Set random seed to any 'int' to get reproducible results. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| learning_rate | 0.001 | Initial learning rate for the optimizer. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| embedding_dimension | 20 | Dimension size of embedding vectors. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| dense_dimension | text: 512 | Dense dimension for sparse features to use if no dense | +| | label: 512 | features are present. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| concat_dimension | text: 512 | Concat dimension for sequence and sentence features. | +| | label: 512 | | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| number_of_negative_examples | 20 | The number of incorrect labels. The algorithm will minimize | +| | | their similarity to the user input during training. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| similarity_type | "auto" | Type of similarity measure to use, either 'auto' or 'cosine' | +| | | or 'inner'. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| loss_type | "cross_entropy" | The type of the loss function, either 'cross_entropy' | +| | | or 'margin'. Type 'margin' is only compatible with | +| | | "model_confidence=cosine", | +| | | which is deprecated (see changelog for 2.3.4). | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| ranking_length | 10 | Number of top responses to report. Set to 0 to report all | +| | | responses. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| renormalize_confidences | False | Normalize the top responses. Applicable only with loss type | +| | | 'cross_entropy' and 'softmax' confidences. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| maximum_positive_similarity | 0.8 | Indicates how similar the algorithm should try to make | +| | | embedding vectors for correct labels. | +| | | Should be 0.0 < ... < 1.0 for 'cosine' similarity type. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| maximum_negative_similarity | -0.4 | Maximum negative similarity for incorrect labels. | +| | | Should be -1.0 < ... < 1.0 for 'cosine' similarity type. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| use_maximum_negative_similarity | True | If 'True' the algorithm only minimizes maximum similarity | +| | | over incorrect intent labels, used only if 'loss_type' is | +| | | set to 'margin'. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| scale_loss | True | Scale loss inverse proportionally to confidence of correct | +| | | prediction. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| regularization_constant | 0.002 | The scale of regularization. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| negative_margin_scale | 0.8 | The scale of how important is to minimize the maximum | +| | | similarity between embeddings of different labels. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| connection_density | 0.2 | Connection density of the weights in dense layers. | +| | | Value should be between 0 and 1. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| drop_rate | 0.2 | Dropout rate for encoder. Value should be between 0 and 1. | +| | | The higher the value the higher the regularization effect. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| drop_rate_attention | 0.0 | Dropout rate for attention. Value should be between 0 and 1. | +| | | The higher the value the higher the regularization effect. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| use_sparse_input_dropout | False | If 'True' apply dropout to sparse input tensors. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| use_dense_input_dropout | False | If 'True' apply dropout to dense input tensors. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| evaluate_every_number_of_epochs | 20 | How often to calculate validation accuracy. | +| | | Set to '-1' to evaluate just once at the end of training. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| evaluate_on_number_of_examples | 0 | How many examples to use for hold out validation set. | +| | | Large values may hurt performance, e.g. model accuracy. | +| | | Set to 0 for no validation. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| use_masked_language_model | False | If 'True' random tokens of the input message will be masked | +| | | and the model should predict those tokens. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| retrieval_intent | None | Name of the intent for which this response selector model is | +| | | trained. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| use_text_as_label | False | Whether to use the actual text of the response as the label | +| | | for training the response selector. Otherwise, it uses the | +| | | response key as the label. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| tensorboard_log_directory | None | If you want to use tensorboard to visualize training | +| | | metrics, set this option to a valid output directory. You | +| | | can view the training metrics after training in tensorboard | +| | | via 'tensorboard --logdir <path-to-given-directory>'. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| tensorboard_log_level | "epoch" | Define when training metrics for tensorboard should be | +| | | logged. Either after every epoch ("epoch") or for every | +| | | training step ("batch"). | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| featurizers | [] | List of featurizer names (alias names). Only features | +| | | coming from the listed names are used. If list is empty | +| | | all available features are used. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| checkpoint_model | False | Save the best performing model during training. Models are | +| | | stored to the location specified by `--out`. Only the one | +| | | best model will be saved. | +| | | Requires `evaluate_on_number_of_examples > 0` and | +| | | `evaluate_every_number_of_epochs > 0` | ++---------------------------------+-------------------+--------------------------------------------------------------+ +| constrain_similarities | False | If `True`, applies sigmoid on all similarity terms and adds | +| | | it to the loss function to ensure that similarity values are | +| | | approximately bounded. Used only if `loss_type=cross_entropy`| ++---------------------------------+-------------------+--------------------------------------------------------------+ +| model_confidence | "softmax" | Affects how model's confidence for each response label | +| | | is computed. Currently, only one value is supported: | +| | | 1. `softmax` - Similarities between input and response label | +| | | embeddings are post-processed with a softmax function, | +| | | as a result of which confidence for all labels sum up to 1. | ++---------------------------------+-------------------+--------------------------------------------------------------+ +``` + +:::note +Parameter `maximum_negative_similarity` is set to a negative value to mimic the original +starspace algorithm in the case `maximum_negative_similarity = maximum_positive_similarity` +and `use_maximum_negative_similarity = False`. +See [starspace paper](https://arxiv.org/abs/1709.03856) for details. + +::: + </details> + + +## Custom Components + +:::info New in 3.0 +Rasa 3.0 unified the implementation of NLU components and policies. +This requires changes to custom components written for earlier versions of Rasa Open +Source. Please see the +[migration guide](migration-guide.mdx#custom-policies-and-custom-components) for a +step-by-step guide for the migration. + +::: + +You can create a custom component to perform a specific task which NLU doesn't currently offer (for example, sentiment analysis). + +You can add a custom component to your pipeline by adding the module path. +So if you have a module called `sentiment` +containing a `SentimentAnalyzer` class: + +```yaml-rasa +pipeline: +- name: "sentiment.SentimentAnalyzer" +``` + +See the [guide on custom graph components](custom-graph-components.mdx) for a complete guide on custom components. +Also be sure to read the section on the [Component Lifecycle](./tuning-your-model.mdx#component-lifecycle). + diff --git a/docs/docs/connectors/audioodes-voiceai-connect.mdx b/docs/docs/connectors/audioodes-voiceai-connect.mdx new file mode 100644 index 0000000..5673d10 --- /dev/null +++ b/docs/docs/connectors/audioodes-voiceai-connect.mdx @@ -0,0 +1,210 @@ +--- +id: audiocodes-voiceai-connect +sidebar_label: Audiocodes VoiceAI Connect +title: Audiocodes VoiceAI Connect +description: Build a Rasa Voice Bot on Audiocodes VoiceAI Connect +--- + +import RasaProLabel from '@theme/RasaProLabel'; +import RasaProBanner from '@theme/RasaProBanner'; + + +<RasaProLabel/> + +<RasaProBanner/> + +Use this channel to connect your Rasa assistant to [Audiocodes VoiceAI connect](https://www.audiocodes.com/solutions-products/voiceai/voiceai-connect). +## Getting Credentials + +To get credentials, create a bot on the [VoiceAI connect portal](https://voiceaiconnect.audiocodes.io/). + +1. Select **Bots** in the left sidebar. +2. Click on the **+** sign to create a new bot. +3. Select **Rasa** as the Bot Framework +4. Set the bot URL and choose a token value. + +:::info Setting the bot URL with a tunneling solution when testing locally + +Visit this [section](../messaging-and-voice-channels.mdx#testing-channels-on-your-local-machine) to learn how to generate +the required bot URL when testing the channel on your local machine. + +::: + +## Setting credentials + +The token value chosen above will be used in the `credentials.yml`: + +```yaml +rasa_plus.channels.audiocodes.AudiocodesInput: + token: <token> +``` + +You can also specify optional parameters: + +| Parameter | Default value | Description | +| --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `token` | No default value | The token to authenticate calls between your Rasa assistant and VoiceAI connect | +| `use_websocket` | `true` | If `true`, Rasa will send messages through a web socket. If set to `false`, Rasa will send messages through http API calls. | +| `keep_alive` | 120 | In seconds. For each ongoing conversation, VoiceAI Connect will periodically verify the conversation is still active on the Rasa side. | + +Then restart your Rasa server to make the new channel endpoint available. + +## Usage + +### Receiving messages from a user + +When a user speaks on the phone, VoiceAI Connect will send a text message (after it is processed by the speech-to-text engine) to your assistant like any other channel. +This message will be interpreted by the NLU and you can then drive the conversation with rules, stories and forms. + +### Sending messages to a user + +Your bot will respond with text messages like with any other channel. The text-to-speech engine will convert the text and deliver it as a voice message to the user. + +Here is an example: + +```yaml +utter_greet: + - text: 'Hello! isn’t every life and every work beautiful?' +``` + +:::note +Only text messages are allowed. Images, attachments, and buttons cannot be used with a voice channel. +::: + +### Handling conversation events + +Non-voice events can also be handled by the bot. Here are a few examples: + +| Event | intent | Description | +| ------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `start` | `vaig_event_start` | VoiceAI will send this intent when it picks-up a phone call. In general, the response to that intent is a welcome or greeting message. [Call context](https://techdocs.audiocodes.com/voice-ai-connect/#VAIG_Combined/call-initiation.htm?TocPath=Bot%2520integration%257CBasic%2520behavior%257C_____1) will be provided through entities | +| `end` | `vaig_event_end` | VoiceAI will send this intent when a call ends. You can use that to call an action that updates the call information. | +| `DTMF` | `vaig_event_DTMF` | VoiceAI will send this intent when receiving a DTMF tone (i.e user presses digit on the keyboard of the phone). The digit(s) sent will be passed in the `value` entity | + +The general pattern is that for every `event` sent, the bot will receive the `vaig_event_<event>` intent, with context information in entities. + +Here is a simple rule to send a greeting message when a call to the bot is initiated: + +```yaml +- rule: New call + steps: + - intent: vaig_event_start + - action: utter_greet +``` + +Check the [VoiceAI Connect documentation](https://techdocs.audiocodes.com/voice-ai-connect/#VAIG_Combined/voiceai_connect.htm?TocPath=VoiceAI%2520Connect%257C_____0) for an exhaustive list of events. + +### Configuring calls + +You can send events from Rasa to VoiceAI Connect to make changes to the current call configuration. +For example, you might want to receive a notification when the user stays silent for more than 5 seconds, or you might need to customize how DTMF digits are sent by VoiceAI Connect. + +Call configuration events are sent with custom messages and are specific to the current conversation (sometimes to a message). +Which means they must be part of your stories or rules so the same behaviour is applied to all conversations. + +Those Rasa responses don't utter anything, they just configure the voice gateway. It is a good practice naming them differently, for example prefixing them with `utter_config_<what_it_does>` + +All the supported events are exhaustively documented in the [VoiceAI Connect documentation](https://techdocs.audiocodes.com/voice-ai-connect/#VAIG_Combined/voiceai_connect.htm?TocPath=VoiceAI%2520Connect%257C_____0). +We will look at one example here to illustrate the use of custom messages and events. + +#### Example: changing a pin code + +In this example we create a flow to allow a user to change a pin code. + +```yaml +- rule: Set pin code + steps: + # User says "I want to change my pin code" + - intent: set_pin_code + # Send the noUserInput configuration event + - action: utter_config_no_user_input + # Send the DTMF format configuration event + - action: utter_config_dtmf_pin_code + # A standard Rasa form to collect the pin code from the user + - action: pin_code_form + - ... +``` + +In the domain, we can add the `utter_config_<config_event>` responses: + +[`noUserInput` event](https://techdocs.audiocodes.com/voice-ai-connect/#VAIG_Combined/inactivity-detection.htm?TocPath=Bot%2520integration%257CReceiving%2520notifications%257C_____3) +```yaml + utter_config_no_user_input: + - custom: + type: event + name: config + sessionParams: + # If user stays silent for 5 seconds or more, the notification will be sent + userNoInputTimeoutMS: 5000 + # If you want to allow for more than one notification during a call + userNoInputRetries: 2 + # Enable the noUserInput notification + userNoInputSendEvent: true +``` + +[`DTMF` event](https://techdocs.audiocodes.com/voice-ai-connect/#VAIG_Combined/receive-dtmf.htm?TocPath=Bot%2520integration%257CReceiving%2520notifications%257C_____2) +```yaml + utter_config_dtmf_pin_code: + - custom: + type: event + name: config + sessionParams: + # Enable grouped collection (i.e will send all digits in a single payload) + dtmfCollect: true + # If more than 5 secs have passed since a digit was pressed, + # the input is considered completed and will be sent to the bot + dtmfCollectInterDigitTimeoutMS: 5000 + # If 6 digits are collected, VoiceAI will send those 6 digits + # even if the user keeps pressing buttons + dtmfCollectMaxDigits: 6 + # If the user presses '#' the input is considered complete + dtmfCollectSubmitDigit: "#" +``` + + +Now you can configure the `pin_code` slot in the `pin_code_form` to extract the pin code from the `value` entity with the `vaig_event_DTMF` intent: + +```yaml + pin_code: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: value + intent: vaig_event_DTMF + not_intent: vaig_event_noUserInput + conditions: + - active_loop: pin_code_form + requested_slot: pin_code +``` + +Notice how `vaig_event_noUserInput` was declared in the `not_intent` field. + +Since the `vaig_event_noUserInput` intent is sent by VoiceAI Connect when the user stays silent as per our configuration, +we must deactivate the form so we can pick up the conversation from a rule or a story and gracefully handle the failure. + +In the following example, we simply cancel the current flow if we receive the `vaig_event_noUserInput` intent (i.e. user stays silent) while the `pin_code_form` loop is active. + +```yaml +- rule: Set pin code - happy path + steps: + - intent: set_pin_code + - action: utter_config_no_user_input + - action: utter_config_dtmf_pin_code + - action: pin_code_form + - active_loop: pin_code_form + - active_loop: null + - slot_was_set: + - requested_slot: null + - action: utter_pin_code_changed + - action: action_pin_code_cleanup + +- rule: Set pin code - no response - cancel. + condition: + - active_loop: pin_code_form + steps: + - intent: vaig_event_noUserInput + - action: utter_cancel_set_pin_code + - action: action_deactivate_loop + - active_loop: null +``` \ No newline at end of file diff --git a/docs/docs/connectors/cisco-webex-teams.mdx b/docs/docs/connectors/cisco-webex-teams.mdx new file mode 100644 index 0000000..79ac290 --- /dev/null +++ b/docs/docs/connectors/cisco-webex-teams.mdx @@ -0,0 +1,53 @@ +--- +id: cisco-webex-teams +sidebar_label: Cisco Webex Teams +title: Cisco Webex Teams +description: Build a Rasa Chat Bot on Cisco Webex +--- + +You first have to create a cisco webex app to get credentials. +Once you have them you can add these to your `credentials.yml`. + +## Getting Credentials + +**How to get the Cisco Webex Teams credentials:** + +You need to set up a bot. Check out the [Cisco Webex for Developers +documentation](https://developer.webex.com/docs/bots) for information +about how to create your bot. + +After you have created the bot through Cisco Webex Teams, you need to create a +room in Cisco Webex Teams. Then add the bot in the room the same way you would +add a person in the room. + +You need to note down the room ID for the room you created. This room ID will +be used in `room` variable in the `credentials.yml` file. + +Please follow this link below to find the room ID +`https://developer.webex.com/endpoint-rooms-get.html` + +In the OAuth & Permissions section, add the URL of the Rasa endpoint +that Webex should forward the messages to. The endpoint for receiving Cisco Webex Teams messages +is `http://<host>:<port>/webhooks/webexteams/webhook`, replacing +the host and port with the appropriate values from your running Rasa server. + +## Running on Cisco Webex Teams + +Add the Webex Teams credentials to your `credentials.yml`: + +```yaml-rasa +webexteams: + access_token: "YOUR-BOT-ACCESS-TOKEN" + room: "YOUR-CISCOWEBEXTEAMS-ROOM-ID" +``` + + +Restart your Rasa server +to make the new channel endpoint available for Cisco Webex Teams to send messages to. + +:::note +If you do not set the `room` keyword +argument, messages will by delivered back to +the user who sent them. + +::: diff --git a/docs/docs/connectors/custom-connectors.mdx b/docs/docs/connectors/custom-connectors.mdx new file mode 100644 index 0000000..792e649 --- /dev/null +++ b/docs/docs/connectors/custom-connectors.mdx @@ -0,0 +1,203 @@ +--- +id: custom-connectors +sidebar_label: Custom Connectors +title: Custom Connectors +description: Deploy and Run a Rasa Chat Bot on a custom chat interface +--- + + +You can implement your own custom channel connector as a python class. You can +use the `rasa.core.channels.rest.RestInput` class as a template. + +A custom connector class must subclass `rasa.core.channels.channel.InputChannel` +and implement at least a `blueprint` and `name` method. + + +## The `name` method + +The `name` method defines the url prefix for the connector's webhook. It also defines the channel name you should use +in any [channel specific response variations](../responses.mdx#channel-specific-response-variations) and the name you +should pass to the `output_channel` query parameter on the [trigger intent endpoint](https://www.rasa.com/docs/rasa/pages/http-api#operation/triggerConversationIntent). + +For example, if your custom channel is named `myio`, you would define the `name` method as: + +```python title=custom_channel.py +from rasa.core.channels.channel import InputChannel + +class MyIO(InputChannel): + def name() -> Text: + """Name of your custom channel.""" + return "myio" +``` + +You would write a response variation specific to the `myio` channel as: + +```yaml-rasa title="domain.yml" +responses: + utter_greet: + - text: Hi! I'm the default greeting. + - text: Hi! I'm the custom channel greeting + channel: myio +``` + +The webhook you give to the custom channel to call would be +`http://<host>:<port>/webhooks/myio/webhook`, replacing +the host and port with the appropriate values from your running Rasa server. + + +## The `blueprint` method + +The `blueprint` method +needs to create a [sanic blueprint](https://sanicframework.org/en/guide/best-practices/blueprints.html#overview) +that can be attached to a sanic server. +Your blueprint should have at least the two routes: `health` on the route `/`, +and `receive` on the route `/webhook` (see example custom channel below). + +As part of your implementation of the `receive` endpoint, you will need to tell +Rasa to handle the user message. You do this by calling + +```python + on_new_message( + rasa.core.channels.channel.UserMessage( + text, + output_channel, + sender_id + ) + ) +``` + +Calling `on_new_message` will send the user message to the [`handle_message`](https://github.com/RasaHQ/rasa/blob/c922253fe890bb4903329d4ade764e0711d384ec/rasa/core/agent.py#L511_) method. + +See more details on the `UserMessage` object [here](https://www.rasa.com/docs/rasa/reference/rasa/core/channels/channel#usermessage-objects). + +The `output_channel` argument refers to an output channel implementing the +[`OutputChannel`](https://www.rasa.com/docs/rasa/reference/rasa/core/channels/channel#outputchannel-objects) class. You can +either implement your own output channel class with the methods for your particular chat channel +(e.g. methods to send text and images) or you can use the +[`CollectingOutputChannel`](https://www.rasa.com/docs/rasa/reference/rasa/core/channels/channel#collectingoutputchannel-objects) +to collect the bot responses Rasa creates while the bot is processing your messages and return +them as part of your endpoint response. This is the way the `RestInput` +channel is implemented. For examples on how to create and use your own output +channel, take a look at the implementations of the other +output channels, e.g. the `SlackBot` in `rasa.core.channels.slack`. + +Here is a simplified example of a custom channel connector that makes use of the `CollectingOutputChannel`: + +```python title="custom_channel.py" +import asyncio +import inspect +from sanic import Sanic, Blueprint, response +from sanic.request import Request +from sanic.response import HTTPResponse +from typing import Text, Dict, Any, Optional, Callable, Awaitable, NoReturn + +import rasa.utils.endpoints +from rasa.core.channels.channel import ( + InputChannel, + CollectingOutputChannel, + UserMessage, +) + +class MyIO(InputChannel): + def name() -> Text: + """Name of your custom channel.""" + return "myio" + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[None]] + ) -> Blueprint: + + custom_webhook = Blueprint( + "custom_webhook_{}".format(type(self).__name__), + inspect.getmodule(self).__name__, + ) + + @custom_webhook.route("/", methods=["GET"]) + async def health(request: Request) -> HTTPResponse: + return response.json({"status": "ok"}) + + @custom_webhook.route("/webhook", methods=["POST"]) + async def receive(request: Request) -> HTTPResponse: + sender_id = request.json.get("sender") # method to get sender_id + text = request.json.get("text") # method to fetch text + input_channel = self.name() # method to fetch input channel + metadata = self.get_metadata(request) # method to get metadata + + collector = CollectingOutputChannel() + + # include exception handling + + await on_new_message( + UserMessage( + text, + collector, + sender_id, + input_channel=input_channel, + metadata=metadata, + ) + ) + + return response.json(collector.messages) + + return custom_webhook +``` + +## Metadata on messages + +If you need to use extra information from your front end in your custom +actions, you can pass this information using the `metadata` key of your user +message. This information will accompany the user message through the Rasa +server into the action server when applicable, where you can find it stored in +the `tracker`. Message metadata will not directly affect NLU classification +or action prediction. + +The `InputChannel` class's default implementation of `get_metadata` **ignores all metadata**. +To extract metadata in a custom connector, implement the `get_metadata` method. +The `SlackInput` channel provides one example of a `get_metadata` method that extracts metadata according to the channel's response format. + +## Credentials for Custom Channels + +To use a custom channel, you need to supply credentials for it in a credentials configuration file +called `credentials.yml`. +This credentials file has to contain the **module path** (not the channel name) of your custom channel and +any required configuration parameters. + +For example, for a custom connector class called `MyIO` saved in a file `addons/custom_channel.py`, +the module path would be `addons.custom_channel.MyIO`, and the credentials could look like: + +```yaml-rasa title="credentials.yml" +addons.custom_channel.MyIO: + username: "user_name" + another_parameter: "some value" +``` + +To make the Rasa +server aware of your custom channel, specify the path to `credentials.yml` to the Rasa server at startup with the command line argument `--credentials` . + + +## Testing the Custom Connector Webhook + +To test your custom connector, you can `POST` messages to the webhook using a json body with the following format: + +```json +{ + "sender": "test_user", // sender ID of the user sending the message + "message": "Hi there!", + "metadata": {} // optional, any extra info you want to add for processing in NLU or custom actions +} +``` + +For a locally running Rasa server, the curl request would look like this: + +```bash +curl --request POST \ + --url http://localhost:5005/webhooks/myio/webhook \ + --header 'Content-Type: application/json' \ + --data '{ + "sender": "test_user", + "message": "Hi there!", + "metadata": {} + }' +``` + + diff --git a/docs/docs/connectors/facebook-messenger.mdx b/docs/docs/connectors/facebook-messenger.mdx new file mode 100644 index 0000000..b495b88 --- /dev/null +++ b/docs/docs/connectors/facebook-messenger.mdx @@ -0,0 +1,139 @@ +--- +id: facebook-messenger +sidebar_label: Facebook Messenger +title: Facebook Messenger +description: Build a Rasa Chat Bot on Facebook Messenger +--- + +## Facebook Setup + +You first need to set up a facebook page and app to get credentials to connect to +Facebook Messenger. Once you have them you can add these to your `credentials.yml`. + +### Getting Credentials + +**How to get the Facebook credentials:** +You need to set up a Facebook app and a page. + +1. To create the app head over to + [Facebook for Developers](https://developers.facebook.com/) + and click on **My Apps** → **Add New App**. + +2. Go onto the dashboard for the app and under **Products**, + find the **Messenger** section and click **Set Up**. Scroll down to + **Token Generation** and click on the link to create a new page for your + app. + +3. Create your page and select it in the dropdown menu for the + **Token Generation**. The shown **Page Access Token** is the + `page-access-token` needed later on. + +4. Locate the **App Secret** in the app dashboard under **Settings** → **Basic**. + This will be your `secret`. + +5. Use the collected `secret` and `page-access-token` in your + `credentials.yml`, and add a field called `verify` containing + a string of your choice. Start `rasa run` with the + `--credentials credentials.yml` option. + +6. Set up a **Webhook** and select at least the **messaging** and + **messaging_postback** subscriptions. Insert your callback URL, which will + look like `https://<host>:<port>/webhooks/facebook/webhook`, replacing + the host and port with the appropriate values from your running Rasa server. + + Insert the **Verify Token** which has to match the `verify` + entry in your `credentials.yml`. + +:::note configure https +Facebook Messenger only forwards +messages to endpoints via `https`, so take appropriate measures to add +it to your setup. For local testing of your bot, see [Testing Channels on Your Local Machine](../messaging-and-voice-channels.mdx#testing-channels-on-your-local-machine). +::: + +For more detailed steps, visit the +[Messenger docs](https://developers.facebook.com/docs/graph-api/webhooks). + +### Running On Facebook Messenger + +Add the Facebook credentials to your `credentials.yml`: + +```yaml-rasa +facebook: + verify: "rasa-bot" + secret: "3e34709d01ea89032asdebfe5a74518" + page-access-token: "EAAbHPa7H9rEBAAuFk4Q3gPKbDedQnx4djJJ1JmQ7CAqO4iJKrQcNT0wtD" +``` + + +Restart your Rasa server +to make the new channel endpoint available for Facebook Messenger to send messages to. + + +## Supported response attachments + +In addition to typical text, image, and custom responses, the Facebook Messenger +channel supports the following additional response attachments: + +* [Buttons](https://developers.facebook.com/docs/messenger-platform/send-messages/buttons) + are structured the same as other Rasa buttons. Facebook API limits the amount of + buttons you can sent in a message to 3. If more than 3 buttons are provided in a + message, Rasa will ignore all provided buttons. + +* [Quick Replies](https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies) + provide a way to present a set of up to 13 buttons in-conversation that contain a + title and optional image, and appear prominently above the composer. You can also + use quick replies to request a person's email address or phone number. + + ```yaml-rasa + utter_fb_quick_reply_example: + - text: Hello World! + quick_replies: + - title: Text quick reply + payload: /example_intent + - title: Image quick reply + payload: /example_intent + image_url: http://example.com/img/red.png + # below are Facebook provided quick replies + # the title and payload will be filled + # with the user's information from their profile + - content_type: user_email + title: + payload: + - content_type: user_phone_number + title: + payload: + ``` + +:::note +Both Quick Reply and Button titles in Facebook Messenger have a character limit of +20. Titles longer than 20 characters will be truncated. + +::: + +* [Elements](https://developers.facebook.com/docs/messenger-platform/send-messages/template/generic) + provide a way to create a horizontally scrollable list up to 10 content elements that + integrate buttons, images, and more alongside text a single message. + + ```yaml-rasa + utter_fb_element_example: + - text: Hello World! + elements: + - title: Element Title 1 + subtitle: Subtitles are supported + buttons: # note the button limit still applies here + - title: Example button A + payload: /example_intent + - title: Example button B + payload: /example_intent + - title: Example button C + payload: /example_intent + - title: Element Title 2 + image_url: http://example.com/img/red.png + buttons: + - title: Example button D + payload: /example_intent + - title: Example button E + payload: /example_intent + - title: Example button F + payload: /example_intent + ``` diff --git a/docs/docs/connectors/hangouts.mdx b/docs/docs/connectors/hangouts.mdx new file mode 100644 index 0000000..8382377 --- /dev/null +++ b/docs/docs/connectors/hangouts.mdx @@ -0,0 +1,73 @@ +--- +id: hangouts +sidebar_label: Google Hangouts Chat +title: Google Hangouts Chat +description: Build a Rasa Chat Bot on Google Hangouts Chat +--- + +## Hangouts Chat Setup + +This channel works similar to the standard Rasa REST channel. For each request from the channel, your bot will +send one response. The response will be displayed to the user either as text or a so-called card (for +more information, see the Cards section). + +In order to connect your Rasa bot to Google Hangouts Chat, you first need to create a project in +Google Developer Console that includes the Hangouts API. There you can specify your bot's endpoint. +This endpoint should look like `https://<host>:<port>/webhooks/hangouts/webhook`, replacing +the host and port with the appropriate values from your running Rasa server. + +:::note configure https +Hangouts Chat only forwards +messages to endpoints via `https`, so take appropriate measures to add +it to your setup. For local testing of your bot, see [Testing Channels on Your Local Machine](../messaging-and-voice-channels.mdx#testing-channels-on-your-local-machine). +::: + +In the Google Developer console, obtain your project id (also known as project number or app ID), which determines the scope for the OAuth2 authorization in case you +want to use OAuth2. The Hangouts Chat API sends a Bearer token with every request, but it is up to +the bot to actually verify the token, hence the channel also works without this. +For more information see the [Google Hangouts documentation](https://developers.google.com/hangouts/chat). If you want the verification to be done, be sure to include `project_id` inside your `credentials.yml` file as shown below. + +The possibility to implement asynchronous communication between Hangouts Chat and bot exists, but due +to the usually synchronous nature of Rasa bots, this functionality is not included in this channel. + +### Running On Hangouts Chat + +Add the Hangouts credentials to your `credentials.yml`: + +```yaml-rasa +hangouts: + # no credentials required here +``` + +If you want to use OAuth2, add the project id obtained from the Google Developer Console: + +```yaml-rasa +hangouts: + project_id: "12345678901" +``` + + +Restart your Rasa server +to make the new channel endpoint available for Google Hangouts to send messages to. + +### Cards and Interactive Cards + +There are two ways in which Hangouts Chat will display bot messages, either as text or card. For each received +request, your bot will send all messages in one response. If one of those messages is a card (e.g. an image), +all other messages are converted to card format as well. + +Interactive cards trigger the `CARD_CLICKED` event for user interactions, e.g. when a button is clicked. When +creating an interactive card, e.g. via `dispatcher.utter_button_message()` in your `actions.py`, you can +specify a payload for each button that is going to be returned with the `CARD_CLICKED` event and extracted +by the `HangoutsInput` channel (for example +`buttons=[{"text":"Yes!", "payload":"/affirm"}, {"text":"Nope.", "payload":"/deny"}])`. +Updating cards is not yet supported. + +For more detailed information on cards, visit the +[Hangouts docs](https://developers.google.com/hangouts/chat/reference). + +### Other Hangouts Chat Events + +Except for `MESSAGE` and `CARD_CLICKED`, Hangouts Chat knows two other event types, `ADDED_TO_SPACE` and +`REMOVED_FROM_SPACE`, which are triggered when your bot is added or removed from a direct message or chat room +space. The default intent names for these events can be modified in the `HangoutsInput` constructor method. diff --git a/docs/docs/connectors/img/slack-app-home.png b/docs/docs/connectors/img/slack-app-home.png new file mode 100644 index 0000000..c42e399 Binary files /dev/null and b/docs/docs/connectors/img/slack-app-home.png differ diff --git a/docs/docs/connectors/img/slack-create-app.png b/docs/docs/connectors/img/slack-create-app.png new file mode 100644 index 0000000..cc7ddf6 Binary files /dev/null and b/docs/docs/connectors/img/slack-create-app.png differ diff --git a/docs/docs/connectors/img/slack-events.png b/docs/docs/connectors/img/slack-events.png new file mode 100644 index 0000000..fd25626 Binary files /dev/null and b/docs/docs/connectors/img/slack-events.png differ diff --git a/docs/docs/connectors/img/slack-install-app.png b/docs/docs/connectors/img/slack-install-app.png new file mode 100644 index 0000000..79e5609 Binary files /dev/null and b/docs/docs/connectors/img/slack-install-app.png differ diff --git a/docs/docs/connectors/img/slack-interactivity.png b/docs/docs/connectors/img/slack-interactivity.png new file mode 100644 index 0000000..46b68fb Binary files /dev/null and b/docs/docs/connectors/img/slack-interactivity.png differ diff --git a/docs/docs/connectors/img/slack-request-url.png b/docs/docs/connectors/img/slack-request-url.png new file mode 100644 index 0000000..283e2fd Binary files /dev/null and b/docs/docs/connectors/img/slack-request-url.png differ diff --git a/docs/docs/connectors/img/slack-scopes.png b/docs/docs/connectors/img/slack-scopes.png new file mode 100644 index 0000000..3b86a2b Binary files /dev/null and b/docs/docs/connectors/img/slack-scopes.png differ diff --git a/docs/docs/connectors/img/slack-secret.png b/docs/docs/connectors/img/slack-secret.png new file mode 100644 index 0000000..c0a8a41 Binary files /dev/null and b/docs/docs/connectors/img/slack-secret.png differ diff --git a/docs/docs/connectors/img/twilio-set-sip-webhook.png b/docs/docs/connectors/img/twilio-set-sip-webhook.png new file mode 100644 index 0000000..befc29b Binary files /dev/null and b/docs/docs/connectors/img/twilio-set-sip-webhook.png differ diff --git a/docs/docs/connectors/img/twilio-set-webhook.png b/docs/docs/connectors/img/twilio-set-webhook.png new file mode 100644 index 0000000..38170f2 Binary files /dev/null and b/docs/docs/connectors/img/twilio-set-webhook.png differ diff --git a/docs/docs/connectors/mattermost.mdx b/docs/docs/connectors/mattermost.mdx new file mode 100644 index 0000000..105b0f8 --- /dev/null +++ b/docs/docs/connectors/mattermost.mdx @@ -0,0 +1,59 @@ +--- +id: mattermost +sidebar_label: Mattermost +title: Mattermost +description: Build a Rasa Chat Bot on Mattermost +--- + +You first have to create a mattermost app to get credentials. +Once you have them you can add these to your `credentials.yml`. + +## Getting Credentials + +Mattermost now uses bot accounts for better security. So you can use their guide to create +your bot to get your token required for the credentials.yml file. + +For more information on creating a bot account please see +[Bot Creation](https://docs.mattermost.com/developer/bot-accounts.html#bot-account-creation). + +For information on converting existing user account into bot account please see +[User Conversion](https://docs.mattermost.com/developer/bot-accounts.html#how-do-i-convert-an-existing-account-to-a-bot-account). + +**How to set up the outgoing webhook:** + +1. To create the Mattermost outgoing webhook, login to your Mattermost + team site and go to **Main Menu > Integrations > Outgoing Webhooks**. + +2. Click **Add outgoing webhook**. + +3. Fill out the details including the channel you want the bot in. + You will need to ensure the **trigger words** section is set up + with `@yourbotname` so that the bot doesn't trigger on everything + that is said. + +4. The **Content Type** must be set to `application/json`. + +5. Make sure **trigger when** is set to value + **first word matches a trigger word exactly**. + +6. Add the Callback URL, which will + look like `http://<host>:<port>/webhooks/mattermost/webhook`, replacing + the host and port with the appropriate values from your running Rasa server. + +For more detailed steps, visit the +[Mattermost docs](https://docs.mattermost.com/guides/developer.html). + +## Running on Mattermost + +Add the Mattermost credentials to your `credentials.yml`: + +```yaml-rasa +mattermost: + url: "https://chat.example.com/api/v4" + token: "xxxxx" # the token for the bot account from creating the bot step. + webhook_url: "https://server.example.com/webhooks/mattermost/webhook" # this should match the callback url from step 6 +``` + + +Restart your Rasa server +to make the new channel endpoint available for Mattermost to send messages to. diff --git a/docs/docs/connectors/microsoft-bot-framework.mdx b/docs/docs/connectors/microsoft-bot-framework.mdx new file mode 100644 index 0000000..5c39f3f --- /dev/null +++ b/docs/docs/connectors/microsoft-bot-framework.mdx @@ -0,0 +1,28 @@ +--- +id: microsoft-bot-framework +sidebar_label: Microsoft Bot Framework +title: Microsoft Bot Framework +description: Build a Rasa Chat Bot on Microsoft Bot Framework +--- + +You first have to create a Microsoft app to get credentials. +Once you have them you can add these to your `credentials.yml`. + +The endpoint URL that Microsoft Bot Framework should send messages to will +look like `http://<host>:<port>/webhooks/botframework/webhook`, replacing +the host and port with the appropriate values from your running Rasa server. + + +## Running on Microsoft Bot Framework + +Add the Botframework credentials to your `credentials.yml`: + +```yaml-rasa +botframework: + app_id: "MICROSOFT_APP_ID" + app_password: "MICROSOFT_APP_PASSWORD" +``` + + +Restart your Rasa server +to make the new channel endpoint available for Microsoft Bot Framework to send messages to. diff --git a/docs/docs/connectors/rocketchat.mdx b/docs/docs/connectors/rocketchat.mdx new file mode 100644 index 0000000..92704fe --- /dev/null +++ b/docs/docs/connectors/rocketchat.mdx @@ -0,0 +1,48 @@ +--- +id: rocketchat +sidebar_label: RocketChat +title: RocketChat +description: Build a Rasa Chat Bot on Rocketchat +--- + +## Getting Credentials + +**How to set up Rocket.Chat:** + +1. Create a user that will be used to post messages, and set its + credentials at credentials file. + +2. Create a Rocket.Chat outgoing webhook by logging in as admin to + Rocket.Chat and going to + **Administration > Integrations > New Integration**. + +3. Select **Outgoing Webhook**. + +4. Set **Event Trigger** section to value **Message Sent**. + +5. Fill out the details, including the channel you want the bot + listen to. Optionally, it is possible to set the + **Trigger Words** section with `@yourbotname` so that the bot + doesn't trigger on everything that is said. + +6. In the **URLs** section, set the URL to + `http://<host>:<port>/webhooks/rocketchat/webhook`, replacing + the host and port with the appropriate values from your running Rasa server. + +For more information on the Rocket.Chat Webhooks, see the +[Rocket.Chat Guide](https://docs.rocket.chat/use-rocket.chat/workspace-administration/integrations). + +## Running on RocketChat + +Add the RocketChat credentials to your `credentials.yml`: + +```yaml-rasa +rocketchat: + user: "yourbotname" + password: "YOUR_PASSWORD" + server_url: "https://demo.rocket.chat" +``` + + +Restart your Rasa server +to make the new channel endpoint available for RocketChat to send messages to. diff --git a/docs/docs/connectors/slack.mdx b/docs/docs/connectors/slack.mdx new file mode 100644 index 0000000..6982204 --- /dev/null +++ b/docs/docs/connectors/slack.mdx @@ -0,0 +1,186 @@ +--- +id: slack +sidebar_label: Slack +title: Slack +description: Build a Rasa Chat Bot on Slack +--- +import createAppImg from './img/slack-create-app.png'; +import eventsImg from './img/slack-events.png'; +import installAppImg from './img/slack-install-app.png'; +import interactivityImg from './img/slack-interactivity.png'; +import requestUrlImg from './img/slack-request-url.png'; +import scopesImg from './img/slack-scopes.png'; +import secretImg from './img/slack-secret.png'; +import appHomeImg from './img/slack-create-app.png'; + +Connecting a bot to Slack requires you to configure it to send messages +(using API credentials) and to receive messages (using a webhook). + +## Sending Messages + +Create a new file `credentials.yml` in the root folder of your Rasa +project (if you've used `rasa init` this file should already exist and you can +just edit it). Add the following lines to the file: + +```yaml-rasa title="credentials.yml" +slack: + slack_channel: "CA003L0XZ" # channel ID, not a channel name! + slack_token: "xoxb-XXX" # token obtained in the next step + slack_signing_secret: "YYY" # secret obtained in the next step +``` + +The `slack_channel` can be a channel or an individual person that the bot should +listen to for communications, in addition to the default behavior of listening +for direct messages and app mentions, i.e. *@app_name*. +To get a channel id, right click on the channel in Slack and choose **Copy Link**. +The id will be the last component in the URL. + +In the next couple steps, you'll create a Slack App to get the values for +`slack_token` and `slack_signing_secret`: + +1. To create the app go to [Your Apps](https://api.slack.com/apps "The Your Apps section of your Slack interface") and click + on **Create New App**. + + <Image img={createAppImg} caption="Create New App" alt="Create New Slack App Screenshot" max-width="500px"/> + + Fill out your **App Name** and select the **Development Workspace** where + you'll play around and build your app. + +2. Head over to **OAuth & Permissions** and scroll down to **Scopes**. Scopes give + your app permission to do things in your workspace. + + To get started, you should at least add the following scopes: + - `app_mentions:read`, + - `channels:history`, + - `chat:write`, + - `groups:history`, + - `im:history`, + - `mpim:history` and + - `reactions:write`. + + <Image img={scopesImg} caption="Set up Slack Permissions" alt="Set up Slack Permissions Screenshot" max-width="500px" /> + + In Slacks API documentation you can find a + [list and explanation of all available scopes](https://api.slack.com/scopes). + +3. On the **OAuth & Permissions** page, click **Install App to Workspace** to add + the bot to your workspace. + + <Image img={installAppImg} caption="Install Slack App" alt="Install Slack App Screenshot" max-width="500px"/> + + Once added, Slack will show you a **Bot User OAuth Access Token** which you'll + need to add to your `credentials.yml` as the value for `slack_token`: + + ```yaml-rasa title="credentials.yml" line={3} + slack: + slack_channel: "your-channel" # choose a channel for your bot + slack_token: "xoxb-XXX" # token obtained in the next step + slack_signing_secret: "YYY" # secret obtained in the next step + ``` + + The token should start with `xoxb`. + +4. Head over to **Basic Information** to gather the **Signing Secret**. + + <Image img={secretImg} caption="Signing Secret" alt="Signing Secret Screenshot" max-width="500px"/> + + Copy the signing secret into your `credentials.yml` as the value for `slack_signing_secret`: + + ```yaml-rasa title="credentials.yml" line={4} + slack: + slack_channel: "your-channel" # choose a channel for your bot + slack_token: "xoxb-XXX" # token obtained in the next step + slack_signing_secret: "YYY" # secret obtained in the next step + ``` + +This setup will allow your bot to send messages. Now let's head over to the setup +for receiving and reacting to messages. + +## Receiving Messages +Before continuing, make sure you have configured a Slack App for [Sending Messages](./slack.mdx#sending-messages) and have added Slack credentials to your `credentials.yml` file. + +To receive messages, you will need a publicly available URL for Slack to reach +your bot and tell you about new messages. If you are running locally, you can +[test channels using ngrok](../messaging-and-voice-channels.mdx#testing-channels-on-your-local-machine) + +1. To configure your bot to receive messages, your bot needs to be running. + Start your bot e.g. using + ```bash + rasa run + ``` + If you are running locally, make sure ngrok (or another tool to retrieve a public + url) is running as well. + +2. To send messages directly to your bot using the slack UI, head to **App Home**, + scroll to the bottom and select the checkbox for + `Allow users to send Slash commands and messages from the messages tab.` + + You might have to quit the Slack app and re-open it before your changes take effect. + + <Image img={appHomeImg} caption="Messages Tab" alt="Allow users to send Slash commands and messages from the messages tab" max-width="500px"/> + +3. Configure the webhook by heading to **Event Subscriptions** and + turning **Enable Events** on. + + As a request URL enter the public url of your bot and append `/webhooks/slack/webhook`, e.g. + `https://<host>/webhooks/slack/webhook` replacing `<host>` with your URL. If you + are using ngrok, your url should look like `https://92832de0.ngrok.io/webhooks/slack/webhook`. + + You won't be able to use a `localhost` url. + + <Image img={requestUrlImg} caption="Request URL" alt="Request URL Screenshot" max-width="500px"/> + +4. As a last step, you'll need to **Subscribe to bot events** on the same page. + You'll need to add the following events: + - `message.channels`, + - `message.groups`, + - `message.im` and + - `message.mpim`. + + <Image img={eventsImg} caption="Subscribe to Bot Events" alt="Subscribe to Bot Events Screenshot" max-width="500px"/> + + Make sure to hit **Save Changes** at the bottom of the page after you've added these events. + + (If you didn't grant all required permissions to your app while setting up + sending of messages, you'll be prompted to **reinstall your app** which you will + need to do. Otherwise, Slack will confirm your change with a **Success!**) + +:::note invite to channels +As per [Slack docs](https://api.slack.com/authentication/basics#calling), make +sure you invite your bot to a channel it should be accessing. You can do this +by using `/invite` in the channel. +::: + +Your bot is now ready to go and will receive webhook notifications about new messages. +## Optional: Interactive Components +After you've completed [Sending Messages](./slack.mdx#sending-messages) and +[Receiving Messages](./slack.mdx#receiving-messages) your bot is ready to go. If you +want to use Slack's [interactive components](https://api.slack.com/block-kit/interactivity) (buttons or menus), you'll need to do +some additional configuration: + +Open the **Interactivity & Shortcuts** page and toggle **Interactivity** to be on. +Afterwards, you'll need to enter the same url into the **Request URL** field which +you used in Step 2 of [Receiving Messages](./slack.mdx#receiving-messages), e.g. `https://<host>/webhooks/slack/webhook`. + +<Image img={interactivityImg} caption="Enabling Interactivity" alt="Interactivity Screenshot" max-width="500px"/> + +## Additional Slack Options + +Here is a complete overview of all the configuration parameters for the Slack +connection: + +```yaml-rasa title="credentials.yml" +slack: + slack_channel: "CA003L0XZ" # channel ID, not a channel name! + slack_token: "xoxb-XXX" # token obtained in the next step + slack_signing_secret: "YYY" # secret obtained in the next step + proxy: "http://myProxy.online" # Proxy Server to route your traffic through. This configuration is optional. Only HTTP proxies are supported + slack_retry_reason_header: "x-slack-retry-reason" # Slack HTTP header name indicating reason that slack send retry request. This configuration is optional. + slack_retry_number_header: "x-slack-retry-num" # Slack HTTP header name indicating the attempt number. This configuration is optional. + errors_ignore_retry: None # Any error codes given by Slack included in this list will be ignored. Error codes are listed [here](https://api.slack.com/events-api#errors). + use_threads: False # If set to True, bot responses will appear as a threaded message in Slack. This configuration is optional and set to False by default. + conversation_granularity: "sender" # sender allows 1 conversation per user (across channels), channel allows 1 conversation per user per channel, thread allows 1 conversation per user per thread. This configuration is optional and set to sender by default. +``` + +Make sure to restart your Rasa server after changing the +`credentials.yml` for the changes to take effect. diff --git a/docs/docs/connectors/telegram.mdx b/docs/docs/connectors/telegram.mdx new file mode 100644 index 0000000..bead114 --- /dev/null +++ b/docs/docs/connectors/telegram.mdx @@ -0,0 +1,97 @@ +--- +id: telegram +sidebar_label: Telegram +title: Telegram +description: Build a Rasa Chat Bot on Telegram +--- + +You first have to create a Telegram bot to get credentials. +Once you have them you can add these to your `credentials.yml`. + +## Getting Credentials + +**How to get the Telegram credentials:** +You need to set up a Telegram bot. + +1. To create the bot, go to [Bot Father](https://web.telegram.org/#/im?p=@BotFather), + enter `/newbot` and follow the instructions. The URL that Telegram should send messages to will look like + `http://<host>:<port>/webhooks/telegram/webhook`, replacing + the host and port with the appropriate values from your running Rasa server. + +2. At the end you should get your `access_token` and the username you + set will be your `verify`. + +3. If you want to use your bot in a group setting, it's advisable to + turn on group privacy mode by entering `/setprivacy`. Then the bot + will only listen when a user's message starts with `/bot`. + +For more information, check out the [Telegram HTTP API](https://core.telegram.org/bots/api). + +## Running on Telegram + +Add the Telegram credentials to your `credentials.yml`: + +```yaml-rasa +telegram: + access_token: "490161424:AAGlRxinBRtKGb21_rlOEMtDFZMXBl6EC0o" + verify: "your_bot" + webhook_url: "https://your_url.com/webhooks/telegram/webhook" +``` + + +Restart your Rasa server +to make the new channel endpoint available for Telegram to send messages to. + +:::note Handling `/start` message + +At the beginning of a conversation, the user will press the 'Start' button in Telegram. +This will trigger a message with the content */start* to be sent. +Make sure your bot can handle this intro message by designing a specific intent in the nlu training data file. +Then add this `start` intent to the domain alongside a story or rule to handle it. + +::: + + +## Supported Response Attachments + +In addition to standard `text:` responses, this channel also supports the following components from the [Telegram API](https://core.telegram.org/bots/api/#message): + +- `button` arguments: + - button_type: inline | vertical | reply +- `custom` arguments: + - photo + - audio + - document + - sticker + - video + - video_note + - animation + - voice + - media + - latitude, longitude (location) + - latitude, longitude, title, address (venue) + - phone_number + - game_short_name + - action + +Examples: + +```yaml-rasa + + utter_ask_transfer_form_confirm: + - buttons: + - payload: /affirm + title: Yes + - payload: /deny + title: No, cancel the transaction + button_type: vertical + text: Would you like to transfer {currency}{amount_of_money} to {PERSON}? + image: "https://i.imgur.com/nGF1K8f.jpg" +``` + +```yaml-rasa + utter_giraffe_sticker: + - text: Here's my giraffe sticker! + custom: + sticker: "https://github.com/TelegramBots/book/raw/master/src/docs/sticker-fred.webp" +``` diff --git a/docs/docs/connectors/twilio-voice.mdx b/docs/docs/connectors/twilio-voice.mdx new file mode 100644 index 0000000..ab54d64 --- /dev/null +++ b/docs/docs/connectors/twilio-voice.mdx @@ -0,0 +1,121 @@ +--- +id: twilio-voice +sidebar_label: Twilio Voice +title: Twilio Voice +description: Deploy a Rasa IVR assistant via the Twilio Voice connector +--- +import twilioWebhook from './img/twilio-set-webhook.png'; +import twilioSipWebhook from './img/twilio-set-sip-webhook.png'; + +You can use the Twilio Voice connector to answer phone calls made to your Twilio phone number or Twilio SIP domain. + +## Running on Twilio + +### Connect to a Twilio Phone Number + +To forward calls from Twilio to your Rasa assistant the webhook for your phone number needs to be updated. Go to the [Phone +Numbers](https://www.twilio.com/console/phone-numbers/incoming) section of your Twilio account and select the phone +number you want to connect to Rasa. Find the `Voice & Fax` section on the screen. Under the `A CALL COMES IN` section +add the webhook URL (e.g. `https://<host>:<port>/webhooks/twilio_voice/webhook`) for your Rasa bot replacing the host +and port with the appropriate values for your deployment. Click `Save` at the bottom of the page. + +<Image img={twilioWebhook} caption="Set Twilio Webhook" alt="Screenshot of Twilio Console to Set Webhook" max-width="500px"/> + +### Connect to a Twilio SIP Domain + +You can also connect your Twilio Voice channel to a Twilio SIP domain. You can follow the +[Twilio Sending SIP](https://www.twilio.com/docs/voice/api/sending-sip) guide to forward SIP requests to your Twilio SIP +domain. Once your SIP domain is configured you will have to forward incoming calls to Rasa. In the Twilio console go to +the [SIP Domains](https://console.twilio.com/develop/voice/manage/sip-domains?frameUrl=/console/voice/sip/endpoints) +section. Select the SIP domain you would like to use. Find the `Call Control Configuration` section and add the +webhook URL (e.g. `https://<host>:<port>/webhooks/twilio_voice/webhook`) for your Rasa bot replacing the host and port +with the appropriate values for your deployment. Click `Save` at the bottom of the page. + +<Image img={twilioSipWebhook} caption="Set Twilio SIP Domain Webhook" alt="Screenshot of Twilio Console to Set Webhook" max-width="500px"/> + +## Configure Channel in Rasa + +In your `credentials.yml` file make sure the `twilio_voice` channel is added. Within `credentials.yml` there are a +number of parameters you can set to control the behavior of your assistant. An example with definitions of each +parameter is below. Unlike the Twilio text channel there is no need to add your Twilio +credentials for the voice channel. Note, changing values for `enhanced` and `assistant_voice` can result in added costs +from Twilio. Review the documentation below for details about these settings. + +```yaml title="credentials.yml" +twilio_voice: + initial_prompt: "hello" + assistant_voice: "woman" + reprompt_fallback_phrase: "I didn't get that could you repeat?" + speech_timeout: "5" + speech_model: "default" + enhanced: "false" +``` + +## Parameter Definitions + +### Initial Prompt + +When Twilio receives a new call and forwards this to Rasa, Twilio does not provide a user message. In this + case Rasa will act as if the user sent the message "Hello". This behavior can be configured in your + `credentials.yml` file by setting the `initial_prompt` parameter to the desired input. The `initial_prompt` value will + be sent to Rasa and the response will be spoken to the user to start the voice conversation. How you greet + a user via a voice channel may differ from a text channel. You should review your responses and consider creating + [channel-specific variations](../responses.mdx#channel-specific-response-variations) where + necessary. + +### Assistant Voice + +You can add personality to your assistant by specifying the type of voice your assistant should speak with. In the +`credentials.yml` file you can add an option for `assistant_voice` to specify the type of voice of your assistant. For a +list of supported voices you can check the [Twilio documentation](https://www.twilio.com/docs/voice/twiml/say#voice). By +default Twilio's `woman` voice will be used. Note that you will incur additional charges for using any of the `Polly` +voices. The Twilio documentation has details about +[Polly pricing](https://www.twilio.com/docs/voice/twiml/say/text-speech#pricing). + +### Reprompt Fallback Phrase + +Unlike text channels where users can review previous messages in the conversation and take their time to +reply, with voice channels users can get confused when there is a pause in the conversation. When a long pause is +detected during a conversation Rasa will repeat the last utterance from the bot to re-prompt the user for a response. If +the previous bot utterance cannot be identified then the message defined in `reprompt_fallback_phrase` will be sent. By +default this is set to "I'm sorry I didn't get that could you rephrase". + +### Speech Timeout + +How long Twilio will collect speech from the caller. This parameter must be set to a number or "auto". If a number is +provided the assistant will collect speech for the specified amount of time. If `speech_timeout` is set to "auto" then +Twilio will collect speech until a pause is detected. The default setting of this parameter is "auto". You can find more +details about `speech_timeout` in the +[Twilio documentation](https://www.twilio.com/docs/voice/twiml/gather#speechtimeout). Note that `speech_timeout="auto"` +is only compatible with `speech_model='numbers_and_commands'`. An error will be raised if an incompatible +`speech_model` is used with the `speech_timeout`. + +### Speech Model + +Adjusting the `speech_model` parameter can help Twilio with the accuracy of its speech-to-text transformation. Valid +values are `default`, `numbers_and_commands`, and `phone_call`. The default setting is "default". You can find more +details about `speech_model` in the +[Twilio documentation](https://www.twilio.com/docs/voice/twiml/gather#speechmodel). + +### Enhanced + +Setting `enhanced` to `true` will allow you to use Twilio's premium speech model to improve the accuracy of +transcription results. Note, setting this parameter to `true` will result in higher Twilio transcription costs. This +parameter is only supported if you also have set the `speech_model` parameter to `phone_call`. By default `enhanced` is +set to "false". You can find more about the `enhanced` speech model option in the +[Twilio documentation](https://www.twilio.com/docs/voice/twiml/gather#enhanced). + +## Custom Voice Responses + +It is highly recommended that you provide voice +[channel specific responses](../responses.mdx#channel-specific-response-variations) for all +responses containing images, emojis and/or abbreviations. Visual media like images and emojis do not translate well to +voice applications. Having voice alternatives to those responses lets you tailor the user experience to the voice +channel. If a response is detected as containing either an image or emoji a warning will be raised. The image or emoji +will be removed from the response and only any accompanying text will be included in the response back to the user. + +In addition to reviewing responses containing images and emojis you should also review all other responses for their +applicability to voice channels. Short-hand abbreviations like "e.g." do not translate well. Voice responses should use +the long-hand versions of any abbreviations. Interactions with users can differ between text and voice channels. +Reviewing all responses in the scope of a voice channel and providing voice specific responses can make for a better +user experience. diff --git a/docs/docs/connectors/twilio.mdx b/docs/docs/connectors/twilio.mdx new file mode 100644 index 0000000..6786b18 --- /dev/null +++ b/docs/docs/connectors/twilio.mdx @@ -0,0 +1,93 @@ +--- +id: twilio +sidebar_label: Twilio +title: Twilio +description: Deploy a Rasa assistant through text message or WhatsApp via the Twilio connector +--- + +You can use the Twilio connector to deploy an assistant that is available over text message. + +## Getting Credentials + +You first have to create a Twilio app to get credentials. +Once you have them you can add these to your `credentials.yml`. + +**How to get the Twilio credentials:** +You need to set up a Twilio account. + +1. Once you have created a Twilio account, you need to create a new + project. The basic important product to select here + is `Programmable SMS`. + +2. Once you have created the project, navigate to the Dashboard of + `Programmable SMS` and click on `Get Started`. Follow the + steps to connect a phone number to the project. + +3. Now you can use the `Account SID`, `Auth Token`, and the phone + number you purchased in your `credentials.yml`. + +4. Configure your webhook URL by navigating to + [Phone Numbers](https://www.twilio.com/console/phone-numbers/incoming) in the Twilio + dashboard and selecting your phone number. Find the `Messaging` section and add + your webhook URL (e.g. `https://<host>:<port>/webhooks/twilio/webhook`, + replacing the host and port with the appropriate values from your running Rasa server) + to the `A MESSAGE COMES IN` setting. + +For more information, see the [Twilio REST API](https://www.twilio.com/docs/iam/api). + +### Connecting to WhatsApp + +You can deploy a Rasa assistant to WhatsApp through Twilio. However, to do so, you have +to have a [WhatsApp Business](https://www.whatsapp.com/business/) profile. Associate +your Whatsapp Business profile with the phone number you purchased through Twilio to +access the [Twilio API for WhatsApp](https://www.twilio.com/docs/whatsapp/api). + +According to the [Twilio API documentation](https://www.twilio.com/docs/whatsapp/api#using-phone-numbers-with-whatsapp), +the phone number you use should be prefixed with whatsapp: in the `credentials.yml` described below. + + +## Running on Twilio + +Add the Twilio credentials to your `credentials.yml`: + +```yaml-rasa +twilio: + account_sid: "ACbc2dxxxxxxxxxxxx19d54bdcd6e41186" + auth_token: "e231c197493a7122d475b4xxxxxxxxxx" + twilio_number: "+440123456789" # if using WhatsApp: "whatsapp:+440123456789" +``` + +Restart your Rasa server +to make the new channel endpoint available for Twilio to send messages to. + + +### Receiving Location Data from Whatsapp with Twilio connector + +This is how you can receive location data (WhatsApp Location) from a user on this channel: + +1. Create an intent named "locationData" and define two entities and slots for Latitude and Longitude respectively. + +```yaml-rasa title="domain.yml" +intents: + - locationData + +slots: + Latitude: + type: text + mappings: + - type: from_entity + entity: Latitude + + Longitude: + type: text + mappings: + - type: from_entity + entity: Longitude + +entities: + - Latitude + - Longitude + +``` + +2. When the user sends a location message, the locationData intent will be triggered and the slots will be set from the entities. Note that you do not need to provide training data for the entities as they are handled by the channel. \ No newline at end of file diff --git a/docs/docs/connectors/your-own-website.mdx b/docs/docs/connectors/your-own-website.mdx new file mode 100644 index 0000000..ae1dbed --- /dev/null +++ b/docs/docs/connectors/your-own-website.mdx @@ -0,0 +1,186 @@ +--- +id: your-own-website +sidebar_label: Your Own Website +title: Your Own Website +description: Deploy and Run a Rasa Chat Bot on a Website +--- + +If you already have an existing website and want to add a Rasa assistant to it, +you can use the [Rasa Chat Widget](#chat-widget) a widget which you can incorporate into your existing webpage by adding a HTML snippet. + +Alternatively, you can also build your own chat widget. + +## REST Channels + +The `RestInput` and `CallbackInput` channels can be used for custom integrations. +They provide a URL where you can post messages and either receive response messages +directly, or asynchronously via a webhook. + +### RestInput + +The REST channel will provide you with a REST endpoint where you can post +user messages and receive the assistant's messages in response. + +Add the REST channel to your credentials.yml: + +```yaml-rasa +rest: + # you don't need to provide anything here - this channel doesn't + # require any credentials +``` + +Restart your Rasa server +to make the REST channel available to receive messages. You can then send messages to +`http://<host>:<port>/webhooks/rest/webhook`, replacing +the host and port with the appropriate values from your running Rasa server. + +#### Request and Response Format + +After making the `rest` input channel available, you can `POST` messages to +`http://<host>:<port>/webhooks/rest/webhook`, with the following format: + +```json +{ + "sender": "test_user", // sender ID of the user sending the message + "message": "Hi there!" +} +``` + +The response from Rasa will be a JSON body of bot responses, for example: + +```json +[ + {"text": "Hey Rasa!"}, {"image": "http://example.com/image.jpg"} +] +``` + +### CallbackInput + +The Callback channel behaves very much like the REST channel, +but instead of directly returning the bot messages to the HTTP +request that sends the message, it will call a URL you can specify +to send bot messages. + +To use the callback channel, add the credentials to your `credentials.yml`: + +```yaml-rasa +callback: + # URL to which Core will send the bot responses + url: "http://localhost:5034/bot" +``` + +Restart your Rasa server +to make the new channel endpoint available to receive messages. +You can then send messages to `http://<host>:<port>/webhooks/callback/webhook`, replacing +the host and port with the appropriate values from your running Rasa server. + +#### Request and Response Format + +After making the `callback` input available, you can `POST` messages to +`http://<host>:<port>/webhooks/callback/webhook` with the following format: + +```json +{ + "sender": "test_user", // sender ID of the user sending the message + "message": "Hi there!" +} +``` + +If successful, the response will be `success`. Once Rasa is ready to send a +message to the user, it will call the `url` specified in your `credentials.yml` with a `POST` +request containing a JSON body of the bot's responses: + +```json +[ + {"text": "Hey Rasa!"}, {"image": "http://example.com/image.jpg"} +] +``` + + +## Websocket Channel + +The SocketIO channel uses websockets and is real-time. To use the SocketIO channel, +add the credentials to your `credentials.yml`: + +```yaml-rasa +socketio: + user_message_evt: user_uttered + bot_message_evt: bot_uttered + session_persistence: true/false +``` + +The first two configuration values define the event names used by Rasa +when sending or receiving messages over socket.io. + +The socket client can pass an object named metadata to supply metadata to the channel. You can configure an alternative key using the metadata_key setting. For example, if your client wants to pass metadata on a key named customData, the setting would be: + +```yaml-rasa +socketio: + metadata_key: customData +``` + +Restart your Rasa server +to make the new channel endpoint available to receive messages. +You can then send messages to `http://<host>:<port>/socket.io`, replacing +the host and port with the appropriate values from your running Rasa server. + +:::note session persistence +By default, the SocketIO channel uses the socket id as `sender_id`, which causes +the session to restart at every page reload. `session_persistence` can be +set to `true` to avoid that. In that case, the frontend is responsible +for generating a session id and sending it to the Rasa Core server by +emitting the event `session_request` with `{session_id: [session_id]}` +immediately after the `connect` event. + +The example [Webchat](https://github.com/botfront/rasa-webchat) +implements this session creation mechanism (version >= 0.5.0). +::: + + +:::note SocketIO client / server compatibility +The version of the SocketIO client connecting to Rasa must be compatible with the versions of +the [python-socketio](https://github.com/miguelgrinberg/python-socketio) and +[python-engineio](https://github.com/miguelgrinberg/python-engineio) packages used by +Rasa. Please refer to the +[`pyproject.toml`](https://github.com/RasaHQ/rasa/blob/main/pyproject.toml) +file relative to your version of Rasa and the official `python-socketio` compatibility table. +::: + +### JWT Authentication + +The SocketIO channel can be optionally configured to perform JWT authentication on connect +by defining the `jwt_key` and optional `jwt_method` in the `credentials.yml` file. + +```yaml-rasa +socketio: + user_message_evt: user_uttered + bot_message_evt: bot_uttered + session_persistence: true + jwt_key: my_public_key + jwt_method: HS256 +``` + +When initially requesting the connection, the client should pass in an encoded payload +as a JSON object under the key `token`: + +```json +{ + "token": "jwt_encoded_payload" +} +``` + +### Chat Widget + +Once you've set up your SocketIO channel, you can use the official Rasa Chat Widget on any webpage. +Just paste the following into your site HTML and paste the URL of your Rasa instance into +the `data-websocket-url` attribute + +```html +<div id="rasa-chat-widget" data-websocket-url="https://your-rasa-url-here/"></div> +<script src="https://unpkg.com/@rasahq/rasa-chat" type="application/javascript"></script> +``` + +For more information, including how to fully customize the widget for your website, you can check out the [full documentation](https://chat-widget-docs.rasa.com/). + +Alternatively, if you want to embed the widget in a React app, there is +[a library in the NPM package repository](https://www.npmjs.com/package/@rasahq/rasa-chat). diff --git a/docs/docs/contextual-conversations.mdx b/docs/docs/contextual-conversations.mdx new file mode 100644 index 0000000..8335336 --- /dev/null +++ b/docs/docs/contextual-conversations.mdx @@ -0,0 +1,193 @@ +--- +id: contextual-conversations +sidebar_label: Contextual Conversations +title: Contextual Conversations +abstract: Taking context into account is often key to providing a good user experience. This page is a guide to creating contextual conversation patterns. +--- + +In a contextual conversation, something beyond the previous step in the conversation +plays a role in what should happen next. For example, if a user asks "How many?", +it's not clear from the message alone what the user is asking about. In the context of the assistant saying, +"You've got mail!", the response could be "You have five letters in your mailbox". In +the context of a conversation about outstanding bills, the response could be, "You have +three overdue bills". The assistant needs to know the previous action to choose the next action. + +To create a context-aware conversational assistant, you need to define +how the conversation history affects the next response. + +For example, if a user asks the example concert bot how to get started, +the bot responds differently based on whether or not they like music: + +A conversation with a user who likes music: + +<Chat caption="User likes music"> +<ChatUserText>How can I get started ?</ChatUserText> +<ChatBotText>I can help you find concerts and venues. Do you like music?</ChatBotText> +<ChatUserText>yes</ChatUserText> +<ChatBotText>Awesome! You can ask me things like "Find me some concerts" or "What's a good venue"</ChatBotText> +</Chat> + +A conversation with a user who doesn't like music: + +<Chat caption="User doesn't like music"> +<ChatUserText>How can I get started ?</ChatUserText> +<ChatBotText>I can help you find concerts and venues. Do you like music?</ChatBotText> +<ChatUserText>no</ChatUserText> +<ChatBotText>Oh no!</ChatBotText> +</Chat> + +## Step-by-step Guide on Creating Contextual Conversation Patterns + +### 1. Defining Slots + +[Slots](domain.mdx#slots) are your assistant's memory. Slots store pieces of information that your +assistant needs to refer to later and can direct the flow of the conversation +based on `slot_was_set` events. There are different [types of slots](domain.mdx#slot-types), +and each affects the conversation flow in its own way. + +In the concert bot example, the `likes_music` slot is a boolean slot. If it is true, the bot sends an intro message. If it is false, the bot sends a different message. +You define a slot and its type in the domain: + +```yaml-rasa title="domain.yml" +slots: + likes_music: + type: bool + mappings: + - type: custom +``` + +### 2. Creating Stories + +[Stories](./stories.mdx) are examples of how conversations should go. +In the example above, the concert bot responds differently for users who like music +and users who don't because of these two stories: + +```yaml-rasa title="stories.yml" +stories: + - story: User likes music + steps: + - intent: how_to_get_started + - action: utter_get_started + - intent: affirm + - action: action_set_music_preference + - slot_was_set: + - likes_music: True + - action: utter_awesome + + - story: User doesn't like music + steps: + - intent: how_to_get_started + - action: utter_get_started + - intent: deny + - action: action_set_music_preference + - slot_was_set: + - likes_music: False + - action: utter_goodbye +``` + +These stories diverge based on the user's intent (`affirm` or `deny`). Based on +the user's intent, a custom action sets a slot that further directs +the conversation. + +### 3. Configuring the TEDPolicy + +In addition to adding stories to account for context, +machine learning policies can help your model generalize +to unseen conversation paths. **It is important +to understand that using machine-learning policies does not mean letting go of control over your +assistant.** If a rule-based policy is able to make a prediction, that prediction will +always have a higher [policy priority](./policies.mdx#policy-priority) and will predict the next action. + +The [TEDPolicy](https://blog.rasa.com/unpacking-the-ted-policy-in-rasa-open-source/) is made to handle +unexpected user behaviors. For example, +in the conversation below (extracted from a conversation on [Rasa X/Enterprise](https://rasa.com/docs/rasa-enterprise/user-guide/review-conversations/)): + +```yaml-rasa +stories: +- story: Story from conversation with a2baab6c83054bfaa8d598459c659d2a on November 28th 2019 + steps: + - checkpoint: STORY_START + - intent: greet + - action: action_greet_user + - slot_was_set: + - shown_privacy: true + - intent: ask_whoisit + - action: action_chitchat + - intent: ask_whatspossible + - action: action_chitchat + - intent: telljoke + - action: action_chitchat + - intent: how_to_get_started + entities: + - product: enterprise + - slot_was_set: + - product: enterprise + - action: utter_explain_enterprise + - action: utter_also_explain_nlucore + - intent: affirm + - action: utter_explain_nlu + - action: utter_explain_core + - action: utter_direct_to_step2 +``` + +Here we can see the user has completed a few chitchat tasks first, and then ultimately +asks how they can get started with Rasa X/Enterprise. The TEDPolicy correctly predicts that +Rasa X/Enterprise should be explained to the user, and then also takes them down the getting started +path, without asking all the qualifying questions first. + +Since the machine-learning policy has generalized to this situation, you should add this story +to your training data to continuously improve your bot and help the model generalize +better in future. [Rasa X/Enterprise](https://rasa.com/docs/rasa-enterprise/) is a tool that can help +you improve your bot and make it more contextual. + + +#### Setting `max_history` + +Usually, only a certain amount of context is relevant to your assistant. +[`max_history`](policies.mdx#max-history) is a hyperparameter for Rasa dialogue management policies +that controls how many steps in a dialogue the model looks at to decide which +action to take next. + +In the story below, the user asks for help three times in a row. The first two times, +the bot sends the same message, but the third time, it hands them off to a human + +```yaml-rasa title="stories.yml" +stories: + - story: user persists in asking for help + steps: + - intent: help + - action: utter_help + - intent: help + - action: utter_help + - intent: help + - action: action_human_handoff +``` + +In order for the model to learn this pattern, it needs to know at least the previous +four steps i.e. `max_history` of four. If `max_history` were 3, the model would not have +enough context to see that the user had already sent two help requests, and would never +predict the human handoff action. + + +You can set the `max_history` by passing it to your policy's settings +in your config file, for example: + +```yaml-rasa title="config.yml" +policies: + - name: "TEDPolicy" + max_history: 5 +``` + +You want to make sure `max_history` is set high enough +to account for the most context your assistant will need to make an accurate +prediction about what to do next. +For more details see the docs on [featurizers](policies.mdx#featurizers). + +## Summary + +Here's a summary of the concepts you can apply to enable your assistant to have contextual conversations: + +- [ ] Write stories for contextual conversations +- [ ] Use slots to store contextual information for later use +- [ ] Set the `max_history` for your policies appropriately for the amount of context your bot needs +- [ ] Use the TEDPolicy for generalization to unseen conversation paths diff --git a/docs/docs/conversation-driven-development.mdx b/docs/docs/conversation-driven-development.mdx new file mode 100644 index 0000000..55e37f4 --- /dev/null +++ b/docs/docs/conversation-driven-development.mdx @@ -0,0 +1,95 @@ +--- +id: conversation-driven-development +sidebar_label: Conversation-Driven Development +title: Conversation-Driven Development +description: Find out about best practices for conversational AI using Conversation-Driven Development. +--- + +## What is CDD? + +Conversation-Driven Development (CDD) is the process of listening to your users and using those insights to improve your AI assistant. It is the overarching best practice approach for chatbot development. + +Developing great AI assistants is challenging because users will always say something you didn't anticipate. The principle behind CDD is that in every conversation users are telling you—in their own words—exactly what they want. By practicing CDD at every stage of bot development, you orient your assistant towards real user language and behavior. + +CDD includes the following actions: + +* **Share** your assistant with users as soon as possible +* **Review** conversations on a regular basis +* **Annotate** messages and use them as NLU training data +* **Test** that your assistant always behaves as you expect +* **Track** when your assistant fails and measure its performance over time +* **Fix** how your assistant handles unsuccessful conversations + +CDD is not a linear process; you'll circle back to the same actions over and over as you develop and improve your bot. + +Read more about these actions and the concept of CDD on the [Rasa Blog](https://blog.rasa.com/conversation-driven-development-a-better-approach-to-building-ai-assistants/). + +You can also check out [Rasa X/Enterprise](https://rasa.com/docs/rasa-enterprise/), a purpose-built tool for CDD. + +## CDD in early stages of development + +If you're at the earliest stage of bot development, it might seem like CDD has no role to play - after all, you have no conversations yet! However, there are CDD actions you can take at the very beginning of bot development: + +1. See the best practices for [NLU data](generating-nlu-data.mdx) and [Stories](writing-stories.mdx) for details on creating training data with CDD in mind. +2. Give your bot to test users early on. + + CDD is all about listening to your users, so the earlier you find some, the better. + + Test users can be anyone who doesn't already know how your bot works from the inside. People on the bot development team should not be test users, since they know exactly what the bot can and can't do. Don't overinstruct your test users; they should have only as much knowledge of the bot's domain as your end users will have. + +3. Set up a CI/CD pipeline. + + CDD leads to frequent, smaller updates to your bot as you gather insights from bot conversations. [Setting up a CI/CD pipeline](setting-up-ci-cd.mdx) early on in development will enable you to act quickly on what you see in conversations. + + +## CDD with a bot in production + +Once your bot is in production, you'll have more conversations to gain insights from. Then you can fully apply CDD actions. +At this stage, you can [install Rasa X/Enterprise on a server](https://rasa.com/docs/rasa-enterprise/installation-and-setup/installation-guide/#helm-chart) +to both deploy your bot and enable CDD with a bot in production. + +### Review + +Look in conversations for what users are really asking for. + +Your test users had at least some instruction about what the bot was intended to do; real users often either have no idea, or ignore instructions given to them. You can't cater to every unexpected user behavior, but you can try to address the main friction points you notice. Here are some things you could consider looking for: + +* Look at conversations where an “out_of_scope” intent or fallback behavior occurred. These could indicate a potential new skill, or just a misclassified user utterance. +* Look for user frustration, such as requests for transfer to a human. +* If the assistant was trained with [`UnexpecTEDIntentPolicy`](./policies.mdx#unexpected-intent-policy) included in the pipeline, + you can look for conversations where `action_unlikely_intent` is predicted at any conversation turn. + An `action_unlikely_intent` is predicted when the last intent expressed by the user is + unexpected in the current conversation context. You can also filter out such conversations by + running a [standalone script](https://gist.github.com/alwx/b426b7b573ff963c85c65ea6466528d7) which does the following: + + - Fetch real conversations from a tracker store. + - Run `rasa test` on the fetched conversations and filter conversations containing `action_unlikely_intent` + in a separate warnings file. You can read more on [how to interpret these warnings](./testing-your-assistant.mdx#interpreting-the-generated-warnings). + + Reviewing this subset of conversations can help you understand if real users have taken a + conversation path which is not present in the training data and hence "surprising" + for machine learning policies like `TEDPolicy`. Adding these conversation paths (with potential + corrections if `TEDPolicy` subsequently failed) as training stories will result in more robust action prediction + by policies such as `TEDPolicy`. Users are encouraged to [adjust the `tolerance` parameter of + `UnexpecTEDIntentPolicy`](./policies.mdx#tuning-the-tolerance-parameter) to control how + "surprising" a conversation should be to be included in the warnings file. + +### Annotate + +Continue to follow [best practices for NLU](generating-nlu-data.mdx) as you add new user utterances from real conversations to your training data. Be careful not to overfit your NLU model to utterances like those already in your training data. This can happen when you continuously add user utterances that were already predicted correctly and with high confidence to your training data. To avoid overfitting and help your model generalize to more diverse user utterances, add only user utterances that the model previously predicted incorrectly or with low confidence. + +### Test + +Add successful user conversations to your [test conversations](testing-your-assistant.mdx). Doing this consistently will help ensure you don't introduce regressions as you make other fixes to your bot. + +### Track + +Look for clues to success and failure to help you track your bot's performance. + +Some metrics are external to your bot. For example, if you are building a bot to relieve demand on a customer service call center, one metric for success could be the reduction in traffic to the call center. Others you can get directly from conversations, such as whether a user reaches a certain action that represents achieving the user goal. + +Automatically tracked metrics are by nature proxy metrics; the only way to get a true measure of success would be to individually review and rate every single conversation with your bot. While this clearly isn't realistic, just keep in mind that no metric is a perfect representation of your bot's performance, so don't rely only on metrics to see where your bot needs improvement. + +### Fix + +Continue to follow [best practices for Stories](writing-stories.mdx) as you expand and improve your bot's skills. Let user demand guide which skills you add and which fixes you make. Make smaller changes frequently rather than making big changes only once in a while. This will help you gauge the effectiveness of changes you're making, since you'll get user feedback more frequently. Your [CI/CD pipeline](setting-up-ci-cd.mdx) should allow you to do so with confidence. diff --git a/docs/docs/custom-actions.mdx b/docs/docs/custom-actions.mdx new file mode 100644 index 0000000..810b4a7 --- /dev/null +++ b/docs/docs/custom-actions.mdx @@ -0,0 +1,53 @@ +--- +id: custom-actions +sidebar_label: Custom Actions +title: Custom Actions +abstract: A custom action can run any code you want, including API calls, database queries etc. They can turn on the lights, add an event to a calendar, check a user's bank balance, or anything else you can imagine. +--- + +For details on how to implement a custom action, see the [SDK documentation](./action-server/running-action-server.mdx). +Any custom action that you want to use in your stories should be added into the +actions section of your [domain](./domain.mdx). + +When the dialogue engine predicts a custom action to be executed, it will call +the action server, with the following information: + +```json +{ + "next_action": "string", + "sender_id": "string", + "tracker": { + "conversation_id": "default", + "slots": {}, + "latest_message": {}, + "latest_event_time": 1537645578.314389, + "followup_action": "string", + "paused": false, + "events": [], + "latest_input_channel": "rest", + "active_loop": {}, + "latest_action": {} + }, + "domain": { + "config": {}, + "session_config": {}, + "intents": [], + "entities": [], + "slots": {}, + "responses": {}, + "actions": [], + "forms": {}, + "e2e_actions": [] + }, + "version": "version" +} +``` + +Your action server should respond with a list of events and responses: + +```json +{ + "events": [{}], + "responses": [{}] +} +``` diff --git a/docs/docs/custom-graph-components.mdx b/docs/docs/custom-graph-components.mdx new file mode 100644 index 0000000..79a7eed --- /dev/null +++ b/docs/docs/custom-graph-components.mdx @@ -0,0 +1,521 @@ +--- +id: custom-graph-components +sidebar_label: Custom Graph Components +title: Custom Graph Components +abstract: You can extend Rasa with custom NLU components and policies. This page provides a guide on how to develop your own custom graph components. +--- +import useBaseUrl from '@docusaurus/useBaseUrl'; + +Rasa provides a variety of [NLU components](components.mdx) and +[policies](policies.mdx) out of the box. You can customize them or create your +own components from scratch by using custom graph components. + +To use your custom graph component with Rasa it has to fulfill the following +requirements: +- It has to implement the [`GraphComponent` interface](#the-graphcomponent-interface) +- It has to be [registered](#registering-graph-components-with-the-model-configuration) + with the used model configuration +- It has to be used in the [configuration file](#using-custom-components-in-your-model-configuration) +- It has to use type annotations. Rasa makes use of the type annotations + to validate your model configuration. + [Forward references](https://www.python.org/dev/peps/pep-0484/#forward-references) + are not allowed. If you're using Python 3.7 you can use + [`from __future__ import annotations`](https://www.python.org/dev/peps/pep-0563/#enabling-the-future-behavior-in-python-3-7) + to get rid of forward references. + + +## Graph Components + +Rasa uses the passed in [model configuration](model-configuration.mdx) to build a +[directed acyclic graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph). +This graph describes the dependencies between the items in your model configuration and +how data flows between them. This has two major benefits: + +- Rasa can use the computational graph to optimize the execution of your + model. Examples for this are efficient caching of training steps or executing + independent steps in parallel. +- Rasa can represent different model architectures flexibly. As long as the + graph remains acyclic Rasa can in theory pass any data to any graph + component based on the model configuration without having to tie the underlying + software architecture to the used model architecture. + +When translating the model configuration to the computational graph +[policies](policies.mdx) and [NLU components](components.mdx) become nodes within this graph. +While there is a distinction between policies and NLU components in your model +configuration, the distinction is abstracted away when they are placed within the graph. +At this point policies and NLU components become abstract *graph components*. +In practice this is represented by the +[`GraphComponent`](custom-graph-components.mdx#the-graphcomponent-interface) +interface: Both policies and NLU components have to inherit from this interface to +become compatible and executable for Rasa's graph. + +<div align="center"> + <img alt="Visualization of the Rasa Graph Architecture" src={useBaseUrl("/img/graph_architecture.png")} width="100%" /> +</div> + +## Getting Started +Before you get started, you have to decide whether you want to implement a custom +[NLU component](components.mdx) or a [policy](policies.mdx). If you are implementing +a custom policy, then we recommend extending the existing +`rasa.core.policies.policy.Policy` class which already implements the `GraphComponent` +interface. + +```python +from rasa.core.policies.policy import Policy +from rasa.engine.recipes.default_recipe import DefaultV1Recipe + +# TODO: Correctly register your graph component +@DefaultV1Recipe.register( + [DefaultV1Recipe.ComponentType.POLICY_WITHOUT_END_TO_END_SUPPORT], is_trainable=True +) +class MyPolicy(Policy): + ... +``` + +If you want to implement a custom NLU component then start out with the following +skeleton: + +```python (docs/sources/data/test_classes/nlu_component_skeleton.py) +``` + + +Read the following sections to find out how to solve the `TODO`s in the example above + and what other methods need to be implemented in your custom component. + +:::note custom tokenizers +If you create a custom tokenizer, you should extend the +`rasa.nlu.tokenizers.tokenizer.Tokenizer` class. The `train` and `process` methods are +already implemented so you only need to overwrite the `tokenize` method. + +::: + + + +## The `GraphComponent` interface +To run your custom NLU component or policy with Rasa it must implement the `GraphComponent` interface. + +```python (docs/sources/data/test_classes/graph_component_interface.py) +``` + +### `create` +The `create` method is used to instantiate your graph component during training and has to be +overridden. Rasa passes the following parameters when calling the method: + +- `config`: This is your component's default configuration merged with the + configuration provided to the graph component in the model configuration file. +- `model_storage`: You can use this to persist and load your graph component. See the + [model persistence](#model-persistence) section for further details on its usage. +- `resource`: The unique identifier of your component within the `model_storage`. + See the [model persistence](#model-persistence) section for further + details on its usage. +- `execution_context`: This provides additional information about the current + mode of execution: + - `model_id`: A unique identifier for the model used during inference. This + parameter is `None` during training. + - `should_add_diagnostic_data`: If `True` then additional diagnostic metadata + should be added to your graph component's predictions on top of the actual prediction. + - `is_finetuning`: If `True` then the graph component can be trained using + [finetuning](command-line-interface.mdx#incremental-training). + - `graph_schema`: The `graph_schema` describes the computational graph which is used + to train your assistant or to make predictions with it. + - `node_name`: The `node_name` is a unique identifier for the step in the graph + schema which is fulfilled by the called graph component + +### `load` +The `load` method is used to instantiate your graph component during inference. The default +implementation of this method calls your `create` method. It is recommended to override +this if your graph component +[persists data as part of the training](#model-persistence). +See [`create`](#create) for a description of the individual parameters. + +### `get_default_config` +The method `get_default_config` returns the default configuration for your graph +component. Its default implementation returns an empty dictionary which implies that +the graph component +does not have any configuration. Rasa will update the default configuration with the given +in the configuration file at runtime. + +### `supported_languages` +The method `supported_languages` specifies which +[languages](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) +a graph component supports. +Rasa will use the `language` key in the model configuration file to +validate that the graph component is valid for usage with the specified language. +If a graph component returns `None` (this is the default implementation), it indicates +that the graph component supports all languages which are not part of +`not_supported_languages`. + + Examples: + - `[]`: The graph component does not support any language + - `None`: All languages are supported expect the languages defined in + `not_supported_languages` + - `["en"]`: The graph component can only be used with English conversations. + +### `not_supported_languages` +The method `not_supported_languages` specifies which [languages](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) your +graph component does not support. Rasa will use the `language` key in the +model configuration file to validate that your graph component is valid for usage with +the specified language. If your graph component returns `None` (this is the default +implementation), you indicate that it supports all languages which are specified in +`supported_languages`. + + Examples: + - `None` or `[]`: All languages specified in `supported_languages` are supported. + - `["en"]`: The graph component can be used with any language except English. + +### `required_packages` +The `required_packages` method indicates which extra Python packages need to +be installed to use this graph component. Rasa will raise an error +during execution if the required libraries are not found at runtime. By default, this method returns an empty list which implies that your graph +component does not have any extra dependencies. + +Examples: +- `[]`: No extra packages are required to use this graph component +- `["spacy"]`: The Python package `spacy` needs to be installed to use + this graph component. + +## Model Persistence +Some graph components require persisting data during training which should be available +to the graph component at inference time. A typical use case is storing model +weights. Rasa provides the +`model_storage` and `resource` parameters to your graph component's `create` and `load` +method for this purpose as shown in the snippet below. The `model_storage` provides +access to data from all graph components. The `resource` allows you to uniquely identify +your graph component's location in the model storage. + + +```python {14,15,24,25} +from __future__ import annotations + +from typing import Any, Dict, Text + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage + +class MyComponent(GraphComponent): + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> MyComponent: + ... + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any + ) -> MyComponent: + ... +``` + +### Writing to the Model Storage + +The snippet below illustrates how to write your graph component's data to the model +storage. +To persist your graph component after training, the `train` method will need to access +to the values of `model_storage` and `resource`. Therefore, you should store the values +of `model_storage` and `resource` at initialization time. + +Your graph component's train method must return the value of `resource` so that Rasa can cache +the training results between trainings. +The `self._model_storage.write_to(self._resource)` context manager provides a path to +a directory where you can persist any data required by your +graph component. + +```python +from __future__ import annotations +import json +from typing import Optional, Dict, Any, Text + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.nlu.training_data.training_data import TrainingData + +class MyComponent(GraphComponent): + + def __init__( + self, + model_storage: ModelStorage, + resource: Resource, + training_artifact: Optional[Dict], + ) -> None: + # Store both `model_storage` and `resource` as object attributes to be able + # to utilize them at the end of the training + self._model_storage = model_storage + self._resource = resource + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> MyComponent: + return cls(model_storage, resource, training_artifact=None) + + def train(self, training_data: TrainingData) -> Resource: + # Train your graph component + ... + + # Persist your graph component + with self._model_storage.write_to(self._resource) as directory_path: + with open(directory_path / "artifact.json", "w") as file: + json.dump({"my": "training artifact"}, file) + + # Return resource to make sure the training artifacts + # can be cached. + return self._resource + +``` + +### Reading from the Model Storage + +Rasa will call the `load` method of your graph component to instantiate it for inference. You can use the context manager `self._model_storage.read_from(resource)` to get a path to the directory where your graph component's data was persisted. Using the provided path you can then load the +persisted data and initialize your graph component with it. Note that the `model_storage` +will throw a `ValueError` in case no persisted data was found for the given `resource`. + +```python +from __future__ import annotations +import json +from typing import Optional, Dict, Any, Text + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage + +class MyComponent(GraphComponent): + + def __init__( + self, + model_storage: ModelStorage, + resource: Resource, + training_artifact: Optional[Dict], + ) -> None: + self._model_storage = model_storage + self._resource = resource + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> MyComponent: + try: + with model_storage.read_from(resource) as directory_path: + with open(directory_path / "artifact.json", "r") as file: + training_artifact = json.load(file) + return cls( + model_storage, resource, training_artifact=training_artifact + ) + except ValueError: + # This allows you to handle the case if there was no + # persisted data for your component + ... +``` + + +## Registering Graph Components with the Model Configuration +To make your graph component available to Rasa you may have to register your +graph component with a recipe. Rasa uses recipes to translate the content +of your model configuration to executable +[graphs](custom-graph-components.mdx#graph-components). +Currently, Rasa supports the `default.v1` and the experimental `graph.v1` recipes. +For `default.v1` recipe, you need to register your graph component by using the `DefaultV1Recipe.register` +decorator: + +```python (docs/sources/data/test_classes/registered_component.py) +``` + +Rasa uses the information provided in the `register` decorator and the +position of your graph component within the configuration file to schedule the execution +of your graph component with its required data. The `DefaultV1Recipe.register` decorator allows you +to specify the following details: + +- `component_types`: This specifies what purpose your graph component fulfills within the + assistant. It is possible to specify multiple types (e.g. if your graph component is both + intent classifier and entity extractor): + * `ComponentType.MODEL_LOADER`: Component type for + [language models](components.mdx#language-models). Graph components of this type provide + pretrained models to other graph components' `train`, `process_training_data` and + `process` methods if they have specified `model_from=<model loader name>`. + This graph component is run during training and + inference. Rasa will use the graph component's `provide` method to + retrieve the model which should be provided to dependent graph components. + + * `ComponentType.MESSAGE_TOKENIZER`: Component type for + [tokenizers](components.mdx#tokenizers). This graph component is run during training and + inference. Rasa will use the graph component's `train` method if + `is_trainable=True` is specified. Rasa will use + `process_training_data` for tokenizing training data examples and `process` + to tokenize messages during inference. + + * `ComponentType.MESSAGE_FEATURIZER`: Component type for + [featurizers](components.mdx#featurizers). This graph component is run during training and + inference. Rasa will use the graph component's `train` method if + `is_trainable=True` is specified. Rasa will use + `process_training_data` for featurizing training data examples and `process` + to featurize messages during inference. + + * `ComponentType.INTENT_CLASSIFIER`: Component type for + [intent classifiers](components.mdx#intent-classifiers). This graph component is run only + during training if `is_trainable=True`. The graph component is always run during + inference. + Rasa will use the graph component's `train` method if + `is_trainable=True` is specified. Rasa will use + the graph component's `process` method to classify the intent of messages during + inference. + + * `ComponentType.ENTITY_EXTRACTOR`: Component type for + [entity extractors](components.mdx#entity-extractors). This graph component is run only + during training if `is_trainable=True`. The graph component is always run during + inference. + Rasa will use the graph component's `train` method if + `is_trainable=True` is specified. Rasa will use + the graph component's `process` method to extract entities during inference. + + * `ComponentType.POLICY_WITHOUT_END_TO_END_SUPPORT`: Component type for + [policies](policies.mdx) which don't require additional end-to-end features + (see [end-to-end training](stories.mdx#end-to-end-training) for more information). + This graph component is run only during training if `is_trainable=True`. + The graph component is always run during inference. + Rasa will use the graph component's `train` method if + `is_trainable=True` is specified. Rasa will use + the graph component's `predict_action_probabilities` to make predictions for the + next action which should be run within a conversation. + + * `ComponentType.POLICY_WITH_END_TO_END_SUPPORT`: Component type for + [policies](policies.mdx) which require additional end-to-end features + (see [end-to-end training](stories.mdx#end-to-end-training) for more information). + The end-to-end features are passed into the graph component's `train` and + `predict_action_probabilities` as `precomputations` parameter. + This graph component is run only during training if `is_trainable=True`. + The graph component is always run during inference. + Rasa will use the graph component's `train` method if + `is_trainable=True` is specified. Rasa will use + the graph component's `predict_action_probabilities` to make predictions for the + next action which should be run within a conversation. + +- `is_trainable`: Specifies if the graph component is required to train itself before it can + process training data for other dependent graph components or before it can make + predictions + +- `model_from`: Specifies if a pretrained + [language model](components.mdx#language-models) needs to be provided to the `train`, + `process_training_data` and `process` methods of the graph component. These methods + have to support the parameter `model` to receive the language model. Note that you + still need to make sure that the graph component which provides this model is part + of your model configuration. A common use case for this is if you want to expose the + [SpacyNLP](components.mdx#spacynlp) language model to your other NLU components. + + +## Using Custom Components in your Model Configuration +You can use custom graph components like any other NLU component or policy within your +[model configuration](model-configuration.mdx). The only change is that you have to specify +the full module name instead of the class name only. The full module name depends on +your module's location in relation to the specified +[PYTHONPATH](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONPATH). +By default, Rasa adds the directory from where you run the CLI to the +`PYTHONPATH`. If you e.g. run the CLI from `/Users/<user>/my-rasa-project` +and your module `MyComponent` is in `/Users/<user>/my-rasa-project/custom_components/my_component.py` +then the module path is `custom_components.my_component.MyComponent`. Everything except +the `name` entry will be passed as `config` to your component. + +```yaml-rasa {5-6,10} title="config.yml" +recipe: default.v1 +language: en +pipeline: +# other NLU components +- name: your.custom.NLUComponent + setting_a: 0.01 + setting_b: string_value + +policies: +# other dialogue policies +- name: your.custom.Policy +``` + +## Implementation Hints + +### Message Metadata +When you [define metadata for your intent examples in your training data](./training-data-format.mdx#training-examples), +your NLU component can access both the intent metadata and the intent example metadata during processing: + +```python +# in your component class + + def process(self, message: Message, **kwargs: Any) -> None: + metadata = message.get("metadata") + print(metadata.get("intent")) + print(metadata.get("example")) +``` + +### Sparse and Dense Message Features +If you create a custom message featurizer, you can return two different kind of +features: sequence features and sentence +features. The sequence features are a matrix of size `(number-of-tokens x +feature-dimension)`, i.e. +the matrix contains a feature vector for every token in the sequence. +The sentence features are represented by a matrix of size `(1 x feature-dimension)`. + +## Examples of Custom Components + +### Dense Message Featurizer + +The following is the example of a dense [message featurizer](components.mdx#featurizers) +which uses a pretrained model: + +```python (docs/sources/data/test_classes/custom_graph_components/nlu_dense.py) +``` + +### Sparse Message Featurizer + +The following is the example of a dense [message featurizer](components.mdx#featurizers) +which trains a new model: + +```python (docs/sources/data/test_classes/custom_graph_components/nlu_sparse.py) +``` + +### NLU Meta Learners +:::info Advanced use case + +NLU Meta learners are an advanced use case. The following section is only relevant +if you have a component that learns parameters based on the output of previous +classifiers. For components that have manually set parameters or logic, you can create +a component with `is_trainable=False` and not worry about the preceding classifiers. + +::: + +NLU Meta learners are intent classifiers or entity extractors that use the predictions +of other trained intent classifiers or entity extractors and try to improve upon their +results. An example for a meta learner would be a component that averages the output +of two previous intent classifiers or a fallback classifier that sets it's threshold +according to the confidence of the intent classifier on training examples. + +Conceptually, to built a trainable fallback classifier you first need to create that +fallback classifier as a custom component: + +```python (docs/sources/data/test_classes/custom_graph_components/nlu_meta_fallback.py) +``` + +Next, you will need to create a custom intent classifier that is also a featurizer, +as the classifiers' output needs to be consumed by another component downstream. +For the custom intent classifier component you also need to define how its predictions +should be added to the message data specifying the `process_training_data` method. +Make sure to not overwrite the true labels for the intents. Here's a template that +shows how to subclass DIET for this purpose: + +```python (docs/sources/data/test_classes/custom_graph_components/nlu_meta_intent_featurizer.py) +``` diff --git a/docs/docs/default-actions.mdx b/docs/docs/default-actions.mdx new file mode 100644 index 0000000..837254d --- /dev/null +++ b/docs/docs/default-actions.mdx @@ -0,0 +1,259 @@ +--- +id: default-actions +sidebar_label: Default Actions +title: Default Actions +abstract: Default actions are actions that are built into the dialogue manager by default. Most of these are automatically predicted based on certain conversation situations. You may want to customize these to personalize your assistant. +--- + +Each of these actions have a default behavior, described in the sections below. +In order to overwrite this default behavior, write a [custom action](./custom-actions.mdx) +whose `name()` method returns the same name as the default action: + + ```python + class ActionRestart(Action): + + def name(self) -> Text: + return "action_restart" + + async def run( + self, dispatcher, tracker: Tracker, domain: Dict[Text, Any] + ) -> List[Dict[Text, Any]]: + + # custom behavior + + return [...] + ``` + +Add this action to the actions section of your domain file so your assistant knows +to use the custom definition instead of the default one: + + ```yaml-rasa + actions: + - action_restart + ``` + +:::caution +After adding this action to your domain file, re-train your model with +`rasa train --force`. Otherwise Rasa won't know you've changed anything +and may skip re-training your dialogue model. + +::: + +## action_listen +This action is predicted to signal that the assistant should do nothing and wait +for the next user input. + +## action_restart +This action resets the whole conversation history, including any slots that were +set during it. + +It can be triggered by the user in a conversation by sending a +"/restart" message, if the [RulePolicy](./rules.mdx) is included in the model configuration. +If you define an `utter_restart` response in your domain, this will be sent to the user as well. + +## action_session_start + +This action starts a new conversation session, and is executed in the following +situations: + * at the beginning of each new conversation + * after a user was inactive for a period defined by the `session_expiration_time` parameter in the domain's +[session configuration](./domain.mdx#session-configuration) + * when a user sends a "/session_start" message during a conversation + +The action will reset the conversation tracker, but by default will not clear any slots that were set. + +### Customization + +The default behavior of the session start action is to take all existing slots and to +carry them over into the next session. Let's say you do not want to carry over all +slots, but only a user's name and their phone number. To do that, you'd override the +`action_session_start` with a custom action that might look like this: + +```python +from typing import Any, Text, Dict, List +from rasa_sdk import Action, Tracker +from rasa_sdk.events import SlotSet, SessionStarted, ActionExecuted, EventType + + +class ActionSessionStart(Action): + def name(self) -> Text: + return "action_session_start" + + @staticmethod + def fetch_slots(tracker: Tracker) -> List[EventType]: + """Collect slots that contain the user's name and phone number.""" + + slots = [] + for key in ("name", "phone_number"): + value = tracker.get_slot(key) + if value is not None: + slots.append(SlotSet(key=key, value=value)) + return slots + + async def run( + self, dispatcher, tracker: Tracker, domain: Dict[Text, Any] + ) -> List[Dict[Text, Any]]: + + # the session should begin with a `session_started` event + events = [SessionStarted()] + + # any slots that should be carried over should come after the + # `session_started` event + events.extend(self.fetch_slots(tracker)) + + # an `action_listen` should be added at the end as a user message follows + events.append(ActionExecuted("action_listen")) + + return events +``` + +If you want to access the metadata which was sent with the user message which triggered +the session start, you can access the special slot `session_started_metadata`: + +```python +from typing import Any, Text, Dict, List +from rasa_sdk import Action, Tracker +from rasa_sdk.events import SessionStarted, ActionExecuted + + +class ActionSessionStart(Action): + def name(self) -> Text: + return "action_session_start" + + async def run( + self, dispatcher, tracker: Tracker, domain: Dict[Text, Any] + ) -> List[Dict[Text, Any]]: + metadata = tracker.get_slot("session_started_metadata") + + # Do something with the metadata + print(metadata) + + # the session should begin with a `session_started` event and an `action_listen` + # as a user message follows + return [SessionStarted(), ActionExecuted("action_listen")] +``` + +## action_default_fallback + +This action undoes the last user-bot interaction and sends the `utter_default` response if it is defined. +It is triggered by low action prediction confidence, if you have this [fallback mechanism](./fallback-handoff.mdx) enabled. + +## action_deactivate_loop + +This action deactivates the active loop and resets the requested slot. This is used when +[handling unhappy paths in forms](./forms.mdx#writing-stories--rules-for-unhappy-form-paths). + +:::note +If you wish to reset all slots, we recommend using a custom action +that returns the [`AllSlotsReset`](https://rasa.com/docs/rasa/reference/rasa/shared/core/events#allslotsreset-objects) event after form deactivation. +::: + + +## action_two_stage_fallback + +This is a fallback loop that can be used to handle low NLU confidence. Read more about +[handling low NLU confidence](./fallback-handoff.mdx#nlu-fallback). + +## action_default_ask_affirmation + +This action is used by the `action_two_stage_fallback` loop. It asks the user to confirm +the intent of their message. This action can be customized to be more personalized +to your specific use case. + +## action_default_ask_rephrase + +This action is used by the `action_two_stage_fallback` loop if the user denies the +intent `action_default_ask_affirmation` displays. It asks the user to rephrase +their message. + +## action_back + +This action undoes the last user-bot interaction. It can be triggered by the user +by sending a "/back" message to the assistant if the [RulePolicy](./policies.mdx#rule-policy) is configured. + | +## Form Action + +By default Rasa uses `FormAction` for processing any +[form logic](forms.mdx). You can override this default action with a custom action by +adding a custom action with the form's name to the domain. +Overriding the default action for forms should **only** be used during the process of +migrating from Rasa 1.0 to 2.0. + +## action_unlikely_intent + +Rasa triggers `action_unlikely_intent` via [`UnexpecTEDIntentPolicy`](./policies.mdx#unexpected-intent-policy). +You can control how often this action is predicted by tuning the [`tolerance`](./policies.mdx#unexpected-intent-policy) +parameter of `UnexpecTEDIntentPolicy`. + +### Customization + +You can customize your assistant's behaviour to configure what should happen once `action_unlikely_intent` +is triggered. For example, as a follow up you can trigger a hand-off to a human agent with a rule: + +```yaml +- rule: trigger human handoff with action_unlikely_intent + steps: + - action: action_unlikely_intent + - action: ask_human_handoff + - intent: affirm + - action: trigger_human_handoff +``` + +Alternatively, you can also override it's behaviour as a [`custom action`](./custom-actions.mdx) by +adding `action_unlikely_intent` to the list of actions in the domain and implementing the custom behaviour: + +```python +class ActionUnlikelyIntent(Action): + + def name(self) -> Text: + return "action_unlikely_intent" + + async def run( + self, dispatcher, tracker: Tracker, domain: Dict[Text, Any], + ) -> List[Dict[Text, Any]]: + + # Implement custom logic here + return [] +``` + +:::note +Since `action_unlikely_intent` can be triggered at any conversation step during inference, +all policies which are trained on only story data, for example - `TEDPolicy`, `UnexpecTEDIntentPolicy`, +`MemoizationPolicy` ignore its presence in the tracker when making a prediction. However, `RulePolicy` +takes its presence into account so that [conversation behaviour is customizable](./default-actions.mdx#customization-1). + +::: + +:::note +`action_unlikely_intent` cannot be included in the training stories. It can **only** be added to rules. + +::: + +## action_extract_slots + +This action runs after each user turn, before the next assistant action prediction and execution. +`action_extract_slots` loops through the [slot mappings](./domain.mdx#slot-mappings) of each domain slot in order to set or update +slots throughout the conversation with information extracted from the latest user message. + +If `action_extract_slots` finds a [custom slot mapping](./domain.mdx#custom-slot-mappings), it will check first if a custom action was defined in the +mapping via the `action` key and then run it. + +After applying all the slot mappings, `action_extract_slots` will run the custom validation action +`action_validate_slot_mappings` if it is present in the domain actions. Otherwise it will immediately return the already +extracted slots. + +Note that custom actions used by slot mappings or slot mapping validation should only return events of type `SlotSet` or +`BotUttered`. Events of any other type are not permitted and will be ignored when updating the tracker. + +The default action `action_extract_slots` replaces the slot extraction previously executed by `FormAction`. +If you wish to set a slot based on information extracted from intents that trigger forms, you must explicitly specify a +mapping that does not contain the `conditions` key. A slot mapping with `conditions` applies only once the specified form is active. +`action_extract_slots` runs directly after each user message, and thus before the activation of the form. +Therefore a mapping that should apply to user messages that trigger a form must not specify `conditions`, or the form +will re-ask for the slot once it is activated. + +:::note +If `action_default_fallback` is the next action predicted and executed by the assistant, this will result in a +`UserUtteranceReverted` event which will unset the slots previously filled in the last user turn. + +::: diff --git a/docs/docs/deploy/deploy-action-server.mdx b/docs/docs/deploy/deploy-action-server.mdx new file mode 100644 index 0000000..667fba5 --- /dev/null +++ b/docs/docs/deploy/deploy-action-server.mdx @@ -0,0 +1,380 @@ +--- +id: deploy-action-server +sidebar_label: Deploy Action Server +title: Deploy Rasa Action Server +abstract: This page explains how to build an Action Server Image and deploy a Rasa Action Server using Helm. The second of three steps in deploying your Rasa assistant. +--- +<!-- this file is version specific, do not use `@site/...` syntax --> +import variables from '../variables.json'; + +:::note +The [Rasa Action Server chart](https://github.com/RasaHQ/helm-charts/tree/main/charts/rasa-action-server) is open source and available in the +[helm-charts repository](https://github.com/rasahq/helm-charts). +Please +[create an issue](https://github.com/RasaHQ/helm-charts/issues/new) in this +repository if you discover bugs or have suggestions for improvements. + +::: + +In the sections below you can learn how to deploy Rasa Action Server by using helm, and how to connect an Action Server deployment to a Rasa deployment. + +## Installation Requirements + +1. Check that you have installed the Kubernetes or OpenShift command line + interface (CLI). You can check this using the following command: + + <Tabs groupId="kubernetes-dist" values={[{"label": "Kubernetes", "value": "kubernetes"}, {"label": "OpenShift", "value": "openshift"}]} defaultValue="kubernetes"> + <TabItem value="kubernetes"> + + ```bash + kubectl version --short --client + + # The output should be similar to this + # Client Version: v1.19.11 + ``` + + </TabItem> + <TabItem value="openshift"> + + ```bash + oc version --client + + # The output should be similar to this + # Client Version: 4.7.13 + ``` + + </TabItem> + </Tabs> + + If this command resulted in an error, please install the + [Kubernetes CLI](https://kubernetes.io/docs/tasks/tools/install-kubectl/) or the + [OpenShift CLI](https://docs.openshift.com/container-platform/4.7/cli_reference/openshift_cli/getting-started-cli.html#installing-openshift-cli) + depending on the cluster you’re using. + +2. Make sure that the Kubernetes / OpenShift CLI is correctly connected to + your cluster. You can do so by using the following commands: + + <Tabs groupId="kubernetes-dist" values={[{"label": "Kubernetes", "value": "kubernetes"}, {"label": "OpenShift", "value": "openshift"}]} defaultValue="kubernetes"> + <TabItem value="kubernetes"> + + ```bash + kubectl version --short + + # The output should be similar to this + # Client Version: v1.19.11 + # Server Version: v1.19.10 + ``` + + </TabItem> + <TabItem value="openshift"> + + ```bash + oc version + + # The output should be similar to this + # Client Version: 4.7.13 + # Kubernetes Version: v1.20.0+df9c838 + ``` + + </TabItem> + </Tabs> + + If you get an error when executing the command, you are not connected to your + cluster. To get the command to connect to the cluster please consult your cluster’s + admin or the documentation of your cloud provider. + +3. Make sure you have the [Helm CLI](https://helm.sh/docs/intro/install/) + installed. To check this, run: + + ```bash + helm version --short + + # The output should be similar to this + # v3.6.0+g7f2df64 + ``` + + If this command leads to an error, please install the + [Helm CLI](https://helm.sh/docs/intro/install/). + + In case you are using a version `<3.5` of Helm, please update to Helm version + `>=3.5`. + +## 1. Installation + +### a. Create Namespace + +We recommend installing Rasa Action Server in a separate +[namespace](https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/) +to avoid interfering with existing cluster deployments. To create a new namespace +run the following command: + +<Tabs groupId="kubernetes-dist" values={[{"label": "Kubernetes", "value": "kubernetes"}, {"label": "OpenShift", "value": "openshift"}]} defaultValue="kubernetes"> + <TabItem value="kubernetes"> + + ```bash + kubectl create namespace <your namespace> + ``` + + </TabItem> + <TabItem value="openshift"> + + ```bash + oc create namespace <your namespace> + ``` + + </TabItem> +</Tabs> + +### b. Deploy Rasa Action Server + +Run the following commands: + +```bash +# Add the repository which contains the Rasa Action Server Helm chart +helm repo add rasa https://helm.rasa.com + +# Deploy Rasa Action Server +helm install \ + --namespace <your namespace> \ + <release name> \ + rasa/rasa-action-server +``` + + +### c. Access Rasa Action Server + +By default the Rasa Action Server deployment is exposed via the `rasa-action-server` (`<release name>`) service and accessible only within a Kubernetes cluster. You can get +the IP address using this command: + +<Tabs groupId="kubernetes-dist" values={[{"label": "Kubernetes", "value": "kubernetes"}, {"label": "OpenShift", "value": "openshift"}]} defaultValue="kubernetes"> + <TabItem value="kubernetes"> + + ```bash + export SERVICE_PORT=$(kubectl get --namespace <your namespace> -o jsonpath="{.spec.ports[0].port}" services <release name>) + kubectl port-forward --namespace <your namespace> svc/<release name> ${SERVICE_PORT}:${SERVICE_PORT} & + ``` + + </TabItem> + <TabItem value="openshift"> + + ```bash + export SERVICE_PORT=$(oc get --namespace <your namespace> -o jsonpath="{.spec.ports[0].port}" services <release name>) + oc port-forward --namespace <your namespace> svc/<release name> ${SERVICE_PORT}:${SERVICE_PORT} & + ``` + + </TabItem> +</Tabs> + +You can then access the deployment on `http://127.0.0.1:${SERVICE_PORT}` + +:::note +The Rasa Action Server Helm chart uses the [rasa-x-demo](https://github.com/RasaHQ/rasa-x-demo) Docker image as default. In the [Building an Action Server Image](#building-an-action-server-image) +you can learn how to build and use your custom Action Server image. +::: + +## Building an Action Server Image + +If you build an image that includes your action code and store it in a container registry, you can run it +as part of your deployment, without having to move code between servers. +In addition, you can add any additional dependencies of systems or Python libraries +that are part of your action code but not included in the base `rasa/rasa-sdk` image. + +### Automating your Action Server Image Builds + +In addition to a manually creating a new Action Server image, you can use the [Rasa Action Server GitHub Action](https://github.com/RasaHQ/action-server-gha) to automate image builds. +If GitHub Actions are new to you, it might be helpful to get familiar with [GitHub Actions Documentation](https://docs.github.com/en/actions). + +The following steps assume that you already created a GitHub repository and you have a DockerHub account. + +To create a workflow for building and pushing a Docker image into a DockerHub registry: + +1. Add GitHub Secrets with your DockerHub login name and password. + You can find details on how to create encrypted secrets for a repository + in the [Github docs](https://docs.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets#creating-encrypted-secrets-for-a-repository) + + The example uses the following secrets: + - `DOCKER_HUB_LOGIN` - a login name for DockerHub + - `DOCKER_HUB_PASSWORD` - a password for DockerHub + +2. In your GitHub repository create a file [`.github/workflows/action_server.yml`](https://github.com/RasaHQ/action-server-gha/blob/master/examples/action_server.yml). + +The GitHub Action workflow below builds a new docker image every time files inside the `actions/` directory have changed and the changes are pushed into the `main` branch. + +```yaml +on: + push: + branches: + - main + paths: + - 'actions/**' + +jobs: + build_and_deploy: + runs-on: ubuntu-latest + name: Build Action Server image and upgrade Rasa X/Enterprise deployment + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - id: action_server + name: Build an action server with custom actions + uses: RasaHQ/action-server-gha@main + # Full list of parameters: https://github.com/RasaHQ/action-server-gha/tree/master#input-arguments + with: + docker_image_name: 'account_username/repository_name' + docker_registry_login: ${{ secrets.DOCKER_HUB_LOGIN }} + docker_registry_password: ${{ secrets.DOCKER_HUB_PASSWORD }} + # More details about github context: + # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#github-context + # + # github.sha - The commit SHA that triggered the workflow run + docker_image_tag: ${{ github.sha }} +``` + +3. Push your changes to the `main` branch. After changes are pushed, the workflow will build and push a new image into the DockerHub registry. + +4. Now, you can use your new brand docker image. + +5. You can also extend your workflow, so that you do not have to manually update your Rasa X/Enterprise deployment. The example below shows how to extend your workflow with an additional step that updates a Rasa X/Enterprise Helm Chart deployment. + +```yaml +on: + push: + branches: + - main + +jobs: + build_and_deploy: + runs-on: ubuntu-latest + name: Build Action Server image and upgrade Rasa X/Enterprise deployment + steps: + [..] + + # This step shows only the example of output parameter usage + # and it's not focused on deployment itself. + - name: "Upgrade a Rasa Action Server deployment" + run: | + helm upgrade --install --reuse-values \ + --set image.name=${{ steps.action_server.outputs.docker_image_name }} \ + --set image.tag=${{ steps.action_server.outputs.docker_image_tag }} rasa-action-server rasa/rasa-action-server +``` + +As you can see it's possible to use output variables from the `action_server` step. The `steps.action_server.outputs.docker_image_name` variable returns +a Docker image name and the `steps.action_server.outputs.docker_image_tag` variable returns a Docker image tag. + +More examples on how to use and customize [Rasa GitHub Actions](https://github.com/RasaHQ/action-server-gha) you can find in the [Rasa GitHub Actions](https://github.com/RasaHQ/action-server-gha) repository. + +#### Manually Building an Action Server + +To create your image: + +1. Make sure your actions are defined in `actions/actions.py`. The `rasa/rasa-sdk` + image will automatically look for the actions in this file. + +2. If your actions have any extra dependencies, create a list of them in a file, + `actions/requirements-actions.txt`. + +3. Create a file named `Dockerfile` in your project directory, + in which you'll extend the official SDK image, copy over your code, and add any custom dependencies (if necessary). + For example: + + <pre><code parentName="pre" className="language-python"> + {`# Extend the official Rasa SDK image + FROM rasa/rasa-sdk:${variables.rasa_sdk_version} + + # Use subdirectory as working directory + WORKDIR /app + + # Copy any additional custom requirements, if necessary (uncomment next line) + # COPY actions/requirements-actions.txt ./ + + # Change back to root user to install dependencies + USER root + + # Install extra requirements for actions code, if necessary (uncomment next line) + # RUN pip install -r requirements-actions.txt + + # Copy actions folder to working directory + COPY ./actions /app/actions + + # By best practices, don't run the code with root user + USER 1001`}</code></pre> + +You can then build the image via the following command: + +```bash +docker build . -t <account_username>/<repository_name>:<custom_image_tag> +``` + +The `<custom_image_tag>` should reference how this image will be different from others. For +example, you could version or date your tags, as well as create different tags that have different code for production +and development servers. You should create a new tag any time you update your code and want to re-deploy it. + +### Using your Custom Action Server Image + +If you're building this image to make it available from another server, you should push the image to a cloud repository. + +This documentation assumes you are pushing your images to [DockerHub](https://hub.docker.com/). +DockerHub will let you host multiple public repositories and +one private repository for free. Be sure to first [create an account](https://hub.docker.com/signup/) +and [create a repository](https://hub.docker.com/signup/) to store your images. You could also push images to +a different Docker registry, such as [Google Container Registry](https://cloud.google.com/container-registry), +[Amazon Elastic Container Registry](https://aws.amazon.com/ecr/), or +[Azure Container Registry](https://azure.microsoft.com/en-us/services/container-registry/). + +You can push the image to DockerHub via: + +```bash +docker login --username <account_username> --password <account_password> +docker push <account_username>/<repository_name>:<custom_image_tag> +``` + +To authenticate and push images to a different container registry, please refer to the documentation of +your chosen container registry. + + +## Setting a Custom Action Server Image + +In order to use a Custom Action Server image along with the Rasa Action Server deployment, you have to +use the following values for your deployment. + +```yaml +# values.yaml +image: + name: "image_name" + tag: "image_tag" +``` + +then upgrade your deployment by executing the command: + +```text +helm upgrade --namespace <namespace> --reuse-values \ + -f values.yaml <release name> rasa/rasa-action-server +``` + +## 2. Connect Rasa Action Server to a Rasa deployment + +If you have deployed your assistant using the Rasa Helm chart, and you have deployed your Rasa Action Server as well. +Now it's time to connect them together. You can do this easily by following the steps: + +a. Create a `rasa-values.yaml` file which will include configuration for the Rasa deployment. + + ```yaml + # rasa-values.yaml + rasa-action-server: + external: + # -- Determine if external URL is used + enabled: true + # -- URL to Rasa Action Server + url: "http://rasa-action-server/webhook" + ``` + + The configuration above tells Rasa which Rasa Action Server to use. In the example the `http://rasa-action-server/webhook` URL is used, + the URL indicates that Rasa Action Server is deployed with release name `rasa-action-server`, and is located in the same namespace as Rasa server deployment. + +b. Upgrade the Rasa deployment + + ```text + helm upgrade -n <namespace> --reuse-values -f rasa-values.yaml \ + <release name> rasa/rasa + ``` diff --git a/docs/docs/deploy/deploy-rasa-pro-services.mdx b/docs/docs/deploy/deploy-rasa-pro-services.mdx new file mode 100644 index 0000000..3dd2886 --- /dev/null +++ b/docs/docs/deploy/deploy-rasa-pro-services.mdx @@ -0,0 +1,178 @@ +--- +id: deploy-rasa-pro-services +sidebar_label: "Deploy Rasa Pro Services" +title: "Deploy Rasa Pro Services" +description: Deploy Rasa Rasa Pro Services in production +--- + +import RasaProLabel from "@theme/RasaProLabel"; +import RasaProBanner from "@theme/RasaProBanner"; + +<RasaProLabel /> + +<RasaProBanner /> + +## Rasa Pro Services Setup + +Installing Rasa Pro Services requires the deployment of a docker +container. The container can be deployed in a different environment than +Rasa Plus. Both deployments need to be able to connect to the same +Kafka cluster to communicate. + +### Prerequisites + +A Rasa Pro Services deployment needs to be connected to + +- a message broker (Kafka) and +- a data warehouse (e.g. PostgreSQL). + +The Kafka deployment should be a production deployment. We recommend a +managed deployment, e.g. using +[Amazon Managed Streaming for Apache Kafka](https://aws.amazon.com/msk/). + +#### System Requirements + +The minimum hardware requirements include information about the +requirements you need to install and use Rasa Pro Services. Hardware +requirements are dependent on the average number of conversations and +expected workload. Your exact needs may be more, depending on your workload. + +The following is the recommended minimum hardware guidance: + +- CPU: 2 vCPU +- Memory: 4 GB +- Disk space: 10 GB + +These requirements correspond to the `t3.medium` instance type on AWS. + +#### License + +Running Rasa Pro Services requires a valid license. You need to supply the +license as an environment variable to the service. + +### Installation and Configuration + +Rasa Plus streams data to Kafka which gets consumed by Rasa Pro Services. +These different services need to be run and configured to be able to communicate. + +1. Configure Rasa Plus to stream data to Kafka. The configuration for + Kafka should be put in a configuration + file `endpoints.yml`: + + ```yaml-rasa title="endpoints.yml" + event_broker: + type: kafka + partition_by_sender: True + topic: rasa-events + url: <BROKER URL> + ``` + + The `<BROKER_URL>` needs to be replaced with an address of a bootstrap + server of your Kafka cluster. Configuration examples for different + security protocols and other parameters can be found in the + [Kafka Event Broker](../event-brokers.mdx#kafka-event-broker) + documentation. + +2. Rasa Pro Services is a docker image. Authenticate to pull + the docker images: + + ```shell + gcloud auth configure-docker europe-west3-docker.pkg.dev + ``` + + Pull the Rasa Pro Services image to your machine: + + ```shell + docker pull europe-west3-docker.pkg.dev/rasa-releases/rasa-pro/rasa-pro + ``` + + :::note + + For a more complete walkthrough on how to manage the authentication + credentials and licenses in a production environment, see the + [Rasa Plus Deployment Guide](/docs/rasa/deploy/deploy-rasa#4-deploy-rasa-assistant) (select the **Rasa Plus** tab). + + ::: + +3. Run the Rasa Pro Services docker image and configure it to receive data + from your Rasa Plus assistant connected to Kafka: + + ```shell + docker run \ + -e RASA_PRO_LICENSE=<your_license> \ + -e KAFKA_BROKER_ADDRESS=<BROKER_URL> \ + -e KAFKA_TOPIC=rasa-events \ + europe-west3-docker.pkg.dev/rasa-releases/rasa-pro/rasa-pro + ``` + + For production deployments, we recommend to us + [Amazon Elastic Container Service](https://docs.docker.com/cloud/ecs-integration/). + +### Docker Container Configuration (Reference) + +The Rasa Pro Services docker container supports configuration through +several environment variables. The following table lists the available +environment variables: + +| Environment Variable | Description | Default | +| :------------------------ | :-------------------------------------------------------------------------------------------------------------------- | :----------------- | +| `RASA_PRO_LICENSE` | **Required**. The license key for Rasa Pro Services. | | +| `KAFKA_BROKER_ADDRESS` | **Required**. The address of the Kafka broker. | | +| `KAFKA_TOPIC` | **Required**. The topic Rasa Plus publishes events to and Rasa Pro consumes from. | `rasa_core_events` | +| `LOGGING_LEVEL` | Set the log level of the application. Valid levels are DEBUG, INFO, WARNING, ERROR, CRITICAL. (Available from 3.0.2) | `INFO` | +| `RASA_ANALYTICS_DB_URL` | The URL of the data lake to store analytics data in. | | +| `KAFKA_SASL_MECHANISM` | The SASL mechanism to use for authentication. Valid mechanisms are | `PLAIN` | +| `KAFKA_SASL_USERNAME` | The username for SASL authentication. | | +| `KAFKA_SASL_PASSWORD` | The password for SASL authentication. | | +| `KAFKA_SECURITY_PROTOCOL` | The security protocol to use for communication with Kafka. Supported mechanisms are `PLAINTEXT` and `SASL_PLAINTEXT`. | `PLAINTEXT` | +| `KAFKA_SSL_CA_LOCATION` | The filepath for SSL CA Certificate that will be used to connect with Kafka (Available from `3.1.0b1`) | | + +### Healthcheck Endpoint + +Rasa Pro Services exposes a health check endpoint at `/healthcheck`. +The endpoint will return a `200` status code if the service is healthy. +If any other status code is returned or if the endpoint is not reachable +the service is unhealthy. + +Example response for `/healthcheck`: + +```json +{ + "details": { + "analytics-consumer": { + "alive": 1, + "isHealthy": true + } + }, + "isHealthy": true +} +``` + +### Connect to a secured Kafka instance + +The connection to a secured Kafka instance can be configured by setting the +following environment variables on the Rasa Pro docker container: +`KAFKA_SASL_MECHANISM`, `KAFKA_SASL_USERNAME`, `KAFKA_SASL_PASSWORD` and +`KAFKA_SECURITY_PROTOCOL`. A detailed description of the parameters can +be found in the +[environment variable reference](#docker-container-configuration-reference). + +The environment variables need to be configured upon startup of the Rasa Pro +docker container. + +Using Kafka `truststores` and `keystores` is currently not supported. + + +### Upgrading Versions + +To upgrade to the latest version of Rasa Pro Services, you must follow these steps: + +1. Read the changelog documentation on breaking changes. +2. Download a new docker container and run it. + +:::note Container Start-up +Note that the container might take some time to start-up as it is running +database schema migrations as part of its start-up. + +If the migrations have failed, the container will be shut down. +::: diff --git a/docs/docs/deploy/deploy-rasa.mdx b/docs/docs/deploy/deploy-rasa.mdx new file mode 100644 index 0000000..2355e59 --- /dev/null +++ b/docs/docs/deploy/deploy-rasa.mdx @@ -0,0 +1,419 @@ +--- +id: deploy-rasa +sidebar_label: "Deploy Rasa" +title: "Deploy Rasa" +description: Deploy a Rasa assistant on Kubernetes/Openshift using Helm +abstract: This page explains how to deploy Rasa Open Source and Rasa Plus using Helm. +--- +<!-- this file is version specific, do not use `@site/...` syntax --> +import variables from './../variables.json'; + +import RasaProLabel from '@theme/RasaProLabel'; +import RasaProBanner from '@theme/RasaProBanner'; + +:::note +The Rasa Helm Chart is open source and available in the +[helm-charts repository](https://github.com/rasahq/helm-charts). +Please +[create an issue](https://github.com/RasaHQ/helm-charts/issues/new) in this +repository if you discover bugs or have suggestions for improvements. + +::: + + +## Installation Requirements + +1. Check that you have installed the Kubernetes or OpenShift command line + interface (CLI). You can check this using the following command: + + <Tabs groupId="kubernetes-dist" values={[{"label": "Kubernetes", "value": "kubernetes"}, {"label": "OpenShift", "value": "openshift"}]} defaultValue="kubernetes"> + <TabItem value="kubernetes"> + + ```bash + kubectl version --short --client + + # The output should be similar to this + # Client Version: v1.19.11 + ``` + + </TabItem> + <TabItem value="openshift"> + + ```bash + oc version --client + + # The output should be similar to this + # Client Version: 4.7.13 + ``` + + </TabItem> + </Tabs> + + If this command resulted in an error, please install the + [Kubernetes CLI](https://kubernetes.io/docs/tasks/tools/install-kubectl/) or the + [OpenShift CLI](https://docs.openshift.com/container-platform/4.7/cli_reference/openshift_cli/getting-started-cli.html#installing-openshift-cli) + depending on the cluster you’re using. + +2. Make sure that the Kubernetes / OpenShift CLI is correctly connected to + your cluster. You can do so by using the following commands: + + <Tabs groupId="kubernetes-dist" values={[{"label": "Kubernetes", "value": "kubernetes"}, {"label": "OpenShift", "value": "openshift"}]} defaultValue="kubernetes"> + <TabItem value="kubernetes"> + + ```bash + kubectl version --short + + # The output should be similar to this + # Client Version: v1.19.11 + # Server Version: v1.19.10 + ``` + + </TabItem> + <TabItem value="openshift"> + + ```bash + oc version + + # The output should be similar to this + # Client Version: 4.7.13 + # Kubernetes Version: v1.20.0+df9c838 + ``` + + </TabItem> + </Tabs> + + If you get an error when executing the command, you are not connected to your + cluster. To get the command to connect to the cluster please consult your cluster’s + admin or the documentation of your cloud provider. + +3. Make sure you have the [Helm CLI](https://helm.sh/docs/intro/install/) + installed. To check this, run: + + ```bash + helm version --short + + # The output should be similar to this + # v3.6.0+g7f2df64 + ``` + + If this command leads to an error, please install the + [Helm CLI](https://helm.sh/docs/intro/install/). + + In case you are using a version `<3.5` of Helm, please update to Helm version + `>=3.5`. + +## Installation + +### 1. Create Namespace + +We recommend installing Rasa in a separate +[namespace](https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/) +to avoid interfering with existing cluster deployments. To create a new namespace +run the following command: + +<Tabs groupId="kubernetes-dist" values={[{"label": "Kubernetes", "value": "kubernetes"}, {"label": "OpenShift", "value": "openshift"}]} defaultValue="kubernetes"> + <TabItem value="kubernetes"> + + ```bash + kubectl create namespace <your namespace> + ``` + + </TabItem> + <TabItem value="openshift"> + + ```bash + oc create namespace <your namespace> + ``` + + </TabItem> +</Tabs> + +### 2. Create Values File + +Prepare an empty file called `rasa-values.yml` which will include all your custom +configuration for the installation with Helm. + +All available values you can find in [the Rasa Helm Chart repository](https://github.com/RasaHQ/helm-charts/tree/main/charts/rasa#values). + +:::note +The default configuration of the Rasa chart deploys a Rasa Server, downloads a model, and serves the downloaded model. +Visit [the Rasa Helm Chart repository](https://github.com/RasaHQ/helm-charts/tree/main/charts/rasa#quick-start) to check out more examples of configuration. + +::: + +### 3. Loading an initial model + +The first time you install Rasa, you may not have a model server available yet, or you may want a lightweight model for testing the deployment. +For this purpose, you can choose between training or downloading an initial model. By default, the Rasa chart downloads an example model from GitHub. +To use this option, you don't have to change anything. + +If you want to define an existing model to download from a URL you define instead, update your `rasa-values.yaml` with the URL according to the following configuration: + + ```yaml + applicationSettings: + initialModel: "https://github.com/RasaHQ/rasa-x-demo/blob/master/models/model.tar.gz?raw=true" + ``` +:::note +The URL for the initial model download has to point to a tar.gz file and must not require authentication. + +::: + +If you want to train an initial model you can do this by setting the `applicationSettings.trainInitialModel` to `true`. +It creates a init container that trains a model based on data located in the `/app` directory. If the `/app` directory is empty it creates a new project. +You can find an example that shows how to download data files from a git repository and train an initial model in the Rasa Helm Charts [examples](https://github.com/RasaHQ/helm-charts/blob/main/examples/rasa/train-model-helmfile.yaml). + +### 4. Deploy Rasa Assistant + + +Run the following commands: + + ```bash + kubectl version --short --client + + # The output should be similar to this + # Client Version: v1.19.11 + ``` + + ```bash + # Add the repository which contains the Rasa Helm Chart + helm repo add rasa https://helm.rasa.com + + # Deploy Rasa + helm install \ + --namespace <your namespace> \ + --values rasa-values.yml \ + <release name> \ + rasa/rasa + ``` + +<Tabs groupId="oss-plus-deploy" values={[{"label": "Rasa Open Source", "value": "oss"}, {"label": "Rasa Plus", "value": "plus"}]} defaultValue="oss"> + <TabItem value="oss"> + + </TabItem> + <TabItem value="plus"> + +<RasaProLabel /> + +<RasaProBanner/> + +To use the Rasa Plus image in a Helm deployment, you will need to substitute `rasa` with `rasa-plus` as +the image name. You will also need to pass your license in an env var +and [specify a pull secret for the image](#step-1-image-pull-secret). + +#### Security Patch Releases +Beginning with Rasa Plus 3.4.0, we release a new Docker image for supported versions each day at 0800 UTC. These images have OS security patches applied and include the date of generation in their tag in the format `YYYYMMDD`. +For example, to use the 3.4.2 image generated on the 1st of February 2023, use the tag `3.4.2-20230201`. These images can optionally be used as a drop-in replacement for the same version of Rasa Plus, but with all OS security updates up to that date applied. + +#### Step 1: image pull secret + +You will need to create a pull secret to access the `rasa-plus` image. You should have +been provided with a JSON credentials file to access the registry. +The file content should look similar to this: + +```json +{ + "type": "service_account", + "project_id": "rasa-platform", + "private_key_id": "somerandomcharacters", + "private_key": "-----BEGIN PRIVATE KEY-----\n\nPBTu1lAJDLo136ZGTdMKi+/TuRqrIMg/..................................................................................................................................\nsjAsuAH4Iz1XdfdenzGnyBZH\n-----END PRIVATE KEY-----\n", + "client_email": "company@rasa-platform.iam.gserviceaccount.com", + "client_id": "12345678910123", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://accounts.google.com/o/oauth2/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/company%40rasa-platform.iam.gserviceaccount.com" +} +``` + +Create the pull secret by running the corresponding command for your cluster environment: + +```bash +# kubernetes +kubectl --namespace <your namespace> \ + create secret docker-registry rasa-plus-pull-secret \ + --docker-server=https://europe-west3-docker.pkg.dev \ + --docker-email=company@rasa-platform.iam.gserviceaccount.com \ # this value needs to be updated with correct client email + --docker-username=_json_key \ + --docker-password="$(cat <your-json-registry-creds-file>.json)" +``` + +```bash +# openshift +oc --namespace <your namespace> \ + create secret docker-registry rasa-plus-pull-secret \ + --docker-server=https://europe-west3-docker.pkg.dev \ + --docker-email=company@rasa-platform.iam.gserviceaccount.com \ # this value needs to be updated with correct client email + --docker-username=_json_key \ + --docker-password="$(cat <your-json-registry-creds-file>.json)" +``` + +#### Step 2: store license in a secret + +Step a: Save the license string to a file, for example: +```sh +echo "<rasaplus-license-key-you-were-emailed>" > license.txt +``` + +Step b: Create a secret to hold the license key: +```sh +kubectl -n <your-namespace> create secret generic rasapro-license --from-file=licensekey=./license.txt +``` +Step c: Remove the file on disk, for example: +```sh +/bin/rm license.txt +``` + +#### Step 3: update `values.yml` + +For the [Rasa Helm Chart](https://github.com/RasaHQ/helm-charts/tree/main/charts/rasa), update as follows: + +```yaml +registry: europe-west3-docker.pkg.dev/rasa-releases/rasa-plus/rasa-plus +command: + - rasa +image: + name: rasa-plus + tag: 0.1.1 + pullSecrets: + - name: rasa-plus-pull-secret +extraEnv: + - name: RASA_PRO_LICENSE + valueFrom: + secretKeyRef: + name: rasapro-license + key: licensekey +``` + +For the [Rasa X/Enterprise Helm Chart](https://github.com/RasaHQ/rasa-x-helm), update as follows: + +```yaml +images: + imagePullSecrets: + - name: rasa-plus-pull-secret +rasa: + command: + - rasa + name: europe-west3-docker.pkg.dev/rasa-releases/rasa-plus/rasa-plus + tag: 0.1.1 + extraEnv: + - name: RASA_PRO_LICENSE + valueFrom: + secretKeyRef: + name: rasapro-license + key: licensekey +``` + + </TabItem> +</Tabs> + + +:::note +**OpenShift only**: If the deployment fails and `oc get events` returns +`1001 is not an allowed group spec.containers[0].securityContext.securityContext.runAsUser`, +re-run the installation command with the following values: + +```yaml +postgresql: + volumePermissions: + securityContext: + runAsUser: "auto" + securityContext: + enabled: false + shmVolume: + chmod: + enabled: false +nginx: + image: + name: nginxinc/nginx-unprivileged + port: 8080 +``` + +Then wait until the deployment is ready. If you want to check on its status, the following command +will block until the Rasa deployment is ready: + +<Tabs groupId="kubernetes-dist" values={[{"label": "Kubernetes", "value": "kubernetes"}, {"label": "OpenShift", "value": "openshift"}]} defaultValue="kubernetes"> + <TabItem value="kubernetes"> + + ```bash + kubectl --namespace <your namespace> \ + wait \ + --for=condition=available \ + --timeout=20m \ + --selector app.kubernetes.io/instance=<release name> \ + deployment + ``` + + </TabItem> + <TabItem value="openshift"> + + ```bash + oc --namespace <your namespace> \ + wait \ + --for=condition=available \ + --timeout=20m \ + --selector app.kubernetes.io/instance=<release name> \ + deployment + ``` + + </TabItem> +</Tabs> + +::: + + +### 5. Access Rasa Assistant + +By default the Rasa deployment is exposed via the `rasa` (`<release name>`) service and accessible only within a Kubernetes cluster. +To access Rasa Assistant by using `kubectl port-forward`, use these commands: + +<Tabs groupId="kubernetes-dist" values={[{"label": "Kubernetes", "value": "kubernetes"}, {"label": "OpenShift", "value": "openshift"}]} defaultValue="kubernetes"> + <TabItem value="kubernetes"> + + ```bash + export SERVICE_PORT=$(kubectl get --namespace <your namespace> -o jsonpath="{.spec.ports[0].port}" services <release name>) + kubectl port-forward --namespace <your namespace> svc/<release name> ${SERVICE_PORT}:${SERVICE_PORT} & + ``` + + </TabItem> + <TabItem value="openshift"> + + ```bash + export SERVICE_PORT=$(oc get --namespace <your namespace> -o jsonpath="{.spec.ports[0].port}" services <release name>) + oc port-forward --namespace <your namespace> svc/<release name> ${SERVICE_PORT}:${SERVICE_PORT} & + ``` + + </TabItem> +</Tabs> + +You can then access the deployment on `http://127.0.0.1:${SERVICE_PORT}` + +The other option is to expose your deployment on `NodePort` and access it directly. + +1. Prepare configuration that switch the rasa service to `NodePort`. + + ```yaml + # rasa-values.yaml + service: + type: "NodePort" + ``` + +2. Upgrade deployment. + + ```text + helm upgrade --namespace <NAMESPACE> --reuse-values -f rasa-values.yaml <RELEASE NAME> rasa/rasa + ``` + +3. Get the node port and address for the rasa service + + ```text + export NODE_PORT=$(kubectl get --namespace <NAMESPACE> -o jsonpath="{.spec.ports[0].nodePort}" services <RELEASE NAME>) + + $ curl http://127.0.0.1:${NODE_PORT} + Hello from Rasa: 2.8.7 + ``` + +Visit [the Rasa Helm Chart README](https://github.com/RasaHQ/helm-charts/tree/main/charts/rasa#exposing-the-rasa-deployment-to-the-public) to learn other ways to expose your deployment. + +## Next Steps + +- Visit [the Rasa Helm Chart repository](https://github.com/RasaHQ/helm-charts/tree/main/charts/rasa) where you can find examples of configuration diff --git a/docs/docs/deploy/introduction.mdx b/docs/docs/deploy/introduction.mdx new file mode 100644 index 0000000..2e0e9d0 --- /dev/null +++ b/docs/docs/deploy/introduction.mdx @@ -0,0 +1,45 @@ +--- +id: introduction +sidebar_label: Introduction +title: Deploying a Rasa Assistant +description: How to deploy your Rasa Assistant with Kubernetes/Openshift +abstract: This section explains when and how to deploy an assistant built with Rasa. + It will allow you to make your assistant available to users and set you up with a production-ready environment. +--- +<!-- this file is version specific, do not use `@site/...` syntax --> +import variables from './../variables.json'; + +:::note +Are you unfamiliar with Docker, Kubernetes and Helm? Check out "[Understanding Rasa Deployments](https://www.youtube.com/watch?v=aAs_RS0ueEw&list=PL75e0qA87dlHmfmu7oPPYA22fmc6GJ2aW)" on our [YouTube channel](https://www.youtube.com/channel/UCJ0V6493mLvqdiVwOKWBODQ). +::: + +## When to Deploy Your Assistant + +The best time to deploy your assistant and make it available to test users is once it can handle the most +important happy paths or is what we call a [minimum viable assistant](../glossary.mdx). Then you can use incoming +conversations to inform further development of your assistant. + + +## Recommended Deployment Method +The [Rasa Helm Chart](https://github.com/RasaHQ/helm-charts/tree/main/charts/rasa) is the production ready method to deploy +your assistant on a Kubernetes or Openshift cluster. For details, see the [deployment instructions](./deploy-rasa.mdx). + +### Cluster Requirements + +To install the Rasa Helm Chart, you need an existing +[Kubernetes cluster](https://kubernetes.io/) or [OpenShift cluster](https://www.openshift.com/). +If you don't have one yet, you can get a managed cluster from a cloud provider like: +* [Google Cloud](https://cloud.google.com/kubernetes-engine), +* [DigitalOcean](https://www.digitalocean.com/products/kubernetes/), +* [Microsoft Azure](https://azure.microsoft.com/en-us/services/kubernetes-service/), or +* [Amazon EKS](https://aws.amazon.com/eks/). + +## Alternative Deployment Methods + +The following deployment methods are not suited to a production deployment, but can be useful for development and testing: + +* [Running an assistant locally on the command line](../command-line-interface.mdx#rasa-run) + +* [Developing an assistant in a Docker container](../docker/building-in-docker.mdx) + +* [Deploying an assistant with Docker Compose](../docker/deploying-in-docker-compose.mdx) diff --git a/docs/docs/docker/building-in-docker.mdx b/docs/docs/docker/building-in-docker.mdx new file mode 100644 index 0000000..9866ad9 --- /dev/null +++ b/docs/docs/docker/building-in-docker.mdx @@ -0,0 +1,267 @@ +--- +id: building-in-docker +sidebar_label: Building a Rasa Assistant in Docker +title: Building a Rasa Assistant in Docker +description: Learn how to build a Rasa assistant in Docker. +--- +import useBaseUrl from '@docusaurus/useBaseUrl'; + +<!-- this file is version specific, do not use `@site/...` syntax --> +import variables from '../variables.json'; + +If you don't have a Rasa project yet, you can build one in Docker without having to install Rasa +on your local machine. If you already have a model you're satisfied with, see +[Deploying a Rasa Assistant](../deploy/introduction.mdx) to learn how to deploy your model. + + +## Installing Docker + +If you're not sure if you have Docker installed, you can check by running: + +```bash +docker -v +# Docker version 18.09.2, build 6247962 +``` + +If Docker is installed on your machine, the output should show you your installed +versions of Docker. If the command doesn't work, you'll have to install Docker. +See [Docker Installation](https://docs.docker.com/install/) for details. + +## Setting up your Rasa Project + +Just like starting a project from scratch, you'll use the `rasa init` command to create a project. +The only difference is that you'll be running Rasa inside a Docker container, using +the image `rasa/rasa`. To initialize your project, run: + +<pre><code parentName="pre" className="language-bash"> +{`docker run -v $(pwd):/app rasa/rasa:${variables.release}-full init --no-prompt`}</code></pre> + +What does this command mean? + +<ul> + <li><inlineCode>-v $(pwd):/app</inlineCode> mounts your current working directory to the working directory + in the Docker container. This means that files you create on your computer will be + visible inside the container, and files created in the container will + get synced back to your computer.</li> + <li><inlineCode>rasa/rasa</inlineCode> is the name of the docker image to run. '{variables.release}-full' is the name of the tag, + which specifies the version and dependencies.</li> + + <li>the Docker image has the <inlineCode>rasa</inlineCode> command as its entrypoint, which means you don't + have to type <inlineCode>rasa init</inlineCode>, just <inlineCode>init</inlineCode> is enough.</li> +</ul> + +Running this command will produce a lot of output. What happens is: + +* a Rasa project is created + +* an initial model is trained using the project's training data. + +To check that the command completed correctly, look at the contents of your working directory: + +```bash +ls -1 +``` + +The initial project files should all be there, as well as a `models` directory that contains your trained model. + +:::note +If you run into permission errors, it may be because the `rasa/rasa` images +run as user `1001` as a best practice, to avoid giving the container `root` permissions. +Hence, all files created by these containers will be owned by user `1001`. See the [Docker documentation](https://docs.docker.com/reference/cli/docker/container/run/) +if you want to run the containers as a different user. + +::: + +## Talking to Your Assistant + +To talk to your newly-trained assistant, run this command: + +<pre><code parentName="pre" className="language-bash"> +{`docker run -it -v $(pwd):/app rasa/rasa:${variables.release}-full shell`}</code></pre> + +This will start a shell where you can chat to your assistant. +Note that this command includes the flags `-it`, which means that you are running +Docker interactively, and you are able to give input via the command line. +For commands which require interactive input, like `rasa shell` and `rasa interactive`, +you need to pass the `-it` flags. + +## Training a Model + +If you edit any training data or edit the `config.yml` file, you'll need to +retrain your Rasa model. You can do so by running: + +<pre><code parentName="pre" className="language-bash"> +{`docker run -v $(pwd):/app rasa/rasa:${variables.release}-full train --domain domain.yml --data data --out models`}</code></pre> + +Here's what's happening in that command: + +<ul> + <li><inlineCode>-v $(pwd):/app</inlineCode>: Mounts your project directory into the Docker + container so that Rasa can train a model on your training data</li> + + <li>rasa/rasa:{variables.release}-full: Use the Rasa image with the tag '{variables.release}-full'</li> + + <li><inlineCode>train</inlineCode>: Execute the <inlineCode>rasa train</inlineCode> command within the container. For more + information see <a href={useBaseUrl("/command-line-interface")}>Command Line Interface</a>.</li> +</ul> + +In this case, we've also passed values for the location of the domain file, training +data, and the models output directory to show how these can be customized. +You can also leave these out, since we are passing the default values. + +## Customizing your Model + +### Choosing a Tag + +<p> + All <inlineCode>rasa/rasa</inlineCode> image tags start with a version number. The current version is {variables.release}. The tags are: +</p> + +* `{version}` + +* `{version}-full` + +* `{version}-spacy-en` + +* `{version}-spacy-de` + +* `{version}-spacy-it` + +* `{version}-mitie-en` + +The `{version}-full` tag includes all possible pipeline dependencies, allowing you to change your `config.yml` +as you like without worrying about missing dependencies. The plain `{version}` tag includes all the +dependencies you need to run the default pipeline created by `rasa init`. + +To keep images as small as possible, we also publish different tags of the `rasa/rasa` image +with different dependencies installed. See [Additional Dependencies](../installation/installing-rasa-open-source.mdx#additional-dependencies) for more dependency information +specific to your pipeline. For example, if you are using components with pre-trained word vectors from spaCy or +MITIE, you should choose the corresponding tag. + +If your model has a dependency that is not included in any of the tags (for example, a different spaCy language model), +you can build a docker image that extends the `rasa/rasa` image. + +:::note +You can see a list of all the versions and tags of the Rasa +Docker image on [DockerHub](https://hub.docker.com/r/rasa/rasa/). + +::: + +:::caution +The `latest` tags correspond to the build of the latest stable version. + +::: + +### Adding Custom Components + +If you are using a custom NLU component or policy in your `config.yml`, you have to add the module file to your +Docker container. You can do this by either mounting the file or by including it in your +own custom image (e.g. if the custom component or policy has extra dependencies). Make sure +that your module is in the Python module search path by setting the +environment variable `PYTHONPATH=$PYTHONPATH:<directory of your module>`. + +### Adding Custom Actions + +To create more sophisticated assistants, you will want to use [Custom Actions](../actions.mdx#custom-actions). +Continuing the example from above, you might want to add an action which tells +the user a joke to cheer them up. + +Build a custom action using the Rasa SDK by editing `actions/actions.py`, for example: + +```python +import requests +import json +from rasa_sdk import Action + + +class ActionJoke(Action): + def name(self): + return "action_joke" + + def run(self, dispatcher, tracker, domain): + request = requests.get('http://api.icndb.com/jokes/random').json() # make an api call + joke = request['value']['joke'] # extract a joke from returned json response + dispatcher.utter_message(text=joke) # send the message back to the user + return [] +``` + +In `data/stories.yml`, replace `utter_cheer_up` in with the custom action `action_joke` +tell your bot to use this new action. + +In `domain.yml`, add a section for custom actions, including your new action: + +```yaml-rasa +actions: + - action_joke +``` + +After updating your domain and stories, you have to retrain your model: + +<pre><code parentName="pre" className="language-bash"> +{`docker run -v $(pwd):/app rasa/rasa:${variables.release}-full train`}</code></pre> + +Your actions will run on a separate server from your Rasa server. First create a network to connect the two containers: + +```bash +docker network create my-project +``` + +You can then run the actions with the following command: + +<pre><code parentName="pre" className="language-bash"> +{`docker run -d -v $(pwd)/actions:/app/actions --net my-project --name action-server rasa/rasa-sdk:${variables.rasa_sdk_version}`}</code></pre> + + +Here's what's happening in that command: + +* `-d`: Runs the container in detached mode so that you can run the rasa container in the same window. + +* `-v $(pwd):/app`: Mounts your project directory into the Docker + container so that the action server can run the code in the `actions` folder + +* `net my-project`: Run the server on a specific network so that the rasa container can find it + +* `--name action-server`: Gives the server a specific name for the rasa server to reference + +* <code>rasa/rasa-sdk:{variables.rasa_sdk_version}</code> : Uses the Rasa SDK image with the tag {`${variables.rasa_sdk_version}`} + +Because the action server is running in detached mode, if you want to stop the container, +do it with `docker stop action-server`. You can also run `docker ps` at any time to see all +of your currently running containers. + +To instruct the Rasa server to use the action server, you have to tell Rasa its location. +Add this endpoint to your `endpoints.yml`, referencing the `--name` you gave the server +(in this example, `action-server`): + +```yaml-rasa +action_endpoint: + url: "http://action-server:5055/webhook" +``` + +Now you can talk to your bot again via the `shell` command: + +<pre><code parentName="pre" className="language-bash"> +{`docker run -it -v $(pwd):/app -p 5005:5005 --net my-project rasa/rasa:${variables.release}-full shell`}</code></pre> + +:::note +If you stop and restart the `action-server` container, you might see an error like this: + +``` +docker: Error response from daemon: Conflict. The container name "/action-server" is +already in use by container "f7ffc625e81ad4ad54cf8704e6ad85123c71781ca0a8e4b862f41c5796c33530". +You have to remove (or rename) that container to be able to reuse that name. +``` + +If that happens, it means you have a (stopped) container with the name already. You can remove it via: + +```bash +docker rm action-server +``` + +::: + +## Deploying your Assistant + +Work on your bot until you have a minimum viable assistant that can handle your happy paths. After +that, you'll want to deploy your model to get feedback from real test users. To do so, you can deploy the +model you created via one of our [recommended deployment methods](../deploy/introduction.mdx#recommended-deployment-method). diff --git a/docs/docs/docker/deploying-in-docker-compose.mdx b/docs/docs/docker/deploying-in-docker-compose.mdx new file mode 100644 index 0000000..905506e --- /dev/null +++ b/docs/docs/docker/deploying-in-docker-compose.mdx @@ -0,0 +1,129 @@ +--- +id: deploying-in-docker-compose +sidebar_label: Deploying a Rasa Assistant in Docker Compose +title: Deploying a Rasa Assistant in Docker Compose +description: Use Docker Compose to deploy a Rasa assistant +--- +<!-- this file is version specific, do not use `@site/...` syntax --> +import variables from '../variables.json'; + +## Installing Docker + +If you're not sure if you have Docker installed, you can check by running: + +```bash +docker -v && docker-compose -v +# Docker version 18.09.2, build 6247962 +# docker-compose version 1.23.2, build 1110ad01 +``` + +If Docker is installed on your machine, the output should show you your installed +versions of Docker and Docker Compose. If the command doesn't work, you'll have to +install Docker. +See [Docker Installation](https://docs.docker.com/install/) for details. + +## Configuring Channels + +To run your AI assistant in production, don't forget to configure your required +[Messaging and Voice Channels](../messaging-and-voice-channels.mdx) in `credentials.yml`. For example, to add a +REST channel, uncomment this section in the `credentials.yml`: + +```yaml-rasa +rest: + # you don't need to provide anything here - this channel doesn't + # require any credentials +``` + +The REST channel will open your bot up to incoming requests at the `/webhooks/rest/webhook` endpoint. + +## Using Docker Compose to Run Multiple Services + +Docker Compose provides an easy way to run multiple containers together without +having to run multiple commands or configure networks. This is essential when you +want to deploy an assistant that also has an action server. + +Start by creating a file called `docker-compose.yml`: + +```bash +touch docker-compose.yml +``` + +Add the following content to the file: + +<pre><code parentName="pre" className="language-yaml"> +{`version: '3.0' +services: + rasa: + image: rasa/rasa:${variables.release}-full + ports: + - 5005:5005 + volumes: + - ./:/app + command: + - run`}</code></pre> + +The file starts with the version of the Docker Compose specification that you +want to use. +Each container is declared as a `service` within the `docker-compose.yml`. +The first service is the `rasa` service, which runs your Rasa server. + +To add the action server, add the image of your action server code. To learn how to deploy +an action server image, see [Building an Action Server Image](../deploy/deploy-action-server.mdx#building-an-action-server-image). + +<pre><code parentName="pre" className="language-yaml"> +{`version: '3.0' +services: + rasa: + image: rasa/rasa:${variables.release}-full + ports: + - 5005:5005 + volumes: + - ./:/app + command: + - run + app: + image: <image:tag> + expose: 5055`}</code></pre> + +The `expose: 5005` is what allows the `rasa` service to reach the `app` service on that port. +To instruct the `rasa` service to send its action requests to that endpoint, add it to your `endpoints.yml`: + +```yaml-rasa +action_endpoint: + url: http://app:5055/webhook +``` + +To run the services configured in your `docker-compose.yml` execute: + +```bash +docker-compose up +``` + +You should then be able to interact with your bot via requests to port 5005, on the webhook endpoint that +corresponds to a [configured channel](#configuring-channels): + +```bash +curl -XPOST http://localhost:5005/webhooks/rest/webhook \ + -H "Content-type: application/json" \ + -d '{"sender": "test", "message": "hello"}' +``` + +## Configuring a Tracker Store + +By default, all conversations are saved in memory. This means that all +conversations are lost as soon as you restart the Rasa server. +If you want to persist your conversations, you can use a different +[Tracker Store](../tracker-stores.mdx). + +To add a tracker store to a Docker Compose deployment, you need to add a new +service to your `docker-compose.yml` and modify the `endpoints.yml` to add +the new tracker store, pointing to your new service. More information about how +to do so can be found in the tracker store documentation: + +* [SQLTrackerStore](../tracker-stores.mdx#sqltrackerstore) + +* [RedisTrackerStore](../tracker-stores.mdx#redistrackerstore) + +* [MongoTrackerStore](../tracker-stores.mdx#mongotrackerstore) + +* [Custom Tracker Store](../tracker-stores.mdx#custom-tracker-store) diff --git a/docs/docs/domain.mdx b/docs/docs/domain.mdx new file mode 100644 index 0000000..471768d --- /dev/null +++ b/docs/docs/domain.mdx @@ -0,0 +1,893 @@ +--- +id: domain +sidebar_label: Domain +title: Domain +abstract: The domain defines the universe in which your assistant operates. It specifies the intents, entities, slots, responses, forms, and actions your bot should know about. It also defines a configuration for conversation sessions. +--- + +Here is a full example of a domain, taken from the +[concertbot](https://github.com/RasaHQ/rasa/tree/main/examples/concertbot) example: + +```yaml-rasa (docs/sources/examples/concertbot/domain.yml) +``` + +## Multiple Domain Files + +The domain can be defined as a single YAML file or split across multiple files in a directory. +When split across multiple files, the domain contents will be read and automatically merged together. + +Using the [command line interface](./command-line-interface.mdx#rasa-train), +you can train a model with split domain files by running: + +```bash +rasa train --domain path_to_domain_directory +``` + +## Intents + +The `intents` key in your domain file lists all intents +used in your [NLU data](./nlu-training-data.mdx) and [conversation training data](./training-data-format.mdx#conversation-training-data). + +### Ignoring Entities for Certain Intents + +To ignore all entities for certain intents, you can +add the `use_entities: []` parameter to the intent in your domain +file like this: + +```yaml-rasa +intents: + - greet: + use_entities: [] +``` + +To ignore some entities or explicitly take only certain entities +into account you can use this syntax: + +```yaml-rasa +intents: +- greet: + use_entities: + - name + - first_name +- farewell: + ignore_entities: + - location + - age + - last_name +``` + +You can only `use_entities` _or_ `ignore_entities` for any single intent. + +Excluded entities for those intents will be unfeaturized and therefore +will not impact the next action predictions. This is useful when you have +an intent where you don't care about the entities being picked up. + +If you list your intents without a `use_entities` or `ignore_entities` +parameter, the entities will be featurized as normal. + + +It is also possible to ignore an entity for all intents +by setting the `influence_conversation` flag to `false` for the entity itself. +See [the entities section](#entities) for details. + +Excluded entities for intents will be unfeaturized and therefore +will not impact the next action predictions. This is useful when you have +an intent where you don't care about the entities being picked up. + +If you list your intents without this parameter, and without setting +`influence_conversation` to `false` for any entities, all entities will be +featurized as normal. + +:::note +If you want these entities not to influence action prediction via slots either, +set the [`influence_conversation: false`](./domain.mdx#slots-and-conversation-behavior) +parameter for slots with the same name. + +::: + +## Entities +:::info New in 3.1 + +As of 3.1, you can use the `influence_conversation` flag under entities. +The flag can be set to `false` to declare that an entity should not +be featurized for any intents. It is a shorthand syntax for adding an entity to +the `ignore_entities` list of every intent in the domain. The flag is optional +and default behaviour remains unchanged. + +::: + +The `entities` section lists all entities that can be +extracted by any [entity extractor](./components.mdx) in your +NLU pipeline. + +For example: + +```yaml-rasa +entities: + - PERSON # entity extracted by SpacyEntityExtractor + - time # entity extracted by DucklingEntityExtractor + - membership_type # custom entity extracted by DIETClassifier + - priority # custom entity extracted by DIETClassifier +``` + +When using multiple domain files, entities can be specified in any domain file, +and can be used or ignored by any intent in any domain file. + +If you are using the feature [Entity Roles and Groups](./nlu-training-data.mdx#entities-roles-and-groups) you also +need to list the roles and groups of an entity in this section. + +For example: + +```yaml-rasa +entities: + - city: # custom entity extracted by DIETClassifier + roles: + - from + - to + - topping: # custom entity extracted by DIETClassifier + groups: + - 1 + - 2 + - size: # custom entity extracted by DIETClassifier + groups: + - 1 + - 2 +``` + +By default, entities influence action prediction. To prevent extracted entities from +influencing the conversation for specific intents you can [ignore entities for certain intents](#ignoring-entities-for-certain-intents). +To ignore an entity for all intents, without having to list it under the `ignore_entities` flag of each intent, +you can set the flag `influence_conversation` to `false` under the entity: + +```yaml-rasa +entities: +- location: + influence_conversation: false +``` +This syntax has the same effect as adding the entity to the `ignore_entities` +list for every intent in the domain. + +Explicitly setting `influence_conversation: true` does not change any behaviour. This is the default setting. + +## Slots + +Slots are your bot's memory. They act as a key-value store +which can be used to store information the user provided (e.g their home city) +as well as information gathered about the outside world (e.g. the result of a +database query). + +Slots are defined in the slots section of your domain with their name, +[type](domain.mdx#slot-types) and if and how they should [influence the assistant's +behavior](domain.mdx#slots-and-conversation-behavior). +The following example defines a slot with name "slot_name", type `text` and +predefined slot mapping `from_entity`. + +```yaml +slots: + slot_name: + type: text + mappings: + - type: from_entity + entity: entity_name +``` + +### Slots and Conversation Behavior + +You can specify whether or not a slot influences the conversation with the +`influence_conversation` property. + +If you want to store information in a slot without it influencing the conversation, +set `influence_conversation: false` when defining your slot. + +The following example defines a slot `age` which will store information about the +user's age, but which will *not* influence the flow of the conversation. This means +that the assistant will ignore the value of the slot each time it predicts the next action. + +```yaml +slots: + age: + type: text + # this slot will not influence the predictions + # of the dialogue policies + influence_conversation: false +``` + +When defining a slot, if you leave out `influence_conversation` or set it to `true`, +that slot will influence the next action prediction, unless it has slot type `any`. +The way the slot influences the conversation +will depend on its [slot type](./domain.mdx#slot-types). + +The following example defines a slot `home_city` that influences the conversation. +A [`text` slot](domain.mdx#text-slot) will +influence the assistant's behavior depending on whether the slot has a value. +The specific value of a `text` slot (e.g. *Bangalore* or *New York* or *Hong Kong*) +doesn't make any difference. + +```yaml +slots: + # this slot will influence the conversation depending on + # whether the slot is set or not + home_city: + type: text + influence_conversation: true +``` + +As an example, consider the two inputs "What is the weather like?" and "What is the +weather like in Bangalore?" The conversation should diverge based on whether +the `home_city` slot was set automatically by the NLU. If the slot is already set, the bot +can predict the `action_forecast` action. If the slot is not set, it needs to get the `home_city` +information before it is able to predict the weather. + +### Slot Types + + +#### Text Slot + +* **Type** + + `text` + +* **Use For** + + Storing text values. + +* **Example** + + ```yaml-rasa + slots: + cuisine: + type: text + mappings: + - type: from_entity + entity: cuisine + ``` + +* **Description** + + If `influence_conversation` is set to `true`, the assistant's behavior will change + depending on whether the slot is set or not. Different texts do not influence the + conversation any further. This means the following two stories are equal: + + ```yaml-rasa + stories: + - story: French cuisine + steps: + - intent: inform + - slot_was_set: + - cuisine: french + + - story: Vietnamese cuisine + steps: + - intent: inform + - slot_was_set: + - cuisine: vietnamese + ``` + +#### Boolean Slot + +* **Type** + + `bool` + +* **Use For** + + Storing `true` or `false` values. + +* **Example** + + ```yaml-rasa + slots: + is_authenticated: + type: bool + mappings: + - type: custom + ``` + +* **Description** + + If `influence_conversation` is set to `true`, the assistant's behavior will change + depending on whether the slot is empty, set to `true` or set to `false`. Note that an + empty `bool` slot influences the conversation differently than if the slot was set to + `false`. + +#### Categorical Slot + +* **Type** + + `categorical` + +* **Use For** + + Storing slots which can take one of N values. + +* **Example** + + ```yaml-rasa + slots: + risk_level: + type: categorical + values: + - low + - medium + - high + mappings: + - type: custom + ``` + +* **Description** + + If `influence_conversation` is set to `true`, the assistant's behavior will change + depending on the concrete value of the slot. This means the assistant's behavior is + different depending on whether the slot in the above example has the value `low`, + `medium`, or `high`. + + A default value `__other__` is automatically added to the user-defined + values. All values encountered which are not explicitly defined in the slot's `values` + are mapped to `__other__`. + `__other__` should not be used as a user-defined value; if it is, it + will still behave as the default to which all unseen values are mapped. + +#### Float Slot + +* **Type** + + `float` + +* **Use For** + + Storing real numbers. + +* **Example** + + ```yaml-rasa + slots: + temperature: + type: float + min_value: -100.0 + max_value: 100.0 + mappings: + - type: custom + ``` + +* **Defaults** + + `max_value=1.0`, `min_value=0.0` + +* **Description** + + If `influence_conversation` is set to `true`, the assistant's behavior will change + depending on the value of the slot. If the value is between `min_value` and + `max_value`, the specific value of the number is used. + All values below `min_value` will be treated as `min_value`, and all values above + `max_value` will be treated as `max_value`. Hence, if `max_value` is set to `1`, + there is no difference between the slot values `2` and `3.5`. + +#### List Slot + +* **Type** + + `list` + +* **Use For** + + Storing lists of values. + +* **Example** + + ```yaml-rasa + slots: + shopping_items: + type: list + mappings: + - type: from_entity + entity: shopping_item + ``` + +* **Description** + + If `influence_conversation` is set to `true`, the assistant's behavior will change + depending on whether the list is empty or not. The length of the list stored in + the slot does not influence the dialogue. It only matters whether list length is zero or non-zero. + +#### Any Slot + +* **Type** + + `any` + +* **Use For** + + Storing arbitrary values (they can be of any type, such as dictionaries or lists). + +* **Example** + + ```yaml-rasa + slots: + shopping_items: + type: any + mappings: + - type: custom + ``` + +* **Description** + + Slots of type `any` are always ignored during conversations. The property + `influence_conversation` cannot be set to `true` for this slot type. If you want to + store a custom data structure which should influence the conversation, use a + [custom slot type](domain.mdx#custom-slot-types). + +#### Custom Slot Types + +Maybe your restaurant booking system can only handle bookings +for up to 6 people. In this case you want the *value* of the +slot to influence the next selected action (and not just whether +it's been specified). You can do this by defining a custom slot class. + +The code below defines a custom slot class called `NumberOfPeopleSlot`. +The featurization defines how the value of this slot gets converted to a vector +so Rasa machine learning model can deal with it. +The `NumberOfPeopleSlot` has three possible “values”, which can be represented with +a vector of length `2`. + +| | | +|--------|----------------| +|`(0,0)` |not yet set | +|`(1,0)` |between 1 and 6 | +|`(0,1)` |more than 6 | + +```python title="my_custom_slots.py" +from rasa.shared.core.slots import Slot + +class NumberOfPeopleSlot(Slot): + + def feature_dimensionality(self): + return 2 + + def as_feature(self): + r = [0.0] * self.feature_dimensionality() + if self.value: + if self.value <= 6: + r[0] = 1.0 + else: + r[1] = 1.0 + return r +``` + +You can implement a custom slot class as an independent python module, +separate from custom action code. Save the code for your custom slot in a directory +alongside an empty file called "\_\_init\_\_.py" so that it will be recognized as a python module. +You can then refer to the custom slot class by it's module path. + +For example, say you have saved the code above in "addons/my_custom_slots.py", a directory relative to your bot project: + +```bash +└── rasa_bot + ├── addons + │ ├── __init__.py + │ └── my_custom_slots.py + ├── config.yml + ├── credentials.yml + ├── data + ├── domain.yml + ├── endpoints.yml +``` + +Your custom slot type's module path +is then `addons.my_custom_slots.NumberOfPeopleSlot`. +Use the module path to refer to the custom slot type in your domain file: + +```yaml-rasa title="domain.yaml" +slots: + people: + type: addons.my_custom_slots.NumberOfPeopleSlot + influence_conversation: true + mappings: + - type: custom +``` + +Now that your custom slot class can be used by Rasa, add training stories that diverge based on the value of the `people` slot. +You could write one story for the case where `people` has a value between 1 and 6, and one for a value greater than six. You can choose any value within these ranges to put in your stories, since they are all featurized the same way (see the featurization table above). + + +```yaml-rasa +stories: +- story: collecting table info + steps: + # ... other story steps + - intent: inform + entities: + - people: 3 + - slot_was_set: + - people: 3 + - action: action_book_table + +- story: too many people at the table + steps: + # ... other story steps + - intent: inform + entities: + - people: 9 + - slot_was_set: + - people: 9 + - action: action_explain_table_limit +``` + +### Slot Mappings + +:::info New in 3.0 + +As of 3.0, slot mappings are defined in the `slots` section of the domain. +This change removes the implicit mechanism of setting slots via auto-fill and replaces it with a new explicit +mechanism of setting slots after every user message. +You will need to explicitly define slot mappings for each slot in the `slots` section of `domain.yml`. +If you are migrating from an earlier version, please read through the [migration guide](./migration-guide.mdx#slot-mappings) +to update your assistant. + +::: + +Rasa comes with four predefined mappings to fill slots +based on the latest user message. + +In addition to the predefined mappings, you can define [custom slot mappings](./domain.mdx#custom-slot-mappings). +All custom slot mappings should contain a mapping of type `custom`. + +Slot mappings are specified as a YAML list of dictionaries under the key `mappings` in the domain file. +Slot mappings are prioritized in the order they are listed in the domain. The first slot mapping found to apply will be used to fill the slot. + +The default behavior is for slot mappings to apply after every user message, regardless of the dialogue context. +To make a slot mapping apply only within the context of a form see [Mapping Conditions](./domain.mdx#mapping-conditions). +There is one additional default limitation on applying `from_entity` slot mappings in the context of a form; +see [unique `from_entity` mapping matching](./domain.mdx#unique-from_entity-mapping-matching) for details. + +Note that you can also define lists of intents for the optional parameters `intent` and `not_intent`. + +#### from_entity + +The `from_entity` slot mapping fills slots based on extracted entities. +The following parameters are required: +- `entity`: the entity that should fill the slot + +The following parameters are optional and can be used to further specify when the mapping applies: +- `intent`: Only applies the mapping when this intent is predicted. +- `not_intent`: Does not apply the mapping when this intent is predicted +- `role`: Only applies the mapping if the extracted entity has this role +- `group`: Only applies the mapping if the extracted entity belongs to this group. + +```yaml-rasa +entities: +- entity_name +slots: + slot_name: + type: any + mappings: + - type: from_entity + entity: entity_name + role: role_name + group: group name + intent: intent_name + not_intent: excluded_intent +``` + +##### Unique `from_entity` mapping matching + +There is an intentional limitation on applying `from_entity` slot mappings in the context of a form. +When a form is active, a `from_entity` slot mapping will be applied only if one or more of the following conditions are met: + +1. The slot with the `from_entity` mapping has just been requested by the form +2. Only one of the active form's `required_slots` has that specific `from_entity` mapping, +including all the attributes of the extracted entity (i.e, entity name, role, group). This is known as a **unique entity mapping** for the form. +The extracted entity will be ignored if the mapping is not unique within the list of `required_slots`. + +This limitation exists to prevent a form from filling multiple required slots with the same extracted entity value. + +For example, in the example below, an entity `date` uniquely sets the slot `arrival_date`, +an entity `city` with a role `from` uniquely sets the slot `departure_city` and +an entity `city` with a role `to` uniquely sets the slot `arrival_city`, +therefore they can be used to fit corresponding slots +even if these slots were not requested. +However, entity `city` without a role can fill both `departure_city` and `arrival_city` +slots, depending which one is requested, so if an entity `city` is extracted when +slot `arrival_date` is requested, it'll be ignored by the form. + +```yaml-rasa +slots: + departure_city: + type: text + mappings: + - type: from_entity + entity: city + role: from + - type: from_entity + entity: city + arrival_city: + type: text + mappings: + - type: from_entity + entity: city + role: to + - type: from_entity + entity: city + arrival_date: + type: any + mappings: + - type: from_entity + entity: date +forms: + your_form: + required_slots: + - departure_city + - arrival_city + - arrival_date +``` + +Note that the unique `from_entity` mapping constraint will **not** prevent filling slots which are not in the active form's `required_slots`; +those mappings will apply as usual, regardless of the uniqueness of the mapping. To limit applicability of a slot mapping to +a specific form, see [Mapping Conditions](./domain.mdx#mapping-conditions). + +#### from_text + +The `from_text` mapping will use the text of the last user utterance to fill the slot +`slot_name`. If `intent_name` is `None`, the slot will be filled regardless of intent name. +Otherwise, the slot will only be filled if the user's intent is `intent_name`. + +The slot mapping will not apply if the intent of the message is `excluded_intent`. + +```yaml-rasa +slots: + slot_name: + type: text + mappings: + - type: from_text + intent: intent_name + not_intent: excluded_intent +``` + +:::note +To maintain the 2.x form behavior when using `from_text` slot mappings, you must use [mapping conditions](./domain.mdx#mapping-conditions), +where both `active_loop` and `requested_slot` keys are defined. +::: + + +#### from_intent + +The `from_intent` mapping will fill slot `slot_name` with value `my_value` if +user intent is `intent_name`. If you choose not to specify the parameter `intent`, +the slot mapping will apply regardless of the intent of the message as long as +the intent is not listed under `not_intent` parameter. + +The following parameter is required: +- `value`: the value that fills the slot `slot_name` + +The following parameters are optional and can be used to further specify when the mapping applies: +- `intent`: Only applies the mapping when this intent is predicted. +- `not_intent`: Does not apply the mapping when this intent is predicted + +Note that if you choose not to define the parameter `intent`, the slot mapping will apply regardless of the intent +of the message as long as the intent is not listed under the `not_intent` parameter. + +```yaml-rasa +slots: + slot_name: + type: any + mappings: + - type: from_intent + value: my_value + intent: intent_name + not_intent: excluded_intent +``` + +#### from_trigger_intent + +The `from_trigger_intent` mapping will fill slot `slot_name` with value `my_value` +if a form is activated by a user message with intent `intent_name`. +The slot mapping will not apply if the intent of the message is +`excluded_intent`. + +```yaml-rasa +slots: + slot_name: + type: any + mappings: + - type: from_trigger_intent + value: my_value + intent: intent_name + not_intent: excluded_intent +``` + +### Mapping Conditions + +To apply a slot mapping only within the context of a form, specify +the name of the form in the `conditions` key of a slot mapping. Conditions list the form name(s) +for which the mapping is applicable in the `active_loop` key. + +:::info New in 3.6 +Slot mappings can now specify `null` as the value of `active_loop` to indicate that the slot should only be filled when +no form is active. Note that `requested_slot` cannot be used in conjunction with `active_loop: null`. +::: + +Conditions can also include the name of the `requested_slot`. If `requested_slot` is not mentioned, +then the slot will be set if relevant information is extracted, regardless of which slot is being +requested by the form. + +```yaml-rasa +slots: + slot_name: + type: text + mappings: + - type: from_text + intent: intent_name + conditions: + - active_loop: your_form + requested_slot: slot_name + - active_loop: another_form +``` + +:::note +If `conditions` are not included in a slot mapping, the slot mapping will be applicable regardless of whether +any form is active. As long as a slot is listed in a form's `required_slots`, the form will prompt for the slot +if it is empty when the form is activated. +::: + +### Custom Slot Mappings + +You can define custom slot mappings using [slot validation actions](./slot-validation-actions.mdx) when none of the +predefined mappings fit your use case. You must define this slot mapping to be of type `custom`, for example: + +```yaml +slots: + day_of_week: + type: text + mappings: + - type: custom + action: action_calculate_day_of_week +``` + +You can also use the `custom` slot mapping to list slots that will be filled by arbitrary custom actions in the course +of a conversation, by listing the type and no specific action. For example: + +```yaml +slots: + handoff_completed: + type: boolean + mappings: + - type: custom +``` + +This slot will not be updated on every user turn, but only once a custom action that returns a `SlotSet` event for it is predicted. + +### Initial slot values + +You can provide an initial value for a slot in your domain file: + +```yaml-rasa +slots: + num_fallbacks: + type: float + initial_value: 0 + mappings: + - type: custom +``` + +## Responses + +Responses are actions that send a message to a user without running any custom code or +returning events. These responses can be defined directly in the domain file under the `responses` key +and can include rich content such as buttons and attachments. For more information on responses and how to define them, +see [Responses](./responses.mdx). + + +## Forms + +Forms are a special type of action meant to help your assistant collect information from a user. +Define forms under the `forms` key in your domain file. +For more information on form and how to define them, see [Forms](./forms.mdx). + + +## Actions + +[Actions](./actions.mdx) are the things your bot can actually do. +For example, an action could: + +* respond to a user, + +* make an external API call, + +* query a database, or + +* just about anything! + +All [custom actions](./custom-actions.mdx) should be listed in your domain, except responses which need not be listed +under `actions:` as they are already listed under `responses:`. + +### Select which actions should receive domain +:::info New in 3.4.3 +You can control if an action should receive a domain or not. +::: + +To do this you must first enable selective domain in you endpoint configuration for +`action_endpoint` in `endpoints.yml`. +```YAML +# endpoints.yml +action_endpoint: + url: "http://localhost:5055/webhook" # URL to your action server + enable_selective_domain: true +``` +**After selective domain for custom actions is enabled, domain will be sent only to +those custom actions which have specifically stated that they need it.** +Custom actions inheriting from rasa-sdk [`FormValidationAction`](./action-server/validation-action.mdx#formvalidationaction-class) +parent class are an exception to this rule as they will always have the domain sent to them. +To specify if an action needs the domain add `{send_domain: true}` to custom action in the list +of actions in `domain.yml`: +```YAML +# domain.yml +actions: + - action_hello_world: {send_domain: True} # will receive domain + - action_calculate_mass_of_sun # will not receive domain + - validate_my_form # will receive domain +``` + + +## Session configuration + +A conversation session represents the dialogue between the assistant and the user. +Conversation sessions can begin in three ways: + +1. the user begins the conversation with the assistant, + +2. the user sends their first message after a configurable period of inactivity, or + +3. a manual session start is triggered with the `/session_start` intent message. + +You can define the period of inactivity after which a new conversation +session is triggered in the domain under the `session_config` key. + +Available parameters are: +- `session_expiration_time` defines the time of inactivity in minutes after which a +new session will begin. +- `carry_over_slots_to_new_session` determines whether +existing set slots should be carried over to new sessions. + +The default session configuration looks as follows: + +```yaml-rasa +session_config: + session_expiration_time: 60 # value in minutes, 0 means infinitely long + carry_over_slots_to_new_session: true # set to false to forget slots between sessions +``` + +This means that if a user sends their first message after 60 minutes of inactivity, a +new conversation session is triggered, and that any existing slots are carried over +into the new session. Setting the value of `session_expiration_time` to `0` means +that sessions will not end (note that the `action_session_start` action will still +be triggered at the very beginning of conversations). + +:::note +A session start triggers the default action `action_session_start`. Its default +implementation moves all existing slots into the new session. Note that all +conversations begin with an `action_session_start`. Overriding this action could +for instance be used to initialize the tracker with slots from an external API +call, or to start the conversation with a bot message. The docs on +[Customizing the session start action](./default-actions.mdx#customization) shows you how to do that. + +::: + +## Config + +The `config` key in the domain file maintains the `store_entities_as_slots` parameter. +This parameter is used only in the context of reading stories and turning them into trackers. If the parameter is set +to `True`, this will result in slots being implicitly set from entities if applicable entities are present in the story. +When an entity matches the `from_entity` slot mapping, `store_entities_as_slots` defines whether the entity value should +be placed in that slot. Therefore, this parameter skips adding an explicit `slot_was_set` step manually in the story. +By default, this behaviour is switched on. + +You can turn off this functionality by setting the `store_entities_as_slots` parameter to `false`: + +```yaml-rasa title="domain.yml" +config: + store_entities_as_slots: false +``` + +:::note looking for config.yml? +If you're looking for information on the `config.yml` file, check out the docs on +[Model Configuration](./model-configuration.mdx). +::: diff --git a/docs/docs/event-brokers.mdx b/docs/docs/event-brokers.mdx new file mode 100644 index 0000000..c1a23b1 --- /dev/null +++ b/docs/docs/event-brokers.mdx @@ -0,0 +1,259 @@ +--- +id: event-brokers +sidebar_label: Event Brokers +title: Event Brokers +description: Find out how open source chatbot framework Rasa allows you to stream events to a message broker. +--- + +An event broker allows you to connect your running assistant to other services that process the data coming +in from conversations. The event broker publishes messages to a message streaming service, +also known as a message broker, to forward Rasa [Events](./action-server/events.mdx) from the Rasa server to other services. + +## Format + +All events are streamed to the broker as serialized dictionaries every time +the tracker updates its state. An example event emitted from the `default` +tracker looks like this: + +```json +{ + "sender_id": "default", + "timestamp": 1528402837.617099, + "event": "bot", + "text": "what your bot said", + "data": "some data about e.g. attachments" + "metadata" { + "a key": "a value", + } +} +``` + +The `event` field takes the event's `type_name` (for more on event +types, check out the [events](./action-server/events.mdx) docs). + +## Pika Event Broker + +The example implementation we're going to show you here uses +[Pika](https://pika.readthedocs.io) , the Python client library for +[RabbitMQ](https://www.rabbitmq.com). + +### Adding a Pika Event Broker Using the Endpoint Configuration + +You can instruct Rasa to stream all events to your Pika event broker by adding an `event_broker` section to your +`endpoints.yml`: + +```yaml-rasa (docs/sources/data/test_endpoints/event_brokers/pika_endpoint.yml) +``` + +A comprehensive list of all arguments that can be customized in the `endpoints.yml` file can be found in the [reference documentation](https://rasa.com/docs/rasa/reference/rasa/core/brokers/pika/#__init__). +Rasa will automatically start streaming events when you restart the Rasa server. + +### Adding SSL options to the Pika Event Broker + +You can create RabbitMQ SSL options by setting the following required environment variables: +- `RABBITMQ_SSL_CLIENT_CERTIFICATE`: path to the SSL client certificate +- `RABBITMQ_SSL_CLIENT_KEY`: path to the SSL client key + +Please note that specifying 'RABBITMQ_SSL_CA_FILE' via environment variables is no longer supported, as well as +specifying `RABBITMQ_SSL_KEY_PASSWORD` environment variable - please use a key file that is not encrypted instead. + +### Adding a Pika Event Broker in Python + +Here is how you add it using Python code: + +```python +import asyncio + +from rasa.core.brokers.pika import PikaEventBroker +from rasa.core.tracker_store import InMemoryTrackerStore + +pika_broker = PikaEventBroker('localhost', + 'username', + 'password', + queues=['rasa_events'], + event_loop=event_loop + ) +asyncio.run(pika_broker.connect()) + +tracker_store = InMemoryTrackerStore(domain=domain, event_broker=pika_broker) +``` + +### Implementing a Pika Event Consumer + +You need to have a RabbitMQ server running, as well as another application +that consumes the events. This consumer to needs to implement Pika's +`start_consuming()` method with a `callback` action. Here's a simple +example: + +```python +import json +import pika + + +def _callback(ch, method, properties, body): + # Do something useful with your incoming message body here, e.g. + # saving it to a database + print("Received event {}".format(json.loads(body))) + +if __name__ == "__main__": + + # RabbitMQ credentials with username and password + credentials = pika.PlainCredentials("username", "password") + + # Pika connection to the RabbitMQ host - typically 'rabbit' in a + # docker environment, or 'localhost' in a local environment + connection = pika.BlockingConnection( + pika.ConnectionParameters("rabbit", credentials=credentials) + ) + + # start consumption of channel + channel = connection.channel() + channel.basic_consume(queue="rasa_events", on_message_callback=_callback, auto_ack=True) + channel.start_consuming() +``` + +## Kafka Event Broker + +While RabbitMQ is the default event broker, it is possible to use [Kafka](https://kafka.apache.org/) as the main broker for your +events. Rasa uses the [confluent-kafka](https://docs.confluent.io/platform/current/clients/confluent-kafka-python/html/index.html#) library, a Kafka client written in Python. +You will need a running Kafka server. + +### Partition Key + +Rasa's Kafka producer can optionally be configured to partition messages by conversation ID. +This can be configured by setting `partition_by_sender` in the `endpoints.yml` file to True. +By default, this parameter is set to `False` and the producer will randomly assign a partition to each message. + +```yaml-rasa title="endpoints.yml" +event_broker: + type: kafka + partition_by_sender: True + security_protocol: PLAINTEXT + topic: topic + url: localhost + client_id: kafka-python-rasa +``` + +### Authentication and Authorization + +Rasa's Kafka producer accepts the following types of security protocols: `SASL_PLAINTEXT`, `SSL`, `PLAINTEXT` +and `SASL_SSL`. + +For development environments, or if the brokers servers and clients are located +into the same machine, you can use simple authentication with `SASL_PLAINTEXT` or `PLAINTEXT`. +By using this protocol, the credentials and messages exchanged between the clients and servers +will be sent in plaintext. Thus, this is not the most secure approach, but since it's simple +to configure, it is useful for simple cluster configurations. +`SASL_PLAINTEXT` protocol requires the setup of the `username` and `password` +previously configured in the broker server. + +If the clients or the brokers in the kafka cluster are located in different +machines, it's important to use the `SSL` or `SASL_SSL` protocol to ensure encryption of data +and client authentication. After generating valid certificates for the brokers and the +clients, the path to the certificate and key generated for the producer must +be provided as arguments, as well as the CA's root certificate. + +When using the `SASL_PLAINTEXT` and `SASL_SSL` protocols, the `sasl_mechanism` can be +optionally configured and is set to `PLAIN` by default. Valid values for `sasl_mechanism` +are: `PLAIN`, `GSSAPI`, `OAUTHBEARER`, `SCRAM-SHA-256`, and `SCRAM-SHA-512`. + +If `GSSAPI` is used for the `sasl_mechanism`, you will need to additionally install +[python-gssapi](https://pypi.org/project/python-gssapi/) and the necessary C library +Kerberos dependencies. + +If the `ssl_check_hostname` parameter is enabled, the clients will verify +if the broker's hostname matches the certificate. It's used on client's connections +and inter-broker connections to prevent man-in-the-middle attacks. + +### Adding a Kafka Event Broker Using the Endpoint Configuration + +You can instruct Rasa to stream all events to your Kafka event broker by adding an `event_broker` section to your +`endpoints.yml`. + +Using the `SASL_PLAINTEXT` protocol the endpoints file must have the following entries: + +```yaml-rasa (docs/sources/data/test_endpoints/event_brokers/kafka_sasl_plaintext_endpoint.yml) +``` + +Using the `PLAINTEXT` protocol the endpoints file must have the following entries: + +```yaml-rasa (docs/sources/data/test_endpoints/event_brokers/kafka_plaintext_endpoint.yml) +``` + +If using the `SSL` protocol, the endpoints file should look like: + +```yaml-rasa (docs/sources/data/test_endpoints/event_brokers/kafka_ssl_endpoint.yml) +``` + +If using the `SASL_SSL` protocol, the endpoints file should look like: + +```yaml-rasa (docs/sources/data/test_endpoints/event_brokers/kafka_sasl_ssl_endpoint.yml) +``` + +## SQL Event Broker + +It is possible to use an SQL database as an event broker. Connections to databases are established using +[SQLAlchemy](https://www.sqlalchemy.org/), a Python library which can interact with many +different types of SQL databases, such as [SQLite](https://sqlite.org/index.html), +[PostgreSQL](https://www.postgresql.org/) and more. The default Rasa installation allows connections to SQLite +and PostgreSQL databases. To see other options, please see the +[SQLAlchemy documentation on SQL dialects](https://docs.sqlalchemy.org/en/13/dialects/index.html). + +### Adding a SQL Event Broker Using the Endpoint Configuration + +To instruct Rasa to save all events to your SQL event broker, add an `event_broker` section to your +`endpoints.yml`. For example, a valid SQLite configuration +could look like this: + +```yaml-rasa title="endpoints.yml" +event_broker: + type: SQL + dialect: sqlite + db: events.db +``` + +PostgreSQL databases can be used as well: + +```yaml-rasa title="endpoints.yml" +event_broker: + type: SQL + url: 127.0.0.1 + port: 5432 + dialect: postgresql + username: myuser + password: mypassword + db: mydatabase +``` + +With this configuration applied, Rasa will create a table called `events` on the database, +where all events will be added. + +## FileEventBroker + +It is possible to use the `FileEventBroker` as an event broker. This implementation will log events to a file in json format. +You can provide a path key in the `endpoints.yml` file if you wish to override the default file name: `rasa_event.log`. + +## Custom Event Broker + +If you need an event broker which is not available out of the box, you can implement your own. +This is done by extending the base class `EventBroker`. + +Your custom event broker class must also implement the following base class methods: +- `from_endpoint_config`: creates an `EventBroker` object from the endpoint configuration. +[(source code - see for signature)](https://github.com/RasaHQ/rasa/blob/main/rasa/core/brokers/broker.py#L45). +- `publish`: publishes a json-formatted [Rasa event](https://rasa.com/docs/rasa/reference/rasa/shared/core/events/) into an event queue. +[(source code - see for signature)](https://github.com/RasaHQ/rasa/blob/main/rasa/core/brokers/broker.py#L63). +- `is_ready`: determine whether or not the event broker is ready. [(source code - see for signature)](https://github.com/RasaHQ/rasa/blob/main/rasa/core/brokers/broker.py#L67). +- `close`: close the connection to an event broker. [(source code - see for signature)](https://github.com/RasaHQ/rasa/blob/main/rasa/core/brokers/broker.py#L75). + +### Configuration + +Put the module path to your custom event broker and the parameters you require in your `endpoints.yml`: + + ```yaml-rasa title="endpoints.yml" + event_broker: + type: path.to.your.module.Class + url: localhost + a_parameter: a value + another_parameter: another value + ``` diff --git a/docs/docs/fallback-handoff.mdx b/docs/docs/fallback-handoff.mdx new file mode 100644 index 0000000..19429f5 --- /dev/null +++ b/docs/docs/fallback-handoff.mdx @@ -0,0 +1,400 @@ +--- +id: fallback-handoff +sidebar_label: Fallback and Human Handoff +title: Fallback and Human Handoff +abstract: This is a guide on how to handle various failures of your assistant. +--- +import useBaseUrl from '@docusaurus/useBaseUrl'; + +Even if you design your bot perfectly, users will inevitably say things to your +assistant that you did not anticipate. In these cases, your assistant will fail, +and it's important you ensure it does so gracefully. + +## Handling Out-of-scope Messages + +To avoid user frustration, you can handle questions you know your users may ask, +but for which you haven't implemented a user goal yet. + + +### 1. Creating an Out-of-scope Intent + +You will need to define an `out_of_scope` intent in your NLU training data and add any known +out-of-scope requests as training examples, for example: + +```yaml-rasa title="nlu.yml" +nlu: +- intent: out_of_scope + examples: | + - I want to order food + - What is 2 + 2? + - Who's the US President? +``` + +As with every intent, you should source the majority of your examples +[from real conversations](conversation-driven-development.mdx "Learning from real conversations via conversation-driven development"). + + +### 2. Defining the response message + +You'll need to define an out-of-scope response in the domain file. +Using the utterance `utter_out_of_scope` as the default response, that would look like: + +```yaml-rasa title="domain.yml" +responses: + utter_out_of_scope: + - text: Sorry, I can't handle that request. +``` + +### 3. Creating an Out-of-Scope Rule + +Finally, you will need to write a rule for what should happen for in out-of-scope request: + +```yaml-rasa title="rules.yml" +rules: +- rule: out-of-scope + steps: + - intent: out_of_scope + - action: utter_out_of_scope +``` + +### Handling Specific Out-of-scope Messages + +If you observe your users asking for certain things that you'll +want to turn into a user goal in future, you can handle these as separate intents, to let +the user know you've understood their message, but don't have a solution quite yet. For example, +if the user asks “I want to apply for a job at Rasa”, we can then reply with +“I understand you're looking for a job, but I'm afraid I can't handle that skill yet.” + +Similar to the `out_of_scope` intent example, you'll need to create a new intent with +training examples, define the response message, and create a rule. + + +## Fallbacks + +Although Rasa will generalize to unseen messages, some +messages might receive a low classification confidence. Using Fallbacks will +help ensure that these low confidence messages are handled gracefully, giving your +assistant the option to either respond with a default message or attempt to disambiguate +the user input. + + +### NLU Fallback + +To handle incoming messages with low NLU confidence, use the +[FallbackClassifier](./components.mdx#fallbackclassifier). +Using this configuration, the intent `nlu_fallback` will be predicted when all other intent +predictions fall below the configured confidence threshold. You can then write a rule +for what the bot should do when `nlu_fallback` is predicted. + +#### 1. Updating the configuration + +To use the FallbackClassifier, add it to your NLU pipeline: + +```yaml-rasa title="config.yml" +pipeline: +# other components +- name: FallbackClassifier + threshold: 0.7 +``` + +#### 2. Defining the response message + +Define the message the bot should send when a message is classified with low confidence +by adding a response: + +```yaml-rasa title="domain.yml" +responses: + utter_please_rephrase: + - text: I'm sorry, I didn't quite understand that. Could you rephrase? +``` + +#### 3. Creating an NLU fallback rule + +The following +[Rule](./rules.mdx) will ask the user to rephrase when they send a message that is +classified with low confidence: + +```yaml-rasa title="rules.yml" +rules: +- rule: Ask the user to rephrase whenever they send a message with low NLU confidence + steps: + - intent: nlu_fallback + - action: utter_please_rephrase +``` + + +### Handling Low Action Confidence + +As users might send unexpected messages, +it is possible that their behavior will lead them down unknown conversation paths. +Rasa's machine learning policies such as the [TED Policy](./policies.mdx#ted-policy) +are optimized to handle these unknown paths. + +To handle cases when the machine learning policies can't predict the +next action with high confidence, you can configure the +[Rule Policy](./policies.mdx#rule-policy) to predict a +default action if no [Policy](./policies.mdx) has a next action prediction with +confidence above a configurable threshold. + +You can configure the action that is run in case low of action confidence as well as +the corresponding confidence threshold using the following steps: + + +#### 1. Updating the configuration + +You will need to add the RulePolicy to your policies in config.yml. +By default, the rule policy comes with the settings below: + +```yaml-rasa title="config.yml" +policies: +- name: RulePolicy + # Confidence threshold for the `core_fallback_action_name` to apply. + # The action will apply if no other action was predicted with + # a confidence >= core_fallback_threshold + core_fallback_threshold: 0.4 + core_fallback_action_name: "action_default_fallback" + enable_fallback_prediction: True +``` + + + +#### 2. Defining the default response message + +To define what your bot will say when action confidence is below the threshold, +define a response `utter_default`: + +```yaml-rasa title="domain.yml" +responses: + utter_default: + - text: Sorry I didn't get that. Can you rephrase? +``` + +When an action confidence is below the threshold, Rasa will run the action +`action_default_fallback`. This will send the response `utter_default` and revert back to the +state of the conversation before the user message that caused the +fallback, so it will not influence the prediction of future actions. + + +#### 3. Customizing the default action (optional) + +`action_default_fallback` is a default action in Rasa that sends the +`utter_default` response to the user. You can create your own custom action to use as a +fallback (see [Custom Actions](./actions.mdx#custom-actions) for more info on custom actions). +The following snippet is an implementation of a custom action which does the same as +`action_default_fallback` but dispatches a different template +`utter_fallback_template`: + +```python title="actions.py" +from typing import Any, Text, Dict, List + +from rasa_sdk import Action, Tracker +from rasa_sdk.events import UserUtteranceReverted +from rasa_sdk.executor import CollectingDispatcher + +class ActionDefaultFallback(Action): + """Executes the fallback action and goes back to the previous state + of the dialogue""" + + def name(self) -> Text: + return ACTION_DEFAULT_FALLBACK_NAME + + async def run( + self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> List[Dict[Text, Any]]: + dispatcher.utter_message(template="my_custom_fallback_template") + + # Revert user message which led to fallback. + return [UserUtteranceReverted()] +``` + + + +### Two-Stage Fallback + +To give the bot a chance to figure out what the user wants, you will usually want it to attempt to disambiguate the user's message by asking +clarifying questions. The Two-Stage Fallback is made to handle low NLU confidence in multiple stages +using the following sequence: + +1. A user message is classified with low confidence + - The user is asked to confirm the intent +2. The user confirms or denies the intent + - If they confirm, the conversation continues as if the intent was classified + with high confidence from the beginning. No further fallback steps are taken. + - If they deny, the user is asked to rephrase their message. +3. The user rephrases their intent + - If the message is classified with high confidence, the conversation + continues as if the user had this intent from the beginning. + - If the rephrased user message still has low confidence, the user + is asked to confirm the intent. +4. The user confirms or denies the rephrased intent + - If they confirm, the conversation continues as if the user had this intent from the beginning. + - If they deny, an ultimate fallback action is triggered (e.g. a handoff to a human). The default ultimate + fallback action is to call `action_default_fallback`. This action causes the bot to utter the `utter_default` + response and to reset the state of the conversation as if the turns that happened during the Two-Stage Fallback did not occur. + +The Two-Stage-Fallback can be enabled using the following steps: + + +#### 1. Updating the configuration + +Add FallbackClassifier to your pipeline and the [RulePolicy](./policies.mdx#rule-policy) +to your policy configuration: + +```yaml title="config.yml" +recipe: default.v1 +pipeline: +# other components +- name: FallbackClassifier + threshold: 0.7 + +policies: +# other policies +- RulePolicy +``` + + +#### 2. Defining the fallback responses + +To define how your bot asks the user to rephrase their message, +define the response `utter_ask_rephrase`: + +```yaml-rasa title="domain.yml" +responses: + utter_ask_rephrase: + - text: I'm sorry, I didn't quite understand that. Could you rephrase? +``` + +Rasa provides default implementations for asking which intent the user +meant and for asking the user to rephrase. To customize the behavior of these actions, +see the documentation on [default actions](default-actions.mdx). + +#### 3. Defining a Two-Stage Fallback rule + +Add the following [Rule](./rules.mdx) to your training data. This rule will make sure +that the Two-Stage-Fallback will be activated whenever a message is received with +low classification confidence: + +```yaml-rasa title="rules.yml" +rules: +- rule: Implementation of the Two-Stage-Fallback + steps: + - intent: nlu_fallback + - action: action_two_stage_fallback + - active_loop: action_two_stage_fallback +``` + +### 4. Defining an ultimate fallback action + +To define the bot's response when the user denies the rephrased intent, define the response `utter_default`: + +```yaml-rasa title="domain.yml" +responses: + utter_default: + - text: I'm sorry, I can't help you. +``` + +Or, you can customize `action_default_fallback` for more complex behavior by writing a [Custom Action](./actions.mdx#custom-actions). +For example, if you want the bot to call a human and stop interacting with the user: + +```python title="actions.py" +from typing import Any, Dict, List, Text + +from rasa_sdk import Action, Tracker +from rasa_sdk.events import UserUtteranceReverted +from rasa_sdk.executor import CollectingDispatcher + +class ActionDefaultFallback(Action): + def name(self) -> Text: + return "action_default_fallback" + + def run( + self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> List[Dict[Text, Any]]: + + # tell the user they are being passed to a customer service agent + dispatcher.utter_message(text="I am passing you to a human...") + + # assume there's a function to call customer service + # pass the tracker so that the agent has a record of the conversation between the user + # and the bot for context + call_customer_service(tracker) + + # pause the tracker so that the bot stops responding to user input + return [ConversationPaused(), UserUtteranceReverted()] +``` + +:::caution Events Returned By A Custom Ultimate Fallback Action + You should include `UserUtteranceReverted()` as one of the events returned by your custom + `action_default_fallback`. Not including this event will cause the tracker to include all events that happened + during the Two-Stage Fallback process which could interfere with subsequent action predictions from the bot's policy + pipeline. It is better to treat events that occurred during the Two-Stage Fallback process as if they did not happen + so that your bot can apply its rules or memorized stories to correctly predict the next action. +::: + + +## Human Handoff + +As part of your fallback action, you may want the bot to hand over to a human agent +e.g. as the final action in Two-Stage-Fallback, or when the user explicitly asks +for a human. A straightforward way to achieve human handoff is to configure your +[messaging or voice channel](messaging-and-voice-channels.mdx) to switch +which host it listens to based on a specific bot or user message. + +For example, as the final action of Two-Stage-Fallback, the bot could ask the user, +"Would you like to be transferred to a human assistant?" and if they say yes, the +bot sends a message with a specific payload like +e.g. "handoff_to_human" to the channel. When the channel sees this message, it stops listening +to the Rasa server, and sends a message to the human channel with the transcript +of the chat conversation up to that point. + +The implementation for handing off to a human from the front end will depend on which +channel you're using. You can +see an example implementation using an adaption of the [chatroom](https://github.com/scalableminds/chatroom) channel +in the [Financial Demo](https://github.com/RasaHQ/financial-demo) and +[Helpdesk-Assistant](https://github.com/RasaHQ/helpdesk-assistant) +starterpacks. + + + +## Summary + +To let your assistant gracefully handle failures, you should handle known +out-of-scope messages and add a form of fallback behavior. If you want to add human +handoff, you can add it in addition or as a final step in your fallback +set up. +Here's a summary of changes you need to make for each method: + +For out-of-scope intents: + - [ ] Add training examples for each out-of-scope intent to your NLU data + - [ ] Define the out-of-scope response or action + - [ ] Define rules for each out-of-scope intent + - [ ] Add the RulePolicy to config.yml + +For single stage NLU fallback: + - [ ] Add FallbackClassifier to your pipeline in config.yml + - [ ] Define the fallback response or action + - [ ] Define a rule for the `nlu_fallback` intent + - [ ] Add the RulePolicy to config.yml + +For handling low core confidence: + - [ ] Configure the RulePolicy for core fallback in config.yml + - [ ] Optionally customize the fallback action you configure + - [ ] Define an `utter_default` response + +For Two-Stage Fallback: + - [ ] Add FallbackClassifier to your pipeline in `config.yml` + - [ ] Define a rule for the `nlu_fallback` intent that triggers the `action_two_stage_fallback` action + - [ ] Define an out-of-scope intent in your domain + - [ ] Add RulePolicy to config.yml + +For handing off to a human: + - [ ] Configure your front end to switch hosts + - [ ] Write a custom action (which could be your fallback action) to send the handoff payload + - [ ] Add a rule for triggering handoff (if not part of fallback) + - [ ] Add RulePolicy to config.yml diff --git a/docs/docs/forms.mdx b/docs/docs/forms.mdx new file mode 100644 index 0000000..ded6db5 --- /dev/null +++ b/docs/docs/forms.mdx @@ -0,0 +1,501 @@ +--- +id: forms +sidebar_label: Forms +title: Forms +description: Follow a rule-based process of information gathering using forms in open source bot framework Rasa. +abstract: One of the most common conversation patterns is to collect a few pieces of information from a user in order to do something (book a restaurant, call an API, search a database, etc.). This is also called **slot filling**. +--- + +## Usage + +To use forms with Rasa you need to make sure that the +[Rule Policy](./policies.mdx#rule-policy) is added to your policy configuration. +For example: + +```yaml-rasa +policies: +- name: RulePolicy +``` + +### Defining a Form + +Define a form by adding it to the `forms` section in your [domain](./domain.mdx). +The name of the form is also the name of the action which you can use in +[stories](./stories.mdx) or [rules](./rules.mdx) to handle form executions. +You will need to specify a list of slot names to the mandatory `required_slots` key. + +The following example form `restaurant_form` will fill the slot +`cuisine` and slot `num_people`. + +```yaml-rasa +entities: +- cuisine +- number +slots: + cuisine: + type: text + mappings: + - type: from_entity + entity: cuisine + num_people: + type: any + mappings: + - type: from_entity + entity: number +forms: + restaurant_form: + required_slots: + - cuisine + - num_people +``` + +You can define a list of intents to ignore for the whole form under the +`ignored_intents` key. Intents listed under `ignored_intents` will be added to the +`not_intent` key of each slot mapping. + +For example, if you do not want any of the required slots of a form to be filled when +the intent is `chitchat`, then you would need to define the following (after the form +name and under the `ignored_intents` keyword): + +```yaml-rasa +entities: +- cuisine +- number +slots: + cuisine: + type: text + mappings: + - type: from_entity + entity: cuisine + num_people: + type: any + mappings: + - type: from_entity + entity: number +forms: + restaurant_form: + ignored_intents: + - chitchat + required_slots: + - cuisine + - num_people +``` + +Once the form action gets called for the first time, the form gets activated and will +prompt the user for the next required slot value. It does this by +looking for a [response](./responses.mdx) called +`utter_ask_<form_name>_<slot_name>` or `utter_ask_<slot_name>` if the former isn't +found. Make sure to define these responses in your domain file for +each required slot. + +### Activating a Form + +To activate a form you need to add a [story](./stories.mdx) or [rule](./rules.mdx), +which describes when the assistant should run the form. In the case a specific intent +triggering a form, you can for example use the following rule: + +```yaml-rasa +rules: +- rule: Activate form + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form +``` + +:::note +The `active_loop: restaurant_form` step indicates that the form should be activated after +`restaurant_form` was run. +::: + +### Deactivating a Form + +A form will automatically deactivate itself once all required slots are filled. +You can describe your assistant's behavior for the end of a form with a rule or a story. +If you don't add an applicable story or rule, the assistant will automatically listen +for the next user message after the form is finished. +The following example runs the utterances `utter_submit` and `utter_slots_values` as soon as the form +`your_form` filled all required slots. + +```yaml-rasa +rules: +- rule: Submit form + condition: + # Condition that form is active. + - active_loop: restaurant_form + steps: + # Form is deactivated + - action: restaurant_form + - active_loop: null + - slot_was_set: + - requested_slot: null + # The actions we want to run when the form is submitted. + - action: utter_submit + - action: utter_slots_values +``` + +Users might want to break out of a form early. Please see +[Writing Stories / Rules for Unhappy Form Paths](./forms.mdx#writing-stories--rules-for-unhappy-form-paths) on how to +write stories or rules for this case. + +### Slot Mappings + +:::caution Changed in 3.0 +As of 3.0, [slot mappings](./domain.mdx#slot-mappings) are defined in the `slots` section of the domain. +This change allows the same slot mapping to be reused across multiple forms, removing any unnecessary duplication. +Please follow the [migration guide](./migration-guide.mdx#slot-mappings) to update your assistant. + +Note specifically the role of [Mapping Conditions](./domain.mdx#mapping-conditions) and the +[unique entity mapping](./domain.mdx#unique-from_entity-mapping-matching) constraint. +::: + +### Writing Stories / Rules for Unhappy Form Paths + +Your users will not always respond with the information you ask of them. +Typically, users will ask questions, make chitchat, change their mind, or otherwise +stray from the happy path. + +While a form is active, if a user's input does not fill the requested slot, the execution of +the form action will be rejected i.e. the form will automatically raise an `ActionExecutionRejection`. +These are the specific scenarios in which a form will raise an `ActionExecutionRejection`: + +* a slot was requested, but the user didn't fill the slot with their last message and + you didn't define a custom action for + [validating slots](forms.mdx#validating-form-input) or + [extracting slots](forms.mdx#custom-slot-mappings). +* a slot was requested, but your custom action for + [validating slots](forms.mdx#validating-form-input) or + [extracting slots](forms.mdx#custom-slot-mappings) didn't return any `SlotSet` events. + +To intentionally reject the form execution, you can also return an `ActionExecutionRejected` event as part of your +custom validations or slot mappings. + +To handle situations that might cause a form's execution to be rejected, you can write rules +or stories that include the expected interruptions. For example, if you expect your users to chitchat with your bot, +you could add a rule to handle this: + +```yaml-rasa +rules: +- rule: Example of an unhappy path + condition: + # Condition that form is active. + - active_loop: restaurant_form + steps: + # This unhappy path handles the case of an intent `chitchat`. + - intent: chitchat + - action: utter_chitchat + # Return to form after handling the `chitchat` intent + - action: restaurant_form + - active_loop: restaurant_form +``` + +In some situations, users may change their mind in the middle of the form action +and decide not to go forward with their initial request. In cases like this, the +assistant should stop asking for the requested slots. + +You can handle such situations +gracefully using a default action `action_deactivate_loop` which will deactivate +the form and reset the requested slot. An example story of such conversation could +look as follows: + +```yaml-rasa +stories: +- story: User interrupts the form and doesn't want to continue + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - intent: stop + - action: utter_ask_continue + - intent: stop + - action: action_deactivate_loop + - active_loop: null +``` + +It is **strongly** recommended that you build these rules or stories using +[interactive learning](./writing-stories.mdx#using-interactive-learning). +If you write these rules / stories by hand you will likely miss important +things. + +## Advanced Usage + +Forms are fully customizable using [Custom Actions](./actions.mdx#custom-actions). + +### Validating Form Input + +After extracting a slot value from user input, you can validate the extracted slots. +By default Rasa only validates if any slot was filled after requesting +a slot. + +You can implement a [Custom Action](./actions.mdx#custom-actions) `validate_<form_name>` +to validate any extracted slots. Make sure to add this action to the `actions` +section of your domain: + +```yaml-rasa +actions: +- validate_restaurant_form +``` + +When the form is executed it will run your custom action after every user turn to validate the latest filled slots. + +This custom action can extend `FormValidationAction` class to simplify +the process of validating extracted slots. In this case, you need to write functions +named `validate_<slot_name>` for every extracted slot. + +The following example shows the implementation of a custom action +which validates that the slot named `cuisine` is valid. + +```python +from typing import Text, List, Any, Dict + +from rasa_sdk import Tracker, FormValidationAction +from rasa_sdk.executor import CollectingDispatcher +from rasa_sdk.types import DomainDict + + +class ValidateRestaurantForm(FormValidationAction): + def name(self) -> Text: + return "validate_restaurant_form" + + @staticmethod + def cuisine_db() -> List[Text]: + """Database of supported cuisines""" + + return ["caribbean", "chinese", "french"] + + def validate_cuisine( + self, + slot_value: Any, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: DomainDict, + ) -> Dict[Text, Any]: + """Validate cuisine value.""" + + if slot_value.lower() in self.cuisine_db(): + # validation succeeded, set the value of the "cuisine" slot to value + return {"cuisine": slot_value} + else: + # validation failed, set this slot to None so that the + # user will be asked for the slot again + return {"cuisine": None} +``` + +You can also extend the `Action` class and retrieve extracted slots with `tracker.slots_to_validate` +to fully customize the validation process. + +### Custom Slot Mappings + +:::caution Changed in 3.0 +The `slots_mapped_in_domain` argument provided to the `required_slots` method of `FormValidationAction` +has been replaced by the `domain_slots` argument, please update your custom actions to the new argument name. +::: + +If none of the predefined [Slot Mappings](./domain.mdx#slot-mappings) fit your use +case, you can use the +[Custom Action](./actions.mdx#custom-actions) `validate_<form_name>` to write your own +extraction code. Rasa will trigger this action when the form is run. + +If you're using the Rasa SDK we recommend you to extend the provided +`FormValidationAction`. When using the `FormValidationAction`, three steps are required +to extract customs slots: + +1. Define a method `extract_<slot_name>` for every slot that should be mapped in a custom way. + Each slot which has been defined in the `domain.yml` file with a custom mapping **must** have its own independent + implementation of an `extract_<slot_name>` method. +2. In your domain file, for your form's `required_slots`, list all required slots, with both predefined and custom mappings. + +In addition, you can override the `required_slots` method to add dynamically requested slots: you can read more in the +[Dynamic Form Behavior](./forms.mdx#dynamic-form-behavior) section. + +:::note +If you have added a slot with a custom mapping in the `slots` section of the domain file which you only +want to be validated within the context of a form by a custom action extending `FormValidationAction`, +please make sure that this slot has a mapping of type `custom` and that the slot name is included in the +form's `required_slots`. +::: + +The following example shows the implementation of a form which extracts a slot +`outdoor_seating` in a custom way, in addition to the slots which use predefined mappings. +The method `extract_outdoor_seating` sets the slot `outdoor_seating` based on whether +the keyword `outdoor` was present in the last user message. + +```python +from typing import Dict, Text, List, Optional, Any + +from rasa_sdk import Tracker +from rasa_sdk.executor import CollectingDispatcher +from rasa_sdk.forms import FormValidationAction + + +class ValidateRestaurantForm(FormValidationAction): + def name(self) -> Text: + return "validate_restaurant_form" + + async def extract_outdoor_seating( + self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict + ) -> Dict[Text, Any]: + text_of_last_user_message = tracker.latest_message.get("text") + sit_outside = "outdoor" in text_of_last_user_message + + return {"outdoor_seating": sit_outside} +``` + +By default the `FormValidationAction` +will automatically set the [`requested_slot`](forms.mdx#the-requested_slot-slot) to the +first slot specified in `required_slots` which is not filled. + +### Dynamic Form Behavior + +By default, Rasa will ask for the next empty slot from the slots +listed for your form in the domain file. If you use +[custom slot mappings](forms.mdx#custom-slot-mappings) and the `FormValidationAction`, +it will ask for the first empty slot returned by the `required_slots` method. If all +slots in `required_slots` are filled the form will be deactivated. + +You can update the required slots of your form dynamically. +This is, for example, useful when you need to fill additional slots based on how +a previous slot was filled or when you want to change the order in which slots are requested. + +If you are using the Rasa SDK, we strongly recommend that you use the `FormValidationAction` and +override `required_slots` to fit your dynamic behavior. You must implement +a method `extract_<slot name>` for every slot which doesn't use a predefined mapping, +as described in [Custom Slot Mappings](forms.mdx#custom-slot-mappings). +The example below will ask the user if they want to sit in +the shade or in the sun in case they said they want to sit outside. + +```python +from typing import Text, List, Optional + +from rasa_sdk.forms import FormValidationAction + +class ValidateRestaurantForm(FormValidationAction): + def name(self) -> Text: + return "validate_restaurant_form" + + async def required_slots( + self, + domain_slots: List[Text], + dispatcher: "CollectingDispatcher", + tracker: "Tracker", + domain: "DomainDict", + ) -> List[Text]: + additional_slots = ["outdoor_seating"] + if tracker.slots.get("outdoor_seating") is True: + # If the user wants to sit outside, ask + # if they want to sit in the shade or in the sun. + additional_slots.append("shade_or_sun") + + return additional_slots + domain_slots +``` + +If conversely, you want to remove a slot from the form's `required_slots` defined in the domain file under certain conditions, +you should copy the `domain_slots` over to a new variable and apply changes to that new variable instead of directly modifying +`domain_slots`. Directly modifying `domain_slots` can cause unexpected behaviour. For example: + +```python +from typing import Text, List, Optional + +from rasa_sdk.forms import FormValidationAction + +class ValidateBookingForm(FormValidationAction): + def name(self) -> Text: + return "validate_booking_form" + + async def required_slots( + self, + domain_slots: List[Text], + dispatcher: "CollectingDispatcher", + tracker: "Tracker", + domain: "DomainDict", + ) -> List[Text]: + updated_slots = domain_slots.copy() + if tracker.slots.get("existing_customer") is True: + # If the user is an existing customer, + # do not request the `email_address` slot + updated_slots.remove("email_address") + + return updated_slots +``` + +### The requested_slot slot + +The slot `requested_slot` is automatically added to the domain as a +slot of type [`text`](domain.mdx#text-slot). The value of the `requested_slot` will be +ignored during conversations. If you want to change this behavior, you need to add +the `requested_slot` to your domain file as a categorical slot with +`influence_conversation` set to `true`. +You might want to do this if you +want to handle your unhappy paths differently, depending on what slot is +currently being asked from the user. For example, if your users respond +to one of the bot's questions with another question, like *why do you need to know that?* +The response to this `explain` intent depends on where we are in the story. +In the restaurant case, your stories would look something like this: + +```yaml-rasa +stories: +- story: explain cuisine slot + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant + - slot_was_set: + - requested_slot: cuisine + - intent: explain + - action: utter_explain_cuisine + - action: restaurant_form + - active_loop: null + +- story: explain num_people slot + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant + - slot_was_set: + - requested_slot: cuisine + - slot_was_set: + - requested_slot: num_people + - intent: explain + - action: utter_explain_num_people + - action: restaurant_form + - active_loop: null +``` + +Again, it is **strongly** recommended that you use +[interactive learning](./writing-stories.mdx#using-interactive-learning) to build these stories. + +### Using a Custom Action to Ask For the Next Slot + +As soon as the form determines which slot has to be filled next by the user, it will +execute the action `utter_ask_<form_name>_<slot_name>` or `utter_ask_<slot_name>` +to ask the user to provide the necessary information. If a regular utterance is not +enough, you can also use a custom action `action_ask_<form_name>_<slot_name>` or +`action_ask_<slot_name>` to ask for the next slot. + +```python +from typing import Dict, Text, List + +from rasa_sdk import Tracker +from rasa_sdk.events import EventType +from rasa_sdk.executor import CollectingDispatcher +from rasa_sdk import Action + + +class AskForSlotAction(Action): + def name(self) -> Text: + return "action_ask_cuisine" + + def run( + self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict + ) -> List[EventType]: + dispatcher.utter_message(text="What cuisine?") + return [] +``` + +If there is more than one asking option for the slot, Rasa prioritizes in the following order: + +1. `action_ask_<form_name>_<slot_name>` +2. `utter_ask_<form_name>_<slot_name>` +3. `action_ask_<slot_name>` +4. `utter_ask_<slot_name>` + diff --git a/docs/docs/generating-nlu-data.mdx b/docs/docs/generating-nlu-data.mdx new file mode 100644 index 0000000..91f8b73 --- /dev/null +++ b/docs/docs/generating-nlu-data.mdx @@ -0,0 +1,211 @@ +--- +id: generating-nlu-data +sidebar_label: Generating NLU Data +title: Generating NLU Data +--- + +NLU (Natural Language Understanding) is the part of Rasa that performs +intent classification, entity extraction, and response retrieval. + +NLU will take in a sentence such as "I am looking for a French restaurant in the center +of town" and return structured data like: + +```json +{ + "intent": "search_restaurant", + "entities": { + "cuisine": "French", + "location": "center" + } +} +``` + +Building NLU models is hard, and building ones that are production-ready is even harder. +Here are some tips for designing your NLU training data and pipeline to get the most +out of your bot. + +## Conversation-Driven Development for NLU + +Conversation-Driven Development (CDD) means letting real user conversations guide your +development. For building a great NLU model, this means two key things: + +### Gather Real Data +When it comes to building out NLU training data, developers are sometimes tempted +to use text generation tools or templates to quickly increase the number of training examples. +This is a bad idea for two reasons: + +* First, your synthetic data won't look like the messages +that users actually send to your assistant, so your model will underperform. +* Second, by training and testing on synthetic data, you trick yourself into thinking that your +model *is* actually performing well, and you won't notice major issues. + + +Remember that if you use a script to generate training data, the only thing your model can +learn is how to reverse-engineer the script. + + +To avoid these problems, it is always a good idea to collect as much real user data +as possible to use as training data. Real user messages can be messy, contain typos, +and be far from 'ideal' examples of your intents. But keep in mind that those are the +messages you're asking your model to make predictions about! +Your assistant will always make mistakes initially, but +the process of training & evaluating on user data will set your model up to generalize +much more effectively in real-world scenarios. + +### Share with Test Users Early + +In order to gather real data, you’re going to need real user messages. A bot developer +can only come up with a limited range of examples, and users will always surprise you +with what they say. This means you should share your bot with test users outside the +development team as early as possible. +See the full [CDD guidelines](./conversation-driven-development.mdx) for more details. + +## Avoiding Intent Confusion + +Intents are classified using character and word-level features extracted from your +training examples, depending on what [featurizers](./components.mdx) +you've added to your NLU pipeline. When different intents contain the same +words ordered in a similar fashion, this can create confusion for the intent classifier. + +### Splitting on Entities vs Intents + +Intent confusion often occurs when you want your assistant's response to be conditioned on +information provided by the user. For example, +"How do I migrate to Rasa from IBM Watson?" versus "I want to migrate from Dialogflow." + +Since each of these messages will lead to a different response, your initial approach might be to create +separate intents for each migration type, e.g. `watson_migration` and `dialogflow_migration`. +However, these intents are trying to achieve the same goal (migrating to Rasa) and will +likely be phrased similarly, which may cause the model to confuse these intents. + +To avoid intent confusion, group these training examples into single `migration` intent and make +the response depend on the value of a categorical `product` slot that comes from an entity. +This also makes it easy to handle the case when no entity is provided, +e.g. "How do I migrate to Rasa?" For example: + +```yaml-rasa +stories: +- story: migrate from IBM Watson + steps: + - intent: migration + entities: + - product + - slot_was_set: + - product: Watson + - action: utter_watson_migration + +- story: migrate from Dialogflow + steps: + - intent: migration + entities: + - product + - slot_was_set: + - product: Dialogflow + - action: utter_dialogflow_migration + +- story: migrate from unspecified + steps: + - intent: migration + - action: utter_ask_migration_product +``` + +## Improving Entity Recognition + +With Rasa, you can define custom entities and annotate them in your training data +to teach your model to recognize them. Rasa also provides components +to extract pre-trained entities, as well as other forms of training data to help +your model recognize and process entities. + +### Pre-trained Entity Extractors + +Common entities such as names, addresses, and cities require a large amount of training +data for an NLU model to generalize effectively. + +Rasa provides two great options for +pre-trained extraction: [SpacyEntityExtractor](./components.mdx#SpacyEntityExtractor) +and [DucklingEntityExtractor](./components.mdx#DucklingEntityExtractor). +Because these extractors have been pre-trained on a large corpus of data, you can use them +to extract the entities they support without annotating them in your training data. + +### Regexes + +Regexes are useful for performing entity extraction on structured patterns such as 5-digit +U.S. zip codes. Regex patterns can be used to generate features for the NLU model to learn, +or as a method of direct entity matching. +See [Regular Expression Features](./training-data-format.mdx#regular-expressions) +for more information. + +### Lookup Tables + +Lookup tables are processed as a regex pattern that checks if any of the lookup table +entries exist in the training example. Similar to regexes, lookup tables can be used +to provide features to the model to improve entity recognition, or used to perform +match-based entity recognition. Examples of useful applications of lookup tables are +flavors of ice cream, brands of bottled water, and even sock length styles +(see [Lookup Tables](./training-data-format.mdx#lookup-tables)). + +### Synonyms + +Adding synonyms to your training data is useful for mapping certain entity values to a +single normalized entity. Synonyms, however, are not meant for improving your model's +entity recognition and have no effect on NLU performance. + +A good use case for synonyms is when normalizing entities belonging to distinct groups. +For example, in an assistant that asks users what insurance policies they're interested +in, they might respond with "my truck," "a car," or "I drive a batmobile." +It would be a good idea to map `truck`, `car`, and `batmobile` to the normalized value +`auto` so that the processing logic will only need to account for a narrow set of +possibilities (see [synonyms](./training-data-format.mdx#synonyms)). + +Synonyms can also be used to standardize the extracted entities. A synonym for `iPhone` can +map `iphone` or `IPHONE` to the synonym without adding these options in the synonym examples. + +## Handling Edge Cases + +### Misspellings + +Coming across misspellings is inevitable, so your bot needs an effective way to +handle this. Keep in mind that the goal is not to correct misspellings, but to +correctly identify intents and entities. For this reason, while a spellchecker may +seem like an obvious solution, adjusting your featurizers and training data is often +sufficient to account for misspellings. + +Adding a character-level featurizer provides +an effective defense against spelling errors by accounting for parts of words, instead +of only whole words. You can add character level featurization to your pipeline by +using the `char_wb` analyzer for the `CountVectorsFeaturizer`, for example: + +```yaml-rasa +pipeline: +# <other components> +- name: CountVectorsFeaturizer + analyze: char_wb + min_ngram: 1 + max_ngram: 4 +# <other components> +``` + +In addition to character-level featurization, you can add common misspellings to +your training data. + +### Defining an Out-of-scope Intent + +It is always a good idea to define an `out_of_scope` intent in your bot to capture +any user messages outside of your bot's domain. When an `out_of_scope` intent is +identified, you can respond with messages such as "I'm not sure how to handle that, +here are some things you can ask me..." to gracefully guide the user towards a +supported skill. + +## Shipping Updates + +Treat your data like code. In the same way that you would never ship code updates +without reviews, updates to your training data should be carefully reviewed because +of the significant influence it can have on your model's performance. + +Use a version control system such as Github or Bitbucket to track changes to your +data and rollback updates when necessary. + +Be sure to build tests for your NLU models to [evaluate performance](./testing-your-assistant.mdx) as training data +and hyper-parameters change. Automate these tests in a [CI pipeline](./setting-up-ci-cd.mdx) such as Jenkins +or Git Workflow to streamline your development process and ensure that only +high-quality updates are shipped. diff --git a/docs/docs/glossary.mdx b/docs/docs/glossary.mdx new file mode 100644 index 0000000..dfbc9a1 --- /dev/null +++ b/docs/docs/glossary.mdx @@ -0,0 +1,211 @@ +--- +id: glossary +sidebar_label: Rasa Glossary +title: Rasa Glossary +description: Glossary for all Rasa-related terms +--- + +## [Action](./actions.mdx) + +A single step that a bot takes in a conversation (e.g. calling an API or sending a response back to the user). + +## [Action Server](./action-server/index.mdx) + +The server that runs custom action code, separate from Rasa. Rasa maintains the Rasa SDK in Python for implementing custom actions, although it's also possible to write custom actions in other languages. + +## Annotation + +Adding labels to messages and conversations so that they can be used to train a model. + +## [Anonymization](./pii-management.mdx) + +The process of replacing personally identifiable information (PII) with masked, artificial or constant text values. +This is done to protect the privacy of users. + +## [Business Logic](./business-logic.mdx) + +Conditions that need to be fulfilled due to business requirements. For example: requiring a first and last name, an address, and a password before an account can be created. In a Rasa assistant, business logic is implemented using rule-based actions like [forms](./forms.mdx). + +## [Chitchat](./chitchat-faqs.mdx) + +A conversation pattern where the user says something that isn't directly related to their goal. +This can include things like greetings, asking how you are etc. +Read about handling [Chitchat and FAQs](./chitchat-faqs.mdx) to learn how to implement this with Rasa. + +## CMS + +A way to store bot responses externally instead of including them directly in the domain. Content Management Systems decouple response text from training data. For more information, see [NLG Servers](./nlg.mdx). + +## [Conversation-Driven Development (CDD)](./conversation-driven-development.mdx) + +The process of using user messages and conversation data to influence the design of an assistant and train the model, combined with engineering best practices. There are 6 steps that make up CDD: Share, Review, Annotate, Fix, Track, and Test. + +## [Conversation Tests](./testing-your-assistant.mdx) + +Modified story format that includes the full text of the user message in addition to the intent label. Test conversations are saved to a test set file (conversation_tests.md), which is used to evaluate the model’s predictions across an entire conversation. + +## [Component](./components.mdx) + +An element in the an assistant's [NLU pipeline](./tuning-your-model.mdx#how-to-choose-a-pipeline) in the [Model Configuration](./model-configuration.mdx). + +Incoming messages are processed by a sequence of components called a pipeline. A component can perform tasks ranging from entity extraction to intent classification to pre-processing. + +## [Conditional Response Variation](./responses.mdx#conditional-response-variations) + +Response variation that can only be used when the current dialogue state satisfies some constraints as defined in the domain or responses files. If there's a match between the constraints and the dialogue state, Rasa can use this variation. + +## [Custom Action](./actions.mdx#custom-actions) + +An action written by a bot developer that can run arbitrary code, mainly to interact with external systems and APIs. + +## [Default Action](./actions.mdx#default-actions) + +A built-in action that comes with predefined functionality. + +## [DIET](./components.mdx#dietclassifier) + +Dual Intent and Entity Transformer. The default NLU architecture used by Rasa, which performs both intent classification and entity extraction. + +## [Domain](./domain.mdx) + +Defines the inputs and outputs of an assistant. + +It includes a list of all the intents, entities, slots, actions, and forms that the assistant knows about. + +## [Entity](./training-data-format.mdx#entities) + +Keywords that can be extracted from a user message. For example: a telephone number, a person's name, a location, the name of a product + +## [Event](./action-server/events.mdx) + +Something that happens in a conversation. For instance, a `UserUttered` event represents a user entering a message, and an `ActionExecuted` event represents the assistant executing an action. All conversations in Rasa are represented as a sequence of events. + +## FAQs + +Frequently asked questions (FAQs) are common questions that your users ask. In the context of building an assistant, +this typically means the user sends a message and the assistant send a response without needing to consider the context of the conversation. +Read about handling [Chitchat and FAQs](./chitchat-faqs.mdx) to learn how to implement this with Rasa. + +## [Form](./forms.mdx) + +A type of custom action that asks the user for multiple pieces of information. + +For example, if you need a city, a cuisine, and a price range to recommend a restaurant, you can create a restaurant form to collect the information. You can describe [business logic](#business-logic) inside a form, like offering the customer a different set of menu options if they mention a food allergy. + +## Happy / Unhappy Paths + +Terms used to describe whether the user’s input is expected or unexpected. If your assistant asks a user for some information and the user provides it, we call that a happy path. Unhappy paths are all possible edge cases. For example, the user refusing to give the requested input, changing the topic of conversation, or correcting something they said earlier. + +## [Intent](./nlu-training-data.mdx) + +In a given user message, the thing that a user is trying to convey or accomplish (e,g., greeting, specifying a location). + +## [Interactive Learning](./writing-stories.mdx#using-interactive-learning) + +In the Rasa CLI, a training mode where the developer corrects and validates the assistant’s predictions at every step of the conversation. The conversation can be saved to the story format and added to the assistant’s training data. + +## [Knowledge Base / Knowledge Graph](./action-server/knowledge-base-actions.mdx) + +A queryable database that represents complex relationships and hierarchies between objects. Knowledge Base Actions allow Rasa to fetch information from a knowledge base and use it in responses. + +## [Level 3 Assistant](https://blog.rasa.com/5-levels-of-conversational-ai-2020-update/) + +An assistant that can handle conversations more complex than simple back-and-forth exchanges. Level 3 assistants are capable of using the context of previous conversation turns to choose the appropriate next action. + +## [Messaging Channels](./messaging-and-voice-channels.mdx) + +Connectors that integrate Rasa with external messaging platforms, where end-users can send and receive messages. Rasa includes built-in messaging channels like Slack, Facebook Messenger, and web chat, as well as the ability to create custom connectors. + +## [Minimum Viable Assistant](./conversation-driven-development.mdx#cdd-in-early-stages-of-development) + +A basic assistant that can handle the most important happy path stories. + +## [NLG](./nlg.mdx) + +Natural Language Generation (NLG) is the process of generating natural language messages to send to a user. + +Rasa uses a simple template-based approach for NLG. Data-driven approaches (such as neural NLG) can be implemented by creating a custom NLG component. + +## [NLU](./nlu-training-data.mdx) + +Natural Language Understanding (NLU) deals with parsing and understanding human language into a structured format. + +## [Pipeline](./tuning-your-model.mdx) + +The list of NLU components (see [NLU Component](#nlu-component)) that defines a Rasa assistant’s NLU system. A user message is processed by each component one by one, before returning the final structured output. + +## [Policy](./policies.mdx) + +Rasa components that predict the dialogue system’s next actionPolicies make decisions about how the conversation flow should proceed. A typical configuration includes multiple policies, and the policy with the highest confidence decides the next action to be taken in the conversation. + +## Rasa Core + +(Outdated - Rasa Core and Rasa NLU were merged into one package in 1.x. The functionality of Core is now referred to as dialogue management) + +The dialogue engine that decides what to do next in a conversation based on the context. Part of the Rasa library. + +## Rasa NLU + +(Outdated - Rasa Core and Rasa NLU were merged into one package in 1.x. The functionality of Rasa NLU is now referred to as NLU) + +Rasa NLU is the part of Rasa that performs Natural Language Understanding ([NLU](#nlu)), including intent classification and entity extraction. + +## [NLU Component](./components.mdx) + +An element in the Rasa NLU pipeline (see [Pipeline](#pipeline)) that processes incoming messages. Components perform tasks ranging from entity extraction to intent classification to pre-processing. + +## [Rasa X/Enterprise](https://rasa.com/docs/rasa-enterprise/) + +A tool for [conversation-driven development](./conversation-driven-development.mdx). Rasa X/Enterprise helps teams share and test an assistant built with Rasa, annotate user messages, and view conversations. + +## [Retrieval Intent](./chitchat-faqs.mdx) + +A special type of intent that can be divided into smaller sub-intents. For example, an FAQ retrieval intent has sub-intents that represent each individual question the assistant knows how to answer. + +## [REST Channel](./connectors/your-own-website.mdx) + +A messaging channel used to build custom connectors. Includes an input channel, where user messages can be posted to Rasa, and the ability to specify a callback URL, where the bot’s response actions will be sent. + +## [Response / Template / Utterance](./responses.mdx) + +A message that an assistant sends to a user. This can include text, buttons, images, and other content. + +## [Rules](./rules.mdx) + +Special training data to specify rule-like behavior, where a specific condition always predicts a specific next action. Examples include +answering FAQs, filling [Forms](./forms.mdx), or handling +[Fallbacks](./fallback-handoff.mdx#fallbacks). + +## [Slot](./domain.mdx#slots) + +A key-value store that Rasa uses to track information over the course of a conversation. + +## [Story](./stories.mdx) + +Training data format for the dialogue model, consisting of a conversation between a user and a bot. The user's messages are represented as annotated intents and entities, and the bot’s responses are represented as a sequence of actions. + +## [TED Policy](./policies.mdx#ted-policy) + +Transformer Embedding Dialogue Policy. TED is the default machine learning-based dialogue policy used by Rasa. TED complements rule-based policies by handling previously unseen situations, where no rule exists to determine the next action. + +## [Template / Response / Utterance](./responses.mdx) + +A message template used to respond to a user. Can include text, buttons, images, and other attachments. + +## [Tracker](./tracker-stores.mdx) + +Rasa component that maintains the state of the dialogue, which is represented as a JSON object listing the events from the current session. + +## User Goal + +The overall goal that a user wants to achieve, e.g. looking up the answer to a question, booking an appointment, or purchasing an insurance policy. + +Some tools refer to the user goal as the “intent,” but in Rasa terminology, an intent is associated with each individual user message. + +## Word embedding / Word vector + +A vector of floating point numbers that represent the meaning of a word. Words that have similar meanings tend to have similar vectors. Word embeddings are often used as an input to machine learning algorithms. + +## Rasa Primitive + +A foundational component used for structuring conversations within Rasa, such as an intent, entity, slot, form, response, action, rule, or story. diff --git a/docs/docs/graph-recipe.mdx b/docs/docs/graph-recipe.mdx new file mode 100644 index 0000000..8b1c441 --- /dev/null +++ b/docs/docs/graph-recipe.mdx @@ -0,0 +1,134 @@ +--- +id: graph-recipe +sidebar_label: Graph Recipe +title: Graph Recipe +description: Learn about Graph Recipe for Rasa. +abstract: Graph recipes provide a more fine tuned configuration for your executable graphs. +--- + +:::tip Default Recipe or Graph Recipe? + +You will probably only need graph recipes if you're running ML experiments or ablation studies on an existing model. We recommend starting with the default recipe and for many applications that will be all that's needed. + +::: + +We now support graph recipes in addition to the default recipe. Graph recipes provide more granular control over how execution graph schemas are built. + +:::caution New in 3.1 +This feature is experimental. +We introduce experimental features to get feedback from our community, so we encourage you to try it out! +However, the functionality might be changed or removed in the future. +If you have feedback (positive or negative) please share it with us on the [Rasa Forum](https://forum.rasa.com). + +::: + + +## Differences with Default Recipe + +There are some differences between the default recipe and the new graph recipe. Main differences are: + +- Default recipe is named `default.v1` in the config file whereas graph recipes are named `graph.v1`. +- Default recipes provide an easy to use recipe structure whereas graph recipes are more advanced and powerful. +- Default recipes are very opinionated and provide various defaults whereas graph recipes are more explicit. +- Default recipes can auto-configure themselves and dump the defaults used to the file if some sections in `config.yml` are missing, whereas graph recipes do none of this and assume what you see is what you get. There are no surprises with graph recipes. +- Default recipe divides graph configuration into mainly two parts: `pipeline` and `policies`. These can also be described as NLU and core (dialogue management) parts. For graph recipe on the other hand, the separation is between training (ie. `train_schema`) and prediction (ie. `predict_schema`). + +:::tip Starting from scratch? + +If you don't know which recipe to choose, use the default recipe to bootstrap your project fast. If later you find that you need more fine-grained control, you can always change your recipe to be a graph recipe. + +::: + +## Graph Configuration File Structure + +Graph recipes share `recipe` and `language` keys with the same meaning. Similarities end there as graph recipes do not have `pipeline` or `policies` keys but they do have `train_schema` and `predict_schema` keys for determining the graph nodes during train and predict runs respectively. In addition to this, target nodes for NLU and core can be specified explicitly with graph recipes, these can be declared with `nlu_target` and `core_target`. If targets are omitted, node names used by default recipe will take over, and these are `run_RegexMessageHandler` and `select_prediction` for nlu and core respectively. + +Here's an example graph recipe: + +```yaml-rasa (../data/test_config/graph_config_short.yml) +``` + +:::note graph targets +For NLU, default target name of `run_RegexMessageHandler` will be used, while for core (dialogue management) the target will be called `select_prediction` if omitted. Make sure you have graph nodes with relevant names in your schema definitions. + +In a similar fashion, note that the default resource needed by the first graph node is fixed to be `__importer__` (representing configuration, training data etc.) for training task and it is `__message__` (representing the message received) for prediction task. Make sure your first nodes make use of these dependencies. + +::: + +## Graph Node Configuration + +As you can see in the example above, graph recipes are very much explicit and you can configure each graph node as you would like. Here is an explanation of what some of the keys mean: + +- `needs`: You can define here what data your graph node requires and from which parent node. Key is the data name, whereas the value would refer to the node name. +```yaml-rasa +needs: + messages: nlu_message_converter +``` +Current graph node needs `messages` which is provided by `nlu_message_converter` node. + +- `uses`: You can provide the class used to instantiate this node with this key. Please provide the full path in Python path syntax, eg. + +```yaml-rasa +uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter +``` +You are not required to use Rasa internal graph component classes and you +can use your own components here. Refer to [custom graph +components](custom-graph-components.mdx) pages to find out how to write your +own graph components. + +- `constructor_name`: This is the constructor used to instantiate your component. Example: + +```yaml-rasa +constructor_name: load +``` + +- `fn`: This is the function used in executing the graph component. Example: + +```yaml-rasa +fn: combine_predictions_from_kwargs +``` + +- `config`: You can provide any configuration parameters for your components using this key. + +```yaml-rasa +config: + language: en + persist: false +``` + +- `eager`: This determines if your component should be eagerly loaded +when the graph is constructed or if it should wait until the +runtime (this is called lazy instantiation). Usually we always +instantiate lazily during training and eagerly during inference (to +avoid slow first prediction). + + +```yaml-rasa +eager: true +``` + +- `resource`: If given, graph node is loaded from this resource instead of instantiated from scratch. This is e.g. used to load a trained component for predictions. + +```yaml-rasa +resource: + name: train_RulePolicy1 +``` + +- `is_target`: Boolean value, if `True` then this node can't be pruned +during fingerprinting (it might be replaced with a cached value +though). This +is e.g. used for all components which train as their result always needs +to be added to the model archive so that the data is available during +inference. + +```yaml-rasa +is_target: false +``` + +- `is_input`: Boolean value; nodes with `is_input` are _always_ run (also during the +fingerprint run). This makes sure that we e.g. detect changes in file +contents. + +```yaml-rasa + is_input: false +``` diff --git a/docs/docs/http-api.mdx b/docs/docs/http-api.mdx new file mode 100644 index 0000000..794e66b --- /dev/null +++ b/docs/docs/http-api.mdx @@ -0,0 +1,136 @@ +--- +id: http-api +sidebar_label: HTTP API +title: Rasa HTTP API +description: Read about Rasa's HTTP API that has endpoints for conversations, training models, and configuring your bot. +abstract: You can use the HTTP API to interact with a running Rasa server. With the API, you can train models, + send messages, run tests, and more. +--- + +:::tip Looking for API endpoints? +Check out the [API Spec](/pages/http-api) for all of the available endpoints as well as their request and response formats. + +::: + +## Enabling the HTTP API + +By default, running a Rasa server does not enable the API endpoints. Interactions +with the bot can happen over the exposed `webhooks/<channel>/webhook` endpoints. + +To enable the API for direct interaction with conversation trackers and other +bot endpoints, add the `--enable-api` parameter to your run command: + +```bash +rasa run --enable-api +``` + +Note that you start the server with an NLU-only model, not all the available endpoints +can be called. Some endpoints will return a 409 status code, as a trained +dialogue model is needed to process the request. + + +:::caution +Make sure to secure your server, either by restricting access to the server (e.g. using firewalls), or +by enabling an authentication method. See [Security Considerations](./http-api.mdx#security-considerations). + +::: + +By default, the HTTP server runs as a single process. You can change the number +of worker processes using the `SANIC_WORKERS` environment variable. It is +recommended that you set the number of workers to the number of available CPU cores +(check out the +[Sanic docs](https://sanicframework.org/en/guide/deployment/running.html#workers) +for more details). This will only work in combination with the +`RedisLockStore` (see [Lock Stores](./lock-stores.mdx). + +:::caution +The [SocketIO channel](./connectors/your-own-website.mdx#websocket-channel) does not support multiple worker processes. + +::: + +## Security Considerations + +We recommend that you don't expose the Rasa Server to the outside world directly, but +rather connect to it via e.g. Nginx. + +Nevertheless, there are two authentication methods built in: + +### Token Based Auth + +To use a plaintext token to secure your server, specify the token in the argument `--auth-token thisismysecret` when starting +the server: + +```bash +rasa run \ + --enable-api \ + --auth-token thisismysecret +``` + +Any clients sending requests to the server must pass the token +as a query parameter, or the request will be rejected. For example, to fetch a tracker from the server: + +```bash +curl -XGET localhost:5005/conversations/default/tracker?token=thisismysecret +``` + +### JWT Based Auth + +To use JWT based authentication, specify the JWT secret in the argument `--jwt-secret thisismysecret` +on startup of the server: + + +```bash +rasa run \ + --enable-api \ + --jwt-secret thisismysecret +``` + +If you want to sign a JWT token with asymmetric algorithms, you can specify the JWT private key to the `--jwt-private-key` +CLI argument. You must pass the public key to the `--jwt-secret` argument, and also specify the algorithm to the +`--jwt-method` argument: + +```bash +rasa run \ + --enable-api \ + --jwt-secret <public_key> \ + --jwt-private-key <private_key> \ + --jwt-method RS512 +``` + +Client requests to the server will need to contain a valid JWT token in +the `Authorization` header that is signed using this secret +and the `HS256` algorithm e.g. + +```text +"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ" + "zdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIi" + "wiaWF0IjoxNTE2MjM5MDIyfQ.qdrr2_a7Sd80gmCWjnDomO" + "Gl8eZFVfKXA6jhncgRn-I" +``` + +The token's payload must contain an object under the `user` key, +which in turn must contain the `username` and `role` attributes. +The following is an example payload for a JWT token: + +```json +{ + "user": { + "username": "<sender_id>", + "role": "user" + } +} +``` + +If the `role` is `admin`, all endpoints are accessible. +If the `role` is `user`, endpoints with a `sender_id` parameter are only accessible +if the `sender_id` matches the payload's `username` property. + +```bash +rasa run \ + -m models \ + --enable-api \ + --jwt-secret thisismysecret +``` + +To create and encode the token, you can use tools such as the [JWT Debugger](https://jwt.io/), or a Python module such as [PyJWT](https://pyjwt.readthedocs.io/en/latest/). + diff --git a/docs/docs/installation/environment-set-up.mdx b/docs/docs/installation/environment-set-up.mdx new file mode 100644 index 0000000..07854ce --- /dev/null +++ b/docs/docs/installation/environment-set-up.mdx @@ -0,0 +1,144 @@ +--- +sidebar_label: Setting up your environment +title: Setting up your environment +description: How to set up your environment before installing Rasa +--- + +## 1. Python Environment Setup + +Check if your Python environment is already configured: + +```bash +python3 --version +pip3 --version +``` + +:::note Python3 Supported Versions + +Currently, rasa supports the following Python versions: `3.7`, `3.8`, `3.9` and `3.10`. +Note that Python `3.10` is only supported for versions `3.4.x` and upwards. +Additionally, rasa installation on Apple Silicon with Python `3.10` is not functional in `3.4.x` but will be supported starting from `3.5.x`. + +::: + + +If these packages are already installed, these commands should display version +numbers for each step, and you can skip to the next step. + +Otherwise, proceed with the instructions below to install them. + +<Tabs values={[{"label": "Ubuntu", "value": "ubuntu"}, {"label": "macOS", "value": "macos"}, {"label": "Windows", "value": "windows"}]} groupId="operating-systems" defaultValue="ubuntu"> + <TabItem value="ubuntu"> + +Fetch the relevant packages using `apt`, and install virtualenv using `pip`. + +```bash +sudo apt update +sudo apt install python3-dev python3-pip +``` + + </TabItem> + <TabItem value="macos"> + +Install the [Homebrew](https://brew.sh) package manager if you haven't already. + +Once you're done, you can install Python3. + +```bash +brew update +brew install python +``` + + </TabItem> + <TabItem value="windows"> + +Make sure the Microsoft VC++ Compiler is installed, so python can compile +any dependencies. You can get the compiler from <a className="reference external" + href="https://visualstudio.microsoft.com/visual-cpp-build-tools/" + target="_blank">Visual Studio</a>. Download the installer and select +VC++ Build tools in the list.Install [Python 3](https://www.python.org/downloads/windows/) (64-bit version) for Windows. + +```bat +C:\> pip3 install -U pip +``` + + </TabItem> +</Tabs> + +## 2. Virtual Environment Setup + +This step is optional, but we strongly recommend isolating python projects +using virtual environments. Tools like +[virtualenv](https://virtualenv.pypa.io/en/latest/) and +[virtualenvwrapper](https://virtualenvwrapper.readthedocs.io/en/latest/) provide +isolated Python environments, which are cleaner than installing packages system-wide +(as they prevent dependency conflicts). They also let you install packages +without root privileges. + +<Tabs values={[{"label": "Ubuntu", "value": "ubuntu"}, {"label": "macOS", "value": "macos"}, {"label": "Windows", "value": "windows"}]} groupId="operating-systems" defaultValue="ubuntu"> + <TabItem value="ubuntu"> + +Create a new virtual environment by choosing a Python interpreter and making a `./venv` directory to hold it: + +```bash +python3 -m venv ./venv +``` + +Activate the virtual environment: + +```bash +source ./venv/bin/activate +``` + + </TabItem> + <TabItem value="macos"> + +Create a new virtual environment by choosing a Python interpreter and making a `./venv` directory to hold it: + +```bash +python3 -m venv ./venv +``` + +Activate the virtual environment: + +```bash +source ./venv/bin/activate +``` + + </TabItem> + <TabItem value="windows"> + +Create a new virtual environment by choosing a Python interpreter and making a `.\\venv` directory to hold it: + +```bat +C:\> python3 -m venv ./venv +``` + +Activate the virtual environment: + +```bat +C:\> .\venv\Scripts\activate +``` + + </TabItem> +</Tabs> + +## M1 / M2 (Apple Silicon) Limitations + +Rasa installations on Apple Silicon _don't_ use [Apple Metal](https://developer.apple.com/metal/) by default. +We found that using the GPU on Apple Silicon increased training time for the +[`DIETClassifier`](../components.mdx#dietclassifier) and [`TEDPolicy`](../policies.mdx#ted-policy) dramatically. +You can, however, install the optional dependency to test it yourself +or try it with other components using: `pip3 install 'rasa[metal]'`. + +Currently, not all of Rasa's dependencies support Apple Silicon natively. This leads +to the following restrictions: + +- You can not run Duckling as a docker container on Apple Silicon. If you want + to use the [duckling entity extractor](../components.mdx#ducklingentityextractor) we recommend a cloud deployment of + duckling. Progress on this can be tracked on the + [Duckling project](https://github.com/facebook/duckling/issues/695). +- Rasa on Apple Silicon does not support the [`ConveRTFeaturizer` component](../components.mdx#convertfeaturizer) or pipelines + containing it. The component relies on `tensorflow-text` which currently + isn't available for Apple Silicon. Progress on this can be tracked on the + [Tensorflow Text project](https://github.com/tensorflow/text/issues/823). diff --git a/docs/docs/installation/installing-rasa-open-source.mdx b/docs/docs/installation/installing-rasa-open-source.mdx new file mode 100644 index 0000000..1a87e35 --- /dev/null +++ b/docs/docs/installation/installing-rasa-open-source.mdx @@ -0,0 +1,180 @@ +--- +sidebar_label: Installing Rasa Open Source +title: Installing Rasa Open Source +description: Install Rasa Open Source on premises to enable local and customizable Natural Language Understanding and Dialogue Management. +--- + +import useBaseUrl from "@docusaurus/useBaseUrl"; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { Button } from "@theme/Button"; + +## Install Rasa Open Source + +<Tabs values={[{"label": "Ubuntu / macOS / Windows", "value": "ubuntu/macos/windows"}]} defaultValue="ubuntu/macos/windows"> + <TabItem value="ubuntu/macos/windows"> + +First make sure your `pip` version is up to date: + +```bash +pip3 install -U pip +``` + +To install Rasa Open Source: + +```bash +pip3 install rasa +``` + + </TabItem> +</Tabs> + +:::note Telemetry reporting +When you run Rasa Open Source for the first time, you’ll see a +message notifying you about anonymous usage data that is being collected. +You can read more about how that data is pulled out and what it is used for in the +[telemetry documentation](../telemetry/telemetry.mdx). +::: + +**Congratulations! You have successfully installed Rasa Open Source!** + +You can now create a new project with: + +```bash +rasa init +``` + +You can learn about the most important Rasa commands in the [Command Line Interface](../command-line-interface.mdx). + +## Building from Source + +If you want to use the development version of Rasa Open Source, you can get it from GitHub: + +```bash +curl -sSL https://install.python-poetry.org | python3 - +git clone https://github.com/RasaHQ/rasa.git +cd rasa +poetry install +``` + +## Additional dependencies + +For some machine learning algorithms you need to install additional python packages. +They aren't installed by default to keep the footprint small. + +The page on [Tuning Your Model](../tuning-your-model.mdx) will help you pick the right +configuration for your assistant and alert you to additional dependencies. + +:::tip Just give me everything! +If you don't mind the additional dependencies lying around, you can use + +```bash +pip3 install 'rasa[full]' +``` + +to install all needed dependencies for every configuration. + +::: + +### Python 3.10 requirements + +_If you are using Linux_, installing `rasa[full]` could result in a failure while installing `tokenizers` and +`cryptography`. + +In order to resolve it, you must follow these steps to install a Rust compiler: + +```bash +apt install rustc && apt install cargo +``` + +After initializing the Rust compiler, you should restart the console and check its installation: + +```bash +rustc --version +``` + +In case the PATH variable had not been automatically setup, run: + +```bash +export PATH="$HOME/.cargo/bin:$PATH" +``` + +_If you are using macOS_, note that installing `rasa[full]` (either via pip or from source) could result in a failure +while installing `tokenizers` (issue described in depth [here](https://github.com/huggingface/tokenizers/issues/1050)). + +In order to resolve it, you must follow these steps to install a Rust compiler: + +```bash +brew install rustup +rustup-init +``` + +After initializing the Rust compiler, you should restart the console and check its installation: + +```bash +rustc --version +``` + +In case the PATH variable had not been automatically setup, run: + +```bash +export PATH="$HOME/.cargo/bin:$PATH" +``` + +### Dependencies for spaCy + +For more information on spaCy models, check out the [spaCy docs](https://spacy.io/usage/models). + +You can install it with the following commands: + +```bash +pip3 install 'rasa[spacy]' +python3 -m spacy download en_core_web_md +``` + +:::tip Using `zsh`? + +In zsh, square brackets are interpreted as patterns on the command line. +To run commands with square brackets, you can either enclose the arguments +with square brackets in quotes, like `pip3 install 'rasa[spacy]'`, or escape +the square brackets using backslashes, like `pip3 install rasa\[spacy\]`. +We recommend using the former method (`pip3 install 'rasa[spacy]'`) in our +documentation because it works as expected across any shell + +::: + +This will install Rasa Open Source as well as spaCy and its language model +for the English language, but many other languages are available too. +We recommend using at least the "medium" sized models (`_md`) instead of the spaCy's +default small `en_core_web_sm` model. Small models require less +memory to run, but will likely reduce intent classification performance. + +### Dependencies for MITIE + +First, run + +```bash +pip3 install git+https://github.com/mit-nlp/MITIE.git +pip3 install 'rasa[mitie]' +``` + +and then download the +[MITIE models](https://github.com/mit-nlp/MITIE/releases/download/v0.4/MITIE-models-v0.2.tar.bz2). +The file you need is `total_word_feature_extractor.dat`. Save this +anywhere. If you want to use MITIE, you need to +tell it where to find this file (in this example it was saved in the +`data` folder of the project directory). + +## Upgrading Versions + +To upgrade your installed version of Rasa Open Source to the latest version from PyPI: + +```bash +pip3 install --upgrade rasa +``` + +To download a specific version, specify the version number: + +```bash +pip3 install rasa==3.0 +``` diff --git a/docs/docs/installation/rasa-pro/installation.mdx b/docs/docs/installation/rasa-pro/installation.mdx new file mode 100644 index 0000000..34df14d --- /dev/null +++ b/docs/docs/installation/rasa-pro/installation.mdx @@ -0,0 +1,214 @@ +--- +sidebar_label: Rasa Pro Installation +title: Rasa Pro Installation +description: Install Rasa Pro in your production environment to create an enterprise ready Rasa deployment. +--- + +import useBaseUrl from "@docusaurus/useBaseUrl"; +import RasaProLabel from "@theme/RasaProLabel"; +import RasaProBanner from "@theme/RasaProBanner"; + +<RasaProLabel /> + +<RasaProBanner /> + +In this section you will learn how to install **Rasa Pro**, a drop-in replacement enterprise-ready version of Rasa Open Source, on your development environment. + +To learn more about deployments, please visit the following sections: + +- [Deploying Rasa Pro in production](../../deploy/deploy-rasa) +- [Deploying Rasa Pro Services in production](../../deploy/deploy-rasa-pro-services) + +To learn more about compatibility between different versions of Rasa Pro and Rasa Pro Services, please see +[compatibility matrix](../../compatibility-matrix.mdx). + +## Rasa Pro Setup + +Rasa Pro requires access credentials to install and a license to use. The instructions below assume you have +credentials and a license. + +Rasa Pro is a drop-in replacement and enterprise-ready version of Rasa Open Source. +Additional functionality is provided through plugins while allowing you to continue using the existing Rasa Open Source CLI commands and parameters as you used to. + +Installing Rasa Pro instead of Rasa Open Source will not break any existing scripts, automation or functionality you have built around Rasa Open Source. + +### Python Package Installation + +The Rasa Pro python package is named `rasa-plus`. The `rasa-plus` python packages as well as the Docker containers are hosted on our GCP Artifact Registry. +As a prerequisite, you will need: + +- to [install](https://cloud.google.com/sdk/docs/install) the Google Cloud CLI. +- to verify that the user or service account you are using has the required permissions to access the repository. + +#### Authentication Set-Up +To authenticate you need use a service account key file provided by Rasa to authenticate with Google Cloud. + +Authenticate with GCP using the service account key. + +```sh +gcloud auth activate-service-account --key-file=service-account.json +``` + +Set up keyring to allow Pip to authenticate with GCP Artifact Registry by installing keyring and then the backend that supports GCP Artifact Registry + +```sh +pip install keyring +pip install keyrings.google-artifactregistry-auth +``` + +Verify that the backends have been installed correctly + +```sh +keyring --list-backends +``` + +The results should include `ChainerBackend` and `GooglePythonAuth`. + + +#### Installing with `pip` + +Enter the following settings to the `.pypirc` file. This can be found: + +- Linux and MacOS: `$HOME/.pypirc` +- Windows: `%USERPROFILE%\.pypirc` + + +``` +[distutils] +index-servers = + rasa-plus-py + +[rasa-plus-py] +repository: https://europe-west3-python.pkg.dev/rasa-releases/rasa-plus-py/ + +``` + +Next, add these specific settings to the pip configuration file. The location for this depends on whether you want to update the per-user file or the file specific to a virtual environment that you are using. + +For the file associated with your operating system user: + +- Linux: `$HOME/.config/pip/pip.conf` or `$HOME/.pip/pip.conf` +- MacOS: `/Library/Application Support/pip/pip.conf` or `$HOME/.config/pip/pip.conf` +- Windows: `%APPDATA%\pip\pip.ini` or `%USERPROFILE%\pip\pip.ini` + +For virtual environments: + +- Linux and macOS: `$VIRTUAL_ENV/pip.conf` +- Windows: `%VIRTUAL_ENV%\pip.ini` + + +``` +[global] +extra-index-url = https://europe-west3-python.pkg.dev/rasa-releases/rasa-plus-py/simple/ + +``` + +Finally, you should be able to run `pip install rasa-plus`. + +#### Installing with `poetry` + +To install `rasa-plus` with `poetry`, you will need to associate the Artifact Registry URL with `rasa-plus` before installing it. +Note that you must upgrade poetry to the latest minor (`1.2.0`) in order for `poetry` to work with the GCP authentication set-up. +Proceed with the following steps: + +1. Run `poetry self add "keyrings.google-artifactregistry-auth"` +2. Add this section to `pyproject.toml`: + +```toml +[[tool.poetry.source]] +name = "rasa-plus" +url = "https://europe-west3-python.pkg.dev/rasa-releases/rasa-plus-py/simple" +default = false +secondary = true + +``` + +3. Run `poetry install`. + + +### Docker Image Installation + +The Rasa Pro Docker image is named `rasa-plus`. The Docker images are hosted on our GCP Artifact Registry. +As a prerequisite, you will need: + +- to [install](https://cloud.google.com/sdk/docs/install) the Google Cloud CLI. +- to verify that the user or service account you are using has the required permissions to access the repository. + +To authenticate you need use a service account key file provided by Rasa to authenticate with Google Cloud. + +```bash +gcloud auth activate-service-account --key-file=${KEYFILE} +gcloud auth configure-docker europe-west3-docker.pkg.dev +docker pull europe-west3-docker.pkg.dev/rasa-releases/rasa-plus/rasa-plus +``` + +### Using An Intermediate Repository +If you are using your own intermediate repository to cache libraries or dependencies (such as Artifactory or Nexus Repository Manager), you may need to generate a set of static credentials that allow you to authenticate with GCP Artifact Registry. + +As a prerequisite, you will need: + +- to [install](https://cloud.google.com/sdk/docs/install) the Google Cloud CLI. +- to verify that the user or service account you are using has the required permissions to access the repository. + +To generate your credentials, run: + +```sh +gcloud artifacts print-settings python \ + --project=rasa-releases \ + --repository=rasa-plus-py \ + --location=europe-west3 \ + --json-key=service-account.json +``` + +Your credentials can be found in the output. The username will be `_json_key_base64` and the password will be a long, base64 encoded string. + +### Runtime Configuration + +Rasa Pro will look for your license in the env var `RASA_PRO_LICENSE`. +You can set this env var temporarily in your terminal, but it is recommended +to set it persistently so that you don't have to set it every time you run +Rasa Pro. + +Bash: + +```sh +## Temporary +export RASA_PRO_LICENSE=<your-license-string> + +## Persistent +echo "export RASA_PRO_LICENSE=<your-license-string>" >> ~/.bashrc +## If you're using a different flavor of bash e.g. Zsh, replace .bashrc with your shell's initialization script e.g. ~/.zshrc +``` + +Windows Powershell: + +```powershell +## Temporary +$env: RASA_PRO_LICENSE=<your-license-string> + +## Persistent +[System.Environment]::SetEnvironmentVariable('RASA_PRO_LICENSE','<your-license-string>') +``` + +Then you can use the [`rasa` CLI](https://rasa.com/docs/rasa/command-line-interface) as usual, for example: + +```sh +rasa init +``` + +:::note + +If you run `rasa train` or `rasa run` after you’ve enabled tracing using Jaeger as a backend in your local development environment, +you might come across this error `OSError: [Errno 40] Message too long`. + +This could be caused by the OS of your local development environment restricting UDP packet size. +You can find out the current UDP packet size by running `sysctl net.inet.udp.maxdgram` on macOS. +You can increase UDP packet size by running `sudo sysctl -w net.inet.udp.maxdgram=65535`. + +::: + +Congratulations! You have now successfully installed Rasa Pro on your development environment. +To learn more about production deployments, visit: + +- [Deploying Rasa Pro in production](../../deploy/deploy-rasa) +- [Deploying Rasa Pro Services in production](../../deploy/deploy-rasa-pro-services) diff --git a/docs/docs/installation/rasa-pro/rasa-pro-artifacts.mdx b/docs/docs/installation/rasa-pro/rasa-pro-artifacts.mdx new file mode 100644 index 0000000..5161812 --- /dev/null +++ b/docs/docs/installation/rasa-pro/rasa-pro-artifacts.mdx @@ -0,0 +1,30 @@ +--- +sidebar_label: Rasa Pro Artifacts +title: Rasa Pro Artifacts +description: artifacts that ship with Rasa Pro. +--- + +import useBaseUrl from "@docusaurus/useBaseUrl"; +import RasaProLabel from "@theme/RasaProLabel"; +import RasaProBanner from "@theme/RasaProBanner"; + +<RasaProLabel /> + +<RasaProBanner /> + +- **Rasa Pro**, a drop-in replacement for Rasa Open Source +- **Rasa Pro Services**, flexible infrastructure and APIs on top of Rasa + Open Source. Rasa Pro Services should be deployed alongside, but separately + from your production assistant. + +Rasa Pro includes the Rasa Plus Python package, which is a drop-in replacement for Rasa Open Source that includes all the functionality of Rasa Open Source as well as additional features. +Rasa Pro features are built on a plugin architecture that is seamlessly integrated with Rasa Open Source. + +For example, in the below diagram, tracing is run as a hook implementation in Rasa Plus, whose specification is defined and registered in Rasa Open Source. + +<img alt="image" src={useBaseUrl("/img/rasa-plus-architecture.png")} /> + +The hook configures the tracing backend as specified in the `endpoints.yml` and instruments model training and message handling actions. +The hook is then called within the Rasa Open Source main module, which is the entry point for the Rasa Open Source command line. + +The plugin architecture enables Rasa Pro to continue enhancing the Rasa Open Source argument parser while maintaining the same runnable command name (`rasa`). diff --git a/docs/docs/introduction.mdx b/docs/docs/introduction.mdx new file mode 100644 index 0000000..8bf217e --- /dev/null +++ b/docs/docs/introduction.mdx @@ -0,0 +1,44 @@ +--- +slug: / +sidebar_label: Introduction +title: Introduction to Rasa Open Source & Rasa Pro +hide_table_of_contents: true +description: Learn more about open-source natural language processing library Rasa for conversation handling, intent classification and entity extraction in on premise chatbots. +--- + +import useBaseUrl from "@docusaurus/useBaseUrl"; +import RasaProLabel from '@theme/RasaProLabel'; +import RasaProBanner from '@theme/RasaProBanner'; + +With over 25 million downloads, Rasa Open Source is the most popular open source framework for building chat and voice-based AI assistants. + +Rasa Pro is an open core product powered by open source conversational AI framework with additional analytics, security, and observability capabilities. + +Rasa Pro is a part of our enterprise solution, Rasa Platform. Another product that makes up of Rasa Platform is Rasa X/Enterprise. +It is our low-code user interface that supports conversational AI Teams reviewing and improving AI Assistants at scale. It must be used with Rasa Pro. +To learn more about Rasa X/Enterprise, see the [Rasa X/Enterprise documentation](https://rasa.com/docs/rasa-enterprise). + +You can also learn more about our release and maintenance policy on [Rasa Product Release and Maintenance Policy](https://rasa.com/rasa-product-release-and-maintenance-policy/). + +<div align="center"> + <img alt="image" src={useBaseUrl("/img/introduction.png")} width="100%" /> +</div> + + +## Rasa Open Source + +Rasa Open Source is an open source conversational AI platform that allows you to understand and hold conversations, and connect to messaging channels and third party systems through a set of APIs. It supplies the building blocks for creating virtual (digital) assistants or chatbots. + + +## Rasa Pro + +<RasaProBanner/> + +Rasa Pro is the commercial, pro-code offering of Rasa that’s built to address enterprise needs +around security, observability and scale. [Read more here](./rasa-pro.mdx). + +When the content is only applicable to Rasa Pro, you will see the below label: + +<RasaProLabel /> + +This means you need to have a Rasa Pro License in order to access the capabilities detailed in that section. diff --git a/docs/docs/jupyter-notebooks.mdx b/docs/docs/jupyter-notebooks.mdx new file mode 100644 index 0000000..bad2157 --- /dev/null +++ b/docs/docs/jupyter-notebooks.mdx @@ -0,0 +1,113 @@ +--- +id: jupyter-notebooks +sidebar_label: Jupyter Notebooks +title: Jupyter Notebooks +description: Learn how to integrate open source chatbot platform Rasa into Jupyter notebooks, alongside all your machine learning code. +--- + +This page contains the most important methods for using Rasa in a Jupyter notebook. + +Running asynchronous Rasa code in Jupyter Notebooks requires an extra requirement, +since Jupyter Notebooks already run on event loops. Install this requirement in +the command line before launching jupyter: + +```bash +pip3 install nest_asyncio +``` + +Then in the first cell of your notebook, include: + +```bash +import nest_asyncio + +nest_asyncio.apply() +print("Event loop ready.") +``` +First, you need to create a project if you don't already have one. +To do this, run this cell, which will create the `test-project` directory and make it +your working directory: + +```bash +from rasa.cli.scaffold import create_initial_project +import os + +project = "test-project" +create_initial_project(project) + +# move into project directory and show files +os.chdir(project) +print(os.listdir(".")) +``` +To train a model, you will have to tell the `train` function +where to find the relevant files. +To define variables that contain these paths, run: + +```bash +config = "config.yml" +training_files = "data/" +domain = "domain.yml" +output = "models/" +print(config, training_files, domain, output) +``` +## Train a Model + +Now we can train a model by passing in the paths to the `rasa.train` function. +Note that the training files are passed as a list. +When training has finished, `rasa.train` returns the path where the trained model has been saved. + +```bash +import rasa + +model_path = rasa.train(domain, config, [training_files], output) +print(model_path) +``` +## Chat with your assistant + +To start chatting to an assistant, call the `chat` function, passing +in the path to your saved model. If you do not have custom actions you can set `endpoints = None` or omit it: + +```bash +from rasa.jupyter import chat + +endpoints = "endpoints.yml" +chat(model_path, endpoints) +``` +## Evaluate your model against test data + +Rasa has a convenience function for getting your training data. +Rasa's `get_core_directory` and `get_nlu_directory` are functions which +recursively find all the stories or NLU data files +and copies them into temporary directories. +The return values are the paths to these newly created directories. + +```bash +import rasa.shared.data as data +nlu_data_directory = data.get_nlu_directory(training_files) +stories_directory = data.get_core_directory(training_files) +print(stories_directory, nlu_data_directory) +``` +To test your model, call the `test` function, passing in the path +to your saved model and directories containing the stories and nlu data +to evaluate on. + +```bash +rasa.test(model_path, stories_directory, nlu_data_directory) +print("Done testing.") +``` +The results of the core evaluation will be written to a file called `results`. +NLU errors will be reported to `errors.json`. +Together, they contain information about the accuracy of your model's +predictions and other metrics. + +```bash +if os.path.isfile("errors.json"): + print("NLU Errors:") + print(open("errors.json").read()) +else: + print("No NLU errors.") + +if os.path.isdir("results"): + print("\n") + print("Core Errors:") + print(open("results/failed_test_stories.yml").read()) +``` diff --git a/docs/docs/language-support.mdx b/docs/docs/language-support.mdx new file mode 100644 index 0000000..35e2006 --- /dev/null +++ b/docs/docs/language-support.mdx @@ -0,0 +1,71 @@ +--- +id: language-support +sidebar_label: Language Support +title: Language Support +abstract: You can use Rasa to build assistants in any language you want. +--- + +Your Rasa assistant can be used on training data in **any language**. +If there are no word embeddings for your language, you can train your featurizers +from scratch with the data you provide. + +In addition, we also support pre-trained word embeddings such as spaCy. For information on +what pipeline is best for your use case, check out [choosing a pipeline](./tuning-your-model.mdx#how-to-choose-a-pipeline). + +## Training a Model in Any Languages + +The following pipeline can be used to train models in whitespace tokenizable languages: + +```yaml-rasa (docs/sources/data/configs_for_docs/default_config.yml) +``` + +To train a Rasa model in your preferred language, define the pipeline in your ``config.yml``. +After you define the pipeline and generate some [NLU training data](./training-data-format.mdx) +in your chosen language, train the model by running the command: + +```bash +rasa train nlu +``` + +Once the training is finished, you can test your model's language skills. +See how your model interprets different input messages by running: + +```bash +rasa shell nlu +``` + +:::note +Even more so when training word embeddings from scratch, more training data will lead to a +better model! If you find your model is having trouble discerning your inputs, try training +with more example sentences. +::: + +## Using Pre-trained Language Models + +If you can find them in your language, language models with pre-trained word vectors are a great way to get started with less data, +as the word vectors are trained on large amounts of data such as Wikipedia. + +### spaCy + +With the [Pre-trained Spacy Embeddings](./components.mdx#spacynlp), you can use spaCy's +[pre-trained language models](https://spacy.io/usage/models#languages) or load fastText vectors, which are available +for [hundreds of languages](https://github.com/facebookresearch/fastText/blob/master/docs/crawl-vectors.md). If you want +to incorporate a custom model you've found into spaCy, check out their page on +[adding languages](https://spacy.io/docs/usage/adding-languages). As described in the documentation, you need to +register your language model and link it to the language identifier, which will allow Rasa to load and use your new language +by passing in your language identifier as the ``language`` option. + +### MITIE + +You can also pre-train your own word vectors from a language corpus using [MITIE](./components.mdx#mitienlp). To do so: + +1. Get a clean language corpus (a Wikipedia dump works) as a set of text files. +2. Build and run `MITIE Wordrep Tool`_ on your corpus. + This can take several hours/days depending on your dataset and your workstation. + You'll need something like 128GB of RAM for wordrep to run -- yes, that's a lot: try to extend your swap. +3. Set the path of your new ``total_word_feature_extractor.dat`` as the ``model`` parameter in your + [configuration](./components.mdx#mitienlp). + +For a full example of how to train MITIE word vectors, check out +[this blogpost](http://www.crownpku.com/2017/07/27/%E7%94%A8Rasa_NLU%E6%9E%84%E5%BB%BA%E8%87%AA%E5%B7%B1%E7%9A%84%E4%B8%AD%E6%96%87NLU%E7%B3%BB%E7%BB%9F.html) +of creating a MITIE model from a Chinese Wikipedia dump. diff --git a/docs/docs/lock-stores.mdx b/docs/docs/lock-stores.mdx new file mode 100644 index 0000000..8f0a1b6 --- /dev/null +++ b/docs/docs/lock-stores.mdx @@ -0,0 +1,199 @@ +--- +id: lock-stores +sidebar_label: Lock Stores +title: Lock Stores +description: Messages that are being processed lock Rasa for a given conversation ID to ensure that multiple incoming messages for that conversation do not interfere with each other. Rasa provides multiple implementations to maintain conversation locks. +--- + + +import RasaProLabel from '@theme/RasaProLabel'; +import RasaProBanner from '@theme/RasaProBanner'; + +Rasa uses a ticket lock mechanism to ensure that incoming messages for a given +conversation ID are processed in the right order, and locks conversations while +messages are actively processed. This means multiple Rasa servers can +be run in parallel as replicated services, and clients do not necessarily need to +address the same node when sending messages for a given conversation ID. + +## InMemoryLockStore (default) + +- **Description** + + `InMemoryLockStore` is the default lock store. It maintains conversation locks + within a single process. + + :::note + This lock store should not be used when multiple Rasa servers are run + parallel. + + ::: + +- **Configuration** + + To use the `InMemoryTrackerStore` no configuration is needed. + +## ConcurrentRedisLockStore + +<RasaProLabel/> + +<RasaProBanner/> + +`ConcurrentRedisLockStore` is a new lock store that uses Redis as a persistence layer and is safe for use with multiple Rasa server replicas. +See the [migration section](#migration-guide) to learn how to switch to this lock store. + +### Description + +The `ConcurrentRedisLockStore` uses Redis as a persistence layer for instances of issued [tickets](https://rasa.com/docs/rasa/reference/rasa/core/lock/#ticket-objects) and the last issued ticket number. + +The ticket number initialization begins at 1, in contrast to that of the`RedisLockStore` which begins at 0. +If the ticket expires, the ticket number will not be reassigned to future tickets; as a result ticket numbers are unique to ticket instances. +Ticket numbers are incremented using the Redis atomic transaction INCR on the persisted last issued ticket number. + +The `ConcurrentRedisLockStore` ensures that only one Rasa instance can handle a conversation at any point in time. +Therefore, this Redis implementation of the `LockStore` can handle messages received in parallel for the same conversation by different Rasa servers +This is the recommended lock store for running a replicated set of Rasa servers. + +### Configuration + +To set up Rasa with Redis the following steps are required: + +1. Start your Redis instance + +2. Add required configuration to your `endpoints.yml` + +```yaml-rasa +lock_store: + type: rasa_plus.components.concurrent_lock_store.ConcurrentRedisLockStore + host: <host of the redis instance, e.g. localhost> + port: <port of your redis instance, usually 6379> + password: <password used for authentication> + db: <number of your database within redis, e.g. 0> + key_prefix: <alphanumeric value to prepend to lock store keys> +``` + +3. To start the Rasa Core server using your Redis backend, add the `--endpoints` + flag, e.g.: + + ```bash + rasa run -m models --endpoints endpoints.yml + ``` + +### Parameters + +- `url` (default: `localhost`): The url of your redis instance +- `port` (default: `6379`): The port which redis is running on +- `db` (default: `1`): The number of your redis database +- `key_prefix` (default: `None`): The prefix to prepend to lock store keys. Must + be alphanumeric +- `password` (default: `None`): Password used for authentication + (`None` equals no authentication) +- `use_ssl` (default: `False`): Whether or not the communication is encrypted -`socket_timeout` (default: `10`): Time in seconds after which an + error is raised if Redis doesn't answer + +### Migration Guide + +To switch from the `RedisLockStore` to the `ConcurrentRedisLockStore`, specify the complete module path to the `ConcurrentRedisLockStore` class as `type` in `endpoints.yml`: + +```yaml-rasa +lock_store: + type: rasa_plus.components.concurrent_lock_store.ConcurrentRedisLockStore + host: <host of the redis instance, e.g. localhost> + port: <port of your redis instance, usually 6379> + password: <password used for authentication> + db: <number of your database within redis, e.g. 0> + key_prefix: <alphanumeric value to prepend to lock store keys> +``` + +You must replace the `url` field in the redis lock store configuration with a field `host` containing the hostname of the redis instance. + +No database migration is required when switching to the `ConcurrentRedisLockStore`. You can use the same Redis instance and database number as you did previously when using the `RedisLockStore`. +You may want to delete all the preexisting keys if using the same Redis database number. +These former key-value items are no longer required by the `ConcurrentRedisLockStore` and the database can be cleared. + +There is no overlap in key-value items stored when using the `RedisLockStore` and the `ConcurrentRedisLockStore`, because the `RedisLockStore` persists serialized [TicketLock](https://rasa.com/docs/rasa/reference/rasa/core/lock/#ticketlock-objects) instances while the `ConcurrentRedisLockStore` instead stores individual [`Ticket`](https://rasa.com/docs/rasa/reference/rasa/core/lock/#ticket-objects) instances, as well as the last issued ticket number. +The `ConcurrentRedisLockStore` recreates the `TicketLock` from the persisted `Ticket` instances, which allows it to handle concurrent messages for the same conversation ID. + +## RedisLockStore + +- **Description** + + `RedisLockStore` maintains conversation locks using Redis as a persistence layer. + This is the recommended lock store for running a replicated set of Rasa servers. + +- **Configuration** + + To set up Rasa with Redis the following steps are required: + + 1. Start your Redis instance + + 2. Add required configuration to your `endpoints.yml` + + ```yaml-rasa + lock_store: + type: "redis" + url: <url of the redis instance, e.g. localhost> + port: <port of your redis instance, usually 6379> + password: <password used for authentication> + db: <number of your database within redis, e.g. 0> + key_prefix: <alphanumeric value to prepend to lock store keys> + ``` + + 3. To start the Rasa Core server using your Redis backend, add the `--endpoints` + flag, e.g.: + + ```bash + rasa run -m models --endpoints endpoints.yml + ``` + +- **Parameters** + + - `url` (default: `localhost`): The url of your redis instance + + - `port` (default: `6379`): The port which redis is running on + + - `db` (default: `1`): The number of your redis database + + - `key_prefix` (default: `None`): The prefix to prepend to lock store keys. Must + be alphanumeric + + - `username` (default: `None`): Username used for authentication + + - `password` (default: `None`): Password used for authentication + (`None` equals no authentication) + + - `use_ssl` (default: `False`): Whether or not the communication is encrypted + + - `ssl_keyfile` (default: `None`): Path to an ssl private key + + - `ssl_certfile` (default: `None`): Path to an ssl certificate + + - `ssl_ca_certs` (default: `None`): The path to a file of concatenated CA certificates in PEM format + + - `socket_timeout` (default: `10`): Time in seconds after which an + error is raised if Redis doesn't answer + +## Custom Lock Store + +If you need a lock store which is not available out of the box, you can implement your own. +This is done by extending the base class `LockStore`. + +Your custom lock store class must also implement the following methods: + +- `get_lock`: fetches lock for `conversation_id` from storage; requires `conversation_id` text parameter and returns `TicketLock` instance. + [(source code - see for signature)](https://github.com/RasaHQ/rasa/blob/main/rasa/core/lock_store.py#L59). +- `save_lock`: commit `lock` object to storage; requires `lock` parameter which is of type `TicketLock`and returns `None`. + [(source code - see for signature)](https://github.com/RasaHQ/rasa/blob/main/rasa/core/lock_store.py#L67). +- `delete_lock`: deletes lock for `conversation_id` from storage; requires `conversation_id` text parameter and returns `None`. + [(source code - see for signature)](https://github.com/RasaHQ/rasa/blob/main/rasa/core/lock_store.py#L63). + +### Configuration + +Put the module path to your custom event broker and the parameters you require in your `endpoints.yml`: + +```yaml-rasa title="endpoints.yml" +lock_store: + type: path.to.your.module.Class + url: localhost + a_parameter: a value + another_parameter: another value +``` diff --git a/docs/docs/markers.mdx b/docs/docs/markers.mdx new file mode 100644 index 0000000..0f74cd5 --- /dev/null +++ b/docs/docs/markers.mdx @@ -0,0 +1,351 @@ +--- +id: markers +sidebar_label: Markers +title: Markers +description: Find out how to mark points of interest in dialogues using Marker conditions. +abstract: Markers are conditions used to describe and mark points of interest in dialogues. +--- + + + +:::caution + +This feature is currently experimental and might change or be removed in the future. Share your feedback in the forum to help us make it production-ready. + +::: + +:::danger Deprecated + +In the upcoming release version 3.7 of Rasa Open Source, we’re removing this experimental feature. For documentation on the markers feature in Rasa Pro, please [click here](./monitoring/analytics/realtime-markers.mdx) + +::: + +## Overview + +Markers are conditions that allow you to describe and mark points of interest in dialogues for evaluating your bot. + +In Rasa, a dialogue is represented as a sequence of events, +which include bot actions that were executed, intents that were detected, and slots that were set. +Markers allow you to describe conditions over such events. When the conditions are met, the relevant events are marked for further analysis or inspection. + +There are several downstream applications for Markers. For example, they can be used to define and measure your bot's **Key Performance Indicators (KPIs)**, +such as **dialogue completion** or **task success**. Take [Carbon Bot](https://rasa.com/blog/using-conversation-tags-to-measure-carbon-bots-success-rate/) +for example, which helps users offset their carbon emissions from flying. For Carbon Bot, you can define dialogue completion as "all mandatory slots have been filled", +and task success as "all mandatory slots have been filled and a carbon estimate has been successfully computed". +Marking when these important events occur allows you to measure Carbon Bot's success rate. + +Markers also allow you to **diagnose your dialogues** by surfacing important events for further inspection. +For example, you might observe that Carbon Bot tends to successfully set the `travel_departure` and `travel_destination` slots, +but fails to set the `travel_flight_class` slot. You can define a marker to quantify how often this behavior occurs +and surface relevant dialogues for review as part of +[Conversation Driven Development (CDD)](./conversation-driven-development.mdx). + +Marker definitions are written in `YAML` in a marker configuration file. For example, here are the markers that define dialogue completion and task success for Carbon Bot: + +```yaml +marker_dialogue_completion: + and: + - slot_was_set: travel_departure + - slot_was_set: travel_destination + - slot_was_set: travel_flight_class + +marker_task_success: + description: "Measure task success where all required slots are set and the custom action was triggered" + and: + - slot_was_set: travel_departure + - slot_was_set: travel_destination + - slot_was_set: travel_flight_class + - action: provide_carbon_estimate +``` + +And here is the marker for surfacing dialogues where all mandatory slots are set except `travel_flight_class`: + +```yaml +marker_dialogue_mandatory_slot_failure: + and: + - slot_was_set: travel_departure + - slot_was_set: travel_destination + - not: + - slot_was_set: travel_flight_class +``` + +The next sections explain how to write marker definitions, how to apply them to your existing dialogues, and what the output format looks like. + +## Defining Markers + +Markers should be defined in a marker configuration file written in `YAML`. +Each marker should have a **unique identifier**, and consists of at least one **event condition**. +Markers can also contain **operators**, which allow you to express more nuanced behavior or +combine event conditions. + +Consider the following marker definition: +```yaml +marker_mood_expressed: + description: "Mood expressed was either unhappy or great" + or: + - intent: mood_unhappy + - intent: mood_great +``` +The unique marker identifier is `marker_mood_expressed`. This marker definition contains one operator `or`, +and two event conditions `intent: mood_unhappy` and `intent: mood_great`. + +This markers will be true at every point in the dialogue where the user +expressed either a `mood_unhappy` or a `mood_great`. More precisely, the marker will be true for every event +which is a `UserUttered()` with the `intent` equal to `mood_unhappy` or a `mood_great`. + +### Event Conditions + +The following event condition labels are supported: + +- `action`: the specified bot action was executed. +- `intent`: the specified user intent was detected. +- `slot_was_set`: the specified slot was set. + +The negated forms of the labels are also supported: + +- `not_action`: the event is not the specified bot action. +- `not_intent`: the event is not the specified user intent. +- `slot_was_not_set`: the specified slot has not been set. + +### Operators + +The following operators are supported: + +- `and`: all listed conditions applied. +- `or`: any of the listed conditions applied. +- `not`: the condition did not apply. This operator only accepts 1 condition. +- `seq`: the list of conditions applied in the specified order, with any number of events occurring in-between. +- `at_least_once`: the listed marker definitions occurred at least once. Only the first occurrence will be marked. +- `never`: the listed marker definitions never occurred. + +### Marker Configuration + +Here is an example of a marker configuration file containing several marker definitions. The example is created for mood bot, +with a new slot `name` to illustrate the use of the label `slot_was_set`: + +```yaml +marker_name_provided: + description: "slot `name` was provided" + slot_was_set: name + +marker_mood_expressed: + or: + - intent: mood_unhappy + - intent: mood_great + +marker_cheer_up_failed: + seq: + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: deny + +marker_bot_not_challenged: + description: "Example of a negated marker, it can be used to surface conversations without bot_challenge intent" + never: + - intent: bot_challenge + +marker_cheer_up_attempted: + at_least_once: + - action: utter_cheer_up + +marker_mood_expressed_and_name_not_provided: + and: + - or: + - intent: mood_unhappy + - intent: mood_great + - not: + - slot_was_set: name +``` +Note the following: + +- Each marker has a unique identifier (or name) such as `marker_name_provided`. + +- Each marker can have an optional `description` key that can be used for documentation. + +- A marker definition can contain a single condition, as shown in `marker_name_provided`. + +- A marker definition can contain a single operator with a list of conditions, as shown in `marker_mood_expressed`, +`marker_cheer_up_failed`, `marker_bot_not_challenged`, and `marker_cheer_up_attempted`. + +- A marker definition can contain nested operators, as shown in `marker_mood_expressed_and_name_not_provided`. + +- The values assigned to event conditions must be valid according to your bot's `domain.yml` file. For example, in +`marker_mood_expressed`, the intents `mood_unhappy` and `mood_unhappy` are both intents listed in the mood bot's `domain.yml` file. + +:::note +You cannot reuse an existing marker name in the definition of another marker. +::: + +## Extracting Markers + +:::info + +Rasa Pro supports real-time processing of markers, Read more about [Real-Time Markers](monitoring/analytics/realtime-markers.mdx) + +::: + +Markers are extracted from dialogues already stored in a tracker store. To learn how to store interactions with your bot in a tracker store, +read the [Tracker Store](./tracker-stores.mdx) page. + +Once you've created your marker definitions in the marker configuration file, and have stored some dialogues in your tracker store, +you can apply your markers to your trackers by running the following command: + +```bash +rasa evaluate markers all --config markers.yml extracted_markers.csv +``` +This script will process the marker definitions you provide in the marker configuration file: `markers.yml`. +The script will output the extracted markers in the specified output file: `extracted_markers.csv`. +It will also produce two summary statistics files. The format of the output files are described in the next section. + +By default, the script will validate your marker definitions against your bot's `domain.yml` file. To specify a different +domain file, use the optional `--domain` argument. + +By default, the script will process the tracker store in your bot's `endpoint.yml`. However, you can specify a different +endpoint file using the optional `--endpoint` argument. + +Three different tracker loading strategies are supported: `all`, `sample_n`, and `first_n`. The option `all` will process +all the trackers in your tracker store. The other two strategies process a subset of `N` trackers, either sequentially +(by using `first_n`), or by sampling uniformly without replacement (using `sample_n`). The sampling strategy also allows you to set the random seed. +For more information on the usage of each strategy, type the following command, replacing `<strategy>` with one of: `all`, `first_n`, and `sample_n`: +```bash +rasa evaluate markers <strategy> --help +``` + +:::note +Each tracker in the tracker store can contain multiple sessions. The script will process each session separately, indexing them by `session_idx`. +::: +The next two sections describe the formats of the extracted markers and computed statistics. + + +### Extracted Markers + +For each marker defined in your marker configuration file, the following information is extracted: +1. **The index of the event** at which the marker applied. +2. **The number of user turns preceding the event** at which the marker applied. Each `UserUttered` event is treated as a user turn. + +The **index of the event** and the **number of preceding user turns** both give an indication of how +long it took to reach an important event, such as task success. +The index of the event will count all events, including ones that are not part of the dialogue, +such as starting a new session or executing a custom action. +The number of preceding user turns, on the other hand, gives you a more intuitive indication of the dialogue length, +and in particular from the perspective of your end user. + +The number of preceding user turns can be used to evaluate and improve your bot. +For example, suppose a user had to rephrase their utterances multiple times, which caused their dialogue to become longer. +The dialogue may eventually reach task success, however, surfacing it would allow you identify utterances that your bot failed to understand. +You can then use these challenging utterances as additional training data to further improve your bot as part of +[Conversation Driven Development (CDD)](./conversation-driven-development.mdx). + +:::note +For markers defined using the `at_least_once` operator, the information above will only be extracted for the first occurrence. +::: + +The extracted markers are stored in a tabular format in the `.csv` file you specify in the script, for example, `extracted_markers.csv`. +The extracted markers output file contains the following columns: + +- `sender_id`: taken from the trackers. +- `session_idx`: an integer indexing sessions, starting with `0`. +- `marker`: the unique marker identifier. +- `event_idx`: an integer indexing events, starting with `0`. +- `num_preceding_user_turns`: an integer indicating the number of user turns preceding the event at which the marker applied. + +Here is an example of the extracted markers output file (for a marker configuration file containing two markers: `marker_mood_expressed` and `marker_cheer_up_failed`): + +``` +sender_id,session_idx,marker,event_idx,num_preceding_user_turns +3c1afa1ed72c4116ba6670a1668f1b4a,0,marker_mood_expressed,2,0 +4d55093e9696452c8d1157fa33fd54b2,0,marker_mood_expressed,7,1 +4d55093e9696452c8d1157fa33fd54b2,0,marker_cheer_up_failed,14,2 +c00b3de97713427d85524c4374125db1,0,marker_mood_expressed,2,0 +``` +Each row represents an occurrence of the marker specified under the `marker` column, for each `sender_id` and `session_idx`. + +### Computed Statistics + +By default, the command computes summary statistics about the information gathered. To disable the statistics computation, use the optional flag `--no-stats`. + +The script computes the following statistics: + +1. **For each session and each marker**: "per-session statistics" which include the arithmetic mean, median, minimum, and maximum number of user turns preceding the event at which the marker applied. +2. **For all sessions and for each marker**: + 1. Overall statistics including the arithmetic mean, median, minimum, and maximum number of user turns preceding the event where the marker applied in any session. + 2. The number of sessions and the percentage of sessions where each marker applied at least once. + +The results are stored in a tabular format in `stats-overall.csv` and `stats-per-session.csv`. You can change prefix `stats` in the file names using the optional argument `--stats-file-prefix `. +For example, the following script will produce the files: `my-statistics-overall.csv` and `my-statistics-per-session.csv`: +```bash +rasa evaluate markers all --stats-file-prefix "my-statistics" extracted_markers.csv +``` + +The two statistics files contain the following columns: + +- `sender_id`: taken from the trackers. If the statistic is computed over all sessions this will be equal to `all`. +- `session_idx`: an integer indexing sessions, starting with `0`. If the statistic is computed over all sessions, this will be equal to `nan` (not a number). +- `marker`: the unique marker identifier. +- `statistic`: a description of the statistic computed. +- `value`: an integer or float value of the computed statistic. If the statistic is not available then `value` will be equal to `nan` (not a number). + +Here is a sample `stats-per-session.csv` output: +``` +sender_id,session_idx,marker,statistic,value +3c1afa1ed72c4116ba6670a1668f1b4a,0,marker_cheer_up_failed,count(number of preceding user turns),0 +4d55093e9696452c8d1157fa33fd54b2,0,marker_cheer_up_failed,count(number of preceding user turns),1 +c00b3de97713427d85524c4374125db1,0,marker_cheer_up_failed,count(number of preceding user turns),0 +3c1afa1ed72c4116ba6670a1668f1b4a,0,marker_cheer_up_failed,max(number of preceding user turns),nan +4d55093e9696452c8d1157fa33fd54b2,0,marker_cheer_up_failed,max(number of preceding user turns),2 +c00b3de97713427d85524c4374125db1,0,marker_cheer_up_failed,max(number of preceding user turns),nan +3c1afa1ed72c4116ba6670a1668f1b4a,0,marker_cheer_up_failed,mean(number of preceding user turns),nan +4d55093e9696452c8d1157fa33fd54b2,0,marker_cheer_up_failed,mean(number of preceding user turns),2.0 +c00b3de97713427d85524c4374125db1,0,marker_cheer_up_failed,mean(number of preceding user turns),nan +3c1afa1ed72c4116ba6670a1668f1b4a,0,marker_cheer_up_failed,median(number of preceding user turns),nan +4d55093e9696452c8d1157fa33fd54b2,0,marker_cheer_up_failed,median(number of preceding user turns),2.0 +c00b3de97713427d85524c4374125db1,0,marker_cheer_up_failed,median(number of preceding user turns),nan +3c1afa1ed72c4116ba6670a1668f1b4a,0,marker_cheer_up_failed,min(number of preceding user turns),nan +4d55093e9696452c8d1157fa33fd54b2,0,marker_cheer_up_failed,min(number of preceding user turns),2 +c00b3de97713427d85524c4374125db1,0,marker_cheer_up_failed,min(number of preceding user turns),nan +3c1afa1ed72c4116ba6670a1668f1b4a,0,marker_mood_expressed,count(number of preceding user turns),1 +4d55093e9696452c8d1157fa33fd54b2,0,marker_mood_expressed,count(number of preceding user turns),1 +c00b3de97713427d85524c4374125db1,0,marker_mood_expressed,count(number of preceding user turns),1 +3c1afa1ed72c4116ba6670a1668f1b4a,0,marker_mood_expressed,max(number of preceding user turns),0 +4d55093e9696452c8d1157fa33fd54b2,0,marker_mood_expressed,max(number of preceding user turns),1 +c00b3de97713427d85524c4374125db1,0,marker_mood_expressed,max(number of preceding user turns),0 +3c1afa1ed72c4116ba6670a1668f1b4a,0,marker_mood_expressed,mean(number of preceding user turns),0.0 +4d55093e9696452c8d1157fa33fd54b2,0,marker_mood_expressed,mean(number of preceding user turns),1.0 +c00b3de97713427d85524c4374125db1,0,marker_mood_expressed,mean(number of preceding user turns),0.0 +3c1afa1ed72c4116ba6670a1668f1b4a,0,marker_mood_expressed,median(number of preceding user turns),0.0 +4d55093e9696452c8d1157fa33fd54b2,0,marker_mood_expressed,median(number of preceding user turns),1.0 +c00b3de97713427d85524c4374125db1,0,marker_mood_expressed,median(number of preceding user turns),0.0 +3c1afa1ed72c4116ba6670a1668f1b4a,0,marker_mood_expressed,min(number of preceding user turns),0 +4d55093e9696452c8d1157fa33fd54b2,0,marker_mood_expressed,min(number of preceding user turns),1 +c00b3de97713427d85524c4374125db1,0,marker_mood_expressed,min(number of preceding user turns),0 +``` +Note that the value for unavailable statistics is `nan`. For example, because `marker_cheer_up_failed` never occurred in + tracker `3c1afa1ed72c4116ba6670a1668f1b4a` session `0`, then the `min`, `max`, `median`, and `mean` number of preceding user turns + are equal to `nan`. + +Here is a sample `stats-overall.csv` output: +``` +sender_id,session_idx,marker,statistic,value +all,nan,-,total_number_of_sessions,3 +all,nan,marker_cheer_up_failed,number_of_sessions_where_marker_applied_at_least_once,1 +all,nan,marker_cheer_up_failed,percentage_of_sessions_where_marker_applied_at_least_once,33.333 +all,nan,marker_mood_expressed,number_of_sessions_where_marker_applied_at_least_once,3 +all,nan,marker_mood_expressed,percentage_of_sessions_where_marker_applied_at_least_once,100.0 +all,nan,marker_cheer_up_failed,count(number of preceding user turns),1 +all,nan,marker_cheer_up_failed,mean(number of preceding user turns),2.0 +all,nan,marker_cheer_up_failed,median(number of preceding user turns),2.0 +all,nan,marker_cheer_up_failed,min(number of preceding user turns),2 +all,nan,marker_cheer_up_failed,max(number of preceding user turns),2 +all,nan,marker_mood_expressed,count(number of preceding user turns),3 +all,nan,marker_mood_expressed,mean(number of preceding user turns),0.333 +all,nan,marker_mood_expressed,median(number of preceding user turns),0.0 +all,nan,marker_mood_expressed,min(number of preceding user turns),0 +all,nan,marker_mood_expressed,max(number of preceding user turns),1 +``` +Note that because each row computes a statistic over all sessions, the `sender_id` is equal to `all`, +and the `session_idx` is equal to `nan`. + +## Configuring the CLI command + +Visit our [CLI page](./command-line-interface.mdx#rasa-evaluate-markers) for more information on configuring the marker extraction and statistics computation process. \ No newline at end of file diff --git a/docs/docs/messaging-and-voice-channels.mdx b/docs/docs/messaging-and-voice-channels.mdx new file mode 100644 index 0000000..e276f3b --- /dev/null +++ b/docs/docs/messaging-and-voice-channels.mdx @@ -0,0 +1,72 @@ +--- +id: messaging-and-voice-channels +sidebar_label: Connecting to a Channel +title: Connecting to Messaging and Voice Channels +description: Check out how to make your Rasa assistant available on platforms like Facebook Messenger, Slack, Telegram or even your very own website. +abstract: Rasa provides many built-in connectors to connect to common messaging and voice channels. + You can also connect to your website or app with pre-configured REST channels or build your own custom connector. +--- + +## Connecting to A Channel + +Learn how to make your assistant available on: + +* [Your Own Website](./connectors/your-own-website.mdx) + +* [Facebook Messenger](./connectors/facebook-messenger.mdx) + +* [Slack](./connectors/slack.mdx) + +* [Telegram](./connectors/telegram.mdx) + +* [Twilio](./connectors/twilio.mdx) + +* [Microsoft Bot Framework](./connectors/microsoft-bot-framework.mdx) + +* [Cisco Webex Teams](./connectors/cisco-webex-teams.mdx) + +* [RocketChat](./connectors/rocketchat.mdx) + +* [Mattermost](./connectors/mattermost.mdx) + +* [Google Hangouts Chat](./connectors/hangouts.mdx) + +* [Custom Connectors](./connectors/custom-connectors.mdx) + + +## Testing Channels on Your Local Machine + +If you're running a Rasa server on `localhost`, +most external channels won't be able to find your server URL, since `localhost` is not open to the internet. + +To make a port on your local machine publicly available on the internet, +you can use [ngrok](https://ngrok.com/). Alternatively, see this [list](https://github.com/anderspitman/awesome-tunneling) +tracking and comparing other tunneling solutions. + +After installing ngrok, run: + +```bash +ngrok http 5005; rasa run +``` + +When you follow the instructions to make your assistant available on a channel, use the ngrok URL. +Specifically, wherever the instructions say to use `https://<host>:<port>/webhooks/<CHANNEL>/webhook`, +use `<ngrok_url>/webhooks/<CHANNEL>/webhook`, replacing `<ngrok_url>` with the randomly generated +URL displayed in your ngrok terminal window. For example, if connecting your bot to Slack, +your URL should resemble `https://26e7e7744191.ngrok.io/webhooks/slack/webhook`. + +:::caution +With the free-tier of ngrok, you can run into limits on how many connections you can make per minute. +As of writing this, it is set to 40 connections / minute. + +::: + +Alternatively you can make your assistant listen on a specific address using the `-i` command line +option: + +```bash +rasa run -p 5005 -i 192.168.69.150 +``` + +This is particularly useful when your internet facing machines connect to backend servers using a VPN +interface. diff --git a/docs/docs/migrate-from.mdx b/docs/docs/migrate-from.mdx new file mode 100644 index 0000000..db12811 --- /dev/null +++ b/docs/docs/migrate-from.mdx @@ -0,0 +1,27 @@ +--- +id: migrate-from +sidebar_label: Migrate From (beta) +title: Migrate From Other Tools (beta) +--- + +Here are a few reasons why we see developers switching from other tools to Rasa: + +* **Faster**: Runs locally - no HTTP requests or server round trips required + +* **Customizable**: Tune models and get higher accuracy with your data set + +* **Open source**: No risk of vendor lock-in - Rasa is under the Apache 2.0 license and you can +use it in commercial projects + +In addition, our open source tools allow developers to build contextual AI assistants and manage dialogues +with machine learning instead of rules - check it out in <a className="reference external" href="http://blog.rasa.com/a-new-approach-to-conversational-software/" target="_blank">this blog post</a>. + +Learn how to migrate from: + +* [Google Dialogflow](./migrate-from/google-dialogflow-to-rasa.mdx) + +* [Wit.ai](./migrate-from/facebook-wit-ai-to-rasa.mdx) + +* [Microsoft LUIS](./migrate-from/microsoft-luis-to-rasa.mdx) + +* [IBM Watson](./migrate-from/ibm-watson-to-rasa.mdx) \ No newline at end of file diff --git a/docs/docs/migrate-from/dialogflow_export.png b/docs/docs/migrate-from/dialogflow_export.png new file mode 100644 index 0000000..41e0d20 Binary files /dev/null and b/docs/docs/migrate-from/dialogflow_export.png differ diff --git a/docs/docs/migrate-from/dialogflow_export_2.png b/docs/docs/migrate-from/dialogflow_export_2.png new file mode 100644 index 0000000..4b0e940 Binary files /dev/null and b/docs/docs/migrate-from/dialogflow_export_2.png differ diff --git a/docs/docs/migrate-from/facebook-wit-ai-to-rasa.mdx b/docs/docs/migrate-from/facebook-wit-ai-to-rasa.mdx new file mode 100644 index 0000000..5888ef6 --- /dev/null +++ b/docs/docs/migrate-from/facebook-wit-ai-to-rasa.mdx @@ -0,0 +1,78 @@ +--- +id: facebook-wit-ai-to-rasa +sidebar_label: "Rasa as open source alternative to Facebook\u2019s Wit.ai - Migration\ + \ Guide" +title: "Rasa as open source alternative to Facebook\u2019s Wit.ai - Migration Guide" +description: Open source alternative to Facebook's Wit.ai for conversational bots and NLP +--- + +To get started with migrating your application from Wit.ai to Rasa: + +## Step 1: Export your Training Data from Wit.ai + +Navigate to your app's setting page by clicking the **Settings** item +in the **Management** section of the left navigation bar. +Scroll down to **Export your data** and hit the button **Download .zip with your data**. + +This will download a file with a `.zip` extension. Unzip this file to create a folder. +The files you want from your download are located in the **utterances** directory. + +## Step 2: Create a Rasa Project + +To create a Rasa project, run: + +```bash +rasa init +``` + +This will create a directory called `data`. +Remove the files in this directory, and move the content of the `utterances` directory +to `data`. + +```bash +rm -rf data/ +mv /path/to/utterances data/ +``` + +## Step 3: Train your NLU model + +To train a model using your Wit data, run: + +```bash +rasa train nlu +``` + +## Step 4: Test your NLU model + +Let's see how your NLU model will interpret some test messages. +To start a testing session, run: + +```bash +rasa shell nlu +``` + +This will prompt your for input. +Type a test message and press 'Enter'. +The output of your NLU model will be printed to the screen. +You can keep entering messages and test as many as you like. +Press 'control + C' to quit. + +## Step 5: Start a Server with your NLU Model + +To start a server with your NLU model, run: + +```bash +rasa run nlu +``` + +This will start a server listening on port 5005. + +To send a request to the server, run: + +```bash +curl 'localhost:5005/model/parse?emulation_mode=wit' -d '{"text": "hello"}' +The `emulation_mode` parameter tells Rasa that you want your json +response to have the same format as you would get from wit.ai. +You can also leave it out to get the result in the usual Rasa format. + +Join the [Rasa Community Forum](https://forum.rasa.com/) and let us know how your migration went! diff --git a/docs/docs/migrate-from/google-dialogflow-to-rasa.mdx b/docs/docs/migrate-from/google-dialogflow-to-rasa.mdx new file mode 100644 index 0000000..80068ba --- /dev/null +++ b/docs/docs/migrate-from/google-dialogflow-to-rasa.mdx @@ -0,0 +1,94 @@ +--- +sidebar_label: Rasa as open source alternative to Google Dialogflow - Migration Guide +title: Rasa as open source alternative to Google Dialogflow - Migration Guide +description: Open source alternative to Google Dialogflow for conversational bots and NLP +--- +import useBaseUrl from '@docusaurus/useBaseUrl'; + +Let's get started with migrating your application from Dialogflow to Rasa (you can find a more detailed +tutorial <a className="reference external" href="http://blog.rasa.com/how-to-migrate-your-existing-google-dialogflow-assistant-to-rasa/" target="_blank">here</a>): + +## Step 1: Export your data from Dialogflow + +Navigate to your agent's settings by clicking the gear icon. + +import dialogflowExport1 from './dialogflow_export.png'; + +<Image img={dialogflowExport1} width="240" caption="Selecting settings" alt="In Dialogflow, the gear icon that indicates settings sits next to the dropdown menu for choosing between projects." /> + + +Click on the 'Export and Import' tab and click on the 'Export as ZIP' button. + +import dialogflowExport2 from './dialogflow_export_2.png'; + +<Image img={dialogflowExport2} caption="Selecting Export and Import" alt="The Settings page will let you select from multiple headers, of which you want to select Export and Import." /> + +This will download a file with a `.zip` extension. Unzip this file to create a folder. + +## Step 2: Create a Rasa Project + +To create a Rasa project, run: + +```bash +rasa init +``` + +This will create a directory called `data`. +Remove the files in this directory, and +move your unzipped folder into this directory. + +```bash +rm -r data/* +mv testagent data/ +``` + +## Step 3: Train your NLU model + +To train a model using your Dialogflow data, run: + +```bash +rasa train nlu +``` + +## Step 4: Test your NLU model + +Let's see how your NLU model will interpret some test messages. +To start a testing session, run: + +```bash +rasa shell nlu +``` + +This will prompt your for input. +Type a test message and press 'Enter'. +The output of your NLU model will be printed to the screen. +You can keep entering messages and test as many as you like. +Press 'control + C' to quit. + +## Step 5: Start a Server with your NLU Model + +To start a server with your NLU model, run: + +```bash +rasa run --enable-api +``` + +This will start a server listening on port 5005. + +To send a request to the server, run: + +```bash +curl 'localhost:5005/model/parse?emulation_mode=dialogflow' -d '{"text": "hello"}' +``` + +The `emulation_mode` parameter tells Rasa that you want your JSON response to have the same format as you would +get from the Dialogflow `sessions.detectIntent` endpoint (the format is +described [here](https://cloud.google.com/dialogflow/es/docs/reference/rest/v2/DetectIntentResponse)). +You can also leave it out to get the result in the usual Rasa format. + +## Terminology: + +The words `intent`, `entity`, and `utterance` have the same meaning in Rasa as they do in Dialogflow. +In Dialogflow, there is a concept called `Fulfillment`. In Rasa we call this a [Custom Action](../actions.mdx#custom-actions). + +Join the [Rasa Community Forum](https://forum.rasa.com/) and let us know how your migration went! diff --git a/docs/docs/migrate-from/ibm-watson-to-rasa.mdx b/docs/docs/migrate-from/ibm-watson-to-rasa.mdx new file mode 100644 index 0000000..a9fccd1 --- /dev/null +++ b/docs/docs/migrate-from/ibm-watson-to-rasa.mdx @@ -0,0 +1,10 @@ +--- +id: ibm-watson-to-rasa +sidebar_label: Rasa as open source alternative to IBM Watson - Migration Tips +title: Rasa as open source alternative to IBM Watson - Migration Tips +description: Open source alternative to IBM Watson for conversational bots and NLP +--- + +There is no support for IBM Watson yet. However, a group of community members is working on a way +to use <a className="reference external" href="https://developer.ibm.com/tutorials/learn-how-to-export-import-a-watson-assistant-workspace/" target="_blank">exported IBM Watson workspaces</a> +in Rasa. If you're interested in that, check out our <a className="reference external" href="https://forum.rasa.com/" target="_blank">Community Forum</a>. diff --git a/docs/docs/migrate-from/luis_export.png b/docs/docs/migrate-from/luis_export.png new file mode 100644 index 0000000..6c21c25 Binary files /dev/null and b/docs/docs/migrate-from/luis_export.png differ diff --git a/docs/docs/migrate-from/microsoft-luis-to-rasa.mdx b/docs/docs/migrate-from/microsoft-luis-to-rasa.mdx new file mode 100644 index 0000000..34a3f84 --- /dev/null +++ b/docs/docs/migrate-from/microsoft-luis-to-rasa.mdx @@ -0,0 +1,87 @@ +--- +id: microsoft-luis-to-rasa +sidebar_label: Rasa as open source alternative to Microsoft LUIS - Migration Guide +title: Rasa as open source alternative to Microsoft LUIS - Migration Guide +description: Open source alternative to Microsoft LUIS for conversational bots and NLP +--- +import useBaseUrl from '@docusaurus/useBaseUrl'; + +Let's get started with migrating your application from LUIS to Rasa: + +## Step 1: Export your Training Data from LUIS + +Go to your list of [LUIS conversation apps](https://www.luis.ai/conversations/applications) and select the application +you want to export. + +import luisImage from './luis_export.png'; + +<Image img={luisImage} caption="Export menu" alt="Opening the menu reveals the 'Export to JSON' option." /> + +Select 'Export' > 'Export as JSON'. This will download a file with a `.json` extension that can be imported directly into Rasa. + +## Step 2: Create a Rasa Project + +To create a Rasa project, run: + +```bash +rasa init +``` + +This will create a directory called `data`. +Remove the files in this directory, and +move your json file into this directory. + +```bash +rm -r data/* +mv /path/to/file.json data/ +``` + +## Step 3: Train your NLU model + +To train a model using your LUIS data, run: + +```bash +rasa train nlu +``` + +## Step 4: Test your NLU model + +Let's see how your NLU model will interpret some test messages. +To start a testing session, run: + +```bash +rasa shell nlu +``` + +This will prompt your for input. +Type a test message and press 'Enter'. +The output of your NLU model will be printed to the screen. +You can keep entering messages and test as many as you like. +Press 'control + C' to quit. + +## Step 5: Start a Server with your NLU Model + +To start a server with your NLU model, run: + +```bash +rasa run +``` + +This will start a server listening on port 5005. + +To send a request to the server, run: + +```bash +curl 'localhost:5005/model/parse?emulation_mode=luis' -d '{"text": "hello"}' +The `emulation_mode` parameter tells Rasa that you want your json +response to have the same format as you would get from LUIS. +You can also leave it out to get the result in the usual Rasa format. + +## Terminology: + +The words `intent`, `entity`, `role`, and `utterance` have the same meaning in Rasa as they do +in LUIS. +LUIS's `patterns` feature is very similar to Rasa NLU's [regex features](./training-data-format.mdx#regular-expressions) +LUIS's `phrase lists` feature does not currently have an equivalent in Rasa NLU. + +Join the [Rasa Community Forum](https://forum.rasa.com/) and let us know how your migration went! diff --git a/docs/docs/migration-guide.mdx b/docs/docs/migration-guide.mdx new file mode 100644 index 0000000..d79bc11 --- /dev/null +++ b/docs/docs/migration-guide.mdx @@ -0,0 +1,2743 @@ +--- +id: migration-guide +sidebar_label: Version Migration Guide +title: Version Migration Guide +description: | + Information about changes between major versions of chatbot framework Rasa Core + and how you can migrate from one version to another. +--- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +This page contains information about changes between major versions and +how you can migrate from one version to another. + + +## Rasa 3.0 to 3.1 + +### Machine Learning Components + +#### TensorFlow Upgrade + +Due to the TensorFlow upgrade, we can't guarantee the exact same output and hence +model performance if your configuration uses `LanguageModelFeaturizer`. +This applies to the case where the model is re-trained with the new Rasa +version without changing the configuration, random seeds, and data as well as to the +case where a model trained with a previous version of Rasa is loaded with +this new version for inference. + +Please check whether your trained model still performs as expected and retrain if needed. + +### NLU JSON Format + +[NLU training data](nlu-training-data.mdx) in JSON format is deprecated and will be +removed in Rasa 4.0. +Please use `rasa data convert nlu -f yaml --data <path to NLU data>` to convert your +NLU JSON data to YAML format before support for NLU JSON data is removed. + +## Rasa 2.x to 3.0 + +### Markdown Data + +Markdown is no longer supported — all the supporting code that was previously deprecated is +now removed, and the convertors are removed as well. + +The related CLI commands `rasa data convert responses` and `rasa data convert config` +were removed. + +If you still have training data in Markdown format then the recommended approach is to use Rasa 2.x +to convert your data from Markdown to YAML. Please use the commands described +[here](./migration-guide.mdx#training-data-files). + +### Model Configuration + +It is required to specify the used `recipe` within the +[model configuration](model-configuration.mdx). As of now Rasa only supports +the `default.v1` recipe and will continue using it even if you don't specify a recipe +in the model configuration. To avoid breaking changes in the future you should +to specify `recipe: "default.v1"` at the top of your model configuration: + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-config" defaultValue="new"> + <TabItem value="old"> + + ```yaml-rasa + language: en + + pipeline: + ... + policies: + ... + ``` + </TabItem> + <TabItem value="new"> + + ```yaml-rasa {1} + recipe: default.v1 + + language: en + + pipeline: + ... + policies: + ... + ``` + </TabItem> +</Tabs> + +### Custom Policies and Custom Components + +Rasa 3.0 changed the way [NLU components](components.mdx) and +[policies](policies.mdx) are trained and run during inference. As part of these changes +the interfaces for NLU components and policies have been unified and adapted. + +The next sections outline which adaptions are required to run your custom NLU components +and policies with Rasa 3.0. + +:::caution + +Please read the updated guide on custom graph components [here](custom-graph-components.mdx) before +continuing to follow the step-by-step guide to migrate your own custom graph components. +::: + +**Type Annotations** + +Until Rasa 3.0 [type annotations](https://docs.python.org/3/library/typing.html) +were not required in custom policies or custom NLU components. +It is now required to use +[type annotations](https://docs.python.org/3/library/typing.html) in custom NLU +components and policies. Rasa uses these type annotations to validate that +your graph components are compatible and correctly configured. As outlined in the custom +[components guide](custom-graph-components.mdx) it is not allowed to use +[forward references](https://www.python.org/dev/peps/pep-0484/#forward-references). + +:::info Forward References with Python 3.7 +Use the +[`from __future__ import annotations`](https://www.python.org/dev/peps/pep-0563/#enabling-the-future-behavior-in-python-3-7) +import to avoid using forward references with Python 3.7. +::: + + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-type-annotations" defaultValue="new"> + <TabItem value="old"> + + ```python + from typing import List, Any + from rasa.core.policies.policy import Policy + + class MyPolicy(Policy): + def train( + self, + training_trackers: List["TrackerWithCachedStates"], + domain, + **kwargs: Any, + ): + ... + ``` + + </TabItem> + <TabItem value="new"> + + ```python + from __future__ import annotations + from typing import List, Any + + from rasa.core.policies.policy import Policy + from rasa.engine.storage.resource import Resource + from rasa.shared.core.domain import Domain + from rasa.shared.core.generator import TrackerWithCachedStates + + class MyPolicy(Policy): + def train( + self, + training_trackers: List[TrackerWithCachedStates], + domain: Domain, + **kwargs: Any, + ) -> Resource: + ... + ``` + + </TabItem> +</Tabs> + +#### Changes to Custom NLU Components + +**Inheriting from `GraphComponent`** + +NLU components which previously inherited from one of the following classes additionally +need to inherit from the +[`GraphComponent` interface](custom-graph-components.mdx#the-graphcomponent-interface): +- `SparseFeaturizer` +- `DenseFeaturizer` +- `IntentClassifier` +- `EntityExtractor` +- `Component` + +This snippet shows the required changes: + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-nlu-parent-class" defaultValue="new"> + <TabItem value="old"> + + ```python + from rasa.nlu.featurizers.sparse_featurizer.sparse_featurizer import SparseFeaturizer + + class MyNLUComponent(SparseFeaturizer): + ... + ``` + + </TabItem> + <TabItem value="new"> + + ```python {3} + from rasa.engine.graph import GraphComponent + from rasa.nlu.featurizers.sparse_featurizer.sparse_featurizer import SparseFeaturizer + + class MyNLUComponent(GraphComponent, SparseFeaturizer): + ... + ``` + + </TabItem> +</Tabs> + +** Inheriting from `EntityExtractorMixin` instead of `EntityExtractor`** + +The `EntityExtractor` class was renamed to `EntityExtractorMixin`: + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-nlu-entity-extractor" defaultValue="new"> + <TabItem value="old"> + + ```python + from rasa.nlu.extractors.extractor import EntityExtractor + + class MyNLUComponent(EntityExtractor): + ... + ``` + + </TabItem> + <TabItem value="new"> + + ```python {2,4} + from rasa.engine.graph import GraphComponent + from rasa.nlu.extractors.extractor import EntityExtractorMixin + + class MyNLUComponent(GraphComponent, EntityExtractorMixin): + ... + ``` + + </TabItem> +</Tabs> + + +**Instantiating a NLU Component for Training** + +NLU components are no longer instantiated via their constructor. Instead, all NLU +components have to override the `create` method of the +[`GraphComponent` interface](custom-graph-components.mdx#the-graphcomponent-interface). The +passed in configuration is your NLU component's default configuration including any updates +from your model configuration file. + + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-nlu-init" defaultValue="new"> + <TabItem value="old"> + + ```python + from typing import Optional, Dict, Text, Any + from rasa.nlu.classifiers.classifier import IntentClassifier + + class MyNLUComponent(IntentClassifier): + + def __init__(self, component_config: Optional[Dict[Text, Any]] = None) -> None: + super().__init__(component_config) + ... + ``` + + </TabItem> + <TabItem value="new"> + + ```python + from typing import Dict, Text, Any + + from rasa.engine.graph import GraphComponent, ExecutionContext + from rasa.engine.storage.resource import Resource + from rasa.engine.storage.storage import ModelStorage + from rasa.nlu.classifiers.classifier import IntentClassifier + + class MyNLUComponent(GraphComponent, IntentClassifier): + + def __init__(self, component_config: Dict[Text, Any]) -> None: + self.component_config = component_config + ... + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + return cls(config) + ``` + + </TabItem> +</Tabs> + +**Persisting a Trained NLU Component** + +NLU components used to be persisted by a call to the NLU component's `persist` method +from outside the NLU component itself. +With Rasa 3.0 NLU components are responsible for persisting themselves. +Use the provided `model_storage` and `resource` parameters +to persist your NLU component at the end of the training and then return the `resource` +as result of your NLU component's `train` method. +See [component persistence](custom-graph-components.mdx#model-persistence) for more details. + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-nlu-persistence" defaultValue="new"> + <TabItem value="old"> + + ```python + from pathlib import Path + from typing import Optional, Any, Text, Dict + + from rasa.nlu.classifiers.classifier import IntentClassifier + from rasa.nlu.config import RasaNLUModelConfig + from rasa.shared.nlu.training_data.training_data import TrainingData + + class MyNLUComponent(IntentClassifier): + def train( + self, + training_data: TrainingData, + config: Optional[RasaNLUModelConfig] = None, + **kwargs: Any, + ) -> None: + ... + + def persist(self, file_name: Text, model_dir: Text) -> Dict[Text, Any]: + file_path = Path(model_dir) / "{file_name}.model_data.json" + rasa.shared.utils.io.create_directory_for_file(file_path) + rasa.shared.utils.io.dump_obj_as_json_to_file(file_path, + self.get_model_data()) + + return {"file": file_name} + ... + ``` + + </TabItem> + <TabItem value="new"> + + ```python + from rasa.engine.graph import GraphComponent + from rasa.engine.storage.resource import Resource + from rasa.nlu.classifiers.classifier import IntentClassifier + from rasa.shared.nlu.training_data.training_data import TrainingData + + class MyNLUComponent(GraphComponent, IntentClassifier): + def train(self, training_data: TrainingData) -> Resource: + ... + self.persist() + return self._resource + + def persist(self) -> None: + with self._model_storage.write_to(self._resource) as directory: + model_data_file = directory / "model_data.json" + rasa.shared.utils.io.dump_obj_as_json_to_file(model_data_file, + self.get_model_data()) + ... + ``` + + </TabItem> +</Tabs> + + +**Instantiating a Trained NLU Component** + +Previously NLU components had to persist their own configuration. Now the config passed +into `load` will automatically contain the configuration which your model was trained with. +To instantiate a persisted NLU component, you need to use `model_storage` and `resource` in your NLU component's +`load` method. + + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-nlu-loading" defaultValue="new"> + <TabItem value="old"> + + ```python + import json + from pathlib import Path + from typing import Text, Dict, Any, Optional + + from rasa.nlu.classifiers.classifier import IntentClassifier + from rasa.nlu.model import Metadata + + class MyNLUComponent(IntentClassifier): + @classmethod + def load( + cls, + meta: Dict[Text, Any], + model_dir: Text, + model_metadata: Metadata = None, + cached_component: Optional["DIETClassifier"] = None, + should_finetune: bool = False, + **kwargs: Any, + ) -> "MyNLUComponent": + file_name = meta.get("file") + file_path = Path(model_dir) / "{file_name}.model_data.json" + model_data = json.loads(rasa.shared.utils.io.read_file(file_path)) + + return cls(model_data) + ... + ``` + + </TabItem> + <TabItem value="new"> + + ```python + from __future__ import annotations + import json + from typing import Any, Text, Dict + + from rasa.engine.graph import GraphComponent, ExecutionContext + from rasa.engine.storage.resource import Resource + from rasa.engine.storage.storage import ModelStorage + from rasa.nlu.classifiers.classifier import IntentClassifier + from rasa.shared.exceptions import FileIOException + + class MyNLUComponent(GraphComponent, IntentClassifier): + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> MyNLUComponent: + model_data = {} + + try: + with model_storage.read_from(resource) as path: + + model_data_file = path / "model_data.json" + model_data = json.loads(rasa.shared.utils.io.read_file(model_data_file)) + + except (ValueError, FileNotFoundError, FileIOException): + logger.debug( + f"Couldn't load metadata for component '{cls.__name__}' as the persisted " + f"model data couldn't be loaded." + ) + + return cls( + config, model_data=model_data + ) + ``` + </TabItem> +</Tabs> + +**Providing a Default Configuration for an NLU Component** + +The default configuration is no longer a static class property but instead returned +by the static method `get_default_config`: + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-nlu-default-config" defaultValue="new"> + <TabItem value="old"> + + ```python + from rasa.nlu.classifiers.classifier import IntentClassifier + + class MyNLUComponent(IntentClassifier): + ... + defaults = {"key1": "value1"} + ``` + + </TabItem> + <TabItem value="new"> + + ```python + from typing import Text, Any, Dict + + from rasa.engine.graph import GraphComponent + from rasa.nlu.classifiers.classifier import IntentClassifier + + class MyNLUComponent(GraphComponent, IntentClassifier): + ... + @staticmethod + def get_default_config() -> Dict[Text, Any]: + return {"key1": "value1"} + + ``` + + </TabItem> +</Tabs> + +**Augmenting Training Data in an NLU Component** + +NLU Components like [tokenizers](components.mdx#tokenizers) or +[featurizers](components.mdx#featurizers) augment the training data with their +output during the model training. Their output is required by NLU components later in the +pipeline. Typically, featurizers require *tokenized* messages and intent +classifiers require *featurized* training data to train themselves. Rasa +3.0 makes these different purposes explicit. Previously both NLU component training and +training data augmentation were done as part of the `train` method. In Rasa +3.0 they are split into `train` and `process_training_data`: + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-component-train-process-split" defaultValue="new"> + <TabItem value="old"> + + ```python + from typing import Optional, Any + + from rasa.nlu.featurizers.sparse_featurizer.sparse_featurizer import SparseFeaturizer + from rasa.nlu.config import RasaNLUModelConfig + from rasa.shared.nlu.training_data.training_data import TrainingData + + class MyNLUComponent(SparseFeaturizer): + def train( + self, + training_data: TrainingData, + config: Optional[RasaNLUModelConfig] = None, + **kwargs: Any, + ) -> None: + self.train_featurizer(training_data) + + for message in training_data.training_examples: + self.add_features(message) + ``` + + </TabItem> + <TabItem value="new"> + + ```python + from rasa.engine.graph import GraphComponent + from rasa.engine.storage.resource import Resource + from rasa.nlu.featurizers.sparse_featurizer.sparse_featurizer import SparseFeaturizer + from rasa.shared.nlu.training_data.training_data import TrainingData + + class MyNLUComponent(GraphComponent, SparseFeaturizer): + def train(self, training_data: TrainingData) -> Resource: + self.train_featurizer(training_data) + + self.persist() + return self._resource + + def process_training_data(self, training_data: TrainingData) -> TrainingData: + for message in training_data.training_examples: + self.add_features(message) + + return training_data + ``` + </TabItem> +</Tabs> + +**Handling Lists of Messages During Inference in an NLU Component** + +NLU components used to receive a single `Message` object during inference. +Starting with Rasa 3.0 all NLU components have to support a list of +messages during inference. Unless your component supports batch predictions the easiest +way to handle this is to loop over the messages. It is also required to return the +message objects at the end of the `process` method. + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-component-process-messages-split" defaultValue="new"> + <TabItem value="old"> + + ```python + from typing import Any + + from rasa.nlu.classifiers.classifier import IntentClassifier + from rasa.shared.nlu.training_data.message import Message + + class MyNLUComponent(IntentClassifier): + def process(self, message: Message, **kwargs: Any) -> None: + self.predict(message) + ``` + + </TabItem> + <TabItem value="new"> + + ```python + from typing import List + + from rasa.engine.graph import GraphComponent + from rasa.nlu.classifiers.classifier import IntentClassifier + from rasa.shared.nlu.training_data.message import Message + + class MyNLUComponent(GraphComponent, IntentClassifier): + def process(self, messages: List[Message]) -> List[Message]: + for message in messages: + self.predict(message) + + return messages + ``` + </TabItem> +</Tabs> + + +**Registering your NLU Component** + +Before you can use your custom NLU component you have to register your NLU component using the +`DefaultV1Recipe.register` decorator. The NLU component types correspond to the existing +parent classes: + +* `Tokenizer`: `ComponentType.MESSAGE_TOKENIZER` +* `SparseFeaturizer` / `DenseFeaturizer`: `ComponentType.MESSAGE_FEATURIZER` +* `IntentClassifier`: `ComponentType.INTENT_CLASSIFIER` +* `EntityExtractor`: `ComponentType.ENTITY_EXTRACTOR` +* If your NLU component provides a pretrained model which should be used by other + NLU components during training and inference use `ComponentType.MODEL_LOADER` + +Specify `is_trainable=True` if the `train` method of your component should be called +during training. + + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-nlu-register" defaultValue="new"> + <TabItem value="old"> + + ```python + from rasa.nlu.classifiers.classifier import IntentClassifier + + class MyNLUComponent(IntentClassifier): + ... + ``` + + </TabItem> + <TabItem value="new"> + + ```python {5-7} + from rasa.engine.recipes.default_recipe import DefaultV1Recipe + from rasa.nlu.classifiers.classifier import IntentClassifier + from rasa.engine.graph import GraphComponent + + @DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER, is_trainable=True + ) + class MyNLUComponent(GraphComponent, IntentClassifier): + ... + ``` + + </TabItem> +</Tabs> + +**Using a Model Provider with your NLU Component** + +If your NLU component requires a pretrained model such as a [Spacy](components.mdx#spacynlp) or +[Mitie](components.mdx#mitienlp) language model you have to specify the NLU component which +provides this model in your model's pipeline before the NLU component which requires +the model. In addition to this you now also need to specify the model loading component in the `model_from` +parameter in the `register` decorator. The model will then be passed to your model's +`train`, `process_training_data` and `process` methods: + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-nlu-model-provider" defaultValue="new"> + <TabItem value="old"> + + ```python + from typing import Optional, Any + + from rasa.nlu.config import RasaNLUModelConfig + from rasa.nlu.classifiers.classifier import IntentClassifier + from rasa.shared.nlu.training_data.message import Message + from rasa.shared.nlu.training_data.training_data import TrainingData + + class MyNLUComponent(IntentClassifier): + def train( + self, + training_data: TrainingData, + cfg: Optional[RasaNLUModelConfig] = None, + **kwargs: Any, + ) -> None: + """Train the featurizer.""" + spacy_nlp = kwargs.get("spacy_nlp") + ... + + def process(self, message: Message, **kwargs: Any) -> None: + spacy_nlp = kwargs.get("spacy_nlp", None) + ... + ``` + + </TabItem> + <TabItem value="new"> + + ```python + from typing import List + + from rasa.engine.graph import GraphComponent + from rasa.engine.recipes.default_recipe import DefaultV1Recipe + from rasa.engine.storage.resource import Resource + from rasa.nlu.classifiers.classifier import IntentClassifier + from rasa.nlu.utils.spacy_utils import SpacyModel + from rasa.shared.nlu.training_data.message import Message + from rasa.shared.nlu.training_data.training_data import TrainingData + + @DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER, is_trainable=True, model_from="SpacyNLP" + ) + class MyNLUComponent(GraphComponent, IntentClassifier): + def train( + self, training_data: TrainingData, model: SpacyModel) -> Resource: + spacy_nlp = model.model + ... + + def process(self, messages: List[Message], model: SpacyModel) -> List[Message]: + spacy_nlp = model.model + ... + ``` + + </TabItem> +</Tabs> + +#### Changes to Custom Policies +This guide leads you through the migration of a custom policy step by step. + +**Instantiating a Policy for Training** + +Policies are no longer instantiated via their constructor. Instead, all policies have +to implement a `create` method. During the policy instantiation the configuration from +the [model configuration](model-configuration.mdx) is passed in as a dictionary instead +of as separate parameters. Similarly, the`featurizers` are no longer instantiated +outside of policies. +Instead, the super class `rasa.core.policies.policy.Policy` instantiates the +featurizers itself. + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-policy-init" defaultValue="new"> + <TabItem value="old"> + + ```python + from typing import Optional, Any + + from rasa.core.constants import DEFAULT_POLICY_PRIORITY + from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer + from rasa.core.policies.policy import Policy + + class MyPolicy(Policy): + def __init__( + self, + featurizer: Optional[TrackerFeaturizer] = None, + priority: int = DEFAULT_POLICY_PRIORITY, + max_history: Optional[int] = None, + **kwargs: Any + ) -> None: + super().__init__(featurizer, priority, **kwargs) + ... + ``` + + </TabItem> + <TabItem value="new"> + + ```python + from typing import Optional, Dict, Text, Any + + from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer + from rasa.core.policies.policy import Policy + from rasa.engine.graph import ExecutionContext + from rasa.engine.storage.resource import Resource + from rasa.engine.storage.storage import ModelStorage + + class MyPolicy(Policy): + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + featurizer: Optional[TrackerFeaturizer] = None, + ) -> None: + super().__init__( + config, model_storage, resource, execution_context, featurizer + ) + ... + ... + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> MyPolicy: + return cls(config, model_storage, resource, execution_context) + + ``` + + </TabItem> +</Tabs> + +**Persisting a Trained Policy** + +Policies used to be persisted by a call to the policy's `persist` method from outside the policy itself. +With Rasa 3.0 policies are responsible for persisting themselves. +Use the provided `model_storage` and `resource` parameters +to persist your graph component at the end of the training and then return the `resource` +as result of your policy's `train` method. See [graph component persistence](custom-graph-components.mdx#model-persistence) for more details. + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-policy-persistence" defaultValue="new"> + <TabItem value="old"> + + ```python + from pathlib import Path + from typing import List, Any, Union, Text + + from rasa.core.policies.policy import Policy + from rasa.shared.core.domain import Domain + from rasa.shared.core.generator import TrackerWithCachedStates + from rasa.shared.nlu.interpreter import NaturalLanguageInterpreter + + class MyPolicy(Policy): + def train( + self, + training_trackers: List[TrackerWithCachedStates], + domain: Domain, + interpreter: NaturalLanguageInterpreter, + **kwargs: Any, + ) -> None: + ... + + def persist(self, path: Union[Text, Path]) -> None: + if self.featurizer is not None: + self.featurizer.persist(path) + + file_path = Path(path) / "model_data.json" + rasa.shared.utils.io.create_directory_for_file(file_path) + rasa.shared.utils.io.dump_obj_as_json_to_file(file_path, + self.get_model_data()) + ... + ``` + + </TabItem> + <TabItem value="new"> + + ```python + from typing import List, Any + + from rasa.core.policies.policy import Policy + from rasa.engine.storage.resource import Resource + from rasa.shared.core.domain import Domain + from rasa.shared.core.generator import TrackerWithCachedStates + + class MyPolicy(Policy): + def train( + self, + training_trackers: List[TrackerWithCachedStates], + domain: Domain, + **kwargs: Any, + ) -> Resource: + ... + self.persist() + return self._resource + + def persist(self) -> None: + with self._model_storage.write_to(self._resource) as directory: + if self.featurizer is not None: + self.featurizer.persist(directory) + + file_path = directory / "model_data.json" + rasa.shared.utils.io.dump_obj_as_json_to_file(file_path, + self.get_model_data()) + ... + ``` + + </TabItem> +</Tabs> + +**Instantiating a Trained Policy** + +Previously policies had to persist their own configuration. Now the config passed +into `load` will automatically contain the configuration which your model was trained with. + +To instantiate a persisted policy, you need to use `model_storage` and `resource` in your policy's +`load` method. + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-policy-loading" defaultValue="new"> + <TabItem value="old"> + + ```python + import json + from pathlib import Path + from types import Union + from typing import Text, Any + + from rasa.core.policies.policy import Policy + + class MyPolicy(Policy): + @classmethod + def load(cls, path: Union[Text, Path], **kwargs: Any) -> "Policy": + featurizer = None + if (Path(path) / FEATURIZER_FILE).is_file(): + featurizer = TrackerFeaturizer.load(path) + + model_data = {} + model_data_file = Path(path) / "model_data.json" + if metadata_file.is_file(): + model_data = json.loads(rasa.shared.utils.io.read_file(model_data_file)) + + return cls(model_data, featurizer) + ... + ``` + + </TabItem> + <TabItem value="new"> + + ```python + import json + from typing import Dict, Text, Any + + from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer + from rasa.core.policies.policy import Policy + from rasa.engine.graph import ExecutionContext + from rasa.engine.storage.resource import Resource + from rasa.engine.storage.storage import ModelStorage + from rasa.shared.exceptions import FileIOException + + class MyPolicy(Policy): + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> MyPolicy: + featurizer = None + model_data = {} + + try: + with model_storage.read_from(resource) as path: + if (Path(path) / FEATURIZER_FILE).is_file(): + featurizer = TrackerFeaturizer.load(path) + + model_data_file = path / "model_data.json" + model_data = json.loads(rasa.shared.utils.io.read_file(model_data_file)) + + except (ValueError, FileNotFoundError, FileIOException): + logger.debug( + f"Couldn't load metadata for policy '{cls.__name__}' as the persisted " + f"metadata couldn't be loaded." + ) + + return cls( + config, model_storage, resource, execution_context, + featurizer=featurizer, model_data=model_data + ) + ``` + + </TabItem> +</Tabs> + +**Providing a Default Configuration for a Policy** + +The default configuration is no longer provided via default values in your policy's +constructor but instead returned by the static method `get_default_config`: + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-policy-default-config" defaultValue="new"> + <TabItem value="old"> + + ```python + from typing import Text + from rasa.core.policies.policy import Policy + + class MyPolicy(Policy): + + def __init__(key1: Text = "value1") -> None: + ... + ``` + + </TabItem> + <TabItem value="new"> + + ```python + from typing import Dict, Text, Any + from rasa.core.policies.policy import Policy + + class MyPolicy(Policy): + + def __init__(self, config: Dict[Text, Any]) -> None: + ... + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + return {"key1": "value1"} + ``` + + </TabItem> +</Tabs> + +**Using End-To-End Features in a Policy** + +To use a custom [end-to-end policy](stories.mdx#end-to-end-training) in Rasa +Open Source 2, you had to use the `interpreter` parameter to featurize the tracker +events manually. In Rasa 3.0, +you need to [register](custom-graph-components.mdx#registering-graph-components-with-the-model-configuration) a policy that requires end-to-end features with type `ComponentType.POLICY_WITH_END_TO_END_SUPPORT`. The features +will be precomputed and passed into your policy during training and inference. + +:::caution +End-To-End features will only be computed and provided to your policy if your training +data actually contains [end-to-end training data](stories.mdx#end-to-end-training). +::: + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-policy-end-to-end-features" defaultValue="new"> + <TabItem value="old"> + + ```python + from typing import List, Any + + from rasa.core.policies.policy import Policy, PolicyPrediction + from rasa.shared.core.domain import Domain + from rasa.shared.core.generator import TrackerWithCachedStates + from rasa.shared.core.trackers import DialogueStateTracker + from rasa.shared.nlu.interpreter import NaturalLanguageInterpreter + + class MyPolicy(Policy): + def train( + self, + training_trackers: List[TrackerWithCachedStates], + domain: Domain, + interpreter: NaturalLanguageInterpreter, + **kwargs: Any, + ) -> None: + ... + model_data, label_ids = self._prepare_for_training( + training_trackers, domain, interpreter, **kwargs + ) + ... + + def predict_action_probabilities( + self, + tracker: DialogueStateTracker, + domain: Domain, + interpreter: NaturalLanguageInterpreter, + **kwargs: Any, + ) -> PolicyPrediction: + ... + tracker_state_features = self._featurize_tracker_for_e2e( + tracker, domain, interpreter + ) + ... + ``` + + </TabItem> + <TabItem value="new"> + + ```python {12,19,23,31,37} + from typing import List, Optional, Dict, Text, Any + + from rasa.core.featurizers.precomputation import MessageContainerForCoreFeaturization + from rasa.core.policies.policy import PolicyPrediction, Policy + from rasa.engine.recipes.default_recipe import DefaultV1Recipe + from rasa.engine.storage.resource import Resource + from rasa.shared.core.domain import Domain + from rasa.shared.core.generator import TrackerWithCachedStates + from rasa.shared.core.trackers import DialogueStateTracker + + @DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.POLICY_WITH_END_TO_END_SUPPORT, is_trainable=True + ) + class MyPolicy(Policy): + def train( + self, + training_trackers: List[TrackerWithCachedStates], + domain: Domain, + precomputations: Optional[MessageContainerForCoreFeaturization] = None, + ) -> Resource: + ... + model_data, label_ids = self._prepare_for_training( + training_trackers, domain, precomputations, + ) + ... + + def predict_action_probabilities( + self, + tracker: DialogueStateTracker, + domain: Domain, + precomputations: Optional[MessageContainerForCoreFeaturization] = None, + rule_only_data: Optional[Dict[Text, Any]] = None, + **kwargs: Any, + ) -> PolicyPrediction: + ... + tracker_state_features = self._featurize_tracker( + tracker, domain, precomputations, rule_only_data=rule_only_data + ) + ... + ``` + + </TabItem> +</Tabs> + +**Registering a Policy** + +Before you can use your custom policy you have to register your policy using the +`DefaultV1Recipe.register` decorator. If your policy requires end-to-end features +specify the graph component type `POLICY_WITH_END_TO_END_SUPPORT`. Otherwise, use +`POLICY_WITHOUT_END_TO_END_SUPPORT`. Specify `is_trainable=True` if the `train` +method of your policy should be called during the training. If your policy is only +used during inference use `is_trainable=False`. + + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-policy-register" defaultValue="new"> + <TabItem value="old"> + + ```python + from rasa.core.policies.policy import Policy + + class MyPolicy(Policy): + ... + ``` + + </TabItem> + <TabItem value="new"> + + ```python {4-6} + from rasa.core.policies.policy import Policy + from rasa.engine.recipes.default_recipe import DefaultV1Recipe + + @DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.POLICY_WITH_END_TO_END_SUPPORT, + is_trainable=True + ) + class MyPolicy(Policy): + ... + ``` + + </TabItem> +</Tabs> + +**Providing Rule-only Data to a Policy** + +Rasa allows excluding [forms](forms.mdx) or [slots](domain.mdx#slots) which +are completely handled by +[rules](rules.mdx) from becoming features in other policies. +In Rasa 2 this information was passed onto the +policies using the `set_shared_policy_states` method which set the policy attribute +`_rule_only_data`. Rasa passes the names of rule-only slots and forms via the +`predict_action_probabilities` method. The passed `rule_only_data` can be `None` +in case the [`RulePolicy`](policies.mdx#rule-policy) is not part of your model +configuration. + +<Tabs values={[{"label": "Rasa 2.0 (old)", "value": "old"}, {"label": "Rasa 3.0 (new)", "value": "new"}]} groupId="3-0-policy-rule-only-data" defaultValue="new"> + <TabItem value="old"> + + ```python + from rasa.core.policies.policy import Policy + from typing import Any + + class MyPolicy(Policy): + + def set_shared_policy_states(self, **kwargs: Any) -> None: + """Sets policy's shared states for correct featurization.""" + self._rule_only_data = kwargs.get("rule_only_data", {}) + ``` + + </TabItem> + <TabItem value="new"> + + ```python {12} + from typing import Optional, Dict, Text, Any + + from rasa.core.policies.policy import Policy, PolicyPrediction + from rasa.shared.core.domain import Domain + from rasa.shared.core.trackers import DialogueStateTracker + + class MyPolicy(Policy): + def predict_action_probabilities( + self, + tracker: DialogueStateTracker, + domain: Domain, + rule_only_data: Optional[Dict[Text, Any]] = None, + ) -> PolicyPrediction: + ... + ``` + + </TabItem> +</Tabs> + +### Training data + +#### Upgrading `version` from `2.0` to `3.0` + +At the top of your training data files, you need to change `version: "2.0"` to `version: "3.1"`. + +We follow semantic versioning for training data versions. This means breaking changes result in a new +major version, while new features result in a new minor version. The latest training data version is 3.1. + +The improvements to `slot mappings` in Rasa 3.0 were breaking changes, so we needed to upgrade +from major version `2.0` to major version `3.0`. + +#### `TrainingDataImporter` + +`TrainingDataImporter` and all its implementations are updated to contain only synchronous methods. +If you have a custom data importer or rely on some functions provided by `TrainingDataImporter`, you need +to update your implementation and function calls. + +For example, this is how data loading should look like in Rasa 3.0: + +```python +from typing import Text +from rasa.shared.importers.importer import TrainingDataImporter + +def load_data(domain_path: Text, config_path: Text): + file_importer = TrainingDataImporter.load_from_config( + config_path, domain_path + ) + # note that all the functions below were async before: + config = file_importer.get_config() + domain = file_importer.get_domain() + stories = file_importer.get_stories() + nlu_data = file_importer.get_nlu_data() +``` + +Since any custom importer implements `TrainingDataImporter`, you should update your custom +importer to contain only sync methods as well: + +```python +from typing import Dict + +from rasa.shared.core.domain import Domain +from rasa.shared.importers.importer import TrainingDataImporter + + +class MyImporter(TrainingDataImporter): + """Example partial implementation of a custom importer component.""" + + # this function was async before + def get_domain(self) -> Domain: + pass + + # this function was also async before + def get_config(self) -> Dict: + pass + + # ... +``` + +`template_variables` and `e2e` arguments also got removed from `get_stories` method of `TrainingDataImporter`. +Its new signature looks this way: + +```python +from typing import Optional + +from rasa.shared.nlu.interpreter import RegexInterpreter +from rasa.shared.core.training_data.structures import StoryGraph + +class TrainingDataImporter: + # ... + + def get_stories( + self, + interpreter: "NaturalLanguageInterpreter" = RegexInterpreter(), + exclusion_percentage: Optional[int] = None, + ) -> StoryGraph: + pass + + # ... +``` + +### Training + +#### `rasa train --dry-run` +Due to changes in the model architecture the behavior of `rasa train --dry-run` changed. +The exit codes now have the following meaning: + +* `0` means that the model does not require an expensive retraining. However, the + responses might still require updating by running `rasa train` +* `1` means that one or multiple components require to be retrained. +* `8` means that the `--force` flag was used and hence any cached results are ignored + and the entire model is retrained. + +### Machine Learning Components + +#### Normalization of Confidences in `DIETClassifier` and `ResponseSelector` + +`DIETClassifier` and `ResponseSelector` will no longer automatically report +re-normalized confidences when `ranking_length` is set to a value greater than `0`. +This change affects the reported confidences but does not influence the final +predicted intent, which might be used by policies. +However, since the reported confidences are affected you might have to tune the +thresholds for fallback mechanisms again. +The previous behavior can still be enforced by setting `renormalize_confidences=True` +when using `model_confidence=softmax`. + +#### Normalization of confidences in `TEDPolicy` + +Predictions of `TEDPolicy` will no longer be modified by masking and renormalizing +confidences. This change can affect the maximum confidence predicted by the +`TEDPolicy` and thereby affect the final result of the policy ensemble. +However, the previous behavior can still be enforced by setting +`ranking_length=10` and `renormalize_confidences=True`. + +#### Removed Policies + +Several dialogue policies that were deprecated in Rasa 2.x have been removed in Rasa 3.0. +If you are migrating a config file with a removed policy, +consult the following migration guides for the individual policies: + +- `FallbackPolicy` [migration guide](#manually-migrating-from-the-fallback-policy) +- `TwoStageFallbackPolicy` [migration guide](#manually-migrating-from-the-two-stage-fallback-policy) +- `MappingPolicy` [migration guide](#manually-migrating-from-the-mapping-policy) +- `FormPolicy` [migration guide](#forms) +- `SklearnPolicy` should be replaced with [TEDPolicy](/policies#ted-policy). + It is recommended to use the [default TEDPolicy config](/model-configuration#suggested-config) as a starting point. + +#### Removed Tokenizers and Featurizers + +The `ConveRTTokenizer`, `LanguageModelTokenizer`, and `HFTransformersNLP` featurizer +components were deprecated in Rasa 2.x and have been removed in Rasa 3.0. See the +[migration guide for Rasa 2.x](#deprecations-2) for replacing these components in your pipeline. + +### Slot Mappings + +As of version 3.0, there is a single explicit mechanism for slot filling enabled by defining slot mappings for each slot +in the `slots` section of the domain file. This approach keeps slots up to date over the course of a conversation, and +removes duplicated effort in mapping the same slots in multiple forms. It is still possible to fill slots from arbitrary +custom actions and not update them on every turn of the conversation if that behavior is desired. + +This new mechanism replaces the implicit slot setting via auto-fill of slots with entities of the same name. +The `auto_fill` key in the domain is no longer available, as well as the `auto_fill` parameter in the constructor of +the `Slot` class. + +While forms continue to request the next slot, slot extraction is now delegated to the default +action [`action_extract_slots`](./default-actions.mdx#action_extract_slots). This action runs in the background +automatically after each user turn. Like `action_listen`, it should not be included in stories. + +Each slot in the `slots` section must include the `mappings` key. The same keys used for predefined mappings in 2.0 are +available in 3.0. Additionally, you can define slots with custom mappings implemented in a custom action which will be +run on every user turn, for example: + +```yaml +slots: + is_existing_customer: + type: bool + mappings: + - type: custom + action: action_verify_customer_status +``` + +You can use [slot validation actions](./slot-validation-actions.mdx) to either validate slots with predefined +mappings, or to both extract and validate slots with custom mappings. + +Slots which will be filled by arbitrary custom actions in the course of the conversation, and which should not be updated +on every user turn, should be listed with mappings of type `custom` and no action. For example: + +```yaml +slots: + handoff_completed: + type: bool + initial_value: false + mappings: + - type: custom +``` + +This slot's value will only change when a custom action is predicted that sets it. This mapping maintains the behavior +from 2.x for a slot which was not filled by an entity or by slot mappings in a form. + +:::note +The `required_slots` of a form used to be a list of slot mappings. +Since slot mappings are relocated to the `slots` section of the domain, `required_slots` has been converted to a list of +slot names only. + +::: + +#### Automatic migration from 2.0 domain format to the 3.0 format + +The only data file that has changed in format is the domain file. +To migrate automatically to the 3.0 domain format, you can run the following command: + +```bash +rasa data migrate -d DOMAIN --out OUT_PATH +``` + +In addition to creating a valid 3.0 domain in the indicated out path, this command will automatically backup your +original domain file(s) in a file labeled `original_domain.yml` or `original_domain` directory if a directory was +provided instead. + +To maintain the behavior of forms in the 2.0 format, all migrated slot mappings will include mapping conditions for +each form. This can be changed manually according to your use case. +See the docs on [mapping conditions](./domain.mdx#mapping-conditions) for more information. + +#### Manually migrating from 2.0 domain format to the 3.0 format + +Each slot in the `slots` section of the domain will need a new key `mappings`. +This key is a list of mappings moved from forms, while the `required_slots` field collapses to a list of slot names. + +Let's consider the following 2.0 domain file: + +```yaml +entities: + - cuisine + - number +slots: + cuisine: + type: text + num_people: + type: float + outdoor_seating: + type: bool +forms: + restaurant_form: + required_slots: + cuisine: + - type: from_entity + entity: cuisine + num_people: + - type: from_entity + entity: number + outdoor_seating: + - type: from_intent + intent: affirm + value: true + - type: from_intent + intent: deny + value: false +``` + +The initial result of migrating this domain to 3.0 format would look like this: + +```yaml +entities: + - cuisine + - number +slots: + cuisine: + type: text + mappings: + - type: from_entity + entity: cuisine + num_people: + type: float + mappings: + - type: from_entity + entity: number + outdoor_seating: + type: bool + mappings: + - type: from_intent + intent: affirm + value: true + - type: from_intent + intent: deny + value: false +forms: + restaurant_form: + required_slots: + - cuisine + - num_people + - outdoor_seating +``` + +For slots that should be filled only in the context of a form, add [mapping conditions](./domain.mdx#mapping-conditions) +to specify which form(s) should be active, as well as indicate if the `requested_slot` should be the same slot. +Adding `conditions` is required to preserve the behavior of slot mappings from 2.0, since without them +the mappings will be applied on each user turn regardless of whether a form is active or not. + +```yaml + slots: + outdoor_seating: + type: bool + mappings: + - type: from_intent + intent: affirm + value: true + conditions: + - active_loop: restaurant_form + requested_slot: outdoor_seating + - type: from_intent + intent: deny + value: false + conditions: + - active_loop: restaurant_form + requested_slot: outdoor_seating +``` + +#### Rasa-SDK Modifications + +If you have used `FormValidationAction` to define custom extraction and validation code in which you override the +`required_slots` method, note that `slots_mapped_in_domain` argument has been replaced by the `domain_slots` argument. +You must make this replacement to continue using your custom code. + +If you have been dynamically filling slots not present in the form's `required_slots` defined in the `domain.yml` +file, note that this behaviour is no longer supported in 3.x. Any dynamic slots with custom mappings, which are set in +the last user turn, will be filled **only if** they are returned by the `required_slots` method of the custom action +inheriting from `FormValidationAction`. To maintain the 2.x behaviour, you must now override the `required_slots` method +of this custom action as per the strong recommendation listed in the [dynamic form documentation](./forms.mdx#dynamic-form-behavior). + +To extract custom slots that are not defined in any form's `required_slots`, you should now use a global [custom slot mapping](./domain.mdx#custom-slot-mappings) +and extend the [ValidationAction class](./action-server/validation-action.mdx#validationaction-class). + +:::note +If you have custom validation actions extending `FormValidationAction` which override `required_slots` method, you should +double-check the dynamic form behavior of your migrated assistant. Slots set by the default action +[`action_extract_slots`](./default-actions.mdx#action_extract_slots) may need to be reset within the context of your +form by the custom validation actions for the form's required slots. For example, if your form dynamically adds a required +slot after the first slot is filled, you may want to reset the potential required slot as part of the first required slot's +validation method to ensure it will be empty when added. + +::: + +## Rasa 2.7 to 2.8 + +:::caution +This release **breaks backward compatibility of machine learning models**. +It is not possible to load models trained with previous versions of Rasa. Please re-train +your assistant before using this version. + +::: + +### Deprecations + +#### Tracker Featurizers + +`training_states_actions_and_entities` method of `TrackerFeaturizer`, `FullDialogueTrackerFeaturizer` and +`MaxHistoryTrackerFeaturizer` classes is deprecated and will be removed in Rasa 3.0 . +If you had a custom tracker featurizer which relied on this method from any of the above classes, please use +`training_states_labels_and_entities` instead. + +`training_states_and_actions` method of `TrackerFeaturizer`, `FullDialogueTrackerFeaturizer` and +`MaxHistoryTrackerFeaturizer` classes is deprecated and will be removed in Rasa 3.0 . +If you had a custom tracker featurizer which relied on this method from any of the above classes, please use +`training_states_and_labels` instead. + +#### State Featurizer + +`encode_all_actions` method of `SingleStateFeaturizer` class is deprecated and will be removed in Rasa 3.0 . +It is recommended to use the method `encode_all_labels` instead. + + +### Incremental Training + +Users don't need to specify an additional buffer size for sparse featurizers anymore during incremental training. + +Space for new sparse features are created dynamically inside the downstream machine learning +models - `DIETClassifier`, `ResponseSelector`. In other words, no extra buffer is created in +advance for additional vocabulary items and space will be dynamically allocated for them inside the model. + +This means there's no need to specify `additional_vocabulary_size` for +[`CountVectorsFeaturizer`](./components.mdx#countvectorsfeaturizer) or +`number_additional_patterns` for [`RegexFeaturizer`](./components.mdx#regexfeaturizer). +These parameters are now deprecated. + +**Before** +```yaml +pipeline: + - name: "WhitespaceTokenizer" + - name: "RegexFeaturizer" + number_additional_patterns: 100 + - name: "CountVectorsFeaturizer" + additional_vocabulary_size: {text: 100, response: 20} +``` + +**Now** +```yaml +pipeline: + - name: "WhitespaceTokenizer" + - name: "RegexFeaturizer" + - name: "CountVectorsFeaturizer" +``` + +### Machine Learning Components + +The option `model_confidence=linear_norm` is deprecated and will be removed in Rasa `3.0.0`. + +Rasa `2.3.0` introduced `linear_norm` as a possible value for `model_confidence` +parameter in machine learning components such as `DIETClassifier`, `ResponseSelector` and `TEDPolicy`. +Based on user feedback, we have identified multiple problems with this option. +Therefore, `model_confidence=linear_norm` is now deprecated and +will be removed in Rasa `3.0.0`. If you were using `model_confidence=linear_norm` for any of the mentioned components, +we recommend to revert it back to `model_confidence=softmax` and re-train the assistant. After re-training, +we also recommend to [re-tune the thresholds for fallback components](./fallback-handoff.mdx#fallbacks). + +## Rasa 2.5 to 2.6 + +### Forms + +#### New `ignored_intents` parameter in Forms + +There is a new parameter under Forms called `ignored_intents`. This parameter +can be used to prevent any required slots in a form from being filled with the specified +intent or intents. Please see the [Forms documentation](forms.mdx) for examples and more +information on how to use it in your `domain.yml` file. + +Before, if a user did not want to fill any slots of a form with a specified intent +they would have to define it under the `not_intent` parameter for every slot mapping +as shown in the following example : + +```yaml-rasa title="domain.yml" +forms: + restaurant_form: + cuisine: + - type: from_entity + entity: cuisine + not_intent: chitchat + num_people: + - type: from_entity + entity: number + intent: [inform, request_restaurant] + not_intent: chitchat + feedback: + - type: from_entity + entity: feedback + not_intent: chitchat +``` + +By introducing the `ignored_intents` parameter, we now only need to define it +in one place and it will affect all the slots of the form : + +```yaml-rasa title="domain.yml" +forms: + restaurant_form: + ignored_intents: chitchat + required_slots: + cuisine: + - type: from_entity + entity: cuisine + num_people: + - type: from_entity + entity: number + intent: [inform, request_restaurant] + feedback: + - type: from_entity + entity: feedback + - type: from_text +``` + + +## Rasa 2.4 to 2.5 + +### Machine Learning Components + +#### `DIET`, `TED`, and `ResponseSelector` + +The former `weight_sparsity` parameter of the `DIETClassifier`, `TEDPolicy`, and the `ResponseSelector`, +is now deprecated and superseded by the new `connection_density` parameter. +The old `weight_sparsity` is roughly equivalent to `1 - connection_density`, except at very low densities +(high sparsities). + +To avoid deprecation issues, you should set `connection_density` to +`1 - your former weight_sparsity setting` throughout the config file. (If you left +`weight_sparsity` at its default setting, you don't need to do anything.) + +#### SpaCy 3.0 + +Rasa now supports spaCy 3.0. This means that we can support more features for more +languages but this also introduced a breaking change. SpaCy 3.0 deprecated the +`spacy link <language model>` command. So from now on you need to use the +[the full model name](https://spacy.io/models) in the `config.yml` file. + +**Before** + +Before you could run `spacy link en en_core_web_md` and then we would be able +to pick up the correct model from the `language` parameter. + +```yaml +language: en + +pipeline: + - name: SpacyNLP +``` + +**Now** + +This behavior will be deprecated and instead you'll want to be explicit in `config.yml`. + +```yaml +language: en + +pipeline: + - name: SpacyNLP + model: en_core_web_md +``` + +**Fallback** + +To make the transition easier, Rasa will try to fall back to a medium spaCy model whenever +a compatible language is configured for the entire pipeline in `config.yml`, even if you don't +specify a `model`. This fallback behavior is temporary and will be deprecated in Rasa 3.0.0. + +We've updated our docs to reflect these changes. All examples now show a direct link to the +correct spaCy model. We've also added a warning to the [SpaCyNLP](components.mdx#spacynlp) +docs that explains the fallback behavior. + +## Rasa 2.3 to Rasa 2.4 + +### Deprecating `template` for `response` +NLG Server +- Changed request format to send `response` as well as `template` as a field. The `template` field will be removed in Rasa 3.0.0. + +`rasa.core.agent` +- The terminology `template` is deprecated and replaced by `response`. Support for `template` from the NLG response will be removed in Rasa 3.0.0. Please see [here](./nlg.mdx) for more details. + +`rasa.core.nlg.generator` +- `generate()` now takes in `utter_action` as a parameter. +- The terminology `template` is deprecated and replaced by `response`. Support for `template` in the `NaturalLanguageGenerator` will be removed in Rasa 3.0.0. + +`rasa.shared.core.domain` +- The property `templates` is deprecated. Use `responses` instead. It will be removed in Rasa 3.0.0. +- `retrieval_intent_templates` will be removed in Rasa 3.0.0. Please use `retrieval_intent_responses` instead. +- `is_retrieval_intent_template` will be removed in Rasa 3.0.0. Please use `is_retrieval_intent_response` instead. +- `check_missing_templates` will be removed in Rasa 3.0.0. Please use `check_missing_responses` instead. + +Response Selector +- The field `template_name` will be deprecated in Rasa 3.0.0. Please use `utter_action` instead. Please see [here](./components.mdx#selectors) for more details. +- The field `response_templates` will be deprecated in Rasa 3.0.0. Please use `responses` instead. Please see [here](./components.mdx#selectors) for more details. + +## Rasa 2.3.3 to Rasa 2.3.4 + +:::caution +This is a release **breaking backwards compatibility of machine learning models**. +It is not possible to load previously trained models if they were trained with `model_confidence=cosine` or +`model_confidence=inner` setting. Please make sure to re-train the assistant before trying to use it with this improved version. + +::: + +### Machine Learning Components + +Rasa `2.3.0` introduced the option of using cosine similarities for model confidences by setting `model_confidence=cosine`. Some post-release experiments revealed that using `model_confidence=cosine` is wrong as it can change the order of predicted labels. That's why this option was removed in Rasa version `2.3.4`. + +`model_confidence=inner` is deprecated as it produces an unbounded range of confidences which can break +the logic of assistants in various other places. + +We encourage you to try `model_confidence=linear_norm` which will produce a linearly normalized version of dot product similarities with each value in the range `[0,1]`. This can be done with the following config: +``` +- name: DIETClassifier + model_confidence: linear_norm + constrain_similarities: True +``` + +If you trained a model with `model_confidence=cosine` or `model_confidence=inner` setting using previous versions of Rasa, please re-train by either removing the `model_confidence` option from the configuration or setting it to `linear_norm`. + + +## Rasa 2.2 to Rasa 2.3 + +### General + +If you want to use Tensorboard for `DIETClassifier`, `ResponseSelector`, or `TEDPolicy` and log metrics after +every (mini)batch, please use 'batch' instead of 'minibatch' as 'tensorboard_log_level'. + + +### Machine Learning Components + +A few changes have been made to the loss function inside machine learning (ML) +components `DIETClassifier`, `ResponseSelector` and `TEDPolicy`. These include: +1. Configuration option `loss_type=softmax` is now deprecated and will be removed in Rasa 3.0.0. Use `loss_type=cross_entropy` instead. +2. The default loss function (`loss_type=cross_entropy`) can add an optional sigmoid cross-entropy loss of all similarity values to constrain +them to an approximate range. You can turn on this option by setting `constrain_similarities=True`. This should help the models to perform better on real world test sets. + +A new option `model_confidence` has been added to each ML component. It affects how the model's confidence for each label is computed during inference. It can take one of three values: +1. `softmax` - Dot product similarities between input and label embeddings are post-processed with a softmax function, as a result of which confidence for all labels sum up to 1. +2. `cosine` - Cosine similarity between input and label embeddings. Confidence for each label will be in the range `[-1,1]`. +3. `linear_norm` - Dot product similarities between input and label embeddings are post-processed with a linear normalization function. Confidence for each label will be in the range `[0,1]`. + +The default value is `softmax`, but we recommend trying `linear_norm`. This should make it easier to [tune thresholds for triggering fallback](./fallback-handoff.mdx#fallbacks). +The value of this option does not affect how confidences are computed for entity predictions in `DIETClassifier`. + +We encourage you to try both the above recommendations. This can be done with the following config: +``` +- name: DIETClassifier + model_confidence: linear_norm + constrain_similarities: True + ... +``` +Once the assistant is re-trained with the above configuration, users should also [tune fallback confidence thresholds](./fallback-handoff.mdx#fallbacks). + +**EDIT**: Some post-release experiments revealed that using `model_confidence=cosine` is wrong as it can change the order of predicted labels. That's why this option was removed in Rasa version `2.3.4`. + +## Rasa 2.1 to Rasa 2.2 + +### General + +`TEDPolicy`'s `transformer_size`, `number_of_transformer_layers`, +and `dense_dimensions` parameters have been renamed. +Please update your configuration files using the following mapping: + +| Old Model Parameter | New Model Parameter | +|-----------------------------|--------------------------------------------------------| +|`transformer_size` |dictionary `transformer_size` with keys | +| |`text`, `action_text`, `label_action_text`, `dialogue` | +|`number_of_transformer_layers`|dictionary `number_of_transformer_layers` with keys | +| |`text`, `action_text`, `label_action_text`, `dialogue` | +|`dense_dimension` |dictionary `dense_dimension` with keys | +| |`text`, `action_text`, `label_action_text`, `intent`, | +| |`action_name`, `label_action_name`, `entities`, `slots`,| +| |`active_loop` | + +For example: + +```yaml-rasa title="config.yml" +policies: + - name: TEDPolicy + transformer_size: + text: 128 + action_text: 128 + label_action_text: 128 + dialogue: 128 + number_of_transformer_layers: + text: 1 + action_text: 1 + label_action_text: 1 + dialogue: 1 + dense_dimension: + text: 128 + action_text: 128 + label_action_text: 128 + intent: 20 + action_name: 20 + label_action_name: 20 + entities: 20 + slots: 20 + active_loop: 20 +``` + + +### Deprecations + +#### Markdown Data +Training and test data in Markdown format is now deprecated. This includes: +- reading and writing of story files in Markdown format +- reading and writing of NLU data in Markdown format +- reading and writing of retrieval intent data in Markdown format + +Support for Markdown data will be removed entirely in Rasa 3.0.0. + +Please convert your existing Markdown data by using the commands +described [here](./migration-guide.mdx#training-data-files). + + +### Policies + +[Policies](./policies.mdx) now require a `**kwargs` argument in their constructor and `load` method. +Policies without `**kwargs` will be supported until Rasa version `3.0.0`. +However when using [incremental training](./command-line-interface.mdx#incremental-training) +`**kwargs` **must** be included. + + +#### Other + +* `Domain.random_template_for` is deprecated and will be removed in Rasa + 3.0.0. You can alternatively use the `TemplatedNaturalLanguageGenerator`. +* `Domain.action_names` is deprecated and will be removed in Rasa + 3.0.0. Please use `Domain.action_names_or_texts` instead. + + +## Rasa 2.0 to Rasa 2.1 + +### Deprecations + +`ConveRTTokenizer` is now deprecated. [ConveRTFeaturizer](./components.mdx#convertfeaturizer) now implements +its behaviour. To migrate, replace `ConveRTTokenizer` with any other tokenizer, for e.g.: + +```yaml +pipeline: + - name: WhitespaceTokenizer + - name: ConveRTFeaturizer + model_url: <Remote/Local path to model files> + ... +``` + +`HFTransformersNLP` and `LanguageModelTokenizer` components are now deprecated. +[LanguageModelFeaturizer](./components.mdx#languagemodelfeaturizer) now implements their behaviour. +To migrate, replace both the above components with any tokenizer and specify the model architecture and model weights +as part of `LanguageModelFeaturizer`, for e.g.: + +```yaml +pipeline: + - name: WhitespaceTokenizer + - name: LanguageModelFeaturizer + model_name: "bert" + model_weights: "rasa/LaBSE" + ... +``` + +## Rasa 1.10 to Rasa 2.0 + +### General + +A lot has changed in version 2.0. Make sure you read +through this guide thoroughly, to make sure all parts of your bot are updated. +A lot of updates can be done automatically with inbuilt commands, others will need +some manual conversion. If you have any feedback about these updates or the migration process, please post it +in the [forum](https://forum.rasa.com/t/rasa-open-source-2-0-is-out-now-internal-draft/35577). + +### Training data files + +As of version 2.0, the new default training data format is yaml. Markdown is still supported, +but this will be deprecated in Rasa 3.0.0. + +You can convert existing NLU, Stories, and NLG (i.e. `responses.md`) training data +files in the Markdown format to the new YAML format using following commands: + +```bash +rasa data convert nlu -f yaml --data={SOURCE_DIR} --out={TARGET_DIR} +rasa data convert nlg -f yaml --data={SOURCE_DIR} --out={TARGET_DIR} +rasa data convert core -f yaml --data={SOURCE_DIR} --out={TARGET_DIR} +``` + +Converted files will have the same names as the original ones but with a +`_converted.yml` suffix. + +If you are using [forms](./migration-guide.mdx#forms) or [response selectors](./migration-guide.mdx#response-selectors), +some additional changes will need to be made as described in their respective sections. + +### Policies + +With the introduction of [rules](./rules.mdx) and the [RulePolicy](./policies.mdx#rule-policy), +the following policies are deprecated: + +- [Mapping Policy](https://rasa.com/docs/rasa/2.x/policies#mapping-policy) +- [Fallback Policy](https://rasa.com/docs/rasa/2.x/policies#fallback-policy) +- [Two-Stage Fallback Policy](https://rasa.com/docs/rasa/2.x/policies#two-stage-fallback-policy) +- [Form Policy](https://rasa.com/docs/rasa/2.x/policies#form-policy) + + +To migrate the policies automatically, you can run the following command: + +```bash +rasa data convert config +``` + +This command will take care of updating your `config.yml` and `domain.yml`, while +making backups of your existing files using the `.bak` suffix. It will also add a +`rules.yml` if necessary. + +Your forms will still function as normal in the old format after this update, but this command +does not convert them into the new format automatically. This should be done manually, as +described in the section on [forms](./migration-guide.mdx#forms). + +You can also migrate the individual policies manually, if you don't want to use the automatic conversion command. + +#### Manually migrating from the Mapping Policy + +If you previously used the [Mapping Policy](https://rasa.com/docs/rasa/2.x/policies#mapping-policy), you +can follow the documentation on [FAQs](./chitchat-faqs.mdx) to convert your mapped +intents to rules. Suppose you previously mapped an intent `ask_is_bot` as follows: + +```yaml-rasa title="domain.yml" +intents: + - ask_is_bot: + triggers: action_is_bot +``` + +This becomes the following rule: + +```yaml-rasa title="rules.yml" +rules: +- rule: Rule to map `ask_is_bot` intent + steps: + - intent: ask_is_bot + - action: action_is_bot +``` + +And you can safely remove any `triggers:` from your domain: + +```yaml-rasa title="domain.yml" +intents: + - ask_is_bot +``` + +Finally, you can replace the Mapping Policy with the +[Rule Policy](./policies.mdx#rule-policy) in your model configuration: + +```yaml-rasa title="config.yml" +policies: + # Other policies + - name: RulePolicy +``` + +#### Manually migrating from the Fallback Policy + +If you previously used the [Fallback Policy](https://rasa.com/docs/rasa/2.x/policies#fallback-policy), the following model +configuration would translate as follows given a previous configuration like this: + +```yaml-rasa title="config.yml" +policies: + - name: "FallbackPolicy" + nlu_threshold: 0.4 + core_threshold: 0.3 + fallback_action_name: "action_default_fallback" + ambiguity_threshold: 0.1 +``` + +The new configuration would then look like: + +```yaml-rasa title="config.yml" +recipe: default.v1 +pipeline: + # Other components + - name: FallbackClassifier + threshold: 0.4 + ambiguity_threshold: 0.1 + +policies: + # Other policies + - name: RulePolicy + core_fallback_threshold: 0.3 + core_fallback_action_name: "action_default_fallback" +``` + +In addition, you need to add a [rule](./rules.mdx) to specify which action to run +in case of low NLU confidence: + +```yaml-rasa title="rules.yml" +rules: + - rule: Ask the user to rephrase whenever they send a message with low NLU confidence + steps: + - intent: nlu_fallback + - action: utter_please_rephrase +``` + +See the documentation on [fallback](./fallback-handoff.mdx#fallbacks) for more +information. + +#### Manually migrating from the Two-Stage-Fallback Policy + +If you previously used the +[Two-Stage Fallback Policy](https://rasa.com/docs/rasa/2.x/policies#two-stage-fallback-policy), with a configuration +like this for example: + +```yaml-rasa title="config.yml" +policies: + - name: TwoStageFallbackPolicy + nlu_threshold: 0.4 + ambiguity_threshold: 0.1 + core_threshold: 0.3 + fallback_core_action_name: "action_default_fallback" + fallback_nlu_action_name: "action_default_fallback" + deny_suggestion_intent_name: "out_of_scope" +``` + +The new configuration would look like this: + +```yaml-rasa title="config.yml" +recipe: default.v1 +pipeline: + # Other components + - name: FallbackClassifier + threshold: 0.4 + ambiguity_threshold: 0.1 + +policies: + # Other policies + - name: RulePolicy + core_fallback_threshold: 0.3 + core_fallback_action_name: "action_default_fallback" +``` + +In addition you need to add a [rule](./rules.mdx) to activate the Two-Stage Fallback for +messages with low NLU confidence. + +```yaml-rasa title="rules.yml" +rules: + - rule: Implementation of the TwoStageFallbackPolicy + steps: + # This intent is automatically triggered by the `FallbackClassifier` in the NLU + # pipeline in case the intent confidence was below the specified threshold. + - intent: nlu_fallback + # The Fallback is now implemented as a form. + - action: action_two_stage_fallback + - active_loop: action_two_stage_fallback +``` + +Note that the previous parameters `fallback_nlu_action_name` and +`deny_suggestion_intent_name` are no longer configurable and have the fixed values +`action_default_fallback` and `out_of_scope`. + +See the [fallback](./fallback-handoff.mdx#fallbacks) documentation for more +information. + +### Forms + +As of version 2.0 the logic for [forms](./forms.mdx) has been moved from the +Rasa SDK to Rasa to simplify implementation and make it easier to write +action servers in other languages. + +This means that forms are no longer implemented using a `FormAction`, but instead +defined in the domain. Any customizations around requesting slots or +[slot validation](./forms.mdx#validating-form-input) can be handled with a `FormValidationAction`. + +Consider a custom form action from 1.x like this: + +```python +from typing import Text, List, Any, Dict, Union +from rasa_sdk import Tracker +from rasa_sdk.executor import CollectingDispatcher +from rasa_sdk.forms import FormAction + +class RestaurantForm(FormAction): + def name(self) -> Text: + return "restaurant_form" + + @staticmethod + def required_slots(tracker: Tracker) -> List[Text]: + return ["cuisine"] + + def slot_mappings(self) -> Dict[Text, Union[Dict, List[Dict]]]: + return { + "cuisine": self.from_entity(entity="cuisine", not_intent="chitchat"), + } + + @staticmethod + def cuisine_db() -> List[Text]: + """Database of supported cuisines""" + + return ["caribbean", "chinese", "french"] + + def validate_cuisine( + self, + value: Text, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> Dict[Text, Any]: + """Validate cuisine value.""" + + if value.lower() in self.cuisine_db(): + # validation succeeded, set the value of the "cuisine" slot to value + return {"cuisine": value} + else: + dispatcher.utter_message(template="utter_wrong_cuisine") + # validation failed, set this slot to None, meaning the + # user will be asked for the slot again + return {"cuisine": None} + + def submit( + self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> List[Dict]: + """Define what the form has to do + after all required slots are filled""" + + # utter submit template + dispatcher.utter_message(template="utter_submit") + return [] +``` + +Start the migration by removing the FormPolicy and adding the [RulePolicy](./policies.mdx#rule-policy) +(if not there already) to your model configuration: + +```yaml-rasa title="config.yml" +policies: + # Other policies + # ... + - name: RulePolicy +``` + +Then you need to define the form, required slots and their slot mappings +in the domain as described in the documentation on [forms](./forms.mdx#defining-a-form): + +```yaml-rasa title="domain.yml" +forms: + restaurant_form: + cuisine: + - type: from_entity + entity: cuisine + not_intent: chitchat +``` +If you ran the command to [convert your stories](./migration-guide.mdx#training-data-Files), +you will have a story that handles form activation and deactivation like this: + +```yaml-rasa title="stories.yml" +stories: + - story: cuisine form + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - active_loop: null + - action: utter_submit +``` + +This will work fine, but the best way to handle form behavior is to remove this story and instead +define two separate rules for form activation and submission: + +```yaml-rasa title="rules.yml" +rules: + - rule: Activate form + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + + - rule: Submit form + condition: + # Condition that form is active. + - active_loop: restaurant_form + steps: + - action: restaurant_form + - active_loop: null + # The action we want to run when the form is submitted. + - action: utter_submit +``` + +The last step is to implement a custom action to validate the form slots. Start by +adding the custom action to your domain: + +```yaml-rasa title="domain.yml" +actions: + # Other actions + # ... + - validate_restaurant_form +``` + +Then add a custom action which validates the `cuisine` slot: + +```python +from typing import Text, List, Any, Dict, Union +from rasa_sdk import Tracker +from rasa_sdk.executor import CollectingDispatcher +from rasa_sdk import FormValidationAction +from rasa_sdk.types import DomainDict + +class RestaurantFormValidator(FormValidationAction): + def name(self) -> Text: + return "validate_restaurant_form" + + @staticmethod + def cuisine_db() -> List[Text]: + """Database of supported cuisines""" + + return ["caribbean", "chinese", "french"] + + def validate_cuisine( + self, + slot_value: Any, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: DomainDict, + ) -> Dict[Text, Any]: + """Validate cuisine value.""" + + if slot_value.lower() in self.cuisine_db(): + # validation succeeded, set the value of the "cuisine" slot to value + return {"cuisine": slot_value} + else: + # validation failed, set this slot to None, meaning the + # user will be asked for the slot again + return {"cuisine": None} +``` + +You can also migrate forms from Rasa SDK to Rasa 2 iteratively. You can for +example migrate one form to the Rasa 2 implementation while continue using +the deprecated Rasa SDK implementation for another form. To continue to use +the deprecated Rasa SDK `FormAction`s, add a custom action with the name of your form to your domain. Note that you should complete the migration as soon as possible as the deprecated `FormAction` +will be removed from the Rasa SDK in Rasa 3. + +```yaml-rasa title="domain.yml" +actions: +# Adding a custom action for a form will +# instruct Rasa to use the +# deprecated Rasa SDK implementation of forms. +- my_form + +forms: + my_form: +``` + +See the [forms](./forms.mdx) documentation for more details. + +### Response Selectors + +Response Selectors are a stable feature as of version 2.0. + +The [conversion command](./migration-guide.mdx#training-data-files) will automatically +convert your `responses.md` file, stories and nlu training data to the new yaml format. +It will also take care of adding the `utter_` prefix to your responses. +Additionally you will need to rename the `respond_` actions in your stories files to use the +`utter_` prefix instead. Run the following command to apply these changes: + +```bash +rasa data convert responses --data {SOURCE_DIR} --out={TARGET_DIR} +``` + +You can also apply these changes manually. For example: + +```yaml-rasa +stories: + - story: chitchat + steps: + - intent: chitchat + - action: respond_chitchat +``` +becomes + +```yaml-rasa +stories: + - story: chitchat + steps: + - intent: chitchat + - action: utter_chitchat +``` + +and you will need to add the `utter_` prefix to the response names in your `responses.md` +as well. For example: + +```yaml-rasa +responses: + chitchat/ask_name: + - text: Oh yeah, I am called the retrieval bot. + + chitchat/ask_weather: + - text: Oh, it does look sunny right now in Berlin. +``` + +becomes + +```yaml-rasa +responses: + utter_chitchat/ask_name: + - text: Oh yeah, I am called the retrieval bot. + + utter_chitchat/ask_weather: + - text: Oh, it does look sunny right now in Berlin. +``` + +Finally, you should remove any actions with the `respond_` prefix from the actions +list in your domain. + +This behavior will work fine when defined as a story, but even better when defined +as a rule. You should consider transferring your retrieval stories to rules. More information +on what that looks like in the [chitchat and FAQs documentation](./chitchat-faqs.mdx). + + +Response Selectors are now trained on retrieval intent labels by default instead +of the actual response text. For most models, this should improve training time +and accuracy of the `ResponseSelector`. + +If you want to revert to the pre-2.0 default behavior, add the `use_text_as_label: true` +parameter to your `ResponseSelector` component: + +```yaml-rasa +pipeline: + # other components + - name: ResponseSelector + use_text_as_label: true +``` + +The output schema of `ResponseSelector` has changed. An example output looks like this: + +```json {3-4,10,11,20} +{ + "response_selector": { + "all_retrieval_intents": [ + "faq" + ], + "default": { + "response": { + "id": 1388783286124362000, + "confidence": 1, + "intent_response_key": "faq/is_legit", + "response_templates": [ + { + "text": "absolutely", + "image": "https://i.imgur.com/nGF1K8f.jpg" + }, + { + "text": "I think so." + } + ] + "template_name": "utter_faq/is_legit" + }, + "ranking": [ + { + "id": 1388783286124362000, + "confidence": 1, + "intent_response_key": "faq/is_legit" + } + ] + } + } +} +``` +As a result of this, if you were previously querying for the key `full_retrieval_intent` as: +```python {2} +response_selector_output.get("default") + .get("full_retrieval_intent") +``` +you should instead now do this: +```python {2-3} +response_selector_output.get("default") + .get("response") + .get("intent_response_key") +``` + + +### Unfeaturized Slots + +[Slots](domain.mdx#slots) of type `unfeaturized` are +deprecated and will be removed in version 3.0. To ignore slot values during +a conversation, set the `influence_conversation` property of the slot to `false`. + +The following snippet is an example of the deprecated unfeaturized slot usage: + +```yaml-rasa +slots: + username: + type: unfeaturized +``` + +To update this to the new format, you can specify the expected data type `text` and +define that the slot should be ignored during the conversation. + +```yaml-rasa +slots: + username: + type: text + # Set `influence_conversation` to `false` + # to ignore the slot value during the conversation. + influence_conversation: false +``` + +If you don't require the slot to have a specific data type, you can use the new slot +type [any](domain.mdx#any-slot). This slot type is always ignored during a conversation +and does not make any assumptions regarding the data type of the slot value. + +```yaml-rasa +slots: + username: + type: any +``` + +Please see the updated [slots documentation](domain.mdx#slots) for more information. + +### Conversation sessions + +[Conversation sessions](domain.mdx#session-configuration) are now enabled by default +if your [Domain](domain.mdx) does not contain a session configuration. Previously a +missing session configuration was treated as if conversation sessions were disabled. +You can explicitly disable conversation sessions using the following snippet: + +```yaml-rasa title="domain.yml" +session_config: + # A session expiration time of `0` + # disables conversation sessions + session_expiration_time: 0 +``` + + +### Dialogue Featurization + +This section is only relevant if you explicitly defined [featurizers](./policies.mdx#featurizers) +in your policy configuration. + +LabelTokenizerSingleStateFeaturizer is deprecated and will be removed in the future. +It should be replaced with SingleStateFeaturizer and some changes should be made to the NLU pipeline. +Add a `Tokenizer` with the option `intent_tokenization_flag: True` and `CountVectorsFeaturizer` +to the NLU pipeline. + +For example: +```yaml-rasa {3-5} +language: en +pipeline: + - name: WhitespaceTokenizer + intent_tokenization_flag: True + - name: CountVectorsFeaturizer + # other components +policies: + # other policies + - name: TEDPolicy + featurizer: + - name: SingleStateFeaturizer + +``` + +BinarySingleStateFeaturizer is deprecated and will be removed in the future. +You should replace it with `SingleStateFeaturizer` and a NLU pipeline +where `intent_tokenization_flag` of a Tokenizer is set to `False`. + +For example: +```yaml-rasa {4} +language: en +pipeline: + - name: WhitespaceTokenizer + intent_tokenization_flag: False + # other components +policies: + # other policies + - name: TEDPolicy + featurizer: + - name: SingleStateFeaturizer + +``` + +### Deprecations + +The deprecated [event brokers](./event-brokers.mdx) FileProducer, KafkaProducer, PikaProducer +and SQLProducer have been removed. If you used these brokers in your +`endpoints.yml` make sure to use the renamed variants instead: + - FileProducer became FileEventBroker + - KafkaProducer became KafkaEventBroker + - PikaProducer became PikaEventBroker + - SQLProducer became SQLEventBroker + +The deprecated EmbeddingIntentClassifier has been removed. If you used this +component in your pipeline configuration (`config.yml`) you can replace it +with [DIETClassifier](./components.mdx#dietclassifier). +It accepts the same configuration parameters. + +The deprecated KerasPolicy has been removed. If you used this +component in your policies configuration (`config.yml`) you can replace it +with [TEDPolicy](./policies.mdx#ted-policy). It accepts the same configuration parameters. + +## Rasa 1.7 to Rasa 1.8 + +:::caution +This is a release **breaking backwards compatibility**. +It is not possible to load previously trained models. Please make sure to retrain a +model before trying to use it with this improved version. + +::: + +### General + +* The [TED Policy](./policies.mdx#ted-policy) replaced the `keras_policy` as recommended machine + learning policy. New projects generated with `rasa init` will automatically use + this policy. In case you want to change your existing model configuration to use the + [TED Policy](./policies.mdx#ted-policy) add this to the `policies` section in your `config.yml` + and remove potentially existing `KerasPolicy` entries: + + ```yaml-rasa + policies: + # - ... other policies + - name: TEDPolicy + max_history: 5 + epochs: 100 + ``` + + The given snippet specifies default values for the parameters `max_history` and + `epochs`. `max_history` is particularly important and strongly depends on your stories. + Please see the docs of the [TED Policy](./policies.mdx#ted-policy) if you want to customize them. + +* All pre-defined pipeline templates are deprecated. **Any templates you use will be + mapped to the new configuration, but the underlying architecture is the same**. + Take a look at [Tuning Your Model](./tuning-your-model.mdx) to decide on what components you should use + in your configuration file. + +* The Embedding Policy was renamed to [TED Policy](./policies.mdx#ted-policy). The functionality of the policy stayed the same. + Please update your configuration files to use `TEDPolicy` instead of `EmbeddingPolicy`. + +* Most of the model options for `EmbeddingPolicy`, `EmbeddingIntentClassifier`, and `ResponseSelector` got + renamed. Please update your configuration files using the following mapping: + + | Old model option | New model option | + |-----------------------------|-----------------------------------------------------| + |hidden_layers_sizes_a |dictionary “hidden_layers_sizes” with key “text” | + |hidden_layers_sizes_b |dictionary “hidden_layers_sizes” with key “label” | + |hidden_layers_sizes_pre_dial |dictionary “hidden_layers_sizes” with key “dialogue” | + |hidden_layers_sizes_bot |dictionary “hidden_layers_sizes” with key “label” | + |num_transformer_layers |number_of_transformer_layers | + |num_heads |number_of_attention_heads | + |max_seq_length |maximum_sequence_length | + |dense_dim |dense_dimension | + |embed_dim |embedding_dimension | + |num_neg |number_of_negative_examples | + |mu_pos |maximum_positive_similarity | + |mu_neg |maximum_negative_similarity | + |use_max_sim_neg |use_maximum_negative_similarity | + |C2 |regularization_constant | + |C_emb |negative_margin_scale | + |droprate_a |droprate_dialogue | + |droprate_b |droprate_label | + |evaluate_every_num_epochs |evaluate_every_number_of_epochs | + |evaluate_on_num_examples |evaluate_on_number_of_examples | + + Old configuration options will be mapped to the new names, and a warning will be thrown. + However, these will be deprecated in a future release. + +* The Embedding Intent Classifier is now deprecated and will be replaced by [DIETClassifier](./components.mdx#dietclassifier) + in the future. + `DIETClassfier` performs intent classification as well as entity recognition. + If you want to get the same model behavior as the current `EmbeddingIntentClassifier`, you can use + the following configuration of `DIETClassifier`: + + ```yaml-rasa + pipeline: + # - ... other components + - name: DIETClassifier + hidden_layers_sizes: + text: [256, 128] + number_of_transformer_layers: 0 + weight_sparsity: 0 + intent_classification: True + entity_recognition: False + use_masked_language_model: False + BILOU_flag: False + scale_loss: True + use_sparse_input_dropout: False + use_dense_input_dropout: False + # ... any other parameters + ``` + + See [DIETClassifier](./components.mdx#dietclassifier) for more information about the new component. + Specifying `EmbeddingIntentClassifier` in the configuration maps to the above component definition, and results in + the same behaviour within the same Rasa version. + +* `CRFEntityExtractor` is now deprecated and will be replaced by `DIETClassifier` in the future. If you want to + get the same model behavior as the current `CRFEntityExtractor`, you can use the following configuration: + + ```yaml-rasa + pipeline: + # - ... other components + - name: LexicalSyntacticFeaturizer + features: [ + ["low", "title", "upper"], + [ + "BOS", + "EOS", + "low", + "prefix5", + "prefix2", + "suffix5", + "suffix3", + "suffix2", + "upper", + "title", + "digit", + ], + ["low", "title", "upper"], + ] + - name: DIETClassifier + intent_classification: False + entity_recognition: True + use_masked_language_model: False + number_of_transformer_layers: 0 + # ... any other parameters + ``` + + `CRFEntityExtractor` featurizes user messages on its own, it does not depend on any featurizer. + We extracted the featurization from the component into the new featurizer [LexicalSyntacticFeaturizer](./components.mdx#lexicalsyntacticfeaturizer). Thus, + in order to obtain the same results as before, you need to add this featurizer to your pipeline before the + [DIETClassifier](./components.mdx#dietclassifier). + Specifying `CRFEntityExtractor` in the configuration maps to the above component definition, the behavior + is unchanged from previous versions. + +* If your pipeline contains `CRFEntityExtractor` and `EmbeddingIntentClassifier` you can substitute both + components with [DIETClassifier](./components.mdx#dietclassifier). You can use the following pipeline for that: + + ```yaml-rasa + pipeline: + # - ... other components + - name: LexicalSyntacticFeaturizer + features: [ + ["low", "title", "upper"], + [ + "BOS", + "EOS", + "low", + "prefix5", + "prefix2", + "suffix5", + "suffix3", + "suffix2", + "upper", + "title", + "digit", + ], + ["low", "title", "upper"], + ] + - name: DIETClassifier + number_of_transformer_layers: 0 + # ... any other parameters + ``` + +## Rasa 1.6 to Rasa 1.7 + +### General + +* By default, the `EmbeddingIntentClassifier`, `EmbeddingPolicy`, and `ResponseSelector` will + now normalize the top 10 confidence results if the `loss_type` is `"softmax"` (which has been + default since 1.3, see [Rasa 1.2 to Rasa 1.3](./migration-guide.mdx#rasa-12-to-rasa-13)). This is configurable via the `ranking_length` + configuration parameter; to turn off normalization to match the previous behavior, set `ranking_length: 0`. + +## Rasa 1.2 to Rasa 1.3 + +:::caution +This is a release **breaking backwards compatibility**. +It is not possible to load previously trained models. Please make sure to retrain a +model before trying to use it with this improved version. + +::: + +### General + +* Default parameters of `EmbeddingIntentClassifier` are changed. See + the Components page for details. + Architecture implementation is changed as well, so **old trained models cannot be loaded**. + Default parameters and architecture for `EmbeddingPolicy` are changed. See [Policies](./policies.mdx) for details. + It uses transformer instead of lstm. **Old trained models cannot be loaded**. + They use `inner` similarity and `softmax` loss by default instead of + `cosine` similarity and `margin` loss (can be set in config file). + They use `balanced` batching strategy by default to counteract class imbalance problem. + The meaning of `evaluate_on_num_examples` is changed. If it is non zero, random examples will be + picked by stratified split and used as **hold out** validation set, so they will be excluded from training data. + We suggest to set it to zero (default) if data set contains a lot of unique examples of dialogue turns. + Removed `label_tokenization_flag` and `label_split_symbol` from component. Instead moved intent splitting to `Tokenizer` components via `intent_tokenization_flag` and `intent_split_symbol` flag. + +* Default `max_history` for `EmbeddingPolicy` is `None` which means it'll use + the `FullDialogueTrackerFeaturizer`. We recommend to set `max_history` to + some finite value in order to use `MaxHistoryTrackerFeaturizer` + for **faster training**. See [Featurizers](./policies.mdx#featurizers) for details. + We recommend to increase `batch_size` for `MaxHistoryTrackerFeaturizer` + (e.g. `"batch_size": [32, 64]`) + +* **Compare** mode of `rasa train core` allows the whole core config comparison. + Therefore, we changed the naming of trained models. They are named by config file + name instead of policy name. Old naming style will not be read correctly when + creating **compare** plots (`rasa test core`). Please remove old trained models + in comparison folder and retrain. Normal core training is unaffected. + +* We updated the **evaluation metric** for our **NER**. We report the weighted precision and f1-score. + So far we included `no-entity` in this report. However, as most of the tokens actually don't have + an entity set, this will influence the weighted precision and f1-score quite a bit. From now on we + exclude `no-entity` from the evaluation. The overall metrics now only include proper entities. You + might see a drop in the performance scores when running the evaluation again. + +* `/` is reserved as a delimiter token to distinguish between retrieval intent and the corresponding response text + identifier. Make sure you don't include `/` symbol in the name of your intents. + + +## Rasa NLU 0.14.x and Rasa Core 0.13.x to Rasa 1.0 + +:::caution +This is a release **breaking backwards compatibility**. +It is not possible to load previously trained models. Please make sure to retrain a +model before trying to use it with this improved version. + +::: + +### General + +* The scripts in `rasa.core` and `rasa.nlu` can no longer be executed. To train, test, run, … an NLU or Core + model, you should now use the command line interface `rasa`. The functionality is, for the most part, the same as before. + Some changes in commands reflect the combined training and running of NLU and Core models, but NLU and Core can still + be trained and used individually. If you attempt to run one of the old scripts in `rasa.core` or `rasa.nlu`, + an error is thrown that points you to the command you + should use instead. See all the new commands at [Command Line Interface](./command-line-interface.mdx). + +* If you have written a custom output channel, all `send_` methods subclassed + from the `OutputChannel` class need to take an additional `\*\*kwargs` + argument. You can use these keyword args from your custom action code or the + templates in your domain file to send any extra parameters used in your + channel's send methods. + +* If you were previously importing the `Button` or `Element` classes from + `rasa_core.dispatcher`, these are now to be imported from `rasa_sdk.utils`. + +* Rasa NLU and Core previously used <a href="https://legacy-docs.rasa.com/docs/nlu/0.15.1/migrations/?&_ga=2.218966814.608734414.1560704810-314462423.1543594887#id1" target="_blank" rel="nofollow noopener noreferrer">separate configuration files</a>. + These two files should be merged into a single file either named `config.yml`, or passed via the `--config` parameter. + +### Script parameters + +* All script parameter names have been unified to follow the same schema. + Any underscores (`_`) in arguments have been replaced with dashes (`-`). + For example: `--max_history` has been changed to `--max-history`. You can + see all of the script parameters in the `--help` output of the commands + in the [Command Line Interface](./command-line-interface.mdx). + +* The `--num_threads` parameter was removed from the `run` command. The + server will always run single-threaded, but will now run asynchronously. If you want to + make use of multiple processes, feel free to check out the [Sanic server + documentation](https://sanicframework.org/en/guide/deployment/running.html#gunicorn). + +* To avoid conflicts in script parameter names, connectors in the `run` command now need to be specified with + `--connector`, as `-c` is no longer supported. The maximum history in the `rasa visualize` command needs to be + defined with `--max-history`. Output paths and log files cannot be specified with `-o` anymore; `--out` and + `--log-file` should be used. NLU data has been standarized to be `--nlu` and the name of + any kind of data files or directory to be `--data`. + +### HTTP API + +* There are numerous HTTP API endpoint changes which can be found [here](./http-api.mdx). diff --git a/docs/docs/model-configuration.mdx b/docs/docs/model-configuration.mdx new file mode 100644 index 0000000..0f378b2 --- /dev/null +++ b/docs/docs/model-configuration.mdx @@ -0,0 +1,62 @@ +--- +id: model-configuration +sidebar_label: Overview +title: Model Configuration +description: Learn about model configuration for Rasa. +abstract: The configuration file defines the components and policies that your model will use to make predictions based on user input. +--- + +The recipe key allows for different types of config and model architecture. +Currently, "default.v1" and the experimental "graph.v1" recipes are supported. + +:::info New in 3.5 + +The config file now includes a new mandatory key `assistant_id` which represents the unique assistant identifier. + +::: + +The `assistant_id` key must specify a unique value to distinguish multiple assistants in deployment. +The assistant identifier will be propagated to each event's metadata, alongside the model id. +Note that if the config file does not include this required key or the placeholder default value is not replaced, a random +assistant name will be generated and added to the configuration everytime when running `rasa train`. + +The language and pipeline keys specify the [components](./components.mdx) used by the model to make NLU predictions. +The policies key defines the [policies](./policies.mdx) used by the model to predict the next action. + +If you don't know which components or policies to choose, you can use +the [Suggested Config](./model-configuration.mdx#suggested-config) feature, which will recommend sensible defaults. + +## Suggested Config + +You can leave the pipeline and/or policies key out of your configuration file. +When you run `rasa train`, the Suggested Config feature will select a default configuration +for the missing key(s) to train the model. + +Make sure to specify the language key in your `config.yml` file with the +2-letter ISO language code. + +Example `config.yml` file: + +```yaml-rasa (docs/sources/data/configs_for_docs/example_for_suggested_config.yml) +``` + +The selected configuration will also be written as comments into the `config.yml` file, +so you can see which configuration was used. For the example above, the resulting file +might look e.g. like this: + +```yaml-rasa (docs/sources/data/configs_for_docs/example_for_suggested_config_after_train.yml) +``` + +If you like, you can then un-comment the suggested configuration for one or both of the +keys and make modifications. Note that this will disable automatic suggestions for this +key when training again. +As long as you leave the configuration commented out and don't specify any configuration +for a key yourself, a default configuration will be suggested whenever you train a new +model. + +:::note nlu- or dialogue- only models + +Only the default configuration for `pipeline` will be automatically selected +if you run `rasa train nlu`, and only the default configuration for `policies` +will be selected if you run `rasa train core`. +::: diff --git a/docs/docs/model-storage.mdx b/docs/docs/model-storage.mdx new file mode 100644 index 0000000..055cc0f --- /dev/null +++ b/docs/docs/model-storage.mdx @@ -0,0 +1,212 @@ +--- +id: model-storage +sidebar_label: Model Storage +title: Model Storage +abstract: | + Models can be stored in different places after you trained your assistant. This + page explains how to configure Rasa to load your models. +--- + +You can load your trained model in three different ways: + +1. Load the model from your local disk (see [Load Model from Disk](./model-storage.mdx#load-model-from-disk)) + +2. Fetch the model from your own HTTP server (see [Load Model from Server](./model-storage.mdx#load-model-from-server)) + +3. Fetch the model from cloud storage like S3 (see [Load Model from Cloud](./model-storage.mdx#load-model-from-cloud)) + +By default all commands of Rasa's CLI will load models from your local disk. + +## Load Model from Disk + +By default models will be loaded from your local disk. You can specify the path +to your model with the `--model` parameter: + +```bash +rasa run --model models/20190506-100418.tar.gz +``` + +If you want to load the latest model in a directory, you can specify +a directory instead of a file: + +```bash +rasa run --model models/ +``` + +Rasa will check all the models in that directory and load the one that was trained +most recently. + +If you don't specify a `--model` argument, Rasa will look for models in the `models/` directory. The two following calls +will load the same model: + +```bash +# this command will load the same model +rasa run --model models/ +# ... as this command (using defaults) +rasa run +``` + +## Load Model from Server + +You can configure the Rasa server to regularly fetch +a model from a server and deploy it. + +### How to Configure Rasa + +You can configure the HTTP server to fetch models from another URL +by adding it to your `endpoints.yml`: + +```yaml-rasa title="endpoints.yml" +models: + url: http://my-server.com/models/default + wait_time_between_pulls: 10 # In seconds, optional, default: 100 +``` +The server will query the `url` for a zipped model every `wait_time_between_pulls` +seconds. + +If you want to pull the model only when starting up the server, you can set the time +between pulls to `null`: + +```yaml-rasa title="endpoints.yml" +models: + url: http://my-server.com/models/default + wait_time_between_pulls: null # fetches model only once +``` + +### How to Configure Your Server + +Rasa will send a `GET` request to the URL you specified in the +`endpoints.yml`, e.g. `http://my-server.com/models/default` in the above examples. +You can use any URL. +The `GET` request will contain an `If-None-Match` header that contains the +model hash of the last model it downloaded. An example request from Rasa Open +Source to your server would look like this: + +```bash +curl --header "If-None-Match: d41d8cd98f00b204e9800998ecf8427e" http://my-server.com/models/default +``` + +The response of your server to this `GET` request should be one of these: +- a status code of `200`, a zipped Rasa Model and set the `ETag` header in + the response to the hash of the model. +- a status code of `304` and an empty response if the `If-None-Match` + header of the request matches the model you want your server to return. + +Rasa uses the `If-None-Match` and `ETag` headers for caching. Setting +the headers will avoid re-downloading the same model over and over, saving +bandwidth and compute resources. + +## Load Model from Cloud + +You can also configure the Rasa server to fetch your model from a remote storage: + +```bash +rasa run --model 20190506-100418.tar.gz --remote-storage aws +``` + +The zipped model will be downloaded from cloud storage, unzipped, and deployed. +Rasa supports loading models from: +- [Amazon S3](https://aws.amazon.com/s3/), +- [Google Cloud Storage](https://cloud.google.com/storage/), +- [Azure Storage](https://azure.microsoft.com/services/storage/) and +- custom implementations for + [Other Remote Storages](./model-storage.mdx#other-remote-storages). + +Models need to be stored in the root folder of the storage service. Currently, it is +not possible to manually specify the path on the cloud storage. + +### Amazon S3 Storage + +Amazon S3 is supported using the `boto3` package which you need to install +as an additional dependency using `pip3`: +```bash +pip3 install boto3 +``` + +For Rasa to be able to authenticate and download the model, you need to set the +following environment variables before running any command requiring the storage: + +* `AWS_SECRET_ACCESS_KEY`: environment variable containing your AWS S3 secret access key + +* `AWS_ACCESS_KEY_ID`: environment variable containing your AWS S3 access key ID + +* `AWS_DEFAULT_REGION`: environment variable specifying the region of your AWS S3 bucket + +* `BUCKET_NAME`: environment variable specifying the S3 bucket + +* `AWS_ENDPOINT_URL`: The complete URL to use for the AWS S3 requests. You need to + specify a complete URL (including the "http/https" scheme), for example: `https://s3.amazonaws.com`. + Note that by setting the bucket name to `BUCKET_NAME` environment variable, you should not provide the bucket or + object URL to `AWS_ENDPOINT_URL`. + + +Once all environment variables are set, you can start the Rasa server with +`remote-storage` option set to `aws`: + +```bash +rasa run --model 20190506-100418.tar.gz --remote-storage aws +``` + +### Google Cloud Storage + +Google Cloud Storage (GCS) is supported using the `google-cloud-storage` package +which you need to install as an additional dependency using `pip3`: +```bash +pip3 install google-cloud-storage +``` + +If you are running Rasa on Google App Engine or Compute Engine, the auth +credentials are already set up (for the GCS in the same project). In this case, +you can skip setting any additional environment variables. + +If you are running locally or on a machine outside of GAE or GCE you need to +provide the authentication details to Rasa manually: +1. Check out the [GCS documentation](https://cloud.google.com/docs/authentication/getting-started#auth-cloud-implicit-python) + and follow the descriptions on "Creating a service account" and + "Setting the environment variable." +2. After you have completed the GCS instructions, you should have set an environment + variable called `GOOGLE_APPLICATION_CREDENTIALS` to the path of a service account + key file with access to your GCS. + +Once all environment variable is set, you can start the Rasa server with +`remote-storage` option set to `gcs`: + +```bash +rasa run --model 20190506-100418.tar.gz --remote-storage gcs +``` + +### Azure Storage + +Azure Storage is supported using the `azure-storage-blob` package which you need +to install as an additional dependency using `pip3`: +```bash +pip3 install azure-storage-blob +``` + +For Rasa to be able to authenticate and download the model, you need to set the +following environment variables before running any command requiring the storage: + +* `AZURE_CONTAINER`: environment variable containing your azure container name + +* `AZURE_ACCOUNT_NAME`: environment variable containing your azure account name + +* `AZURE_ACCOUNT_KEY`: environment variable containing your account key + +Once all environment variables are set, you can start the Rasa server with +`remote-storage` option set to `azure`: + +```bash +rasa run --model 20190506-100418.tar.gz --remote-storage azure +``` + +### Other Remote Storages + +If you want to use any other Cloud Storage, you can provide your own python +implementation of the [`rasa.nlu.persistor.Persistor`](/reference/rasa/nlu/persistor) class. + +You can start the Rasa server with `remote-storage` option set to +the module path of your persistor implementation: + +```bash +rasa run --remote-storage <your module>.<class name> +``` diff --git a/docs/docs/monitoring/analytics/data-structure-reference.mdx b/docs/docs/monitoring/analytics/data-structure-reference.mdx new file mode 100644 index 0000000..9f6fc95 --- /dev/null +++ b/docs/docs/monitoring/analytics/data-structure-reference.mdx @@ -0,0 +1,909 @@ +--- +id: data-structure-reference +sidebar_label: Data structure reference +title: Data structure reference +abstract: Overview of the data structure created by the Analytics pipeline. + Description of all tables and attributes that can be used to build + analytics dashboards. +--- + +import useBaseUrl from "@docusaurus/useBaseUrl"; + +The data structure is created by the Analytics pipeline and treated as +a public API. The versioning of the API follows the +[Rasa Product Release and Maintenance Policy](https://rasa.com/rasa-product-release-and-maintenance-policy/). +All [Internal Tables](#internal-tables) should be considered private and may +change without notice. + +## Database Table Overview + +<!-- the diagram is generated from a database that has the correct schema + you need to connect to a live database, e.g. the one connected to our + example metabase. afterwards, you can use + https://app.trevor.io/datasources/617ec3a0-526f-4295-ad5a-3a31f0f4c027#m=ap + and its database map to create screenshots for all tables or the individual + tables --> + +<div align="center"> + <img + alt="An overview of the components of Rasa Pro." + src={useBaseUrl("/img/analytics/analytics-er-db.png")} + width="100%" + /> +</div> + +## Common Terms + +- **a sender** is a user who is talking to the assistant through a channel. + A user might have multiple senders if they use multiple channels, e.g. + communicating with the assistant through a website and through a + channel integrated into a mobile app. +- **a session** is a conversation between a sender and the assistant. + A session is started when a sender sends a message to the assistant and + ends when the session either has been timed out or explicitly ended. + If a session is interrupted by a longer period of inactivity new + activity will trigger a new session to be created ([configurable + through the session timeout](../../domain.mdx#session-configuration)). +- **a turn** always starts with a message from a sender and ends right before + the next message from the sender. A turn can also end with a session + being timed out or explicitly ended. A turn will usually contain at least + one bot response. + +## Tables + +### rasa_sender + +A sender is a user who interacts with the assistant through a Rasa Channel. +If the assistant supports more than one channel, a user might have multiple +senders. For example, a user might have a sender for the Facebook channel +and a sender for the Slack channel. + +<div align="center"> + <img + alt="Rasa Slot ER." + src={useBaseUrl("/img/analytics/analytics-er-rasa-sender.png")} + height="100%" + /> +</div> + +#### `id` sender identifier + +The unique identifier of the sender is generated by Analytics. Sender +gets a different, generated id assigned. The `id` differs from the `sender_id` +used by the Rasa channels, the `sender_id` in Rasa is the +`sender_key` in Analytics. + +- Type: `varchar(36)` +- Example: `a78783c4-bef7-4e55-9ec7-5afb4420f19a` + +#### `sender_key` Rasa channel sender identifier + +The unique identifier used by the Rasa channel to identify this sender. The +`sender_key` is specific to the channel implementation in Rasa and +the format depends on the channel. + +- Type: `varchar(255)` +- Example: `fb26ba0a9d8b4bd99e2b8716acb19e4b` + +#### `channel` Rasa channel name + +Name of the channel that is used for this sender. The channel names are +defined in the implementation of the respective Rasa channel. + +- Type: `varchar(255)` +- Example: `socket.io` + +#### `first_seen` first contact with this sender + +The date and time of the first contact with this sender. Corresponds to the +time of the first event of the first session created for this sender. + +- Type: `DateTime` +- Example: `2022-06-28 02:15:49.326936` + +#### `last_seen` latest contact with this sender + +The date and time of the last contact with this sender. Corresponds to the +time of the latest event of the latest session created for this sender. + +- Type: `DateTime` +- Example: `2022-10-28 02:15:49.326936` + +--- + +### rasa_session + +The `rasa_session` table contains information about all conversation +sessions that users started with the assistant. New sessions are created +for every new user and for users who return to the assistant. The +conditions that trigger a new session to start can be +configured in the [Rasa Domain](../../domain.mdx#session-configuration). + +<div align="center"> + <img + alt="Rasa Slot ER." + src={useBaseUrl("/img/analytics/analytics-er-rasa-session.png")} + height="100%" + /> +</div> + +#### `id` session identifier + +The unique identifier of the session. Every session gets a different, +generated id assigned. + +- Type: `varchar(36)` +- Example: `63b150a6-21a3-4e6c-bb24-5ab6ddc30cf1` + +#### `sender_id` sender who started the session + +The unique identifier of the sender who started the session. It is a +foreign key to the [`rasa_sender.id`](#rasa_sender) column. + +- Type: `varchar(36)` +- Example: `9e4ebded-f232-4cc5-af78-d98daa0c1a53` + +#### `timestamp` creation date time + +The timestamp when the session was created. The timestamp is a UTC. + +- Type: `DateTime` +- Example: `2022-06-28 02:15:49.326936` + +#### `start_sequence_number` start of the session + +The sequence number of the first event in this session. All events belong +to exactly one session. The start sequence number is always smaller or equal +to the `end_sequence_number`. The difference between start and end sequence +numbers does not equal the number of events in this session since +sequence numbers are incremented across multiple conversations. + +- Type: `Integer` +- Example: `78` + +#### `end_sequence_number` end of the session + +The sequence number of the last event in the session. + +- Type: `Integer` +- Example: `91` + +--- + +### rasa_turn + +The `rasa_turn` table contains information about all conversation +turns. A turn is one interaction between a user and an assistant. +A turn always starts with a user message. It ends with the last event +before the next user message or with the end of a session. A turn +will usually be one user message followed by one or multiple assistant +responses. All events between the user message and the end of the turn +belong to the same turn. + +<div align="center"> + <img + alt="Rasa Slot ER." + src={useBaseUrl("/img/analytics/analytics-er-rasa-turn.png")} + height="100%" + /> +</div> + +#### `id` session identifier + +The unique identifier of the turn. Every turn gets a different +generated id assigned. + +- Type: `varchar(36)` +- Example: `ffa5d0cd-f5a6-45a4-9506-ba7ffd76edf1` + +#### `sender_id` sender who started the turn + +The unique identifier of the sender who started the turn. It is a +foreign key to the [`rasa_sender.id`](#rasa_sender) column. + +- Type: `varchar(36)` +- Example: `9e4ebded-f232-4cc5-af78-d98daa0c1a53` + +#### `session_id` session identifier + +The unique identifier of the session this turn is part of. +It is a foreign key to the [`rasa_session.id`](#rasa_session) column. + +- Type: `varchar(36)` +- Example: `63b150a6-21a3-4e6c-bb24-5ab6ddc30cf1` + +#### `start_sequence_number` start of the turn + +The sequence number of the first event in this turn. All events belong +to exactly one session. The start sequence number is always smaller or equal +to the `end_sequence_number`. The difference between start and end sequence +numbers does not equal the number of events in this session since +sequence numbers are incremented across multiple conversations. + +- Type: `Integer` +- Example: `79` + +#### `end_sequence_number` end of the turn + +The sequence number of the last event in this turn. + +- Type: `Integer` +- Example: `82` + +--- + +### rasa_event + +The `rasa_event` table contains all events that an assistant created. Events +are created for every user message, bot response, and action that is executed +as well as for a lot of internal changes to a conversation session. +[Overview of all Rasa Events](../../action-server/events.mdx). + +<div align="center"> + <img + alt="Rasa Slot ER." + src={useBaseUrl("/img/analytics/analytics-er-rasa-event.png")} + height="100%" + /> +</div> + +#### `id` event identifier + +The unique identifier of the event. Every event gets different, +generated id assigned. + +- Type: `varchar(36)` +- Example: `f5adcd16-b18d-4c5c-95f0-1747b20cb0e6` + +#### `sender_id` sender whose conversation the event belongs to + +The unique identifier of the sender whose conversation this event is part of. +It is a foreign key to the [`rasa_sender.id`](#rasa_sender) column. + +- Type: `varchar(36)` +- Example: `9e4ebded-f232-4cc5-af78-d98daa0c1a53` + +#### `session_id` session identifier + +The unique identifier of the session this event is part of. +It is a foreign key to the [`rasa_session.id`](#rasa_session) column. + +- Type: `varchar(36)` +- Example: `63b150a6-21a3-4e6c-bb24-5ab6ddc30cf1` + +#### `timestamp` creation date time + +The timestamp when the event was created. The timestamp is a UTC. + +- Type: `DateTime` +- Example: `2022-06-28 02:15:49.326936` + +#### `event_type` kind of event + +The type of the event. The event type is a string and can be one of the +following: + +- `user`: The user sent a message to the assistant. +- `bot`: The assistant sent a message to the user. +- `action`: The assistant executed an action. +- `session_started`: A new session was started. +- `action_execution_rejected`: An action failed to execute. +- `active_loop`: The assistant is currently in a loop. +- `slot`: A slot was set. +- `followup`: A follow-up action was triggered. +- `loop_interrupted`: A loop was interrupted. +- `pause`: A session is paused, e.g. because the session was handed over + to a human agent. +- `restart`: A session was restarted. This will trigger a new session to + be started. The state of the assistant will be reset. +- `rewind`: The assistant rewinds to a previous state. +- `user_featurization`: The assistant featurized the user input. + +The event type defines how the event is interpreted and how the event +affects the conversation. For example, the `user` event type will +be interpreted as a user message and the `bot` event type will be +interpreted as a bot response. + +- Type: `varchar(255)` +- Example: `action` + +#### `model_id` model identifier + +The identifier of the Rasa model that was running as part of the assistant +when this event was created. + +- Type: `varchar(255)` +- Example: `75a985b7b86d442ca013d61ea4781b22` + +#### `environment` name of the assistant environment + +The name of the environment of the assistant that created this event. +The environment is a string that is set up during the start of the assistant, + +- Type: `varchar(255)` +- Example: `production` + +#### `sequence_number` start of the event + +The sequence number of the event. The events of a session always have +increasing sequence numbers. Sequence numbers are not guaranteed to be +sequential for events following one another. But sequence numbers can +be used to order the events of a session. + +- Type: `Integer` +- Example: `78` + +--- + +### rasa_bot_message + +A message sent by the assistant to a user will be tracked in the +`rasa_bot_message` table. The table contains information about the +sent message. + +<div align="center"> + <img + alt="Rasa Slot ER." + src={useBaseUrl("/img/analytics/analytics-er-rasa-bot-message.png")} + height="100%" + /> +</div> + +#### `id` bot message identifier + +The unique identifier of the bot message is generated by Analytics. + +- Type: `varchar(36)` +- Example: `2f2e5384-1bfa-4b53-90a7-c75e5f20b117` + +#### `event_id` id of the event of this message + +The unique identifier of the event that created this bot message. +It is a foreign key to the [`rasa_event.id`](#rasa_event) column. + +- Type: `varchar(36)` +- Example: `f5adcd16-b18d-4c5c-95f0-1747b20cb0e6` + +#### `sender_id` sender whose conversation the message belongs to + +The unique identifier of the sender whose conversation this message is part of. +It is a foreign key to the [`rasa_sender.id`](#rasa_sender) column. + +- Type: `varchar(36)` +- Example: `9e4ebded-f232-4cc5-af78-d98daa0c1a53` + +#### `session_id` session identifier + +The unique identifier of the session this message is part of. +It is a foreign key to the [`rasa_session.id`](#rasa_session) column. + +- Type: `varchar(36)` +- Example: `63b150a6-21a3-4e6c-bb24-5ab6ddc30cf1` + +#### `timestamp` creation date time + +The timestamp when the message was created. The timestamp is a UTC. + +- Type: `DateTime` +- Example: `2022-06-28 02:15:49.326936` + +#### `template_name` name of the template used to generate the message + +The name of the template that Rasa used to generate the bot message. Might +be empty if the message was not generated from a template but a custom +action. + +- Type: `varchar(255)` +- Example: `utter_greet` + +#### `text` message content + +The text of the bot message. + +- Type: `varchar(65535)` +- Example: `Ok, what can I help you with?` + +#### `model_id` model identifier + +The identifier of the Rasa model that was running as part of the assistant +when this message was created. + +- Type: `varchar(255)` +- Example: `75a985b7b86d442ca013d61ea4781b22` + +#### `sequence_number` start of the event + +The sequence number of the message. The events of a session always have +increasing sequence numbers. The sequence number of this message is the same +as the one of the underlying event. + +- Type: `Integer` +- Example: `78` + +--- + +### rasa_user_message + +A message sent by a user to the assistant will be tracked in the +`rasa_user_message` table. The table contains information about the +sent message. + +<div align="center"> + <img + alt="Rasa Slot ER." + src={useBaseUrl("/img/analytics/analytics-er-rasa-user-message.png")} + height="100%" + /> +</div> + +#### `id` user message identifier + +The unique identifier of the user message is generated by Analytics. + +- Type: `varchar(36)` +- Example: `49fdd79e-976b-47c2-ab27-a4c3d743a1c9` + +#### `event_id` id of the event of this message + +The unique identifier of the event that created this user message. +It is a foreign key to the [`rasa_event.id`](#rasa_event) column. + +- Type: `varchar(36)` +- Example: `f5adcd16-b18d-4c5c-95f0-1747b20cb0e6` + +#### `sender_id` sender whose conversation the message belongs to + +The unique identifier of the sender whose conversation this message is part of. +It is a foreign key to the [`rasa_sender.id`](#rasa_sender) column. + +- Type: `varchar(36)` +- Example: `9e4ebded-f232-4cc5-af78-d98daa0c1a53` + +#### `session_id` session identifier + +The unique identifier of the session this message is part of. +It is a foreign key to the [`rasa_session.id`](#rasa_session) column. + +- Type: `varchar(36)` +- Example: `63b150a6-21a3-4e6c-bb24-5ab6ddc30cf1` + +#### `intent` classification of the text + +The name of the intent that Rasa classified the text as. One of the intents +in the domain used to train the model. + +- Type: `varchar(255)` +- Example: `book_flight` + +#### `retrieval_intent` classification of the text + +The name of the retrieval intent that Rasa classified the text as. Only +populated if there is a configured retrieval intent. + +- Type: `varchar(255)` +- Example: `book_flight/faq` + +#### `confidence` certainty the model predicted for classifications + +The confidence of the ML model's intent prediction. The confidence is a +value between 0 and 1. The higher the value, the more certain the model is +that the intent is correct. + +- Type: `Float` +- Example: `0.8798527419567108` + +#### `text` message content + +The text of the user message. + +- Type: `varchar(65535)` +- Example: `I want to book a flight.` + +#### `timestamp` creation date time + +The timestamp when the message was created. The timestamp is a UTC. + +- Type: `DateTime` +- Example: `2022-06-28 02:15:49.326936` + +#### `model_id` model identifier + +The identifier of the Rasa model that was running as part of the assistant +when this message was created. + +- Type: `varchar(255)` +- Example: `75a985b7b86d442ca013d61ea4781b22` + +#### `sequence_number` start of the event + +The sequence number of the message. The events of a session always have +increasing sequence numbers. The sequence number of this message is the same +as the one of the underlying event. + +- Type: `Integer` +- Example: `78` + +#### `message_id` unique id for the message text + +A unique id that identifies the text of the message. + +- Type: `varchar(255)` +- Example: `7cdb5700ac9c493aa46987b77d91c363` + +--- + +### rasa_action + +An action executed by the assistant. All actions the bot executes are tracked +in the `rasa_action` table. The table contains information about the +executed action and its prediction. + +<div align="center"> + <img + alt="Rasa Slot ER." + src={useBaseUrl("/img/analytics/analytics-er-rasa-action.png")} + height="100%" + /> +</div> + +#### `id` action identifier + +The unique identifier of the action execution is generated by Analytics. + +- Type: `varchar(36)` +- Example: `bd074dc7-e745-4db6-86d0-75b0af7bc067` + +#### `event_id` id of the event of this action execution + +The unique identifier of the event that created this action execution. +It is a foreign key to the [`rasa_event.id`](#rasa_event) column. + +- Type: `varchar(36)` +- Example: `f5adcd16-b18d-4c5c-95f0-1747b20cb0e6` + +#### `sender_id` sender whose conversation triggered this action execution + +The unique identifier of the sender whose conversation triggered this action +execution. It is a foreign key to the [`rasa_sender.id`](#rasa_sender) column. + +- Type: `varchar(36)` +- Example: `9e4ebded-f232-4cc5-af78-d98daa0c1a53` + +#### `session_id` session identifier + +The unique identifier of the session this action execution is part of. +It is a foreign key to the [`rasa_session.id`](#rasa_session) column. + +- Type: `varchar(36)` +- Example: `63b150a6-21a3-4e6c-bb24-5ab6ddc30cf1` + +#### `name` name of the executed action + +The name of the action that Rasa has predicted and executed. One of the actions +in the domain used to train the model. + +- Type: `varchar(255)` +- Example: `action_book_flight` + +#### `confidence` ML models certainty of the predicted action + +The confidence of ML model's action prediction. The confidence is a +value between 0 and 1. The higher the value, the more certain the model is +that the action is correct. + +- Type: `Float` +- Example: `0.9398527419567108` + +#### `policy` name of the policy that predicted the action + +The name of the policy that predicted this action. The policy is a component +in the Rasa assistant that makes a prediction. The policy can be a rule +policy, a memoization policy, or an ML policy. + +- Type: `varchar(255)` +- Example: `policy_2_TEDPolicy` + +#### `timestamp` creation date time + +The timestamp when the action was executed. The timestamp is a UTC. + +- Type: `DateTime` +- Example: `2022-06-28 02:15:49.326936` + +#### `model_id` model identifier + +The identifier of the Rasa model that was running as part of the assistant +when this action was executed. + +- Type: `varchar(255)` +- Example: `75a985b7b86d442ca013d61ea4781b22` + +#### `sequence_number` start of the event + +The sequence number of the executed action. The events of a session always have +increasing sequence numbers. The sequence number of this executed action is +the same as the one of the underlying event. + +- Type: `Integer` +- Example: `78` + +--- + +### rasa_slot + +A slot that has been set for a session. All changes to slot values are tracked +in the `rasa_slot` table. The table contains information about the +change in the value of the slot. + +<div align="center"> + <img + alt="Rasa Slot ER." + src={useBaseUrl("/img/analytics/analytics-er-rasa-slot.png")} + height="100%" + /> +</div> + +#### `id` slot change identifier + +The unique identifier of this change in slot values is generated by Analytics. + +- Type: `varchar(36)` +- Example: `a793d284-b5b9-4cef-be8a-bc0f58c70c28` + +#### `event_id` id of the event that triggered this slot change + +The unique identifier of the event that triggered this change +in the slot value. It is a foreign key to +the [`rasa_event.id`](#rasa_event) column. + +- Type: `varchar(36)` +- Example: `f5adcd16-b18d-4c5c-95f0-1747b20cb0e6` + +#### `sender_id` sender whose conversation triggered this slot change + +The unique identifier of the sender whose conversation triggered this +slot change. It is a foreign key to the [`rasa_sender.id`](#rasa_sender) column. + +- Type: `varchar(36)` +- Example: `9e4ebded-f232-4cc5-af78-d98daa0c1a53` + +#### `session_id` session identifier + +The unique identifier of the session this slot change is part of. +It is a foreign key to the [`rasa_session.id`](#rasa_session) column. + +- Type: `varchar(36)` +- Example: `63b150a6-21a3-4e6c-bb24-5ab6ddc30cf1` + +#### `slot_path` path of the slot + +A path to the slot that was changed. The path identifies the slot by its +name, the sender and the session. The path is a string that looks like +`<sender_id>/<session_id>/<slot_name>`. + +- Type: `varchar(255)` +- Example: `9e4ebded-f232-4cc5-af78-d98daa0c1a53/63b150a6-21a3-4e6c-bb24-5ab6ddc30cf1/email` + +#### `name` name of the slot + +The name of the changed slot. The name of the slot is the same +as the name of the slot in the domain. + +- Type: `varchar(255)` +- Example: `email` + +#### `value` new slot value + +The new value of the slot for the session. The value is a dumped +JSON object. + +- Type: `varchar(65535)` +- Example: `john@example.com` + +#### `timestamp` creation date time + +The timestamp when the slot value was changed. The timestamp is a UTC. + +- Type: `DateTime` +- Example: `2022-06-28 02:15:49.326936` + +#### `sequence_number` start of the event + +The sequence number of the slot change. The events of a session always have +increasing sequence numbers. The sequence number of the slot change is +the same as the one of the underlying event. + +- Type: `Integer` +- Example: `78` + +--- + +### rasa_session_slot_state + +The state of a slot at the end of a session. The state of a slot is the +value of the slot at the end of a session. The state of a slot is +stored in the `rasa_session_slot_state` table. + +<div align="center"> + <img + alt="Rasa Slot ER." + src={useBaseUrl("/img/analytics/analytics-er-rasa-session-slot-state.png")} + height="100%" + /> +</div> + +#### `id` path of the slot + +A path to the slot. The path identifies the slot by its +name, the sender and the session. The path is a string that looks like +`<sender_id>/<session_id>/<slot_name>`. + +- Type: `varchar(255)` +- Example: `9e4ebded-f232-4cc5-af78-d98daa0c1a53/63b150a6-21a3-4e6c-bb24-5ab6ddc30cf1/email` + +#### `sender_id` sender whose conversation this slot is part of + +The unique identifier of the sender whose conversation this slot is part of. +It is a foreign key to the [`rasa_sender.id`](#rasa_sender) column. + +- Type: `varchar(36)` +- Example: `9e4ebded-f232-4cc5-af78-d98daa0c1a53` + +#### `session_id` session identifier + +The unique identifier of the session this slot is part of. +It is a foreign key to the [`rasa_session.id`](#rasa_session) column. + +- Type: `varchar(36)` +- Example: `63b150a6-21a3-4e6c-bb24-5ab6ddc30cf1` + +#### `name` name of the slot + +The name of the slot. The name of the slot is the same +as the name of the slot in the domain. + +- Type: `varchar(255)` +- Example: `email` + +#### `value` last value of the slot in the session + +The value of the slot at the end of the session. The value is a dumped +JSON object. If a slot is changed multiple times during a session, +the value is set to the last change. + +- Type: `varchar(65535)` +- Example: `john@example.com` + +#### `timestamp` creation date time + +Time of the last update of the slot in this session. The timestamp is a UTC. + +- Type: `DateTime` +- Example: `2022-06-21 02:15:49.326936` + +### rasa_patterns + +Patterns are marker definitions that have been received from Rasa. This table is called `patterns` +to distinguish them from extracted markers which are stored in `rasa_markers` table. It +stores the configuration of markers (which can be thought of as a pattern +of conversational events) along with their metadata. + +#### `id` pattern identifier + +The unique identifier of the rasa pattern is generated by Analytics. + +- Type: `varchar(36)` +- Example: `bd074dc7-e745-4db6-86d0-75b0af7bc067` + +#### `name` pattern name + +Name of the pattern + +- Type: `varchar()` +- Example: `registration success` + +#### `description` pattern description + +Description of the pattern + +- Type: `varchar()` +- Example: `This marker identifies successful account registration in the chat` + +#### `config` pattern configuration + +Pattern configuration dictionary stored as an escaped string + +- Type: `varchar()` +- Example: `"{'or': [{'intent': 'mood_unhappy'},{'intent': 'mood_great'}]}"` + +#### `is_active` soft-delete flag + +Only patterns with `is_active==True` are processed during real-time analysis + +- Type: `boolean` + +#### `created_at` creation date time + +Time of creation of this pattern. The timestamp is a UTC. + +- Type: `DateTime` +- Example: `2022-06-21 02:15:49.326936` + +#### `updated_at` update date time + +Time of the last update of the pattern in this session. The timestamp is a UTC. + +- Type: `DateTime` +- Example: `2022-06-21 02:15:49.326936` + +### rasa_markers + +Extracted markers from the conversations. Each row in this table corresponds +to a marker along with details of the pattern, sender, session and the last event where it was extracted. + +#### `id` marker identifier + +The unique identifier of the extracted rasa marker is generated by Analytics. + +- Type: `varchar(36)` +- Example: `bd074dc7-e745-4db6-86d0-75b0af7bc067` + +#### `pattern_id` pattern which was applied in this marker + +The unique identifier of the pattern which was applied in this marker. It is +a foreign key to the [`rasa_patterns.id`](#rasa_patterns) column + +- Type: `varchar(36)` +- Example: `9e4ebded-f232-4cc5-af78-d98daa0c1a53` + +#### `sender_id` sender identifier + +The unique identifier of the sender whose conversation this marker is part of. +It is a foreign key to the [`rasa_sender.id`](#rasa_sender) column. + +- Type: `varchar(36)` +- Example: `9e4ebded-f232-4cc5-af78-d98daa0c1a53` + +#### `session_id` session identifier + +The unique identifier of the session this marker is part of. +It is a foreign key to the [`rasa_session.id`](#rasa_session) column. + +- Type: `varchar(36)` +- Example: `63b150a6-21a3-4e6c-bb24-5ab6ddc30cf1` + +#### `event_id` event identifier + +The unique identifier of the event from event broker where this marker was applied. +Note that a marker can be applied across multiple events, this is the ID of the last event in the sequence. + +- Type: `varchar(36)` +- Example: `63b150a6-21a3-4e6c-bb24-5ab6ddc30cf1` + +#### `num_preceding_user_turns` Number of Proeeding User turns + +an integer indicating the number of user turns preceding the event at which the marker applied. + +- Type: `integer` +- Example: `4` + +#### `created_at` creation date time + +Time of creation of this marker. The timestamp is a UTC. + +- Type: `DateTime` +- Example: `2022-06-21 02:15:49.326936` + + +## Internal Tables + +Internal tables are used to store information about the assistant and +the events that are sent to the assistant. They are not meant to be +queried directly but are required for the functioning of Analytics. They +are a private API that is used by the Analytics service internally +and might change without notice. + +Internal tables: + +- `_rasa_raw_event` +- `alembic_version` diff --git a/docs/docs/monitoring/analytics/example-queries.mdx b/docs/docs/monitoring/analytics/example-queries.mdx new file mode 100644 index 0000000..fd3c240 --- /dev/null +++ b/docs/docs/monitoring/analytics/example-queries.mdx @@ -0,0 +1,215 @@ +--- +id: example-queries +sidebar_label: Example queries +title: Example queries +description: +--- + +import useBaseUrl from "@docusaurus/useBaseUrl"; + +This section helps you get started with analyzing your assistant's conversations. +The examples use SQL queries together with an example visualization in Metabase. + +For more metrics and categories of conversations, +see [Types of metrics](./getting-started-with-analytics.mdx#types-of-metrics). + + +<!-- +The queries below are taken from a dashboard in Metabase: +https://rasa-technologies-gmbh.metabaseapp.com/dashboard/3-example-queries-for-the-docs + +Please note that, given the data in the Metabase instance, we had to cheat and modify +the HTML of the graphs before taking the screenshots, otherwise we'd have data from +2020 instead of 2022. + +Please also note that some SQL queries in this page are not simple copy-paste from +Metabase queries because we used filters in Metabase to remove the noise and make +the graphs look nice. + +Please make sure that these SQL queries are kept in sync with the dashboard, it'll +make your colleagues' work next time easier :). + +--> + +## Number of sessions per month + +A common high-level usage metric of your assistant is the number of +sessions per month. Here is how it would look as an SQL query: + +```sql +SELECT + date_trunc('month', "public"."rasa_session"."timestamp") AS "first_seen", + count(*) AS "count" +FROM "public"."rasa_session" +GROUP BY 1 +ORDER BY 1 ASC +``` + +<figure align="center"> + <img + alt="Number of sessions per month visualized in Metabase." + src={useBaseUrl("/img/analytics/graph-number-sessions-month.png")} + width="100%" + /> + <figcaption>Number of sessions per month visualized in Metabase.</figcaption> +</figure> + +## Number of sessions per channel + +If you're connecting your assistant to multiple channels, it could be +useful to look at the number of sessions per channel, let's say per week. +The query you would need for this metric is: + +```sql +SELECT + "public"."rasa_sender"."channel" AS "channel", + "public"."rasa_sender"."first_seen" AS "timestamp", + count(distinct "public"."rasa_sender"."sender_key") AS "count" +FROM "public"."rasa_sender" +GROUP BY 1, 2 +ORDER BY 1 ASC, 2 ASC +``` + +<figure align="center"> + <img + alt="Number of sessions per channel visualized in Metabase." + src={useBaseUrl("/img/analytics/graph-number-sessions-channel.png")} + width="100%" + /> + <figcaption> + The number of sessions per channel as visualized in Metabase. + </figcaption> +</figure> + +## Top N intents + +To improve your assistant, you could look into the variety of intents +your users express. The query below selects the top 5 intents which +could help you have a good perspective on that topic: + +```sql +SELECT + "public"."rasa_user_message"."intent" AS "intent", + count(*) AS "count" +FROM "public"."rasa_user_message" +GROUP BY 1 +ORDER BY 2 DESC, 1 ASC +LIMIT 5 +``` + +<figure align="center"> + <img + alt="Top 5 intents visualized in Metabase." + src={useBaseUrl("/img/analytics/graph-top-5-intents.png")} + width="100%" + /> + <figcaption>Top 5 intents visualized in Metabase.</figcaption> +</figure> + +Moreover, you can look for the intent distribution over time: + +```sql +SELECT + "public"."rasa_user_message"."intent" AS "intent", + date_trunc('month', "public"."rasa_user_message"."timestamp") AS "timestamp", + count(*) AS "count" FROM "public"."rasa_user_message" +GROUP BY 1, 2 +ORDER BY 1 ASC, 2 ASC +``` + +<figure align="center"> + <img + alt="Intent distribution over time visualized in Metabase." + src={useBaseUrl("/img/analytics/graph-intent-distribution.png")} + width="100%" + /> + <figcaption>Intent distribution over time visualized in Metabase.</figcaption> +</figure> + +## Escalation rate + +The escalation rate or human hand-off rate is a measure of the number of +conversations the assistant passes to a human agent. This metric can +help you gain a better understanding of what happens during a conversation. +Let's say you have an intent named `handoff_to_support`. You'll get the +escalation rate over time with this sample query: + +```sql +WITH "sessions" AS ( + SELECT + "public"."rasa_user_message"."session_id" AS "session_id", + date_trunc('month', "public"."rasa_user_message"."timestamp") AS "timestamp", + ( + CASE "public"."rasa_user_message"."intent" + WHEN 'handoff_to_support' + THEN 1 ELSE 0 + END + ) AS "has_handoff_to_support" + FROM "public"."rasa_user_message" +), +"sessions_with_handoff" AS ( + SELECT + "session_id", + "timestamp", + SUM("has_handoff_to_support") AS "has_handoff_to_support" + FROM "sessions" + GROUP BY 1, 2 +) +SELECT + "timestamp", + SUM("has_handoff_to_support") / count(*) AS "escalation_rate" +FROM "sessions_with_handoff" +GROUP BY 1 ASC +ORDER BY 1 ASC +``` + +<figure align="center"> + <img + alt="Escalation rate visualized in Metabase." + src={useBaseUrl("/img/analytics/graph-escalation-rate.png")} + width="100%" + /> + <figcaption>Escalation rate visualized in Metabase.</figcaption> +</figure> + +## Abandonment rate + +Abandonment rate can be defined in many different custom ways, +however here we'll define it as a session ending without a user message +after a specific message was uttered by the bot, e.g. `utter_ask_name`. +You could adapt the metric to detect sessions ending without a user +message after a specific set of intents. The SQL query would look like this: + +```sql +WITH "sessions" AS ( + SELECT + DISTINCT ON ("public"."rasa_event"."session_id") "public"."rasa_event"."session_id", + "public"."rasa_event"."timestamp" AS "timestamp", + ( + CASE + WHEN "public"."rasa_bot_message"."template_name" = 'utter_ask_name' + THEN 1 ELSE 0 + END + ) AS "is_abandonned" + FROM "public"."rasa_event" + INNER JOIN "public"."rasa_bot_message" + ON "public"."rasa_event"."id" = "public"."rasa_bot_message"."event_id" + WHERE "public"."rasa_event"."event_type" = 'bot' + ORDER BY 1, 2 DESC +) +SELECT + date_trunc('month', "timestamp") AS "timestamp", + SUM("is_abandonned")::float / count(*) AS "abandonment_rate" +FROM "sessions" +GROUP BY 1 +ORDER BY 1 ASC +``` + +<figure align="center"> + <img + alt="Abandonment rate visualized in Metabase." + src={useBaseUrl("/img/analytics/graph-abandonment-rate.png")} + width="100%" + /> + <figcaption>Abandonment rate visualized in Metabase.</figcaption> +</figure> diff --git a/docs/docs/monitoring/analytics/getting-started-with-analytics.mdx b/docs/docs/monitoring/analytics/getting-started-with-analytics.mdx new file mode 100644 index 0000000..6e952dc --- /dev/null +++ b/docs/docs/monitoring/analytics/getting-started-with-analytics.mdx @@ -0,0 +1,322 @@ +--- +id: getting-started-with-analytics +sidebar_label: Getting started +title: Getting started with Analytics +description: +abstract: Visualise and process Rasa assistant metrics in the + tooling of choice. +--- + +import useBaseUrl from "@docusaurus/useBaseUrl"; +import RasaProLabel from "@theme/RasaProLabel"; +import RasaProBanner from "@theme/RasaProBanner"; +import { Grid } from "@theme/Grid"; + +<RasaProLabel /> + +<RasaProBanner /> + +Analytics Data Pipeline helps visualize and process Rasa assistant metrics in the +tooling (BI tools, data warehouses) of your choice. Visualizations +and analysis of the production assistant and its conversations allow +you to assess ROI and improve the performance of the assistant over time. + +<Grid columns="1fr 1fr" noMargin noGap verticalAlign="middle"> + <img + alt="Rasa Pro Analytics - Sessions per channel" + src={useBaseUrl("/img/analytics/graph-number-sessions-channel.png")} + width="100%" + /> + <img + alt="Rasa Pro Analytics - Escalation rate" + src={useBaseUrl("/img/analytics/graph-escalation-rate.png")} + width="100%" + /> + <img + alt="Rasa Pro Analytics - Intents distribution over time" + src={useBaseUrl("/img/analytics/graph-intent-distribution.png")} + width="100%" + /> + <img + alt="Rasa Pro Analytics - Top intents" + src={useBaseUrl("/img/analytics/graph-top-5-intents.png")} + width="100%" + /> +</Grid> + +Measuring a Rasa assistant's success and making strategic investments +will lead to better business outcomes. It will also help you understand +the needs of your end users and better serve them. + +<div align="center"> + <img + alt="An overview of the components of Rasa Pro." + src={useBaseUrl("/img/rasa-pro-analytics-overview.png")} + width="100%" + /> +</div> + +Rasa Pro will connect to your production assistant using Kafka. +The pipeline will compute analytics as soon as the docker container +is successfully deployed and connected to your data warehouse and +Kafka instances. + +## Types of metrics + +Metrics collected from your assistant can broadly be categorized as + +- User Analytics: Who are the users of the assistant, and how do they feel +about it? Examples: demographics, channels, sentiment analysis +- Usage Analytics: How is the assistant’s overall health and what kind of +traffic is coming to it? Examples: total number of sessions, time per +session, errors and error rates +- Conversation Analytics: What happened during the conversation? +Examples: number of messages sent, abandonment depth, number of topics +introduced by user, top N intents +- Business Analytics: How is the assistant performing with regard to business goals? +Examples: ROI of assistant per LoB, time comparison of assistant vs agent, containment rate + +In this version of the Analytics pipeline, measurement of the following metrics +is possible + +| Metric | Category | Meaning | +|--------|----------|---------| +| Number of conversations| Usage Analytics | Total number of conversations | +| Number of users | Usage Analytics | Total number of users | +| Number of sessions | Usage Analytics | Gross traffic to assistant | +| Channels used | Usage Analytics | Sessions by channel | +| User session count | User Analytics | Total number of user sessions or average sessions per user | +| Top N intents | Conversation Analytics | Top intents across all users | +| Avg response time | Conversation Analytics | Average response time for assistant | +| Containment rate | Business Analytics | % of conversations handled purely by assistant (not handed to human agent | +| Abandonment rate | Business Analytics | % of abandoned conversations | +| Escalation rate | Business Analytics | % of conversations escalated to human agent | + + +For examples of how you can extract these metrics, +see [Example queries](./example-queries.mdx). + +## Prerequisites + +- A production deployment of Kafka is required to set up Rasa Pro. + We recommend using [Amazon Managed Streaming for + Apache Kafka](https://aws.amazon.com/msk/). +- A production deployment of a data lake needs to be connected to + the data pipeline. Rasa Pro directly supports the following data lakes: + + - [PostgreSQL](https://aws.amazon.com/rds/postgresql/) ( + **recommended**. All PostgreSQL >= 11.0 are supported) + - [Amazon Redshift](https://aws.amazon.com/redshift/) + + Virtually any other data lakes can be configured to sync with your deployment of PostgreSQL. + You can find additional instructions on how to connect your PostgreSQL deployment to either [BigQuery](#bigquery) + or [Snowflake](#snowflake) in the [Connect a data warehouse step](#2-connect-a-data-warehouse). + + We recommend managed deployments of your data lake to minimize maintenance + efforts. + +## 1. Connect an assistant + +To connect an assistant to Rasa Pro Services, you need to connect the assistant +to an event broker. The assistant will stream all events to the event broker, +which will then be consumed by Rasa Pro Services. + +The configuration of the assistant is the first step of +[Installation and Configuration](./deploy/deploy-rasa-pro-services.mdx/#installation-and-configuration). +No additional configuration is required to connect the assistant to the +Analytics pipeline. After the assistant is deployed, the Analytics pipeline +will receive the data from the assistant and persist it to your +data warehouse which will be configured in the next step. + +## 2. Connect a data warehouse + +You can choose to configure the analytics pipeline to stream the +transformed conversational data to two different data warehouse types in AWS: + +- [PostgreSQL](#postgresql) +- [Redshift](#redshift) + +Additionally, any data warehouse is supported if it is configured in sync with +your deployment of PostgreSQL, [as recommended for Redshift](#streaming-from-postgresql-to-redshift). + +We have included brief instructions for syncing your PostgreSQL deployment with: + +- [BigQuery](#bigquery) +- [Snowflake](#snowflake) + +### PostgreSQL + +You can use Amazon Relational Database Service (RDS) to create a PostgreSQL +DB instance which is the environment that will run your PostgreSQL database. + +First, you must set up Amazon RDS by completing the instructions listed +[here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_SettingUp.html). +Next, create the PostgreSQL DB instance. You can follow one of the +following instruction sets: + +- the AWS _Easy create_ instructions listed in the [**Creating a PostgreSQL DB instance** section](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_GettingStarted.CreatingConnecting.PostgreSQL.html#CHAP_GettingStarted.Creating.PostgreSQL) +- the AWS [_Standard create_ instructions](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_CreateDBInstance.html) + +To build the DB URL in the DBAPI format specified in the +[**Connect an assistance** section](#connect-an-assistant) you must enter +the database credentials. You must obtain the database username and password +after you select a database authentication option during the process of +the PostgreSQL DB instance creation: + +- **Password authentication** to use database credentials only, in which + case you must enter a username for the master username, as well as generate + the master password. +- [**Password and IAM DB authentication**](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html) + to use IAM users and roles for the authentication of database users. +- [**Password and Kerberos authentication**](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/postgresql-kerberos.html) + +Finally, when running the Analytics pipeline Docker container, set the +[environment variable `RASA_ANALYTICS_DB_URL`](../../deploy/deploy-rasa-pro-services.mdx#docker-container-configuration-reference) +to the PostgreSQL Amazon RDS DB instance URL. + +### Redshift + +If you prefer instead to set up Amazon Redshift as your choice of data lake, +you can choose to either stream the data from the PostgreSQL source database +within the Amazon RDS DB instance created at the [**PostgreSQL step**](#postgresql) +to the Redshift target or [connect directly](#direct-connection) to Amazon Redshift. + +#### Streaming from PostgreSQL to Redshift + +If you meet the [prerequisites](https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Target.Redshift.html#CHAP_Target.Redshift.Prerequisites) +for using an Amazon Redshift database as a target, you will need to implement two steps: + +1. configure the PostgreSQL source for AWS Database Migration Service + (DMS) by following these [instructions](https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Source.PostgreSQL.html) +2. configure the Redshift target for AWS DMS following the instructions + [here](https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Target.Redshift.html). + +#### Direct connection + +You can get started on enabling the direct connection with Redshift by +following these resources on creating an [Amazon Redshift cluster](https://docs.aws.amazon.com/redshift/latest/mgmt/working-with-clusters.html). +Before you do so, you should take into account that streaming historical +data directly to Redshift could take much longer than streaming directly +to or via the PostgreSQL RDS instance. + +You must update the `RASA_ANALYTICS_DB_URL` to the Redshift cluster DB URL +which must follow the following format: + +``` +redshift://<USER>:<PASSWORD>@<AWS URL>:5439/<DB NAME> +``` + +For example: + +``` +redshift://awsuser:4324312adfaGQ@analytics.cp1yucixmagz.us-east-1.redshift.amazonaws.com:5439/analytics +``` + +:::caution Redshift write performance + +As a result of performance considerations, we strongly advise against +choosing a direct connection to Redshift. Redshift is a great data lake for +analytics but lacks the necessary write performance to directly stream data to it. + +::: + +### BigQuery + +To stream data from PostgreSQL to BigQuery, you can use [Datastream for BigQuery](https://cloud.google.com/datastream/docs). +Datastream for BigQuery supports several [PostgreSQL deployment types](https://cloud.google.com/datastream/docs/configure-your-source-postgresql-database#overview), +including [CloudSQL](https://cloud.google.com/sql/docs/postgres). + +Before you begin, make sure to check the Datastream [prerequisites](https://cloud.google.com/datastream/docs/before-you-begin), as well as +additional Datastream networking connectivity [requirements](https://cloud.google.com/datastream/docs/quickstart-replication-to-bigquery#requirements). + +You can closely follow [this quickstart guide](https://cloud.google.com/datastream/docs/quickstart-replication-to-bigquery) on replicating data from PostgreSQL CloudSQL to BigQuery with Datastream. + +Alternatively, you can deep dive into the following Datastream set-up guides: + +- [Configure your source PostgreSQL database](https://cloud.google.com/datastream/docs/configure-your-source-postgresql-database) +- Optional: use [customer-managed encryption keys](https://cloud.google.com/datastream/docs/use-cmek) +- Create a [connection profile for PostgreSQL database](https://cloud.google.com/datastream/docs/create-connection-profiles#cp4postgresdb) +- Create a [stream](https://cloud.google.com/datastream/docs/create-a-stream) + +### Snowflake + +You can sync your PostgreSQL deployment manually or via an automated [partner solution](https://docs.snowflake.com/en/user-guide/ecosystem-etl.html). + +The instructions for manual sync include the following steps: +- Extract data from PostgreSQL to file using `COPY INTO` [command](https://docs.snowflake.com/en/sql-reference/sql/copy-into-location.html). + You should also explore the Snowflake [data loading best practices](https://docs.snowflake.com/en/user-guide/data-load-considerations.html) before extraction. +- Stage the extracted data files to either internal or external locations such as AWS S3, Google Cloud Storage or Microsoft Azure. +- Copy staged files to Snowflake tables using `COPY INTO` [command](https://docs.snowflake.com/en/sql-reference/sql/copy-into-table.html). + You can decide to use [bulk data loading](https://docs.snowflake.com/en/user-guide/data-load-bulk.html) into Snowflake or to load continuously using [Snowpipe](https://docs.snowflake.com/en/user-guide/data-load-snowpipe.html). + Alternatively you can also benefit from [this plugin](https://cdap.atlassian.net/wiki/spaces/DOCS/pages/694157985/Cloud+Storage+to+Snowflake+Action) to load data to an existing Snowflake table. + +## 3. Ingest past conversations (optional) + +When Analytics is connected to your Kafka instance, it will consume +all prior events on the Kafka topic and ingest them into the database. +Kafka has a retention policy for events on a [topic which defaults to +7 days](https://docs.confluent.io/platform/current/installation/configuration/topic-configs.html#topicconfigs_retention.ms). + +If you want to process events from conversations that are older than the +retention policy configured for the Rasa topic, you can manually +ingest events from past conversations. + +Manually ingesting data from past conversations requires a connection to the +tracker store. The tracker store contains past conversations and a +connection to the Kafka cluster. Use the `rasa export` command to export +the events stored in the tracker store to Kafka: + +```shell +rasa export --endpoints endpoints.yml +``` + +Configure the export to read from your production tracker store +and write to Kafka as an event broker, e.g. + +```yaml-rasa title="endpoints.yml" + tracker_store: + type: SQL + dialect: "postgresql" + url: "localhost" + db: "tracker" + username: postgres + password: password + + event_broker: + type: kafka + topic: rasa-events + url: localhost:29092 + partition_by_sender: true +``` + +:::note + +Running manual ingestion of past events multiple times will result in +duplicated events. There is currently no deduplication implemented in +Analytics. Every ingested event will be stored in the database, +even if it was processed previously. +::: + +## 4. Connect a BI Solution + +Connecting a business intelligence platform to the data warehouse varies for +each platform. We provide example instructions for Metabase and Tableau but +you can use any BI platform which supports AWS Redshift or PostgreSQL. + +### Example: Metabase + +Metabase is a free and open-source business intelligence platform. It +provides a simple interface to query and visualize data. Metabase can +be connected to PostgreSQL or Redshift databases. + +- [Connecting Metabase to PostgreSQL](https://www.metabase.com/data_sources/postgresql) +- [Connecting Metabase to Redshift](https://www.metabase.com/data_sources/amazon-redshift) + +### Example: Tableau + +Tableau is a business intelligence platform. It provides a flexible interface +to build business intelligence dashboards. Tableau can be connected to +PostgreSQL or Redshift databases. + +- [Connecting Tableau to PostgreSQL](https://help.tableau.com/current/pro/desktop/en-us/examples_postgresql.htm) +- [Connecting Tableau to Redshift](https://help.tableau.com/current/pro/desktop/en-us/examples_amazonredshift.htm) diff --git a/docs/docs/monitoring/analytics/realtime-markers.mdx b/docs/docs/monitoring/analytics/realtime-markers.mdx new file mode 100644 index 0000000..28d2590 --- /dev/null +++ b/docs/docs/monitoring/analytics/realtime-markers.mdx @@ -0,0 +1,97 @@ +--- +id: realtime-markers +sidebar_label: Real-Time Markers +title: Real-Time Analysis of Markers +hide_table_of_contents: false +--- + +import useBaseUrl from "@docusaurus/useBaseUrl"; +import RasaProLabel from "@theme/RasaProLabel"; +import RasaProBanner from "@theme/RasaProBanner"; +import { Grid } from "@theme/Grid"; + +<RasaProLabel /> + +<RasaProBanner /> + +:::info New in 3.6 + +Process markers real-time from Rasa +Assistants to track metrics like solution and abandonment rates, +add metatags to conversations, +or filter conversations for Conversation Driven Development + +::: + +In Rasa Pro, [Markers](../../markers.mdx) are a powerful feature that allows +you to track and extract custom conversational events. With the Analytics +Data Pipeline, you can now process these markers in real-time, enabling +you to gain valuable insights and enhance the performance of your Rasa +Assistant. In this guide, we'll explore how to leverage real-time +analysis of markers to track solution and abandonment rates in your conversations. + +<img alt="flow of information with realtime markers" src={useBaseUrl("/img/analytics/realtime-markers.png")} /> + +## Defining Markers + +Please consult the [Markers](../../markers.mdx/#defining-markers) section of Rasa documentation +for details about defining markers. + +## Enable Real-time Processing + +To enable real-time analysis of markers in your Rasa project, you need to +upload the markers to Analytics Data Pipeline with the `rasa markers upload` +command. This command validates the marker YAML against the domain file to +make sure all the slots, intents and actions referred in the file also exist +in the domain and uploads the marker configuration YAML to the +Analytics Data Pipeline (where they're persisted in a database) and exits. +To get started, make sure you have the latest +version of Rasa installed. Then, open your command line +interface and navigate to your Rasa project directory. Run the following command: + +``` +rasa markers upload --config=<path-to-config-file> -d=<path-to-domain-file> -rasa-pro-services-url=<url> +``` + +By default, this command validates the marker configuration file against +your bot's domain.yml file. To specify a different domain file, use the +optional `-d` argument. + +This command should be run whenever there is a change in the marker +configuration file. The changes might include addition of new markers, +changing an existing marker or removing an existing marker. + +:::note + +This command **uploads** the marker configurations to the data pipeline. The pipeline assumes the configuration +file is the source of truth and only processes the markers defined in it. If you remove a marker from this file, +and run this command, then the processing for that marker is stopped. + +::: + +### Configuring the CLI command + +Visit our [CLI page](./command-line-interface.mdx#rasa-marker-upload) +for more information about the command line arguments available. + +## How are Markers processed? + +The markers YAML file describes the pattern of events for marker extraction. +Once the YAML files are uploaded, the patterns to be used for marker extraction are +stored in the `rasa_patterns` table. As the Kafka Consumer starts receiving events +from the Rasa Assistant, it starts analyzing them for markers. The Pipeline +processes all the events from the Kafka Event Broker and identifies points of +interest in the conversation that match the marker. The extracted markers are then stored +in the `rasa_marker` table. + +The evaluation of Markers in the Pipeline is similar to the `rasa evaluate markers` +command which can be used to process Markers from the conversations in Tracker Store. +Read more about it [here](../../markers.mdx/#extracting-markers) + +Extracted markers are added to the `rasa_markers` table in the database +immediately once they are processed. Each row in this table contains +the foreign key identifiers for the pattern, session, sender and +last event when marker was extracted along with `num_preceding_user_turns` +which tracks the number of turns preceding the event at which the marker applied. +Check out the [Data Structure Reference](data-structure-reference.mdx/#rasa-markers) page for the +database schema of relevant tables. diff --git a/docs/docs/monitoring/load-testing-guidelines.mdx b/docs/docs/monitoring/load-testing-guidelines.mdx new file mode 100644 index 0000000..a794d73 --- /dev/null +++ b/docs/docs/monitoring/load-testing-guidelines.mdx @@ -0,0 +1,44 @@ +--- +sidebar_label: Load Testing Guidelines +title: Load Testing Guidelines +description: | + Information about how best to scale up your bot to support parallel user activity + and how you can use tracing to help debug issues. +--- + +## Overview + +In order to gather metrics on our system's ability to handle increased loads and users, we have performed tests to evaluate the maximum number of concurrent users a Rasa assistant can handle with certain machine configurations. +In each test case we spawned the following number of concurrent users at peak concurrency using a [spawn rate](https://docs.locust.io/en/1.5.0/configuration.html#all-available-configuration-options) of 1000 users per second. +In our tests we used the Rasa [HTTP-API](https://rasa.com/docs/rasa/pages/http-api) and the [Locust](https://locust.io/) open source load testing tool. + + +| Users | CPU | Memory | +|--------------------------|----------------------------------------------|---------------| +| Up to 50,000 | 6vCPU | 16 GB | +| Up to 80,000 | 6vCPU, with almost 90% CPU usage | 16 GB | + + +### Some recommendations to improve latency +- Sanic Workers must be mapped 1:1 to CPU for both Rasa Pro and Rasa Action Server +- Create `async` actions to avoid any blocking I/O +- `enable_selective_domain: true` : Domain is only sent for actions that needs it. This massively trims the payload between the two pods. +- Consider using compute efficient machines on cloud which are optimized for high performance computing such as the C5 instances on AWS. + However, as they are low on memory, models need to be trained lightweight. + + +| Machine | RasaPro | Rasa Action Server | +|--------------------------------|------------------------------------------------|--------------------------------------------------| +| AWS C5 or Azure F or Gcloud C2 | 3-7vCPU, 10-16Gb Memory, 3-7 Sanic Threads | 3-7vCPU, 2-12Gb Memory, 3-7 Sanic Threads | + + +### Debugging bot related issues while scaling up + +To test the Rasa [HTTP-API](https://rasa.com/docs/rasa/pages/http-api) ability to handle a large number of concurrent user activity we used the Rasa Pro [tracing](./tracing.mdx) capability +along with a tracing backend or collector, such as Jaeger, to collect traces for the bot under test. + +:::note + +Our team is currently in the process of running additional performance-related tests. More information will be added here as we progress. + +::: \ No newline at end of file diff --git a/docs/docs/monitoring/tracing.mdx b/docs/docs/monitoring/tracing.mdx new file mode 100644 index 0000000..6177799 --- /dev/null +++ b/docs/docs/monitoring/tracing.mdx @@ -0,0 +1,164 @@ +--- +sidebar_label: Tracing +title: Tracing +description: Resolve performance issues faster and identify bottlenecks + through OpenTelemetry-based tracing +--- + +import RasaProLabel from "@theme/RasaProLabel"; + +import RasaProBanner from "@theme/RasaProBanner"; + +<RasaProLabel /> + +<RasaProBanner /> + +## Tracing + +Distributed tracing tracks requests as they flow through a distributed +system (in this case: a Rasa assistant), sending data about the requests +to a **tracing backend** which collects all trace data and enables +inspecting it. Trace data helps you understand the flow of requests +through both the components of a single service (Rasa itself), and across +different distributed services, for example, your action server. + +### Supported Tracing Backends/Collectors + +To trace requests in Rasa Pro, you can either use +[Jaeger](https://www.jaegertracing.io/) as a backend, or use +the [OTEL Collector (OpenTelemetry Collector)](https://opentelemetry.io/docs/collector/). +to collect traces and then send them to the backend of your choice. +See [Configuring a Tracing Backend or Collector](#configuring-a-tracing-backend-or-collector) +for instructions. + +### Enabling / Disabling + +Tracing is automatically enabled in Rasa Pro by +[configuring a supported tracing backend](#configuring-a-tracing-backend-or-collector). +No further action is required to enable tracing. + +You can disable tracing by leaving the `tracing:` configuration key empty +in your endpoints file. + +### Action Server + +The trace context is sent along with requests to the custom action server +using the [W3C Trace Context Specification](https://www.w3.org/TR/trace-context/). +You can use this trace context to continue tracing the request through +your custom action code. See [traced events](#traced-events) for +details on what attributes are made available as part of the trace context. + +## Configuring a Tracing Backend or Collector + +To configure a tracing backend or collector, add a `tracing` entry to your endpoints +i.e. in your `endpoints.yml` file, or in the relevant section of your Helm values in a deployment. + +### Jaeger + +To configure a Jaeger tracing backend, specify the `type` as `jaeger`. + +```yaml +tracing: + type: jaeger + host: localhost + port: 6831 + service_name: rasa + sync_export: ~ +``` + +### OTEL Collector + +Collectors are components that collect traces in a vendor-agnostic way and then forward them to various backends. +For example, the OpenTelemetry Collector (OTEL) can collect traces from multiple different components and instrumentation libraries, and then export them to multiple different backends e.g. jaeger. + +To configure an OTEL Collector, specify the `type` as `otlp`. + +```yaml +tracing: + type: otlp + endpoint: my-otlp-host:4318 + insecure: false + service_name: rasa + root_certificates: ./tests/unit/tracing/fixtures/ca.pem +``` + +## Traced Events + +The Rasa service areas that are traceable cover the actions required to: + +- **train a model** (i.e., the training of each [graph component](https://rasa.com/docs/rasa/custom-graph-components#graph-components)) +- **handle a message** + +### Model Training + +Tracing is enabled for model training by instrumenting Rasa [`GraphTrainer`](https://rasa.com/docs/rasa/reference/rasa/engine/training/graph_trainer/#graphtrainer-objects) and [`GraphNode`](https://rasa.com/docs/rasa/reference/rasa/engine/graph/#graphnode-objects) classes. + +#### `GraphTrainer` Attributes + +The following attributes can be inspected during training of `GraphTrainer`: + +- `training_type` of model configuration: + - `"NLU"` + - `"CORE"` + - `"BOTH"` + - `"END-TO-END"` +- `language` of model configuration +- `recipe_name` used in the `config.yml` file +- `output_filename`: the location where the packaged model is saved +- `is_finetuning`: boolean argument, if `True` enables incremental training + +#### `GraphNode` Attributes + +The following attributes are captured during the training (as well as prediction during message handling) of every graph node: + +- `node_name` +- `component_class` +- `fn_name`: method of component class that gets called + +### Message Handling + +The following Rasa classes are instrumented to enable tracing during message handling: + +- [`Agent`](https://rasa.com/docs/rasa/reference/rasa/core/agent/#agent-objects) +- [`MessageProcessor`](https://rasa.com/docs/rasa/reference/rasa/core/processor/#messageprocessor-objects) +- [`TrackerStore`](https://rasa.com/docs/rasa/tracker-stores) +- [`LockStore`](https://rasa.com/docs/rasa/lock-stores) + +Namely, these operations are now traceable: + +- receiving a message +- parsing the message +- predicting the next action +- running the action +- retrieving and saving the tracker +- locking the conversation +- publishing to the event broker +- passing the trace context to the action server + +#### `Agent` Attributes + +Tracing the `Agent` instance handling a message captures the following attributes: + +- `input_channel`: the name of the channel connector +- `sender_id`: the conversation id +- `model_id`: a unique identifier for the model +- `model_name`: the model name + +#### `MessageProcessor` Attributes + +The following `MessageProcessor` attributes are extracted during the tracing: + +- `number_of_events`: number of events in tracker +- `action_name`: the name of the predicted and executed action +- `sender_id`: the conversation id of the `DialogueStateTracker` object +- `message_id`: the unique message id + +The latter three attributes are also injected in the trace context that gets passed to the requests made to the custom action server. + +#### `TrackerStore` & `LockStore` Attributes + +Observable `TrackerStore` and `LockStore` attributes include: + +- `number_of_streamed_events`: number of new events to stream +- `broker_class`: the `EventBroker` on which the new events are published +- `lock_store_class`: Name of lock store used to lock conversations while messages are actively processed diff --git a/docs/docs/nlg.mdx b/docs/docs/nlg.mdx new file mode 100644 index 0000000..26a62a7 --- /dev/null +++ b/docs/docs/nlg.mdx @@ -0,0 +1,245 @@ +--- +id: nlg +sidebar_label: NLG +title: NLG Servers +--- + +Retraining the bot just to change the text copy can be suboptimal for +some workflows. That's why Rasa also allows you to outsource the +response generation and separate it from the dialogue learning. + +The assistant will still learn to predict actions and to react to user input +based on past dialogues, but the responses it sends back to the user +will be generated outside of Rasa. + +When the assistant wants to send a message to the user, it will call an +external HTTP server that you define. + +## Responding to Requests + +### Request Format + +When your model predicts that your bot should send a response to the user, +it will send a request to your server, giving you the information +required to select or generate a response. + +The body of the `POST` request sent to your NLG endpoint will be structured +like this: + +:::info New in 3.6 +We have added an `id` field to the request body. +This field contains the ID of the response variation. +You can use this information to compose/select a proper response variation on your NLG server. + +::: + +```json +{ + "response":"utter_what_can_do", + "arguments":{ + + }, + "id": "<response_variation_id>", + "tracker":{ + "sender_id":"user_0", + "slots":{ + + }, + "latest_message":{ + "intent":{ + "id":3014457480322877053, + "name":"greet", + "confidence":0.9999994039535522 + }, + "entities":[ + + ], + "text":"Hello", + "message_id":"94838d6f49ff4366b254b6f6d23a90cf", + "metadata":{ + + }, + "intent_ranking":[ + { + "id":3014457480322877053, + "name":"greet", + "confidence":0.9999994039535522 + }, + { + "id":8842445304628198686, + "name":"ask_forget_reminders", + "confidence":5.675940428773174e-07 + }, + { + "id":-2566831912141022859, + "name":"bye", + "confidence":3.418941929567154e-08 + }, + { + "id":8340513453672591403, + "name":"ask_id", + "confidence":2.5274500714544956e-08 + }, + { + "id":5822154213939471096, + "name":"ask_remind_call", + "confidence":2.4177523982871207e-08 + } + ] + }, + "latest_event_time":1599476297.694504, + "followup_action":null, + "paused":false, + "events":[ + { + "event":"action", + "timestamp":1599476297.68784, + "name":"action_session_start", + "policy":null, + "confidence":null + }, + { + "event":"session_started", + "timestamp":1599476297.6878452 + }, + { + "event":"action", + "timestamp":1599476297.6878562, + "name":"action_listen", + "policy":null, + "confidence":null + }, + { + "event":"user", + "timestamp":1599476297.694504, + "text":"Hello", + "parse_data":{ + "intent":{ + "id":3014457480322877053, + "name":"greet", + "confidence":0.9999994039535522 + }, + "entities":[ + + ], + "text":"Hello", + "message_id":"94838d6f49ff4366b254b6f6d23a90cf", + "metadata":{ + + }, + "intent_ranking":[ + { + "id":3014457480322877053, + "name":"greet", + "confidence":0.9999994039535522 + }, + { + "id":8842445304628198686, + "name":"ask_forget_reminders", + "confidence":5.675940428773174e-07 + }, + { + "id":-2566831912141022859, + "name":"bye", + "confidence":3.418941929567154e-08 + }, + { + "id":8340513453672591403, + "name":"ask_id", + "confidence":2.5274500714544956e-08 + }, + { + "id":5822154213939471096, + "name":"ask_remind_call", + "confidence":2.4177523982871207e-08 + } + ] + }, + "input_channel":"rest", + "message_id":"94838d6f49ff4366b254b6f6d23a90cf", + "metadata":{ + + } + } + ], + "latest_input_channel":"rest", + "active_loop":{ + + }, + "latest_action_name":"action_listen" + }, + "channel":{ + "name":"collector" + } +} +``` + +Here is an overview of the high-level keys in the post request: + +Key | Description +---|--- +`response` | The name of the response predicted by Rasa. +`id` | An optional string representing the response variation ID, can be null. +`arguments` | Optional keyword arguments that can be provided by custom actions. +`tracker` | A dictionary containing the entire conversation history. +`channel` | The output channel this message will be sent to. + +You can use any or all of this information to decide +how to generate your response. + + +### Response Format + +The endpoint needs to respond with the generated response. +Rasa will then send this response back to the user. + +Below are the possible keys of a response and their (empty) types: + +```json +{ + "text": "Some text", + "buttons": [], + "image": null, # string of image URL + "elements": [], + "attachments": [], + "custom": {} +} +``` + +You can choose to provide just text, or a combination of different types of rich responses. +Just like [the responses defined in the domain file](./responses.mdx), a response needs to contain at the very least +either `text` or `custom` to be a valid response. + +:::caution Calling responses from stories +If you use an external NLG service, you don't need to specify the +responses under `responses` in the domain. However, you still need to add the response names +to the `actions` list of the domain if you want to call them directly from +your stories. + +::: + + +## Configuring the Server URL + +To tell Rasa where to find your NLG server, add the URL to your `endpoints.yml`: + +```yaml-rasa title="endpoints.yml" +nlg: + url: http://localhost:5055/nlg +``` + +If your NLG server is protected and Rasa will need authentication to +access it, you can configure authentication in the endpoints: + +```yaml-rasa title="endpoints.yml" +nlg: + url: http://localhost:5055/nlg + # + # You can also specify additional parameters, if you need them: + # headers: + # my-custom-header: value + # token: "my_authentication_token" # will be passed as a GET parameter + # basic_auth: + # username: user + # password: pass +``` diff --git a/docs/docs/nlu-only-server.mdx b/docs/docs/nlu-only-server.mdx new file mode 100644 index 0000000..5dbb647 --- /dev/null +++ b/docs/docs/nlu-only-server.mdx @@ -0,0 +1,36 @@ +--- +id: nlu-only-server +sidebar_label: NLU-Only Server +title: NLU-Only Server +description: Read about connecting to a Rasa NLU-only server using the HTTP API. +abstract: You can run an NLU-only server and use the HTTP API to connect to it. +--- + + +## Connecting to an NLU server + +You can connect a [Rasa NLU-only server](./nlu-only.mdx#running-an-nlu-server) to a separately running Rasa dialogue management only server +by adding the connection details to the dialogue management server's endpoint configuration file: + +```yaml title="endpoints.yml" +nlu: + url: "http://<your nlu host>:<your nlu port>" + token: <token> # [optional] + token_name: <name of the token> # [optional] (default: token) +``` + +The `token` and `token_name` refer to optional [authentication parameters](./http-api.mdx#token-based-auth). + +The dialogue management server should serve a model that does not include an NLU model. +To obtain a dialogue management only model, train a model with `rasa train core` or use +`rasa train` but exclude all NLU data. + +When the dialogue management server receives a message, it will [send a request](https://rasa.com/docs/rasa/pages/http-api#operation/parseModelMessage) to +`http://<your nlu host>:<your nlu port>/model/parse` and use the parsing information returned. + +:::note endpoint configuration +The endpoint configuration for the dialogue management server will include an `nlu` endpoint that refers to your NLU only server. Therefore you should **use a separate endpoint configuration file** for the NLU server, excluding the `nlu` endpoint. +::: + +If you are implementing a custom NLU server (i.e. not Rasa NLU), your server should provide a `/model/parse` endpoint that responds to requests in the same +format as a Rasa NLU server does. diff --git a/docs/docs/nlu-only.mdx b/docs/docs/nlu-only.mdx new file mode 100644 index 0000000..a42b1aa --- /dev/null +++ b/docs/docs/nlu-only.mdx @@ -0,0 +1,53 @@ +--- +id: nlu-only +sidebar_label: Using NLU Only +title: Using NLU Only +abstract: Find out how to use only Rasa NLU as a standalone NLU service for your chatbot or virtual assistant. +--- + +If you want to use Rasa only as an NLU component, you can! + +## Training NLU-only models + +To train an NLU model only, run: + +```bash +rasa train nlu +``` + +This will look for NLU training data files in the ``data/`` directory +and saves a trained model in the ``models/`` directory. +The name of the model will start with ``nlu-``. + + +## Testing your NLU model on the command line + +To try out your NLU model on the command line, run the following command: + +```bash +rasa shell nlu +``` + +This will start the rasa shell and ask you to type in a message to test. +You can keep typing in as many messages as you like. + +Alternatively, you can leave out the ``nlu`` argument and pass in a nlu-only model directly: + +```bash +rasa shell -m models/nlu-20190515-144445.tar.gz +``` + +## Running an NLU server + +To start a server with your NLU model, pass in the model name at runtime: + +```bash +rasa run --enable-api -m models/nlu-20190515-144445.tar.gz +``` + +You can then request predictions from your model using the ``/model/parse`` endpoint. +To do this, run: + +```bash +curl localhost:5005/model/parse -d '{"text":"hello"}' +``` diff --git a/docs/docs/nlu-training-data.mdx b/docs/docs/nlu-training-data.mdx new file mode 100644 index 0000000..a12b35c --- /dev/null +++ b/docs/docs/nlu-training-data.mdx @@ -0,0 +1,333 @@ +--- +id: nlu-training-data +sidebar_label: NLU Training Data +title: NLU Training Data +description: Read more about how to format training data with Rasa NLU for open source natural language processing. +abstract: NLU training data stores structured information about user messages. +--- + +The goal of NLU (Natural Language Understanding) is to extract structured information from user messages. This usually includes the user's [intent](glossary.mdx#intent) and any +[entities](glossary.mdx#entity) their message contains. You can +add extra information such as [regular expressions](#regular-expressions) and [lookup tables](#lookup-tables) to your +training data to help the model identify intents and entities correctly. + + +## Training Examples + +NLU training data consists of example user utterances categorized by +intent. +To make it easier to use your intents, give them names that relate to what the user wants to accomplish with that intent, keep them in lowercase, and avoid spaces and special characters. + +:::note +The `/` symbol is reserved as a delimiter to separate [retrieval intents](glossary.mdx#retrieval-intent) from response text identifiers. Make sure not +to use it in the name of your intents. + +::: + + +## Entities + +[Entities](glossary.mdx#entity) are structured pieces of information inside a user message. +For entity extraction to work, you need to either specify training data to train an ML model or you need to define [regular expressions](#regular-expressions-for-entity-extraction) to extract entities using the [`RegexEntityExtractor`](components.mdx#regexentityextractor) based on a character pattern. + +When deciding which entities you need to extract, think about what information your assistant needs for its user goals. The user might provide additional pieces of information that you don't need for any user goal; you don't need to extract these as entities. + +See the [training data format](./training-data-format.mdx) for details on how to annotate entities in your training data. + +## Synonyms + +Synonyms map extracted entities to a value other than the literal text extracted in a case-insensitive manner. +You can use synonyms when there are multiple ways users refer to the same +thing. Think of the end goal of extracting an entity, and figure out from there which values should be considered equivalent. + +Let's say you had an entity `account` that you use to look up the user's balance. One of the possible account types is "credit". Your users also refer to their "credit" account as "credit +account" and "credit card account". + +In this case, you could define "credit card account" and "credit account" as +synonyms to "credit": + +```yaml-rasa +nlu: +- synonym: credit + examples: | + - credit card account + - credit account +``` + +Then, if either of these phrases is extracted as an entity, it will be +mapped to the value `credit`. Any alternate casing of these phrases (e.g. `CREDIT`, `credit ACCOUNT`) will also be mapped to the synonym. + +:::note Provide Training Examples +Synonym mapping only happens **after** entities have been extracted. +That means that your training examples should include the synonym examples +(`credit card account` and `credit account`) so that the model will learn to +recognize these as entities and replace them with `credit`. +::: + +See the [training data format](./training-data-format.mdx) for details on how to include synonyms in your training data. + +## Regular Expressions + +You can use regular expressions to improve intent classification and +entity extraction in combination with the [`RegexFeaturizer`](components.mdx#regexfeaturizer) and [`RegexEntityExtractor`](components.mdx#regexentityextractor) components in the pipeline. + + +### Regular Expressions for Intent Classification + +You can use regular expressions to improve intent classification by including the `RegexFeaturizer` component in your pipeline. When using the `RegexFeaturizer`, a regex does not act as a rule for classifying an intent. It only provides a feature that the intent classifier will use +to learn patterns for intent classification. +Currently, all intent classifiers make use of available regex features. + +The name of a regex in this case is a human readable description. It can help you remember what a regex is used for, and it is the title of the corresponding pattern feature. It does not have to match any intent or entity name. A regex for a "help" request might look like this: + +```yaml-rasa +nlu: +- regex: help + examples: | + - \bhelp\b +``` + +The intent being matched could be `greet`,`help_me`, `assistance` or anything else. + +Try to create your regular expressions in a way that they match as few +words as possible. E.g. using `\bhelp\b` instead of `help.*`, as the +later one might match the whole message whereas the first one only +matches a single word. + +:::note Provide Training Examples +The `RegexFeaturizer` provides features to the intent classifier, but it doesn't predict the intent directly. Include enough examples containing the regular expression so that the intent classifier can learn to use the regular expression feature. +::: + +### Regular Expressions for Entity Extraction + +If your entity has a deterministic structure, you can use regular expressions in one of two ways: + +#### Regular Expressions as Features + +You can use regular expressions to create features for the [`RegexFeaturizer`](components.mdx#regexfeaturizer) component in your NLU pipeline. + +When using a regular expression with the `RegexFeaturizer`, the +name of the regular expression does not matter. +When using the `RegexFeaturizer`, a regular expression provides a feature +that helps the model learn an association between intents/entities and inputs +that fit the regular expression. + +:::note Provide Training Examples +The `RegexFeaturizer` provides features to the entity extractor, but it doesn't predict the entity directly. Include enough examples containing the regular expression so that the entity extractor can learn to use the regular expression feature. + +::: + +Regex features for entity extraction +are currently only supported by the `CRFEntityExtractor` and `DIETClassifier` components. Other entity extractors, like +`MitieEntityExtractor` or `SpacyEntityExtractor`, won't use the generated +features and their presence will not improve entity recognition for +these extractors. + +#### Regular Expressions for Rule-based Entity Extraction + +You can use regular expressions for rule-based entity extraction using the [`RegexEntityExtractor`](components.mdx#regexentityextractor) component in your NLU pipeline. + +When using the `RegexEntityExtractor`, the name of the regular expression should +match the name of the entity you want to extract. +For example, you could extract account numbers of 10-12 digits by including this regular expression and at least two annotated examples in your training data: + +```yaml-rasa +nlu: +- regex: account_number + examples: | + - \d{10,12} +- intent: inform + examples: | + - my account number is [1234567891](account_number) + - This is my account number [1234567891](account_number) +``` + +Whenever a user message contains a sequence of 10-12 digits, it will be extracted as an `account_number` entity. `RegexEntityExtractor` doesn't require training examples to learn to extract the entity, but you do need at least two annotated examples of the entity so that the NLU model can register it as an entity at training time. + + +## Lookup Tables + +Lookup tables are lists of words used to generate +case-insensitive regular expression patterns. They can be used in the same ways as [regular expressions](#regular-expressions) are used, in combination with the [`RegexFeaturizer`](components.mdx#regexfeaturizer) and [`RegexEntityExtractor`](components.mdx#regexentityextractor) components in the pipeline. + +You can use lookup tables to help extract entities which have a known set of possible values. Keep your lookup tables as specific as possible. For example, to extract country names, you could add a lookup table of all countries in the world: + +```yaml-rasa +nlu: +- lookup: country + examples: | + - Afghanistan + - Albania + - ... + - Zambia + - Zimbabwe +``` + +When using lookup tables with `RegexFeaturizer`, provide enough examples for the intent or entity you want to match so that the model can learn to use the generated regular expression as a feature. When using lookup tables with `RegexEntityExtractor`, provide at least two annotated examples of the entity so that the NLU model can register it as an entity at training time. + + +## Entities Roles and Groups + +Annotating words as custom entities allows you to define certain concepts in your training data. +For example, you can identify cities by annotating them: + +``` +I want to fly from [Berlin]{"entity": "city"} to [San Francisco]{"entity": "city"} . +``` + +However, sometimes you want to add more details to your entities. + +For example, to build an assistant that should book a flight, the assistant needs to know which of the two cities in the example above is the departure city and which is the +destination city. +`Berlin` and `San Francisco` are both cities, but they play different **roles** in the message. +To distinguish between the different roles, you can assign a role label in addition to the entity label. + +``` +- I want to fly from [Berlin]{"entity": "city", "role": "departure"} to [San Francisco]{"entity": "city", "role": "destination"}. +``` + +You can also group different entities by specifying a **group** label next to the entity label. +The group label can, for example, be used to define different orders. +In the following example, the group label specifies which toppings go with which pizza and +what size each pizza should be. + +``` +Give me a [small]{"entity": "size", "group": "1"} pizza with [mushrooms]{"entity": "topping", "group": "1"} and +a [large]{"entity": "size", "group": "2"} [pepperoni]{"entity": "topping", "group": "2"} +``` + +See the [Training Data Format](training-data-format.mdx#entities) for details on how to define entities with roles and groups in your training data. + +The entity object returned by the extractor will include the detected role/group label. + +```json +{ + "text": "Book a flight from Berlin to SF", + "intent": "book_flight", + "entities": [ + { + "start": 19, + "end": 25, + "value": "Berlin", + "entity": "city", + "role": "departure", + "extractor": "DIETClassifier", + }, + { + "start": 29, + "end": 31, + "value": "San Francisco", + "entity": "city", + "role": "destination", + "extractor": "DIETClassifier", + } + ] +} +``` + +:::note +Entity roles and groups are currently only supported by the [DIETClassifier](./components.mdx#dietclassifier) and [CRFEntityExtractor](./components.mdx#crfentityextractor). + +::: + +In order to properly train your model with entities that have roles and groups, make sure to include enough training +examples for every combination of entity and role or group label. +To enable the model to generalize, make sure to have some variation in your training examples. +For example, you should include examples like `fly TO y FROM x`, not only `fly FROM x TO y`. + +To fill slots from entities with a specific role/group, you need to define a `from_entity` [slot mapping](./domain.mdx#slot-mappings) +for the slot and specify the role/group that is required. For example: + +```yaml-rasa +entities: + - city: + roles: + - departure + - destination + +slots: + departure: + type: any + mappings: + - type: from_entity + entity: city + role: departure + destination: + type: any + mappings: + - type: from_entity + entity: city + role: destination +``` + +### Entity Roles and Groups influencing dialogue predictions + +If you want to influence the dialogue predictions by roles or groups, you need to modify your stories to contain +the desired role or group label. You also need to list the corresponding roles and groups of an entity in your +[domain file](./domain.mdx#entities). + +Let's assume you want to output a different sentence depending on what the user's location is. E.g. +if the user just arrived from London, you might want to ask how the trip to London was. But if the user is on the way +to Madrid, you might want to wish the user a good stay. You can achieve this with the +following two stories: + +```yaml-rasa +stories: +- story: The user just arrived from another city. + steps: + - intent: greet + - action: utter_greet + - intent: inform_location + entities: + - city: London + role: from + - action: utter_ask_about_trip + +- story: The user is going to another city. + steps: + - intent: greet + - action: utter_greet + - intent: inform_location + entities: + - city: Madrid + role: to + - action: utter_wish_pleasant_stay +``` + +## BILOU Entity Tagging + +The [DIETClassifier](./components.mdx#dietclassifier) and [CRFEntityExtractor](./components.mdx#crfentityextractor) +have the option `BILOU_flag`, which refers to a tagging schema that can be +used by the machine learning model when processing entities. +`BILOU` is short for Beginning, Inside, Last, Outside, and Unit-length. + +For example, the training example + +``` +[Alex]{"entity": "person"} is going with [Marty A. Rick]{"entity": "person"} to [Los Angeles]{"entity": "location"}. +``` + +is first split into a list of tokens. Then the machine learning model applies the tagging schema +as shown below depending on the value of the option `BILOU_flag`: + +| token | `BILOU_flag = true` | `BILOU_flag = false` | +|---------|----------------------|-----------------------| +| alex | U-person | person | +| is | O | O | +| going | O | O | +| with | O | O | +| marty | B-person | person | +| a | I-person | person | +| rick | L-person | person | +| to | O | O | +| los | B-location | location | +| angeles | L-location | location | + +The BILOU tagging schema is richer compared to the normal tagging schema. It may help to improve the +performance of the machine learning model when predicting entities. + +:::note inconsistent BILOU tags +When the option `BILOU_flag` is set to `True`, the model may predict inconsistent BILOU tags, e.g. +`B-person I-location L-person`. Rasa uses some heuristics to clean up the inconsistent BILOU tags. +For example, `B-person I-location L-person` would be changed into `B-person I-person L-person`. +::: diff --git a/docs/docs/pii-management.mdx b/docs/docs/pii-management.mdx new file mode 100644 index 0000000..e65e5ec --- /dev/null +++ b/docs/docs/pii-management.mdx @@ -0,0 +1,292 @@ +--- +id: pii-management +sidebar_label: PII Management +title: PII Management +hide_table_of_contents: false +--- +import RasaProLabel from "@theme/RasaProLabel"; + +import RasaProBanner from "@theme/RasaProBanner"; + +<RasaProLabel /> + +<RasaProBanner /> + +:::info New in 3.6 + +You can now anonymize personally identifiable information (PII) data in your logs and events streamed via Kafka event +broker only. Continue reading to learn how to enable the anonymization of PII data. + +::: + +Management of sensitive customer data collected by assistants is a critical requirement for complying with regulations +and managing the data securely while making it available for analysis. +Rasa Pro 3.6 introduces the ability to anonymize PII data in your logs and events streamed via the Kafka event broker. + +Note that the Tracker Store continues to store conversation data which is not anonymized. This is required so that we +retain a source of truth of pristine data for non-repudiation purposes. Learn how to secure your Tracker Store +using [Vault secrets manager](./secrets-managers.mdx) to protect the source of truth with rotating credentials. + +In addition, because the tracker store data plays a vital role in the assistant's dialogue management at inference time, +anonymizing data in flight could have undesirable consequences for the assistant's dialogue management action predictions. + +## Architecture Overview + +The anonymization of the Rasa event is run through an anonymization pipeline at the end of the dialogue management +action prediction and execution. Note that the processing done by the anonymization pipeline is scheduled as a background +task and does not affect the assistant's response time. + +The anonymization steps are as follows: +1. The Rasa agent invokes the anonymization pipeline during each user message handling. +2. The anonymization pipeline runs through a sequence of anonymization rules that are applied to the new events. +3. The pipeline publishes the anonymized events to the Kafka topic that is mapped to the anonymization rule list in +`endpoints.yml`. +4. The tracker store saves the original events that are not anonymized. + +### Supported Rasa Events + +The Rasa events that are anonymized include the following: +- `user` +- `bot` +- `slot` +- `entities` + +### Supported PII entity types + +The anonymization pipeline uses [Microsoft Presidio](https://microsoft.github.io/presidio/text_anonymization/) as both entity recognizer and +anonymizer. Presidio is an open-source library that supports a wide range of entity types and anonymization methods. + +You can specify any of the out-of-the-box [supported Presidio entity types](https://microsoft.github.io/presidio/supported_entities/) in the anonymization rules. +Note that it is currently not possible to add custom entity types for the Rasa Pro anonymization pipeline. + +## How to write anonymization rules + +You can now write anonymization rules in `endpoints.yml` to explicitly declare which Presidio entities should be anonymized. +The anonymization pipeline is configurable via the `anonymization` section in the `endpoints.yml` file. +This section must have the structure given in the following example: + +```yaml +anonymization: + metadata: + language: en + model_name: en_core_web_lg + model_provider: spacy + rule_lists: + - id: rules_1 + rules: + - entity: PERSON + substitution: text + value: John Doe + - entity: LOCATION + substitution: faker + - entity: CREDIT_CARD + substitution: faker + - entity: IBAN_CODE + substitution: faker + - id: rules_2 + rules: + - entity: CREDIT_CARD + substitution: mask + - entity: IBAN_CODE + substitution: mask +``` + +### How to populate the metadata section + +The metadata section contains the following fields: `language`, `model_name`, and `model_provider`. + +The `language` field specifies the language code of the text to be anonymized. Note that you can only specify one language +per anonymization pipeline, therefore the capability is currently not able to handle language switching by end users. + +The `model_name` and `model_provider` fields specify the name and provider of the Presidio model to be used for +anonymization. The available model providers are `spacy`, `stanza` and `transformers`. + +If you want to use `spacy`, we strongly recommend to use the available large models, such as `en_core_web_lg` or `es_core_news_lg`. +This is to ensure more accurate entity recognition. + +:::caution +When using large models, you must ensure that your Rasa Pro environment has enough memory to load the model. + +::: + +If you opt for using the `transformers` model provider, you must specify two model names in the `model_name` field: +the HuggingFace model name and the spaCy model name, where the spaCy model would wrap the transformers NER model. +For example: + +```yaml +anonymization: + metadata: + language: en + model_name: + spacy: en_core_web_lg + transformers: dslim/bert-base-NER + model_provider: transformers +``` +#### How to install the language model + +You must install the model you declare to the `model_name` field in your Rasa Pro environment. +For example, if you declare `model_name: en_core_web_lg`, you must install the spaCy `en_core_web_lg` model in your Rasa Pro environment. +You can follow model installation instructions for spaCy and stanza models in the [Presidio official documentation](https://microsoft.github.io/presidio/analyzer/nlp_engines/spacy_stanza/). + +In the case of the `transformers` model provider, you must install _both_ models that you declare in the `model_name` field +in your Rasa Pro environment. For example, if you declare `transformers: dslim/bert-base-NER` in `endpoints.yml`, you must +install the `dslim/bert-base-NER` model in your Rasa Pro environment. You can find model download instructions for +`HuggingFace` in the [Presidio official documentation](https://microsoft.github.io/presidio/analyzer/nlp_engines/transformers/). + +:::note +Not all languages have a pre-trained language model available. If you want to use a language that does not have +a pre-trained language model available, you must train your own [spaCy](https://spacy.io/usage/training), [stanza](https://stanfordnlp.github.io/stanza/training.html) +or [huggingface](https://huggingface.co/docs/transformers/training) model and install it in your Rasa Pro environment. +::: + +### How to populate the rule_lists section + +The `rule_lists` section contains a list of anonymization rule lists. Each rule list must have a unique `id` of type +string and a list of `rules`. Each rule must have an `entity` field and a `substitution` field. The `entity` field +specifies the Presidio entity type to be anonymized and must be in uppercase. Note that regular expressions are currently +not supported for identifying entities. + +The `substitution` field specifies the anonymization method to be used. Currently, the +following anonymization methods are supported: `text`, `mask`, and `faker`: +- The `text` anonymization method replaces the original entity value with the value specified in the `value` field. +In the following example, the `PERSON` entity value will be replaced with `John Doe`. +```yaml +anonymization: + metadata: + language: en + model_name: en_core_web_lg + model_provider: spacy + rule_lists: + - id: rules_1 + rules: + - entity: PERSON + substitution: text + value: John Doe +``` +- The `mask` anonymization method replaces the original entity value with a mask of the same length using the character '*'. +For example, if the original entity value is `John Doe`, the anonymized value will be `********`. +```yaml +anonymization: + metadata: + language: en + model_name: en_core_web_lg + model_provider: spacy + rule_lists: + - id: rules_1 + rules: + - entity: PERSON + substitution: mask +``` + +- The `faker` anonymization method replaces the original entity value with a fake value generated by the [Faker](https://faker.readthedocs.io/en/stable/) library. +For example, if the original entity value is `John Doe`, the anonymized value will be replaced with a fake name generated by the Faker library. +```yaml +anonymization: + metadata: + language: en + model_name: en_core_web_lg + model_provider: spacy + rule_lists: + - id: rules_1 + rules: + - entity: PERSON + substitution: faker +``` + +If no substitution method is specified, the default substitution method is `mask`. + +The `value` field is only required for the `text` anonymization method. It specifies the text to be used as the anonymized value. +If the `value` field is not specified, the original entity value to be anonymized will be replaced with the entity type +name between brackets. For example, if the `value` field is not specified for the `PERSON` entity type, the anonymized +value will be `<PERSON>`. + +The `faker` anonymization method uses the [Faker](https://faker.readthedocs.io/en/stable/) library to generate fake data. +By default, the `faker` anonymization method will generate fake data in English unless a localized Presidio +entity type is used. For example, if you use the `faker` substitution method for the `ES_NIF` entity type, the generated +fake data will match the format of a Spanish NIF. + +The `faker` substitution method does not support the following Presidio entity types: +- `CRYPTO`, `NRP`, `MEDICAL_LICENSE` +- `US_BANK_NUMBER`, `US_DRIVER_LICENSE` +- `UK_NHS` +- `IT_FISCAL_CODE`, `IT_DRIVER_LICENSE`, `IT_PASSPORT`, `IT_IDENTITY_CARD` +- `SG_NRIC_FIN` +- `AU_ABN`, `AU_ACN`, `AU_TFN`, `AU_MEDICARE` + +If any of the above entities is used together with the `faker` substitution method, the anonymization pipeline will default +to the `mask` substitution method. + +## How to update the Kafka event broker configuration + +The anonymization pipeline uses Kafka to publish the anonymized events to the Kafka topic that is mapped to the +anonymization rule list. You can configure the Kafka event broker in the `endpoints.yml` file. The Kafka event broker +must contain the `anonymization_topics` section, which must have the following structure: + +```yaml +event_broker: + type: kafka + partition_by_sender: True + security_protocol: PLAINTEXT + url: localhost + anonymization_topics: + - name: topic_1 + anonymization_rules: rules_1 + - name: topic_2 + anonymization_rules: rules_2 + client_id: kafka-python-rasa +``` + +The `anonymization_topics` section contains a list of Kafka topics to which the anonymized events will be published. +Each Kafka topic must have a `name` field and an `anonymization_rules` field. The `name` field specifies the name of the +Kafka topic. The `anonymization_rules` field specifies the `id` of the anonymization rule list to be used for the Kafka topic. + +### Streaming anonymized events to Rasa X/Enterprise with Kafka + +Streaming anonymized events to Rasa X/Enterprise is only supported for Rasa X/Enterprise versions `1.3.0` and above. +In addition, you must use the Kafka event broker, other event broker types are not supported. + +You can stream anonymized events to Rasa X/Enterprise via Kafka by adding the `rasa_x_consumer: true` key-value pair to +the `anonymization_topics` section: + +```yaml +event_broker: + type: kafka + partition_by_sender: True + url: localhost + anonymization_topics: + - name: topic_1 + anonymization_rules: rules_1 + rasa_x_consumer: true + - name: topic_2 + anonymization_rules: rules_2 +``` + +If multiple Kafka anonymization topics contain the `rasa_x_consumer` key-value pair, the anonymized events will be streamed +to the Kafka topic that is mapped to the first topic in the `anonymization_topics` list that contains the `rasa_x_consumer` +key-value pair. + +Note that the `rasa_x_consumer` key-value pair is optional. If it is not specified, the anonymized events will be published +to the Kafka topic, but they will not be streamed to Rasa X/Enterprise. + +## How to enable anonymization of PII in logs + +You can enable anonymization of PII in logs by filling the `logger` section in the `endpoints.yml` file. +The `logger` section must have the following structure: + +```yaml +logger: + formatter: + anonymization_rules: rules_1 +``` + +The `anonymization_rules` field specifies the `id` of the anonymization rule list to be used for the logs. + +:::caution +We strongly recommend to run with log level INFO in production. +Running with log level DEBUG will increase the assistant's response latency because of processing delays. +::: + +Note that running `rasa shell` in debug mode with a Kafka event broker might result in logs related to the event publishing +to be printed to console **after** the bot message. This behaviour is expected because the event anonymization and publishing +is done asynchronously as a background task, so it will complete after the assistant has already predicted and executed the +bot response. diff --git a/docs/docs/policies.mdx b/docs/docs/policies.mdx new file mode 100644 index 0000000..ee26d27 --- /dev/null +++ b/docs/docs/policies.mdx @@ -0,0 +1,1149 @@ +--- +id: policies +sidebar_label: Policies +title: Policies +abstract: Your assistant uses policies to decide which action to take at each step in a conversation. There are machine-learning and rule-based policies that your assistant can use in tandem. +--- +You can customize the policies your assistant uses by specifying the `policies` +key in your project's `config.yml`. +There are different policies to choose from, and you can include +multiple policies in a single configuration. Here's an example of +what a list of policies might look like: + +```yaml-rasa title="config.yml" +recipe: default.v1 +language: # your language +pipeline: + # - <pipeline components> + +policies: + - name: MemoizationPolicy + - name: TEDPolicy + max_history: 5 + epochs: 200 + - name: RulePolicy +``` +:::tip Starting from scratch? + +If you don't know which policies to choose, leave out the `policies` key from your `config.yml` completely. +If you do, the [Suggested Config](.//model-configuration.mdx#suggested-config) +feature will provide default policies for you. + +::: + +## Action Selection + +At every turn, each policy defined in your configuration will +predict a next action with a certain confidence level. For more information +about how each policy makes its decision, read into the policy's description below. +The policy that predicts with the highest confidence decides the assistant's next action. + +:::note Maximum number of predictions +By default, your assistant can predict a maximum of 10 next actions +after each user message. To update this value, +you can set the environment variable `MAX_NUMBER_OF_PREDICTIONS` +to the desired number of maximum predictions. + +::: + +### Policy Priority + +In the case that two policies predict with equal confidence (for example, the Memoization +and Rule Policies might both predict with confidence 1), the priority of the +policies is considered. Rasa policies have default priorities that are set to ensure the +expected outcome in the case of a tie. They look like this, where higher numbers have higher priority: + +<!-- We want to have high priority policies first; it's not possible to use a Markdown ordered list for that. --> + +* 6 - `RulePolicy` + +* 3 - `MemoizationPolicy` or `AugmentedMemoizationPolicy` + +* 2 - `UnexpecTEDIntentPolicy` + +* 1 - `TEDPolicy` + + +In general, it is not recommended to have more +than one policy per priority level in your configuration. If you have 2 policies with the same priority and they predict +with the same confidence, the resulting action will be chosen randomly. + +If you create your own policy, use these priorities as a guide for figuring out the priority of your policy. +If your policy is a machine learning policy, it should most likely have priority 1, the same as the `TEDPolicy`. + +:::warning overriding policy priorities +All policy priorities are configurable via the `priority` parameter in the policy's configuration, +but we **do not recommend** changing them outside of specific cases such as custom policies. +Doing so can lead to unexpected and undesired bot behavior. + +::: + + +## Machine Learning Policies + +### TED Policy + +The Transformer Embedding Dialogue (TED) Policy is +a multi-task architecture for next action prediction and entity +recognition. The architecture consists of several transformer encoders which are shared for both tasks. +A sequence of entity labels is predicted through a Conditional Random Field (CRF) tagging layer on top of the +user sequence transformer encoder output corresponding to the input sequence of tokens. +For the next action prediction, the dialogue transformer encoder output and the system action labels are embedded into a +single semantic vector space. We use the dot-product loss to maximize the similarity with the target label and +minimize similarities with negative samples. + +If you want to learn more about the model, check out +[our paper](https://arxiv.org/abs/1910.00486) and on our +[youtube channel](https://www.youtube.com/watch?v=j90NvurJI4I&list=PL75e0qA87dlG-za8eLI6t0_Pbxafk-cxb&index=14&ab_channel=Rasa). +where we explain the model architecture in detail. + +TED Policy architecture comprises the following steps: + +1. Concatenate features for + - user input (user intent and entities) or user text processed through a user sequence transformer encoder, + - previous system actions or bot utterances processed through a bot sequence transformer encoder, + - slots and active forms + + for each time step into an input vector to the embedding layer that precedes the + dialogue transformer. + +2. Feed the embedding of the input vector into the dialogue transformer encoder. + +3. Apply a dense layer to the output of the dialogue transformer to get embeddings of the dialogue for each time step. + +4. Apply a dense layer to create embeddings for system actions for each time step. + +5. Calculate the similarity between the dialogue embedding and embedded system actions. + This step is based on the [StarSpace](https://arxiv.org/abs/1709.03856) idea. + +6. Concatenate the token-level output of the user sequence transformer encoder + with the output of the dialogue transformer encoder for each time step. + +7. Apply CRF algorithm to predict contextual entities for each user text input. + +**Configuration:** + +You can pass configuration parameters to the `TEDPolicy` using the `config.yml` file. +If you want to fine-tune your model, start by modifying the following parameters: + +* `epochs`: + This parameter sets the number of times the algorithm will see the training data (default: `1`). + One `epoch` is equals to one forward pass and one backward pass of all the training examples. + Sometimes the model needs more epochs to properly learn. + Sometimes more epochs don't influence the performance. + The lower the number of epochs the faster the model is trained. + Here is how the config would look like: + + ```yaml-rasa title="config.yml" + policies: + - name: TEDPolicy + epochs: 200 + ``` + +* `max_history`: + This parameter controls how much dialogue history the model looks at to decide which + action to take next. Default `max_history` for this policy is `None`, + which means that the complete dialogue history since session restart is taken into + account. If you want to limit the model to only see a certain number of previous + dialogue turns, you can set `max_history` to a finite value. + Please note that you should pick `max_history` carefully, so that the model has enough + previous dialogue turns to create a correct prediction. + See [Featurizers](#featurizers) for more details. + Here is how the config would look like: + + ```yaml-rasa title="config.yml" + policies: + - name: TEDPolicy + max_history: 8 + ``` + +* `number_of_transformer_layers`: + This parameter sets the number of sequence transformer encoder layers to use for + sequential transformer encoders for user, action and action label texts and for + dialogue transformer encoder. + (defaults: `text: 1, action_text: 1, label_action_text: 1, dialogue: 1`). + The number of sequence transformer encoder layers corresponds + to the transformer blocks to use for the model. + +* `transformer_size`: + This parameter sets the number of units in the sequence transformer encoder layers to use for + sequential transformer encoders for user, action and action label texts and for + dialogue transformer encoder. + (defaults: `text: 128, action_text: 128, label_action_text: 128, dialogue: 128`). + The vectors coming out of the transformer encoders will have the given `transformer_size`. + +* `connection_density`: + This parameter defines the fraction of kernel weights that are set to non zero values for all feed forward + layers in the model (default: `0.2`). The value should be between 0 and 1. If you set `connection_density` + to 1, no kernel weights will be set to 0, the layer acts as a standard feed forward layer. You should not + set `connection_density` to 0 as this would result in all kernel weights being 0, i.e. the model is not able + to learn. + +* `split_entities_by_comma`: + This parameter defines whether adjacent entities separated by a comma should be treated as one, or split. For example, + entities with the type `ingredients`, like "apple, banana" can be split into "apple" and "banana". An entity with type + `address`, like "Schönhauser Allee 175, 10119 Berlin" should be treated as one. + + Can either be + `True`/`False` globally: + ```yaml-rasa title="config.yml" + policies: + - name: TEDPolicy + split_entities_by_comma: True + ``` + or set per entity type, such as: + ```yaml-rasa title="config.yml" + policies: + - name: TEDPolicy + split_entities_by_comma: + address: False + ingredients: True + ``` + +* `constrain_similarities`: + This parameter when set to `True` applies a sigmoid cross entropy loss over all similarity terms. + This helps in keeping similarities between input and negative labels to smaller values. + This should help in better generalization of the model to real world test sets. + +* `model_confidence`: + This parameter allows the user to configure how confidences are computed during inference. Currently, only one value is supported: + * `softmax`: Confidences are in the range `[0, 1]` (old behavior and current default). Computed similarities are normalized with the `softmax` activation function. + +* `use_gpu`: + This parameter defines whether a GPU (if available) will be used training. By default, `TEDPolicy` will be trained on GPU + if a GPU is available (i.e. `use_gpu` is `True`). To enforce that `TEDPolicy` uses only the CPU for training, set `use_gpu` to `False`. + +The above configuration parameters are the ones you should configure to fit your model to your data. +However, additional parameters exist that can be adapted. + +<details><summary>More configurable parameters</summary> + +``` ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| Parameter | Default Value | Description | ++=======================================+========================+==============================================================+ +| hidden_layers_sizes | text: [] | Hidden layer sizes for layers before the embedding layers | +| | action_text: [] | for user messages and bot messages in previous actions | +| | label_action_text: [] | and labels. The number of hidden layers is | +| | | equal to the length of the corresponding list. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| dense_dimension | text: 128 | Dense dimension for sparse features to use after they are | +| | action_text: 128 | converted into dense features. | +| | label_action_text: 128 | | +| | intent: 20 | | +| | action_name: 20 | | +| | label_action_name: 20 | | +| | entities: 20 | | +| | slots: 20 | | +| | active_loop: 20 | | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| concat_dimension | text: 128 | Common dimension to which sequence and sentence features of | +| | action_text: 128 | different dimensions get converted before concatenation. | +| | label_action_text: 128 | | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| encoding_dimension | 50 | Dimension size of embedding vectors | +| | | before the dialogue transformer encoder. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| transformer_size | text: 128 | Number of units in user text sequence transformer encoder. | +| | action_text: 128 | Number of units in bot text sequence transformer encoder. | +| | label_action_text: 128 | Number of units in bot text sequence transformer encoder. | +| | dialogue: 128 | Number of units in dialogue transformer encoder. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| number_of_transformer_layers | text: 1 | Number of layers in user text sequence transformer encoder. | +| | action_text: 1 | Number of layers in bot text sequence transformer encoder. | +| | label_action_text: 1 | Number of layers in bot text sequence transformer encoder. | +| | dialogue: 1 | Number of layers in dialogue transformer encoder. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| number_of_attention_heads | 4 | Number of self-attention heads in transformers. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| unidirectional_encoder | True | Use a unidirectional or bidirectional encoder | +| | | for `text`, `action_text`, and `label_action_text`. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| use_key_relative_attention | False | If 'True' use key relative embeddings in attention. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| use_value_relative_attention | False | If 'True' use value relative embeddings in attention. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| max_relative_position | None | Maximum position for relative embeddings. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| batch_size | [64, 256] | Initial and final value for batch sizes. | +| | | Batch size will be linearly increased for each epoch. | +| | | If constant `batch_size` is required, pass an int, e.g. `8`. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| batch_strategy | "balanced" | Strategy used when creating batches. | +| | | Can be either 'sequence' or 'balanced'. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| epochs | 1 | Number of epochs to train. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| random_seed | None | Set random seed to any 'int' to get reproducible results. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| learning_rate | 0.001 | Initial learning rate for the optimizer. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| embedding_dimension | 20 | Dimension size of dialogue & system action embedding vectors.| ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| number_of_negative_examples | 20 | The number of incorrect labels. The algorithm will minimize | +| | | their similarity to the user input during training. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| similarity_type | "auto" | Type of similarity measure to use, either 'auto' or 'cosine' | +| | | or 'inner'. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| loss_type | "cross_entropy" | The type of the loss function, either 'cross_entropy' | +| | | or 'margin'. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| ranking_length | 0 | Number of top actions to include in prediction. Confidences | +| | | of all other actions will be set to 0. Set to 0 to let the | +| | | prediction include confidences for all actions. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| renormalize_confidences | False | Normalize the top predictions. Applicable only with loss | +| | | type 'cross_entropy' and 'softmax' confidences. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| maximum_positive_similarity | 0.8 | Indicates how similar the algorithm should try to make | +| | | embedding vectors for correct labels. | +| | | Should be 0.0 < ... < 1.0 for 'cosine' similarity type. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| maximum_negative_similarity | -0.2 | Maximum negative similarity for incorrect labels. | +| | | Should be -1.0 < ... < 1.0 for 'cosine' similarity type. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| use_maximum_negative_similarity | True | If 'True' the algorithm only minimizes maximum similarity | +| | | over incorrect intent labels, used only if 'loss_type' is | +| | | set to 'margin'. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| scale_loss | True | Scale loss inverse proportionally to confidence of correct | +| | | prediction. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| regularization_constant | 0.001 | The scale of regularization. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| negative_margin_scale | 0.8 | The scale of how important it is to minimize the maximum | +| | | similarity between embeddings of different labels. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| drop_rate_dialogue | 0.1 | Dropout rate for embedding layers of dialogue features. | +| | | Value should be between 0 and 1. | +| | | The higher the value the higher the regularization effect. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| drop_rate_label | 0.0 | Dropout rate for embedding layers of label features. | +| | | Value should be between 0 and 1. | +| | | The higher the value the higher the regularization effect. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| drop_rate_attention | 0.0 | Dropout rate for attention. Value should be between 0 and 1. | +| | | The higher the value the higher the regularization effect. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| connection_density | 0.2 | Connection density of the weights in dense layers. | +| | | Value should be between 0 and 1. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| use_sparse_input_dropout | True | If 'True' apply dropout to sparse input tensors. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| use_dense_input_dropout | True | If 'True' apply dropout to sparse features after they are | +| | | converted into dense features. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| evaluate_every_number_of_epochs | 20 | How often to calculate validation accuracy. | +| | | Set to '-1' to evaluate just once at the end of training. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| evaluate_on_number_of_examples | 0 | How many examples to use for hold out validation set. | +| | | Large values may hurt performance, e.g. model accuracy. | +| | | Keep at 0 if your data set contains a lot of unique examples | +| | | of dialogue turns. | +| | | Set to 0 for no validation. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| tensorboard_log_directory | None | If you want to use tensorboard to visualize training | +| | | metrics, set this option to a valid output directory. You | +| | | can view the training metrics after training in tensorboard | +| | | via 'tensorboard --logdir <path-to-given-directory>'. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| tensorboard_log_level | "epoch" | Define when training metrics for tensorboard should be | +| | | logged. Either after every epoch ('epoch') or for every | +| | | training step ('batch'). | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| checkpoint_model | False | Save the best performing model during training. Models are | +| | | stored to the location specified by `--out`. Only the one | +| | | best model will be saved. | +| | | Requires `evaluate_on_number_of_examples > 0` and | +| | | `evaluate_every_number_of_epochs > 0` | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| e2e_confidence_threshold | 0.5 | The threshold that ensures that end-to-end is picked only if | +| | | the policy is confident enough. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| featurizers | [] | List of featurizer names (alias names). Only features | +| | | coming from the listed names are used. If list is empty | +| | | all available features are used. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| entity_recognition | True | If 'True' entity recognition is trained and entities are | +| | | extracted. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| constrain_similarities | False | If `True`, applies sigmoid on all similarity terms and adds | +| | | it to the loss function to ensure that similarity values are | +| | | approximately bounded. | +| | | Used only when `loss_type=cross_entropy`. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| model_confidence | "softmax" | Affects how model's confidence for each action | +| | | is computed. Currently, only one value is supported: | +| | | 1. `softmax` - Similarities between input and action | +| | | embeddings are post-processed with a softmax function, | +| | | as a result of which confidence for all labels sum up to 1. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| BILOU_flag | True | If 'True', additional BILOU tags are added to entity labels. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| split_entities_by_comma | True | Splits a list of extracted entities by comma to treat each | +| | | one of them as a single entity. Can either be `True`/`False` | +| | | globally, or set per entity type, such as: | +| | | ``` | +| | | - name: TEDPolicy | +| | | split_entities_by_comma: | +| | | address: True | +| | | ``` | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +``` + +:::note +The parameter `maximum_negative_similarity` is set to a negative value to mimic the original +starspace algorithm in the case `maximum_negative_similarity = maximum_positive_similarity` and +`use_maximum_negative_similarity = False`. See [starspace paper](https://arxiv.org/abs/1709.03856) +for details. + +::: + +</details> + +:::note +In addition to the config parameters above, `TEDPolicy` prediction performance and +training time are affected by the `--augmentation` argument of the `rasa train` +command. For more information see +[Data Augmentation](./policies.mdx#data-augmentation). + +::: + + +### UnexpecTED Intent Policy + +:::info New in 2.8 +This feature is experimental. +We introduce experimental features to get feedback from our community, so we encourage you to try it out! +However, the functionality might be changed or removed in the future. +If you have feedback (positive or negative) please share it with us on the [Rasa Forum](https://forum.rasa.com). + +::: + +`UnexpecTEDIntentPolicy` helps you review conversations and also allows your bot to react +to unlikely user turns. It is an auxiliary policy that should only be used in +conjunction with at least one other policy, as the only action that it can trigger +is the special [`action_unlikely_intent`](./default-actions.mdx#action_unlikely_intent) action. + +`UnexpecTEDIntentPolicy` has the same model architecture as [`TEDPolicy`](./policies.mdx#ted-policy). +The difference is at a task level. Instead of learning the best action to be triggered next, +`UnexpecTEDIntentPolicy` learns the set of intents that are most likely to be expressed by the user +given the conversation context from training stories. It uses the learned information at inference time by +checking if the predicted intent by NLU is the most likely intent. If the intent predicted +by NLU is indeed likely to occur given the conversation context, `UnexpecTEDIntentPolicy` does not trigger +any action. Otherwise, it triggers an [`action_unlikely_intent`](./default-actions.mdx#action_unlikely_intent) + with a confidence of `1.00`. + +`UnexpecTEDIntentPolicy` should be viewed as an aid for `TEDPolicy`. Since, `TEDPolicy` is expected to improve +with better coverage of unique conversation paths that the assistant is expected to handle in the training data, +`UnexpecTEDIntentPolicy` helps to surface these unique conversation paths from past conversations. For example, if you had +the following story in your training data: + +```yaml-rasa title="stories.yml" +stories: +- story: book_restaurant_table + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - action: restaurant_form + - active_loop: null + - slot_was_set: + - requested_slot: null +``` + +but an actual conversation might encounter interjections inside the form which you haven't accounted for: + +```yaml-rasa {11-14} title="stories.yml" +stories: +- story: actual_conversation + steps: + - user: | + I'm looking for a restaurant. + intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - slot_was_set: + - requested_slot: cuisine + - user: | + Does it matter? I want to be quick. + intent: deny +``` + +As soon as the `deny` intent gets triggered, the policy handling the form will keep requesting for the `cuisine` slot +to be filled, as the training stories don't say that this case should be treated differently. +To help you identify that a special story that handles the user's `deny` intent might be missing at this point, + `UnexpecTEDIntentPolicy` can trigger an `action_unlikely_intent` action right after `deny` intent. + Subsequently, you can improve your assistant by adding a new training story that handles this particular case. + +To reduce false warnings, `UnexpecTEDIntentPolicy` has two mechanisms in place at inference time: + +1. `UnexpecTEDIntentPolicy`'s [priority](./policies.mdx#policy-priority) is intentionally kept lower than all +[rule based policies](./policies.mdx#rule-based-policies) since rules may exist for situations that are novel for +`TEDPolicy` or `UnexpecTEDIntentPolicy`. + +2. `UnexpecTEDIntentPolicy` does not predict an `action_unlikely_intent` if the last predicted intent +isn't present in any of the training stories, which might happen if an intent is only used in rules. + +#### Prediction of `action_unlikely_intent` + +`UnexpecTEDIntentPolicy` is invoked immediately after a user utterance and can either +trigger `action_unlikely_intent` or abstain (in which case other policies will predict actions). +To determine if `action_unlikely_intent` should be triggered, `UnexpecTEDIntentPolicy` computes a score + for the user's intent in the current dialogue context and checks if this score is below a + certain threshold score. + + This threshold score is computed by collecting the ML model's output on many "negative examples". + These negative examples are combinations of dialogue contexts and user + intents that are _incorrect_. `UnexpecTEDIntentPolicy` generates these negative examples from your + training data by picking a random story part and pairing it with a random intent that doesn't + occur at this point. For example, if you had just one training story: + + ```rasa-yaml title="stories.yml" + version: 2.0 + stories: + - story: happy path 1 + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_goodbye + ``` + + and an intent `affirm`, then a valid negative example will be: + + ```rasa-yaml {7} title="negative_stories.yml" + version: 2.0 + stories: + - story: negative example with affirm unexpected + steps: + - intent: greet + - action: utter_greet + - intent: affirm + ``` + + Here, `affirm` intent is unexpected as it doesn't occur in this particular conversation context across all training stories. + For each intent, `UnexpecTEDIntentPolicy` uses these negative examples to figure out the range of scores the model + predicts. The threshold score is picked from this range of scores in such a way that the predicted score for a + certain percentage of negative examples is higher than the threshold score and hence `action_unlikely_intent` + is not triggered for them. This percentage of negative examples can be controlled by the `tolerance` parameter. + The higher the `tolerance`, the lower the intent's score (the more unlikely the intent) needs to be + before `UnexpecTEDIntentPolicy` triggers the `action_unlikely_intent` action. + +**Configuration:** + +You can pass configuration parameters to the `UnexpecTEDIntentPolicy` using the `config.yml` file. +If you want to fine-tune model's performance, start by modifying the following parameters: + +* `epochs`: + This parameter sets the number of times the algorithm will see the training data (default: `1`). + One `epoch` is equals to one forward pass and one backward pass of all the training examples. + Sometimes the model needs more epochs to learn properly. + Sometimes more epochs don't influence the performance. + The lower the number of epochs the faster the model is trained. + Here is how the config would look like: + + ```yaml-rasa title="config.yml" + policies: + - name: UnexpecTEDIntentPolicy + epochs: 200 + ``` + +* `max_history`: + This parameter controls how much dialogue history the model looks at before making an inference. + Default `max_history` for this policy is `None`, which means that the complete dialogue history + since session (re)start is taken into account. If you want to limit the model + to only see a certain number of previous + dialogue turns, you can set `max_history` to a finite value. + Please note that you should pick `max_history` carefully, so that the model has enough + previous dialogue turns to create a correct prediction. + Depending on your dataset, higher values of `max_history` can result in more frequent prediction of `action_unlikely_intent` + as the number of unique possible conversation paths increases as more dialogue context is taken + into account. Similarly, lowering the value of `max_history` can result in `action_unlikely_intent` being + triggered less often but can also be a stronger indicator that the corresponding conversation path + is highly unique and hence unexpected. + We recommend you to set the `max_history` of `UnexpecTEDIntentPolicy` equal to that of `TEDPolicy`. + Here is how the config would look like: + + ```yaml-rasa title="config.yml" + policies: + - name: UnexpecTEDIntentPolicy + max_history: 8 + ``` + +* `ignore_intents_list`: + This parameter lets you configure `UnexpecTEDIntentPolicy` to not predict `action_unlikely_intent` for + a subset of intents. You might want to do this if you come across a certain list of intents for which there + are too many false warnings generated. + +* `tolerance`: + The `tolerance` parameter is a number that ranges from `0.0` to `1.0` (inclusive). + It helps to adjust the threshold score used during + [prediction of `action_unlikely_intent`](./policies.mdx#prediction-of-action_unlikely_intent) + at inference time. + + Here, `0.0` means that the threshold score will be adjusted in such a way that `0%` of negative + examples encountered during training are predicted with a score lower than the threshold score. + Hence, conversation contexts from all negative examples will trigger an `action_unlikely_intent` action. + + A tolerance of `0.1` means that the threshold score will be adjusted in a way such that 10% of negative + examples encountered during training are predicted with a score lower than the threshold score. + + A tolerance of `1.0` means that the threshold score is so low that `UnexpecTEDIntentPolicy` would not + trigger `action_unlikely_intent` for any of the negative examples that it has encountered + during training. + +* `use_gpu`: + This parameter defines whether a GPU (if available) will be used training. By default, `UnexpecTEDIntentPolicy` will be trained on GPU + if a GPU is available (i.e. `use_gpu` is `True`). To enforce that `UnexpecTEDIntentPolicy` uses only the CPU for training, set `use_gpu` to `False`. + +The above configuration parameters are the ones you should try tweaking according to your use case and training data. +However, additional parameters exist that you could adapt. + +<details><summary>More configurable parameters</summary> + +``` ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| Parameter | Default Value | Description | ++=======================================+========================+==============================================================+ +| hidden_layers_sizes | text: [] | Hidden layer sizes for layers before the embedding layers | +| | | for user messages and bot messages in previous actions | +| | | and labels. The number of hidden layers is | +| | | equal to the length of the corresponding list. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| dense_dimension | text: 128 | Dense dimension for sparse features to use after they are | +| | intent: 20 | converted into dense features. | +| | action_name: 20 | | +| | label_intent: 20 | | +| | entities: 20 | | +| | slots: 20 | | +| | active_loop: 20 | | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| concat_dimension | text: 128 | Common dimension to which sequence and sentence features of | +| | | different dimensions get converted before concatenation. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| encoding_dimension | 50 | Dimension size of embedding vectors | +| | | before the dialogue transformer encoder. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| transformer_size | text: 128 | Number of units in user text sequence transformer encoder. | +| | dialogue: 128 | Number of units in dialogue transformer encoder. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| number_of_transformer_layers | text: 1 | Number of layers in user text sequence transformer encoder. | +| | dialogue: 1 | Number of layers in dialogue transformer encoder. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| number_of_attention_heads | 4 | Number of self-attention heads in transformers. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| unidirectional_encoder | True | Use a unidirectional or bidirectional encoder | +| | | for `text`, `action_text`, and `label_action_text`. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| use_key_relative_attention | False | If 'True' use key relative embeddings in attention. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| use_value_relative_attention | False | If 'True' use value relative embeddings in attention. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| max_relative_position | None | Maximum position for relative embeddings. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| batch_size | [64, 256] | Initial and final value for batch sizes. | +| | | Batch size will be linearly increased for each epoch. | +| | | If constant `batch_size` is required, pass an int, e.g. `8`. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| batch_strategy | "balanced" | Strategy used when creating batches. | +| | | Can be either 'sequence' or 'balanced'. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| epochs | 1 | Number of epochs to train. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| random_seed | None | Set random seed to any 'int' to get reproducible results. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| learning_rate | 0.001 | Initial learning rate for the optimizer. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| embedding_dimension | 20 | Dimension size of dialogue & system action embedding vectors.| ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| number_of_negative_examples | 20 | The number of incorrect labels. The algorithm will minimize | +| | | their similarity to the user input during training. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| ranking_length | 10 | Number of top actions to normalize scores for. Applicable | +| | | only with loss type 'cross_entropy' and 'softmax' | +| | | confidences. Set to 0 to disable normalization. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| scale_loss | True | Scale loss inverse proportionally to confidence of correct | +| | | prediction. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| regularization_constant | 0.001 | The scale of regularization. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| drop_rate_dialogue | 0.1 | Dropout rate for embedding layers of dialogue features. | +| | | Value should be between 0 and 1. | +| | | The higher the value the higher the regularization effect. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| drop_rate_label | 0.0 | Dropout rate for embedding layers of label features. | +| | | Value should be between 0 and 1. | +| | | The higher the value the higher the regularization effect. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| drop_rate_attention | 0.0 | Dropout rate for attention. Value should be between 0 and 1. | +| | | The higher the value the higher the regularization effect. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| use_sparse_input_dropout | True | If 'True' apply dropout to sparse input tensors. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| use_dense_input_dropout | True | If 'True' apply dropout to sparse features after they are | +| | | converted into dense features. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| evaluate_every_number_of_epochs | 20 | How often to calculate validation accuracy. | +| | | Set to '-1' to evaluate just once at the end of training. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| evaluate_on_number_of_examples | 0 | How many examples to use for hold out validation set. | +| | | Large values may hurt performance, e.g. model accuracy. | +| | | Keep at 0 if your data set contains a lot of unique examples | +| | | of dialogue turns. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| tensorboard_log_directory | None | If you want to use tensorboard to visualize training | +| | | metrics, set this option to a valid output directory. You | +| | | can view the training metrics after training in tensorboard | +| | | via 'tensorboard --logdir <path-to-given-directory>'. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| tensorboard_log_level | "epoch" | Define when training metrics for tensorboard should be | +| | | logged. Either after every epoch ('epoch') or for every | +| | | training step ('batch'). | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| checkpoint_model | False | Save the best performing model during training. Models are | +| | | stored to the location specified by `--out`. Only the one | +| | | best model will be saved. | +| | | Requires `evaluate_on_number_of_examples > 0` and | +| | | `evaluate_every_number_of_epochs > 0` | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| featurizers | [] | List of featurizer names (alias names). Only features | +| | | coming from the listed names are used. If list is empty | +| | | all available features are used. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| ignore_intents_list | [] | This parameter lets you configure `UnexpecTEDIntentPolicy` to ignore| +| | | the prediction of `action_unlikely_intent` for a subset of | +| | | intents. You might want to do this if you come across a | +| | | certain list of intents for which there are too many false | +| | | warnings generated. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +| tolerance | 0.0 | The `tolerance` parameter is a number that ranges from `0.0` | +| | | to `1.0` (inclusive). It helps to adjust the threshold score | +| | | used during prediction of `action_unlikely_intent` at | +| | | inference time. Here, `0.0` means that the score threshold | +| | | is the one that `UnexpecTEDIntentPolicy` had determined at training | +| | | time. A tolerance of `1.0` means that the threshold score | +| | | is so low that `IntentTED` would not trigger | +| | | `action_unlikely_intent` for any of the "negative examples" | +| | | that it has encountered during training. These negative | +| | | examples are combinations of dialogue contexts and user | +| | | intents that are _incorrect_. `UnexpecTEDIntentPolicy` generates | +| | | these negative examples from your training data by picking a | +| | | random story part and pairing it with a random intent that | +| | | doesn't occur at this point. | ++---------------------------------------+------------------------+--------------------------------------------------------------+ +``` + +</details> + +#### Tuning the tolerance parameter + +When [reviewing real conversations](./conversation-driven-development.mdx#review), we encourage you +to tune the `tolerance` parameter in `UnexpecTEDIntentPolicy`'s configuration to reduce the number +of false warnings (intents that actually are likely given the conversation context). +As you increase the value of `tolerance` from `0` to `1` in steps of `0.05`, +the number of false warnings should decrease. However, increasing the `tolerance` will +also result in fewer triggers of `action_unlikely_intent` and hence more conversation +paths not present in training stories will be missing in the set of flagged conversations. +If you change the `max_history` value and retrain a model, you might have to re-adjust the `tolerance` value as well. + +:::note +`UnexpecTEDIntentPolicy` is only trained on [stories](./stories.mdx) and not [rules](./rules.mdx) from the training data. + +::: + +### Memoization Policy + +The `MemoizationPolicy` remembers the stories from your +training data. It checks if the current conversation matches the stories in your +`stories.yml` file. If so, it will predict the next action from the matching +stories of your training data with a confidence of `1.0`. If no matching conversation +is found, the policy predicts `None` with confidence `0.0`. + +When looking for a match in your training data, the policy will take the last +`max_history` number of turns of the conversation into account. +One “turn” includes the message sent by the user and any actions the +assistant performed before waiting for the next message. + +You can configure the number of turns the `MemoizationPolicy` should use in your +configuration: +```yaml title="config.yml" +policies: + - name: "MemoizationPolicy" + max_history: 3 +``` + + +### Augmented Memoization Policy + +The `AugmentedMemoizationPolicy` remembers examples from training +stories for up to `max_history` turns, just like the `MemoizationPolicy`. +Additionally, it has a forgetting mechanism that will forget a certain amount +of steps in the conversation history and try to find a match in your stories +with the reduced history. It predicts the next action with confidence `1.0` +if a match is found, otherwise it predicts `None` with confidence `0.0`. + +:::note Slots and predictions +If you have dialogues where some slots that are set during +prediction time might not be set in training stories (e.g. in training +stories starting with a [reminder](./reaching-out-to-user.mdx#reminders), not all previous slots are set), +make sure to add the relevant stories without slots to your training +data as well. + +::: + + +## Rule-based Policies + +### Rule Policy + +The `RulePolicy` is a policy that handles conversation parts that follow +a fixed behavior (e.g. business logic). It makes predictions based on +any `rules` you have in your training data. See the +[Rules documentation](./rules.mdx) for further information on how to define rules. + +The `RulePolicy` has the following configuration options: + +```yaml title="config.yml" +policies: + - name: "RulePolicy" + core_fallback_threshold: 0.3 + core_fallback_action_name: action_default_fallback + enable_fallback_prediction: true + restrict_rules: true + check_for_contradictions: true +``` + +* `core_fallback_threshold` (default: `0.3`): Please see the + [fallback documentation](fallback-handoff.mdx#handling-low-action-confidence) for + further information. +* `core_fallback_action_name` (default: `action_default_fallback`): Please see the + [fallback documentation](fallback-handoff.mdx#handling-low-action-confidence) for + further information. +* `enable_fallback_prediction` (default: `true`): Please see the + [fallback documentation](fallback-handoff.mdx#handling-low-action-confidence) for + further information. +* `check_for_contradictions` (default: `true`): + Before training, the RulePolicy will perform a check to make sure that + slots and active loops set by actions are defined consistently for all rules. + The following snippet contains an example of an incomplete rule: + + ```yaml-rasa + rules: + - rule: complete rule + steps: + - intent: search_venues + - action: action_search_venues + - slot_was_set: + - venues: [{"name": "Big Arena", "reviews": 4.5}] + + - rule: incomplete rule + steps: + - intent: search_venues + - action: action_search_venues + ``` + + In the second `incomplete rule`, `action_search_venues` should set + the `venues` slot because it is set in `complete rule`, but this event is missing. + There are several possible ways to fix this rule. + + In the case when `action_search_venues` can't find + a venue and the `venues` slot should not be set, + you should explicitly set the value of the slot to `null`. + In the following story `RulePolicy` will predict `utter_venues_not_found` + only if the slot `venues` is not set: + + ```yaml-rasa + rules: + - rule: fixes incomplete rule + steps: + - intent: search_venues + - action: action_search_venues + - slot_was_set: + - venues: null + - action: utter_venues_not_found + ``` + + If you want the slot setting to be handled by a different rule or story, + you should add `wait_for_user_input: false` to the end of the rule snippet: + + ```yaml-rasa + rules: + - rule: incomplete rule + steps: + - intent: search_venues + - action: action_search_venues + wait_for_user_input: false + ``` + + After training, the RulePolicy will check that none of the rules or stories contradict + each other. The following snippet is an example of two contradicting rules: + + ```yaml-rasa + rules: + - rule: Chitchat + steps: + - intent: chitchat + - action: utter_chitchat + + - rule: Greet instead of chitchat + steps: + - intent: chitchat + - action: utter_greet # `utter_greet` contradicts `utter_chitchat` from the rule above + ``` + * `restrict_rules` (default: `true`): Rules are restricted to one user turn, but + there can be multiple bot events, including e.g. a form being filled and its subsequent submission. + Changing this parameter to `false` may result in unexpected behavior. + + :::caution Overusing rules + Overusing rules for purposes outside of the [recommended use cases](rules.mdx) + will make it very hard to maintain your assistant as the complexity grows. + + ::: + +## Configuring Policies + +### Max History + +One important hyperparameter for Rasa policies is the `max_history`. +This controls how much dialogue history the model looks at to decide which +action to take next. + +You can set the `max_history` by passing it to your policy +in the policy configuration in your `config.yml`. +The default value is `None`, which means that the complete dialogue history since session +restart is taken in the account. + +```yaml-rasa title="config.yml" {3} +policies: + - name: TEDPolicy + max_history: 5 + epochs: 200 + batch_size: 50 + max_training_samples: 300 +``` + +:::note +`RulePolicy` doesn't have max history parameter, it always consider the full length +of provided rules. Please see [Rules](./rules.mdx) for further information. +::: + +As an example, let's say you have an `out_of_scope` intent which +describes off-topic user messages. If your bot sees this intent multiple +times in a row, you might want to tell the user what you can help them +with. So your story might look like this: + +```yaml-rasa +stories: + - story: utter help after 2 fallbacks + steps: + - intent: out_of_scope + - action: utter_default + - intent: out_of_scope + - action: utter_default + - intent: out_of_scope + - action: utter_help_message +``` + +For your model to learn this pattern, the `max_history` +has to be at least 4. + +If you increase your `max_history`, your model will become bigger and +training will take longer. If you have some information that should +affect the dialogue very far into the future, you should store it as a +slot. Slot information is always available for every featurizer. + +### Data Augmentation + +When you train a model, Rasa will create +longer stories by randomly combining +the ones in your stories files. +Take the stories below as an example: + +```yaml-rasa +stories: + - story: thank + steps: + - intent: thankyou + - action: utter_youarewelcome + - story: say goodbye + steps: + - intent: goodbye + - action: utter_goodbye +``` + +You actually want to teach your policy to **ignore** the dialogue history +when it isn't relevant and to respond with the same action no matter +what happened before. To achieve this, individual stories are +concatenated into longer stories. From the example above, data augmentation +might produce a story by combining `thank` with `say goodbye` and then `thank` again, +equivalent to: + +```yaml-rasa +stories: + - story: thank -> say goodbye -> thank + steps: + - intent: thankyou + - action: utter_youarewelcome + - intent: goodbye + - action: utter_goodbye + - intent: thankyou + - action: utter_youarewelcome +``` + +You can alter this behavior with the `--augmentation` flag, +which allows you to set the `augmentation_factor`. +The `augmentation_factor` determines how many augmented stories are +subsampled during training. The augmented stories are subsampled before training +since their number can quickly become very large, and you want to limit it. +The number of sampled stories is `augmentation_factor` x10. +By default `augmentation_factor` is set to 50, resulting in a maximum of 500 augmented stories. + +`--augmentation 0` disables all augmentation behavior. `TEDPolicy` is the **only** policy +affected by augmentation. Other policies like `MemoizationPolicy` or `RulePolicy` +automatically ignore all augmented stories (regardless of the `augmentation_factor`). + +`--augmentation` is an important parameter when trying to reduce `TEDPolicy` training +time. Reducing the `augmentation_factor` decreases the size of the training data +and subsequently the time to train the policy. However, reducing the amount of data +augmentation can also reduce the performance of `TEDPolicy`. We recommend using +a memoization based policy along with `TEDPolicy` when reducing the amount of data +augmentation to compensate. + +### Featurizers + +In order to apply machine learning algorithms to conversational AI, you need +to build up vector representations of conversations. + +Each story corresponds to a tracker which consists of the states of the +conversation just before each action was taken. + +#### State Featurizers + +Every event in a trackers history creates a new state (e.g. running a bot +action, receiving a user message, setting slots). Featurizing a single state +of the tracker has two steps: + +1. **Tracker provides a bag of active features**: + + * features indicating intents and entities, if this is the first + state in a turn, e.g. it's the first action we will take after + parsing the user's message. (e.g. + `[intent_restaurant_search, entity_cuisine]` ) + + * features indicating which slots are currently defined, e.g. + `slot_location` if the user previously mentioned the area + they're searching for restaurants. + + * features indicating the results of any API calls stored in + slots, e.g. `slot_matches` + + * features indicating what the last bot action or bot utterance was (e.g. + `prev_action_listen`) + + * features indicating if any loop is active and which one + +2. **Convert all the features into numeric vectors**: + + `SingleStateFeaturizer` uses the Rasa NLU pipeline to convert the intent and + bot action names or bot utterances into numeric vectors. + See the [NLU Model Configuration](./model-configuration.mdx) documentation + for the details on how to configure Rasa NLU pipeline. + + Entities, slots and active loops are featurized as one-hot encodings + to indicate their presence. + +:::note +If the domain defines the possible `actions`, +`[ActionGreet, ActionGoodbye]`, +4 additional default actions are added: +`[ActionListen(), ActionRestart(), +ActionDefaultFallback(), ActionDeactivateForm()]`. +Therefore, label `0` indicates default action listen, label `1` +default restart, label `2` a greeting and `3` indicates goodbye. + +::: + +#### Tracker Featurizers + +A policy can be trained to learn two kinds of labels - + +1. The next most appropriate action to be triggered by the assistant. For example, [`TEDPolicy`](#ted-policy) is trained to do this. +2. The next most likely intent that a user can express. For example, [`UnexpecTEDIntentPolicy`](#unexpected-intent-policy) is trained to learn this. + +Hence, a tracker can be featurized to learn one of the labels mentioned above. +Depending on the policy, the target labels correspond to bot actions or bot utterances +represented as an index in a list of all possible actions or +set of intents represented as an index in a list of all possible intents. + +Tracker Featurizers come in three different flavours: + +##### 1. Full Dialogue + +`FullDialogueTrackerFeaturizer` creates a numerical representation of +stories to feed to a recurrent neural network where the whole dialogue +is fed to a network and the gradient is backpropagated from all time steps. +The target label is the most appropriate bot action or bot utterance which should be triggered in the +context of the conversation. +The `TrackerFeaturizer` iterates over tracker +states and calls a `SingleStateFeaturizer` for each state to create numeric input features for a policy. + + +##### 2. Max History + +`MaxHistoryTrackerFeaturizer` operates very similarly to `FullDialogueTrackerFeaturizer` as +it creates an array of previous tracker states for each bot action or bot utterance but with the parameter +`max_history` defining how many states go into each row of input features. +If `max_history` is not specified, the algorithm takes +the whole length of a dialogue into account. +Deduplication is performed to filter out duplicated turns (bot actions +or bot utterances) in terms of their previous states. + +For some algorithms a flat feature vector is needed, so input features +should be reshaped to `(num_unique_turns, max_history * num_input_features)`. + +##### 3. Intent Max History + +`IntentMaxHistoryTrackerFeaturizer` inherits from `MaxHistoryTrackerFeaturizer`. Since, it is used by +[`UnexpecTEDIntentPolicy`](#unexpected-intent-policy), the target labels that it creates are the intents that can be +expressed by a user in the context of a conversation tracker. Unlike +other tracker featurizers, there can be multiple target labels. Hence, it pads the +list of target labels with a constant value (`-1`) on the right to return an equally sized list of target labels +for each input conversation tracker. + +Just like `MaxHistoryTrackerFeaturizer`, it also performs deduplication to +filter out duplicated turns. However, it yields one featurized tracker per correct intent +for the corresponding tracker. For example, if the correct labels for an input conversation tracker have the following +indices - `[0, 2, 4]`, then the featurizer will yield three pairs of featurized trackers and target labels. +The featurized trackers will be identical to each other but the target labels in each pair will be +`[0, 2, 4]`, `[4, 0, 2]`, `[2, 4, 0]`. + + +## Custom Policies + +:::info New in 3.0 +Rasa 3.0 unified the implementation of NLU components and policies. +This requires changes to custom policies written for earlier versions of Rasa Open +Source. Please see the +[migration guide](migration-guide.mdx#custom-policies-and-custom-components) for a +step-by-step guide for the migration. + +::: + + +You can also write custom policies and reference them in your configuration. In the example below, the +last two lines show how to use a custom policy class and pass arguments to it. +See the [guide on custom graph components](custom-graph-components.mdx) for a complete guide on custom policies. + +```yaml-rasa {9-10} +policies: + - name: "TEDPolicy" + max_history: 5 + epochs: 200 + - name: "RulePolicy" + - name: "path.to.your.policy.class" + arg1: "..." +``` + diff --git a/docs/docs/rasa-pro-changelog.mdx b/docs/docs/rasa-pro-changelog.mdx new file mode 100644 index 0000000..164b542 --- /dev/null +++ b/docs/docs/rasa-pro-changelog.mdx @@ -0,0 +1,97 @@ +--- +id: rasa-pro-changelog +sidebar_label: Rasa Pro Change Log +title: Rasa Pro Change Log +--- + +All notable changes to Rasa Pro will be documented in this page. +This product adheres to [Semantic Versioning](https://semver.org/) starting with version 3.3 (initial version). + +Rasa Pro consists of two deployable artifacts: Rasa Plus and Rasa Pro Services. You can read the change log for both artifacts below. + +You can find all changes related to Rasa Open Source in the dedicated [Rasa Open Source change log](./changelog.mdx). + +## Rasa Pro 3.6 + +### Rasa Plus 3.6 + +#### Features +- Implemented PII (Personally Identifiable Information) management using Microsoft Presidio as the entity analyzer and + anonymization engine. + The feature covers the following: + - anonymization of Rasa events (`UserUttered`, `BotUttered`, `SlotSet`, `EntitiesAdded`) before they are streamed to + Kafka event broker anonymization topics specified in `endpoints.yml`. + - anonymization of Rasa logs that expose PII data + + The main components of the feature are: + - anonymization rules that define in `endpoints.yml` the PII entities to be anonymized and the anonymization method to be used + - anonymization executor that executes the anonymization rules on a given text + - anonymization orchestrator that orchestrates the execution of the anonymization rules and publishes + the anonymized event to the matched Kafka topic. + - anonymization pipeline that contains a list of orchestrators and is registered to a singleton provider component, + which gets invoked in hook calls in Rasa Open Source when the pipeline must be retrieved for anonymizing events and logs. + + Please read through the [PII Management](./pii-management.mdx) section in the official documentation to learn how to get started. + +- Implemented support for real time evaluation of [Markers](./monitoring/analytics/realtime-markers.mdx) with the Analytics + Data Pipeline. For this feature, we've added support for `rasa markers upload` command. Running this command validates the marker configuration file against the domain file and uploads the configuration to Analytics Data Pipeline. + +### Improvements +- Add `rasa marker upload` command to upload markers to the Rasa Pro Services. + +- Enhance the validation of the `anonymization` key in `endpoints.yaml` by introducing checks for required fields and duplicate IDs. + +### Bugfixes +- Anonymize `rasa_events` structlog key. +- Fixes issue with uploading locally trained model to a cloud rasa-plus instance where the conversation does not + go as expected because slots don't get set correctly, e.g. an error is logged "Tried to set non existent slot 'placeholder_slot_name'." Make sure you added all your slots to your domain file. + This is because the updated domain during the cloud upload did not get passed to the wrapped tracker store of the `AuthRetryTrackerStore` rasa-plus component. + The fix was to add domain property and setter methods to the `AuthRetryTrackerStore` component. +- When using `rasa studio upload`, if no specific `intents` or `entities` are specified by the user, the update will now include all available `intents` or `entities`. + +#### Deprecations and Removal +- Removed Python 3.7 support as [it reaches its end of life in June 2023](https://devguide.python.org/versions/) + +### Rasa Pro Services 3.1 + +#### Features +- You can now process [Markers](./monitoring/analytics/realtime-markers.mdx) with the Analytics Data Pipeline in real-time, enabling you to gain valuable insights and improve the performance of your Rasa Assistant. + +## Rasa Pro 3.5 + +### Rasa Plus 3.5 + +#### Features +- [End-to-end testing](./testing-your-assistant.mdx#end-to-end-testing) is an enhanced and comprehensive CLI-based testing tool that allows you to test conversation scenarios with different pre-configured contexts, execute custom actions, verify response texts or names, and assert when slots are filled. It is available using the new `rasa test e2e` command. +- You can now store your assistant's secrets in an [external credentials manager](./secrets-managers.mdx). In this release, Rasa Pro currently supports credentials manager for the Tracker Store with HashiCorp Vault. + +### Rasa Pro Services 3.0 + +*No significant change from last minor version.* + +## Rasa Pro 3.4 + +### Rasa Plus 3.4 + +#### Features +- Added a new [IVR channel connector](./connectors/audioodes-voiceai-connect.mdx) to connect your assistant to AudioCodes VoiceAI Connect. + +#### Improvements +- Rasa Pro now supports Python 3.10. + +### Rasa Pro Services 3.0 + +*No significant change from last minor version.* + +## Rasa Pro 3.3 + +### Rasa Plus 3.3 + +#### Features +- [Tracing capabilities](./monitoring/tracing.mdx) for your Rasa Pro assistant. Distributed tracing tracks requests as they flow through a distributed system (in this case: a Rasa assistant), sending data about the requests to a tracing backend which collects all trace data and enables inspecting it. With this version of the Tracing feature, Rasa Pro supports OpenTelemetry. +- [Concurrent Lock Store](./lock-stores.mdx#concurrentredislockstore) is a new lock store that uses Redis as a persistence layer and is safe for use with multiple Rasa server replicas. + +### Rasa Pro Services 3.0 + +#### Features +- [Analytics Data Pipeline](./monitoring/analytics/getting-started-with-analytics.mdx) helps visualize and process Rasa assistant metrics in the tooling (BI tools, data warehouses) of your choice. Visualizations and analysis of the production assistant and its conversations allow you to assess ROI and improve the performance of the assistant over time. diff --git a/docs/docs/rasa-pro.mdx b/docs/docs/rasa-pro.mdx new file mode 100644 index 0000000..b1ab335 --- /dev/null +++ b/docs/docs/rasa-pro.mdx @@ -0,0 +1,73 @@ +--- +id: rasa-pro +sidebar_label: Rasa Pro +title: Rasa Pro +hide_table_of_contents: true +--- + +Rasa Pro is the commercial, pro-code offering of Rasa built to address enterprise needs +around security, observability and scale. + +Rasa Pro integrates seamlessly with enterprise technology stacks and is the recommended platform +for all Rasa enterprise customers. +To read more about installing Rasa Pro, click [here](./installation/rasa-pro/installation.mdx). + +## Rasa Pro Features + +### Analytics with Conversational Data Pipeline + +Visualise Rasa metrics in a third-party tool to measure the performance of your assistant. + +[Read more here](./monitoring/analytics/getting-started-with-analytics.mdx). + +### Concurrent Lock Store + +Scale deployment and reliably handle high volumes of traffic across multiple Rasa instances with the confidence that no messages will be dropped. + +[Read more here](./lock-stores.mdx#concurrentredislockstore). + +### End-to-End Testing + +Test your assistant with our end-to-end testing solution designed to meet enterprise-grade integration and acceptance testing criteria. + +[Read more here](./testing-your-assistant.mdx#end-to-end-testing). + +### IVR Voice Connector + +Integrate with best-in-class IVR systems through our OOTB voice connectors. + +[Read more here](./connectors/audioodes-voiceai-connect.mdx). + +### Observability (Tracing) + +Resolve performance issues faster and identify bottlenecks in message handling and model training. + +[Read more here](./monitoring/tracing.mdx). + +### PII Handling + +Anonymize PII (Personal Identifiable Information) in logs and events streamed via the Kafka event broker. + +[Read more here](./pii-management.mdx) + +### Real-Time Markers + +Mark points of interest in conversations to support the targeted analysis of user journeys real time. + +[Read more here](./monitoring/analytics/realtime-markers.mdx) + +### Secrets Management + +Enhance security with our seamless Vault integration, enabling dynamic credential rotation for Rasa databases without system disruptions. + +[Read more here](./secrets-managers.mdx). + +### Security Scanning for Vulnerability Protection + +Nightly and proactive security patches on your docker image to make sure dependencies are always up to date. + +### Spaces (Alpha Release) + +Modularize your assistant for better scaling and team collaboration. + +[Read more here](./spaces.mdx) diff --git a/docs/docs/reaching-out-to-user.mdx b/docs/docs/reaching-out-to-user.mdx new file mode 100644 index 0000000..66dfe16 --- /dev/null +++ b/docs/docs/reaching-out-to-user.mdx @@ -0,0 +1,438 @@ +--- +id: reaching-out-to-user +sidebar_label: Reaching Out to the User +title: Reaching Out to the User +abstract: Sometimes you want your assistant to reach out to the user without the user's prompting. For example, you might want the assistant to send a message when the user opens the chat window, or you might want to prompt the user if they haven't sent a message for a while. This page is a guide to enabling your assistant to reach out to the user proactively. +--- + + +## Reaching out first + +In most use cases, when the user opens the chat window with your assistant, you will want the +assistant to send the first message. Doing this can give the user an idea of what the bot +can or can't do and set them up to have a more successful conversation. +Some [messaging or voice channels](./messaging-and-voice-channels.mdx) have existing configuration +options to send a payload to the assistant when the user first starts the conversation, +but you can also add this option to your [own custom channel](./connectors/your-own-website.mdx). + + +Once you've configured your channel to send a payload, you will need to specify how the +assistant should react and greet the user. You can either re-use an existing intent's behavior +for this, or specify a new intent and rule for this. Below is a guide on how to specify a +welcome rule. + +### 1. Update the configuration + +Since you are using a rule for this behavior, you need to add the [RulePolicy](./policies.mdx#rule-policy) +to your configuration file: + +```yaml-rasa title="config.yml" +policies: + # other policies + - name: RulePolicy +``` + +### 2. Add a rule + +To have the assistant respond to the intent `greet` with a welcome message +only at the beginning of a conversation, add the following rule: + +```yaml-rasa title="rules.yml" +rules: + - rule: welcome user + conversation_start: true # this rule only applies at the beginning of a conversation + steps: + - intent: greet + - action: utter_welcome +``` + +### 3. Add a response + +Finally, add a response for the `utter_welcome` utter action to your domain: + +```yaml-rasa title="domain.yml" +responses: + utter_welcome: + - text: Hi there! What can I help you with today? +``` + +## External Events + +Sometimes you want an external device to change the course of an ongoing conversation. +For example, if you have a moisture-sensor attached to a Raspberry Pi, you could use it to notify +you when a plant needs watering via your assistant. + +The examples below are from the [reminderbot example bot](https://github.com/RasaHQ/rasa/blob/main/examples/reminderbot), +which includes both reminders and external events. + +### 1. Trigger an Intent + +To have an event from an external device change the course of an ongoing conversation, you can +have the device post to the +[`trigger_intent` endpoint](/pages/http-api#operation/triggerConversationIntent) of your conversation. +The `trigger_intent` endpoint injects a user intent (possibly with entities) into your conversation. +For Rasa, it is as if you entered a message that got classified with that specific intent and entities. +The assistant will then predict and execute the next action as usual. + + +For example, the following post request would inject the intent `EXTERNAL_dry_plant` and the `plant` entity +into the conversation with id `user123`: + +``` +curl -H "Content-Type: application/json" -X POST \ + -d '{"name": "EXTERNAL_dry_plant", "entities": {"plant": "Orchid"}}' \ + "http://localhost:5005/conversations/user123/trigger_intent?output_channel=latest" +``` + +### 2. Get the Conversation ID + +In a real-life scenario, your external device would get the conversation ID from an API or a database. +In the dry plant example, you might have a database of plants, the users that water them, and the users' +conversation IDs. Your Raspberry Pi would get the conversation ID directly from the database. +To try out the reminderbot example locally, you'll need to get the conversation ID manually. See +the reminderbot [README](https://github.com/RasaHQ/rasa/blob/main/examples/reminderbot) for more information. + +### 3. Add NLU Training Data + +In the dry plant example, your Raspberry Pi needs to send a message with the intent +`EXTERNAL_dry_plant` to the `trigger_intent` endpoint. This intent will be reserved for use by the Raspberry Pi, so +there won't be any NLU training examples for it. + +```yaml-rasa title="domain.yml" +intents: + - EXTERNAL_dry_plant +``` + +:::note +You should name intents that come from other devices with the `EXTERNAL_` prefix because it makes it +easier to see which intents are expected to come from external devices when working with your training data. +::: + +### 4. Update the Domain + +To tell the assistant which plant needs watering, you can define an entity that you'll post along with the intent. +To be able to use the entity value directly in a response, define a `from_entity` slot mapping for the `plant` slot: + +```yaml-rasa title="domain.yml" +entities: + - plant + +slots: + plant: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: plant +``` + +#### 5. Add a Rule + +You'll need a rule that tells your assistant how to respond when it receives a message from the Raspberry Pi. + +```yaml-rasa title="rules.yml" +rules: + - rule: warn about dry plant + steps: + - intent: EXTERNAL_dry_plant + - action: utter_warn_dry +``` + +#### 6. Add a Response + +You'll need to define the response text for `utter_warn_dry`: + +```yaml-rasa title="domain.yml" +responses: + utter_warn_dry: + - text: "Your {plant} needs some water!" +``` + +The response will use the value from the slot `plant` to warn about the specific plant that needs watering. + +### Try it out + +To try out the dry plant notification example, you'll need to start a [CallbackChannel](./connectors/your-own-website.mdx#callbackinput). + +:::caution +External Events and Reminders don't work in request-response channels like the `rest` channel or `rasa shell`. +Custom connectors for assistants implementing reminders or external events should be built +off of the [CallbackInput channel](./connectors/your-own-website.mdx#callbackinput) instead of the RestInput channel. + +See the [reminderbot README](https://github.com/RasaHQ/rasa/blob/main/examples/reminderbot/README.md) +for instructions on how to test your reminders locally. +::: + +Run this POST request to simulate the external event, using your conversation ID: + +```bash +curl -H "Content-Type: application/json" -X POST -d \ +'{"name": "EXTERNAL_dry_plant", "entities": {"plant": "Orchid"}}' \ +"http://localhost:5005/conversations/user1234/trigger_intent?output_channel=latest" +``` + +You should see the bot respond in your channel: + +<Chat caption="A reminder"> + <ChatBotText>Your Orchid needs some water!</ChatBotText> +</Chat> + +## Reminders + +You can have your assistant reach out to the user after a set amount of time by using [Reminders](./action-server/events.mdx#reminder). +The examples below are from the [reminderbot example bot](https://github.com/RasaHQ/rasa/blob/main/examples/reminderbot). +You can clone it and follow the instructions in `README` to try out the full version. + +### Scheduling Reminders + +#### 1. Define a Reminder + +To schedule a reminder, you need to define a custom action that returns +the `ReminderScheduled` event. For example, the following custom action +schedules a reminder for five minutes from now: + +```python title="actions.py" +import datetime +from rasa_sdk.events import ReminderScheduled +from rasa_sdk import Action + +class ActionSetReminder(Action): + """Schedules a reminder, supplied with the last message's entities.""" + + def name(self) -> Text: + return "action_set_reminder" + + async def run( + self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> List[Dict[Text, Any]]: + + dispatcher.utter_message("I will remind you in 5 minutes.") + + date = datetime.datetime.now() + datetime.timedelta(minutes=5) + entities = tracker.latest_message.get("entities") + + reminder = ReminderScheduled( + "EXTERNAL_reminder", + trigger_date_time=date, + entities=entities, + name="my_reminder", + kill_on_user_message=False, + ) + + return [reminder] +``` + +The first argument for the `ReminderScheduled` event is the reminder's name, in this case, `EXTERNAL_reminder`. +The reminder name will be used later as an intent to trigger a reaction to the reminder. +Name the reminder name with the +`EXTERNAL_` prefix to make it easier to see what's going on in your training data. + +You can see that the last messages' `entities` are also passed to the reminder. +This allows the action that reacts to the reminder to make use of the entities +from the user's scheduling message. + +For example, if you want your assistant to remind you to call a friend, you could +send it a message like "Remind me to call Paul". If "Paul" is extracted as a `PERSON` +entity, the action reacting to the reminder can use it to say "Remember to call Paul!" + +#### 2. Add a Rule + +To schedule a reminder, add a rule: + +```yaml-rasa title="rules.yml" +rules: +- rule: Schedule a reminder + steps: + - intent: ask_remind_call + entities: + - PERSON + - action: action_set_reminder +``` + + +#### 3. Add Training Data + +You should add NLU training examples for scheduling the reminder: + +```yaml-rasa title="nlu.yml" +nlu: +- intent: ask_remind_call + examples: | + - remind me to call John + - later I have to call Alan + - Please, remind me to call Vova + - please remind me to call Tanja + - I must not forget to call Juste +``` + +You should also add it to your domain: + +```yaml-rasa title="domain.yml" +intents: + - ask_remind_call +``` + +#### 4. Update your Pipeline + +By adding SpacyNLP and SpacyEntityExtractor to your pipeline in config.yml, you won't need to annotate any of the +names in your training data, since Spacy has a `PERSON` dimension: + +```yaml-rasa title="config.yml" +pipeline: +# other components +- name: SpacyNLP + model: "en_core_web_md" +- name: SpacyEntityExtractor + dimensions: ["PERSON"] +``` + + +### Reacting to Reminders + +#### 1. Define a Reaction + +The bot reaches out to the user after receiving a +POST request to the `trigger_intent` endpoint. Reminders, however, send +the request to the right conversation ID automatically after a certain amount of time using +the name that you define in the `ReminderScheduled` event. + +To define a reaction to the reminder, you only need to write a [rule](./rules.mdx) that +tells the bot what action to take when it receives the reminder intent. + +In the call reminder example, you want to use the entities that come with the +reminder to be reminded to call specific people, so you need to write a custom +action that does that: + +```python title="actions.py" +class ActionReactToReminder(Action): + """Reminds the user to call someone.""" + + def name(self) -> Text: + return "action_react_to_reminder" + + async def run( + self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> List[Dict[Text, Any]]: + + name = next(tracker.get_slot("PERSON"), "someone") + dispatcher.utter_message(f"Remember to call {name}!") + + return [] +``` + +#### 2. Add a Rule + +To tell your bot what action to run when a reminder is triggered, add a rule. + +```yaml-rasa title="rules.yml" +rules: +- rule: Trigger `action_react_to_reminder` for `EXTERNAL_reminder` + steps: + - intent: EXTERNAL_reminder + - action: action_react_to_reminder +``` + +#### 3. Add Training Data + +You'll need to define the intent that triggers reacting to the reminder. You don't need to add any training examples, +since the intent is reserved for the reminder. + +```yaml-rasa title="domain.yml" +intents: +- intent: EXTERNAL_reminder +``` + + +### Cancelling Reminders + +#### 1. Define an Action that Cancels a Reminder + +To cancel a reminder that you've already scheduled, you need a custom action +that returns the `ReminderCancelled()` event. + +Returning `ReminderCancelled()` cancels all the reminders that are currently scheduled. +If you only want to cancel certain reminders, you can specify some parameters by which to narrow down the scheduled reminders: + +* `ReminderCancelled(intent="EXTERNAL_greet")` cancels all reminders with intent `EXTERNAL_greet` + +* `ReminderCancelled(entities={})` cancels all reminders with the given entities + +* `ReminderCancelled("...")` cancels the one unique reminder with the given name “`...`” that you supplied + during its creation + + +For the call reminder example, you can define a custom action `action_forget_reminders` that cancels +all reminders: + +```python title="actions.py" +class ForgetReminders(Action): + """Cancels all reminders.""" + + def name(self) -> Text: + return "action_forget_reminders" + + async def run( + self, dispatcher, tracker: Tracker, domain: Dict[Text, Any] + ) -> List[Dict[Text, Any]]: + + dispatcher.utter_message(f"Okay, I'll cancel all your reminders.") + + # Cancel all reminders + return [ReminderCancelled()] +``` + + +:::caution +All reminders are cancelled whenever you shutdown your Rasa server. +::: + +#### 2. Add a Rule + +You'll need to add a rule for cancelling a reminder. + +```yaml-rasa title="rules.yml" +rules: +- rule: Cancel a reminder + steps: + - intent: ask_forget_reminders + - action: action_forget_reminders +``` + + +#### 3. Add Training Data + +You'll need to define an intent that triggers cancelling the reminder. + + +```yaml-rasa title="nlu.yml" +nlu: +- intent: ask_forget_reminders + examples: | + - Forget about the reminder + - do not remind me + - cancel the reminder + - cancel all reminders please +``` + +You should also add it to domain.yml: +```yaml-rasa title="domain.yml" +intents: +- intent: ask_forget_reminders +``` + + + +### Try it Out + +To try out reminders you'll need to start a [CallbackChannel](./connectors/your-own-website.mdx#callbackinput). +You'll also need to start the action server to schedule, react to, and cancel your reminders. +See the [reminderbot README](https://github.com/RasaHQ/rasa/blob/main/examples/reminderbot) for details. + +Then, if you send the bot a message like `Remind me to call Paul Pots`, you should get a reminder +back five minutes later that says `Remember to call Paul Pots!`. diff --git a/docs/docs/responses.mdx b/docs/docs/responses.mdx new file mode 100644 index 0000000..7470a7f --- /dev/null +++ b/docs/docs/responses.mdx @@ -0,0 +1,463 @@ +--- +id: responses +sidebar_label: Responses +title: Responses +abstract: Responses are messages that your assistant sends to the user. A response is usually only text, but can also include content like images and buttons. +--- +## Defining Responses + + +Responses go under the `responses` key in your domain file or in a separate "responses.yml" file. Each response name should start with `utter_`. +For example, you could add responses for greeting and saying goodbye under the response names `utter_greet` and `utter_bye`: + +```yaml-rasa {4-8} title="domain.yml" +intents: + - greet + +responses: + utter_greet: + - text: "Hi there!" + utter_bye: + - text: "See you!" +``` + +If you are using [retrieval intents](glossary.mdx#retrieval-intent) in your assistant, you also need to add responses +for your assistant's replies to these intents: + +```yaml-rasa {5-6,8-9} +intents: + - chitchat + +responses: + utter_chitchat/ask_name: + - text: Oh yeah, I am called the retrieval bot. + + utter_chitchat/ask_weather: + - text: Oh, it does look sunny right now in Berlin. +``` + +:::note +Notice the special format of response names for retrieval intents. Each name starts with `utter_`, +followed by the retrieval intent's name (here `chitchat`) and finally a suffix specifying +the different response keys (here `ask_name` and `ask_weather`). See [the documentation for NLU +training examples](./training-data-format.mdx#training-examples) to learn more. +::: + +### Using Variables in Responses + +You can use variables to insert information into responses. +Within a response, a variable is enclosed in curly brackets. For example, see the variable `name` below: + +```yaml-rasa {3} title="domain.yml" +responses: + utter_greet: + - text: "Hey, {name}. How are you?" +``` + +When the `utter_greet` response is used, Rasa automatically fills in the variable with the +value found in the slot called `name`. If such a slot doesn't exist or is empty, the variable gets +filled with `None`. + +Another way to fill in a variable is within a [custom action](./custom-actions.mdx). +In your custom action code, you can supply values to a response to fill in specific variables. +If you're using the Rasa SDK for your action server, +you can pass a value for the variable as a keyword argument to [`dispatcher.utter_message`](./action-server/sdk-dispatcher.mdx): + +```python {3} +dispatcher.utter_message( + template="utter_greet", + name="Sara" +) +``` + +If you use a [different custom action server](./action-server/index.mdx#other-action-servers), +supply the values by adding extra parameters to the responses your server returns: + +```json {8} +{ + "events":[ + ... + ], + "responses":[ + { + "template":"utter_greet", + "name":"Sara" + } + ] +} +``` + + +### Response Variations + +You can make your assistant's replies more interesting if you provide multiple response variations +to choose from for a given response name: +```yaml-rasa {3-4} title="domain.yml" +responses: + utter_greet: + - text: "Hey, {name}. How are you?" + - text: "Hey, {name}. How is your day going?" +``` +In this example, when `utter_greet` gets predicted as the next action, Rasa will randomly pick one +of the two response variations to use. + +#### IDs for Responses + +:::note New in Rasa 3.6 +You can now set an ID for any response. +This is useful when you want to use the [NLG server](./nlg.mdx) to generate the response. + +Type for ID is string. + +::: + +Example of response variations with ID: +```yaml-rasa {3-4} title="domain.yml" +responses: + utter_greet: + - id: "greet_1" + text: "Hey, {name}. How are you?" + - id: "greet_2" + text: "Hey, {name}. How is your day going?" +``` + +### Channel-Specific Response Variations + +To specify different response variations depending on which channel +the user is connected to, use channel-specific response variations. + +In the following example, the `channel` key makes the first response variation channel-specific for +the `slack` channel while the second variation is not channel-specific: + +```yaml-rasa {4} title="domain.yml" +responses: + utter_ask_game: + - text: "Which game would you like to play on Slack?" + channel: "slack" + - text: "Which game would you like to play?" +``` + +:::note +Make sure the value of the `channel` key matches the value returned by the `name()` method of your +input channel. If you are using a built-in channel, this value will also match the channel name used +in your `credentials.yml` file. +::: + +When your assistant looks for suitable response variations under a given response name, it will first +try to choose from channel-specific variations for the current channel. +If there are no such variations, the assistant will choose from any response variations which are not +channel-specific. + +In the above example, the second response variation has no `channel` specified and can be used by +your assistant for all channels other than `slack`. + +:::caution +For each response, try to have at least one response variation without the `channel` key. +This allows your assistant to properly respond in all environments, such as in new channels, +in the shell and in interactive learning. +::: + + +### Conditional Response Variations + +Specific response variations can also be selected based on one or more slot values using a conditional +response variation. A conditional response variation is defined in the domain or responses YAML files +similarly to a standard response variation but with an additional `condition` key. This key specifies +a list of slot `name` and `value` constraints. + +When a response is triggered during a dialogue, the constraints of each conditional response variation +are checked against the current dialogue state. If all constraint slot values are equal to the corresponding +slot values of the current dialogue state, the response variation is eligible to be used by your conversational +assistant. + +:::note +The comparison of dialogue state slot values and constraint slot values is performed by the +equality "==" operator which requires the type of slot values to match too. +For example, if the constraint is specified as `value: true`, then the slot needs to be filled +with a boolean `true`, not the string `"true"`. +::: + +In the following example, we will define one conditional response variation with one constraint, +that the `logged_in` slot is set to `true`: + +```yaml-rasa title="domain.yml" +slots: + logged_in: + type: bool + influence_conversation: False + mappings: + - type: custom + name: + type: text + influence_conversation: False + mappings: + - type: custom + +responses: + utter_greet: + - condition: + - type: slot + name: logged_in + value: true + text: "Hey, {name}. Nice to see you again! How are you?" + + - text: "Welcome. How is your day going?" +``` + +```yaml-rasa title="stories.yml" +stories: +- story: greet + steps: + - action: action_log_in + - slot_was_set: + - logged_in: true + - intent: greet + - action: utter_greet +``` + +In the example above, the first response variation (``"Hey, {name}. Nice to see you again! How are you?"``) +will be used whenever the `utter_greet` action is executed and the `logged_in` slot is set to `true`. +The second variation, which has no condition, will be treated as the default and used whenever +`logged_in` is not equal to `true`. + +:::caution +It is highly recommended to always provide a default response variation without a condition +to guard against those cases when no conditional response matches filled slots. +::: + +During a dialogue, Rasa will choose from all conditional response variations whose constraints are satisfied. +If there are multiple eligible conditional response variations, Rasa will pick one at random. +For example, consider the following response: + +```yaml-rasa title="domain.yml" +responses: + utter_greet: + - condition: + - type: slot + name: logged_in + value: true + text: "Hey, {name}. Nice to see you again! How are you?" + + - condition: + - type: slot + name: eligible_for_upgrade + value: true + text: "Welcome, {name}. Did you know you are eligible for a free upgrade?" + + - text: "Welcome. How is your day going?" +``` + +If `logged_in` and `eligible_for_upgrade` are both set to `true` then both the first and second response +variations are eligible to be used, and will be chosen by the conversational assistant with equal probability. + +You can continue using channel-specific response variations alongside conditional response variations +as shown in the example below. + +```yaml-rasa title="domain.yml" +slots: + logged_in: + type: bool + influence_conversation: False + mappings: + - type: custom + name: + type: text + influence_conversation: False + mappings: + - type: custom + +responses: + utter_greet: + - condition: + - type: slot + name: logged_in + value: true + text: "Hey, {name}. Nice to see you again on Slack! How are you?" + channel: slack + + - text: "Welcome. How is your day going?" +``` + +Rasa will prioritize the selection of responses in the following order: + +1. conditional response variations with matching channel +2. default responses with matching channel +3. conditional response variations with no matching channel +4. default responses with no matching channel + + +## Rich Responses + +You can make responses rich by adding visual and interactive elements. +There are several types of elements that are supported across many channels: + +### Buttons + +Here is an example of a response that uses buttons: + +```yaml-rasa {4-8} title="domain.yml" +responses: + utter_greet: + - text: "Hey! How are you?" + buttons: + - title: "great" + payload: "/mood_great" + - title: "super sad" + payload: "/mood_sad" +``` + +Each button in the list of `buttons` should have two keys: +* `title`: The text displayed on the buttons that the user sees. +* `payload`: The message sent from the user to the assistant when the button is clicked. + +If you would like the buttons to also pass entities to the assistant: + +```yaml-rasa {4-8} title="domain.yml" +responses: + utter_greet: + - text: "Hey! Would you like to purchase motor or home insurance?" + buttons: + - title: "Motor insurance" + payload: '/inform{{"insurance":"motor"}}' + - title: "Home insurance" + payload: '/inform{{"insurance":"home"}}' +``` + +Passing multiple entities is also possible with: + +``` +'/intent_name{{"entity_type_1":"entity_value_1", "entity_type_2": "entity_value_2"}}' +``` + +:::note overwrite nlu with buttons +You can use buttons to overwrite the NLU prediction and trigger a specific intent and entities. + +Messages starting with `/` are sent handled by the +`RegexInterpreter`, which expects NLU input in a shortened `/intent{entities}` format. +In the example above, if the user clicks a button, the user input +will be classified as either the `mood_great` or `mood_sad` intent. + +You can include entities with the intent to be passed to the `RegexInterpreter` using the following format: + +`/inform{"ORG":"Rasa", "GPE":"Germany"}` + +The `RegexInterpreter` will classify the message above with the intent `inform` and extract the entities +`Rasa` and `Germany` which are of type `ORG` and `GPE` respectively. + +::: + +:::note escaping curly braces in domain.yml +You need to write the `/intent{entities}` shorthand response with double curly braces in domain.yml so that the assistant does not +treat it as a [variable in a response](#using-variables-in-responses) and interpolate the content within the curly braces. +::: + +:::caution Check your channel +Keep in mind that it is up to the implementation of the output +channel how to display the defined buttons. For example, some +channels have a limit on the number of +buttons you can provide. Check your channel's documentation under +**Concepts > Channel Connectors** for any channel-specific restrictions. +::: + +### Images + +You can add images to a response by providing a URL to the image under the `image` key: + +```yaml-rasa {3} title="domain.yml" + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" +``` + +### Custom Output Payloads + +You can send any arbitrary output to the output channel using the +`custom` key. The output channel receives the object stored under the `custom` key +as a JSON payload. + +Here's an example of how to send a +[date picker](https://api.slack.com/reference/block-kit/block-elements#datepicker) to the +[Slack Output Channel](connectors/slack.mdx): + +```yaml-rasa {3-14} title="domain.yml" +responses: + utter_take_bet: + - custom: + blocks: + - type: section + text: + text: "Make a bet on when the world will end:" + type: mrkdwn + accessory: + type: datepicker + initial_date: '2019-05-21' + placeholder: + type: plain_text + text: Select a date +``` + +## Using Responses in Conversations + +### Calling Responses as Actions + +If the name of the response starts with `utter_`, the response can +directly be used as an action, without being listed in the `actions` section of your domain. You would add the response +to the domain: + + +```yaml-rasa title="domain.yml" +responses: + utter_greet: + - text: "Hey! How are you?" +``` + +You can use that same response as an action in your stories: + +```yaml-rasa {5} title="stories.yml" +stories: +- story: greet user + steps: + - intent: greet + - action: utter_greet +``` + +When the `utter_greet` action runs, it will send the message from +the response back to the user. + +:::note Changing responses +If you want to change the text, or any other part of the response, +you need to retrain the assistant before these changes will be picked up. +::: + +### Calling Responses from Custom Actions + + +You can use the responses to generate response messages from your +custom actions. If you're using Rasa SDK as your action server, you can use the dispatcher to generate the response message, for example: + +```python {8} title="actions.py" +from rasa_sdk.interfaces import Action + +class ActionGreet(Action): + def name(self): + return 'action_greet' + + def run(self, dispatcher, tracker, domain): + dispatcher.utter_message(template="utter_greet") + return [] +``` + +If you use a +[different custom action server](./action-server/index.mdx#other-action-servers), +your server should return the following JSON to call the `utter_greet` response: + +```json {5} +{ + "events":[], + "responses":[ + { + "template":"utter_greet" + } + ] +} +``` diff --git a/docs/docs/rules.mdx b/docs/docs/rules.mdx new file mode 100644 index 0000000..40d2eb7 --- /dev/null +++ b/docs/docs/rules.mdx @@ -0,0 +1,195 @@ +--- +id: rules +sidebar_label: Rules +title: Rules +description: Use Rasa rules to respond to FAQs, fill forms, or handle fallbacks gracefully. +abstract: Rules are a type of training data used to train your assistant's dialogue management model. + Rules describe short pieces of conversations that should always follow the same path. +--- + +**Don't overuse rules**. Rules are great to handle small specific conversation patterns, but +unlike [stories](./stories.mdx), rules don't have the power to generalize to unseen conversation +paths. Combine rules and stories to make your assistant robust and able to handle +real user behavior. + +If you can't decide whether to write a story or a rule to implement a certain behavior, see the +best practices for [Writing Conversation Data](./writing-stories.mdx)). + +For additional examples on implementing rules in a Rasa assistant, refer to +our [Rules example bot](https://github.com/RasaHQ/rasa/tree/main/examples/rules). + +## Writing a Rule + +Before you start writing rules, you have to make sure that the +[Rule Policy](./policies.mdx#rule-policy) is added to your model configuration: + +```yaml-rasa +policies: +- ... # Other policies +- name: RulePolicy +``` + +Rules can then be added to the `rules` section of your training data. + +To indicate that a rule can apply at any point in a conversation, start with the +intent which starts the conversation and then add the actions which your assistant +should perform in response to that. + +```yaml-rasa +rules: + +- rule: Say `hello` whenever the user sends a message with intent `greet` + steps: + - intent: greet + - action: utter_greet +``` + +This example rule applies at the start of conversation as well as when the user decides +to send a message with an intent `greet` in the middle of an ongoing conversation. + +Dialogue turns that only appear as rules in the training data and do not appear in stories +will be ignored by ML-only policies like `TEDPolicy` at prediction time. + +```yaml-rasa +rules: +- rule: Say `hello` whenever the user sends a message with intent `greet` + steps: + - intent: greet + - action: utter_greet + +stories: +- story: story to find a restaurant + steps: + - intent: find_restaurant + - action: restaurant_form + - action: utter_restaurant_found +``` + +For example if you define the greeting rule as above and don't add it to any of your stories, +after `RulePolicy` predicts `utter_greet`, `TEDPolicy` will +make predictions as if the `greet, utter_greet` turn did not occur. + + +### Rules for the Conversation Start + +To write a rule which only applies at the beginning of a conversation, add a +`conversation_start: true` to your rule: + +```yaml-rasa +rules: + +- rule: Say `hello` when the user starts a conversation with intent `greet` + conversation_start: true + steps: + - intent: greet + - action: utter_greet +``` + +If a user sends a message with the intent `greet` later in the conversation, the rule will not match. + +### Rules with Conditions + +Conditions describe requirements which have to be fulfilled for a rule to be +applicable. To do so, add any information about the prior conversation under the +`condition` key: + +```yaml-rasa +rules: + +- rule: Only say `hello` if the user provided a name + condition: + - slot_was_set: + - user_provided_name: true + steps: + - intent: greet + - action: utter_greet +``` + +Possible information that you can include under `condition` includes `slot_was_set` events +and `active_loop` events. + +### Skip Waiting for User Input at the End of a Rule + +By default, rules will wait for the next user message when finished with the last step: + +```yaml-rasa +rules: + +- rule: Rule which will wait for user message when it was applied + steps: + - intent: greet + - action: utter_greet + # - action: action_listen + # Every rule implicitly includes a prediction for `action_listen` as last step. + # This means that Rasa will wait for the next user message. +``` + +If you want to hand over the next action prediction to another story or rule, add +`wait_for_user_input: false` to your rule: + +```yaml-rasa +rules: + +- rule: Rule which will not wait for user message once it was applied + steps: + - intent: greet + - action: utter_greet + wait_for_user_input: false +``` + +This indicates that the assistant should execute another action +before waiting for more user input. + +### Abort a Rule + +Rules are designed to handle multiple output steps of a chatbot. +They are terminated as soon as user interaction is required. +This happens automatically via [launching a form](#rules-and-forms), since it starts with the user input of the first slot. +Therefore all steps after launch are ignored. + +Termination, however, can also be achieved manually. +This can be useful to implement conditional termination criteria. +Here is an example: + +```yaml-rasa +rules: + +- rule: Rule which will be conditionaly terminated + steps: + - intent: greet + - action: action_check_termination + - action: utter_greet + wait_for_user_input: true +``` + +```python +from rasa_sdk import Action +from rasa_sdk.events import FollowupAction + +class ActionCheckTermination(Action): + + def name(self): + return "action_check_termination" + + def run(self, dispatcher, tracker, domain): + + # your business logic here + should_terminate = check_for_termination(<params>) + + if should_terminate: + return [FollowupAction("action_listen")] + + return [] +``` + +utter_greet is never executed when termination is done, even after user input, because it causes a new intent prediction. + +### Rules and Forms + +When a [Form](./forms.mdx) is active, the bot will make predictions based on +how the form is defined, ignoring rules. Rules become applicable again if: + +- the form fills all required slots +- the form rejects its execution (see +[Handling Unhappy Paths](./forms.mdx#writing-stories--rules-for-unhappy-form-paths) for + more details) diff --git a/docs/docs/sdk_changelog.mdx b/docs/docs/sdk_changelog.mdx new file mode 100644 index 0000000..c27023b --- /dev/null +++ b/docs/docs/sdk_changelog.mdx @@ -0,0 +1,7 @@ +--- +id: sdk_changelog +sidebar_label: Rasa SDK Change Log +title: Rasa SDK Change Log +--- + +The Rasa SDK changelog can be found in the [Rasa SDK repository](https://github.com/RasaHQ/rasa-sdk/blob/main/CHANGELOG.mdx) \ No newline at end of file diff --git a/docs/docs/secrets-managers.mdx b/docs/docs/secrets-managers.mdx new file mode 100644 index 0000000..d241848 --- /dev/null +++ b/docs/docs/secrets-managers.mdx @@ -0,0 +1,124 @@ +--- +id: secrets-managers +sidebar_label: Secrets Managers +title: Secrets Managers +description: Safeguard credentials your service uses to authenticate to external resources. +abstract: You can store your assistant's secrets in an external credentials manager. + Rasa Pro currently supports credentials manager for the Tracker Store +--- + +<!-- this file is version specific, do not use `@site/...` syntax --> +import RasaProLabel from '@theme/RasaProLabel'; +import RasaProBanner from '@theme/RasaProBanner'; + +<RasaProLabel /> +<RasaProBanner/> + +:::info Available in Rasa Pro from 3.5.0 +::: + + +Rasa Pro supports the following secrets managers: +* [HashiCorp Vault](https://www.hashicorp.com/products/vault) + + +Currently, Rasa Pro supports safeguarding credentials for the following services: +- [Tracker Stores](./tracker-stores.mdx) + + +## HashiCorp Vault Secrets Manager + +Use Vault Secrets Manager to store credentials used to authenticate access to external services. +The credentials are stored in a Vault instance and can be encrypted at rest. +To store credentials in a Vault instance, you can read the official Vault docs +[Storing secrets in Vault](https://developer.hashicorp.com/vault/tutorials/getting-started/getting-started-first-secret). + +You can also encrypt credentials at rest with +[Vault Transit Engine](https://www.vaultproject.io/docs/secrets/transit). + +:::note +Expiring tokens need to be renewed periodically, and the renewal process is done over the network, +Rasa Pro will try to renew the token 15 seconds before it expires. +If the token's time-to-live (TTL) is less than 15 seconds, we will try to renew it after 1 second, +but it might fail due to network latency. + +Rasa Pro has a built-in retry mechanism for renewing the token. + +If the token is not renewed successfully it will be considered expired +and Rasa Pro will not be able to access the secrets. +You will need to create a new renewable token and restart Rasa Pro with new token. +::: + + +### Authentication +Rasa Pro can authenticate to Vault through +[Token authentication](https://www.vaultproject.io/docs/auth/token). + +Both `expiring` and `non-expiring` (so called, root tokens) tokens are supported. +Rasa Pro will automatically renew the token if it is expiring. + + +### How to configure access to Vault + +Access to Vault secrets manager can be configured with environment variables +and through `endpoints.yml` configuration file. + +Environment variables and `endpoints.yml` configuration file are merged together +and **the values from the environment variables take precedence**. + +The following environment variables are available: + +| Environment Variable | Description | Default | +|:------------------------------|:-----------------------------------------------------------------------------------------------|:----------------------| +| `SECRET_MANAGER` | **Required**. The secrets manager to use. *Currently only "vault" is supported* | `vault` | +| `VAULT_HOST` | **Required**. The address of the vault server | | +| `VAULT_TOKEN` | **Required**. token to authenticate to the vault server | | +| `VAULT_RASA_SECRETS_PATH` | Path to the secrets in the vault server | `rasa-secrets` | +| `VAULT_TRANSIT_MOUNT_POINT` | If transit secrets engine is enabled, set this to mount point of the transit engine | | + +To configure the Vault secrets manager, you can fill the following section in `endpoints.yml` file: +```yaml-rasa title="endpoints.yml +secrets_manager: + type: vault # required - the secrets manager to use + token: <token> # required - token to authenticate to the vault server + url: "http://localhost:1234" # required - the address of the vault server + secrets_path: rasa-secrets # path to the secrets in the vault server if not set it defaults to `rasa-secrets` + transit_mount_point: transit # if transit secrets engine is enabled, set this to mount point of the transit engine +``` + +#### Store access credentials in environment variables +A simple example on how to combine environment variables and `endpoints.yml` configuration file +would be to store access token in the environment variable and the rest of the configuration +in the `endpoints.yml` file. + +```bash +# environment variables +VAULT_TOKEN=<token used to authenticate to Vault> +``` + + +```yaml-rasa title="endpoints.yml +secrets_manager: + type: vault + url: "http://localhost:1234" + secrets_path: rasa-secrets # if not set it defaults to `rasa-secrets` + transit_mount_point: transit # if you have enabled transit secrets engine, and you want to use it +``` + +### How to configure Tracker Store with Vault Secrets Manager +1. Configure Rasa to access the Vault instance + + Checkout the [How to configure access to Vault](#how-to-configure-access-to-vault) section for more details. + +2. Configure Rasa to use the Vault secrets manager to fetch credentials for the tracker store + ```yaml-rasa title="endpoints.yml + tracker_store: + type: SQL + url: localhost:5432 + username: + source: secrets_manager.vault + secret_key: sql_store_username + password: + source: secrets_manager.vault + secret_key: sql_store_password + ``` diff --git a/docs/docs/setting-up-ci-cd.mdx b/docs/docs/setting-up-ci-cd.mdx new file mode 100644 index 0000000..95b6447 --- /dev/null +++ b/docs/docs/setting-up-ci-cd.mdx @@ -0,0 +1,152 @@ +--- +id: setting-up-ci-cd +sidebar_label: Setting up CI/CD +title: Setting up CI/CD +description: Set up a CI/CD pipeline to ensure that iterative improvements to your assistant are tested and deployed with minimum manual effort +abstract: Even though developing a contextual assistant is different from developing traditional + software, you should still follow software development best practices. + Setting up a Continuous Integration (CI) and Continuous Deployment (CD) + pipeline ensures that incremental updates to your bot are improving it, not harming it. +--- +import useBaseUrl from '@docusaurus/useBaseUrl'; + +## Overview + +Continuous Integration (CI) is the practice of merging in code changes +frequently and automatically testing changes as they are committed. Continuous +Deployment (CD) means automatically deploying integrated changes to a staging +or production environment. Together, they allow you to make more frequent improvements +to your assistant and efficiently test and deploy those changes. + +This guide will cover what should go in a CI/CD pipeline, specific to a +Rasa project. How you implement that pipeline is up to you. +There are many CI/CD tools out there, such as [GitHub Actions](https://github.com/features/actions), +[GitLab CI/CD](https://docs.gitlab.com/ee/ci/), [Jenkins](https://www.jenkins.io/doc/), and +[CircleCI](https://circleci.com/docs/). We recommend choosing a tool that integrates with +whatever Git repository you use. + +## Continuous Integration (CI) + +The best way to improve an assistant is with frequent [incremental updates](https://rasa.com/docs/rasa-enterprise/user-guide/fix-problems). +No matter how small a change is, you want to be sure that it doesn't introduce +new problems or negatively impact the performance of your assistant. + +It is usually best to run CI checks on merge / pull requests or on commit. Most tests are +quick enough to run on every change. However, you can choose to run more +resource-intensive tests only when certain files have been changed or when some +other indicator is present. For example, if your code is hosted on Github, +you can make a test run only if the pull request has a certain label (e.g. “NLU testing required”). + +### CI Pipeline Overview + +Your CI pipeline should include model training and testing as steps to streamline the deployment process. +The first step after saving new training data is to kick off the pipeline. This can be initiated manually +or when you create or update a pull request. + +Next, you need to run various sets of test to see the impact of your changes. This includes running +tests for data validation, NLU cross validation, and story testing. For +more information about testing, see [Testing Your Assistant](./testing-your-assistant.mdx). + +The last step is to review the results of your test and push the changes if the tests are successful. +Once the new model is trained and tested, it can be deployed automatically using a Continuous +Deployment pipeline. + +### GitHub Actions CI Pipeline + +You can use the [Rasa Train-Test Github Action](https://github.com/RasaHQ/rasa-train-test-gha) +in your CI pipeline to automatically perform data validation, training, and testing. + +An example CI pipeline using the Github Action is shown below: + +```yaml +jobs: + training-testing: + name: Training and Testing + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Rasa Train and Test GitHub Action + uses: RasaHQ/rasa-train-test-gha@main + with: + requirements_file: requirements.txt + data_validate: true + rasa_train: true + cross_validation: true + rasa_test: true + test_type: all + publish_summary: true + github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Upload model + if: github.ref == 'refs/heads/main' + uses: actions/upload-artifact@master + with: + name: model + path: models +``` + +In this pipeline, the Rasa Train-Test Github Action is performing data validation, model training, and story testing +in the first step and the model file is uploaded as an artifact in the second step. + +The complete list of configurable parameters for the Rasa Train-Test Github Action is available in the repository's +[README](https://github.com/RasaHQ/rasa-train-test-gha#input-arguments). + +When `publish_summary` is set to `true`, this action will automatically publish the model's test results to the associated +Pull Request as a comment: + +<img alt="image" src={useBaseUrl("/img/train-test-github-action.png")} /> + +The pull request can be approved or denied based on the evaluation results and, in many cases, you will want to automate the model's deployment if all CI checks pass. You can continue to the next section to learn more about Continuous Deployment. + + +## Continuous Deployment (CD) + +To get improvements out to your users frequently, you will want to automate as +much of the deployment process as possible. + +CD steps usually run on push or merge to a certain branch, once CI checks have +succeeded. + +### Deploying Your Rasa Model + +If you ran [test stories](./testing-your-assistant.mdx) in your CI pipeline, +you'll already have a trained model. You can set up your CD pipeline to upload the trained model to your +Rasa server if the CI results are satisfactory. For example, to upload a model to Rasa X/Enterprise: + +```bash +curl -k -F "model=@models/my_model.tar.gz" "https://example.rasa.com/api/projects/default/models?api_token={your_api_token}" +``` + +If you are using Rasa X/Enterprise, you can also [tag the uploaded model](https://rasa.com/docs/rasa-enterprise/pages/http-api/#tag/Models/paths/~1projects~1{project_id}~1models~1{model}~1tags~1{tag}/put) +as production (or whichever deployment you want to tag if using multiple [deployment environments](https://rasa.com/docs/rasa-enterprise/enterprise/deployment-environments/#)): + +```bash +curl -X PUT "https://example.rasa.com/api/projects/default/models/my_model/tags/production" +``` + +:::caution updates to action code +If your update includes changes to both your model and your action +code, and these changes depend on each other in any way, you should **not** +automatically tag the model as `production`. You will first need to build and +deploy your updated action server, so that the new model won't e.g. call +actions that don't exist in the pre-update action server. +::: + +### Deploying Your Action Server + +You can automate +[building and uploading a new image for your action server](./deploy/deploy-action-server.mdx#building-an-action-server-image) +to an image repository for each +update to your action code. As noted above, be careful with +automatically deploying a new image tag to production if the action server +would be incompatible with the current production model. + +## Example CI/CD pipelines + +As examples, see the CI/CD pipelines for +[Sara](https://github.com/RasaHQ/rasa-demo/blob/main/.github/workflows/continuous_integration.yml), +the Rasa assistant that you can talk to in the Rasa Docs, and +[Carbon Bot](https://github.com/RasaHQ/carbon-bot/blob/master/.github/workflows/model_ci.yml). +Both use [Github Actions](https://github.com/features/actions) as a CI/CD tool. + +These examples are just two of many possibilities. If you have a CI/CD setup you like, please +share it with the Rasa community on the [forum](https://forum.rasa.com). diff --git a/docs/docs/slot-validation-actions.mdx b/docs/docs/slot-validation-actions.mdx new file mode 100644 index 0000000..db5d3e5 --- /dev/null +++ b/docs/docs/slot-validation-actions.mdx @@ -0,0 +1,48 @@ +--- +id: slot-validation-actions +sidebar_label: Slot Validation Actions +title: Slot Validation Actions +abstract: A slot validation action is a special type of custom action, designed to handle custom extraction and/or validation of slot values. This can be used to validate slots with predefined mappings or extract slots with custom mappings. +--- + +You can do custom extraction and validation of slot values by writing custom actions in three different ways. + +## `action_validate_slot_mappings` + +You can use the `action_validate_slot_mappings` action to define custom extraction and / or validation of slots that +can be set or updated outside of a form context. + +This action is called automatically at the end of the default action [`action_extract_slots`](./default-actions.mdx#action_extract_slots), +so the name must not be changed. If you are using Rasa SDK, you should extend the Rasa SDK [`ValidationAction` class](./action-server/validation-action.mdx#how-to-subclass-validationaction). +If you are using a different action server, you will need to implement an action class with equivalent functionality to +the Rasa SDK class. Please see [the action server docs](./action-server/validation-action.mdx#validationaction-class-implementation) for details. + +With this option, you do not need to specify the `action` key in the [custom slot mapping](./domain.mdx#custom-slot-mappings), +since the default action [`action_extract_slots`](./default-actions.mdx#action_extract_slots) runs `action_validate_slot_mappings` +automatically if present in the `actions` section of the domain. + +## `validate_<form name>` + +Custom actions named `validate_<form name>` will run automatically if the form it specifies in its name is activated. +If you are using Rasa SDK, the custom action should inherit from the Rasa SDK [`FormValidationAction` class](./action-server/validation-action.mdx#formvalidationaction-class). +If you are not using Rasa SDK, you will need to implement an action or action class with equivalent functionality to the +`FormValidationClass` in your custom action server. Please see [the action server docs](./action-server/validation-action.mdx#formvalidationaction-class-implementation) for details. + +## Regular Custom Action + +You can use a regular custom action [custom action](./custom-actions.mdx) that returns [`slot`](./action-server/events.mdx#slot) +events for custom slot extraction. Use this option if neither [`action_validate_slot_mappings`](#action_validate_slot_mappings) or +[`validate_<form name>`](#validate_form-name) meet your needs. +For example, if you want to reuse the same custom action explicitly in a story or rule, you should use a regular custom +action for custom slot extraction. +A slot validation action should only return `slot` and [`bot`](./action-server/events.mdx#bot) events. +Any other event type will be filtered out by the default action [`action_extract_slots`](./default-actions.mdx#action_extract_slots). +The name of the custom action must be specified in the `action` key of the relevant [custom slot mapping](./domain.mdx#custom-slot-mappings) +in the domain. +Note that the action name must be listed in the domain `actions` section too. + +:::note Using different actions for extraction and validation + +You can use both a regular custom action and `action_validate_slot_mappings` together to extract and validate a slot. For example, you can specify a regular custom action as the `action` for a `custom` slot mapping, and also add validation logic for the same slot to `action_validate_slot_mappings`. The custom action specified in the custom slot mapping will be called first, and `action_validate_slot_mappings` will be called afterwards. + +::: diff --git a/docs/docs/spaces.mdx b/docs/docs/spaces.mdx new file mode 100644 index 0000000..7ea2b74 --- /dev/null +++ b/docs/docs/spaces.mdx @@ -0,0 +1,267 @@ +--- +id: spaces +sidebar_label: Spaces +title: Spaces +description: Learn about Spaces for Rasa. +abstract: Spaces are a way to modularize your assistant that increases isolation between subparts and classification performance for intents and entities that are only relevant in specific contexts. +--- + +import RasaProLabel from "@theme/RasaProLabel"; +import RasaProBanner from "@theme/RasaProBanner"; +import useBaseUrl from '@docusaurus/useBaseUrl'; + +<RasaProLabel /> + +<RasaProBanner /> + +:::caution New in 3.6.0a1 +This is an alpha release. +This alpha release will allow us to gather feedback from our users and integrate it into future releases. We encourage you to try this out! +The functionality of this alpha release might change in the future. +If you have feedback (positive or negative) please share it with us via your support contact. + +::: + +Spaces are a way to modularize your assistant. As assistants grow in scale and scope, the +potential for conflicts between intents, entities, and other [primitives](./glossary.mdx#rasa-primitive) grows as well. +That is because in a regular rasa assistant all intents, entities, and actions are +available at any time, giving the assistant more choices to distinguish between the more that gets +added. Spaces provide a basic separation between parts of the bot through the activation +and deactivation of groups of [rasa primitives](./glossary.mdx#rasa-primitive). Each of these groups is called a Space. +Spaces are activated when certain designated intents, called entry intents, are +predicted. With the activation of a Space, all the primitives for the Space become +available for subsequent interactions with the user. Good ways to think about spaces are + +* that they allow you to specify follow-up primitives, which only become accessible after another intent has come beforehand +* that they are like multiple sub-bots merged into one with the possibility of sharing primitives + + +## When to use Spaces + +Spaces can be helpful when you are dealing with multiple domains of your business in +a single assistant. Oftentimes, this leads to multitude of forms, entities, and inform +intents that start to overlap at some point. Form filling and inform intents are a +typical case of having this follow-up structure mentioned above. Another good case is +when you want to be able to define different behavior for help- or clarification +requests based on the subdomain or process the user is in. This is technically possible +today with stories, but it can be cumbersome to describe the full event horizon +for every interaction route. + +## When not to use Spaces / Limitations + +Because spaces split a rasa bot into multiple parts, it is interacting with almost +every of the existing features of rasa. We made it work with almost all of them, but +there are some exceptions. This means, however, if you have created +customization beyond components that are aligned with the way rasa normally +works, spaces might not work for you. + +Another important limitation is that spaces currently do not support stories. We know +this is a big limitations for many existing bots. However, because stories and their +existing policies work with a fixed event horizon (`max_history`), they are at this +point not compatible with the idea of being able to describe encapsulated units of logic +well. + +Currently, the only entity extractor that is works with the boundaries that spaces +create, is an adapted version of the `CRFEntityExtractor`. You can find more on this +in the following sections. + + +## An example spaces bot +We have created a bot using spaces for you to look at, learn from and experiment with. +It features three different spaces in the financial domain. [Here is the repository](https://github.com/RasaHQ/financial-spaces-bot) + + +## How to use spaces + +Spaces are defined by separate sets of domain, nlu, and rule files. These separate +sets are then assembled to create a unified assistant. The assembly plan needs to be +added to the `config.yml` using the newly introduces `spaces` key. Second, you need +to use a special data importer that reads and enacts the assembly plan. Third, you need +to add some specific space-aware components to your NLU pipeline. The following is the +`config.yml` of the financial spaces bot linked above: + +```yaml +# https://rasa.com/docs/rasa/model-configuration/ +recipe: default.v1 + +# Configuration for Rasa NLU. +# https://rasa.com/docs/rasa/nlu/components/ +language: en + +spaces: + - name: main + domain: main/domain.yml + nlu: main/nlu/training_data.yml + nlu_test: main/nlu/test_data.yml + rules: main/rules.yml + - name: transfer_money + domain: transfer_money/domain.yml + nlu: transfer_money/nlu/training_data.yml + nlu_test: transfer_money/nlu/test_data.yml + rules: transfer_money/rules.yml + entry_intents: + - transfer_money + - name: investment + domain: investment/domain.yml + nlu: investment/nlu/training_data.yml + nlu_test: investment/nlu/test_data.yml + rules: investment/rules.yml + entry_intents: + - buy_stock + - name: pay_cc + domain: pay_cc/domain.yml + nlu: pay_cc/nlu/training_data.yml + nlu_test: pay_cc/nlu/test_data.yml + rules: pay_cc/rules.yml + entry_intents: + - pay_cc + +importers: + - name: "rasa_plus.spaces.space_data_importer.SpaceDataImporter" + temporary_working_directory: spaces + +pipeline: + - name: WhitespaceTokenizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + - name: rasa_plus.spaces.components.spaces_crf_entity_extractor.SpacesCRFEntityExtractor + - name: EntitySynonymMapper + - name: DIETClassifier + epochs: 100 + ranking_length: 0 + entity_recognition: false + BILOU_flag: false + - name: rasa_plus.spaces.components.filter_and_rerank.FilterAndRerank + - name: FallbackClassifier + threshold: 0.3 + ambiguity_threshold: 0.1 + +policies: + - name: RulePolicy + +``` + +In the above config file, you can see the Spaces definition with the main space, which +is a designated name for a space for shared primitives, and the three +subspaces for transferring money, investment, and credit card payments. The main space +is not required, but the only way to share primitives between spaces. Each path value +can either point to a single file or a directory. + +Second, you can also see the importer +`rasa_plus.spaces.space_data_importer.SpaceDataImporter` being used. This importer +reads the above spaces definition and assembles the bot. The results can be seen in the +temporary working directory. + +Third, you can see the specific NLU components that are necessary to make spaces work. +The most important is the `FilterAndRerank` component which is central to make spaces +work by post-processing intent rankings and entity extractions. If you also need entity +extraction you need to use the `SpacesCRFEntityExtractor` to make that work with spaces. + +Finally, you can see that we are only using the `RulePolicy` here. + +After you have created your spaces and made adjustments to the `config.yml`, you can +train your new assistant using `rasa train -c config.yml` and start it afterward with +`rasa shell`. + +### Training only a specific subspace + +We have added `--space` argument to the rasa train command to give you the option to +only train one specific subspace: + +`rasa train` -> trains the full assistant with all spaces +`rasa train --space investment` -> trains an assistant only containing the investment and the main space + +Other commands were not adjusted as they use trained assistants as inputs. + +## How do spaces work? +In the following we'll look at how things work under the hood in spaces to give you a +better understanding of what is happening in case you should need it. + +One of the most aspects is that spaces form a hierarchy with two layers. At the top +is the main space which contains shared primitives that should be usable by all spaces. +The main space is always active. Anything that is defined in a subspace, can +only be used by that space and not by other subspaces or the main space. + +<img alt="two layer space hierarchy" src={useBaseUrl("/img/spaces_hierarchy.png")} /> + +### What happens during the assembly? + +The most important step during the assembly is the prefixing. During this step every +intent, entity, slot, action, form, utterance that is defined in a space's domain file +is prefixed (infixed for utterances) with this space's name. For example, an intent +`ask_transfer_charge` in the `transfer_money` domain would become +`transfer_money.ask_transfer_charge` and every reference of this intent would be +adjusted. The final assistant then works on the prefixed data. + +An exception to this is the main space. Anything in the main space and all its +primitives that are used in other spaces, will not be prefixed. + +Another exception are rules. They don't have a name that can be prefixed. Instead, +we add a condition to each rule that it is only applicable while it's space is active. + +### How is space activation tracked? + +A space is activated when any of their entry intents is predicted. A space can have +multiple entry intents. However, only a single space can be active at a given time. +So when space A is active and an entry intent of space B +is predicted, space A will be deactivated and space B is active from now on. + +Space activation is tracked through slots that are automatically +generated during assembly. + +### How does filter and rerank work? + +The filter and rerank component post-processes the intent ranking of the [intent +classification components](./components.mdx#intent-classifiers). It accesses the [tracker](./action-server/sdk-tracker.mdx) +and checks which space are active or would be activated, in case an entry intent is at the top. + +It then removes any intents from the ranking that are not possible. Further it also +removes any entities that are not possible given the space activation status or +about to be predicted entry intents. + +### How does entity recognition work differently? + +Usually, entity recognizers in Rasa only return a single label per token in a message. +Thus, there is no ranking, that could be post-processed as in the case of intents. We +have built our `SpacesCRFEntityExtractor` in a way that it creates multiple extractors. +One for each space. Now, during the post-processing step, we can filter out the +extractor of the spaces that are not activated. + +### Custom Actions + +Custom actions work as before. However, the tracker, the domain, and slots +will be stripped of any information from other spaces before being handed to your +action. Additionally, every event such as slot sets, will be prefixed after they are +returned by your action. All of this ensures that from the view point of your custom +action, you don't need to worry about the other spaces and accidentally leaking +or altering information. This warrants isolation between your Spaces. Note that +custom actions from the main spaces are not inherited by subspaces. + +### Response Selection + +Response selection works as before. The only small difference is that in your +`config.yaml` you'll need to specify the retrieval intent including it's final prefix. +If your retrieval intent is `investment_faq` in the `investment` space, then in your +config you'll need to set `investment.investment_faq` as the retrieval intent. If the +retrieval intent belongs to the main space, no prefix is added. That's why +, in this case, during the definition of the retrieval intent, no prefixing is necessary. + + +### Lookup tables and Synonyms + +[Lookup Tables](./training-data-format.mdx#lookup-tables) and +[Synonyms](./training-data-format.mdx#synonyms) work with spaces. However, they are not +truly isolated between spaces. So there can be some unanticipated interactions. +For synonyms, specifically: +* Assume two spaces define the same synonym. Space A: "IB" -> "Instant banking". +Space B: "IB"-> "iron bank". A warning is given that one value overwrites the other. +* A similar thing can happen if "IB" is an entity in both spaces but only one +defines it a synonym. Any entity with value IB will always be mapped to that synonym +no matter which space is active. + +For lookup tables no adverse interactions are known. diff --git a/docs/docs/stories.mdx b/docs/docs/stories.mdx new file mode 100644 index 0000000..ddfc50d --- /dev/null +++ b/docs/docs/stories.mdx @@ -0,0 +1,261 @@ +--- +id: stories +sidebar_label: Stories +title: Stories +description: Stories are used to teach Rasa real conversation designs to learn from providing the basis for a scalable machine learning dialogue management. +abstract: Stories are a type of training data used to train your assistant's dialogue management + model. Stories can be used to train models that are able to generalize to unseen conversation paths. +--- +## Format + +A story is a representation of a conversation between a user and an AI assistant, +converted into a specific format where user inputs are expressed as intents +(and entities when necessary), +while the assistant's responses and actions are expressed as action names. + +Here's an example of a dialogue in the Rasa story format: + +```yaml-rasa +stories: +- story: collect restaurant booking info # name of the story - just for debugging + steps: + - intent: greet # user message with no entities + - action: utter_ask_howcanhelp + - intent: inform # user message with entities + entities: + - location: "rome" + - price: "cheap" + - action: utter_on_it # action that the bot should execute + - action: utter_ask_cuisine + - intent: inform + entities: + - cuisine: "spanish" + - action: utter_ask_num_people +``` + + +### User Messages + +While writing stories, you do not have to deal with the specific contents of +the messages that the users send. Instead, you can take advantage of the output +from the NLU pipeline, which lets you use just the combination of an intent and +entities to refer to all the possible messages the users can send to mean the +same thing. + +It is important to include the entities here as well because the policies learn +to predict the next action based on a *combination* of both the intent and +entities (you can, however, change this behavior using the +[use_entities](./domain.mdx#ignoring-entities-for-certain-intents) attribute). + +### Actions + +All actions executed by the bot, including [responses](./responses.mdx) are listed +in stories under the `action` key. + +You can use a response from your domain as an action by listing it as one +in a story. Similarly, you can indicate that a story should call a custom action by including +the name of the custom action from the `actions` list in your domain. + + +### Events + +During training, Rasa does not call the action server. This means +that your assistant's dialogue management model doesn't know which events a custom +action will return. + +Because of this, events such as setting a slot or activating/deactivating a form have to be +explicitly written out as part of the stories. For more info, see the documentation on +[Events](./action-server/events.mdx). + +#### Slot Events + +Slot events are written under `slot_was_set` in a story. If this slot is set +inside a custom action, add the `slot_was_set` event immediately following the +custom action call. If your custom action resets a slot value to `None`, the +corresponding event for that would look like this: + +```yaml-rasa {6-7} +stories: +- story: set slot to none + steps: + # ... other story steps + - action: my_custom_action + - slot_was_set: + - my_slot: null +``` + +#### Form Events + +There are three kinds of events that need to be kept in mind while dealing with +forms in stories. + +* A form action event (e.g. `- action: restaurant_form`) is used in the beginning when first starting a form, and also while resuming the form action when the form is already active. + +* A form activation event (e.g. `- active_loop: restaurant_form`) is used right after the first form action event. + +* A form deactivation event (e.g. `- active_loop: null`), which is used to deactivate the form. + +:::note writing form stories +In order to get around the pitfall of forgetting to add events, the recommended +way to write these stories is to use [interactive learning](./writing-stories.mdx#using-interactive-learning). + +::: + +## Checkpoints and OR statements + +Checkpoints and OR statements should be used with caution, if at all. +There is usually a better way to achieve what you want by using [Rules](./rules.mdx) or +the [ResponseSelector](./components.mdx#responseselector). + +### Checkpoints + +You can use checkpoints to modularize and simplify your training +data. Checkpoints can be useful, but **do not overuse them**. Using +lots of checkpoints can quickly make your example stories hard to +understand, and will slow down training. + +Here is an example of stories that +contain checkpoints: + +```yaml-rasa +stories: +- story: beginning of flow + steps: + - intent: greet + - action: action_ask_user_question + - checkpoint: check_asked_question + +- story: handle user affirm + steps: + - checkpoint: check_asked_question + - intent: affirm + - action: action_handle_affirmation + - checkpoint: check_flow_finished + +- story: handle user deny + steps: + - checkpoint: check_asked_question + - intent: deny + - action: action_handle_denial + - checkpoint: check_flow_finished + +- story: finish flow + steps: + - checkpoint: check_flow_finished + - intent: goodbye + - action: utter_goodbye +``` + +:::note +Unlike regular stories, checkpoints are not restricted to starting with +user input. As long as the checkpoint is inserted at the right points +in the main stories, the first event can be a custom action or a response +as well. + +::: + +### Or Statements + +Another way to write shorter stories, or to handle multiple intents +or slot events the same way, is to use an `or` statement. +For example, if you ask the user to confirm something, and you want +to treat the `affirm` and `thankyou` intents in the same way. +The story below will be converted into two stories at training time: + +```yaml-rasa +stories: +- story: + steps: + # ... previous steps + - action: utter_ask_confirm + - or: + - intent: affirm + - intent: thankyou + - action: action_handle_affirmation +``` + +You can also use `or` statements with slot events. +The following means the story requires that the current value for +the `name` slot is set and is either `joe` or `bob`: + +```yaml-rasa +stories: +- story: + steps: + - intent: greet + - action: utter_greet + - intent: tell_name + - or: + - slot_was_set: + - name: joe + - slot_was_set: + - name: bob + # ... next actions +``` + +`or` statements can be useful, but if you are using a +lot of them, it is probably better to restructure your domain and/or intents. +Overusing OR statements will slow down training. + +## Test Conversation Format + +The test conversation format is a format that combines both NLU data and stories +into a single file for evaluation. Read more about this format in [Testing Your Assistant](./testing-your-assistant.mdx). + +:::caution testing only +This format is only used for testing and cannot be used for training. + +::: + + +## End-to-end Training + +:::info New in 2.2 +End-to-end training is an experimental feature. +We introduce experimental features to get feedback from our community, so we encourage you to try it out! +However, the functionality might be changed or removed in the future. +If you have feedback (positive or negative) please share it with us on the [Rasa Forum](https://forum.rasa.com). + +::: + +With end-to-end training, you do not have to deal with the specific +intents of the messages that are extracted by the NLU pipeline +or with separate `utter_` responses in the domain file. +Instead, you can include the text of the user messages and/or bot responses directly in your stories. +See the [training data format](./training-data-format.mdx#end-to-end-training) +for detailed description of how to write end-to-end stories. + +You can mix training data in the end-to-end format with labeled training data which has +`intent`s and `action`s specified: Stories can have some steps defined by intents/actions +and other steps defined directly by user or bot utterances. + +We call it end-to-end training because policies can consume and predict actual text. +For end-to-end user inputs, intents classified by the NLU pipeline +and extracted entities are ignored. + + +Only [Rule Policy](./policies.mdx#rule-policy) +and [TED Policy](./policies.mdx#ted-policy) allow end-to-end training. + +- `RulePolicy` uses simple string matching during prediction. Namely, + rules based on user text will only match if the user + text strings inside your rules and input during prediction are identical. + +- `TEDPolicy` passes user text through an additional Neural Network to create + hidden representations of the text. In order to obtain robust performance you + need to provide enough training stories to capture a variety of user texts for any + end-to-end dialogue turn. + +Rasa policies are trained for next utterance selection. +The only difference to creating `utter_` response is how `TEDPolicy` featurizes +bot utterances. +In case of an `utter_` action, `TEDPolicy` sees only the name of the action, while +if you provide actual utterance using `bot` key, +`TEDPolicy` will featurize it as textual input depending on the NLU configuration. +This can help in case of similar utterances in slightly different situations. +However, this can also make things harder to learn because the fact that different +utterances have similar texts make it easier for `TEDPolicy` to confuse these utterances. + +End-to-end training requires significantly more parameters in `TEDPolicy`. +Therefore, training an end-to-end model might require significant computational +resources depending on how many end-to-end turns you have in your stories. diff --git a/docs/docs/telemetry/events.json b/docs/docs/telemetry/events.json new file mode 100644 index 0000000..3d64c75 --- /dev/null +++ b/docs/docs/telemetry/events.json @@ -0,0 +1,560 @@ +{ + "sections": [ + "Model Training", + "Model Testing", + "End-to-End Testing", + "Model Serving", + "Markers Extraction", + "Data Handling", + "Miscellaneous" + ], + "defaultSection": "Miscellaneous", + "events": { + "Training Started": { + "description": "A training of a Rasa machine learning model got started. The event provides information on aggregated training data statistics.", + "type": "object", + "section": "Model Training", + "properties": { + "language": { + "type": "string", + "minLength": 1, + "description": "Language model is trained with, e.g. 'en'." + }, + "training_id": { + "type": "string", + "minLength": 1, + "description": "Generated unique identifier for this training." + }, + "type": { + "type": "string", + "description": "Type of model trained, either 'nlu', 'core' or 'rasa'." + }, + "pipeline": { + "oneOf": [ + {"type": "null"}, + {"type": "string"}, + {"type": "array", "items": {"type": "object"}} + ], + "description": "List of the pipeline configurations used for training." + }, + "policies": { + "oneOf": [ + {"type": "null"}, + {"type": "array", "items": {"type": "object"}} + ], + "description": "List of the policy configurations used for training." + }, + "train_schema": { + "oneOf": [ + {"type": "null"}, + {"type": "object"} + ], + "description": "Training graph schema for graph recipe" + }, + "predict_schema": { + "oneOf": [ + {"type": "null"}, + {"type": "object"} + ], + "description": "Predict graph schema for graph recipe" + }, + "num_intent_examples": { + "type": "integer", + "description": "Number of NLU examples." + }, + "num_entity_examples": { + "type": "integer", + "description": "Number of entity examples." + }, + "num_actions": { + "type": "integer", + "description": "Number of actions defined in the domain." + }, + "num_templates": { + "type": "integer", + "description": "Number of templates or responses defined in the domain." + }, + "num_conditional_response_variations": { + "type": "integer", + "description": "Number of conditional response variations defined in the domain." + }, + "num_slot_mappings": { + "type": "integer", + "description": "Number of total slot mappings defined in the domain." + }, + "num_custom_slot_mappings": { + "type": "integer", + "description": "Number of custom slot mappings defined in the domain." + }, + "num_conditional_slot_mappings": { + "type": "integer", + "description": "Number of slot mappings with conditions attached defined in the domain." + }, + "num_slots": { + "type": "integer", + "description": "Number of slots defined in the domain." + }, + "num_forms": { + "type": "integer", + "description": "Number of forms defined in the domain." + }, + "num_intents": { + "type": "integer", + "description": "Number of intents defined in the domain." + }, + "num_entities": { + "type": "integer", + "description": "Number of entities defined in the domain." + }, + "num_story_steps": { + "type": "integer", + "description": "Number of story steps available." + }, + "num_lookup_tables": { + "type": "integer", + "description": "Number of different lookup tables." + }, + "num_synonyms": { + "type": "integer", + "description": "Total number of entity synonyms defined." + }, + "num_regexes": { + "type": "integer", + "description": "Total number of regexes defined." + }, + "is_finetuning": { + "type": "boolean", + "description": "True if a model is trained by finetuning an existing model." + }, + "recipe": { + "type": "string", + "description": "Recipe used in training the model, either 'default.v1' or 'graph.v1'." + } + }, + "additionalProperties": false, + "required": [ + "language", + "training_id", + "type", + "pipeline", + "policies", + "train_schema", + "predict_schema", + "num_intent_examples", + "num_entity_examples", + "num_actions", + "num_templates", + "num_slots", + "num_forms", + "num_intents", + "num_entities", + "num_story_steps", + "num_lookup_tables", + "num_synonyms", + "num_regexes" + ] + }, + "Training Completed": { + "description": "The training of a Rasa machine learning model finished. The event provides information about the resulting model.", + "type": "object", + "section": "Model Training", + "properties": { + "training_id": { + "type": "string", + "minLength": 1, + "description": "Generated unique identifier for this training. Can be used to join with 'Training Started'." + }, + "type": { + "type": "string", + "description": "Type of model trained, either 'nlu', 'core' or 'rasa'." + }, + "runtime": { + "type": "integer", + "description": "The time in seconds it took to train the model." + } + }, + "additionalProperties": false, + "required": [ + "training_id", + "type", + "runtime" + ] + }, + "Telemetry Disabled": { + "description": "Triggered when telemetry reporting gets disabled. Last event sent before disabling telemetry. This event is not sent, if the user never enabled telemetry reporting before deactivating it." + }, + "Training Data Split": { + "description": "Triggered when training data gets split.", + "type": "object", + "section": "Data Handling", + "properties": { + "fraction": { + "type": "number", + "description": "Percentage of the data which goes into training data (the rest goes into the test set)." + }, + "type": { + "type": "string", + "description": "Type of data, either 'nlu', 'core' or 'rasa'." + } + }, + "additionalProperties": false, + "required": [ + "fraction", + "type" + ] + }, + "Training Data Validated": { + "description": "Triggered when training data gets validated.", + "type": "object", + "section": "Data Handling", + "properties": { + "validation_success": { + "type": "boolean", + "description": "whether the validation was successful" + } + }, + "additionalProperties": false, + "required": [ + "validation_success" + ] + }, + "Training Data Converted": { + "description": "Triggered when training data gets converted.", + "type": "object", + "section": "Data Handling", + "properties": { + "output_format": { + "type": "string", + "description": "target format of the converter" + }, + "type": { + "type": "string", + "description": "Type of data, either 'nlu', 'core', 'config' or 'nlg'." + } + }, + "additionalProperties": false, + "required": [ + "output_format", + "type" + ] + }, + "Tracker Exported": { + "description": "Triggered when conversations get exported from a tracker store through an event broker.", + "type": "object", + "section": "Data Handling", + "properties": { + "event_broker": { + "type": "string", + "description": "Name of the used event broker" + }, + "tracker_store": { + "type": "string", + "description": "Name of the used tracker store" + }, + "number_of_exported_events": { + "type": "integer", + "description": "Number of events exported through the event broker" + } + }, + "additionalProperties": false, + "required": [ + "event_broker", + "tracker_store", + "number_of_exported_events" + ] + }, + "Interactive Learning Started": { + "description": "Triggered when an interactive learning session got started.", + "type": "object", + "section": "Model Serving", + "properties": { + "skip_visualization": { + "type": "boolean", + "description": "Whether the visualization of stories should be shown during the interactive learning session" + }, + "save_in_e2e": { + "type": "boolean", + "description": "Whether the data should be stored in end-to-end format" + } + }, + "additionalProperties": false, + "required": [ + "skip_visualization", + "save_in_e2e" + ] + }, + "Server Started": { + "description": "Triggered when a Rasa Open Source server gets started.", + "type": "object", + "section": "Model Serving", + "properties": { + "input_channels": { + "type": "array", + "items": {"type": "string"}, + "description": "Names of the used input channels" + }, + "api_enabled": { + "type": "boolean", + "description": "Indicator if the API is enabled or if only the input channel is running" + }, + "number_of_workers": { + "type": "integer", + "description": "Amount of Sanic workers started as part of the server" + }, + "endpoints_nlg": { + "type": ["string", "null"], + "description": "Type of the used NLG endpoint" + }, + "endpoints_nlu": { + "type": ["string", "null"], + "description": "Type of the used NLU endpoint" + }, + "endpoints_action_server": { + "type": ["string", "null"], + "description": "Type of the used action server" + }, + "endpoints_model_server": { + "type": ["string", "null"], + "description": "Type of the used model server" + }, + "endpoints_tracker_store": { + "type": ["string", "null"], + "description": "Type of the used tracker store" + }, + "endpoints_lock_store": { + "type": ["string", "null"], + "description": "Type of the used lock store" + }, + "endpoints_event_broker": { + "type": ["string", "null"], + "description": "Type of the used event broker" + }, + "project": { + "type": ["string", "null"], + "description": "Hash of the deployed model the server is started with" + } + }, + "additionalProperties": false, + "required": [ + "input_channels", + "api_enabled", + "number_of_workers", + "endpoints_nlg", + "endpoints_nlu", + "endpoints_action_server", + "endpoints_model_server", + "endpoints_tracker_store", + "endpoints_lock_store", + "endpoints_event_broker", + "project" + ] + }, + "Project Created": { + "description": "Triggered when a project is created using rasa init.", + "type": "object", + "properties": { + "init_directory": { + "type": "string", + "description": "Hash of the directory path the project is created in" + } + }, + "additionalProperties": false, + "required": [ + "init_directory" + ] + }, + "Shell Started": { + "description": "Triggered when a shell session is started to talk to a trained bot.", + "type": "object", + "section": "Model Serving", + "properties": { + "type": { + "type": "string", + "description": "Type of the model, either 'nlu', 'core' or 'rasa'." + } + }, + "additionalProperties": false, + "required": [ + "type" + ] + }, + "Rasa X Local Started": { + "section": "Model Serving", + "description": "Triggered when a Rasa X is started in local mode." + }, + "Story Visualization Started": { + "section": "Data Handling", + "description": "Triggered when stories are getting visualized." + }, + "Model Core Tested": { + "description": "Triggered when a Core model is getting tested.", + "type": "object", + "section": "Model Testing", + "properties": { + "project": { + "type": ["string", "null"], + "description": "Fingerprint of the project the tested model got trained in." + }, + "num_story_steps": { + "type": "integer", + "description": "Number of story steps used for testing" + }, + "end_to_end": { + "type": "boolean", + "description": "Indicates if tests are running in end-to-end mode, testing message handling and dialogue handling at the same time" + } + }, + "additionalProperties": false, + "required": [ + "project", + "num_story_steps", + "end_to_end" + ] + }, + "Model NLU Tested": { + "description": "Triggered when an NLU model is getting tested.", + "type": "object", + "section": "Model Testing", + "properties": { + "num_intent_examples": { + "type": "integer", + "description": "Number of NLU examples." + }, + "num_entity_examples": { + "type": "integer", + "description": "Number of entity examples." + }, + "num_lookup_tables": { + "type": "integer", + "description": "Number of different lookup tables." + }, + "num_synonyms": { + "type": "integer", + "description": "Total number of entity synonyms defined." + }, + "num_regexes": { + "type": "integer", + "description": "Total number of regexes defined." + } + }, + "additionalProperties": false, + "required": [ + "num_intent_examples", + "num_entity_examples", + "num_lookup_tables", + "num_synonyms", + "num_regexes" + ] + }, + "Markers Extraction Initiated": { + "description": "Triggered when marker extraction has been initiated.", + "type": "object", + "section": "Markers Extraction", + "properties": { + "strategy": { + "type": "string", + "description": "Strategy to use when selecting trackers to extract from." + }, + "only_extract": { + "type": "boolean", + "description": "Indicates if path to write out statistics hasn't been specified." + }, + "seed": { + "type": "boolean", + "description": "The seed to initialise the random number generator for use with the 'sample' strategy." + }, + "count": { + "type": ["integer", "null"], + "description": "Number of trackers to extract from (for any strategy except 'all')." + } + }, + "additionalProperties": false, + "required": [ + "strategy", + "only_extract", + "seed", + "count" + ] + }, + "Markers Extracted": { + "description": "Triggered when markers have been extracted.", + "type": "object", + "section": "Markers Extraction", + "properties": { + "trackers_count": { + "type": "integer", + "description": "Number of processed trackers." + } + }, + "additionalProperties": false, + "required": [ + "trackers_count" + ] + }, + "Markers Parsed": { + "description": "Triggered when markers have been successfully parsed.", + "type": "object", + "section": "Markers Extraction", + "properties": { + "marker_count": { + "type": "integer", + "description": "Number of parsed markers." + }, + "max_depth": { + "type": "integer", + "description": "Maximum depth of the parsed markers." + }, + "branching_factor": { + "type": "integer", + "description": "Maximum number of children of any of the parsed markers." + } + }, + "additionalProperties": false, + "required": [ + "marker_count", + "max_depth", + "branching_factor" + ] + }, + "Markers Statistics Computed": { + "description": "Triggered when marker statistics have been computed.", + "type": "object", + "section": "Markers Extraction", + "properties": { + "trackers_count": { + "type": "integer", + "description": "Number of processed trackers." + } + }, + "additionalProperties": false, + "required": [ + "trackers_count" + ] + }, + "End-to-End Testing Started": { + "description": "Triggered when end-to-end testing has been started.", + "type": "object", + "section": "End-to-End Testing", + "properties": { + "number_of_test_cases": { + "type": "integer", + "description": "Number of test cases to be run." + }, + "number_of_fixtures": { + "type": "integer", + "description": "Number of fixtures defined globally." + }, + "uses_fixtures": { + "type": "boolean", + "description": "Indicates if any fixtures have been defined globally." + } + }, + "additionalProperties": false, + "required": [ + "number_of_test_cases", + "number_of_fixtures", + "uses_fixtures" + ] + } + } +} diff --git a/docs/docs/telemetry/telemetry.mdx b/docs/docs/telemetry/telemetry.mdx new file mode 100644 index 0000000..6d5c9ac --- /dev/null +++ b/docs/docs/telemetry/telemetry.mdx @@ -0,0 +1,130 @@ +--- +id: telemetry +sidebar_label: Rasa Telemetry +title: Rasa Telemetry +abstract: | + Rasa uses telemetry to report anonymous usage information. This information + is essential to help improve Rasa for all users. +--- + +For the team working on Rasa it is important to understand +how the product is used. It allows us to properly prioritize our research +efforts and feature development. + +You will be notified about the telemetry reporting when running Rasa +for the first time. + +## How to opt-out + +You can opt out of telemetry reporting at any time by running the command: +```bash +rasa telemetry disable +``` + +or by defining `RASA_TELEMETRY_ENABLED=false` as an environment variable. +If you want to enable reporting again, you can run: +```bash +rasa telemetry enable +``` + +## Why do we use telemetry reporting? + +**Anonymous** telemetry data allow us to prioritize our research efforts +and feature development based on usage. We want to collect aggregated +information on usage and reliability so that we can ensure a high-quality product. + +So how will we use the reported telemetry data? Here are some examples +of what we use the data for: + +- We will be able to know which languages, pipelines and policies are used. + This will enable us to direct our research efforts towards text and + dialogue handling projects that will have the biggest impact for our users. +- We will be able to know data set sizes and general structure (e.g. the number + of intents). This allows us to better test our software on different types + of data sets and optimize the frameworks performance. +- We will be able to get more detail on the types of errors you are running + into while building an assistant (e.g. initialization, training, etc.). + This will let us improve the quality of our framework and better focus our + time on solving more common, frustrating issues. + +## What about sensitive data? + +Your sensitive data never leaves your machine. We: +- **don't** report any personal identifiable information +- **don't** report your training data +- **don't** report any messages your assistant receives or sends + +:::note Inspect what is reported +You can view all the telemetry information that is reported +by defining the environment variable `RASA_TELEMETRY_DEBUG=true`, for example when running the train command: +```bash +RASA_TELEMETRY_DEBUG=true rasa train +``` +When you set `RASA_TELEMETRY_DEBUG` no information will be sent to any server, +instead it will be logged to the commandline as a json dump for you to inspect. +::: + +## What do we report? + +Rasa reports aggregated usage details, command invocations, performance +measurements and errors. +We use the telemetry data to better understand usage patterns. The reported data +will directly allow us to better decide how to design future features +and prioritize current work. + +Specifically, we collect the following information for all telemetry events: + +- Type of the reported event (e.g. *Training Started*) +- Rasa machine ID: This is generated with a UUID and stored in the global Rasa + config at `~/.config/rasa/global.yml` and sent as `metrics_id` +- One-way hash of the current working directory or a hash of the git remote +- General OS level information (operating system, number of CPUs, number of + GPUs and whether the command is run inside a CI) +- Current Rasa and Python version +- Whether the command is run inside a Docker container +- Hash of the license (if you are using Rasa Pro) + +Here is an example report that shows the data reported to Rasa after running +`rasa train`: +```json +{ + "userId": "38d23c36c9be443281196080fcdd707d", + "event": "Training Started", + "properties": { + "language": "en", + "num_intent_examples": 68, + "num_entity_examples": 0, + "num_actions": 17, + "num_templates": 6, + "num_conditional_response_variations": 5, + "num_slot_mappings": 10, + "num_custom_slot_mappings": 2, + "num_conditional_slot_mappings": 3, + "num_slots": 0, + "num_forms": 0, + "num_intents": 6, + "num_entities": 0, + "num_story_steps": 5, + "num_lookup_tables": 0, + "num_synonyms": 0, + "num_regexes": 0, + "metrics_id": "38d23c36c9be443281196080fcdd707d" + }, + "context": { + "os": { + "name": "Darwin", + "version": "19.4.0" + }, + "ci": false, + "project": "a0a7178e6e5f9e6484c5cfa3ea4497ffc0c96d0ad3f3ad8e9399a1edd88e3cf4", + "python": "3.7.5", + "rasa_open_source": "2.0.0", + "cpu": 16, + "docker": false, + "license_hash": "t1a7170e6e5f9e6484c5cfa3ea4497ffc0c96a0ad3f3ad8e9399adadd88e3cf5" + } +} +``` + +We **cannot identify individual users** from the dataset. It is anonymized and +untraceable back to the user. diff --git a/docs/docs/testing-your-assistant.mdx b/docs/docs/testing-your-assistant.mdx new file mode 100644 index 0000000..c355f0d --- /dev/null +++ b/docs/docs/testing-your-assistant.mdx @@ -0,0 +1,676 @@ +--- +id: testing-your-assistant +sidebar_label: Testing Your Assistant +title: Testing Your Assistant +abstract: Rasa lets you validate and test dialogues end-to-end by running through + test stories. In addition, you can + also test the dialogue management and the message processing (NLU) + separately. +--- +import useBaseUrl from '@docusaurus/useBaseUrl'; + +import RasaProLabel from "@theme/RasaProLabel"; + +import RasaProBanner from "@theme/RasaProBanner"; + +## Validating Data and Stories + +Data validation verifies that no mistakes or major inconsistencies appear in your domain, NLU +data, or story data. To validate your data, have your CI run this command: + +```bash +rasa data validate +``` + +If you pass a `max_history` value to one or more policies in your `config.yml` file, provide the +smallest of those values as + +```bash +rasa data validate --max-history <max_history> +``` + +If data validation results in errors, training a model can also fail or yield bad performance, so it's +always good to run this check before training a model. By including the +`--fail-on-warnings` flag, this step will fail on warnings indicating more minor issues. + +:::note +Running `rasa data validate` does **not** test if your [rules](./rules.mdx) are consistent with your stories. +However, during training, the `RulePolicy` checks for conflicts between rules and stories. Any such conflict will abort training. +::: + +To read more about the validator and all of the available options, see [the documentation for +`rasa data validate`](./command-line-interface.mdx#rasa-data-validate). + +## Writing Test Stories + +Testing your trained model on test stories is the best way to have confidence in how your assistant +will act in certain situations. Written in a modified story +format, test stories allow you to provide entire conversations and test that, given certain +user input, your model will behave in the expected manner. This is especially +important as you start introducing more complicated stories from user +conversations. + +Test stories are like +the stories in your training data, but include the user message as well. + +Here are some examples: + +<Tabs values={[{"label": "Basics", "value": "basics"}, {"label": "Button Payload", "value": "buttons"}, {"label": "Custom Actions", "value": "customactions"}, {"label": "Forms Happy Path", "value": "formshappypath"}, {"label": "Forms Unhappy Path", "value": "formsunhappypath"}]} defaultValue="basics"> + <TabItem value="basics"> + + ```yaml-rasa title="tests/test_stories.yml" {5,9,13} + stories: + - story: A basic story test + steps: + - user: | + hello + intent: greet + - action: utter_ask_howcanhelp + - user: | + show me [chinese]{"entity": "cuisine"} restaurants + intent: inform + - action: utter_ask_location + - user: | + in [Paris]{"entity": "location"} + intent: inform + - action: utter_ask_price + ``` + + </TabItem> + <TabItem value="buttons"> + + ```yaml-rasa title="tests/test_stories.yml" {8,11} + stories: + - story: A test where a user clicks on a button with payload + steps: + - user: | + hello + intent: greet + - action: utter_ask_howcanhelp + - user: /inform{{"cuisine":"chinese"}} + intent: inform + - action: utter_ask_location + - user: /inform{{"location":"Paris"}} + intent: inform + - action: utter_ask_price + ``` + + + </TabItem> + <TabItem value="customactions"> + + ```yaml-rasa title="tests/test_stories.yml" {5,12} + stories: + - story: A test where a custom action returns events + steps: + - user: | + hey + intent: greet + - action: my_custom_action + - slot_was_set: + - my_slot: "value added by custom action" + - action: utter_ask_age + - user: | + thanks + intent: thankyou + - action: utter_no_worries + ``` + + + </TabItem> + <TabItem value="formshappypath"> + + ```yaml-rasa title="tests/test_stories.yml" {5,9,14,20} + stories: + - story: A test story with a form + steps: + - user: | + hi + intent: greet + - action: utter_greet + - user: | + im looking for a restaurant + intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - user: | + [afghan](cuisine) food + intent: inform + - action: restaurant_form + - active_loop: null + - action: utter_slots_values + - user: | + thanks + intent: thankyou + - action: utter_no_worries + ``` + + </TabItem> + <TabItem value="formsunhappypath"> + + ```yaml-rasa title="tests/test_stories.yml" {5,9,14,21} + stories: + - story: A test story with unexpected input during a form + steps: + - user: | + hi + intent: greet + - action: utter_greet + - user: | + im looking for a restaurant + intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - user: | + How's the weather? + intent: chitchat + - action: utter_chitchat + - action: restaurant_form + - active_loop: null + - action: utter_slots_values + - user: | + thanks + intent: thankyou + - action: utter_no_worries + ``` + + </TabItem> + <TabItem value="entities"> + + ```yaml-rasa title="tests/test_stories.yml" {5,9,13} + stories: + - story: A basic test story with multiple entities for a single token + steps: + - user: | + hello + intent: greet + - action: utter_ask_howcanhelp + - user: | + show me [chinese]{"entity": "cuisine"} restaurants + intent: inform + - action: utter_ask_location + - user: | + in [Paris][{"entity": "location"}, {"entity": "city"}] + intent: inform + - action: utter_ask_price + ``` + </TabItem> +</Tabs> + +By default, the command will run tests on stories from any files with names starting with `test_`. You can also provide +a specific test stories file or directory with the `--stories` argument. +You can test your assistant against them by running: + +```bash +rasa test +``` + +Conversation testing is only as thorough and accurate as the test +cases you include, so you should continue to grow your set of test stories +as you make improvements to your assistant. A good rule of thumb to follow is that you should aim for your test stories +to be representative of the true distribution of real conversations. + +See the [CLI documentation on `rasa test`](./command-line-interface.mdx#rasa-test) for +more configuration options. + +:::caution Testing Custom Actions +[Custom Actions](./custom-actions.mdx) are not executed as part of test stories. If your custom +actions append any events to the conversation, this has to be reflected in your test story +(e.g. by adding `slot_was_set` events to your test story). + +To test the code of your custom actions, you should write unit tests +for them and include these tests in your [CI/CD pipeline](./setting-up-ci-cd.mdx). + +::: + +## Evaluating an NLU Model + +In addition to testing stories, you can also test the natural language understanding (NLU) model separately. +Once your assistant is deployed in the real world, it will be processing messages that it hasn't seen +in the training data. To simulate this, you should always set aside some part of your data for testing. +You can either: + +1) [use a held out test set](#using-a-held-out-test-set) by shuffling and splitting your NLU data + +2) [use cross-validation](#using-cross-validation), which automatically creates +multiple train/test splits + +### Using a Held-Out Test Set + +If you use the train-test +set approach, it is best to [shuffle and split your data](./command-line-interface.mdx#rasa-data-split) +using `rasa data split` every time you evaluate your model, as +opposed to using a static NLU test set, which can easily become outdated. + +You can split your NLU data into train and test sets using: + +```bash +rasa data split nlu +``` + +Next, you can see how well your trained NLU model predicts the +data from the test set you generated, using: + +```bash {2} +rasa test nlu + --nlu train_test_split/test_data.yml +``` + + +### Using Cross-Validation + +If you've made significant changes to your NLU training data (e.g. +splitting an intent into two intents or adding a lot of training examples), you should run a +full NLU evaluation using cross-validation. Cross-validation automatically creates +multiple train/test splits and averages the results of evaluations on each train/test split. +This means all your data is evaluated during cross-validation, making cross-validation the most +thorough way to automatically test your NLU model. + + +To run NLU testing in cross-validation mode run: + +```bash {3} +rasa test nlu + --nlu data/nlu + --cross-validation +``` + +You can specify the number of test/train splits used with the `-f/--folds` flag: + + +```bash {4} +rasa test nlu + --nlu data/nlu + --cross-validation + --folds 5 +``` + +Note that during cross-validation, the NLU model will be trained for each fold, +so cross-validation with a large data set and a high number of folds can be time-consuming. +On a small data set, a high number of folds can result in too few examples per intent being available for each test split. + +On the other hand, if you specify a low number of folds, your data will be split into much larger chunks, +and there will be proportionally less data to train on for each fold. + +Choose a number of folds that balances both considerations for your dataset size. + +:::tip hyperparameter tuning +To further improve your model check out this +[tutorial on hyperparameter tuning](https://blog.rasa.com/rasa-nlu-in-depth-part-3-hyperparameters/). +::: + + +### Comparing NLU Pipelines + +To get the most out of your training data, you should train and evaluate your model on different pipelines +and different amounts of training data. + +To do so, pass multiple configuration files to the `rasa test` command: + +```bash {2} +rasa test nlu --nlu data/nlu.yml + --config config_1.yml config_2.yml +``` + +This performs several steps: +1. Create a global 80% train / 20% test split from `data/nlu.yml`. +2. Exclude a certain percentage of data from the global train split. +3. Train models for each configuration on remaining training data. +4. Evaluate each model on the global test split. + +The above process is repeated with different percentages of training data in step 2 +to give you an idea of how each pipeline will behave if you increase the amount of training data. +Since training is not completely deterministic, the whole process is repeated +three times for each configuration specified. + +A graph with the mean and standard deviations of +[f1-scores](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.f1_score.html) +across all runs is plotted. +The f1-score graph, along with all train/test sets, the trained models, classification and error reports, +will be saved into a folder called `nlu_comparison_results`. + +Inspecting the f1-score graph can help you understand if you have enough data for your NLU model. +If the graph shows that f1-score is still improving when all of the training data is used, +it may improve further with more data. But if f1-score has plateaued when all training data is used, +adding more data may not help. + +If you want to change the number of runs or exclusion percentages, you can: + +```bash {3} +rasa test nlu --nlu data/nlu.yml + --config config_1.yml config_2.yml + --runs 4 --percentages 0 25 50 70 90 +``` + +### Interpreting the Output + +#### Intent Classifiers + +The `rasa test` script will produce a report (`intent_report.json`), confusion matrix (`intent_confusion_matrix.png`) +and confidence histogram (`intent_histogram.png`) for your intent classification model. + +The report logs [precision](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.precision_score.html), +[recall](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.recall_score.html) and +[f1-score](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.f1_score.html) for each intent, +as well as providing an overall average. You can save these reports as JSON files using the `--report` argument. + +The confusion matrix shows which intents are mistaken for others. +Any samples which have been incorrectly predicted are logged and saved to a file called `errors.json` for easier debugging. +<div align="center"> + <img alt="image" src={useBaseUrl("/img/intent_confusion_matrix_example.png")} width="70%" /> +</div> + +The histogram allows you to visualize the confidence for all predictions, +with the correct and incorrect predictions being displayed by blue and red bars respectively. +Improving the quality of your training data will move the blue histogram bars up the plot and the +red histogram bars down the plot. It should also help in reducing the number of red histogram bars itself. +<div align="center"> + <img alt="image" src={useBaseUrl("/img/intent_histogram_example.png")} width="70%" /> +</div> + +#### Response Selectors + +`rasa test` evaluates response selectors in the same way that it evaluates intent classifiers, producing a +report (`response_selection_report.json`), confusion matrix (`response_selection_confusion_matrix.png`), +confidence histogram (`response_selection_histogram.png`) and errors (`response_selection_errors.json`). +If your pipeline includes multiple response selectors, they are evaluated in a single report. + +The report logs precision, recall and f1 measure for +each sub-intent of a [retrieval intent](./glossary.mdx#retrieval-intent) and provides an overall average. +You can save these reports as JSON files using the `--report` argument. + +#### Entity Extraction + +`rasa test` reports recall, precision, and f1-score for each entity type that +your trainable entity extractors are trained to recognize. + +Only trainable entity extractors, such as the `DIETClassifier` and `CRFEntityExtractor` are +evaluated by `rasa test`. Pretrained extractors like the `DucklingHTTPExtractor` are not evaluated. + +If you have multiple entity extractors in your pipeline, or use some custom extractors, +multiple entities might be associated with the same token. In this case, +you can use a list notation in the test files, such as + +```yaml +stories: +- story: A basic test story with multiple entities for a single token + steps: + - user: | + I like [ice cream][{\"entity\": \"food\"}, {\"entity\": \"desert\"}] + intent: inform + # ... +``` + +:::caution incorrect entity annotations +If any of your entities are incorrectly annotated, your evaluation may fail. One common problem +is that an entity cannot stop or start inside a token. +For example, if you have an example for a `name` entity +like `[Brian](name)'s house`, this is only valid if your tokenizer splits `Brian's` into +multiple tokens. + +::: + +#### Entity Scoring + +To evaluate entity extraction we apply a simple tag-based approach. We don't consider +[BILOU tags](nlu-training-data.mdx#bilou-entity-tagging) exactly, but only the +entity type tags on a per token basis. For location entity like “near Alexanderplatz” we +expect the labels `LOC LOC` instead of the BILOU-based `B-LOC L-LOC`. + +Our approach is more lenient when it comes to evaluation, as it rewards +partial extraction and does not penalize the splitting of entities. +For example, given the aforementioned entity “near Alexanderplatz” and a system that extracts +“Alexanderplatz”, our approach rewards the extraction of “Alexanderplatz” and penalizes the missed out word “near”. + +The BILOU-based approach, however, would label this as a complete failure since it expects Alexanderplatz +to be labeled as a last token in an entity (`L-LOC`) instead of a single token entity (`U-LOC`). Note also that +a split extraction of “near” and “Alexanderplatz” would get full scores on our approach and zero on the +BILOU-based one. + +Here's a comparison between the two scoring mechanisms for the phrase “near Alexanderplatz tonight”: + +| extracted |Simple tags (score) | BILOU tags (score) | +|----------------------------------------------------|--------------------|-----------------------| +|`[near Alexanderplatz](loc) [tonight](time)` |loc loc time (3) |B-loc L-loc U-time (3) | +|`[near](loc) [Alexanderplatz](loc) [tonight](time)` |loc loc time (3) |U-loc U-loc U-time (1) | +|`near [Alexanderplatz](loc) [tonight](time)` |O loc time (2) |O U-loc U-time (1) | +|`[near](loc) Alexanderplatz [tonight](time)` |loc O time (2) |U-loc O U-time (1) | +|`[near Alexanderplatz tonight](loc)` |loc loc loc (2) |B-loc I-loc L-loc (1) | + + +## Evaluating a Dialogue Model + +You can evaluate your trained dialogue model on a set of test stories +by using the test script: + +```bash +rasa test core --stories test_stories.yml --out results +``` + +This will print any failed stories to `results/failed_test_stories.yml`. +A story fails if at least one of the actions was predicted incorrectly. + +The test script will also save a confusion matrix to a file called +`results/story_confmat.pdf`. For each action in your domain, the confusion +matrix shows how often the action was correctly predicted and how often an +incorrect action was predicted instead. + +### Interpreting the generated warnings + +The test script will also generate a warnings file called `results/stories_with_warnings.yml`. +This file contains all test stories for which [`action_unlikely_intent`](./default-actions.mdx#action_unlikely_intent) +was predicted at any conversation turn but all actions from the original story were predicted correctly. +However, if a test story originally included an `action_unlikely_intent`, for example to ensure [a rule is designed to +trigger the conversation path after an `action_unlikely_intent`](./default-actions.mdx#customization-1) but the ensemble of +policies failed to do so, then the corresponding story will end up in `results/failed_test_stories.yml` as +a failed story. + +The stories are sorted by the severity of `action_unlikely_intent`'s prediction. +This severity is calculated by [`UnexpecTEDIntentPolicy`](./policies.mdx#unexpected-intent-policy) itself at prediction time. +The higher the severity, the more unlikely is the intent and hence reviewing that particular +conversation path becomes more critical. + +Note, that `action_unlikely_intent` is predicted by +`UnexpecTEDIntentPolicy` which employs a machine learning based model +under the hood and hence can result in false warnings as well. You can choose to ignore such warnings +if the conversation paths in these stories are already present in the training stories. + +### Comparing Policy Configurations + +To choose a configuration for your dialogue model, or to choose hyperparameters for a +specific policy, you want to measure how well your dialogue model will generalize +to conversations it hasn't seen before. Especially in the beginning +of a project, when you don't have a lot of real conversations to train +your bot on, you may not want to exclude some to use as a test set. + +Rasa has some scripts to help you choose and fine-tune your policy configuration. +Once you are happy with it, you can then train your final configuration on your +full data set. + +To do this, you first have to train models for your different +configurations. Create two (or more) config files including the policies you want to +compare, and then provide them to the train script to train your models: + +```bash +rasa train core -c config_1.yml config_2.yml \ + --out comparison_models --runs 3 --percentages 0 5 25 50 70 95 +``` + +Similar to how the [NLU model was evaluated](./testing-your-assistant.mdx#comparing-nlu-pipelines), the above +command trains the dialogue model on multiple configurations and different amounts of training data. +For each config file provided, Rasa will train dialogue models +with 0, 5, 25, 50, 70 and 95% of your training stories excluded from the training +data. This is repeated three times to ensure consistent results. + +Once this script has finished, you can pass multiple models to the test script +to compare the models you just trained: + +```bash +rasa test core -m comparison_models --stories stories_folder + --out comparison_results --evaluate-model-directory +``` + +This will evaluate each model on the stories in `stories_folder` +(can be either training or test set) and plot some graphs +to show you which policy performs best. Since the previous train command +excluded some amount of training data to train each model, +the above test command can measure how well your model predicts the held-out stories. +To compare single policies, create config files containing only one policy each. + +:::note +This training process can take a long time, so we'd suggest letting it run +somewhere in the background where it can't be interrupted. + +::: + +### Testing Action Code + +The approach used to test your action code will depend on how it is +implemented. For example, if you connect to external APIs, you should write integration tests to ensure +that those APIs respond as expected to common inputs. However you test your action code, you should +include these tests in your CI pipeline so that they run each time you make changes. + +If you have any questions or problems, please share them with us in the dedicated +[testing section on our forum](https://forum.rasa.com/tags/testing)! + + +## End-To-End Testing + +<RasaProLabel /> + +<RasaProBanner /> + +:::info New in 3.5 + +You can now use end-to-end testing to test your assistant as a whole, including dialogue management and custom actions. + +::: + +End-to-end testing is an enhanced and comprehensive CLI-based testing tool that allows you to test conversation scenarios +with different pre-configured contexts, execute [custom actions](./action-server/actions.mdx), verify [response](./responses.mdx) +texts or names, and assert when [slots](./domain.mdx#slots) are filled. + +End-to-end testing is not limited to testing only the NLU or the dialogue model and allows you to design +effective acceptance or integration tests. The main features of end-to-end testing are: +- integration with the [action server](./action-server/running-action-server.mdx): you can execute custom actions in your +tests; the prerequisite is to start the action server in the background. +- test parametrization (e.g. different user profiles or other external factors): you can define multiple test fixtures +with different pre-filled slots and re-use them in your tests. +- verifying response texts or names: you can assert that the bot response text (including [interpolated responses](./responses.mdx#using-variables-in-responses) +with slot values and [conditional response variations](./responses.mdx#conditional-response-variations)) or `utter` name +is as expected. +- asserting that the bot sets the slot value as expected. + +### How to write test cases + +To write test cases, you need to create a YAML file inside the `tests` directory of your project. The name of the file +should be `e2e_test_cases.yml`. You can also create a subdirectory inside the `tests` directory and place your test case +YAML files there. These files will be automatically discovered and run by Rasa Pro, however you need to provide +the path to the subdirectory as positional argument to the `rasa test e2e` command. + +Each input file must contain the `test_cases` required key. The value of this key is a list of test cases. +Each test case must include a name given to the `test_case` key and a list of test steps given to the `steps` key. +A step can be either one of the following: + +- `user`: a user message +- `bot`: a bot response +- `utter`: a domain utterance +- `slot_was_set`: a slot name and the value it was set to + +You can also add the optional `fixtures` top level key if pre-filled slots are required for setting any individual +test case context. The `fixtures` key is a list of fixture names (which must be unique) and each fixture name maps to a +list of slot key-value pairs. If one of the test cases requires a pre-filled slot, you can add the fixture name to the +test case definition, by adding the fixture name to the optional `fixtures` key in the test case. The slot key-value +pairs will be set before the test case is run. + +The following example shows a test case file with fixtures and two test cases that make use of all available steps: + +```yaml +fixtures: + - premium: # name of the fixture must be provided and be unique + - membership_type: premium # every fixture can contain multiple slot key-value pairs + - logged_in: True + - standard: + - logged_in: True + - membership_type: standard + +test_cases: + - test_case: "test_premium_booking" + fixtures: + - premium # re-use the name of the fixture provided in fixtures section + steps: + - user: "Hi!" + - bot: "Welcome back! How can I help you?" + - user: "I want to book a trip." + - utter: utter_ask_location + - user: "I would like to travel to Lisbon." + - slot_was_set: + - location: "Lisbon" + - utter: utter_ask_date + - user: "I would like to travel on 22nd of June." + - slot_was_set: + - travel_date: "2023-06-22" + - bot: "Great! I will book your trip to Lisbon on 22nd of June." + - bot: "You saved 20% by being a premium member." + + - test_case: "test_anonymous_booking" + steps: + - user: "Hi!" + - bot: "Hey! How can I help you?" + - user: "I want to book a trip." + - utter: utter_ask_location + - user: "I would like to travel to Paris." + - slot_was_set: + - location: "Paris" + - utter: utter_ask_date + - user: "I would like to travel on 2nd of April." + - slot_was_set: + - travel_date: "2023-04-02" + - bot: "Great! I will book your trip to Paris on 2nd of April." + - bot: "You can also choose to save 20% by becoming a premium member." +``` + +:::note + +If you are using multiple consecutive `slot_was_set` steps in your test case, the order in which these are defined must +match the order in which the slots are filled in the dialogue. + +::: + +### How to run the tests + +To run the end-to-end tests locally or in the CI pipeline, use the [`rasa test e2e` command](./command-line-interface.mdx#rasa-test-e2e). +The command takes the following arguments: +- positional argument for the path to the test cases file or directory containing the test cases: `rasa test e2e <path>` +If unspecified, the default path is `tests/e2e_test_cases.yml`. +- optional argument for the trained model: `--model <path>` +- optional argument for retrieving the trained model from [remote storage](./model-storage.mdx#load-model-from-cloud): `--remote-storage <remote-storage-location>` +- optional argument for the `endpoints.yml` file: `--endpoints <path>` +- optional argument for stopping the test run at first failure: `rasa test e2e --fail-fast` +- optional argument for exporting the test results to `e2e_results.yml` file: `rasa test e2e -o` + +#### Testing custom actions + +If the test cases include custom actions, start the action server first: + +```bash +rasa run actions && rasa test e2e +``` + +### How to interpret the output + +By default, the results are always printed to `stdout` and the command will exit with exit code `0` (if all tests passed) +or `1` (in case of test failures). + +The output style is inspired by `pytest`: + +- Failed test cases will be stacked, each highlighting the difference in identified mismatches in similar style to `git diff`: +expected messages will be preceded by `+` prefix, while actual messages will be preceded by `-` prefix. +- The short test summary includes a list of every failed test case name and file location in a new line. + +If `-o` flag is specified in the command, the results are also written to the `tests/e2e_results.yml` file, which will +contain a list of test results with the following keys: +- `name`: the name of the test case +- `pass_status`: the status of the test case, either `True` or `False` +- `expected_steps`: the expected test steps +- `difference`: a list of differences between the expected and actual test steps diff --git a/docs/docs/tracker-stores.mdx b/docs/docs/tracker-stores.mdx new file mode 100644 index 0000000..f048558 --- /dev/null +++ b/docs/docs/tracker-stores.mdx @@ -0,0 +1,415 @@ +--- +id: tracker-stores +sidebar_label: Tracker Stores +title: Tracker Stores +description: All conversations are stored within a tracker store. Read how Rasa provides implementations for different store types out of the box. +abstract: Your assistant's conversations are stored within a tracker store. + Rasa provides implementations for different store types out of the box, + or you can create your own custom one. +--- + +<!-- this file is version specific, do not use `@site/...` syntax --> +import variables from './variables.json'; + +## InMemoryTrackerStore (default) + +`InMemoryTrackerStore` is the default tracker store. It is used if no other +tracker store is configured. It stores the conversation history in memory. + +:::note +As this store keeps all history in memory, the entire history is lost if you restart the Rasa server. + +::: + + + +### **Configuration** + +No configuration is needed to use the `InMemoryTrackerStore`. + + +## SQLTrackerStore + +You can use an `SQLTrackerStore` to store your assistant's conversation history in an SQL database. + +### Configuration + +To set up Rasa with SQL the following steps are required: + +1. Add required configuration to your `endpoints.yml`: + + ```yaml-rasa title="endpoints.yml" + tracker_store: + type: SQL + dialect: "postgresql" # the dialect used to interact with the db + url: "" # (optional) host of the sql db, e.g. "localhost" + db: "rasa" # path to your db + username: # username used for authentication + password: # password used for authentication + query: # optional dictionary to be added as a query string to the connection URL + driver: my-driver + ``` + +2. To start the Rasa server using your SQL backend, + add the `--endpoints` flag, e.g.: + + ```bash + rasa run -m models --endpoints endpoints.yml + ``` + +3. If deploying your model in Docker Compose, add the service to your `docker-compose.yml`: + + ```yaml-rasa title="docker-compose.yml" + postgres: + image: postgres:latest + ``` + + To route requests to the new service, make sure that the `url` in your `endpoints.yml` + references the service name: + + ```yaml-rasa {4} title="endpoints.yml" + tracker_store: + type: SQL + dialect: "postgresql" # the dialect used to interact with the db + url: "postgres" + db: "rasa" # path to your db + username: # username used for authentication + password: # password used for authentication + query: # optional dictionary to be added as a query string to the connection URL + driver: my-driver + ``` + +#### Configuration Parameters + +* `domain` (default: `None`): Domain object associated with this tracker store + +* `dialect` (default: `sqlite`): The dialect used to communicate with your SQL backend. Consult the [SQLAlchemy docs](https://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls) for available dialects. + +* `url` (default: `None`): URL of your SQL server + +* `port` (default: `None`): Port of your SQL server + +* `db` (default: `rasa.db`): The path to the database to be used + +* `username` (default: `None`): The username which is used for authentication + +* `password` (default: `None`): The password which is used for authentication + +* `event_broker` (default: `None`): Event broker to publish events to + +* `login_db` (default: `None`): Alternative database name to which initially connect, and create the database specified by `db` (PostgreSQL only) + +* `query` (default: `None`): Dictionary of options to be passed to the dialect and/or the DBAPI upon connect + + + +#### Compatible Databases + +The following databases are officially compatible with the `SQLTrackerStore`: + * PostgreSQL + * Oracle > 11.0 + * SQLite + +#### Configuring Oracle + +To use the SQLTrackerStore with Oracle, there are a few additional steps. +First, create a database `tracker` in your Oracle database and create a user with access to it. +Create a sequence in the database with the following command, where username is the user you created +(read more about creating sequences in the [Oracle Documentation](https://docs.oracle.com/cd/B28359_01/server.111/b28310/views002.htm#ADMIN11794)): + +```sql +CREATE SEQUENCE username.events_seq; +``` + +Next you have to extend the Rasa image to include the necessary drivers and clients. +First download the [Oracle Instant Client](https://www.oracle.com/database/technologies/instant-client/linux-x86-64-downloads.html), +rename it to `oracle.rpm` and store it in the directory from where you'll be building the docker image. +Copy the following into a file called `Dockerfile`: + +<pre><code parentName="pre" className="language-bash"> +{`FROM rasa/rasa:${variables.release}-full + +# Switch to root user to install packages +USER root + +RUN apt-get update -qq && apt-get install -y --no-install-recommends alien libaio1 && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +# Copy in oracle instaclient +# https://www.oracle.com/database/technologies/instant-client/linux-x86-64-downloads.html +COPY oracle.rpm oracle.rpm + +# Install the Python wrapper library for the Oracle drivers +RUN pip install cx-Oracle + +# Install Oracle client libraries +RUN alien -i oracle.rpm + +USER 1001`} +</code></pre> + +Then build the docker image: + +<pre><code parentName="pre" className="language-bash"> +{`docker build . -t rasa-oracle:${variables.release}-oracle-full`} +</code></pre> + +Now you can configure the tracker store in the `endpoints.yml` as described above, +and start the container. The `dialect` parameter with this setup will be `oracle+cx_oracle`. +Read more about [Deploying a Rasa Assistant](./deploy/introduction.mdx). + + +## RedisTrackerStore + +You can store your assistant's conversation history in [Redis](https://redis.io/) by using the +`RedisTrackerStore`. +Redis is a fast in-memory key-value store which can optionally also persist data. + + + +### Configuration + +To set up Rasa with Redis the following steps are required: + +1. Start your Redis instance + +2. Add required configuration to your `endpoints.yml`: + + ```yaml-rasa title="endpoints.yml" + tracker_store: + type: redis + url: <url of the redis instance, e.g. localhost> + port: <port of your redis instance, usually 6379> + key_prefix: <alphanumeric value to prepend to tracker store keys> + db: <number of your database within redis, e.g. 0> + password: <password used for authentication> + use_ssl: <whether or not the communication is encrypted, default `false`> + ``` + + ```bash + rasa run -m models --endpoints endpoints.yml + ``` + +4. If deploying your model in Docker Compose, add the service to your `docker-compose.yml`: + + ```yaml-rasa title="docker-compose.yml" + redis: + image: redis:latest + ``` + + To route requests to the new service, make sure that the `url` in your `endpoints.yml` + references the service name: + + ```yaml-rasa {3} title="endpoints.yml" + tracker_store: + type: redis + url: <url of the redis instance, e.g. localhost> + port: <port of your redis instance, usually 6379> + db: <number of your database within redis, e.g. 0> + key_prefix: <alphanumeric value to prepend to tracker store keys> + password: <password used for authentication> + use_ssl: <whether or not the communication is encrypted, default `false`> + ``` + +* `url` (default: `localhost`): The url of your redis instance + +* `port` (default: `6379`): The port which redis is running on + +* `db` (default: `0`): The number of your redis database + +* `key_prefix` (default: `None`): The prefix to prepend to tracker store keys. Must + be alphanumeric + +* `username` (default: `None`): Username used for authentication + +* `password` (default: `None`): Password used for authentication +(`None` equals no authentication) + +* `record_exp` (default: `None`): Record expiry in seconds + +* `use_ssl` (default: `False`): whether or not to use SSL for transit encryption + +## MongoTrackerStore + + +You can store your assistant's conversation history in [MongoDB](https://www.mongodb.com/) using the `MongoTrackerStore`. +MongoDB is a free and open-source cross-platform document-oriented NoSQL database. + +### Configuration + +1. Start your MongoDB instance. + +2. Add required configuration to your `endpoints.yml`: + + ```yaml-rasa title="endpoints.yml" + tracker_store: + type: mongod + url: <url to your mongo instance, e.g. mongodb://localhost:27017> + db: <name of the db within your mongo instance, e.g. rasa> + username: <username used for authentication> + password: <password used for authentication> + auth_source: <database name associated with the user's credentials> + ``` + + You can also add more advanced configurations (like enabling ssl) by appending + a parameter to the url field, e.g. `mongodb://localhost:27017/?ssl=true`. + +3. To start the Rasa server using your configured MongoDB instance, + add the `--endpoints` flag, for example: + + ```bash + rasa run -m models --endpoints endpoints.yml + ``` + +4. If deploying your model in Docker Compose, add the service to your `docker-compose.yml`: + + ```yaml-rasa title="docker-compose.yml" + mongo: + image: mongo + environment: + MONGO_INITDB_ROOT_USERNAME: rasa + MONGO_INITDB_ROOT_PASSWORD: example + mongo-express: # this service is a MongoDB UI, and is optional + image: mongo-express + ports: + - 8081:8081 + environment: + ME_CONFIG_MONGODB_ADMINUSERNAME: rasa + ME_CONFIG_MONGODB_ADMINPASSWORD: example + ``` + + To route requests to this database, make sure to set the `url` in your `endpoints.yml` as the service name, + and specify the user and password: + + ```yaml-rasa {3,5,6} title="endpoints.yml" + tracker_store: + type: mongod + url: mongodb://mongo:27017 + db: <name of the db within your mongo instance, e.g. rasa> + username: <username used for authentication> + password: <password used for authentication> + auth_source: <database name associated with the user's credentials> + ``` + + + +#### Configuration Parameters + +* `url` (default: `mongodb://localhost:27017`): URL of your MongoDB + +* `db` (default: `rasa`): The database name which should be used + +* `username` (default: `0`): The username which is used for authentication + +* `password` (default: `None`): The password which is used for authentication + +* `auth_source` (default: `admin`): database name associated with the user's credentials. + +* `collection` (default: `conversations`): The collection name which is +used to store the conversations + + +## DynamoTrackerStore + +You can store your assistant's conversation history in +[DynamoDB](https://aws.amazon.com/dynamodb/) by using a `DynamoTrackerStore`. +DynamoDB is a hosted NoSQL database offered by Amazon Web Services (AWS). + + +### Configuration + +1. Start your DynamoDB instance. + +2. Add required configuration to your `endpoints.yml`: + + ```yaml-rasa title="endpoints.yml" + tracker_store: + type: dynamo + table_name: <name of the table to create, e.g. rasa> + region: <name of the region associated with the client> + ``` + +3. To start the Rasa server using your configured `DynamoDB` instance, + add the `--endpoints` flag, e.g.: + + ```bash + rasa run -m models --endpoints endpoints.yml + ``` + + +#### Configuration Parameters + +* `table_name` (default: `states`): name of the DynamoDB table + +* `region` (default: `us-east-1`): name of the region associated with the client + + +## Custom Tracker Store + +If you need a tracker store which is not available out of the box, you can implement your own. +This is done by extending the base class `TrackerStore` and one of the provided mixin classes that implement the +`serialise_tracker` method: `SerializedTrackerAsText` or `SerializedTrackerAsDict`. + +To write a custom tracker store, extend the `TrackerStore` base class. Your constructor has to +provide a parameter `host`. +The constructor also needs to make a `super` call to the base class `TrackerStore` using `domain` and `event_broker` arguments: + +``` +super().__init__(domain, event_broker, **kwargs) +``` + +Your custom tracker store class must also implement the following three methods: +- `save`: saves the conversation to the tracker store. [(source code - see for signature)](https://github.com/RasaHQ/rasa/blob/main/rasa/core/tracker_store.py#L243). +- `retrieve`: retrieves tracker for the latest conversation session. [(source code - see for signature)](https://github.com/RasaHQ/rasa/blob/main/rasa/core/tracker_store.py#L261). +- `keys`: returns the set of values for the tracker store's primary key. [(source code - see for signature)](https://github.com/RasaHQ/rasa/blob/main/rasa/core/tracker_store.py#L319). + +### Configuration + +Put the module path to your custom tracker store and the parameters you require in your `endpoints.yml`: + + ```yaml-rasa title="endpoints.yml" + tracker_store: + type: path.to.your.module.Class + url: localhost + a_parameter: a value + another_parameter: another value + ``` + +If you are deploying in Docker Compose, you have two options to add this store to Rasa: +extending the Rasa image to include the module, or mounting the module as volume. + + Make sure to add the corresponding service as well. For example, mounting it as a volume would look like so: + + ```yaml {5,6,7} title="docker-compose.yml" + rasa: + <existing rasa service configuration> + volumes: + - <existing volume mappings, if there are any> + - ./path/to/your/module.py:/app/path/to/your/module.py + custom-tracker-store: + image: custom-image:tag + ``` + + ```yaml-rasa {3} title="endpoints.yml" + tracker_store: + type: path.to.your.module.Class + url: custom-tracker-store + a_parameter: a value + another_parameter: another value + ``` + +## Fallback Tracker Store + +In case the primary tracker store configured in `endpoints.yml` becomes unavailable, the rasa agent will issue an +error message and fall back on the `InMemoryTrackerStore` implementation. A new dialogue session will be started for +each turn, which will be saved separately in the `InMemoryTrackerStore` fallback. + +As soon as the primary tracker store comes back up, it will replace the fallback tracker store and save the +conversation from this point going forward. However, note that any previous states saved in the `InMemoryTrackerStore` +fallback will be lost. + +:::warning Using the same redis instance as lock-store and tracker store + +You must not use the same Redis instance as both lock store and tracker store. +If the Redis instance becomes unavailable, the conversation will hang because there is no fall back mechanism +implemented for the lock store (as it is for the tracker store interfaces). +::: diff --git a/docs/docs/training-data-format.mdx b/docs/docs/training-data-format.mdx new file mode 100644 index 0000000..4eb628b --- /dev/null +++ b/docs/docs/training-data-format.mdx @@ -0,0 +1,804 @@ +--- +id: training-data-format +sidebar_label: Training Data Format +title: Training Data Format +description: Description of the YAML format for training data +abstract: This page describes the different types of training data + that go into a Rasa assistant and how this training data is structured. +--- + +## Overview + +Rasa uses [YAML](https://yaml.org/spec/1.2/spec.html) as +a unified and extendable way to manage all training data, +including NLU data, stories and rules. + +You can split the training data over any number of YAML files, +and each file can contain any combination of NLU data, stories, and rules. +The training data parser determines the training data type using top level keys. + +The [domain](glossary.mdx#domain) uses +the same YAML format as the training data and can also be split across +multiple files or combined in one file. The domain includes +the definitions for [responses](responses.mdx) and [forms](forms.mdx). +See the [documentation for the domain](domain.mdx) for information on how to format your domain file. + +:::note Legacy Formats +Looking for Markdown data format? It's removed in Rasa 3.0, but +you can still find the documentation for <a href="https://legacy-docs-v1.rasa.com/nlu/training-data-format/" target="_blank" rel="nofollow noopener noreferrer">markdown NLU data</a> and <a href="https://legacy-docs-v1.rasa.com/core/stories/" target="_blank" rel="nofollow noopener noreferrer">markdown stories</a>. +If you still have your training data in Markdown format then the recommended approach is to use Rasa 2.x +to convert your data from Markdown to YAML. [The migration guide](./migration-guide.mdx#training-data-files) +explains how to do this. +::: + + +### High-Level Structure + +Each file can contain one or more **keys** with corresponding training +data. One file can contain multiple keys, but each key can only appear +once in a single file. The available keys are: + +- `version` +- `nlu` +- `stories` +- `rules` + +You should specify the `version` key in all YAML training data files. +If you don't specify a version key in your training data file, Rasa +will assume you are using the latest training data format specification supported +by the version of Rasa you have installed. +Training data files with a Rasa version greater than the version you have +installed on your machine will be skipped. +Currently, the latest training data format specification for Rasa 3.x is 3.1. + +### Example + +Here's a short example which keeps all training data in a single file: + +```yaml-rasa +version: "3.1" + +nlu: +- intent: greet + examples: | + - Hey + - Hi + - hey there [Sara](name) + +- intent: faq/language + examples: | + - What language do you speak? + - Do you only handle english? + +stories: +- story: greet and faq + steps: + - intent: greet + - action: utter_greet + - intent: faq + - action: utter_faq + +rules: +- rule: Greet user + steps: + - intent: greet + - action: utter_greet + +``` + +To specify your test stories, you need to put them into a separate file: +```yaml-rasa title="tests/test_stories.yml" +stories: +- story: greet and ask language +- steps: + - user: | + hey + intent: greet + - action: utter_greet + - user: | + what language do you speak + intent: faq/language + - action: utter_faq +``` +[Test stories](#test-stories) use the same format as the story training data and should be placed +in a separate file with the prefix `test_`. + +:::note The `|` symbol +As shown in the above examples, the `user` and `examples` keys are followed by `|` +(pipe) symbol. In YAML `|` identifies multi-line strings with preserved indentation. +This helps to keep special symbols like `"`, `'` and others still available in the +training examples. +::: + +## NLU Training Data + +[NLU](glossary.mdx#nlu) training data consists of example user utterances categorized by +[intent](glossary.mdx#intent). Training examples can also include [entities](glossary.mdx#entity). Entities are structured +pieces of information that can be extracted from a user's message. You can also +add extra information such as regular expressions and lookup tables to your +training data to help the model identify intents and entities correctly. + +NLU training data is defined under the `nlu` key. Items that can be added under this key are: + +- [Training examples](#training-examples) grouped by user intent e.g. + optionally with annotated [entities](#entities) + +```yaml-rasa +nlu: +- intent: check_balance + examples: | + - What's my [credit](account) balance? + - What's the balance on my [credit card account]{"entity":"account","value":"credit"} +``` + +- [Synonyms](#synonyms) + +```yaml-rasa +nlu: +- synonym: credit + examples: | + - credit card account + - credit account +``` + +- [Regular expressions](#regular-expressions) + +```yaml-rasa +nlu: +- regex: account_number + examples: | + - \d{10,12} +``` + +- [Lookup tables](#lookup-tables) + +```yaml-rasa +nlu: +- lookup: banks + examples: | + - JPMC + - Comerica + - Bank of America +``` + +### Training Examples + +Training examples are grouped by [intent](glossary.mdx#intent) and listed under the +`examples` key. Usually, you'll list one example per line as follows: + +```yaml-rasa +nlu: +- intent: greet + examples: | + - hey + - hi + - whats up +``` + +However, it's also possible to use an extended format if you have a custom NLU component and need metadata for your examples: + + +```yaml-rasa +nlu: +- intent: greet + examples: + - text: | + hi + metadata: + sentiment: neutral + - text: | + hey there! +``` + +The `metadata` key can contain arbitrary key-value data that is tied to an example and +accessible by the components in the NLU pipeline. +In the example above, the sentiment metadata could be used by a custom component in +the pipeline for sentiment analysis. + +You can also specify this metadata at the intent level: + +```yaml-rasa +nlu: +- intent: greet + metadata: + sentiment: neutral + examples: + - text: | + hi + - text: | + hey there! +``` + +In this case, the content of the `metadata` key is passed to every intent example. + + +If you want to specify [retrieval intents](glossary.mdx#retrieval-intent), then your NLU examples will look as follows: +```yaml-rasa +nlu: +- intent: chitchat/ask_name + examples: | + - What is your name? + - May I know your name? + - What do people call you? + - Do you have a name for yourself? + +- intent: chitchat/ask_weather + examples: | + - What's the weather like today? + - Does it look sunny outside today? + - Oh, do you mind checking the weather for me please? + - I like sunny days in Berlin. +``` +All retrieval intents have a suffix +added to them which identifies a particular response key for your assistant. In the +above example, `ask_name` and `ask_weather` are the suffixes. The suffix is separated from +the retrieval intent name by a `/` delimiter. + +:::note Special meaning of `/` +As shown in the above examples, the `/` symbol is reserved as a delimiter to separate +retrieval intents from their associated response keys. Make sure not to use it in the +name of your intents. +::: + + +### Entities + +[Entities](glossary.mdx#entity) are structured pieces of information that can be extracted from a user's message. + +Entities are annotated in training examples with the entity's name. +In addition to the entity name, you can annotate an entity with [synonyms](nlu-training-data.mdx#synonyms), [roles, or groups](nlu-training-data.mdx#entities-roles-and-groups). + +In training examples, entity annotation would look like this: + +```yaml-rasa +nlu: +- intent: check_balance + examples: | + - how much do I have on my [savings](account) account + - how much money is in my [checking]{"entity": "account"} account + - What's the balance on my [credit card account]{"entity":"account","value":"credit"} + +``` + +The full possible syntax for annotating an entity is: + +```text +[<entity-text>]{"entity": "<entity name>", "role": "<role name>", "group": "<group name>", "value": "<entity synonym>"} +``` + +The keywords `role`, `group`, and `value` are optional in this notation. +The `value` field refers to synonyms. To understand what the labels `role` and `group` are +for, see the section on [entity roles and groups](./nlu-training-data.mdx#entities-roles-and-groups). + + +### Synonyms + +Synonyms normalize your training data by mapping an +extracted entity to a value other than the literal text extracted. +You can define synonyms using the format: + +```yaml-rasa +nlu: +- synonym: credit + examples: | + - credit card account + - credit account +``` + +You can also define synonyms in-line in your training examples by +specifying the `value` of the entity: + +```yaml-rasa +nlu: +- intent: check_balance + examples: | + - how much do I have on my [credit card account]{"entity": "account", "value": "credit"} + - how much do I owe on my [credit account]{"entity": "account", "value": "credit"} +``` + +Read more about synonyms on the [NLU Training Data page](./nlu-training-data.mdx#synonyms). + +### Regular Expressions + +You can use regular expressions to improve intent classification and +entity extraction using the [`RegexFeaturizer`](components.mdx#regexfeaturizer) and [`RegexEntityExtractor`](components.mdx#regexentityextractor) components. + +The format for defining a regular expression is as follows: + +```yaml-rasa +nlu: +- regex: account_number + examples: | + - \d{10,12} +``` + +Here `account_number` is the name of the regular expression. When used as features for the `RegexFeaturizer` the name of the regular expression does not matter. When using the `RegexEntityExtractor`, the name of the regular expression should match the name of the entity you want to extract. + + +Read more about when and how to use regular expressions with each component on the [NLU Training Data page](./nlu-training-data.mdx#regular-expressions). + + + +### Lookup Tables + +Lookup tables are lists of words used to generate +case-insensitive regular expression patterns. The format is as follows: + +```yaml-rasa +nlu: +- lookup: banks + examples: | + - JPMC + - Bank of America +``` + +When you supply a lookup table in your training data, the contents of that table +are combined into one large regular expression. This regex is used to check +each training example to see if it contains matches for entries in the +lookup table. + +Lookup table regexes are processed identically to the regular +expressions directly specified in the training data and can be used +either with the [RegexFeaturizer](components.mdx#regexfeaturizer) +or with the [RegexEntityExtractor](components.mdx#regexentityextractor). +The name of the lookup table is subject to the same constraints as the +name of a regex feature. + +Read more about using lookup tables on the [NLU Training Data page](./nlu-training-data.mdx#lookup-tables). + +## Conversation Training Data + +Stories and rules are both representations of conversations between a user +and a conversational assistant. They are used to train the dialogue management +model. [Stories](stories.mdx) are used to train a machine learning model +to identify patterns in conversations and generalize to unseen conversation paths. +[Rules](rules.mdx) describe small pieces of conversations that should always +follow the same path and are used to train the +[RulePolicy](policies.mdx#rule-policy). + + +### Stories + +Stories are composed of: + + - `story`: The story's name. The name is arbitrary and not used in training; + you can use it as a human-readable reference for the story. + - `metadata`: arbitrary and optional, not used in training, + you can use it to store relevant information about the story + like e.g. the author + - a list of `steps`: The user messages and actions that make up the story + +For example: + +```yaml-rasa +stories: +- story: Greet the user + metadata: + author: Somebody + key: value + steps: + # list of steps + - intent: greet + - action: utter_greet +``` + +Each step can be one of the following: + + - A [user message](#user-messages), represented by **intent** and **entities**. + - An [or statement](#or-statement), which includes two or more user messages under it. + - A bot [action](#actions). + - A [form](#forms). + - A [slot was set](#slots) event. + - A [checkpoint](#checkpoints), which connects the story to another story. + + +#### User Messages + +All user messages are specified with the `intent:` +key and an optional `entities:` key. + +While writing stories, you do not have to deal with the specific +contents of the messages that the users send. Instead, you can take +advantage of the output from the NLU pipeline, which uses +a combination of an intent and entities to refer to all possible +messages the users can send with the same meaning. + +User messages follow the format: + +```yaml-rasa {4-6} +stories: +- story: user message structure + steps: + - intent: intent_name # Required + entities: # Optional + - entity_name: entity_value + - action: action_name +``` + +For example, to represent the sentence +`I want to check my credit balance`, where `credit` is an entity: + +```yaml-rasa {4-6} +stories: +- story: story with entities + steps: + - intent: account_balance + entities: + - account_type: credit + - action: action_credit_account_balance +``` + +It is important to include the entities here as well because the +policies learn to predict the next action based on a *combination* of +both the intent and entities (you can, however, change this behavior +using the [`use_entities`](#entities) attribute). + + +#### Actions + +All actions executed by the bot are specified with the `action:` key followed +by the name of the action. +While writing stories, you will encounter two types of actions: + + +1. [Responses](domain.mdx#responses): start with `utter_` and + send a specific message to the user. e.g. + +```yaml-rasa {5} +stories: +- story: story with a response + steps: + - intent: greet + - action: utter_greet +``` + +2. [Custom actions](custom-actions.mdx): start with `action_`, run + arbitrary code and send any number of messages (or none). + +```yaml-rasa {5} +stories: +- story: story with a custom action + steps: + - intent: feedback + - action: action_store_feedback +``` + +#### Forms + + +A [form](glossary.mdx#form) is a specific kind of custom action that contains the logic to loop over +a set of required slots and ask the user for this information. You +[define a form](forms.mdx#defining-a-form) in the `forms` section in your domain. +Once defined, you should specify the [happy path](glossary.mdx#happy--unhappy-paths) +for a form as a [rule](forms.mdx). You should include interruptions of forms or +other "unhappy paths" in stories so that the model can +generalize to unseen conversation sequences. +As a step in a story, a form takes the following format: + + +```yaml-rasa +stories: +- story: story with a form + steps: + - intent: find_restaurant + - action: restaurant_form # Activate the form + - active_loop: restaurant_form # This form is currently active + - active_loop: null # Form complete, no form is active + - action: utter_restaurant_found +``` + + +The `action` step activates the form and begins looping over the required slots. The `active_loop: restaurant_form` +step indicates that there is a currently active form. Much like a `slot_was_set` step, +a `form` step doesn't **set** a form to active but indicates that it should already be activated. +In the same way, the `active_loop: null` step indicates that no form should be active before the subsequent +steps are taken. + +A form can be interrupted and remain active; in this case the interruption should come after the +`action: <form to activate>` step and be followed by the `active_loop: <active form>` step. +An interruption of a form could look like this: + +```yaml-rasa +stories: +- story: interrupted food + steps: + - intent: request_restaurant + - action: restaurant_form + - intent: chitchat + - action: utter_chitchat + - active_loop: restaurant_form + - active_loop: null + - action: utter_slots_values +``` + + +#### Slots + +A slot event is specified under the key `slot_was_set:` with the +slot name and optionally the slot's value. + +**[Slots](domain.mdx#slots)** act as the bots memory. +Slots are **set** by either the default action [`action_extract_slots`](./default-actions.mdx#action_extract_slots) according to the +[slot mappings](./domain.mdx#slot-mappings) specified in the domain, or by custom actions. +They are **referenced** by stories in `slot_was_set` steps. For example: + +```yaml-rasa {5-6} +stories: +- story: story with a slot + steps: + - intent: celebrate_bot + - slot_was_set: + - feedback_value: positive + - action: utter_yay +``` + +This means the story requires that the current value for the `feedback_value` +slot be `positive` for the conversation to continue as specified. + +Whether or not you need to include the slot's value depends on the +[slot type](domain.mdx#slot-types) and whether the value can or should +influence the dialogue. If the value doesn't matter, as is the case for e.g. `text` slots, +you can list only the slot's name: + +```yaml-rasa {5-6} +stories: +- story: story with a slot + steps: + - intent: greet + - slot_was_set: + - name + - action: utter_greet_user_by_name +``` + +The initial value for any slot by default is `null`, and you can use it to check if the slot was not set: + +```yaml-rasa {5-6} +stories: +- story: French cuisine + steps: + - intent: inform + - slot_was_set: + - cuisine: null +``` + +:::note How slots work +Stories do not **set** slots. The slot must be set by the default action `action_extract_slots` if a slot mapping applies, or custom +action **before** the `slot_was_set` step. +::: + + +#### Checkpoints + +Checkpoints are specified with the `checkpoint:` key, either at the beginning +or the end of a story. + + +Checkpoints are ways to connect stories together. They can be either the first +or the last step in a story. If they are the last step in a story, that story +will be connected to each other story that starts with the checkpoint of the +same name when the model is trained. Here is an example of a story that ends +with a checkpoint, and one that starts with the same checkpoint: + +```yaml-rasa +stories: +- story: story_with_a_checkpoint_1 + steps: + - intent: greet + - action: utter_greet + - checkpoint: greet_checkpoint + +- story: story_with_a_checkpoint_2 + steps: + - checkpoint: greet_checkpoint + - intent: book_flight + - action: action_book_flight +``` + +Checkpoints at the beginning of stories can also be conditional on +slots being set, for example: + +```yaml-rasa {6-8} +stories: +- story: story_with_a_conditional_checkpoint + steps: + - checkpoint: greet_checkpoint + # This checkpoint should only apply if slots are set to the specified value + slot_was_set: + - context_scenario: holiday + - holiday_name: thanksgiving + - intent: greet + - action: utter_greet_thanksgiving +``` + + +Checkpoints can help simplify your training data and reduce redundancy in it, +but **do not overuse them**. Using lots of checkpoints can quickly make your +stories hard to understand. It makes sense to use them if a sequence of steps +is repeated often in different stories, but stories without checkpoints +are easier to read and write. + +#### OR statement + +`or` steps are ways to handle multiple intents or slot events the same way, +without writing a separate story for each intent. For example, if you ask the user to +confirm something, you might want to treat the `affirm` and `thankyou` intents in the +same way. Stories with `or` steps will be converted into multiple +separate stories at training time. +For example, the following story would be converted to two stories at training time: + +```yaml-rasa {6-8} +stories: +- story: story with OR + steps: + - intent: signup_newsletter + - action: utter_ask_confirm + - or: + - intent: affirm + - intent: thanks + - action: action_signup_newsletter +``` + +You can also use `or` statements with slot events. +The following means the story requires that the current value for +the `name` slot is set and is either `joe` or `bob`. This story +would be converted to two stories at training time. + +```yaml-rasa {6-8} +stories: +- story: + steps: + - intent: greet + - action: utter_greet + - intent: tell_name + - or: + - slot_was_set: + - name: joe + - slot_was_set: + - name: bob + # ... next actions +``` + +Just like checkpoints, OR statements can be useful, but if you are using a lot of them, +it is probably better to restructure your domain and/or intents. + +:::warning Don't overuse +Overusing these features (both checkpoints and OR statements) will slow down training. +::: + +### Rules + +Rules are listed under the `rules` key and look similar to stories. A rule also has a `steps` +key, which contains a list of the same steps as stories do. Rules can additionally +contain the `conversation_started` and `conditions` keys. These are used to specify conditions +under which the rule should apply. + +A rule that with a condition looks like this: + +```yaml-rasa +rules: +- rule: Only say `hey` when the user provided a name + condition: + - slot_was_set: + - user_provided_name: true + steps: + - intent: greet + - action: utter_greet +``` + +For more information about writing rules, see [Rules](rules.mdx#writing-a-rule). + +## Test Stories + +Test stories check if a message is classified correctly as well as the action predictions. + +Test stories use the same format as [stories](#stories), +except that user message steps can include a `user` to specify the actual +text and entity annotations of the user message. Here's an example of a +test story: + +```yaml-rasa +stories: +- story: A basic end-to-end test + steps: + - user: | + hey + intent: greet + - action: utter_ask_howcanhelp + - user: | + show me [chinese]{"entity": "cuisine"} restaurants + intent: inform + - action: utter_ask_location + - user: | + in [Paris]{"entity": "location"} + intent: inform + - action: utter_ask_price +``` + +You can run the tests using the following command: +```bash +rasa test +``` + +If you want to know more about testing head over to +[Testing Your Assistant](testing-your-assistant.mdx). + + +## End-to-end Training + +:::info New in 2.2 +End-to-end training is an experimental feature. +We introduce experimental features to get feedback from our community, so we encourage you to try it out! +However, the functionality might be changed or removed in the future. +If you have feedback (positive or negative) please share it with us on the [Rasa Forum](https://forum.rasa.com). + +::: + +With [end-to-end training](stories.mdx#end-to-end-training), you do not have to deal with the specific +intents of the messages that are extracted by the NLU pipeline. +Instead, you can put the text of the user message directly in the stories, +by using `user` key. + +These end-to-end user messages follow the format: + +```yaml-rasa {4} +stories: +- story: user message structure + steps: + - user: the actual text of the user message + - action: action_name +``` + +In addition, you can add entity tags that can be extracted +by the [TED Policy](./policies.mdx#ted-policy). +The syntax for entity tags is the same as in +[the NLU training data](./training-data-format.mdx#entities). +For example, the following story contains the user utterance +` I can always go for sushi`. By using the syntax from the NLU training data +`[sushi](cuisine)`, you can mark `sushi` as an entity of type `cuisine`. + +```yaml-rasa {4} +stories: +- story: story with entities + steps: + - user: I can always go for [sushi](cuisine) + - action: utter_suggest_cuisine +``` + + +Similarly, you can put bot utterances directly in the stories, +by using the `bot` key followed by the text that you want your bot to say. + +A story with only a bot utterance might look like this: + +```yaml-rasa {7} +stories: +- story: story with an end-to-end response + steps: + - intent: greet + entities: + - name: Ivan + - bot: Hello, a person with a name! +``` + +You can also have a mixed end-to-end story: + +```yaml-rasa +stories: +- story: full end-to-end story + steps: + - intent: greet + entities: + - name: Ivan + - bot: Hello, a person with a name! + - intent: search_restaurant + - action: utter_suggest_cuisine + - user: I can always go for [sushi](cuisine) + - bot: Personally, I prefer pizza, but sure let's search sushi restaurants + - action: utter_suggest_cuisine + - user: Have a beautiful day! + - action: utter_goodbye +``` + +Rasa end-to-end training is fully integrated with standard Rasa approach. +It means that you can have mixed stories with some steps defined by actions or intents +and other steps defined directly by user messages or bot responses. diff --git a/docs/docs/training-data-importers.mdx b/docs/docs/training-data-importers.mdx new file mode 100644 index 0000000..fda9df5 --- /dev/null +++ b/docs/docs/training-data-importers.mdx @@ -0,0 +1,219 @@ +--- +id: training-data-importers +sidebar_label: Importers +title: Training Data Importers +description: Change the way Rasa imports training data by replacing the default importer or writing your own importer. +abstract: Rasa has built-in logic to collect and load training data written in Rasa format, but + you can also customize how your training data gets imported using custom training data importers. +--- + +Using the [`--data` command line argument](command-line-interface.mdx) you can specify where Rasa should look +for training data on your disk. Rasa then loads any potential training files and uses +them to train your assistant. + +If needed, you can also customize how Rasa imports training data. +Potential use cases for this might be: + +* using a custom parser to load training data in other formats + +* using different approaches to collect training data (e.g. loading them from different resources) + +You can [write a custom importer](#writing-a-custom-importer) and instruct Rasa to use it by adding the section +`importers` to your configuration file and specifying the importer with its +full class path: + +```yaml-rasa {2-4} title="config.yml" +importers: +- name: "module.CustomImporter" + parameter1: "value" + parameter2: "value2" +- name: "RasaFileImporter" +``` + +The `name` key is used to determine which importer should be loaded. Any extra +parameters are passed as constructor arguments to the loaded importer. + +:::note +`TrainingDataImporter` and its subclasses contain no async methods since Rasa 3.0. +In order to migrate your custom importers and make them work with Rasa 3.0, you also need to +replace your async methods with synchronized ones. +Please see the [migration guide](migration-guide.mdx#rasa-2x-to-30) for more information. +::: + +:::tip +You can specify multiple importers. Rasa will automatically merge their results. + +::: + +## RasaFileImporter (default) + +By default Rasa uses the importer `RasaFileImporter`. If you want to use it on its +own, you don't have to specify anything in your configuration file. +If you want to use it together with other importers, add it to your +configuration file: + +```yaml-rasa {5} title="config.yml" +importers: +- name: "module.CustomImporter" + parameter1: "value" + parameter2: "value2" +- name: "RasaFileImporter" +``` + +## MultiProjectImporter (experimental) + +:::info New in 1.3 +This feature is currently experimental and might change or be removed in the future. +Share your feedback on it in the [forum](https://forum.rasa.com) to help +us making this feature ready for production. + +::: + +With this importer you can train a model by combining multiple +reusable Rasa projects. +You might, for example, handle chitchat with one project and greet your users with +another. These projects can be developed in isolation, and then combined when you train +your assistant. + +For example, consider the following directory structure: + +```bash +. +├── config.yml +└── projects + ├── GreetBot + │   ├── data + │   │   ├── nlu.yml + │   │   └── stories.yml + │   └── domain.yml + └── ChitchatBot + ├── config.yml + ├── data + │   ├── nlu.yml + │   └── stories.yml + └── domain.yml +``` + +Here the contextual AI assistant imports the `ChitchatBot` project which in turn +imports the `GreetBot` project. Project imports are defined in the configuration files of +each project. + +To instruct Rasa to use the `MultiProjectImporter` module, you need add it to the `importers` list in your root `config.yml`. + +```yaml-rasa title="./config.yml" +importers: +- name: MultiProjectImporter +``` + +Then, in the same file, specify which projects you want to import by adding them to the `imports` list. + +```yaml-rasa title="./config.yml" +imports: +- projects/ChitchatBot +``` + +The configuration file of the `ChitchatBot` needs to reference `GreetBot`: + +```yaml-rasa title="./ChitchatBot/config.yml" +imports: +- ../GreetBot +``` + +Since the `GreetBot` project does not specify further project to import, it doesn't need a `config.yml`. + +Rasa uses paths relative from the configuration file to import projects. +These can be anywhere on your filesystem where file access is permitted. + +During the training process Rasa will import all required training files, combine +them, and train a unified AI assistant. The training data is merged at +runtime, so no additional training data files are created. + +:::caution Policies and NLU Pipelines +Rasa will use the policy and NLU pipeline configuration of the root project +directory during training. **Policy and NLU configurations of imported projects +will be ignored.** + +::: + +:::caution watch out for merging +Equal intents, entities, slots, responses, actions and forms will be merged, +e.g. if two projects have training data for an intent `greet`, +their training data will be combined. + +::: + +## Writing a Custom Importer + +If you are writing a custom importer, this importer has to implement the interface of +[`TrainingDataImporter`](reference/rasa/shared/importers/importer.md#trainingdataimporter-objects): + +```python +from typing import Optional, Text, Dict, List, Union + +import rasa +from rasa.shared.core.domain import Domain +from rasa.shared.nlu.interpreter import RegexInterpreter +from rasa.shared.core.training_data.structures import StoryGraph +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.nlu.training_data.training_data import TrainingData + + +class MyImporter(TrainingDataImporter): + """Example implementation of a custom importer component.""" + + def __init__( + self, + config_file: Optional[Text] = None, + domain_path: Optional[Text] = None, + training_data_paths: Optional[Union[List[Text], Text]] = None, + **kwargs: Dict + ): + """Constructor of your custom file importer. + + Args: + config_file: Path to configuration file from command line arguments. + domain_path: Path to domain file from command line arguments. + training_data_paths: Path to training files from command line arguments. + **kwargs: Extra parameters passed through configuration in configuration file. + """ + + pass + + def get_domain(self) -> Domain: + path_to_domain_file = self._custom_get_domain_file() + return Domain.load(path_to_domain_file) + + def _custom_get_domain_file(self) -> Text: + pass + + def get_stories( + self, + interpreter: "NaturalLanguageInterpreter" = RegexInterpreter(), + exclusion_percentage: Optional[int] = None, + ) -> StoryGraph: + from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, + ) + + path_to_stories = self._custom_get_story_file() + return YAMLStoryReader.read_from_file(path_to_stories, self.get_domain()) + + def _custom_get_story_file(self) -> Text: + pass + + def get_config(self) -> Dict: + path_to_config = self._custom_get_config_file() + return rasa.utils.io.read_config_file(path_to_config) + + def _custom_get_config_file(self) -> Text: + pass + + def get_nlu_data(self, language: Optional[Text] = "en") -> TrainingData: + from rasa.shared.nlu.training_data import loading + + path_to_nlu_file = self._custom_get_nlu_file() + return loading.load_data(path_to_nlu_file) + + def _custom_get_nlu_file(self) -> Text: + pass +``` diff --git a/docs/docs/tuning-your-model.mdx b/docs/docs/tuning-your-model.mdx new file mode 100644 index 0000000..38b92c2 --- /dev/null +++ b/docs/docs/tuning-your-model.mdx @@ -0,0 +1,522 @@ +--- +id: tuning-your-model +sidebar_label: Tuning Your NLU Model +title: Tuning Your NLU Model +abstract: Rasa will provide you with a suggested NLU config on initialization of the project, but as your project grows, it's likely that you will need to adjust your config to suit your training data. +--- +import useBaseUrl from '@docusaurus/useBaseUrl'; + + +## How to Choose a Pipeline + +In Rasa, incoming messages are processed by a sequence of components. +These components are executed one after another in a so-called processing `pipeline` defined in your `config.yml`. +Choosing an NLU pipeline allows you to customize your model and finetune it on your dataset. + +To get started, you can let the +[Suggested Config](.//model-configuration.mdx#suggested-config) feature choose a +default pipeline for you. +Just provide your bot's `language` in the `config.yml` file and leave the `pipeline` key +out or empty. + +```yaml-rasa +language: fr # your 2-letter language code + +pipeline: +# intentionally left empty +``` + +### Sensible Starting Pipelines + +If you're starting from scratch, it's often helpful to start with pretrained word embeddings. +Pre-trained word embeddings are helpful as they already encode some kind of linguistic knowledge. +For example, if you have a sentence like “I want to buy apples” in your training data, and Rasa is asked to predict +the intent for “get pears”, your model already knows that the words “apples” and “pears” are very similar. +This is especially useful if you don't have enough training data. + +If you are getting started with a one of [spaCy's supported languages](https://spacy.io/usage/models#languages), +we recommend the following pipeline: + +```yaml-rasa (docs/sources/data/configs_for_docs/default_spacy_config.yml) +``` + +It uses the [SpacyFeaturizer](./components.mdx#spacyfeaturizer), which provides +pre-trained word embeddings (see [Language Models](./components.mdx#language-models)). + +If you don't use any pre-trained word embeddings inside your pipeline, you are not bound to a specific language +and can train your model to be more domain specific. + +If there are no word embeddings for your language or you have very domain specific terminology, +we recommend using the following pipeline: + +```yaml-rasa (docs/sources/data/configs_for_docs/default_config.yml) +``` + +This pipeline uses the [CountVectorsFeaturizer](./components.mdx#countvectorsfeaturizer) to train +on only the training data you provide. This pipeline can handle any language in which words are +separated by spaces. If this is not the case for your language, check out [alternatives to the +WhitespaceTokenizer](./components.mdx#tokenizers). + +:::note +If you want to use custom components in your pipeline, see [Custom NLU Components](./components.mdx). + +::: + + +### Component Lifecycle + +Each component processes an input and/or creates an output. The order of the components is determined by +the order they are listed in the `config.yml`; the output of a component can be used by any other component that +comes after it in the pipeline. Some components only produce information used by other components +in the pipeline. Other components produce `output` attributes that are returned after +the processing has finished. + +For example, for the sentence `"I am looking for Chinese food"`, the output is: + +```json +{ + "text": "I am looking for Chinese food", + "entities": [ + { + "start": 8, + "end": 15, + "value": "chinese", + "entity": "cuisine", + "extractor": "DIETClassifier", + "confidence": 0.864 + } + ], + "intent": {"confidence": 0.6485910906220309, "name": "restaurant_search"}, + "intent_ranking": [ + {"confidence": 0.6485910906220309, "name": "restaurant_search"}, + {"confidence": 0.1416153159565678, "name": "affirm"} + ] +} +``` + +This is created as a combination of the results of the different components in the following pipeline: + +```yaml-rasa +pipeline: + - name: WhitespaceTokenizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + - name: EntitySynonymMapper + - name: ResponseSelector +``` + +For example, the `entities` attribute here is created by the `DIETClassifier` component. + +Every component can implement several methods from the `Component` base class; in a pipeline these different methods +will be called in a specific order. Assuming we added the following pipeline to our `config.yml`: + +```yaml-rasa +pipeline: + - name: "Component A" + - name: "Component B" + - name: "Last Component" +``` + +The image below shows the call order during the training of this pipeline: + +import componentLifecycleImg from './component-lifecycle-img.png'; + +<Image img={componentLifecycleImg} caption="Component Lifecycle" alt="The component lifecycle during training. Components are processed in the order they're listed in the configuration file. All components are created and initialized in order before they are trained in order and then persisted in order." /> + +Before the first component is created using the `create` function, a so +called `context` is created (which is nothing more than a python dict). +This context is used to pass information between the components. For example, +one component can calculate feature vectors for the training data, store +that within the context and another component can retrieve these feature +vectors from the context and do intent classification. + +Initially the context is filled with all configuration values. The arrows +in the image show the call order and visualize the path of the passed +context. After all components are trained and persisted, the +final context dictionary is used to persist the model's metadata. + +### Doing Multi-Intent Classification + +You can use multi-intent classification to predict multiple intents (e.g. `check_balances+transfer_money`), or to model hierarchical intent structure (e.g. `feedback+positive` being more similar to `feedback+negative` than `chitchat`). + +To do multi-intent classification, you need to use the [DIETClassifier](./components.mdx#dietclassifier) in your pipeline. +You'll also need to define these flags in whichever tokenizer you are using: + +* `intent_tokenization_flag`: Set it to `True`, so that intent labels are tokenized. + +* `intent_split_symbol`: Set it to the delimiter string that splits the intent labels. In this case `+`, default `_`. + +Here's an example configuration: + +```yaml-rasa +language: "en" + +pipeline: +- name: "WhitespaceTokenizer" + intent_tokenization_flag: True + intent_split_symbol: "+" +- name: "CountVectorsFeaturizer" +- name: "DIETClassifier" +``` + + +#### When to Use Multi-Intents + +Let's say you have a financial services bot and you have examples for intents `check_balances` and `transfer_money`: + +```yaml-rasa +nlu: +- intent: check_balances + examples: | + - How much money do I have? + - what's my account balance? + +- intent: transfer_money + examples: | + - I want to transfer money to my savings account + - transfer money +``` + +However, your bot receives incoming messages like this one, which combine both intents: + +<Chat caption="User wants to know balance in order to transfer money"> +<ChatUserText>How much money do I have? I want to transfer some to savings.</ChatUserText> +</Chat> + +If you see enough of these examples, you can create a new intent multi-intent `check_balances+transfer_money` and add the incoming examples to it, for example: + +```yaml-rasa +nlu: +- intent: check_balances+transfer_money + examples: | + - How much money do I have? I want to transfer some to savings. + - What's the balance on my account? I need to transfer some so I want to know how much I have +``` + + +:::note +The model will not predict any combination of intents for which examples are not explicitly given in training data. As accounting for every possible intent combination would result in combinatorial explosion of the number of intents, you should only add those combinations of intents for which you see enough examples coming in from real users. + +::: + + +#### How to Use Multi-Intents for Dialogue Management + +Multi-intent classification is intended to help with the downstream task of action prediction *after* a multi-intent. There are two complementary ways to use multi intents in dialogue training data: + +1) Add regular stories or rules for the multi-intent. For example, given the following two rules for each individual intent: + +```yaml-rasa +rules: +- rule: check account balance + steps: + - intent: check_balances + - action: action_check_balances +- rule: transfer money + steps: + - intent: transfer_money + - action: action_transfer_money +``` + +You could add another rule for the multi-intent that specifies a sequence of actions to address both intents: + +``` +rules: +- rule: check balances and transfer money + steps: + - intent: check_balances+transfer_money + - action: action_check_balances + - action: action_transfer_money +``` + +2) Allow a machine-learning policy to generalize to the multi-intent scenario from single-intent stories. + +When using a multi-intent, the intent is featurized for machine learning policies using multi-hot encoding. That means the featurization of `check_balances+transfer_money` will overlap with the featurization of each individual intent. Machine learning policies (like [TEDPolicy](./policies.mdx#ted-policy)) can then make a prediction based on the multi-intent even if it does not explicitly appear in any stories. It will typically act as if only one of the individual intents was present, however, so it is always a good idea to write a specific story or rule that deals with the multi-intent case. + + +### Comparing Pipelines + +Rasa gives you the tools to compare the performance of multiple pipelines on your data directly. +See [Comparing NLU Pipelines](./testing-your-assistant.mdx#comparing-nlu-pipelines) for more information. + +## Choosing the Right Components + +There are components for entity extraction, for intent classification, response selection, +pre-processing, and others. +If you want to add your own component, for example to run a spell-check or to +do sentiment analysis, check out [Custom NLU Components](./components.mdx). + +A pipeline usually consists of three main parts: + +### Tokenization + +You can process whitespace-tokenized (i.e. words are separated by spaces) languages +with the [WhitespaceTokenizer](./components.mdx#whitespacetokenizer). If your language is not whitespace-tokenized, you should use a different tokenizer. +We support a number of different [tokenizers](./components.mdx), or you can +create your own [custom tokenizer](./components.mdx). + +:::note +Some components further down the pipeline may require a specific tokenizer. You can find those requirements +on the individual components' `requires` parameter. If a required component is missing inside the pipeline, an +error will be thrown. + +::: + +### Featurization + +You need to decide whether to use components that provide pre-trained word embeddings or not. We recommend in cases +of small amounts of training data to start with pre-trained word embeddings. Once you have a larger amount of data +and ensure that most relevant words will be in your data and therefore will have a word embedding, supervised +embeddings, which learn word meanings directly from your training data, can make your model more specific to your domain. +If you can't find a pre-trained model for your language, you should use supervised embeddings. + +#### Pre-trained Embeddings + +The advantage of using pre-trained word embeddings in your pipeline is that if you have a training example like: +“I want to buy apples”, and Rasa is asked to predict the intent for “get pears”, your model already knows that the +words “apples” and “pears” are very similar. This is especially useful if you don't have enough training data. +We support a few components that provide pre-trained word embeddings: + +1. [MitieFeaturizer](./components.mdx#mitiefeaturizer) + +2. [SpacyFeaturizer](./components.mdx#spacyfeaturizer) + +3. [ConveRTFeaturizer](./components.mdx#convertfeaturizer) + +4. [LanguageModelFeaturizer](./components.mdx#languagemodelfeaturizer) + +If your training data is in English, we recommend using the [ConveRTFeaturizer](./components.mdx#convertfeaturizer). +The advantage of the [ConveRTFeaturizer](./components.mdx#convertfeaturizer) is that it doesn't treat each word of the user message independently, but +creates a contextual vector representation for the complete sentence. For example, if you +have a training example, like: “Can I book a car?”, and Rasa is asked to predict the intent for “I need a ride from +my place”, since the contextual vector representation for both examples are already very similar, the intent classified +for both is highly likely to be the same. This is also useful if you don't have enough training data. + +An alternative to [ConveRTFeaturizer](./components.mdx#convertfeaturizer) is the [LanguageModelFeaturizer](./components.mdx#languagemodelfeaturizer) which uses pre-trained language +models such as BERT, GPT-2, etc. to extract similar contextual vector representations for the complete sentence. See +[LanguageModelFeaturizer](./components.mdx#languagemodelfeaturizer) for a full list of supported language models. + +If your training data is not in English you can also use a different variant of a language model which +is pre-trained in the language specific to your training data. +For example, there are chinese (`bert-base-chinese`) and japanese (`bert-base-japanese`) variants of the BERT model. +A full list of different variants of +these language models is available in the +[official documentation of the Transformers library](https://huggingface.co/models?library=tf&sort=downloads). + +[spacynlp](./components.mdx#spacyfeaturizer) also provides word embeddings in many different languages, +so you can use this as another alternative, depending on the language of your training data. + +#### Supervised Embeddings + +If you don't use any pre-trained word embeddings inside your pipeline, you are not bound to a specific language +and can train your model to be more domain specific. For example, in general English, the word “balance” is closely +related to “symmetry”, but very different to the word “cash”. In a banking domain, “balance” and “cash” are closely +related and you'd like your model to capture that. +You should only use featurizers from the category [sparse featurizers](./components.mdx#featurizers), such as +[CountVectorsFeaturizer](./components.mdx#countvectorsfeaturizer), [RegexFeaturizer](./components.mdx#regexfeaturizer) or [LexicalSyntacticFeaturizer](./components.mdx#lexicalsyntacticfeaturizer), if you don't want to use +pre-trained word embeddings. + +### Intent Classification / Response Selectors + +Depending on your data you may want to only perform intent classification, entity recognition or response selection. +Or you might want to combine multiple of those tasks. We support several components for each of the tasks. +We recommend using [DIETClassifier](./components.mdx#dietclassifier) for intent classification and entity recognition +and [ResponseSelector](./components.mdx#responseselector) for response selection. + +By default all of these components consume all available features produced in the pipeline. +However, sometimes it makes sense to restrict the features that are used by a specific component. +For example, [ResponseSelector](./components.mdx#responseselector) is likely to perform better if no features from the +[RegexFeaturizer](./components.mdx#regexfeaturizer) or [LexicalSyntacticFeaturizer](./components.mdx#lexicalsyntacticfeaturizer) are used. +To achieve that, you can do the following: +Set an alias for every featurizer in your pipeline via the option `alias`. +By default the alias is set the the full featurizer class name, for example, `RegexFeaturizer`. +You can then specify, for example, on the [ResponseSelector](./components.mdx#responseselector) via the option `featurizers` what features from +which featurizers should be used. +If you don't set the option `featurizers` all available features will be used. + +Here is an example configuration file where the `DIETClassifier` is using all available features and the +`ResponseSelector` is just using the features from the `ConveRTFeaturizer` and the `CountVectorsFeaturizer`. + +```yaml-rasa (docs/sources/data/configs_for_docs/config_featurizers.yml) +``` + +### Entity Extraction + +Entity extraction involves parsing user messages for required pieces of information. Rasa +provides entity extractors for custom entities as well as pre-trained ones like dates and locations. +Here is a summary of the available extractors and what they are best used for: + +| Component | Requires | Model | Notes | +|--------------------------|------------------|-------------------------------------------------|----------------------------------| +|`DIETClassifier` | N/A |conditional random field on top of a transformer |good for training custom entities | +|`CRFEntityExtractor` |sklearn-crfsuite |conditional random field |good for training custom entities | +|`SpacyEntityExtractor` |spaCy |averaged perceptron |provides pre-trained entities | +|`DucklingEntityExtractor` |running duckling |context-free grammar |provides pre-trained entities | +|`MitieEntityExtractor` |MITIE |structured SVM |good for training custom entities | +|`EntitySynonymMapper` |existing entities |N/A |maps known synonyms | + +## Improving Performance + +### Handling Class Imbalance + +Classification algorithms often do not perform well if there is a large class imbalance, +for example if you have a lot of training data for some intents and very little training data for others. +To mitigate this problem, you can use a `balanced` batching strategy. +This algorithm ensures that all classes are represented in every batch, or at least in +as many subsequent batches as possible, still mimicking the fact that some classes are more frequent than others. +Balanced batching is used by default. In order to turn it off and use a classic batching strategy include +`batch_strategy: sequence` in your config file. + +```yaml-rasa +language: "en" + +pipeline: +# - ... other components +- name: "DIETClassifier" + batch_strategy: sequence +``` + +### Accessing Diagnostic Data + +To gain a better understanding of what your models do, you can access intermediate results of the prediction process. +To do this, you need to access the `diagnostic_data` field of the [Message](./reference/rasa/shared/nlu/training_data/message.md#message-objects) +and [Prediction](./reference/rasa/core/policies/policy.md#policyprediction-objects) objects, which contain +information about attention weights and other intermediate results of the inference computation. +You can use this information for debugging and fine-tuning, e.g. with [RasaLit](https://github.com/RasaHQ/rasalit). + +After you've [trained a model](.//command-line-interface.mdx#rasa-train), you can access diagnostic data for DIET, +given a processed message, like this: + +```python +nlu_diagnostic_data = message.as_dict()[DIAGNOSTIC_DATA] + +for component_name, diagnostic_data in nlu_diagnostic_data.items(): + attention_weights = diagnostic_data["attention_weights"] + print(f"attention_weights for {component_name}:") + print(attention_weights) + + text_transformed = diagnostic_data["text_transformed"] + print(f"\ntext_transformed for {component_name}:") + print(text_transformed) +``` + +And you can access diagnostic data for TED like this: + +```python +prediction = policy.predict_action_probabilities( + GREET_RULE, domain, RegexInterpreter() +) +print(f"{prediction.diagnostic_data.get('attention_weights')}") +``` + + +## Configuring Tensorflow + +TensorFlow allows configuring options in the runtime environment via +[TF Config submodule](https://www.tensorflow.org/api_docs/python/tf/config). Rasa supports a smaller subset of these +configuration options and makes appropriate calls to the `tf.config` submodule. +This smaller subset comprises of configurations that developers frequently use with Rasa. +All configuration options are specified using environment variables as shown in subsequent sections. + +### Deterministic Operations + +If you are using GPUs and have one or more sparse featurizer(s) in +your pipeline, and/or use any of `TEDPolicy`, `UnexpecTEDIntentPolicy`, `DIETClassifier`, +or `ResponseSelector`, training and testing will fail if you set the environment variable +`TF_DETERMINISTIC_OPS=1`, because there are no deterministic GPU implementations of +underlying tensorflow ops `tf.sparse.sparse_dense_matmul`, +`tf.nn.sparse_softmax_cross_entropy_with_logits`, +and `tf.math.unsorted_segment` ops. For more information see [here](https://github.com/tensorflow/community/blob/master/rfcs/20210119-determinism.md) + +For the above reasons, the models are also not guaranteed to yield the exact same +performance when trained on GPU across multiple runs. This even applies to situations where training is run +multiple times on the same training data, with the same config +and random seeds set appropriately, while evaluating on a standard held-out test set. +Internal experiments have shown the following fluctuation in model performance when trained and evaluated on a held-out +test set on a variety of datasets (experiments were run 5 times on each dataset): + +``` ++-----------------------+------------+------------------+------------------+-------------------+ +| Task, | Number of | Average standard | Minimum standard | Maximum standard | +| Metric (Range) | datasets | deviation | deviation | deviation | ++-----------------------+------------+------------------+------------------+-------------------+ +| Intent Classification | 11 | 0.0042 | 2.4e-5 | 0.0176 | +| Macro F1 (0-1) | | | | | ++-----------------------+------------+------------------+------------------+-------------------+ +| Entity Recognition | 7 | 0.0019 | 0.0007 | 0.0044 | +| Macro F1 (0-1) | | | | | ++-----------------------+------------+------------------+------------------+-------------------+ +| Response Selection | 2 | 0.0098 | 0.0003 | 0.0231 | +| Macro F1 (0-1) | | | | | ++-----------------------+------------+------------------+------------------+-------------------+ +| Action Selection | 5 | 0.0025 | 0.0010 | 0.0053 | +| Macro F1 (0-1) | | | | | ++-----------------------+------------+------------------+------------------+-------------------+ +| Conversation Success | 5 | 0.0077 | 0.0052 | 0.0103 | +| Accuracy (0-1) | | | | | ++-----------------------+------------+------------------+------------------+-------------------+ +``` + +The above experiments were run on an Nvidia Tesla P4 GPU. You can expect similar fluctuations in +the model performance when you evaluate on your dataset. +Across different pipeline configurations tested, the fluctuation is more pronounced +when you use sparse featurizers in your pipeline. You can see which featurizers are sparse [here](./components.mdx#featurizers), +by checking the "Type" of a featurizer. + +Model performance on the above tasks should still be reproducible across multiple runs when +trained on a CPU and none of these have changed: + +1. Training data +2. Test data +3. Configuration pipeline +4. Random Seed in configuration's components. + +### Optimizing CPU Performance + +:::note +We recommend that you configure these options only if you are an advanced TensorFlow user and understand the +implementation of the machine learning components in your pipeline. These options affect how operations are carried +out under the hood in Tensorflow. Leaving them at their default values is fine. + +::: + +Depending on the TensorFlow operations a NLU component or Core policy uses, you can leverage multi-core CPU +parallelism by tuning these options. + +#### Parallelizing One Operation + +Set `TF_INTRA_OP_PARALLELISM_THREADS` as an environment variable to specify the maximum number of threads that can be used +to parallelize the execution of one operation. For example, operations like `tf.matmul()` and `tf.reduce_sum` can be executed +on multiple threads running in parallel. The default value for this variable is `0` which means TensorFlow would +allocate one thread per CPU core. + +#### Parallelizing Multiple Operations + +Set `TF_INTER_OP_PARALLELISM_THREADS` as an environment variable to specify the maximum number of threads that can be used +to parallelize the execution of multiple **non-blocking** operations. These would include operations that do not have a +directed path between them in the TensorFlow graph. In other words, the computation of one operation does not affect the +computation of the other operation. The default value for this variable is `0` which means TensorFlow would allocate one thread per CPU core. + +To understand more about how these two options differ from each other, refer to this +[stackoverflow thread](https://stackoverflow.com/questions/41233635/meaning-of-inter-op-parallelism-threads-and-intra-op-parallelism-threads/41233901#41233901). + +### Optimizing GPU Performance + +#### Limiting GPU Memory Growth + +TensorFlow by default blocks all the available GPU memory for the running process. This can be limiting if you are running +multiple TensorFlow processes and want to distribute memory across them. To prevent Rasa from blocking all +of the available GPU memory, set the environment variable `TF_FORCE_GPU_ALLOW_GROWTH` to `True`. + +#### Restricting Absolute GPU Memory Available + +You may want to limit the absolute amount of GPU memory that can be used by a Rasa process. + +For example, say you have two visible GPUs(`GPU:0` and `GPU:1`) and you want to allocate 1024 MB from the first GPU +and 2048 MB from the second GPU. You can do this by setting the environment variable `TF_GPU_MEMORY_ALLOC` to `"0:1024, 1:2048"`. diff --git a/docs/docs/unexpected-input.mdx b/docs/docs/unexpected-input.mdx new file mode 100644 index 0000000..34edb53 --- /dev/null +++ b/docs/docs/unexpected-input.mdx @@ -0,0 +1,115 @@ +--- +id: unexpected-input +sidebar_label: Handling Unexpected Input +title: Handling Unexpected Input +abstract: One thing you can count on when building a conversational assistant is that users + will say unexpected things. This page is a guide on handling unexpected input. +--- + +Unexpected input is a deviation from the [happy path](glossary.mdx#happy--unhappy-paths) +that you have defined. For example: + +- A user walks away in the middle of a conversation about their subscription, then comes back +and says "hi!" +- A user asks "Why do you need to know that?" when the bot asks for their email address. + +This page is a guide on methods for handling unexpected input that is still within your bot's domain. +Depending on what kind of unexpected +input you're trying to handle, some or all of the methods describe may be applicable for you. This guide +is not about disambiguating user input or handling out-of-scope questions; for these cases see +the guide on [fallback and human handoff](fallback-handoff.mdx). + + +## User Interjections + +There are two kinds of unexpected input: generic interjections, and contextual interjections. +Generic interjections are interruptions that should always get the same response regardless of the +conversation context. If you already have a rule defining the response to an intent, you don't +need to do anything else to handle it as an interruption. [FAQs and chitchat](./chitchat-faqs.mdx) are common generic interjections. +A contextual interjection is one whose response depends on the conversation context. +For example, if a user asks "Why do you need that?", the answer will depend on what the bot just +asked for. + +### Contextual Interjections + +Handling contextual interjections is similar to handling [contextual conversations](contextual-conversations.mdx) +in general. + +One common case of contextual interjections is during slot filling for [form](forms.mdx), where the user +asks “Why do you need to know that?” or "Can you explain that?". +The response should differ for each slot. For example: + +<Chat caption="A contextual interjection"> +<ChatUserText>Hi</ChatUserText> +<ChatBotText>Hello! I am restaurant search assistant! How can I help?</ChatBotText> +<ChatUserText>I'm looking for a restaurant</ChatUserText> +<ChatBotText>What cuisine?</ChatBotText> +<ChatUserText>French</ChatUserText> +<ChatBotText>How many people?</ChatBotText> +<ChatUserText>Why do you need to know that?</ChatUserText> +<ChatBotText>I need to know how many people are in your party to ensure the restaurant can accomodate you.</ChatBotText> +<ChatBotText>How many people?</ChatBotText> +</Chat> + +Since we want the `requested_slot` to influence the conversation, +we need to set the property `influence_conversation` of the slot `requested_slot` +to `true`, and assign it the categorical type: + +```yaml-rasa title="domain.yml" +slots: + requested_slot: + type: categorical + values: + - cuisine + - num_people + - outdoor_seating + - preferences + - feedback + influence_conversation: true + mappings: + - type: custom +``` + +This means that the dialogue model will pay attention to the value of the slot when making a prediction +(read more about how [slots influence the assistant's behaviour](./domain.mdx#slots-and-conversation-behavior)). + +You can then write stories for specific responses to interjections based on the value of `requested_slot`, for example: + +```yaml-rasa title="stories.yml" +stories: +- story: cuisine interjection + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - slot_was_set: + - requested_slot: cuisine + - intent: explain + - action: utter_explain_cuisine + - action: restaurant_form + +- story: number of people interjection + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - slot_was_set: + - requested_slot: num_people + - intent: explain + - action: utter_explain_num_people + - action: restaurant_form +``` + + +## Summary + +How you handle unexpected input depends on whether the response should be +context sensitive or not. + +For generic interjections: + - [ ] Define rules for single-turn interactions + - [ ] Use the ResponseSelector for [FAQ and chitchat interruptions](chitchat-faqs.mdx) + +For contextual interjections: + - [ ] Make `requested_slot` a categorical slot (for forms) + - [ ] Write stories for context-specific responses to interjections, using slot values where applicable diff --git a/docs/docs/writing-stories.mdx b/docs/docs/writing-stories.mdx new file mode 100644 index 0000000..523e080 --- /dev/null +++ b/docs/docs/writing-stories.mdx @@ -0,0 +1,567 @@ +--- +id: writing-stories +sidebar_label: Writing Conversation Data +title: Writing Conversation Data +abstract: Conversation data includes the stories and rules that make up + the training data for your Rasa assistant's dialogue management + model. Well-written conversation data allows your assistant to reliably + follow conversation paths you've laid out and generalize to + unexpected paths. +--- +import useBaseUrl from '@docusaurus/useBaseUrl'; + +## Designing Stories + +When designing stories, there are two groups of conversational interactions that need +to be accounted for: happy and unhappy paths. Happy paths describe when the user is +following the conversation flow as you'd expect and always providing the necessary +information when prompted. However, users will often deviate from happy +paths with questions, chit chat, or other asks. We call these unhappy path. + +It's important for your bot to handle unhappy paths gracefully, but it's also impossible +to predict what path a given user might take. +Often, developers will try to account for every possible diverging path when designing +unhappy paths. Planning for every possible state in a state machine (many of which will never be reached) +requires a lot of extra work and increases training time significantly. + +Instead, we recommend taking a [conversation-driven development](./conversation-driven-development.mdx) +approach when designing unhappy paths. +Conversation-Driven Development promotes sharing your bot as early as possible with test users and +collecting real conversation data that tells you exactly how users diverge from the +happy paths. From this data, you can create stories to accomplish what the user is +requesting and start to think about ways to guide them back into a happy path. + + +## When to Write Stories vs. Rules + +[Rules](./rules.mdx) are a type of training data used by the dialogue manager for +handling pieces of conversations that should always follow the same path. + +Rules can be useful when implementing: + +* [One-turn interactions](./chitchat-faqs.mdx): Some messages do not require any context to answer them. + Rules are an easy way to map intents to responses, specifying fixed answers to these messages. + +* [Fallback behavior](./fallback-handoff.mdx): + In combination with the [FallbackClassifier](./components.mdx#fallbackclassifier), + you can write rules to respond to low-confidence user messages with a certain fallback behavior. + +* [Forms](./forms.mdx): Both activating and submitting a form will often follow a fixed path. + You can also write rules to handle [unexpected input](./unexpected-input.mdx) during a form. + +Because rules do not generalize to unseen conversations, you should reserve them for +single-turn conversation snippets, and use stories to train on multi-turn +conversations. + +An example of a rule where the bot returns a fixed response "utter_greet" to a user +message with intent "greet" would be: + +```yaml-rasa +rules: +- rule: Greeting Rule + steps: + - intent: greet + - action: utter_greet +``` + +For multiple-turn interactions, you should define a story, for example: + +```yaml-rasa +stories: + - story: Greeting and ask user how they're doing + steps: + - intent: greet + - action: utter_greet + - action: utter_ask_how_doing + - intent: doing_great + - action: utter_happy +``` + + + + +## Managing the Conversation Flow + +Here are some tips for managing the conversation flow in your stories: + +### When to Use Slots to Influence Conversations + +Slots act as your bot’s memory. When you define a slot, you can define whether a +[slot](domain.mdx#slots) should influence the conversation or not. +Slots with the property `influence_conversation` set to `false` can only store +information. Slots with the property `influence_conversation` set to `true` can affect +the dialogue flow based on the information stored in it. + +Slots can be set after every user message based on [slot mappings](./domain.mdx#slot-mappings). +They can also be set by a [custom action](./actions.mdx) run in response to a user message. +All slots which influence the conversation need to be added to your stories or rules. +For example, you can use a boolean slot set by a custom action to control the dialogue +flow based on its value using the following stories: + +```yaml-rasa +stories: +- story: Welcome message, premium user + steps: + - intent: greet + - action: action_check_profile + - slot_was_set: + - premium_account: true + - action: utter_welcome_premium + +- story: Welcome message, basic user + steps: + - intent: greet + - action: action_check_profile + - slot_was_set: + - premium_account: false + - action: utter_welcome_basic + - action: utter_ask_upgrade +``` + +In cases where you don't want a slot to affect the conversation flow, you should +set the slot's property `influence_conversation` to `false`. You do not need to +include `slot_was_set` events for slots in your stories which do not influence the +conversation. + +### Implementing Branching Logic + +When writing stories, sometimes the next action will depend on a value returned in one of your custom +actions. In these cases, it's important to find the right balance between returning slots and +using custom action code directly to affect what your bot does next. + +In cases where a value is used only to determine the bot's response, consider embedding the +decision logic inside a custom action as opposed to using a featurized slot in your +stories. This can help reduce overall complexity and make your stories easier to manage. + +For example, you can convert these stories: + +```yaml-rasa +stories: +- story: It's raining now + steps: + - intent: check_for_rain + - action: action_check_for_rain + - slot_was_set: + - raining: true + - action: utter_is_raining + - action: utter_bring_umbrella + +- story: It isn't raining now + steps: + - intent: check_for_rain + - action: action_check_for_rain + - slot_was_set: + - raining: false + - action: utter_not_raining + - action: utter_no_umbrella_needed +``` + +into a single story: + +```yaml-rasa +stories: +- story: check for rain + steps: + - intent: check_for_rain + - action: action_check_for_rain +``` + +with the custom action code: + +```python +def run(self, dispatcher, tracker, domain): + is_raining = check_rain() + if is_raining: + dispatcher.utter_message(template="utter_is_raining") + dispatcher.utter_message(template="utter_bring_umbrella") + else: + dispatcher.utter_message(template="utter_not_raining") + dispatcher.utter_message(template="utter_no_umbrella_needed") + return [] +``` + +In cases where the value is used to influence the action flow going forward, +return a featurized slot to determine the stories. For example, if you want to collect +information about new users, but not returning ones, your stories might look like this: + +```yaml-rasa +stories: +- story: greet new user + steps: + - intent: greet + - action: check_user_status + - slot_was_set: + - new_user: true + - action: utter_greet + - action: new_user_form + - active_loop: new_user_form + - active_loop: null + +- story: greet returning user + steps: + - intent: greet + - action: check_user_status + - slot_was_set: + - new_user: false + - action: utter_greet + - action: utter_how_can_help +``` + + +### Using OR statements and Checkpoints + +[OR statements](./stories.mdx#or-statements) and [checkpoints](./stories.mdx#checkpoints) +can be useful for reducing the number of stories you have to write. However, they should +be used with caution. Overusing OR statements or checkpoints will slow down training, +and creating too many checkpoints can make your stories hard to understand. + +#### OR statements + +In stories where different intents or slot events are handled by your bot in the same way, +you can use OR statements as an alternative to creating a new story. + +For example, you can merge these two stories: + +```yaml-rasa +stories: +- story: newsletter signup + steps: + - intent: signup_newsletter + - action: utter_ask_confirm_signup + - intent: affirm + - action: action_signup_newsletter + +- story: newsletter signup, confirm via thanks + steps: + - intent: signup_newsletter + - action: utter_ask_confirm_signup + - intent: thanks + - action: action_signup_newsletter +``` + +into a single story with an OR statement: + +```yaml-rasa {6-8} +stories: +- story: newsletter signup with OR + steps: + - intent: signup_newsletter + - action: utter_ask_confirm_signup + - or: + - intent: affirm + - intent: thanks + - action: action_signup_newsletter +``` + +At training time, this story will be split into the two original stories. + +:::caution consider restructuring data +If you notice that you are using OR statements frequently +in your stories, consider restructuring your intents to reduce their granularity and +more broadly capture user messages. +::: + +#### Checkpoints + +Checkpoints are useful for modularizing your stories into separate blocks that are +repeated often. For example, if you want your bot to ask for user feedback at the end of +each conversation flow, you can use a checkpoint to avoid having to include the feedback +interaction at the end of each story: + +```yaml-rasa +stories: +- story: beginning of conversation + steps: + - intent: greet + - action: utter_greet + - intent: goodbye + - action: utter_goodbye + - checkpoint: ask_feedback + +- story: user provides feedback + steps: + - checkpoint: ask_feedback + - action: utter_ask_feedback + - intent: inform + - action: utter_thank_you + - action: utter_anything_else + +- story: user doesn't have feedback + steps: + - checkpoint: ask_feedback + - action: utter_ask_feedback + - intent: deny + - action: utter_no_problem + - action: utter_anything_else +``` + +:::warning do not overuse +Checkpoints are meant to make it easier to re-use certain sections of conversation in lots +of different stories. We highly discourage using checkpoints inside existing checkpoints, +as this increases training time significantly and makes your stories difficult to understand. +::: + +### Creating Logical Breaks in Stories + +When designing conversation flows, it is often tempting to create long story +examples that capture a complete conversational interaction from start to finish. +In many cases, this will increase the number of training stories required +to account for branching paths. Instead, consider separating your +longer stories into smaller conversational blocks that handle sub-tasks. + +A happy path story for handling a lost credit card might look like: + +```yaml-rasa +stories: +- story: Customer loses a credit card, reviews transactions, and gets a new card + steps: + - intent: card_lost + - action: check_transactions + - slot_was_set: + - reviewed_transactions: ["starbucks"] + - action: utter_ask_fraudulent_transactions + - intent: inform + - action: action_update_transactions + - intent: affirm + - action: utter_confirm_transaction_dispute + - action: utter_replace_card + - action: mailing_address_form + - active_loop: mailing_address + - active_loop: null + - action: utter_sent_replacement + - action: utter_anything_else + - intent: affirm + - action: utter_help +``` + +Handling a lost credit card involves a series of sub-tasks, namely +checking spending history for fraudulent transactions, confirming a mailing +address for a replacement card, and then following up with the user +with any additional requests. In this conversation arc, there are +several places where the bot prompts for user input, creating +branching paths that need to be accounted for. + +For example, when prompted with "utter_ask_fraudulent_transactions", +the user might respond with a "deny" intent if none are applicable. +The user might also choose to respond with a "deny" intent when asked +if there's anything else the bot can help them with. + +We can separate out this long story into several smaller stories as: + +```yaml-rasa +stories: +- story: Customer loses a credit card + steps: + - intent: card_lost + - action: utter_card_locked + - action: spending_history_form + - active_loop: spending_history_form + - active_loop: null + - slot_was_set: + - reviewed_transactions: ["starbucks"] + - action: utter_ask_fraudulent_transactions + +- story: Customer reviews transactions and gets a new card + steps: + - action: utter_ask_fraudulent_transactions + - intent: inform + - action: action_update_transactions + - slot_was_set: + - reviewed_transactions: ["target", "starbucks"] + - intent: affirm + - action: utter_confirm_transaction_dispute + - action: utter_replace_card + - action: mailing_address_form + - active_loop: mailing_address + - active_loop: null + - action: utter_sent_replacement + - action: utter_anything_else + +- story: Customer has something else they need help with + steps: + - action: utter_anything_else + - intent: affirm + - action: utter_help +``` + +## Handling Context Switching + +Often, users will not respond with the information you ask of them and instead deviate +from the happy path with unrelated questions. Using +[CDD](./conversation-driven-development.mdx) to understand +what unhappy paths your users are taking, you can create stories for handling context +switching. + + +### Using Rules for Context Switching + +In some cases, such as single-turn interjections, you can handle context-switching +using [rules](./rules.mdx) instead of through [stories](./stories.mdx). + +Consider this conversation scenario: + +<Chat caption="A single-turn interjection"> +<ChatUserText>Hi, I want to pay my credit card bill</ChatUserText> +<ChatBotText>Can do! Which of these credit accounts do you want to make a payment towards?</ChatBotText> +<ChatBotText><p>- Credit Account A</p> +<p>- Credit Account B</p></ChatBotText> +<ChatUserText>Credit account B</ChatUserText> +<ChatBotText>How much do you want to pay?</ChatBotText> +<ChatUserText>What's my account balance?</ChatUserText> +<ChatBotText>Your current account balance is $1532.00</ChatBotText> +<ChatBotText>How much do you want to pay?</ChatBotText> +<ChatUserText>I want to pay the minimum balance</ChatUserText> +</Chat> + +In this example, the user is in the middle of paying their credit card bill, asks for +their account balance, and is then guided back into the credit card payment form. +Because asking for the account balance should always get the same response regardless of context, +you can create a rule that will automatically be triggered inside of an existing flow: + +```yaml-rasa +rules: +- rule: Check my account balance + steps: + - intent: check_account_balance + - action: action_get_account_balance +``` + + +By default, the form will continue to stay active and re-prompt for the necessary +information, without having to create an additional training story. + +### Using Stories for Context Switching + +You'll need to write additional stories for handling context switching when the +user's interjection requires multiple conversation turns. If you have two distinct +conversational flows and want the user to be able to switch between the flows, you will +need to create stories that specify how the switching will occur and how the context +is maintained. + +For example, if you want to switch context upon a user ask and then return to the +original flow after that ask is complete: + +<Chat caption="A contextual interjection"> +<ChatUserText>Hi, I want to pay my credit card bill</ChatUserText> +<ChatBotText>Can do! Which of these credit accounts do you want to make a payment towards?</ChatBotText> +<ChatBotText><p>- Credit Account A</p> +<p>- Credit Account B</p></ChatBotText> +<ChatUserText>Credit account B</ChatUserText> +<ChatBotText>How much do you want to pay?</ChatBotText> +<ChatUserText>Actually, I want to make a transfer</ChatUserText> +<ChatBotText>No problem!</ChatBotText> +<ChatBotText>Who would you like to transfer money to?</ChatBotText> +<ChatUserText>I want to send $500 to Katy Parrow</ChatUserText> +<ChatBotText>Successfully transferred $500 to Katy Parrow.</ChatBotText> +<ChatBotText>Would you like to pay your credit card bill now?</ChatBotText> +</Chat> + +You will need to create a story that describes this context-switching interaction: + +```yaml-rasa +stories: +- story: Context switch from credit card payment to money transfer + steps: + - intent: pay_credit_card + - action: credit_card_payment_form + - active_loop: credit_card_payment_form + - intent: transfer_money # - user requests a money transfer + - active_loop: null # - deactivate the credit card form + - action: transfer_money_form # - switch to the money transfer form + - active_loop: transfer_money_form + - active_loop: null + - action: utter_continue_credit_card_payment # - once the money transfer is completed, + # ask the user to return to the + # credit card payment form +``` + +## Managing Conversation Data Files + +You can provide training data to Rasa as +a single file or as a directory containing multiple files. +When writing stories and rules, it's usually a good idea to create separate +files based on the types of conversations being represented. + +For example, you might create a file `chitchat.yml` for handling chitchat, +and a `faqs.yml` file for FAQs. +Refer to our [rasa-demo bot](https://github.com/RasaHQ/rasa-demo) +for examples of story file management in complex assistants. + + +## Using Interactive Learning + +Interactive learning makes it easy to write stories by talking to your bot and providing feedback. +This is a powerful way to explore what your bot can do, and the easiest way to fix any mistakes +it makes. One advantage of machine learning-based dialogue is that when +your bot doesn't know how to do something yet, you can just teach it! + +In Rasa, you can run interactive learning in the command line with +[`rasa interactive`](./command-line-interface.mdx#rasa-interactive). + +### Command-line Interactive Learning + +The CLI command `rasa interactive` will start interactive learning on the command line. +If your bot has custom actions, make sure to also +[run your action server](./action-server/running-action-server.mdx) in a separate terminal window. + +In interactive mode, you will be asked to confirm every intent and action prediction +before the bot proceeds. Here's an example: + +```text +? Next user input: hello + +? Is the NLU classification for 'hello' with intent 'hello' correct? Yes + +------ +Chat History + + # Bot You +──────────────────────────────────────────── + 1 action_listen +──────────────────────────────────────────── + 2 hello + intent: hello 1.00 +------ + +? The bot wants to run 'utter_greet', correct? (Y/n) + +``` + +You'll be able to see the conversation history and slot values at each step of the conversation. + +If you type ||y|| to approve a prediction, the bot will continue. If you type ||n||, you will +be given the chance to correct the prediction before continuing: + +```text +? What is the next action of the bot? (Use arrow keys) + » <create new action> + 1.00 utter_greet + 0.00 ... + 0.00 action_back + 0.00 action_deactivate_loop + 0.00 action_default_ask_affirmation + 0.00 action_default_ask_rephrase + 0.00 action_default_fallback + 0.00 action_listen + 0.00 action_restart + 0.00 action_session_start + 0.00 action_two_stage_fallback + 0.00 utter_cheer_up + 0.00 utter_did_that_help + 0.00 utter_goodbye + 0.00 utter_happy + 0.00 utter_iamabot +``` + +At any point, you can use ||Ctrl-C|| to access the menu, allowing you to create more stories and export the +data from the stories you've created so far. + +```text +? Do you want to stop? (Use arrow keys) + » Continue + Undo Last + Fork + Start Fresh + Export & Quit +``` + diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js new file mode 100644 index 0000000..04d57a4 --- /dev/null +++ b/docs/docusaurus.config.js @@ -0,0 +1,180 @@ +const path = require('path'); +const { remarkProgramOutput } = require('./plugins/program_output'); +const { + rehypePlugins: themeRehypePlugins, + remarkPlugins: themeRemarkPlugins, +} = require('@rasahq/docusaurus-theme-tabula'); + +const isDev = process.env.NODE_ENV === 'development'; +const isStaging = process.env.NETLIFY && process.env.CONTEXT === 'staging'; +const isPreview = process.env.NETLIFY && process.env.CONTEXT === 'deploy-preview'; + +const BASE_URL = '/docs/rasa/'; +const SITE_URL = 'https://rasa.com'; +// NOTE: this allows switching between local dev instances of rasa/rasa-enterprise +const SWAP_URL = isDev ? 'http://localhost:3001' : SITE_URL; + +let existingVersions = []; +try { existingVersions = require('./versions.json'); } catch (e) { console.info('no versions.json file found') } + +const routeBasePath = '/'; + +const versionLabels = { + current: 'Main/Unreleased' +}; + +module.exports = { + customFields: { + // FIXME: this is a simplistic solution to https://github.com/RasaHQ/rasa/issues/7011 + // either (A): create a more sophisticated solution to link the precise branch and doc to be edited, according to branch settings + // or (B): create a README document (or a section in the main README) which explains how to contribute docs fixes, and link all edit links to this + rootEditUrl: 'https://github.com/rasahq/rasa/', + productLogo: '/img/blocks.png', + versionLabels, + legacyVersions: [{ + label: 'Legacy 1.x', + href: 'https://legacy-docs-v1.rasa.com', + target: '_blank', + rel: 'nofollow noopener noreferrer', + }], + redocPages: [ + { + title: 'Rasa HTTP API', + specUrl: '/spec/rasa.yml', + slug: '/pages/http-api', + }, + { + title: 'Rasa Action Server API', + specUrl: '/spec/action-server.yml', + slug: '/pages/action-server-api', + } + ] + }, + title: 'Rasa & Rasa Pro Documentation', + url: SITE_URL, + baseUrl: BASE_URL, + favicon: '/img/favicon.ico', + organizationName: 'RasaHQ', + projectName: 'rasa', + themeConfig: { + announcementBar: { + id: 'rasa_sdk_change', // Any value that will identify this message. + content: '<strong>Rasa SDK<strong> documentation has been moved to a <a href="https://rasa.com/docs/rasa/action-server/">section</a> of <strong>Rasa Open Source</strong>.', + backgroundColor: '#6200F5', // Defaults to `#fff`. + textColor: '#fff', // Defaults to `#000`. + // isCloseable: false, // Defaults to `true`. + }, + algolia: { + appId: '94J0KRFPTZ', + apiKey: '75ea5a8c8f4b16405c560a4ba786256b', + indexName: 'rasa', + inputSelector: '.search-bar', + + }, + navbar: { + hideOnScroll: false, + title: 'Rasa', + items: [ + { + label: 'Rasa', + to: path.join('/', BASE_URL), + position: 'left', + }, + { + target: '_self', + label: 'Rasa X/Enterprise', + position: 'left', + href: `${SWAP_URL}/docs/rasa-enterprise/`, + }, + { + href: 'https://github.com/rasahq/rasa', + className: 'header-github-link', + 'aria-label': 'GitHub repository', + position: 'right', + }, + { + target: '_self', + href: 'https://blog.rasa.com/', + label: 'Blog', + position: 'right', + }, + { + label: 'Community', + position: 'right', + items: [ + { + target: '_self', + href: 'https://rasa.com/community/join/', + label: 'Community Hub', + }, + { + target: '_self', + href: 'https://forum.rasa.com', + label: 'Forum', + }, + { + target: '_self', + href: 'https://rasa.com/community/contribute/', + label: 'How to Contribute', + }, + { + target: '_self', + href: 'https://rasa.com/showcase/', + label: 'Community Showcase', + }, + ], + }, + ], + }, + footer: { + copyright: `Copyright © ${new Date().getFullYear()} Rasa Technologies GmbH`, + }, + gtm: { + containerID: 'GTM-MMHSZCS', + }, + }, + themes: [ + '@docusaurus/theme-search-algolia', + '@rasahq/docusaurus-theme-tabula', + path.resolve(__dirname, './themes/theme-custom') + ], + plugins: [ + ['@docusaurus/plugin-content-docs/', { + routeBasePath, + sidebarPath: require.resolve('./sidebars.js'), + editUrl: 'https://github.com/rasahq/rasa/edit/main/docs/', + showLastUpdateTime: true, + showLastUpdateAuthor: true, + rehypePlugins: [ + ...themeRehypePlugins, + ], + remarkPlugins: [ + ...themeRemarkPlugins, + remarkProgramOutput, + ], + lastVersion: existingVersions[0] || 'current', // aligns / to last versioned folder in production + versions: { + current: { + label: versionLabels['current'], + path: existingVersions.length < 1 ? '' : 'next', + }, + }, + }], + ['@docusaurus/plugin-content-pages', {}], + [ + '@docusaurus/plugin-ideal-image', + { + sizes: [160, 226, 320, 452, 640, 906, 1280, 1810, 2560], + quality: 70, + }, + ], + ['@docusaurus/plugin-sitemap', + { + cacheTime: 600 * 1000, // 600 sec - cache purge period + changefreq: 'weekly', + priority: 0.5, + }], + isDev && ['@docusaurus/plugin-debug', {}], + [path.resolve(__dirname, './plugins/google-tagmanager'), {}], + ].filter(Boolean), +}; diff --git a/docs/netlify.toml b/docs/netlify.toml new file mode 100644 index 0000000..d7bac10 --- /dev/null +++ b/docs/netlify.toml @@ -0,0 +1,204 @@ +## CONTENT 301 REDIRECTIONS + +[[redirects]] +from = "/docs/rasa/user-guide/running-rasa-with-docker/" +to = "/docs/rasa/user-guide/docker/building-in-docker/" + +[[redirects]] +from = "/docs/rasa/user-guide/running-the-server/" +to = "/docs/rasa/user-guide/configuring-http-api/" + +[[redirects]] +from = "/docs/rasa/user-guide/running-rasa-with-docker/#adding-custom-actions" +to = "/docs/rasa/user-guide/how-to-deploy/#deploying-your-action-server" + +[[redirects]] +from = "/docs/rasa/user-guide/how-to-deploy/#adding-custom-actions" +to = "/docs/rasa/user-guide/how-to-deploy/#deploying-your-action-server" + +[[redirects]] +from = "/docs/rasa/user-guide/how-to-deploy/#using-docker-compose-to-run-multiple-services" +to = "/docs/rasa/user-guide/docker/deploying-in-docker-compose/" + +[[redirects]] +from = "/docs/rasa/user-guide/evaluating-models/" +to = "/docs/rasa/user-guide/testing-your-assistant/" + +[[redirects]] +from = "/docs/rasa/user-guide/evaluating-models/#end-to-end-evaluation" +to = "/docs/rasa/user-guide/testing-your-assistant/#end-to-end-testing" + +[[redirects]] +from = "/docs/rasa/user-guide/installation/" +to = "/docs/rasa/installation/" + +[[redirects]] +from = "/docs/rasa/nlu/training-data-format/" +to = "/docs/rasa/nlu-training-data/" + +[[redirects]] +from = "/docs/rasa/core/domains/" +to = "/docs/rasa/domain/" + +[[redirects]] +from = "/docs/rasa/user-guide/testing-your-assistant/" +to = "/docs/rasa/testing-your-assistant/" + +[[redirects]] +from = "/docs/rasa/user-guide/command-line-interface/" +to = "/docs/rasa/command-line-interface/" + +[[redirects]] +from = "/docs/rasa/user-guide/setting-up-ci-cd/" +to = "/docs/rasa/setting-up-ci-cd/" + +[[redirects]] +from = "/docs/rasa/user-guide/rasa-tutorial/" +to = "/docs/rasa/prototype-an-assistant/" + +[[redirects]] +from = "/docs/rasa/user-guide/building-assistants/" +to = "/docs/rasa/chitchat-faqs/" + +[[redirects]] +from = "/docs/rasa/core/stories/" +to = "/docs/rasa/stories/" + +[[redirects]] +from = "/docs/rasa/user-guide/messaging-and-voice-channels/" +to = "/docs/rasa/messaging-and-voice-channels/" + +[[redirects]] +from = "/docs/rasa/user-guide/configuring-http-api/" +to = "/docs/rasa/http-api/" + +[[redirects]] +from = "/docs/rasa/user-guide/how-to-deploy/" +to = "/docs/rasa/how-to-deploy/" + +[[redirects]] +from = "/docs/rasa/user-guide/cloud-storage/" +to = "/docs/rasa/model-storage/" + +[[redirects]] +from = "/docs/rasa/nlu/choosing-a-pipeline/" +to = "/docs/rasa/tuning-your-model/" + +[[redirects]] +from = "/docs/rasa/nlu/components/" +to = "/docs/rasa/components/" + +[[redirects]] +from = "/docs/rasa/user-guide/connectors/*" +to = "/docs/rasa/connectors/:splat" + +[[redirects]] +from = "/docs/rasa/nlu/entity-extraction/" +to = "/docs/rasa/nlu-training-data/" + +[[redirects]] +from = "/docs/rasa/core/responses/" +to = "/docs/rasa/domain/#responses" + +[[redirects]] +from = "/docs/rasa/core/actions/" +to = "/docs/rasa/actions/" + +[[redirects]] +from = "/docs/rasa/core/reminders-and-external-events/" +to = "/docs/rasa/reaching-out-to-user/" + +[[redirects]] +from = "/docs/rasa/core/policies/" +to = "/docs/rasa/policies/" + +[[redirects]] +from = "/docs/rasa/core/slots/" +to = "/docs/rasa/domain/#slots" + +[[redirects]] +from = "/docs/rasa/core/retrieval-actions/" +to = "/docs/rasa/retrieval-actions/" + +[[redirects]] +from = "/docs/rasa/core/forms/" +to = "/docs/rasa/forms/" + +[[redirects]] +from = "/docs/rasa/core/interactive-learning/" +to = "/docs/rasa/writing-stories/#using-interactive-learning" + +[[redirects]] +from = "/docs/rasa/core/fallback-actions/" +to = "/docs/rasa/fallback-handoff/#fallbacks" + +[[redirects]] +from = "/docs/rasa/dialogue-elements/small-talk/" +to = "/docs/rasa/chitchat-faqs/" + +[[redirects]] +from = "/docs/rasa/api/action-server/" +to = "/docs/rasa/pages/action-server-api/" + +[[redirects]] +from = "/docs/rasa/api/http-api/" +to = "/docs/rasa/pages/http-api/" + +[[redirects]] +from = "/docs/rasa/api/jupyter-notebooks/" +to = "/docs/rasa/jupyter-notebooks/" + +[[redirects]] +from = "/docs/rasa/api/custom-nlu-components/" +to = "/docs/rasa/components/#custom-components" + +[[redirects]] +from = "/docs/rasa/api/tracker-stores/" +to = "/docs/rasa/tracker-stores/" + +[[redirects]] +from = "/docs/rasa/api/event-brokers/" +to = "/docs/rasa/event-brokers/" + +[[redirects]] +from = "/docs/rasa/api/lock-stores/" +to = "/docs/rasa/lock-stores/" + +[[redirects]] +from = "/docs/rasa/api/training-data-importers/" +to = "/docs/rasa/training-data-importers/" + +[[redirects]] +from = "/docs/rasa/retrieval-actions" +to = "/docs/rasa/chitchat-faqs/" + +[[redirects]] +from = "/docs/rasa/playground/" +to = "/docs/rasa/" + +# Redirects for latest version permalinks +# this needs to be updated when we cut a new version +[[redirects]] +from = "/docs/rasa/3.x/*" +to = "/docs/rasa/:splat" +# end permalink section + +# FIXME: this requires some feedback as to the 404, like in the website +[[redirects]] +from = "/docs/rasa/*" +to = "/docs/rasa/index.html" +status = 404 + + +[[redirects]] +from = "/docs/rasa/installation" +to = "/docs/rasa/installation/environment-set-up/" + +[[redirects]] +from = "/docs/rasa/deploy/deploy-rasa-plus/" +to = "/docs/rasa/deploy/deploy-rasa/" + + +[[redirects]] +from = "/docs/rasa/action-server/deploy-action-server" +to = "/docs/rasa/deploy/deploy-action-server/" diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..c977062 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,129 @@ +{ + "name": "rasa", + "version": "0.0.0", + "private": true, + "scripts": { + "theme:link": "yarn link @rasahq/docusaurus-theme-tabula", + "theme:install": "yarn unlink @rasahq/docusaurus-theme-tabula && yarn install", + "theme:upgrade": "yarn upgrade @rasahq/docusaurus-theme-tabula --latest", + "pre-build": "yarn copy-md-files && yarn variables && yarn program-outputs && yarn included-sources && yarn autodoc && yarn telemetry", + "start": "yarn pre-build && yarn develop", + "clear": "docusaurus clear", + "develop": "docusaurus start --port 3000", + "build": "`which node` --max-old-space-size=8192 `which npx` docusaurus build --out-dir build/docs/rasa", + "serve": "netlify dev --dir=build --port=5000", + "deploy-preview": "netlify deploy --dir=build", + "deploy": "netlify deploy --dir=build --prod", + "new-version": "docusaurus docs:version", + "variables": "node scripts/compile_variables.js", + "program-outputs": "node scripts/compile_program_outputs.js", + "copy-md-files": "node scripts/copy_md_files.js", + "telemetry": "node scripts/compile_telemetry_reference.js --unhandled-rejections=strict", + "included-sources": "node scripts/compile_included_sources.js", + "autodoc": "echo 'Generating autodoc' && pydoc-markdown", + "clean": "find docs/sources -type f -not -name '.keep' -print0 | xargs -0 -I {} rm {}", + "ci": "yarn install --frozen-lockfile", + "mdx-lint": "remark --frail docs/*.mdx docs/*/*.mdx", + "update-versioned-sources": "node scripts/update_versioned_sources.js" + }, + "dependencies": { + "@docusaurus/core": "2.0.0-alpha.63", + "@docusaurus/plugin-client-redirects": "2.0.0-alpha.63", + "@docusaurus/plugin-content-docs": "2.0.0-alpha.63", + "@docusaurus/plugin-content-pages": "2.0.0-alpha.63", + "@docusaurus/plugin-debug": "2.0.0-alpha.63", + "@docusaurus/plugin-ideal-image": "2.0.0-alpha.63", + "@docusaurus/plugin-sitemap": "2.0.0-alpha.63", + "@docusaurus/theme-search-algolia": "2.0.0-alpha.63", + "@fortawesome/fontawesome-svg-core": "^1.2.30", + "@fortawesome/free-solid-svg-icons": "^5.15.3", + "@fortawesome/react-fontawesome": "^0.1.13", + "@lunelson/sass-calc": "^1.2.0", + "@lunelson/sass-lerp": "^1.0.0", + "@lunelson/sass-maps-next": "^1.0.0", + "@lunelson/sass-throw": "^2.1.0", + "@lunelson/sass-u": "^0.11.0", + "@mdx-js/mdx": "^1.6.22", + "@mdx-js/react": "^1.6.22", + "@rasahq/docusaurus-theme-tabula": "^0.8.3", + "classnames": "^2.3.1", + "clsx": "^1.1.1", + "core-js": "^3.15.1", + "fibers": "^5.0.0", + "fs-extra": "^9.0.1", + "globby": "^11.0.4", + "mobx": "^4.3.1", + "postcss-preset-env": "^6.7.0", + "postcss-pseudo-any": "^1.0.1", + "react": "^16.8.4", + "react-dom": "^16.8.4", + "react-promise": "^3.0.2", + "react-router-dom": "^5.2.0", + "redoc": "^2.0.0-rc.31", + "remark": "^13.0.0", + "remark-collapse": "^0.1.2", + "remark-sources": "^1.1.0", + "sass": "^1.26.10", + "sass-loader": "^9.0.3", + "styled-components": "^4.2.0", + "tinycolor2": "^1.4.2", + "unist-util-visit-children": "^1.1.3", + "utility-opentype": "^0.1.4" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "netlify-cli": "^3.39.0", + "remark-cli": "^8.0.1", + "remark-lint": "^8.0.0", + "remark-lint-no-dead-urls": "^1.1.0", + "remark-validate-links": "^10.0.4", + "toml": "^3.0.0" + }, + "resolutions": { + "react-dev-utils": "^11.0", + "immer": "^8.0.1", + "dompurify": "^2.0.17", + "ini": "^1.3.6", + "jpeg-js": "^0.4.0" + }, + "remarkConfig": { + "plugins": [ + [ + "remark-validate-links", + { + "prefix": "/docs/", + "lines": false + } + ], + "remark-lint", + [ + "remark-lint-no-dead-urls", + { + "skipUrlPatterns": [ + "^https://github\\.com/rasahq/rasa/issues/[0-9]+$", + "\\.prototyping\\.rasa\\.com", + "^https://github\\.com/mit-nlp/MITIE/releases/download/v0\\.4/MITIE-models-v0\\.2\\.tar\\.bz2$", + "^https://forum.rasa.com/t/rasa-open-source-2-0-is-out-now-internal-draft/35577$" + ] + } + ] + ] + }, + "telemetryReference": { + "outputPath": "./docs/telemetry/reference.mdx" + }, + "engines": { + "node": "^12" + } +} diff --git a/docs/plugins/google-tagmanager/client.js b/docs/plugins/google-tagmanager/client.js new file mode 100644 index 0000000..b1db6df --- /dev/null +++ b/docs/plugins/google-tagmanager/client.js @@ -0,0 +1,19 @@ +import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; + +export default (function () { + if (!ExecutionEnvironment.canUseDOM) { + return null; + } + + return { + onRouteUpdate({location}) { + if (location) { + setTimeout(() => { + window.dataLayer.push({ + event: 'route-update', + }); + }, 50); + } + }, + }; +})(); diff --git a/docs/plugins/google-tagmanager/index.js b/docs/plugins/google-tagmanager/index.js new file mode 100644 index 0000000..f337512 --- /dev/null +++ b/docs/plugins/google-tagmanager/index.js @@ -0,0 +1,93 @@ +const path = require('path'); + +module.exports = function (context, options) { + const { siteConfig } = context; + const { themeConfig } = siteConfig; + const { gtm } = themeConfig || {}; + + if (!gtm) { + throw new Error( + `You need to specify 'gtm' object in 'themeConfig' with 'containerID' field in it to use google-tagmanager`, + ); + } + + const { containerID } = gtm; + + if (!containerID) { + throw new Error( + 'You specified the `gtm` object in `themeConfig` but the `containerID` field was missing. ' + + 'Please ensure this is not a mistake.', + ); + } + + return { + name: 'google-tagmanager', + + getClientModules() { + return [path.resolve(__dirname, './client')]; + }, + + injectHtmlTags() { + return { + headTags: [ + { + tagName: 'link', + attributes: { + rel: 'preconnect', + href: 'https://www.google-analytics.com', + }, + }, + { + tagName: 'link', + attributes: { + rel: 'preconnect', + href: 'https://www.googletagmanager.com', + }, + }, + { + tagName: 'link', + attributes: { + rel: 'stylesheet', + href: 'https://assets.rasa.com/styles/klaro.css', + }, + }, + { + tagName: 'script', + attributes: { + 'type': 'opt-in', + 'data-type': 'text/javascript', + 'data-name': 'analytics', + }, + innerHTML: ` +window.dataLayer = window.dataLayer || [{ + deployContext: (window.netlifyMeta && window.netlifyMeta.CONTEXT) || 'development', + branchName: window.netlifyMeta && window.netlifyMeta.BRANCH, +}]; +(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': +new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], +j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= +'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); +})(window,document,'script','dataLayer','${containerID}'); + `, + }, + { + tagName: 'script', + attributes: { + defer: true, + src: 'https://assets.rasa.com/scripts/klaro_config.js' + } + }, + { + tagName: 'script', + attributes: { + defer: true, + src: 'https://assets.rasa.com/scripts/klaro.js' + } + }, + ], + preBodyTags: [], + postBodyTags: [], + }; + }, + }; +}; diff --git a/docs/plugins/included_source.js b/docs/plugins/included_source.js new file mode 100644 index 0000000..ab040f4 --- /dev/null +++ b/docs/plugins/included_source.js @@ -0,0 +1,132 @@ +/** + This plugin gives us the ability to include source files + in a code-block in the docs, by using the following + syntax: + + ```python (docs/sources/path/to/file.py) + ``` + + To make it work, you need to prefix the source file by `docs/sources/`. + + It relies on `remark-source` and on a pre-build phase, + before docusaurus is started (or built). It allows us to support separate + versions of the docs (and of the program outputs). +*/ +const path = require('path'); +const fs = require('fs-extra'); +const globby = require('globby'); + +const { readFile, outputFile } = fs; + +const defaultOptions = { + docsDir: './docs', + relativeSourceDir: 'sources', + include: ['**.mdx', '**.md'], + pathPrefix: '../', +}; + +const _getIncludedSourceRe = (sourceDir) => `\`\`\`([a-z\-]+) \\(${sourceDir}/([^\\]\\s]+)\\)\n\`\`\``; + +/** + This function is used copy the included sources + requested in the docs. It parses all the docs files, + finds the included sources and copy them under the `sourceDir`. + + Options: + - docsDir: the directory containing the docs files + - relativeSourceDir: the directory that will contain the included sources + - include: list of patterns to look for doc files + - pathPrefix: a path prefix to use for reading the sources +*/ +async function getIncludedSources(options) { + + options = { ...defaultOptions, ...options }; + const { docsDir, include, relativeSourceDir, pathPrefix } = options; + const cleanedSourceDir = path.join(docsDir.replace('./', ''), relativeSourceDir); + const includedSourceRe = _getIncludedSourceRe(cleanedSourceDir); + + // first, gather all the docs files + const docsFiles = await globby(include, { + cwd: docsDir, + }); + const seen = new Set(); + // second, read every file source + let sourceFiles = await Promise.all(docsFiles.map(async (source) => { + const data = await readFile(`${docsDir}/${source}`); + const sourceFiles = []; + let group, sourceFile, content; + // third, find out if there is a source to be included + // there can be multiple sources in the same file + const re = new RegExp(includedSourceRe, 'gi'); + while ((group = re.exec(data)) !== null) { + sourceFile = group[2]; + if (seen.has(sourceFile)) { + continue; + } + seen.add(sourceFile); + // fourth, read the source file + content = await readFile(`${pathPrefix}${sourceFile}`); + sourceFiles.push([sourceFile, content]); + } + return sourceFiles; + })); + sourceFiles = sourceFiles.flat().filter(pair => pair.length > 0); + + // finally, write all the source files in the source directory + return await Promise.all(sourceFiles.map(async ([sourceFile, content]) => { + return await outputFile(`${docsDir}/${relativeSourceDir}/${sourceFile}`, content); + })); +}; + + +/** + This function is used to ensure that all the documentation files + which rely on the `remark-source` plugin to load source files into + the docs continue to work across documentation versions. There is no way for + `remark-source` to know about the file structure that we have, that's + why we need this extra function. The workflow is: + 1. Doc files are put in a version folder upon release of a new version; + 2. The files, previously on the `main` branch, used to point to source files + contained in `docs/source/...`; + 3. Now that they are in a versioned folder, we need to them to point to source files + contained in `versioned_docs/version-xxx/sources/...`. This is what this function + does. + + Options: + - docsDir: the directory containing the versionned docs files + - relativeSourceDir: the directory that will contain the included sources + - include: list of patterns to look for doc files +*/ +async function updateVersionedSources(options) { + options = { ...defaultOptions, ...options }; + const { docsDir, include, relativeSourceDir } = options; + const originalSourceDir = path.join(defaultOptions.docsDir.replace('./', ''), relativeSourceDir); + const newSourceDir = path.join(docsDir.replace('./', ''), relativeSourceDir); + const includedSourceRe = _getIncludedSourceRe(originalSourceDir); + + // first, gather all the docs files + const docsFiles = await globby(include, { + cwd: docsDir, + }); + const seen = new Set(); + // second, read every doc file and compute their updated content + let newDocsFiles = await Promise.all(docsFiles.map(async (source) => { + const data = await readFile(`${docsDir}/${source}`); + // third, find out if there is a source to be included + // there can be multiple sources in the same file + const re = new RegExp(includedSourceRe, 'gi'); + const updatedData = data.toString().replace(re, `\`\`\`$1 (${newSourceDir}/$2)\n\`\`\``); + return (updatedData != data) ? [`${docsDir}/${source}`, updatedData] : []; + })); + + newDocsFiles = newDocsFiles.filter(pair => pair.length > 0); + + // finally, update all the docs files with the path to the versioned source + return await Promise.all(newDocsFiles.map(async ([docsFile, updatedContent]) => { + return await outputFile(docsFile, updatedContent); + })); +} + + +module.exports = getIncludedSources; +module.exports.updateVersionedSources = updateVersionedSources; diff --git a/docs/plugins/program_output.js b/docs/plugins/program_output.js new file mode 100644 index 0000000..481bffe --- /dev/null +++ b/docs/plugins/program_output.js @@ -0,0 +1,153 @@ +/** + This plugin gives us the ability to insert the output of + a program in a code-block in the docs, by using the following + syntax: + + ```text [rasa --help] + ``` + It is inspired by `remark-source` and also relies on a pre-build phase, + before docusaurus is started (or built). It allows us to support separate + versions of the docs (and of the program outputs). + + + Caveat: this plugin operates at 2 separate points in time: + 1. During the "pre-build" phase, pre-generating files inside the + sources/ folder. This phase is run on the "main" version of the repo + (main branch, release branches, tags, etc...), which doesn't have + several versions of the docs site. This is why `getProgramOutputs()` is + version-agnostic and simply outputs files in `mainSourceDir`. + 2. At the build phase, happening on the `documentation` branch. In this context, + there are multiple versions of the docs, and the plugins needs to support that. + This is why the `remarkProgramOutput()` is version-aware and takes care + of loading the files from a versioned path. +*/ +const fs = require('fs'); +const globby = require('globby'); +const visitChildren = require('unist-util-visit-children'); +const { promisify } = require('util'); + +const exec = promisify(require('child_process').exec); +const { readFile, writeFile } = fs.promises; + + +const PROGRAM_OUTPUT_RE = /```[a-z]+ \[([^\]]+)\]\n```/; + +const VERSIONED_DOCS_PATH_RE = /(\/versioned_docs\/version-\d+\.x)\//; + +const defaultOptions = { + docsDir: './docs', + sourceDirectoryName: 'sources', + mainSourceDir: './docs/sources', + include: ['**.mdx', '**.md'], + commandPrefix: '', +}; + +/** + This function is use to get output of programs + requested in the docs. It parses all the docs files, + generates outputs and save them as files. + + Options: + - docsDir: the directory containing the docs files + - mainSourceDir: the directory that will contain the program outputs + - sourceDirectoryName: the name (relative) of the source directory + - include: list of patterns to look for doc files + - commandPrefix: a prefix to be prepended before each command +*/ +async function getProgramOutputs(options) { + + options = { ...defaultOptions, ...options }; + const { docsDir, include, mainSourceDir, commandPrefix } = options; + // first, gather all the docs files + const docsFiles = await globby(include, { + cwd: docsDir, + }); + const seen = new Set(); + // second, read every file source + let commands = await Promise.all(docsFiles.map(async (source) => { + const data = await readFile(`${docsDir}/${source}`); + const commands = []; + let group, command, stdout; + // third, find out if there is a program output to be generated + // there can be multiple outputs in the same file + const re = new RegExp(PROGRAM_OUTPUT_RE, 'gi'); + while ((group = re.exec(data)) !== null) { + command = group[1]; + if (seen.has(command)) { + continue; + } + seen.add(command); + // fourth, call the command to generate the output + output = await exec(`${commandPrefix} ${command}`); + commands.push([command, output.stdout]); + } + return commands; + })); + commands = commands.flat().filter(pair => pair.length > 0); + + // finally, write all the command outputs as files in the `mainSourceDir` + return await Promise.all(commands.map(async ([command, output]) => { + return await writeFile(`${mainSourceDir}/${commandToFilename(command)}`, output); + })); +}; + + +/** + Custom remark plugin to replace the following blocks: + + ```text [rasa --help] + ``` + + with the actual output of the program (here `rasa --help`). + It relies on the output of `getProgramOutputs()` above, + and is inspired by `remark-sources` plugin. +*/ +function remarkProgramOutput(options = {}) { + options = { ...defaultOptions, ...options }; + return (root, { history }) => { + visitChildren((node, index, parent) => { + if (node && node.type === 'code') { + const content = readCommandOutput(node.meta, options, history[0]); + if (content !== undefined) { + node.value = content; + } + } + })(root); + }; +} + + +function readCommandOutput(meta, { mainSourceDir, sourceDirectoryName }, filename) { + if (!meta) { + return undefined; + } + if (meta[0] !== '[' || meta[meta.length - 1] !== ']') { + return undefined; + } + meta = meta.slice(1, -1); + const sourceFile = `${getVersionedSourceDir(mainSourceDir, sourceDirectoryName, filename)}/${commandToFilename(meta)}` + try { + return fs.readFileSync(sourceFile, { encoding: 'utf8' }); + } catch (e) { + throw new Error(`Failed to read file: ${sourceFile} for meta ${meta}`); + } +} + + +function commandToFilename(command) { + return command.replace(/[^a-z0-9]/gi, '_').toLowerCase() + '.txt'; +} + +/** + By analyzing the `filename` argument, we can guess if the file we want + to load is in a specific version of the docs, or the main one. +*/ +function getVersionedSourceDir(mainSourceDir, sourceDirectoryName, filename) { + const re = new RegExp(VERSIONED_DOCS_PATH_RE, 'gi'); + const match = re.exec(filename); + return match === null ? mainSourceDir : `.${match[1]}/${sourceDirectoryName}`; +} + + +module.exports = getProgramOutputs; +module.exports.remarkProgramOutput = remarkProgramOutput; diff --git a/docs/pydoc-markdown.yml b/docs/pydoc-markdown.yml new file mode 100644 index 0000000..1929585 --- /dev/null +++ b/docs/pydoc-markdown.yml @@ -0,0 +1,21 @@ +loaders: + - type: python + search_path: [../] + packages: + - rasa +processors: + - type: filter + skip_empty_modules: true + - type: smart + - type: crossref +renderer: + type: docusaurus + docs_base_path: docs/ + sidebar_top_level_label: null + sidebar_top_level_module_label: 'Code reference' + markdown: + render_module_header_template: | + --- + sidebar_label: {module_name} + title: {module_name} + --- diff --git a/docs/scripts/compile_included_sources.js b/docs/scripts/compile_included_sources.js new file mode 100644 index 0000000..ee9f928 --- /dev/null +++ b/docs/scripts/compile_included_sources.js @@ -0,0 +1,7 @@ +const getIncludedSources = require('../plugins/included_source.js'); + +console.info('Computing included sources'); +getIncludedSources({ + docsDir: './docs', + include: ['**.mdx', '**.md'], +}); diff --git a/docs/scripts/compile_program_outputs.js b/docs/scripts/compile_program_outputs.js new file mode 100644 index 0000000..41473ff --- /dev/null +++ b/docs/scripts/compile_program_outputs.js @@ -0,0 +1,8 @@ +const getProgramOutputs = require('../plugins/program_output.js'); + +console.info('Computing program outputs'); +getProgramOutputs({ + docsDir: './docs', + include: ['**.mdx', '**.md'], + commandPrefix: 'RASA_TELEMETRY_ENABLED=false poetry run', +}); diff --git a/docs/scripts/compile_telemetry_reference.js b/docs/scripts/compile_telemetry_reference.js new file mode 100644 index 0000000..6e0779a --- /dev/null +++ b/docs/scripts/compile_telemetry_reference.js @@ -0,0 +1,88 @@ +const fs = require('fs'); +const config = require("../package.json").telemetryReference; +const eventsSchema = require("../docs/telemetry/events.json"); + +const { writeFile } = fs.promises; + +const isFrontendEvent = (event) => event.indexOf(" ") === -1 && event.indexOf(":") !== -1; + +const MDX_HEADER = `--- +id: reference +sidebar_label: Telemetry Event Reference +title: Telemetry Event Reference +abstract: | + Event descriptions of telemetry data we report in order to improve our products. +--- +Telemetry events are only reported if telemetry is enabled. A detailed explanation +on the reasoning behind collecting optional telemetry events can be found in our +[telemetry documentation](./telemetry.mdx). +`; + +const renderEventBadge = (event) => { + const kind = isFrontendEvent(event) ? 'frontend' : 'backend'; + return `<span class="badge badge--primary">${kind}</span>`; +}; + +const renderTelemetryEventDetails = (type, properties) => { + const renderEnum = (_enum) => _enum.length ? ` Can be one of: ${_enum.map(e => `\`"${e}"\``).join(', ')}.` : ''; + + switch (type) { + case 'object': + return `Event properties:\n +${Object.entries(properties) + .map(([property, { type, description, enum: _enum = [] }]) => `- \`${property}\` *(${type})*: ${description}${renderEnum(_enum)}`) + .join('\n') +} +`; + case 'null': + return ''; + case undefined: + return ''; + default: + throw new Error(`Unexpected event type: "${type}".`); + } +}; + +const renderTelemetryEventDescription = (event, schema) => { + if (schema.description) { + return schema.description; + } + throw new Error(`Missing description for event ${event}`); +}; + +const renderTelemetryEvent = (event, schema) => (` +### ${event} +${renderEventBadge(event)} ${renderTelemetryEventDescription(event, schema)} +${renderTelemetryEventDetails(schema.type, schema.properties)} +`); + +const renderTelemetrySection = (section, events) => (` +## ${section} +${Object.entries(events).map(([event, schema]) => renderTelemetryEvent(event, schema)).join('\n')} +`); + +/** + This function is used to write the reference of our telemetry events + inside the docs. It's run as part of the pre-build step. + Options: + - outputPath: where to output the telemetry reference (mdx file) +*/ +async function writeTelemetryReference({ outputPath }) { + + const sections = Object.fromEntries(eventsSchema.sections.map((section) => [section, {}])); + Object.entries(eventsSchema.events).forEach(([event, schema]) => { + const section = schema.section || eventsSchema.defaultSection; + if (sections[section] === undefined) { + throw new Error(`Unknown section ${section}, please add it to the "sections" array.`); + } + sections[section][event] = schema; + }); + + // const content = Object.entries(eventsSchema.events).map(([event, schema]) => renderTelemetryEvent(event, schema)).join('\n'); + const content = Object.entries(sections).map(([section, events]) => renderTelemetrySection(section, events)).join('\n'); + await writeFile(outputPath, `${MDX_HEADER}\n\n${content}`); +}; + + +console.info('Compiling telemetry reference'); +writeTelemetryReference(config); diff --git a/docs/scripts/compile_variables.js b/docs/scripts/compile_variables.js new file mode 100644 index 0000000..2e2bebc --- /dev/null +++ b/docs/scripts/compile_variables.js @@ -0,0 +1,33 @@ +const { readFileSync, writeFileSync } = require('fs'); +const { execSync } = require('child_process'); + +const toml = require('toml'); + +const VARIABLES_FILE_PATH = './docs/variables.json'; +const PYPROJECT_FILE_PATH = '../pyproject.toml'; +const COMMAND_RASA_SDK_VERSION = + 'python -c "from rasa_sdk import __version__ as rasa_sdk_version; print(rasa_sdk_version)"'; +const DISCLAIMER = 'this file is automatically generated, please do not update it manually'; +const JSON_SPACE_INDENT = 4; + +const getRasaVersion = () => { + const pyproject = readFileSync(PYPROJECT_FILE_PATH).toString(); + return toml.parse(pyproject).tool.poetry.version; +}; + +const getRasaSdkVersion = () => execSync(COMMAND_RASA_SDK_VERSION).toString().trim(); + +const writeVariablesFile = () => { + const variables = JSON.stringify( + { + release: getRasaVersion(), + rasa_sdk_version: getRasaSdkVersion(), + }, + null, + JSON_SPACE_INDENT, + ); + writeFileSync(VARIABLES_FILE_PATH, variables); +}; + +console.info(`Computing docs variables and writing to ${VARIABLES_FILE_PATH}`); +writeVariablesFile(); diff --git a/docs/scripts/copy_md_files.js b/docs/scripts/copy_md_files.js new file mode 100644 index 0000000..d7b743a --- /dev/null +++ b/docs/scripts/copy_md_files.js @@ -0,0 +1,33 @@ +const fs = require('fs'); + +const { copyFile } = fs.promises; + +const defaultOptions = { + files: {}, + docsDir: './docs', +}; + +/** + This function is used to copy markdown files from a source + outside the `docs/` folder to a destination inside the `docs/` folder. + + Options: + - files: a mapping of source: destination + - docsDir: the docs folder +*/ +async function copyMarkdownFiles(options) { + options = { ...defaultOptions, ...options }; + const { docsDir, files } = options; + + for (const [source, destination] of Object.entries(files)) { + await copyFile(source, `${docsDir}/${destination}`); + } +} + +console.info('Copying markdown files'); +copyMarkdownFiles({ + docsDir: './docs', + files: { + '../CHANGELOG.mdx': 'changelog.mdx', + }, +}); diff --git a/docs/scripts/update_versioned_sources.js b/docs/scripts/update_versioned_sources.js new file mode 100644 index 0000000..f7566d8 --- /dev/null +++ b/docs/scripts/update_versioned_sources.js @@ -0,0 +1,16 @@ +const fs = require("fs"); +const includedSources = require('../plugins/included_source.js'); + + +const version = process.argv[2]; +if (!version) {; + throw new Error("Missing version argument."); +} + +const docsDir = `./versioned_docs/version-${version}`; +if (!fs.existsSync(docsDir)) { + throw new Error(`Documentation for version ${version} doesn't exist.`); +} + +console.info(`Updating sources in ${version} documentation`); +includedSources.updateVersionedSources({ docsDir }); diff --git a/docs/sidebars.js b/docs/sidebars.js new file mode 100644 index 0000000..d6e7f08 --- /dev/null +++ b/docs/sidebars.js @@ -0,0 +1,267 @@ +module.exports = { + default: [ + "introduction", + "rasa-pro", + { + type: "category", + label: "Installation", + collapsed: true, + items: [ + "installation/environment-set-up", + "installation/installing-rasa-open-source", + { + label: "Installing Rasa Pro", + collapsed: true, + type: "category", + items: [ + "installation/rasa-pro/rasa-pro-artifacts", + "installation/rasa-pro/installation", + ], + }, + ], + }, + { + type: "category", + label: "Building Assistants", + collapsed: false, + items: [ + "migrate-from", + "command-line-interface", + { + type: "category", + label: "Best Practices", + collapsed: true, + items: [ + "conversation-driven-development", + "generating-nlu-data", + "writing-stories", + ], + }, + { + type: "category", + label: "Conversation Patterns", + collapsed: true, + items: [ + "chitchat-faqs", + "business-logic", + "fallback-handoff", + "unexpected-input", + "contextual-conversations", + "reaching-out-to-user", + ], + }, + { + type: "category", + label: "Preparing For Production", + collapsed: true, + items: [ + "messaging-and-voice-channels", + "tuning-your-model", + "testing-your-assistant", + "setting-up-ci-cd", + ], + }, + "glossary", + ], + }, + { + type: "category", + label: "Deploying Assistants", + collapsed: true, + items: [ + "deploy/introduction", + "deploy/deploy-rasa", + "deploy/deploy-action-server", + "deploy/deploy-rasa-pro-services", + ], + }, + { + type: "category", + label: "Monitoring and Analyzing Assistants", + collapsed: true, + items: [ + { + type: "category", + label: "Analytics", + collapsed: true, + items: [ + "monitoring/analytics/getting-started-with-analytics", + "monitoring/analytics/realtime-markers", + "monitoring/analytics/example-queries", + "monitoring/analytics/data-structure-reference", + ], + }, + "monitoring/tracing", + "monitoring/load-testing-guidelines", + ], + }, + "pii-management", + { + type: "category", + label: "Concepts", + collapsed: false, + items: [ + { + type: "category", + label: "Training Data", + items: [ + "training-data-format", + "nlu-training-data", + "stories", + "rules", + ], + }, + "domain", + { + type: "category", + label: "Config", + items: [ + "model-configuration", + "components", + "policies", + "custom-graph-components", + "training-data-importers", + "language-support", + "graph-recipe", + "spaces", + ], + }, + { + type: "category", + label: "Actions", + items: [ + "actions", + "responses", + "custom-actions", + "forms", + "default-actions", + "slot-validation-actions", + ], + }, + { + type: "category", + label: "Evaluation", + items: ["markers"], + }, + { + type: "category", + label: "Channel Connectors", + items: [ + { + type: "category", + label: "Text & Chat", + items: [ + "connectors/facebook-messenger", + "connectors/slack", + "connectors/telegram", + "connectors/twilio", + "connectors/hangouts", + "connectors/microsoft-bot-framework", + "connectors/cisco-webex-teams", + "connectors/rocketchat", + "connectors/mattermost", + ], + }, + { + type: "category", + label: "Voice", + items: ["connectors/audiocodes-voiceai-connect"], + }, + "connectors/custom-connectors", + ], + }, + { + type: "category", + label: "Architecture", // name still confusing with architecture page elsewhere + items: [ + "arch-overview", + "tracker-stores", + "event-brokers", + "model-storage", + "lock-stores", + "secrets-managers", + "nlu-only", + "nlg", + ], + }, + ], + }, + { + type: "category", + label: "Action Server", + collapsed: true, + items: [ + "action-server/index", + { + "Action Server Fundamentals": [ + "action-server/actions", + "action-server/events", + ], + }, + { + "Using the Rasa SDK": [ + "action-server/running-action-server", + { + type: "category", + label: "Writing Custom Actions", + collapsed: true, + items: [ + "action-server/sdk-actions", + "action-server/sdk-tracker", + "action-server/sdk-dispatcher", + "action-server/sdk-events", + { + type: "category", + label: "Special Action Types", + collapsed: true, + items: [ + "action-server/knowledge-bases", + "action-server/validation-action", + ], + }, + ], + }, + "action-server/sanic-extensions", + ], + }, + ], + }, + { + type: "category", + label: "APIs", + collapsed: true, + items: [ + "http-api", + "nlu-only-server", + // 'jupyter-notebooks', + ], + }, + { + type: "category", + label: "Reference", + collapsed: true, + items: [ + "telemetry/telemetry", + "telemetry/reference", + require("./docs/reference/sidebar.json"), + ], + }, + { + type: "category", + label: "Change Log", + collapsed: true, + items: [ + "rasa-pro-changelog", + "changelog", + "sdk_changelog", + "compatibility-matrix", + "migration-guide", + { + type: "link", + label: "Actively Maintained Versions", + href: "https://rasa.com/rasa-product-release-and-maintenance-policy/", + }, + ], + }, + ], +}; diff --git a/docs/static/img/analytics/analytics-db-schema-old.png b/docs/static/img/analytics/analytics-db-schema-old.png new file mode 100644 index 0000000..46610b4 Binary files /dev/null and b/docs/static/img/analytics/analytics-db-schema-old.png differ diff --git a/docs/static/img/analytics/analytics-er-db.png b/docs/static/img/analytics/analytics-er-db.png new file mode 100644 index 0000000..90c6b4f Binary files /dev/null and b/docs/static/img/analytics/analytics-er-db.png differ diff --git a/docs/static/img/analytics/analytics-er-rasa-action.png b/docs/static/img/analytics/analytics-er-rasa-action.png new file mode 100644 index 0000000..7dde010 Binary files /dev/null and b/docs/static/img/analytics/analytics-er-rasa-action.png differ diff --git a/docs/static/img/analytics/analytics-er-rasa-bot-message.png b/docs/static/img/analytics/analytics-er-rasa-bot-message.png new file mode 100644 index 0000000..92470dd Binary files /dev/null and b/docs/static/img/analytics/analytics-er-rasa-bot-message.png differ diff --git a/docs/static/img/analytics/analytics-er-rasa-event.png b/docs/static/img/analytics/analytics-er-rasa-event.png new file mode 100644 index 0000000..712dd7e Binary files /dev/null and b/docs/static/img/analytics/analytics-er-rasa-event.png differ diff --git a/docs/static/img/analytics/analytics-er-rasa-sender.png b/docs/static/img/analytics/analytics-er-rasa-sender.png new file mode 100644 index 0000000..909589d Binary files /dev/null and b/docs/static/img/analytics/analytics-er-rasa-sender.png differ diff --git a/docs/static/img/analytics/analytics-er-rasa-session-slot-state.png b/docs/static/img/analytics/analytics-er-rasa-session-slot-state.png new file mode 100644 index 0000000..8512dfc Binary files /dev/null and b/docs/static/img/analytics/analytics-er-rasa-session-slot-state.png differ diff --git a/docs/static/img/analytics/analytics-er-rasa-session.png b/docs/static/img/analytics/analytics-er-rasa-session.png new file mode 100644 index 0000000..335e41c Binary files /dev/null and b/docs/static/img/analytics/analytics-er-rasa-session.png differ diff --git a/docs/static/img/analytics/analytics-er-rasa-slot.png b/docs/static/img/analytics/analytics-er-rasa-slot.png new file mode 100644 index 0000000..46bc4d6 Binary files /dev/null and b/docs/static/img/analytics/analytics-er-rasa-slot.png differ diff --git a/docs/static/img/analytics/analytics-er-rasa-turn.png b/docs/static/img/analytics/analytics-er-rasa-turn.png new file mode 100644 index 0000000..c9db72a Binary files /dev/null and b/docs/static/img/analytics/analytics-er-rasa-turn.png differ diff --git a/docs/static/img/analytics/analytics-er-rasa-user-message.png b/docs/static/img/analytics/analytics-er-rasa-user-message.png new file mode 100644 index 0000000..56d33df Binary files /dev/null and b/docs/static/img/analytics/analytics-er-rasa-user-message.png differ diff --git a/docs/static/img/analytics/analytics-examples.png b/docs/static/img/analytics/analytics-examples.png new file mode 100644 index 0000000..359c4e3 Binary files /dev/null and b/docs/static/img/analytics/analytics-examples.png differ diff --git a/docs/static/img/analytics/graph-abandonment-rate.png b/docs/static/img/analytics/graph-abandonment-rate.png new file mode 100644 index 0000000..da2c690 Binary files /dev/null and b/docs/static/img/analytics/graph-abandonment-rate.png differ diff --git a/docs/static/img/analytics/graph-escalation-rate.png b/docs/static/img/analytics/graph-escalation-rate.png new file mode 100644 index 0000000..9796309 Binary files /dev/null and b/docs/static/img/analytics/graph-escalation-rate.png differ diff --git a/docs/static/img/analytics/graph-intent-distribution.png b/docs/static/img/analytics/graph-intent-distribution.png new file mode 100644 index 0000000..4b72a52 Binary files /dev/null and b/docs/static/img/analytics/graph-intent-distribution.png differ diff --git a/docs/static/img/analytics/graph-number-sessions-channel.png b/docs/static/img/analytics/graph-number-sessions-channel.png new file mode 100644 index 0000000..6f96989 Binary files /dev/null and b/docs/static/img/analytics/graph-number-sessions-channel.png differ diff --git a/docs/static/img/analytics/graph-number-sessions-month.png b/docs/static/img/analytics/graph-number-sessions-month.png new file mode 100644 index 0000000..a9dbf26 Binary files /dev/null and b/docs/static/img/analytics/graph-number-sessions-month.png differ diff --git a/docs/static/img/analytics/graph-top-5-intents.png b/docs/static/img/analytics/graph-top-5-intents.png new file mode 100644 index 0000000..a994cf8 Binary files /dev/null and b/docs/static/img/analytics/graph-top-5-intents.png differ diff --git a/docs/static/img/analytics/realtime-markers.png b/docs/static/img/analytics/realtime-markers.png new file mode 100644 index 0000000..267a114 Binary files /dev/null and b/docs/static/img/analytics/realtime-markers.png differ diff --git a/docs/static/img/architecture.png b/docs/static/img/architecture.png new file mode 100644 index 0000000..5b7ff0f Binary files /dev/null and b/docs/static/img/architecture.png differ diff --git a/docs/static/img/blocks.png b/docs/static/img/blocks.png new file mode 100644 index 0000000..669a1a3 Binary files /dev/null and b/docs/static/img/blocks.png differ diff --git a/docs/static/img/graph_architecture.png b/docs/static/img/graph_architecture.png new file mode 100644 index 0000000..da15602 Binary files /dev/null and b/docs/static/img/graph_architecture.png differ diff --git a/docs/static/img/intent_confusion_matrix_example.png b/docs/static/img/intent_confusion_matrix_example.png new file mode 100644 index 0000000..fd23199 Binary files /dev/null and b/docs/static/img/intent_confusion_matrix_example.png differ diff --git a/docs/static/img/intent_histogram_example.png b/docs/static/img/intent_histogram_example.png new file mode 100644 index 0000000..e8caa06 Binary files /dev/null and b/docs/static/img/intent_histogram_example.png differ diff --git a/docs/static/img/introduction.png b/docs/static/img/introduction.png new file mode 100644 index 0000000..205b2af Binary files /dev/null and b/docs/static/img/introduction.png differ diff --git a/docs/static/img/logo-rasa-oss.png b/docs/static/img/logo-rasa-oss.png new file mode 100644 index 0000000..e84e066 Binary files /dev/null and b/docs/static/img/logo-rasa-oss.png differ diff --git a/docs/static/img/logo-rasa-x.png b/docs/static/img/logo-rasa-x.png new file mode 100644 index 0000000..0762b05 Binary files /dev/null and b/docs/static/img/logo-rasa-x.png differ diff --git a/docs/static/img/og-image.png b/docs/static/img/og-image.png new file mode 100644 index 0000000..4484992 Binary files /dev/null and b/docs/static/img/og-image.png differ diff --git a/docs/static/img/rasa-plus-architecture.png b/docs/static/img/rasa-plus-architecture.png new file mode 100644 index 0000000..211234f Binary files /dev/null and b/docs/static/img/rasa-plus-architecture.png differ diff --git a/docs/static/img/rasa-pro-analytics-overview.png b/docs/static/img/rasa-pro-analytics-overview.png new file mode 100644 index 0000000..9fcda67 Binary files /dev/null and b/docs/static/img/rasa-pro-analytics-overview.png differ diff --git a/docs/static/img/spaces_hierarchy.png b/docs/static/img/spaces_hierarchy.png new file mode 100644 index 0000000..2e4d61d Binary files /dev/null and b/docs/static/img/spaces_hierarchy.png differ diff --git a/docs/static/img/train-test-github-action.png b/docs/static/img/train-test-github-action.png new file mode 100644 index 0000000..f1ac97d Binary files /dev/null and b/docs/static/img/train-test-github-action.png differ diff --git a/docs/static/js/rasa-chatblock.min.js b/docs/static/js/rasa-chatblock.min.js new file mode 100644 index 0000000..be74947 --- /dev/null +++ b/docs/static/js/rasa-chatblock.min.js @@ -0,0 +1,43 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.ChatBlock=t():e.ChatBlock=t()}(window,(function(){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=43)}([function(e,t,n){"use strict";e.exports=n(28)},function(e,t,n){"use strict";n.d(t,"a",(function(){return d})),n.d(t,"b",(function(){return C})),n.d(t,"c",(function(){return p})),n.d(t,"d",(function(){return y})),n.d(t,"e",(function(){return w})),n.d(t,"f",(function(){return h}));var r=n(21),o=n.n(r),i=n(0),a=n(18),l=n(9),u=n(8),c=n(16),s=n(6),f=Object(i.createContext)("undefined"!=typeof HTMLElement?Object(a.a)():null),p=Object(i.createContext)({}),d=f.Provider,h=function(e){return Object(i.forwardRef)((function(t,n){return Object(i.createElement)(f.Consumer,null,(function(r){return e(t,r,n)}))}))},m="__EMOTION_TYPE_PLEASE_DO_NOT_USE__",g=Object.prototype.hasOwnProperty,v=function(e,t,n,r){var o=null===n?t.css:t.css(n);"string"==typeof o&&void 0!==e.registered[o]&&(o=e.registered[o]);var a=t[m],c=[o],s="";"string"==typeof t.className?s=Object(l.a)(e.registered,c,t.className):null!=t.className&&(s=t.className+" ");var f=Object(u.a)(c);Object(l.b)(e,f,"string"==typeof a);s+=e.key+"-"+f.name;var p={};for(var d in t)g.call(t,d)&&"css"!==d&&d!==m&&(p[d]=t[d]);return p.ref=r,p.className=s,Object(i.createElement)(a,p)},b=h((function(e,t,n){return"function"==typeof e.css?Object(i.createElement)(p.Consumer,null,(function(r){return v(t,e,r,n)})):v(t,e,null,n)}));var y=function(e,t){var n=arguments;if(null==t||!g.call(t,"css"))return i.createElement.apply(void 0,n);var r=n.length,o=new Array(r);o[0]=b;var a={};for(var l in t)g.call(t,l)&&(a[l]=t[l]);a[m]=e,o[1]=a;for(var u=2;u<r;u++)o[u]=n[u];return i.createElement.apply(null,o)},w=(i.Component,function(){var e=s.a.apply(void 0,arguments),t="animation-"+e.name;return{name:t,styles:"@keyframes "+t+"{"+e.styles+"}",anim:1,toString:function(){return"_EMO_"+this.name+"_"+this.styles+"_EMO_"}}}),x=function e(t){for(var n=t.length,r=0,o="";r<n;r++){var i=t[r];if(null!=i){var a=void 0;switch(typeof i){case"boolean":break;case"object":if(Array.isArray(i))a=e(i);else for(var l in a="",i)i[l]&&l&&(a&&(a+=" "),a+=l);break;default:a=i}a&&(o&&(o+=" "),o+=a)}}return o};function E(e,t,n){var r=[],o=Object(l.a)(e,r,n);return r.length<2?n:o+t(r)}var C=h((function(e,t){return Object(i.createElement)(p.Consumer,null,(function(n){var r=function(){for(var e=arguments.length,n=new Array(e),r=0;r<e;r++)n[r]=arguments[r];var o=Object(u.a)(n,t.registered);return Object(l.b)(t,o,!1),t.key+"-"+o.name},o={css:r,cx:function(){for(var e=arguments.length,n=new Array(e),o=0;o<e;o++)n[o]=arguments[o];return E(t.registered,r,x(n))},theme:n},i=e.children(o);return!0,i}))}))},function(e,t,n){e.exports=n(34)()},function(e,t,n){"use strict";n.r(t),n.d(t,"get",(function(){return c})),n.d(t,"createParser",(function(){return s})),n.d(t,"createStyleFunction",(function(){return d})),n.d(t,"compose",(function(){return m})),n.d(t,"system",(function(){return h})),n.d(t,"margin",(function(){return B})),n.d(t,"padding",(function(){return H})),n.d(t,"space",(function(){return W})),n.d(t,"color",(function(){return y})),n.d(t,"layout",(function(){return g})),n.d(t,"typography",(function(){return x})),n.d(t,"flexbox",(function(){return C})),n.d(t,"border",(function(){return P})),n.d(t,"background",(function(){return z})),n.d(t,"position",(function(){return j})),n.d(t,"grid",(function(){return O})),n.d(t,"shadow",(function(){return U})),n.d(t,"boxShadow",(function(){return $})),n.d(t,"textShadow",(function(){return $})),n.d(t,"variant",(function(){return Q})),n.d(t,"buttonStyle",(function(){return G})),n.d(t,"textStyle",(function(){return X})),n.d(t,"colorStyle",(function(){return q})),n.d(t,"borders",(function(){return A})),n.d(t,"width",(function(){return K})),n.d(t,"height",(function(){return Z})),n.d(t,"minWidth",(function(){return J})),n.d(t,"minHeight",(function(){return ee})),n.d(t,"maxWidth",(function(){return te})),n.d(t,"maxHeight",(function(){return ne})),n.d(t,"size",(function(){return re})),n.d(t,"verticalAlign",(function(){return oe})),n.d(t,"display",(function(){return ie})),n.d(t,"overflow",(function(){return ae})),n.d(t,"overflowX",(function(){return le})),n.d(t,"overflowY",(function(){return ue})),n.d(t,"opacity",(function(){return ce})),n.d(t,"fontSize",(function(){return se})),n.d(t,"fontFamily",(function(){return fe})),n.d(t,"fontWeight",(function(){return pe})),n.d(t,"lineHeight",(function(){return de})),n.d(t,"textAlign",(function(){return he})),n.d(t,"fontStyle",(function(){return me})),n.d(t,"letterSpacing",(function(){return ge})),n.d(t,"alignItems",(function(){return ve})),n.d(t,"alignContent",(function(){return be})),n.d(t,"justifyItems",(function(){return ye})),n.d(t,"justifyContent",(function(){return we})),n.d(t,"flexWrap",(function(){return xe})),n.d(t,"flexDirection",(function(){return Ee})),n.d(t,"flex",(function(){return Ce})),n.d(t,"flexGrow",(function(){return ke})),n.d(t,"flexShrink",(function(){return Se})),n.d(t,"flexBasis",(function(){return Oe})),n.d(t,"justifySelf",(function(){return Te})),n.d(t,"alignSelf",(function(){return _e})),n.d(t,"order",(function(){return Pe})),n.d(t,"gridGap",(function(){return Ae})),n.d(t,"gridColumnGap",(function(){return Me})),n.d(t,"gridRowGap",(function(){return ze})),n.d(t,"gridColumn",(function(){return Fe})),n.d(t,"gridRow",(function(){return De})),n.d(t,"gridAutoFlow",(function(){return je})),n.d(t,"gridAutoColumns",(function(){return Le})),n.d(t,"gridAutoRows",(function(){return Re})),n.d(t,"gridTemplateColumns",(function(){return Ie})),n.d(t,"gridTemplateRows",(function(){return Ne})),n.d(t,"gridTemplateAreas",(function(){return Ve})),n.d(t,"gridArea",(function(){return Be})),n.d(t,"borderWidth",(function(){return He})),n.d(t,"borderStyle",(function(){return We})),n.d(t,"borderColor",(function(){return Ue})),n.d(t,"borderTop",(function(){return $e})),n.d(t,"borderRight",(function(){return Ye})),n.d(t,"borderBottom",(function(){return Qe})),n.d(t,"borderLeft",(function(){return Ge})),n.d(t,"borderRadius",(function(){return Xe})),n.d(t,"backgroundImage",(function(){return qe})),n.d(t,"backgroundSize",(function(){return Ke})),n.d(t,"backgroundPosition",(function(){return Ze})),n.d(t,"backgroundRepeat",(function(){return Je})),n.d(t,"zIndex",(function(){return et})),n.d(t,"top",(function(){return tt})),n.d(t,"right",(function(){return nt})),n.d(t,"bottom",(function(){return rt})),n.d(t,"left",(function(){return ot})),n.d(t,"style",(function(){return it}));var r=n(4),o=n.n(r),i=function(e,t){var n=o()({},e,t);for(var r in e){var i;e[r]&&"object"==typeof t[r]&&o()(n,((i={})[r]=o()(e[r],t[r]),i))}return n},a={breakpoints:[40,52,64].map((function(e){return e+"em"}))},l=function(e){return"@media screen and (min-width: "+e+")"},u=function(e,t){return c(t,e,e)},c=function(e,t,n,r,o){for(t=t&&t.split?t.split("."):[t],r=0;r<t.length;r++)e=e?e[t[r]]:o;return e===o?n:e},s=function e(t){var n={},r=function(e){var r,u,s={},d=!1,h=e.theme&&e.theme.disableStyledSystemCache;for(var m in e)if(t[m]){var g=t[m],v=e[m],b=c(e.theme,g.scale,g.defaults);if("object"!=typeof v)o()(s,g(v,b,e));else{if(n.breakpoints=!h&&n.breakpoints||c(e.theme,"breakpoints",a.breakpoints),Array.isArray(v)){n.media=!h&&n.media||[null].concat(n.breakpoints.map(l)),s=i(s,f(n.media,g,b,v,e));continue}null!==v&&(s=i(s,p(n.breakpoints,g,b,v,e)),d=!0)}}return d&&(r=s,u={},Object.keys(r).sort((function(e,t){return e.localeCompare(t,void 0,{numeric:!0,sensitivity:"base"})})).forEach((function(e){u[e]=r[e]})),s=u),s};r.config=t,r.propNames=Object.keys(t),r.cache=n;var u=Object.keys(t).filter((function(e){return"config"!==e}));return u.length>1&&u.forEach((function(n){var o;r[n]=e(((o={})[n]=t[n],o))})),r},f=function(e,t,n,r,i){var a={};return r.slice(0,e.length).forEach((function(r,l){var u,c=e[l],s=t(r,n,i);c?o()(a,((u={})[c]=o()({},a[c],s),u)):o()(a,s)})),a},p=function(e,t,n,r,i){var a={};for(var u in r){var c=e[u],s=t(r[u],n,i);if(c){var f,p=l(c);o()(a,((f={})[p]=o()({},a[p],s),f))}else o()(a,s)}return a},d=function(e){var t=e.properties,n=e.property,r=e.scale,o=e.transform,i=void 0===o?u:o,a=e.defaultScale;t=t||[n];var l=function(e,n,r){var o={},a=i(e,n,r);if(null!==a)return t.forEach((function(e){o[e]=a})),o};return l.scale=r,l.defaults=a,l},h=function(e){void 0===e&&(e={});var t={};return Object.keys(e).forEach((function(n){var r=e[n];t[n]=!0!==r?"function"!=typeof r?d(r):r:d({property:n,scale:n})})),s(t)},m=function(){for(var e={},t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];n.forEach((function(t){t&&t.config&&o()(e,t.config)}));var i=s(e);return i},g=h({width:{property:"width",scale:"sizes",transform:function(e,t){return c(t,e,!function(e){return"number"==typeof e&&!isNaN(e)}(e)||e>1?e:100*e+"%")}},height:{property:"height",scale:"sizes"},minWidth:{property:"minWidth",scale:"sizes"},minHeight:{property:"minHeight",scale:"sizes"},maxWidth:{property:"maxWidth",scale:"sizes"},maxHeight:{property:"maxHeight",scale:"sizes"},size:{properties:["width","height"],scale:"sizes"},overflow:!0,overflowX:!0,overflowY:!0,display:!0,verticalAlign:!0}),v=g,b={color:{property:"color",scale:"colors"},backgroundColor:{property:"backgroundColor",scale:"colors"},opacity:!0};b.bg=b.backgroundColor;var y=h(b),w=y,x=h({fontFamily:{property:"fontFamily",scale:"fonts"},fontSize:{property:"fontSize",scale:"fontSizes",defaultScale:[12,14,16,20,24,32,48,64,72]},fontWeight:{property:"fontWeight",scale:"fontWeights"},lineHeight:{property:"lineHeight",scale:"lineHeights"},letterSpacing:{property:"letterSpacing",scale:"letterSpacings"},textAlign:!0,fontStyle:!0}),E=x,C=h({alignItems:!0,alignContent:!0,justifyItems:!0,justifyContent:!0,flexWrap:!0,flexDirection:!0,flex:!0,flexGrow:!0,flexShrink:!0,flexBasis:!0,justifySelf:!0,alignSelf:!0,order:!0}),k=C,S={space:[0,4,8,16,32,64,128,256,512]},O=h({gridGap:{property:"gridGap",scale:"space",defaultScale:S.space},gridColumnGap:{property:"gridColumnGap",scale:"space",defaultScale:S.space},gridRowGap:{property:"gridRowGap",scale:"space",defaultScale:S.space},gridColumn:!0,gridRow:!0,gridAutoFlow:!0,gridAutoColumns:!0,gridAutoRows:!0,gridTemplateColumns:!0,gridTemplateRows:!0,gridTemplateAreas:!0,gridArea:!0}),T=O,_={border:{property:"border",scale:"borders"},borderWidth:{property:"borderWidth",scale:"borderWidths"},borderStyle:{property:"borderStyle",scale:"borderStyles"},borderColor:{property:"borderColor",scale:"colors"},borderRadius:{property:"borderRadius",scale:"radii"},borderTop:{property:"borderTop",scale:"borders"},borderTopLeftRadius:{property:"borderTopLeftRadius",scale:"radii"},borderTopRightRadius:{property:"borderTopRightRadius",scale:"radii"},borderRight:{property:"borderRight",scale:"borders"},borderBottom:{property:"borderBottom",scale:"borders"},borderBottomLeftRadius:{property:"borderBottomLeftRadius",scale:"radii"},borderBottomRightRadius:{property:"borderBottomRightRadius",scale:"radii"},borderLeft:{property:"borderLeft",scale:"borders"},borderX:{properties:["borderLeft","borderRight"],scale:"borders"},borderY:{properties:["borderTop","borderBottom"],scale:"borders"},borderTopWidth:{property:"borderTopWidth",scale:"borderWidths"},borderTopColor:{property:"borderTopColor",scale:"colors"},borderTopStyle:{property:"borderTopStyle",scale:"borderStyles"}};_.borderTopLeftRadius={property:"borderTopLeftRadius",scale:"radii"},_.borderTopRightRadius={property:"borderTopRightRadius",scale:"radii"},_.borderBottomWidth={property:"borderBottomWidth",scale:"borderWidths"},_.borderBottomColor={property:"borderBottomColor",scale:"colors"},_.borderBottomStyle={property:"borderBottomStyle",scale:"borderStyles"},_.borderBottomLeftRadius={property:"borderBottomLeftRadius",scale:"radii"},_.borderBottomRightRadius={property:"borderBottomRightRadius",scale:"radii"},_.borderLeftWidth={property:"borderLeftWidth",scale:"borderWidths"},_.borderLeftColor={property:"borderLeftColor",scale:"colors"},_.borderLeftStyle={property:"borderLeftStyle",scale:"borderStyles"},_.borderRightWidth={property:"borderRightWidth",scale:"borderWidths"},_.borderRightColor={property:"borderRightColor",scale:"colors"},_.borderRightStyle={property:"borderRightStyle",scale:"borderStyles"};var P=h(_),A=P,M={background:!0,backgroundImage:!0,backgroundSize:!0,backgroundPosition:!0,backgroundRepeat:!0};M.bgImage=M.backgroundImage,M.bgSize=M.backgroundSize,M.bgPosition=M.backgroundPosition,M.bgRepeat=M.backgroundRepeat;var z=h(M),F=z,D={space:[0,4,8,16,32,64,128,256,512]},j=h({position:!0,zIndex:{property:"zIndex",scale:"zIndices"},top:{property:"top",scale:"space",defaultScale:D.space},right:{property:"right",scale:"space",defaultScale:D.space},bottom:{property:"bottom",scale:"space",defaultScale:D.space},left:{property:"left",scale:"space",defaultScale:D.space}}),L=j,R={space:[0,4,8,16,32,64,128,256,512]},I=function(e){return"number"==typeof e&&!isNaN(e)},N=function(e,t){if(!I(e))return c(t,e,e);var n=e<0,r=Math.abs(e),o=c(t,r,r);return I(o)?o*(n?-1:1):n?"-"+o:o},V={};V.margin={margin:{property:"margin",scale:"space",transform:N,defaultScale:R.space},marginTop:{property:"marginTop",scale:"space",transform:N,defaultScale:R.space},marginRight:{property:"marginRight",scale:"space",transform:N,defaultScale:R.space},marginBottom:{property:"marginBottom",scale:"space",transform:N,defaultScale:R.space},marginLeft:{property:"marginLeft",scale:"space",transform:N,defaultScale:R.space},marginX:{properties:["marginLeft","marginRight"],scale:"space",transform:N,defaultScale:R.space},marginY:{properties:["marginTop","marginBottom"],scale:"space",transform:N,defaultScale:R.space}},V.margin.m=V.margin.margin,V.margin.mt=V.margin.marginTop,V.margin.mr=V.margin.marginRight,V.margin.mb=V.margin.marginBottom,V.margin.ml=V.margin.marginLeft,V.margin.mx=V.margin.marginX,V.margin.my=V.margin.marginY,V.padding={padding:{property:"padding",scale:"space",defaultScale:R.space},paddingTop:{property:"paddingTop",scale:"space",defaultScale:R.space},paddingRight:{property:"paddingRight",scale:"space",defaultScale:R.space},paddingBottom:{property:"paddingBottom",scale:"space",defaultScale:R.space},paddingLeft:{property:"paddingLeft",scale:"space",defaultScale:R.space},paddingX:{properties:["paddingLeft","paddingRight"],scale:"space",defaultScale:R.space},paddingY:{properties:["paddingTop","paddingBottom"],scale:"space",defaultScale:R.space}},V.padding.p=V.padding.padding,V.padding.pt=V.padding.paddingTop,V.padding.pr=V.padding.paddingRight,V.padding.pb=V.padding.paddingBottom,V.padding.pl=V.padding.paddingLeft,V.padding.px=V.padding.paddingX,V.padding.py=V.padding.paddingY;var B=h(V.margin),H=h(V.padding),W=m(B,H),U=h({boxShadow:{property:"boxShadow",scale:"shadows"},textShadow:{property:"textShadow",scale:"shadows"}}),$=U,Y=n(5),Q=function(e){var t,n,r=e.scale,o=e.prop,i=void 0===o?"variant":o,a=e.variants,l=void 0===a?{}:a,u=e.key;(n=Object.keys(l).length?function(e,t,n){return Object(Y.default)(c(t,e,null))(n.theme)}:function(e,t){return c(t,e,null)}).scale=r||u,n.defaults=l;var f=((t={})[i]=n,t);return s(f)},G=Q({key:"buttons"}),X=Q({key:"textStyles",prop:"textStyle"}),q=Q({key:"colorStyles",prop:"colors"}),K=v.width,Z=v.height,J=v.minWidth,ee=v.minHeight,te=v.maxWidth,ne=v.maxHeight,re=v.size,oe=v.verticalAlign,ie=v.display,ae=v.overflow,le=v.overflowX,ue=v.overflowY,ce=w.opacity,se=E.fontSize,fe=E.fontFamily,pe=E.fontWeight,de=E.lineHeight,he=E.textAlign,me=E.fontStyle,ge=E.letterSpacing,ve=k.alignItems,be=k.alignContent,ye=k.justifyItems,we=k.justifyContent,xe=k.flexWrap,Ee=k.flexDirection,Ce=k.flex,ke=k.flexGrow,Se=k.flexShrink,Oe=k.flexBasis,Te=k.justifySelf,_e=k.alignSelf,Pe=k.order,Ae=T.gridGap,Me=T.gridColumnGap,ze=T.gridRowGap,Fe=T.gridColumn,De=T.gridRow,je=T.gridAutoFlow,Le=T.gridAutoColumns,Re=T.gridAutoRows,Ie=T.gridTemplateColumns,Ne=T.gridTemplateRows,Ve=T.gridTemplateAreas,Be=T.gridArea,He=A.borderWidth,We=A.borderStyle,Ue=A.borderColor,$e=A.borderTop,Ye=A.borderRight,Qe=A.borderBottom,Ge=A.borderLeft,Xe=A.borderRadius,qe=F.backgroundImage,Ke=F.backgroundSize,Ze=F.backgroundPosition,Je=F.backgroundRepeat,et=L.zIndex,tt=L.top,nt=L.right,rt=L.bottom,ot=L.left,it=function(e){var t=e.prop,n=e.cssProperty,r=e.alias,o=e.key,i=e.transformValue,a=e.scale,l=e.properties,u={};return u[t]=d({properties:l,property:n||t,scale:o,defaultScale:a,transform:i}),r&&(u[r]=u[t]),s(u)}},function(e,t,n){"use strict"; +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/var r=Object.getOwnPropertySymbols,o=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable;function a(e){if(null==e)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(e)}e.exports=function(){try{if(!Object.assign)return!1;var e=new String("abc");if(e[5]="de","5"===Object.getOwnPropertyNames(e)[0])return!1;for(var t={},n=0;n<10;n++)t["_"+String.fromCharCode(n)]=n;if("0123456789"!==Object.getOwnPropertyNames(t).map((function(e){return t[e]})).join(""))return!1;var r={};return"abcdefghijklmnopqrst".split("").forEach((function(e){r[e]=e})),"abcdefghijklmnopqrst"===Object.keys(Object.assign({},r)).join("")}catch(e){return!1}}()?Object.assign:function(e,t){for(var n,l,u=a(e),c=1;c<arguments.length;c++){for(var s in n=Object(arguments[c]))o.call(n,s)&&(u[s]=n[s]);if(r){l=r(n);for(var f=0;f<l.length;f++)i.call(n,l[f])&&(u[l[f]]=n[l[f]])}}return u}},function(e,t,n){"use strict";function r(){return(r=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}n.r(t),n.d(t,"get",(function(){return o})),n.d(t,"responsive",(function(){return p})),n.d(t,"css",(function(){return d}));var o=function(e,t,n,r,o){for(t=t&&t.split?t.split("."):[t],r=0;r<t.length;r++)e=e?e[t[r]]:o;return e===o?n:e},i=[40,52,64].map((function(e){return e+"em"})),a={space:[0,4,8,16,32,64,128,256,512],fontSizes:[12,14,16,20,24,32,48,64,72]},l={bg:"backgroundColor",m:"margin",mt:"marginTop",mr:"marginRight",mb:"marginBottom",ml:"marginLeft",mx:"marginX",my:"marginY",p:"padding",pt:"paddingTop",pr:"paddingRight",pb:"paddingBottom",pl:"paddingLeft",px:"paddingX",py:"paddingY"},u={marginX:["marginLeft","marginRight"],marginY:["marginTop","marginBottom"],paddingX:["paddingLeft","paddingRight"],paddingY:["paddingTop","paddingBottom"],size:["width","height"]},c={color:"colors",backgroundColor:"colors",borderColor:"colors",margin:"space",marginTop:"space",marginRight:"space",marginBottom:"space",marginLeft:"space",marginX:"space",marginY:"space",padding:"space",paddingTop:"space",paddingRight:"space",paddingBottom:"space",paddingLeft:"space",paddingX:"space",paddingY:"space",top:"space",right:"space",bottom:"space",left:"space",gridGap:"space",gridColumnGap:"space",gridRowGap:"space",gap:"space",columnGap:"space",rowGap:"space",fontFamily:"fonts",fontSize:"fontSizes",fontWeight:"fontWeights",lineHeight:"lineHeights",letterSpacing:"letterSpacings",border:"borders",borderTop:"borders",borderRight:"borders",borderBottom:"borders",borderLeft:"borders",borderWidth:"borderWidths",borderStyle:"borderStyles",borderRadius:"radii",borderTopRightRadius:"radii",borderTopLeftRadius:"radii",borderBottomRightRadius:"radii",borderBottomLeftRadius:"radii",borderTopWidth:"borderWidths",borderTopColor:"colors",borderTopStyle:"borderStyles",borderBottomWidth:"borderWidths",borderBottomColor:"colors",borderBottomStyle:"borderStyles",borderLeftWidth:"borderWidths",borderLeftColor:"colors",borderLeftStyle:"borderStyles",borderRightWidth:"borderWidths",borderRightColor:"colors",borderRightStyle:"borderStyles",outlineColor:"colors",boxShadow:"shadows",textShadow:"shadows",zIndex:"zIndices",width:"sizes",minWidth:"sizes",maxWidth:"sizes",height:"sizes",minHeight:"sizes",maxHeight:"sizes",flexBasis:"sizes",size:"sizes",fill:"colors",stroke:"colors"},s=function(e,t){if("number"!=typeof t||t>=0)return o(e,t,t);var n=Math.abs(t),r=o(e,n,n);return"string"==typeof r?"-"+r:-1*r},f=["margin","marginTop","marginRight","marginBottom","marginLeft","marginX","marginY","top","bottom","left","right"].reduce((function(e,t){var n;return r({},e,((n={})[t]=s,n))}),{}),p=function(e){return function(t){var n={},r=o(t,"breakpoints",i),a=[null].concat(r.map((function(e){return"@media screen and (min-width: "+e+")"})));for(var l in e){var u="function"==typeof e[l]?e[l](t):e[l];if(null!=u)if(Array.isArray(u))for(var c=0;c<u.slice(0,a.length).length;c++){var s=a[c];s?(n[s]=n[s]||{},null!=u[c]&&(n[s][l]=u[c])):n[l]=u[c]}else n[l]=u}return n}},d=function e(t){return function(n){void 0===n&&(n={});var i=r({},a,{},n.theme||n),s={},d="function"==typeof t?t(i):t,h=p(d)(i);for(var m in h){var g=h[m],v="function"==typeof g?g(i):g;if("variant"!==m)if(v&&"object"==typeof v)s[m]=e(v)(i);else{var b=o(l,m,m),y=o(c,b),w=o(i,y,o(i,b,{})),x=o(f,b,o)(w,v,v);if(u[b])for(var E=u[b],C=0;C<E.length;C++)s[E[C]]=x;else s[b]=x}else s=r({},s,{},e(o(i,v))(i))}return s}};t.default=d},function(e,t,n){"use strict";var r=n(8);t.a=function(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];return Object(r.a)(t)}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.Flex=t.Box=void 0;u(n(0));var r=u(n(11)),o=n(3),i=function(e){if(e&&e.__esModule)return e;var t=l();if(t&&t.has(e))return t.get(e);var n={};if(null!=e){var r=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var o in e)if(Object.prototype.hasOwnProperty.call(e,o)){var i=r?Object.getOwnPropertyDescriptor(e,o):null;i&&(i.get||i.set)?Object.defineProperty(n,o,i):n[o]=e[o]}}n.default=e,t&&t.set(e,n);return n}(n(5)),a=u(n(32));function l(){if("function"!=typeof WeakMap)return null;var e=new WeakMap;return l=function(){return e},e}function u(e){return e&&e.__esModule?e:{default:e}}var c=(0,r.default)("div",{shouldForwardProp:a.default})({boxSizing:"border-box",margin:0,minWidth:0},(function(e){return(0,i.default)(e.__css)(e.theme)}),(function(e){var t=e.theme,n=e.variant,r=e.tx,o=void 0===r?"variants":r;return(0,i.default)((0,i.get)(t,o+"."+n,(0,i.get)(t,n)))(t)}),(function(e){return(0,i.default)(e.sx)(e.theme)}),(function(e){return e.css}),(0,o.compose)(o.space,o.layout,o.typography,o.color,o.flexbox));t.Box=c;var s=(0,r.default)(c)({display:"flex"});t.Flex=s},function(e,t,n){"use strict";n.d(t,"a",(function(){return m}));var r=function(e){for(var t,n=0,r=0,o=e.length;o>=4;++r,o-=4)t=1540483477*(65535&(t=255&e.charCodeAt(r)|(255&e.charCodeAt(++r))<<8|(255&e.charCodeAt(++r))<<16|(255&e.charCodeAt(++r))<<24))+(59797*(t>>>16)<<16),n=1540483477*(65535&(t^=t>>>24))+(59797*(t>>>16)<<16)^1540483477*(65535&n)+(59797*(n>>>16)<<16);switch(o){case 3:n^=(255&e.charCodeAt(r+2))<<16;case 2:n^=(255&e.charCodeAt(r+1))<<8;case 1:n=1540483477*(65535&(n^=255&e.charCodeAt(r)))+(59797*(n>>>16)<<16)}return(((n=1540483477*(65535&(n^=n>>>13))+(59797*(n>>>16)<<16))^n>>>15)>>>0).toString(36)},o={animationIterationCount:1,borderImageOutset:1,borderImageSlice:1,borderImageWidth:1,boxFlex:1,boxFlexGroup:1,boxOrdinalGroup:1,columnCount:1,columns:1,flex:1,flexGrow:1,flexPositive:1,flexShrink:1,flexNegative:1,flexOrder:1,gridRow:1,gridRowEnd:1,gridRowSpan:1,gridRowStart:1,gridColumn:1,gridColumnEnd:1,gridColumnSpan:1,gridColumnStart:1,msGridRow:1,msGridRowSpan:1,msGridColumn:1,msGridColumnSpan:1,fontWeight:1,lineHeight:1,opacity:1,order:1,orphans:1,tabSize:1,widows:1,zIndex:1,zoom:1,WebkitLineClamp:1,fillOpacity:1,floodOpacity:1,stopOpacity:1,strokeDasharray:1,strokeDashoffset:1,strokeMiterlimit:1,strokeOpacity:1,strokeWidth:1},i=n(12),a=/[A-Z]|^ms/g,l=/_EMO_([^_]+?)_([^]*?)_EMO_/g,u=function(e){return 45===e.charCodeAt(1)},c=function(e){return null!=e&&"boolean"!=typeof e},s=Object(i.a)((function(e){return u(e)?e:e.replace(a,"-$&").toLowerCase()})),f=function(e,t){switch(e){case"animation":case"animationName":if("string"==typeof t)return t.replace(l,(function(e,t,n){return d={name:t,styles:n,next:d},t}))}return 1===o[e]||u(e)||"number"!=typeof t||0===t?t:t+"px"};function p(e,t,n,r){if(null==n)return"";if(void 0!==n.__emotion_styles)return n;switch(typeof n){case"boolean":return"";case"object":if(1===n.anim)return d={name:n.name,styles:n.styles,next:d},n.name;if(void 0!==n.styles){var o=n.next;if(void 0!==o)for(;void 0!==o;)d={name:o.name,styles:o.styles,next:d},o=o.next;return n.styles+";"}return function(e,t,n){var r="";if(Array.isArray(n))for(var o=0;o<n.length;o++)r+=p(e,t,n[o],!1);else for(var i in n){var a=n[i];if("object"!=typeof a)null!=t&&void 0!==t[a]?r+=i+"{"+t[a]+"}":c(a)&&(r+=s(i)+":"+f(i,a)+";");else if(!Array.isArray(a)||"string"!=typeof a[0]||null!=t&&void 0!==t[a[0]]){var l=p(e,t,a,!1);switch(i){case"animation":case"animationName":r+=s(i)+":"+l+";";break;default:r+=i+"{"+l+"}"}}else for(var u=0;u<a.length;u++)c(a[u])&&(r+=s(i)+":"+f(i,a[u])+";")}return r}(e,t,n);case"function":if(void 0!==e){var i=d,a=n(e);return d=i,p(e,t,a,r)}break;case"string":}if(null==t)return n;var l=t[n];return void 0===l||r?n:l}var d,h=/label:\s*([^\s;\n{]+)\s*;/g;var m=function(e,t,n){if(1===e.length&&"object"==typeof e[0]&&null!==e[0]&&void 0!==e[0].styles)return e[0];var o=!0,i="";d=void 0;var a=e[0];null==a||void 0===a.raw?(o=!1,i+=p(n,t,a,!1)):i+=a[0];for(var l=1;l<e.length;l++)i+=p(n,t,e[l],46===i.charCodeAt(i.length-1)),o&&(i+=a[l]);h.lastIndex=0;for(var u,c="";null!==(u=h.exec(i));)c+="-"+u[1];return{name:r(i)+c,styles:i,next:d}}},function(e,t,n){"use strict";n.d(t,"a",(function(){return r})),n.d(t,"b",(function(){return o}));function r(e,t,n){var r="";return n.split(" ").forEach((function(n){void 0!==e[n]?t.push(e[n]):r+=n+" "})),r}var o=function(e,t,n){var r=e.key+"-"+t.name;if(!1===n&&void 0===e.registered[r]&&(e.registered[r]=t.styles),void 0===e.inserted[t.name]){var o=t;do{e.insert("."+r,o,e.sheet,!0);o=o.next}while(void 0!==o)}}},function(e,t,n){"use strict";!function e(){if("undefined"!=typeof __REACT_DEVTOOLS_GLOBAL_HOOK__&&"function"==typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE){0;try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(e){console.error(e)}}}(),e.exports=n(29)},function(e,t,n){"use strict";n.r(t);var r=n(14),o=n.n(r),i=n(0),a=n(15),l=n(1),u=n(9),c=n(8),s=a.a,f=function(e){return"theme"!==e&&"innerRef"!==e},p=function(e){return"string"==typeof e&&e.charCodeAt(0)>96?s:f};function d(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function h(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?d(n,!0).forEach((function(t){o()(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):d(n).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}var m=function e(t,n){var r,o,a;void 0!==n&&(r=n.label,a=n.target,o=t.__emotion_forwardProp&&n.shouldForwardProp?function(e){return t.__emotion_forwardProp(e)&&n.shouldForwardProp(e)}:n.shouldForwardProp);var s=t.__emotion_real===t,f=s&&t.__emotion_base||t;"function"!=typeof o&&s&&(o=t.__emotion_forwardProp);var d=o||p(f),m=!d("as");return function(){var g=arguments,v=s&&void 0!==t.__emotion_styles?t.__emotion_styles.slice(0):[];if(void 0!==r&&v.push("label:"+r+";"),null==g[0]||void 0===g[0].raw)v.push.apply(v,g);else{0,v.push(g[0][0]);for(var b=g.length,y=1;y<b;y++)v.push(g[y],g[0][y])}var w=Object(l.f)((function(e,t,n){return Object(i.createElement)(l.c.Consumer,null,(function(r){var l=m&&e.as||f,s="",h=[],g=e;if(null==e.theme){for(var b in g={},e)g[b]=e[b];g.theme=r}"string"==typeof e.className?s=Object(u.a)(t.registered,h,e.className):null!=e.className&&(s=e.className+" ");var y=Object(c.a)(v.concat(h),t.registered,g);Object(u.b)(t,y,"string"==typeof l);s+=t.key+"-"+y.name,void 0!==a&&(s+=" "+a);var w=m&&void 0===o?p(l):d,x={};for(var E in e)m&&"as"===E||w(E)&&(x[E]=e[E]);return x.className=s,x.ref=n||e.innerRef,Object(i.createElement)(l,x)}))}));return w.displayName=void 0!==r?r:"Styled("+("string"==typeof f?f:f.displayName||f.name||"Component")+")",w.defaultProps=t.defaultProps,w.__emotion_real=w,w.__emotion_base=f,w.__emotion_styles=v,w.__emotion_forwardProp=o,Object.defineProperty(w,"toString",{value:function(){return"."+a}}),w.withComponent=function(t,r){return e(t,void 0!==r?h({},n||{},{},r):n).apply(void 0,v)},w}}.bind();["a","abbr","address","area","article","aside","audio","b","base","bdi","bdo","big","blockquote","body","br","button","canvas","caption","cite","code","col","colgroup","data","datalist","dd","del","details","dfn","dialog","div","dl","dt","em","embed","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","iframe","img","input","ins","kbd","keygen","label","legend","li","link","main","map","mark","marquee","menu","menuitem","meta","meter","nav","noscript","object","ol","optgroup","option","output","p","param","picture","pre","progress","q","rp","rt","ruby","s","samp","script","section","select","small","source","span","strong","style","sub","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","title","tr","track","u","ul","var","video","wbr","circle","clipPath","defs","ellipse","foreignObject","g","image","line","linearGradient","mask","path","pattern","polygon","polyline","radialGradient","rect","stop","svg","text","tspan"].forEach((function(e){m[e]=m(e)}));t.default=m},function(e,t,n){"use strict";t.a=function(e){var t={};return function(n){return void 0===t[n]&&(t[n]=e(n)),t[n]}}},function(e,t,n){"use strict";(function(e,r){function o(e){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function i(e,t){for(var n=0;n<t.length;n++){var r=t[n];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(e,r.key,r)}}function a(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function l(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{},r=Object.keys(n);"function"==typeof Object.getOwnPropertySymbols&&(r=r.concat(Object.getOwnPropertySymbols(n).filter((function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable})))),r.forEach((function(t){a(e,t,n[t])}))}return e}function u(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=[],r=!0,o=!1,i=void 0;try{for(var a,l=e[Symbol.iterator]();!(r=(a=l.next()).done)&&(n.push(a.value),!t||n.length!==t);r=!0);}catch(e){o=!0,i=e}finally{try{r||null==l.return||l.return()}finally{if(o)throw i}}return n}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance")}()}n.d(t,"a",(function(){return ke})),n.d(t,"b",(function(){return xe})),n.d(t,"c",(function(){return Ce}));var c=function(){},s={},f={},p={mark:c,measure:c};try{"undefined"!=typeof window&&(s=window),"undefined"!=typeof document&&(f=document),"undefined"!=typeof MutationObserver&&MutationObserver,"undefined"!=typeof performance&&(p=performance)}catch(e){}var d=(s.navigator||{}).userAgent,h=void 0===d?"":d,m=s,g=f,v=p,b=(m.document,!!g.documentElement&&!!g.head&&"function"==typeof g.addEventListener&&"function"==typeof g.createElement),y=(~h.indexOf("MSIE")||h.indexOf("Trident/"),function(){try{}catch(e){return!1}}(),[1,2,3,4,5,6,7,8,9,10]),w=y.concat([11,12,13,14,15,16,17,18,19,20]),x={GROUP:"group",SWAP_OPACITY:"swap-opacity",PRIMARY:"primary",SECONDARY:"secondary"},E=(["xs","sm","lg","fw","ul","li","border","pull-left","pull-right","spin","pulse","rotate-90","rotate-180","rotate-270","flip-horizontal","flip-vertical","flip-both","stack","stack-1x","stack-2x","inverse","layers","layers-text","layers-counter",x.GROUP,x.SWAP_OPACITY,x.PRIMARY,x.SECONDARY].concat(y.map((function(e){return"".concat(e,"x")}))).concat(w.map((function(e){return"w-".concat(e)}))),m.FontAwesomeConfig||{});if(g&&"function"==typeof g.querySelector){[["data-family-prefix","familyPrefix"],["data-replacement-class","replacementClass"],["data-auto-replace-svg","autoReplaceSvg"],["data-auto-add-css","autoAddCss"],["data-auto-a11y","autoA11y"],["data-search-pseudo-elements","searchPseudoElements"],["data-observe-mutations","observeMutations"],["data-mutate-approach","mutateApproach"],["data-keep-original-source","keepOriginalSource"],["data-measure-performance","measurePerformance"],["data-show-missing-icons","showMissingIcons"]].forEach((function(e){var t=u(e,2),n=t[0],r=t[1],o=function(e){return""===e||"false"!==e&&("true"===e||e)}(function(e){var t=g.querySelector("script["+e+"]");if(t)return t.getAttribute(e)}(n));null!=o&&(E[r]=o)}))}var C=l({},{familyPrefix:"fa",replacementClass:"svg-inline--fa",autoReplaceSvg:!0,autoAddCss:!0,autoA11y:!0,searchPseudoElements:!1,observeMutations:!0,mutateApproach:"async",keepOriginalSource:!0,measurePerformance:!1,showMissingIcons:!0},E);C.autoReplaceSvg||(C.observeMutations=!1);var k=l({},C);m.FontAwesomeConfig=k;var S=m||{};S.___FONT_AWESOME___||(S.___FONT_AWESOME___={}),S.___FONT_AWESOME___.styles||(S.___FONT_AWESOME___.styles={}),S.___FONT_AWESOME___.hooks||(S.___FONT_AWESOME___.hooks={}),S.___FONT_AWESOME___.shims||(S.___FONT_AWESOME___.shims=[]);var O=S.___FONT_AWESOME___,T=[];b&&((g.documentElement.doScroll?/^loaded|^c/:/^loaded|^i|^c/).test(g.readyState)||g.addEventListener("DOMContentLoaded",(function e(){g.removeEventListener("DOMContentLoaded",e),1,T.map((function(e){return e()}))})));var _,P=function(){},A=void 0!==e&&void 0!==e.process&&"function"==typeof e.process.emit,M=void 0===r?setTimeout:r,z=[];function F(){for(var e=0;e<z.length;e++)z[e][0](z[e][1]);z=[],_=!1}function D(e,t){z.push([e,t]),_||(_=!0,M(F,0))}function j(e){var t=e.owner,n=t._state,r=t._data,o=e[n],i=e.then;if("function"==typeof o){n="fulfilled";try{r=o(r)}catch(e){N(i,e)}}L(i,r)||("fulfilled"===n&&R(i,r),"rejected"===n&&N(i,r))}function L(e,t){var n;try{if(e===t)throw new TypeError("A promises callback cannot return that same promise.");if(t&&("function"==typeof t||"object"===o(t))){var r=t.then;if("function"==typeof r)return r.call(t,(function(r){n||(n=!0,t===r?I(e,r):R(e,r))}),(function(t){n||(n=!0,N(e,t))})),!0}}catch(t){return n||N(e,t),!0}return!1}function R(e,t){e!==t&&L(e,t)||I(e,t)}function I(e,t){"pending"===e._state&&(e._state="settled",e._data=t,D(B,e))}function N(e,t){"pending"===e._state&&(e._state="settled",e._data=t,D(H,e))}function V(e){e._then=e._then.forEach(j)}function B(e){e._state="fulfilled",V(e)}function H(t){t._state="rejected",V(t),!t._handled&&A&&e.process.emit("unhandledRejection",t._data,t)}function W(t){e.process.emit("rejectionHandled",t)}function U(e){if("function"!=typeof e)throw new TypeError("Promise resolver "+e+" is not a function");if(this instanceof U==!1)throw new TypeError("Failed to construct 'Promise': Please use the 'new' operator, this object constructor cannot be called as a function.");this._then=[],function(e,t){function n(e){N(t,e)}try{e((function(e){R(t,e)}),n)}catch(e){n(e)}}(e,this)}U.prototype={constructor:U,_state:"pending",_then:null,_data:void 0,_handled:!1,then:function(e,t){var n={owner:this,then:new this.constructor(P),fulfilled:e,rejected:t};return!t&&!e||this._handled||(this._handled=!0,"rejected"===this._state&&A&&D(W,this)),"fulfilled"===this._state||"rejected"===this._state?D(j,n):this._then.push(n),n.then},catch:function(e){return this.then(null,e)}},U.all=function(e){if(!Array.isArray(e))throw new TypeError("You must pass an array to Promise.all().");return new U((function(t,n){var r=[],o=0;function i(e){return o++,function(n){r[e]=n,--o||t(r)}}for(var a,l=0;l<e.length;l++)(a=e[l])&&"function"==typeof a.then?a.then(i(l),n):r[l]=a;o||t(r)}))},U.race=function(e){if(!Array.isArray(e))throw new TypeError("You must pass an array to Promise.race().");return new U((function(t,n){for(var r,o=0;o<e.length;o++)(r=e[o])&&"function"==typeof r.then?r.then(t,n):t(r)}))},U.resolve=function(e){return e&&"object"===o(e)&&e.constructor===U?e:new U((function(t){t(e)}))},U.reject=function(e){return new U((function(t,n){n(e)}))};var $={size:16,x:0,y:0,rotate:0,flipX:!1,flipY:!1};function Y(e){if(e&&b){var t=g.createElement("style");t.setAttribute("type","text/css"),t.innerHTML=e;for(var n=g.head.childNodes,r=null,o=n.length-1;o>-1;o--){var i=n[o],a=(i.tagName||"").toUpperCase();["STYLE","LINK"].indexOf(a)>-1&&(r=i)}return g.head.insertBefore(t,r),e}}function Q(){for(var e=12,t="";e-- >0;)t+="0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"[62*Math.random()|0];return t}function G(e){return"".concat(e).replace(/&/g,"&").replace(/"/g,""").replace(/'/g,"'").replace(/</g,"<").replace(/>/g,">")}function X(e){return Object.keys(e||{}).reduce((function(t,n){return t+"".concat(n,": ").concat(e[n],";")}),"")}function q(e){return e.size!==$.size||e.x!==$.x||e.y!==$.y||e.rotate!==$.rotate||e.flipX||e.flipY}function K(e){var t=e.transform,n=e.containerWidth,r=e.iconWidth,o={transform:"translate(".concat(n/2," 256)")},i="translate(".concat(32*t.x,", ").concat(32*t.y,") "),a="scale(".concat(t.size/16*(t.flipX?-1:1),", ").concat(t.size/16*(t.flipY?-1:1),") "),l="rotate(".concat(t.rotate," 0 0)");return{outer:o,inner:{transform:"".concat(i," ").concat(a," ").concat(l)},path:{transform:"translate(".concat(r/2*-1," -256)")}}}var Z={x:0,y:0,width:"100%",height:"100%"};function J(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];return e.attributes&&(e.attributes.fill||t)&&(e.attributes.fill="black"),e}function ee(e){var t=e.icons,n=t.main,r=t.mask,o=e.prefix,i=e.iconName,a=e.transform,u=e.symbol,c=e.title,s=e.maskId,f=e.titleId,p=e.extra,d=e.watchable,h=void 0!==d&&d,m=r.found?r:n,g=m.width,v=m.height,b="fa-w-".concat(Math.ceil(g/v*16)),y=[k.replacementClass,i?"".concat(k.familyPrefix,"-").concat(i):"",b].filter((function(e){return-1===p.classes.indexOf(e)})).concat(p.classes).join(" "),w={children:[],attributes:l({},p.attributes,{"data-prefix":o,"data-icon":i,class:y,role:p.attributes.role||"img",xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 ".concat(g," ").concat(v)})};h&&(w.attributes["data-fa-i2svg"]=""),c&&w.children.push({tag:"title",attributes:{id:w.attributes["aria-labelledby"]||"title-".concat(f||Q())},children:[c]});var x=l({},w,{prefix:o,iconName:i,main:n,mask:r,maskId:s,transform:a,symbol:u,styles:p.styles}),E=r.found&&n.found?function(e){var t,n=e.children,r=e.attributes,o=e.main,i=e.mask,a=e.maskId,u=e.transform,c=o.width,s=o.icon,f=i.width,p=i.icon,d=K({transform:u,containerWidth:f,iconWidth:c}),h={tag:"rect",attributes:l({},Z,{fill:"white"})},m=s.children?{children:s.children.map(J)}:{},g={tag:"g",attributes:l({},d.inner),children:[J(l({tag:s.tag,attributes:l({},s.attributes,d.path)},m))]},v={tag:"g",attributes:l({},d.outer),children:[g]},b="mask-".concat(a||Q()),y="clip-".concat(a||Q()),w={tag:"mask",attributes:l({},Z,{id:b,maskUnits:"userSpaceOnUse",maskContentUnits:"userSpaceOnUse"}),children:[h,v]},x={tag:"defs",children:[{tag:"clipPath",attributes:{id:y},children:(t=p,"g"===t.tag?t.children:[t])},w]};return n.push(x,{tag:"rect",attributes:l({fill:"currentColor","clip-path":"url(#".concat(y,")"),mask:"url(#".concat(b,")")},Z)}),{children:n,attributes:r}}(x):function(e){var t=e.children,n=e.attributes,r=e.main,o=e.transform,i=X(e.styles);if(i.length>0&&(n.style=i),q(o)){var a=K({transform:o,containerWidth:r.width,iconWidth:r.width});t.push({tag:"g",attributes:l({},a.outer),children:[{tag:"g",attributes:l({},a.inner),children:[{tag:r.icon.tag,children:r.icon.children,attributes:l({},r.icon.attributes,a.path)}]}]})}else t.push(r.icon);return{children:t,attributes:n}}(x),C=E.children,S=E.attributes;return x.children=C,x.attributes=S,u?function(e){var t=e.prefix,n=e.iconName,r=e.children,o=e.attributes,i=e.symbol;return[{tag:"svg",attributes:{style:"display: none;"},children:[{tag:"symbol",attributes:l({},o,{id:!0===i?"".concat(t,"-").concat(k.familyPrefix,"-").concat(n):i}),children:r}]}]}(x):function(e){var t=e.children,n=e.main,r=e.mask,o=e.attributes,i=e.styles,a=e.transform;if(q(a)&&n.found&&!r.found){var u={x:n.width/n.height/2,y:.5};o.style=X(l({},i,{"transform-origin":"".concat(u.x+a.x/16,"em ").concat(u.y+a.y/16,"em")}))}return[{tag:"svg",attributes:o,children:t}]}(x)}var te=function(){},ne=(k.measurePerformance&&v&&v.mark&&v.measure,function(e,t,n,r){var o,i,a,l=Object.keys(e),u=l.length,c=void 0!==r?function(e,t){return function(n,r,o,i){return e.call(t,n,r,o,i)}}(t,r):t;for(void 0===n?(o=1,a=e[l[0]]):(o=0,a=n);o<u;o++)a=c(a,e[i=l[o]],i,e);return a});function re(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},r=n.skipHooks,o=void 0!==r&&r,i=Object.keys(t).reduce((function(e,n){var r=t[n];return!!r.icon?e[r.iconName]=r.icon:e[n]=r,e}),{});"function"!=typeof O.hooks.addPack||o?O.styles[e]=l({},O.styles[e]||{},i):O.hooks.addPack(e,i),"fas"===e&&re("fa",t)}var oe=O.styles,ie=O.shims,ae=function(){var e=function(e){return ne(oe,(function(t,n,r){return t[r]=ne(n,e,{}),t}),{})};e((function(e,t,n){return t[3]&&(e[t[3]]=n),e})),e((function(e,t,n){var r=t[2];return e[n]=n,r.forEach((function(t){e[t]=n})),e}));var t="far"in oe;ne(ie,(function(e,n){var r=n[0],o=n[1],i=n[2];return"far"!==o||t||(o="fas"),e[r]={prefix:o,iconName:i},e}),{})};ae();O.styles;function le(e,t,n){if(e&&e[t]&&e[t][n])return{prefix:t,iconName:n,icon:e[t][n]}}function ue(e){var t=e.tag,n=e.attributes,r=void 0===n?{}:n,o=e.children,i=void 0===o?[]:o;return"string"==typeof e?G(e):"<".concat(t," ").concat(function(e){return Object.keys(e||{}).reduce((function(t,n){return t+"".concat(n,'="').concat(G(e[n]),'" ')}),"").trim()}(r),">").concat(i.map(ue).join(""),"</").concat(t,">")}var ce=function(e){var t={size:16,x:0,y:0,flipX:!1,flipY:!1,rotate:0};return e?e.toLowerCase().split(" ").reduce((function(e,t){var n=t.toLowerCase().split("-"),r=n[0],o=n.slice(1).join("-");if(r&&"h"===o)return e.flipX=!0,e;if(r&&"v"===o)return e.flipY=!0,e;if(o=parseFloat(o),isNaN(o))return e;switch(r){case"grow":e.size=e.size+o;break;case"shrink":e.size=e.size-o;break;case"left":e.x=e.x-o;break;case"right":e.x=e.x+o;break;case"up":e.y=e.y-o;break;case"down":e.y=e.y+o;break;case"rotate":e.rotate=e.rotate+o}return e}),t):t};function se(e){this.name="MissingIcon",this.message=e||"Icon unavailable",this.stack=(new Error).stack}se.prototype=Object.create(Error.prototype),se.prototype.constructor=se;var fe={fill:"currentColor"},pe={attributeType:"XML",repeatCount:"indefinite",dur:"2s"},de={tag:"path",attributes:l({},fe,{d:"M156.5,447.7l-12.6,29.5c-18.7-9.5-35.9-21.2-51.5-34.9l22.7-22.7C127.6,430.5,141.5,440,156.5,447.7z M40.6,272H8.5 c1.4,21.2,5.4,41.7,11.7,61.1L50,321.2C45.1,305.5,41.8,289,40.6,272z M40.6,240c1.4-18.8,5.2-37,11.1-54.1l-29.5-12.6 C14.7,194.3,10,216.7,8.5,240H40.6z M64.3,156.5c7.8-14.9,17.2-28.8,28.1-41.5L69.7,92.3c-13.7,15.6-25.5,32.8-34.9,51.5 L64.3,156.5z M397,419.6c-13.9,12-29.4,22.3-46.1,30.4l11.9,29.8c20.7-9.9,39.8-22.6,56.9-37.6L397,419.6z M115,92.4 c13.9-12,29.4-22.3,46.1-30.4l-11.9-29.8c-20.7,9.9-39.8,22.6-56.8,37.6L115,92.4z M447.7,355.5c-7.8,14.9-17.2,28.8-28.1,41.5 l22.7,22.7c13.7-15.6,25.5-32.9,34.9-51.5L447.7,355.5z M471.4,272c-1.4,18.8-5.2,37-11.1,54.1l29.5,12.6 c7.5-21.1,12.2-43.5,13.6-66.8H471.4z M321.2,462c-15.7,5-32.2,8.2-49.2,9.4v32.1c21.2-1.4,41.7-5.4,61.1-11.7L321.2,462z M240,471.4c-18.8-1.4-37-5.2-54.1-11.1l-12.6,29.5c21.1,7.5,43.5,12.2,66.8,13.6V471.4z M462,190.8c5,15.7,8.2,32.2,9.4,49.2h32.1 c-1.4-21.2-5.4-41.7-11.7-61.1L462,190.8z M92.4,397c-12-13.9-22.3-29.4-30.4-46.1l-29.8,11.9c9.9,20.7,22.6,39.8,37.6,56.9 L92.4,397z M272,40.6c18.8,1.4,36.9,5.2,54.1,11.1l12.6-29.5C317.7,14.7,295.3,10,272,8.5V40.6z M190.8,50 c15.7-5,32.2-8.2,49.2-9.4V8.5c-21.2,1.4-41.7,5.4-61.1,11.7L190.8,50z M442.3,92.3L419.6,115c12,13.9,22.3,29.4,30.5,46.1 l29.8-11.9C470,128.5,457.3,109.4,442.3,92.3z M397,92.4l22.7-22.7c-15.6-13.7-32.8-25.5-51.5-34.9l-12.6,29.5 C370.4,72.1,384.4,81.5,397,92.4z"})},he=l({},pe,{attributeName:"opacity"});l({},fe,{cx:"256",cy:"364",r:"28"}),l({},pe,{attributeName:"r",values:"28;14;28;28;14;28;"}),l({},he,{values:"1;0;1;1;0;1;"}),l({},fe,{opacity:"1",d:"M263.7,312h-16c-6.6,0-12-5.4-12-12c0-71,77.4-63.9,77.4-107.8c0-20-17.8-40.2-57.4-40.2c-29.1,0-44.3,9.6-59.2,28.7 c-3.9,5-11.1,6-16.2,2.4l-13.1-9.2c-5.6-3.9-6.9-11.8-2.6-17.2c21.2-27.2,46.4-44.7,91.2-44.7c52.3,0,97.4,29.8,97.4,80.2 c0,67.6-77.4,63.5-77.4,107.8C275.7,306.6,270.3,312,263.7,312z"}),l({},he,{values:"1;0;0;0;0;1;"}),l({},fe,{opacity:"0",d:"M232.5,134.5l7,168c0.3,6.4,5.6,11.5,12,11.5h9c6.4,0,11.7-5.1,12-11.5l7-168c0.3-6.8-5.2-12.5-12-12.5h-23 C237.7,122,232.2,127.7,232.5,134.5z"}),l({},he,{values:"0;0;1;1;0;0;"}),O.styles;function me(e){var t=e[0],n=e[1],r=u(e.slice(4),1)[0];return{found:!0,width:t,height:n,icon:Array.isArray(r)?{tag:"g",attributes:{class:"".concat(k.familyPrefix,"-").concat(x.GROUP)},children:[{tag:"path",attributes:{class:"".concat(k.familyPrefix,"-").concat(x.SECONDARY),fill:"currentColor",d:r[0]}},{tag:"path",attributes:{class:"".concat(k.familyPrefix,"-").concat(x.PRIMARY),fill:"currentColor",d:r[1]}}]}:{tag:"path",attributes:{fill:"currentColor",d:r}}}}O.styles;function ge(){var e="svg-inline--fa",t=k.familyPrefix,n=k.replacementClass,r='svg:not(:root).svg-inline--fa {\n overflow: visible;\n}\n\n.svg-inline--fa {\n display: inline-block;\n font-size: inherit;\n height: 1em;\n overflow: visible;\n vertical-align: -0.125em;\n}\n.svg-inline--fa.fa-lg {\n vertical-align: -0.225em;\n}\n.svg-inline--fa.fa-w-1 {\n width: 0.0625em;\n}\n.svg-inline--fa.fa-w-2 {\n width: 0.125em;\n}\n.svg-inline--fa.fa-w-3 {\n width: 0.1875em;\n}\n.svg-inline--fa.fa-w-4 {\n width: 0.25em;\n}\n.svg-inline--fa.fa-w-5 {\n width: 0.3125em;\n}\n.svg-inline--fa.fa-w-6 {\n width: 0.375em;\n}\n.svg-inline--fa.fa-w-7 {\n width: 0.4375em;\n}\n.svg-inline--fa.fa-w-8 {\n width: 0.5em;\n}\n.svg-inline--fa.fa-w-9 {\n width: 0.5625em;\n}\n.svg-inline--fa.fa-w-10 {\n width: 0.625em;\n}\n.svg-inline--fa.fa-w-11 {\n width: 0.6875em;\n}\n.svg-inline--fa.fa-w-12 {\n width: 0.75em;\n}\n.svg-inline--fa.fa-w-13 {\n width: 0.8125em;\n}\n.svg-inline--fa.fa-w-14 {\n width: 0.875em;\n}\n.svg-inline--fa.fa-w-15 {\n width: 0.9375em;\n}\n.svg-inline--fa.fa-w-16 {\n width: 1em;\n}\n.svg-inline--fa.fa-w-17 {\n width: 1.0625em;\n}\n.svg-inline--fa.fa-w-18 {\n width: 1.125em;\n}\n.svg-inline--fa.fa-w-19 {\n width: 1.1875em;\n}\n.svg-inline--fa.fa-w-20 {\n width: 1.25em;\n}\n.svg-inline--fa.fa-pull-left {\n margin-right: 0.3em;\n width: auto;\n}\n.svg-inline--fa.fa-pull-right {\n margin-left: 0.3em;\n width: auto;\n}\n.svg-inline--fa.fa-border {\n height: 1.5em;\n}\n.svg-inline--fa.fa-li {\n width: 2em;\n}\n.svg-inline--fa.fa-fw {\n width: 1.25em;\n}\n\n.fa-layers svg.svg-inline--fa {\n bottom: 0;\n left: 0;\n margin: auto;\n position: absolute;\n right: 0;\n top: 0;\n}\n\n.fa-layers {\n display: inline-block;\n height: 1em;\n position: relative;\n text-align: center;\n vertical-align: -0.125em;\n width: 1em;\n}\n.fa-layers svg.svg-inline--fa {\n -webkit-transform-origin: center center;\n transform-origin: center center;\n}\n\n.fa-layers-counter, .fa-layers-text {\n display: inline-block;\n position: absolute;\n text-align: center;\n}\n\n.fa-layers-text {\n left: 50%;\n top: 50%;\n -webkit-transform: translate(-50%, -50%);\n transform: translate(-50%, -50%);\n -webkit-transform-origin: center center;\n transform-origin: center center;\n}\n\n.fa-layers-counter {\n background-color: #ff253a;\n border-radius: 1em;\n -webkit-box-sizing: border-box;\n box-sizing: border-box;\n color: #fff;\n height: 1.5em;\n line-height: 1;\n max-width: 5em;\n min-width: 1.5em;\n overflow: hidden;\n padding: 0.25em;\n right: 0;\n text-overflow: ellipsis;\n top: 0;\n -webkit-transform: scale(0.25);\n transform: scale(0.25);\n -webkit-transform-origin: top right;\n transform-origin: top right;\n}\n\n.fa-layers-bottom-right {\n bottom: 0;\n right: 0;\n top: auto;\n -webkit-transform: scale(0.25);\n transform: scale(0.25);\n -webkit-transform-origin: bottom right;\n transform-origin: bottom right;\n}\n\n.fa-layers-bottom-left {\n bottom: 0;\n left: 0;\n right: auto;\n top: auto;\n -webkit-transform: scale(0.25);\n transform: scale(0.25);\n -webkit-transform-origin: bottom left;\n transform-origin: bottom left;\n}\n\n.fa-layers-top-right {\n right: 0;\n top: 0;\n -webkit-transform: scale(0.25);\n transform: scale(0.25);\n -webkit-transform-origin: top right;\n transform-origin: top right;\n}\n\n.fa-layers-top-left {\n left: 0;\n right: auto;\n top: 0;\n -webkit-transform: scale(0.25);\n transform: scale(0.25);\n -webkit-transform-origin: top left;\n transform-origin: top left;\n}\n\n.fa-lg {\n font-size: 1.3333333333em;\n line-height: 0.75em;\n vertical-align: -0.0667em;\n}\n\n.fa-xs {\n font-size: 0.75em;\n}\n\n.fa-sm {\n font-size: 0.875em;\n}\n\n.fa-1x {\n font-size: 1em;\n}\n\n.fa-2x {\n font-size: 2em;\n}\n\n.fa-3x {\n font-size: 3em;\n}\n\n.fa-4x {\n font-size: 4em;\n}\n\n.fa-5x {\n font-size: 5em;\n}\n\n.fa-6x {\n font-size: 6em;\n}\n\n.fa-7x {\n font-size: 7em;\n}\n\n.fa-8x {\n font-size: 8em;\n}\n\n.fa-9x {\n font-size: 9em;\n}\n\n.fa-10x {\n font-size: 10em;\n}\n\n.fa-fw {\n text-align: center;\n width: 1.25em;\n}\n\n.fa-ul {\n list-style-type: none;\n margin-left: 2.5em;\n padding-left: 0;\n}\n.fa-ul > li {\n position: relative;\n}\n\n.fa-li {\n left: -2em;\n position: absolute;\n text-align: center;\n width: 2em;\n line-height: inherit;\n}\n\n.fa-border {\n border: solid 0.08em #eee;\n border-radius: 0.1em;\n padding: 0.2em 0.25em 0.15em;\n}\n\n.fa-pull-left {\n float: left;\n}\n\n.fa-pull-right {\n float: right;\n}\n\n.fa.fa-pull-left,\n.fas.fa-pull-left,\n.far.fa-pull-left,\n.fal.fa-pull-left,\n.fab.fa-pull-left {\n margin-right: 0.3em;\n}\n.fa.fa-pull-right,\n.fas.fa-pull-right,\n.far.fa-pull-right,\n.fal.fa-pull-right,\n.fab.fa-pull-right {\n margin-left: 0.3em;\n}\n\n.fa-spin {\n -webkit-animation: fa-spin 2s infinite linear;\n animation: fa-spin 2s infinite linear;\n}\n\n.fa-pulse {\n -webkit-animation: fa-spin 1s infinite steps(8);\n animation: fa-spin 1s infinite steps(8);\n}\n\n@-webkit-keyframes fa-spin {\n 0% {\n -webkit-transform: rotate(0deg);\n transform: rotate(0deg);\n }\n 100% {\n -webkit-transform: rotate(360deg);\n transform: rotate(360deg);\n }\n}\n\n@keyframes fa-spin {\n 0% {\n -webkit-transform: rotate(0deg);\n transform: rotate(0deg);\n }\n 100% {\n -webkit-transform: rotate(360deg);\n transform: rotate(360deg);\n }\n}\n.fa-rotate-90 {\n -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";\n -webkit-transform: rotate(90deg);\n transform: rotate(90deg);\n}\n\n.fa-rotate-180 {\n -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";\n -webkit-transform: rotate(180deg);\n transform: rotate(180deg);\n}\n\n.fa-rotate-270 {\n -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";\n -webkit-transform: rotate(270deg);\n transform: rotate(270deg);\n}\n\n.fa-flip-horizontal {\n -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";\n -webkit-transform: scale(-1, 1);\n transform: scale(-1, 1);\n}\n\n.fa-flip-vertical {\n -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";\n -webkit-transform: scale(1, -1);\n transform: scale(1, -1);\n}\n\n.fa-flip-both, .fa-flip-horizontal.fa-flip-vertical {\n -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";\n -webkit-transform: scale(-1, -1);\n transform: scale(-1, -1);\n}\n\n:root .fa-rotate-90,\n:root .fa-rotate-180,\n:root .fa-rotate-270,\n:root .fa-flip-horizontal,\n:root .fa-flip-vertical,\n:root .fa-flip-both {\n -webkit-filter: none;\n filter: none;\n}\n\n.fa-stack {\n display: inline-block;\n height: 2em;\n position: relative;\n width: 2.5em;\n}\n\n.fa-stack-1x,\n.fa-stack-2x {\n bottom: 0;\n left: 0;\n margin: auto;\n position: absolute;\n right: 0;\n top: 0;\n}\n\n.svg-inline--fa.fa-stack-1x {\n height: 1em;\n width: 1.25em;\n}\n.svg-inline--fa.fa-stack-2x {\n height: 2em;\n width: 2.5em;\n}\n\n.fa-inverse {\n color: #fff;\n}\n\n.sr-only {\n border: 0;\n clip: rect(0, 0, 0, 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n}\n\n.sr-only-focusable:active, .sr-only-focusable:focus {\n clip: auto;\n height: auto;\n margin: 0;\n overflow: visible;\n position: static;\n width: auto;\n}\n\n.svg-inline--fa .fa-primary {\n fill: var(--fa-primary-color, currentColor);\n opacity: 1;\n opacity: var(--fa-primary-opacity, 1);\n}\n\n.svg-inline--fa .fa-secondary {\n fill: var(--fa-secondary-color, currentColor);\n opacity: 0.4;\n opacity: var(--fa-secondary-opacity, 0.4);\n}\n\n.svg-inline--fa.fa-swap-opacity .fa-primary {\n opacity: 0.4;\n opacity: var(--fa-secondary-opacity, 0.4);\n}\n\n.svg-inline--fa.fa-swap-opacity .fa-secondary {\n opacity: 1;\n opacity: var(--fa-primary-opacity, 1);\n}\n\n.svg-inline--fa mask .fa-primary,\n.svg-inline--fa mask .fa-secondary {\n fill: black;\n}\n\n.fad.fa-inverse {\n color: #fff;\n}';if("fa"!==t||n!==e){var o=new RegExp("\\.".concat("fa","\\-"),"g"),i=new RegExp("\\--".concat("fa","\\-"),"g"),a=new RegExp("\\.".concat(e),"g");r=r.replace(o,".".concat(t,"-")).replace(i,"--".concat(t,"-")).replace(a,".".concat(n))}return r}function ve(){k.autoAddCss&&!Ee&&(Y(ge()),Ee=!0)}function be(e,t){return Object.defineProperty(e,"abstract",{get:t}),Object.defineProperty(e,"html",{get:function(){return e.abstract.map((function(e){return ue(e)}))}}),Object.defineProperty(e,"node",{get:function(){if(b){var t=g.createElement("div");return t.innerHTML=e.html,t.children}}}),e}function ye(e){var t=e.prefix,n=void 0===t?"fa":t,r=e.iconName;if(r)return le(xe.definitions,n,r)||le(O.styles,n,r)}var we,xe=new(function(){function e(){!function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,e),this.definitions={}}var t,n,r;return t=e,(n=[{key:"add",value:function(){for(var e=this,t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];var o=n.reduce(this._pullDefinitions,{});Object.keys(o).forEach((function(t){e.definitions[t]=l({},e.definitions[t]||{},o[t]),re(t,o[t]),ae()}))}},{key:"reset",value:function(){this.definitions={}}},{key:"_pullDefinitions",value:function(e,t){var n=t.prefix&&t.iconName&&t.icon?{0:t}:t;return Object.keys(n).map((function(t){var r=n[t],o=r.prefix,i=r.iconName,a=r.icon;e[o]||(e[o]={}),e[o][i]=a})),e}}])&&i(t.prototype,n),r&&i(t,r),e}()),Ee=!1,Ce={transform:function(e){return ce(e)}},ke=(we=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=t.transform,r=void 0===n?$:n,o=t.symbol,i=void 0!==o&&o,a=t.mask,u=void 0===a?null:a,c=t.maskId,s=void 0===c?null:c,f=t.title,p=void 0===f?null:f,d=t.titleId,h=void 0===d?null:d,m=t.classes,g=void 0===m?[]:m,v=t.attributes,b=void 0===v?{}:v,y=t.styles,w=void 0===y?{}:y;if(e){var x=e.prefix,E=e.iconName,C=e.icon;return be(l({type:"icon"},e),(function(){return ve(),k.autoA11y&&(p?b["aria-labelledby"]="".concat(k.replacementClass,"-title-").concat(h||Q()):(b["aria-hidden"]="true",b.focusable="false")),ee({icons:{main:me(C),mask:u?me(u.icon):{found:!1,width:null,height:null,icon:{}}},prefix:x,iconName:E,transform:l({},$,r),symbol:i,title:p,maskId:s,titleId:h,extra:{attributes:b,styles:w,classes:g}})}))}},function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=(e||{}).icon?e:ye(e||{}),r=t.mask;return r&&(r=(r||{}).icon?r:ye(r||{})),we(n,l({},t,{mask:r}))})}).call(this,n(19),n(36).setImmediate)},function(e,t){e.exports=function(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}},function(e,t,n){"use strict";var r=n(12),o=/^((children|dangerouslySetInnerHTML|key|ref|autoFocus|defaultValue|defaultChecked|innerHTML|suppressContentEditableWarning|suppressHydrationWarning|valueLink|accept|acceptCharset|accessKey|action|allow|allowUserMedia|allowPaymentRequest|allowFullScreen|allowTransparency|alt|async|autoComplete|autoPlay|capture|cellPadding|cellSpacing|challenge|charSet|checked|cite|classID|className|cols|colSpan|content|contentEditable|contextMenu|controls|controlsList|coords|crossOrigin|data|dateTime|decoding|default|defer|dir|disabled|disablePictureInPicture|download|draggable|encType|form|formAction|formEncType|formMethod|formNoValidate|formTarget|frameBorder|headers|height|hidden|high|href|hrefLang|htmlFor|httpEquiv|id|inputMode|integrity|is|keyParams|keyType|kind|label|lang|list|loading|loop|low|marginHeight|marginWidth|max|maxLength|media|mediaGroup|method|min|minLength|multiple|muted|name|nonce|noValidate|open|optimum|pattern|placeholder|playsInline|poster|preload|profile|radioGroup|readOnly|referrerPolicy|rel|required|reversed|role|rows|rowSpan|sandbox|scope|scoped|scrolling|seamless|selected|shape|size|sizes|slot|span|spellCheck|src|srcDoc|srcLang|srcSet|start|step|style|summary|tabIndex|target|title|type|useMap|value|width|wmode|wrap|about|datatype|inlist|prefix|property|resource|typeof|vocab|autoCapitalize|autoCorrect|autoSave|color|inert|itemProp|itemScope|itemType|itemID|itemRef|on|results|security|unselectable|accentHeight|accumulate|additive|alignmentBaseline|allowReorder|alphabetic|amplitude|arabicForm|ascent|attributeName|attributeType|autoReverse|azimuth|baseFrequency|baselineShift|baseProfile|bbox|begin|bias|by|calcMode|capHeight|clip|clipPathUnits|clipPath|clipRule|colorInterpolation|colorInterpolationFilters|colorProfile|colorRendering|contentScriptType|contentStyleType|cursor|cx|cy|d|decelerate|descent|diffuseConstant|direction|display|divisor|dominantBaseline|dur|dx|dy|edgeMode|elevation|enableBackground|end|exponent|externalResourcesRequired|fill|fillOpacity|fillRule|filter|filterRes|filterUnits|floodColor|floodOpacity|focusable|fontFamily|fontSize|fontSizeAdjust|fontStretch|fontStyle|fontVariant|fontWeight|format|from|fr|fx|fy|g1|g2|glyphName|glyphOrientationHorizontal|glyphOrientationVertical|glyphRef|gradientTransform|gradientUnits|hanging|horizAdvX|horizOriginX|ideographic|imageRendering|in|in2|intercept|k|k1|k2|k3|k4|kernelMatrix|kernelUnitLength|kerning|keyPoints|keySplines|keyTimes|lengthAdjust|letterSpacing|lightingColor|limitingConeAngle|local|markerEnd|markerMid|markerStart|markerHeight|markerUnits|markerWidth|mask|maskContentUnits|maskUnits|mathematical|mode|numOctaves|offset|opacity|operator|order|orient|orientation|origin|overflow|overlinePosition|overlineThickness|panose1|paintOrder|pathLength|patternContentUnits|patternTransform|patternUnits|pointerEvents|points|pointsAtX|pointsAtY|pointsAtZ|preserveAlpha|preserveAspectRatio|primitiveUnits|r|radius|refX|refY|renderingIntent|repeatCount|repeatDur|requiredExtensions|requiredFeatures|restart|result|rotate|rx|ry|scale|seed|shapeRendering|slope|spacing|specularConstant|specularExponent|speed|spreadMethod|startOffset|stdDeviation|stemh|stemv|stitchTiles|stopColor|stopOpacity|strikethroughPosition|strikethroughThickness|string|stroke|strokeDasharray|strokeDashoffset|strokeLinecap|strokeLinejoin|strokeMiterlimit|strokeOpacity|strokeWidth|surfaceScale|systemLanguage|tableValues|targetX|targetY|textAnchor|textDecoration|textRendering|textLength|to|transform|u1|u2|underlinePosition|underlineThickness|unicode|unicodeBidi|unicodeRange|unitsPerEm|vAlphabetic|vHanging|vIdeographic|vMathematical|values|vectorEffect|version|vertAdvY|vertOriginX|vertOriginY|viewBox|viewTarget|visibility|widths|wordSpacing|writingMode|x|xHeight|x1|x2|xChannelSelector|xlinkActuate|xlinkArcrole|xlinkHref|xlinkRole|xlinkShow|xlinkTitle|xlinkType|xmlBase|xmlns|xmlnsXlink|xmlLang|xmlSpace|y|y1|y2|yChannelSelector|z|zoomAndPan|for|class|autofocus)|(([Dd][Aa][Tt][Aa]|[Aa][Rr][Ii][Aa]|x)-.*))$/,i=Object(r.a)((function(e){return o.test(e)||111===e.charCodeAt(0)&&110===e.charCodeAt(1)&&e.charCodeAt(2)<91}));t.a=i},function(e,t,n){"use strict";n.d(t,"a",(function(){return r}));var r=function(){function e(e){this.isSpeedy=void 0===e.speedy||e.speedy,this.tags=[],this.ctr=0,this.nonce=e.nonce,this.key=e.key,this.container=e.container,this.before=null}var t=e.prototype;return t.insert=function(e){if(this.ctr%(this.isSpeedy?65e3:1)==0){var t,n=function(e){var t=document.createElement("style");return t.setAttribute("data-emotion",e.key),void 0!==e.nonce&&t.setAttribute("nonce",e.nonce),t.appendChild(document.createTextNode("")),t}(this);t=0===this.tags.length?this.before:this.tags[this.tags.length-1].nextSibling,this.container.insertBefore(n,t),this.tags.push(n)}var r=this.tags[this.tags.length-1];if(this.isSpeedy){var o=function(e){if(e.sheet)return e.sheet;for(var t=0;t<document.styleSheets.length;t++)if(document.styleSheets[t].ownerNode===e)return document.styleSheets[t]}(r);try{var i=105===e.charCodeAt(1)&&64===e.charCodeAt(0);o.insertRule(e,i?0:o.cssRules.length)}catch(e){0}}else r.appendChild(document.createTextNode(e));this.ctr++},t.flush=function(){this.tags.forEach((function(e){return e.parentNode.removeChild(e)})),this.tags=[],this.ctr=0},e}()},function(e,t,n){"use strict";t.a=function(e){var t=new WeakMap;return function(n){if(t.has(n))return t.get(n);var r=e(n);return t.set(n,r),r}}},function(e,t,n){"use strict";var r=n(16);var o=function(e){function t(e,t,r){var o=t.trim().split(h);t=o;var i=o.length,a=e.length;switch(a){case 0:case 1:var l=0;for(e=0===a?"":e[0]+" ";l<i;++l)t[l]=n(e,t[l],r).trim();break;default:var u=l=0;for(t=[];l<i;++l)for(var c=0;c<a;++c)t[u++]=n(e[c]+" ",o[l],r).trim()}return t}function n(e,t,n){var r=t.charCodeAt(0);switch(33>r&&(r=(t=t.trim()).charCodeAt(0)),r){case 38:return t.replace(m,"$1"+e.trim());case 58:return e.trim()+t.replace(m,"$1"+e.trim());default:if(0<1*n&&0<t.indexOf("\f"))return t.replace(m,(58===e.charCodeAt(0)?"":"$1")+e.trim())}return e+t}function r(e,t,n,i){var a=e+";",l=2*t+3*n+4*i;if(944===l){e=a.indexOf(":",9)+1;var u=a.substring(e,a.length-1).trim();return u=a.substring(0,e).trim()+u+";",1===P||2===P&&o(u,1)?"-webkit-"+u+u:u}if(0===P||2===P&&!o(a,1))return a;switch(l){case 1015:return 97===a.charCodeAt(10)?"-webkit-"+a+a:a;case 951:return 116===a.charCodeAt(3)?"-webkit-"+a+a:a;case 963:return 110===a.charCodeAt(5)?"-webkit-"+a+a:a;case 1009:if(100!==a.charCodeAt(4))break;case 969:case 942:return"-webkit-"+a+a;case 978:return"-webkit-"+a+"-moz-"+a+a;case 1019:case 983:return"-webkit-"+a+"-moz-"+a+"-ms-"+a+a;case 883:if(45===a.charCodeAt(8))return"-webkit-"+a+a;if(0<a.indexOf("image-set(",11))return a.replace(S,"$1-webkit-$2")+a;break;case 932:if(45===a.charCodeAt(4))switch(a.charCodeAt(5)){case 103:return"-webkit-box-"+a.replace("-grow","")+"-webkit-"+a+"-ms-"+a.replace("grow","positive")+a;case 115:return"-webkit-"+a+"-ms-"+a.replace("shrink","negative")+a;case 98:return"-webkit-"+a+"-ms-"+a.replace("basis","preferred-size")+a}return"-webkit-"+a+"-ms-"+a+a;case 964:return"-webkit-"+a+"-ms-flex-"+a+a;case 1023:if(99!==a.charCodeAt(8))break;return"-webkit-box-pack"+(u=a.substring(a.indexOf(":",15)).replace("flex-","").replace("space-between","justify"))+"-webkit-"+a+"-ms-flex-pack"+u+a;case 1005:return p.test(a)?a.replace(f,":-webkit-")+a.replace(f,":-moz-")+a:a;case 1e3:switch(t=(u=a.substring(13).trim()).indexOf("-")+1,u.charCodeAt(0)+u.charCodeAt(t)){case 226:u=a.replace(y,"tb");break;case 232:u=a.replace(y,"tb-rl");break;case 220:u=a.replace(y,"lr");break;default:return a}return"-webkit-"+a+"-ms-"+u+a;case 1017:if(-1===a.indexOf("sticky",9))break;case 975:switch(t=(a=e).length-10,l=(u=(33===a.charCodeAt(t)?a.substring(0,t):a).substring(e.indexOf(":",7)+1).trim()).charCodeAt(0)+(0|u.charCodeAt(7))){case 203:if(111>u.charCodeAt(8))break;case 115:a=a.replace(u,"-webkit-"+u)+";"+a;break;case 207:case 102:a=a.replace(u,"-webkit-"+(102<l?"inline-":"")+"box")+";"+a.replace(u,"-webkit-"+u)+";"+a.replace(u,"-ms-"+u+"box")+";"+a}return a+";";case 938:if(45===a.charCodeAt(5))switch(a.charCodeAt(6)){case 105:return u=a.replace("-items",""),"-webkit-"+a+"-webkit-box-"+u+"-ms-flex-"+u+a;case 115:return"-webkit-"+a+"-ms-flex-item-"+a.replace(E,"")+a;default:return"-webkit-"+a+"-ms-flex-line-pack"+a.replace("align-content","").replace(E,"")+a}break;case 973:case 989:if(45!==a.charCodeAt(3)||122===a.charCodeAt(4))break;case 931:case 953:if(!0===k.test(e))return 115===(u=e.substring(e.indexOf(":")+1)).charCodeAt(0)?r(e.replace("stretch","fill-available"),t,n,i).replace(":fill-available",":stretch"):a.replace(u,"-webkit-"+u)+a.replace(u,"-moz-"+u.replace("fill-",""))+a;break;case 962:if(a="-webkit-"+a+(102===a.charCodeAt(5)?"-ms-"+a:"")+a,211===n+i&&105===a.charCodeAt(13)&&0<a.indexOf("transform",10))return a.substring(0,a.indexOf(";",27)+1).replace(d,"$1-webkit-$2")+a}return a}function o(e,t){var n=e.indexOf(1===t?":":"{"),r=e.substring(0,3!==t?n:10);return n=e.substring(n+1,e.length-1),F(2!==t?r:r.replace(C,"$1"),n,t)}function i(e,t){var n=r(t,t.charCodeAt(0),t.charCodeAt(1),t.charCodeAt(2));return n!==t+";"?n.replace(x," or ($1)").substring(4):"("+t+")"}function a(e,t,n,r,o,i,a,l,c,s){for(var f,p=0,d=t;p<z;++p)switch(f=M[p].call(u,e,d,n,r,o,i,a,l,c,s)){case void 0:case!1:case!0:case null:break;default:d=f}if(d!==t)return d}function l(e){return void 0!==(e=e.prefix)&&(F=null,e?"function"!=typeof e?P=1:(P=2,F=e):P=0),l}function u(e,n){var l=e;if(33>l.charCodeAt(0)&&(l=l.trim()),l=[l],0<z){var u=a(-1,n,l,l,T,O,0,0,0,0);void 0!==u&&"string"==typeof u&&(n=u)}var f=function e(n,l,u,f,p){for(var d,h,m,y,x,E=0,C=0,k=0,S=0,M=0,F=0,j=m=d=0,L=0,R=0,I=0,N=0,V=u.length,B=V-1,H="",W="",U="",$="";L<V;){if(h=u.charCodeAt(L),L===B&&0!==C+S+k+E&&(0!==C&&(h=47===C?10:47),S=k=E=0,V++,B++),0===C+S+k+E){if(L===B&&(0<R&&(H=H.replace(s,"")),0<H.trim().length)){switch(h){case 32:case 9:case 59:case 13:case 10:break;default:H+=u.charAt(L)}h=59}switch(h){case 123:for(d=(H=H.trim()).charCodeAt(0),m=1,N=++L;L<V;){switch(h=u.charCodeAt(L)){case 123:m++;break;case 125:m--;break;case 47:switch(h=u.charCodeAt(L+1)){case 42:case 47:e:{for(j=L+1;j<B;++j)switch(u.charCodeAt(j)){case 47:if(42===h&&42===u.charCodeAt(j-1)&&L+2!==j){L=j+1;break e}break;case 10:if(47===h){L=j+1;break e}}L=j}}break;case 91:h++;case 40:h++;case 34:case 39:for(;L++<B&&u.charCodeAt(L)!==h;);}if(0===m)break;L++}switch(m=u.substring(N,L),0===d&&(d=(H=H.replace(c,"").trim()).charCodeAt(0)),d){case 64:switch(0<R&&(H=H.replace(s,"")),h=H.charCodeAt(1)){case 100:case 109:case 115:case 45:R=l;break;default:R=A}if(N=(m=e(l,R,m,h,p+1)).length,0<z&&(x=a(3,m,R=t(A,H,I),l,T,O,N,h,p,f),H=R.join(""),void 0!==x&&0===(N=(m=x.trim()).length)&&(h=0,m="")),0<N)switch(h){case 115:H=H.replace(w,i);case 100:case 109:case 45:m=H+"{"+m+"}";break;case 107:m=(H=H.replace(g,"$1 $2"))+"{"+m+"}",m=1===P||2===P&&o("@"+m,3)?"@-webkit-"+m+"@"+m:"@"+m;break;default:m=H+m,112===f&&(W+=m,m="")}else m="";break;default:m=e(l,t(l,H,I),m,f,p+1)}U+=m,m=I=R=j=d=0,H="",h=u.charCodeAt(++L);break;case 125:case 59:if(1<(N=(H=(0<R?H.replace(s,""):H).trim()).length))switch(0===j&&(d=H.charCodeAt(0),45===d||96<d&&123>d)&&(N=(H=H.replace(" ",":")).length),0<z&&void 0!==(x=a(1,H,l,n,T,O,W.length,f,p,f))&&0===(N=(H=x.trim()).length)&&(H="\0\0"),d=H.charCodeAt(0),h=H.charCodeAt(1),d){case 0:break;case 64:if(105===h||99===h){$+=H+u.charAt(L);break}default:58!==H.charCodeAt(N-1)&&(W+=r(H,d,h,H.charCodeAt(2)))}I=R=j=d=0,H="",h=u.charCodeAt(++L)}}switch(h){case 13:case 10:47===C?C=0:0===1+d&&107!==f&&0<H.length&&(R=1,H+="\0"),0<z*D&&a(0,H,l,n,T,O,W.length,f,p,f),O=1,T++;break;case 59:case 125:if(0===C+S+k+E){O++;break}default:switch(O++,y=u.charAt(L),h){case 9:case 32:if(0===S+E+C)switch(M){case 44:case 58:case 9:case 32:y="";break;default:32!==h&&(y=" ")}break;case 0:y="\\0";break;case 12:y="\\f";break;case 11:y="\\v";break;case 38:0===S+C+E&&(R=I=1,y="\f"+y);break;case 108:if(0===S+C+E+_&&0<j)switch(L-j){case 2:112===M&&58===u.charCodeAt(L-3)&&(_=M);case 8:111===F&&(_=F)}break;case 58:0===S+C+E&&(j=L);break;case 44:0===C+k+S+E&&(R=1,y+="\r");break;case 34:case 39:0===C&&(S=S===h?0:0===S?h:S);break;case 91:0===S+C+k&&E++;break;case 93:0===S+C+k&&E--;break;case 41:0===S+C+E&&k--;break;case 40:if(0===S+C+E){if(0===d)switch(2*M+3*F){case 533:break;default:d=1}k++}break;case 64:0===C+k+S+E+j+m&&(m=1);break;case 42:case 47:if(!(0<S+E+k))switch(C){case 0:switch(2*h+3*u.charCodeAt(L+1)){case 235:C=47;break;case 220:N=L,C=42}break;case 42:47===h&&42===M&&N+2!==L&&(33===u.charCodeAt(N+2)&&(W+=u.substring(N,L+1)),y="",C=0)}}0===C&&(H+=y)}F=M,M=h,L++}if(0<(N=W.length)){if(R=l,0<z&&(void 0!==(x=a(2,W,R,n,T,O,N,f,p,f))&&0===(W=x).length))return $+W+U;if(W=R.join(",")+"{"+W+"}",0!=P*_){switch(2!==P||o(W,2)||(_=0),_){case 111:W=W.replace(b,":-moz-$1")+W;break;case 112:W=W.replace(v,"::-webkit-input-$1")+W.replace(v,"::-moz-$1")+W.replace(v,":-ms-input-$1")+W}_=0}}return $+W+U}(A,l,n,0,0);return 0<z&&(void 0!==(u=a(-2,f,l,l,T,O,f.length,0,0,0))&&(f=u)),"",_=0,O=T=1,f}var c=/^\0+/g,s=/[\0\r\f]/g,f=/: */g,p=/zoo|gra/,d=/([,: ])(transform)/g,h=/,\r+?/g,m=/([\t\r\n ])*\f?&/g,g=/@(k\w+)\s*(\S*)\s*/,v=/::(place)/g,b=/:(read-only)/g,y=/[svh]\w+-[tblr]{2}/,w=/\(\s*(.*)\s*\)/g,x=/([\s\S]*?);/g,E=/-self|flex-/g,C=/[^]*?(:[rp][el]a[\w-]+)[^]*/,k=/stretch|:\s*\w+\-(?:conte|avail)/,S=/([^-])(image-set\()/,O=1,T=1,_=0,P=1,A=[],M=[],z=0,F=null,D=0;return u.use=function e(t){switch(t){case void 0:case null:z=M.length=0;break;default:if("function"==typeof t)M[z++]=t;else if("object"==typeof t)for(var n=0,r=t.length;n<r;++n)e(t[n]);else D=0|!!t}return e},u.set=l,void 0!==e&&l(e),u};n(17);function i(e){e&&a.current.insert(e+"}")}var a={current:null},l=function(e,t,n,r,o,l,u,c,s,f){switch(e){case 1:switch(t.charCodeAt(0)){case 64:return a.current.insert(t+";"),"";case 108:if(98===t.charCodeAt(2))return""}break;case 2:if(0===c)return t+"/*|*/";break;case 3:switch(c){case 102:case 112:return a.current.insert(n[0]+t),"";default:return t+(0===f?"/*|*/":"")}case-2:t.split("/*|*/}").forEach(i)}};t.a=function(e){void 0===e&&(e={});var t,n=e.key||"css";void 0!==e.prefix&&(t={prefix:e.prefix});var i=new o(t);var u,c={};u=e.container||document.head;var s,f=document.querySelectorAll("style[data-emotion-"+n+"]");Array.prototype.forEach.call(f,(function(e){e.getAttribute("data-emotion-"+n).split(" ").forEach((function(e){c[e]=!0})),e.parentNode!==u&&u.appendChild(e)})),i.use(e.stylisPlugins)(l),s=function(e,t,n,r){var o=t.name;a.current=n,i(e,t.styles),r&&(p.inserted[o]=!0)};var p={key:n,sheet:new r.a({key:n,container:u,nonce:e.nonce,speedy:e.speedy}),nonce:e.nonce,inserted:c,registered:{},insert:s};return p}},function(e,t){var n;n=function(){return this}();try{n=n||new Function("return this")()}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},o=function(){function e(e,t){for(var n=0;n<t.length;n++){var r=t[n];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(e,r.key,r)}}return function(t,n,r){return n&&e(t.prototype,n),r&&e(t,r),t}}(),i=n(0),a=u(i),l=u(n(2));function u(e){return e&&e.__esModule?e:{default:e}}var c={position:"absolute",top:0,left:0,visibility:"hidden",height:0,overflow:"scroll",whiteSpace:"pre"},s=["extraWidth","injectStyles","inputClassName","inputRef","inputStyle","minWidth","onAutosize","placeholderIsMinWidth"],f=function(e,t){t.style.fontSize=e.fontSize,t.style.fontFamily=e.fontFamily,t.style.fontWeight=e.fontWeight,t.style.fontStyle=e.fontStyle,t.style.letterSpacing=e.letterSpacing,t.style.textTransform=e.textTransform},p=!("undefined"==typeof window||!window.navigator)&&/MSIE |Trident\/|Edge\//.test(window.navigator.userAgent),d=function(){return p?"_"+Math.random().toString(36).substr(2,12):void 0},h=function(e){function t(e){!function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,t);var n=function(e,t){if(!e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!t||"object"!=typeof t&&"function"!=typeof t?e:t}(this,(t.__proto__||Object.getPrototypeOf(t)).call(this,e));return n.inputRef=function(e){n.input=e,"function"==typeof n.props.inputRef&&n.props.inputRef(e)},n.placeHolderSizerRef=function(e){n.placeHolderSizer=e},n.sizerRef=function(e){n.sizer=e},n.state={inputWidth:e.minWidth,inputId:e.id||d()},n}return function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function, not "+typeof t);e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}),t&&(Object.setPrototypeOf?Object.setPrototypeOf(e,t):e.__proto__=t)}(t,e),o(t,[{key:"componentDidMount",value:function(){this.mounted=!0,this.copyInputStyles(),this.updateInputWidth()}},{key:"UNSAFE_componentWillReceiveProps",value:function(e){var t=e.id;t!==this.props.id&&this.setState({inputId:t||d()})}},{key:"componentDidUpdate",value:function(e,t){t.inputWidth!==this.state.inputWidth&&"function"==typeof this.props.onAutosize&&this.props.onAutosize(this.state.inputWidth),this.updateInputWidth()}},{key:"componentWillUnmount",value:function(){this.mounted=!1}},{key:"copyInputStyles",value:function(){if(this.mounted&&window.getComputedStyle){var e=this.input&&window.getComputedStyle(this.input);e&&(f(e,this.sizer),this.placeHolderSizer&&f(e,this.placeHolderSizer))}}},{key:"updateInputWidth",value:function(){if(this.mounted&&this.sizer&&void 0!==this.sizer.scrollWidth){var e=void 0;e=this.props.placeholder&&(!this.props.value||this.props.value&&this.props.placeholderIsMinWidth)?Math.max(this.sizer.scrollWidth,this.placeHolderSizer.scrollWidth)+2:this.sizer.scrollWidth+2,(e+="number"===this.props.type&&void 0===this.props.extraWidth?16:parseInt(this.props.extraWidth)||0)<this.props.minWidth&&(e=this.props.minWidth),e!==this.state.inputWidth&&this.setState({inputWidth:e})}}},{key:"getInput",value:function(){return this.input}},{key:"focus",value:function(){this.input.focus()}},{key:"blur",value:function(){this.input.blur()}},{key:"select",value:function(){this.input.select()}},{key:"renderStyles",value:function(){var e=this.props.injectStyles;return p&&e?a.default.createElement("style",{dangerouslySetInnerHTML:{__html:"input#"+this.state.inputId+"::-ms-clear {display: none;}"}}):null}},{key:"render",value:function(){var e=[this.props.defaultValue,this.props.value,""].reduce((function(e,t){return null!=e?e:t})),t=r({},this.props.style);t.display||(t.display="inline-block");var n=r({boxSizing:"content-box",width:this.state.inputWidth+"px"},this.props.inputStyle),o=function(e,t){var n={};for(var r in e)t.indexOf(r)>=0||Object.prototype.hasOwnProperty.call(e,r)&&(n[r]=e[r]);return n}(this.props,[]);return function(e){s.forEach((function(t){return delete e[t]}))}(o),o.className=this.props.inputClassName,o.id=this.state.inputId,o.style=n,a.default.createElement("div",{className:this.props.className,style:t},this.renderStyles(),a.default.createElement("input",r({},o,{ref:this.inputRef})),a.default.createElement("div",{ref:this.sizerRef,style:c},e),this.props.placeholder?a.default.createElement("div",{ref:this.placeHolderSizerRef,style:c},this.props.placeholder):null)}}]),t}(i.Component);h.propTypes={className:l.default.string,defaultValue:l.default.any,extraWidth:l.default.oneOfType([l.default.number,l.default.string]),id:l.default.string,injectStyles:l.default.bool,inputClassName:l.default.string,inputRef:l.default.func,inputStyle:l.default.object,minWidth:l.default.oneOfType([l.default.number,l.default.string]),onAutosize:l.default.func,onChange:l.default.func,placeholder:l.default.string,placeholderIsMinWidth:l.default.bool,style:l.default.object,value:l.default.any},h.defaultProps={minWidth:1,injectStyles:!0},t.default=h},function(e,t){e.exports=function(e,t){e.prototype=Object.create(t.prototype),e.prototype.constructor=e,e.__proto__=t}},function(e,t,n){var r; +/*! + Copyright (c) 2017 Jed Watson. + Licensed under the MIT License (MIT), see + http://jedwatson.github.io/classnames +*/!function(){"use strict";var n={}.hasOwnProperty;function o(){for(var e=[],t=0;t<arguments.length;t++){var r=arguments[t];if(r){var i=typeof r;if("string"===i||"number"===i)e.push(r);else if(Array.isArray(r)&&r.length){var a=o.apply(null,r);a&&e.push(a)}else if("object"===i)for(var l in r)n.call(r,l)&&r[l]&&e.push(l)}}return e.join(" ")}e.exports?(o.default=o,e.exports=o):void 0===(r=function(){return o}.apply(t,[]))||(e.exports=r)}()},function(e,t,n){"use strict";var r,o=this&&this.__extends||(r=function(e,t){return(r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n])})(e,t)},function(e,t){function n(){this.constructor=e}r(e,t),e.prototype=null===t?Object.create(t):(n.prototype=t.prototype,new n)}),i=this&&this.__assign||function(){return(i=Object.assign||function(e){for(var t,n=1,r=arguments.length;n<r;n++)for(var o in t=arguments[n])Object.prototype.hasOwnProperty.call(t,o)&&(e[o]=t[o]);return e}).apply(this,arguments)},a=this&&this.__createBinding||(Object.create?function(e,t,n,r){void 0===r&&(r=n),Object.defineProperty(e,r,{enumerable:!0,get:function(){return t[n]}})}:function(e,t,n,r){void 0===r&&(r=n),e[r]=t[n]}),l=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),u=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var n in e)"default"!==n&&Object.hasOwnProperty.call(e,n)&&a(t,e,n);return l(t,e),t},c=this&&this.__rest||function(e,t){var n={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(r=Object.getOwnPropertySymbols(e);o<r.length;o++)t.indexOf(r[o])<0&&Object.prototype.propertyIsEnumerable.call(e,r[o])&&(n[r[o]]=e[r[o]])}return n},s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});var f=u(n(0)),p=s(n(33)),d=u(n(2));function h(e){return e&&e.replace(/ |\u202F|\u00A0/g," ")}var m=function(e){function t(){var t=null!==e&&e.apply(this,arguments)||this;return t.lastHtml=t.props.html,t.el="function"==typeof t.props.innerRef?{current:null}:f.createRef(),t.getEl=function(){return(t.props.innerRef&&"function"!=typeof t.props.innerRef?t.props.innerRef:t.el).current},t.emitChange=function(e){var n=t.getEl();if(n){var r=n.innerHTML;if(t.props.onChange&&r!==t.lastHtml){var o=Object.assign({},e,{target:{value:r}});t.props.onChange(o)}t.lastHtml=r}},t}return o(t,e),t.prototype.render=function(){var e=this,t=this.props,n=t.tagName,r=t.html,o=t.innerRef,a=c(t,["tagName","html","innerRef"]);return f.createElement(n||"div",i(i({},a),{ref:"function"==typeof o?function(t){o(t),e.el.current=t}:o||this.el,onInput:this.emitChange,onBlur:this.props.onBlur||this.emitChange,onKeyUp:this.props.onKeyUp||this.emitChange,onKeyDown:this.props.onKeyDown||this.emitChange,contentEditable:!this.props.disabled,dangerouslySetInnerHTML:{__html:r}}),this.props.children)},t.prototype.shouldComponentUpdate=function(e){var t=this.props,n=this.getEl();return!n||(h(e.html)!==h(n.innerHTML)||(t.disabled!==e.disabled||t.tagName!==e.tagName||t.className!==e.className||t.innerRef!==e.innerRef||!p.default(t.style,e.style)))},t.prototype.componentDidUpdate=function(){var e=this.getEl();e&&(this.props.html!==e.innerHTML&&(e.innerHTML=this.props.html),this.lastHtml=this.props.html,function(e){var t=document.createTextNode("");e.appendChild(t);var n=document.activeElement===e;if(null!==t&&null!==t.nodeValue&&n){var r=window.getSelection();if(null!==r){var o=document.createRange();o.setStart(t,t.nodeValue.length),o.collapse(!0),r.removeAllRanges(),r.addRange(o)}e instanceof HTMLElement&&e.focus()}}(e))},t.propTypes={html:d.string.isRequired,onChange:d.func,disabled:d.bool,tagName:d.string,className:d.string,style:d.object,innerRef:d.oneOfType([d.object,d.func])},t}(f.Component);t.default=m},function(e,t){function n(){return e.exports=n=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},n.apply(this,arguments)}e.exports=n},function(e,t,n){"use strict";var r=n(39),o={childContextTypes:!0,contextType:!0,contextTypes:!0,defaultProps:!0,displayName:!0,getDefaultProps:!0,getDerivedStateFromError:!0,getDerivedStateFromProps:!0,mixins:!0,propTypes:!0,type:!0},i={name:!0,length:!0,prototype:!0,caller:!0,callee:!0,arguments:!0,arity:!0},a={$$typeof:!0,compare:!0,defaultProps:!0,displayName:!0,propTypes:!0,type:!0},l={};function u(e){return r.isMemo(e)?a:l[e.$$typeof]||o}l[r.ForwardRef]={$$typeof:!0,render:!0,defaultProps:!0,displayName:!0,propTypes:!0},l[r.Memo]=a;var c=Object.defineProperty,s=Object.getOwnPropertyNames,f=Object.getOwnPropertySymbols,p=Object.getOwnPropertyDescriptor,d=Object.getPrototypeOf,h=Object.prototype;e.exports=function e(t,n,r){if("string"!=typeof n){if(h){var o=d(n);o&&o!==h&&e(t,o,r)}var a=s(n);f&&(a=a.concat(f(n)));for(var l=u(t),m=u(n),g=0;g<a.length;++g){var v=a[g];if(!(i[v]||r&&r[v]||m&&m[v]||l&&l[v])){var b=p(n,v);try{c(t,v,b)}catch(e){}}}}return t}},function(e,t,n){var r,o,i=n(41),a=n(42),l=0,u=0;e.exports=function(e,t,n){var c=t&&n||0,s=t||[],f=(e=e||{}).node||r,p=void 0!==e.clockseq?e.clockseq:o;if(null==f||null==p){var d=i();null==f&&(f=r=[1|d[0],d[1],d[2],d[3],d[4],d[5]]),null==p&&(p=o=16383&(d[6]<<8|d[7]))}var h=void 0!==e.msecs?e.msecs:(new Date).getTime(),m=void 0!==e.nsecs?e.nsecs:u+1,g=h-l+(m-u)/1e4;if(g<0&&void 0===e.clockseq&&(p=p+1&16383),(g<0||h>l)&&void 0===e.nsecs&&(m=0),m>=1e4)throw new Error("uuid.v1(): Can't create more than 10M uuids/sec");l=h,u=m,o=p;var v=(1e4*(268435455&(h+=122192928e5))+m)%4294967296;s[c++]=v>>>24&255,s[c++]=v>>>16&255,s[c++]=v>>>8&255,s[c++]=255&v;var b=h/4294967296*1e4&268435455;s[c++]=b>>>8&255,s[c++]=255&b,s[c++]=b>>>24&15|16,s[c++]=b>>>16&255,s[c++]=p>>>8|128,s[c++]=255&p;for(var y=0;y<6;++y)s[c+y]=f[y];return t||a(s)}},function(e,t){e.exports="data:image/svg+xml,%3Csvg width='220px' height='220px' viewBox='0 0 220 220' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E %3C!-- Generator: Sketch 55.2 (78181) - https://sketchapp.com --%3E %3Ctitle%3E1%3C/title%3E %3Cdesc%3ECreated with Sketch.%3C/desc%3E %3Cg id='1' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E %3Cg id='Asset-1' transform='translate(36.000000, 38.000000)'%3E %3Cpath d='M101.255751,105 L94,105 L94,84.0765259 L122,84.0765259 L121.867388,84 C121.876502,87.8996565 120.814098,91.725407 118.798376,95.0516178 C115.074511,101.236144 108.423147,105.008112 101.255751,105 Z' id='Path' fill='%23D4D4FF' fill-rule='nonzero'%3E%3C/path%3E %3Cpolygon id='Path' fill='%23B0AEFF' fill-rule='nonzero' points='121 66 109.002652 55.5030946 121 45 100 47.6180371 100 63.3819629'%3E%3C/polygon%3E %3Cpath d='M79,118 L94,133' id='Path' stroke='%23B0AEFF' stroke-width='7.0048' stroke-linecap='round' stroke-linejoin='round'%3E%3C/path%3E %3Cpath d='M60,118 L75,133' id='Path' stroke='%23B0AEFF' stroke-width='7.0048' stroke-linecap='round' stroke-linejoin='round'%3E%3C/path%3E %3Cpath d='M78.5,16 L78.5,30' id='Path' stroke='%23B0AEFF' stroke-width='5.6056' stroke-linecap='round' stroke-linejoin='round'%3E%3C/path%3E %3Cpath d='M88,20 L78,30' id='Path' stroke='%23B0AEFF' stroke-width='5.6056' stroke-linecap='round' stroke-linejoin='round'%3E%3C/path%3E %3Cpath d='M69,20 L79,30' id='Path' stroke='%23B0AEFF' stroke-width='5.6056' stroke-linecap='round' stroke-linejoin='round'%3E%3C/path%3E %3Cpath d='M77.6699038,25 L77.6699038,25 C61.488461,25.0413434 48.3810695,38.1746516 48.3398076,54.3880893 L48.3398076,93.6119107 C48.3398076,115.38342 40,123 40,123 L77.6699038,123 C93.8513466,122.958657 106.958738,109.825348 107,93.6119107 L107,54.3880893 C106.958738,38.1746516 93.8513466,25.0413434 77.6699038,25 Z' id='Path' fill='%23DDDBF8' fill-rule='nonzero'%3E%3C/path%3E %3Ccircle id='Oval' fill='%23EEEEFF' fill-rule='nonzero' cx='77.5' cy='56.5' r='21.5'%3E%3C/circle%3E %3Ccircle id='Oval' fill='%23FFFFFF' fill-rule='nonzero' cx='77.5' cy='56.5' r='15.5'%3E%3C/circle%3E %3Ccircle id='Oval' fill='%237456FF' fill-rule='nonzero' opacity='0.8' cx='70.5' cy='56.5' r='6.5'%3E%3C/circle%3E %3Cpath d='M34,83 L41.2559819,83 C52.7125868,83 62,92.4020203 62,104 L62,104 L34,104 L34,83 Z' id='Path' fill='%23DDDBF8' fill-rule='nonzero' transform='translate(48.000000, 93.500000) rotate(-180.000000) translate(-48.000000, -93.500000) '%3E%3C/path%3E %3Cpath d='M0,6.53468433 L0,24.1122369 C0.0103593728,27.7169789 2.95404136,30.6366464 6.58843252,30.6469213 L26.4920369,30.6469213 L38.4995809,40 L38.4115675,30.6469213 C42.044532,30.6332463 44.9862125,27.7155639 45,24.1122369 L45,6.53468433 C44.9896406,2.9299424 42.0459586,0.0102748614 38.4115675,0 L6.58843252,0 C2.95261143,0.00686235963 0.00691880296,2.92852414 0,6.53468433 Z' id='Path' fill='%23DDDBF8' fill-rule='nonzero' opacity='0.5'%3E%3C/path%3E %3Cpath d='M25.0000125,19.5055464 C24.9972733,18.8983672 25.4443814,18.3497279 26.1325716,18.1158164 C26.8207619,17.8819049 27.6142716,18.008866 28.1425703,18.437415 C28.6708691,18.8659641 28.8297367,19.5115591 28.5449897,20.0727387 C28.2602428,20.6339182 27.5880547,20.9999899 26.8423047,20.9999899 C26.3543279,21.0014511 25.8857177,20.8446522 25.5400265,20.564232 C25.1943352,20.2838117 25.0000125,19.9028556 25.0000125,19.5055464 L25.0000125,19.5055464 Z M31.3290371,19.5055464 C31.3290371,18.6801937 32.1508101,18.0111131 33.1645186,18.0111131 C34.178227,18.0111131 35,18.6801937 35,19.5055464 C35,20.3308991 34.178227,20.9999796 33.1645186,20.9999796 C32.6777186,20.9999796 32.2108567,20.8425308 31.8666372,20.5622702 C31.5224176,20.2820096 31.3290371,19.9018947 31.3290371,19.5055464 L31.3290371,19.5055464 Z' id='Shape' fill='%237456FF' fill-rule='nonzero' opacity='0.5'%3E%3C/path%3E %3Cpath d='M26.9971635,10.5 C27.0431005,9.7408407 26.5258343,9.07825825 25.8261406,9 L13.1738594,9 C12.4741657,9.07825825 11.9568995,9.7408407 12.0028365,10.5 C11.9568995,11.2591593 12.4741657,11.9217417 13.1738594,12 L25.8261406,12 C26.5258343,11.9217417 27.0431005,11.2591593 26.9971635,10.5 Z' id='Path' fill='%237456FF' fill-rule='nonzero' opacity='0.5'%3E%3C/path%3E %3Cpath d='M7.36585366,20 L18.6341463,20 C19.3884865,20 20,19.3284271 20,18.5 C20,17.6715729 19.3884865,17 18.6341463,17 L7.36585366,17 C6.61151351,17 6,17.6715729 6,18.5 C6,19.3284271 6.61151351,20 7.36585366,20 Z' id='Path' fill='%237456FF' fill-rule='nonzero' opacity='0.5'%3E%3C/path%3E %3Cpath d='M58,95 C58,96.7625 55.4939759,96.7625 55.4939759,98.525 C55.4939759,100.2875 58,100.28125 58,102.04375 C58,103.80625 55.4939759,103.8 55.4939759,105.5625 C55.4939759,107.325 58,107.325 58,109.0875 C58,110.85 55.4939759,110.84375 55.4939759,112.60625 C55.4939759,114.36875 58,114.36875 58,116.13125 C58,117.89375 55.4939759,117.89375 55.4939759,119.65 C55.4939759,121.40625 58,121.4125 58,123.175 C58,124.9375 55.4939759,124.9375 55.4939759,126.7 C55.4939759,128.4625 58,128.4625 58,130.225 C58,131.9875 57.373494,131.6875 55.9889157,132.7625 C54.153253,134.15625 54.4414458,135 52.1359036,135 C49.8303614,135 49.8303614,132.5 47.5248193,132.5 C45.2192771,132.5 45.2192771,135 42.9137349,135 C40.6081928,135 40.6081928,132.5 38.3026506,132.5 C35.9971084,132.5 35.9971084,135 33.6915663,135 C31.3860241,135 31.3860241,132.5 29.0742169,132.5 C26.7624096,132.5 26.7686747,135 24.4631325,135 C22.1575904,135 22.1575904,132.5 19.8457831,132.5 C17.5339759,132.5 17.540241,135 15.2346988,135 C12.9291566,135 12.9291566,132.5 10.6173494,132.5 C8.30554217,132.5 8.31180723,135 6,135' id='Path' stroke='%23FFE9C9' stroke-width='2.64'%3E%3C/path%3E %3Ccircle id='Oval' fill='%23416BFF' fill-rule='nonzero' opacity='0.1' cx='137' cy='20' r='11'%3E%3C/circle%3E %3Ccircle id='Oval' fill='%23416BFF' fill-rule='nonzero' opacity='0.2' cx='137' cy='20' r='5'%3E%3C/circle%3E %3Ccircle id='Oval' fill='%23416BFF' fill-rule='nonzero' opacity='0.2' cx='103' cy='6' r='2'%3E%3C/circle%3E %3Ccircle id='Oval' fill='%23416BFF' fill-rule='nonzero' opacity='0.2' cx='13' cy='95' r='4'%3E%3C/circle%3E %3Cpath d='M44.0209256,72.5923178 C41.4584408,69.6097492 39.074157,66.4785585 36.8805047,63.2150703 C38.2495564,62.5367429 39.612328,61.8458539 40.9688196,61.154965 C41.932408,60.6173829 42.6312006,59.7056357 42.8999263,58.6353602 C43.1686521,57.5650847 42.9834462,56.4313291 42.3881117,55.502237 C41.2451419,53.6808025 40.1586927,51.8174958 39.1287639,49.9123172 C38.6624143,48.9529293 37.772646,48.2682613 36.7258897,48.0633292 C35.6791335,47.858397 34.5969099,48.1569919 33.8032785,48.8697029 C31.291257,51.088922 28.8190093,53.3311708 26.3865352,55.5964492 L25.6894493,56.22453 C23.8556736,57.9077868 23.4097898,61.054472 24.8730423,63.5605147 C28.1365947,69.1628952 31.9034311,74.4564413 36.1268983,79.3755913 C36.3843805,79.6707894 36.6355826,79.9659874 36.8993449,80.2611854 C41.1778568,85.1298869 45.8882793,89.6012272 50.9729449,93.6204658 C53.2714445,95.4167771 56.4114713,95.4167771 58.3582879,93.8654173 L59.0993342,93.2373364 C61.6867163,91.1437335 64.2510715,89.0103521 66.7923998,86.8371922 C67.6067477,86.1493635 68.0519005,85.1197289 67.9951657,84.0552052 C67.9384308,82.9906815 67.3863604,82.0142076 66.5035174,81.4168542 C64.7576625,80.1355692 63.0641414,78.7998505 61.422954,77.4096982 C60.585952,76.6935234 59.4903749,76.3548524 58.3952869,76.4737688 C57.300199,76.5926852 56.3028311,77.1586304 55.6390247,78.0377791 C54.7598172,79.2939408 53.8868898,80.5501026 53.0202424,81.8062644 C50.1009599,79.1786373 47.3372397,76.3831273 44.7431318,73.4339462 C44.4793695,73.1513098 44.2595676,72.8686734 44.0209256,72.5923178 Z' id='Path' stroke='%23FF9A01' stroke-width='1.9272' fill='%23FF9A01' fill-rule='nonzero'%3E%3C/path%3E %3Cpath d='M37.8741184,49.6272546 C39.3097483,49.6293565 40.4730333,50.7926387 40.4751388,52.2282685 L40.4843275,58.4935492 C40.486425,59.9237518 39.3287171,61.0848604 37.8985146,61.0869579 C37.8959848,61.0869617 37.893455,61.0869617 37.8909252,61.086958 C36.4552954,61.084856 35.2920103,59.9215738 35.2899048,58.485944 L35.2807162,52.2206633 C35.2786186,50.7904607 36.4363265,49.6293521 37.866529,49.6272546 C37.8690588,49.6272509 37.8715886,49.6272509 37.8741184,49.6272546 Z' id='Rectangle' fill='%23FFE9C9' fill-rule='nonzero' transform='translate(37.882522, 55.357106) rotate(-30.000000) translate(-37.882522, -55.357106) '%3E%3C/path%3E %3Cpath d='M61.8238779,76.5731041 C63.257979,76.5754841 64.4199543,77.7374606 64.4223327,79.1715617 L64.432756,85.4564795 C64.4351243,86.8844444 63.2794498,88.0439586 61.8514848,88.0463268 C61.8486252,88.0463316 61.8457655,88.0463316 61.8429058,88.0463268 C60.4088047,88.0439468 59.2468294,86.8819702 59.244451,85.4478691 L59.2340277,79.1629514 C59.2316595,77.7349864 60.387334,76.5754723 61.815299,76.5731041 C61.8181586,76.5730993 61.8210183,76.5730993 61.8238779,76.5731041 Z' id='Rectangle' fill='%23FFE9C9' fill-rule='nonzero' transform='translate(61.833392, 82.309715) rotate(-50.670000) translate(-61.833392, -82.309715) '%3E%3C/path%3E %3Cpath d='M35,83.237151 C35,81.6825835 36.2115505,75.5357876 37.4670433,77.3226468 C38.7225361,79.1095061 45,94 45,94 C45,94 35,84.7976748 35,83.237151 Z' id='Path' fill='%23DDDBF8' fill-rule='nonzero'%3E%3C/path%3E %3C/g%3E %3C/g%3E %3C/svg%3E"},function(e,t,n){"use strict"; +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var r=n(4),o="function"==typeof Symbol&&Symbol.for,i=o?Symbol.for("react.element"):60103,a=o?Symbol.for("react.portal"):60106,l=o?Symbol.for("react.fragment"):60107,u=o?Symbol.for("react.strict_mode"):60108,c=o?Symbol.for("react.profiler"):60114,s=o?Symbol.for("react.provider"):60109,f=o?Symbol.for("react.context"):60110,p=o?Symbol.for("react.forward_ref"):60112,d=o?Symbol.for("react.suspense"):60113,h=o?Symbol.for("react.memo"):60115,m=o?Symbol.for("react.lazy"):60116,g="function"==typeof Symbol&&Symbol.iterator;function v(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n<arguments.length;n++)t+="&args[]="+encodeURIComponent(arguments[n]);return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}var b={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},y={};function w(e,t,n){this.props=e,this.context=t,this.refs=y,this.updater=n||b}function x(){}function E(e,t,n){this.props=e,this.context=t,this.refs=y,this.updater=n||b}w.prototype.isReactComponent={},w.prototype.setState=function(e,t){if("object"!=typeof e&&"function"!=typeof e&&null!=e)throw Error(v(85));this.updater.enqueueSetState(this,e,t,"setState")},w.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")},x.prototype=w.prototype;var C=E.prototype=new x;C.constructor=E,r(C,w.prototype),C.isPureReactComponent=!0;var k={current:null},S=Object.prototype.hasOwnProperty,O={key:!0,ref:!0,__self:!0,__source:!0};function T(e,t,n){var r,o={},a=null,l=null;if(null!=t)for(r in void 0!==t.ref&&(l=t.ref),void 0!==t.key&&(a=""+t.key),t)S.call(t,r)&&!O.hasOwnProperty(r)&&(o[r]=t[r]);var u=arguments.length-2;if(1===u)o.children=n;else if(1<u){for(var c=Array(u),s=0;s<u;s++)c[s]=arguments[s+2];o.children=c}if(e&&e.defaultProps)for(r in u=e.defaultProps)void 0===o[r]&&(o[r]=u[r]);return{$$typeof:i,type:e,key:a,ref:l,props:o,_owner:k.current}}function _(e){return"object"==typeof e&&null!==e&&e.$$typeof===i}var P=/\/+/g,A=[];function M(e,t,n,r){if(A.length){var o=A.pop();return o.result=e,o.keyPrefix=t,o.func=n,o.context=r,o.count=0,o}return{result:e,keyPrefix:t,func:n,context:r,count:0}}function z(e){e.result=null,e.keyPrefix=null,e.func=null,e.context=null,e.count=0,10>A.length&&A.push(e)}function F(e,t,n){return null==e?0:function e(t,n,r,o){var l=typeof t;"undefined"!==l&&"boolean"!==l||(t=null);var u=!1;if(null===t)u=!0;else switch(l){case"string":case"number":u=!0;break;case"object":switch(t.$$typeof){case i:case a:u=!0}}if(u)return r(o,t,""===n?"."+D(t,0):n),1;if(u=0,n=""===n?".":n+":",Array.isArray(t))for(var c=0;c<t.length;c++){var s=n+D(l=t[c],c);u+=e(l,s,r,o)}else if(null===t||"object"!=typeof t?s=null:s="function"==typeof(s=g&&t[g]||t["@@iterator"])?s:null,"function"==typeof s)for(t=s.call(t),c=0;!(l=t.next()).done;)u+=e(l=l.value,s=n+D(l,c++),r,o);else if("object"===l)throw r=""+t,Error(v(31,"[object Object]"===r?"object with keys {"+Object.keys(t).join(", ")+"}":r,""));return u}(e,"",t,n)}function D(e,t){return"object"==typeof e&&null!==e&&null!=e.key?function(e){var t={"=":"=0",":":"=2"};return"$"+(""+e).replace(/[=:]/g,(function(e){return t[e]}))}(e.key):t.toString(36)}function j(e,t){e.func.call(e.context,t,e.count++)}function L(e,t,n){var r=e.result,o=e.keyPrefix;e=e.func.call(e.context,t,e.count++),Array.isArray(e)?R(e,r,n,(function(e){return e})):null!=e&&(_(e)&&(e=function(e,t){return{$$typeof:i,type:e.type,key:t,ref:e.ref,props:e.props,_owner:e._owner}}(e,o+(!e.key||t&&t.key===e.key?"":(""+e.key).replace(P,"$&/")+"/")+n)),r.push(e))}function R(e,t,n,r,o){var i="";null!=n&&(i=(""+n).replace(P,"$&/")+"/"),F(e,L,t=M(t,i,r,o)),z(t)}var I={current:null};function N(){var e=I.current;if(null===e)throw Error(v(321));return e}var V={ReactCurrentDispatcher:I,ReactCurrentBatchConfig:{suspense:null},ReactCurrentOwner:k,IsSomeRendererActing:{current:!1},assign:r};t.Children={map:function(e,t,n){if(null==e)return e;var r=[];return R(e,r,null,t,n),r},forEach:function(e,t,n){if(null==e)return e;F(e,j,t=M(null,null,t,n)),z(t)},count:function(e){return F(e,(function(){return null}),null)},toArray:function(e){var t=[];return R(e,t,null,(function(e){return e})),t},only:function(e){if(!_(e))throw Error(v(143));return e}},t.Component=w,t.Fragment=l,t.Profiler=c,t.PureComponent=E,t.StrictMode=u,t.Suspense=d,t.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=V,t.cloneElement=function(e,t,n){if(null==e)throw Error(v(267,e));var o=r({},e.props),a=e.key,l=e.ref,u=e._owner;if(null!=t){if(void 0!==t.ref&&(l=t.ref,u=k.current),void 0!==t.key&&(a=""+t.key),e.type&&e.type.defaultProps)var c=e.type.defaultProps;for(s in t)S.call(t,s)&&!O.hasOwnProperty(s)&&(o[s]=void 0===t[s]&&void 0!==c?c[s]:t[s])}var s=arguments.length-2;if(1===s)o.children=n;else if(1<s){c=Array(s);for(var f=0;f<s;f++)c[f]=arguments[f+2];o.children=c}return{$$typeof:i,type:e.type,key:a,ref:l,props:o,_owner:u}},t.createContext=function(e,t){return void 0===t&&(t=null),(e={$$typeof:f,_calculateChangedBits:t,_currentValue:e,_currentValue2:e,_threadCount:0,Provider:null,Consumer:null}).Provider={$$typeof:s,_context:e},e.Consumer=e},t.createElement=T,t.createFactory=function(e){var t=T.bind(null,e);return t.type=e,t},t.createRef=function(){return{current:null}},t.forwardRef=function(e){return{$$typeof:p,render:e}},t.isValidElement=_,t.lazy=function(e){return{$$typeof:m,_ctor:e,_status:-1,_result:null}},t.memo=function(e,t){return{$$typeof:h,type:e,compare:void 0===t?null:t}},t.useCallback=function(e,t){return N().useCallback(e,t)},t.useContext=function(e,t){return N().useContext(e,t)},t.useDebugValue=function(){},t.useEffect=function(e,t){return N().useEffect(e,t)},t.useImperativeHandle=function(e,t,n){return N().useImperativeHandle(e,t,n)},t.useLayoutEffect=function(e,t){return N().useLayoutEffect(e,t)},t.useMemo=function(e,t){return N().useMemo(e,t)},t.useReducer=function(e,t,n){return N().useReducer(e,t,n)},t.useRef=function(e){return N().useRef(e)},t.useState=function(e){return N().useState(e)},t.version="16.13.1"},function(e,t,n){"use strict"; +/** @license React v16.13.1 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var r=n(0),o=n(4),i=n(30);function a(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n<arguments.length;n++)t+="&args[]="+encodeURIComponent(arguments[n]);return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}if(!r)throw Error(a(227));function l(e,t,n,r,o,i,a,l,u){var c=Array.prototype.slice.call(arguments,3);try{t.apply(n,c)}catch(e){this.onError(e)}}var u=!1,c=null,s=!1,f=null,p={onError:function(e){u=!0,c=e}};function d(e,t,n,r,o,i,a,s,f){u=!1,c=null,l.apply(p,arguments)}var h=null,m=null,g=null;function v(e,t,n){var r=e.type||"unknown-event";e.currentTarget=g(n),function(e,t,n,r,o,i,l,p,h){if(d.apply(this,arguments),u){if(!u)throw Error(a(198));var m=c;u=!1,c=null,s||(s=!0,f=m)}}(r,t,void 0,e),e.currentTarget=null}var b=null,y={};function w(){if(b)for(var e in y){var t=y[e],n=b.indexOf(e);if(!(-1<n))throw Error(a(96,e));if(!E[n]){if(!t.extractEvents)throw Error(a(97,e));for(var r in E[n]=t,n=t.eventTypes){var o=void 0,i=n[r],l=t,u=r;if(C.hasOwnProperty(u))throw Error(a(99,u));C[u]=i;var c=i.phasedRegistrationNames;if(c){for(o in c)c.hasOwnProperty(o)&&x(c[o],l,u);o=!0}else i.registrationName?(x(i.registrationName,l,u),o=!0):o=!1;if(!o)throw Error(a(98,r,e))}}}}function x(e,t,n){if(k[e])throw Error(a(100,e));k[e]=t,S[e]=t.eventTypes[n].dependencies}var E=[],C={},k={},S={};function O(e){var t,n=!1;for(t in e)if(e.hasOwnProperty(t)){var r=e[t];if(!y.hasOwnProperty(t)||y[t]!==r){if(y[t])throw Error(a(102,t));y[t]=r,n=!0}}n&&w()}var T=!("undefined"==typeof window||void 0===window.document||void 0===window.document.createElement),_=null,P=null,A=null;function M(e){if(e=m(e)){if("function"!=typeof _)throw Error(a(280));var t=e.stateNode;t&&(t=h(t),_(e.stateNode,e.type,t))}}function z(e){P?A?A.push(e):A=[e]:P=e}function F(){if(P){var e=P,t=A;if(A=P=null,M(e),t)for(e=0;e<t.length;e++)M(t[e])}}function D(e,t){return e(t)}function j(e,t,n,r,o){return e(t,n,r,o)}function L(){}var R=D,I=!1,N=!1;function V(){null===P&&null===A||(L(),F())}function B(e,t,n){if(N)return e(t,n);N=!0;try{return R(e,t,n)}finally{N=!1,V()}}var H=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,W=Object.prototype.hasOwnProperty,U={},$={};function Y(e,t,n,r,o,i){this.acceptsBooleans=2===t||3===t||4===t,this.attributeName=r,this.attributeNamespace=o,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i}var Q={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach((function(e){Q[e]=new Y(e,0,!1,e,null,!1)})),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach((function(e){var t=e[0];Q[t]=new Y(t,1,!1,e[1],null,!1)})),["contentEditable","draggable","spellCheck","value"].forEach((function(e){Q[e]=new Y(e,2,!1,e.toLowerCase(),null,!1)})),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach((function(e){Q[e]=new Y(e,2,!1,e,null,!1)})),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach((function(e){Q[e]=new Y(e,3,!1,e.toLowerCase(),null,!1)})),["checked","multiple","muted","selected"].forEach((function(e){Q[e]=new Y(e,3,!0,e,null,!1)})),["capture","download"].forEach((function(e){Q[e]=new Y(e,4,!1,e,null,!1)})),["cols","rows","size","span"].forEach((function(e){Q[e]=new Y(e,6,!1,e,null,!1)})),["rowSpan","start"].forEach((function(e){Q[e]=new Y(e,5,!1,e.toLowerCase(),null,!1)}));var G=/[\-:]([a-z])/g;function X(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach((function(e){var t=e.replace(G,X);Q[t]=new Y(t,1,!1,e,null,!1)})),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach((function(e){var t=e.replace(G,X);Q[t]=new Y(t,1,!1,e,"http://www.w3.org/1999/xlink",!1)})),["xml:base","xml:lang","xml:space"].forEach((function(e){var t=e.replace(G,X);Q[t]=new Y(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1)})),["tabIndex","crossOrigin"].forEach((function(e){Q[e]=new Y(e,1,!1,e.toLowerCase(),null,!1)})),Q.xlinkHref=new Y("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0),["src","href","action","formAction"].forEach((function(e){Q[e]=new Y(e,1,!1,e.toLowerCase(),null,!0)}));var q=r.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;function K(e,t,n,r){var o=Q.hasOwnProperty(t)?Q[t]:null;(null!==o?0===o.type:!r&&(2<t.length&&("o"===t[0]||"O"===t[0])&&("n"===t[1]||"N"===t[1])))||(function(e,t,n,r){if(null==t||function(e,t,n,r){if(null!==n&&0===n.type)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return!r&&(null!==n?!n.acceptsBooleans:"data-"!==(e=e.toLowerCase().slice(0,5))&&"aria-"!==e);default:return!1}}(e,t,n,r))return!0;if(r)return!1;if(null!==n)switch(n.type){case 3:return!t;case 4:return!1===t;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}(t,n,o,r)&&(n=null),r||null===o?function(e){return!!W.call($,e)||!W.call(U,e)&&(H.test(e)?$[e]=!0:(U[e]=!0,!1))}(t)&&(null===n?e.removeAttribute(t):e.setAttribute(t,""+n)):o.mustUseProperty?e[o.propertyName]=null===n?3!==o.type&&"":n:(t=o.attributeName,r=o.attributeNamespace,null===n?e.removeAttribute(t):(n=3===(o=o.type)||4===o&&!0===n?"":""+n,r?e.setAttributeNS(r,t,n):e.setAttribute(t,n))))}q.hasOwnProperty("ReactCurrentDispatcher")||(q.ReactCurrentDispatcher={current:null}),q.hasOwnProperty("ReactCurrentBatchConfig")||(q.ReactCurrentBatchConfig={suspense:null});var Z=/^(.*)[\\\/]/,J="function"==typeof Symbol&&Symbol.for,ee=J?Symbol.for("react.element"):60103,te=J?Symbol.for("react.portal"):60106,ne=J?Symbol.for("react.fragment"):60107,re=J?Symbol.for("react.strict_mode"):60108,oe=J?Symbol.for("react.profiler"):60114,ie=J?Symbol.for("react.provider"):60109,ae=J?Symbol.for("react.context"):60110,le=J?Symbol.for("react.concurrent_mode"):60111,ue=J?Symbol.for("react.forward_ref"):60112,ce=J?Symbol.for("react.suspense"):60113,se=J?Symbol.for("react.suspense_list"):60120,fe=J?Symbol.for("react.memo"):60115,pe=J?Symbol.for("react.lazy"):60116,de=J?Symbol.for("react.block"):60121,he="function"==typeof Symbol&&Symbol.iterator;function me(e){return null===e||"object"!=typeof e?null:"function"==typeof(e=he&&e[he]||e["@@iterator"])?e:null}function ge(e){if(null==e)return null;if("function"==typeof e)return e.displayName||e.name||null;if("string"==typeof e)return e;switch(e){case ne:return"Fragment";case te:return"Portal";case oe:return"Profiler";case re:return"StrictMode";case ce:return"Suspense";case se:return"SuspenseList"}if("object"==typeof e)switch(e.$$typeof){case ae:return"Context.Consumer";case ie:return"Context.Provider";case ue:var t=e.render;return t=t.displayName||t.name||"",e.displayName||(""!==t?"ForwardRef("+t+")":"ForwardRef");case fe:return ge(e.type);case de:return ge(e.render);case pe:if(e=1===e._status?e._result:null)return ge(e)}return null}function ve(e){var t="";do{e:switch(e.tag){case 3:case 4:case 6:case 7:case 10:case 9:var n="";break e;default:var r=e._debugOwner,o=e._debugSource,i=ge(e.type);n=null,r&&(n=ge(r.type)),r=i,i="",o?i=" (at "+o.fileName.replace(Z,"")+":"+o.lineNumber+")":n&&(i=" (created by "+n+")"),n="\n in "+(r||"Unknown")+i}t+=n,e=e.return}while(e);return t}function be(e){switch(typeof e){case"boolean":case"number":case"object":case"string":case"undefined":return e;default:return""}}function ye(e){var t=e.type;return(e=e.nodeName)&&"input"===e.toLowerCase()&&("checkbox"===t||"radio"===t)}function we(e){e._valueTracker||(e._valueTracker=function(e){var t=ye(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&void 0!==n&&"function"==typeof n.get&&"function"==typeof n.set){var o=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return o.call(this)},set:function(e){r=""+e,i.call(this,e)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(e){r=""+e},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}(e))}function xe(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=ye(e)?e.checked?"true":"false":e.value),(e=r)!==n&&(t.setValue(e),!0)}function Ee(e,t){var n=t.checked;return o({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:null!=n?n:e._wrapperState.initialChecked})}function Ce(e,t){var n=null==t.defaultValue?"":t.defaultValue,r=null!=t.checked?t.checked:t.defaultChecked;n=be(null!=t.value?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:"checkbox"===t.type||"radio"===t.type?null!=t.checked:null!=t.value}}function ke(e,t){null!=(t=t.checked)&&K(e,"checked",t,!1)}function Se(e,t){ke(e,t);var n=be(t.value),r=t.type;if(null!=n)"number"===r?(0===n&&""===e.value||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if("submit"===r||"reset"===r)return void e.removeAttribute("value");t.hasOwnProperty("value")?Te(e,t.type,n):t.hasOwnProperty("defaultValue")&&Te(e,t.type,be(t.defaultValue)),null==t.checked&&null!=t.defaultChecked&&(e.defaultChecked=!!t.defaultChecked)}function Oe(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!("submit"!==r&&"reset"!==r||void 0!==t.value&&null!==t.value))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}""!==(n=e.name)&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,""!==n&&(e.name=n)}function Te(e,t,n){"number"===t&&e.ownerDocument.activeElement===e||(null==n?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}function _e(e,t){return e=o({children:void 0},t),(t=function(e){var t="";return r.Children.forEach(e,(function(e){null!=e&&(t+=e)})),t}(t.children))&&(e.children=t),e}function Pe(e,t,n,r){if(e=e.options,t){t={};for(var o=0;o<n.length;o++)t["$"+n[o]]=!0;for(n=0;n<e.length;n++)o=t.hasOwnProperty("$"+e[n].value),e[n].selected!==o&&(e[n].selected=o),o&&r&&(e[n].defaultSelected=!0)}else{for(n=""+be(n),t=null,o=0;o<e.length;o++){if(e[o].value===n)return e[o].selected=!0,void(r&&(e[o].defaultSelected=!0));null!==t||e[o].disabled||(t=e[o])}null!==t&&(t.selected=!0)}}function Ae(e,t){if(null!=t.dangerouslySetInnerHTML)throw Error(a(91));return o({},t,{value:void 0,defaultValue:void 0,children:""+e._wrapperState.initialValue})}function Me(e,t){var n=t.value;if(null==n){if(n=t.children,t=t.defaultValue,null!=n){if(null!=t)throw Error(a(92));if(Array.isArray(n)){if(!(1>=n.length))throw Error(a(93));n=n[0]}t=n}null==t&&(t=""),n=t}e._wrapperState={initialValue:be(n)}}function ze(e,t){var n=be(t.value),r=be(t.defaultValue);null!=n&&((n=""+n)!==e.value&&(e.value=n),null==t.defaultValue&&e.defaultValue!==n&&(e.defaultValue=n)),null!=r&&(e.defaultValue=""+r)}function Fe(e){var t=e.textContent;t===e._wrapperState.initialValue&&""!==t&&null!==t&&(e.value=t)}var De="http://www.w3.org/1999/xhtml",je="http://www.w3.org/2000/svg";function Le(e){switch(e){case"svg":return"http://www.w3.org/2000/svg";case"math":return"http://www.w3.org/1998/Math/MathML";default:return"http://www.w3.org/1999/xhtml"}}function Re(e,t){return null==e||"http://www.w3.org/1999/xhtml"===e?Le(t):"http://www.w3.org/2000/svg"===e&&"foreignObject"===t?"http://www.w3.org/1999/xhtml":e}var Ie,Ne=function(e){return"undefined"!=typeof MSApp&&MSApp.execUnsafeLocalFunction?function(t,n,r,o){MSApp.execUnsafeLocalFunction((function(){return e(t,n)}))}:e}((function(e,t){if(e.namespaceURI!==je||"innerHTML"in e)e.innerHTML=t;else{for((Ie=Ie||document.createElement("div")).innerHTML="<svg>"+t.valueOf().toString()+"</svg>",t=Ie.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}}));function Ve(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&3===n.nodeType)return void(n.nodeValue=t)}e.textContent=t}function Be(e,t){var n={};return n[e.toLowerCase()]=t.toLowerCase(),n["Webkit"+e]="webkit"+t,n["Moz"+e]="moz"+t,n}var He={animationend:Be("Animation","AnimationEnd"),animationiteration:Be("Animation","AnimationIteration"),animationstart:Be("Animation","AnimationStart"),transitionend:Be("Transition","TransitionEnd")},We={},Ue={};function $e(e){if(We[e])return We[e];if(!He[e])return e;var t,n=He[e];for(t in n)if(n.hasOwnProperty(t)&&t in Ue)return We[e]=n[t];return e}T&&(Ue=document.createElement("div").style,"AnimationEvent"in window||(delete He.animationend.animation,delete He.animationiteration.animation,delete He.animationstart.animation),"TransitionEvent"in window||delete He.transitionend.transition);var Ye=$e("animationend"),Qe=$e("animationiteration"),Ge=$e("animationstart"),Xe=$e("transitionend"),qe="abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange seeked seeking stalled suspend timeupdate volumechange waiting".split(" "),Ke=new("function"==typeof WeakMap?WeakMap:Map);function Ze(e){var t=Ke.get(e);return void 0===t&&(t=new Map,Ke.set(e,t)),t}function Je(e){var t=e,n=e;if(e.alternate)for(;t.return;)t=t.return;else{e=t;do{0!=(1026&(t=e).effectTag)&&(n=t.return),e=t.return}while(e)}return 3===t.tag?n:null}function et(e){if(13===e.tag){var t=e.memoizedState;if(null===t&&(null!==(e=e.alternate)&&(t=e.memoizedState)),null!==t)return t.dehydrated}return null}function tt(e){if(Je(e)!==e)throw Error(a(188))}function nt(e){if(!(e=function(e){var t=e.alternate;if(!t){if(null===(t=Je(e)))throw Error(a(188));return t!==e?null:e}for(var n=e,r=t;;){var o=n.return;if(null===o)break;var i=o.alternate;if(null===i){if(null!==(r=o.return)){n=r;continue}break}if(o.child===i.child){for(i=o.child;i;){if(i===n)return tt(o),e;if(i===r)return tt(o),t;i=i.sibling}throw Error(a(188))}if(n.return!==r.return)n=o,r=i;else{for(var l=!1,u=o.child;u;){if(u===n){l=!0,n=o,r=i;break}if(u===r){l=!0,r=o,n=i;break}u=u.sibling}if(!l){for(u=i.child;u;){if(u===n){l=!0,n=i,r=o;break}if(u===r){l=!0,r=i,n=o;break}u=u.sibling}if(!l)throw Error(a(189))}}if(n.alternate!==r)throw Error(a(190))}if(3!==n.tag)throw Error(a(188));return n.stateNode.current===n?e:t}(e)))return null;for(var t=e;;){if(5===t.tag||6===t.tag)return t;if(t.child)t.child.return=t,t=t.child;else{if(t===e)break;for(;!t.sibling;){if(!t.return||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}}return null}function rt(e,t){if(null==t)throw Error(a(30));return null==e?t:Array.isArray(e)?Array.isArray(t)?(e.push.apply(e,t),e):(e.push(t),e):Array.isArray(t)?[e].concat(t):[e,t]}function ot(e,t,n){Array.isArray(e)?e.forEach(t,n):e&&t.call(n,e)}var it=null;function at(e){if(e){var t=e._dispatchListeners,n=e._dispatchInstances;if(Array.isArray(t))for(var r=0;r<t.length&&!e.isPropagationStopped();r++)v(e,t[r],n[r]);else t&&v(e,t,n);e._dispatchListeners=null,e._dispatchInstances=null,e.isPersistent()||e.constructor.release(e)}}function lt(e){if(null!==e&&(it=rt(it,e)),e=it,it=null,e){if(ot(e,at),it)throw Error(a(95));if(s)throw e=f,s=!1,f=null,e}}function ut(e){return(e=e.target||e.srcElement||window).correspondingUseElement&&(e=e.correspondingUseElement),3===e.nodeType?e.parentNode:e}function ct(e){if(!T)return!1;var t=(e="on"+e)in document;return t||((t=document.createElement("div")).setAttribute(e,"return;"),t="function"==typeof t[e]),t}var st=[];function ft(e){e.topLevelType=null,e.nativeEvent=null,e.targetInst=null,e.ancestors.length=0,10>st.length&&st.push(e)}function pt(e,t,n,r){if(st.length){var o=st.pop();return o.topLevelType=e,o.eventSystemFlags=r,o.nativeEvent=t,o.targetInst=n,o}return{topLevelType:e,eventSystemFlags:r,nativeEvent:t,targetInst:n,ancestors:[]}}function dt(e){var t=e.targetInst,n=t;do{if(!n){e.ancestors.push(n);break}var r=n;if(3===r.tag)r=r.stateNode.containerInfo;else{for(;r.return;)r=r.return;r=3!==r.tag?null:r.stateNode.containerInfo}if(!r)break;5!==(t=n.tag)&&6!==t||e.ancestors.push(n),n=Tn(r)}while(n);for(n=0;n<e.ancestors.length;n++){t=e.ancestors[n];var o=ut(e.nativeEvent);r=e.topLevelType;var i=e.nativeEvent,a=e.eventSystemFlags;0===n&&(a|=64);for(var l=null,u=0;u<E.length;u++){var c=E[u];c&&(c=c.extractEvents(r,t,i,o,a))&&(l=rt(l,c))}lt(l)}}function ht(e,t,n){if(!n.has(e)){switch(e){case"scroll":Gt(t,"scroll",!0);break;case"focus":case"blur":Gt(t,"focus",!0),Gt(t,"blur",!0),n.set("blur",null),n.set("focus",null);break;case"cancel":case"close":ct(e)&&Gt(t,e,!0);break;case"invalid":case"submit":case"reset":break;default:-1===qe.indexOf(e)&&Qt(e,t)}n.set(e,null)}}var mt,gt,vt,bt=!1,yt=[],wt=null,xt=null,Et=null,Ct=new Map,kt=new Map,St=[],Ot="mousedown mouseup touchcancel touchend touchstart auxclick dblclick pointercancel pointerdown pointerup dragend dragstart drop compositionend compositionstart keydown keypress keyup input textInput close cancel copy cut paste click change contextmenu reset submit".split(" "),Tt="focus blur dragenter dragleave mouseover mouseout pointerover pointerout gotpointercapture lostpointercapture".split(" ");function _t(e,t,n,r,o){return{blockedOn:e,topLevelType:t,eventSystemFlags:32|n,nativeEvent:o,container:r}}function Pt(e,t){switch(e){case"focus":case"blur":wt=null;break;case"dragenter":case"dragleave":xt=null;break;case"mouseover":case"mouseout":Et=null;break;case"pointerover":case"pointerout":Ct.delete(t.pointerId);break;case"gotpointercapture":case"lostpointercapture":kt.delete(t.pointerId)}}function At(e,t,n,r,o,i){return null===e||e.nativeEvent!==i?(e=_t(t,n,r,o,i),null!==t&&(null!==(t=_n(t))&>(t)),e):(e.eventSystemFlags|=r,e)}function Mt(e){var t=Tn(e.target);if(null!==t){var n=Je(t);if(null!==n)if(13===(t=n.tag)){if(null!==(t=et(n)))return e.blockedOn=t,void i.unstable_runWithPriority(e.priority,(function(){vt(n)}))}else if(3===t&&n.stateNode.hydrate)return void(e.blockedOn=3===n.tag?n.stateNode.containerInfo:null)}e.blockedOn=null}function zt(e){if(null!==e.blockedOn)return!1;var t=Zt(e.topLevelType,e.eventSystemFlags,e.container,e.nativeEvent);if(null!==t){var n=_n(t);return null!==n&>(n),e.blockedOn=t,!1}return!0}function Ft(e,t,n){zt(e)&&n.delete(t)}function Dt(){for(bt=!1;0<yt.length;){var e=yt[0];if(null!==e.blockedOn){null!==(e=_n(e.blockedOn))&&mt(e);break}var t=Zt(e.topLevelType,e.eventSystemFlags,e.container,e.nativeEvent);null!==t?e.blockedOn=t:yt.shift()}null!==wt&&zt(wt)&&(wt=null),null!==xt&&zt(xt)&&(xt=null),null!==Et&&zt(Et)&&(Et=null),Ct.forEach(Ft),kt.forEach(Ft)}function jt(e,t){e.blockedOn===t&&(e.blockedOn=null,bt||(bt=!0,i.unstable_scheduleCallback(i.unstable_NormalPriority,Dt)))}function Lt(e){function t(t){return jt(t,e)}if(0<yt.length){jt(yt[0],e);for(var n=1;n<yt.length;n++){var r=yt[n];r.blockedOn===e&&(r.blockedOn=null)}}for(null!==wt&&jt(wt,e),null!==xt&&jt(xt,e),null!==Et&&jt(Et,e),Ct.forEach(t),kt.forEach(t),n=0;n<St.length;n++)(r=St[n]).blockedOn===e&&(r.blockedOn=null);for(;0<St.length&&null===(n=St[0]).blockedOn;)Mt(n),null===n.blockedOn&&St.shift()}var Rt={},It=new Map,Nt=new Map,Vt=["abort","abort",Ye,"animationEnd",Qe,"animationIteration",Ge,"animationStart","canplay","canPlay","canplaythrough","canPlayThrough","durationchange","durationChange","emptied","emptied","encrypted","encrypted","ended","ended","error","error","gotpointercapture","gotPointerCapture","load","load","loadeddata","loadedData","loadedmetadata","loadedMetadata","loadstart","loadStart","lostpointercapture","lostPointerCapture","playing","playing","progress","progress","seeking","seeking","stalled","stalled","suspend","suspend","timeupdate","timeUpdate",Xe,"transitionEnd","waiting","waiting"];function Bt(e,t){for(var n=0;n<e.length;n+=2){var r=e[n],o=e[n+1],i="on"+(o[0].toUpperCase()+o.slice(1));i={phasedRegistrationNames:{bubbled:i,captured:i+"Capture"},dependencies:[r],eventPriority:t},Nt.set(r,t),It.set(r,i),Rt[o]=i}}Bt("blur blur cancel cancel click click close close contextmenu contextMenu copy copy cut cut auxclick auxClick dblclick doubleClick dragend dragEnd dragstart dragStart drop drop focus focus input input invalid invalid keydown keyDown keypress keyPress keyup keyUp mousedown mouseDown mouseup mouseUp paste paste pause pause play play pointercancel pointerCancel pointerdown pointerDown pointerup pointerUp ratechange rateChange reset reset seeked seeked submit submit touchcancel touchCancel touchend touchEnd touchstart touchStart volumechange volumeChange".split(" "),0),Bt("drag drag dragenter dragEnter dragexit dragExit dragleave dragLeave dragover dragOver mousemove mouseMove mouseout mouseOut mouseover mouseOver pointermove pointerMove pointerout pointerOut pointerover pointerOver scroll scroll toggle toggle touchmove touchMove wheel wheel".split(" "),1),Bt(Vt,2);for(var Ht="change selectionchange textInput compositionstart compositionend compositionupdate".split(" "),Wt=0;Wt<Ht.length;Wt++)Nt.set(Ht[Wt],0);var Ut=i.unstable_UserBlockingPriority,$t=i.unstable_runWithPriority,Yt=!0;function Qt(e,t){Gt(t,e,!1)}function Gt(e,t,n){var r=Nt.get(t);switch(void 0===r?2:r){case 0:r=Xt.bind(null,t,1,e);break;case 1:r=qt.bind(null,t,1,e);break;default:r=Kt.bind(null,t,1,e)}n?e.addEventListener(t,r,!0):e.addEventListener(t,r,!1)}function Xt(e,t,n,r){I||L();var o=Kt,i=I;I=!0;try{j(o,e,t,n,r)}finally{(I=i)||V()}}function qt(e,t,n,r){$t(Ut,Kt.bind(null,e,t,n,r))}function Kt(e,t,n,r){if(Yt)if(0<yt.length&&-1<Ot.indexOf(e))e=_t(null,e,t,n,r),yt.push(e);else{var o=Zt(e,t,n,r);if(null===o)Pt(e,r);else if(-1<Ot.indexOf(e))e=_t(o,e,t,n,r),yt.push(e);else if(!function(e,t,n,r,o){switch(t){case"focus":return wt=At(wt,e,t,n,r,o),!0;case"dragenter":return xt=At(xt,e,t,n,r,o),!0;case"mouseover":return Et=At(Et,e,t,n,r,o),!0;case"pointerover":var i=o.pointerId;return Ct.set(i,At(Ct.get(i)||null,e,t,n,r,o)),!0;case"gotpointercapture":return i=o.pointerId,kt.set(i,At(kt.get(i)||null,e,t,n,r,o)),!0}return!1}(o,e,t,n,r)){Pt(e,r),e=pt(e,r,null,t);try{B(dt,e)}finally{ft(e)}}}}function Zt(e,t,n,r){if(null!==(n=Tn(n=ut(r)))){var o=Je(n);if(null===o)n=null;else{var i=o.tag;if(13===i){if(null!==(n=et(o)))return n;n=null}else if(3===i){if(o.stateNode.hydrate)return 3===o.tag?o.stateNode.containerInfo:null;n=null}else o!==n&&(n=null)}}e=pt(e,r,n,t);try{B(dt,e)}finally{ft(e)}return null}var Jt={animationIterationCount:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},en=["Webkit","ms","Moz","O"];function tn(e,t,n){return null==t||"boolean"==typeof t||""===t?"":n||"number"!=typeof t||0===t||Jt.hasOwnProperty(e)&&Jt[e]?(""+t).trim():t+"px"}function nn(e,t){for(var n in e=e.style,t)if(t.hasOwnProperty(n)){var r=0===n.indexOf("--"),o=tn(n,t[n],r);"float"===n&&(n="cssFloat"),r?e.setProperty(n,o):e[n]=o}}Object.keys(Jt).forEach((function(e){en.forEach((function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Jt[t]=Jt[e]}))}));var rn=o({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function on(e,t){if(t){if(rn[e]&&(null!=t.children||null!=t.dangerouslySetInnerHTML))throw Error(a(137,e,""));if(null!=t.dangerouslySetInnerHTML){if(null!=t.children)throw Error(a(60));if("object"!=typeof t.dangerouslySetInnerHTML||!("__html"in t.dangerouslySetInnerHTML))throw Error(a(61))}if(null!=t.style&&"object"!=typeof t.style)throw Error(a(62,""))}}function an(e,t){if(-1===e.indexOf("-"))return"string"==typeof t.is;switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var ln=De;function un(e,t){var n=Ze(e=9===e.nodeType||11===e.nodeType?e:e.ownerDocument);t=S[t];for(var r=0;r<t.length;r++)ht(t[r],e,n)}function cn(){}function sn(e){if(void 0===(e=e||("undefined"!=typeof document?document:void 0)))return null;try{return e.activeElement||e.body}catch(t){return e.body}}function fn(e){for(;e&&e.firstChild;)e=e.firstChild;return e}function pn(e,t){var n,r=fn(e);for(e=0;r;){if(3===r.nodeType){if(n=e+r.textContent.length,e<=t&&n>=t)return{node:r,offset:t-e};e=n}e:{for(;r;){if(r.nextSibling){r=r.nextSibling;break e}r=r.parentNode}r=void 0}r=fn(r)}}function dn(){for(var e=window,t=sn();t instanceof e.HTMLIFrameElement;){try{var n="string"==typeof t.contentWindow.location.href}catch(e){n=!1}if(!n)break;t=sn((e=t.contentWindow).document)}return t}function hn(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&("input"===t&&("text"===e.type||"search"===e.type||"tel"===e.type||"url"===e.type||"password"===e.type)||"textarea"===t||"true"===e.contentEditable)}var mn=null,gn=null;function vn(e,t){switch(e){case"button":case"input":case"select":case"textarea":return!!t.autoFocus}return!1}function bn(e,t){return"textarea"===e||"option"===e||"noscript"===e||"string"==typeof t.children||"number"==typeof t.children||"object"==typeof t.dangerouslySetInnerHTML&&null!==t.dangerouslySetInnerHTML&&null!=t.dangerouslySetInnerHTML.__html}var yn="function"==typeof setTimeout?setTimeout:void 0,wn="function"==typeof clearTimeout?clearTimeout:void 0;function xn(e){for(;null!=e;e=e.nextSibling){var t=e.nodeType;if(1===t||3===t)break}return e}function En(e){e=e.previousSibling;for(var t=0;e;){if(8===e.nodeType){var n=e.data;if("$"===n||"$!"===n||"$?"===n){if(0===t)return e;t--}else"/$"===n&&t++}e=e.previousSibling}return null}var Cn=Math.random().toString(36).slice(2),kn="__reactInternalInstance$"+Cn,Sn="__reactEventHandlers$"+Cn,On="__reactContainere$"+Cn;function Tn(e){var t=e[kn];if(t)return t;for(var n=e.parentNode;n;){if(t=n[On]||n[kn]){if(n=t.alternate,null!==t.child||null!==n&&null!==n.child)for(e=En(e);null!==e;){if(n=e[kn])return n;e=En(e)}return t}n=(e=n).parentNode}return null}function _n(e){return!(e=e[kn]||e[On])||5!==e.tag&&6!==e.tag&&13!==e.tag&&3!==e.tag?null:e}function Pn(e){if(5===e.tag||6===e.tag)return e.stateNode;throw Error(a(33))}function An(e){return e[Sn]||null}function Mn(e){do{e=e.return}while(e&&5!==e.tag);return e||null}function zn(e,t){var n=e.stateNode;if(!n)return null;var r=h(n);if(!r)return null;n=r[t];e:switch(t){case"onClick":case"onClickCapture":case"onDoubleClick":case"onDoubleClickCapture":case"onMouseDown":case"onMouseDownCapture":case"onMouseMove":case"onMouseMoveCapture":case"onMouseUp":case"onMouseUpCapture":case"onMouseEnter":(r=!r.disabled)||(r=!("button"===(e=e.type)||"input"===e||"select"===e||"textarea"===e)),e=!r;break e;default:e=!1}if(e)return null;if(n&&"function"!=typeof n)throw Error(a(231,t,typeof n));return n}function Fn(e,t,n){(t=zn(e,n.dispatchConfig.phasedRegistrationNames[t]))&&(n._dispatchListeners=rt(n._dispatchListeners,t),n._dispatchInstances=rt(n._dispatchInstances,e))}function Dn(e){if(e&&e.dispatchConfig.phasedRegistrationNames){for(var t=e._targetInst,n=[];t;)n.push(t),t=Mn(t);for(t=n.length;0<t--;)Fn(n[t],"captured",e);for(t=0;t<n.length;t++)Fn(n[t],"bubbled",e)}}function jn(e,t,n){e&&n&&n.dispatchConfig.registrationName&&(t=zn(e,n.dispatchConfig.registrationName))&&(n._dispatchListeners=rt(n._dispatchListeners,t),n._dispatchInstances=rt(n._dispatchInstances,e))}function Ln(e){e&&e.dispatchConfig.registrationName&&jn(e._targetInst,null,e)}function Rn(e){ot(e,Dn)}var In=null,Nn=null,Vn=null;function Bn(){if(Vn)return Vn;var e,t,n=Nn,r=n.length,o="value"in In?In.value:In.textContent,i=o.length;for(e=0;e<r&&n[e]===o[e];e++);var a=r-e;for(t=1;t<=a&&n[r-t]===o[i-t];t++);return Vn=o.slice(e,1<t?1-t:void 0)}function Hn(){return!0}function Wn(){return!1}function Un(e,t,n,r){for(var o in this.dispatchConfig=e,this._targetInst=t,this.nativeEvent=n,e=this.constructor.Interface)e.hasOwnProperty(o)&&((t=e[o])?this[o]=t(n):"target"===o?this.target=r:this[o]=n[o]);return this.isDefaultPrevented=(null!=n.defaultPrevented?n.defaultPrevented:!1===n.returnValue)?Hn:Wn,this.isPropagationStopped=Wn,this}function $n(e,t,n,r){if(this.eventPool.length){var o=this.eventPool.pop();return this.call(o,e,t,n,r),o}return new this(e,t,n,r)}function Yn(e){if(!(e instanceof this))throw Error(a(279));e.destructor(),10>this.eventPool.length&&this.eventPool.push(e)}function Qn(e){e.eventPool=[],e.getPooled=$n,e.release=Yn}o(Un.prototype,{preventDefault:function(){this.defaultPrevented=!0;var e=this.nativeEvent;e&&(e.preventDefault?e.preventDefault():"unknown"!=typeof e.returnValue&&(e.returnValue=!1),this.isDefaultPrevented=Hn)},stopPropagation:function(){var e=this.nativeEvent;e&&(e.stopPropagation?e.stopPropagation():"unknown"!=typeof e.cancelBubble&&(e.cancelBubble=!0),this.isPropagationStopped=Hn)},persist:function(){this.isPersistent=Hn},isPersistent:Wn,destructor:function(){var e,t=this.constructor.Interface;for(e in t)this[e]=null;this.nativeEvent=this._targetInst=this.dispatchConfig=null,this.isPropagationStopped=this.isDefaultPrevented=Wn,this._dispatchInstances=this._dispatchListeners=null}}),Un.Interface={type:null,target:null,currentTarget:function(){return null},eventPhase:null,bubbles:null,cancelable:null,timeStamp:function(e){return e.timeStamp||Date.now()},defaultPrevented:null,isTrusted:null},Un.extend=function(e){function t(){}function n(){return r.apply(this,arguments)}var r=this;t.prototype=r.prototype;var i=new t;return o(i,n.prototype),n.prototype=i,n.prototype.constructor=n,n.Interface=o({},r.Interface,e),n.extend=r.extend,Qn(n),n},Qn(Un);var Gn=Un.extend({data:null}),Xn=Un.extend({data:null}),qn=[9,13,27,32],Kn=T&&"CompositionEvent"in window,Zn=null;T&&"documentMode"in document&&(Zn=document.documentMode);var Jn=T&&"TextEvent"in window&&!Zn,er=T&&(!Kn||Zn&&8<Zn&&11>=Zn),tr=String.fromCharCode(32),nr={beforeInput:{phasedRegistrationNames:{bubbled:"onBeforeInput",captured:"onBeforeInputCapture"},dependencies:["compositionend","keypress","textInput","paste"]},compositionEnd:{phasedRegistrationNames:{bubbled:"onCompositionEnd",captured:"onCompositionEndCapture"},dependencies:"blur compositionend keydown keypress keyup mousedown".split(" ")},compositionStart:{phasedRegistrationNames:{bubbled:"onCompositionStart",captured:"onCompositionStartCapture"},dependencies:"blur compositionstart keydown keypress keyup mousedown".split(" ")},compositionUpdate:{phasedRegistrationNames:{bubbled:"onCompositionUpdate",captured:"onCompositionUpdateCapture"},dependencies:"blur compositionupdate keydown keypress keyup mousedown".split(" ")}},rr=!1;function or(e,t){switch(e){case"keyup":return-1!==qn.indexOf(t.keyCode);case"keydown":return 229!==t.keyCode;case"keypress":case"mousedown":case"blur":return!0;default:return!1}}function ir(e){return"object"==typeof(e=e.detail)&&"data"in e?e.data:null}var ar=!1;var lr={eventTypes:nr,extractEvents:function(e,t,n,r){var o;if(Kn)e:{switch(e){case"compositionstart":var i=nr.compositionStart;break e;case"compositionend":i=nr.compositionEnd;break e;case"compositionupdate":i=nr.compositionUpdate;break e}i=void 0}else ar?or(e,n)&&(i=nr.compositionEnd):"keydown"===e&&229===n.keyCode&&(i=nr.compositionStart);return i?(er&&"ko"!==n.locale&&(ar||i!==nr.compositionStart?i===nr.compositionEnd&&ar&&(o=Bn()):(Nn="value"in(In=r)?In.value:In.textContent,ar=!0)),i=Gn.getPooled(i,t,n,r),o?i.data=o:null!==(o=ir(n))&&(i.data=o),Rn(i),o=i):o=null,(e=Jn?function(e,t){switch(e){case"compositionend":return ir(t);case"keypress":return 32!==t.which?null:(rr=!0,tr);case"textInput":return(e=t.data)===tr&&rr?null:e;default:return null}}(e,n):function(e,t){if(ar)return"compositionend"===e||!Kn&&or(e,t)?(e=Bn(),Vn=Nn=In=null,ar=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1<t.char.length)return t.char;if(t.which)return String.fromCharCode(t.which)}return null;case"compositionend":return er&&"ko"!==t.locale?null:t.data;default:return null}}(e,n))?((t=Xn.getPooled(nr.beforeInput,t,n,r)).data=e,Rn(t)):t=null,null===o?t:null===t?o:[o,t]}},ur={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0};function cr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return"input"===t?!!ur[e.type]:"textarea"===t}var sr={change:{phasedRegistrationNames:{bubbled:"onChange",captured:"onChangeCapture"},dependencies:"blur change click focus input keydown keyup selectionchange".split(" ")}};function fr(e,t,n){return(e=Un.getPooled(sr.change,e,t,n)).type="change",z(n),Rn(e),e}var pr=null,dr=null;function hr(e){lt(e)}function mr(e){if(xe(Pn(e)))return e}function gr(e,t){if("change"===e)return t}var vr=!1;function br(){pr&&(pr.detachEvent("onpropertychange",yr),dr=pr=null)}function yr(e){if("value"===e.propertyName&&mr(dr))if(e=fr(dr,e,ut(e)),I)lt(e);else{I=!0;try{D(hr,e)}finally{I=!1,V()}}}function wr(e,t,n){"focus"===e?(br(),dr=n,(pr=t).attachEvent("onpropertychange",yr)):"blur"===e&&br()}function xr(e){if("selectionchange"===e||"keyup"===e||"keydown"===e)return mr(dr)}function Er(e,t){if("click"===e)return mr(t)}function Cr(e,t){if("input"===e||"change"===e)return mr(t)}T&&(vr=ct("input")&&(!document.documentMode||9<document.documentMode));var kr={eventTypes:sr,_isInputEventSupported:vr,extractEvents:function(e,t,n,r){var o=t?Pn(t):window,i=o.nodeName&&o.nodeName.toLowerCase();if("select"===i||"input"===i&&"file"===o.type)var a=gr;else if(cr(o))if(vr)a=Cr;else{a=xr;var l=wr}else(i=o.nodeName)&&"input"===i.toLowerCase()&&("checkbox"===o.type||"radio"===o.type)&&(a=Er);if(a&&(a=a(e,t)))return fr(a,n,r);l&&l(e,o,t),"blur"===e&&(e=o._wrapperState)&&e.controlled&&"number"===o.type&&Te(o,"number",o.value)}},Sr=Un.extend({view:null,detail:null}),Or={Alt:"altKey",Control:"ctrlKey",Meta:"metaKey",Shift:"shiftKey"};function Tr(e){var t=this.nativeEvent;return t.getModifierState?t.getModifierState(e):!!(e=Or[e])&&!!t[e]}function _r(){return Tr}var Pr=0,Ar=0,Mr=!1,zr=!1,Fr=Sr.extend({screenX:null,screenY:null,clientX:null,clientY:null,pageX:null,pageY:null,ctrlKey:null,shiftKey:null,altKey:null,metaKey:null,getModifierState:_r,button:null,buttons:null,relatedTarget:function(e){return e.relatedTarget||(e.fromElement===e.srcElement?e.toElement:e.fromElement)},movementX:function(e){if("movementX"in e)return e.movementX;var t=Pr;return Pr=e.screenX,Mr?"mousemove"===e.type?e.screenX-t:0:(Mr=!0,0)},movementY:function(e){if("movementY"in e)return e.movementY;var t=Ar;return Ar=e.screenY,zr?"mousemove"===e.type?e.screenY-t:0:(zr=!0,0)}}),Dr=Fr.extend({pointerId:null,width:null,height:null,pressure:null,tangentialPressure:null,tiltX:null,tiltY:null,twist:null,pointerType:null,isPrimary:null}),jr={mouseEnter:{registrationName:"onMouseEnter",dependencies:["mouseout","mouseover"]},mouseLeave:{registrationName:"onMouseLeave",dependencies:["mouseout","mouseover"]},pointerEnter:{registrationName:"onPointerEnter",dependencies:["pointerout","pointerover"]},pointerLeave:{registrationName:"onPointerLeave",dependencies:["pointerout","pointerover"]}},Lr={eventTypes:jr,extractEvents:function(e,t,n,r,o){var i="mouseover"===e||"pointerover"===e,a="mouseout"===e||"pointerout"===e;if(i&&0==(32&o)&&(n.relatedTarget||n.fromElement)||!a&&!i)return null;(i=r.window===r?r:(i=r.ownerDocument)?i.defaultView||i.parentWindow:window,a)?(a=t,null!==(t=(t=n.relatedTarget||n.toElement)?Tn(t):null)&&(t!==Je(t)||5!==t.tag&&6!==t.tag)&&(t=null)):a=null;if(a===t)return null;if("mouseout"===e||"mouseover"===e)var l=Fr,u=jr.mouseLeave,c=jr.mouseEnter,s="mouse";else"pointerout"!==e&&"pointerover"!==e||(l=Dr,u=jr.pointerLeave,c=jr.pointerEnter,s="pointer");if(e=null==a?i:Pn(a),i=null==t?i:Pn(t),(u=l.getPooled(u,a,n,r)).type=s+"leave",u.target=e,u.relatedTarget=i,(n=l.getPooled(c,t,n,r)).type=s+"enter",n.target=i,n.relatedTarget=e,s=t,(r=a)&&s)e:{for(c=s,a=0,e=l=r;e;e=Mn(e))a++;for(e=0,t=c;t;t=Mn(t))e++;for(;0<a-e;)l=Mn(l),a--;for(;0<e-a;)c=Mn(c),e--;for(;a--;){if(l===c||l===c.alternate)break e;l=Mn(l),c=Mn(c)}l=null}else l=null;for(c=l,l=[];r&&r!==c&&(null===(a=r.alternate)||a!==c);)l.push(r),r=Mn(r);for(r=[];s&&s!==c&&(null===(a=s.alternate)||a!==c);)r.push(s),s=Mn(s);for(s=0;s<l.length;s++)jn(l[s],"bubbled",u);for(s=r.length;0<s--;)jn(r[s],"captured",n);return 0==(64&o)?[u]:[u,n]}};var Rr="function"==typeof Object.is?Object.is:function(e,t){return e===t&&(0!==e||1/e==1/t)||e!=e&&t!=t},Ir=Object.prototype.hasOwnProperty;function Nr(e,t){if(Rr(e,t))return!0;if("object"!=typeof e||null===e||"object"!=typeof t||null===t)return!1;var n=Object.keys(e),r=Object.keys(t);if(n.length!==r.length)return!1;for(r=0;r<n.length;r++)if(!Ir.call(t,n[r])||!Rr(e[n[r]],t[n[r]]))return!1;return!0}var Vr=T&&"documentMode"in document&&11>=document.documentMode,Br={select:{phasedRegistrationNames:{bubbled:"onSelect",captured:"onSelectCapture"},dependencies:"blur contextmenu dragend focus keydown keyup mousedown mouseup selectionchange".split(" ")}},Hr=null,Wr=null,Ur=null,$r=!1;function Yr(e,t){var n=t.window===t?t.document:9===t.nodeType?t:t.ownerDocument;return $r||null==Hr||Hr!==sn(n)?null:("selectionStart"in(n=Hr)&&hn(n)?n={start:n.selectionStart,end:n.selectionEnd}:n={anchorNode:(n=(n.ownerDocument&&n.ownerDocument.defaultView||window).getSelection()).anchorNode,anchorOffset:n.anchorOffset,focusNode:n.focusNode,focusOffset:n.focusOffset},Ur&&Nr(Ur,n)?null:(Ur=n,(e=Un.getPooled(Br.select,Wr,e,t)).type="select",e.target=Hr,Rn(e),e))}var Qr={eventTypes:Br,extractEvents:function(e,t,n,r,o,i){if(!(i=!(o=i||(r.window===r?r.document:9===r.nodeType?r:r.ownerDocument)))){e:{o=Ze(o),i=S.onSelect;for(var a=0;a<i.length;a++)if(!o.has(i[a])){o=!1;break e}o=!0}i=!o}if(i)return null;switch(o=t?Pn(t):window,e){case"focus":(cr(o)||"true"===o.contentEditable)&&(Hr=o,Wr=t,Ur=null);break;case"blur":Ur=Wr=Hr=null;break;case"mousedown":$r=!0;break;case"contextmenu":case"mouseup":case"dragend":return $r=!1,Yr(n,r);case"selectionchange":if(Vr)break;case"keydown":case"keyup":return Yr(n,r)}return null}},Gr=Un.extend({animationName:null,elapsedTime:null,pseudoElement:null}),Xr=Un.extend({clipboardData:function(e){return"clipboardData"in e?e.clipboardData:window.clipboardData}}),qr=Sr.extend({relatedTarget:null});function Kr(e){var t=e.keyCode;return"charCode"in e?0===(e=e.charCode)&&13===t&&(e=13):e=t,10===e&&(e=13),32<=e||13===e?e:0}var Zr={Esc:"Escape",Spacebar:" ",Left:"ArrowLeft",Up:"ArrowUp",Right:"ArrowRight",Down:"ArrowDown",Del:"Delete",Win:"OS",Menu:"ContextMenu",Apps:"ContextMenu",Scroll:"ScrollLock",MozPrintableKey:"Unidentified"},Jr={8:"Backspace",9:"Tab",12:"Clear",13:"Enter",16:"Shift",17:"Control",18:"Alt",19:"Pause",20:"CapsLock",27:"Escape",32:" ",33:"PageUp",34:"PageDown",35:"End",36:"Home",37:"ArrowLeft",38:"ArrowUp",39:"ArrowRight",40:"ArrowDown",45:"Insert",46:"Delete",112:"F1",113:"F2",114:"F3",115:"F4",116:"F5",117:"F6",118:"F7",119:"F8",120:"F9",121:"F10",122:"F11",123:"F12",144:"NumLock",145:"ScrollLock",224:"Meta"},eo=Sr.extend({key:function(e){if(e.key){var t=Zr[e.key]||e.key;if("Unidentified"!==t)return t}return"keypress"===e.type?13===(e=Kr(e))?"Enter":String.fromCharCode(e):"keydown"===e.type||"keyup"===e.type?Jr[e.keyCode]||"Unidentified":""},location:null,ctrlKey:null,shiftKey:null,altKey:null,metaKey:null,repeat:null,locale:null,getModifierState:_r,charCode:function(e){return"keypress"===e.type?Kr(e):0},keyCode:function(e){return"keydown"===e.type||"keyup"===e.type?e.keyCode:0},which:function(e){return"keypress"===e.type?Kr(e):"keydown"===e.type||"keyup"===e.type?e.keyCode:0}}),to=Fr.extend({dataTransfer:null}),no=Sr.extend({touches:null,targetTouches:null,changedTouches:null,altKey:null,metaKey:null,ctrlKey:null,shiftKey:null,getModifierState:_r}),ro=Un.extend({propertyName:null,elapsedTime:null,pseudoElement:null}),oo=Fr.extend({deltaX:function(e){return"deltaX"in e?e.deltaX:"wheelDeltaX"in e?-e.wheelDeltaX:0},deltaY:function(e){return"deltaY"in e?e.deltaY:"wheelDeltaY"in e?-e.wheelDeltaY:"wheelDelta"in e?-e.wheelDelta:0},deltaZ:null,deltaMode:null}),io={eventTypes:Rt,extractEvents:function(e,t,n,r){var o=It.get(e);if(!o)return null;switch(e){case"keypress":if(0===Kr(n))return null;case"keydown":case"keyup":e=eo;break;case"blur":case"focus":e=qr;break;case"click":if(2===n.button)return null;case"auxclick":case"dblclick":case"mousedown":case"mousemove":case"mouseup":case"mouseout":case"mouseover":case"contextmenu":e=Fr;break;case"drag":case"dragend":case"dragenter":case"dragexit":case"dragleave":case"dragover":case"dragstart":case"drop":e=to;break;case"touchcancel":case"touchend":case"touchmove":case"touchstart":e=no;break;case Ye:case Qe:case Ge:e=Gr;break;case Xe:e=ro;break;case"scroll":e=Sr;break;case"wheel":e=oo;break;case"copy":case"cut":case"paste":e=Xr;break;case"gotpointercapture":case"lostpointercapture":case"pointercancel":case"pointerdown":case"pointermove":case"pointerout":case"pointerover":case"pointerup":e=Dr;break;default:e=Un}return Rn(t=e.getPooled(o,t,n,r)),t}};if(b)throw Error(a(101));b=Array.prototype.slice.call("ResponderEventPlugin SimpleEventPlugin EnterLeaveEventPlugin ChangeEventPlugin SelectEventPlugin BeforeInputEventPlugin".split(" ")),w(),h=An,m=_n,g=Pn,O({SimpleEventPlugin:io,EnterLeaveEventPlugin:Lr,ChangeEventPlugin:kr,SelectEventPlugin:Qr,BeforeInputEventPlugin:lr});var ao=[],lo=-1;function uo(e){0>lo||(e.current=ao[lo],ao[lo]=null,lo--)}function co(e,t){lo++,ao[lo]=e.current,e.current=t}var so={},fo={current:so},po={current:!1},ho=so;function mo(e,t){var n=e.type.contextTypes;if(!n)return so;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var o,i={};for(o in n)i[o]=t[o];return r&&((e=e.stateNode).__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=i),i}function go(e){return null!=(e=e.childContextTypes)}function vo(){uo(po),uo(fo)}function bo(e,t,n){if(fo.current!==so)throw Error(a(168));co(fo,t),co(po,n)}function yo(e,t,n){var r=e.stateNode;if(e=t.childContextTypes,"function"!=typeof r.getChildContext)return n;for(var i in r=r.getChildContext())if(!(i in e))throw Error(a(108,ge(t)||"Unknown",i));return o({},n,{},r)}function wo(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||so,ho=fo.current,co(fo,e),co(po,po.current),!0}function xo(e,t,n){var r=e.stateNode;if(!r)throw Error(a(169));n?(e=yo(e,t,ho),r.__reactInternalMemoizedMergedChildContext=e,uo(po),uo(fo),co(fo,e)):uo(po),co(po,n)}var Eo=i.unstable_runWithPriority,Co=i.unstable_scheduleCallback,ko=i.unstable_cancelCallback,So=i.unstable_requestPaint,Oo=i.unstable_now,To=i.unstable_getCurrentPriorityLevel,_o=i.unstable_ImmediatePriority,Po=i.unstable_UserBlockingPriority,Ao=i.unstable_NormalPriority,Mo=i.unstable_LowPriority,zo=i.unstable_IdlePriority,Fo={},Do=i.unstable_shouldYield,jo=void 0!==So?So:function(){},Lo=null,Ro=null,Io=!1,No=Oo(),Vo=1e4>No?Oo:function(){return Oo()-No};function Bo(){switch(To()){case _o:return 99;case Po:return 98;case Ao:return 97;case Mo:return 96;case zo:return 95;default:throw Error(a(332))}}function Ho(e){switch(e){case 99:return _o;case 98:return Po;case 97:return Ao;case 96:return Mo;case 95:return zo;default:throw Error(a(332))}}function Wo(e,t){return e=Ho(e),Eo(e,t)}function Uo(e,t,n){return e=Ho(e),Co(e,t,n)}function $o(e){return null===Lo?(Lo=[e],Ro=Co(_o,Qo)):Lo.push(e),Fo}function Yo(){if(null!==Ro){var e=Ro;Ro=null,ko(e)}Qo()}function Qo(){if(!Io&&null!==Lo){Io=!0;var e=0;try{var t=Lo;Wo(99,(function(){for(;e<t.length;e++){var n=t[e];do{n=n(!0)}while(null!==n)}})),Lo=null}catch(t){throw null!==Lo&&(Lo=Lo.slice(e+1)),Co(_o,Yo),t}finally{Io=!1}}}function Go(e,t,n){return 1073741821-(1+((1073741821-e+t/10)/(n/=10)|0))*n}function Xo(e,t){if(e&&e.defaultProps)for(var n in t=o({},t),e=e.defaultProps)void 0===t[n]&&(t[n]=e[n]);return t}var qo={current:null},Ko=null,Zo=null,Jo=null;function ei(){Jo=Zo=Ko=null}function ti(e){var t=qo.current;uo(qo),e.type._context._currentValue=t}function ni(e,t){for(;null!==e;){var n=e.alternate;if(e.childExpirationTime<t)e.childExpirationTime=t,null!==n&&n.childExpirationTime<t&&(n.childExpirationTime=t);else{if(!(null!==n&&n.childExpirationTime<t))break;n.childExpirationTime=t}e=e.return}}function ri(e,t){Ko=e,Jo=Zo=null,null!==(e=e.dependencies)&&null!==e.firstContext&&(e.expirationTime>=t&&(Aa=!0),e.firstContext=null)}function oi(e,t){if(Jo!==e&&!1!==t&&0!==t)if("number"==typeof t&&1073741823!==t||(Jo=e,t=1073741823),t={context:e,observedBits:t,next:null},null===Zo){if(null===Ko)throw Error(a(308));Zo=t,Ko.dependencies={expirationTime:0,firstContext:t,responders:null}}else Zo=Zo.next=t;return e._currentValue}var ii=!1;function ai(e){e.updateQueue={baseState:e.memoizedState,baseQueue:null,shared:{pending:null},effects:null}}function li(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,baseQueue:e.baseQueue,shared:e.shared,effects:e.effects})}function ui(e,t){return(e={expirationTime:e,suspenseConfig:t,tag:0,payload:null,callback:null,next:null}).next=e}function ci(e,t){if(null!==(e=e.updateQueue)){var n=(e=e.shared).pending;null===n?t.next=t:(t.next=n.next,n.next=t),e.pending=t}}function si(e,t){var n=e.alternate;null!==n&&li(n,e),null===(n=(e=e.updateQueue).baseQueue)?(e.baseQueue=t.next=t,t.next=t):(t.next=n.next,n.next=t)}function fi(e,t,n,r){var i=e.updateQueue;ii=!1;var a=i.baseQueue,l=i.shared.pending;if(null!==l){if(null!==a){var u=a.next;a.next=l.next,l.next=u}a=l,i.shared.pending=null,null!==(u=e.alternate)&&(null!==(u=u.updateQueue)&&(u.baseQueue=l))}if(null!==a){u=a.next;var c=i.baseState,s=0,f=null,p=null,d=null;if(null!==u)for(var h=u;;){if((l=h.expirationTime)<r){var m={expirationTime:h.expirationTime,suspenseConfig:h.suspenseConfig,tag:h.tag,payload:h.payload,callback:h.callback,next:null};null===d?(p=d=m,f=c):d=d.next=m,l>s&&(s=l)}else{null!==d&&(d=d.next={expirationTime:1073741823,suspenseConfig:h.suspenseConfig,tag:h.tag,payload:h.payload,callback:h.callback,next:null}),iu(l,h.suspenseConfig);e:{var g=e,v=h;switch(l=t,m=n,v.tag){case 1:if("function"==typeof(g=v.payload)){c=g.call(m,c,l);break e}c=g;break e;case 3:g.effectTag=-4097&g.effectTag|64;case 0:if(null==(l="function"==typeof(g=v.payload)?g.call(m,c,l):g))break e;c=o({},c,l);break e;case 2:ii=!0}}null!==h.callback&&(e.effectTag|=32,null===(l=i.effects)?i.effects=[h]:l.push(h))}if(null===(h=h.next)||h===u){if(null===(l=i.shared.pending))break;h=a.next=l.next,l.next=u,i.baseQueue=a=l,i.shared.pending=null}}null===d?f=c:d.next=p,i.baseState=f,i.baseQueue=d,au(s),e.expirationTime=s,e.memoizedState=c}}function pi(e,t,n){if(e=t.effects,t.effects=null,null!==e)for(t=0;t<e.length;t++){var r=e[t],o=r.callback;if(null!==o){if(r.callback=null,r=o,o=n,"function"!=typeof r)throw Error(a(191,r));r.call(o)}}}var di=q.ReactCurrentBatchConfig,hi=(new r.Component).refs;function mi(e,t,n,r){n=null==(n=n(r,t=e.memoizedState))?t:o({},t,n),e.memoizedState=n,0===e.expirationTime&&(e.updateQueue.baseState=n)}var gi={isMounted:function(e){return!!(e=e._reactInternalFiber)&&Je(e)===e},enqueueSetState:function(e,t,n){e=e._reactInternalFiber;var r=Yl(),o=di.suspense;(o=ui(r=Ql(r,e,o),o)).payload=t,null!=n&&(o.callback=n),ci(e,o),Gl(e,r)},enqueueReplaceState:function(e,t,n){e=e._reactInternalFiber;var r=Yl(),o=di.suspense;(o=ui(r=Ql(r,e,o),o)).tag=1,o.payload=t,null!=n&&(o.callback=n),ci(e,o),Gl(e,r)},enqueueForceUpdate:function(e,t){e=e._reactInternalFiber;var n=Yl(),r=di.suspense;(r=ui(n=Ql(n,e,r),r)).tag=2,null!=t&&(r.callback=t),ci(e,r),Gl(e,n)}};function vi(e,t,n,r,o,i,a){return"function"==typeof(e=e.stateNode).shouldComponentUpdate?e.shouldComponentUpdate(r,i,a):!t.prototype||!t.prototype.isPureReactComponent||(!Nr(n,r)||!Nr(o,i))}function bi(e,t,n){var r=!1,o=so,i=t.contextType;return"object"==typeof i&&null!==i?i=oi(i):(o=go(t)?ho:fo.current,i=(r=null!=(r=t.contextTypes))?mo(e,o):so),t=new t(n,i),e.memoizedState=null!==t.state&&void 0!==t.state?t.state:null,t.updater=gi,e.stateNode=t,t._reactInternalFiber=e,r&&((e=e.stateNode).__reactInternalMemoizedUnmaskedChildContext=o,e.__reactInternalMemoizedMaskedChildContext=i),t}function yi(e,t,n,r){e=t.state,"function"==typeof t.componentWillReceiveProps&&t.componentWillReceiveProps(n,r),"function"==typeof t.UNSAFE_componentWillReceiveProps&&t.UNSAFE_componentWillReceiveProps(n,r),t.state!==e&&gi.enqueueReplaceState(t,t.state,null)}function wi(e,t,n,r){var o=e.stateNode;o.props=n,o.state=e.memoizedState,o.refs=hi,ai(e);var i=t.contextType;"object"==typeof i&&null!==i?o.context=oi(i):(i=go(t)?ho:fo.current,o.context=mo(e,i)),fi(e,n,o,r),o.state=e.memoizedState,"function"==typeof(i=t.getDerivedStateFromProps)&&(mi(e,t,i,n),o.state=e.memoizedState),"function"==typeof t.getDerivedStateFromProps||"function"==typeof o.getSnapshotBeforeUpdate||"function"!=typeof o.UNSAFE_componentWillMount&&"function"!=typeof o.componentWillMount||(t=o.state,"function"==typeof o.componentWillMount&&o.componentWillMount(),"function"==typeof o.UNSAFE_componentWillMount&&o.UNSAFE_componentWillMount(),t!==o.state&&gi.enqueueReplaceState(o,o.state,null),fi(e,n,o,r),o.state=e.memoizedState),"function"==typeof o.componentDidMount&&(e.effectTag|=4)}var xi=Array.isArray;function Ei(e,t,n){if(null!==(e=n.ref)&&"function"!=typeof e&&"object"!=typeof e){if(n._owner){if(n=n._owner){if(1!==n.tag)throw Error(a(309));var r=n.stateNode}if(!r)throw Error(a(147,e));var o=""+e;return null!==t&&null!==t.ref&&"function"==typeof t.ref&&t.ref._stringRef===o?t.ref:((t=function(e){var t=r.refs;t===hi&&(t=r.refs={}),null===e?delete t[o]:t[o]=e})._stringRef=o,t)}if("string"!=typeof e)throw Error(a(284));if(!n._owner)throw Error(a(290,e))}return e}function Ci(e,t){if("textarea"!==e.type)throw Error(a(31,"[object Object]"===Object.prototype.toString.call(t)?"object with keys {"+Object.keys(t).join(", ")+"}":t,""))}function ki(e){function t(t,n){if(e){var r=t.lastEffect;null!==r?(r.nextEffect=n,t.lastEffect=n):t.firstEffect=t.lastEffect=n,n.nextEffect=null,n.effectTag=8}}function n(n,r){if(!e)return null;for(;null!==r;)t(n,r),r=r.sibling;return null}function r(e,t){for(e=new Map;null!==t;)null!==t.key?e.set(t.key,t):e.set(t.index,t),t=t.sibling;return e}function o(e,t){return(e=Ou(e,t)).index=0,e.sibling=null,e}function i(t,n,r){return t.index=r,e?null!==(r=t.alternate)?(r=r.index)<n?(t.effectTag=2,n):r:(t.effectTag=2,n):n}function l(t){return e&&null===t.alternate&&(t.effectTag=2),t}function u(e,t,n,r){return null===t||6!==t.tag?((t=Pu(n,e.mode,r)).return=e,t):((t=o(t,n)).return=e,t)}function c(e,t,n,r){return null!==t&&t.elementType===n.type?((r=o(t,n.props)).ref=Ei(e,t,n),r.return=e,r):((r=Tu(n.type,n.key,n.props,null,e.mode,r)).ref=Ei(e,t,n),r.return=e,r)}function s(e,t,n,r){return null===t||4!==t.tag||t.stateNode.containerInfo!==n.containerInfo||t.stateNode.implementation!==n.implementation?((t=Au(n,e.mode,r)).return=e,t):((t=o(t,n.children||[])).return=e,t)}function f(e,t,n,r,i){return null===t||7!==t.tag?((t=_u(n,e.mode,r,i)).return=e,t):((t=o(t,n)).return=e,t)}function p(e,t,n){if("string"==typeof t||"number"==typeof t)return(t=Pu(""+t,e.mode,n)).return=e,t;if("object"==typeof t&&null!==t){switch(t.$$typeof){case ee:return(n=Tu(t.type,t.key,t.props,null,e.mode,n)).ref=Ei(e,null,t),n.return=e,n;case te:return(t=Au(t,e.mode,n)).return=e,t}if(xi(t)||me(t))return(t=_u(t,e.mode,n,null)).return=e,t;Ci(e,t)}return null}function d(e,t,n,r){var o=null!==t?t.key:null;if("string"==typeof n||"number"==typeof n)return null!==o?null:u(e,t,""+n,r);if("object"==typeof n&&null!==n){switch(n.$$typeof){case ee:return n.key===o?n.type===ne?f(e,t,n.props.children,r,o):c(e,t,n,r):null;case te:return n.key===o?s(e,t,n,r):null}if(xi(n)||me(n))return null!==o?null:f(e,t,n,r,null);Ci(e,n)}return null}function h(e,t,n,r,o){if("string"==typeof r||"number"==typeof r)return u(t,e=e.get(n)||null,""+r,o);if("object"==typeof r&&null!==r){switch(r.$$typeof){case ee:return e=e.get(null===r.key?n:r.key)||null,r.type===ne?f(t,e,r.props.children,o,r.key):c(t,e,r,o);case te:return s(t,e=e.get(null===r.key?n:r.key)||null,r,o)}if(xi(r)||me(r))return f(t,e=e.get(n)||null,r,o,null);Ci(t,r)}return null}function m(o,a,l,u){for(var c=null,s=null,f=a,m=a=0,g=null;null!==f&&m<l.length;m++){f.index>m?(g=f,f=null):g=f.sibling;var v=d(o,f,l[m],u);if(null===v){null===f&&(f=g);break}e&&f&&null===v.alternate&&t(o,f),a=i(v,a,m),null===s?c=v:s.sibling=v,s=v,f=g}if(m===l.length)return n(o,f),c;if(null===f){for(;m<l.length;m++)null!==(f=p(o,l[m],u))&&(a=i(f,a,m),null===s?c=f:s.sibling=f,s=f);return c}for(f=r(o,f);m<l.length;m++)null!==(g=h(f,o,m,l[m],u))&&(e&&null!==g.alternate&&f.delete(null===g.key?m:g.key),a=i(g,a,m),null===s?c=g:s.sibling=g,s=g);return e&&f.forEach((function(e){return t(o,e)})),c}function g(o,l,u,c){var s=me(u);if("function"!=typeof s)throw Error(a(150));if(null==(u=s.call(u)))throw Error(a(151));for(var f=s=null,m=l,g=l=0,v=null,b=u.next();null!==m&&!b.done;g++,b=u.next()){m.index>g?(v=m,m=null):v=m.sibling;var y=d(o,m,b.value,c);if(null===y){null===m&&(m=v);break}e&&m&&null===y.alternate&&t(o,m),l=i(y,l,g),null===f?s=y:f.sibling=y,f=y,m=v}if(b.done)return n(o,m),s;if(null===m){for(;!b.done;g++,b=u.next())null!==(b=p(o,b.value,c))&&(l=i(b,l,g),null===f?s=b:f.sibling=b,f=b);return s}for(m=r(o,m);!b.done;g++,b=u.next())null!==(b=h(m,o,g,b.value,c))&&(e&&null!==b.alternate&&m.delete(null===b.key?g:b.key),l=i(b,l,g),null===f?s=b:f.sibling=b,f=b);return e&&m.forEach((function(e){return t(o,e)})),s}return function(e,r,i,u){var c="object"==typeof i&&null!==i&&i.type===ne&&null===i.key;c&&(i=i.props.children);var s="object"==typeof i&&null!==i;if(s)switch(i.$$typeof){case ee:e:{for(s=i.key,c=r;null!==c;){if(c.key===s){switch(c.tag){case 7:if(i.type===ne){n(e,c.sibling),(r=o(c,i.props.children)).return=e,e=r;break e}break;default:if(c.elementType===i.type){n(e,c.sibling),(r=o(c,i.props)).ref=Ei(e,c,i),r.return=e,e=r;break e}}n(e,c);break}t(e,c),c=c.sibling}i.type===ne?((r=_u(i.props.children,e.mode,u,i.key)).return=e,e=r):((u=Tu(i.type,i.key,i.props,null,e.mode,u)).ref=Ei(e,r,i),u.return=e,e=u)}return l(e);case te:e:{for(c=i.key;null!==r;){if(r.key===c){if(4===r.tag&&r.stateNode.containerInfo===i.containerInfo&&r.stateNode.implementation===i.implementation){n(e,r.sibling),(r=o(r,i.children||[])).return=e,e=r;break e}n(e,r);break}t(e,r),r=r.sibling}(r=Au(i,e.mode,u)).return=e,e=r}return l(e)}if("string"==typeof i||"number"==typeof i)return i=""+i,null!==r&&6===r.tag?(n(e,r.sibling),(r=o(r,i)).return=e,e=r):(n(e,r),(r=Pu(i,e.mode,u)).return=e,e=r),l(e);if(xi(i))return m(e,r,i,u);if(me(i))return g(e,r,i,u);if(s&&Ci(e,i),void 0===i&&!c)switch(e.tag){case 1:case 0:throw e=e.type,Error(a(152,e.displayName||e.name||"Component"))}return n(e,r)}}var Si=ki(!0),Oi=ki(!1),Ti={},_i={current:Ti},Pi={current:Ti},Ai={current:Ti};function Mi(e){if(e===Ti)throw Error(a(174));return e}function zi(e,t){switch(co(Ai,t),co(Pi,e),co(_i,Ti),e=t.nodeType){case 9:case 11:t=(t=t.documentElement)?t.namespaceURI:Re(null,"");break;default:t=Re(t=(e=8===e?t.parentNode:t).namespaceURI||null,e=e.tagName)}uo(_i),co(_i,t)}function Fi(){uo(_i),uo(Pi),uo(Ai)}function Di(e){Mi(Ai.current);var t=Mi(_i.current),n=Re(t,e.type);t!==n&&(co(Pi,e),co(_i,n))}function ji(e){Pi.current===e&&(uo(_i),uo(Pi))}var Li={current:0};function Ri(e){for(var t=e;null!==t;){if(13===t.tag){var n=t.memoizedState;if(null!==n&&(null===(n=n.dehydrated)||"$?"===n.data||"$!"===n.data))return t}else if(19===t.tag&&void 0!==t.memoizedProps.revealOrder){if(0!=(64&t.effectTag))return t}else if(null!==t.child){t.child.return=t,t=t.child;continue}if(t===e)break;for(;null===t.sibling;){if(null===t.return||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}function Ii(e,t){return{responder:e,props:t}}var Ni=q.ReactCurrentDispatcher,Vi=q.ReactCurrentBatchConfig,Bi=0,Hi=null,Wi=null,Ui=null,$i=!1;function Yi(){throw Error(a(321))}function Qi(e,t){if(null===t)return!1;for(var n=0;n<t.length&&n<e.length;n++)if(!Rr(e[n],t[n]))return!1;return!0}function Gi(e,t,n,r,o,i){if(Bi=i,Hi=t,t.memoizedState=null,t.updateQueue=null,t.expirationTime=0,Ni.current=null===e||null===e.memoizedState?va:ba,e=n(r,o),t.expirationTime===Bi){i=0;do{if(t.expirationTime=0,!(25>i))throw Error(a(301));i+=1,Ui=Wi=null,t.updateQueue=null,Ni.current=ya,e=n(r,o)}while(t.expirationTime===Bi)}if(Ni.current=ga,t=null!==Wi&&null!==Wi.next,Bi=0,Ui=Wi=Hi=null,$i=!1,t)throw Error(a(300));return e}function Xi(){var e={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};return null===Ui?Hi.memoizedState=Ui=e:Ui=Ui.next=e,Ui}function qi(){if(null===Wi){var e=Hi.alternate;e=null!==e?e.memoizedState:null}else e=Wi.next;var t=null===Ui?Hi.memoizedState:Ui.next;if(null!==t)Ui=t,Wi=e;else{if(null===e)throw Error(a(310));e={memoizedState:(Wi=e).memoizedState,baseState:Wi.baseState,baseQueue:Wi.baseQueue,queue:Wi.queue,next:null},null===Ui?Hi.memoizedState=Ui=e:Ui=Ui.next=e}return Ui}function Ki(e,t){return"function"==typeof t?t(e):t}function Zi(e){var t=qi(),n=t.queue;if(null===n)throw Error(a(311));n.lastRenderedReducer=e;var r=Wi,o=r.baseQueue,i=n.pending;if(null!==i){if(null!==o){var l=o.next;o.next=i.next,i.next=l}r.baseQueue=o=i,n.pending=null}if(null!==o){o=o.next,r=r.baseState;var u=l=i=null,c=o;do{var s=c.expirationTime;if(s<Bi){var f={expirationTime:c.expirationTime,suspenseConfig:c.suspenseConfig,action:c.action,eagerReducer:c.eagerReducer,eagerState:c.eagerState,next:null};null===u?(l=u=f,i=r):u=u.next=f,s>Hi.expirationTime&&(Hi.expirationTime=s,au(s))}else null!==u&&(u=u.next={expirationTime:1073741823,suspenseConfig:c.suspenseConfig,action:c.action,eagerReducer:c.eagerReducer,eagerState:c.eagerState,next:null}),iu(s,c.suspenseConfig),r=c.eagerReducer===e?c.eagerState:e(r,c.action);c=c.next}while(null!==c&&c!==o);null===u?i=r:u.next=l,Rr(r,t.memoizedState)||(Aa=!0),t.memoizedState=r,t.baseState=i,t.baseQueue=u,n.lastRenderedState=r}return[t.memoizedState,n.dispatch]}function Ji(e){var t=qi(),n=t.queue;if(null===n)throw Error(a(311));n.lastRenderedReducer=e;var r=n.dispatch,o=n.pending,i=t.memoizedState;if(null!==o){n.pending=null;var l=o=o.next;do{i=e(i,l.action),l=l.next}while(l!==o);Rr(i,t.memoizedState)||(Aa=!0),t.memoizedState=i,null===t.baseQueue&&(t.baseState=i),n.lastRenderedState=i}return[i,r]}function ea(e){var t=Xi();return"function"==typeof e&&(e=e()),t.memoizedState=t.baseState=e,e=(e=t.queue={pending:null,dispatch:null,lastRenderedReducer:Ki,lastRenderedState:e}).dispatch=ma.bind(null,Hi,e),[t.memoizedState,e]}function ta(e,t,n,r){return e={tag:e,create:t,destroy:n,deps:r,next:null},null===(t=Hi.updateQueue)?(t={lastEffect:null},Hi.updateQueue=t,t.lastEffect=e.next=e):null===(n=t.lastEffect)?t.lastEffect=e.next=e:(r=n.next,n.next=e,e.next=r,t.lastEffect=e),e}function na(){return qi().memoizedState}function ra(e,t,n,r){var o=Xi();Hi.effectTag|=e,o.memoizedState=ta(1|t,n,void 0,void 0===r?null:r)}function oa(e,t,n,r){var o=qi();r=void 0===r?null:r;var i=void 0;if(null!==Wi){var a=Wi.memoizedState;if(i=a.destroy,null!==r&&Qi(r,a.deps))return void ta(t,n,i,r)}Hi.effectTag|=e,o.memoizedState=ta(1|t,n,i,r)}function ia(e,t){return ra(516,4,e,t)}function aa(e,t){return oa(516,4,e,t)}function la(e,t){return oa(4,2,e,t)}function ua(e,t){return"function"==typeof t?(e=e(),t(e),function(){t(null)}):null!=t?(e=e(),t.current=e,function(){t.current=null}):void 0}function ca(e,t,n){return n=null!=n?n.concat([e]):null,oa(4,2,ua.bind(null,t,e),n)}function sa(){}function fa(e,t){return Xi().memoizedState=[e,void 0===t?null:t],e}function pa(e,t){var n=qi();t=void 0===t?null:t;var r=n.memoizedState;return null!==r&&null!==t&&Qi(t,r[1])?r[0]:(n.memoizedState=[e,t],e)}function da(e,t){var n=qi();t=void 0===t?null:t;var r=n.memoizedState;return null!==r&&null!==t&&Qi(t,r[1])?r[0]:(e=e(),n.memoizedState=[e,t],e)}function ha(e,t,n){var r=Bo();Wo(98>r?98:r,(function(){e(!0)})),Wo(97<r?97:r,(function(){var r=Vi.suspense;Vi.suspense=void 0===t?null:t;try{e(!1),n()}finally{Vi.suspense=r}}))}function ma(e,t,n){var r=Yl(),o=di.suspense;o={expirationTime:r=Ql(r,e,o),suspenseConfig:o,action:n,eagerReducer:null,eagerState:null,next:null};var i=t.pending;if(null===i?o.next=o:(o.next=i.next,i.next=o),t.pending=o,i=e.alternate,e===Hi||null!==i&&i===Hi)$i=!0,o.expirationTime=Bi,Hi.expirationTime=Bi;else{if(0===e.expirationTime&&(null===i||0===i.expirationTime)&&null!==(i=t.lastRenderedReducer))try{var a=t.lastRenderedState,l=i(a,n);if(o.eagerReducer=i,o.eagerState=l,Rr(l,a))return}catch(e){}Gl(e,r)}}var ga={readContext:oi,useCallback:Yi,useContext:Yi,useEffect:Yi,useImperativeHandle:Yi,useLayoutEffect:Yi,useMemo:Yi,useReducer:Yi,useRef:Yi,useState:Yi,useDebugValue:Yi,useResponder:Yi,useDeferredValue:Yi,useTransition:Yi},va={readContext:oi,useCallback:fa,useContext:oi,useEffect:ia,useImperativeHandle:function(e,t,n){return n=null!=n?n.concat([e]):null,ra(4,2,ua.bind(null,t,e),n)},useLayoutEffect:function(e,t){return ra(4,2,e,t)},useMemo:function(e,t){var n=Xi();return t=void 0===t?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Xi();return t=void 0!==n?n(t):t,r.memoizedState=r.baseState=t,e=(e=r.queue={pending:null,dispatch:null,lastRenderedReducer:e,lastRenderedState:t}).dispatch=ma.bind(null,Hi,e),[r.memoizedState,e]},useRef:function(e){return e={current:e},Xi().memoizedState=e},useState:ea,useDebugValue:sa,useResponder:Ii,useDeferredValue:function(e,t){var n=ea(e),r=n[0],o=n[1];return ia((function(){var n=Vi.suspense;Vi.suspense=void 0===t?null:t;try{o(e)}finally{Vi.suspense=n}}),[e,t]),r},useTransition:function(e){var t=ea(!1),n=t[0];return t=t[1],[fa(ha.bind(null,t,e),[t,e]),n]}},ba={readContext:oi,useCallback:pa,useContext:oi,useEffect:aa,useImperativeHandle:ca,useLayoutEffect:la,useMemo:da,useReducer:Zi,useRef:na,useState:function(){return Zi(Ki)},useDebugValue:sa,useResponder:Ii,useDeferredValue:function(e,t){var n=Zi(Ki),r=n[0],o=n[1];return aa((function(){var n=Vi.suspense;Vi.suspense=void 0===t?null:t;try{o(e)}finally{Vi.suspense=n}}),[e,t]),r},useTransition:function(e){var t=Zi(Ki),n=t[0];return t=t[1],[pa(ha.bind(null,t,e),[t,e]),n]}},ya={readContext:oi,useCallback:pa,useContext:oi,useEffect:aa,useImperativeHandle:ca,useLayoutEffect:la,useMemo:da,useReducer:Ji,useRef:na,useState:function(){return Ji(Ki)},useDebugValue:sa,useResponder:Ii,useDeferredValue:function(e,t){var n=Ji(Ki),r=n[0],o=n[1];return aa((function(){var n=Vi.suspense;Vi.suspense=void 0===t?null:t;try{o(e)}finally{Vi.suspense=n}}),[e,t]),r},useTransition:function(e){var t=Ji(Ki),n=t[0];return t=t[1],[pa(ha.bind(null,t,e),[t,e]),n]}},wa=null,xa=null,Ea=!1;function Ca(e,t){var n=ku(5,null,null,0);n.elementType="DELETED",n.type="DELETED",n.stateNode=t,n.return=e,n.effectTag=8,null!==e.lastEffect?(e.lastEffect.nextEffect=n,e.lastEffect=n):e.firstEffect=e.lastEffect=n}function ka(e,t){switch(e.tag){case 5:var n=e.type;return null!==(t=1!==t.nodeType||n.toLowerCase()!==t.nodeName.toLowerCase()?null:t)&&(e.stateNode=t,!0);case 6:return null!==(t=""===e.pendingProps||3!==t.nodeType?null:t)&&(e.stateNode=t,!0);case 13:default:return!1}}function Sa(e){if(Ea){var t=xa;if(t){var n=t;if(!ka(e,t)){if(!(t=xn(n.nextSibling))||!ka(e,t))return e.effectTag=-1025&e.effectTag|2,Ea=!1,void(wa=e);Ca(wa,n)}wa=e,xa=xn(t.firstChild)}else e.effectTag=-1025&e.effectTag|2,Ea=!1,wa=e}}function Oa(e){for(e=e.return;null!==e&&5!==e.tag&&3!==e.tag&&13!==e.tag;)e=e.return;wa=e}function Ta(e){if(e!==wa)return!1;if(!Ea)return Oa(e),Ea=!0,!1;var t=e.type;if(5!==e.tag||"head"!==t&&"body"!==t&&!bn(t,e.memoizedProps))for(t=xa;t;)Ca(e,t),t=xn(t.nextSibling);if(Oa(e),13===e.tag){if(!(e=null!==(e=e.memoizedState)?e.dehydrated:null))throw Error(a(317));e:{for(e=e.nextSibling,t=0;e;){if(8===e.nodeType){var n=e.data;if("/$"===n){if(0===t){xa=xn(e.nextSibling);break e}t--}else"$"!==n&&"$!"!==n&&"$?"!==n||t++}e=e.nextSibling}xa=null}}else xa=wa?xn(e.stateNode.nextSibling):null;return!0}function _a(){xa=wa=null,Ea=!1}var Pa=q.ReactCurrentOwner,Aa=!1;function Ma(e,t,n,r){t.child=null===e?Oi(t,null,n,r):Si(t,e.child,n,r)}function za(e,t,n,r,o){n=n.render;var i=t.ref;return ri(t,o),r=Gi(e,t,n,r,i,o),null===e||Aa?(t.effectTag|=1,Ma(e,t,r,o),t.child):(t.updateQueue=e.updateQueue,t.effectTag&=-517,e.expirationTime<=o&&(e.expirationTime=0),Ga(e,t,o))}function Fa(e,t,n,r,o,i){if(null===e){var a=n.type;return"function"!=typeof a||Su(a)||void 0!==a.defaultProps||null!==n.compare||void 0!==n.defaultProps?((e=Tu(n.type,null,r,null,t.mode,i)).ref=t.ref,e.return=t,t.child=e):(t.tag=15,t.type=a,Da(e,t,a,r,o,i))}return a=e.child,o<i&&(o=a.memoizedProps,(n=null!==(n=n.compare)?n:Nr)(o,r)&&e.ref===t.ref)?Ga(e,t,i):(t.effectTag|=1,(e=Ou(a,r)).ref=t.ref,e.return=t,t.child=e)}function Da(e,t,n,r,o,i){return null!==e&&Nr(e.memoizedProps,r)&&e.ref===t.ref&&(Aa=!1,o<i)?(t.expirationTime=e.expirationTime,Ga(e,t,i)):La(e,t,n,r,i)}function ja(e,t){var n=t.ref;(null===e&&null!==n||null!==e&&e.ref!==n)&&(t.effectTag|=128)}function La(e,t,n,r,o){var i=go(n)?ho:fo.current;return i=mo(t,i),ri(t,o),n=Gi(e,t,n,r,i,o),null===e||Aa?(t.effectTag|=1,Ma(e,t,n,o),t.child):(t.updateQueue=e.updateQueue,t.effectTag&=-517,e.expirationTime<=o&&(e.expirationTime=0),Ga(e,t,o))}function Ra(e,t,n,r,o){if(go(n)){var i=!0;wo(t)}else i=!1;if(ri(t,o),null===t.stateNode)null!==e&&(e.alternate=null,t.alternate=null,t.effectTag|=2),bi(t,n,r),wi(t,n,r,o),r=!0;else if(null===e){var a=t.stateNode,l=t.memoizedProps;a.props=l;var u=a.context,c=n.contextType;"object"==typeof c&&null!==c?c=oi(c):c=mo(t,c=go(n)?ho:fo.current);var s=n.getDerivedStateFromProps,f="function"==typeof s||"function"==typeof a.getSnapshotBeforeUpdate;f||"function"!=typeof a.UNSAFE_componentWillReceiveProps&&"function"!=typeof a.componentWillReceiveProps||(l!==r||u!==c)&&yi(t,a,r,c),ii=!1;var p=t.memoizedState;a.state=p,fi(t,r,a,o),u=t.memoizedState,l!==r||p!==u||po.current||ii?("function"==typeof s&&(mi(t,n,s,r),u=t.memoizedState),(l=ii||vi(t,n,l,r,p,u,c))?(f||"function"!=typeof a.UNSAFE_componentWillMount&&"function"!=typeof a.componentWillMount||("function"==typeof a.componentWillMount&&a.componentWillMount(),"function"==typeof a.UNSAFE_componentWillMount&&a.UNSAFE_componentWillMount()),"function"==typeof a.componentDidMount&&(t.effectTag|=4)):("function"==typeof a.componentDidMount&&(t.effectTag|=4),t.memoizedProps=r,t.memoizedState=u),a.props=r,a.state=u,a.context=c,r=l):("function"==typeof a.componentDidMount&&(t.effectTag|=4),r=!1)}else a=t.stateNode,li(e,t),l=t.memoizedProps,a.props=t.type===t.elementType?l:Xo(t.type,l),u=a.context,"object"==typeof(c=n.contextType)&&null!==c?c=oi(c):c=mo(t,c=go(n)?ho:fo.current),(f="function"==typeof(s=n.getDerivedStateFromProps)||"function"==typeof a.getSnapshotBeforeUpdate)||"function"!=typeof a.UNSAFE_componentWillReceiveProps&&"function"!=typeof a.componentWillReceiveProps||(l!==r||u!==c)&&yi(t,a,r,c),ii=!1,u=t.memoizedState,a.state=u,fi(t,r,a,o),p=t.memoizedState,l!==r||u!==p||po.current||ii?("function"==typeof s&&(mi(t,n,s,r),p=t.memoizedState),(s=ii||vi(t,n,l,r,u,p,c))?(f||"function"!=typeof a.UNSAFE_componentWillUpdate&&"function"!=typeof a.componentWillUpdate||("function"==typeof a.componentWillUpdate&&a.componentWillUpdate(r,p,c),"function"==typeof a.UNSAFE_componentWillUpdate&&a.UNSAFE_componentWillUpdate(r,p,c)),"function"==typeof a.componentDidUpdate&&(t.effectTag|=4),"function"==typeof a.getSnapshotBeforeUpdate&&(t.effectTag|=256)):("function"!=typeof a.componentDidUpdate||l===e.memoizedProps&&u===e.memoizedState||(t.effectTag|=4),"function"!=typeof a.getSnapshotBeforeUpdate||l===e.memoizedProps&&u===e.memoizedState||(t.effectTag|=256),t.memoizedProps=r,t.memoizedState=p),a.props=r,a.state=p,a.context=c,r=s):("function"!=typeof a.componentDidUpdate||l===e.memoizedProps&&u===e.memoizedState||(t.effectTag|=4),"function"!=typeof a.getSnapshotBeforeUpdate||l===e.memoizedProps&&u===e.memoizedState||(t.effectTag|=256),r=!1);return Ia(e,t,n,r,i,o)}function Ia(e,t,n,r,o,i){ja(e,t);var a=0!=(64&t.effectTag);if(!r&&!a)return o&&xo(t,n,!1),Ga(e,t,i);r=t.stateNode,Pa.current=t;var l=a&&"function"!=typeof n.getDerivedStateFromError?null:r.render();return t.effectTag|=1,null!==e&&a?(t.child=Si(t,e.child,null,i),t.child=Si(t,null,l,i)):Ma(e,t,l,i),t.memoizedState=r.state,o&&xo(t,n,!0),t.child}function Na(e){var t=e.stateNode;t.pendingContext?bo(0,t.pendingContext,t.pendingContext!==t.context):t.context&&bo(0,t.context,!1),zi(e,t.containerInfo)}var Va,Ba,Ha,Wa={dehydrated:null,retryTime:0};function Ua(e,t,n){var r,o=t.mode,i=t.pendingProps,a=Li.current,l=!1;if((r=0!=(64&t.effectTag))||(r=0!=(2&a)&&(null===e||null!==e.memoizedState)),r?(l=!0,t.effectTag&=-65):null!==e&&null===e.memoizedState||void 0===i.fallback||!0===i.unstable_avoidThisFallback||(a|=1),co(Li,1&a),null===e){if(void 0!==i.fallback&&Sa(t),l){if(l=i.fallback,(i=_u(null,o,0,null)).return=t,0==(2&t.mode))for(e=null!==t.memoizedState?t.child.child:t.child,i.child=e;null!==e;)e.return=i,e=e.sibling;return(n=_u(l,o,n,null)).return=t,i.sibling=n,t.memoizedState=Wa,t.child=i,n}return o=i.children,t.memoizedState=null,t.child=Oi(t,null,o,n)}if(null!==e.memoizedState){if(o=(e=e.child).sibling,l){if(i=i.fallback,(n=Ou(e,e.pendingProps)).return=t,0==(2&t.mode)&&(l=null!==t.memoizedState?t.child.child:t.child)!==e.child)for(n.child=l;null!==l;)l.return=n,l=l.sibling;return(o=Ou(o,i)).return=t,n.sibling=o,n.childExpirationTime=0,t.memoizedState=Wa,t.child=n,o}return n=Si(t,e.child,i.children,n),t.memoizedState=null,t.child=n}if(e=e.child,l){if(l=i.fallback,(i=_u(null,o,0,null)).return=t,i.child=e,null!==e&&(e.return=i),0==(2&t.mode))for(e=null!==t.memoizedState?t.child.child:t.child,i.child=e;null!==e;)e.return=i,e=e.sibling;return(n=_u(l,o,n,null)).return=t,i.sibling=n,n.effectTag|=2,i.childExpirationTime=0,t.memoizedState=Wa,t.child=i,n}return t.memoizedState=null,t.child=Si(t,e,i.children,n)}function $a(e,t){e.expirationTime<t&&(e.expirationTime=t);var n=e.alternate;null!==n&&n.expirationTime<t&&(n.expirationTime=t),ni(e.return,t)}function Ya(e,t,n,r,o,i){var a=e.memoizedState;null===a?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:r,tail:n,tailExpiration:0,tailMode:o,lastEffect:i}:(a.isBackwards=t,a.rendering=null,a.renderingStartTime=0,a.last=r,a.tail=n,a.tailExpiration=0,a.tailMode=o,a.lastEffect=i)}function Qa(e,t,n){var r=t.pendingProps,o=r.revealOrder,i=r.tail;if(Ma(e,t,r.children,n),0!=(2&(r=Li.current)))r=1&r|2,t.effectTag|=64;else{if(null!==e&&0!=(64&e.effectTag))e:for(e=t.child;null!==e;){if(13===e.tag)null!==e.memoizedState&&$a(e,n);else if(19===e.tag)$a(e,n);else if(null!==e.child){e.child.return=e,e=e.child;continue}if(e===t)break e;for(;null===e.sibling;){if(null===e.return||e.return===t)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}r&=1}if(co(Li,r),0==(2&t.mode))t.memoizedState=null;else switch(o){case"forwards":for(n=t.child,o=null;null!==n;)null!==(e=n.alternate)&&null===Ri(e)&&(o=n),n=n.sibling;null===(n=o)?(o=t.child,t.child=null):(o=n.sibling,n.sibling=null),Ya(t,!1,o,n,i,t.lastEffect);break;case"backwards":for(n=null,o=t.child,t.child=null;null!==o;){if(null!==(e=o.alternate)&&null===Ri(e)){t.child=o;break}e=o.sibling,o.sibling=n,n=o,o=e}Ya(t,!0,n,null,i,t.lastEffect);break;case"together":Ya(t,!1,null,null,void 0,t.lastEffect);break;default:t.memoizedState=null}return t.child}function Ga(e,t,n){null!==e&&(t.dependencies=e.dependencies);var r=t.expirationTime;if(0!==r&&au(r),t.childExpirationTime<n)return null;if(null!==e&&t.child!==e.child)throw Error(a(153));if(null!==t.child){for(n=Ou(e=t.child,e.pendingProps),t.child=n,n.return=t;null!==e.sibling;)e=e.sibling,(n=n.sibling=Ou(e,e.pendingProps)).return=t;n.sibling=null}return t.child}function Xa(e,t){switch(e.tailMode){case"hidden":t=e.tail;for(var n=null;null!==t;)null!==t.alternate&&(n=t),t=t.sibling;null===n?e.tail=null:n.sibling=null;break;case"collapsed":n=e.tail;for(var r=null;null!==n;)null!==n.alternate&&(r=n),n=n.sibling;null===r?t||null===e.tail?e.tail=null:e.tail.sibling=null:r.sibling=null}}function qa(e,t,n){var r=t.pendingProps;switch(t.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return null;case 1:return go(t.type)&&vo(),null;case 3:return Fi(),uo(po),uo(fo),(n=t.stateNode).pendingContext&&(n.context=n.pendingContext,n.pendingContext=null),null!==e&&null!==e.child||!Ta(t)||(t.effectTag|=4),null;case 5:ji(t),n=Mi(Ai.current);var i=t.type;if(null!==e&&null!=t.stateNode)Ba(e,t,i,r,n),e.ref!==t.ref&&(t.effectTag|=128);else{if(!r){if(null===t.stateNode)throw Error(a(166));return null}if(e=Mi(_i.current),Ta(t)){r=t.stateNode,i=t.type;var l=t.memoizedProps;switch(r[kn]=t,r[Sn]=l,i){case"iframe":case"object":case"embed":Qt("load",r);break;case"video":case"audio":for(e=0;e<qe.length;e++)Qt(qe[e],r);break;case"source":Qt("error",r);break;case"img":case"image":case"link":Qt("error",r),Qt("load",r);break;case"form":Qt("reset",r),Qt("submit",r);break;case"details":Qt("toggle",r);break;case"input":Ce(r,l),Qt("invalid",r),un(n,"onChange");break;case"select":r._wrapperState={wasMultiple:!!l.multiple},Qt("invalid",r),un(n,"onChange");break;case"textarea":Me(r,l),Qt("invalid",r),un(n,"onChange")}for(var u in on(i,l),e=null,l)if(l.hasOwnProperty(u)){var c=l[u];"children"===u?"string"==typeof c?r.textContent!==c&&(e=["children",c]):"number"==typeof c&&r.textContent!==""+c&&(e=["children",""+c]):k.hasOwnProperty(u)&&null!=c&&un(n,u)}switch(i){case"input":we(r),Oe(r,l,!0);break;case"textarea":we(r),Fe(r);break;case"select":case"option":break;default:"function"==typeof l.onClick&&(r.onclick=cn)}n=e,t.updateQueue=n,null!==n&&(t.effectTag|=4)}else{switch(u=9===n.nodeType?n:n.ownerDocument,e===ln&&(e=Le(i)),e===ln?"script"===i?((e=u.createElement("div")).innerHTML="<script><\/script>",e=e.removeChild(e.firstChild)):"string"==typeof r.is?e=u.createElement(i,{is:r.is}):(e=u.createElement(i),"select"===i&&(u=e,r.multiple?u.multiple=!0:r.size&&(u.size=r.size))):e=u.createElementNS(e,i),e[kn]=t,e[Sn]=r,Va(e,t),t.stateNode=e,u=an(i,r),i){case"iframe":case"object":case"embed":Qt("load",e),c=r;break;case"video":case"audio":for(c=0;c<qe.length;c++)Qt(qe[c],e);c=r;break;case"source":Qt("error",e),c=r;break;case"img":case"image":case"link":Qt("error",e),Qt("load",e),c=r;break;case"form":Qt("reset",e),Qt("submit",e),c=r;break;case"details":Qt("toggle",e),c=r;break;case"input":Ce(e,r),c=Ee(e,r),Qt("invalid",e),un(n,"onChange");break;case"option":c=_e(e,r);break;case"select":e._wrapperState={wasMultiple:!!r.multiple},c=o({},r,{value:void 0}),Qt("invalid",e),un(n,"onChange");break;case"textarea":Me(e,r),c=Ae(e,r),Qt("invalid",e),un(n,"onChange");break;default:c=r}on(i,c);var s=c;for(l in s)if(s.hasOwnProperty(l)){var f=s[l];"style"===l?nn(e,f):"dangerouslySetInnerHTML"===l?null!=(f=f?f.__html:void 0)&&Ne(e,f):"children"===l?"string"==typeof f?("textarea"!==i||""!==f)&&Ve(e,f):"number"==typeof f&&Ve(e,""+f):"suppressContentEditableWarning"!==l&&"suppressHydrationWarning"!==l&&"autoFocus"!==l&&(k.hasOwnProperty(l)?null!=f&&un(n,l):null!=f&&K(e,l,f,u))}switch(i){case"input":we(e),Oe(e,r,!1);break;case"textarea":we(e),Fe(e);break;case"option":null!=r.value&&e.setAttribute("value",""+be(r.value));break;case"select":e.multiple=!!r.multiple,null!=(n=r.value)?Pe(e,!!r.multiple,n,!1):null!=r.defaultValue&&Pe(e,!!r.multiple,r.defaultValue,!0);break;default:"function"==typeof c.onClick&&(e.onclick=cn)}vn(i,r)&&(t.effectTag|=4)}null!==t.ref&&(t.effectTag|=128)}return null;case 6:if(e&&null!=t.stateNode)Ha(0,t,e.memoizedProps,r);else{if("string"!=typeof r&&null===t.stateNode)throw Error(a(166));n=Mi(Ai.current),Mi(_i.current),Ta(t)?(n=t.stateNode,r=t.memoizedProps,n[kn]=t,n.nodeValue!==r&&(t.effectTag|=4)):((n=(9===n.nodeType?n:n.ownerDocument).createTextNode(r))[kn]=t,t.stateNode=n)}return null;case 13:return uo(Li),r=t.memoizedState,0!=(64&t.effectTag)?(t.expirationTime=n,t):(n=null!==r,r=!1,null===e?void 0!==t.memoizedProps.fallback&&Ta(t):(r=null!==(i=e.memoizedState),n||null===i||null!==(i=e.child.sibling)&&(null!==(l=t.firstEffect)?(t.firstEffect=i,i.nextEffect=l):(t.firstEffect=t.lastEffect=i,i.nextEffect=null),i.effectTag=8)),n&&!r&&0!=(2&t.mode)&&(null===e&&!0!==t.memoizedProps.unstable_avoidThisFallback||0!=(1&Li.current)?Tl===wl&&(Tl=xl):(Tl!==wl&&Tl!==xl||(Tl=El),0!==zl&&null!==kl&&(Fu(kl,Ol),Du(kl,zl)))),(n||r)&&(t.effectTag|=4),null);case 4:return Fi(),null;case 10:return ti(t),null;case 17:return go(t.type)&&vo(),null;case 19:if(uo(Li),null===(r=t.memoizedState))return null;if(i=0!=(64&t.effectTag),null===(l=r.rendering)){if(i)Xa(r,!1);else if(Tl!==wl||null!==e&&0!=(64&e.effectTag))for(l=t.child;null!==l;){if(null!==(e=Ri(l))){for(t.effectTag|=64,Xa(r,!1),null!==(i=e.updateQueue)&&(t.updateQueue=i,t.effectTag|=4),null===r.lastEffect&&(t.firstEffect=null),t.lastEffect=r.lastEffect,r=t.child;null!==r;)l=n,(i=r).effectTag&=2,i.nextEffect=null,i.firstEffect=null,i.lastEffect=null,null===(e=i.alternate)?(i.childExpirationTime=0,i.expirationTime=l,i.child=null,i.memoizedProps=null,i.memoizedState=null,i.updateQueue=null,i.dependencies=null):(i.childExpirationTime=e.childExpirationTime,i.expirationTime=e.expirationTime,i.child=e.child,i.memoizedProps=e.memoizedProps,i.memoizedState=e.memoizedState,i.updateQueue=e.updateQueue,l=e.dependencies,i.dependencies=null===l?null:{expirationTime:l.expirationTime,firstContext:l.firstContext,responders:l.responders}),r=r.sibling;return co(Li,1&Li.current|2),t.child}l=l.sibling}}else{if(!i)if(null!==(e=Ri(l))){if(t.effectTag|=64,i=!0,null!==(n=e.updateQueue)&&(t.updateQueue=n,t.effectTag|=4),Xa(r,!0),null===r.tail&&"hidden"===r.tailMode&&!l.alternate)return null!==(t=t.lastEffect=r.lastEffect)&&(t.nextEffect=null),null}else 2*Vo()-r.renderingStartTime>r.tailExpiration&&1<n&&(t.effectTag|=64,i=!0,Xa(r,!1),t.expirationTime=t.childExpirationTime=n-1);r.isBackwards?(l.sibling=t.child,t.child=l):(null!==(n=r.last)?n.sibling=l:t.child=l,r.last=l)}return null!==r.tail?(0===r.tailExpiration&&(r.tailExpiration=Vo()+500),n=r.tail,r.rendering=n,r.tail=n.sibling,r.lastEffect=t.lastEffect,r.renderingStartTime=Vo(),n.sibling=null,t=Li.current,co(Li,i?1&t|2:1&t),n):null}throw Error(a(156,t.tag))}function Ka(e){switch(e.tag){case 1:go(e.type)&&vo();var t=e.effectTag;return 4096&t?(e.effectTag=-4097&t|64,e):null;case 3:if(Fi(),uo(po),uo(fo),0!=(64&(t=e.effectTag)))throw Error(a(285));return e.effectTag=-4097&t|64,e;case 5:return ji(e),null;case 13:return uo(Li),4096&(t=e.effectTag)?(e.effectTag=-4097&t|64,e):null;case 19:return uo(Li),null;case 4:return Fi(),null;case 10:return ti(e),null;default:return null}}function Za(e,t){return{value:e,source:t,stack:ve(t)}}Va=function(e,t){for(var n=t.child;null!==n;){if(5===n.tag||6===n.tag)e.appendChild(n.stateNode);else if(4!==n.tag&&null!==n.child){n.child.return=n,n=n.child;continue}if(n===t)break;for(;null===n.sibling;){if(null===n.return||n.return===t)return;n=n.return}n.sibling.return=n.return,n=n.sibling}},Ba=function(e,t,n,r,i){var a=e.memoizedProps;if(a!==r){var l,u,c=t.stateNode;switch(Mi(_i.current),e=null,n){case"input":a=Ee(c,a),r=Ee(c,r),e=[];break;case"option":a=_e(c,a),r=_e(c,r),e=[];break;case"select":a=o({},a,{value:void 0}),r=o({},r,{value:void 0}),e=[];break;case"textarea":a=Ae(c,a),r=Ae(c,r),e=[];break;default:"function"!=typeof a.onClick&&"function"==typeof r.onClick&&(c.onclick=cn)}for(l in on(n,r),n=null,a)if(!r.hasOwnProperty(l)&&a.hasOwnProperty(l)&&null!=a[l])if("style"===l)for(u in c=a[l])c.hasOwnProperty(u)&&(n||(n={}),n[u]="");else"dangerouslySetInnerHTML"!==l&&"children"!==l&&"suppressContentEditableWarning"!==l&&"suppressHydrationWarning"!==l&&"autoFocus"!==l&&(k.hasOwnProperty(l)?e||(e=[]):(e=e||[]).push(l,null));for(l in r){var s=r[l];if(c=null!=a?a[l]:void 0,r.hasOwnProperty(l)&&s!==c&&(null!=s||null!=c))if("style"===l)if(c){for(u in c)!c.hasOwnProperty(u)||s&&s.hasOwnProperty(u)||(n||(n={}),n[u]="");for(u in s)s.hasOwnProperty(u)&&c[u]!==s[u]&&(n||(n={}),n[u]=s[u])}else n||(e||(e=[]),e.push(l,n)),n=s;else"dangerouslySetInnerHTML"===l?(s=s?s.__html:void 0,c=c?c.__html:void 0,null!=s&&c!==s&&(e=e||[]).push(l,s)):"children"===l?c===s||"string"!=typeof s&&"number"!=typeof s||(e=e||[]).push(l,""+s):"suppressContentEditableWarning"!==l&&"suppressHydrationWarning"!==l&&(k.hasOwnProperty(l)?(null!=s&&un(i,l),e||c===s||(e=[])):(e=e||[]).push(l,s))}n&&(e=e||[]).push("style",n),i=e,(t.updateQueue=i)&&(t.effectTag|=4)}},Ha=function(e,t,n,r){n!==r&&(t.effectTag|=4)};var Ja="function"==typeof WeakSet?WeakSet:Set;function el(e,t){var n=t.source,r=t.stack;null===r&&null!==n&&(r=ve(n)),null!==n&&ge(n.type),t=t.value,null!==e&&1===e.tag&&ge(e.type);try{console.error(t)}catch(e){setTimeout((function(){throw e}))}}function tl(e){var t=e.ref;if(null!==t)if("function"==typeof t)try{t(null)}catch(t){bu(e,t)}else t.current=null}function nl(e,t){switch(t.tag){case 0:case 11:case 15:case 22:return;case 1:if(256&t.effectTag&&null!==e){var n=e.memoizedProps,r=e.memoizedState;t=(e=t.stateNode).getSnapshotBeforeUpdate(t.elementType===t.type?n:Xo(t.type,n),r),e.__reactInternalSnapshotBeforeUpdate=t}return;case 3:case 5:case 6:case 4:case 17:return}throw Error(a(163))}function rl(e,t){if(null!==(t=null!==(t=t.updateQueue)?t.lastEffect:null)){var n=t=t.next;do{if((n.tag&e)===e){var r=n.destroy;n.destroy=void 0,void 0!==r&&r()}n=n.next}while(n!==t)}}function ol(e,t){if(null!==(t=null!==(t=t.updateQueue)?t.lastEffect:null)){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function il(e,t,n){switch(n.tag){case 0:case 11:case 15:case 22:return void ol(3,n);case 1:if(e=n.stateNode,4&n.effectTag)if(null===t)e.componentDidMount();else{var r=n.elementType===n.type?t.memoizedProps:Xo(n.type,t.memoizedProps);e.componentDidUpdate(r,t.memoizedState,e.__reactInternalSnapshotBeforeUpdate)}return void(null!==(t=n.updateQueue)&&pi(n,t,e));case 3:if(null!==(t=n.updateQueue)){if(e=null,null!==n.child)switch(n.child.tag){case 5:e=n.child.stateNode;break;case 1:e=n.child.stateNode}pi(n,t,e)}return;case 5:return e=n.stateNode,void(null===t&&4&n.effectTag&&vn(n.type,n.memoizedProps)&&e.focus());case 6:case 4:case 12:return;case 13:return void(null===n.memoizedState&&(n=n.alternate,null!==n&&(n=n.memoizedState,null!==n&&(n=n.dehydrated,null!==n&&Lt(n)))));case 19:case 17:case 20:case 21:return}throw Error(a(163))}function al(e,t,n){switch("function"==typeof Eu&&Eu(t),t.tag){case 0:case 11:case 14:case 15:case 22:if(null!==(e=t.updateQueue)&&null!==(e=e.lastEffect)){var r=e.next;Wo(97<n?97:n,(function(){var e=r;do{var n=e.destroy;if(void 0!==n){var o=t;try{n()}catch(e){bu(o,e)}}e=e.next}while(e!==r)}))}break;case 1:tl(t),"function"==typeof(n=t.stateNode).componentWillUnmount&&function(e,t){try{t.props=e.memoizedProps,t.state=e.memoizedState,t.componentWillUnmount()}catch(t){bu(e,t)}}(t,n);break;case 5:tl(t);break;case 4:sl(e,t,n)}}function ll(e){var t=e.alternate;e.return=null,e.child=null,e.memoizedState=null,e.updateQueue=null,e.dependencies=null,e.alternate=null,e.firstEffect=null,e.lastEffect=null,e.pendingProps=null,e.memoizedProps=null,e.stateNode=null,null!==t&&ll(t)}function ul(e){return 5===e.tag||3===e.tag||4===e.tag}function cl(e){e:{for(var t=e.return;null!==t;){if(ul(t)){var n=t;break e}t=t.return}throw Error(a(160))}switch(t=n.stateNode,n.tag){case 5:var r=!1;break;case 3:case 4:t=t.containerInfo,r=!0;break;default:throw Error(a(161))}16&n.effectTag&&(Ve(t,""),n.effectTag&=-17);e:t:for(n=e;;){for(;null===n.sibling;){if(null===n.return||ul(n.return)){n=null;break e}n=n.return}for(n.sibling.return=n.return,n=n.sibling;5!==n.tag&&6!==n.tag&&18!==n.tag;){if(2&n.effectTag)continue t;if(null===n.child||4===n.tag)continue t;n.child.return=n,n=n.child}if(!(2&n.effectTag)){n=n.stateNode;break e}}r?function e(t,n,r){var o=t.tag,i=5===o||6===o;if(i)t=i?t.stateNode:t.stateNode.instance,n?8===r.nodeType?r.parentNode.insertBefore(t,n):r.insertBefore(t,n):(8===r.nodeType?(n=r.parentNode).insertBefore(t,r):(n=r).appendChild(t),null!==(r=r._reactRootContainer)&&void 0!==r||null!==n.onclick||(n.onclick=cn));else if(4!==o&&null!==(t=t.child))for(e(t,n,r),t=t.sibling;null!==t;)e(t,n,r),t=t.sibling}(e,n,t):function e(t,n,r){var o=t.tag,i=5===o||6===o;if(i)t=i?t.stateNode:t.stateNode.instance,n?r.insertBefore(t,n):r.appendChild(t);else if(4!==o&&null!==(t=t.child))for(e(t,n,r),t=t.sibling;null!==t;)e(t,n,r),t=t.sibling}(e,n,t)}function sl(e,t,n){for(var r,o,i=t,l=!1;;){if(!l){l=i.return;e:for(;;){if(null===l)throw Error(a(160));switch(r=l.stateNode,l.tag){case 5:o=!1;break e;case 3:case 4:r=r.containerInfo,o=!0;break e}l=l.return}l=!0}if(5===i.tag||6===i.tag){e:for(var u=e,c=i,s=n,f=c;;)if(al(u,f,s),null!==f.child&&4!==f.tag)f.child.return=f,f=f.child;else{if(f===c)break e;for(;null===f.sibling;){if(null===f.return||f.return===c)break e;f=f.return}f.sibling.return=f.return,f=f.sibling}o?(u=r,c=i.stateNode,8===u.nodeType?u.parentNode.removeChild(c):u.removeChild(c)):r.removeChild(i.stateNode)}else if(4===i.tag){if(null!==i.child){r=i.stateNode.containerInfo,o=!0,i.child.return=i,i=i.child;continue}}else if(al(e,i,n),null!==i.child){i.child.return=i,i=i.child;continue}if(i===t)break;for(;null===i.sibling;){if(null===i.return||i.return===t)return;4===(i=i.return).tag&&(l=!1)}i.sibling.return=i.return,i=i.sibling}}function fl(e,t){switch(t.tag){case 0:case 11:case 14:case 15:case 22:return void rl(3,t);case 1:return;case 5:var n=t.stateNode;if(null!=n){var r=t.memoizedProps,o=null!==e?e.memoizedProps:r;e=t.type;var i=t.updateQueue;if(t.updateQueue=null,null!==i){for(n[Sn]=r,"input"===e&&"radio"===r.type&&null!=r.name&&ke(n,r),an(e,o),t=an(e,r),o=0;o<i.length;o+=2){var l=i[o],u=i[o+1];"style"===l?nn(n,u):"dangerouslySetInnerHTML"===l?Ne(n,u):"children"===l?Ve(n,u):K(n,l,u,t)}switch(e){case"input":Se(n,r);break;case"textarea":ze(n,r);break;case"select":t=n._wrapperState.wasMultiple,n._wrapperState.wasMultiple=!!r.multiple,null!=(e=r.value)?Pe(n,!!r.multiple,e,!1):t!==!!r.multiple&&(null!=r.defaultValue?Pe(n,!!r.multiple,r.defaultValue,!0):Pe(n,!!r.multiple,r.multiple?[]:"",!1))}}}return;case 6:if(null===t.stateNode)throw Error(a(162));return void(t.stateNode.nodeValue=t.memoizedProps);case 3:return void((t=t.stateNode).hydrate&&(t.hydrate=!1,Lt(t.containerInfo)));case 12:return;case 13:if(n=t,null===t.memoizedState?r=!1:(r=!0,n=t.child,Dl=Vo()),null!==n)e:for(e=n;;){if(5===e.tag)i=e.stateNode,r?"function"==typeof(i=i.style).setProperty?i.setProperty("display","none","important"):i.display="none":(i=e.stateNode,o=null!=(o=e.memoizedProps.style)&&o.hasOwnProperty("display")?o.display:null,i.style.display=tn("display",o));else if(6===e.tag)e.stateNode.nodeValue=r?"":e.memoizedProps;else{if(13===e.tag&&null!==e.memoizedState&&null===e.memoizedState.dehydrated){(i=e.child.sibling).return=e,e=i;continue}if(null!==e.child){e.child.return=e,e=e.child;continue}}if(e===n)break;for(;null===e.sibling;){if(null===e.return||e.return===n)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}return void pl(t);case 19:return void pl(t);case 17:return}throw Error(a(163))}function pl(e){var t=e.updateQueue;if(null!==t){e.updateQueue=null;var n=e.stateNode;null===n&&(n=e.stateNode=new Ja),t.forEach((function(t){var r=wu.bind(null,e,t);n.has(t)||(n.add(t),t.then(r,r))}))}}var dl="function"==typeof WeakMap?WeakMap:Map;function hl(e,t,n){(n=ui(n,null)).tag=3,n.payload={element:null};var r=t.value;return n.callback=function(){Ll||(Ll=!0,Rl=r),el(e,t)},n}function ml(e,t,n){(n=ui(n,null)).tag=3;var r=e.type.getDerivedStateFromError;if("function"==typeof r){var o=t.value;n.payload=function(){return el(e,t),r(o)}}var i=e.stateNode;return null!==i&&"function"==typeof i.componentDidCatch&&(n.callback=function(){"function"!=typeof r&&(null===Il?Il=new Set([this]):Il.add(this),el(e,t));var n=t.stack;this.componentDidCatch(t.value,{componentStack:null!==n?n:""})}),n}var gl,vl=Math.ceil,bl=q.ReactCurrentDispatcher,yl=q.ReactCurrentOwner,wl=0,xl=3,El=4,Cl=0,kl=null,Sl=null,Ol=0,Tl=wl,_l=null,Pl=1073741823,Al=1073741823,Ml=null,zl=0,Fl=!1,Dl=0,jl=null,Ll=!1,Rl=null,Il=null,Nl=!1,Vl=null,Bl=90,Hl=null,Wl=0,Ul=null,$l=0;function Yl(){return 0!=(48&Cl)?1073741821-(Vo()/10|0):0!==$l?$l:$l=1073741821-(Vo()/10|0)}function Ql(e,t,n){if(0==(2&(t=t.mode)))return 1073741823;var r=Bo();if(0==(4&t))return 99===r?1073741823:1073741822;if(0!=(16&Cl))return Ol;if(null!==n)e=Go(e,0|n.timeoutMs||5e3,250);else switch(r){case 99:e=1073741823;break;case 98:e=Go(e,150,100);break;case 97:case 96:e=Go(e,5e3,250);break;case 95:e=2;break;default:throw Error(a(326))}return null!==kl&&e===Ol&&--e,e}function Gl(e,t){if(50<Wl)throw Wl=0,Ul=null,Error(a(185));if(null!==(e=Xl(e,t))){var n=Bo();1073741823===t?0!=(8&Cl)&&0==(48&Cl)?Jl(e):(Kl(e),0===Cl&&Yo()):Kl(e),0==(4&Cl)||98!==n&&99!==n||(null===Hl?Hl=new Map([[e,t]]):(void 0===(n=Hl.get(e))||n>t)&&Hl.set(e,t))}}function Xl(e,t){e.expirationTime<t&&(e.expirationTime=t);var n=e.alternate;null!==n&&n.expirationTime<t&&(n.expirationTime=t);var r=e.return,o=null;if(null===r&&3===e.tag)o=e.stateNode;else for(;null!==r;){if(n=r.alternate,r.childExpirationTime<t&&(r.childExpirationTime=t),null!==n&&n.childExpirationTime<t&&(n.childExpirationTime=t),null===r.return&&3===r.tag){o=r.stateNode;break}r=r.return}return null!==o&&(kl===o&&(au(t),Tl===El&&Fu(o,Ol)),Du(o,t)),o}function ql(e){var t=e.lastExpiredTime;if(0!==t)return t;if(!zu(e,t=e.firstPendingTime))return t;var n=e.lastPingedTime;return 2>=(e=n>(e=e.nextKnownPendingLevel)?n:e)&&t!==e?0:e}function Kl(e){if(0!==e.lastExpiredTime)e.callbackExpirationTime=1073741823,e.callbackPriority=99,e.callbackNode=$o(Jl.bind(null,e));else{var t=ql(e),n=e.callbackNode;if(0===t)null!==n&&(e.callbackNode=null,e.callbackExpirationTime=0,e.callbackPriority=90);else{var r=Yl();if(1073741823===t?r=99:1===t||2===t?r=95:r=0>=(r=10*(1073741821-t)-10*(1073741821-r))?99:250>=r?98:5250>=r?97:95,null!==n){var o=e.callbackPriority;if(e.callbackExpirationTime===t&&o>=r)return;n!==Fo&&ko(n)}e.callbackExpirationTime=t,e.callbackPriority=r,t=1073741823===t?$o(Jl.bind(null,e)):Uo(r,Zl.bind(null,e),{timeout:10*(1073741821-t)-Vo()}),e.callbackNode=t}}}function Zl(e,t){if($l=0,t)return ju(e,t=Yl()),Kl(e),null;var n=ql(e);if(0!==n){if(t=e.callbackNode,0!=(48&Cl))throw Error(a(327));if(mu(),e===kl&&n===Ol||nu(e,n),null!==Sl){var r=Cl;Cl|=16;for(var o=ou();;)try{uu();break}catch(t){ru(e,t)}if(ei(),Cl=r,bl.current=o,1===Tl)throw t=_l,nu(e,n),Fu(e,n),Kl(e),t;if(null===Sl)switch(o=e.finishedWork=e.current.alternate,e.finishedExpirationTime=n,r=Tl,kl=null,r){case wl:case 1:throw Error(a(345));case 2:ju(e,2<n?2:n);break;case xl:if(Fu(e,n),n===(r=e.lastSuspendedTime)&&(e.nextKnownPendingLevel=fu(o)),1073741823===Pl&&10<(o=Dl+500-Vo())){if(Fl){var i=e.lastPingedTime;if(0===i||i>=n){e.lastPingedTime=n,nu(e,n);break}}if(0!==(i=ql(e))&&i!==n)break;if(0!==r&&r!==n){e.lastPingedTime=r;break}e.timeoutHandle=yn(pu.bind(null,e),o);break}pu(e);break;case El:if(Fu(e,n),n===(r=e.lastSuspendedTime)&&(e.nextKnownPendingLevel=fu(o)),Fl&&(0===(o=e.lastPingedTime)||o>=n)){e.lastPingedTime=n,nu(e,n);break}if(0!==(o=ql(e))&&o!==n)break;if(0!==r&&r!==n){e.lastPingedTime=r;break}if(1073741823!==Al?r=10*(1073741821-Al)-Vo():1073741823===Pl?r=0:(r=10*(1073741821-Pl)-5e3,0>(r=(o=Vo())-r)&&(r=0),(n=10*(1073741821-n)-o)<(r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*vl(r/1960))-r)&&(r=n)),10<r){e.timeoutHandle=yn(pu.bind(null,e),r);break}pu(e);break;case 5:if(1073741823!==Pl&&null!==Ml){i=Pl;var l=Ml;if(0>=(r=0|l.busyMinDurationMs)?r=0:(o=0|l.busyDelayMs,r=(i=Vo()-(10*(1073741821-i)-(0|l.timeoutMs||5e3)))<=o?0:o+r-i),10<r){Fu(e,n),e.timeoutHandle=yn(pu.bind(null,e),r);break}}pu(e);break;default:throw Error(a(329))}if(Kl(e),e.callbackNode===t)return Zl.bind(null,e)}}return null}function Jl(e){var t=e.lastExpiredTime;if(t=0!==t?t:1073741823,0!=(48&Cl))throw Error(a(327));if(mu(),e===kl&&t===Ol||nu(e,t),null!==Sl){var n=Cl;Cl|=16;for(var r=ou();;)try{lu();break}catch(t){ru(e,t)}if(ei(),Cl=n,bl.current=r,1===Tl)throw n=_l,nu(e,t),Fu(e,t),Kl(e),n;if(null!==Sl)throw Error(a(261));e.finishedWork=e.current.alternate,e.finishedExpirationTime=t,kl=null,pu(e),Kl(e)}return null}function eu(e,t){var n=Cl;Cl|=1;try{return e(t)}finally{0===(Cl=n)&&Yo()}}function tu(e,t){var n=Cl;Cl&=-2,Cl|=8;try{return e(t)}finally{0===(Cl=n)&&Yo()}}function nu(e,t){e.finishedWork=null,e.finishedExpirationTime=0;var n=e.timeoutHandle;if(-1!==n&&(e.timeoutHandle=-1,wn(n)),null!==Sl)for(n=Sl.return;null!==n;){var r=n;switch(r.tag){case 1:null!=(r=r.type.childContextTypes)&&vo();break;case 3:Fi(),uo(po),uo(fo);break;case 5:ji(r);break;case 4:Fi();break;case 13:case 19:uo(Li);break;case 10:ti(r)}n=n.return}kl=e,Sl=Ou(e.current,null),Ol=t,Tl=wl,_l=null,Al=Pl=1073741823,Ml=null,zl=0,Fl=!1}function ru(e,t){for(;;){try{if(ei(),Ni.current=ga,$i)for(var n=Hi.memoizedState;null!==n;){var r=n.queue;null!==r&&(r.pending=null),n=n.next}if(Bi=0,Ui=Wi=Hi=null,$i=!1,null===Sl||null===Sl.return)return Tl=1,_l=t,Sl=null;e:{var o=e,i=Sl.return,a=Sl,l=t;if(t=Ol,a.effectTag|=2048,a.firstEffect=a.lastEffect=null,null!==l&&"object"==typeof l&&"function"==typeof l.then){var u=l;if(0==(2&a.mode)){var c=a.alternate;c?(a.updateQueue=c.updateQueue,a.memoizedState=c.memoizedState,a.expirationTime=c.expirationTime):(a.updateQueue=null,a.memoizedState=null)}var s=0!=(1&Li.current),f=i;do{var p;if(p=13===f.tag){var d=f.memoizedState;if(null!==d)p=null!==d.dehydrated;else{var h=f.memoizedProps;p=void 0!==h.fallback&&(!0!==h.unstable_avoidThisFallback||!s)}}if(p){var m=f.updateQueue;if(null===m){var g=new Set;g.add(u),f.updateQueue=g}else m.add(u);if(0==(2&f.mode)){if(f.effectTag|=64,a.effectTag&=-2981,1===a.tag)if(null===a.alternate)a.tag=17;else{var v=ui(1073741823,null);v.tag=2,ci(a,v)}a.expirationTime=1073741823;break e}l=void 0,a=t;var b=o.pingCache;if(null===b?(b=o.pingCache=new dl,l=new Set,b.set(u,l)):void 0===(l=b.get(u))&&(l=new Set,b.set(u,l)),!l.has(a)){l.add(a);var y=yu.bind(null,o,u,a);u.then(y,y)}f.effectTag|=4096,f.expirationTime=t;break e}f=f.return}while(null!==f);l=Error((ge(a.type)||"A React component")+" suspended while rendering, but no fallback UI was specified.\n\nAdd a <Suspense fallback=...> component higher in the tree to provide a loading indicator or placeholder to display."+ve(a))}5!==Tl&&(Tl=2),l=Za(l,a),f=i;do{switch(f.tag){case 3:u=l,f.effectTag|=4096,f.expirationTime=t,si(f,hl(f,u,t));break e;case 1:u=l;var w=f.type,x=f.stateNode;if(0==(64&f.effectTag)&&("function"==typeof w.getDerivedStateFromError||null!==x&&"function"==typeof x.componentDidCatch&&(null===Il||!Il.has(x)))){f.effectTag|=4096,f.expirationTime=t,si(f,ml(f,u,t));break e}}f=f.return}while(null!==f)}Sl=su(Sl)}catch(e){t=e;continue}break}}function ou(){var e=bl.current;return bl.current=ga,null===e?ga:e}function iu(e,t){e<Pl&&2<e&&(Pl=e),null!==t&&e<Al&&2<e&&(Al=e,Ml=t)}function au(e){e>zl&&(zl=e)}function lu(){for(;null!==Sl;)Sl=cu(Sl)}function uu(){for(;null!==Sl&&!Do();)Sl=cu(Sl)}function cu(e){var t=gl(e.alternate,e,Ol);return e.memoizedProps=e.pendingProps,null===t&&(t=su(e)),yl.current=null,t}function su(e){Sl=e;do{var t=Sl.alternate;if(e=Sl.return,0==(2048&Sl.effectTag)){if(t=qa(t,Sl,Ol),1===Ol||1!==Sl.childExpirationTime){for(var n=0,r=Sl.child;null!==r;){var o=r.expirationTime,i=r.childExpirationTime;o>n&&(n=o),i>n&&(n=i),r=r.sibling}Sl.childExpirationTime=n}if(null!==t)return t;null!==e&&0==(2048&e.effectTag)&&(null===e.firstEffect&&(e.firstEffect=Sl.firstEffect),null!==Sl.lastEffect&&(null!==e.lastEffect&&(e.lastEffect.nextEffect=Sl.firstEffect),e.lastEffect=Sl.lastEffect),1<Sl.effectTag&&(null!==e.lastEffect?e.lastEffect.nextEffect=Sl:e.firstEffect=Sl,e.lastEffect=Sl))}else{if(null!==(t=Ka(Sl)))return t.effectTag&=2047,t;null!==e&&(e.firstEffect=e.lastEffect=null,e.effectTag|=2048)}if(null!==(t=Sl.sibling))return t;Sl=e}while(null!==Sl);return Tl===wl&&(Tl=5),null}function fu(e){var t=e.expirationTime;return t>(e=e.childExpirationTime)?t:e}function pu(e){var t=Bo();return Wo(99,du.bind(null,e,t)),null}function du(e,t){do{mu()}while(null!==Vl);if(0!=(48&Cl))throw Error(a(327));var n=e.finishedWork,r=e.finishedExpirationTime;if(null===n)return null;if(e.finishedWork=null,e.finishedExpirationTime=0,n===e.current)throw Error(a(177));e.callbackNode=null,e.callbackExpirationTime=0,e.callbackPriority=90,e.nextKnownPendingLevel=0;var o=fu(n);if(e.firstPendingTime=o,r<=e.lastSuspendedTime?e.firstSuspendedTime=e.lastSuspendedTime=e.nextKnownPendingLevel=0:r<=e.firstSuspendedTime&&(e.firstSuspendedTime=r-1),r<=e.lastPingedTime&&(e.lastPingedTime=0),r<=e.lastExpiredTime&&(e.lastExpiredTime=0),e===kl&&(Sl=kl=null,Ol=0),1<n.effectTag?null!==n.lastEffect?(n.lastEffect.nextEffect=n,o=n.firstEffect):o=n:o=n.firstEffect,null!==o){var i=Cl;Cl|=32,yl.current=null,mn=Yt;var l=dn();if(hn(l)){if("selectionStart"in l)var u={start:l.selectionStart,end:l.selectionEnd};else e:{var c=(u=(u=l.ownerDocument)&&u.defaultView||window).getSelection&&u.getSelection();if(c&&0!==c.rangeCount){u=c.anchorNode;var s=c.anchorOffset,f=c.focusNode;c=c.focusOffset;try{u.nodeType,f.nodeType}catch(e){u=null;break e}var p=0,d=-1,h=-1,m=0,g=0,v=l,b=null;t:for(;;){for(var y;v!==u||0!==s&&3!==v.nodeType||(d=p+s),v!==f||0!==c&&3!==v.nodeType||(h=p+c),3===v.nodeType&&(p+=v.nodeValue.length),null!==(y=v.firstChild);)b=v,v=y;for(;;){if(v===l)break t;if(b===u&&++m===s&&(d=p),b===f&&++g===c&&(h=p),null!==(y=v.nextSibling))break;b=(v=b).parentNode}v=y}u=-1===d||-1===h?null:{start:d,end:h}}else u=null}u=u||{start:0,end:0}}else u=null;gn={activeElementDetached:null,focusedElem:l,selectionRange:u},Yt=!1,jl=o;do{try{hu()}catch(e){if(null===jl)throw Error(a(330));bu(jl,e),jl=jl.nextEffect}}while(null!==jl);jl=o;do{try{for(l=e,u=t;null!==jl;){var w=jl.effectTag;if(16&w&&Ve(jl.stateNode,""),128&w){var x=jl.alternate;if(null!==x){var E=x.ref;null!==E&&("function"==typeof E?E(null):E.current=null)}}switch(1038&w){case 2:cl(jl),jl.effectTag&=-3;break;case 6:cl(jl),jl.effectTag&=-3,fl(jl.alternate,jl);break;case 1024:jl.effectTag&=-1025;break;case 1028:jl.effectTag&=-1025,fl(jl.alternate,jl);break;case 4:fl(jl.alternate,jl);break;case 8:sl(l,s=jl,u),ll(s)}jl=jl.nextEffect}}catch(e){if(null===jl)throw Error(a(330));bu(jl,e),jl=jl.nextEffect}}while(null!==jl);if(E=gn,x=dn(),w=E.focusedElem,u=E.selectionRange,x!==w&&w&&w.ownerDocument&&function e(t,n){return!(!t||!n)&&(t===n||(!t||3!==t.nodeType)&&(n&&3===n.nodeType?e(t,n.parentNode):"contains"in t?t.contains(n):!!t.compareDocumentPosition&&!!(16&t.compareDocumentPosition(n))))}(w.ownerDocument.documentElement,w)){null!==u&&hn(w)&&(x=u.start,void 0===(E=u.end)&&(E=x),"selectionStart"in w?(w.selectionStart=x,w.selectionEnd=Math.min(E,w.value.length)):(E=(x=w.ownerDocument||document)&&x.defaultView||window).getSelection&&(E=E.getSelection(),s=w.textContent.length,l=Math.min(u.start,s),u=void 0===u.end?l:Math.min(u.end,s),!E.extend&&l>u&&(s=u,u=l,l=s),s=pn(w,l),f=pn(w,u),s&&f&&(1!==E.rangeCount||E.anchorNode!==s.node||E.anchorOffset!==s.offset||E.focusNode!==f.node||E.focusOffset!==f.offset)&&((x=x.createRange()).setStart(s.node,s.offset),E.removeAllRanges(),l>u?(E.addRange(x),E.extend(f.node,f.offset)):(x.setEnd(f.node,f.offset),E.addRange(x))))),x=[];for(E=w;E=E.parentNode;)1===E.nodeType&&x.push({element:E,left:E.scrollLeft,top:E.scrollTop});for("function"==typeof w.focus&&w.focus(),w=0;w<x.length;w++)(E=x[w]).element.scrollLeft=E.left,E.element.scrollTop=E.top}Yt=!!mn,gn=mn=null,e.current=n,jl=o;do{try{for(w=e;null!==jl;){var C=jl.effectTag;if(36&C&&il(w,jl.alternate,jl),128&C){x=void 0;var k=jl.ref;if(null!==k){var S=jl.stateNode;switch(jl.tag){case 5:x=S;break;default:x=S}"function"==typeof k?k(x):k.current=x}}jl=jl.nextEffect}}catch(e){if(null===jl)throw Error(a(330));bu(jl,e),jl=jl.nextEffect}}while(null!==jl);jl=null,jo(),Cl=i}else e.current=n;if(Nl)Nl=!1,Vl=e,Bl=t;else for(jl=o;null!==jl;)t=jl.nextEffect,jl.nextEffect=null,jl=t;if(0===(t=e.firstPendingTime)&&(Il=null),1073741823===t?e===Ul?Wl++:(Wl=0,Ul=e):Wl=0,"function"==typeof xu&&xu(n.stateNode,r),Kl(e),Ll)throw Ll=!1,e=Rl,Rl=null,e;return 0!=(8&Cl)||Yo(),null}function hu(){for(;null!==jl;){var e=jl.effectTag;0!=(256&e)&&nl(jl.alternate,jl),0==(512&e)||Nl||(Nl=!0,Uo(97,(function(){return mu(),null}))),jl=jl.nextEffect}}function mu(){if(90!==Bl){var e=97<Bl?97:Bl;return Bl=90,Wo(e,gu)}}function gu(){if(null===Vl)return!1;var e=Vl;if(Vl=null,0!=(48&Cl))throw Error(a(331));var t=Cl;for(Cl|=32,e=e.current.firstEffect;null!==e;){try{var n=e;if(0!=(512&n.effectTag))switch(n.tag){case 0:case 11:case 15:case 22:rl(5,n),ol(5,n)}}catch(t){if(null===e)throw Error(a(330));bu(e,t)}n=e.nextEffect,e.nextEffect=null,e=n}return Cl=t,Yo(),!0}function vu(e,t,n){ci(e,t=hl(e,t=Za(n,t),1073741823)),null!==(e=Xl(e,1073741823))&&Kl(e)}function bu(e,t){if(3===e.tag)vu(e,e,t);else for(var n=e.return;null!==n;){if(3===n.tag){vu(n,e,t);break}if(1===n.tag){var r=n.stateNode;if("function"==typeof n.type.getDerivedStateFromError||"function"==typeof r.componentDidCatch&&(null===Il||!Il.has(r))){ci(n,e=ml(n,e=Za(t,e),1073741823)),null!==(n=Xl(n,1073741823))&&Kl(n);break}}n=n.return}}function yu(e,t,n){var r=e.pingCache;null!==r&&r.delete(t),kl===e&&Ol===n?Tl===El||Tl===xl&&1073741823===Pl&&Vo()-Dl<500?nu(e,Ol):Fl=!0:zu(e,n)&&(0!==(t=e.lastPingedTime)&&t<n||(e.lastPingedTime=n,Kl(e)))}function wu(e,t){var n=e.stateNode;null!==n&&n.delete(t),0===(t=0)&&(t=Ql(t=Yl(),e,null)),null!==(e=Xl(e,t))&&Kl(e)}gl=function(e,t,n){var r=t.expirationTime;if(null!==e){var o=t.pendingProps;if(e.memoizedProps!==o||po.current)Aa=!0;else{if(r<n){switch(Aa=!1,t.tag){case 3:Na(t),_a();break;case 5:if(Di(t),4&t.mode&&1!==n&&o.hidden)return t.expirationTime=t.childExpirationTime=1,null;break;case 1:go(t.type)&&wo(t);break;case 4:zi(t,t.stateNode.containerInfo);break;case 10:r=t.memoizedProps.value,o=t.type._context,co(qo,o._currentValue),o._currentValue=r;break;case 13:if(null!==t.memoizedState)return 0!==(r=t.child.childExpirationTime)&&r>=n?Ua(e,t,n):(co(Li,1&Li.current),null!==(t=Ga(e,t,n))?t.sibling:null);co(Li,1&Li.current);break;case 19:if(r=t.childExpirationTime>=n,0!=(64&e.effectTag)){if(r)return Qa(e,t,n);t.effectTag|=64}if(null!==(o=t.memoizedState)&&(o.rendering=null,o.tail=null),co(Li,Li.current),!r)return null}return Ga(e,t,n)}Aa=!1}}else Aa=!1;switch(t.expirationTime=0,t.tag){case 2:if(r=t.type,null!==e&&(e.alternate=null,t.alternate=null,t.effectTag|=2),e=t.pendingProps,o=mo(t,fo.current),ri(t,n),o=Gi(null,t,r,e,o,n),t.effectTag|=1,"object"==typeof o&&null!==o&&"function"==typeof o.render&&void 0===o.$$typeof){if(t.tag=1,t.memoizedState=null,t.updateQueue=null,go(r)){var i=!0;wo(t)}else i=!1;t.memoizedState=null!==o.state&&void 0!==o.state?o.state:null,ai(t);var l=r.getDerivedStateFromProps;"function"==typeof l&&mi(t,r,l,e),o.updater=gi,t.stateNode=o,o._reactInternalFiber=t,wi(t,r,e,n),t=Ia(null,t,r,!0,i,n)}else t.tag=0,Ma(null,t,o,n),t=t.child;return t;case 16:e:{if(o=t.elementType,null!==e&&(e.alternate=null,t.alternate=null,t.effectTag|=2),e=t.pendingProps,function(e){if(-1===e._status){e._status=0;var t=e._ctor;t=t(),e._result=t,t.then((function(t){0===e._status&&(t=t.default,e._status=1,e._result=t)}),(function(t){0===e._status&&(e._status=2,e._result=t)}))}}(o),1!==o._status)throw o._result;switch(o=o._result,t.type=o,i=t.tag=function(e){if("function"==typeof e)return Su(e)?1:0;if(null!=e){if((e=e.$$typeof)===ue)return 11;if(e===fe)return 14}return 2}(o),e=Xo(o,e),i){case 0:t=La(null,t,o,e,n);break e;case 1:t=Ra(null,t,o,e,n);break e;case 11:t=za(null,t,o,e,n);break e;case 14:t=Fa(null,t,o,Xo(o.type,e),r,n);break e}throw Error(a(306,o,""))}return t;case 0:return r=t.type,o=t.pendingProps,La(e,t,r,o=t.elementType===r?o:Xo(r,o),n);case 1:return r=t.type,o=t.pendingProps,Ra(e,t,r,o=t.elementType===r?o:Xo(r,o),n);case 3:if(Na(t),r=t.updateQueue,null===e||null===r)throw Error(a(282));if(r=t.pendingProps,o=null!==(o=t.memoizedState)?o.element:null,li(e,t),fi(t,r,null,n),(r=t.memoizedState.element)===o)_a(),t=Ga(e,t,n);else{if((o=t.stateNode.hydrate)&&(xa=xn(t.stateNode.containerInfo.firstChild),wa=t,o=Ea=!0),o)for(n=Oi(t,null,r,n),t.child=n;n;)n.effectTag=-3&n.effectTag|1024,n=n.sibling;else Ma(e,t,r,n),_a();t=t.child}return t;case 5:return Di(t),null===e&&Sa(t),r=t.type,o=t.pendingProps,i=null!==e?e.memoizedProps:null,l=o.children,bn(r,o)?l=null:null!==i&&bn(r,i)&&(t.effectTag|=16),ja(e,t),4&t.mode&&1!==n&&o.hidden?(t.expirationTime=t.childExpirationTime=1,t=null):(Ma(e,t,l,n),t=t.child),t;case 6:return null===e&&Sa(t),null;case 13:return Ua(e,t,n);case 4:return zi(t,t.stateNode.containerInfo),r=t.pendingProps,null===e?t.child=Si(t,null,r,n):Ma(e,t,r,n),t.child;case 11:return r=t.type,o=t.pendingProps,za(e,t,r,o=t.elementType===r?o:Xo(r,o),n);case 7:return Ma(e,t,t.pendingProps,n),t.child;case 8:case 12:return Ma(e,t,t.pendingProps.children,n),t.child;case 10:e:{r=t.type._context,o=t.pendingProps,l=t.memoizedProps,i=o.value;var u=t.type._context;if(co(qo,u._currentValue),u._currentValue=i,null!==l)if(u=l.value,0===(i=Rr(u,i)?0:0|("function"==typeof r._calculateChangedBits?r._calculateChangedBits(u,i):1073741823))){if(l.children===o.children&&!po.current){t=Ga(e,t,n);break e}}else for(null!==(u=t.child)&&(u.return=t);null!==u;){var c=u.dependencies;if(null!==c){l=u.child;for(var s=c.firstContext;null!==s;){if(s.context===r&&0!=(s.observedBits&i)){1===u.tag&&((s=ui(n,null)).tag=2,ci(u,s)),u.expirationTime<n&&(u.expirationTime=n),null!==(s=u.alternate)&&s.expirationTime<n&&(s.expirationTime=n),ni(u.return,n),c.expirationTime<n&&(c.expirationTime=n);break}s=s.next}}else l=10===u.tag&&u.type===t.type?null:u.child;if(null!==l)l.return=u;else for(l=u;null!==l;){if(l===t){l=null;break}if(null!==(u=l.sibling)){u.return=l.return,l=u;break}l=l.return}u=l}Ma(e,t,o.children,n),t=t.child}return t;case 9:return o=t.type,r=(i=t.pendingProps).children,ri(t,n),r=r(o=oi(o,i.unstable_observedBits)),t.effectTag|=1,Ma(e,t,r,n),t.child;case 14:return i=Xo(o=t.type,t.pendingProps),Fa(e,t,o,i=Xo(o.type,i),r,n);case 15:return Da(e,t,t.type,t.pendingProps,r,n);case 17:return r=t.type,o=t.pendingProps,o=t.elementType===r?o:Xo(r,o),null!==e&&(e.alternate=null,t.alternate=null,t.effectTag|=2),t.tag=1,go(r)?(e=!0,wo(t)):e=!1,ri(t,n),bi(t,r,o),wi(t,r,o,n),Ia(null,t,r,!0,e,n);case 19:return Qa(e,t,n)}throw Error(a(156,t.tag))};var xu=null,Eu=null;function Cu(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.effectTag=0,this.lastEffect=this.firstEffect=this.nextEffect=null,this.childExpirationTime=this.expirationTime=0,this.alternate=null}function ku(e,t,n,r){return new Cu(e,t,n,r)}function Su(e){return!(!(e=e.prototype)||!e.isReactComponent)}function Ou(e,t){var n=e.alternate;return null===n?((n=ku(e.tag,t,e.key,e.mode)).elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.effectTag=0,n.nextEffect=null,n.firstEffect=null,n.lastEffect=null),n.childExpirationTime=e.childExpirationTime,n.expirationTime=e.expirationTime,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=null===t?null:{expirationTime:t.expirationTime,firstContext:t.firstContext,responders:t.responders},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Tu(e,t,n,r,o,i){var l=2;if(r=e,"function"==typeof e)Su(e)&&(l=1);else if("string"==typeof e)l=5;else e:switch(e){case ne:return _u(n.children,o,i,t);case le:l=8,o|=7;break;case re:l=8,o|=1;break;case oe:return(e=ku(12,n,t,8|o)).elementType=oe,e.type=oe,e.expirationTime=i,e;case ce:return(e=ku(13,n,t,o)).type=ce,e.elementType=ce,e.expirationTime=i,e;case se:return(e=ku(19,n,t,o)).elementType=se,e.expirationTime=i,e;default:if("object"==typeof e&&null!==e)switch(e.$$typeof){case ie:l=10;break e;case ae:l=9;break e;case ue:l=11;break e;case fe:l=14;break e;case pe:l=16,r=null;break e;case de:l=22;break e}throw Error(a(130,null==e?e:typeof e,""))}return(t=ku(l,n,t,o)).elementType=e,t.type=r,t.expirationTime=i,t}function _u(e,t,n,r){return(e=ku(7,e,r,t)).expirationTime=n,e}function Pu(e,t,n){return(e=ku(6,e,null,t)).expirationTime=n,e}function Au(e,t,n){return(t=ku(4,null!==e.children?e.children:[],e.key,t)).expirationTime=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Mu(e,t,n){this.tag=t,this.current=null,this.containerInfo=e,this.pingCache=this.pendingChildren=null,this.finishedExpirationTime=0,this.finishedWork=null,this.timeoutHandle=-1,this.pendingContext=this.context=null,this.hydrate=n,this.callbackNode=null,this.callbackPriority=90,this.lastExpiredTime=this.lastPingedTime=this.nextKnownPendingLevel=this.lastSuspendedTime=this.firstSuspendedTime=this.firstPendingTime=0}function zu(e,t){var n=e.firstSuspendedTime;return e=e.lastSuspendedTime,0!==n&&n>=t&&e<=t}function Fu(e,t){var n=e.firstSuspendedTime,r=e.lastSuspendedTime;n<t&&(e.firstSuspendedTime=t),(r>t||0===n)&&(e.lastSuspendedTime=t),t<=e.lastPingedTime&&(e.lastPingedTime=0),t<=e.lastExpiredTime&&(e.lastExpiredTime=0)}function Du(e,t){t>e.firstPendingTime&&(e.firstPendingTime=t);var n=e.firstSuspendedTime;0!==n&&(t>=n?e.firstSuspendedTime=e.lastSuspendedTime=e.nextKnownPendingLevel=0:t>=e.lastSuspendedTime&&(e.lastSuspendedTime=t+1),t>e.nextKnownPendingLevel&&(e.nextKnownPendingLevel=t))}function ju(e,t){var n=e.lastExpiredTime;(0===n||n>t)&&(e.lastExpiredTime=t)}function Lu(e,t,n,r){var o=t.current,i=Yl(),l=di.suspense;i=Ql(i,o,l);e:if(n){t:{if(Je(n=n._reactInternalFiber)!==n||1!==n.tag)throw Error(a(170));var u=n;do{switch(u.tag){case 3:u=u.stateNode.context;break t;case 1:if(go(u.type)){u=u.stateNode.__reactInternalMemoizedMergedChildContext;break t}}u=u.return}while(null!==u);throw Error(a(171))}if(1===n.tag){var c=n.type;if(go(c)){n=yo(n,c,u);break e}}n=u}else n=so;return null===t.context?t.context=n:t.pendingContext=n,(t=ui(i,l)).payload={element:e},null!==(r=void 0===r?null:r)&&(t.callback=r),ci(o,t),Gl(o,i),i}function Ru(e){if(!(e=e.current).child)return null;switch(e.child.tag){case 5:default:return e.child.stateNode}}function Iu(e,t){null!==(e=e.memoizedState)&&null!==e.dehydrated&&e.retryTime<t&&(e.retryTime=t)}function Nu(e,t){Iu(e,t),(e=e.alternate)&&Iu(e,t)}function Vu(e,t,n){var r=new Mu(e,t,n=null!=n&&!0===n.hydrate),o=ku(3,null,null,2===t?7:1===t?3:0);r.current=o,o.stateNode=r,ai(o),e[On]=r.current,n&&0!==t&&function(e,t){var n=Ze(t);Ot.forEach((function(e){ht(e,t,n)})),Tt.forEach((function(e){ht(e,t,n)}))}(0,9===e.nodeType?e:e.ownerDocument),this._internalRoot=r}function Bu(e){return!(!e||1!==e.nodeType&&9!==e.nodeType&&11!==e.nodeType&&(8!==e.nodeType||" react-mount-point-unstable "!==e.nodeValue))}function Hu(e,t,n,r,o){var i=n._reactRootContainer;if(i){var a=i._internalRoot;if("function"==typeof o){var l=o;o=function(){var e=Ru(a);l.call(e)}}Lu(t,a,e,o)}else{if(i=n._reactRootContainer=function(e,t){if(t||(t=!(!(t=e?9===e.nodeType?e.documentElement:e.firstChild:null)||1!==t.nodeType||!t.hasAttribute("data-reactroot"))),!t)for(var n;n=e.lastChild;)e.removeChild(n);return new Vu(e,0,t?{hydrate:!0}:void 0)}(n,r),a=i._internalRoot,"function"==typeof o){var u=o;o=function(){var e=Ru(a);u.call(e)}}tu((function(){Lu(t,a,e,o)}))}return Ru(a)}function Wu(e,t,n){var r=3<arguments.length&&void 0!==arguments[3]?arguments[3]:null;return{$$typeof:te,key:null==r?null:""+r,children:e,containerInfo:t,implementation:n}}function Uu(e,t){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:null;if(!Bu(t))throw Error(a(200));return Wu(e,t,null,n)}Vu.prototype.render=function(e){Lu(e,this._internalRoot,null,null)},Vu.prototype.unmount=function(){var e=this._internalRoot,t=e.containerInfo;Lu(null,e,null,(function(){t[On]=null}))},mt=function(e){if(13===e.tag){var t=Go(Yl(),150,100);Gl(e,t),Nu(e,t)}},gt=function(e){13===e.tag&&(Gl(e,3),Nu(e,3))},vt=function(e){if(13===e.tag){var t=Yl();Gl(e,t=Ql(t,e,null)),Nu(e,t)}},_=function(e,t,n){switch(t){case"input":if(Se(e,n),t=n.name,"radio"===n.type&&null!=t){for(n=e;n.parentNode;)n=n.parentNode;for(n=n.querySelectorAll("input[name="+JSON.stringify(""+t)+'][type="radio"]'),t=0;t<n.length;t++){var r=n[t];if(r!==e&&r.form===e.form){var o=An(r);if(!o)throw Error(a(90));xe(r),Se(r,o)}}}break;case"textarea":ze(e,n);break;case"select":null!=(t=n.value)&&Pe(e,!!n.multiple,t,!1)}},D=eu,j=function(e,t,n,r,o){var i=Cl;Cl|=4;try{return Wo(98,e.bind(null,t,n,r,o))}finally{0===(Cl=i)&&Yo()}},L=function(){0==(49&Cl)&&(function(){if(null!==Hl){var e=Hl;Hl=null,e.forEach((function(e,t){ju(t,e),Kl(t)})),Yo()}}(),mu())},R=function(e,t){var n=Cl;Cl|=2;try{return e(t)}finally{0===(Cl=n)&&Yo()}};var $u,Yu,Qu={Events:[_n,Pn,An,O,C,Rn,function(e){ot(e,Ln)},z,F,Kt,lt,mu,{current:!1}]};Yu=($u={findFiberByHostInstance:Tn,bundleType:0,version:"16.13.1",rendererPackageName:"react-dom"}).findFiberByHostInstance,function(e){if("undefined"==typeof __REACT_DEVTOOLS_GLOBAL_HOOK__)return!1;var t=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(t.isDisabled||!t.supportsFiber)return!0;try{var n=t.inject(e);xu=function(e){try{t.onCommitFiberRoot(n,e,void 0,64==(64&e.current.effectTag))}catch(e){}},Eu=function(e){try{t.onCommitFiberUnmount(n,e)}catch(e){}}}catch(e){}}(o({},$u,{overrideHookState:null,overrideProps:null,setSuspenseHandler:null,scheduleUpdate:null,currentDispatcherRef:q.ReactCurrentDispatcher,findHostInstanceByFiber:function(e){return null===(e=nt(e))?null:e.stateNode},findFiberByHostInstance:function(e){return Yu?Yu(e):null},findHostInstancesForRefresh:null,scheduleRefresh:null,scheduleRoot:null,setRefreshHandler:null,getCurrentFiber:null})),t.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=Qu,t.createPortal=Uu,t.findDOMNode=function(e){if(null==e)return null;if(1===e.nodeType)return e;var t=e._reactInternalFiber;if(void 0===t){if("function"==typeof e.render)throw Error(a(188));throw Error(a(268,Object.keys(e)))}return e=null===(e=nt(t))?null:e.stateNode},t.flushSync=function(e,t){if(0!=(48&Cl))throw Error(a(187));var n=Cl;Cl|=1;try{return Wo(99,e.bind(null,t))}finally{Cl=n,Yo()}},t.hydrate=function(e,t,n){if(!Bu(t))throw Error(a(200));return Hu(null,e,t,!0,n)},t.render=function(e,t,n){if(!Bu(t))throw Error(a(200));return Hu(null,e,t,!1,n)},t.unmountComponentAtNode=function(e){if(!Bu(e))throw Error(a(40));return!!e._reactRootContainer&&(tu((function(){Hu(null,null,e,!1,(function(){e._reactRootContainer=null,e[On]=null}))})),!0)},t.unstable_batchedUpdates=eu,t.unstable_createPortal=function(e,t){return Uu(e,t,2<arguments.length&&void 0!==arguments[2]?arguments[2]:null)},t.unstable_renderSubtreeIntoContainer=function(e,t,n,r){if(!Bu(n))throw Error(a(200));if(null==e||void 0===e._reactInternalFiber)throw Error(a(38));return Hu(e,t,n,!1,r)},t.version="16.13.1"},function(e,t,n){"use strict";e.exports=n(31)},function(e,t,n){"use strict"; +/** @license React v0.19.1 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var r,o,i,a,l;if("undefined"==typeof window||"function"!=typeof MessageChannel){var u=null,c=null,s=function(){if(null!==u)try{var e=t.unstable_now();u(!0,e),u=null}catch(e){throw setTimeout(s,0),e}},f=Date.now();t.unstable_now=function(){return Date.now()-f},r=function(e){null!==u?setTimeout(r,0,e):(u=e,setTimeout(s,0))},o=function(e,t){c=setTimeout(e,t)},i=function(){clearTimeout(c)},a=function(){return!1},l=t.unstable_forceFrameRate=function(){}}else{var p=window.performance,d=window.Date,h=window.setTimeout,m=window.clearTimeout;if("undefined"!=typeof console){var g=window.cancelAnimationFrame;"function"!=typeof window.requestAnimationFrame&&console.error("This browser doesn't support requestAnimationFrame. Make sure that you load a polyfill in older browsers. https://fb.me/react-polyfills"),"function"!=typeof g&&console.error("This browser doesn't support cancelAnimationFrame. Make sure that you load a polyfill in older browsers. https://fb.me/react-polyfills")}if("object"==typeof p&&"function"==typeof p.now)t.unstable_now=function(){return p.now()};else{var v=d.now();t.unstable_now=function(){return d.now()-v}}var b=!1,y=null,w=-1,x=5,E=0;a=function(){return t.unstable_now()>=E},l=function(){},t.unstable_forceFrameRate=function(e){0>e||125<e?console.error("forceFrameRate takes a positive int between 0 and 125, forcing framerates higher than 125 fps is not unsupported"):x=0<e?Math.floor(1e3/e):5};var C=new MessageChannel,k=C.port2;C.port1.onmessage=function(){if(null!==y){var e=t.unstable_now();E=e+x;try{y(!0,e)?k.postMessage(null):(b=!1,y=null)}catch(e){throw k.postMessage(null),e}}else b=!1},r=function(e){y=e,b||(b=!0,k.postMessage(null))},o=function(e,n){w=h((function(){e(t.unstable_now())}),n)},i=function(){m(w),w=-1}}function S(e,t){var n=e.length;e.push(t);e:for(;;){var r=n-1>>>1,o=e[r];if(!(void 0!==o&&0<_(o,t)))break e;e[r]=t,e[n]=o,n=r}}function O(e){return void 0===(e=e[0])?null:e}function T(e){var t=e[0];if(void 0!==t){var n=e.pop();if(n!==t){e[0]=n;e:for(var r=0,o=e.length;r<o;){var i=2*(r+1)-1,a=e[i],l=i+1,u=e[l];if(void 0!==a&&0>_(a,n))void 0!==u&&0>_(u,a)?(e[r]=u,e[l]=n,r=l):(e[r]=a,e[i]=n,r=i);else{if(!(void 0!==u&&0>_(u,n)))break e;e[r]=u,e[l]=n,r=l}}}return t}return null}function _(e,t){var n=e.sortIndex-t.sortIndex;return 0!==n?n:e.id-t.id}var P=[],A=[],M=1,z=null,F=3,D=!1,j=!1,L=!1;function R(e){for(var t=O(A);null!==t;){if(null===t.callback)T(A);else{if(!(t.startTime<=e))break;T(A),t.sortIndex=t.expirationTime,S(P,t)}t=O(A)}}function I(e){if(L=!1,R(e),!j)if(null!==O(P))j=!0,r(N);else{var t=O(A);null!==t&&o(I,t.startTime-e)}}function N(e,n){j=!1,L&&(L=!1,i()),D=!0;var r=F;try{for(R(n),z=O(P);null!==z&&(!(z.expirationTime>n)||e&&!a());){var l=z.callback;if(null!==l){z.callback=null,F=z.priorityLevel;var u=l(z.expirationTime<=n);n=t.unstable_now(),"function"==typeof u?z.callback=u:z===O(P)&&T(P),R(n)}else T(P);z=O(P)}if(null!==z)var c=!0;else{var s=O(A);null!==s&&o(I,s.startTime-n),c=!1}return c}finally{z=null,F=r,D=!1}}function V(e){switch(e){case 1:return-1;case 2:return 250;case 5:return 1073741823;case 4:return 1e4;default:return 5e3}}var B=l;t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(e){e.callback=null},t.unstable_continueExecution=function(){j||D||(j=!0,r(N))},t.unstable_getCurrentPriorityLevel=function(){return F},t.unstable_getFirstCallbackNode=function(){return O(P)},t.unstable_next=function(e){switch(F){case 1:case 2:case 3:var t=3;break;default:t=F}var n=F;F=t;try{return e()}finally{F=n}},t.unstable_pauseExecution=function(){},t.unstable_requestPaint=B,t.unstable_runWithPriority=function(e,t){switch(e){case 1:case 2:case 3:case 4:case 5:break;default:e=3}var n=F;F=e;try{return t()}finally{F=n}},t.unstable_scheduleCallback=function(e,n,a){var l=t.unstable_now();if("object"==typeof a&&null!==a){var u=a.delay;u="number"==typeof u&&0<u?l+u:l,a="number"==typeof a.timeout?a.timeout:V(e)}else a=V(e),u=l;return e={id:M++,callback:n,priorityLevel:e,startTime:u,expirationTime:a=u+a,sortIndex:-1},u>l?(e.sortIndex=u,S(A,e),null===O(P)&&e===O(A)&&(L?i():L=!0,o(I,u-l))):(e.sortIndex=a,S(P,e),j||D||(j=!0,r(N))),e},t.unstable_shouldYield=function(){var e=t.unstable_now();R(e);var n=O(P);return n!==z&&null!==z&&null!==n&&null!==n.callback&&n.startTime<=e&&n.expirationTime<z.expirationTime||a()},t.unstable_wrapCallback=function(e){var t=F;return function(){var n=F;F=t;try{return e.apply(this,arguments)}finally{F=n}}}},function(e,t,n){"use strict";n.r(t),n.d(t,"props",(function(){return a})),n.d(t,"createShouldForwardProp",(function(){return l}));var r=n(12),o=n(15),i=n(3),a=Object(i.compose)(i.space,i.typography,i.color,i.layout,i.flexbox,i.border,i.background,i.position,i.grid,i.shadow,i.buttonStyle,i.textStyle,i.colorStyle).propNames,l=function(e){var t=new RegExp("^("+e.join("|")+")$");return Object(r.a)((function(e){return Object(o.a)(e)&&!t.test(e)}))};t.default=l(a)},function(e,t,n){"use strict";var r=Array.isArray,o=Object.keys,i=Object.prototype.hasOwnProperty;e.exports=function e(t,n){if(t===n)return!0;if(t&&n&&"object"==typeof t&&"object"==typeof n){var a,l,u,c=r(t),s=r(n);if(c&&s){if((l=t.length)!=n.length)return!1;for(a=l;0!=a--;)if(!e(t[a],n[a]))return!1;return!0}if(c!=s)return!1;var f=t instanceof Date,p=n instanceof Date;if(f!=p)return!1;if(f&&p)return t.getTime()==n.getTime();var d=t instanceof RegExp,h=n instanceof RegExp;if(d!=h)return!1;if(d&&h)return t.toString()==n.toString();var m=o(t);if((l=m.length)!==o(n).length)return!1;for(a=l;0!=a--;)if(!i.call(n,m[a]))return!1;for(a=l;0!=a--;)if(!e(t[u=m[a]],n[u]))return!1;return!0}return t!=t&&n!=n}},function(e,t,n){"use strict";var r=n(35);function o(){}function i(){}i.resetWarningCache=o,e.exports=function(){function e(e,t,n,o,i,a){if(a!==r){var l=new Error("Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types");throw l.name="Invariant Violation",l}}function t(){return e}e.isRequired=e;var n={array:e,bool:e,func:e,number:e,object:e,string:e,symbol:e,any:e,arrayOf:t,element:e,elementType:e,instanceOf:t,node:e,objectOf:t,oneOf:t,oneOfType:t,shape:t,exact:t,checkPropTypes:i,resetWarningCache:o};return n.PropTypes=n,n}},function(e,t,n){"use strict";e.exports="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED"},function(e,t,n){(function(e){var r=void 0!==e&&e||"undefined"!=typeof self&&self||window,o=Function.prototype.apply;function i(e,t){this._id=e,this._clearFn=t}t.setTimeout=function(){return new i(o.call(setTimeout,r,arguments),clearTimeout)},t.setInterval=function(){return new i(o.call(setInterval,r,arguments),clearInterval)},t.clearTimeout=t.clearInterval=function(e){e&&e.close()},i.prototype.unref=i.prototype.ref=function(){},i.prototype.close=function(){this._clearFn.call(r,this._id)},t.enroll=function(e,t){clearTimeout(e._idleTimeoutId),e._idleTimeout=t},t.unenroll=function(e){clearTimeout(e._idleTimeoutId),e._idleTimeout=-1},t._unrefActive=t.active=function(e){clearTimeout(e._idleTimeoutId);var t=e._idleTimeout;t>=0&&(e._idleTimeoutId=setTimeout((function(){e._onTimeout&&e._onTimeout()}),t))},n(37),t.setImmediate="undefined"!=typeof self&&self.setImmediate||void 0!==e&&e.setImmediate||this&&this.setImmediate,t.clearImmediate="undefined"!=typeof self&&self.clearImmediate||void 0!==e&&e.clearImmediate||this&&this.clearImmediate}).call(this,n(19))},function(e,t,n){(function(e,t){!function(e,n){"use strict";if(!e.setImmediate){var r,o,i,a,l,u=1,c={},s=!1,f=e.document,p=Object.getPrototypeOf&&Object.getPrototypeOf(e);p=p&&p.setTimeout?p:e,"[object process]"==={}.toString.call(e.process)?r=function(e){t.nextTick((function(){h(e)}))}:!function(){if(e.postMessage&&!e.importScripts){var t=!0,n=e.onmessage;return e.onmessage=function(){t=!1},e.postMessage("","*"),e.onmessage=n,t}}()?e.MessageChannel?((i=new MessageChannel).port1.onmessage=function(e){h(e.data)},r=function(e){i.port2.postMessage(e)}):f&&"onreadystatechange"in f.createElement("script")?(o=f.documentElement,r=function(e){var t=f.createElement("script");t.onreadystatechange=function(){h(e),t.onreadystatechange=null,o.removeChild(t),t=null},o.appendChild(t)}):r=function(e){setTimeout(h,0,e)}:(a="setImmediate$"+Math.random()+"$",l=function(t){t.source===e&&"string"==typeof t.data&&0===t.data.indexOf(a)&&h(+t.data.slice(a.length))},e.addEventListener?e.addEventListener("message",l,!1):e.attachEvent("onmessage",l),r=function(t){e.postMessage(a+t,"*")}),p.setImmediate=function(e){"function"!=typeof e&&(e=new Function(""+e));for(var t=new Array(arguments.length-1),n=0;n<t.length;n++)t[n]=arguments[n+1];var o={callback:e,args:t};return c[u]=o,r(u),u++},p.clearImmediate=d}function d(e){delete c[e]}function h(e){if(s)setTimeout(h,0,e);else{var t=c[e];if(t){s=!0;try{!function(e){var t=e.callback,n=e.args;switch(n.length){case 0:t();break;case 1:t(n[0]);break;case 2:t(n[0],n[1]);break;case 3:t(n[0],n[1],n[2]);break;default:t.apply(void 0,n)}}(t)}finally{d(e),s=!1}}}}}("undefined"==typeof self?void 0===e?this:e:self)}).call(this,n(19),n(38))},function(e,t){var n,r,o=e.exports={};function i(){throw new Error("setTimeout has not been defined")}function a(){throw new Error("clearTimeout has not been defined")}function l(e){if(n===setTimeout)return setTimeout(e,0);if((n===i||!n)&&setTimeout)return n=setTimeout,setTimeout(e,0);try{return n(e,0)}catch(t){try{return n.call(null,e,0)}catch(t){return n.call(this,e,0)}}}!function(){try{n="function"==typeof setTimeout?setTimeout:i}catch(e){n=i}try{r="function"==typeof clearTimeout?clearTimeout:a}catch(e){r=a}}();var u,c=[],s=!1,f=-1;function p(){s&&u&&(s=!1,u.length?c=u.concat(c):f=-1,c.length&&d())}function d(){if(!s){var e=l(p);s=!0;for(var t=c.length;t;){for(u=c,c=[];++f<t;)u&&u[f].run();f=-1,t=c.length}u=null,s=!1,function(e){if(r===clearTimeout)return clearTimeout(e);if((r===a||!r)&&clearTimeout)return r=clearTimeout,clearTimeout(e);try{r(e)}catch(t){try{return r.call(null,e)}catch(t){return r.call(this,e)}}}(e)}}function h(e,t){this.fun=e,this.array=t}function m(){}o.nextTick=function(e){var t=new Array(arguments.length-1);if(arguments.length>1)for(var n=1;n<arguments.length;n++)t[n-1]=arguments[n];c.push(new h(e,t)),1!==c.length||s||l(d)},h.prototype.run=function(){this.fun.apply(null,this.array)},o.title="browser",o.browser=!0,o.env={},o.argv=[],o.version="",o.versions={},o.on=m,o.addListener=m,o.once=m,o.off=m,o.removeListener=m,o.removeAllListeners=m,o.emit=m,o.prependListener=m,o.prependOnceListener=m,o.listeners=function(e){return[]},o.binding=function(e){throw new Error("process.binding is not supported")},o.cwd=function(){return"/"},o.chdir=function(e){throw new Error("process.chdir is not supported")},o.umask=function(){return 0}},function(e,t,n){"use strict";e.exports=n(40)},function(e,t,n){"use strict"; +/** @license React v16.13.1 + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var r="function"==typeof Symbol&&Symbol.for,o=r?Symbol.for("react.element"):60103,i=r?Symbol.for("react.portal"):60106,a=r?Symbol.for("react.fragment"):60107,l=r?Symbol.for("react.strict_mode"):60108,u=r?Symbol.for("react.profiler"):60114,c=r?Symbol.for("react.provider"):60109,s=r?Symbol.for("react.context"):60110,f=r?Symbol.for("react.async_mode"):60111,p=r?Symbol.for("react.concurrent_mode"):60111,d=r?Symbol.for("react.forward_ref"):60112,h=r?Symbol.for("react.suspense"):60113,m=r?Symbol.for("react.suspense_list"):60120,g=r?Symbol.for("react.memo"):60115,v=r?Symbol.for("react.lazy"):60116,b=r?Symbol.for("react.block"):60121,y=r?Symbol.for("react.fundamental"):60117,w=r?Symbol.for("react.responder"):60118,x=r?Symbol.for("react.scope"):60119;function E(e){if("object"==typeof e&&null!==e){var t=e.$$typeof;switch(t){case o:switch(e=e.type){case f:case p:case a:case u:case l:case h:return e;default:switch(e=e&&e.$$typeof){case s:case d:case v:case g:case c:return e;default:return t}}case i:return t}}}function C(e){return E(e)===p}t.AsyncMode=f,t.ConcurrentMode=p,t.ContextConsumer=s,t.ContextProvider=c,t.Element=o,t.ForwardRef=d,t.Fragment=a,t.Lazy=v,t.Memo=g,t.Portal=i,t.Profiler=u,t.StrictMode=l,t.Suspense=h,t.isAsyncMode=function(e){return C(e)||E(e)===f},t.isConcurrentMode=C,t.isContextConsumer=function(e){return E(e)===s},t.isContextProvider=function(e){return E(e)===c},t.isElement=function(e){return"object"==typeof e&&null!==e&&e.$$typeof===o},t.isForwardRef=function(e){return E(e)===d},t.isFragment=function(e){return E(e)===a},t.isLazy=function(e){return E(e)===v},t.isMemo=function(e){return E(e)===g},t.isPortal=function(e){return E(e)===i},t.isProfiler=function(e){return E(e)===u},t.isStrictMode=function(e){return E(e)===l},t.isSuspense=function(e){return E(e)===h},t.isValidElementType=function(e){return"string"==typeof e||"function"==typeof e||e===a||e===p||e===u||e===l||e===h||e===m||"object"==typeof e&&null!==e&&(e.$$typeof===v||e.$$typeof===g||e.$$typeof===c||e.$$typeof===s||e.$$typeof===d||e.$$typeof===y||e.$$typeof===w||e.$$typeof===x||e.$$typeof===b)},t.typeOf=E},function(e,t){var n="undefined"!=typeof crypto&&crypto.getRandomValues&&crypto.getRandomValues.bind(crypto)||"undefined"!=typeof msCrypto&&"function"==typeof window.msCrypto.getRandomValues&&msCrypto.getRandomValues.bind(msCrypto);if(n){var r=new Uint8Array(16);e.exports=function(){return n(r),r}}else{var o=new Array(16);e.exports=function(){for(var e,t=0;t<16;t++)0==(3&t)&&(e=4294967296*Math.random()),o[t]=e>>>((3&t)<<3)&255;return o}}},function(e,t){for(var n=[],r=0;r<256;++r)n[r]=(r+256).toString(16).substr(1);e.exports=function(e,t){var r=t||0,o=n;return[o[e[r++]],o[e[r++]],o[e[r++]],o[e[r++]],"-",o[e[r++]],o[e[r++]],"-",o[e[r++]],o[e[r++]],"-",o[e[r++]],o[e[r++]],"-",o[e[r++]],o[e[r++]],o[e[r++]],o[e[r++]],o[e[r++]],o[e[r++]]].join("")}},function(e,t,n){"use strict";n.r(t),n.d(t,"default",(function(){return Xr}));var r=n(0),o=n.n(r),i=n(10),a=n.n(i),l=n(7);function u(){return(u=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}var c=Object(r.forwardRef)((function(e,t){return o.a.createElement(l.Box,u({ref:t,tx:"text"},e))})),s=Object(r.forwardRef)((function(e,t){return o.a.createElement(l.Box,u({ref:t,as:"h2",tx:"text",variant:"heading"},e,{__css:{fontSize:4,fontFamily:"heading",fontWeight:"heading",lineHeight:"heading"}}))})),f=(Object(r.forwardRef)((function(e,t){return o.a.createElement(l.Box,u({ref:t,as:"a",variant:"link"},e))})),Object(r.forwardRef)((function(e,t){return o.a.createElement(l.Box,u({ref:t,as:"button",tx:"buttons",variant:"primary"},e,{__css:{appearance:"none",display:"inline-block",textAlign:"center",lineHeight:"inherit",textDecoration:"none",fontSize:"inherit",px:3,py:2,color:"white",bg:"primary",border:0,borderRadius:4}}))}))),p=Object(r.forwardRef)((function(e,t){return o.a.createElement(l.Box,u({ref:t,as:"img"},e,{__css:{maxWidth:"100%",height:"auto"}}))})),d=Object(r.forwardRef)((function(e,t){return o.a.createElement(l.Box,u({ref:t,variant:"card"},e))})),h=function(){return(h=Object.assign||function(e){for(var t,n=1,r=arguments.length;n<r;n++)for(var o in t=arguments[n])Object.prototype.hasOwnProperty.call(t,o)&&(e[o]=t[o]);return e}).apply(this,arguments)},m=function(e,t){var n={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(r=Object.getOwnPropertySymbols(e);o<r.length;o++)t.indexOf(r[o])<0&&Object.prototype.propertyIsEnumerable.call(e,r[o])&&(n[r[o]]=e[r[o]])}return n};var g=function(e){var t,n=e.sx,o=e.variant,i=void 0===o?"medium":o,a=e.children,u=e.username,c=m(e,["sx","variant","children","username"]);return r.createElement(l.Flex,h({variant:"avatar."+i,justifyContent:"center",alignItems:"center",fontSize:4,color:"neutral_0",bg:"cyan_2"},c,{sx:h({textAlign:"center",borderRadius:"50%",lineHeight:"1em"},n||{})}),a||((t=u)?t.split(" ").slice(0,2).map((function(e){return e.slice(0,1).toUpperCase()})).join(""):null)||null)},v=n(22),b=n.n(v),y=function(){return(y=Object.assign||function(e){for(var t,n=1,r=arguments.length;n<r;n++)for(var o in t=arguments[n])Object.prototype.hasOwnProperty.call(t,o)&&(e[o]=t[o]);return e}).apply(this,arguments)},w=function(e,t){var n={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(r=Object.getOwnPropertySymbols(e);o<r.length;o++)t.indexOf(r[o])<0&&Object.prototype.propertyIsEnumerable.call(e,r[o])&&(n[r[o]]=e[r[o]])}return n};var x,E=function(e){var t=e.showWhenDisabled,n=e.active,o=e.fullWidth,i=e.large,a=e.block,l=e.variant,u=void 0===l?"primary":l,c=w(e,["showWhenDisabled","active","fullWidth","large","block","variant"]),s=("icon"===u||"headerIcon"===u)&&!t,p=c.disabled||!c.onClick&&!c.href;return r.createElement(f,y({as:"button",variant:"button."+u,className:b()({active:n,large:i,disabled:c.disabled}),sx:y(y({},a?{borderRadius:0}:{}),{alignSelf:"center",cursor:p?"not-allowed":"pointer",letterSpacing:0,position:"relative",transition:"all .2s ease","&:disabled, &.disabled":{boxShadow:"none",display:s?"none":void 0},width:o?"100%":null})},c))},C=n(11),k=n(23),S=n.n(k),O=function(e,t){return Object.defineProperty?Object.defineProperty(e,"raw",{value:t}):e.raw=t,e},T=function(e,t){var n="function"==typeof Symbol&&e[Symbol.iterator];if(!n)return e;var r,o,i=n.call(e),a=[];try{for(;(void 0===t||t-- >0)&&!(r=i.next()).done;)a.push(r.value)}catch(e){o={error:e}}finally{try{r&&!r.done&&(n=i.return)&&n.call(i)}finally{if(o)throw o.error}}return a},_=Object(C.default)(S.a)(x||(x=O(["\n height: 100%;\n overflow: auto;\n display: inline-block;\n width: 100%;\n height: max-content;\n min-height: 24px;\n line-height: 24px;\n font-size: 14px;\n white-space: pre-wrap;\n word-wrap: break-word;\n user-select: text;\n outline: none;\n color: #252d40;\n\n &:empty:before {\n content: attr(placeholder);\n position: absolute;\n color: #979fb4;\n pointer-events: none;\n }\n"],["\n height: 100%;\n overflow: auto;\n display: inline-block;\n width: 100%;\n height: max-content;\n min-height: 24px;\n line-height: 24px;\n font-size: 14px;\n white-space: pre-wrap;\n word-wrap: break-word;\n user-select: text;\n outline: none;\n color: #252d40;\n\n &:empty:before {\n content: attr(placeholder);\n position: absolute;\n color: #979fb4;\n pointer-events: none;\n }\n"]))),P=r.memo((function(e){var t=e.autoFocus,n=void 0===t||t,o=e.onSendMessage,i=T(r.useState(""),2),a=i[0],u=i[1],c=r.useRef(null);r.useEffect((function(){n&&c.current&&c.current.focus()}),[n]);var s=r.useCallback((function(){a&&(o(a),u(""))}),[a,o]),f=r.useCallback((function(){u(""),o("/restart")}),[o]),p=r.useCallback((function(e){"<br>"!==e.target.value?u(e.target.value):u("")}),[u]);return r.createElement(l.Flex,{flex:1,sx:{height:"72px"},onKeyDown:function(e){s&&"Enter"===e.key&&(e.preventDefault(),s())}},r.createElement(l.Flex,{flex:1,pl:4,alignItems:"center",sx:{overflow:"auto"}},r.createElement(_,{placeholder:"Start typing a message…",html:a,onChange:p})),r.createElement(l.Flex,null,r.createElement(E,{variant:"headerIcon",p:"28px",onClick:f},r.createElement(K,{icon:"sync"})),r.createElement(l.Box,null,r.createElement(E,{variant:"headerIcon",p:"28px",marginRight:2,disabled:""===a,showWhenDisabled:!0,onClick:s},r.createElement(K,{icon:"paper-plane"})))))})),A=n(13),M=n(2),z=n.n(M);function F(e){return(F="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function D(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function j(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function L(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?j(Object(n),!0).forEach((function(t){D(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):j(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function R(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function I(e){return function(e){if(Array.isArray(e)){for(var t=0,n=new Array(e.length);t<e.length;t++)n[t]=e[t];return n}}(e)||function(e){if(Symbol.iterator in Object(e)||"[object Arguments]"===Object.prototype.toString.call(e))return Array.from(e)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance")}()}function N(e){return t=e,(t-=0)==t?e:(e=e.replace(/[\-_\s]+(.)?/g,(function(e,t){return t?t.toUpperCase():""}))).substr(0,1).toLowerCase()+e.substr(1);var t}function V(e){return e.split(";").map((function(e){return e.trim()})).filter((function(e){return e})).reduce((function(e,t){var n,r=t.indexOf(":"),o=N(t.slice(0,r)),i=t.slice(r+1).trim();return o.startsWith("webkit")?e[(n=o,n.charAt(0).toUpperCase()+n.slice(1))]=i:e[o]=i,e}),{})}var B=!1;try{B=!0}catch(e){}function H(e){return null===e?null:"object"===F(e)&&e.prefix&&e.iconName?e:Array.isArray(e)&&2===e.length?{prefix:e[0],iconName:e[1]}:"string"==typeof e?{prefix:"fas",iconName:e}:void 0}function W(e,t){return Array.isArray(t)&&t.length>0||!Array.isArray(t)&&t?D({},e,t):{}}function U(e){var t=e.icon,n=e.mask,r=e.symbol,o=e.className,i=e.title,a=H(t),l=W("classes",[].concat(I(function(e){var t,n=e.spin,r=e.pulse,o=e.fixedWidth,i=e.inverse,a=e.border,l=e.listItem,u=e.flip,c=e.size,s=e.rotation,f=e.pull,p=(D(t={"fa-spin":n,"fa-pulse":r,"fa-fw":o,"fa-inverse":i,"fa-border":a,"fa-li":l,"fa-flip-horizontal":"horizontal"===u||"both"===u,"fa-flip-vertical":"vertical"===u||"both"===u},"fa-".concat(c),null!=c),D(t,"fa-rotate-".concat(s),null!=s),D(t,"fa-pull-".concat(f),null!=f),D(t,"fa-swap-opacity",e.swapOpacity),t);return Object.keys(p).map((function(e){return p[e]?e:null})).filter((function(e){return e}))}(e)),I(o.split(" ")))),u=W("transform","string"==typeof e.transform?A.c.transform(e.transform):e.transform),c=W("mask",H(n)),s=Object(A.a)(a,L({},l,{},u,{},c,{symbol:r,title:i}));if(!s)return function(){var e;!B&&console&&"function"==typeof console.error&&(e=console).error.apply(e,arguments)}("Could not find icon",a),null;var f=s.abstract,p={};return Object.keys(e).forEach((function(t){U.defaultProps.hasOwnProperty(t)||(p[t]=e[t])})),$(f[0],p)}U.displayName="FontAwesomeIcon",U.propTypes={border:z.a.bool,className:z.a.string,mask:z.a.oneOfType([z.a.object,z.a.array,z.a.string]),fixedWidth:z.a.bool,inverse:z.a.bool,flip:z.a.oneOf(["horizontal","vertical","both"]),icon:z.a.oneOfType([z.a.object,z.a.array,z.a.string]),listItem:z.a.bool,pull:z.a.oneOf(["right","left"]),pulse:z.a.bool,rotation:z.a.oneOf([90,180,270]),size:z.a.oneOf(["lg","xs","sm","1x","2x","3x","4x","5x","6x","7x","8x","9x","10x"]),spin:z.a.bool,symbol:z.a.oneOfType([z.a.bool,z.a.string]),title:z.a.string,transform:z.a.oneOfType([z.a.string,z.a.object]),swapOpacity:z.a.bool},U.defaultProps={border:!1,className:"",mask:null,fixedWidth:!1,inverse:!1,flip:null,icon:null,listItem:!1,pull:null,pulse:!1,rotation:null,size:null,spin:!1,symbol:!1,title:"",transform:null,swapOpacity:!1};var $=function e(t,n){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};if("string"==typeof n)return n;var o=(n.children||[]).map((function(n){return e(t,n)})),i=Object.keys(n.attributes||{}).reduce((function(e,t){var r=n.attributes[t];switch(t){case"class":e.attrs.className=r,delete n.attributes.class;break;case"style":e.attrs.style=V(r);break;default:0===t.indexOf("aria-")||0===t.indexOf("data-")?e.attrs[t.toLowerCase()]=r:e.attrs[N(t)]=r}return e}),{attrs:{}}),a=r.style,l=void 0===a?{}:a,u=R(r,["style"]);return i.attrs.style=L({},i.attrs.style,{},l),t.apply(void 0,[n.tag,L({},i.attrs,{},u)].concat(I(o)))}.bind(null,o.a.createElement),Y=n(3),Q=function(){return(Q=Object.assign||function(e){for(var t,n=1,r=arguments.length;n<r;n++)for(var o in t=arguments[n])Object.prototype.hasOwnProperty.call(t,o)&&(e[o]=t[o]);return e}).apply(this,arguments)},G=function(e,t){var n={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(r=Object.getOwnPropertySymbols(e);o<r.length;o++)t.indexOf(r[o])<0&&Object.prototype.propertyIsEnumerable.call(e,r[o])&&(n[r[o]]=e[r[o]])}return n},X=Object(C.default)((function(e){e.color,e.fontSize,e.opacity;var t=G(e,["color","fontSize","opacity"]);return r.createElement(U,Q({},t))}))(Object(Y.compose)(Y.space,Y.color,Y.fontSize));function q(e){var t=e.icon,n=G(e,["icon"]),o="string"==typeof t?["fal",t]:t;return r.createElement(X,Q({icon:o,style:{width:"1em"}},n))}q.defaultProps={color:"inherit",fontSize:"inherit"};var K=q,Z=function(){return(Z=Object.assign||function(e){for(var t,n=1,r=arguments.length;n<r;n++)for(var o in t=arguments[n])Object.prototype.hasOwnProperty.call(t,o)&&(e[o]=t[o]);return e}).apply(this,arguments)},J=function(e,t){var n={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(r=Object.getOwnPropertySymbols(e);o<r.length;o++)t.indexOf(r[o])<0&&Object.prototype.propertyIsEnumerable.call(e,r[o])&&(n[r[o]]=e[r[o]])}return n},ee=function(e,t){var n="function"==typeof Symbol&&e[Symbol.iterator];if(!n)return e;var r,o,i=n.call(e),a=[];try{for(;(void 0===t||t-- >0)&&!(r=i.next()).done;)a.push(r.value)}catch(e){o={error:e}}finally{try{r&&!r.done&&(n=i.return)&&n.call(i)}finally{if(o)throw o.error}}return a};var te=function(e){e.ref;var t=e.src,n=e.placeholder,o=J(e,["ref","src","placeholder"]),i=ee(r.useState(!1),2),a=i[0],l=i[1];return r.useEffect((function(){var e,n=new Image;t&&(e=ee([t,function(){return l(!0)},function(){return l(!1)}],3),n.src=e[0],n.onerror=e[1],n.onload=e[2])}),[t]),r.createElement(r.Fragment,null,a&&n?n:r.createElement(p,Z({src:t},o)))};var ne=function(){return(ne=Object.assign||function(e){for(var t,n=1,r=arguments.length;n<r;n++)for(var o in t=arguments[n])Object.prototype.hasOwnProperty.call(t,o)&&(e[o]=t[o]);return e}).apply(this,arguments)},re=function(e,t){var n={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(r=Object.getOwnPropertySymbols(e);o<r.length;o++)t.indexOf(r[o])<0&&Object.prototype.propertyIsEnumerable.call(e,r[o])&&(n[r[o]]=e[r[o]])}return n};var oe=function(e){var t=e.sx,n=e.loading,o=e.inheritColor,i=e.variant,a=void 0===i?"bodyPrimary":i,l=re(e,["sx","loading","inheritColor","variant"]);return r.createElement(c,ne({as:"code"===a?"code":"p",variant:"text."+a},l,{sx:ne(ne(ne({display:"block",fontWeight:400,letterSpacing:n?"0px important":0,m:0,maxWidth:"100%",overflow:"hidden",textOverflow:"ellipsis"},o?{color:"inherit !important"}:{}),n?{color:"neutral_1 !important"}:{}),t||{})}))},ie=n(1),ae=function(){};function le(e,t){return t?"-"===t[0]?e+t:e+"__"+t:e}function ue(e,t,n){var r=[n];if(t&&e)for(var o in t)t.hasOwnProperty(o)&&t[o]&&r.push(""+le(e,o));return r.filter((function(e){return e})).map((function(e){return String(e).trim()})).join(" ")}var ce=function(e){return Array.isArray(e)?e.filter(Boolean):"object"==typeof e&&null!==e?[e]:[]};function se(e){return[document.documentElement,document.body,window].indexOf(e)>-1}function fe(e){return se(e)?window.pageYOffset:e.scrollTop}function pe(e,t){se(e)?window.scrollTo(0,t):e.scrollTop=t}function de(e,t,n,r){void 0===n&&(n=200),void 0===r&&(r=ae);var o=fe(e),i=t-o,a=0;!function t(){var l,u=i*((l=(l=a+=10)/n-1)*l*l+1)+o;pe(e,u),a<n?window.requestAnimationFrame(t):r(e)}()}function he(){try{return document.createEvent("TouchEvent"),!0}catch(e){return!1}}var me=n(6),ge=n(20),ve=n.n(ge);function be(){return(be=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}function ye(e,t){e.prototype=Object.create(t.prototype),e.prototype.constructor=e,e.__proto__=t}function we(e){var t=e.maxHeight,n=e.menuEl,r=e.minHeight,o=e.placement,i=e.shouldScroll,a=e.isFixedPosition,l=e.theme.spacing,u=function(e){var t=getComputedStyle(e),n="absolute"===t.position,r=/(auto|scroll)/,o=document.documentElement;if("fixed"===t.position)return o;for(var i=e;i=i.parentElement;)if(t=getComputedStyle(i),(!n||"static"!==t.position)&&r.test(t.overflow+t.overflowY+t.overflowX))return i;return o}(n),c={placement:"bottom",maxHeight:t};if(!n||!n.offsetParent)return c;var s=u.getBoundingClientRect().height,f=n.getBoundingClientRect(),p=f.bottom,d=f.height,h=f.top,m=n.offsetParent.getBoundingClientRect().top,g=window.innerHeight,v=fe(u),b=parseInt(getComputedStyle(n).marginBottom,10),y=parseInt(getComputedStyle(n).marginTop,10),w=m-y,x=g-h,E=w+v,C=s-v-h,k=p-g+v+b,S=v+h-y;switch(o){case"auto":case"bottom":if(x>=d)return{placement:"bottom",maxHeight:t};if(C>=d&&!a)return i&&de(u,k,160),{placement:"bottom",maxHeight:t};if(!a&&C>=r||a&&x>=r)return i&&de(u,k,160),{placement:"bottom",maxHeight:a?x-b:C-b};if("auto"===o||a){var O=t,T=a?w:E;return T>=r&&(O=Math.min(T-b-l.controlHeight,t)),{placement:"top",maxHeight:O}}if("bottom"===o)return pe(u,k),{placement:"bottom",maxHeight:t};break;case"top":if(w>=d)return{placement:"top",maxHeight:t};if(E>=d&&!a)return i&&de(u,S,160),{placement:"top",maxHeight:t};if(!a&&E>=r||a&&w>=r){var _=t;return(!a&&E>=r||a&&w>=r)&&(_=a?w-y:E-y),i&&de(u,S,160),{placement:"top",maxHeight:_}}return{placement:"bottom",maxHeight:t};default:throw new Error('Invalid placement provided "'+o+'".')}return c}var xe=function(e){return"auto"===e?"bottom":e},Ee=function(e){function t(){for(var t,n=arguments.length,r=new Array(n),o=0;o<n;o++)r[o]=arguments[o];return(t=e.call.apply(e,[this].concat(r))||this).state={maxHeight:t.props.maxMenuHeight,placement:null},t.getPlacement=function(e){var n=t.props,r=n.minMenuHeight,o=n.maxMenuHeight,i=n.menuPlacement,a=n.menuPosition,l=n.menuShouldScrollIntoView,u=n.theme,c=t.context.getPortalPlacement;if(e){var s="fixed"===a,f=we({maxHeight:o,menuEl:e,minHeight:r,placement:i,shouldScroll:l&&!s,isFixedPosition:s,theme:u});c&&c(f),t.setState(f)}},t.getUpdatedProps=function(){var e=t.props.menuPlacement,n=t.state.placement||xe(e);return be({},t.props,{placement:n,maxHeight:t.state.maxHeight})},t}return ye(t,e),t.prototype.render=function(){return(0,this.props.children)({ref:this.getPlacement,placerProps:this.getUpdatedProps()})},t}(r.Component);Ee.contextTypes={getPortalPlacement:z.a.func};var Ce=function(e){var t=e.theme,n=t.spacing.baseUnit;return{color:t.colors.neutral40,padding:2*n+"px "+3*n+"px",textAlign:"center"}},ke=Ce,Se=Ce,Oe=function(e){var t=e.children,n=e.className,r=e.cx,o=e.getStyles,i=e.innerProps;return Object(ie.d)("div",be({css:o("noOptionsMessage",e),className:r({"menu-notice":!0,"menu-notice--no-options":!0},n)},i),t)};Oe.defaultProps={children:"No options"};var Te=function(e){var t=e.children,n=e.className,r=e.cx,o=e.getStyles,i=e.innerProps;return Object(ie.d)("div",be({css:o("loadingMessage",e),className:r({"menu-notice":!0,"menu-notice--loading":!0},n)},i),t)};Te.defaultProps={children:"Loading..."};var _e=function(e){function t(){for(var t,n=arguments.length,r=new Array(n),o=0;o<n;o++)r[o]=arguments[o];return(t=e.call.apply(e,[this].concat(r))||this).state={placement:null},t.getPortalPlacement=function(e){var n=e.placement;n!==xe(t.props.menuPlacement)&&t.setState({placement:n})},t}ye(t,e);var n=t.prototype;return n.getChildContext=function(){return{getPortalPlacement:this.getPortalPlacement}},n.render=function(){var e=this.props,t=e.appendTo,n=e.children,r=e.controlElement,o=e.menuPlacement,a=e.menuPosition,l=e.getStyles,u="fixed"===a;if(!t&&!u||!r)return null;var c=this.state.placement||xe(o),s=function(e){var t=e.getBoundingClientRect();return{bottom:t.bottom,height:t.height,left:t.left,right:t.right,top:t.top,width:t.width}}(r),f=u?0:window.pageYOffset,p={offset:s[c]+f,position:a,rect:s},d=Object(ie.d)("div",{css:l("menuPortal",p)},n);return t?Object(i.createPortal)(d,t):d},t}(r.Component);_e.childContextTypes={getPortalPlacement:z.a.func};var Pe=Array.isArray,Ae=Object.keys,Me=Object.prototype.hasOwnProperty;function ze(e,t){try{return function e(t,n){if(t===n)return!0;if(t&&n&&"object"==typeof t&&"object"==typeof n){var r,o,i,a=Pe(t),l=Pe(n);if(a&&l){if((o=t.length)!=n.length)return!1;for(r=o;0!=r--;)if(!e(t[r],n[r]))return!1;return!0}if(a!=l)return!1;var u=t instanceof Date,c=n instanceof Date;if(u!=c)return!1;if(u&&c)return t.getTime()==n.getTime();var s=t instanceof RegExp,f=n instanceof RegExp;if(s!=f)return!1;if(s&&f)return t.toString()==n.toString();var p=Ae(t);if((o=p.length)!==Ae(n).length)return!1;for(r=o;0!=r--;)if(!Me.call(n,p[r]))return!1;for(r=o;0!=r--;)if(!("_owner"===(i=p[r])&&t.$$typeof||e(t[i],n[i])))return!1;return!0}return t!=t&&n!=n}(e,t)}catch(e){if(e.message&&e.message.match(/stack|recursion/i))return console.warn("Warning: react-fast-compare does not handle circular references.",e.name,e.message),!1;throw e}}function Fe(){return(Fe=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}function De(){var e=function(e,t){t||(t=e.slice(0));return e.raw=t,e}(["\n 0%, 80%, 100% { opacity: 0; }\n 40% { opacity: 1; }\n"]);return De=function(){return e},e}function je(){return(je=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}var Le={name:"19bqh2r",styles:"display:inline-block;fill:currentColor;line-height:1;stroke:currentColor;stroke-width:0;"},Re=function(e){var t=e.size,n=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,["size"]);return Object(ie.d)("svg",je({height:t,width:t,viewBox:"0 0 20 20","aria-hidden":"true",focusable:"false",css:Le},n))},Ie=function(e){return Object(ie.d)(Re,je({size:20},e),Object(ie.d)("path",{d:"M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z"}))},Ne=function(e){return Object(ie.d)(Re,je({size:20},e),Object(ie.d)("path",{d:"M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z"}))},Ve=function(e){var t=e.isFocused,n=e.theme,r=n.spacing.baseUnit,o=n.colors;return{label:"indicatorContainer",color:t?o.neutral60:o.neutral20,display:"flex",padding:2*r,transition:"color 150ms",":hover":{color:t?o.neutral80:o.neutral40}}},Be=Ve,He=Ve,We=Object(ie.e)(De()),Ue=function(e){var t=e.delay,n=e.offset;return Object(ie.d)("span",{css:Object(me.a)({animation:We+" 1s ease-in-out "+t+"ms infinite;",backgroundColor:"currentColor",borderRadius:"1em",display:"inline-block",marginLeft:n?"1em":null,height:"1em",verticalAlign:"top",width:"1em"},"")})},$e=function(e){var t=e.className,n=e.cx,r=e.getStyles,o=e.innerProps,i=e.isRtl;return Object(ie.d)("div",je({},o,{css:r("loadingIndicator",e),className:n({indicator:!0,"loading-indicator":!0},t)}),Object(ie.d)(Ue,{delay:0,offset:i}),Object(ie.d)(Ue,{delay:160,offset:!0}),Object(ie.d)(Ue,{delay:320,offset:!i}))};function Ye(){return(Ye=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}$e.defaultProps={size:4};function Qe(){return(Qe=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}function Ge(){return(Ge=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}var Xe=function(e){return{label:"input",background:0,border:0,fontSize:"inherit",opacity:e?0:1,outline:0,padding:0,color:"inherit"}};function qe(){return(qe=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}var Ke=function(e){var t=e.children,n=e.innerProps;return Object(ie.d)("div",n,t)},Ze=Ke,Je=Ke;var et=function(e){var t=e.children,n=e.className,r=e.components,o=e.cx,i=e.data,a=e.getStyles,l=e.innerProps,u=e.isDisabled,c=e.removeProps,s=e.selectProps,f=r.Container,p=r.Label,d=r.Remove;return Object(ie.d)(ie.b,null,(function(r){var h=r.css,m=r.cx;return Object(ie.d)(f,{data:i,innerProps:qe({},l,{className:m(h(a("multiValue",e)),o({"multi-value":!0,"multi-value--is-disabled":u},n))}),selectProps:s},Object(ie.d)(p,{data:i,innerProps:{className:m(h(a("multiValueLabel",e)),o({"multi-value__label":!0},n))},selectProps:s},t),Object(ie.d)(d,{data:i,innerProps:qe({className:m(h(a("multiValueRemove",e)),o({"multi-value__remove":!0},n))},c),selectProps:s}))}))};function tt(){return(tt=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}et.defaultProps={cropWithEllipsis:!0};function nt(){return(nt=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}function rt(){return(rt=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}function ot(){return(ot=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}var it={ClearIndicator:function(e){var t=e.children,n=e.className,r=e.cx,o=e.getStyles,i=e.innerProps;return Object(ie.d)("div",je({},i,{css:o("clearIndicator",e),className:r({indicator:!0,"clear-indicator":!0},n)}),t||Object(ie.d)(Ie,null))},Control:function(e){var t=e.children,n=e.cx,r=e.getStyles,o=e.className,i=e.isDisabled,a=e.isFocused,l=e.innerRef,u=e.innerProps,c=e.menuIsOpen;return Object(ie.d)("div",Ye({ref:l,css:r("control",e),className:n({control:!0,"control--is-disabled":i,"control--is-focused":a,"control--menu-is-open":c},o)},u),t)},DropdownIndicator:function(e){var t=e.children,n=e.className,r=e.cx,o=e.getStyles,i=e.innerProps;return Object(ie.d)("div",je({},i,{css:o("dropdownIndicator",e),className:r({indicator:!0,"dropdown-indicator":!0},n)}),t||Object(ie.d)(Ne,null))},DownChevron:Ne,CrossIcon:Ie,Group:function(e){var t=e.children,n=e.className,r=e.cx,o=e.getStyles,i=e.Heading,a=e.headingProps,l=e.label,u=e.theme,c=e.selectProps;return Object(ie.d)("div",{css:o("group",e),className:r({group:!0},n)},Object(ie.d)(i,Qe({},a,{selectProps:c,theme:u,getStyles:o,cx:r}),l),Object(ie.d)("div",null,t))},GroupHeading:function(e){var t=e.className,n=e.cx,r=e.getStyles,o=e.theme,i=(e.selectProps,function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,["className","cx","getStyles","theme","selectProps"]));return Object(ie.d)("div",Qe({css:r("groupHeading",Qe({theme:o},i)),className:n({"group-heading":!0},t)},i))},IndicatorsContainer:function(e){var t=e.children,n=e.className,r=e.cx,o=e.getStyles;return Object(ie.d)("div",{css:o("indicatorsContainer",e),className:r({indicators:!0},n)},t)},IndicatorSeparator:function(e){var t=e.className,n=e.cx,r=e.getStyles,o=e.innerProps;return Object(ie.d)("span",je({},o,{css:r("indicatorSeparator",e),className:n({"indicator-separator":!0},t)}))},Input:function(e){var t=e.className,n=e.cx,r=e.getStyles,o=e.innerRef,i=e.isHidden,a=e.isDisabled,l=e.theme,u=(e.selectProps,function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,["className","cx","getStyles","innerRef","isHidden","isDisabled","theme","selectProps"]));return Object(ie.d)("div",{css:r("input",Ge({theme:l},u))},Object(ie.d)(ve.a,Ge({className:n({input:!0},t),inputRef:o,inputStyle:Xe(i),disabled:a},u)))},LoadingIndicator:$e,Menu:function(e){var t=e.children,n=e.className,r=e.cx,o=e.getStyles,i=e.innerRef,a=e.innerProps;return Object(ie.d)("div",be({css:o("menu",e),className:r({menu:!0},n)},a,{ref:i}),t)},MenuList:function(e){var t=e.children,n=e.className,r=e.cx,o=e.getStyles,i=e.isMulti,a=e.innerRef;return Object(ie.d)("div",{css:o("menuList",e),className:r({"menu-list":!0,"menu-list--is-multi":i},n),ref:a},t)},MenuPortal:_e,LoadingMessage:Te,NoOptionsMessage:Oe,MultiValue:et,MultiValueContainer:Ze,MultiValueLabel:Je,MultiValueRemove:function(e){var t=e.children,n=e.innerProps;return Object(ie.d)("div",n,t||Object(ie.d)(Ie,{size:14}))},Option:function(e){var t=e.children,n=e.className,r=e.cx,o=e.getStyles,i=e.isDisabled,a=e.isFocused,l=e.isSelected,u=e.innerRef,c=e.innerProps;return Object(ie.d)("div",tt({css:o("option",e),className:r({option:!0,"option--is-disabled":i,"option--is-focused":a,"option--is-selected":l},n),ref:u},c),t)},Placeholder:function(e){var t=e.children,n=e.className,r=e.cx,o=e.getStyles,i=e.innerProps;return Object(ie.d)("div",nt({css:o("placeholder",e),className:r({placeholder:!0},n)},i),t)},SelectContainer:function(e){var t=e.children,n=e.className,r=e.cx,o=e.getStyles,i=e.innerProps,a=e.isDisabled,l=e.isRtl;return Object(ie.d)("div",Fe({css:o("container",e),className:r({"--is-disabled":a,"--is-rtl":l},n)},i),t)},SingleValue:function(e){var t=e.children,n=e.className,r=e.cx,o=e.getStyles,i=e.isDisabled,a=e.innerProps;return Object(ie.d)("div",rt({css:o("singleValue",e),className:r({"single-value":!0,"single-value--is-disabled":i},n)},a),t)},ValueContainer:function(e){var t=e.children,n=e.className,r=e.cx,o=e.isMulti,i=e.getStyles,a=e.hasValue;return Object(ie.d)("div",{css:i("valueContainer",e),className:r({"value-container":!0,"value-container--is-multi":o,"value-container--has-value":a},n)},t)}};function at(e,t){if(e.length!==t.length)return!1;for(var n=0;n<e.length;n++)if(e[n]!==t[n])return!1;return!0}var lt=function(e,t){var n;void 0===t&&(t=at);var r,o=[],i=!1;return function(){for(var a=[],l=0;l<arguments.length;l++)a[l]=arguments[l];return i&&n===this&&t(a,o)||(r=e.apply(this,a),i=!0,n=this,o=a),r}},ut=[{base:"A",letters:/[\u0041\u24B6\uFF21\u00C0\u00C1\u00C2\u1EA6\u1EA4\u1EAA\u1EA8\u00C3\u0100\u0102\u1EB0\u1EAE\u1EB4\u1EB2\u0226\u01E0\u00C4\u01DE\u1EA2\u00C5\u01FA\u01CD\u0200\u0202\u1EA0\u1EAC\u1EB6\u1E00\u0104\u023A\u2C6F]/g},{base:"AA",letters:/[\uA732]/g},{base:"AE",letters:/[\u00C6\u01FC\u01E2]/g},{base:"AO",letters:/[\uA734]/g},{base:"AU",letters:/[\uA736]/g},{base:"AV",letters:/[\uA738\uA73A]/g},{base:"AY",letters:/[\uA73C]/g},{base:"B",letters:/[\u0042\u24B7\uFF22\u1E02\u1E04\u1E06\u0243\u0182\u0181]/g},{base:"C",letters:/[\u0043\u24B8\uFF23\u0106\u0108\u010A\u010C\u00C7\u1E08\u0187\u023B\uA73E]/g},{base:"D",letters:/[\u0044\u24B9\uFF24\u1E0A\u010E\u1E0C\u1E10\u1E12\u1E0E\u0110\u018B\u018A\u0189\uA779]/g},{base:"DZ",letters:/[\u01F1\u01C4]/g},{base:"Dz",letters:/[\u01F2\u01C5]/g},{base:"E",letters:/[\u0045\u24BA\uFF25\u00C8\u00C9\u00CA\u1EC0\u1EBE\u1EC4\u1EC2\u1EBC\u0112\u1E14\u1E16\u0114\u0116\u00CB\u1EBA\u011A\u0204\u0206\u1EB8\u1EC6\u0228\u1E1C\u0118\u1E18\u1E1A\u0190\u018E]/g},{base:"F",letters:/[\u0046\u24BB\uFF26\u1E1E\u0191\uA77B]/g},{base:"G",letters:/[\u0047\u24BC\uFF27\u01F4\u011C\u1E20\u011E\u0120\u01E6\u0122\u01E4\u0193\uA7A0\uA77D\uA77E]/g},{base:"H",letters:/[\u0048\u24BD\uFF28\u0124\u1E22\u1E26\u021E\u1E24\u1E28\u1E2A\u0126\u2C67\u2C75\uA78D]/g},{base:"I",letters:/[\u0049\u24BE\uFF29\u00CC\u00CD\u00CE\u0128\u012A\u012C\u0130\u00CF\u1E2E\u1EC8\u01CF\u0208\u020A\u1ECA\u012E\u1E2C\u0197]/g},{base:"J",letters:/[\u004A\u24BF\uFF2A\u0134\u0248]/g},{base:"K",letters:/[\u004B\u24C0\uFF2B\u1E30\u01E8\u1E32\u0136\u1E34\u0198\u2C69\uA740\uA742\uA744\uA7A2]/g},{base:"L",letters:/[\u004C\u24C1\uFF2C\u013F\u0139\u013D\u1E36\u1E38\u013B\u1E3C\u1E3A\u0141\u023D\u2C62\u2C60\uA748\uA746\uA780]/g},{base:"LJ",letters:/[\u01C7]/g},{base:"Lj",letters:/[\u01C8]/g},{base:"M",letters:/[\u004D\u24C2\uFF2D\u1E3E\u1E40\u1E42\u2C6E\u019C]/g},{base:"N",letters:/[\u004E\u24C3\uFF2E\u01F8\u0143\u00D1\u1E44\u0147\u1E46\u0145\u1E4A\u1E48\u0220\u019D\uA790\uA7A4]/g},{base:"NJ",letters:/[\u01CA]/g},{base:"Nj",letters:/[\u01CB]/g},{base:"O",letters:/[\u004F\u24C4\uFF2F\u00D2\u00D3\u00D4\u1ED2\u1ED0\u1ED6\u1ED4\u00D5\u1E4C\u022C\u1E4E\u014C\u1E50\u1E52\u014E\u022E\u0230\u00D6\u022A\u1ECE\u0150\u01D1\u020C\u020E\u01A0\u1EDC\u1EDA\u1EE0\u1EDE\u1EE2\u1ECC\u1ED8\u01EA\u01EC\u00D8\u01FE\u0186\u019F\uA74A\uA74C]/g},{base:"OI",letters:/[\u01A2]/g},{base:"OO",letters:/[\uA74E]/g},{base:"OU",letters:/[\u0222]/g},{base:"P",letters:/[\u0050\u24C5\uFF30\u1E54\u1E56\u01A4\u2C63\uA750\uA752\uA754]/g},{base:"Q",letters:/[\u0051\u24C6\uFF31\uA756\uA758\u024A]/g},{base:"R",letters:/[\u0052\u24C7\uFF32\u0154\u1E58\u0158\u0210\u0212\u1E5A\u1E5C\u0156\u1E5E\u024C\u2C64\uA75A\uA7A6\uA782]/g},{base:"S",letters:/[\u0053\u24C8\uFF33\u1E9E\u015A\u1E64\u015C\u1E60\u0160\u1E66\u1E62\u1E68\u0218\u015E\u2C7E\uA7A8\uA784]/g},{base:"T",letters:/[\u0054\u24C9\uFF34\u1E6A\u0164\u1E6C\u021A\u0162\u1E70\u1E6E\u0166\u01AC\u01AE\u023E\uA786]/g},{base:"TZ",letters:/[\uA728]/g},{base:"U",letters:/[\u0055\u24CA\uFF35\u00D9\u00DA\u00DB\u0168\u1E78\u016A\u1E7A\u016C\u00DC\u01DB\u01D7\u01D5\u01D9\u1EE6\u016E\u0170\u01D3\u0214\u0216\u01AF\u1EEA\u1EE8\u1EEE\u1EEC\u1EF0\u1EE4\u1E72\u0172\u1E76\u1E74\u0244]/g},{base:"V",letters:/[\u0056\u24CB\uFF36\u1E7C\u1E7E\u01B2\uA75E\u0245]/g},{base:"VY",letters:/[\uA760]/g},{base:"W",letters:/[\u0057\u24CC\uFF37\u1E80\u1E82\u0174\u1E86\u1E84\u1E88\u2C72]/g},{base:"X",letters:/[\u0058\u24CD\uFF38\u1E8A\u1E8C]/g},{base:"Y",letters:/[\u0059\u24CE\uFF39\u1EF2\u00DD\u0176\u1EF8\u0232\u1E8E\u0178\u1EF6\u1EF4\u01B3\u024E\u1EFE]/g},{base:"Z",letters:/[\u005A\u24CF\uFF3A\u0179\u1E90\u017B\u017D\u1E92\u1E94\u01B5\u0224\u2C7F\u2C6B\uA762]/g},{base:"a",letters:/[\u0061\u24D0\uFF41\u1E9A\u00E0\u00E1\u00E2\u1EA7\u1EA5\u1EAB\u1EA9\u00E3\u0101\u0103\u1EB1\u1EAF\u1EB5\u1EB3\u0227\u01E1\u00E4\u01DF\u1EA3\u00E5\u01FB\u01CE\u0201\u0203\u1EA1\u1EAD\u1EB7\u1E01\u0105\u2C65\u0250]/g},{base:"aa",letters:/[\uA733]/g},{base:"ae",letters:/[\u00E6\u01FD\u01E3]/g},{base:"ao",letters:/[\uA735]/g},{base:"au",letters:/[\uA737]/g},{base:"av",letters:/[\uA739\uA73B]/g},{base:"ay",letters:/[\uA73D]/g},{base:"b",letters:/[\u0062\u24D1\uFF42\u1E03\u1E05\u1E07\u0180\u0183\u0253]/g},{base:"c",letters:/[\u0063\u24D2\uFF43\u0107\u0109\u010B\u010D\u00E7\u1E09\u0188\u023C\uA73F\u2184]/g},{base:"d",letters:/[\u0064\u24D3\uFF44\u1E0B\u010F\u1E0D\u1E11\u1E13\u1E0F\u0111\u018C\u0256\u0257\uA77A]/g},{base:"dz",letters:/[\u01F3\u01C6]/g},{base:"e",letters:/[\u0065\u24D4\uFF45\u00E8\u00E9\u00EA\u1EC1\u1EBF\u1EC5\u1EC3\u1EBD\u0113\u1E15\u1E17\u0115\u0117\u00EB\u1EBB\u011B\u0205\u0207\u1EB9\u1EC7\u0229\u1E1D\u0119\u1E19\u1E1B\u0247\u025B\u01DD]/g},{base:"f",letters:/[\u0066\u24D5\uFF46\u1E1F\u0192\uA77C]/g},{base:"g",letters:/[\u0067\u24D6\uFF47\u01F5\u011D\u1E21\u011F\u0121\u01E7\u0123\u01E5\u0260\uA7A1\u1D79\uA77F]/g},{base:"h",letters:/[\u0068\u24D7\uFF48\u0125\u1E23\u1E27\u021F\u1E25\u1E29\u1E2B\u1E96\u0127\u2C68\u2C76\u0265]/g},{base:"hv",letters:/[\u0195]/g},{base:"i",letters:/[\u0069\u24D8\uFF49\u00EC\u00ED\u00EE\u0129\u012B\u012D\u00EF\u1E2F\u1EC9\u01D0\u0209\u020B\u1ECB\u012F\u1E2D\u0268\u0131]/g},{base:"j",letters:/[\u006A\u24D9\uFF4A\u0135\u01F0\u0249]/g},{base:"k",letters:/[\u006B\u24DA\uFF4B\u1E31\u01E9\u1E33\u0137\u1E35\u0199\u2C6A\uA741\uA743\uA745\uA7A3]/g},{base:"l",letters:/[\u006C\u24DB\uFF4C\u0140\u013A\u013E\u1E37\u1E39\u013C\u1E3D\u1E3B\u017F\u0142\u019A\u026B\u2C61\uA749\uA781\uA747]/g},{base:"lj",letters:/[\u01C9]/g},{base:"m",letters:/[\u006D\u24DC\uFF4D\u1E3F\u1E41\u1E43\u0271\u026F]/g},{base:"n",letters:/[\u006E\u24DD\uFF4E\u01F9\u0144\u00F1\u1E45\u0148\u1E47\u0146\u1E4B\u1E49\u019E\u0272\u0149\uA791\uA7A5]/g},{base:"nj",letters:/[\u01CC]/g},{base:"o",letters:/[\u006F\u24DE\uFF4F\u00F2\u00F3\u00F4\u1ED3\u1ED1\u1ED7\u1ED5\u00F5\u1E4D\u022D\u1E4F\u014D\u1E51\u1E53\u014F\u022F\u0231\u00F6\u022B\u1ECF\u0151\u01D2\u020D\u020F\u01A1\u1EDD\u1EDB\u1EE1\u1EDF\u1EE3\u1ECD\u1ED9\u01EB\u01ED\u00F8\u01FF\u0254\uA74B\uA74D\u0275]/g},{base:"oi",letters:/[\u01A3]/g},{base:"ou",letters:/[\u0223]/g},{base:"oo",letters:/[\uA74F]/g},{base:"p",letters:/[\u0070\u24DF\uFF50\u1E55\u1E57\u01A5\u1D7D\uA751\uA753\uA755]/g},{base:"q",letters:/[\u0071\u24E0\uFF51\u024B\uA757\uA759]/g},{base:"r",letters:/[\u0072\u24E1\uFF52\u0155\u1E59\u0159\u0211\u0213\u1E5B\u1E5D\u0157\u1E5F\u024D\u027D\uA75B\uA7A7\uA783]/g},{base:"s",letters:/[\u0073\u24E2\uFF53\u00DF\u015B\u1E65\u015D\u1E61\u0161\u1E67\u1E63\u1E69\u0219\u015F\u023F\uA7A9\uA785\u1E9B]/g},{base:"t",letters:/[\u0074\u24E3\uFF54\u1E6B\u1E97\u0165\u1E6D\u021B\u0163\u1E71\u1E6F\u0167\u01AD\u0288\u2C66\uA787]/g},{base:"tz",letters:/[\uA729]/g},{base:"u",letters:/[\u0075\u24E4\uFF55\u00F9\u00FA\u00FB\u0169\u1E79\u016B\u1E7B\u016D\u00FC\u01DC\u01D8\u01D6\u01DA\u1EE7\u016F\u0171\u01D4\u0215\u0217\u01B0\u1EEB\u1EE9\u1EEF\u1EED\u1EF1\u1EE5\u1E73\u0173\u1E77\u1E75\u0289]/g},{base:"v",letters:/[\u0076\u24E5\uFF56\u1E7D\u1E7F\u028B\uA75F\u028C]/g},{base:"vy",letters:/[\uA761]/g},{base:"w",letters:/[\u0077\u24E6\uFF57\u1E81\u1E83\u0175\u1E87\u1E85\u1E98\u1E89\u2C73]/g},{base:"x",letters:/[\u0078\u24E7\uFF58\u1E8B\u1E8D]/g},{base:"y",letters:/[\u0079\u24E8\uFF59\u1EF3\u00FD\u0177\u1EF9\u0233\u1E8F\u00FF\u1EF7\u1E99\u1EF5\u01B4\u024F\u1EFF]/g},{base:"z",letters:/[\u007A\u24E9\uFF5A\u017A\u1E91\u017C\u017E\u1E93\u1E95\u01B6\u0225\u0240\u2C6C\uA763]/g}],ct=function(e){for(var t=0;t<ut.length;t++)e=e.replace(ut[t].letters,ut[t].base);return e};function st(){return(st=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}var ft=function(e){return e.replace(/^\s+|\s+$/g,"")},pt=function(e){return e.label+" "+e.value};function dt(){return(dt=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}var ht={name:"1laao21-a11yText",styles:"label:a11yText;z-index:9999;border:0;clip:rect(1px, 1px, 1px, 1px);height:1px;width:1px;position:absolute;overflow:hidden;padding:0;white-space:nowrap;"},mt=function(e){return Object(ie.d)("span",dt({css:ht},e))};function gt(){return(gt=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}function vt(e){e.in,e.out,e.onExited,e.appear,e.enter,e.exit;var t=e.innerRef,n=(e.emotion,function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,["in","out","onExited","appear","enter","exit","innerRef","emotion"]));return Object(ie.d)("input",gt({ref:t},n,{css:Object(me.a)({label:"dummyInput",background:0,border:0,fontSize:"inherit",outline:0,padding:0,width:1,color:"transparent",left:-100,opacity:0,position:"relative",transform:"scale(0)"},"")}))}var bt=function(e){var t,n;function r(){return e.apply(this,arguments)||this}n=e,(t=r).prototype=Object.create(n.prototype),t.prototype.constructor=t,t.__proto__=n;var o=r.prototype;return o.componentDidMount=function(){this.props.innerRef(Object(i.findDOMNode)(this))},o.componentWillUnmount=function(){this.props.innerRef(null)},o.render=function(){return this.props.children},r}(r.Component),yt=["boxSizing","height","overflow","paddingRight","position"],wt={boxSizing:"border-box",overflow:"hidden",position:"relative",height:"100%"};function xt(e){e.preventDefault()}function Et(e){e.stopPropagation()}function Ct(){var e=this.scrollTop,t=this.scrollHeight,n=e+this.offsetHeight;0===e?this.scrollTop=1:n===t&&(this.scrollTop=e-1)}function kt(){return"ontouchstart"in window||navigator.maxTouchPoints}var St=!(!window.document||!window.document.createElement),Ot=0,Tt=function(e){var t,n;function r(){for(var t,n=arguments.length,r=new Array(n),o=0;o<n;o++)r[o]=arguments[o];return(t=e.call.apply(e,[this].concat(r))||this).originalStyles={},t.listenerOptions={capture:!1,passive:!1},t}n=e,(t=r).prototype=Object.create(n.prototype),t.prototype.constructor=t,t.__proto__=n;var o=r.prototype;return o.componentDidMount=function(){var e=this;if(St){var t=this.props,n=t.accountForScrollbars,r=t.touchScrollTarget,o=document.body,i=o&&o.style;if(n&&yt.forEach((function(t){var n=i&&i[t];e.originalStyles[t]=n})),n&&Ot<1){var a=parseInt(this.originalStyles.paddingRight,10)||0,l=document.body?document.body.clientWidth:0,u=window.innerWidth-l+a||0;Object.keys(wt).forEach((function(e){var t=wt[e];i&&(i[e]=t)})),i&&(i.paddingRight=u+"px")}o&&kt()&&(o.addEventListener("touchmove",xt,this.listenerOptions),r&&(r.addEventListener("touchstart",Ct,this.listenerOptions),r.addEventListener("touchmove",Et,this.listenerOptions))),Ot+=1}},o.componentWillUnmount=function(){var e=this;if(St){var t=this.props,n=t.accountForScrollbars,r=t.touchScrollTarget,o=document.body,i=o&&o.style;Ot=Math.max(Ot-1,0),n&&Ot<1&&yt.forEach((function(t){var n=e.originalStyles[t];i&&(i[t]=n)})),o&&kt()&&(o.removeEventListener("touchmove",xt,this.listenerOptions),r&&(r.removeEventListener("touchstart",Ct,this.listenerOptions),r.removeEventListener("touchmove",Et,this.listenerOptions)))}},o.render=function(){return null},r}(r.Component);Tt.defaultProps={accountForScrollbars:!0};var _t={name:"1dsbpcp",styles:"position:fixed;left:0;bottom:0;right:0;top:0;"},Pt=function(e){var t,n;function r(){for(var t,n=arguments.length,r=new Array(n),o=0;o<n;o++)r[o]=arguments[o];return(t=e.call.apply(e,[this].concat(r))||this).state={touchScrollTarget:null},t.getScrollTarget=function(e){e!==t.state.touchScrollTarget&&t.setState({touchScrollTarget:e})},t.blurSelectInput=function(){document.activeElement&&document.activeElement.blur()},t}return n=e,(t=r).prototype=Object.create(n.prototype),t.prototype.constructor=t,t.__proto__=n,r.prototype.render=function(){var e=this.props,t=e.children,n=e.isEnabled,r=this.state.touchScrollTarget;return n?Object(ie.d)("div",null,Object(ie.d)("div",{onClick:this.blurSelectInput,css:_t}),Object(ie.d)(bt,{innerRef:this.getScrollTarget},t),r?Object(ie.d)(Tt,{touchScrollTarget:r}):null):t},r}(r.PureComponent);var At=function(e){var t,n;function r(){for(var t,n=arguments.length,r=new Array(n),o=0;o<n;o++)r[o]=arguments[o];return(t=e.call.apply(e,[this].concat(r))||this).isBottom=!1,t.isTop=!1,t.scrollTarget=void 0,t.touchStart=void 0,t.cancelScroll=function(e){e.preventDefault(),e.stopPropagation()},t.handleEventDelta=function(e,n){var r=t.props,o=r.onBottomArrive,i=r.onBottomLeave,a=r.onTopArrive,l=r.onTopLeave,u=t.scrollTarget,c=u.scrollTop,s=u.scrollHeight,f=u.clientHeight,p=t.scrollTarget,d=n>0,h=s-f-c,m=!1;h>n&&t.isBottom&&(i&&i(e),t.isBottom=!1),d&&t.isTop&&(l&&l(e),t.isTop=!1),d&&n>h?(o&&!t.isBottom&&o(e),p.scrollTop=s,m=!0,t.isBottom=!0):!d&&-n>c&&(a&&!t.isTop&&a(e),p.scrollTop=0,m=!0,t.isTop=!0),m&&t.cancelScroll(e)},t.onWheel=function(e){t.handleEventDelta(e,e.deltaY)},t.onTouchStart=function(e){t.touchStart=e.changedTouches[0].clientY},t.onTouchMove=function(e){var n=t.touchStart-e.changedTouches[0].clientY;t.handleEventDelta(e,n)},t.getScrollTarget=function(e){t.scrollTarget=e},t}n=e,(t=r).prototype=Object.create(n.prototype),t.prototype.constructor=t,t.__proto__=n;var i=r.prototype;return i.componentDidMount=function(){this.startListening(this.scrollTarget)},i.componentWillUnmount=function(){this.stopListening(this.scrollTarget)},i.startListening=function(e){e&&("function"==typeof e.addEventListener&&e.addEventListener("wheel",this.onWheel,!1),"function"==typeof e.addEventListener&&e.addEventListener("touchstart",this.onTouchStart,!1),"function"==typeof e.addEventListener&&e.addEventListener("touchmove",this.onTouchMove,!1))},i.stopListening=function(e){"function"==typeof e.removeEventListener&&e.removeEventListener("wheel",this.onWheel,!1),"function"==typeof e.removeEventListener&&e.removeEventListener("touchstart",this.onTouchStart,!1),"function"==typeof e.removeEventListener&&e.removeEventListener("touchmove",this.onTouchMove,!1)},i.render=function(){return o.a.createElement(bt,{innerRef:this.getScrollTarget},this.props.children)},r}(r.Component);function Mt(e){var t=e.isEnabled,n=void 0===t||t,r=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,["isEnabled"]);return n?o.a.createElement(At,r):r.children}var zt=function(e,t){void 0===t&&(t={});var n=t,r=n.isSearchable,o=n.isMulti,i=n.label,a=n.isDisabled;switch(e){case"menu":return"Use Up and Down to choose options"+(a?"":", press Enter to select the currently focused option")+", press Escape to exit the menu, press Tab to select the option and exit the menu.";case"input":return(i||"Select")+" is focused "+(r?",type to refine list":"")+", press Down to open the menu, "+(o?" press left to focus selected values":"");case"value":return"Use left and right to toggle between focused values, press Backspace to remove the currently focused value"}},Ft=function(e,t){var n=t.value,r=t.isDisabled;if(n)switch(e){case"deselect-option":case"pop-value":case"remove-value":return"option "+n+", deselected.";case"select-option":return r?"option "+n+" is disabled. Select another option.":"option "+n+", selected."}},Dt=function(e){return!!e.isDisabled};var jt={clearIndicator:He,container:function(e){var t=e.isDisabled;return{label:"container",direction:e.isRtl?"rtl":null,pointerEvents:t?"none":null,position:"relative"}},control:function(e){var t=e.isDisabled,n=e.isFocused,r=e.theme,o=r.colors,i=r.borderRadius,a=r.spacing;return{label:"control",alignItems:"center",backgroundColor:t?o.neutral5:o.neutral0,borderColor:t?o.neutral10:n?o.primary:o.neutral20,borderRadius:i,borderStyle:"solid",borderWidth:1,boxShadow:n?"0 0 0 1px "+o.primary:null,cursor:"default",display:"flex",flexWrap:"wrap",justifyContent:"space-between",minHeight:a.controlHeight,outline:"0 !important",position:"relative",transition:"all 100ms","&:hover":{borderColor:n?o.primary:o.neutral30}}},dropdownIndicator:Be,group:function(e){var t=e.theme.spacing;return{paddingBottom:2*t.baseUnit,paddingTop:2*t.baseUnit}},groupHeading:function(e){var t=e.theme.spacing;return{label:"group",color:"#999",cursor:"default",display:"block",fontSize:"75%",fontWeight:"500",marginBottom:"0.25em",paddingLeft:3*t.baseUnit,paddingRight:3*t.baseUnit,textTransform:"uppercase"}},indicatorsContainer:function(){return{alignItems:"center",alignSelf:"stretch",display:"flex",flexShrink:0}},indicatorSeparator:function(e){var t=e.isDisabled,n=e.theme,r=n.spacing.baseUnit,o=n.colors;return{label:"indicatorSeparator",alignSelf:"stretch",backgroundColor:t?o.neutral10:o.neutral20,marginBottom:2*r,marginTop:2*r,width:1}},input:function(e){var t=e.isDisabled,n=e.theme,r=n.spacing,o=n.colors;return{margin:r.baseUnit/2,paddingBottom:r.baseUnit/2,paddingTop:r.baseUnit/2,visibility:t?"hidden":"visible",color:o.neutral80}},loadingIndicator:function(e){var t=e.isFocused,n=e.size,r=e.theme,o=r.colors,i=r.spacing.baseUnit;return{label:"loadingIndicator",color:t?o.neutral60:o.neutral20,display:"flex",padding:2*i,transition:"color 150ms",alignSelf:"center",fontSize:n,lineHeight:1,marginRight:n,textAlign:"center",verticalAlign:"middle"}},loadingMessage:Se,menu:function(e){var t,n=e.placement,r=e.theme,o=r.borderRadius,i=r.spacing,a=r.colors;return(t={label:"menu"})[function(e){return e?{bottom:"top",top:"bottom"}[e]:"bottom"}(n)]="100%",t.backgroundColor=a.neutral0,t.borderRadius=o,t.boxShadow="0 0 0 1px hsla(0, 0%, 0%, 0.1), 0 4px 11px hsla(0, 0%, 0%, 0.1)",t.marginBottom=i.menuGutter,t.marginTop=i.menuGutter,t.position="absolute",t.width="100%",t.zIndex=1,t},menuList:function(e){var t=e.maxHeight,n=e.theme.spacing.baseUnit;return{maxHeight:t,overflowY:"auto",paddingBottom:n,paddingTop:n,position:"relative",WebkitOverflowScrolling:"touch"}},menuPortal:function(e){var t=e.rect,n=e.offset,r=e.position;return{left:t.left,position:r,top:n,width:t.width,zIndex:1}},multiValue:function(e){var t=e.theme,n=t.spacing,r=t.borderRadius;return{label:"multiValue",backgroundColor:t.colors.neutral10,borderRadius:r/2,display:"flex",margin:n.baseUnit/2,minWidth:0}},multiValueLabel:function(e){var t=e.theme,n=t.borderRadius,r=t.colors,o=e.cropWithEllipsis;return{borderRadius:n/2,color:r.neutral80,fontSize:"85%",overflow:"hidden",padding:3,paddingLeft:6,textOverflow:o?"ellipsis":null,whiteSpace:"nowrap"}},multiValueRemove:function(e){var t=e.theme,n=t.spacing,r=t.borderRadius,o=t.colors;return{alignItems:"center",borderRadius:r/2,backgroundColor:e.isFocused&&o.dangerLight,display:"flex",paddingLeft:n.baseUnit,paddingRight:n.baseUnit,":hover":{backgroundColor:o.dangerLight,color:o.danger}}},noOptionsMessage:ke,option:function(e){var t=e.isDisabled,n=e.isFocused,r=e.isSelected,o=e.theme,i=o.spacing,a=o.colors;return{label:"option",backgroundColor:r?a.primary:n?a.primary25:"transparent",color:t?a.neutral20:r?a.neutral0:"inherit",cursor:"default",display:"block",fontSize:"inherit",padding:2*i.baseUnit+"px "+3*i.baseUnit+"px",width:"100%",userSelect:"none",WebkitTapHighlightColor:"rgba(0, 0, 0, 0)",":active":{backgroundColor:!t&&(r?a.primary:a.primary50)}}},placeholder:function(e){var t=e.theme,n=t.spacing;return{label:"placeholder",color:t.colors.neutral50,marginLeft:n.baseUnit/2,marginRight:n.baseUnit/2,position:"absolute",top:"50%",transform:"translateY(-50%)"}},singleValue:function(e){var t=e.isDisabled,n=e.theme,r=n.spacing,o=n.colors;return{label:"singleValue",color:t?o.neutral40:o.neutral80,marginLeft:r.baseUnit/2,marginRight:r.baseUnit/2,maxWidth:"calc(100% - "+2*r.baseUnit+"px)",overflow:"hidden",position:"absolute",textOverflow:"ellipsis",whiteSpace:"nowrap",top:"50%",transform:"translateY(-50%)"}},valueContainer:function(e){var t=e.theme.spacing;return{alignItems:"center",display:"flex",flex:1,flexWrap:"wrap",padding:t.baseUnit/2+"px "+2*t.baseUnit+"px",WebkitOverflowScrolling:"touch",position:"relative",overflow:"hidden"}}};var Lt={borderRadius:4,colors:{primary:"#2684FF",primary75:"#4C9AFF",primary50:"#B2D4FF",primary25:"#DEEBFF",danger:"#DE350B",dangerLight:"#FFBDAD",neutral0:"hsl(0, 0%, 100%)",neutral5:"hsl(0, 0%, 95%)",neutral10:"hsl(0, 0%, 90%)",neutral20:"hsl(0, 0%, 80%)",neutral30:"hsl(0, 0%, 70%)",neutral40:"hsl(0, 0%, 60%)",neutral50:"hsl(0, 0%, 50%)",neutral60:"hsl(0, 0%, 40%)",neutral70:"hsl(0, 0%, 30%)",neutral80:"hsl(0, 0%, 20%)",neutral90:"hsl(0, 0%, 10%)"},spacing:{baseUnit:4,controlHeight:38,menuGutter:8}};function Rt(){return(Rt=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}function It(e){if(void 0===e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return e}var Nt,Vt={backspaceRemovesValue:!0,blurInputOnSelect:he(),captureMenuScroll:!he(),closeMenuOnSelect:!0,closeMenuOnScroll:!1,components:{},controlShouldRenderValue:!0,escapeClearsValue:!1,filterOption:function(e,t){var n=st({ignoreCase:!0,ignoreAccents:!0,stringify:pt,trim:!0,matchFrom:"any"},Nt),r=n.ignoreCase,o=n.ignoreAccents,i=n.stringify,a=n.trim,l=n.matchFrom,u=a?ft(t):t,c=a?ft(i(e)):i(e);return r&&(u=u.toLowerCase(),c=c.toLowerCase()),o&&(u=ct(u),c=ct(c)),"start"===l?c.substr(0,u.length)===u:c.indexOf(u)>-1},formatGroupLabel:function(e){return e.label},getOptionLabel:function(e){return e.label},getOptionValue:function(e){return e.value},isDisabled:!1,isLoading:!1,isMulti:!1,isRtl:!1,isSearchable:!0,isOptionDisabled:Dt,loadingMessage:function(){return"Loading..."},maxMenuHeight:300,minMenuHeight:140,menuIsOpen:!1,menuPlacement:"bottom",menuPosition:"absolute",menuShouldBlockScroll:!1,menuShouldScrollIntoView:!function(){try{return/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)}catch(e){return!1}}(),noOptionsMessage:function(){return"No options"},openMenuOnFocus:!1,openMenuOnClick:!0,options:[],pageSize:5,placeholder:"Select...",screenReaderStatus:function(e){var t=e.count;return t+" result"+(1!==t?"s":"")+" available"},styles:{},tabIndex:"0",tabSelectsValue:!0},Bt=1,Ht=function(e){var t,n;function r(t){var n;(n=e.call(this,t)||this).state={ariaLiveSelection:"",ariaLiveContext:"",focusedOption:null,focusedValue:null,inputIsHidden:!1,isFocused:!1,menuOptions:{render:[],focusable:[]},selectValue:[]},n.blockOptionHover=!1,n.isComposing=!1,n.clearFocusValueOnUpdate=!1,n.commonProps=void 0,n.components=void 0,n.hasGroups=!1,n.initialTouchX=0,n.initialTouchY=0,n.inputIsHiddenAfterUpdate=void 0,n.instancePrefix="",n.openAfterFocus=!1,n.scrollToFocusedOptionOnUpdate=!1,n.userIsDragging=void 0,n.controlRef=null,n.getControlRef=function(e){n.controlRef=e},n.focusedOptionRef=null,n.getFocusedOptionRef=function(e){n.focusedOptionRef=e},n.menuListRef=null,n.getMenuListRef=function(e){n.menuListRef=e},n.inputRef=null,n.getInputRef=function(e){n.inputRef=e},n.cacheComponents=function(e){n.components=ot({},it,{components:e}.components)},n.focus=n.focusInput,n.blur=n.blurInput,n.onChange=function(e,t){var r=n.props;(0,r.onChange)(e,Rt({},t,{name:r.name}))},n.setValue=function(e,t,r){void 0===t&&(t="set-value");var o=n.props,i=o.closeMenuOnSelect,a=o.isMulti;n.onInputChange("",{action:"set-value"}),i&&(n.inputIsHiddenAfterUpdate=!a,n.onMenuClose()),n.clearFocusValueOnUpdate=!0,n.onChange(e,{action:t,option:r})},n.selectOption=function(e){var t=n.props,r=t.blurInputOnSelect,o=t.isMulti,i=n.state.selectValue;if(o)if(n.isOptionSelected(e,i)){var a=n.getOptionValue(e);n.setValue(i.filter((function(e){return n.getOptionValue(e)!==a})),"deselect-option",e),n.announceAriaLiveSelection({event:"deselect-option",context:{value:n.getOptionLabel(e)}})}else n.isOptionDisabled(e,i)?n.announceAriaLiveSelection({event:"select-option",context:{value:n.getOptionLabel(e),isDisabled:!0}}):(n.setValue([].concat(i,[e]),"select-option",e),n.announceAriaLiveSelection({event:"select-option",context:{value:n.getOptionLabel(e)}}));else n.isOptionDisabled(e,i)?n.announceAriaLiveSelection({event:"select-option",context:{value:n.getOptionLabel(e),isDisabled:!0}}):(n.setValue(e,"select-option"),n.announceAriaLiveSelection({event:"select-option",context:{value:n.getOptionLabel(e)}}));r&&n.blurInput()},n.removeValue=function(e){var t=n.state.selectValue,r=n.getOptionValue(e),o=t.filter((function(e){return n.getOptionValue(e)!==r}));n.onChange(o.length?o:null,{action:"remove-value",removedValue:e}),n.announceAriaLiveSelection({event:"remove-value",context:{value:e?n.getOptionLabel(e):""}}),n.focusInput()},n.clearValue=function(){var e=n.props.isMulti;n.onChange(e?[]:null,{action:"clear"})},n.popValue=function(){var e=n.state.selectValue,t=e[e.length-1],r=e.slice(0,e.length-1);n.announceAriaLiveSelection({event:"pop-value",context:{value:t?n.getOptionLabel(t):""}}),n.onChange(r.length?r:null,{action:"pop-value",removedValue:t})},n.getOptionLabel=function(e){return n.props.getOptionLabel(e)},n.getOptionValue=function(e){return n.props.getOptionValue(e)},n.getStyles=function(e,t){var r=jt[e](t);r.boxSizing="border-box";var o=n.props.styles[e];return o?o(r,t):r},n.getElementId=function(e){return n.instancePrefix+"-"+e},n.getActiveDescendentId=function(){var e=n.props.menuIsOpen,t=n.state,r=t.menuOptions,o=t.focusedOption;if(o&&e){var i=r.focusable.indexOf(o),a=r.render[i];return a&&a.key}},n.announceAriaLiveSelection=function(e){var t=e.event,r=e.context;n.setState({ariaLiveSelection:Ft(t,r)})},n.announceAriaLiveContext=function(e){var t=e.event,r=e.context;n.setState({ariaLiveContext:zt(t,Rt({},r,{label:n.props["aria-label"]}))})},n.onMenuMouseDown=function(e){0===e.button&&(e.stopPropagation(),e.preventDefault(),n.focusInput())},n.onMenuMouseMove=function(e){n.blockOptionHover=!1},n.onControlMouseDown=function(e){var t=n.props.openMenuOnClick;n.state.isFocused?n.props.menuIsOpen?"INPUT"!==e.target.tagName&&"TEXTAREA"!==e.target.tagName&&n.onMenuClose():t&&n.openMenu("first"):(t&&(n.openAfterFocus=!0),n.focusInput()),"INPUT"!==e.target.tagName&&"TEXTAREA"!==e.target.tagName&&e.preventDefault()},n.onDropdownIndicatorMouseDown=function(e){if(!(e&&"mousedown"===e.type&&0!==e.button||n.props.isDisabled)){var t=n.props,r=t.isMulti,o=t.menuIsOpen;n.focusInput(),o?(n.inputIsHiddenAfterUpdate=!r,n.onMenuClose()):n.openMenu("first"),e.preventDefault(),e.stopPropagation()}},n.onClearIndicatorMouseDown=function(e){e&&"mousedown"===e.type&&0!==e.button||(n.clearValue(),e.stopPropagation(),n.openAfterFocus=!1,"touchend"===e.type?n.focusInput():setTimeout((function(){return n.focusInput()})))},n.onScroll=function(e){"boolean"==typeof n.props.closeMenuOnScroll?e.target instanceof HTMLElement&&se(e.target)&&n.props.onMenuClose():"function"==typeof n.props.closeMenuOnScroll&&n.props.closeMenuOnScroll(e)&&n.props.onMenuClose()},n.onCompositionStart=function(){n.isComposing=!0},n.onCompositionEnd=function(){n.isComposing=!1},n.onTouchStart=function(e){var t=e.touches.item(0);t&&(n.initialTouchX=t.clientX,n.initialTouchY=t.clientY,n.userIsDragging=!1)},n.onTouchMove=function(e){var t=e.touches.item(0);if(t){var r=Math.abs(t.clientX-n.initialTouchX),o=Math.abs(t.clientY-n.initialTouchY);n.userIsDragging=r>5||o>5}},n.onTouchEnd=function(e){n.userIsDragging||(n.controlRef&&!n.controlRef.contains(e.target)&&n.menuListRef&&!n.menuListRef.contains(e.target)&&n.blurInput(),n.initialTouchX=0,n.initialTouchY=0)},n.onControlTouchEnd=function(e){n.userIsDragging||n.onControlMouseDown(e)},n.onClearIndicatorTouchEnd=function(e){n.userIsDragging||n.onClearIndicatorMouseDown(e)},n.onDropdownIndicatorTouchEnd=function(e){n.userIsDragging||n.onDropdownIndicatorMouseDown(e)},n.handleInputChange=function(e){var t=e.currentTarget.value;n.inputIsHiddenAfterUpdate=!1,n.onInputChange(t,{action:"input-change"}),n.onMenuOpen()},n.onInputFocus=function(e){var t=n.props,r=t.isSearchable,o=t.isMulti;n.props.onFocus&&n.props.onFocus(e),n.inputIsHiddenAfterUpdate=!1,n.announceAriaLiveContext({event:"input",context:{isSearchable:r,isMulti:o}}),n.setState({isFocused:!0}),(n.openAfterFocus||n.props.openMenuOnFocus)&&n.openMenu("first"),n.openAfterFocus=!1},n.onInputBlur=function(e){n.menuListRef&&n.menuListRef.contains(document.activeElement)?n.inputRef.focus():(n.props.onBlur&&n.props.onBlur(e),n.onInputChange("",{action:"input-blur"}),n.onMenuClose(),n.setState({focusedValue:null,isFocused:!1}))},n.onOptionHover=function(e){n.blockOptionHover||n.state.focusedOption===e||n.setState({focusedOption:e})},n.shouldHideSelectedOptions=function(){var e=n.props,t=e.hideSelectedOptions,r=e.isMulti;return void 0===t?r:t},n.onKeyDown=function(e){var t=n.props,r=t.isMulti,o=t.backspaceRemovesValue,i=t.escapeClearsValue,a=t.inputValue,l=t.isClearable,u=t.isDisabled,c=t.menuIsOpen,s=t.onKeyDown,f=t.tabSelectsValue,p=t.openMenuOnFocus,d=n.state,h=d.focusedOption,m=d.focusedValue,g=d.selectValue;if(!(u||"function"==typeof s&&(s(e),e.defaultPrevented))){switch(n.blockOptionHover=!0,e.key){case"ArrowLeft":if(!r||a)return;n.focusValue("previous");break;case"ArrowRight":if(!r||a)return;n.focusValue("next");break;case"Delete":case"Backspace":if(a)return;if(m)n.removeValue(m);else{if(!o)return;r?n.popValue():l&&n.clearValue()}break;case"Tab":if(n.isComposing)return;if(e.shiftKey||!c||!f||!h||p&&n.isOptionSelected(h,g))return;n.selectOption(h);break;case"Enter":if(229===e.keyCode)break;if(c){if(!h)return;if(n.isComposing)return;n.selectOption(h);break}return;case"Escape":c?(n.inputIsHiddenAfterUpdate=!1,n.onInputChange("",{action:"menu-close"}),n.onMenuClose()):l&&i&&n.clearValue();break;case" ":if(a)return;if(!c){n.openMenu("first");break}if(!h)return;n.selectOption(h);break;case"ArrowUp":c?n.focusOption("up"):n.openMenu("last");break;case"ArrowDown":c?n.focusOption("down"):n.openMenu("first");break;case"PageUp":if(!c)return;n.focusOption("pageup");break;case"PageDown":if(!c)return;n.focusOption("pagedown");break;case"Home":if(!c)return;n.focusOption("first");break;case"End":if(!c)return;n.focusOption("last");break;default:return}e.preventDefault()}},n.buildMenuOptions=function(e,t){var r=e.inputValue,o=void 0===r?"":r,i=e.options,a=function(e,r){var i=n.isOptionDisabled(e,t),a=n.isOptionSelected(e,t),l=n.getOptionLabel(e),u=n.getOptionValue(e);if(!(n.shouldHideSelectedOptions()&&a||!n.filterOption({label:l,value:u,data:e},o))){var c=i?void 0:function(){return n.onOptionHover(e)},s=i?void 0:function(){return n.selectOption(e)},f=n.getElementId("option")+"-"+r;return{innerProps:{id:f,onClick:s,onMouseMove:c,onMouseOver:c,tabIndex:-1},data:e,isDisabled:i,isSelected:a,key:f,label:l,type:"option",value:u}}};return i.reduce((function(e,t,r){if(t.options){n.hasGroups||(n.hasGroups=!0);var o=t.options.map((function(t,n){var o=a(t,r+"-"+n);return o&&e.focusable.push(t),o})).filter(Boolean);if(o.length){var i=n.getElementId("group")+"-"+r;e.render.push({type:"group",key:i,data:t,options:o})}}else{var l=a(t,""+r);l&&(e.render.push(l),e.focusable.push(t))}return e}),{render:[],focusable:[]})};var r=t.value;n.cacheComponents=lt(n.cacheComponents,ze).bind(It(It(n))),n.cacheComponents(t.components),n.instancePrefix="react-select-"+(n.props.instanceId||++Bt);var o=ce(r);n.buildMenuOptions=lt(n.buildMenuOptions,(function(e,t){var n=e,r=n[0],o=n[1],i=t,a=i[0];return ze(o,i[1])&&ze(r.inputValue,a.inputValue)&&ze(r.options,a.options)})).bind(It(It(n)));var i=t.menuIsOpen?n.buildMenuOptions(t,o):{render:[],focusable:[]};return n.state.menuOptions=i,n.state.selectValue=o,n}n=e,(t=r).prototype=Object.create(n.prototype),t.prototype.constructor=t,t.__proto__=n;var i=r.prototype;return i.componentDidMount=function(){this.startListeningComposition(),this.startListeningToTouch(),this.props.closeMenuOnScroll&&document&&document.addEventListener&&document.addEventListener("scroll",this.onScroll,!0),this.props.autoFocus&&this.focusInput()},i.UNSAFE_componentWillReceiveProps=function(e){var t=this.props,n=t.options,r=t.value,o=t.menuIsOpen,i=t.inputValue;if(this.cacheComponents(e.components),e.value!==r||e.options!==n||e.menuIsOpen!==o||e.inputValue!==i){var a=ce(e.value),l=e.menuIsOpen?this.buildMenuOptions(e,a):{render:[],focusable:[]},u=this.getNextFocusedValue(a),c=this.getNextFocusedOption(l.focusable);this.setState({menuOptions:l,selectValue:a,focusedOption:c,focusedValue:u})}null!=this.inputIsHiddenAfterUpdate&&(this.setState({inputIsHidden:this.inputIsHiddenAfterUpdate}),delete this.inputIsHiddenAfterUpdate)},i.componentDidUpdate=function(e){var t,n,r,o,i,a=this.props,l=a.isDisabled,u=a.menuIsOpen,c=this.state.isFocused;(c&&!l&&e.isDisabled||c&&u&&!e.menuIsOpen)&&this.focusInput(),this.menuListRef&&this.focusedOptionRef&&this.scrollToFocusedOptionOnUpdate&&(t=this.menuListRef,n=this.focusedOptionRef,r=t.getBoundingClientRect(),o=n.getBoundingClientRect(),i=n.offsetHeight/3,o.bottom+i>r.bottom?pe(t,Math.min(n.offsetTop+n.clientHeight-t.offsetHeight+i,t.scrollHeight)):o.top-i<r.top&&pe(t,Math.max(n.offsetTop-i,0)),this.scrollToFocusedOptionOnUpdate=!1)},i.componentWillUnmount=function(){this.stopListeningComposition(),this.stopListeningToTouch(),document.removeEventListener("scroll",this.onScroll,!0)},i.onMenuOpen=function(){this.props.onMenuOpen()},i.onMenuClose=function(){var e=this.props,t=e.isSearchable,n=e.isMulti;this.announceAriaLiveContext({event:"input",context:{isSearchable:t,isMulti:n}}),this.onInputChange("",{action:"menu-close"}),this.props.onMenuClose()},i.onInputChange=function(e,t){this.props.onInputChange(e,t)},i.focusInput=function(){this.inputRef&&this.inputRef.focus()},i.blurInput=function(){this.inputRef&&this.inputRef.blur()},i.openMenu=function(e){var t=this,n=this.state,r=n.selectValue,o=n.isFocused,i=this.buildMenuOptions(this.props,r),a=this.props.isMulti,l="first"===e?0:i.focusable.length-1;if(!a){var u=i.focusable.indexOf(r[0]);u>-1&&(l=u)}this.scrollToFocusedOptionOnUpdate=!(o&&this.menuListRef),this.inputIsHiddenAfterUpdate=!1,this.setState({menuOptions:i,focusedValue:null,focusedOption:i.focusable[l]},(function(){t.onMenuOpen(),t.announceAriaLiveContext({event:"menu"})}))},i.focusValue=function(e){var t=this.props,n=t.isMulti,r=t.isSearchable,o=this.state,i=o.selectValue,a=o.focusedValue;if(n){this.setState({focusedOption:null});var l=i.indexOf(a);a||(l=-1,this.announceAriaLiveContext({event:"value"}));var u=i.length-1,c=-1;if(i.length){switch(e){case"previous":c=0===l?0:-1===l?u:l-1;break;case"next":l>-1&&l<u&&(c=l+1)}-1===c&&this.announceAriaLiveContext({event:"input",context:{isSearchable:r,isMulti:n}}),this.setState({inputIsHidden:-1!==c,focusedValue:i[c]})}}},i.focusOption=function(e){void 0===e&&(e="first");var t=this.props.pageSize,n=this.state,r=n.focusedOption,o=n.menuOptions.focusable;if(o.length){var i=0,a=o.indexOf(r);r||(a=-1,this.announceAriaLiveContext({event:"menu"})),"up"===e?i=a>0?a-1:o.length-1:"down"===e?i=(a+1)%o.length:"pageup"===e?(i=a-t)<0&&(i=0):"pagedown"===e?(i=a+t)>o.length-1&&(i=o.length-1):"last"===e&&(i=o.length-1),this.scrollToFocusedOptionOnUpdate=!0,this.setState({focusedOption:o[i],focusedValue:null}),this.announceAriaLiveContext({event:"menu",context:{isDisabled:Dt(o[i])}})}},i.getTheme=function(){return this.props.theme?"function"==typeof this.props.theme?this.props.theme(Lt):Rt({},Lt,this.props.theme):Lt},i.getCommonProps=function(){var e=this.clearValue,t=this.getStyles,n=this.setValue,r=this.selectOption,o=this.props,i=o.classNamePrefix,a=o.isMulti,l=o.isRtl,u=o.options,c=this.state.selectValue,s=this.hasValue();return{cx:ue.bind(null,i),clearValue:e,getStyles:t,getValue:function(){return c},hasValue:s,isMulti:a,isRtl:l,options:u,selectOption:r,setValue:n,selectProps:o,theme:this.getTheme()}},i.getNextFocusedValue=function(e){if(this.clearFocusValueOnUpdate)return this.clearFocusValueOnUpdate=!1,null;var t=this.state,n=t.focusedValue,r=t.selectValue.indexOf(n);if(r>-1){if(e.indexOf(n)>-1)return n;if(r<e.length)return e[r]}return null},i.getNextFocusedOption=function(e){var t=this.state.focusedOption;return t&&e.indexOf(t)>-1?t:e[0]},i.hasValue=function(){return this.state.selectValue.length>0},i.hasOptions=function(){return!!this.state.menuOptions.render.length},i.countOptions=function(){return this.state.menuOptions.focusable.length},i.isClearable=function(){var e=this.props,t=e.isClearable,n=e.isMulti;return void 0===t?n:t},i.isOptionDisabled=function(e,t){return"function"==typeof this.props.isOptionDisabled&&this.props.isOptionDisabled(e,t)},i.isOptionSelected=function(e,t){var n=this;if(t.indexOf(e)>-1)return!0;if("function"==typeof this.props.isOptionSelected)return this.props.isOptionSelected(e,t);var r=this.getOptionValue(e);return t.some((function(e){return n.getOptionValue(e)===r}))},i.filterOption=function(e,t){return!this.props.filterOption||this.props.filterOption(e,t)},i.formatOptionLabel=function(e,t){if("function"==typeof this.props.formatOptionLabel){var n=this.props.inputValue,r=this.state.selectValue;return this.props.formatOptionLabel(e,{context:t,inputValue:n,selectValue:r})}return this.getOptionLabel(e)},i.formatGroupLabel=function(e){return this.props.formatGroupLabel(e)},i.startListeningComposition=function(){document&&document.addEventListener&&(document.addEventListener("compositionstart",this.onCompositionStart,!1),document.addEventListener("compositionend",this.onCompositionEnd,!1))},i.stopListeningComposition=function(){document&&document.removeEventListener&&(document.removeEventListener("compositionstart",this.onCompositionStart),document.removeEventListener("compositionend",this.onCompositionEnd))},i.startListeningToTouch=function(){document&&document.addEventListener&&(document.addEventListener("touchstart",this.onTouchStart,!1),document.addEventListener("touchmove",this.onTouchMove,!1),document.addEventListener("touchend",this.onTouchEnd,!1))},i.stopListeningToTouch=function(){document&&document.removeEventListener&&(document.removeEventListener("touchstart",this.onTouchStart),document.removeEventListener("touchmove",this.onTouchMove),document.removeEventListener("touchend",this.onTouchEnd))},i.constructAriaLiveMessage=function(){var e=this.state,t=e.ariaLiveContext,n=e.selectValue,r=e.focusedValue,o=e.focusedOption,i=this.props,a=i.options,l=i.menuIsOpen,u=i.inputValue,c=i.screenReaderStatus;return(r?function(e){var t=e.focusedValue,n=e.getOptionLabel,r=e.selectValue;return"value "+n(t)+" focused, "+(r.indexOf(t)+1)+" of "+r.length+"."}({focusedValue:r,getOptionLabel:this.getOptionLabel,selectValue:n}):"")+" "+(o&&l?function(e){var t=e.focusedOption,n=e.getOptionLabel,r=e.options;return"option "+n(t)+" focused"+(t.isDisabled?" disabled":"")+", "+(r.indexOf(t)+1)+" of "+r.length+"."}({focusedOption:o,getOptionLabel:this.getOptionLabel,options:a}):"")+" "+function(e){var t=e.inputValue;return e.screenReaderMessage+(t?" for search term "+t:"")+"."}({inputValue:u,screenReaderMessage:c({count:this.countOptions()})})+" "+t},i.renderInput=function(){var e=this.props,t=e.isDisabled,n=e.isSearchable,r=e.inputId,i=e.inputValue,a=e.tabIndex,l=this.components.Input,u=this.state.inputIsHidden,c=r||this.getElementId("input"),s={"aria-autocomplete":"list","aria-label":this.props["aria-label"],"aria-labelledby":this.props["aria-labelledby"]};if(!n)return o.a.createElement(vt,Rt({id:c,innerRef:this.getInputRef,onBlur:this.onInputBlur,onChange:ae,onFocus:this.onInputFocus,readOnly:!0,disabled:t,tabIndex:a,value:""},s));var f=this.commonProps,p=f.cx,d=f.theme,h=f.selectProps;return o.a.createElement(l,Rt({autoCapitalize:"none",autoComplete:"off",autoCorrect:"off",cx:p,getStyles:this.getStyles,id:c,innerRef:this.getInputRef,isDisabled:t,isHidden:u,onBlur:this.onInputBlur,onChange:this.handleInputChange,onFocus:this.onInputFocus,selectProps:h,spellCheck:"false",tabIndex:a,theme:d,type:"text",value:i},s))},i.renderPlaceholderOrValue=function(){var e=this,t=this.components,n=t.MultiValue,r=t.MultiValueContainer,i=t.MultiValueLabel,a=t.MultiValueRemove,l=t.SingleValue,u=t.Placeholder,c=this.commonProps,s=this.props,f=s.controlShouldRenderValue,p=s.isDisabled,d=s.isMulti,h=s.inputValue,m=s.placeholder,g=this.state,v=g.selectValue,b=g.focusedValue,y=g.isFocused;if(!this.hasValue()||!f)return h?null:o.a.createElement(u,Rt({},c,{key:"placeholder",isDisabled:p,isFocused:y}),m);if(d)return v.map((function(t,l){var u=t===b;return o.a.createElement(n,Rt({},c,{components:{Container:r,Label:i,Remove:a},isFocused:u,isDisabled:p,key:e.getOptionValue(t),index:l,removeProps:{onClick:function(){return e.removeValue(t)},onTouchEnd:function(){return e.removeValue(t)},onMouseDown:function(e){e.preventDefault(),e.stopPropagation()}},data:t}),e.formatOptionLabel(t,"value"))}));if(h)return null;var w=v[0];return o.a.createElement(l,Rt({},c,{data:w,isDisabled:p}),this.formatOptionLabel(w,"value"))},i.renderClearIndicator=function(){var e=this.components.ClearIndicator,t=this.commonProps,n=this.props,r=n.isDisabled,i=n.isLoading,a=this.state.isFocused;if(!this.isClearable()||!e||r||!this.hasValue()||i)return null;var l={onMouseDown:this.onClearIndicatorMouseDown,onTouchEnd:this.onClearIndicatorTouchEnd,"aria-hidden":"true"};return o.a.createElement(e,Rt({},t,{innerProps:l,isFocused:a}))},i.renderLoadingIndicator=function(){var e=this.components.LoadingIndicator,t=this.commonProps,n=this.props,r=n.isDisabled,i=n.isLoading,a=this.state.isFocused;if(!e||!i)return null;return o.a.createElement(e,Rt({},t,{innerProps:{"aria-hidden":"true"},isDisabled:r,isFocused:a}))},i.renderIndicatorSeparator=function(){var e=this.components,t=e.DropdownIndicator,n=e.IndicatorSeparator;if(!t||!n)return null;var r=this.commonProps,i=this.props.isDisabled,a=this.state.isFocused;return o.a.createElement(n,Rt({},r,{isDisabled:i,isFocused:a}))},i.renderDropdownIndicator=function(){var e=this.components.DropdownIndicator;if(!e)return null;var t=this.commonProps,n=this.props.isDisabled,r=this.state.isFocused,i={onMouseDown:this.onDropdownIndicatorMouseDown,onTouchEnd:this.onDropdownIndicatorTouchEnd,"aria-hidden":"true"};return o.a.createElement(e,Rt({},t,{innerProps:i,isDisabled:n,isFocused:r}))},i.renderMenu=function(){var e=this,t=this.components,n=t.Group,r=t.GroupHeading,i=t.Menu,a=t.MenuList,l=t.MenuPortal,u=t.LoadingMessage,c=t.NoOptionsMessage,s=t.Option,f=this.commonProps,p=this.state,d=p.focusedOption,h=p.menuOptions,m=this.props,g=m.captureMenuScroll,v=m.inputValue,b=m.isLoading,y=m.loadingMessage,w=m.minMenuHeight,x=m.maxMenuHeight,E=m.menuIsOpen,C=m.menuPlacement,k=m.menuPosition,S=m.menuPortalTarget,O=m.menuShouldBlockScroll,T=m.menuShouldScrollIntoView,_=m.noOptionsMessage,P=m.onMenuScrollToTop,A=m.onMenuScrollToBottom;if(!E)return null;var M,z=function(t){var n=d===t.data;return t.innerRef=n?e.getFocusedOptionRef:void 0,o.a.createElement(s,Rt({},f,t,{isFocused:n}),e.formatOptionLabel(t.data,"menu"))};if(this.hasOptions())M=h.render.map((function(t){if("group"===t.type){t.type;var i=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(t,["type"]),a=t.key+"-heading";return o.a.createElement(n,Rt({},f,i,{Heading:r,headingProps:{id:a},label:e.formatGroupLabel(t.data)}),t.options.map((function(e){return z(e)})))}if("option"===t.type)return z(t)}));else if(b){var F=y({inputValue:v});if(null===F)return null;M=o.a.createElement(u,f,F)}else{var D=_({inputValue:v});if(null===D)return null;M=o.a.createElement(c,f,D)}var j={minMenuHeight:w,maxMenuHeight:x,menuPlacement:C,menuPosition:k,menuShouldScrollIntoView:T},L=o.a.createElement(Ee,Rt({},f,j),(function(t){var n=t.ref,r=t.placerProps,l=r.placement,u=r.maxHeight;return o.a.createElement(i,Rt({},f,j,{innerRef:n,innerProps:{onMouseDown:e.onMenuMouseDown,onMouseMove:e.onMenuMouseMove},isLoading:b,placement:l}),o.a.createElement(Mt,{isEnabled:g,onTopArrive:P,onBottomArrive:A},o.a.createElement(Pt,{isEnabled:O},o.a.createElement(a,Rt({},f,{innerRef:e.getMenuListRef,isLoading:b,maxHeight:u}),M))))}));return S||"fixed"===k?o.a.createElement(l,Rt({},f,{appendTo:S,controlElement:this.controlRef,menuPlacement:C,menuPosition:k}),L):L},i.renderFormField=function(){var e=this,t=this.props,n=t.delimiter,r=t.isDisabled,i=t.isMulti,a=t.name,l=this.state.selectValue;if(a&&!r){if(i){if(n){var u=l.map((function(t){return e.getOptionValue(t)})).join(n);return o.a.createElement("input",{name:a,type:"hidden",value:u})}var c=l.length>0?l.map((function(t,n){return o.a.createElement("input",{key:"i-"+n,name:a,type:"hidden",value:e.getOptionValue(t)})})):o.a.createElement("input",{name:a,type:"hidden"});return o.a.createElement("div",null,c)}var s=l[0]?this.getOptionValue(l[0]):"";return o.a.createElement("input",{name:a,type:"hidden",value:s})}},i.renderLiveRegion=function(){return this.state.isFocused?o.a.createElement(mt,{"aria-live":"polite"},o.a.createElement("p",{id:"aria-selection-event"}," ",this.state.ariaLiveSelection),o.a.createElement("p",{id:"aria-context"}," ",this.constructAriaLiveMessage())):null},i.render=function(){var e=this.components,t=e.Control,n=e.IndicatorsContainer,r=e.SelectContainer,i=e.ValueContainer,a=this.props,l=a.className,u=a.id,c=a.isDisabled,s=a.menuIsOpen,f=this.state.isFocused,p=this.commonProps=this.getCommonProps();return o.a.createElement(r,Rt({},p,{className:l,innerProps:{id:u,onKeyDown:this.onKeyDown},isDisabled:c,isFocused:f}),this.renderLiveRegion(),o.a.createElement(t,Rt({},p,{innerRef:this.getControlRef,innerProps:{onMouseDown:this.onControlMouseDown,onTouchEnd:this.onControlTouchEnd},isDisabled:c,isFocused:f,menuIsOpen:s}),o.a.createElement(i,Rt({},p,{isDisabled:c}),this.renderPlaceholderOrValue(),this.renderInput()),o.a.createElement(n,Rt({},p,{isDisabled:c}),this.renderClearIndicator(),this.renderLoadingIndicator(),this.renderIndicatorSeparator(),this.renderDropdownIndicator())),this.renderMenu(),this.renderFormField())},r}(r.Component);function Wt(){return(Wt=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}Ht.defaultProps=Vt;var Ut={defaultInputValue:"",defaultMenuIsOpen:!1,defaultValue:null},$t=n(18);r.Component,Yt=Ht,Gt=Qt=function(e){var t,n;function r(){for(var t,n=arguments.length,r=new Array(n),o=0;o<n;o++)r[o]=arguments[o];return(t=e.call.apply(e,[this].concat(r))||this).select=void 0,t.state={inputValue:void 0!==t.props.inputValue?t.props.inputValue:t.props.defaultInputValue,menuIsOpen:void 0!==t.props.menuIsOpen?t.props.menuIsOpen:t.props.defaultMenuIsOpen,value:void 0!==t.props.value?t.props.value:t.props.defaultValue},t.onChange=function(e,n){t.callProp("onChange",e,n),t.setState({value:e})},t.onInputChange=function(e,n){var r=t.callProp("onInputChange",e,n);t.setState({inputValue:void 0!==r?r:e})},t.onMenuOpen=function(){t.callProp("onMenuOpen"),t.setState({menuIsOpen:!0})},t.onMenuClose=function(){t.callProp("onMenuClose"),t.setState({menuIsOpen:!1})},t}n=e,(t=r).prototype=Object.create(n.prototype),t.prototype.constructor=t,t.__proto__=n;var i=r.prototype;return i.focus=function(){this.select.focus()},i.blur=function(){this.select.blur()},i.getProp=function(e){return void 0!==this.props[e]?this.props[e]:this.state[e]},i.callProp=function(e){if("function"==typeof this.props[e]){for(var t,n=arguments.length,r=new Array(n>1?n-1:0),o=1;o<n;o++)r[o-1]=arguments[o];return(t=this.props)[e].apply(t,r)}},i.render=function(){var e=this,t=this.props,n=(t.defaultInputValue,t.defaultMenuIsOpen,t.defaultValue,function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(t,["defaultInputValue","defaultMenuIsOpen","defaultValue"]));return o.a.createElement(Yt,Wt({},n,{ref:function(t){e.select=t},inputValue:this.getProp("inputValue"),menuIsOpen:this.getProp("menuIsOpen"),onChange:this.onChange,onInputChange:this.onInputChange,onMenuClose:this.onMenuClose,onMenuOpen:this.onMenuOpen,value:this.getProp("value")}))},r}(r.Component),Qt.defaultProps=Ut;var Yt,Qt,Gt,Xt=n(14),qt=n.n(Xt),Kt=n(17);n(24),n(25);function Zt(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}var Jt=function(e,t){return"function"==typeof t?t(e):function(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?Zt(n,!0).forEach((function(t){qt()(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):Zt(n).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}({},e,{},t)},en=Object(Kt.a)((function(e){return Object(Kt.a)((function(t){return Jt(e,t)}))})),tn=function(e){return Object(r.createElement)(ie.c.Consumer,null,(function(t){return e.theme!==t&&(t=en(t)(e.theme)),Object(r.createElement)(ie.c.Provider,{value:t},e.children)}))};n(5);var nn=function(){return(nn=Object.assign||function(e){for(var t,n=1,r=arguments.length;n<r;n++)for(var o in t=arguments[n])Object.prototype.hasOwnProperty.call(t,o)&&(e[o]=t[o]);return e}).apply(this,arguments)},rn=Object(C.default)(d)(Y.borderRadius);function on(e){return r.createElement(rn,nn({},e))}on.defaultProps={bg:"neutral_0",borderRadius:"normal",boxShadow:"card",p:3};var an=function(){return(an=Object.assign||function(e){for(var t,n=1,r=arguments.length;n<r;n++)for(var o in t=arguments[n])Object.prototype.hasOwnProperty.call(t,o)&&(e[o]=t[o]);return e}).apply(this,arguments)},ln=function(e,t){var n={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(r=Object.getOwnPropertySymbols(e);o<r.length;o++)t.indexOf(r[o])<0&&Object.prototype.propertyIsEnumerable.call(e,r[o])&&(n[r[o]]=e[r[o]])}return n};var un=function(e){var t=e.sx,n=e.inheritColor,o=e.variant,i=void 0===o?"h3":o,a=e.loading,l=ln(e,["sx","inheritColor","variant","loading"]);return r.createElement(s,an({as:i,variant:"heading."+i},l,{sx:an(an(an({fontFamily:"Lato",fontWeight:1,letterSpacing:0,lineHeight:"normal",m:0,color:"neutral_8"},t||{}),n?{color:"inherit !important"}:{}),a?{letterSpacing:"0px",color:"neutral_2 !important"}:{})}))},cn=function(){return(cn=Object.assign||function(e){for(var t,n=1,r=arguments.length;n<r;n++)for(var o in t=arguments[n])Object.prototype.hasOwnProperty.call(t,o)&&(e[o]=t[o]);return e}).apply(this,arguments)},sn=function(e,t){var n={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(r=Object.getOwnPropertySymbols(e);o<r.length;o++)t.indexOf(r[o])<0&&Object.prototype.propertyIsEnumerable.call(e,r[o])&&(n[r[o]]=e[r[o]])}return n};var fn=function(e){var t=e.reverseAlign,n=void 0!==t&&t,o=e.children,i=e.text,a=e.contents,u=e.username,c=sn(e,["reverseAlign","children","text","contents","username"]),s=r.useMemo((function(){return n?"primary":"secondary"}),[n]);return r.createElement(l.Flex,cn({flexDirection:n?"row-reverse":"row"},c),r.createElement(g,cn({variant:"medium",username:u},n?{ml:2}:{mr:2}),!u&&r.createElement(K,{icon:["fas","robot"]})),r.createElement(l.Flex,{flexDirection:"column",style:{maxWidth:"320px"}},a&&a.image&&r.createElement(te,{src:a.image,width:1,sx:{borderRadius:"16px 16px 0 0"}}),r.createElement(hn,{reverseAlign:n,variant:s,borderRadius:a&&a.image?"0 0 16px 16px":null,sx:i||o?{}:{color:"neutral_6",fontStyle:"italic"}},i||"There is no response text here"),r.createElement(mn,{buttons:a&&a.buttons})))},pn=function(){return(pn=Object.assign||function(e){for(var t,n=1,r=arguments.length;n<r;n++)for(var o in t=arguments[n])Object.prototype.hasOwnProperty.call(t,o)&&(e[o]=t[o]);return e}).apply(this,arguments)},dn=function(e,t){var n={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(r=Object.getOwnPropertySymbols(e);o<r.length;o++)t.indexOf(r[o])<0&&Object.prototype.propertyIsEnumerable.call(e,r[o])&&(n[r[o]]=e[r[o]])}return n};var hn=function(e){var t=e.sx,n=e.reverseAlign,o=void 0!==n&&n,i=e.variant,a=void 0===i?"primary":i,u=e.borderRadius,c=dn(e,["sx","reverseAlign","variant","borderRadius"]);return r.createElement(l.Box,pn({px:"20px",py:"10px",maxWidth:"408px",width:[0,"stretch"],fontSize:2,variant:"messageBubble."+a,sx:pn(pn(pn({letterSpacing:0,lineHeight:"normal",overflowWrap:"break-word"},t||{}),o?{alignSelf:"flex-end"}:{alignSelf:"flex-start"}),u?{borderRadius:u}:o?{borderRadius:"20px 4px 20px 20px"}:{borderRadius:"4px 20px 20px 20px"})},c))};var mn=function(e){var t=e.onClick,n=e.buttons,o=void 0===n?[]:n;return o&&0!==o.length?r.createElement(r.Fragment,null,Array.isArray(o)&&r.createElement(l.Flex,{mt:2,px:"12px",flexWrap:"wrap"},o.map((function(e,n){return r.createElement(E,{disabled:!t,key:e&&e.payload||"new-button-"+n,onClick:function(n){n.stopPropagation(),t&&t(e?e.payload:"")},mr:1,mb:1,variant:"primary"},e&&e.title||"New Button")})))):null};var gn=function(){return(gn=Object.assign||function(e){for(var t,n=1,r=arguments.length;n<r;n++)for(var o in t=arguments[n])Object.prototype.hasOwnProperty.call(t,o)&&(e[o]=t[o]);return e}).apply(this,arguments)},vn=function(e,t){var n={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(r=Object.getOwnPropertySymbols(e);o<r.length;o++)t.indexOf(r[o])<0&&Object.prototype.propertyIsEnumerable.call(e,r[o])&&(n[r[o]]=e[r[o]])}return n};function bn(e){var t=e.actions,n=e.body,o=e.heading,i=e.image,a=e.imageAlt,u=e.imageWidth,c=void 0===u?220:u,s=e.imageStyle,f=void 0===s?{}:s,p=vn(e,["actions","body","heading","image","imageAlt","imageWidth","imageStyle"]);return r.createElement(l.Flex,gn({flex:"1 1 auto",color:"neutral_6",flexDirection:"column",justifyContent:"center",alignItems:"center",style:{height:"100%",backgroundColor:"transparent"}},p),i&&r.createElement(te,{mb:4,width:c,src:i,alt:a,style:f}),o&&r.createElement(un,{variant:"h4"},o),r.createElement(oe,{as:"div",inheritColor:!0,textAlign:"center",variant:"captionSecondary",lineHeight:"normal",sx:{maxWidth:"220px"},p:2},n),t&&r.createElement(l.Box,{mt:3},t))}bn.defaultProps={bg:"neutral_0"};var yn=bn;n(26);var wn=function(){return(wn=Object.assign||function(e){for(var t,n=1,r=arguments.length;n<r;n++)for(var o in t=arguments[n])Object.prototype.hasOwnProperty.call(t,o)&&(e[o]=t[o]);return e}).apply(this,arguments)},xn={bg:"primary_2",border:"none",borderRadius:"normal",boxShadow:"button",color:"neutral_0",fontSize:1,fontWeight:1,lineHeight:"normal",px:3,py:"6px","&.large":{fontSize:2,py:"10px"},"&:hover":{bg:"primary_1"},"&:disabled, &.disabled":{color:"neutral_6",bg:"neutral_3",boxShadow:"none"}},En={bg:"transparent",border:"none",borderRadius:"normal",color:"neutral_6",fontSize:"10px",lineHeight:0,p:2,"&:hover":{color:"neutral_7"},"&.active":{color:"primary_1"}},Cn={primary:xn,primaryDark:wn(wn({},xn),{boxShadow:"none"}),login:wn(wn({},xn),{letterSpacing:"1.1px !important",lineHeight:"28px"}),secondary:{bg:"transparent",border:"1px solid",borderColor:"primary_2",borderRadius:"normal",color:"primary_2",fontSize:1,fontWeight:1,lineHeight:"normal",px:3,py:"5px","&.large":{fontSize:2,py:"9px"},"&:hover":{borderColor:"primary_1",color:"primary_1"},"&:disabled, &.disabled":{borderColor:"neutral_5",color:"neutral_6"}},dark:{bg:"neutral_8",borderRadius:"normal",color:"neutral_0",fontSize:1,fontWeight:1,lineHeight:"normal",px:3,py:"6px","&.large":{fontSize:2,py:"10px"}},inverse:{bg:"transparent",border:"1px solid",borderColor:"neutral_0",borderRadius:"normal",color:"neutral_0",fontSize:1,fontWeight:1,lineHeight:"normal",px:3,py:"6px","&.large":{fontSize:2,py:"10px"}},plain:{bg:"transparent",border:"none",color:"neutral_0",fontSize:1,fontWeight:1,lineHeight:"small",p:0,"&.large":{fontSize:2}},icon:{bg:"transparent",border:"none",borderRadius:"normal",color:"neutral_6",fontSize:"16px",lineHeight:0,p:"12px","&.active":{bg:"transparent",color:"primary_2"},"&:hover":{bg:"primary_2",color:"neutral_0"}},headerIcon:{bg:"neutral_0",border:"none",borderRadius:0,color:"neutral_6",fontSize:"16px",lineHeight:0,p:"28px","&.active":{bg:"primary_2",color:"neutral_0"},"&:hover":{"&.active":{bg:"primary_1",color:"neutral_0"},bg:"neutral_1",color:"neutral_6"}},plainIcon:En,plainIconDark:wn(wn({},En),{"&:hover":wn(wn({},En["&:hover"]),{color:"neutral_1"})}),barIcon:{bg:"transparent",border:"none",borderRadius:0,color:"primary_2",fontSize:"16px",lineHeight:0,p:"24px","&.active":{bg:"primary_2",color:"neutral_0"},"&:hover":{"&.active":{bg:"primary_1",color:"neutral_0"},bg:"neutral_1",color:"primary_1"}},onboardingIcon:wn(wn({},xn),{bg:"neutral_0",color:"primary_2",padding:0,border:"1px solid",borderColor:"primary_4",borderRadius:50,fontSize:"20px",width:"48px",height:"48px","&:hover":{bg:"neutral_1",color:"neutral_7"},"&.active":{color:"primary_1"}})},kn={alert:{primary:{bg:"primary_2",color:"neutral_0"},error:{bg:"secondary_2",color:"neutral_0"},success:{bg:"cyan_3",color:"#56bdba",borderColor:"cyan_2",border:"solid 1px"},alertThatUsesColoursThatArentInTheStyleguide:{bg:"#e8fff7",color:"#56bdba",border:"solid 1px",borderColor:"#88deca"}},avatar:{small:{width:"28px",height:"28px"},medium:{width:"32px",height:"32px"},large:{width:"40px",height:"40px"},xl:{width:"56px",height:"56px"}},messageBubble:{primary:{bg:"primary_2",color:"neutral_0"},secondary:{bg:"neutral_3",color:"neutral_8"},flagged:{bg:"secondary_2",color:"neutral_0"},neutral:{bg:"neutral_0",color:"neutral_8"},custom:{bg:"neutral_3",color:"neutral_8",padding:0}},separator:{left:{"& > :first-of-type":{pr:"12px"},"&:before":{display:"none"}},center:{"& > :first-of-type":{px:"12px"}},right:{"& > :first-of-type":{pl:"12px"},"&:after":{display:"none"}}}},Sn={button:Cn,option:{default:{color:"neutral_8","&:hover":{bg:"neutral_1"}},primary:{color:"primary_2","&:hover":{bg:"neutral_1",color:"primary_1","&.active":{bg:"primary_1",color:"neutral_0"}}}}},On={text:{bodyPrimary:{color:"neutral_8",fontSize:3,lineHeight:"normal"},bodySecondary:{color:"neutral_8",fontSize:2,lineHeight:"normal"},captionPrimary:{color:"neutral_6",fontSize:2,lineHeight:"small"},captionSecondary:{color:"neutral_6",fontSize:0,lineHeight:"tight"},metadata:{color:"neutral_7",fontSize:0,lineHeight:"tight"},code:{color:"neutral_8",fontFamily:"IBM Plex Mono",fontSize:1,lineHeight:"normal"}},heading:{h1:{fontSize:6},h2:{fontSize:4},h3:{fontSize:3},h4:{fontSize:2},h5:{fontSize:1}}},Tn=[400,700],_n={primary_1:"#4A3DD4",primary_2:"#574AE2",primary_3:"#6C5EF9",primary_4:"#DDDCF6",neutral_0:"#FFFFFF",neutral_1:"#F6F7FB",neutral_2:"#F2F4FA",neutral_3:"#ECEFF7",neutral_4:"#DDE2EF",neutral_5:"#CED5E7",neutral_6:"#979FB4",neutral_7:"#2C3951",neutral_8:"#252D40",secondary_1:"#972422",secondary_2:"#C6423F",secondary_3:"#E85653",secondary_4:"#F8CBCA",orange_1:"#EB9015",orange_2:"#FF9F1C",orange_3:"#F8DEBB",yellow_1:"#E4B912",yellow_2:"#FFD121",yellow_3:"#FBF2D1",green_1:"#48A9A6",green_2:"#4DD6D2",green_3:"#D6EBEA",blue_1:"#3158D6",blue_2:"#446FF8",blue_3:"#D0E3FC",cyan_1:"#4DD6D2",cyan_2:"#87E5E2",cyan_3:"#DEFBFA",dark_1:"#2C3951",dark_2:"#263146",grey:"#dde2ef",black:"#333333",white:"#ffffff",active:"#e6f7ff"},Pn={breakpoints:["0","500px","800px","1200px","1900px"],colors:_n,colours:_n,fontSizes:[11,12,13,14,16,20,24,32,48,64,72],fontWeights:Tn,gradients:{primary_1:"linear-gradient(56deg, rgba(113, 22, 208, 0.95), #4e61e1)"},heights:[32,40,48,56,64],letterSpacings:[.6,.7,.9,1.4],lineHeights:{large:"36px",big:"24px",normal:"20px",small:"16px",tight:"12px"},space:[0,4,8,16,24,32,40,48,56,64,72,80,128,256,512],fonts:{mono:'"IBM Plex Mono", "Courier New", Courier, monospace',sansSerif:'"Lato", -apple-system, BlinkMacSystemFont, "avenir next", avenir, "helvetica neue", helvetica, ubuntu, roboto, noto, "segoe ui", arial, sans-serif'},active:{background:_n.active},radii:{tiny:"2px",small:"4px",normal:"8px",large:"16px"},actions:{default:{color:_n.neutral_6},incorrect:{color:_n.neutral_6,textDecoration:"line-through"},correct:{color:_n.primary_2,fontWeight:Tn[1]}},shadows:{sm:"0 6px 21px 0 rgba(152, 152, 152, 0.06)",md:"0 3px 6px 0 rgba(96,97,149,0.20)",lg:"0 11px 43px 0 rgba(120, 119, 150, 0.1)",selectBox:"0 2px 8px 0 "+_n.neutral_5,card:"0 2px 4px 0 rgba(207, 207, 207, 0.21)",button:"0 2px 4px 0 rgba(207, 207, 207, 0.67)"},menuItem:{default:{color:_n.neutral_6,backgroundColor:"transparent","&:hover":{color:_n.neutral_0,backgroundColor:_n.dark_2}}},text:On,buttons:Sn,variants:kn},An=function(e){var t=e.children;return r.createElement(tn,{theme:Pn},t)},Mn={prefix:"fas",iconName:"arrow-left",icon:[448,512,[],"f060","M257.5 445.1l-22.2 22.2c-9.4 9.4-24.6 9.4-33.9 0L7 273c-9.4-9.4-9.4-24.6 0-33.9L201.4 44.7c9.4-9.4 24.6-9.4 33.9 0l22.2 22.2c9.5 9.5 9.3 25-.4 34.3L136.6 216H424c13.3 0 24 10.7 24 24v32c0 13.3-10.7 24-24 24H136.6l120.5 114.8c9.8 9.3 10 24.8.4 34.3z"]},zn={prefix:"fas",iconName:"bookmark",icon:[384,512,[],"f02e","M0 512V48C0 21.49 21.49 0 48 0h288c26.51 0 48 21.49 48 48v464L192 400 0 512z"]},Fn={prefix:"fas",iconName:"check",icon:[512,512,[],"f00c","M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204-.001z"]},Dn={prefix:"fas",iconName:"check-circle",icon:[512,512,[],"f058","M504 256c0 136.967-111.033 248-248 248S8 392.967 8 256 119.033 8 256 8s248 111.033 248 248zM227.314 387.314l184-184c6.248-6.248 6.248-16.379 0-22.627l-22.627-22.627c-6.248-6.249-16.379-6.249-22.628 0L216 308.118l-70.059-70.059c-6.248-6.248-16.379-6.248-22.628 0l-22.627 22.627c-6.248 6.248-6.248 16.379 0 22.627l104 104c6.249 6.249 16.379 6.249 22.628.001z"]},jn={prefix:"fas",iconName:"circle",icon:[512,512,[],"f111","M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8z"]},Ln={prefix:"fas",iconName:"exclamation-circle",icon:[512,512,[],"f06a","M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"]},Rn={prefix:"fas",iconName:"flag-alt",icon:[512,512,[],"f74c","M32 0C14.3 0 0 14.3 0 32v464c0 8.8 7.2 16 16 16h32c8.8 0 16-7.2 16-16V32C64 14.3 49.7 0 32 0zm430.6 4.2C291.3 91.5 305.4-62.2 96 32.4V384c185.7-92.2 221.7 53.3 397.5-23.1 11.4-5 18.5-16.5 18.5-28.8V30.8c0-25.1-26.8-38.1-49.4-26.6z"]},In={prefix:"fas",iconName:"minus",icon:[448,512,[],"f068","M416 208H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h384c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"]},Nn={prefix:"fas",iconName:"robot",icon:[640,512,[],"f544","M32,224H64V416H32A31.96166,31.96166,0,0,1,0,384V256A31.96166,31.96166,0,0,1,32,224Zm512-48V448a64.06328,64.06328,0,0,1-64,64H160a64.06328,64.06328,0,0,1-64-64V176a79.974,79.974,0,0,1,80-80H288V32a32,32,0,0,1,64,0V96H464A79.974,79.974,0,0,1,544,176ZM264,256a40,40,0,1,0-40,40A39.997,39.997,0,0,0,264,256Zm-8,128H192v32h64Zm96,0H288v32h64ZM456,256a40,40,0,1,0-40,40A39.997,39.997,0,0,0,456,256Zm-8,128H384v32h64ZM640,256V384a31.96166,31.96166,0,0,1-32,32H576V224h32A31.96166,31.96166,0,0,1,640,256Z"]},Vn={prefix:"fas",iconName:"spinner",icon:[512,512,[],"f110","M304 48c0 26.51-21.49 48-48 48s-48-21.49-48-48 21.49-48 48-48 48 21.49 48 48zm-48 368c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.49-48-48-48zm208-208c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.49-48-48-48zM96 256c0-26.51-21.49-48-48-48S0 229.49 0 256s21.49 48 48 48 48-21.49 48-48zm12.922 99.078c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48c0-26.509-21.491-48-48-48zm294.156 0c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48c0-26.509-21.49-48-48-48zM108.922 60.922c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.491-48-48-48z"]},Bn={prefix:"fas",iconName:"user",icon:[448,512,[],"f007","M224 256c70.7 0 128-57.3 128-128S294.7 0 224 0 96 57.3 96 128s57.3 128 128 128zm89.6 32h-16.7c-22.2 10.2-46.9 16-72.9 16s-50.6-5.8-72.9-16h-16.7C60.2 288 0 348.2 0 422.4V464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48v-41.6c0-74.2-60.2-134.4-134.4-134.4z"]},Hn={prefix:"far",iconName:"check-circle",icon:[512,512,[],"f058","M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm0 48c110.532 0 200 89.451 200 200 0 110.532-89.451 200-200 200-110.532 0-200-89.451-200-200 0-110.532 89.451-200 200-200m140.204 130.267l-22.536-22.718c-4.667-4.705-12.265-4.736-16.97-.068L215.346 303.697l-59.792-60.277c-4.667-4.705-12.265-4.736-16.97-.069l-22.719 22.536c-4.705 4.667-4.736 12.265-.068 16.971l90.781 91.516c4.667 4.705 12.265 4.736 16.97.068l172.589-171.204c4.704-4.668 4.734-12.266.067-16.971z"]},Wn={prefix:"far",iconName:"clone",icon:[512,512,[],"f24d","M464 0H144c-26.51 0-48 21.49-48 48v48H48c-26.51 0-48 21.49-48 48v320c0 26.51 21.49 48 48 48h320c26.51 0 48-21.49 48-48v-48h48c26.51 0 48-21.49 48-48V48c0-26.51-21.49-48-48-48zM362 464H54a6 6 0 0 1-6-6V150a6 6 0 0 1 6-6h42v224c0 26.51 21.49 48 48 48h224v42a6 6 0 0 1-6 6zm96-96H150a6 6 0 0 1-6-6V54a6 6 0 0 1 6-6h308a6 6 0 0 1 6 6v308a6 6 0 0 1-6 6z"]},Un={prefix:"far",iconName:"flag-alt",icon:[512,512,[],"f74c","M472.5 0c-7 0-14.3 1.5-21.2 4.6-50.5 22.7-87.8 30.3-119.1 30.3C266.1 34.9 227.7.4 151.4.4c-28.4 0-62.2 4.9-104.5 18C44.3 7.9 35.3 0 24 0 10.7 0 0 10.7 0 24v476c0 6.6 5.4 12 12 12h24c6.6 0 12-5.4 12-12V398.1c37.3-11.8 69.6-16.5 98.5-16.5 81.2 0 137.8 34.4 219.1 34.4 35.3 0 75.1-6.5 123.7-25 14-5.4 22.8-17.9 22.8-31.2V33.4C512 13 493.4 0 472.5 0zM464 349.1c-35.3 12.7-67.6 18.9-98.5 18.9-75.5 0-128.5-34.4-219.1-34.4-31.9 0-64.5 4.7-98.5 14.2V68.5C87.7 55 121.7 48.4 151.4 48.4c66.3 0 105.2 34.5 180.8 34.5 40.3 0 82.3-10 131.8-31.5v297.7z"]},$n={prefix:"far",iconName:"star",icon:[576,512,[],"f005","M528.1 171.5L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l130.7 68.7c23.2 12.2 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6zM388.6 312.3l23.7 138.4L288 385.4l-124.3 65.3 23.7-138.4-100.6-98 139-20.2 62.2-126 62.2 126 139 20.2-100.6 98z"]},Yn={prefix:"fal",iconName:"angle-right",icon:[192,512,[],"f105","M166.9 264.5l-117.8 116c-4.7 4.7-12.3 4.7-17 0l-7.1-7.1c-4.7-4.7-4.7-12.3 0-17L127.3 256 25.1 155.6c-4.7-4.7-4.7-12.3 0-17l7.1-7.1c4.7-4.7 12.3-4.7 17 0l117.8 116c4.6 4.7 4.6 12.3-.1 17z"]},Qn={prefix:"fal",iconName:"arrow-to-bottom",icon:[384,512,[],"f33d","M348.5 264l-148 148.5c-4.7 4.7-12.3 4.7-17 0L35.5 264c-4.7-4.7-4.7-12.3 0-17l7.1-7.1c4.7-4.7 12.3-4.7 17 0l115.4 116V44c0-6.6 5.4-12 12-12h10c6.6 0 12 5.4 12 12v311.9L324.4 240c4.7-4.7 12.3-4.7 17 0l7.1 7.1c4.7 4.6 4.7 12.2 0 16.9zM384 468v-8c0-6.6-5.4-12-12-12H12c-6.6 0-12 5.4-12 12v8c0 6.6 5.4 12 12 12h360c6.6 0 12-5.4 12-12z"]},Gn={prefix:"fal",iconName:"arrow-to-top",icon:[384,512,[],"f341","M35.5 248l148-148.5c4.7-4.7 12.3-4.7 17 0l148 148.5c4.7 4.7 4.7 12.3 0 17l-7.1 7.1c-4.7 4.7-12.3 4.7-17 0L209 156.1V468c0 6.6-5.4 12-12 12h-10c-6.6 0-12-5.4-12-12V156.1L59.6 272c-4.7 4.7-12.3 4.7-17 0l-7.1-7.1c-4.7-4.6-4.7-12.2 0-16.9zM0 44v8c0 6.6 5.4 12 12 12h360c6.6 0 12-5.4 12-12v-8c0-6.6-5.4-12-12-12H12C5.4 32 0 37.4 0 44z"]},Xn={prefix:"fal",iconName:"bookmark",icon:[384,512,[],"f02e","M336 0H48C21.49 0 0 21.49 0 48v464l192-112 192 112V48c0-26.51-21.49-48-48-48zm16 456.287l-160-93.333-160 93.333V48c0-8.822 7.178-16 16-16h288c8.822 0 16 7.178 16 16v408.287z"]},qn={prefix:"fal",iconName:"calendar-alt",icon:[448,512,[],"f073","M400 64h-48V12c0-6.6-5.4-12-12-12h-8c-6.6 0-12 5.4-12 12v52H128V12c0-6.6-5.4-12-12-12h-8c-6.6 0-12 5.4-12 12v52H48C21.5 64 0 85.5 0 112v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48zM48 96h352c8.8 0 16 7.2 16 16v48H32v-48c0-8.8 7.2-16 16-16zm352 384H48c-8.8 0-16-7.2-16-16V192h384v272c0 8.8-7.2 16-16 16zM148 320h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm-96 96h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm-96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm192 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12z"]},Kn={prefix:"fal",iconName:"chart-line",icon:[512,512,[],"f201","M504 416H32V72c0-4.42-3.58-8-8-8H8c-4.42 0-8 3.58-8 8v360c0 8.84 7.16 16 16 16h488c4.42 0 8-3.58 8-8v-16c0-4.42-3.58-8-8-8zM98.34 263.03c-3.12 3.12-3.12 8.19 0 11.31l11.31 11.31c3.12 3.12 8.19 3.12 11.31 0l72.69-72.01 84.69 84.69c6.25 6.25 16.38 6.25 22.63 0l93.53-93.53 44.04 44.04c4.95 4.95 11.03 7.16 17 7.16 12.48 0 24.46-9.7 24.46-24.34V112.19c0-8.94-7.25-16.19-16.19-16.19H344.34c-21.64 0-32.47 26.16-17.17 41.46l44.71 44.71-82.22 82.22-84.63-84.63c-6.23-6.23-16.32-6.25-22.57-.05l-84.12 83.32zM362.96 128H448v85.04L362.96 128z"]},Zn={prefix:"fal",iconName:"check",icon:[448,512,[],"f00c","M413.505 91.951L133.49 371.966l-98.995-98.995c-4.686-4.686-12.284-4.686-16.971 0L6.211 284.284c-4.686 4.686-4.686 12.284 0 16.971l118.794 118.794c4.686 4.686 12.284 4.686 16.971 0l299.813-299.813c4.686-4.686 4.686-12.284 0-16.971l-11.314-11.314c-4.686-4.686-12.284-4.686-16.97 0z"]},Jn={prefix:"fal",iconName:"check-circle",icon:[512,512,[],"f058","M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm0 464c-118.664 0-216-96.055-216-216 0-118.663 96.055-216 216-216 118.664 0 216 96.055 216 216 0 118.663-96.055 216-216 216zm141.63-274.961L217.15 376.071c-4.705 4.667-12.303 4.637-16.97-.068l-85.878-86.572c-4.667-4.705-4.637-12.303.068-16.97l8.52-8.451c4.705-4.667 12.303-4.637 16.97.068l68.976 69.533 163.441-162.13c4.705-4.667 12.303-4.637 16.97.068l8.451 8.52c4.668 4.705 4.637 12.303-.068 16.97z"]},er={prefix:"fal",iconName:"chevron-down",icon:[448,512,[],"f078","M443.5 162.6l-7.1-7.1c-4.7-4.7-12.3-4.7-17 0L224 351 28.5 155.5c-4.7-4.7-12.3-4.7-17 0l-7.1 7.1c-4.7 4.7-4.7 12.3 0 17l211 211.1c4.7 4.7 12.3 4.7 17 0l211-211.1c4.8-4.7 4.8-12.3.1-17z"]},tr={prefix:"fal",iconName:"circle-notch",icon:[512,512,[],"f1ce","M288 24.103v8.169a11.995 11.995 0 0 0 9.698 11.768C396.638 63.425 472 150.461 472 256c0 118.663-96.055 216-216 216-118.663 0-216-96.055-216-216 0-104.534 74.546-192.509 174.297-211.978A11.993 11.993 0 0 0 224 32.253v-8.147c0-7.523-6.845-13.193-14.237-11.798C94.472 34.048 7.364 135.575 8.004 257.332c.72 137.052 111.477 246.956 248.531 246.667C393.255 503.711 504 392.789 504 256c0-121.187-86.924-222.067-201.824-243.704C294.807 10.908 288 16.604 288 24.103z"]},nr={prefix:"fal",iconName:"clipboard",icon:[384,512,[],"f328","M336 64h-88.6c.4-2.6.6-5.3.6-8 0-30.9-25.1-56-56-56s-56 25.1-56 56c0 2.7.2 5.4.6 8H48C21.5 64 0 85.5 0 112v352c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48zM192 32c13.3 0 24 10.7 24 24s-10.7 24-24 24-24-10.7-24-24 10.7-24 24-24zm160 432c0 8.8-7.2 16-16 16H48c-8.8 0-16-7.2-16-16V112c0-8.8 7.2-16 16-16h48v20c0 6.6 5.4 12 12 12h168c6.6 0 12-5.4 12-12V96h48c8.8 0 16 7.2 16 16z"]},rr={prefix:"fal",iconName:"code-merge",icon:[384,512,[],"f387","M304 192c-41.7 0-76 32-79.7 72.8-25.2-1.3-61.6-7.9-88.8-31.7-20.3-17.8-32.8-43-37.5-75.1 35.5-8.2 62-40 62-77.9 0-44.2-35.8-80-80-80S0 35.8 0 80c0 38.7 27.5 71 64 78.4v195.2C27.5 361 0 393.3 0 432c0 44.2 35.8 80 80 80s80-35.8 80-80c0-38.7-27.5-71-64-78.4V237.4c5.5 7.2 11.7 13.9 18.6 19.9C151 289 197.9 296.1 228 297c10.5 31.9 40.5 55 76 55 44.2 0 80-35.8 80-80s-35.8-80-80-80zM32 80c0-26.5 21.5-48 48-48s48 21.5 48 48-21.5 48-48 48-48-21.5-48-48zm96 352c0 26.5-21.5 48-48 48s-48-21.5-48-48 21.5-48 48-48 48 21.5 48 48zm176-112c-26.5 0-48-21.5-48-48s21.5-48 48-48 48 21.5 48 48-21.5 48-48 48z"]},or={prefix:"fal",iconName:"cog",icon:[512,512,[],"f013","M482.696 299.276l-32.61-18.827a195.168 195.168 0 0 0 0-48.899l32.61-18.827c9.576-5.528 14.195-16.902 11.046-27.501-11.214-37.749-31.175-71.728-57.535-99.595-7.634-8.07-19.817-9.836-29.437-4.282l-32.562 18.798a194.125 194.125 0 0 0-42.339-24.48V38.049c0-11.13-7.652-20.804-18.484-23.367-37.644-8.909-77.118-8.91-114.77 0-10.831 2.563-18.484 12.236-18.484 23.367v37.614a194.101 194.101 0 0 0-42.339 24.48L105.23 81.345c-9.621-5.554-21.804-3.788-29.437 4.282-26.36 27.867-46.321 61.847-57.535 99.595-3.149 10.599 1.47 21.972 11.046 27.501l32.61 18.827a195.168 195.168 0 0 0 0 48.899l-32.61 18.827c-9.576 5.528-14.195 16.902-11.046 27.501 11.214 37.748 31.175 71.728 57.535 99.595 7.634 8.07 19.817 9.836 29.437 4.283l32.562-18.798a194.08 194.08 0 0 0 42.339 24.479v37.614c0 11.13 7.652 20.804 18.484 23.367 37.645 8.909 77.118 8.91 114.77 0 10.831-2.563 18.484-12.236 18.484-23.367v-37.614a194.138 194.138 0 0 0 42.339-24.479l32.562 18.798c9.62 5.554 21.803 3.788 29.437-4.283 26.36-27.867 46.321-61.847 57.535-99.595 3.149-10.599-1.47-21.972-11.046-27.501zm-65.479 100.461l-46.309-26.74c-26.988 23.071-36.559 28.876-71.039 41.059v53.479a217.145 217.145 0 0 1-87.738 0v-53.479c-33.621-11.879-43.355-17.395-71.039-41.059l-46.309 26.74c-19.71-22.09-34.689-47.989-43.929-75.958l46.329-26.74c-6.535-35.417-6.538-46.644 0-82.079l-46.329-26.74c9.24-27.969 24.22-53.869 43.929-75.969l46.309 26.76c27.377-23.434 37.063-29.065 71.039-41.069V44.464a216.79 216.79 0 0 1 87.738 0v53.479c33.978 12.005 43.665 17.637 71.039 41.069l46.309-26.76c19.709 22.099 34.689 47.999 43.929 75.969l-46.329 26.74c6.536 35.426 6.538 46.644 0 82.079l46.329 26.74c-9.24 27.968-24.219 53.868-43.929 75.957zM256 160c-52.935 0-96 43.065-96 96s43.065 96 96 96 96-43.065 96-96-43.065-96-96-96zm0 160c-35.29 0-64-28.71-64-64s28.71-64 64-64 64 28.71 64 64-28.71 64-64 64z"]},ir={prefix:"fal",iconName:"comment",icon:[512,512,[],"f075","M256 64c123.5 0 224 79 224 176S379.5 416 256 416c-28.3 0-56.3-4.3-83.2-12.8l-15.2-4.8-13 9.2c-23 16.3-58.5 35.3-102.6 39.6 12-15.1 29.8-40.4 40.8-69.6l7.1-18.7-13.7-14.6C47.3 313.7 32 277.6 32 240c0-97 100.5-176 224-176m0-32C114.6 32 0 125.1 0 240c0 47.6 19.9 91.2 52.9 126.3C38 405.7 7 439.1 6.5 439.5c-6.6 7-8.4 17.2-4.6 26 3.8 8.8 12.4 14.5 22 14.5 61.5 0 110-25.7 139.1-46.3 29 9.1 60.2 14.3 93 14.3 141.4 0 256-93.1 256-208S397.4 32 256 32z"]},ar={prefix:"fal",iconName:"comments",icon:[576,512,[],"f086","M569.9 441.1c-.5-.4-22.6-24.2-37.9-54.9 27.5-27.1 44-61.1 44-98.2 0-80-76.5-146.1-176.2-157.9C368.4 72.5 294.3 32 208 32 93.1 32 0 103.6 0 192c0 37 16.5 71 44 98.2-15.3 30.7-37.3 54.5-37.7 54.9-6.3 6.7-8.1 16.5-4.4 25 3.6 8.5 12 14 21.2 14 53.5 0 96.7-20.2 125.2-38.8 9.1 2.1 18.4 3.7 28 4.8 31.5 57.5 105.5 98 191.8 98 20.8 0 40.8-2.4 59.8-6.8 28.5 18.5 71.6 38.8 125.2 38.8 9.2 0 17.5-5.5 21.2-14 3.6-8.5 1.9-18.3-4.4-25zM155.4 314l-13.2-3-11.4 7.4c-20.1 13.1-50.5 28.2-87.7 32.5 8.8-11.3 20.2-27.6 29.5-46.4L83 283.7l-16.5-16.3C50.7 251.9 32 226.2 32 192c0-70.6 79-128 176-128s176 57.4 176 128-79 128-176 128c-17.7 0-35.4-2-52.6-6zm289.8 100.4l-11.4-7.4-13.2 3.1c-17.2 4-34.9 6-52.6 6-65.1 0-122-25.9-152.4-64.3C326.9 348.6 416 278.4 416 192c0-9.5-1.3-18.7-3.3-27.7C488.1 178.8 544 228.7 544 288c0 34.2-18.7 59.9-34.5 75.4L493 379.7l10.3 20.7c9.4 18.9 20.8 35.2 29.5 46.4-37.1-4.2-67.5-19.4-87.6-32.4z"]},lr={prefix:"fal",iconName:"edit",icon:[576,512,[],"f044","M417.8 315.5l20-20c3.8-3.8 10.2-1.1 10.2 4.2V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V112c0-26.5 21.5-48 48-48h292.3c5.3 0 8 6.5 4.2 10.2l-20 20c-1.1 1.1-2.7 1.8-4.2 1.8H48c-8.8 0-16 7.2-16 16v352c0 8.8 7.2 16 16 16h352c8.8 0 16-7.2 16-16V319.7c0-1.6.6-3.1 1.8-4.2zm145.9-191.2L251.2 436.8l-99.9 11.1c-13.4 1.5-24.7-9.8-23.2-23.2l11.1-99.9L451.7 12.3c16.4-16.4 43-16.4 59.4 0l52.6 52.6c16.4 16.4 16.4 43 0 59.4zm-93.6 48.4L403.4 106 169.8 339.5l-8.3 75.1 75.1-8.3 233.5-233.6zm71-85.2l-52.6-52.6c-3.8-3.8-10.2-4-14.1 0L426 83.3l66.7 66.7 48.4-48.4c3.9-3.8 3.9-10.2 0-14.1z"]},ur={prefix:"fal",iconName:"ellipsis-h",icon:[320,512,[],"f141","M192 256c0 17.7-14.3 32-32 32s-32-14.3-32-32 14.3-32 32-32 32 14.3 32 32zm88-32c-17.7 0-32 14.3-32 32s14.3 32 32 32 32-14.3 32-32-14.3-32-32-32zm-240 0c-17.7 0-32 14.3-32 32s14.3 32 32 32 32-14.3 32-32-14.3-32-32-32z"]},cr={prefix:"fal",iconName:"ellipsis-v",icon:[64,512,[],"f142","M32 224c17.7 0 32 14.3 32 32s-14.3 32-32 32-32-14.3-32-32 14.3-32 32-32zM0 136c0 17.7 14.3 32 32 32s32-14.3 32-32-14.3-32-32-32-32 14.3-32 32zm0 240c0 17.7 14.3 32 32 32s32-14.3 32-32-14.3-32-32-32-32 14.3-32 32z"]},sr={prefix:"fal",iconName:"exclamation-circle",icon:[512,512,[],"f06a","M256 40c118.621 0 216 96.075 216 216 0 119.291-96.61 216-216 216-119.244 0-216-96.562-216-216 0-119.203 96.602-216 216-216m0-32C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm-11.49 120h22.979c6.823 0 12.274 5.682 11.99 12.5l-7 168c-.268 6.428-5.556 11.5-11.99 11.5h-8.979c-6.433 0-11.722-5.073-11.99-11.5l-7-168c-.283-6.818 5.167-12.5 11.99-12.5zM256 340c-15.464 0-28 12.536-28 28s12.536 28 28 28 28-12.536 28-28-12.536-28-28-28z"]},fr={prefix:"fal",iconName:"file-check",icon:[384,512,[],"f316","M369.941 97.941l-83.882-83.882A48 48 0 0 0 252.118 0H48C21.49 0 0 21.49 0 48v416c0 26.51 21.49 48 48 48h288c26.51 0 48-21.49 48-48V131.882a48 48 0 0 0-14.059-33.941zm-22.627 22.628a15.89 15.89 0 0 1 4.195 7.431H256V32.491a15.88 15.88 0 0 1 7.431 4.195l83.883 83.883zM336 480H48c-8.837 0-16-7.163-16-16V48c0-8.837 7.163-16 16-16h176v104c0 13.255 10.745 24 24 24h104v304c0 8.837-7.163 16-16 16zm-34.467-210.949l-134.791 133.71c-4.7 4.663-12.288 4.642-16.963-.046l-67.358-67.552c-4.683-4.697-4.672-12.301.024-16.985l8.505-8.48c4.697-4.683 12.301-4.672 16.984.024l50.442 50.587 117.782-116.837c4.709-4.671 12.313-4.641 16.985.068l8.458 8.527c4.672 4.709 4.641 12.313-.068 16.984z"]},pr={prefix:"fal",iconName:"highlighter",icon:[544,512,[],"f591","M528.61 75.91l-60.49-60.52C457.91 5.16 444.45 0 430.98 0a52.38 52.38 0 0 0-34.75 13.15L110.59 261.8c-10.29 9.08-14.33 23.35-10.33 36.49l12.49 41.02-36.54 36.56c-6.74 6.75-6.74 17.68 0 24.43l.25.26L0 479.98 99.88 512l43.99-44.01.02.02c6.75 6.75 17.69 6.75 24.44 0l36.46-36.47 40.91 12.53c18.01 5.51 31.41-4.54 36.51-10.32l248.65-285.9c18.35-20.82 17.37-52.32-2.25-71.94zM91.05 475.55l-32.21-10.33 40.26-42.03 22.14 22.15-30.19 30.21zm167.16-62.99c-.63.72-1.4.94-2.32.94-.26 0-.54-.02-.83-.05l-40.91-12.53-18.39-5.63-39.65 39.67-46.85-46.88 39.71-39.72-5.6-18.38-12.49-41.02c-.34-1.13.01-2.36.73-3l44.97-39.15 120.74 120.8-39.11 44.95zm248.51-285.73L318.36 343.4l-117.6-117.66L417.4 37.15c4.5-3.97 17.55-9.68 28.1.88l60.48 60.52c7.65 7.65 8.04 20 .74 28.28z"]},dr={prefix:"fal",iconName:"image",icon:[512,512,[],"f03e","M464 64H48C21.49 64 0 85.49 0 112v288c0 26.51 21.49 48 48 48h416c26.51 0 48-21.49 48-48V112c0-26.51-21.49-48-48-48zm16 336c0 8.822-7.178 16-16 16H48c-8.822 0-16-7.178-16-16V112c0-8.822 7.178-16 16-16h416c8.822 0 16 7.178 16 16v288zM112 232c30.928 0 56-25.072 56-56s-25.072-56-56-56-56 25.072-56 56 25.072 56 56 56zm0-80c13.234 0 24 10.766 24 24s-10.766 24-24 24-24-10.766-24-24 10.766-24 24-24zm207.029 23.029L224 270.059l-31.029-31.029c-9.373-9.373-24.569-9.373-33.941 0l-88 88A23.998 23.998 0 0 0 64 344v28c0 6.627 5.373 12 12 12h360c6.627 0 12-5.373 12-12v-92c0-6.365-2.529-12.47-7.029-16.971l-88-88c-9.373-9.372-24.569-9.372-33.942 0zM416 352H96v-4.686l80-80 48 48 112-112 80 80V352z"]},hr={prefix:"fal",iconName:"inbox",icon:[576,512,[],"f01c","M566.819 227.377L462.377 83.768A48.001 48.001 0 0 0 423.557 64H152.443a47.998 47.998 0 0 0-38.819 19.768L9.181 227.377A47.996 47.996 0 0 0 0 255.609V400c0 26.51 21.49 48 48 48h480c26.51 0 48-21.49 48-48V255.609a47.996 47.996 0 0 0-9.181-28.232zM139.503 102.589A16.048 16.048 0 0 1 152.443 96h271.115c5.102 0 9.939 2.463 12.94 6.589L524.796 224H388.223l-32 64H219.777l-32-64H51.204l88.299-121.411zM544 272v128c0 8.823-7.178 16-16 16H48c-8.822 0-16-7.177-16-16V272c0-8.837 7.163-16 16-16h120l32 64h176l32-64h120c8.837 0 16 7.163 16 16z"]},mr={prefix:"fal",iconName:"info-circle",icon:[512,512,[],"f05a","M256 40c118.621 0 216 96.075 216 216 0 119.291-96.61 216-216 216-119.244 0-216-96.562-216-216 0-119.203 96.602-216 216-216m0-32C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm-36 344h12V232h-12c-6.627 0-12-5.373-12-12v-8c0-6.627 5.373-12 12-12h48c6.627 0 12 5.373 12 12v140h12c6.627 0 12 5.373 12 12v8c0 6.627-5.373 12-12 12h-72c-6.627 0-12-5.373-12-12v-8c0-6.627 5.373-12 12-12zm36-240c-17.673 0-32 14.327-32 32s14.327 32 32 32 32-14.327 32-32-14.327-32-32-32z"]},gr={prefix:"fal",iconName:"lightbulb",icon:[352,512,[],"f0eb","M176 0C73.05 0-.12 83.54 0 176.24c.06 44.28 16.5 84.67 43.56 115.54C69.21 321.03 93.85 368.68 96 384l.06 75.18c0 3.15.94 6.22 2.68 8.84l24.51 36.84c2.97 4.46 7.97 7.14 13.32 7.14h78.85c5.36 0 10.36-2.68 13.32-7.14l24.51-36.84c1.74-2.62 2.67-5.7 2.68-8.84L256 384c2.26-15.72 26.99-63.19 52.44-92.22C335.55 260.85 352 220.37 352 176 352 78.8 273.2 0 176 0zm47.94 454.31L206.85 480h-61.71l-17.09-25.69-.01-6.31h95.9v6.31zm.04-38.31h-95.97l-.07-32h96.08l-.04 32zm60.4-145.32c-13.99 15.96-36.33 48.1-50.58 81.31H118.21c-14.26-33.22-36.59-65.35-50.58-81.31C44.5 244.3 32.13 210.85 32.05 176 31.87 99.01 92.43 32 176 32c79.4 0 144 64.6 144 144 0 34.85-12.65 68.48-35.62 94.68zM176 64c-61.75 0-112 50.25-112 112 0 8.84 7.16 16 16 16s16-7.16 16-16c0-44.11 35.88-80 80-80 8.84 0 16-7.16 16-16s-7.16-16-16-16z"]},vr={prefix:"fal",iconName:"lightbulb-on",icon:[640,512,[],"f672","M320,64A112.14,112.14,0,0,0,208,176a16,16,0,0,0,32,0,80.09,80.09,0,0,1,80-80,16,16,0,0,0,0-32Zm0-64C217.06,0,143.88,83.55,144,176.23a175,175,0,0,0,43.56,115.55C213.22,321,237.84,368.69,240,384l.06,75.19a15.88,15.88,0,0,0,2.69,8.83l24.5,36.84A16,16,0,0,0,280.56,512h78.85a16,16,0,0,0,13.34-7.14L397.25,468a16.17,16.17,0,0,0,2.69-8.83L400,384c2.25-15.72,27-63.19,52.44-92.22A175.9,175.9,0,0,0,320,0Zm47.94,454.31L350.84,480H289.12l-17.06-25.69,0-6.31h95.91ZM368,416H272l-.06-32H368Zm60.41-145.31c-14,15.95-36.32,48.09-50.57,81.29H262.22c-14.28-33.21-36.6-65.34-50.6-81.29A143.47,143.47,0,0,1,176.06,176C175.88,99,236.44,32,320,32c79.41,0,144,64.59,144,144A143.69,143.69,0,0,1,428.38,270.69ZM96,176a16,16,0,0,0-16-16H16a16,16,0,0,0,0,32H80A16,16,0,0,0,96,176ZM528,64a16.17,16.17,0,0,0,7.16-1.69l64-32A16,16,0,0,0,584.84,1.69l-64,32A16,16,0,0,0,528,64Zm96,96H560a16,16,0,0,0,0,32h64a16,16,0,0,0,0-32ZM119.16,33.69l-64-32A16,16,0,0,0,40.84,30.31l64,32A16.17,16.17,0,0,0,112,64a16,16,0,0,0,7.16-30.31Zm480,288-64-32a16,16,0,0,0-14.32,28.63l64,32a16,16,0,0,0,14.32-28.63ZM112,288a16.17,16.17,0,0,0-7.16,1.69l-64,32a16,16,0,0,0,14.32,28.63l64-32A16,16,0,0,0,112,288Z"]},br={prefix:"fal",iconName:"location",icon:[512,512,[],"f601","M504 240h-56.81C439.48 146.76 365.24 72.52 272 64.81V8c0-4.42-3.58-8-8-8h-16c-4.42 0-8 3.58-8 8v56.81C146.76 72.52 72.52 146.76 64.81 240H8c-4.42 0-8 3.58-8 8v16c0 4.42 3.58 8 8 8h56.81c7.71 93.24 81.95 167.48 175.19 175.19V504c0 4.42 3.58 8 8 8h16c4.42 0 8-3.58 8-8v-56.81c93.24-7.71 167.48-81.95 175.19-175.19H504c4.42 0 8-3.58 8-8v-16c0-4.42-3.58-8-8-8zM256 416c-88.22 0-160-71.78-160-160S167.78 96 256 96s160 71.78 160 160-71.78 160-160 160zm0-256c-53.02 0-96 42.98-96 96s42.98 96 96 96 96-42.98 96-96-42.98-96-96-96zm0 160c-35.29 0-64-28.71-64-64s28.71-64 64-64 64 28.71 64 64-28.71 64-64 64z"]},yr={prefix:"fal",iconName:"long-arrow-right",icon:[448,512,[],"f178","M311.03 131.515l-7.071 7.07c-4.686 4.686-4.686 12.284 0 16.971L387.887 239H12c-6.627 0-12 5.373-12 12v10c0 6.627 5.373 12 12 12h375.887l-83.928 83.444c-4.686 4.686-4.686 12.284 0 16.971l7.071 7.07c4.686 4.686 12.284 4.686 16.97 0l116.485-116c4.686-4.686 4.686-12.284 0-16.971L328 131.515c-4.686-4.687-12.284-4.687-16.97 0z"]},wr={prefix:"fal",iconName:"minus",icon:[384,512,[],"f068","M376 232H8c-4.42 0-8 3.58-8 8v32c0 4.42 3.58 8 8 8h368c4.42 0 8-3.58 8-8v-32c0-4.42-3.58-8-8-8z"]},xr={prefix:"fal",iconName:"paper-plane",icon:[512,512,[],"f1d8","M464 4.3L16 262.7C-7 276-4.7 309.9 19.8 320L160 378v102c0 30.2 37.8 43.3 56.7 20.3l60.7-73.8 126.4 52.2c19.1 7.9 40.7-4.2 43.8-24.7l64-417.1C515.7 10.2 487-9 464 4.3zM192 480v-88.8l54.5 22.5L192 480zm224-30.9l-206.2-85.2 199.5-235.8c4.8-5.6-2.9-13.2-8.5-8.4L145.5 337.3 32 290.5 480 32l-64 417.1z"]},Er={prefix:"fal",iconName:"paperclip",icon:[512,512,[],"f0c6","M149.106 512c-33.076 0-66.153-12.59-91.333-37.771-50.364-50.361-50.364-132.305-.002-182.665L319.842 29.498c39.331-39.331 103.328-39.331 142.66 0 39.331 39.332 39.331 103.327 0 142.657l-222.63 222.626c-28.297 28.301-74.347 28.303-102.65 0-28.3-28.301-28.3-74.349 0-102.649l170.301-170.298c4.686-4.686 12.284-4.686 16.97 0l5.661 5.661c4.686 4.686 4.686 12.284 0 16.971l-170.3 170.297c-15.821 15.821-15.821 41.563.001 57.385 15.821 15.82 41.564 15.82 57.385 0l222.63-222.626c26.851-26.851 26.851-70.541 0-97.394-26.855-26.851-70.544-26.849-97.395 0L80.404 314.196c-37.882 37.882-37.882 99.519 0 137.401 37.884 37.881 99.523 37.882 137.404.001l217.743-217.739c4.686-4.686 12.284-4.686 16.97 0l5.661 5.661c4.686 4.686 4.686 12.284 0 16.971L240.44 474.229C215.26 499.41 182.183 512 149.106 512z"]},Cr={prefix:"fal",iconName:"pen",icon:[512,512,[],"f304","M493.25 56.26l-37.51-37.51C443.25 6.25 426.87 0 410.49 0s-32.76 6.25-45.26 18.74L12.85 371.12.15 485.34C-1.45 499.72 9.88 512 23.95 512c.89 0 1.78-.05 2.69-.15l114.14-12.61 352.48-352.48c24.99-24.99 24.99-65.51-.01-90.5zM126.09 468.68l-93.03 10.31 10.36-93.17 263.89-263.89 82.77 82.77-263.99 263.98zm344.54-344.54l-57.93 57.93-82.77-82.77 57.93-57.93c6.04-6.04 14.08-9.37 22.63-9.37 8.55 0 16.58 3.33 22.63 9.37l37.51 37.51c12.47 12.48 12.47 32.78 0 45.26z"]},kr={prefix:"fal",iconName:"plus",icon:[384,512,[],"f067","M376 232H216V72c0-4.42-3.58-8-8-8h-32c-4.42 0-8 3.58-8 8v160H8c-4.42 0-8 3.58-8 8v32c0 4.42 3.58 8 8 8h160v160c0 4.42 3.58 8 8 8h32c4.42 0 8-3.58 8-8V280h160c4.42 0 8-3.58 8-8v-32c0-4.42-3.58-8-8-8z"]},Sr={prefix:"fal",iconName:"plus-circle",icon:[512,512,[],"f055","M384 250v12c0 6.6-5.4 12-12 12h-98v98c0 6.6-5.4 12-12 12h-12c-6.6 0-12-5.4-12-12v-98h-98c-6.6 0-12-5.4-12-12v-12c0-6.6 5.4-12 12-12h98v-98c0-6.6 5.4-12 12-12h12c6.6 0 12 5.4 12 12v98h98c6.6 0 12 5.4 12 12zm120 6c0 137-111 248-248 248S8 393 8 256 119 8 256 8s248 111 248 248zm-32 0c0-119.9-97.3-216-216-216-119.9 0-216 97.3-216 216 0 119.9 97.3 216 216 216 119.9 0 216-97.3 216-216z"]},Or={prefix:"fal",iconName:"save",icon:[448,512,[],"f0c7","M433.941 129.941l-83.882-83.882A48 48 0 0 0 316.118 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V163.882a48 48 0 0 0-14.059-33.941zM288 64v96H96V64h192zm128 368c0 8.822-7.178 16-16 16H48c-8.822 0-16-7.178-16-16V80c0-8.822 7.178-16 16-16h16v104c0 13.255 10.745 24 24 24h208c13.255 0 24-10.745 24-24V64.491a15.888 15.888 0 0 1 7.432 4.195l83.882 83.882A15.895 15.895 0 0 1 416 163.882V432zM224 232c-48.523 0-88 39.477-88 88s39.477 88 88 88 88-39.477 88-88-39.477-88-88-88zm0 144c-30.879 0-56-25.121-56-56s25.121-56 56-56 56 25.121 56 56-25.121 56-56 56z"]},Tr={prefix:"fal",iconName:"search",icon:[512,512,[],"f002","M508.5 481.6l-129-129c-2.3-2.3-5.3-3.5-8.5-3.5h-10.3C395 312 416 262.5 416 208 416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c54.5 0 104-21 141.1-55.2V371c0 3.2 1.3 6.2 3.5 8.5l129 129c4.7 4.7 12.3 4.7 17 0l9.9-9.9c4.7-4.7 4.7-12.3 0-17zM208 384c-97.3 0-176-78.7-176-176S110.7 32 208 32s176 78.7 176 176-78.7 176-176 176z"]},_r={prefix:"fal",iconName:"search-minus",icon:[512,512,[],"f010","M307.8 223.8h-200c-6.6 0-12-5.4-12-12v-8c0-6.6 5.4-12 12-12h200c6.6 0 12 5.4 12 12v8c0 6.6-5.4 12-12 12zM508.3 497L497 508.3c-4.7 4.7-12.3 4.7-17 0l-129-129c-2.3-2.3-3.5-5.3-3.5-8.5v-8.5C310.6 395.7 261.7 416 208 416 93.8 416 1.5 324.9 0 210.7-1.5 93.7 93.7-1.5 210.7 0 324.9 1.5 416 93.8 416 208c0 53.7-20.3 102.6-53.7 139.5h8.5c3.2 0 6.2 1.3 8.5 3.5l129 129c4.7 4.7 4.7 12.3 0 17zM384 208c0-97.3-78.7-176-176-176S32 110.7 32 208s78.7 176 176 176 176-78.7 176-176z"]},Pr={prefix:"fal",iconName:"share-alt",icon:[448,512,[],"f1e0","M352 320c-28.6 0-54.2 12.5-71.8 32.3l-95.5-59.7c9.6-23.4 9.7-49.8 0-73.2l95.5-59.7c17.6 19.8 43.2 32.3 71.8 32.3 53 0 96-43 96-96S405 0 352 0s-96 43-96 96c0 13 2.6 25.3 7.2 36.6l-95.5 59.7C150.2 172.5 124.6 160 96 160c-53 0-96 43-96 96s43 96 96 96c28.6 0 54.2-12.5 71.8-32.3l95.5 59.7c-4.7 11.3-7.2 23.6-7.2 36.6 0 53 43 96 96 96s96-43 96-96c-.1-53-43.1-96-96.1-96zm0-288c35.3 0 64 28.7 64 64s-28.7 64-64 64-64-28.7-64-64 28.7-64 64-64zM96 320c-35.3 0-64-28.7-64-64s28.7-64 64-64 64 28.7 64 64-28.7 64-64 64zm256 160c-35.3 0-64-28.7-64-64s28.7-64 64-64 64 28.7 64 64-28.7 64-64 64z"]},Ar={prefix:"fal",iconName:"sitemap",icon:[640,512,[],"f0e8","M608 352h-32v-97.59c0-16.77-13.62-30.41-30.41-30.41H336v-64h48c17.67 0 32-14.33 32-32V32c0-17.67-14.33-32-32-32H256c-17.67 0-32 14.33-32 32v96c0 17.67 14.33 32 32 32h48v64H94.41C77.62 224 64 237.64 64 254.41V352H32c-17.67 0-32 14.33-32 32v96c0 17.67 14.33 32 32 32h96c17.67 0 32-14.33 32-32v-96c0-17.67-14.33-32-32-32H96v-96h208v96h-32c-17.67 0-32 14.33-32 32v96c0 17.67 14.33 32 32 32h96c17.67 0 32-14.33 32-32v-96c0-17.67-14.33-32-32-32h-32v-96h208v96h-32c-17.67 0-32 14.33-32 32v96c0 17.67 14.33 32 32 32h96c17.67 0 32-14.33 32-32v-96c0-17.67-14.33-32-32-32zm-480 32v96H32v-96h96zm240 0v96h-96v-96h96zM256 128V32h128v96H256zm352 352h-96v-96h96v96z"]},Mr={prefix:"fal",iconName:"sliders-h",icon:[512,512,[],"f1de","M504 384H192v-40c0-13.3-10.7-24-24-24h-48c-13.3 0-24 10.7-24 24v40H8c-4.4 0-8 3.6-8 8v16c0 4.4 3.6 8 8 8h88v40c0 13.3 10.7 24 24 24h48c13.3 0 24-10.7 24-24v-40h312c4.4 0 8-3.6 8-8v-16c0-4.4-3.6-8-8-8zm-344 64h-32v-96h32v96zM504 96H256V56c0-13.3-10.7-24-24-24h-48c-13.3 0-24 10.7-24 24v40H8c-4.4 0-8 3.6-8 8v16c0 4.4 3.6 8 8 8h152v40c0 13.3 10.7 24 24 24h48c13.3 0 24-10.7 24-24v-40h248c4.4 0 8-3.6 8-8v-16c0-4.4-3.6-8-8-8zm-280 64h-32V64h32v96zm280 80h-88v-40c0-13.3-10.7-24-24-24h-48c-13.3 0-24 10.7-24 24v40H8c-4.4 0-8 3.6-8 8v16c0 4.4 3.6 8 8 8h312v40c0 13.3 10.7 24 24 24h48c13.3 0 24-10.7 24-24v-40h88c4.4 0 8-3.6 8-8v-16c0-4.4-3.6-8-8-8zm-120 64h-32v-96h32v96z"]},zr={prefix:"fal",iconName:"spinner-third",icon:[512,512,[],"f3f4","M460.115 373.846l-6.941-4.008c-5.546-3.202-7.564-10.177-4.661-15.886 32.971-64.838 31.167-142.731-5.415-205.954-36.504-63.356-103.118-103.876-175.8-107.701C260.952 39.963 256 34.676 256 28.321v-8.012c0-6.904 5.808-12.337 12.703-11.982 83.552 4.306 160.157 50.861 202.106 123.67 42.069 72.703 44.083 162.322 6.034 236.838-3.14 6.149-10.75 8.462-16.728 5.011z"]},Fr={prefix:"fal",iconName:"sync",icon:[512,512,[],"f021","M492 8h-10c-6.627 0-12 5.373-12 12v110.627C426.929 57.261 347.224 8 256 8 123.228 8 14.824 112.338 8.31 243.493 7.971 250.311 13.475 256 20.301 256h10.016c6.353 0 11.646-4.949 11.977-11.293C48.157 132.216 141.097 42 256 42c82.862 0 154.737 47.077 190.289 116H332c-6.627 0-12 5.373-12 12v10c0 6.627 5.373 12 12 12h160c6.627 0 12-5.373 12-12V20c0-6.627-5.373-12-12-12zm-.301 248h-10.015c-6.352 0-11.647 4.949-11.977 11.293C463.841 380.158 370.546 470 256 470c-82.608 0-154.672-46.952-190.299-116H180c6.627 0 12-5.373 12-12v-10c0-6.627-5.373-12-12-12H20c-6.627 0-12 5.373-12 12v160c0 6.627 5.373 12 12 12h10c6.627 0 12-5.373 12-12V381.373C85.071 454.739 164.777 504 256 504c132.773 0 241.176-104.338 247.69-235.493.339-6.818-5.165-12.507-11.991-12.507z"]},Dr={prefix:"fal",iconName:"tachometer-alt",icon:[576,512,[],"f3fd","M288 152c13.26 0 24-10.74 24-24s-10.74-24-24-24-24 10.74-24 24 10.74 24 24 24zm-136 8c-13.26 0-24 10.74-24 24s10.74 24 24 24 24-10.74 24-24-10.74-24-24-24zm272 0c-13.26 0-24 10.74-24 24s10.74 24 24 24 24-10.74 24-24-10.74-24-24-24zm56 136c-13.26 0-24 10.74-24 24s10.74 24 24 24 24-10.74 24-24-10.74-24-24-24zM288 32C128.94 32 0 160.94 0 320c0 52.8 14.25 102.26 39.06 144.8 5.61 9.62 16.3 15.2 27.44 15.2h443c11.14 0 21.83-5.58 27.44-15.2C561.75 422.26 576 372.8 576 320c0-159.06-128.94-288-288-288zm221.5 416l-442.8.68C44 409.75 32 365.26 32 320 32 178.84 146.84 64 288 64s256 114.84 256 256c0 45.26-12 89.75-34.5 128zM96 296c-13.26 0-24 10.74-24 24s10.74 24 24 24 24-10.74 24-24-10.74-24-24-24zm269.22-167.12c-8.19-2.78-17.44 1.55-20.34 9.89l-51.83 149.74c-1.69-.13-3.31-.51-5.04-.51-35.35 0-64 28.65-64 64s28.65 64 64 64 64-28.65 64-64c0-22.25-11.38-41.82-28.62-53.29l51.74-149.48c2.87-8.34-1.54-17.46-9.91-20.35zM288 384c-17.64 0-32-14.36-32-32s14.36-32 32-32 32 14.36 32 32-14.36 32-32 32z"]},jr={prefix:"fal",iconName:"times",icon:[320,512,[],"f00d","M193.94 256L296.5 153.44l21.15-21.15c3.12-3.12 3.12-8.19 0-11.31l-22.63-22.63c-3.12-3.12-8.19-3.12-11.31 0L160 222.06 36.29 98.34c-3.12-3.12-8.19-3.12-11.31 0L2.34 120.97c-3.12 3.12-3.12 8.19 0 11.31L126.06 256 2.34 379.71c-3.12 3.12-3.12 8.19 0 11.31l22.63 22.63c3.12 3.12 8.19 3.12 11.31 0L160 289.94 262.56 392.5l21.15 21.15c3.12 3.12 8.19 3.12 11.31 0l22.63-22.63c3.12-3.12 3.12-8.19 0-11.31L193.94 256z"]},Lr={prefix:"fal",iconName:"times-circle",icon:[512,512,[],"f057","M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 464c-118.7 0-216-96.1-216-216 0-118.7 96.1-216 216-216 118.7 0 216 96.1 216 216 0 118.7-96.1 216-216 216zm94.8-285.3L281.5 256l69.3 69.3c4.7 4.7 4.7 12.3 0 17l-8.5 8.5c-4.7 4.7-12.3 4.7-17 0L256 281.5l-69.3 69.3c-4.7 4.7-12.3 4.7-17 0l-8.5-8.5c-4.7-4.7-4.7-12.3 0-17l69.3-69.3-69.3-69.3c-4.7-4.7-4.7-12.3 0-17l8.5-8.5c4.7-4.7 12.3-4.7 17 0l69.3 69.3 69.3-69.3c4.7-4.7 12.3-4.7 17 0l8.5 8.5c4.6 4.7 4.6 12.3 0 17z"]},Rr={prefix:"fal",iconName:"trash-alt",icon:[448,512,[],"f2ed","M296 432h16a8 8 0 0 0 8-8V152a8 8 0 0 0-8-8h-16a8 8 0 0 0-8 8v272a8 8 0 0 0 8 8zm-160 0h16a8 8 0 0 0 8-8V152a8 8 0 0 0-8-8h-16a8 8 0 0 0-8 8v272a8 8 0 0 0 8 8zM440 64H336l-33.6-44.8A48 48 0 0 0 264 0h-80a48 48 0 0 0-38.4 19.2L112 64H8a8 8 0 0 0-8 8v16a8 8 0 0 0 8 8h24v368a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V96h24a8 8 0 0 0 8-8V72a8 8 0 0 0-8-8zM171.2 38.4A16.1 16.1 0 0 1 184 32h80a16.1 16.1 0 0 1 12.8 6.4L296 64H152zM384 464a16 16 0 0 1-16 16H80a16 16 0 0 1-16-16V96h320zm-168-32h16a8 8 0 0 0 8-8V152a8 8 0 0 0-8-8h-16a8 8 0 0 0-8 8v272a8 8 0 0 0 8 8z"]},Ir={prefix:"fal",iconName:"undo",icon:[512,512,[],"f0e2","M20 8h10c6.627 0 12 5.373 12 12v110.625C85.196 57.047 165.239 7.715 256.793 8.001 393.18 8.428 504.213 120.009 504 256.396 503.786 393.181 392.834 504 256 504c-63.926 0-122.202-24.187-166.178-63.908-5.113-4.618-5.354-12.561-.482-17.433l7.069-7.069c4.503-4.503 11.749-4.714 16.482-.454C150.782 449.238 200.935 470 256 470c117.744 0 214-95.331 214-214 0-117.744-95.331-214-214-214-82.862 0-154.737 47.077-190.289 116H180c6.627 0 12 5.373 12 12v10c0 6.627-5.373 12-12 12H20c-6.627 0-12-5.373-12-12V20c0-6.627 5.373-12 12-12z"]},Nr={prefix:"fal",iconName:"user",icon:[448,512,[],"f007","M313.6 288c-28.7 0-42.5 16-89.6 16-47.1 0-60.8-16-89.6-16C60.2 288 0 348.2 0 422.4V464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48v-41.6c0-74.2-60.2-134.4-134.4-134.4zM416 464c0 8.8-7.2 16-16 16H48c-8.8 0-16-7.2-16-16v-41.6C32 365.9 77.9 320 134.4 320c19.6 0 39.1 16 89.6 16 50.4 0 70-16 89.6-16 56.5 0 102.4 45.9 102.4 102.4V464zM224 256c70.7 0 128-57.3 128-128S294.7 0 224 0 96 57.3 96 128s57.3 128 128 128zm0-224c52.9 0 96 43.1 96 96s-43.1 96-96 96-96-43.1-96-96 43.1-96 96-96z"]},Vr=function(e,t){var n="function"==typeof Symbol&&e[Symbol.iterator];if(!n)return e;var r,o,i=n.call(e),a=[];try{for(;(void 0===t||t-- >0)&&!(r=i.next()).done;)a.push(r.value)}catch(e){o={error:e}}finally{try{r&&!r.done&&(n=i.return)&&n.call(i)}finally{if(o)throw o.error}}return a},Br=function(){for(var e=[],t=0;t<arguments.length;t++)e=e.concat(Vr(arguments[t]));return e},Hr=[Yn,Mn,Qn,Gn,Xn,zn,qn,Kn,Zn,Jn,Hn,Dn,Fn,er,jn,tr,nr,Wn,rr,or,ir,ar,lr,ur,cr,sr,Ln,fr,Rn,Un,pr,dr,hr,mr,gr,vr,br,yr,wr,In,Er,xr,Cr,kr,Sr,Nn,Or,Tr,_r,Pr,Ar,Mr,Vn,zr,$n,Fr,Dr,jr,Lr,Rr,Ir,Nr,Bn];var Wr={NOT_TRAINED:"not_trained",TRAINING:"training",DEPLOYING:"deploying",NEEDS_TO_BE_RETRAINED:"needs_to_be_retrained",READY:"ready"},Ur={border:0,margin:0};function $r(e){var t=e.onTrainClick,n=e.placeholderImage;switch(e.state){case Wr.READY:default:return r.createElement(yn,{image:n,imageStyle:Ur,heading:"Talk to your assistant!",body:"Your assistant has been trained! Type something to test it out..."});case Wr.NOT_TRAINED:return r.createElement(yn,{image:n,imageStyle:Ur,body:r.createElement(E,{large:!0,onClick:t},"Train")});case Wr.TRAINING:return r.createElement(yn,{image:n,imageStyle:Ur,body:r.createElement(E,{large:!0},r.createElement(K,{icon:["fas","spinner"],spin:!0,mr:2}),"Training...")});case Wr.DEPLOYING:return r.createElement(yn,{image:n,imageStyle:Ur,heading:"Training finished",body:"Deploying model..."});case Wr.NEEDS_TO_BE_RETRAINED:return r.createElement(yn,{image:n,imageStyle:Ur,heading:"You made some changes.",body:r.createElement(r.Fragment,null,r.createElement(l.Box,{mb:4},"Train a new model to talk to the updated assistant."),r.createElement(E,{large:!0,onClick:t},"Train"))})}}function Yr(e){var t=e.onSendMessage,n=e.onTrainClick,o=e.placeholderImage,i=e.state,a=e.tracker,u=e.username,c=r.useMemo((function(){return a&&a.events&&a.events.slice(0).reverse().filter((function(e){return"user"===e.event||"bot"===e.event}))}),[a]);return r.createElement(l.Flex,{flexDirection:"column",sx:{height:"100%"}},c&&c.length>0&&i==Wr.READY?r.createElement(l.Flex,{flexDirection:"column-reverse",sx:{height:"100%",overflow:"auto"},px:4,py:3},c.map((function(e,t){return r.createElement(l.Box,{my:2,key:t},r.createElement(fn,{username:"user"===e.event?u:null,contents:e.data,reverseAlign:"user"===e.event,text:e.text}))}))):r.createElement(l.Flex,{justifyContent:"center",flex:1},r.createElement($r,{onTrainClick:n,placeholderImage:o,state:i})),i===Wr.READY&&r.createElement(l.Flex,{sx:{borderTop:"solid 1px",borderColor:"neutral_4",marginTop:"auto"}},r.createElement(P,{onSendMessage:t})))}var Qr=n(27),Gr=n.n(Qr),Xr={init:function(e){var t=e.onSendMessage,n=e.onTrainClick,r=e.tracker,i=e.username,l=e.state,u=e.selector;A.b.add.apply(A.b,Br(Hr)),a.a.render(o.a.createElement(An,{theme:Pn},o.a.createElement(Yr,{onSendMessage:t,onTrainClick:n,placeholderImage:Gr.a,state:l,tracker:r,username:i})),document.querySelector(u))},State:Wr}}])})); \ No newline at end of file diff --git a/docs/static/spec/action-server.yml b/docs/static/spec/action-server.yml new file mode 100644 index 0000000..67fabda --- /dev/null +++ b/docs/static/spec/action-server.yml @@ -0,0 +1,128 @@ +openapi: "3.0.2" +info: + title: "Rasa SDK - Action Server Endpoint" + version: "0.0.0" + description: >- + API of the action server which is used by Rasa + to execute custom actions. +servers: + - url: "http://localhost:5055/webhook" + description: "Local development action server" +paths: + /: + post: + summary: Core request to execute a custom action + description: >- + Rasa Core sends a request to the action server to execute a + certain custom action. As a response to the action call from Core, + you can modify the tracker, e.g. by setting slots and send responses + back to the user. + operationId: call_action + requestBody: + description: >- + Describes the action to be called and provides information on the + current state of the conversation. + required: true + content: + application/json: + schema: + type: object + properties: + next_action: + description: The name of the action which should be executed. + type: string + sender_id: + description: >- + Unique id of the user who is having the + current conversation. + type: string + tracker: + $ref: "./rasa.yml#/components/schemas/Tracker" + domain: + $ref: "./rasa.yml#/components/schemas/Domain" + responses: + 200: + description: Action was executed succesfully. + content: + application/json: + schema: + type: object + properties: + events: + description: Events returned by the action. + type: array + items: + $ref: "./rasa.yml#/components/schemas/Event" + responses: + description: >- + List of responses which should be sent to the user + type: array + items: + $ref: "#/components/schemas/Response" + 400: + description: >- + Action execution was rejected. This is the same as returning + an `ActionExecutionRejected` event. + content: + application/json: + schema: + type: object + properties: + action_name: + type: string + description: >- + Name of the action which rejected its execution. + error: + type: string + description: The error message. + 500: + description: >- + The action server encountered an exception while running the action. +components: + schemas: + Response: + oneOf: + - $ref: '#/components/schemas/TextResponse' + - $ref: '#/components/schemas/TemplateResponse' + - $ref: '#/components/schemas/ButtonResponse' + TextResponse: + description: Text which the bot should utter. + type: object + properties: + text: + description: The text which should be uttered. + type: string + required: ["text"] + TemplateResponse: + description: Response template the bot should utter. + type: object + properties: + template: + description: Name of the template + type: string + additionalProperties: + description: Keyword argument to fill the template + type: string + required: ["template"] + ButtonResponse: + description: Text with buttons which should be sent to the user. + type: object + properties: + text: + type: string + description: Message + buttons: + type: array + items: + $ref: '#/components/schemas/Button' + Button: + description: >- + A button which can be clicked by the user in the conversation. + type: object + properties: + title: + type: string + description: The text on the button + payload: + type: string + description: Payload which is sent if the button is pressed. diff --git a/docs/static/spec/rasa.yml b/docs/static/spec/rasa.yml new file mode 100644 index 0000000..a329999 --- /dev/null +++ b/docs/static/spec/rasa.yml @@ -0,0 +1,2247 @@ +openapi: 3.0.1 +info: + title: "Rasa - Server Endpoints" + version: "1.0.0" + description: >- + The Rasa server provides endpoints to retrieve trackers of + conversations as well as endpoints to modify them. Additionally, + endpoints for training and testing models are provided. +servers: + - url: "http://localhost:5005" + description: "Local development server" + +paths: + /: + get: + tags: + - Server Information + summary: Health endpoint of Rasa Server + operationId: getHealth + description: >- + This URL can be used as an endpoint to run + health checks against. When the server is running + this will return 200. + responses: + 200: + description: Up and running + content: + text/plain: + schema: + type: string + description: Welcome text of Rasa Server + example: >- + Hello from Rasa: 1.0.0 + + /version: + get: + tags: + - Server Information + operationId: getVersion + summary: Version of Rasa + description: >- + Returns the version of Rasa. + responses: + 200: + description: Version of Rasa + content: + application/json: + schema: + type: object + properties: + version: + type: string + description: >- + Rasa version number + minimum_compatible_version: + type: string + description: >- + Minimum version this Rasa version is + able to load models from + example: + version: 1.0.0 + minimum_compatible_version: 1.0.0 + + /status: + get: + security: + - TokenAuth: [] + - JWT: [] + operationId: getStatus + tags: + - Server Information + summary: Status of the Rasa server + description: >- + Information about the server and the currently loaded Rasa model. + responses: + 200: + description: Success + content: + application/json: + schema: + type: object + properties: + model_id: + type: string + description: ID of the loaded model + example: 75a985b7b86d442ca013d61ea4781b22 + model_file: + type: string + description: Path of the loaded model + example: 20190429-103105.tar.gz + num_active_training_jobs: + type: integer + description: Number of running training processes + example: 2 + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 409: + $ref: '#/components/responses/409Conflict' + + + /conversations/{conversation_id}/tracker: + get: + security: + - TokenAuth: [] + - JWT: [] + operationId: getConversationTracker + tags: + - Tracker + summary: Retrieve a conversations tracker + description: >- + The tracker represents the state of the conversation. + The state of the tracker is created by applying a + sequence of events, which modify the state. These + events can optionally be included in the response. + parameters: + - $ref: '#/components/parameters/conversation_id' + - $ref: '#/components/parameters/include_events' + - $ref: '#/components/parameters/until' + responses: + 200: + $ref: '#/components/responses/200Tracker' + 400: + $ref: '#/components/responses/400BadRequest' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 409: + $ref: '#/components/responses/409Conflict' + 500: + $ref: '#/components/responses/500ServerError' + + /conversations/{conversation_id}/tracker/events: + post: + security: + - TokenAuth: [] + - JWT: [] + operationId: addConversationTrackerEvents + tags: + - Tracker + summary: Append events to a tracker + description: >- + Appends one or multiple new events to the tracker state of the conversation. + Any existing events will be kept and the new events will be appended, + updating the existing state. + If events are appended to a new conversation ID, the tracker will be + initialised with a new session. + parameters: + - $ref: '#/components/parameters/conversation_id' + - $ref: '#/components/parameters/include_events' + - $ref: '#/components/parameters/output_channel' + - in: query + name: execute_side_effects + schema: + type: boolean + default: False + description: >- + If `true`, any ``BotUttered`` event will be forwarded to the + channel specified in the ``output_channel`` parameter. Any + ``ReminderScheduled`` or ``ReminderCancelled`` event will also be + processed. + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Event' + - $ref: '#/components/schemas/EventList' + responses: + 200: + $ref: '#/components/responses/200Tracker' + 400: + $ref: '#/components/responses/400BadRequest' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 409: + $ref: '#/components/responses/409Conflict' + 500: + $ref: '#/components/responses/500ServerError' + + put: + security: + - TokenAuth: [] + - JWT: [] + operationId: replaceConversationTrackerEvents + tags: + - Tracker + summary: Replace a trackers events + description: >- + Replaces all events of a tracker with the passed + list of events. This endpoint should not be used to + modify trackers in a production setup, but rather + for creating training data. + parameters: + - $ref: '#/components/parameters/conversation_id' + - $ref: '#/components/parameters/include_events' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EventList' + responses: + 200: + $ref: '#/components/responses/200Tracker' + 400: + $ref: '#/components/responses/400BadRequest' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 409: + $ref: '#/components/responses/409Conflict' + 500: + $ref: '#/components/responses/500ServerError' + + /conversations/{conversation_id}/story: + get: + security: + - TokenAuth: [] + - JWT: [] + operationId: getConversationStory + tags: + - Tracker + summary: Retrieve an end-to-end story corresponding to a conversation + description: >- + The story represents the whole conversation in end-to-end + format. This can be posted to the '/test/stories' endpoint and used + as a test. + parameters: + - $ref: '#/components/parameters/conversation_id' + - $ref: '#/components/parameters/until' + - $ref: '#/components/parameters/all_sessions' + responses: + 200: + $ref: '#/components/responses/200Story' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 409: + $ref: '#/components/responses/409Conflict' + 500: + $ref: '#/components/responses/500ServerError' + + /conversations/{conversation_id}/execute: + post: + security: + - TokenAuth: [] + - JWT: [] + operationId: executeConversationAction + tags: + - Tracker + summary: Run an action in a conversation + deprecated: true + description: >- + DEPRECATED. Runs the action, calling the action server if necessary. + Any responses sent by the executed action will be forwarded + to the channel specified in the output_channel parameter. + If no output channel is specified, any messages that should be + sent to the user will be included in the response of this endpoint. + parameters: + - $ref: '#/components/parameters/conversation_id' + - $ref: '#/components/parameters/include_events' + - $ref: '#/components/parameters/output_channel' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ActionRequest' + responses: + 200: + description: Success + content: + application/json: + schema: + type: object + properties: + tracker: + $ref: '#/components/schemas/Tracker' + messages: + type: array + items: + $ref: '#/components/schemas/BotMessage' + 400: + $ref: '#/components/responses/400BadRequest' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 409: + $ref: '#/components/responses/409Conflict' + 500: + $ref: '#/components/responses/500ServerError' + + /conversations/{conversation_id}/trigger_intent: + post: + security: + - TokenAuth: [] + - JWT: [] + operationId: triggerConversationIntent + tags: + - Tracker + summary: Inject an intent into a conversation + description: >- + Sends a specified intent and list of entities in place of a + user message. The bot then predicts and executes a response action. + Any responses sent by the executed action will be forwarded + to the channel specified in the ``output_channel`` parameter. + If no output channel is specified, any messages that should be + sent to the user will be included in the response of this endpoint. + parameters: + - $ref: '#/components/parameters/conversation_id' + - $ref: '#/components/parameters/include_events' + - $ref: '#/components/parameters/output_channel' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IntentTriggerRequest' + responses: + 200: + description: Success + content: + application/json: + schema: + type: object + properties: + tracker: + $ref: '#/components/schemas/Tracker' + messages: + type: array + items: + $ref: '#/components/schemas/BotMessage' + 400: + $ref: '#/components/responses/400BadRequest' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 409: + $ref: '#/components/responses/409Conflict' + 500: + $ref: '#/components/responses/500ServerError' + + /conversations/{conversation_id}/predict: + post: + security: + - TokenAuth: [] + - JWT: [] + operationId: predictConversationAction + tags: + - Tracker + summary: Predict the next action + description: >- + Runs the conversations tracker through the model's + policies to predict the scores of all actions present + in the model's domain. Actions are returned in the + 'scores' array, sorted on their 'score' values. + The state of the tracker is not modified. + parameters: + - $ref: '#/components/parameters/conversation_id' + responses: + 200: + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/PredictResult' + 400: + $ref: '#/components/responses/400BadRequest' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 409: + $ref: '#/components/responses/409Conflict' + 500: + $ref: '#/components/responses/500ServerError' + + + /conversations/{conversation_id}/messages: + post: + security: + - TokenAuth: [] + - JWT: [] + operationId: addConversationMessage + tags: + - Tracker + summary: Add a message to a tracker + description: >- + Adds a message to a tracker. This doesn't trigger + the prediction loop. It will log the message + on the tracker and return, no actions will be + predicted or run. This is often used together with the + predict endpoint. + parameters: + - $ref: '#/components/parameters/conversation_id' + - $ref: '#/components/parameters/include_events' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Message' + responses: + 200: + $ref: '#/components/responses/200Tracker' + 400: + $ref: '#/components/responses/400BadRequest' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 409: + $ref: '#/components/responses/409Conflict' + 500: + $ref: '#/components/responses/500ServerError' + + /model/train: + post: + security: + - TokenAuth: [] + - JWT: [] + operationId: trainModel + tags: + - Model + summary: Train a Rasa model + description: >- + Trains a new Rasa model. Depending on the data given only a dialogue model, + only a NLU model, or a model combining a trained dialogue model with an + NLU model will be trained. The new model is not loaded by default. + parameters: + - in: query + name: save_to_default_model_directory + schema: + type: boolean + default: True + description: >- + If `true` (default) the trained model will be saved in the default model + directory, if `false` it will be saved in a temporary directory + - in: query + name: force_training + schema: + type: boolean + default: False + description: Force a model training even if the data has not changed + - in: query + name: augmentation + schema: + type: string + default: 50 + description: How much data augmentation to use during training + - in: query + name: num_threads + schema: + type: string + default: 1 + description: Maximum amount of threads to use when training + - $ref: '#/components/parameters/callback_url' + requestBody: + required: true + description: >- + The training data should be in YAML format. + content: + application/yaml: + schema: + $ref: '#/components/schemas/YAMLTrainingRequest' + example: | + pipeline: [] + + policies: [] + + intents: + - greet + - goodbye + + entities: [] + slots: {} + actions: [] + forms: {} + e2e_actions: [] + + responses: + utter_greet: + - text: "Hey! How are you?" + + utter_goodbye: + - text: "Bye" + + session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true + + nlu: + - intent: greet + examples: | + - hey + - hello + + - intent: goodbye + examples: | + - bye + - goodbye + + rules: + + - rule: Say goodbye anytime the user says goodbye + steps: + - intent: goodbye + - action: utter_goodbye + + stories: + + - story: happy path + steps: + - intent: greet + - action: utter_greet + - intent: goodbye + - action: utter_goodbye + + responses: + 200: + description: Zipped Rasa model + headers: + filename: + schema: + type: string + description: File name of the trained model. + content: + application/octet-stream: + schema: + $ref: '#/components/schemas/TrainingResult' + 204: + $ref: '#/components/responses/204Callback' + 400: + $ref: '#/components/responses/400BadRequest' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 500: + $ref: '#/components/responses/500ServerError' + + /model/test/stories: + post: + security: + - TokenAuth: [] + - JWT: [] + operationId: testModelStories + tags: + - Model + summary: Evaluate stories + description: >- + Evaluates one or multiple stories against the currently + loaded Rasa model. + parameters: + - $ref: '#/components/parameters/e2e' + requestBody: + required: true + content: + text/yml: + schema: + $ref: '#/components/schemas/StoriesTrainingData' + responses: + 200: + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluationStoriesResult' + 400: + $ref: '#/components/responses/400BadRequest' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 409: + $ref: '#/components/responses/409Conflict' + 500: + $ref: '#/components/responses/500ServerError' + + /model/test/intents: + post: + security: + - TokenAuth: [] + - JWT: [] + operationId: testModelIntent + tags: + - Model + summary: Perform an intent evaluation + description: >- + Evaluates NLU model against a model or using cross-validation. + parameters: + - $ref: '#/components/parameters/model' + - $ref: '#/components/parameters/callback_url' + - in: query + name: cross_validation_folds + schema: + type: integer + default: null + description: >- + Number of cross validation folds. If this parameter is specified + the given training data will be used for a cross-validation instead of + using it as test set for the specified model. Note that this is only + supported for YAML data. + requestBody: + required: true + content: + application/x-yaml: + schema: + type: string + description: >- + NLU training data and model configuration. The model configuration is + only required if cross-validation is used. + example: >- + nlu: + - intent: greet + examples: | + - hey + - hello + - hi + - intent: bye + examples: | + - goodbye + - bye + - cheers + + pipeline: + - name: KeywordIntentClassifier + application/json: + schema: + $ref: '#/components/schemas/RasaNLUData' + + responses: + 200: + description: NLU evaluation result + content: + application/json: + schema: + $ref: '#/components/schemas/NLUEvaluationResult' + 204: + $ref: '#/components/responses/204Callback' + 400: + $ref: '#/components/responses/400BadRequest' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 409: + $ref: '#/components/responses/409Conflict' + 500: + $ref: '#/components/responses/500ServerError' + + /model/predict: + post: + security: + - TokenAuth: [] + - JWT: [] + operationId: predictModelAction + tags: + - Model + summary: Predict an action on a temporary state + description: >- + Predicts the next action on the tracker state as it is + posted to this endpoint. Rasa will create a temporary + tracker from the provided events and will use it to + predict an action. No messages will be sent and no + action will be run. + parameters: + - $ref: '#/components/parameters/include_events' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EventList' + responses: + 200: + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/PredictResult' + 400: + $ref: '#/components/responses/400BadRequest' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 409: + $ref: '#/components/responses/409Conflict' + 500: + $ref: '#/components/responses/500ServerError' + + /model/parse: + post: + security: + - TokenAuth: [] + - JWT: [] + operationId: parseModelMessage + tags: + - Model + summary: Parse a message using the Rasa model + description: >- + Predicts the intent and entities of the message + posted to this endpoint. No messages will be stored + to a conversation and no action will be run. This will + just retrieve the NLU parse results. + parameters: + - $ref: '#/components/parameters/emulation_mode' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + text: + type: string + description: Message to be parsed + example: "Hello, I am Rasa!" + message_id: + type: string + description: Optional ID for message to be parsed + example: "b2831e73-1407-4ba0-a861-0f30a42a2a5a" + responses: + 200: + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/ParseResult' + 400: + $ref: '#/components/responses/400BadRequest' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 500: + $ref: '#/components/responses/500ServerError' + + /model: + put: + security: + - TokenAuth: [] + - JWT: [] + operationId: replaceModel + tags: + - Model + summary: Replace the currently loaded model + description: >- + Updates the currently loaded model. + First, tries to load the model from the local (note: local to Rasa server) + storage system. + Secondly, tries to load the model from the provided model server configuration. + Last, tries to load the model from the provided remote storage. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ModelRequest' + responses: + 204: + description: Model was successfully replaced. + 400: + $ref: '#/components/responses/400BadRequest' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 500: + $ref: '#/components/responses/500ServerError' + + delete: + security: + - TokenAuth: [] + - JWT: [] + operationId: unloadModel + tags: + - Model + summary: Unload the trained model + description: >- + Unloads the currently loaded trained model from the server. + responses: + 204: + description: Model was sucessfully unloaded. + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + + /domain: + get: + security: + - TokenAuth: [] + - JWT: [] + operationId: getDomain + tags: + - Domain + summary: Retrieve the loaded domain + description: >- + Returns the domain specification the currently loaded + model is using. + responses: + 200: + description: Domain was successfully retrieved. + content: + application/json: + schema: + $ref: '#/components/schemas/Domain' + application/yaml: + schema: + $ref: '#/components/schemas/Domain' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 406: + $ref: '#/components/responses/406InvalidHeader' + 500: + $ref: '#/components/responses/500ServerError' + + +components: + + securitySchemes: + + TokenAuth: + type: apiKey + in: query + name: token + description: | + A plaintext token to secure your server, specified at startup in the argument `--auth-token thisismysecret` + JWT: + type: http + scheme: bearer + bearerFormat: JWT + description: | + A JWT token that is signed using the JWT secret specified at startup in the argument `--jwt-secret thisismysecret`, + using the `HS256` algorithm. + + The token's payload must contain an object under the `user` key, + which in turn must contain the `username` and `role` attributes. + The following is an example payload for a JWT token: + + ```json + { + "user": { + "username": "<sender_id>", + "role": "user" + } + } + ``` + + If the `role` is `admin`, all endpoints are accessible. + If the `role` is `user`, endpoints with a `sender_id` parameter are only accessible + if the `sender_id` matches the payload's `username` property. + + parameters: + + conversation_id: + in: path + name: conversation_id + description: Id of the conversation + example: default + schema: + type: string + required: true + batch_size: + in: query + name: batch_size + description: Batch size to use for training. + example: 5 + schema: + type: number + default: 5 + required: false + epochs: + in: query + name: epochs + description: Number of epochs to train. + example: 30 + schema: + type: number + default: 30 + required: false + e2e: + in: query + name: e2e + description: Perform an end-to-end evaluation on the posted stories. + example: false + schema: + type: boolean + default: false + required: false + all_sessions: + in: query + name: all_sessions + description: >- + Whether to fetch all sessions in a conversation, or only the latest session + + * `true` - fetch all conversation sessions. + + * `false` - [default] fetch only the latest conversation session. + + example: false + schema: + type: boolean + default: false + required: false + model: + in: query + name: model + description: >- + Model that should be used for evaluation. + If the parameter is set, the model will be + fetched with the currently loaded configuration + setup. However, the currently loaded model + will not be updated. The state of the server + will not change. If the parameter is not set, + the currently loaded model will be used for + the evaluation. + example: rasa-model.tar.gz + schema: + type: string + required: false + include_events: + in: query + name: include_events + description: >- + Specify which events of the tracker the response + should contain. + + * `ALL` - every logged event. + + * `APPLIED` - only events that contribute to the trackers state. This excludes reverted utterances and actions that got undone. + + * `AFTER_RESTART` - all events since the last `restarted` event. + This includes utterances that got reverted and actions that got undone. + + * `NONE` - no events. + + example: AFTER_RESTART + schema: + type: string + default: AFTER_RESTART + enum: + - ALL + - APPLIED + - AFTER_RESTART + - NONE + emulation_mode: + in: query + name: emulation_mode + description: >- + Specify the emulation mode to use. + Emulation mode transforms the response JSON to the format expected by the service specified as the emulation_mode. + Requests must still be sent in the regular Rasa format. + + example: LUIS + schema: + type: string + enum: + - WIT + - LUIS + - DIALOGFLOW + until: + in: query + name: until + description: >- + All events previous to the passed timestamp will be replayed. + Events that occur exactly at the target time will be included. + example: 1559744410 + schema: + type: number + default: None + required: false + output_channel: + in: query + name: output_channel + description: >- + The bot's utterances will be forwarded to this channel. It uses the credentials + listed in `credentials.yml` to connect. In case the channel does + not support this, the utterances will be returned in the response body. Use + `latest` to try to send the messages to the latest channel the user used. + Currently supported channels are listed in the permitted values for the + parameter. + example: "slack" + schema: + type: string + enum: + - latest + - slack + - callback + - facebook + - rocketchat + - telegram + - twilio + - webexteams + - socketio + callback_url: + in: query + name: callback_url + description: >- + If specified the call will return immediately with an empty response and status + code 204. The actual result or any errors will be sent to the given callback + URL as the body of a post request. + example: "https://example.com/rasa_evaluations" + schema: + type: string + default: None + required: false + responses: + + 200Tracker: + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Tracker' + 200Story: + description: Success + content: + text/yml: + example: >- + - story: story_00055028 + steps: + - user: | + hello + intent: greet + - action: utter_ask_howcanhelp + - user: | + I'm looking for a [moderately priced]{"entity": "price", "value": "moderate"} [Indian]{"entity": "cuisine"} restaurant for [two]({"entity": "people"}) people + intent: inform + - action: utter_on_it + - action: utter_ask_location + 204Callback: + description: >- + The incoming request specified a `callback_url` and hence the request will + return immediately with an empty response. The actual response will be sent to + the provided `callback_url` via POST request. + 400BadRequest: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + version: "1.0.0" + status: "failure" + reason: "BadRequest" + code: 400 + 401NotAuthenticated: + description: User is not authenticated. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + version: "1.0.0" + status: "failure" + reason: "NotAuthenticated" + message: >- + User is not authenticated to access resource. + code: 401 + 403NotAuthorized: + description: User has insufficient permission. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + version: "1.0.0" + status: "failure" + reason: "NotAuthorized" + message: >- + User has insufficient permission to access resource. + code: 403 + 406InvalidHeader: + description: Invalid header provided. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + version: "1.0.0" + status: "failure" + reason: "InvalidHeader" + message: >- + Invalid header was provided with the request. + code: 406 + 409Conflict: + description: The request conflicts with the currently loaded model. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + version: "1.0.0" + status: "failure" + reason: "Conflict" + message: >- + The request conflicts with the currently loaded model. + code: 409 + 500ServerError: + description: An unexpected error occurred. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + version: "1.0.0" + status: "ServerError" + message: >- + An unexpected error occurred. + code: 500 + + + schemas: + + ModelRequest: + type: object + properties: + model_file: + type: string + description: Path to model file + example: "/absolute-path-to-models-directory/models/20190512.tar.gz" + model_server: + $ref: '#/components/schemas/EndpointConfig' + remote_storage: + description: Name of remote storage system + type: string + example: "aws" + enum: + - aws + - gcs + - azure + + ActionRequest: + type: object + properties: + name: + description: Name of the action to be executed. + type: string + example: utter_greet + policy: + description: Name of the policy that predicted the action. + type: string + nullable: true + confidence: + description: Confidence of the prediction. + type: number + nullable: true + example: 0.987232 + required: ["name"] + + IntentTriggerRequest: + type: object + properties: + name: + description: Name of the intent to be executed. + type: string + example: greet + entities: + description: Entities to be passed on. + type: object + nullable: true + example: {"temperature": "high"} + required: ["name"] + + Message: + type: object + properties: + text: + type: string + description: >- + Message text + example: Hello! + sender: + type: string + description: >- + Origin of the message - who sent it + example: user + enum: + - user + parse_data: + $ref: '#/components/schemas/ParseResult' + required: ["text", "sender"] + + Entity: + type: object + description: Entities within a message + properties: + start: + type: integer + description: Char offset of the start + end: + type: integer + description: Char offset of the end + value: + type: string + description: Found value for entity + entity: + type: string + description: Type of the entity + confidence: + type: number + required: ["start", "end", "value", "entity"] + + Intent: + type: object + description: Intent of the text + properties: + confidence: + type: number + description: Confidence of the intent + example: 0.6323 + name: + type: string + description: Intent name + example: greet + required: ["confidence", "name"] + + ParseResult: + type: object + properties: + entities: + type: array + description: Parsed entities + items: + $ref: '#/components/schemas/Entity' + intent: + $ref: '#/components/schemas/Intent' + intent_ranking: + type: array + description: Scores of all intents + items: + $ref: '#/components/schemas/Intent' + text: + type: string + description: Text of the message + example: "Hello!" + description: >- + NLU parser information. If set, message + will not be passed through NLU, but instead + this parsing information will be used. + required: ["text"] + + LatestAction: + type: object + properties: + action_name: + type: string + description: latest action name + action_text: + type: string + description: text of last bot utterance + description: >- + Latest bot action. + + Event: + anyOf: + - $ref: '#/components/schemas/UserEvent' + - $ref: '#/components/schemas/BotEvent' + - $ref: '#/components/schemas/SessionStartedEvent' + - $ref: '#/components/schemas/ActionEvent' + - $ref: '#/components/schemas/SlotEvent' + - $ref: '#/components/schemas/ResetSlotsEvent' + - $ref: '#/components/schemas/RestartEvent' + - $ref: '#/components/schemas/ReminderEvent' + - $ref: '#/components/schemas/CancelReminderEvent' + - $ref: '#/components/schemas/PauseEvent' + - $ref: '#/components/schemas/ResumeEvent' + - $ref: '#/components/schemas/FollowupEvent' + - $ref: '#/components/schemas/ExportEvent' + - $ref: '#/components/schemas/UndoEvent' + - $ref: '#/components/schemas/RewindEvent' + - $ref: '#/components/schemas/AgentEvent' + - $ref: '#/components/schemas/EntitiesAddedEvent' + - $ref: '#/components/schemas/UserFeaturizationEvent' + - $ref: '#/components/schemas/ActionExecutionRejectedEvent' + - $ref: '#/components/schemas/FormValidationEvent' + - $ref: '#/components/schemas/LoopInterruptedEvent' + - $ref: '#/components/schemas/FormEvent' + - $ref: '#/components/schemas/ActiveLoopEvent' + + BasicEvent: + type: object + properties: + event: + type: string + description: Event name + example: "slot" + timestamp: + type: integer + description: Time of application + example: null + metadata: + type: object + properties: {} + example: + arbitrary_metadata_key: "some string" + more_metadata: 1.0 + required: + - event + + UserEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + description: >- + Event for incoming user message. + properties: + event: + enum: + - user + example: "user" + text: + type: string + nullable: true + description: Text of user message. + input_channel: + type: string + nullable: true + message_id: + type: string + nullable: true + parse_data: + $ref: '#/components/schemas/ParseResult' + + ActionEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - action + example: "action" + policy: + type: string + nullable: true + confidence: + type: number + nullable: true + name: + type: string + nullable: true + hide_rule_turn: + type: boolean + action_text: + type: string + nullable: true + + SlotEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - slot + example: "slot" + name: + type: string + value: {} + required: + - name + - value + + EntitiesAddedEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - entities + example: "entities" + entities: + type: array + items: + type: object + properties: + start: + type: integer + end: + type: integer + entity: + type: string + confidence: + type: number + extractor: + type: string + nullable: true + value: {} + role: + type: string + nullable: true + group: + type: string + nullable: true + required: + - entity + - value + required: + - entities + + UserFeaturizationEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - user_featurization + example: "user_featurization" + + CancelReminderEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - cancel_reminder + example: "cancel_reminder" + + ReminderEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - reminder + example: "reminder" + + ActionExecutionRejectedEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - action_execution_rejected + example: "action_execution_rejected" + + FormValidationEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - form_validation + example: "form_validation" + + LoopInterruptedEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - loop_interrupted + example: "loop_interrupted" + + FormEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - form + example: "form" + + ActiveLoopEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - active_loop + example: "active_loop" + + ResetSlotsEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - reset_slots + example: "reset_slots" + + ResumeEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - resume + example: "resume" + + PauseEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - pause + example: "pause" + + FollowupEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - followup + example: "followup" + + ExportEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - export + example: "export" + + RestartEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - restart + example: "restart" + + UndoEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - undo + example: "undo" + + RewindEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - rewind + example: "rewind" + + BotEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - bot + example: "bot" + + SessionStartedEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - session_started + example: "session_started" + + AgentEvent: + allOf: + - $ref: '#/components/schemas/BasicEvent' + - type: object + properties: + event: + enum: + - agent + example: "agent" + + EventList: + type: array + items: + $ref: '#/components/schemas/Event' + + Domain: + type: object + description: The bot's domain. + properties: + config: + type: object + description: Addional option + properties: + store_entities_as_slots: + type: boolean + description: Store all entites as slot when found + example: false + intents: + type: array + description: All intent names and properties + items: + $ref: '#/components/schemas/IntentDescription' + entities: + type: array + description: All entity names + items: + type: string + example: ['person', 'location'] + slots: + description: Slot names and configuration + type: object + additionalProperties: + $ref: '#/components/schemas/SlotDescription' + responses: + description: Bot response templates + type: object + additionalProperties: + $ref: '#/components/schemas/TemplateDescription' + actions: + description: Available action names + type: array + items: + type: string + example: ['action_greet', 'action_goodbye', 'action_listen'] + + BotMessage: + type: object + properties: + recipient_id: + type: string + description: Id of the message receiver + text: + type: string + description: Message + image: + type: string + description: Image URL + buttons: + type: array + description: Quick reply buttons + items: + type: object + properties: + title: + type: string + description: Button caption + payload: + type: string + description: Payload to be sent if button is clicked + attachement: + type: array + description: Additional information + items: + type: object + properties: + title: + type: string + description: Attachement caption + payload: + type: string + description: Attachement payload + + Tracker: + type: object + description: Conversation tracker which stores the conversation state. + properties: + conversation_id: + type: string + description: ID of the conversation + example: default + slots: + type: array + description: Slot values + items: + $ref: '#/components/schemas/Slot' + latest_message: + $ref: '#/components/schemas/ParseResult' + latest_event_time: + type: number + description: Most recent event time + example: 1537645578.314389 + followup_action: + type: string + description: Deterministic scheduled next action + paused: + type: boolean + description: Bot is paused + example: false + events: + description: Event history + $ref: '#/components/schemas/EventList' + latest_input_channel: + type: string + description: Communication channel + example: rest + latest_action_name: + type: string + description: Name of last bot action + example: action_listen + latest_action: + $ref: '#/components/schemas/LatestAction' + active_loop: + type: object + description: Name of the active loop + properties: + name: + type: string + description: Name of the active loop + example: restaurant_form + + Error: + type: object + properties: + version: + type: string + description: Rasa version + status: + type: string + enum: ["failure"] + description: Status of the requested action + message: + type: string + description: Error message + reason: + type: string + description: Error category + details: + type: object + description: Additional error information + help: + type: string + description: Optional URL to additonal material + code: + type: number + description: HTTP status code + + PredictResult: + type: object + properties: + scores: + type: array + description: Prediction results + items: + type: object + properties: + action: + type: string + description: Action name + example: utter_greet + score: + type: number + description: Assigned score + example: 1.0 + policy: + type: string + description: >- + Policy which predicted the most likely action + example: policy_2_TEDPolicy + tracker: + $ref: '#/components/schemas/Tracker' + + EndpointConfig: + type: object + properties: + url: + type: string + description: URL pointing to model + params: + type: object + description: Parameters of request + headers: + type: object + description: HTTP headers + basic_auth: + description: Basic authentification data + type: object + token: + description: Token + type: string + token_name: + description: Name of token + type: string + wait_time_between_pulls: + type: integer + description: Time to wait between pulls from model server + + YAMLTrainingRequest: + type: object + properties: + pipeline: + description: Pipeline list + type: array + policies: + description: Policies list + type: array + entities: + description: Entity list + type: array + slots: + description: Slots list + type: array + actions: + description: Action list + type: array + forms: + description: Forms list + type: array + e2e_actions: + description: E2E Action list + type: array + responses: + description: Bot response templates + type: object + additionalProperties: + $ref: '#/components/schemas/TemplateDescription' + session_config: + description: Session configuration options + type: object + properties: + session_expiration_time: + type: integer + carry_over_slots_to_new_session: + type: boolean + nlu: + description: Rasa NLU data, array of intents + type: array + rules: + description: Rule list + type: array + stories: + description: Rasa Core stories in YAML format + type: array + + force: + type: boolean + description: >- + Force a model training even if the data has not changed + example: false + deprecated: True + save_to_default_model_directory: + type: boolean + description: >- + If `true` (default) the trained model will be saved in the default model + directory, if `false` it will be saved in a temporary directory + deprecated: True + + RetrievalIntentsTrainingData: + type: string + description: Rasa response texts for retrieval intents in YAML format + example: >- + chitchat/ask_name: + - text: my name is Sara, Rasa's documentation bot! + chitchat/ask_weather: + - text: it's always sunny where I live + + StoriesTrainingData: + type: string + description: Rasa Core stories in YAML format + example: >- + - story: happy path + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy + + - story: sad path 1 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + + - story: sad path 2 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: deny + - action: utter_goodbye + + - story: say goodbye + steps: + - intent: goodbye + - action: utter_goodbye + + TrainingResult: + type: string + format: binary + + NLUEvaluationResult: + type: object + properties: + intent_evaluation: + description: Rasa NLU intent evaluation + $ref: '#/components/schemas/EvaluationItem' + response_selection_evaluation: + description: Evaluation for the retrieval intents + $ref: '#/components/schemas/EvaluationItem' + entity_evaluation: + description: Rasa NLU entity evaluation. + type: object + additionalProperties: + type: object + description: Evaluation for a specific extractor + $ref: '#/components/schemas/EvaluationItem' + + EvaluationItem: + type: object + description: Evaluation Result + properties: + report: + $ref: '#/components/schemas/EvaluationReport' + accuracy: + type: number + example: 0.19047619047619047 + f1_score: + type: number + example: 0.06095238095238095 + precision: + type: number + example: 0.036281179138321996 + predictions: + type: array + description: The predictions for each item in the test set + items: + type: object + properties: + intent: + type: string + example: greet + predicted: + type: string + example: greet + text: + type: string + example: "hey" + confidence: + type: number + example: 0.9973567 + errors: + description: The errors which were made during the testing. + type: array + items: + oneOf: + - $ref: '#/components/schemas/IntentTestError' + - $ref: '#/components/schemas/EntityTestError' + - $ref: '#/components/schemas/ResponseSelectorTestError' + + IntentTestError: + description: Intent prediction errors which was made during testing + type: object + properties: + text: + type: string + description: Test message + example: are you alright? + intent_response_key_target: + description: Expected intent + type: string + intent_response_key_prediction: + description: Predicted intent + $ref: '#/components/schemas/Intent' + + EntityTestError: + description: Entity prediction errors which was made during testing + type: object + properties: + text: + type: string + description: Test message + example: what is the weather in zurich? + entities: + description: Expected entities + type: array + items: + $ref: '#/components/schemas/Entity' + predicted_entities: + description: Predicted entities + type: array + items: + $ref: '#/components/schemas/Entity' + + ResponseSelectorTestError: + description: Error during response prediction which was made during testing + type: object + properties: + text: + type: string + description: Test message + example: are you alright? + intent_response_key_target: + description: Expected retrieval intent + type: string + intent_response_key_prediction: + description: Predicted retrieval intent + $ref: '#/components/schemas/Intent' + + EvaluationReport: + type: object + description: >- + Sklearn classification report, see + http://scikit-learn.org/stable/modules/generated/sklearn.metrics.classification_report.html + example: + greet: + precision: 0.123 + recall: 0.456 + f1-score: 0.12 + support: 100 + confused_with: + chitchat: 3 + nlu_fallback: 5 + micro avg: + precision: 0.123 + recall: 0.456 + f1-score: 0.12 + support: 100 + macro avg: + precision: 0.123 + recall: 0.456 + f1-score: 0.12 + support: 100 + weightedq avg: + precision: 0.123 + recall: 0.456 + f1-score: 0.12 + support: 100 + + EvaluationStoriesResult: + type: object + properties: + actions: + type: array + items: + type: object + properties: + action: + type: string + description: Name of the actual action + example: utter_ask_howcanhelp + predicted: + type: string + description: Name of the predicted action + example: utter_ask_howcanhelp + policy: + type: string + description: Machine-learning policy used in the prediction + example: policy_0_MemoizationPolicy + confidence: + type: string + description: Confidence score of the prediction + example: 1.0 + description: >- + Accuracy of the classification, + http://scikit-learn.org/stable/modules/generated/sklearn.metrics.accuracy_score.html + is_end_to_end_evaluation: + type: boolean + description: True if evaluation is end-to-end, false otherwise + example: true + precision: + type: number + description: >- + Precision of the classification, see + http://scikit-learn.org/stable/modules/generated/sklearn.metrics.precision_score.html + example: 1.0 + f1: + type: number + description: >- + F1 score of the classification, + http://scikit-learn.org/stable/modules/generated/sklearn.metrics.precision_score.html + example: 0.9333333333333333 + accuracy: + type: number + description: >- + Accuracy of the classification, + http://scikit-learn.org/stable/modules/generated/sklearn.metrics.accuracy_score.html + example: 0.9 + in_training_data_fraction: + type: number + description: >- + Fraction of stories that are present in the training data of the + model loaded at evaluation time. + example: 0.8571428571428571 + report: + type: object + description: >- + Sklearn classification reported extended with information about conversation accuracy. + additionalProperties: + type: object + properties: + intent_name: + type: string + classification_report: + $ref: '#/components/schemas/EvaluationReport' + properties: + conversation_accuracy: + $ref: '#/components/schemas/ConversationAccuracyReport' + + ConversationAccuracyReport: + type: object + properties: + accuracy: + type: number + example: 0.19047619047619047 + correct: + type: number + example: 18 + with_warnings: + type: number + example: 1 + total: + type: number + example: 20 + + Slot: + type: object + additionalProperties: + $ref: '#/components/schemas/SlotValue' + example: + slot_name: slot_value + + SlotValue: + oneOf: + - type: string + - type: array + items: + type: string + + SlotDescription: + type: object + properties: + auto_fill: + type: boolean + initial_value: + type: string + nullable: true + type: + type: string + values: + type: array + items: + type: string + required: ['type', 'auto_fill'] + + TemplateDescription: + type: object + properties: + text: + type: string + description: Template text + required: ['text'] + + IntentDescription: + type: object + additionalProperties: + type: object + properties: + use_entities: + type: boolean + + RasaNLUData: + type: object + properties: + common_examples: + type: object + items: + type: array + items: + $ref: '#/components/schemas/CommonExample' + example: + rasa_nlu_data: + common_examples: + - text: hey + intent: greet + entities: [] + - text: dear sir + intent: greet + entities: [] + - text: i'm looking for a place to eat + intent: restaurant_search + entities: [] + - text: i'm looking for a place in the north of town + intent: restaurant_search + entities: + - start: 31 + end: 36 + value: north + entity: location + - text: show me a mexican place in the centre + intent: restaurant_search + entities: + - start: 31 + end: 37 + value: centre + entity: location + - start: 10 + end: 17 + value: mexican + entity: cuisine + + CommonExample: + type: object + properties: + entities: + description: Expected entities + type: array + items: + $ref: '#/components/schemas/Entity' + intent: + type: string + description: Intent name + text: + type: string + description: Text of the message + example: "Hello!" diff --git a/docs/themes/theme-custom/custom.css b/docs/themes/theme-custom/custom.css new file mode 100644 index 0000000..f66cfae --- /dev/null +++ b/docs/themes/theme-custom/custom.css @@ -0,0 +1,39 @@ +.admonition-info, .alert--info { + color: rgb(25, 60, 71) !important; + background-color: rgb(238, 249, 253) !important; + border-left: 8px solid rgb(76, 179, 212) !important; +} + + +@media (max-width: 996px) { + .dropdown { + display: none !important; + } +} + +/* Override Tabula so column width are stricly determined by content width */ +.prose table, [class^=prose-] table, [class*=" prose-"] table, .prose thead, [class^=prose-] thead, [class*=" prose-"] thead, .prose tbody, [class^=prose-] tbody, [class*=" prose-"] tbody { + width: auto !important; +} + +/* Make sure header and body columns share same width */ +.prose thead, [class^=prose-] thead, [class*=" prose-"] thead, .prose tbody, [class^=prose-] tbody, [class*=" prose-"] tbody { + display: table-header-group !important; +} + +.prose th, [class^=prose-] th, [class*=" prose-"] th, .prose td, [class^=prose-] td, [class*=" prose-"] td { + padding: 5px !important; + border-style: solid; + border-color: rgb(221, 221, 221); + border-width: 0.75px; +} + +/* The th bottom border is not needed because the td below will have a top border */ +.prose tr:last-child>th, [class^=prose-] tr:last-child>th, [class*=" prose-"] tr:last-child>th { + border-bottom-style: none; +} + +/* Tables without headers */ +.prose th:empty, [class^=prose-] th:empty, [class*=" prose-"] th:empty { + border-style: none; +} diff --git a/docs/themes/theme-custom/index.js b/docs/themes/theme-custom/index.js new file mode 100644 index 0000000..c0038ec --- /dev/null +++ b/docs/themes/theme-custom/index.js @@ -0,0 +1,36 @@ +const path = require("path"); + +// FIXME: this package is copied from +// https://github.com/facebook/docusaurus/tree/afe9ff91a4247316f0081c9b080655d575298416/packages/docusaurus-theme-live-codeblock/src +module.exports = function (context) { + const { + siteConfig: { url: siteUrl, baseUrl }, + } = context; + + return { + name: "theme-custom", + + getThemePath() { + return path.resolve(__dirname, "./theme"); + }, + + // FIXME: this needs to be fixed in the theme, see https://github.com/RasaHQ/docusaurus-tabula/issues/11 + getClientModules() { + return [require.resolve("./custom.css")]; + }, + + injectHtmlTags() { + return { + headTags: [ + { + tagName: "meta", + attributes: { + property: "og:image", + content: `${siteUrl}${baseUrl}img/og-image.png`, + }, + }, + ], + }; + }, + }; +}; diff --git a/docs/themes/theme-custom/theme/Grid/index.jsx b/docs/themes/theme-custom/theme/Grid/index.jsx new file mode 100644 index 0000000..aba81c2 --- /dev/null +++ b/docs/themes/theme-custom/theme/Grid/index.jsx @@ -0,0 +1,29 @@ +import styled, { css } from 'styled-components'; +export const Grid = styled.div` + display: grid; + grid-column-gap: ${({ spacious, tiny, noGap }) => + noGap ? 0 : spacious ? '5rem' : tiny ? '.75rem' : '1.875rem'}; + grid-row-gap: ${({ spacious, tiny, noGap }) => + noGap ? 0 : spacious ? '5rem' : tiny ? '.75rem' : '1.875rem'}; + ${({ noMargin }) => !noMargin && 'margin-bottom: 1.5rem;'} + text-align: left; + grid-auto-flow: ${({ autoFlow = 'row' }) => autoFlow}; + + ${({ centered }) => + centered && + css` + justify-content: center; + `} + + ${({ verticalAlign }) => + verticalAlign && + css` + align-items: ${verticalAlign}; + `} + + @media (min-width: 768px) { + justify-content: ${({ justify = 'space-between' }) => justify}; + grid-template-columns: ${({ columns = '1fr 1fr' }) => columns}; + } +`; +Grid.displayName = 'Grid'; diff --git a/docs/themes/theme-custom/theme/RasaProBanner/index.jsx b/docs/themes/theme-custom/theme/RasaProBanner/index.jsx new file mode 100644 index 0000000..ef975c6 --- /dev/null +++ b/docs/themes/theme-custom/theme/RasaProBanner/index.jsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import clsx from 'clsx'; + +import styles from './styles.module.css'; + +function RasaProBanner({isLoading, ...props}) { + return ( + <> + <div class="mdx-box admonition admonition-tip alert alert--success"> + <div class="mdx-box admonition-heading"> + <h5> + <span class="admonition-icon"> + <svg xmlns="http://www.w3.org/2000/svg" width="12" height="16" viewBox="0 0 12 16"> + <path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"> + </path> + </svg> + </span>Rasa Pro License + </h5> + </div> + <div class="mdx-box admonition-content"> + <p>You'll need a license to get started with Rasa Pro. + {' '} + <a href="https://rasa.com/connect-with-rasa/" target="_blank" rel="noopener noreferrer"> + Talk with Sales + </a> + </p> + </div> + </div> + </> + ) +} + +export default RasaProBanner; diff --git a/docs/themes/theme-custom/theme/RasaProBanner/styles.module.css b/docs/themes/theme-custom/theme/RasaProBanner/styles.module.css new file mode 100644 index 0000000..55cfcd7 --- /dev/null +++ b/docs/themes/theme-custom/theme/RasaProBanner/styles.module.css @@ -0,0 +1,18 @@ + + +.label { + background-color:#F6D261; + border: 1px solid transparent; + border-radius: 8px; + padding: 2px 12px; + font-size: 15px !important; + font-weight: 600; + + display: inline-block; +} + +.label[disabled] { + background-color: var(--ifm-color-gray-500); + cursor: default; +} + diff --git a/docs/themes/theme-custom/theme/RasaProLabel/index.jsx b/docs/themes/theme-custom/theme/RasaProLabel/index.jsx new file mode 100644 index 0000000..a0a7685 --- /dev/null +++ b/docs/themes/theme-custom/theme/RasaProLabel/index.jsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import clsx from 'clsx'; + +import styles from './styles.module.css'; + +function RasaProLabel({isLoading, ...props}) { + return ( + <div className={clsx(styles.label)}>Rasa Pro Only</div> + ) +} + +export default RasaProLabel; diff --git a/docs/themes/theme-custom/theme/RasaProLabel/styles.module.css b/docs/themes/theme-custom/theme/RasaProLabel/styles.module.css new file mode 100644 index 0000000..55cfcd7 --- /dev/null +++ b/docs/themes/theme-custom/theme/RasaProLabel/styles.module.css @@ -0,0 +1,18 @@ + + +.label { + background-color:#F6D261; + border: 1px solid transparent; + border-radius: 8px; + padding: 2px 12px; + font-size: 15px !important; + font-weight: 600; + + display: inline-block; +} + +.label[disabled] { + background-color: var(--ifm-color-gray-500); + cursor: default; +} + diff --git a/docs/yarn.lock b/docs/yarn.lock new file mode 100644 index 0000000..0114407 --- /dev/null +++ b/docs/yarn.lock @@ -0,0 +1,17628 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@algolia/cache-browser-local-storage@4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.5.1.tgz#bdf58c30795683fd48310c552c3a10f10fb26e2b" + integrity sha512-TAQHRHaCUAR0bNhUHG0CnO6FTx3EMPwZQrjPuNS6kHvCQ/H8dVD0sLsHyM8C7U4j33xPQCWi9TBnSx8cYXNmNw== + dependencies: + "@algolia/cache-common" "4.5.1" + +"@algolia/cache-common@4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@algolia/cache-common/-/cache-common-4.5.1.tgz#3aefda3382dc30b67091b01a3d7461d937082821" + integrity sha512-Sux+pcedQi9sfScIiQdl6pEaTVl712qM9OblvDhnaeF1v6lf4jyTlRTiBLP7YBLuvO1Yo54W3maf03kmz9PVhA== + +"@algolia/cache-in-memory@4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@algolia/cache-in-memory/-/cache-in-memory-4.5.1.tgz#127cd473474f62300a157f4ee3b3f6836003cf35" + integrity sha512-fzwAtBFwveuG+E5T/namChEIvdVl0DoV3djV1C078b/JpO5+DeAwuXIJGYbyl950u170n5NEYuIwYG+R6h4lJQ== + dependencies: + "@algolia/cache-common" "4.5.1" + +"@algolia/client-account@4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@algolia/client-account/-/client-account-4.5.1.tgz#7d3ccda09d3c7849b171c915da0833e7649bab33" + integrity sha512-2WFEaI7Zf4ljnBsSAS4e+YylZ5glovm78xFg4E1JKA8PE6M+TeIgUY6HO2ouLh2dqQKxc9UfdAT1Loo/dha2iQ== + dependencies: + "@algolia/client-common" "4.5.1" + "@algolia/client-search" "4.5.1" + "@algolia/transporter" "4.5.1" + +"@algolia/client-analytics@4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-4.5.1.tgz#bfc2a7292a9ea789ca3c99f79b1f96c08d378828" + integrity sha512-bTmZUU8zhZMWBeGEQ/TVqLoL3OOT0benU0HtS3iOnQURwb+AOCv3RsgZvkj2djp+M24Q6P8/L34uBJMmCurbLg== + dependencies: + "@algolia/client-common" "4.5.1" + "@algolia/client-search" "4.5.1" + "@algolia/requester-common" "4.5.1" + "@algolia/transporter" "4.5.1" + +"@algolia/client-common@4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-4.5.1.tgz#91a401eba6eafd7cc74a0aeccb4c6e6cb1e72026" + integrity sha512-5CpIf8IK1hke7q+N4e+A4TWdFXVJ5Qwyaa0xS84DrDO8HQ7vfYbDvG1oYa9hVEtGn6c3WVKPAvuWynK+fXQQCA== + dependencies: + "@algolia/requester-common" "4.5.1" + "@algolia/transporter" "4.5.1" + +"@algolia/client-recommendation@4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@algolia/client-recommendation/-/client-recommendation-4.5.1.tgz#57a1fe30987c90b10d5119b8e7d6cd91c423e54c" + integrity sha512-GiFrNSImoEBUQICjFBEoxPGzrjWji8PY9GeMg2CNvOYcRQ0Xt0Y36v9GN53NLjvB7QdQ2FlE1Cuv/PLUfS/aQQ== + dependencies: + "@algolia/client-common" "4.5.1" + "@algolia/requester-common" "4.5.1" + "@algolia/transporter" "4.5.1" + +"@algolia/client-search@4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-4.5.1.tgz#cb798c99d6621e29a36334b92205518a74ecdf3e" + integrity sha512-wjuOTte9Auo9Cg4fL0709PjeJ9rXFh4okYUrOt/2SWqQid6DSdZOp+BtyaHKV3E94sj+SlmMxkMUacYluYg5zA== + dependencies: + "@algolia/client-common" "4.5.1" + "@algolia/requester-common" "4.5.1" + "@algolia/transporter" "4.5.1" + +"@algolia/logger-common@4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@algolia/logger-common/-/logger-common-4.5.1.tgz#18d654516369a28e25ad7eee4fc2882fd47ed8ec" + integrity sha512-ZoVnGriinlLHlkvn5K7djOUn1/1IeTjU8rDzOJ3t06T+2hQytgJghaX7rSwKIeH4CjWMy61w8jLisuGJRBOEeg== + +"@algolia/logger-console@4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@algolia/logger-console/-/logger-console-4.5.1.tgz#c9def97c20bea5eecb4b07f8d3f733c0192d1761" + integrity sha512-1qa7K18+uAgxyWuguayaDS5ViiZFcOjI3J5ACBb0i/n7RsXUo149lP6mwmx6TIU7s135hT0f0TCqnvfMvN1ilA== + dependencies: + "@algolia/logger-common" "4.5.1" + +"@algolia/requester-browser-xhr@4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.5.1.tgz#838b55209d2c83572df261338f7cd75be36de401" + integrity sha512-tsQz+9pZw9dwPm/wMvZDpsWFZgmghLjXi4c3O4rfwoP/Ikum5fhle5fiR14yb4Lw4WlOQ1AJIHJvrg1qLIG8hQ== + dependencies: + "@algolia/requester-common" "4.5.1" + +"@algolia/requester-common@4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@algolia/requester-common/-/requester-common-4.5.1.tgz#a34d02daa6093e112b528d3bcd5a5467c00ba823" + integrity sha512-bPCiLvhHKXaka7f5FLtheChToz0yHVhvza64naFJRRh/3kC0nvyrvQ0ogjiydiSrGIfdNDyyTVfKGdk4gS5gyA== + +"@algolia/requester-node-http@4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-4.5.1.tgz#29911c104c6714a5cb29d3991f2b50c52301e091" + integrity sha512-BfFc2h9eQOKu1gGs3DtQO7GrVZW/rxUgpJVLja4UVQyGplJyTCrFgkTyfl+8rb3MkNgA/S2LNo7cKNSPfpqeAQ== + dependencies: + "@algolia/requester-common" "4.5.1" + +"@algolia/transporter@4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@algolia/transporter/-/transporter-4.5.1.tgz#e0a5c64f358b6751f867001f51f384d6fc7ede14" + integrity sha512-asPDNToDAPhH0tM6qKGTn1l0wTlNUbekpa1ifZ6v+qhSjo3VdqGyp+2VeciJOBW/wVHXh3HUbAcycvLERRlCLg== + dependencies: + "@algolia/cache-common" "4.5.1" + "@algolia/logger-common" "4.5.1" + "@algolia/requester-common" "4.5.1" + +"@babel/code-frame@7.10.4", "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" + integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== + dependencies: + "@babel/highlight" "^7.10.4" + +"@babel/code-frame@^7.12.11", "@babel/code-frame@^7.5.5": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" + integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== + dependencies: + "@babel/highlight" "^7.10.4" + +"@babel/code-frame@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb" + integrity sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw== + dependencies: + "@babel/highlight" "^7.14.5" + +"@babel/compat-data@^7.10.4", "@babel/compat-data@^7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.11.0.tgz#e9f73efe09af1355b723a7f39b11bad637d7c99c" + integrity sha512-TPSvJfv73ng0pfnEOh17bYMPQbI95+nGWc71Ss4vZdRBHTDqmM9Z8ZV4rYz8Ks7sfzc95n30k6ODIq5UGnXcYQ== + dependencies: + browserslist "^4.12.0" + invariant "^2.2.4" + semver "^5.5.0" + +"@babel/compat-data@^7.12.5", "@babel/compat-data@^7.12.7": + version "7.12.7" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.12.7.tgz#9329b4782a7d6bbd7eef57e11addf91ee3ef1e41" + integrity sha512-YaxPMGs/XIWtYqrdEOZOCPsVWfEoriXopnsz3/i7apYPXQ3698UFhS6dVT1KN5qOsWmVgw/FOrmQgpRaZayGsw== + +"@babel/compat-data@^7.13.11", "@babel/compat-data@^7.14.5", "@babel/compat-data@^7.14.7": + version "7.14.7" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.14.7.tgz#7b047d7a3a89a67d2258dc61f604f098f1bc7e08" + integrity sha512-nS6dZaISCXJ3+518CWiBfEr//gHyMO02uDxBkXTKZDN5POruCnOZ1N4YBRZDCabwF8nZMWBpRxIicmXtBs+fvw== + +"@babel/core@7.12.9": + version "7.12.9" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.12.9.tgz#fd450c4ec10cdbb980e2928b7aa7a28484593fc8" + integrity sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/generator" "^7.12.5" + "@babel/helper-module-transforms" "^7.12.1" + "@babel/helpers" "^7.12.5" + "@babel/parser" "^7.12.7" + "@babel/template" "^7.12.7" + "@babel/traverse" "^7.12.9" + "@babel/types" "^7.12.7" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.1" + json5 "^2.1.2" + lodash "^4.17.19" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.5.0" + +"@babel/core@^7.11.4", "@babel/core@^7.7.5", "@babel/core@^7.9.0": + version "7.14.6" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.14.6.tgz#e0814ec1a950032ff16c13a2721de39a8416fcab" + integrity sha512-gJnOEWSqTk96qG5BoIrl5bVtc23DCycmIePPYnamY9RboYdI4nFy5vAQMSl81O5K/W0sLDWfGysnOECC+KUUCA== + dependencies: + "@babel/code-frame" "^7.14.5" + "@babel/generator" "^7.14.5" + "@babel/helper-compilation-targets" "^7.14.5" + "@babel/helper-module-transforms" "^7.14.5" + "@babel/helpers" "^7.14.6" + "@babel/parser" "^7.14.6" + "@babel/template" "^7.14.5" + "@babel/traverse" "^7.14.5" + "@babel/types" "^7.14.5" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.1.2" + semver "^6.3.0" + source-map "^0.5.0" + +"@babel/generator@^7.11.5": + version "7.11.6" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.11.6.tgz#b868900f81b163b4d464ea24545c61cbac4dc620" + integrity sha512-DWtQ1PV3r+cLbySoHrwn9RWEgKMBLLma4OBQloPRyDYvc5msJM9kvTLo1YnlJd1P/ZuKbdli3ijr5q3FvAF3uA== + dependencies: + "@babel/types" "^7.11.5" + jsesc "^2.5.1" + source-map "^0.5.0" + +"@babel/generator@^7.12.11", "@babel/generator@^7.12.5": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.11.tgz#98a7df7b8c358c9a37ab07a24056853016aba3af" + integrity sha512-Ggg6WPOJtSi8yYQvLVjG8F/TlpWDlKx0OpS4Kt+xMQPs5OaGYWy+v1A+1TvxI6sAMGZpKWWoAQ1DaeQbImlItA== + dependencies: + "@babel/types" "^7.12.11" + jsesc "^2.5.1" + source-map "^0.5.0" + +"@babel/generator@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.14.5.tgz#848d7b9f031caca9d0cd0af01b063f226f52d785" + integrity sha512-y3rlP+/G25OIX3mYKKIOlQRcqj7YgrvHxOLbVmyLJ9bPmi5ttvUmpydVjcFjZphOktWuA7ovbx91ECloWTfjIA== + dependencies: + "@babel/types" "^7.14.5" + jsesc "^2.5.1" + source-map "^0.5.0" + +"@babel/helper-annotate-as-pure@^7.0.0", "@babel/helper-annotate-as-pure@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3" + integrity sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA== + dependencies: + "@babel/types" "^7.10.4" + +"@babel/helper-annotate-as-pure@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.14.5.tgz#7bf478ec3b71726d56a8ca5775b046fc29879e61" + integrity sha512-EivH9EgBIb+G8ij1B2jAwSH36WnGvkQSEC6CkX/6v6ZFlw5fVOHvsgGF4uiEHO2GzMvunZb6tDLQEQSdrdocrA== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-builder-binary-assignment-operator-visitor@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.10.4.tgz#bb0b75f31bf98cbf9ff143c1ae578b87274ae1a3" + integrity sha512-L0zGlFrGWZK4PbT8AszSfLTM5sDU1+Az/En9VrdT8/LmEiJt4zXt+Jve9DCAnQcbqDhCI+29y/L93mrDzddCcg== + dependencies: + "@babel/helper-explode-assignable-expression" "^7.10.4" + "@babel/types" "^7.10.4" + +"@babel/helper-builder-binary-assignment-operator-visitor@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.14.5.tgz#b939b43f8c37765443a19ae74ad8b15978e0a191" + integrity sha512-YTA/Twn0vBXDVGJuAX6PwW7x5zQei1luDDo2Pl6q1qZ7hVNl0RZrhHCQG/ArGpR29Vl7ETiB8eJyrvpuRp300w== + dependencies: + "@babel/helper-explode-assignable-expression" "^7.14.5" + "@babel/types" "^7.14.5" + +"@babel/helper-builder-react-jsx-experimental@^7.10.4", "@babel/helper-builder-react-jsx-experimental@^7.11.5": + version "7.11.5" + resolved "https://registry.yarnpkg.com/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.11.5.tgz#4ea43dd63857b0a35cd1f1b161dc29b43414e79f" + integrity sha512-Vc4aPJnRZKWfzeCBsqTBnzulVNjABVdahSPhtdMD3Vs80ykx4a87jTHtF/VR+alSrDmNvat7l13yrRHauGcHVw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.10.4" + "@babel/helper-module-imports" "^7.10.4" + "@babel/types" "^7.11.5" + +"@babel/helper-builder-react-jsx@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.10.4.tgz#8095cddbff858e6fa9c326daee54a2f2732c1d5d" + integrity sha512-5nPcIZ7+KKDxT1427oBivl9V9YTal7qk0diccnh7RrcgrT/pGFOjgGw1dgryyx1GvHEpXVfoDF6Ak3rTiWh8Rg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.10.4" + "@babel/types" "^7.10.4" + +"@babel/helper-compilation-targets@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.10.4.tgz#804ae8e3f04376607cc791b9d47d540276332bd2" + integrity sha512-a3rYhlsGV0UHNDvrtOXBg8/OpfV0OKTkxKPzIplS1zpx7CygDcWWxckxZeDd3gzPzC4kUT0A4nVFDK0wGMh4MQ== + dependencies: + "@babel/compat-data" "^7.10.4" + browserslist "^4.12.0" + invariant "^2.2.4" + levenary "^1.1.1" + semver "^5.5.0" + +"@babel/helper-compilation-targets@^7.12.5": + version "7.12.5" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.12.5.tgz#cb470c76198db6a24e9dbc8987275631e5d29831" + integrity sha512-+qH6NrscMolUlzOYngSBMIOQpKUGPPsc61Bu5W10mg84LxZ7cmvnBHzARKbDoFxVvqqAbj6Tg6N7bSrWSPXMyw== + dependencies: + "@babel/compat-data" "^7.12.5" + "@babel/helper-validator-option" "^7.12.1" + browserslist "^4.14.5" + semver "^5.5.0" + +"@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.14.5.tgz#7a99c5d0967911e972fe2c3411f7d5b498498ecf" + integrity sha512-v+QtZqXEiOnpO6EYvlImB6zCD2Lel06RzOPzmkz/D/XgQiUu3C/Jb1LOqSt/AIA34TYi/Q+KlT8vTQrgdxkbLw== + dependencies: + "@babel/compat-data" "^7.14.5" + "@babel/helper-validator-option" "^7.14.5" + browserslist "^4.16.6" + semver "^6.3.0" + +"@babel/helper-create-class-features-plugin@^7.10.4": + version "7.10.5" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.5.tgz#9f61446ba80e8240b0a5c85c6fdac8459d6f259d" + integrity sha512-0nkdeijB7VlZoLT3r/mY3bUkw3T8WG/hNw+FATs/6+pG2039IJWjTYL0VTISqsNHMUTEnwbVnc89WIJX9Qed0A== + dependencies: + "@babel/helper-function-name" "^7.10.4" + "@babel/helper-member-expression-to-functions" "^7.10.5" + "@babel/helper-optimise-call-expression" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-replace-supers" "^7.10.4" + "@babel/helper-split-export-declaration" "^7.10.4" + +"@babel/helper-create-class-features-plugin@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.12.1.tgz#3c45998f431edd4a9214c5f1d3ad1448a6137f6e" + integrity sha512-hkL++rWeta/OVOBTRJc9a5Azh5mt5WgZUGAKMD8JM141YsE08K//bp1unBBieO6rUKkIPyUE0USQ30jAy3Sk1w== + dependencies: + "@babel/helper-function-name" "^7.10.4" + "@babel/helper-member-expression-to-functions" "^7.12.1" + "@babel/helper-optimise-call-expression" "^7.10.4" + "@babel/helper-replace-supers" "^7.12.1" + "@babel/helper-split-export-declaration" "^7.10.4" + +"@babel/helper-create-class-features-plugin@^7.14.5": + version "7.14.6" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.14.6.tgz#f114469b6c06f8b5c59c6c4e74621f5085362542" + integrity sha512-Z6gsfGofTxH/+LQXqYEK45kxmcensbzmk/oi8DmaQytlQCgqNZt9XQF8iqlI/SeXWVjaMNxvYvzaYw+kh42mDg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.14.5" + "@babel/helper-function-name" "^7.14.5" + "@babel/helper-member-expression-to-functions" "^7.14.5" + "@babel/helper-optimise-call-expression" "^7.14.5" + "@babel/helper-replace-supers" "^7.14.5" + "@babel/helper-split-export-declaration" "^7.14.5" + +"@babel/helper-create-regexp-features-plugin@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.10.4.tgz#fdd60d88524659a0b6959c0579925e425714f3b8" + integrity sha512-2/hu58IEPKeoLF45DBwx3XFqsbCXmkdAay4spVr2x0jYgRxrSNp+ePwvSsy9g6YSaNDcKIQVPXk1Ov8S2edk2g== + dependencies: + "@babel/helper-annotate-as-pure" "^7.10.4" + "@babel/helper-regex" "^7.10.4" + regexpu-core "^4.7.0" + +"@babel/helper-create-regexp-features-plugin@^7.12.1": + version "7.12.7" + resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.12.7.tgz#2084172e95443fa0a09214ba1bb328f9aea1278f" + integrity sha512-idnutvQPdpbduutvi3JVfEgcVIHooQnhvhx0Nk9isOINOIGYkZea1Pk2JlJRiUnMefrlvr0vkByATBY/mB4vjQ== + dependencies: + "@babel/helper-annotate-as-pure" "^7.10.4" + regexpu-core "^4.7.1" + +"@babel/helper-create-regexp-features-plugin@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.14.5.tgz#c7d5ac5e9cf621c26057722fb7a8a4c5889358c4" + integrity sha512-TLawwqpOErY2HhWbGJ2nZT5wSkR192QpN+nBg1THfBfftrlvOh+WbhrxXCH4q4xJ9Gl16BGPR/48JA+Ryiho/A== + dependencies: + "@babel/helper-annotate-as-pure" "^7.14.5" + regexpu-core "^4.7.1" + +"@babel/helper-define-map@^7.10.4": + version "7.10.5" + resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.10.5.tgz#b53c10db78a640800152692b13393147acb9bb30" + integrity sha512-fMw4kgFB720aQFXSVaXr79pjjcW5puTCM16+rECJ/plGS+zByelE8l9nCpV1GibxTnFVmUuYG9U8wYfQHdzOEQ== + dependencies: + "@babel/helper-function-name" "^7.10.4" + "@babel/types" "^7.10.5" + lodash "^4.17.19" + +"@babel/helper-define-polyfill-provider@^0.2.2": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.2.3.tgz#0525edec5094653a282688d34d846e4c75e9c0b6" + integrity sha512-RH3QDAfRMzj7+0Nqu5oqgO5q9mFtQEVvCRsi8qCEfzLR9p2BHfn5FzhSB2oj1fF7I2+DcTORkYaQ6aTR9Cofew== + dependencies: + "@babel/helper-compilation-targets" "^7.13.0" + "@babel/helper-module-imports" "^7.12.13" + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/traverse" "^7.13.0" + debug "^4.1.1" + lodash.debounce "^4.0.8" + resolve "^1.14.2" + semver "^6.1.2" + +"@babel/helper-explode-assignable-expression@^7.10.4": + version "7.11.4" + resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.11.4.tgz#2d8e3470252cc17aba917ede7803d4a7a276a41b" + integrity sha512-ux9hm3zR4WV1Y3xXxXkdG/0gxF9nvI0YVmKVhvK9AfMoaQkemL3sJpXw+Xbz65azo8qJiEz2XVDUpK3KYhH3ZQ== + dependencies: + "@babel/types" "^7.10.4" + +"@babel/helper-explode-assignable-expression@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.14.5.tgz#8aa72e708205c7bb643e45c73b4386cdf2a1f645" + integrity sha512-Htb24gnGJdIGT4vnRKMdoXiOIlqOLmdiUYpAQ0mYfgVT/GDm8GOYhgi4GL+hMKrkiPRohO4ts34ELFsGAPQLDQ== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-function-name@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz#d2d3b20c59ad8c47112fa7d2a94bc09d5ef82f1a" + integrity sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ== + dependencies: + "@babel/helper-get-function-arity" "^7.10.4" + "@babel/template" "^7.10.4" + "@babel/types" "^7.10.4" + +"@babel/helper-function-name@^7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.12.11.tgz#1fd7738aee5dcf53c3ecff24f1da9c511ec47b42" + integrity sha512-AtQKjtYNolKNi6nNNVLQ27CP6D9oFR6bq/HPYSizlzbp7uC1M59XJe8L+0uXjbIaZaUJF99ruHqVGiKXU/7ybA== + dependencies: + "@babel/helper-get-function-arity" "^7.12.10" + "@babel/template" "^7.12.7" + "@babel/types" "^7.12.11" + +"@babel/helper-function-name@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz#89e2c474972f15d8e233b52ee8c480e2cfcd50c4" + integrity sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ== + dependencies: + "@babel/helper-get-function-arity" "^7.14.5" + "@babel/template" "^7.14.5" + "@babel/types" "^7.14.5" + +"@babel/helper-get-function-arity@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz#98c1cbea0e2332f33f9a4661b8ce1505b2c19ba2" + integrity sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A== + dependencies: + "@babel/types" "^7.10.4" + +"@babel/helper-get-function-arity@^7.12.10": + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.10.tgz#b158817a3165b5faa2047825dfa61970ddcc16cf" + integrity sha512-mm0n5BPjR06wh9mPQaDdXWDoll/j5UpCAPl1x8fS71GHm7HA6Ua2V4ylG1Ju8lvcTOietbPNNPaSilKj+pj+Ag== + dependencies: + "@babel/types" "^7.12.10" + +"@babel/helper-get-function-arity@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz#25fbfa579b0937eee1f3b805ece4ce398c431815" + integrity sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-hoist-variables@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.10.4.tgz#d49b001d1d5a68ca5e6604dda01a6297f7c9381e" + integrity sha512-wljroF5PgCk2juF69kanHVs6vrLwIPNp6DLD+Lrl3hoQ3PpPPikaDRNFA+0t81NOoMt2DL6WW/mdU8k4k6ZzuA== + dependencies: + "@babel/types" "^7.10.4" + +"@babel/helper-hoist-variables@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz#e0dd27c33a78e577d7c8884916a3e7ef1f7c7f8d" + integrity sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-member-expression-to-functions@^7.10.4", "@babel/helper-member-expression-to-functions@^7.10.5": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.11.0.tgz#ae69c83d84ee82f4b42f96e2a09410935a8f26df" + integrity sha512-JbFlKHFntRV5qKw3YC0CvQnDZ4XMwgzzBbld7Ly4Mj4cbFy3KywcR8NtNctRToMWJOVvLINJv525Gd6wwVEx/Q== + dependencies: + "@babel/types" "^7.11.0" + +"@babel/helper-member-expression-to-functions@^7.12.1", "@babel/helper-member-expression-to-functions@^7.12.7": + version "7.12.7" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.7.tgz#aa77bd0396ec8114e5e30787efa78599d874a855" + integrity sha512-DCsuPyeWxeHgh1Dus7APn7iza42i/qXqiFPWyBDdOFtvS581JQePsc1F/nD+fHrcswhLlRc2UpYS1NwERxZhHw== + dependencies: + "@babel/types" "^7.12.7" + +"@babel/helper-member-expression-to-functions@^7.14.5": + version "7.14.7" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.14.7.tgz#97e56244beb94211fe277bd818e3a329c66f7970" + integrity sha512-TMUt4xKxJn6ccjcOW7c4hlwyJArizskAhoSTOCkA0uZ+KghIaci0Qg9R043kUMWI9mtQfgny+NQ5QATnZ+paaA== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz#4c5c54be04bd31670a7382797d75b9fa2e5b5620" + integrity sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw== + dependencies: + "@babel/types" "^7.10.4" + +"@babel/helper-module-imports@^7.12.1", "@babel/helper-module-imports@^7.12.5": + version "7.12.5" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.12.5.tgz#1bfc0229f794988f76ed0a4d4e90860850b54dfb" + integrity sha512-SR713Ogqg6++uexFRORf/+nPXMmWIn80TALu0uaFb+iQIUoR7bOC7zBWyzBs5b3tBBJXuyD0cRu1F15GyzjOWA== + dependencies: + "@babel/types" "^7.12.5" + +"@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz#6d1a44df6a38c957aa7c312da076429f11b422f3" + integrity sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-module-transforms@^7.10.4", "@babel/helper-module-transforms@^7.10.5": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz#b16f250229e47211abdd84b34b64737c2ab2d359" + integrity sha512-02EVu8COMuTRO1TAzdMtpBPbe6aQ1w/8fePD2YgQmxZU4gpNWaL9gK3Jp7dxlkUlUCJOTaSeA+Hrm1BRQwqIhg== + dependencies: + "@babel/helper-module-imports" "^7.10.4" + "@babel/helper-replace-supers" "^7.10.4" + "@babel/helper-simple-access" "^7.10.4" + "@babel/helper-split-export-declaration" "^7.11.0" + "@babel/template" "^7.10.4" + "@babel/types" "^7.11.0" + lodash "^4.17.19" + +"@babel/helper-module-transforms@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.12.1.tgz#7954fec71f5b32c48e4b303b437c34453fd7247c" + integrity sha512-QQzehgFAZ2bbISiCpmVGfiGux8YVFXQ0abBic2Envhej22DVXV9nCFaS5hIQbkyo1AdGb+gNME2TSh3hYJVV/w== + dependencies: + "@babel/helper-module-imports" "^7.12.1" + "@babel/helper-replace-supers" "^7.12.1" + "@babel/helper-simple-access" "^7.12.1" + "@babel/helper-split-export-declaration" "^7.11.0" + "@babel/helper-validator-identifier" "^7.10.4" + "@babel/template" "^7.10.4" + "@babel/traverse" "^7.12.1" + "@babel/types" "^7.12.1" + lodash "^4.17.19" + +"@babel/helper-module-transforms@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.14.5.tgz#7de42f10d789b423eb902ebd24031ca77cb1e10e" + integrity sha512-iXpX4KW8LVODuAieD7MzhNjmM6dzYY5tfRqT+R9HDXWl0jPn/djKmA+G9s/2C2T9zggw5tK1QNqZ70USfedOwA== + dependencies: + "@babel/helper-module-imports" "^7.14.5" + "@babel/helper-replace-supers" "^7.14.5" + "@babel/helper-simple-access" "^7.14.5" + "@babel/helper-split-export-declaration" "^7.14.5" + "@babel/helper-validator-identifier" "^7.14.5" + "@babel/template" "^7.14.5" + "@babel/traverse" "^7.14.5" + "@babel/types" "^7.14.5" + +"@babel/helper-optimise-call-expression@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz#50dc96413d594f995a77905905b05893cd779673" + integrity sha512-n3UGKY4VXwXThEiKrgRAoVPBMqeoPgHVqiHZOanAJCG9nQUL2pLRQirUzl0ioKclHGpGqRgIOkgcIJaIWLpygg== + dependencies: + "@babel/types" "^7.10.4" + +"@babel/helper-optimise-call-expression@^7.12.10": + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.10.tgz#94ca4e306ee11a7dd6e9f42823e2ac6b49881e2d" + integrity sha512-4tpbU0SrSTjjt65UMWSrUOPZTsgvPgGG4S8QSTNHacKzpS51IVWGDj0yCwyeZND/i+LSN2g/O63jEXEWm49sYQ== + dependencies: + "@babel/types" "^7.12.10" + +"@babel/helper-optimise-call-expression@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.14.5.tgz#f27395a8619e0665b3f0364cddb41c25d71b499c" + integrity sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-plugin-utils@7.10.4", "@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" + integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== + +"@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz#5ac822ce97eec46741ab70a517971e443a70c5a9" + integrity sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ== + +"@babel/helper-regex@^7.10.4": + version "7.10.5" + resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.10.5.tgz#32dfbb79899073c415557053a19bd055aae50ae0" + integrity sha512-68kdUAzDrljqBrio7DYAEgCoJHxppJOERHOgOrDN7WjOzP0ZQ1LsSDRXcemzVZaLvjaJsJEESb6qt+znNuENDg== + dependencies: + lodash "^4.17.19" + +"@babel/helper-remap-async-to-generator@^7.10.4": + version "7.11.4" + resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.11.4.tgz#4474ea9f7438f18575e30b0cac784045b402a12d" + integrity sha512-tR5vJ/vBa9wFy3m5LLv2faapJLnDFxNWff2SAYkSE4rLUdbp7CdObYFgI7wK4T/Mj4UzpjPwzR8Pzmr5m7MHGA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.10.4" + "@babel/helper-wrap-function" "^7.10.4" + "@babel/template" "^7.10.4" + "@babel/types" "^7.10.4" + +"@babel/helper-remap-async-to-generator@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.12.1.tgz#8c4dbbf916314f6047dc05e6a2217074238347fd" + integrity sha512-9d0KQCRM8clMPcDwo8SevNs+/9a8yWVVmaE80FGJcEP8N1qToREmWEGnBn8BUlJhYRFz6fqxeRL1sl5Ogsed7A== + dependencies: + "@babel/helper-annotate-as-pure" "^7.10.4" + "@babel/helper-wrap-function" "^7.10.4" + "@babel/types" "^7.12.1" + +"@babel/helper-remap-async-to-generator@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.14.5.tgz#51439c913612958f54a987a4ffc9ee587a2045d6" + integrity sha512-rLQKdQU+HYlxBwQIj8dk4/0ENOUEhA/Z0l4hN8BexpvmSMN9oA9EagjnhnDpNsRdWCfjwa4mn/HyBXO9yhQP6A== + dependencies: + "@babel/helper-annotate-as-pure" "^7.14.5" + "@babel/helper-wrap-function" "^7.14.5" + "@babel/types" "^7.14.5" + +"@babel/helper-replace-supers@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.10.4.tgz#d585cd9388ea06e6031e4cd44b6713cbead9e6cf" + integrity sha512-sPxZfFXocEymYTdVK1UNmFPBN+Hv5mJkLPsYWwGBxZAxaWfFu+xqp7b6qWD0yjNuNL2VKc6L5M18tOXUP7NU0A== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.10.4" + "@babel/helper-optimise-call-expression" "^7.10.4" + "@babel/traverse" "^7.10.4" + "@babel/types" "^7.10.4" + +"@babel/helper-replace-supers@^7.12.1": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.12.11.tgz#ea511658fc66c7908f923106dd88e08d1997d60d" + integrity sha512-q+w1cqmhL7R0FNzth/PLLp2N+scXEK/L2AHbXUyydxp828F4FEa5WcVoqui9vFRiHDQErj9Zof8azP32uGVTRA== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.12.7" + "@babel/helper-optimise-call-expression" "^7.12.10" + "@babel/traverse" "^7.12.10" + "@babel/types" "^7.12.11" + +"@babel/helper-replace-supers@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.14.5.tgz#0ecc0b03c41cd567b4024ea016134c28414abb94" + integrity sha512-3i1Qe9/8x/hCHINujn+iuHy+mMRLoc77b2nI9TB0zjH1hvn9qGlXjWlggdwUcju36PkPCy/lpM7LLUdcTyH4Ow== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.14.5" + "@babel/helper-optimise-call-expression" "^7.14.5" + "@babel/traverse" "^7.14.5" + "@babel/types" "^7.14.5" + +"@babel/helper-simple-access@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz#0f5ccda2945277a2a7a2d3a821e15395edcf3461" + integrity sha512-0fMy72ej/VEvF8ULmX6yb5MtHG4uH4Dbd6I/aHDb/JVg0bbivwt9Wg+h3uMvX+QSFtwr5MeItvazbrc4jtRAXw== + dependencies: + "@babel/template" "^7.10.4" + "@babel/types" "^7.10.4" + +"@babel/helper-simple-access@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.12.1.tgz#32427e5aa61547d38eb1e6eaf5fd1426fdad9136" + integrity sha512-OxBp7pMrjVewSSC8fXDFrHrBcJATOOFssZwv16F3/6Xtc138GHybBfPbm9kfiqQHKhYQrlamWILwlDCeyMFEaA== + dependencies: + "@babel/types" "^7.12.1" + +"@babel/helper-simple-access@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.14.5.tgz#66ea85cf53ba0b4e588ba77fc813f53abcaa41c4" + integrity sha512-nfBN9xvmCt6nrMZjfhkl7i0oTV3yxR4/FztsbOASyTvVcoYd0TRHh7eMLdlEcCqobydC0LAF3LtC92Iwxo0wyw== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-skip-transparent-expression-wrappers@^7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.11.0.tgz#eec162f112c2f58d3af0af125e3bb57665146729" + integrity sha512-0XIdiQln4Elglgjbwo9wuJpL/K7AGCY26kmEt0+pRP0TAj4jjyNq1MjoRvikrTVqKcx4Gysxt4cXvVFXP/JO2Q== + dependencies: + "@babel/types" "^7.11.0" + +"@babel/helper-skip-transparent-expression-wrappers@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.12.1.tgz#462dc63a7e435ade8468385c63d2b84cce4b3cbf" + integrity sha512-Mf5AUuhG1/OCChOJ/HcADmvcHM42WJockombn8ATJG3OnyiSxBK/Mm5x78BQWvmtXZKHgbjdGL2kin/HOLlZGA== + dependencies: + "@babel/types" "^7.12.1" + +"@babel/helper-skip-transparent-expression-wrappers@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.14.5.tgz#96f486ac050ca9f44b009fbe5b7d394cab3a0ee4" + integrity sha512-dmqZB7mrb94PZSAOYtr+ZN5qt5owZIAgqtoTuqiFbHFtxgEcmQlRJVI+bO++fciBunXtB6MK7HrzrfcAzIz2NQ== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-split-export-declaration@^7.10.4", "@babel/helper-split-export-declaration@^7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz#f8a491244acf6a676158ac42072911ba83ad099f" + integrity sha512-74Vejvp6mHkGE+m+k5vHY93FX2cAtrw1zXrZXRlG4l410Nm9PxfEiVTn1PjDPV5SnmieiueY4AFg2xqhNFuuZg== + dependencies: + "@babel/types" "^7.11.0" + +"@babel/helper-split-export-declaration@^7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.11.tgz#1b4cc424458643c47d37022223da33d76ea4603a" + integrity sha512-LsIVN8j48gHgwzfocYUSkO/hjYAOJqlpJEc7tGXcIm4cubjVUf8LGW6eWRyxEu7gA25q02p0rQUWoCI33HNS5g== + dependencies: + "@babel/types" "^7.12.11" + +"@babel/helper-split-export-declaration@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz#22b23a54ef51c2b7605d851930c1976dd0bc693a" + integrity sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-validator-identifier@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2" + integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw== + +"@babel/helper-validator-identifier@^7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" + integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== + +"@babel/helper-validator-identifier@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz#d0f0e277c512e0c938277faa85a3968c9a44c0e8" + integrity sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg== + +"@babel/helper-validator-option@^7.12.1", "@babel/helper-validator-option@^7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.11.tgz#d66cb8b7a3e7fe4c6962b32020a131ecf0847f4f" + integrity sha512-TBFCyj939mFSdeX7U7DDj32WtzYY7fDcalgq8v3fBZMNOJQNn7nOYzMaUCiPxPYfCup69mtIpqlKgMZLvQ8Xhw== + +"@babel/helper-validator-option@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz#6e72a1fff18d5dfcb878e1e62f1a021c4b72d5a3" + integrity sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow== + +"@babel/helper-wrap-function@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.10.4.tgz#8a6f701eab0ff39f765b5a1cfef409990e624b87" + integrity sha512-6py45WvEF0MhiLrdxtRjKjufwLL1/ob2qDJgg5JgNdojBAZSAKnAjkyOCNug6n+OBl4VW76XjvgSFTdaMcW0Ug== + dependencies: + "@babel/helper-function-name" "^7.10.4" + "@babel/template" "^7.10.4" + "@babel/traverse" "^7.10.4" + "@babel/types" "^7.10.4" + +"@babel/helper-wrap-function@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.14.5.tgz#5919d115bf0fe328b8a5d63bcb610f51601f2bff" + integrity sha512-YEdjTCq+LNuNS1WfxsDCNpgXkJaIyqco6DAelTUjT4f2KIWC1nBcaCaSdHTBqQVLnTBexBcVcFhLSU1KnYuePQ== + dependencies: + "@babel/helper-function-name" "^7.14.5" + "@babel/template" "^7.14.5" + "@babel/traverse" "^7.14.5" + "@babel/types" "^7.14.5" + +"@babel/helpers@^7.12.5": + version "7.12.5" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.12.5.tgz#1a1ba4a768d9b58310eda516c449913fe647116e" + integrity sha512-lgKGMQlKqA8meJqKsW6rUnc4MdUk35Ln0ATDqdM1a/UpARODdI4j5Y5lVfUScnSNkJcdCRAaWkspykNoFg9sJA== + dependencies: + "@babel/template" "^7.10.4" + "@babel/traverse" "^7.12.5" + "@babel/types" "^7.12.5" + +"@babel/helpers@^7.14.6": + version "7.14.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.14.6.tgz#5b58306b95f1b47e2a0199434fa8658fa6c21635" + integrity sha512-yesp1ENQBiLI+iYHSJdoZKUtRpfTlL1grDIX9NRlAVppljLw/4tTyYupIB7uIYmC3stW/imAv8EqaKaS/ibmeA== + dependencies: + "@babel/template" "^7.14.5" + "@babel/traverse" "^7.14.5" + "@babel/types" "^7.14.5" + +"@babel/highlight@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.10.4.tgz#7d1bdfd65753538fabe6c38596cdb76d9ac60143" + integrity sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA== + dependencies: + "@babel/helper-validator-identifier" "^7.10.4" + chalk "^2.0.0" + js-tokens "^4.0.0" + +"@babel/highlight@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.14.5.tgz#6861a52f03966405001f6aa534a01a24d99e8cd9" + integrity sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg== + dependencies: + "@babel/helper-validator-identifier" "^7.14.5" + chalk "^2.0.0" + js-tokens "^4.0.0" + +"@babel/parser@^7.0.0", "@babel/parser@^7.10.4", "@babel/parser@^7.11.5": + version "7.11.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037" + integrity sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q== + +"@babel/parser@^7.12.11", "@babel/parser@^7.12.7", "@babel/parser@^7.9.4": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.11.tgz#9ce3595bcd74bc5c466905e86c535b8b25011e79" + integrity sha512-N3UxG+uuF4CMYoNj8AhnbAcJF0PiuJ9KHuy1lQmkYsxTer/MAH9UBNHsBoAX/4s6NvlDD047No8mYVGGzLL4hg== + +"@babel/parser@^7.14.5", "@babel/parser@^7.14.6", "@babel/parser@^7.14.7": + version "7.14.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.14.7.tgz#6099720c8839ca865a2637e6c85852ead0bdb595" + integrity sha512-X67Z5y+VBJuHB/RjwECp8kSl5uYi0BvRbNeWqkaJCVh+LiTPl19WBUfG627psSgp9rSf6ojuXghQM3ha6qHHdA== + +"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.14.5.tgz#4b467302e1548ed3b1be43beae2cc9cf45e0bb7e" + integrity sha512-ZoJS2XCKPBfTmL122iP6NM9dOg+d4lc9fFk3zxc8iDjvt8Pk4+TlsHSKhIPf6X+L5ORCdBzqMZDjL/WHj7WknQ== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5" + "@babel/plugin-proposal-optional-chaining" "^7.14.5" + +"@babel/plugin-proposal-async-generator-functions@^7.10.4": + version "7.10.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.10.5.tgz#3491cabf2f7c179ab820606cec27fed15e0e8558" + integrity sha512-cNMCVezQbrRGvXJwm9fu/1sJj9bHdGAgKodZdLqOQIpfoH3raqmRPBM17+lh7CzhiKRRBrGtZL9WcjxSoGYUSg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-remap-async-to-generator" "^7.10.4" + "@babel/plugin-syntax-async-generators" "^7.8.0" + +"@babel/plugin-proposal-async-generator-functions@^7.12.1": + version "7.12.12" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.12.tgz#04b8f24fd4532008ab4e79f788468fd5a8476566" + integrity sha512-nrz9y0a4xmUrRq51bYkWJIO5SBZyG2ys2qinHsN0zHDHVsUaModrkpyWWWXfGqYQmOL3x9sQIcTNN/pBGpo09A== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-remap-async-to-generator" "^7.12.1" + "@babel/plugin-syntax-async-generators" "^7.8.0" + +"@babel/plugin-proposal-async-generator-functions@^7.14.7": + version "7.14.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.14.7.tgz#784a48c3d8ed073f65adcf30b57bcbf6c8119ace" + integrity sha512-RK8Wj7lXLY3bqei69/cc25gwS5puEc3dknoFPFbqfy3XxYQBQFvu4ioWpafMBAB+L9NyptQK4nMOa5Xz16og8Q== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-remap-async-to-generator" "^7.14.5" + "@babel/plugin-syntax-async-generators" "^7.8.4" + +"@babel/plugin-proposal-class-properties@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.10.4.tgz#a33bf632da390a59c7a8c570045d1115cd778807" + integrity sha512-vhwkEROxzcHGNu2mzUC0OFFNXdZ4M23ib8aRRcJSsW8BZK9pQMD7QB7csl97NBbgGZO7ZyHUyKDnxzOaP4IrCg== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-proposal-class-properties@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.12.1.tgz#a082ff541f2a29a4821065b8add9346c0c16e5de" + integrity sha512-cKp3dlQsFsEs5CWKnN7BnSHOd0EOW8EKpEjkoz1pO2E5KzIDNV9Ros1b0CnmbVgAGXJubOYVBOGCT1OmJwOI7w== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-proposal-class-properties@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.14.5.tgz#40d1ee140c5b1e31a350f4f5eed945096559b42e" + integrity sha512-q/PLpv5Ko4dVc1LYMpCY7RVAAO4uk55qPwrIuJ5QJ8c6cVuAmhu7I/49JOppXL6gXf7ZHzpRVEUZdYoPLM04Gg== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-proposal-class-static-block@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.14.5.tgz#158e9e10d449c3849ef3ecde94a03d9f1841b681" + integrity sha512-KBAH5ksEnYHCegqseI5N9skTdxgJdmDoAOc0uXa+4QMYKeZD0w5IARh4FMlTNtaHhbB8v+KzMdTgxMMzsIy6Yg== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/plugin-syntax-class-static-block" "^7.14.5" + +"@babel/plugin-proposal-dynamic-import@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.10.4.tgz#ba57a26cb98b37741e9d5bca1b8b0ddf8291f17e" + integrity sha512-up6oID1LeidOOASNXgv/CFbgBqTuKJ0cJjz6An5tWD+NVBNlp3VNSBxv2ZdU7SYl3NxJC7agAQDApZusV6uFwQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-dynamic-import" "^7.8.0" + +"@babel/plugin-proposal-dynamic-import@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.12.1.tgz#43eb5c2a3487ecd98c5c8ea8b5fdb69a2749b2dc" + integrity sha512-a4rhUSZFuq5W8/OO8H7BL5zspjnc1FLd9hlOxIK/f7qG4a0qsqk8uvF/ywgBA8/OmjsapjpvaEOYItfGG1qIvQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-dynamic-import" "^7.8.0" + +"@babel/plugin-proposal-dynamic-import@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.14.5.tgz#0c6617df461c0c1f8fff3b47cd59772360101d2c" + integrity sha512-ExjiNYc3HDN5PXJx+bwC50GIx/KKanX2HiggnIUAYedbARdImiCU4RhhHfdf0Kd7JNXGpsBBBCOm+bBVy3Gb0g== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/plugin-syntax-dynamic-import" "^7.8.3" + +"@babel/plugin-proposal-export-namespace-from@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.10.4.tgz#570d883b91031637b3e2958eea3c438e62c05f54" + integrity sha512-aNdf0LY6/3WXkhh0Fdb6Zk9j1NMD8ovj3F6r0+3j837Pn1S1PdNtcwJ5EG9WkVPNHPxyJDaxMaAOVq4eki0qbg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-export-namespace-from" "^7.8.3" + +"@babel/plugin-proposal-export-namespace-from@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.12.1.tgz#8b9b8f376b2d88f5dd774e4d24a5cc2e3679b6d4" + integrity sha512-6CThGf0irEkzujYS5LQcjBx8j/4aQGiVv7J9+2f7pGfxqyKh3WnmVJYW3hdrQjyksErMGBPQrCnHfOtna+WLbw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-export-namespace-from" "^7.8.3" + +"@babel/plugin-proposal-export-namespace-from@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.14.5.tgz#dbad244310ce6ccd083072167d8cea83a52faf76" + integrity sha512-g5POA32bXPMmSBu5Dx/iZGLGnKmKPc5AiY7qfZgurzrCYgIztDlHFbznSNCoQuv57YQLnQfaDi7dxCtLDIdXdA== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/plugin-syntax-export-namespace-from" "^7.8.3" + +"@babel/plugin-proposal-json-strings@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.10.4.tgz#593e59c63528160233bd321b1aebe0820c2341db" + integrity sha512-fCL7QF0Jo83uy1K0P2YXrfX11tj3lkpN7l4dMv9Y9VkowkhkQDwFHFd8IiwyK5MZjE8UpbgokkgtcReH88Abaw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-json-strings" "^7.8.0" + +"@babel/plugin-proposal-json-strings@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.12.1.tgz#d45423b517714eedd5621a9dfdc03fa9f4eb241c" + integrity sha512-GoLDUi6U9ZLzlSda2Df++VSqDJg3CG+dR0+iWsv6XRw1rEq+zwt4DirM9yrxW6XWaTpmai1cWJLMfM8qQJf+yw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-json-strings" "^7.8.0" + +"@babel/plugin-proposal-json-strings@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.14.5.tgz#38de60db362e83a3d8c944ac858ddf9f0c2239eb" + integrity sha512-NSq2fczJYKVRIsUJyNxrVUMhB27zb7N7pOFGQOhBKJrChbGcgEAqyZrmZswkPk18VMurEeJAaICbfm57vUeTbQ== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/plugin-syntax-json-strings" "^7.8.3" + +"@babel/plugin-proposal-logical-assignment-operators@^7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.11.0.tgz#9f80e482c03083c87125dee10026b58527ea20c8" + integrity sha512-/f8p4z+Auz0Uaf+i8Ekf1iM7wUNLcViFUGiPxKeXvxTSl63B875YPiVdUDdem7hREcI0E0kSpEhS8tF5RphK7Q== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + +"@babel/plugin-proposal-logical-assignment-operators@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.12.1.tgz#f2c490d36e1b3c9659241034a5d2cd50263a2751" + integrity sha512-k8ZmVv0JU+4gcUGeCDZOGd0lCIamU/sMtIiX3UWnUc5yzgq6YUGyEolNYD+MLYKfSzgECPcqetVcJP9Afe/aCA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + +"@babel/plugin-proposal-logical-assignment-operators@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.14.5.tgz#6e6229c2a99b02ab2915f82571e0cc646a40c738" + integrity sha512-YGn2AvZAo9TwyhlLvCCWxD90Xq8xJ4aSgaX3G5D/8DW94L8aaT+dS5cSP+Z06+rCJERGSr9GxMBZ601xoc2taw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + +"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.1", "@babel/plugin-proposal-nullish-coalescing-operator@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.12.1.tgz#3ed4fff31c015e7f3f1467f190dbe545cd7b046c" + integrity sha512-nZY0ESiaQDI1y96+jk6VxMOaL4LPo/QDHBqL+SF3/vl6dHkTwHlOI8L4ZwuRBHgakRBw5zsVylel7QPbbGuYgg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" + +"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.10.4.tgz#02a7e961fc32e6d5b2db0649e01bf80ddee7e04a" + integrity sha512-wq5n1M3ZUlHl9sqT2ok1T2/MTt6AXE0e1Lz4WzWBr95LsAZ5qDXe4KnFuauYyEyLiohvXFMdbsOTMyLZs91Zlw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" + +"@babel/plugin-proposal-nullish-coalescing-operator@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.14.5.tgz#ee38589ce00e2cc59b299ec3ea406fcd3a0fdaf6" + integrity sha512-gun/SOnMqjSb98Nkaq2rTKMwervfdAoz6NphdY0vTfuzMfryj+tDGb2n6UkDKwez+Y8PZDhE3D143v6Gepp4Hg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + +"@babel/plugin-proposal-numeric-separator@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.10.4.tgz#ce1590ff0a65ad12970a609d78855e9a4c1aef06" + integrity sha512-73/G7QoRoeNkLZFxsoCCvlg4ezE4eM+57PnOqgaPOozd5myfj7p0muD1mRVJvbUWbOzD+q3No2bWbaKy+DJ8DA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + +"@babel/plugin-proposal-numeric-separator@^7.12.7": + version "7.12.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.12.7.tgz#8bf253de8139099fea193b297d23a9d406ef056b" + integrity sha512-8c+uy0qmnRTeukiGsjLGy6uVs/TFjJchGXUeBqlG4VWYOdJWkhhVPdQ3uHwbmalfJwv2JsV0qffXP4asRfL2SQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + +"@babel/plugin-proposal-numeric-separator@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.14.5.tgz#83631bf33d9a51df184c2102a069ac0c58c05f18" + integrity sha512-yiclALKe0vyZRZE0pS6RXgjUOt87GWv6FYa5zqj15PvhOGFO69R5DusPlgK/1K5dVnCtegTiWu9UaBSrLLJJBg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + +"@babel/plugin-proposal-object-rest-spread@7.12.1", "@babel/plugin-proposal-object-rest-spread@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz#def9bd03cea0f9b72283dac0ec22d289c7691069" + integrity sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.0" + "@babel/plugin-transform-parameters" "^7.12.1" + +"@babel/plugin-proposal-object-rest-spread@^7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.11.0.tgz#bd81f95a1f746760ea43b6c2d3d62b11790ad0af" + integrity sha512-wzch41N4yztwoRw0ak+37wxwJM2oiIiy6huGCoqkvSTA9acYWcPfn9Y4aJqmFFJ70KTJUu29f3DQ43uJ9HXzEA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.0" + "@babel/plugin-transform-parameters" "^7.10.4" + +"@babel/plugin-proposal-object-rest-spread@^7.14.7": + version "7.14.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.14.7.tgz#5920a2b3df7f7901df0205974c0641b13fd9d363" + integrity sha512-082hsZz+sVabfmDWo1Oct1u1AgbKbUAyVgmX4otIc7bdsRgHBXwTwb3DpDmD4Eyyx6DNiuz5UAATT655k+kL5g== + dependencies: + "@babel/compat-data" "^7.14.7" + "@babel/helper-compilation-targets" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-transform-parameters" "^7.14.5" + +"@babel/plugin-proposal-optional-catch-binding@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.10.4.tgz#31c938309d24a78a49d68fdabffaa863758554dd" + integrity sha512-LflT6nPh+GK2MnFiKDyLiqSqVHkQnVf7hdoAvyTnnKj9xB3docGRsdPuxp6qqqW19ifK3xgc9U5/FwrSaCNX5g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" + +"@babel/plugin-proposal-optional-catch-binding@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.12.1.tgz#ccc2421af64d3aae50b558a71cede929a5ab2942" + integrity sha512-hFvIjgprh9mMw5v42sJWLI1lzU5L2sznP805zeT6rySVRA0Y18StRhDqhSxlap0oVgItRsB6WSROp4YnJTJz0g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" + +"@babel/plugin-proposal-optional-catch-binding@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.14.5.tgz#939dd6eddeff3a67fdf7b3f044b5347262598c3c" + integrity sha512-3Oyiixm0ur7bzO5ybNcZFlmVsygSIQgdOa7cTfOYCMY+wEPAYhZAJxi3mixKFCTCKUhQXuCTtQ1MzrpL3WT8ZQ== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + +"@babel/plugin-proposal-optional-chaining@^7.10.3", "@babel/plugin-proposal-optional-chaining@^7.12.7": + version "7.12.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.12.7.tgz#e02f0ea1b5dc59d401ec16fb824679f683d3303c" + integrity sha512-4ovylXZ0PWmwoOvhU2vhnzVNnm88/Sm9nx7V8BPgMvAzn5zDou3/Awy0EjglyubVHasJj+XCEkr/r1X3P5elCA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" + "@babel/plugin-syntax-optional-chaining" "^7.8.0" + +"@babel/plugin-proposal-optional-chaining@^7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.11.0.tgz#de5866d0646f6afdaab8a566382fe3a221755076" + integrity sha512-v9fZIu3Y8562RRwhm1BbMRxtqZNFmFA2EG+pT2diuU8PT3H6T/KXoZ54KgYisfOFZHV6PfvAiBIZ9Rcz+/JCxA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-skip-transparent-expression-wrappers" "^7.11.0" + "@babel/plugin-syntax-optional-chaining" "^7.8.0" + +"@babel/plugin-proposal-optional-chaining@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.14.5.tgz#fa83651e60a360e3f13797eef00b8d519695b603" + integrity sha512-ycz+VOzo2UbWNI1rQXxIuMOzrDdHGrI23fRiz/Si2R4kv2XZQ1BK8ccdHwehMKBlcH/joGW/tzrUmo67gbJHlQ== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + +"@babel/plugin-proposal-private-methods@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.10.4.tgz#b160d972b8fdba5c7d111a145fc8c421fc2a6909" + integrity sha512-wh5GJleuI8k3emgTg5KkJK6kHNsGEr0uBTDBuQUBJwckk9xs1ez79ioheEVVxMLyPscB0LfkbVHslQqIzWV6Bw== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-proposal-private-methods@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.12.1.tgz#86814f6e7a21374c980c10d38b4493e703f4a389" + integrity sha512-mwZ1phvH7/NHK6Kf8LP7MYDogGV+DKB1mryFOEwx5EBNQrosvIczzZFTUmWaeujd5xT6G1ELYWUz3CutMhjE1w== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-proposal-private-methods@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.14.5.tgz#37446495996b2945f30f5be5b60d5e2aa4f5792d" + integrity sha512-838DkdUA1u+QTCplatfq4B7+1lnDa/+QMI89x5WZHBcnNv+47N8QEj2k9I2MUU9xIv8XJ4XvPCviM/Dj7Uwt9g== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-proposal-private-property-in-object@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.14.5.tgz#9f65a4d0493a940b4c01f8aa9d3f1894a587f636" + integrity sha512-62EyfyA3WA0mZiF2e2IV9mc9Ghwxcg8YTu8BS4Wss4Y3PY725OmS9M0qLORbJwLqFtGh+jiE4wAmocK2CTUK2Q== + dependencies: + "@babel/helper-annotate-as-pure" "^7.14.5" + "@babel/helper-create-class-features-plugin" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + +"@babel/plugin-proposal-unicode-property-regex@^7.10.4", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.10.4.tgz#4483cda53041ce3413b7fe2f00022665ddfaa75d" + integrity sha512-H+3fOgPnEXFL9zGYtKQe4IDOPKYlZdF1kqFDQRRb8PK4B8af1vAGK04tF5iQAAsui+mHNBQSAtd2/ndEDe9wuA== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-proposal-unicode-property-regex@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.12.1.tgz#2a183958d417765b9eae334f47758e5d6a82e072" + integrity sha512-MYq+l+PvHuw/rKUz1at/vb6nCnQ2gmJBNaM62z0OgH7B2W1D9pvkpYtlti9bGtizNIU1K3zm4bZF9F91efVY0w== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-proposal-unicode-property-regex@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.14.5.tgz#0f95ee0e757a5d647f378daa0eca7e93faa8bbe8" + integrity sha512-6axIeOU5LnY471KenAB9vI8I5j7NQ2d652hIYwVyRfgaZT5UpiqFKCuVXCDMSrU+3VFafnu2c5m3lrWIlr6A5Q== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-async-generators@^7.8.0", "@babel/plugin-syntax-async-generators@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" + integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-class-properties@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.10.4.tgz#6644e6a0baa55a61f9e3231f6c9eeb6ee46c124c" + integrity sha512-GCSBF7iUle6rNugfURwNmCGG3Z/2+opxAMLs1nND4bhEG5PuxTIggDBoeYYSujAlLtsupzOHYJQgPS3pivwXIA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-class-properties@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.1.tgz#bcb297c5366e79bebadef509549cd93b04f19978" + integrity sha512-U40A76x5gTwmESz+qiqssqmeEsKvcSyvtgktrm0uzcARAmM9I1jR221f6Oq+GmHrcD+LvZDag1UTOTe2fL3TeA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-class-properties@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" + integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-class-static-block@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz#195df89b146b4b78b3bf897fd7a257c84659d406" + integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-dynamic-import@^7.8.0", "@babel/plugin-syntax-dynamic-import@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3" + integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-export-namespace-from@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz#028964a9ba80dbc094c915c487ad7c4e7a66465a" + integrity sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-syntax-json-strings@^7.8.0", "@babel/plugin-syntax-json-strings@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" + integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-jsx@7.12.1", "@babel/plugin-syntax-jsx@^7.10.4": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz#9d9d357cc818aa7ae7935917c1257f67677a0926" + integrity sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" + integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0", "@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-numeric-separator@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" + integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-object-rest-spread@7.8.3", "@babel/plugin-syntax-object-rest-spread@^7.8.0", "@babel/plugin-syntax-object-rest-spread@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" + integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.8.0", "@babel/plugin-syntax-optional-catch-binding@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" + integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.0", "@babel/plugin-syntax-optional-chaining@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-private-property-in-object@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad" + integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-top-level-await@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.10.4.tgz#4bbeb8917b54fcf768364e0a81f560e33a3ef57d" + integrity sha512-ni1brg4lXEmWyafKr0ccFWkJG0CeMt4WV1oyeBW6EFObF4oOHclbkj5cARxAPQyAQ2UTuplJyK4nfkXIMMFvsQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-top-level-await@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.12.1.tgz#dd6c0b357ac1bb142d98537450a319625d13d2a0" + integrity sha512-i7ooMZFS+a/Om0crxZodrTzNEPJHZrlMVGMTEpFAj6rYY/bKCddB0Dk/YxfPuYXOopuhKk/e1jV6h+WUU9XN3A== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-top-level-await@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" + integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-typescript@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.12.1.tgz#460ba9d77077653803c3dd2e673f76d66b4029e5" + integrity sha512-UZNEcCY+4Dp9yYRCAHrHDU+9ZXLYaY9MgBXSRLkB9WjYFRR6quJBumfVrEkUxrePPBwFcpWfNKXqVRQQtm7mMA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-arrow-functions@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.10.4.tgz#e22960d77e697c74f41c501d44d73dbf8a6a64cd" + integrity sha512-9J/oD1jV0ZCBcgnoFWFq1vJd4msoKb/TCpGNFyyLt0zABdcvgK3aYikZ8HjzB14c26bc7E3Q1yugpwGy2aTPNA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-arrow-functions@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.12.1.tgz#8083ffc86ac8e777fbe24b5967c4b2521f3cb2b3" + integrity sha512-5QB50qyN44fzzz4/qxDPQMBCTHgxg3n0xRBLJUmBlLoU/sFvxVWGZF/ZUfMVDQuJUKXaBhbupxIzIfZ6Fwk/0A== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-arrow-functions@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.14.5.tgz#f7187d9588a768dd080bf4c9ffe117ea62f7862a" + integrity sha512-KOnO0l4+tD5IfOdi4x8C1XmEIRWUjNRV8wc6K2vz/3e8yAOoZZvsRXRRIF/yo/MAOFb4QjtAw9xSxMXbSMRy8A== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-async-to-generator@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.10.4.tgz#41a5017e49eb6f3cda9392a51eef29405b245a37" + integrity sha512-F6nREOan7J5UXTLsDsZG3DXmZSVofr2tGNwfdrVwkDWHfQckbQXnXSPfD7iO+c/2HGqycwyLST3DnZ16n+cBJQ== + dependencies: + "@babel/helper-module-imports" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-remap-async-to-generator" "^7.10.4" + +"@babel/plugin-transform-async-to-generator@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.12.1.tgz#3849a49cc2a22e9743cbd6b52926d30337229af1" + integrity sha512-SDtqoEcarK1DFlRJ1hHRY5HvJUj5kX4qmtpMAm2QnhOlyuMC4TMdCRgW6WXpv93rZeYNeLP22y8Aq2dbcDRM1A== + dependencies: + "@babel/helper-module-imports" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-remap-async-to-generator" "^7.12.1" + +"@babel/plugin-transform-async-to-generator@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.14.5.tgz#72c789084d8f2094acb945633943ef8443d39e67" + integrity sha512-szkbzQ0mNk0rpu76fzDdqSyPu0MuvpXgC+6rz5rpMb5OIRxdmHfQxrktL8CYolL2d8luMCZTR0DpIMIdL27IjA== + dependencies: + "@babel/helper-module-imports" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-remap-async-to-generator" "^7.14.5" + +"@babel/plugin-transform-block-scoped-functions@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.10.4.tgz#1afa595744f75e43a91af73b0d998ecfe4ebc2e8" + integrity sha512-WzXDarQXYYfjaV1szJvN3AD7rZgZzC1JtjJZ8dMHUyiK8mxPRahynp14zzNjU3VkPqPsO38CzxiWO1c9ARZ8JA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-block-scoped-functions@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.12.1.tgz#f2a1a365bde2b7112e0a6ded9067fdd7c07905d9" + integrity sha512-5OpxfuYnSgPalRpo8EWGPzIYf0lHBWORCkj5M0oLBwHdlux9Ri36QqGW3/LR13RSVOAoUUMzoPI/jpE4ABcHoA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-block-scoped-functions@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.14.5.tgz#e48641d999d4bc157a67ef336aeb54bc44fd3ad4" + integrity sha512-dtqWqdWZ5NqBX3KzsVCWfQI3A53Ft5pWFCT2eCVUftWZgjc5DpDponbIF1+c+7cSGk2wN0YK7HGL/ezfRbpKBQ== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-block-scoping@^7.10.4": + version "7.11.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.11.1.tgz#5b7efe98852bef8d652c0b28144cd93a9e4b5215" + integrity sha512-00dYeDE0EVEHuuM+26+0w/SCL0BH2Qy7LwHuI4Hi4MH5gkC8/AqMN5uWFJIsoXZrAphiMm1iXzBw6L2T+eA0ew== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-block-scoping@^7.12.11": + version "7.12.12" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.12.tgz#d93a567a152c22aea3b1929bb118d1d0a175cdca" + integrity sha512-VOEPQ/ExOVqbukuP7BYJtI5ZxxsmegTwzZ04j1aF0dkSypGo9XpDHuOrABsJu+ie+penpSJheDJ11x1BEZNiyQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-block-scoping@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.14.5.tgz#8cc63e61e50f42e078e6f09be775a75f23ef9939" + integrity sha512-LBYm4ZocNgoCqyxMLoOnwpsmQ18HWTQvql64t3GvMUzLQrNoV1BDG0lNftC8QKYERkZgCCT/7J5xWGObGAyHDw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-classes@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.10.4.tgz#405136af2b3e218bc4a1926228bc917ab1a0adc7" + integrity sha512-2oZ9qLjt161dn1ZE0Ms66xBncQH4In8Sqw1YWgBUZuGVJJS5c0OFZXL6dP2MRHrkU/eKhWg8CzFJhRQl50rQxA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.10.4" + "@babel/helper-define-map" "^7.10.4" + "@babel/helper-function-name" "^7.10.4" + "@babel/helper-optimise-call-expression" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-replace-supers" "^7.10.4" + "@babel/helper-split-export-declaration" "^7.10.4" + globals "^11.1.0" + +"@babel/plugin-transform-classes@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.12.1.tgz#65e650fcaddd3d88ddce67c0f834a3d436a32db6" + integrity sha512-/74xkA7bVdzQTBeSUhLLJgYIcxw/dpEpCdRDiHgPJ3Mv6uC11UhjpOhl72CgqbBCmt1qtssCyB2xnJm1+PFjog== + dependencies: + "@babel/helper-annotate-as-pure" "^7.10.4" + "@babel/helper-define-map" "^7.10.4" + "@babel/helper-function-name" "^7.10.4" + "@babel/helper-optimise-call-expression" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-replace-supers" "^7.12.1" + "@babel/helper-split-export-declaration" "^7.10.4" + globals "^11.1.0" + +"@babel/plugin-transform-classes@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.14.5.tgz#0e98e82097b38550b03b483f9b51a78de0acb2cf" + integrity sha512-J4VxKAMykM06K/64z9rwiL6xnBHgB1+FVspqvlgCdwD1KUbQNfszeKVVOMh59w3sztHYIZDgnhOC4WbdEfHFDA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.14.5" + "@babel/helper-function-name" "^7.14.5" + "@babel/helper-optimise-call-expression" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-replace-supers" "^7.14.5" + "@babel/helper-split-export-declaration" "^7.14.5" + globals "^11.1.0" + +"@babel/plugin-transform-computed-properties@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.10.4.tgz#9ded83a816e82ded28d52d4b4ecbdd810cdfc0eb" + integrity sha512-JFwVDXcP/hM/TbyzGq3l/XWGut7p46Z3QvqFMXTfk6/09m7xZHJUN9xHfsv7vqqD4YnfI5ueYdSJtXqqBLyjBw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-computed-properties@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.12.1.tgz#d68cf6c9b7f838a8a4144badbe97541ea0904852" + integrity sha512-vVUOYpPWB7BkgUWPo4C44mUQHpTZXakEqFjbv8rQMg7TC6S6ZhGZ3otQcRH6u7+adSlE5i0sp63eMC/XGffrzg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-computed-properties@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.14.5.tgz#1b9d78987420d11223d41195461cc43b974b204f" + integrity sha512-pWM+E4283UxaVzLb8UBXv4EIxMovU4zxT1OPnpHJcmnvyY9QbPPTKZfEj31EUvG3/EQRbYAGaYEUZ4yWOBC2xg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-destructuring@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.10.4.tgz#70ddd2b3d1bea83d01509e9bb25ddb3a74fc85e5" + integrity sha512-+WmfvyfsyF603iPa6825mq6Qrb7uLjTOsa3XOFzlYcYDHSS4QmpOWOL0NNBY5qMbvrcf3tq0Cw+v4lxswOBpgA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-destructuring@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.12.1.tgz#b9a570fe0d0a8d460116413cb4f97e8e08b2f847" + integrity sha512-fRMYFKuzi/rSiYb2uRLiUENJOKq4Gnl+6qOv5f8z0TZXg3llUwUhsNNwrwaT/6dUhJTzNpBr+CUvEWBtfNY1cw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-destructuring@^7.14.7": + version "7.14.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.14.7.tgz#0ad58ed37e23e22084d109f185260835e5557576" + integrity sha512-0mDE99nK+kVh3xlc5vKwB6wnP9ecuSj+zQCa/n0voENtP/zymdT4HH6QEb65wjjcbqr1Jb/7z9Qp7TF5FtwYGw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-dotall-regex@^7.10.4", "@babel/plugin-transform-dotall-regex@^7.4.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.10.4.tgz#469c2062105c1eb6a040eaf4fac4b488078395ee" + integrity sha512-ZEAVvUTCMlMFAbASYSVQoxIbHm2OkG2MseW6bV2JjIygOjdVv8tuxrCTzj1+Rynh7ODb8GivUy7dzEXzEhuPaA== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-dotall-regex@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.12.1.tgz#a1d16c14862817b6409c0a678d6f9373ca9cd975" + integrity sha512-B2pXeRKoLszfEW7J4Hg9LoFaWEbr/kzo3teWHmtFCszjRNa/b40f9mfeqZsIDLLt/FjwQ6pz/Gdlwy85xNckBA== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-dotall-regex@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.14.5.tgz#2f6bf76e46bdf8043b4e7e16cf24532629ba0c7a" + integrity sha512-loGlnBdj02MDsFaHhAIJzh7euK89lBrGIdM9EAtHFo6xKygCUGuuWe07o1oZVk287amtW1n0808sQM99aZt3gw== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-duplicate-keys@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.10.4.tgz#697e50c9fee14380fe843d1f306b295617431e47" + integrity sha512-GL0/fJnmgMclHiBTTWXNlYjYsA7rDrtsazHG6mglaGSTh0KsrW04qml+Bbz9FL0LcJIRwBWL5ZqlNHKTkU3xAA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-duplicate-keys@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.12.1.tgz#745661baba295ac06e686822797a69fbaa2ca228" + integrity sha512-iRght0T0HztAb/CazveUpUQrZY+aGKKaWXMJ4uf9YJtqxSUe09j3wteztCUDRHs+SRAL7yMuFqUsLoAKKzgXjw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-duplicate-keys@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.14.5.tgz#365a4844881bdf1501e3a9f0270e7f0f91177954" + integrity sha512-iJjbI53huKbPDAsJ8EmVmvCKeeq21bAze4fu9GBQtSLqfvzj2oRuHVx4ZkDwEhg1htQ+5OBZh/Ab0XDf5iBZ7A== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-exponentiation-operator@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.10.4.tgz#5ae338c57f8cf4001bdb35607ae66b92d665af2e" + integrity sha512-S5HgLVgkBcRdyQAHbKj+7KyuWx8C6t5oETmUuwz1pt3WTWJhsUV0WIIXuVvfXMxl/QQyHKlSCNNtaIamG8fysw== + dependencies: + "@babel/helper-builder-binary-assignment-operator-visitor" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-exponentiation-operator@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.12.1.tgz#b0f2ed356ba1be1428ecaf128ff8a24f02830ae0" + integrity sha512-7tqwy2bv48q+c1EHbXK0Zx3KXd2RVQp6OC7PbwFNt/dPTAV3Lu5sWtWuAj8owr5wqtWnqHfl2/mJlUmqkChKug== + dependencies: + "@babel/helper-builder-binary-assignment-operator-visitor" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-exponentiation-operator@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.14.5.tgz#5154b8dd6a3dfe6d90923d61724bd3deeb90b493" + integrity sha512-jFazJhMBc9D27o9jDnIE5ZErI0R0m7PbKXVq77FFvqFbzvTMuv8jaAwLZ5PviOLSFttqKIW0/wxNSDbjLk0tYA== + dependencies: + "@babel/helper-builder-binary-assignment-operator-visitor" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-for-of@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.10.4.tgz#c08892e8819d3a5db29031b115af511dbbfebae9" + integrity sha512-ItdQfAzu9AlEqmusA/65TqJ79eRcgGmpPPFvBnGILXZH975G0LNjP1yjHvGgfuCxqrPPueXOPe+FsvxmxKiHHQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-for-of@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.12.1.tgz#07640f28867ed16f9511c99c888291f560921cfa" + integrity sha512-Zaeq10naAsuHo7heQvyV0ptj4dlZJwZgNAtBYBnu5nNKJoW62m0zKcIEyVECrUKErkUkg6ajMy4ZfnVZciSBhg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-for-of@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.14.5.tgz#dae384613de8f77c196a8869cbf602a44f7fc0eb" + integrity sha512-CfmqxSUZzBl0rSjpoQSFoR9UEj3HzbGuGNL21/iFTmjb5gFggJp3ph0xR1YBhexmLoKRHzgxuFvty2xdSt6gTA== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-function-name@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.10.4.tgz#6a467880e0fc9638514ba369111811ddbe2644b7" + integrity sha512-OcDCq2y5+E0dVD5MagT5X+yTRbcvFjDI2ZVAottGH6tzqjx/LKpgkUepu3hp/u4tZBzxxpNGwLsAvGBvQ2mJzg== + dependencies: + "@babel/helper-function-name" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-function-name@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.12.1.tgz#2ec76258c70fe08c6d7da154003a480620eba667" + integrity sha512-JF3UgJUILoFrFMEnOJLJkRHSk6LUSXLmEFsA23aR2O5CSLUxbeUX1IZ1YQ7Sn0aXb601Ncwjx73a+FVqgcljVw== + dependencies: + "@babel/helper-function-name" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-function-name@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.14.5.tgz#e81c65ecb900746d7f31802f6bed1f52d915d6f2" + integrity sha512-vbO6kv0fIzZ1GpmGQuvbwwm+O4Cbm2NrPzwlup9+/3fdkuzo1YqOZcXw26+YUJB84Ja7j9yURWposEHLYwxUfQ== + dependencies: + "@babel/helper-function-name" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-literals@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.10.4.tgz#9f42ba0841100a135f22712d0e391c462f571f3c" + integrity sha512-Xd/dFSTEVuUWnyZiMu76/InZxLTYilOSr1UlHV+p115Z/Le2Fi1KXkJUYz0b42DfndostYlPub3m8ZTQlMaiqQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-literals@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.12.1.tgz#d73b803a26b37017ddf9d3bb8f4dc58bfb806f57" + integrity sha512-+PxVGA+2Ag6uGgL0A5f+9rklOnnMccwEBzwYFL3EUaKuiyVnUipyXncFcfjSkbimLrODoqki1U9XxZzTvfN7IQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-literals@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.14.5.tgz#41d06c7ff5d4d09e3cf4587bd3ecf3930c730f78" + integrity sha512-ql33+epql2F49bi8aHXxvLURHkxJbSmMKl9J5yHqg4PLtdE6Uc48CH1GS6TQvZ86eoB/ApZXwm7jlA+B3kra7A== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-member-expression-literals@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.10.4.tgz#b1ec44fcf195afcb8db2c62cd8e551c881baf8b7" + integrity sha512-0bFOvPyAoTBhtcJLr9VcwZqKmSjFml1iVxvPL0ReomGU53CX53HsM4h2SzckNdkQcHox1bpAqzxBI1Y09LlBSw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-member-expression-literals@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.12.1.tgz#496038602daf1514a64d43d8e17cbb2755e0c3ad" + integrity sha512-1sxePl6z9ad0gFMB9KqmYofk34flq62aqMt9NqliS/7hPEpURUCMbyHXrMPlo282iY7nAvUB1aQd5mg79UD9Jg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-member-expression-literals@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.14.5.tgz#b39cd5212a2bf235a617d320ec2b48bcc091b8a7" + integrity sha512-WkNXxH1VXVTKarWFqmso83xl+2V3Eo28YY5utIkbsmXoItO8Q3aZxN4BTS2k0hz9dGUloHK26mJMyQEYfkn/+Q== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-modules-amd@^7.10.4": + version "7.10.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.10.5.tgz#1b9cddaf05d9e88b3aad339cb3e445c4f020a9b1" + integrity sha512-elm5uruNio7CTLFItVC/rIzKLfQ17+fX7EVz5W0TMgIHFo1zY0Ozzx+lgwhL4plzl8OzVn6Qasx5DeEFyoNiRw== + dependencies: + "@babel/helper-module-transforms" "^7.10.5" + "@babel/helper-plugin-utils" "^7.10.4" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-amd@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.12.1.tgz#3154300b026185666eebb0c0ed7f8415fefcf6f9" + integrity sha512-tDW8hMkzad5oDtzsB70HIQQRBiTKrhfgwC/KkJeGsaNFTdWhKNt/BiE8c5yj19XiGyrxpbkOfH87qkNg1YGlOQ== + dependencies: + "@babel/helper-module-transforms" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-amd@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.14.5.tgz#4fd9ce7e3411cb8b83848480b7041d83004858f7" + integrity sha512-3lpOU8Vxmp3roC4vzFpSdEpGUWSMsHFreTWOMMLzel2gNGfHE5UWIh/LN6ghHs2xurUp4jRFYMUIZhuFbody1g== + dependencies: + "@babel/helper-module-transforms" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-commonjs@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.10.4.tgz#66667c3eeda1ebf7896d41f1f16b17105a2fbca0" + integrity sha512-Xj7Uq5o80HDLlW64rVfDBhao6OX89HKUmb+9vWYaLXBZOma4gA6tw4Ni1O5qVDoZWUV0fxMYA0aYzOawz0l+1w== + dependencies: + "@babel/helper-module-transforms" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-simple-access" "^7.10.4" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-commonjs@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.12.1.tgz#fa403124542636c786cf9b460a0ffbb48a86e648" + integrity sha512-dY789wq6l0uLY8py9c1B48V8mVL5gZh/+PQ5ZPrylPYsnAvnEMjqsUXkuoDVPeVK+0VyGar+D08107LzDQ6pag== + dependencies: + "@babel/helper-module-transforms" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-simple-access" "^7.12.1" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-commonjs@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.14.5.tgz#7aaee0ea98283de94da98b28f8c35701429dad97" + integrity sha512-en8GfBtgnydoao2PS+87mKyw62k02k7kJ9ltbKe0fXTHrQmG6QZZflYuGI1VVG7sVpx4E1n7KBpNlPb8m78J+A== + dependencies: + "@babel/helper-module-transforms" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-simple-access" "^7.14.5" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-systemjs@^7.10.4": + version "7.10.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.10.5.tgz#6270099c854066681bae9e05f87e1b9cadbe8c85" + integrity sha512-f4RLO/OL14/FP1AEbcsWMzpbUz6tssRaeQg11RH1BP/XnPpRoVwgeYViMFacnkaw4k4wjRSjn3ip1Uw9TaXuMw== + dependencies: + "@babel/helper-hoist-variables" "^7.10.4" + "@babel/helper-module-transforms" "^7.10.5" + "@babel/helper-plugin-utils" "^7.10.4" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-systemjs@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.12.1.tgz#663fea620d593c93f214a464cd399bf6dc683086" + integrity sha512-Hn7cVvOavVh8yvW6fLwveFqSnd7rbQN3zJvoPNyNaQSvgfKmDBO9U1YL9+PCXGRlZD9tNdWTy5ACKqMuzyn32Q== + dependencies: + "@babel/helper-hoist-variables" "^7.10.4" + "@babel/helper-module-transforms" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-validator-identifier" "^7.10.4" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-systemjs@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.14.5.tgz#c75342ef8b30dcde4295d3401aae24e65638ed29" + integrity sha512-mNMQdvBEE5DcMQaL5LbzXFMANrQjd2W7FPzg34Y4yEz7dBgdaC+9B84dSO+/1Wba98zoDbInctCDo4JGxz1VYA== + dependencies: + "@babel/helper-hoist-variables" "^7.14.5" + "@babel/helper-module-transforms" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-validator-identifier" "^7.14.5" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-umd@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.10.4.tgz#9a8481fe81b824654b3a0b65da3df89f3d21839e" + integrity sha512-mohW5q3uAEt8T45YT7Qc5ws6mWgJAaL/8BfWD9Dodo1A3RKWli8wTS+WiQ/knF+tXlPirW/1/MqzzGfCExKECA== + dependencies: + "@babel/helper-module-transforms" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-modules-umd@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.12.1.tgz#eb5a218d6b1c68f3d6217b8fa2cc82fec6547902" + integrity sha512-aEIubCS0KHKM0zUos5fIoQm+AZUMt1ZvMpqz0/H5qAQ7vWylr9+PLYurT+Ic7ID/bKLd4q8hDovaG3Zch2uz5Q== + dependencies: + "@babel/helper-module-transforms" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-modules-umd@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.14.5.tgz#fb662dfee697cce274a7cda525190a79096aa6e0" + integrity sha512-RfPGoagSngC06LsGUYyM9QWSXZ8MysEjDJTAea1lqRjNECE3y0qIJF/qbvJxc4oA4s99HumIMdXOrd+TdKaAAA== + dependencies: + "@babel/helper-module-transforms" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-named-capturing-groups-regex@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.10.4.tgz#78b4d978810b6f3bcf03f9e318f2fc0ed41aecb6" + integrity sha512-V6LuOnD31kTkxQPhKiVYzYC/Jgdq53irJC/xBSmqcNcqFGV+PER4l6rU5SH2Vl7bH9mLDHcc0+l9HUOe4RNGKA== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.10.4" + +"@babel/plugin-transform-named-capturing-groups-regex@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.12.1.tgz#b407f5c96be0d9f5f88467497fa82b30ac3e8753" + integrity sha512-tB43uQ62RHcoDp9v2Nsf+dSM8sbNodbEicbQNA53zHz8pWUhsgHSJCGpt7daXxRydjb0KnfmB+ChXOv3oADp1Q== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.12.1" + +"@babel/plugin-transform-named-capturing-groups-regex@^7.14.7": + version "7.14.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.14.7.tgz#60c06892acf9df231e256c24464bfecb0908fd4e" + integrity sha512-DTNOTaS7TkW97xsDMrp7nycUVh6sn/eq22VaxWfEdzuEbRsiaOU0pqU7DlyUGHVsbQbSghvjKRpEl+nUCKGQSg== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.14.5" + +"@babel/plugin-transform-new-target@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.10.4.tgz#9097d753cb7b024cb7381a3b2e52e9513a9c6888" + integrity sha512-YXwWUDAH/J6dlfwqlWsztI2Puz1NtUAubXhOPLQ5gjR/qmQ5U96DY4FQO8At33JN4XPBhrjB8I4eMmLROjjLjw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-new-target@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.12.1.tgz#80073f02ee1bb2d365c3416490e085c95759dec0" + integrity sha512-+eW/VLcUL5L9IvJH7rT1sT0CzkdUTvPrXC2PXTn/7z7tXLBuKvezYbGdxD5WMRoyvyaujOq2fWoKl869heKjhw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-new-target@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.14.5.tgz#31bdae8b925dc84076ebfcd2a9940143aed7dbf8" + integrity sha512-Nx054zovz6IIRWEB49RDRuXGI4Gy0GMgqG0cII9L3MxqgXz/+rgII+RU58qpo4g7tNEx1jG7rRVH4ihZoP4esQ== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-object-super@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.10.4.tgz#d7146c4d139433e7a6526f888c667e314a093894" + integrity sha512-5iTw0JkdRdJvr7sY0vHqTpnruUpTea32JHmq/atIWqsnNussbRzjEDyWep8UNztt1B5IusBYg8Irb0bLbiEBCQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-replace-supers" "^7.10.4" + +"@babel/plugin-transform-object-super@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.12.1.tgz#4ea08696b8d2e65841d0c7706482b048bed1066e" + integrity sha512-AvypiGJH9hsquNUn+RXVcBdeE3KHPZexWRdimhuV59cSoOt5kFBmqlByorAeUlGG2CJWd0U+4ZtNKga/TB0cAw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-replace-supers" "^7.12.1" + +"@babel/plugin-transform-object-super@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.14.5.tgz#d0b5faeac9e98597a161a9cf78c527ed934cdc45" + integrity sha512-MKfOBWzK0pZIrav9z/hkRqIk/2bTv9qvxHzPQc12RcVkMOzpIKnFCNYJip00ssKWYkd8Sf5g0Wr7pqJ+cmtuFg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-replace-supers" "^7.14.5" + +"@babel/plugin-transform-parameters@^7.10.4": + version "7.10.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.10.5.tgz#59d339d58d0b1950435f4043e74e2510005e2c4a" + integrity sha512-xPHwUj5RdFV8l1wuYiu5S9fqWGM2DrYc24TMvUiRrPVm+SM3XeqU9BcokQX/kEUe+p2RBwy+yoiR1w/Blq6ubw== + dependencies: + "@babel/helper-get-function-arity" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-parameters@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.12.1.tgz#d2e963b038771650c922eff593799c96d853255d" + integrity sha512-xq9C5EQhdPK23ZeCdMxl8bbRnAgHFrw5EOC3KJUsSylZqdkCaFEXxGSBuTSObOpiiHHNyb82es8M1QYgfQGfNg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-parameters@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.14.5.tgz#49662e86a1f3ddccac6363a7dfb1ff0a158afeb3" + integrity sha512-Tl7LWdr6HUxTmzQtzuU14SqbgrSKmaR77M0OKyq4njZLQTPfOvzblNKyNkGwOfEFCEx7KeYHQHDI0P3F02IVkA== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-property-literals@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.10.4.tgz#f6fe54b6590352298785b83edd815d214c42e3c0" + integrity sha512-ofsAcKiUxQ8TY4sScgsGeR2vJIsfrzqvFb9GvJ5UdXDzl+MyYCaBj/FGzXuv7qE0aJcjWMILny1epqelnFlz8g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-property-literals@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.12.1.tgz#41bc81200d730abb4456ab8b3fbd5537b59adecd" + integrity sha512-6MTCR/mZ1MQS+AwZLplX4cEySjCpnIF26ToWo942nqn8hXSm7McaHQNeGx/pt7suI1TWOWMfa/NgBhiqSnX0cQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-property-literals@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.14.5.tgz#0ddbaa1f83db3606f1cdf4846fa1dfb473458b34" + integrity sha512-r1uilDthkgXW8Z1vJz2dKYLV1tuw2xsbrp3MrZmD99Wh9vsfKoob+JTgri5VUb/JqyKRXotlOtwgu4stIYCmnw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-react-constant-elements@^7.9.0": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.10.4.tgz#0f485260bf1c29012bb973e7e404749eaac12c9e" + integrity sha512-cYmQBW1pXrqBte1raMkAulXmi7rjg3VI6ZLg9QIic8Hq7BtYXaWuZSxsr2siOMI6SWwpxjWfnwhTUrd7JlAV7g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-react-display-name@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.10.4.tgz#b5795f4e3e3140419c3611b7a2a3832b9aef328d" + integrity sha512-Zd4X54Mu9SBfPGnEcaGcOrVAYOtjT2on8QZkLKEq1S/tHexG39d9XXGZv19VfRrDjPJzFmPfTAqOQS1pfFOujw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-react-jsx-development@^7.10.4": + version "7.11.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.11.5.tgz#e1439e6a57ee3d43e9f54ace363fb29cefe5d7b6" + integrity sha512-cImAmIlKJ84sDmpQzm4/0q/2xrXlDezQoixy3qoz1NJeZL/8PRon6xZtluvr4H4FzwlDGI5tCcFupMnXGtr+qw== + dependencies: + "@babel/helper-builder-react-jsx-experimental" "^7.11.5" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-jsx" "^7.10.4" + +"@babel/plugin-transform-react-jsx-self@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.10.4.tgz#cd301a5fed8988c182ed0b9d55e9bd6db0bd9369" + integrity sha512-yOvxY2pDiVJi0axdTWHSMi5T0DILN+H+SaeJeACHKjQLezEzhLx9nEF9xgpBLPtkZsks9cnb5P9iBEi21En3gg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-jsx" "^7.10.4" + +"@babel/plugin-transform-react-jsx-source@^7.10.4": + version "7.10.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.10.5.tgz#34f1779117520a779c054f2cdd9680435b9222b4" + integrity sha512-wTeqHVkN1lfPLubRiZH3o73f4rfon42HpgxUSs86Nc+8QIcm/B9s8NNVXu/gwGcOyd7yDib9ikxoDLxJP0UiDA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-jsx" "^7.10.4" + +"@babel/plugin-transform-react-jsx@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.10.4.tgz#673c9f913948764a4421683b2bef2936968fddf2" + integrity sha512-L+MfRhWjX0eI7Js093MM6MacKU4M6dnCRa/QPDwYMxjljzSCzzlzKzj9Pk4P3OtrPcxr2N3znR419nr3Xw+65A== + dependencies: + "@babel/helper-builder-react-jsx" "^7.10.4" + "@babel/helper-builder-react-jsx-experimental" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-jsx" "^7.10.4" + +"@babel/plugin-transform-react-pure-annotations@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.10.4.tgz#3eefbb73db94afbc075f097523e445354a1c6501" + integrity sha512-+njZkqcOuS8RaPakrnR9KvxjoG1ASJWpoIv/doyWngId88JoFlPlISenGXjrVacZUIALGUr6eodRs1vmPnF23A== + dependencies: + "@babel/helper-annotate-as-pure" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-regenerator@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.10.4.tgz#2015e59d839074e76838de2159db421966fd8b63" + integrity sha512-3thAHwtor39A7C04XucbMg17RcZ3Qppfxr22wYzZNcVIkPHfpM9J0SO8zuCV6SZa265kxBJSrfKTvDCYqBFXGw== + dependencies: + regenerator-transform "^0.14.2" + +"@babel/plugin-transform-regenerator@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.12.1.tgz#5f0a28d842f6462281f06a964e88ba8d7ab49753" + integrity sha512-gYrHqs5itw6i4PflFX3OdBPMQdPbF4bj2REIUxlMRUFk0/ZOAIpDFuViuxPjUL7YC8UPnf+XG7/utJvqXdPKng== + dependencies: + regenerator-transform "^0.14.2" + +"@babel/plugin-transform-regenerator@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.14.5.tgz#9676fd5707ed28f522727c5b3c0aa8544440b04f" + integrity sha512-NVIY1W3ITDP5xQl50NgTKlZ0GrotKtLna08/uGY6ErQt6VEQZXla86x/CTddm5gZdcr+5GSsvMeTmWA5Ii6pkg== + dependencies: + regenerator-transform "^0.14.2" + +"@babel/plugin-transform-reserved-words@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.10.4.tgz#8f2682bcdcef9ed327e1b0861585d7013f8a54dd" + integrity sha512-hGsw1O6Rew1fkFbDImZIEqA8GoidwTAilwCyWqLBM9f+e/u/sQMQu7uX6dyokfOayRuuVfKOW4O7HvaBWM+JlQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-reserved-words@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.12.1.tgz#6fdfc8cc7edcc42b36a7c12188c6787c873adcd8" + integrity sha512-pOnUfhyPKvZpVyBHhSBoX8vfA09b7r00Pmm1sH+29ae2hMTKVmSp4Ztsr8KBKjLjx17H0eJqaRC3bR2iThM54A== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-reserved-words@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.14.5.tgz#c44589b661cfdbef8d4300dcc7469dffa92f8304" + integrity sha512-cv4F2rv1nD4qdexOGsRQXJrOcyb5CrgjUH9PKrrtyhSDBNWGxd0UIitjyJiWagS+EbUGjG++22mGH1Pub8D6Vg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-runtime@^7.9.0": + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.12.10.tgz#af0fded4e846c4b37078e8e5d06deac6cd848562" + integrity sha512-xOrUfzPxw7+WDm9igMgQCbO3cJKymX7dFdsgRr1eu9n3KjjyU4pptIXbXPseQDquw+W+RuJEJMHKHNsPNNm3CA== + dependencies: + "@babel/helper-module-imports" "^7.12.5" + "@babel/helper-plugin-utils" "^7.10.4" + semver "^5.5.1" + +"@babel/plugin-transform-shorthand-properties@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.10.4.tgz#9fd25ec5cdd555bb7f473e5e6ee1c971eede4dd6" + integrity sha512-AC2K/t7o07KeTIxMoHneyX90v3zkm5cjHJEokrPEAGEy3UCp8sLKfnfOIGdZ194fyN4wfX/zZUWT9trJZ0qc+Q== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-shorthand-properties@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.12.1.tgz#0bf9cac5550fce0cfdf043420f661d645fdc75e3" + integrity sha512-GFZS3c/MhX1OusqB1MZ1ct2xRzX5ppQh2JU1h2Pnfk88HtFTM+TWQqJNfwkmxtPQtb/s1tk87oENfXJlx7rSDw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-shorthand-properties@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.14.5.tgz#97f13855f1409338d8cadcbaca670ad79e091a58" + integrity sha512-xLucks6T1VmGsTB+GWK5Pl9Jl5+nRXD1uoFdA5TSO6xtiNjtXTjKkmPdFXVLGlK5A2/or/wQMKfmQ2Y0XJfn5g== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-spread@^7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.11.0.tgz#fa84d300f5e4f57752fe41a6d1b3c554f13f17cc" + integrity sha512-UwQYGOqIdQJe4aWNyS7noqAnN2VbaczPLiEtln+zPowRNlD+79w3oi2TWfYe0eZgd+gjZCbsydN7lzWysDt+gw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-skip-transparent-expression-wrappers" "^7.11.0" + +"@babel/plugin-transform-spread@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.12.1.tgz#527f9f311be4ec7fdc2b79bb89f7bf884b3e1e1e" + integrity sha512-vuLp8CP0BE18zVYjsEBZ5xoCecMK6LBMMxYzJnh01rxQRvhNhH1csMMmBfNo5tGpGO+NhdSNW2mzIvBu3K1fng== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" + +"@babel/plugin-transform-spread@^7.14.6": + version "7.14.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.14.6.tgz#6bd40e57fe7de94aa904851963b5616652f73144" + integrity sha512-Zr0x0YroFJku7n7+/HH3A2eIrGMjbmAIbJSVv0IZ+t3U2WUQUA64S/oeied2e+MaGSjmt4alzBCsK9E8gh+fag== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5" + +"@babel/plugin-transform-sticky-regex@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.10.4.tgz#8f3889ee8657581130a29d9cc91d7c73b7c4a28d" + integrity sha512-Ddy3QZfIbEV0VYcVtFDCjeE4xwVTJWTmUtorAJkn6u/92Z/nWJNV+mILyqHKrUxXYKA2EoCilgoPePymKL4DvQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-regex" "^7.10.4" + +"@babel/plugin-transform-sticky-regex@^7.12.7": + version "7.12.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.12.7.tgz#560224613ab23987453948ed21d0b0b193fa7fad" + integrity sha512-VEiqZL5N/QvDbdjfYQBhruN0HYjSPjC4XkeqW4ny/jNtH9gcbgaqBIXYEZCNnESMAGs0/K/R7oFGMhOyu/eIxg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-sticky-regex@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.14.5.tgz#5b617542675e8b7761294381f3c28c633f40aeb9" + integrity sha512-Z7F7GyvEMzIIbwnziAZmnSNpdijdr4dWt+FJNBnBLz5mwDFkqIXU9wmBcWWad3QeJF5hMTkRe4dAq2sUZiG+8A== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-template-literals@^7.10.4": + version "7.10.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.10.5.tgz#78bc5d626a6642db3312d9d0f001f5e7639fde8c" + integrity sha512-V/lnPGIb+KT12OQikDvgSuesRX14ck5FfJXt6+tXhdkJ+Vsd0lDCVtF6jcB4rNClYFzaB2jusZ+lNISDk2mMMw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-template-literals@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.12.1.tgz#b43ece6ed9a79c0c71119f576d299ef09d942843" + integrity sha512-b4Zx3KHi+taXB1dVRBhVJtEPi9h1THCeKmae2qP0YdUHIFhVjtpqqNfxeVAa1xeHVhAy4SbHxEwx5cltAu5apw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-template-literals@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.14.5.tgz#a5f2bc233937d8453885dc736bdd8d9ffabf3d93" + integrity sha512-22btZeURqiepOfuy/VkFr+zStqlujWaarpMErvay7goJS6BWwdd6BY9zQyDLDa4x2S3VugxFb162IZ4m/S/+Gg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-typeof-symbol@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.10.4.tgz#9509f1a7eec31c4edbffe137c16cc33ff0bc5bfc" + integrity sha512-QqNgYwuuW0y0H+kUE/GWSR45t/ccRhe14Fs/4ZRouNNQsyd4o3PG4OtHiIrepbM2WKUBDAXKCAK/Lk4VhzTaGA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-typeof-symbol@^7.12.10": + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.10.tgz#de01c4c8f96580bd00f183072b0d0ecdcf0dec4b" + integrity sha512-JQ6H8Rnsogh//ijxspCjc21YPd3VLVoYtAwv3zQmqAt8YGYUtdo5usNhdl4b9/Vir2kPFZl6n1h0PfUz4hJhaA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-typeof-symbol@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.14.5.tgz#39af2739e989a2bd291bf6b53f16981423d457d4" + integrity sha512-lXzLD30ffCWseTbMQzrvDWqljvZlHkXU+CnseMhkMNqU1sASnCsz3tSzAaH3vCUXb9PHeUb90ZT1BdFTm1xxJw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-typescript@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.12.1.tgz#d92cc0af504d510e26a754a7dbc2e5c8cd9c7ab4" + integrity sha512-VrsBByqAIntM+EYMqSm59SiMEf7qkmI9dqMt6RbD/wlwueWmYcI0FFK5Fj47pP6DRZm+3teXjosKlwcZJ5lIMw== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-typescript" "^7.12.1" + +"@babel/plugin-transform-unicode-escapes@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.10.4.tgz#feae523391c7651ddac115dae0a9d06857892007" + integrity sha512-y5XJ9waMti2J+e7ij20e+aH+fho7Wb7W8rNuu72aKRwCHFqQdhkdU2lo3uZ9tQuboEJcUFayXdARhcxLQ3+6Fg== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-unicode-escapes@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.1.tgz#5232b9f81ccb07070b7c3c36c67a1b78f1845709" + integrity sha512-I8gNHJLIc7GdApm7wkVnStWssPNbSRMPtgHdmH3sRM1zopz09UWPS4x5V4n1yz/MIWTVnJ9sp6IkuXdWM4w+2Q== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-unicode-escapes@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.14.5.tgz#9d4bd2a681e3c5d7acf4f57fa9e51175d91d0c6b" + integrity sha512-crTo4jATEOjxj7bt9lbYXcBAM3LZaUrbP2uUdxb6WIorLmjNKSpHfIybgY4B8SRpbf8tEVIWH3Vtm7ayCrKocA== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-unicode-regex@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.10.4.tgz#e56d71f9282fac6db09c82742055576d5e6d80a8" + integrity sha512-wNfsc4s8N2qnIwpO/WP2ZiSyjfpTamT2C9V9FDH/Ljub9zw6P3SjkXcFmc0RQUt96k2fmIvtla2MMjgTwIAC+A== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-unicode-regex@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.12.1.tgz#cc9661f61390db5c65e3febaccefd5c6ac3faecb" + integrity sha512-SqH4ClNngh/zGwHZOOQMTD+e8FGWexILV+ePMyiDJttAWRh5dhDL8rcl5lSgU3Huiq6Zn6pWTMvdPAb21Dwdyg== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-unicode-regex@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.14.5.tgz#4cd09b6c8425dd81255c7ceb3fb1836e7414382e" + integrity sha512-UygduJpC5kHeCiRw/xDVzC+wj8VaYSoKl5JNVmbP7MadpNinAm3SvZCxZ42H37KZBKztz46YC73i9yV34d0Tzw== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/preset-env@^7.11.5": + version "7.14.7" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.14.7.tgz#5c70b22d4c2d893b03d8c886a5c17422502b932a" + integrity sha512-itOGqCKLsSUl0Y+1nSfhbuuOlTs0MJk2Iv7iSH+XT/mR8U1zRLO7NjWlYXB47yhK4J/7j+HYty/EhFZDYKa/VA== + dependencies: + "@babel/compat-data" "^7.14.7" + "@babel/helper-compilation-targets" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-validator-option" "^7.14.5" + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.14.5" + "@babel/plugin-proposal-async-generator-functions" "^7.14.7" + "@babel/plugin-proposal-class-properties" "^7.14.5" + "@babel/plugin-proposal-class-static-block" "^7.14.5" + "@babel/plugin-proposal-dynamic-import" "^7.14.5" + "@babel/plugin-proposal-export-namespace-from" "^7.14.5" + "@babel/plugin-proposal-json-strings" "^7.14.5" + "@babel/plugin-proposal-logical-assignment-operators" "^7.14.5" + "@babel/plugin-proposal-nullish-coalescing-operator" "^7.14.5" + "@babel/plugin-proposal-numeric-separator" "^7.14.5" + "@babel/plugin-proposal-object-rest-spread" "^7.14.7" + "@babel/plugin-proposal-optional-catch-binding" "^7.14.5" + "@babel/plugin-proposal-optional-chaining" "^7.14.5" + "@babel/plugin-proposal-private-methods" "^7.14.5" + "@babel/plugin-proposal-private-property-in-object" "^7.14.5" + "@babel/plugin-proposal-unicode-property-regex" "^7.14.5" + "@babel/plugin-syntax-async-generators" "^7.8.4" + "@babel/plugin-syntax-class-properties" "^7.12.13" + "@babel/plugin-syntax-class-static-block" "^7.14.5" + "@babel/plugin-syntax-dynamic-import" "^7.8.3" + "@babel/plugin-syntax-export-namespace-from" "^7.8.3" + "@babel/plugin-syntax-json-strings" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + "@babel/plugin-syntax-top-level-await" "^7.14.5" + "@babel/plugin-transform-arrow-functions" "^7.14.5" + "@babel/plugin-transform-async-to-generator" "^7.14.5" + "@babel/plugin-transform-block-scoped-functions" "^7.14.5" + "@babel/plugin-transform-block-scoping" "^7.14.5" + "@babel/plugin-transform-classes" "^7.14.5" + "@babel/plugin-transform-computed-properties" "^7.14.5" + "@babel/plugin-transform-destructuring" "^7.14.7" + "@babel/plugin-transform-dotall-regex" "^7.14.5" + "@babel/plugin-transform-duplicate-keys" "^7.14.5" + "@babel/plugin-transform-exponentiation-operator" "^7.14.5" + "@babel/plugin-transform-for-of" "^7.14.5" + "@babel/plugin-transform-function-name" "^7.14.5" + "@babel/plugin-transform-literals" "^7.14.5" + "@babel/plugin-transform-member-expression-literals" "^7.14.5" + "@babel/plugin-transform-modules-amd" "^7.14.5" + "@babel/plugin-transform-modules-commonjs" "^7.14.5" + "@babel/plugin-transform-modules-systemjs" "^7.14.5" + "@babel/plugin-transform-modules-umd" "^7.14.5" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.14.7" + "@babel/plugin-transform-new-target" "^7.14.5" + "@babel/plugin-transform-object-super" "^7.14.5" + "@babel/plugin-transform-parameters" "^7.14.5" + "@babel/plugin-transform-property-literals" "^7.14.5" + "@babel/plugin-transform-regenerator" "^7.14.5" + "@babel/plugin-transform-reserved-words" "^7.14.5" + "@babel/plugin-transform-shorthand-properties" "^7.14.5" + "@babel/plugin-transform-spread" "^7.14.6" + "@babel/plugin-transform-sticky-regex" "^7.14.5" + "@babel/plugin-transform-template-literals" "^7.14.5" + "@babel/plugin-transform-typeof-symbol" "^7.14.5" + "@babel/plugin-transform-unicode-escapes" "^7.14.5" + "@babel/plugin-transform-unicode-regex" "^7.14.5" + "@babel/preset-modules" "^0.1.4" + "@babel/types" "^7.14.5" + babel-plugin-polyfill-corejs2 "^0.2.2" + babel-plugin-polyfill-corejs3 "^0.2.2" + babel-plugin-polyfill-regenerator "^0.2.2" + core-js-compat "^3.15.0" + semver "^6.3.0" + +"@babel/preset-env@^7.9.0": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.12.11.tgz#55d5f7981487365c93dbbc84507b1c7215e857f9" + integrity sha512-j8Tb+KKIXKYlDBQyIOy4BLxzv1NUOwlHfZ74rvW+Z0Gp4/cI2IMDPBWAgWceGcE7aep9oL/0K9mlzlMGxA8yNw== + dependencies: + "@babel/compat-data" "^7.12.7" + "@babel/helper-compilation-targets" "^7.12.5" + "@babel/helper-module-imports" "^7.12.5" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-validator-option" "^7.12.11" + "@babel/plugin-proposal-async-generator-functions" "^7.12.1" + "@babel/plugin-proposal-class-properties" "^7.12.1" + "@babel/plugin-proposal-dynamic-import" "^7.12.1" + "@babel/plugin-proposal-export-namespace-from" "^7.12.1" + "@babel/plugin-proposal-json-strings" "^7.12.1" + "@babel/plugin-proposal-logical-assignment-operators" "^7.12.1" + "@babel/plugin-proposal-nullish-coalescing-operator" "^7.12.1" + "@babel/plugin-proposal-numeric-separator" "^7.12.7" + "@babel/plugin-proposal-object-rest-spread" "^7.12.1" + "@babel/plugin-proposal-optional-catch-binding" "^7.12.1" + "@babel/plugin-proposal-optional-chaining" "^7.12.7" + "@babel/plugin-proposal-private-methods" "^7.12.1" + "@babel/plugin-proposal-unicode-property-regex" "^7.12.1" + "@babel/plugin-syntax-async-generators" "^7.8.0" + "@babel/plugin-syntax-class-properties" "^7.12.1" + "@babel/plugin-syntax-dynamic-import" "^7.8.0" + "@babel/plugin-syntax-export-namespace-from" "^7.8.3" + "@babel/plugin-syntax-json-strings" "^7.8.0" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.0" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" + "@babel/plugin-syntax-optional-chaining" "^7.8.0" + "@babel/plugin-syntax-top-level-await" "^7.12.1" + "@babel/plugin-transform-arrow-functions" "^7.12.1" + "@babel/plugin-transform-async-to-generator" "^7.12.1" + "@babel/plugin-transform-block-scoped-functions" "^7.12.1" + "@babel/plugin-transform-block-scoping" "^7.12.11" + "@babel/plugin-transform-classes" "^7.12.1" + "@babel/plugin-transform-computed-properties" "^7.12.1" + "@babel/plugin-transform-destructuring" "^7.12.1" + "@babel/plugin-transform-dotall-regex" "^7.12.1" + "@babel/plugin-transform-duplicate-keys" "^7.12.1" + "@babel/plugin-transform-exponentiation-operator" "^7.12.1" + "@babel/plugin-transform-for-of" "^7.12.1" + "@babel/plugin-transform-function-name" "^7.12.1" + "@babel/plugin-transform-literals" "^7.12.1" + "@babel/plugin-transform-member-expression-literals" "^7.12.1" + "@babel/plugin-transform-modules-amd" "^7.12.1" + "@babel/plugin-transform-modules-commonjs" "^7.12.1" + "@babel/plugin-transform-modules-systemjs" "^7.12.1" + "@babel/plugin-transform-modules-umd" "^7.12.1" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.12.1" + "@babel/plugin-transform-new-target" "^7.12.1" + "@babel/plugin-transform-object-super" "^7.12.1" + "@babel/plugin-transform-parameters" "^7.12.1" + "@babel/plugin-transform-property-literals" "^7.12.1" + "@babel/plugin-transform-regenerator" "^7.12.1" + "@babel/plugin-transform-reserved-words" "^7.12.1" + "@babel/plugin-transform-shorthand-properties" "^7.12.1" + "@babel/plugin-transform-spread" "^7.12.1" + "@babel/plugin-transform-sticky-regex" "^7.12.7" + "@babel/plugin-transform-template-literals" "^7.12.1" + "@babel/plugin-transform-typeof-symbol" "^7.12.10" + "@babel/plugin-transform-unicode-escapes" "^7.12.1" + "@babel/plugin-transform-unicode-regex" "^7.12.1" + "@babel/preset-modules" "^0.1.3" + "@babel/types" "^7.12.11" + core-js-compat "^3.8.0" + semver "^5.5.0" + +"@babel/preset-env@^7.9.5": + version "7.11.5" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.11.5.tgz#18cb4b9379e3e92ffea92c07471a99a2914e4272" + integrity sha512-kXqmW1jVcnB2cdueV+fyBM8estd5mlNfaQi6lwLgRwCby4edpavgbFhiBNjmWA3JpB/yZGSISa7Srf+TwxDQoA== + dependencies: + "@babel/compat-data" "^7.11.0" + "@babel/helper-compilation-targets" "^7.10.4" + "@babel/helper-module-imports" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-proposal-async-generator-functions" "^7.10.4" + "@babel/plugin-proposal-class-properties" "^7.10.4" + "@babel/plugin-proposal-dynamic-import" "^7.10.4" + "@babel/plugin-proposal-export-namespace-from" "^7.10.4" + "@babel/plugin-proposal-json-strings" "^7.10.4" + "@babel/plugin-proposal-logical-assignment-operators" "^7.11.0" + "@babel/plugin-proposal-nullish-coalescing-operator" "^7.10.4" + "@babel/plugin-proposal-numeric-separator" "^7.10.4" + "@babel/plugin-proposal-object-rest-spread" "^7.11.0" + "@babel/plugin-proposal-optional-catch-binding" "^7.10.4" + "@babel/plugin-proposal-optional-chaining" "^7.11.0" + "@babel/plugin-proposal-private-methods" "^7.10.4" + "@babel/plugin-proposal-unicode-property-regex" "^7.10.4" + "@babel/plugin-syntax-async-generators" "^7.8.0" + "@babel/plugin-syntax-class-properties" "^7.10.4" + "@babel/plugin-syntax-dynamic-import" "^7.8.0" + "@babel/plugin-syntax-export-namespace-from" "^7.8.3" + "@babel/plugin-syntax-json-strings" "^7.8.0" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.0" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" + "@babel/plugin-syntax-optional-chaining" "^7.8.0" + "@babel/plugin-syntax-top-level-await" "^7.10.4" + "@babel/plugin-transform-arrow-functions" "^7.10.4" + "@babel/plugin-transform-async-to-generator" "^7.10.4" + "@babel/plugin-transform-block-scoped-functions" "^7.10.4" + "@babel/plugin-transform-block-scoping" "^7.10.4" + "@babel/plugin-transform-classes" "^7.10.4" + "@babel/plugin-transform-computed-properties" "^7.10.4" + "@babel/plugin-transform-destructuring" "^7.10.4" + "@babel/plugin-transform-dotall-regex" "^7.10.4" + "@babel/plugin-transform-duplicate-keys" "^7.10.4" + "@babel/plugin-transform-exponentiation-operator" "^7.10.4" + "@babel/plugin-transform-for-of" "^7.10.4" + "@babel/plugin-transform-function-name" "^7.10.4" + "@babel/plugin-transform-literals" "^7.10.4" + "@babel/plugin-transform-member-expression-literals" "^7.10.4" + "@babel/plugin-transform-modules-amd" "^7.10.4" + "@babel/plugin-transform-modules-commonjs" "^7.10.4" + "@babel/plugin-transform-modules-systemjs" "^7.10.4" + "@babel/plugin-transform-modules-umd" "^7.10.4" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.10.4" + "@babel/plugin-transform-new-target" "^7.10.4" + "@babel/plugin-transform-object-super" "^7.10.4" + "@babel/plugin-transform-parameters" "^7.10.4" + "@babel/plugin-transform-property-literals" "^7.10.4" + "@babel/plugin-transform-regenerator" "^7.10.4" + "@babel/plugin-transform-reserved-words" "^7.10.4" + "@babel/plugin-transform-shorthand-properties" "^7.10.4" + "@babel/plugin-transform-spread" "^7.11.0" + "@babel/plugin-transform-sticky-regex" "^7.10.4" + "@babel/plugin-transform-template-literals" "^7.10.4" + "@babel/plugin-transform-typeof-symbol" "^7.10.4" + "@babel/plugin-transform-unicode-escapes" "^7.10.4" + "@babel/plugin-transform-unicode-regex" "^7.10.4" + "@babel/preset-modules" "^0.1.3" + "@babel/types" "^7.11.5" + browserslist "^4.12.0" + core-js-compat "^3.6.2" + invariant "^2.2.2" + levenary "^1.1.1" + semver "^5.5.0" + +"@babel/preset-modules@^0.1.3", "@babel/preset-modules@^0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.4.tgz#362f2b68c662842970fdb5e254ffc8fc1c2e415e" + integrity sha512-J36NhwnfdzpmH41M1DrnkkgAqhZaqr/NBdPfQ677mLzlaXo+oDiv1deyCDtgAhz8p328otdob0Du7+xgHGZbKg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-proposal-unicode-property-regex" "^7.4.4" + "@babel/plugin-transform-dotall-regex" "^7.4.4" + "@babel/types" "^7.4.4" + esutils "^2.0.2" + +"@babel/preset-react@^7.9.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/preset-react/-/preset-react-7.10.4.tgz#92e8a66d816f9911d11d4cc935be67adfc82dbcf" + integrity sha512-BrHp4TgOIy4M19JAfO1LhycVXOPWdDbTRep7eVyatf174Hff+6Uk53sDyajqZPu8W1qXRBiYOfIamek6jA7YVw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-transform-react-display-name" "^7.10.4" + "@babel/plugin-transform-react-jsx" "^7.10.4" + "@babel/plugin-transform-react-jsx-development" "^7.10.4" + "@babel/plugin-transform-react-jsx-self" "^7.10.4" + "@babel/plugin-transform-react-jsx-source" "^7.10.4" + "@babel/plugin-transform-react-pure-annotations" "^7.10.4" + +"@babel/preset-typescript@^7.9.0": + version "7.12.7" + resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.12.7.tgz#fc7df8199d6aae747896f1e6c61fc872056632a3" + integrity sha512-nOoIqIqBmHBSEgBXWR4Dv/XBehtIFcw9PqZw6rFYuKrzsZmOQm3PR5siLBnKZFEsDb03IegG8nSjU/iXXXYRmw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-validator-option" "^7.12.1" + "@babel/plugin-transform-typescript" "^7.12.1" + +"@babel/runtime-corejs3@^7.10.4": + version "7.12.5" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.12.5.tgz#ffee91da0eb4c6dae080774e94ba606368e414f4" + integrity sha512-roGr54CsTmNPPzZoCP1AmDXuBoNao7tnSA83TXTwt+UK5QVyh1DIJnrgYRPWKCF2flqZQXwa7Yr8v7VmLzF0YQ== + dependencies: + core-js-pure "^3.0.0" + regenerator-runtime "^0.13.4" + +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": + version "7.11.2" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" + integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== + dependencies: + regenerator-runtime "^0.13.4" + +"@babel/runtime@^7.10.2": + version "7.14.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.6.tgz#535203bc0892efc7dec60bdc27b2ecf6e409062d" + integrity sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg== + dependencies: + regenerator-runtime "^0.13.4" + +"@babel/template@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278" + integrity sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/parser" "^7.10.4" + "@babel/types" "^7.10.4" + +"@babel/template@^7.12.7": + version "7.12.7" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc" + integrity sha512-GkDzmHS6GV7ZeXfJZ0tLRBhZcMcY0/Lnb+eEbXDBfCAcZCjrZKe6p3J4we/D24O9Y8enxWAg1cWwof59yLh2ow== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/parser" "^7.12.7" + "@babel/types" "^7.12.7" + +"@babel/template@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.14.5.tgz#a9bc9d8b33354ff6e55a9c60d1109200a68974f4" + integrity sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g== + dependencies: + "@babel/code-frame" "^7.14.5" + "@babel/parser" "^7.14.5" + "@babel/types" "^7.14.5" + +"@babel/traverse@^7.0.0", "@babel/traverse@^7.10.4": + version "7.11.5" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.11.5.tgz#be777b93b518eb6d76ee2e1ea1d143daa11e61c3" + integrity sha512-EjiPXt+r7LiCZXEfRpSJd+jUMnBd4/9OUv7Nx3+0u9+eimMwJmG0Q98lw4/289JCoxSE8OolDMNZaaF/JZ69WQ== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/generator" "^7.11.5" + "@babel/helper-function-name" "^7.10.4" + "@babel/helper-split-export-declaration" "^7.11.0" + "@babel/parser" "^7.11.5" + "@babel/types" "^7.11.5" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.19" + +"@babel/traverse@^7.12.1", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.5", "@babel/traverse@^7.12.9", "@babel/traverse@^7.9.0": + version "7.12.12" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.12.tgz#d0cd87892704edd8da002d674bc811ce64743376" + integrity sha512-s88i0X0lPy45RrLM8b9mz8RPH5FqO9G9p7ti59cToE44xFm1Q+Pjh5Gq4SXBbtb88X7Uy7pexeqRIQDDMNkL0w== + dependencies: + "@babel/code-frame" "^7.12.11" + "@babel/generator" "^7.12.11" + "@babel/helper-function-name" "^7.12.11" + "@babel/helper-split-export-declaration" "^7.12.11" + "@babel/parser" "^7.12.11" + "@babel/types" "^7.12.12" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.19" + +"@babel/traverse@^7.13.0", "@babel/traverse@^7.14.5": + version "7.14.7" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.14.7.tgz#64007c9774cfdc3abd23b0780bc18a3ce3631753" + integrity sha512-9vDr5NzHu27wgwejuKL7kIOm4bwEtaPQ4Z6cpCmjSuaRqpH/7xc4qcGEscwMqlkwgcXl6MvqoAjZkQ24uSdIZQ== + dependencies: + "@babel/code-frame" "^7.14.5" + "@babel/generator" "^7.14.5" + "@babel/helper-function-name" "^7.14.5" + "@babel/helper-hoist-variables" "^7.14.5" + "@babel/helper-split-export-declaration" "^7.14.5" + "@babel/parser" "^7.14.7" + "@babel/types" "^7.14.5" + debug "^4.1.0" + globals "^11.1.0" + +"@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.11.5", "@babel/types@^7.4.4", "@babel/types@^7.9.5": + version "7.11.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d" + integrity sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q== + dependencies: + "@babel/helper-validator-identifier" "^7.10.4" + lodash "^4.17.19" + to-fast-properties "^2.0.0" + +"@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.11", "@babel/types@^7.12.12", "@babel/types@^7.12.5", "@babel/types@^7.12.7": + version "7.12.12" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.12.tgz#4608a6ec313abbd87afa55004d373ad04a96c299" + integrity sha512-lnIX7piTxOH22xE7fDXDbSHg9MM1/6ORnafpJmov5rs0kX5g4BZxeXNJLXsMRiO0U5Rb8/FvMS6xlTnTHvxonQ== + dependencies: + "@babel/helper-validator-identifier" "^7.12.11" + lodash "^4.17.19" + to-fast-properties "^2.0.0" + +"@babel/types@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.14.5.tgz#3bb997ba829a2104cedb20689c4a5b8121d383ff" + integrity sha512-M/NzBpEL95I5Hh4dwhin5JlE7EzO5PHMAuzjxss3tiOBD46KfQvVedN/3jEPZvdRvtsK2222XfdHogNIttFgcg== + dependencies: + "@babel/helper-validator-identifier" "^7.14.5" + to-fast-properties "^2.0.0" + +"@bugsnag/browser@^7.3.3": + version "7.3.3" + resolved "https://registry.yarnpkg.com/@bugsnag/browser/-/browser-7.3.3.tgz#aa839512a60811fe76ba5f02e16b2cb6646838c7" + integrity sha512-P3oQU/abDsDqSuuxVFDBp3XNIYeGLsbtGWwCM+iEg1ZGAUk/yP7NfRLqotMheZ0uC8a+4PSIS+U3+slZF7LCJw== + dependencies: + "@bugsnag/core" "^7.3.3" + +"@bugsnag/core@^7.3.3": + version "7.3.3" + resolved "https://registry.yarnpkg.com/@bugsnag/core/-/core-7.3.3.tgz#9d231e5a603a60b74b864cd59c1ebe6fdc2ff70f" + integrity sha512-DjAwzxQtyKgQxPGLmM+cZZZVkVsecUDowliguvcGojHHmdeIEDIBpu5LrZBQtLNk83SjM1RIyAEPdzaiHGWbzg== + dependencies: + "@bugsnag/cuid" "^3.0.0" + "@bugsnag/safe-json-stringify" "^6.0.0" + error-stack-parser "^2.0.3" + iserror "0.0.2" + stack-generator "^2.0.3" + +"@bugsnag/cuid@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@bugsnag/cuid/-/cuid-3.0.0.tgz#2ee7642a30aee6dc86f5e7f824653741e42e5c35" + integrity sha512-LOt8aaBI+KvOQGneBtpuCz3YqzyEAehd1f3nC5yr9TIYW1+IzYKa2xWS4EiMz5pPOnRPHkyyS5t/wmSmN51Gjg== + +"@bugsnag/js@^7.0.0": + version "7.3.3" + resolved "https://registry.yarnpkg.com/@bugsnag/js/-/js-7.3.3.tgz#de18b841049b84fca94eb71835c7ba49423fd6dc" + integrity sha512-4++QE1cebYVI87smjc9IsyIJWtNjL5WG4jp7htd+HEtT0QkhbokIbRH2b4MpN6+l4Y7GzyzMBNDOr1qDdBluBw== + dependencies: + "@bugsnag/browser" "^7.3.3" + "@bugsnag/node" "^7.3.3" + +"@bugsnag/node@^7.3.3": + version "7.3.3" + resolved "https://registry.yarnpkg.com/@bugsnag/node/-/node-7.3.3.tgz#666917687a587e2a797359f74f03aff624f8dae3" + integrity sha512-quyJXiqcMDVMdDqO0kW1277K3osdI/XgHKtY0K/4EcN2u4Qlg0crBNw5dQo+DCL4yllDQcWRfeuFv718QW4xlQ== + dependencies: + "@bugsnag/core" "^7.3.3" + byline "^5.0.0" + error-stack-parser "^2.0.2" + iserror "^0.0.2" + pump "^3.0.0" + stack-generator "^2.0.3" + +"@bugsnag/safe-json-stringify@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@bugsnag/safe-json-stringify/-/safe-json-stringify-6.0.0.tgz#22abdcd83e008c369902976730c34c150148a758" + integrity sha512-htzFO1Zc57S8kgdRK9mLcPVTW1BY2ijfH7Dk2CeZmspTWKdKqSo1iwmqrq2WtRjFlo8aRZYgLX0wFrDXF/9DLA== + +"@csstools/convert-colors@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@csstools/convert-colors/-/convert-colors-1.4.0.tgz#ad495dc41b12e75d588c6db8b9834f08fa131eb7" + integrity sha512-5a6wqoJV/xEdbRNKVo6I4hO3VjyDq//8q2f9I6PBAvMesJHFauXDorcNCsr9RzvsZnaWi5NYCcfyqP1QeFHFbw== + +"@dabh/diagnostics@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.2.tgz#290d08f7b381b8f94607dc8f471a12c675f9db31" + integrity sha512-+A1YivoVDNNVCdfozHSR8v/jyuuLTMXwjWuxPFlFlUapXoGc+Gj9mDlTDDfrwl7rXCl2tNZ0kE8sIBO6YOn96Q== + dependencies: + colorspace "1.1.x" + enabled "2.0.x" + kuler "^2.0.0" + +"@docsearch/css@^1.0.0-alpha.28": + version "1.0.0-alpha.28" + resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-1.0.0-alpha.28.tgz#c8a2cd8c1bb3a6855c51892e9dbdab5d42fe6e23" + integrity sha512-1AhRzVdAkrWwhaxTX6/R7SnFHz8yLz1W8I/AldlTrfbNvZs9INk1FZiEFTJdgHaP68nhgQNWSGlQiDiI3y2RYg== + +"@docsearch/react@^1.0.0-alpha.27": + version "1.0.0-alpha.28" + resolved "https://registry.yarnpkg.com/@docsearch/react/-/react-1.0.0-alpha.28.tgz#4f039ed79f8b3332b19a57677b219aebc5010e9d" + integrity sha512-XjJOnCBXn+UZmtuDmgzlVIHnnvh6yHVwG4aFq8AXN6xJEIX3f180FvGaowFWAxgdtHplJxFGux0Xx4piHqBzIw== + dependencies: + "@docsearch/css" "^1.0.0-alpha.28" + "@francoischalifour/autocomplete-core" "^1.0.0-alpha.28" + "@francoischalifour/autocomplete-preset-algolia" "^1.0.0-alpha.28" + algoliasearch "^4.0.0" + +"@docusaurus/core@2.0.0-alpha.63": + version "2.0.0-alpha.63" + resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-2.0.0-alpha.63.tgz#e0b1db9b7a14773c6e0a56d48ba9ab85fe322ee4" + integrity sha512-IVSL29ZQAB7wuBTPgtJAvHUfmQRBY/MS2ypxPTwlhPqTORPIqeEaLFgyeWQ0RgZ5iqzB5WVQiEUk2Kx0WI3/Qw== + dependencies: + "@babel/core" "^7.9.0" + "@babel/plugin-proposal-nullish-coalescing-operator" "^7.10.1" + "@babel/plugin-proposal-optional-chaining" "^7.10.3" + "@babel/plugin-syntax-dynamic-import" "^7.8.3" + "@babel/plugin-transform-runtime" "^7.9.0" + "@babel/preset-env" "^7.9.0" + "@babel/preset-react" "^7.9.4" + "@babel/preset-typescript" "^7.9.0" + "@babel/runtime" "^7.9.2" + "@babel/runtime-corejs3" "^7.10.4" + "@docusaurus/types" "2.0.0-alpha.63" + "@docusaurus/utils" "2.0.0-alpha.63" + "@docusaurus/utils-validation" "2.0.0-alpha.63" + "@endiliey/static-site-generator-webpack-plugin" "^4.0.0" + "@hapi/joi" "^17.1.1" + "@svgr/webpack" "^5.4.0" + babel-loader "^8.1.0" + babel-plugin-dynamic-import-node "^2.3.0" + boxen "^4.2.0" + cache-loader "^4.1.0" + chalk "^3.0.0" + chokidar "^3.3.0" + commander "^4.0.1" + copy-webpack-plugin "^6.0.3" + core-js "^2.6.5" + css-loader "^3.4.2" + del "^5.1.0" + detect-port "^1.3.0" + eta "^1.1.1" + express "^4.17.1" + file-loader "^6.0.0" + fs-extra "^8.1.0" + globby "^10.0.1" + html-minifier-terser "^5.0.5" + html-tags "^3.1.0" + html-webpack-plugin "^4.0.4" + import-fresh "^3.2.1" + inquirer "^7.2.0" + is-root "^2.1.0" + leven "^3.1.0" + lodash "^4.5.2" + lodash.flatmap "^4.5.0" + lodash.has "^4.5.2" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + mini-css-extract-plugin "^0.8.0" + nprogress "^0.2.0" + null-loader "^3.0.0" + optimize-css-assets-webpack-plugin "^5.0.3" + pnp-webpack-plugin "^1.6.4" + postcss-loader "^3.0.0" + postcss-preset-env "^6.7.0" + react-dev-utils "^10.2.1" + react-helmet "^6.0.0-beta" + react-loadable "^5.5.0" + react-loadable-ssr-addon "^0.3.0" + react-router "^5.1.2" + react-router-config "^5.1.1" + react-router-dom "^5.1.2" + resolve-pathname "^3.0.0" + semver "^6.3.0" + serve-handler "^6.1.3" + shelljs "^0.8.4" + std-env "^2.2.1" + terser-webpack-plugin "^3.0.3" + update-notifier "^4.1.0" + url-loader "^4.1.0" + wait-file "^1.0.5" + webpack "^4.41.2" + webpack-bundle-analyzer "^3.6.1" + webpack-dev-server "^3.11.0" + webpack-merge "^4.2.2" + webpackbar "^4.0.0" + +"@docusaurus/lqip-loader@2.0.0-alpha.63": + version "2.0.0-alpha.63" + resolved "https://registry.yarnpkg.com/@docusaurus/lqip-loader/-/lqip-loader-2.0.0-alpha.63.tgz#412f5110dc12da05a568ae2e08d1abb463ad88dc" + integrity sha512-Z5ExRtfw+RAgutN1+p7Ch0Bm2M+GPo9vFGVgbYJIi0UUsC/RKJ4+ucKTQZAC0hrNvFPodcddVt6VcQhB7nHHig== + dependencies: + loader-utils "^1.2.3" + lodash.sortby "^4.7.0" + node-vibrant "^3.1.5" + +"@docusaurus/mdx-loader@2.0.0-alpha.63": + version "2.0.0-alpha.63" + resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-2.0.0-alpha.63.tgz#e6b363bdcfae84306aad74a162130672df33ef40" + integrity sha512-zqnjIOlUSn4sXvZPydYI3z+n9U/9E4bYDn4PHNqNTxPVm5cWk+YPZtq1dP7IGkM5xCZpkApOj4oOh9qmRafoug== + dependencies: + "@babel/parser" "^7.9.4" + "@babel/traverse" "^7.9.0" + "@docusaurus/core" "2.0.0-alpha.63" + "@docusaurus/utils" "2.0.0-alpha.63" + "@mdx-js/mdx" "^1.5.8" + "@mdx-js/react" "^1.5.8" + escape-html "^1.0.3" + file-loader "^6.0.0" + fs-extra "^8.1.0" + github-slugger "^1.3.0" + gray-matter "^4.0.2" + loader-utils "^1.2.3" + mdast-util-to-string "^1.1.0" + remark-emoji "^2.1.0" + stringify-object "^3.3.0" + unist-util-visit "^2.0.2" + url-loader "^4.1.0" + +"@docusaurus/plugin-client-redirects@2.0.0-alpha.63": + version "2.0.0-alpha.63" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-2.0.0-alpha.63.tgz#42604223d7eb97962e2e7576b0652f73f652ba6b" + integrity sha512-KiET6iltFLHXQQDq8JIt4ayjPqwUoCmMBQlzeOHRhVN3kqWvKEUWnFK/ZnIadSIT5p3aWli9vxon1spBeQjYzA== + dependencies: + "@docusaurus/core" "2.0.0-alpha.63" + "@docusaurus/types" "2.0.0-alpha.63" + "@docusaurus/utils" "2.0.0-alpha.63" + "@docusaurus/utils-validation" "2.0.0-alpha.63" + "@hapi/joi" "^17.1.1" + "@types/hapi__joi" "^17.1.2" + chalk "^3.0.0" + eta "^1.1.1" + fs-extra "^8.1.0" + globby "^10.0.1" + lodash "^4.17.15" + +"@docusaurus/plugin-content-docs@2.0.0-alpha.63": + version "2.0.0-alpha.63" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-2.0.0-alpha.63.tgz#1971291320c66e7b5767f0a9e2feccafc4d8b7dd" + integrity sha512-acBjlUqLSYxY7dqbRYWpc+BHyVa0/f1O7slMKgaNuIs6xiv8vBNDpRUSkaef0MXc7L7Ez7ecxQ1r0aNV+Qsg5w== + dependencies: + "@docusaurus/core" "2.0.0-alpha.63" + "@docusaurus/mdx-loader" "2.0.0-alpha.63" + "@docusaurus/types" "2.0.0-alpha.63" + "@docusaurus/utils" "2.0.0-alpha.63" + "@docusaurus/utils-validation" "2.0.0-alpha.63" + "@hapi/joi" "17.1.1" + chalk "^3.0.0" + execa "^3.4.0" + fs-extra "^8.1.0" + globby "^10.0.1" + import-fresh "^3.2.1" + loader-utils "^1.2.3" + lodash "^4.17.19" + lodash.flatmap "^4.5.0" + lodash.groupby "^4.6.0" + lodash.pick "^4.4.0" + lodash.pickby "^4.6.0" + lodash.sortby "^4.6.0" + remark-admonitions "^1.2.1" + shelljs "^0.8.4" + utility-types "^3.10.0" + webpack "^4.41.2" + +"@docusaurus/plugin-content-pages@2.0.0-alpha.63": + version "2.0.0-alpha.63" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-2.0.0-alpha.63.tgz#6ff1f5b3f11d4235c07163a429c2ac15e23ada06" + integrity sha512-+nnNkWOx3pHJZcZ5VRG0EULiiwTqMrY11Ib4xj8m7kHxbgr2CxUqnXBk61DYqS2wozvxCUvxk8TV8GJUrMxLag== + dependencies: + "@docusaurus/core" "2.0.0-alpha.63" + "@docusaurus/mdx-loader" "2.0.0-alpha.63" + "@docusaurus/types" "2.0.0-alpha.63" + "@docusaurus/utils" "2.0.0-alpha.63" + "@docusaurus/utils-validation" "2.0.0-alpha.63" + "@hapi/joi" "17.1.1" + globby "^10.0.1" + loader-utils "^1.2.3" + minimatch "^3.0.4" + remark-admonitions "^1.2.1" + slash "^3.0.0" + webpack "^4.41.2" + +"@docusaurus/plugin-debug@2.0.0-alpha.63": + version "2.0.0-alpha.63" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-2.0.0-alpha.63.tgz#731adf7c698614f5f7233c048ec7f5d9f31338a9" + integrity sha512-pQ2BXSg7mMkH484R9akm9wvjCZ2Rqed/so/V8Ur+xoCycJG/JrIsQE3FnMXupLJIrHzO5EgiR4qJBdDDY5AMbA== + dependencies: + "@docusaurus/core" "2.0.0-alpha.63" + "@docusaurus/types" "2.0.0-alpha.63" + "@docusaurus/utils" "2.0.0-alpha.63" + react-json-view "^1.19.1" + +"@docusaurus/plugin-ideal-image@2.0.0-alpha.63": + version "2.0.0-alpha.63" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-ideal-image/-/plugin-ideal-image-2.0.0-alpha.63.tgz#620b29b27d58a4ea51f69b5e896d62ff02e72c72" + integrity sha512-VMDIHRc66JnaPd8reZCZosbod/vd7aRpYMrutPcuNJgaCuSScv7nOFOcbsuXjnZLG2fgvjg9bUrdudIwmT4Z0g== + dependencies: + "@docusaurus/core" "2.0.0-alpha.63" + "@docusaurus/lqip-loader" "2.0.0-alpha.63" + "@docusaurus/types" "2.0.0-alpha.63" + "@endiliey/react-ideal-image" "^0.0.11" + "@endiliey/responsive-loader" "^1.3.2" + react-waypoint "^9.0.2" + sharp "^0.25.2" + webpack "^4.41.2" + +"@docusaurus/plugin-sitemap@2.0.0-alpha.63": + version "2.0.0-alpha.63" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-2.0.0-alpha.63.tgz#3da3aa917f5ab225cc371dee79db1b9ec788ad3a" + integrity sha512-XD4tNAXQNLv3r5Mm/RhLLcVjG1gE8UyIqaTcyW5kchhLHk1N2KWhedvu+0mSF7Ya5WzkH1cPepTVWIeznl84zA== + dependencies: + "@docusaurus/core" "2.0.0-alpha.63" + "@docusaurus/types" "2.0.0-alpha.63" + "@hapi/joi" "17.1.1" + fs-extra "^8.1.0" + sitemap "^3.2.2" + +"@docusaurus/theme-search-algolia@2.0.0-alpha.63": + version "2.0.0-alpha.63" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-2.0.0-alpha.63.tgz#d1563706ac4ec1d8096b32b022ac8bceee5c51d9" + integrity sha512-fqivQVnYOhPsSF/sjaWw5hZ9ltfnejqcXVlT4BNOHUu9p4Jup5vKkRytbotho1/8JTj/XG22RCPnNAcsBtdRDw== + dependencies: + "@docsearch/react" "^1.0.0-alpha.27" + "@docusaurus/core" "2.0.0-alpha.63" + "@docusaurus/utils" "2.0.0-alpha.63" + "@hapi/joi" "^17.1.1" + algoliasearch "^4.0.0" + algoliasearch-helper "^3.1.1" + clsx "^1.1.1" + eta "^1.1.1" + lodash "^4.17.19" + +"@docusaurus/types@2.0.0-alpha.63": + version "2.0.0-alpha.63" + resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-2.0.0-alpha.63.tgz#6733479ba8e0aebba183da510e7d74e3cdf45356" + integrity sha512-rzCYQKrB8xArXfSoad0RM1VtGfhmsSC3wXLVNrCFXp3F9sPTgRuev5jfEfvMWbJdkLZasNA4eNbMJ6JlrnfE4Q== + dependencies: + "@types/webpack" "^4.41.0" + commander "^4.0.1" + querystring "0.2.0" + webpack-merge "^4.2.2" + +"@docusaurus/utils-validation@2.0.0-alpha.63": + version "2.0.0-alpha.63" + resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-2.0.0-alpha.63.tgz#78e31bfa289a5c310a018465f232e4c7e369cff8" + integrity sha512-q+bHBobym6nFiK4nkJEIoPsIdHQDEatHYxv5MU1mzd8jZ2ajHYbvqjA2DWCSEZ4wrkJa0RwoP+9HUKdr3ZAPrA== + dependencies: + "@docusaurus/utils" "2.0.0-alpha.63" + "@hapi/joi" "17.1.1" + chalk "^3.0.0" + +"@docusaurus/utils@2.0.0-alpha.63": + version "2.0.0-alpha.63" + resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-2.0.0-alpha.63.tgz#a33b10ae8f4920cba7cbc55566ad1c8abd716442" + integrity sha512-QrRuZAoj11cFgQMvjeNK12ds6y3VvPnYKu0mDpUp6jzB1fSlvIeKdnGR5Ju81LJ1CCXojNQkR8PtNK6kp1+KaA== + dependencies: + escape-string-regexp "^2.0.0" + fs-extra "^8.1.0" + gray-matter "^4.0.2" + lodash.camelcase "^4.3.0" + lodash.kebabcase "^4.1.1" + resolve-pathname "^3.0.0" + +"@emotion/is-prop-valid@^0.8.1": + version "0.8.8" + resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" + integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== + dependencies: + "@emotion/memoize" "0.7.4" + +"@emotion/memoize@0.7.4": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb" + integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== + +"@emotion/unitless@^0.7.0": + version "0.7.5" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.7.5.tgz#77211291c1900a700b8a78cfafda3160d76949ed" + integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg== + +"@endiliey/react-ideal-image@^0.0.11": + version "0.0.11" + resolved "https://registry.yarnpkg.com/@endiliey/react-ideal-image/-/react-ideal-image-0.0.11.tgz#dc3803d04e1409cf88efa4bba0f67667807bdf27" + integrity sha512-QxMjt/Gvur/gLxSoCy7VIyGGGrGmDN+VHcXkN3R2ApoWX0EYUE+hMgPHSW/PV6VVebZ1Nd4t2UnGRBDihu16JQ== + +"@endiliey/responsive-loader@^1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@endiliey/responsive-loader/-/responsive-loader-1.3.2.tgz#b9276747293b57c1ae9df59c6f5bec7624b157b7" + integrity sha512-j77koHZIW8L6s7kw/VdZhORamdP7laW4+Gcu7Ddt7iRSXniADDk9HtKUZrBXC90hMYo+Kb4XdlqMizvMI8JGrA== + dependencies: + loader-utils "^1.2.3" + +"@endiliey/static-site-generator-webpack-plugin@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@endiliey/static-site-generator-webpack-plugin/-/static-site-generator-webpack-plugin-4.0.0.tgz#94bfe58fd83aeda355de797fcb5112adaca3a6b1" + integrity sha512-3MBqYCs30qk1OBRC697NqhGouYbs71D1B8hrk/AFJC6GwF2QaJOQZtA1JYAaGSe650sZ8r5ppRTtCRXepDWlng== + dependencies: + bluebird "^3.7.1" + cheerio "^0.22.0" + eval "^0.1.4" + url "^0.11.0" + webpack-sources "^1.4.3" + +"@exodus/schemasafe@^1.0.0-rc.2": + version "1.0.0-rc.2" + resolved "https://registry.yarnpkg.com/@exodus/schemasafe/-/schemasafe-1.0.0-rc.2.tgz#3a0214ce90709f4d4c19d526890ed2e42b286a66" + integrity sha512-W98NvvOe/Med3o66xTO03pd7a2omZebH79PV64gSE+ceDdU8uxQhFTa7ISiD1kseyqyOrMyW5/MNdsGEU02i3Q== + +"@fortawesome/fontawesome-common-types@^0.2.30", "@fortawesome/fontawesome-common-types@^0.2.35": + version "0.2.35" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.35.tgz#01dd3d054da07a00b764d78748df20daf2b317e9" + integrity sha512-IHUfxSEDS9dDGqYwIW7wTN6tn/O8E0n5PcAHz9cAaBoZw6UpG20IG/YM3NNLaGPwPqgjBAFjIURzqoQs3rrtuw== + +"@fortawesome/fontawesome-svg-core@^1.2.30": + version "1.2.30" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.30.tgz#f56dc6791861fe5d1af04fb8abddb94658c576db" + integrity sha512-E3sAXATKCSVnT17HYmZjjbcmwihrNOCkoU7dVMlasrcwiJAHxSKeZ+4WN5O+ElgO/FaYgJmASl8p9N7/B/RttA== + dependencies: + "@fortawesome/fontawesome-common-types" "^0.2.30" + +"@fortawesome/free-solid-svg-icons@^5.15.3": + version "5.15.3" + resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.3.tgz#52eebe354f60dc77e0bde934ffc5c75ffd04f9d8" + integrity sha512-XPeeu1IlGYqz4VWGRAT5ukNMd4VHUEEJ7ysZ7pSSgaEtNvSo+FLurybGJVmiqkQdK50OkSja2bfZXOeyMGRD8Q== + dependencies: + "@fortawesome/fontawesome-common-types" "^0.2.35" + +"@fortawesome/react-fontawesome@^0.1.13": + version "0.1.13" + resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.13.tgz#ce9654bf4e537108014ec022f2423dbe8114fd62" + integrity sha512-/HrLnIft5Ks2511Pz6TxHBIctC9QalVscAC64sufQ4sJH/sXaQlG3uR9LCu6VpEwkBemgcBLrz/QPNP/ddbjDg== + dependencies: + prop-types "^15.7.2" + +"@francoischalifour/autocomplete-core@^1.0.0-alpha.28": + version "1.0.0-alpha.28" + resolved "https://registry.yarnpkg.com/@francoischalifour/autocomplete-core/-/autocomplete-core-1.0.0-alpha.28.tgz#6b9d8491288e77f831e9b345d461623b0d3f5005" + integrity sha512-rL9x+72btViw+9icfBKUJjZj87FgjFrD2esuTUqtj4RAX3s4AuVZiN8XEsfjQBSc6qJk31cxlvqZHC/BIyYXgg== + +"@francoischalifour/autocomplete-preset-algolia@^1.0.0-alpha.28": + version "1.0.0-alpha.28" + resolved "https://registry.yarnpkg.com/@francoischalifour/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.0.0-alpha.28.tgz#a5ad7996f42e43e4acbb4e0010d663746d0e9997" + integrity sha512-bprfNmYt1opFUFEtD2XfY/kEsm13bzHQgU80uMjhuK0DJ914IjolT1GytpkdM6tJ4MBvyiJPP+bTtWO+BZ7c7w== + +"@hapi/address@2.x.x": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" + integrity sha512-QD1PhQk+s31P1ixsX0H0Suoupp3VMXzIVMSwobR3F3MSUO2YCV0B7xqLcUw/Bh8yuvd3LhpyqLQWTNcRmp6IdQ== + +"@hapi/address@^4.0.1", "@hapi/address@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@hapi/address/-/address-4.1.0.tgz#d60c5c0d930e77456fdcde2598e77302e2955e1d" + integrity sha512-SkszZf13HVgGmChdHo/PxchnSaCJ6cetVqLzyciudzZRT0jcOouIF/Q93mgjw8cce+D+4F4C1Z/WrfFN+O3VHQ== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@hapi/bourne@1.x.x": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-1.3.2.tgz#0a7095adea067243ce3283e1b56b8a8f453b242a" + integrity sha512-1dVNHT76Uu5N3eJNTYcvxee+jzX4Z9lfciqRRHCU27ihbUcYi+iSc2iml5Ke1LXe1SyJCLA0+14Jh4tXJgOppA== + +"@hapi/formula@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@hapi/formula/-/formula-2.0.0.tgz#edade0619ed58c8e4f164f233cda70211e787128" + integrity sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A== + +"@hapi/hoek@8.x.x", "@hapi/hoek@^8.3.0": + version "8.5.1" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-8.5.1.tgz#fde96064ca446dec8c55a8c2f130957b070c6e06" + integrity sha512-yN7kbciD87WzLGc5539Tn0sApjyiGHAJgKvG9W8C7O+6c7qmoQMfVs0W4bX17eqz6C78QJqqFrtgdK5EWf6Qow== + +"@hapi/hoek@^9.0.0": + version "9.1.0" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.1.0.tgz#6c9eafc78c1529248f8f4d92b0799a712b6052c6" + integrity sha512-i9YbZPN3QgfighY/1X1Pu118VUz2Fmmhd6b2n0/O8YVgGGfw0FbUYoA97k7FkpGJ+pLCFEDLUmAPPV4D1kpeFw== + +"@hapi/joi@17.1.1", "@hapi/joi@^17.1.1": + version "17.1.1" + resolved "https://registry.yarnpkg.com/@hapi/joi/-/joi-17.1.1.tgz#9cc8d7e2c2213d1e46708c6260184b447c661350" + integrity sha512-p4DKeZAoeZW4g3u7ZeRo+vCDuSDgSvtsB/NpfjXEHTUjSeINAi/RrVOWiVQ1isaoLzMvFEhe8n5065mQq1AdQg== + dependencies: + "@hapi/address" "^4.0.1" + "@hapi/formula" "^2.0.0" + "@hapi/hoek" "^9.0.0" + "@hapi/pinpoint" "^2.0.0" + "@hapi/topo" "^5.0.0" + +"@hapi/joi@^15.1.0": + version "15.1.1" + resolved "https://registry.yarnpkg.com/@hapi/joi/-/joi-15.1.1.tgz#c675b8a71296f02833f8d6d243b34c57b8ce19d7" + integrity sha512-entf8ZMOK8sc+8YfeOlM8pCfg3b5+WZIKBfUaaJT8UsjAAPjartzxIYm3TIbjvA4u+u++KbcXD38k682nVHDAQ== + dependencies: + "@hapi/address" "2.x.x" + "@hapi/bourne" "1.x.x" + "@hapi/hoek" "8.x.x" + "@hapi/topo" "3.x.x" + +"@hapi/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@hapi/pinpoint/-/pinpoint-2.0.0.tgz#805b40d4dbec04fc116a73089494e00f073de8df" + integrity sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw== + +"@hapi/topo@3.x.x": + version "3.1.6" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-3.1.6.tgz#68d935fa3eae7fdd5ab0d7f953f3205d8b2bfc29" + integrity sha512-tAag0jEcjwH+P2quUfipd7liWCNX2F8NvYjQp2wtInsZxnMlypdw0FtAOLxtvvkO+GSRRbmNi8m/5y42PQJYCQ== + dependencies: + "@hapi/hoek" "^8.3.0" + +"@hapi/topo@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.0.0.tgz#c19af8577fa393a06e9c77b60995af959be721e7" + integrity sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@jest/types@^24.9.0": + version "24.9.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-24.9.0.tgz#63cb26cb7500d069e5a389441a7c6ab5e909fc59" + integrity sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^1.1.1" + "@types/yargs" "^13.0.0" + +"@jest/types@^25.5.0": + version "25.5.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-25.5.0.tgz#4d6a4793f7b9599fc3680877b856a97dbccf2a9d" + integrity sha512-OXD0RgQ86Tu3MazKo8bnrkDRaDXXMGUqd+kTtLtK1Zb7CRzQcaSRPPPV37SvYTdevXEBVxe0HXylEjs8ibkmCw== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^1.1.1" + "@types/yargs" "^15.0.0" + chalk "^3.0.0" + +"@jimp/bmp@^0.9.8": + version "0.9.8" + resolved "https://registry.yarnpkg.com/@jimp/bmp/-/bmp-0.9.8.tgz#5933ab8fb359889bec380b0f7802163374933624" + integrity sha512-CZYQPEC3iUBMuaGWrtIG+GKNl93q/PkdudrCKJR/B96dfNngsmoosEm3LuFgJHEcJIfvnJkNqKw74l+zEiqCbg== + dependencies: + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.8" + bmp-js "^0.1.0" + core-js "^3.4.1" + +"@jimp/core@^0.9.8": + version "0.9.8" + resolved "https://registry.yarnpkg.com/@jimp/core/-/core-0.9.8.tgz#b2b74263a80559c0ee244e0f2d1052b36a358b85" + integrity sha512-N4GCjcXb0QwR5GBABDK2xQ3cKyaF7LlCYeJEG9mV7G/ynBoRqJe4JA6YKU9Ww9imGkci/4A594nQo8tUIqdcBw== + dependencies: + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.8" + any-base "^1.1.0" + buffer "^5.2.0" + core-js "^3.4.1" + exif-parser "^0.1.12" + file-type "^9.0.0" + load-bmfont "^1.3.1" + mkdirp "^0.5.1" + phin "^2.9.1" + pixelmatch "^4.0.2" + tinycolor2 "^1.4.1" + +"@jimp/custom@^0.9.3": + version "0.9.8" + resolved "https://registry.yarnpkg.com/@jimp/custom/-/custom-0.9.8.tgz#1e9d904b1b05aa22b00b899baba2be7c0704a5d1" + integrity sha512-1UpJjI7fhX02BWLJ/KEqPwkHH60eNkCNeD6hEd+IZdTwLXfZCfFiM5BVlpgiZYZJSsVoRiAL4ne2Q5mCiKPKyw== + dependencies: + "@babel/runtime" "^7.7.2" + "@jimp/core" "^0.9.8" + core-js "^3.4.1" + +"@jimp/gif@^0.9.8": + version "0.9.8" + resolved "https://registry.yarnpkg.com/@jimp/gif/-/gif-0.9.8.tgz#513aff511634c338d1ab33a7bba1ba3412220b5b" + integrity sha512-LEbfpcO1sBJIQCJHchZjNlyNxzPjZQQ4X32klpQHZJG58n9FvL7Uuh1rpkrJRbqv3cU3P0ENNtTrsBDxsYwcfA== + dependencies: + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.8" + core-js "^3.4.1" + omggif "^1.0.9" + +"@jimp/jpeg@^0.9.8": + version "0.9.8" + resolved "https://registry.yarnpkg.com/@jimp/jpeg/-/jpeg-0.9.8.tgz#8c086f69d0e8c46e43a7db9725576edc30925cb1" + integrity sha512-5u29SUzbZ32ZMmOaz3gO0hXatwSCnsvEAXRCKZoPPgbsPoyFAiZKVxjfLzjkeQF6awkvJ8hZni5chM15SNMg+g== + dependencies: + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.8" + core-js "^3.4.1" + jpeg-js "^0.3.4" + +"@jimp/plugin-resize@^0.9.3": + version "0.9.8" + resolved "https://registry.yarnpkg.com/@jimp/plugin-resize/-/plugin-resize-0.9.8.tgz#eef750b77f1cc06e8bcf9b390860c95c489dcc02" + integrity sha512-L80NZ+HKsiKFyeDc6AfneC4+5XACrdL2vnyAVfAAsb3pmamgT/jDInWvvGhyI0Y76vx2w6XikplzEznW/QQvWg== + dependencies: + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.8" + core-js "^3.4.1" + +"@jimp/png@^0.9.8": + version "0.9.8" + resolved "https://registry.yarnpkg.com/@jimp/png/-/png-0.9.8.tgz#f88dacc9b9da1c2ea8e91026a9530d0fb45c4409" + integrity sha512-9CqR8d40zQCDhbnXHqcwkAMnvlV0vk9xSyE6LHjkYHS7x18Unsz5txQdsaEkEcXxCrOQSoWyITfLezlrWXRJAA== + dependencies: + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.8" + core-js "^3.4.1" + pngjs "^3.3.3" + +"@jimp/tiff@^0.9.8": + version "0.9.8" + resolved "https://registry.yarnpkg.com/@jimp/tiff/-/tiff-0.9.8.tgz#91dc3eab2f222e23414f139e917f3407caa73560" + integrity sha512-eMxcpJivJqMByn2dZxUHLeh6qvVs5J/52kBF3TFa3C922OJ97D9l1C1h0WKUCBqFMWzMYapQQ4vwnLgpJ5tkow== + dependencies: + "@babel/runtime" "^7.7.2" + core-js "^3.4.1" + utif "^2.0.1" + +"@jimp/types@^0.9.3": + version "0.9.8" + resolved "https://registry.yarnpkg.com/@jimp/types/-/types-0.9.8.tgz#46980a4a7bfcadf2f0484d187c32b4e7d6d61b8e" + integrity sha512-H5y/uqt0lqJ/ZN8pWqFG+pv8jPAppMKkTMByuC8YBIjWSsornwv44hjiWl93sbYhduLZY8ubz/CbX9jH2X6EwA== + dependencies: + "@babel/runtime" "^7.7.2" + "@jimp/bmp" "^0.9.8" + "@jimp/gif" "^0.9.8" + "@jimp/jpeg" "^0.9.8" + "@jimp/png" "^0.9.8" + "@jimp/tiff" "^0.9.8" + core-js "^3.4.1" + timm "^1.6.1" + +"@jimp/utils@^0.9.8": + version "0.9.8" + resolved "https://registry.yarnpkg.com/@jimp/utils/-/utils-0.9.8.tgz#6a6f47158ec6b424f03df0f55f0baff5b4b5e096" + integrity sha512-UK0Fu0eevQlpRXq5ff4o/71HJlpX9wJMddJjMYg9vUqCCl8ZnumRAljfShHFhGyO+Vc9IzN6dd8Y5JZZTp1KOw== + dependencies: + "@babel/runtime" "^7.7.2" + core-js "^3.4.1" + +"@lunelson/sass-calc@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@lunelson/sass-calc/-/sass-calc-1.2.0.tgz#7880a17cea6631f7e5c63315617dd2708809b2c5" + integrity sha512-KFX7k4fbGZsFj9MGvIbdMwLARex+pUASZFEMn8BjTGcqtJQQVWMF89O9nQ0zpBAfWvmopmIIqHvk31xD4+YgyQ== + +"@lunelson/sass-lerp@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@lunelson/sass-lerp/-/sass-lerp-1.0.0.tgz#807a2a658dadd038b25fa658aa8ec3c63dface47" + integrity sha512-nNrfgF53/WaRtUHTP1+bhaC51SYznFw9lnjmoU1FHK7OR64iEeVoyNUpj8TN4tsWMiUi+oTWkofB383NZ7MHHA== + +"@lunelson/sass-maps-next@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@lunelson/sass-maps-next/-/sass-maps-next-1.0.0.tgz#8888d909264928fd777473b320e60e47e0beb132" + integrity sha512-xPmvDj9gCF5lUxV4B/NJhHjyDjTHWNWNXFKm9G/nYQ/jEcYRPVkE5l7VDXQZb0HDKVqpLNrPfbtU+PxAACiCsQ== + +"@lunelson/sass-throw@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@lunelson/sass-throw/-/sass-throw-2.1.0.tgz#37c5ba3ae36d454cecfcd53d34651367f48ce964" + integrity sha512-8ntqqHvSlqCqdR4M5wlMKVMj/lJ+oIN+qi8226F1ald7y16O9IIgPNUKKWSHpvlNwkykiE3MX9V/wgOshSYnyQ== + +"@lunelson/sass-u@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@lunelson/sass-u/-/sass-u-0.11.0.tgz#6bc30d6ecad00058efecabc3689c11a0b8302eb5" + integrity sha512-uEeJ+v6xbQxHvFdF+AaG1zkTwX+Iu1KBDUNgFARmRR5RA1ffdAKWgSAOx0AOmoozeDtOGOQcQjOF9txpz+ob0w== + +"@mdx-js/mdx@^1.5.8", "@mdx-js/mdx@^1.6.22": + version "1.6.22" + resolved "https://registry.yarnpkg.com/@mdx-js/mdx/-/mdx-1.6.22.tgz#8a723157bf90e78f17dc0f27995398e6c731f1ba" + integrity sha512-AMxuLxPz2j5/6TpF/XSdKpQP1NlG0z11dFOlq+2IP/lSgl11GY8ji6S/rgsViN/L0BDvHvUMruRb7ub+24LUYA== + dependencies: + "@babel/core" "7.12.9" + "@babel/plugin-syntax-jsx" "7.12.1" + "@babel/plugin-syntax-object-rest-spread" "7.8.3" + "@mdx-js/util" "1.6.22" + babel-plugin-apply-mdx-type-prop "1.6.22" + babel-plugin-extract-import-names "1.6.22" + camelcase-css "2.0.1" + detab "2.0.4" + hast-util-raw "6.0.1" + lodash.uniq "4.5.0" + mdast-util-to-hast "10.0.1" + remark-footnotes "2.0.0" + remark-mdx "1.6.22" + remark-parse "8.0.3" + remark-squeeze-paragraphs "4.0.0" + style-to-object "0.3.0" + unified "9.2.0" + unist-builder "2.0.3" + unist-util-visit "2.0.3" + +"@mdx-js/react@^1.5.8", "@mdx-js/react@^1.6.22": + version "1.6.22" + resolved "https://registry.yarnpkg.com/@mdx-js/react/-/react-1.6.22.tgz#ae09b4744fddc74714ee9f9d6f17a66e77c43573" + integrity sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg== + +"@mdx-js/util@1.6.22": + version "1.6.22" + resolved "https://registry.yarnpkg.com/@mdx-js/util/-/util-1.6.22.tgz#219dfd89ae5b97a8801f015323ffa4b62f45718b" + integrity sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA== + +"@mrmlnc/readdir-enhanced@^2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" + integrity sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g== + dependencies: + call-me-maybe "^1.0.1" + glob-to-regexp "^0.3.0" + +"@netlify/build@^12.10.0": + version "12.10.0" + resolved "https://registry.yarnpkg.com/@netlify/build/-/build-12.10.0.tgz#db3b60e003c6e8796a479eacf7aebcde5b2a76b3" + integrity sha512-cVyx/k1OvFmO3lghyuJsjfkcSvF2z4u7A5ibllo4qvUqPXO8KPeqP9OcHuQgJ/e7TkxWf+iMMubKDOgrusR3qQ== + dependencies: + "@bugsnag/js" "^7.0.0" + "@netlify/cache-utils" "^1.0.7" + "@netlify/config" "^9.0.0" + "@netlify/functions-utils" "^1.3.13" + "@netlify/git-utils" "^1.0.8" + "@netlify/plugin-edge-handlers" "^1.11.19" + "@netlify/plugins-list" "^2.17.0" + "@netlify/run-utils" "^1.0.6" + "@netlify/zip-it-and-ship-it" "4.4.2" + "@sindresorhus/slugify" "^1.1.0" + "@ungap/from-entries" "^0.2.1" + ansi-escapes "^4.3.2" + array-flat-polyfill "^1.0.1" + chalk "^3.0.0" + clean-stack "^2.2.0" + dot-prop "^5.3.0" + execa "^3.3.0" + figures "^3.2.0" + filter-obj "^2.0.1" + got "^9.6.0" + indent-string "^4.0.0" + is-plain-obj "^2.1.0" + js-yaml "^4.0.0" + keep-func-props "^3.0.0" + locate-path "^5.0.0" + log-process-errors "^5.1.2" + make-dir "^3.0.2" + map-obj "^4.0.0" + memoize-one "^5.2.1" + os-name "^3.1.0" + p-event "^4.1.0" + p-every "^2.0.0" + p-locate "^4.1.0" + p-reduce "^2.1.0" + path-exists "^4.0.0" + path-type "^4.0.0" + pkg-dir "^4.2.0" + pretty-ms "^5.1.0" + ps-list "^6.3.0" + read-pkg-up "^7.0.1" + readdirp "^3.4.0" + resolve "^2.0.0-next.1" + safe-json-stringify "^1.2.0" + semver "^6.3.0" + statsd-client "0.4.7" + string-width "^4.2.0" + strip-ansi "^6.0.0" + supports-color "^7.1.0" + tmp-promise "^3.0.2" + update-notifier "^4.1.0" + uuid "^8.0.0" + yargs "^15.3.1" + +"@netlify/cache-utils@^1.0.7": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@netlify/cache-utils/-/cache-utils-1.0.7.tgz#edbb2fbf15882f7fbcd51ca74b74779cadaa56f8" + integrity sha512-yrdrnQkzg/qMovoFYwQ24UVt/OyHtP+t0KpQFd7eBl6gnuuGGgxFocaFFv6eKpMVwzHTsOwx/y9B/FcC3/6cfA== + dependencies: + array-flat-polyfill "^1.0.1" + cpy "^8.1.0" + del "^5.1.0" + get-stream "^5.1.0" + global-cache-dir "^1.0.1" + globby "^10.0.2" + locate-path "^5.0.0" + make-dir "^3.1.0" + move-file "^1.2.0" + path-exists "^4.0.0" + readdirp "^3.4.0" + +"@netlify/config@^9.0.0", "@netlify/config@^9.1.0": + version "9.1.0" + resolved "https://registry.yarnpkg.com/@netlify/config/-/config-9.1.0.tgz#cbcdadd417597ea640fe605bc8250d9744dc5dc2" + integrity sha512-XP3z4bm/D5KsZZB85lzgciJp6v2tF3jxJaeuq7hB7bEfVaHqGJfHesalhrqEXjB9lATD63UR6h7YmzRq2DmIpQ== + dependencies: + "@ungap/from-entries" "^0.2.1" + array-flat-polyfill "^1.0.1" + chalk "^3.0.0" + deepmerge "^4.2.2" + dot-prop "^5.3.0" + execa "^3.4.0" + fast-safe-stringify "^2.0.7" + figures "^3.2.0" + filter-obj "^2.0.1" + find-up "^4.1.0" + indent-string "^4.0.0" + is-plain-obj "^2.1.0" + js-yaml "^4.0.0" + make-dir "^3.1.0" + map-obj "^4.0.0" + netlify "^7.0.1" + netlify-redirect-parser "^8.0.0" + omit.js "^2.0.2" + p-locate "^4.1.0" + path-exists "^4.0.0" + path-type "^4.0.0" + toml "^3.0.0" + tomlify-j0.4 "^3.0.0" + validate-npm-package-name "^3.0.0" + yargs "^15.3.0" + +"@netlify/esbuild@^0.13.6": + version "0.13.6" + resolved "https://registry.yarnpkg.com/@netlify/esbuild/-/esbuild-0.13.6.tgz#ef0fda98604e708528ef0a57e853c50a6fc987f2" + integrity sha512-tiKmDcHM2riSVN79c0mJY/67EBDafXQAMitHuLiCDAMdtz3kfv+NqdVG5krgf5lWR8Uf8AeZrUW5Q9RP25REvw== + +"@netlify/framework-info@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@netlify/framework-info/-/framework-info-5.4.0.tgz#8c5bc9f6d1405299b24da9ad6c0b5188ca24f093" + integrity sha512-eelZCGCm0F3wUClqSfKqeYXHn/hHyHzm+QhMBr3iBVcOQI7uujfwXjq5mS1gD3quJHF3jGrJ3QgzPa9X5v5Fwg== + dependencies: + ajv "^8.0.0" + filter-obj "^2.0.1" + is-plain-obj "^3.0.0" + locate-path "^6.0.0" + p-filter "^2.1.0" + p-locate "^5.0.0" + read-pkg-up "^7.0.1" + semver "^7.3.4" + +"@netlify/functions-utils@^1.3.13": + version "1.3.47" + resolved "https://registry.yarnpkg.com/@netlify/functions-utils/-/functions-utils-1.3.47.tgz#bb74db6b311e9ca5dc698d5feb515388ad7fb191" + integrity sha512-lJoa3aIuyBiPNo7XawtGQRTDFCmO9wSpoVDhO+e6t2YoCoCFXlJxZulZdalJ+JnRphlEMKHCMA3jeaVhVjjHuQ== + dependencies: + "@netlify/zip-it-and-ship-it" "4.4.2" + cpy "^8.1.0" + path-exists "^4.0.0" + +"@netlify/git-utils@^1.0.8": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@netlify/git-utils/-/git-utils-1.0.11.tgz#bea71ca324f81a5f438e6e8e8d93bb324121862c" + integrity sha512-bvlvFAB9VU3wTYYEEUinsOeRFxZ/MmetffzHehSMEyP00kXakvrySq4XbC6G8u3wCDln34eOjKDt8uPYoqfuNQ== + dependencies: + execa "^3.4.0" + map-obj "^4.0.0" + micromatch "^4.0.2" + moize "^6.0.0" + path-exists "^4.0.0" + +"@netlify/open-api@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@netlify/open-api/-/open-api-2.5.0.tgz#d0d947ec21a0a8e8dd46f542c60b6cb25609b0f2" + integrity sha512-KiXfYPO/X24p7EYQjcjBTizoyfY3U8zPv68Rte0EtayW2ZSqIslLLpNNd2gteqdh0Q83mzSiESdhlQHd0Ckjjg== + +"@netlify/plugin-edge-handlers@^1.11.19": + version "1.11.19" + resolved "https://registry.yarnpkg.com/@netlify/plugin-edge-handlers/-/plugin-edge-handlers-1.11.19.tgz#cad2547190e453c6fd6f1aa01c52cae7eeba97ef" + integrity sha512-SAsVNIxF0asduPXbsr3wbyAB7lPZQxhG90EeOu0/Fo+BLhpO4g+6eH13I60qJpWCI/ucXXqSrUw8y6Oy5wpY0g== + dependencies: + "@babel/core" "^7.11.4" + "@babel/preset-env" "^7.11.5" + "@rollup/plugin-babel" "^5.2.0" + "@rollup/plugin-commonjs" "^18.0.0" + "@rollup/plugin-inject" "^4.0.2" + "@rollup/plugin-json" "^4.1.0" + "@rollup/plugin-node-resolve" "^11.0.0" + "@types/node" "^14.0.27" + buffer-es6 "^4.9.3" + del "^6.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.1" + path-type "^4.0.0" + process-es6 "^0.11.6" + rollup "^2.23.1" + rollup-plugin-node-polyfills "^0.2.1" + rollup-plugin-terser "^7.0.2" + typescript "^4.1.5" + +"@netlify/plugins-list@^2.17.0", "@netlify/plugins-list@^2.18.0": + version "2.18.0" + resolved "https://registry.yarnpkg.com/@netlify/plugins-list/-/plugins-list-2.18.0.tgz#6ffb8b8824c9c691f411ddc25d53b2cfa302aee1" + integrity sha512-lbI7P0CaSH23r2sF4z3DX02PqQ8ENUtRullQXEQXUareZfrXlOV6F8sl/LAWcgjE/kuh51eIAu/hWstsZFEQIQ== + +"@netlify/routing-local-proxy-darwin-arm64@^0.30.1": + version "0.30.1" + resolved "https://registry.yarnpkg.com/@netlify/routing-local-proxy-darwin-arm64/-/routing-local-proxy-darwin-arm64-0.30.1.tgz#fea52b0488805ed32458aed138b28a67221e3869" + integrity sha512-i7UZsD0P0w9ZQGxrbNCPTaT0mrkPwAVCHEI5uHFIpAERahSAJbz4NYY8P1ubdYOdxjFXyGX3l5lTOvrpcc/mug== + +"@netlify/routing-local-proxy-darwin-x64@^0.30.1": + version "0.30.1" + resolved "https://registry.yarnpkg.com/@netlify/routing-local-proxy-darwin-x64/-/routing-local-proxy-darwin-x64-0.30.1.tgz#49580cdd786c44d4dbefb0665b8ee8837112751b" + integrity sha512-Jl8nPH/dkCUfSF4WuQss23gMMyTjgh4cqhCqlw5mfmp3+r1sdy7i346kY15rshPbBs+SrFbmxDfhk2zfqBV7nQ== + +"@netlify/routing-local-proxy-linux-x64@^0.30.1": + version "0.30.1" + resolved "https://registry.yarnpkg.com/@netlify/routing-local-proxy-linux-x64/-/routing-local-proxy-linux-x64-0.30.1.tgz#8b8f89c3e2bf17f6ac176408fd6cc1f61dd2d3ab" + integrity sha512-fbwCrZ9RRxzxxrtAh3u4y+nzbpcPDwVLQf1pdO9OPs3D5C95Fju2FgYVW8pnPr/E96CzWb0l8rSMZl7R507iNA== + +"@netlify/routing-local-proxy-win32-x64@^0.30.1": + version "0.30.1" + resolved "https://registry.yarnpkg.com/@netlify/routing-local-proxy-win32-x64/-/routing-local-proxy-win32-x64-0.30.1.tgz#b74dc956acaf11ca8710c03707aaaea4c3b37c1c" + integrity sha512-Adgz3O3RFjk1DgoyY7G21bMyrssTWaeD2942qgO8Kp33AJDsQ4vJbyflmmdWtYms2hKP29eu/0LJAziKG65/xw== + +"@netlify/routing-local-proxy@^0.30.1": + version "0.30.1" + resolved "https://registry.yarnpkg.com/@netlify/routing-local-proxy/-/routing-local-proxy-0.30.1.tgz#ee046add2a75af72fe5c34411ad298cd30307b57" + integrity sha512-9IfNBXDJA2JLvHA5GRDTpOgPpU3dovL8ezB1+igxvtinnl4Nnl9G4D9N6leqf1ecC6aZrx9St4OATqVwY2LryQ== + optionalDependencies: + "@netlify/routing-local-proxy-darwin-arm64" "^0.30.1" + "@netlify/routing-local-proxy-darwin-x64" "^0.30.1" + "@netlify/routing-local-proxy-linux-x64" "^0.30.1" + "@netlify/routing-local-proxy-win32-x64" "^0.30.1" + +"@netlify/run-utils@^1.0.6": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@netlify/run-utils/-/run-utils-1.0.7.tgz#a03ef3524faf17b8f11989724803c6177869e593" + integrity sha512-YFi1Sf+ktQICS3tAKu7/uiGzLXgi8RNVwH9naUkziXwXQNH2oxDhKgy0/Zv5Nw0zMDJyKWrJ3xObWEC57mJ/KA== + dependencies: + execa "^3.4.0" + +"@netlify/zip-it-and-ship-it@4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@netlify/zip-it-and-ship-it/-/zip-it-and-ship-it-4.4.2.tgz#fed055d932a8b3d500f73a648a5d6d277787815f" + integrity sha512-627ZNqfc4mdhU5mYDpcKUbmx8RoAUu8ZQiW/RIt5s7uDhruba3bK776Ygzog5b14hYWthT41/bf+9wQ+O9NFWw== + dependencies: + "@netlify/esbuild" "^0.13.6" + archiver "^5.3.0" + array-flat-polyfill "^1.0.1" + common-path-prefix "^3.0.0" + cp-file "^9.0.0" + del "^6.0.0" + elf-cam "^0.1.1" + end-of-stream "^1.4.4" + filter-obj "^2.0.1" + find-up "^5.0.0" + glob "^7.1.6" + junk "^3.1.0" + locate-path "^6.0.0" + make-dir "^3.1.0" + merge-options "^3.0.4" + minimatch "^3.0.4" + p-map "^4.0.0" + path-exists "^4.0.0" + pkg-dir "^5.0.0" + precinct "^8.0.0" + read-package-json-fast "^2.0.2" + require-package-name "^2.0.1" + resolve "^2.0.0-next.1" + semver "^7.0.0" + unixify "^1.0.0" + yargs "^16.0.0" + +"@nodelib/fs.scandir@2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b" + integrity sha512-eGmwYQn3gxo4r7jdQnkrrN6bY478C3P+a/y72IJukF8LjB6ZHeB3c+Ehacj3sYeSmUXGlnA67/PmbM9CVwL7Dw== + dependencies: + "@nodelib/fs.stat" "2.0.3" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.3", "@nodelib/fs.stat@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.3.tgz#34dc5f4cabbc720f4e60f75a747e7ecd6c175bd3" + integrity sha512-bQBFruR2TAwoevBEd/NWMoAAtNGzTRgdrqnYCc7dhzfoNvqPzLyqlEQnzZ3kVnNrSp25iyxE00/3h2fqGAGArA== + +"@nodelib/fs.stat@^1.1.2": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b" + integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.4.tgz#011b9202a70a6366e436ca5c065844528ab04976" + integrity sha512-1V9XOY4rDW0rehzbrcqAmHnz8e7SKvX27gh8Gt2WgB0+pdzdiLV83p72kZPU+jvMbS1qU5mauP2iOvO8rhmurQ== + dependencies: + "@nodelib/fs.scandir" "2.1.3" + fastq "^1.6.0" + +"@npmcli/move-file@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-1.0.1.tgz#de103070dac0f48ce49cf6693c23af59c0f70464" + integrity sha512-Uv6h1sT+0DrblvIrolFtbvM1FgWm+/sy4B3pvLp67Zys+thcukzS5ekn7HsZFGpWP4Q3fYJCljbWQE/XivMRLw== + dependencies: + mkdirp "^1.0.4" + +"@oclif/color@^0.x": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@oclif/color/-/color-0.1.2.tgz#28b07e2850d9ce814d0b587ce3403b7ad8f7d987" + integrity sha512-M9o+DOrb8l603qvgz1FogJBUGLqcMFL1aFg2ZEL0FbXJofiNTLOWIeB4faeZTLwE6dt0xH9GpCVpzksMMzGbmA== + dependencies: + ansi-styles "^3.2.1" + chalk "^3.0.0" + strip-ansi "^5.2.0" + supports-color "^5.4.0" + tslib "^1" + +"@oclif/command@^1", "@oclif/command@^1.5.12", "@oclif/command@^1.5.20", "@oclif/command@^1.6.0", "@oclif/command@^1.6.1": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@oclif/command/-/command-1.8.0.tgz#c1a499b10d26e9d1a611190a81005589accbb339" + integrity sha512-5vwpq6kbvwkQwKqAoOU3L72GZ3Ta8RRrewKj9OJRolx28KLJJ8Dg9Rf7obRwt5jQA9bkYd8gqzMTrI7H3xLfaw== + dependencies: + "@oclif/config" "^1.15.1" + "@oclif/errors" "^1.3.3" + "@oclif/parser" "^3.8.3" + "@oclif/plugin-help" "^3" + debug "^4.1.1" + semver "^7.3.2" + +"@oclif/config@^1", "@oclif/config@^1.15.1": + version "1.17.0" + resolved "https://registry.yarnpkg.com/@oclif/config/-/config-1.17.0.tgz#ba8639118633102a7e481760c50054623d09fcab" + integrity sha512-Lmfuf6ubjQ4ifC/9bz1fSCHc6F6E653oyaRXxg+lgT4+bYf9bk+nqrUpAbrXyABkCqgIBiFr3J4zR/kiFdE1PA== + dependencies: + "@oclif/errors" "^1.3.3" + "@oclif/parser" "^3.8.0" + debug "^4.1.1" + globby "^11.0.1" + is-wsl "^2.1.1" + tslib "^2.0.0" + +"@oclif/errors@^1.2.1", "@oclif/errors@^1.2.2", "@oclif/errors@^1.3.3", "@oclif/errors@^1.3.4": + version "1.3.4" + resolved "https://registry.yarnpkg.com/@oclif/errors/-/errors-1.3.4.tgz#a96f94536b4e25caa72eff47e8b3ed04f6995f55" + integrity sha512-pJKXyEqwdfRTUdM8n5FIHiQQHg5ETM0Wlso8bF9GodczO40mF5Z3HufnYWJE7z8sGKxOeJCdbAVZbS8Y+d5GCw== + dependencies: + clean-stack "^3.0.0" + fs-extra "^8.1" + indent-string "^4.0.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +"@oclif/linewrap@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@oclif/linewrap/-/linewrap-1.0.0.tgz#aedcb64b479d4db7be24196384897b5000901d91" + integrity sha512-Ups2dShK52xXa8w6iBWLgcjPJWjais6KPJQq3gQ/88AY6BXoTX+MIGFPrWQO1KLMiQfoTpcLnUwloN4brrVUHw== + +"@oclif/parser@^3.8.0", "@oclif/parser@^3.8.3", "@oclif/parser@^3.8.4": + version "3.8.5" + resolved "https://registry.yarnpkg.com/@oclif/parser/-/parser-3.8.5.tgz#c5161766a1efca7343e1f25d769efbefe09f639b" + integrity sha512-yojzeEfmSxjjkAvMRj0KzspXlMjCfBzNRPkWw8ZwOSoNWoJn+OCS/m/S+yfV6BvAM4u2lTzX9Y5rCbrFIgkJLg== + dependencies: + "@oclif/errors" "^1.2.2" + "@oclif/linewrap" "^1.0.0" + chalk "^2.4.2" + tslib "^1.9.3" + +"@oclif/plugin-help@^3", "@oclif/plugin-help@^3.0.0": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@oclif/plugin-help/-/plugin-help-3.2.2.tgz#063ee08cee556573a5198fbdfdaa32796deba0ed" + integrity sha512-SPZ8U8PBYK0n4srFjCLedk0jWU4QlxgEYLCXIBShJgOwPhTTQknkUlsEwaMIevvCU4iCQZhfMX+D8Pz5GZjFgA== + dependencies: + "@oclif/command" "^1.5.20" + "@oclif/config" "^1.15.1" + "@oclif/errors" "^1.2.2" + chalk "^4.1.0" + indent-string "^4.0.0" + lodash.template "^4.4.0" + string-width "^4.2.0" + strip-ansi "^6.0.0" + widest-line "^3.1.0" + wrap-ansi "^4.0.0" + +"@oclif/plugin-not-found@^1.1.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@oclif/plugin-not-found/-/plugin-not-found-1.2.4.tgz#160108c82f0aa10f4fb52cee4e0135af34b7220b" + integrity sha512-G440PCuMi/OT8b71aWkR+kCWikngGtyRjOR24sPMDbpUFV4+B3r51fz1fcqeUiiEOYqUpr0Uy/sneUe1O/NfBg== + dependencies: + "@oclif/color" "^0.x" + "@oclif/command" "^1.6.0" + cli-ux "^4.9.0" + fast-levenshtein "^2.0.6" + lodash "^4.17.13" + +"@oclif/plugin-plugins@^1.9.3": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@oclif/plugin-plugins/-/plugin-plugins-1.10.1.tgz#13666d7c2f591a77f7a16334feee59f9eb216eb1" + integrity sha512-JDUA3NtOa4OlH8ofUBXQMTFlpEkSmeE9BxoQTD6+BeUvMgqFuZThENucRvCD00sywhCmDngmIYN59gKcXpGJeQ== + dependencies: + "@oclif/color" "^0.x" + "@oclif/command" "^1.5.12" + "@oclif/errors" "^1.2.2" + chalk "^4.1.0" + cli-ux "^5.2.1" + debug "^4.1.0" + fs-extra "^9.0" + http-call "^5.2.2" + load-json-file "^5.2.0" + npm-run-path "^4.0.1" + semver "^7.3.2" + tslib "^2.0.0" + yarn "^1.21.1" + +"@oclif/screen@^1.0.3": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@oclif/screen/-/screen-1.0.4.tgz#b740f68609dfae8aa71c3a6cab15d816407ba493" + integrity sha512-60CHpq+eqnTxLZQ4PGHYNwUX572hgpMHGPtTWMjdTMsAvlm69lZV/4ly6O3sAYkomo4NggGcomrDpBe34rxUqw== + +"@octokit/auth-token@^2.4.4": + version "2.4.5" + resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-2.4.5.tgz#568ccfb8cb46f36441fac094ce34f7a875b197f3" + integrity sha512-BpGYsPgJt05M7/L/5FoE1PiAbdxXFZkX/3kDYcsvd1v6UhlnE5e96dTDr0ezX/EFwciQxf3cNV0loipsURU+WA== + dependencies: + "@octokit/types" "^6.0.3" + +"@octokit/core@^3.5.0": + version "3.5.1" + resolved "https://registry.yarnpkg.com/@octokit/core/-/core-3.5.1.tgz#8601ceeb1ec0e1b1b8217b960a413ed8e947809b" + integrity sha512-omncwpLVxMP+GLpLPgeGJBF6IWJFjXDS5flY5VbppePYX9XehevbDykRH9PdCdvqt9TS5AOTiDide7h0qrkHjw== + dependencies: + "@octokit/auth-token" "^2.4.4" + "@octokit/graphql" "^4.5.8" + "@octokit/request" "^5.6.0" + "@octokit/request-error" "^2.0.5" + "@octokit/types" "^6.0.3" + before-after-hook "^2.2.0" + universal-user-agent "^6.0.0" + +"@octokit/endpoint@^6.0.1": + version "6.0.5" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-6.0.5.tgz#43a6adee813c5ffd2f719e20cfd14a1fee7c193a" + integrity sha512-70K5u6zd45ItOny6aHQAsea8HHQjlQq85yqOMe+Aj8dkhN2qSJ9T+Q3YjUjEYfPRBcuUWNgMn62DQnP/4LAIiQ== + dependencies: + "@octokit/types" "^5.0.0" + is-plain-object "^4.0.0" + universal-user-agent "^6.0.0" + +"@octokit/graphql@^4.5.8": + version "4.6.4" + resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-4.6.4.tgz#0c3f5bed440822182e972317122acb65d311a5ed" + integrity sha512-SWTdXsVheRmlotWNjKzPOb6Js6tjSqA2a8z9+glDJng0Aqjzti8MEWOtuT8ZSu6wHnci7LZNuarE87+WJBG4vg== + dependencies: + "@octokit/request" "^5.6.0" + "@octokit/types" "^6.0.3" + universal-user-agent "^6.0.0" + +"@octokit/openapi-types@^7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-7.3.5.tgz#ffd195472f345b32fb29ba3c86a1ee68c09f24b3" + integrity sha512-6bm5lzGDOeSnWHM5W8OZ86RD2KpchynU+/Qlm5hNEFjfLDhwfAY2lSe68YRUEYFGlxSHe0HmakyhvmtWoD3Zog== + +"@octokit/plugin-paginate-rest@^2.6.2": + version "2.13.5" + resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.13.5.tgz#e459f9b5dccbe0a53f039a355d5b80c0a2b0dc57" + integrity sha512-3WSAKBLa1RaR/7GG+LQR/tAZ9fp9H9waE9aPXallidyci9oZsfgsLn5M836d3LuDC6Fcym+2idRTBpssHZePVg== + dependencies: + "@octokit/types" "^6.13.0" + +"@octokit/plugin-request-log@^1.0.2": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz#5e50ed7083a613816b1e4a28aeec5fb7f1462e85" + integrity sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA== + +"@octokit/plugin-rest-endpoint-methods@5.3.4": + version "5.3.4" + resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-5.3.4.tgz#291dea5af9eecf4d457884d9efbcf72aabbaff57" + integrity sha512-2Y2q/FYCsW5tcwIqgnLOgzZXEb3I1VoSQGyHs/Zki/Ufs5H+uT0maPVHatLKw90LQbqK7ON8NpL3Y8IyzG6pNA== + dependencies: + "@octokit/types" "^6.16.7" + deprecation "^2.3.1" + +"@octokit/request-error@^2.0.5", "@octokit/request-error@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-2.1.0.tgz#9e150357831bfc788d13a4fd4b1913d60c74d677" + integrity sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg== + dependencies: + "@octokit/types" "^6.0.3" + deprecation "^2.0.0" + once "^1.4.0" + +"@octokit/request@^5.6.0": + version "5.6.0" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.6.0.tgz#6084861b6e4fa21dc40c8e2a739ec5eff597e672" + integrity sha512-4cPp/N+NqmaGQwbh3vUsYqokQIzt7VjsgTYVXiwpUP2pxd5YiZB2XuTedbb0SPtv9XS7nzAKjAuQxmY8/aZkiA== + dependencies: + "@octokit/endpoint" "^6.0.1" + "@octokit/request-error" "^2.1.0" + "@octokit/types" "^6.16.1" + is-plain-object "^5.0.0" + node-fetch "^2.6.1" + universal-user-agent "^6.0.0" + +"@octokit/rest@^18.0.0": + version "18.6.3" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-18.6.3.tgz#0283de05d5dbc9d3adcacb208c055db03000abf9" + integrity sha512-BeV2P48RR3MVPhSBq6KXXHMVHEJg5vnFBkFN1GKPXBohXTp+eb0gJq+5iYgkjbOMG6biNvkKllPDWJchpQHHiA== + dependencies: + "@octokit/core" "^3.5.0" + "@octokit/plugin-paginate-rest" "^2.6.2" + "@octokit/plugin-request-log" "^1.0.2" + "@octokit/plugin-rest-endpoint-methods" "5.3.4" + +"@octokit/types@^5.0.0": + version "5.4.1" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-5.4.1.tgz#d5d5f2b70ffc0e3f89467c3db749fa87fc3b7031" + integrity sha512-OlMlSySBJoJ6uozkr/i03nO5dlYQyE05vmQNZhAh9MyO4DPBP88QlwsDVLmVjIMFssvIZB6WO0ctIGMRG+xsJQ== + dependencies: + "@types/node" ">= 8" + +"@octokit/types@^6.0.3", "@octokit/types@^6.13.0", "@octokit/types@^6.16.1", "@octokit/types@^6.16.7": + version "6.16.7" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.16.7.tgz#f27e87daf7ed87bde3e67b2e71956468fa4d02a7" + integrity sha512-OuQELiwIKeDySgNID52vm33wDRc2aaX8lKYgAw9Hmw939ITow1HspT8/AH3M3jgGFUMDmHlMNBNEmH7xV7ggXQ== + dependencies: + "@octokit/openapi-types" "^7.3.5" + +"@rasahq/docusaurus-theme-tabula@^0.8.3": + version "0.8.3" + resolved "https://registry.yarnpkg.com/@rasahq/docusaurus-theme-tabula/-/docusaurus-theme-tabula-0.8.3.tgz#42169bbb334a8efaaafb87f3daac283fe879a0e6" + integrity sha512-2IYgcp00jNDzbv/qd7Gxyg0UGfgFbqaA0E1PEhWFHc2d5Q44WyyvT8lyLazoiGxODCKRh6j33hp5xiIDjjJ98A== + dependencies: + clsx "^1.1.1" + copy-text-to-clipboard "^2.2.0" + fibers "^5.0.0" + joi "^17.1.1" + lodash "^4.17.19" + parse-numeric-range "^0.0.2" + postcss-preset-env "^6.7.0" + postcss-pseudo-any "^1.0.1" + prism-react-renderer "^1.1.0" + prismjs "^1.20.0" + prop-types "^15.7.2" + react-toggle "^4.1.1" + rehype-figure "^1.0.1" + remark-abbr "^1.4.0" + remark-collapse "^0.1.2" + remark-images "^2.0.0" + remark-kbd "^1.0.21" + remark-mark-plus "^1.0.21" + remark-sources "^1.0.3" + remark-unwrap-images "^2.0.0" + +"@redocly/react-dropdown-aria@^2.0.11": + version "2.0.11" + resolved "https://registry.yarnpkg.com/@redocly/react-dropdown-aria/-/react-dropdown-aria-2.0.11.tgz#532b864b329237e646abe45d0f8edc923e77370a" + integrity sha512-rmuSC2JFFl4DkPDdGVrmffT9KcbG2AB5jvhxPIrOc1dO9mHRMUUftQY35KZlvWqqSSqVn+AM+J9dhiTo1ZqR8A== + +"@rollup/plugin-babel@^5.2.0": + version "5.3.0" + resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz#9cb1c5146ddd6a4968ad96f209c50c62f92f9879" + integrity sha512-9uIC8HZOnVLrLHxayq/PTzw+uS25E14KPUBh5ktF+18Mjo5yK0ToMMx6epY0uEgkjwJw0aBW4x2horYXh8juWw== + dependencies: + "@babel/helper-module-imports" "^7.10.4" + "@rollup/pluginutils" "^3.1.0" + +"@rollup/plugin-commonjs@^18.0.0": + version "18.1.0" + resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-18.1.0.tgz#5a760d757af168a50727c0ae080251fbfcc5eb02" + integrity sha512-h3e6T9rUxVMAQswpDIobfUHn/doMzM9sgkMrsMWCFLmB84PSoC8mV8tOloAJjSRwdqhXBqstlX2BwBpHJvbhxg== + dependencies: + "@rollup/pluginutils" "^3.1.0" + commondir "^1.0.1" + estree-walker "^2.0.1" + glob "^7.1.6" + is-reference "^1.2.1" + magic-string "^0.25.7" + resolve "^1.17.0" + +"@rollup/plugin-inject@^4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@rollup/plugin-inject/-/plugin-inject-4.0.2.tgz#55b21bb244a07675f7fdde577db929c82fc17395" + integrity sha512-TSLMA8waJ7Dmgmoc8JfPnwUwVZgLjjIAM6MqeIFqPO2ODK36JqE0Cf2F54UTgCUuW8da93Mvoj75a6KAVWgylw== + dependencies: + "@rollup/pluginutils" "^3.0.4" + estree-walker "^1.0.1" + magic-string "^0.25.5" + +"@rollup/plugin-json@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@rollup/plugin-json/-/plugin-json-4.1.0.tgz#54e09867ae6963c593844d8bd7a9c718294496f3" + integrity sha512-yfLbTdNS6amI/2OpmbiBoW12vngr5NW2jCJVZSBEz+H5KfUJZ2M7sDjk0U6GOOdCWFVScShte29o9NezJ53TPw== + dependencies: + "@rollup/pluginutils" "^3.0.8" + +"@rollup/plugin-node-resolve@^11.0.0": + version "11.2.1" + resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz#82aa59397a29cd4e13248b106e6a4a1880362a60" + integrity sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg== + dependencies: + "@rollup/pluginutils" "^3.1.0" + "@types/resolve" "1.17.1" + builtin-modules "^3.1.0" + deepmerge "^4.2.2" + is-module "^1.0.0" + resolve "^1.19.0" + +"@rollup/pluginutils@^3.0.4", "@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b" + integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg== + dependencies: + "@types/estree" "0.0.39" + estree-walker "^1.0.1" + picomatch "^2.2.2" + +"@samverschueren/stream-to-observable@^0.3.0": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.1.tgz#a21117b19ee9be70c379ec1877537ef2e1c63301" + integrity sha512-c/qwwcHyafOQuVQJj0IlBjf5yYgBI7YPJ77k4fOJYesb41jio65eaJODRUmfYKhTOFBrIZ66kgvGPlNbjuoRdQ== + dependencies: + any-observable "^0.3.0" + +"@sindresorhus/is@^0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" + integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== + +"@sindresorhus/is@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" + integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow== + +"@sindresorhus/is@^2.0.0": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-2.1.1.tgz#ceff6a28a5b4867c2dd4a1ba513de278ccbe8bb1" + integrity sha512-/aPsuoj/1Dw/kzhkgz+ES6TxG0zfTMGLwuK2ZG00k/iJzYHTLCE8mVU8EPqEOp/lmxPoq1C1C9RYToRKb2KEfg== + +"@sindresorhus/slugify@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/slugify/-/slugify-1.1.0.tgz#2f195365d9b953384305b62664b44b4036c49430" + integrity sha512-ujZRbmmizX26yS/HnB3P9QNlNa4+UvHh+rIse3RbOXLp8yl6n1TxB4t7NHggtVgS8QmmOtzXo48kCxZGACpkPw== + dependencies: + "@sindresorhus/transliterate" "^0.1.1" + escape-string-regexp "^4.0.0" + +"@sindresorhus/transliterate@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@sindresorhus/transliterate/-/transliterate-0.1.1.tgz#779b31244781d3c898f185b61d58c89e7c782674" + integrity sha512-QSdIQ5keUFAZ3KLbfbsntW39ox0Ym8183RqTwBq/ZEFoN3NQAtGV+qWaNdzKpIDHgj9J2CQ2iNDRVU11Zyr7MQ== + dependencies: + escape-string-regexp "^2.0.0" + lodash.deburr "^4.1.0" + +"@svgr/babel-plugin-add-jsx-attribute@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz#81ef61947bb268eb9d50523446f9c638fb355906" + integrity sha512-ZFf2gs/8/6B8PnSofI0inYXr2SDNTDScPXhN7k5EqD4aZ3gi6u+rbmZHVB8IM3wDyx8ntKACZbtXSm7oZGRqVg== + +"@svgr/babel-plugin-remove-jsx-attribute@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-5.4.0.tgz#6b2c770c95c874654fd5e1d5ef475b78a0a962ef" + integrity sha512-yaS4o2PgUtwLFGTKbsiAy6D0o3ugcUhWK0Z45umJ66EPWunAz9fuFw2gJuje6wqQvQWOTJvIahUwndOXb7QCPg== + +"@svgr/babel-plugin-remove-jsx-empty-expression@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-5.0.1.tgz#25621a8915ed7ad70da6cea3d0a6dbc2ea933efd" + integrity sha512-LA72+88A11ND/yFIMzyuLRSMJ+tRKeYKeQ+mR3DcAZ5I4h5CPWN9AHyUzJbWSYp/u2u0xhmgOe0+E41+GjEueA== + +"@svgr/babel-plugin-replace-jsx-attribute-value@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-5.0.1.tgz#0b221fc57f9fcd10e91fe219e2cd0dd03145a897" + integrity sha512-PoiE6ZD2Eiy5mK+fjHqwGOS+IXX0wq/YDtNyIgOrc6ejFnxN4b13pRpiIPbtPwHEc+NT2KCjteAcq33/F1Y9KQ== + +"@svgr/babel-plugin-svg-dynamic-title@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-5.4.0.tgz#139b546dd0c3186b6e5db4fefc26cb0baea729d7" + integrity sha512-zSOZH8PdZOpuG1ZVx/cLVePB2ibo3WPpqo7gFIjLV9a0QsuQAzJiwwqmuEdTaW2pegyBE17Uu15mOgOcgabQZg== + +"@svgr/babel-plugin-svg-em-dimensions@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-5.4.0.tgz#6543f69526632a133ce5cabab965deeaea2234a0" + integrity sha512-cPzDbDA5oT/sPXDCUYoVXEmm3VIoAWAPT6mSPTJNbQaBNUuEKVKyGH93oDY4e42PYHRW67N5alJx/eEol20abw== + +"@svgr/babel-plugin-transform-react-native-svg@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-5.4.0.tgz#00bf9a7a73f1cad3948cdab1f8dfb774750f8c80" + integrity sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q== + +"@svgr/babel-plugin-transform-svg-component@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.4.0.tgz#a2212b4d018e6075a058bb7e220a66959ef7a03c" + integrity sha512-zLl4Fl3NvKxxjWNkqEcpdSOpQ3LGVH2BNFQ6vjaK6sFo2IrSznrhURIPI0HAphKiiIwNYjAfE0TNoQDSZv0U9A== + +"@svgr/babel-preset@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-preset/-/babel-preset-5.4.0.tgz#da21854643e1c4ad2279239baa7d5a8b128c1f15" + integrity sha512-Gyx7cCxua04DBtyILTYdQxeO/pwfTBev6+eXTbVbxe4HTGhOUW6yo7PSbG2p6eJMl44j6XSequ0ZDP7bl0nu9A== + dependencies: + "@svgr/babel-plugin-add-jsx-attribute" "^5.4.0" + "@svgr/babel-plugin-remove-jsx-attribute" "^5.4.0" + "@svgr/babel-plugin-remove-jsx-empty-expression" "^5.0.1" + "@svgr/babel-plugin-replace-jsx-attribute-value" "^5.0.1" + "@svgr/babel-plugin-svg-dynamic-title" "^5.4.0" + "@svgr/babel-plugin-svg-em-dimensions" "^5.4.0" + "@svgr/babel-plugin-transform-react-native-svg" "^5.4.0" + "@svgr/babel-plugin-transform-svg-component" "^5.4.0" + +"@svgr/core@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@svgr/core/-/core-5.4.0.tgz#655378ee43679eb94fee3d4e1976e38252dff8e7" + integrity sha512-hWGm1DCCvd4IEn7VgDUHYiC597lUYhFau2lwJBYpQWDirYLkX4OsXu9IslPgJ9UpP7wsw3n2Ffv9sW7SXJVfqQ== + dependencies: + "@svgr/plugin-jsx" "^5.4.0" + camelcase "^6.0.0" + cosmiconfig "^6.0.0" + +"@svgr/hast-util-to-babel-ast@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.4.0.tgz#bb5d002e428f510aa5b53ec0a02377a95b367715" + integrity sha512-+U0TZZpPsP2V1WvVhqAOSTk+N+CjYHdZx+x9UBa1eeeZDXwH8pt0CrQf2+SvRl/h2CAPRFkm+Ey96+jKP8Bsgg== + dependencies: + "@babel/types" "^7.9.5" + +"@svgr/plugin-jsx@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@svgr/plugin-jsx/-/plugin-jsx-5.4.0.tgz#ab47504c55615833c6db70fca2d7e489f509787c" + integrity sha512-SGzO4JZQ2HvGRKDzRga9YFSqOqaNrgLlQVaGvpZ2Iht2gwRp/tq+18Pvv9kS9ZqOMYgyix2LLxZMY1LOe9NPqw== + dependencies: + "@babel/core" "^7.7.5" + "@svgr/babel-preset" "^5.4.0" + "@svgr/hast-util-to-babel-ast" "^5.4.0" + svg-parser "^2.0.2" + +"@svgr/plugin-svgo@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@svgr/plugin-svgo/-/plugin-svgo-5.4.0.tgz#45d9800b7099a6f7b4d85ebac89ab9abe8592f64" + integrity sha512-3Cgv3aYi1l6SHyzArV9C36yo4kgwVdF3zPQUC6/aCDUeXAofDYwE5kk3e3oT5ZO2a0N3lB+lLGvipBG6lnG8EA== + dependencies: + cosmiconfig "^6.0.0" + merge-deep "^3.0.2" + svgo "^1.2.2" + +"@svgr/webpack@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@svgr/webpack/-/webpack-5.4.0.tgz#b68bc86e29cf007292b96ced65f80971175632e0" + integrity sha512-LjepnS/BSAvelnOnnzr6Gg0GcpLmnZ9ThGFK5WJtm1xOqdBE/1IACZU7MMdVzjyUkfFqGz87eRE4hFaSLiUwYg== + dependencies: + "@babel/core" "^7.9.0" + "@babel/plugin-transform-react-constant-elements" "^7.9.0" + "@babel/preset-env" "^7.9.5" + "@babel/preset-react" "^7.9.4" + "@svgr/core" "^5.4.0" + "@svgr/plugin-jsx" "^5.4.0" + "@svgr/plugin-svgo" "^5.4.0" + loader-utils "^2.0.0" + +"@szmarczak/http-timer@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" + integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA== + dependencies: + defer-to-connect "^1.0.1" + +"@szmarczak/http-timer@^4.0.0": + version "4.0.5" + resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-4.0.5.tgz#bfbd50211e9dfa51ba07da58a14cdfd333205152" + integrity sha512-PyRA9sm1Yayuj5OIoJ1hGt2YISX45w9WcFbh6ddT0Z/0yaFxOtGLInr4jUfU1EAFVs0Yfyfev4RNwBlUaHdlDQ== + dependencies: + defer-to-connect "^2.0.0" + +"@types/anymatch@*": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" + integrity sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA== + +"@types/cacheable-request@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.1.tgz#5d22f3dded1fd3a84c0bbeb5039a7419c2c91976" + integrity sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ== + dependencies: + "@types/http-cache-semantics" "*" + "@types/keyv" "*" + "@types/node" "*" + "@types/responselike" "*" + +"@types/color-name@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" + integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== + +"@types/decompress@*": + version "4.2.3" + resolved "https://registry.yarnpkg.com/@types/decompress/-/decompress-4.2.3.tgz#98eed48af80001038aa05690b2094915f296fe65" + integrity sha512-W24e3Ycz1UZPgr1ZEDHlK4XnvOr+CpJH3qNsFeqXwwlW/9END9gxn3oJSsp7gYdiQxrXUHwUUd3xuzVz37MrZQ== + dependencies: + "@types/node" "*" + +"@types/download@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@types/download/-/download-8.0.0.tgz#aa713d3587cdf03d90e3ddc1517ea32e3d22b23c" + integrity sha512-0/LRe3VzcyFIRto6YiJH9OG05ASLeypv5BVrlWjWRBSLCeyFb4up4hqO27j98ASzx1fU2V3ftuWR/sy3PM4NPA== + dependencies: + "@types/decompress" "*" + "@types/got" "^8" + "@types/node" "*" + +"@types/estree@*": + version "0.0.48" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.48.tgz#18dc8091b285df90db2f25aa7d906cfc394b7f74" + integrity sha512-LfZwXoGUDo0C3me81HXgkBg5CTQYb6xzEl+fNmbO4JdRiSKQ8A0GD1OBBvKAIsbCUgoyAty7m99GqqMQe784ew== + +"@types/estree@0.0.39": + version "0.0.39" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" + integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== + +"@types/glob@^7.1.1": + version "7.1.3" + resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.3.tgz#e6ba80f36b7daad2c685acd9266382e68985c183" + integrity sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w== + dependencies: + "@types/minimatch" "*" + "@types/node" "*" + +"@types/got@^8": + version "8.3.5" + resolved "https://registry.yarnpkg.com/@types/got/-/got-8.3.5.tgz#d8a0e8fa7598681b332a4d27779b022b2e55fb7f" + integrity sha512-AaXSrIF99SjjtPVNmCmYb388HML+PKEJb/xmj4SbL2ZO0hHuETZZzyDIKfOqaEoAHZEuX4sC+FRFrHYJoIby6A== + dependencies: + "@types/node" "*" + +"@types/hapi__joi@^17.1.2": + version "17.1.7" + resolved "https://registry.yarnpkg.com/@types/hapi__joi/-/hapi__joi-17.1.7.tgz#a479bc593823fb7bd5600fea37917c8e5c71629c" + integrity sha512-byC9slpuqyDeEKegl/ViT8PCQRSp0CYkhLzz0cPxqxV6QC53Ht1cr2jG5iwOvtt/RxylUbTpXNqpgbf9Qu8QFw== + +"@types/hast@^2.0.0": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.1.tgz#b16872f2a6144c7025f296fb9636a667ebb79cd9" + integrity sha512-viwwrB+6xGzw+G1eWpF9geV3fnsDgXqHG+cqgiHrvQfDUW5hzhCyV7Sy3UJxhfRFBsgky2SSW33qi/YrIkjX5Q== + dependencies: + "@types/unist" "*" + +"@types/html-minifier-terser@^5.0.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-5.1.0.tgz#551a4589b6ee2cc9c1dff08056128aec29b94880" + integrity sha512-iYCgjm1dGPRuo12+BStjd1HiVQqhlRhWDOQigNxn023HcjnhsiFz9pc6CzJj4HwDCSQca9bxTL4PxJDbkdm3PA== + +"@types/http-cache-semantics@*": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz#9140779736aa2655635ee756e2467d787cfe8a2a" + integrity sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A== + +"@types/http-proxy@^1.17.4": + version "1.17.4" + resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.4.tgz#e7c92e3dbe3e13aa799440ff42e6d3a17a9d045b" + integrity sha512-IrSHl2u6AWXduUaDLqYpt45tLVCtYv7o4Z0s1KghBCDgIIS9oW5K1H8mZG/A2CfeLdEa7rTd1ACOiHBc1EMT2Q== + dependencies: + "@types/node" "*" + +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" + integrity sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw== + +"@types/istanbul-lib-report@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686" + integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg== + dependencies: + "@types/istanbul-lib-coverage" "*" + +"@types/istanbul-reports@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-1.1.2.tgz#e875cc689e47bce549ec81f3df5e6f6f11cfaeb2" + integrity sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw== + dependencies: + "@types/istanbul-lib-coverage" "*" + "@types/istanbul-lib-report" "*" + +"@types/json-schema@^7.0.5": + version "7.0.6" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0" + integrity sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw== + +"@types/json-schema@^7.0.6": + version "7.0.7" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" + integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== + +"@types/keyv@*", "@types/keyv@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.1.tgz#e45a45324fca9dab716ab1230ee249c9fb52cfa7" + integrity sha512-MPtoySlAZQ37VoLaPcTHCu1RWJ4llDkULYZIzOYxlhxBqYPB0RsRlmMU0R6tahtFe27mIdkHV+551ZWV4PLmVw== + dependencies: + "@types/node" "*" + +"@types/lodash@^4.14.53": + version "4.14.161" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.161.tgz#a21ca0777dabc6e4f44f3d07f37b765f54188b18" + integrity sha512-EP6O3Jkr7bXvZZSZYlsgt5DIjiGr0dXP1/jVEwVLTFgg0d+3lWVQkRavYVQszV7dYUwvg0B8R0MBDpcmXg7XIA== + +"@types/mdast@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.3.tgz#2d7d671b1cd1ea3deb306ea75036c2a0407d2deb" + integrity sha512-SXPBMnFVQg1s00dlMCc/jCdvPqdE4mXaMMCeRlxLDmTAEoegHT53xKtkDnzDTOcmMHUfcjyf36/YYZ6SxRdnsw== + dependencies: + "@types/unist" "*" + +"@types/minimatch@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" + integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== + +"@types/node-fetch@^2.1.6": + version "2.5.7" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" + integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw== + dependencies: + "@types/node" "*" + form-data "^3.0.0" + +"@types/node@*", "@types/node@>= 8": + version "14.6.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.6.4.tgz#a145cc0bb14ef9c4777361b7bbafa5cf8e3acb5a" + integrity sha512-Wk7nG1JSaMfMpoMJDKUsWYugliB2Vy55pdjLpmLixeyMi7HizW2I/9QoxsPCkXl3dO+ZOVqPumKaDUv5zJu2uQ== + +"@types/node@^10.11.7": + version "10.17.30" + resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.30.tgz#20556a0d7f62b83e163973a6cd640af636d3dd3b" + integrity sha512-euU8QLX0ipj+5mOYa4ZqZoTv+53BY7yTg9I2ZIhDXgiI3M+0n4mdAt9TQCuvxVAgU179g8OsRLaBt0qEi0T6xA== + +"@types/node@^13.11.1": + version "13.13.17" + resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.17.tgz#fba8bdd9be9a61adbceac654450673c2f520b0f0" + integrity sha512-rGZftvdDpsYtG/rOlDOwny1f6Aq4FHJdGSVfPg5vC2DaR9Rt4W2OpsOF5GTU2bSqZmwTkfnsvJhhzpMWYxxlEA== + +"@types/node@^14.0.27": + version "14.17.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.4.tgz#218712242446fc868d0e007af29a4408c7765bc0" + integrity sha512-8kQ3+wKGRNN0ghtEn7EGps/B8CzuBz1nXZEIGGLP2GnwbqYn4dbTs7k+VKLTq1HvZLRCIDtN3Snx1Ege8B7L5A== + +"@types/normalize-package-data@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" + integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA== + +"@types/parse-json@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" + integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== + +"@types/parse5@^5.0.0": + version "5.0.3" + resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.3.tgz#e7b5aebbac150f8b5fdd4a46e7f0bd8e65e19109" + integrity sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw== + +"@types/q@^1.5.1": + version "1.5.4" + resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24" + integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug== + +"@types/resolve@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" + integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw== + dependencies: + "@types/node" "*" + +"@types/responselike@*": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29" + integrity sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA== + dependencies: + "@types/node" "*" + +"@types/semver@^7.0.0": + version "7.3.6" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.6.tgz#e9831776f4512a7ba6da53e71c26e5fb67882d63" + integrity sha512-0caWDWmpCp0uifxFh+FaqK3CuZ2SkRR/ZRxAV5+zNdC3QVUi6wyOJnefhPvtNt8NQWXB5OA93BUvZsXpWat2Xw== + +"@types/source-list-map@*": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" + integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA== + +"@types/tapable@*", "@types/tapable@^1.0.5": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.6.tgz#a9ca4b70a18b270ccb2bc0aaafefd1d486b7ea74" + integrity sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA== + +"@types/uglify-js@*": + version "3.9.3" + resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.9.3.tgz#d94ed608e295bc5424c9600e6b8565407b6b4b6b" + integrity sha512-KswB5C7Kwduwjj04Ykz+AjvPcfgv/37Za24O2EDzYNbwyzOo8+ydtvzUfZ5UMguiVu29Gx44l1A6VsPPcmYu9w== + dependencies: + source-map "^0.6.1" + +"@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e" + integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ== + +"@types/webpack-sources@*": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@types/webpack-sources/-/webpack-sources-1.4.2.tgz#5d3d4dea04008a779a90135ff96fb5c0c9e6292c" + integrity sha512-77T++JyKow4BQB/m9O96n9d/UUHWLQHlcqXb9Vsf4F1+wKNrrlWNFPDLKNT92RJnCSL6CieTc+NDXtCVZswdTw== + dependencies: + "@types/node" "*" + "@types/source-list-map" "*" + source-map "^0.7.3" + +"@types/webpack@^4.41.0", "@types/webpack@^4.41.8": + version "4.41.22" + resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.41.22.tgz#ff9758a17c6bd499e459b91e78539848c32d0731" + integrity sha512-JQDJK6pj8OMV9gWOnN1dcLCyU9Hzs6lux0wBO4lr1+gyEhIBR9U3FMrz12t2GPkg110XAxEAw2WHF6g7nZIbRQ== + dependencies: + "@types/anymatch" "*" + "@types/node" "*" + "@types/tapable" "*" + "@types/uglify-js" "*" + "@types/webpack-sources" "*" + source-map "^0.6.0" + +"@types/yargs-parser@*": + version "15.0.0" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d" + integrity sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw== + +"@types/yargs@^13.0.0": + version "13.0.10" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-13.0.10.tgz#e77bf3fc73c781d48c2eb541f87c453e321e5f4b" + integrity sha512-MU10TSgzNABgdzKvQVW1nuuT+sgBMWeXNc3XOs5YXV5SDAK+PPja2eUuBNB9iqElu03xyEDqlnGw0jgl4nbqGQ== + dependencies: + "@types/yargs-parser" "*" + +"@types/yargs@^15.0.0": + version "15.0.13" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-15.0.13.tgz#34f7fec8b389d7f3c1fd08026a5763e072d3c6dc" + integrity sha512-kQ5JNTrbDv3Rp5X2n/iUu37IJBDU2gsZ5R/g1/KHOOEc5IKfUFjXT6DENPGduh08I/pamwtEq4oul7gUqKTQDQ== + dependencies: + "@types/yargs-parser" "*" + +"@typescript-eslint/types@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.28.0.tgz#a33504e1ce7ac51fc39035f5fe6f15079d4dafb0" + integrity sha512-p16xMNKKoiJCVZY5PW/AfILw2xe1LfruTcfAKBj3a+wgNYP5I9ZEKNDOItoRt53p4EiPV6iRSICy8EPanG9ZVA== + +"@typescript-eslint/typescript-estree@^4.8.2": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.28.0.tgz#e66d4e5aa2ede66fec8af434898fe61af10c71cf" + integrity sha512-m19UQTRtxMzKAm8QxfKpvh6OwQSXaW1CdZPoCaQuLwAq7VZMNuhJmZR4g5281s2ECt658sldnJfdpSZZaxUGMQ== + dependencies: + "@typescript-eslint/types" "4.28.0" + "@typescript-eslint/visitor-keys" "4.28.0" + debug "^4.3.1" + globby "^11.0.3" + is-glob "^4.0.1" + semver "^7.3.5" + tsutils "^3.21.0" + +"@typescript-eslint/visitor-keys@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.28.0.tgz#255c67c966ec294104169a6939d96f91c8a89434" + integrity sha512-PjJyTWwrlrvM5jazxYF5ZPs/nl0kHDZMVbuIcbpawVXaDPelp3+S9zpOz5RmVUfS/fD5l5+ZXNKnWhNYjPzCvw== + dependencies: + "@typescript-eslint/types" "4.28.0" + eslint-visitor-keys "^2.0.0" + +"@ungap/from-entries@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@ungap/from-entries/-/from-entries-0.2.1.tgz#7e86196b8b2e99d73106a8f25c2a068326346354" + integrity sha512-CAqefTFAfnUPwYqsWHXpOxHaq1Zo5UQ3m9Zm2p09LggGe57rqHoBn3c++xcoomzXKynAUuiBMDUCQvKMnXjUpA== + +"@webassemblyjs/ast@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964" + integrity sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA== + dependencies: + "@webassemblyjs/helper-module-context" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/wast-parser" "1.9.0" + +"@webassemblyjs/floating-point-hex-parser@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.9.0.tgz#3c3d3b271bddfc84deb00f71344438311d52ffb4" + integrity sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA== + +"@webassemblyjs/helper-api-error@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz#203f676e333b96c9da2eeab3ccef33c45928b6a2" + integrity sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw== + +"@webassemblyjs/helper-buffer@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz#a1442d269c5feb23fcbc9ef759dac3547f29de00" + integrity sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA== + +"@webassemblyjs/helper-code-frame@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.9.0.tgz#647f8892cd2043a82ac0c8c5e75c36f1d9159f27" + integrity sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA== + dependencies: + "@webassemblyjs/wast-printer" "1.9.0" + +"@webassemblyjs/helper-fsm@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-fsm/-/helper-fsm-1.9.0.tgz#c05256b71244214671f4b08ec108ad63b70eddb8" + integrity sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw== + +"@webassemblyjs/helper-module-context@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-module-context/-/helper-module-context-1.9.0.tgz#25d8884b76839871a08a6c6f806c3979ef712f07" + integrity sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g== + dependencies: + "@webassemblyjs/ast" "1.9.0" + +"@webassemblyjs/helper-wasm-bytecode@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz#4fed8beac9b8c14f8c58b70d124d549dd1fe5790" + integrity sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw== + +"@webassemblyjs/helper-wasm-section@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz#5a4138d5a6292ba18b04c5ae49717e4167965346" + integrity sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-buffer" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/wasm-gen" "1.9.0" + +"@webassemblyjs/ieee754@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz#15c7a0fbaae83fb26143bbacf6d6df1702ad39e4" + integrity sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg== + dependencies: + "@xtuc/ieee754" "^1.2.0" + +"@webassemblyjs/leb128@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.9.0.tgz#f19ca0b76a6dc55623a09cffa769e838fa1e1c95" + integrity sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw== + dependencies: + "@xtuc/long" "4.2.2" + +"@webassemblyjs/utf8@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.9.0.tgz#04d33b636f78e6a6813227e82402f7637b6229ab" + integrity sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w== + +"@webassemblyjs/wasm-edit@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz#3fe6d79d3f0f922183aa86002c42dd256cfee9cf" + integrity sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-buffer" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/helper-wasm-section" "1.9.0" + "@webassemblyjs/wasm-gen" "1.9.0" + "@webassemblyjs/wasm-opt" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" + "@webassemblyjs/wast-printer" "1.9.0" + +"@webassemblyjs/wasm-gen@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz#50bc70ec68ded8e2763b01a1418bf43491a7a49c" + integrity sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/ieee754" "1.9.0" + "@webassemblyjs/leb128" "1.9.0" + "@webassemblyjs/utf8" "1.9.0" + +"@webassemblyjs/wasm-opt@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz#2211181e5b31326443cc8112eb9f0b9028721a61" + integrity sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-buffer" "1.9.0" + "@webassemblyjs/wasm-gen" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" + +"@webassemblyjs/wasm-parser@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz#9d48e44826df4a6598294aa6c87469d642fff65e" + integrity sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-api-error" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/ieee754" "1.9.0" + "@webassemblyjs/leb128" "1.9.0" + "@webassemblyjs/utf8" "1.9.0" + +"@webassemblyjs/wast-parser@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-parser/-/wast-parser-1.9.0.tgz#3031115d79ac5bd261556cecc3fa90a3ef451914" + integrity sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/floating-point-hex-parser" "1.9.0" + "@webassemblyjs/helper-api-error" "1.9.0" + "@webassemblyjs/helper-code-frame" "1.9.0" + "@webassemblyjs/helper-fsm" "1.9.0" + "@xtuc/long" "4.2.2" + +"@webassemblyjs/wast-printer@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz#4935d54c85fef637b00ce9f52377451d00d47899" + integrity sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/wast-parser" "1.9.0" + "@xtuc/long" "4.2.2" + +"@xtuc/ieee754@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" + integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA== + +"@xtuc/long@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" + integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== + +accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7: + version "1.3.7" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" + integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA== + dependencies: + mime-types "~2.1.24" + negotiator "0.6.2" + +acorn-walk@^7.1.1: + version "7.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" + integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== + +acorn@^6.4.1: + version "6.4.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474" + integrity sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA== + +acorn@^7.1.1: + version "7.4.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.0.tgz#e1ad486e6c54501634c6c397c5c121daa383607c" + integrity sha512-+G7P8jJmCHr+S+cLfQxygbWhXy+8YTVGzAkpEbcLo2mLoL7tij/VG41QSHACSf5QgYRhMZYHuNc6drJaO0Da+w== + +address@1.1.2, address@^1.0.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" + integrity sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA== + +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + +aggregate-error@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" + integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== + dependencies: + clean-stack "^2.0.0" + indent-string "^4.0.0" + +ajv-errors@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d" + integrity sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ== + +ajv-keywords@^3.1.0, ajv-keywords@^3.4.1, ajv-keywords@^3.5.2: + version "3.5.2" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" + integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== + +ajv@^5.5.2: + version "5.5.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965" + integrity sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU= + dependencies: + co "^4.6.0" + fast-deep-equal "^1.0.0" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.3.0" + +ajv@^6.1.0, ajv@^6.10.2, ajv@^6.12.4: + version "6.12.4" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.4.tgz#0614facc4522127fa713445c6bfd3ebd376e2234" + integrity sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^6.12.5: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^8.0.0: + version "8.6.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.6.0.tgz#60cc45d9c46a477d80d92c48076d972c342e5720" + integrity sha512-cnUG4NSBiM4YFBxgZIj/In3/6KX+rQ2l2YPRVcvAMQGWEPKuXoPIhxzwqh31jA3IPbI4qEOp/5ILI4ynioXsGQ== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + +algoliasearch-helper@^3.1.1: + version "3.2.2" + resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.2.2.tgz#12451c8e368935348453c8879785b20e1788c33c" + integrity sha512-/3XvE33R+gQKaiPdy3nmHYqhF8hqIu8xnlOicVxb1fD6uMFmxW8rGLzzrRfsPfxgAfm+c1NslLb3TzQVIB8aVA== + dependencies: + events "^1.1.1" + +algoliasearch@^4.0.0: + version "4.5.1" + resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-4.5.1.tgz#fd20cd76f6ba3fbecdd4e11bdaefefb44abc0b38" + integrity sha512-b6yT1vWMlBdVObQipKxvt0M6SEvGetVj+FFFlo0Fy06gkdj6WCJaS4t10Q/hC3I2VG9QmpCqlK3Esgg1y1E+uw== + dependencies: + "@algolia/cache-browser-local-storage" "4.5.1" + "@algolia/cache-common" "4.5.1" + "@algolia/cache-in-memory" "4.5.1" + "@algolia/client-account" "4.5.1" + "@algolia/client-analytics" "4.5.1" + "@algolia/client-common" "4.5.1" + "@algolia/client-recommendation" "4.5.1" + "@algolia/client-search" "4.5.1" + "@algolia/logger-common" "4.5.1" + "@algolia/logger-console" "4.5.1" + "@algolia/requester-browser-xhr" "4.5.1" + "@algolia/requester-common" "4.5.1" + "@algolia/requester-node-http" "4.5.1" + "@algolia/transporter" "4.5.1" + +all-node-versions@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/all-node-versions/-/all-node-versions-8.0.0.tgz#d3a4dcffe5c589775c7ddbd15b63dbcc71face14" + integrity sha512-cF8ibgj23U7ai4qjSFzpeccwDXUlPFMzKe0Z6qf6gChR+9S0JMyzYz6oYz4n0nHi/FLH9BJIefsONsMH/WDM2w== + dependencies: + fetch-node-website "^5.0.3" + filter-obj "^2.0.1" + get-stream "^5.1.0" + global-cache-dir "^2.0.0" + jest-validate "^25.3.0" + path-exists "^4.0.0" + semver "^7.3.2" + write-file-atomic "^3.0.3" + +alphanum-sort@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" + integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM= + +ansi-align@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.0.tgz#b536b371cf687caaef236c18d3e21fe3797467cb" + integrity sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw== + dependencies: + string-width "^3.0.0" + +ansi-colors@^3.0.0: + version "3.2.4" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.4.tgz#e3a3da4bfbae6c86a9c285625de124a234026fbf" + integrity sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA== + +ansi-escapes@^3.0.0, ansi-escapes@^3.1.0, ansi-escapes@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" + integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== + +ansi-escapes@^4.2.1, ansi-escapes@^4.3.0, ansi-escapes@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.1.tgz#a5c47cc43181f1f38ffd7076837700d395522a61" + integrity sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA== + dependencies: + type-fest "^0.11.0" + +ansi-escapes@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + +ansi-html@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" + integrity sha1-gTWEAhliqenm/QOflA0S9WynhZ4= + +ansi-regex@^0.2.0, ansi-regex@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-0.2.1.tgz#0d8e946967a3d8143f93e24e298525fc1b2235f9" + integrity sha1-DY6UaWej2BQ/k+JOKYUl/BsiNfk= + +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + +ansi-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" + integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + +ansi-regex@^4.0.0, ansi-regex@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" + integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== + +ansi-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + +ansi-styles@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-1.1.0.tgz#eaecbf66cd706882760b2f4691582b8f55d7a7de" + integrity sha1-6uy/Zs1waIJ2Cy9GkVgrj1XXp94= + +ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" + integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= + +ansi-styles@^3.2.0, ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0, ansi-styles@^4.2.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359" + integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA== + dependencies: + "@types/color-name" "^1.1.1" + color-convert "^2.0.1" + +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + +ansicolors@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/ansicolors/-/ansicolors-0.3.2.tgz#665597de86a9ffe3aa9bfbe6cae5c6ea426b4979" + integrity sha1-ZlWX3oap/+Oqm/vmyuXG6kJrSXk= + +any-base@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/any-base/-/any-base-1.1.0.tgz#ae101a62bc08a597b4c9ab5b7089d456630549fe" + integrity sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg== + +any-observable@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b" + integrity sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog== + +anymatch@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" + integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== + dependencies: + micromatch "^3.1.4" + normalize-path "^2.1.1" + +anymatch@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142" + integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +aproba@^1.0.3, aproba@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" + integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== + +archive-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/archive-type/-/archive-type-4.0.0.tgz#f92e72233056dfc6969472749c267bdb046b1d70" + integrity sha1-+S5yIzBW38aWlHJ0nCZ72wRrHXA= + dependencies: + file-type "^4.2.0" + +archiver-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-2.1.0.tgz#e8a460e94b693c3e3da182a098ca6285ba9249e2" + integrity sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw== + dependencies: + glob "^7.1.4" + graceful-fs "^4.2.0" + lazystream "^1.0.0" + lodash.defaults "^4.2.0" + lodash.difference "^4.5.0" + lodash.flatten "^4.4.0" + lodash.isplainobject "^4.0.6" + lodash.union "^4.6.0" + normalize-path "^3.0.0" + readable-stream "^2.0.0" + +archiver@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/archiver/-/archiver-5.3.0.tgz#dd3e097624481741df626267564f7dd8640a45ba" + integrity sha512-iUw+oDwK0fgNpvveEsdQ0Ase6IIKztBJU2U0E9MzszMfmVVUyv1QJhS2ITW9ZCqx8dktAxVAjWWkKehuZE8OPg== + dependencies: + archiver-utils "^2.1.0" + async "^3.2.0" + buffer-crc32 "^0.2.1" + readable-stream "^3.6.0" + readdir-glob "^1.0.0" + tar-stream "^2.2.0" + zip-stream "^4.1.0" + +are-we-there-yet@~1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" + integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== + dependencies: + delegates "^1.0.0" + readable-stream "^2.0.6" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= + +arr-flatten@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" + integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== + +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= + +array-flat-polyfill@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/array-flat-polyfill/-/array-flat-polyfill-1.0.1.tgz#1e3a4255be619dfbffbfd1d635c1cf357cd034e7" + integrity sha512-hfJmKupmQN0lwi0xG6FQ5U8Rd97RnIERplymOv/qpq8AoNKPPAnxJadjFA23FNWm88wykh9HmpLJUUwUtNU/iw== + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= + +array-flatten@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.2.tgz#24ef80a28c1a893617e2149b0c6d0d788293b099" + integrity sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ== + +array-union@^1.0.1, array-union@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" + integrity sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk= + dependencies: + array-uniq "^1.0.1" + +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + +array-uniq@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" + integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY= + +array-unique@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" + integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= + +arrify@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" + integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== + +asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= + +ascii-table@0.0.9: + version "0.0.9" + resolved "https://registry.yarnpkg.com/ascii-table/-/ascii-table-0.0.9.tgz#06a6604d6a55d4bf41a9a47d9872d7a78da31e73" + integrity sha1-BqZgTWpV1L9BqaR9mHLXp42jHnM= + +asn1.js@^5.2.0: + version "5.4.1" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" + integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + safer-buffer "^2.1.0" + +assert@^1.1.1: + version "1.5.0" + resolved "https://registry.yarnpkg.com/assert/-/assert-1.5.0.tgz#55c109aaf6e0aefdb3dc4b71240c70bf574b18eb" + integrity sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA== + dependencies: + object-assign "^4.1.1" + util "0.10.3" + +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= + +ast-module-types@^2.3.2, ast-module-types@^2.4.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/ast-module-types/-/ast-module-types-2.6.0.tgz#f9f367fd273bbe01e52f2c51b5f46b65801d5d7f" + integrity sha512-zXSoVaMrf2R+r+ISid5/9a8SXm1LLdkhHzh6pSRhj9jklzruOOl1hva1YmFT33wAstg/f9ZndJAlq1BSrFLSGA== + +ast-module-types@^2.7.0, ast-module-types@^2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/ast-module-types/-/ast-module-types-2.7.1.tgz#3f7989ef8dfa1fdb82dfe0ab02bdfc7c77a57dd3" + integrity sha512-Rnnx/4Dus6fn7fTqdeLEAn5vUll5w7/vts0RN608yFa6si/rDOUonlIIiwugHBFWjylHjxm9owoSZn71KwG4gw== + +async-each@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" + integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== + +async-limiter@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" + integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== + +async@^2.6.2: + version "2.6.3" + resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" + integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg== + dependencies: + lodash "^4.17.14" + +async@^3.1.0, async@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" + integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== + +async@~1.5: + version "1.5.2" + resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" + integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + +atob@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" + integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== + +autoprefixer@^9.6.1: + version "9.8.6" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.8.6.tgz#3b73594ca1bf9266320c5acf1588d74dea74210f" + integrity sha512-XrvP4VVHdRBCdX1S3WXVD8+RyG9qeb1D5Sn1DeLiG2xfSpzellk5k54xbUERJ3M5DggQxes39UGOTP8CFrEGbg== + dependencies: + browserslist "^4.12.0" + caniuse-lite "^1.0.30001109" + colorette "^1.2.1" + normalize-range "^0.1.2" + num2fraction "^1.2.2" + postcss "^7.0.32" + postcss-value-parser "^4.1.0" + +aws-sdk@^2.689.0: + version "2.748.0" + resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.748.0.tgz#660d9d8202b1e48820ae9e018abad8d7b51af0b1" + integrity sha512-H+DCioQ4AChoBxGMtagcJ3a0mM0lOh3ta/dWIrfPTECAlRIuxlrBDp78cRhPgvdYYUxW54kB/IaHGR2xPP8JXw== + dependencies: + buffer "4.9.2" + events "1.1.1" + ieee754 "1.1.13" + jmespath "0.15.0" + querystring "0.2.0" + sax "1.2.1" + url "0.10.3" + uuid "3.3.2" + xml2js "0.4.19" + +babel-loader@^8.1.0: + version "8.2.2" + resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.2.2.tgz#9363ce84c10c9a40e6c753748e1441b60c8a0b81" + integrity sha512-JvTd0/D889PQBtUXJ2PXaKU/pjZDMtHA9V2ecm+eNRmmBCMR09a+fmpGTNwnJtFmFl5Ei7Vy47LjBb+L0wQ99g== + dependencies: + find-cache-dir "^3.3.1" + loader-utils "^1.4.0" + make-dir "^3.1.0" + schema-utils "^2.6.5" + +babel-plugin-apply-mdx-type-prop@1.6.22: + version "1.6.22" + resolved "https://registry.yarnpkg.com/babel-plugin-apply-mdx-type-prop/-/babel-plugin-apply-mdx-type-prop-1.6.22.tgz#d216e8fd0de91de3f1478ef3231e05446bc8705b" + integrity sha512-VefL+8o+F/DfK24lPZMtJctrCVOfgbqLAGZSkxwhazQv4VxPg3Za/i40fu22KR2m8eEda+IfSOlPLUSIiLcnCQ== + dependencies: + "@babel/helper-plugin-utils" "7.10.4" + "@mdx-js/util" "1.6.22" + +babel-plugin-dynamic-import-node@^2.3.0, babel-plugin-dynamic-import-node@^2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3" + integrity sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ== + dependencies: + object.assign "^4.1.0" + +babel-plugin-extract-import-names@1.6.22: + version "1.6.22" + resolved "https://registry.yarnpkg.com/babel-plugin-extract-import-names/-/babel-plugin-extract-import-names-1.6.22.tgz#de5f9a28eb12f3eb2578bf74472204e66d1a13dc" + integrity sha512-yJ9BsJaISua7d8zNT7oRG1ZLBJCIdZ4PZqmH8qa9N5AK01ifk3fnkc98AXhtzE7UkfCsEumvoQWgoYLhOnJ7jQ== + dependencies: + "@babel/helper-plugin-utils" "7.10.4" + +babel-plugin-polyfill-corejs2@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.2.2.tgz#e9124785e6fd94f94b618a7954e5693053bf5327" + integrity sha512-kISrENsJ0z5dNPq5eRvcctITNHYXWOA4DUZRFYCz3jYCcvTb/A546LIddmoGNMVYg2U38OyFeNosQwI9ENTqIQ== + dependencies: + "@babel/compat-data" "^7.13.11" + "@babel/helper-define-polyfill-provider" "^0.2.2" + semver "^6.1.1" + +babel-plugin-polyfill-corejs3@^0.2.2: + version "0.2.3" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.2.3.tgz#72add68cf08a8bf139ba6e6dfc0b1d504098e57b" + integrity sha512-rCOFzEIJpJEAU14XCcV/erIf/wZQMmMT5l5vXOpL5uoznyOGfDIjPj6FVytMvtzaKSTSVKouOCTPJ5OMUZH30g== + dependencies: + "@babel/helper-define-polyfill-provider" "^0.2.2" + core-js-compat "^3.14.0" + +babel-plugin-polyfill-regenerator@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.2.2.tgz#b310c8d642acada348c1fa3b3e6ce0e851bee077" + integrity sha512-Goy5ghsc21HgPDFtzRkSirpZVW35meGoTmTOb2bxqdl60ghub4xOidgNTHaZfQ2FaxQsKmwvXtOAkcIS4SMBWg== + dependencies: + "@babel/helper-define-polyfill-provider" "^0.2.2" + +"babel-plugin-styled-components@>= 1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/babel-plugin-styled-components/-/babel-plugin-styled-components-1.11.1.tgz#5296a9e557d736c3186be079fff27c6665d63d76" + integrity sha512-YwrInHyKUk1PU3avIRdiLyCpM++18Rs1NgyMXEAQC33rIXs/vro0A+stf4sT0Gf22Got+xRWB8Cm0tw+qkRzBA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.0.0" + "@babel/helper-module-imports" "^7.0.0" + babel-plugin-syntax-jsx "^6.18.0" + lodash "^4.17.11" + +babel-plugin-syntax-jsx@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" + integrity sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY= + +backoff@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/backoff/-/backoff-2.5.0.tgz#f616eda9d3e4b66b8ca7fca79f695722c5f8e26f" + integrity sha1-9hbtqdPktmuMp/ynn2lXIsX44m8= + dependencies: + precond "0.2" + +bail@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.5.tgz#b6fa133404a392cbc1f8c4bf63f5953351e7a776" + integrity sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ== + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + +base16@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/base16/-/base16-1.0.0.tgz#e297f60d7ec1014a7a971a39ebc8a98c0b681e70" + integrity sha1-4pf2DX7BAUp6lxo568ipjAtoHnA= + +base64-js@^1.0.2: + version "1.3.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" + integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== + +base@^0.11.1: + version "0.11.2" + resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== + dependencies: + cache-base "^1.0.1" + class-utils "^0.3.5" + component-emitter "^1.2.1" + define-property "^1.0.0" + isobject "^3.0.1" + mixin-deep "^1.2.0" + pascalcase "^0.1.1" + +batch@0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" + integrity sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY= + +before-after-hook@^2.2.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.2.tgz#a6e8ca41028d90ee2c24222f201c90956091613e" + integrity sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ== + +better-ajv-errors@^0.6.1, better-ajv-errors@^0.6.7: + version "0.6.7" + resolved "https://registry.yarnpkg.com/better-ajv-errors/-/better-ajv-errors-0.6.7.tgz#b5344af1ce10f434fe02fc4390a5a9c811e470d1" + integrity sha512-PYgt/sCzR4aGpyNy5+ViSQ77ognMnWq7745zM+/flYO4/Yisdtp9wDQW2IKCyVYPUxQt3E/b5GBSwfhd1LPdlg== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/runtime" "^7.0.0" + chalk "^2.4.1" + core-js "^3.2.1" + json-to-ast "^2.0.3" + jsonpointer "^4.0.1" + leven "^3.1.0" + +better-opn@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/better-opn/-/better-opn-2.1.1.tgz#94a55b4695dc79288f31d7d0e5f658320759f7c6" + integrity sha512-kIPXZS5qwyKiX/HcRvDYfmBQUa8XP17I0mYZZ0y4UhpYOSvtsLHDYqmomS+Mj20aDvD3knEiQ0ecQy2nhio3yA== + dependencies: + open "^7.0.3" + +bfj@^6.1.1: + version "6.1.2" + resolved "https://registry.yarnpkg.com/bfj/-/bfj-6.1.2.tgz#325c861a822bcb358a41c78a33b8e6e2086dde7f" + integrity sha512-BmBJa4Lip6BPRINSZ0BPEIfB1wUY/9rwbwvIHQA1KjX9om29B6id0wnWXq7m3bn5JrUVjeOTnVuhPT1FiHwPGw== + dependencies: + bluebird "^3.5.5" + check-types "^8.0.3" + hoopy "^0.1.4" + tryer "^1.0.1" + +big.js@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" + integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== + +binary-extensions@^1.0.0: + version "1.13.1" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" + integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== + +binary-extensions@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.1.0.tgz#30fa40c9e7fe07dbc895678cd287024dea241dd9" + integrity sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ== + +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + +bl@^1.0.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.3.tgz#1e8dd80142eac80d7158c9dccc047fb620e035e7" + integrity sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww== + dependencies: + readable-stream "^2.3.5" + safe-buffer "^5.1.1" + +bl@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.0.3.tgz#12d6287adc29080e22a705e5764b2a9522cdc489" + integrity sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + +bl@^4.0.3, bl@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + +bluebird@^3.5.5, bluebird@^3.7.1: + version "3.7.2" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" + integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== + +blueimp-md5@^2.10.0: + version "2.18.0" + resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.18.0.tgz#1152be1335f0c6b3911ed9e36db54f3e6ac52935" + integrity sha512-vE52okJvzsVWhcgUHOv+69OG3Mdg151xyn41aVQN/5W5S+S43qZhxECtYLAEHMSFWX6Mv5IZrzj3T5+JqXfj5Q== + +bmp-js@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/bmp-js/-/bmp-js-0.1.0.tgz#e05a63f796a6c1ff25f4771ec7adadc148c07233" + integrity sha1-4Fpj95amwf8l9Hcex62twUjAcjM= + +bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0: + version "4.11.9" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" + integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw== + +bn.js@^5.1.1: + version "5.1.3" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.3.tgz#beca005408f642ebebea80b042b4d18d2ac0ee6b" + integrity sha512-GkTiFpjFtUzU9CbMeJ5iazkCzGL3jrhzerzZIuqLABjbwRaFt33I9tUdSNryIptM+RxDet6OKm2WnLXzW51KsQ== + +body-parser@1.19.0, body-parser@^1.19.0: + version "1.19.0" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" + integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== + dependencies: + bytes "3.1.0" + content-type "~1.0.4" + debug "2.6.9" + depd "~1.1.2" + http-errors "1.7.2" + iconv-lite "0.4.24" + on-finished "~2.3.0" + qs "6.7.0" + raw-body "2.4.0" + type-is "~1.6.17" + +bonjour@^3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/bonjour/-/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5" + integrity sha1-jokKGD2O6aI5OzhExpGkK897yfU= + dependencies: + array-flatten "^2.1.0" + deep-equal "^1.0.1" + dns-equal "^1.0.0" + dns-txt "^2.0.2" + multicast-dns "^6.0.1" + multicast-dns-service-types "^1.1.0" + +boolbase@^1.0.0, boolbase@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= + +boxen@^4.1.0, boxen@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/boxen/-/boxen-4.2.0.tgz#e411b62357d6d6d36587c8ac3d5d974daa070e64" + integrity sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ== + dependencies: + ansi-align "^3.0.0" + camelcase "^5.3.1" + chalk "^3.0.0" + cli-boxes "^2.2.0" + string-width "^4.1.0" + term-size "^2.1.0" + type-fest "^0.8.1" + widest-line "^3.1.0" + +boxen@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.0.1.tgz#657528bdd3f59a772b8279b831f27ec2c744664b" + integrity sha512-49VBlw+PrWEF51aCmy7QIteYPIFZxSpvqBdP/2itCPPlJ49kj9zg/XPRFrdkne2W+CfwXUls8exMvu1RysZpKA== + dependencies: + ansi-align "^3.0.0" + camelcase "^6.2.0" + chalk "^4.1.0" + cli-boxes "^2.2.1" + string-width "^4.2.0" + type-fest "^0.20.2" + widest-line "^3.1.0" + wrap-ansi "^7.0.0" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^2.3.1, braces@^2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" + integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + +braces@^3.0.1, braces@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +brorand@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" + integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= + +browserify-aes@^1.0.0, browserify-aes@^1.0.4: + version "1.2.0" + resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" + integrity sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== + dependencies: + buffer-xor "^1.0.3" + cipher-base "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.3" + inherits "^2.0.1" + safe-buffer "^5.0.1" + +browserify-cipher@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/browserify-cipher/-/browserify-cipher-1.0.1.tgz#8d6474c1b870bfdabcd3bcfcc1934a10e94f15f0" + integrity sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w== + dependencies: + browserify-aes "^1.0.4" + browserify-des "^1.0.0" + evp_bytestokey "^1.0.0" + +browserify-des@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/browserify-des/-/browserify-des-1.0.2.tgz#3af4f1f59839403572f1c66204375f7a7f703e9c" + integrity sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A== + dependencies: + cipher-base "^1.0.1" + des.js "^1.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + +browserify-rsa@^4.0.0, browserify-rsa@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524" + integrity sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ= + dependencies: + bn.js "^4.1.0" + randombytes "^2.0.1" + +browserify-sign@^4.0.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.1.tgz#eaf4add46dd54be3bb3b36c0cf15abbeba7956c3" + integrity sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg== + dependencies: + bn.js "^5.1.1" + browserify-rsa "^4.0.1" + create-hash "^1.2.0" + create-hmac "^1.1.7" + elliptic "^6.5.3" + inherits "^2.0.4" + parse-asn1 "^5.1.5" + readable-stream "^3.6.0" + safe-buffer "^5.2.0" + +browserify-zlib@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f" + integrity sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA== + dependencies: + pako "~1.0.5" + +browserslist@4.14.2: + version "4.14.2" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.2.tgz#1b3cec458a1ba87588cc5e9be62f19b6d48813ce" + integrity sha512-HI4lPveGKUR0x2StIz+2FXfDk9SfVMrxn6PLh1JeGUwcuoDkdKZebWiyLRJ68iIPDpMI4JLVDf7S7XzslgWOhw== + dependencies: + caniuse-lite "^1.0.30001125" + electron-to-chromium "^1.3.564" + escalade "^3.0.2" + node-releases "^1.1.61" + +browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.6.4, browserslist@^4.8.5: + version "4.14.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.1.tgz#cb2b490ba881d45dc3039078c7ed04411eaf3fa3" + integrity sha512-zyBTIHydW37pnb63c7fHFXUG6EcqWOqoMdDx6cdyaDFriZ20EoVxcE95S54N+heRqY8m8IUgB5zYta/gCwSaaA== + dependencies: + caniuse-lite "^1.0.30001124" + electron-to-chromium "^1.3.562" + escalade "^3.0.2" + node-releases "^1.1.60" + +browserslist@^4.14.5, browserslist@^4.16.1: + version "4.16.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.1.tgz#bf757a2da376b3447b800a16f0f1c96358138766" + integrity sha512-UXhDrwqsNcpTYJBTZsbGATDxZbiVDsx6UjpmRUmtnP10pr8wAYr5LgFoEFw9ixriQH2mv/NX2SfGzE/o8GndLA== + dependencies: + caniuse-lite "^1.0.30001173" + colorette "^1.2.1" + electron-to-chromium "^1.3.634" + escalade "^3.1.1" + node-releases "^1.1.69" + +browserslist@^4.16.6: + version "4.16.6" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2" + integrity sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ== + dependencies: + caniuse-lite "^1.0.30001219" + colorette "^1.2.2" + electron-to-chromium "^1.3.723" + escalade "^3.1.1" + node-releases "^1.1.71" + +buffer-alloc-unsafe@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" + integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg== + +buffer-alloc@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec" + integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow== + dependencies: + buffer-alloc-unsafe "^1.1.0" + buffer-fill "^1.0.0" + +buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3: + version "0.2.13" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" + integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= + +buffer-equal@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-0.0.1.tgz#91bc74b11ea405bc916bc6aa908faafa5b4aac4b" + integrity sha1-kbx0sR6kBbyRa8aqkI+q+ltKrEs= + +buffer-es6@^4.9.3: + version "4.9.3" + resolved "https://registry.yarnpkg.com/buffer-es6/-/buffer-es6-4.9.3.tgz#f26347b82df76fd37e18bcb5288c4970cfd5c404" + integrity sha1-8mNHuC33b9N+GLy1KIxJcM/VxAQ= + +buffer-fill@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" + integrity sha1-+PeLdniYiO858gXNY39o5wISKyw= + +buffer-from@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" + integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== + +buffer-indexof@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c" + integrity sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g== + +buffer-json@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/buffer-json/-/buffer-json-2.0.0.tgz#f73e13b1e42f196fe2fd67d001c7d7107edd7c23" + integrity sha512-+jjPFVqyfF1esi9fvfUs3NqM0pH1ziZ36VP4hmA/y/Ssfo/5w5xHKfTw9BwQjoJ1w/oVtpLomqwUHKdefGyuHw== + +buffer-xor@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" + integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk= + +buffer@4.9.2, buffer@^4.3.0: + version "4.9.2" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.2.tgz#230ead344002988644841ab0244af8c44bbe3ef8" + integrity sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg== + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" + isarray "^1.0.0" + +buffer@^5.2.0, buffer@^5.2.1, buffer@^5.5.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786" + integrity sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw== + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" + +builtin-modules@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.2.0.tgz#45d5db99e7ee5e6bc4f362e008bf917ab5049887" + integrity sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA== + +builtin-status-codes@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" + integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug= + +builtins@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/builtins/-/builtins-1.0.3.tgz#cb94faeb61c8696451db36534e1422f94f0aee88" + integrity sha1-y5T662HIaWRR2zZTThQi+U8K7og= + +byline@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/byline/-/byline-5.0.0.tgz#741c5216468eadc457b03410118ad77de8c1ddb1" + integrity sha1-dBxSFkaOrcRXsDQQEYrXfejB3bE= + +bytes@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" + integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg= + +bytes@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" + integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== + +cacache@^12.0.2: + version "12.0.4" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.4.tgz#668bcbd105aeb5f1d92fe25570ec9525c8faa40c" + integrity sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ== + dependencies: + bluebird "^3.5.5" + chownr "^1.1.1" + figgy-pudding "^3.5.1" + glob "^7.1.4" + graceful-fs "^4.1.15" + infer-owner "^1.0.3" + lru-cache "^5.1.1" + mississippi "^3.0.0" + mkdirp "^0.5.1" + move-concurrently "^1.0.1" + promise-inflight "^1.0.1" + rimraf "^2.6.3" + ssri "^6.0.1" + unique-filename "^1.1.1" + y18n "^4.0.0" + +cacache@^15.0.5: + version "15.0.5" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.0.5.tgz#69162833da29170d6732334643c60e005f5f17d0" + integrity sha512-lloiL22n7sOjEEXdL8NAjTgv9a1u43xICE9/203qonkZUCj5X1UEWIdf2/Y0d6QcCtMzbKQyhrcDbdvlZTs/+A== + dependencies: + "@npmcli/move-file" "^1.0.1" + chownr "^2.0.0" + fs-minipass "^2.0.0" + glob "^7.1.4" + infer-owner "^1.0.4" + lru-cache "^6.0.0" + minipass "^3.1.1" + minipass-collect "^1.0.2" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.2" + mkdirp "^1.0.3" + p-map "^4.0.0" + promise-inflight "^1.0.1" + rimraf "^3.0.2" + ssri "^8.0.0" + tar "^6.0.2" + unique-filename "^1.1.1" + +cache-base@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" + integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== + dependencies: + collection-visit "^1.0.0" + component-emitter "^1.2.1" + get-value "^2.0.6" + has-value "^1.0.0" + isobject "^3.0.1" + set-value "^2.0.0" + to-object-path "^0.3.0" + union-value "^1.0.0" + unset-value "^1.0.0" + +cache-loader@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/cache-loader/-/cache-loader-4.1.0.tgz#9948cae353aec0a1fcb1eafda2300816ec85387e" + integrity sha512-ftOayxve0PwKzBF/GLsZNC9fJBXl8lkZE3TOsjkboHfVHVkL39iUEs1FO07A33mizmci5Dudt38UZrrYXDtbhw== + dependencies: + buffer-json "^2.0.0" + find-cache-dir "^3.0.0" + loader-utils "^1.2.3" + mkdirp "^0.5.1" + neo-async "^2.6.1" + schema-utils "^2.0.0" + +cacheable-lookup@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-2.0.1.tgz#87be64a18b925234875e10a9bb1ebca4adce6b38" + integrity sha512-EMMbsiOTcdngM/K6gV/OxF2x0t07+vMOWxZNSCRQMjO2MY2nhZQ6OYhOOpyQrbhqsgtvKGI7hcq6xjnA92USjg== + dependencies: + "@types/keyv" "^3.1.1" + keyv "^4.0.0" + +cacheable-request@^2.1.1: + version "2.1.4" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-2.1.4.tgz#0d808801b6342ad33c91df9d0b44dc09b91e5c3d" + integrity sha1-DYCIAbY0KtM8kd+dC0TcCbkeXD0= + dependencies: + clone-response "1.0.2" + get-stream "3.0.0" + http-cache-semantics "3.8.1" + keyv "3.0.0" + lowercase-keys "1.0.0" + normalize-url "2.0.1" + responselike "1.0.2" + +cacheable-request@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912" + integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg== + dependencies: + clone-response "^1.0.2" + get-stream "^5.1.0" + http-cache-semantics "^4.0.0" + keyv "^3.0.0" + lowercase-keys "^2.0.0" + normalize-url "^4.1.0" + responselike "^1.0.2" + +cacheable-request@^7.0.1: + version "7.0.2" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.2.tgz#ea0d0b889364a25854757301ca12b2da77f91d27" + integrity sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew== + dependencies: + clone-response "^1.0.2" + get-stream "^5.1.0" + http-cache-semantics "^4.0.0" + keyv "^4.0.0" + lowercase-keys "^2.0.0" + normalize-url "^6.0.1" + responselike "^2.0.0" + +cachedir@^2.2.0, cachedir@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8" + integrity sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw== + +call-bind@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" + integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== + dependencies: + function-bind "^1.1.1" + get-intrinsic "^1.0.2" + +call-me-maybe@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" + integrity sha1-JtII6onje1y95gJQoV8DHBak1ms= + +caller-callsite@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" + integrity sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ= + dependencies: + callsites "^2.0.0" + +caller-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" + integrity sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ= + dependencies: + caller-callsite "^2.0.0" + +callsite@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" + integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA= + +callsites@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" + integrity sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA= + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camel-case@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.1.tgz#1fc41c854f00e2f7d0139dfeba1542d6896fe547" + integrity sha512-7fa2WcG4fYFkclIvEmxBbTvmibwF2/agfEBc6q3lOpVu0A13ltLsA+Hr/8Hp6kp5f+G7hKi6t8lys6XxP+1K6Q== + dependencies: + pascal-case "^3.1.1" + tslib "^1.10.0" + +camelcase-css@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" + integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== + +camelcase@^5.0.0, camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +camelcase@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.0.0.tgz#5259f7c30e35e278f1bdc2a4d91230b37cad981e" + integrity sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w== + +camelcase@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" + integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== + +camelize@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b" + integrity sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs= + +caniuse-api@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" + integrity sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw== + dependencies: + browserslist "^4.0.0" + caniuse-lite "^1.0.0" + lodash.memoize "^4.1.2" + lodash.uniq "^4.5.0" + +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001124, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001173: + version "1.0.30001214" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001214.tgz" + integrity sha512-O2/SCpuaU3eASWVaesQirZv1MSjUNOvmugaD8zNSJqw6Vv5SGwoOpA9LJs3pNPfM745nxqPvfZY3MQKY4AKHYg== + +caniuse-lite@^1.0.30001219: + version "1.0.30001240" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001240.tgz#ec15d125b590602c8731545c5351ff054ad2d52f" + integrity sha512-nb8mDzfMdxBDN7ZKx8chWafAdBp5DAAlpWvNyUGe5tcDWd838zpzDN3Rah9cjCqhfOKkrvx40G2SDtP0qiWX/w== + +cardinal@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/cardinal/-/cardinal-2.1.1.tgz#7cc1055d822d212954d07b085dea251cc7bc5505" + integrity sha1-fMEFXYItISlU0HsIXeolHMe8VQU= + dependencies: + ansicolors "~0.3.2" + redeyed "~2.1.0" + +ccount@^1.0.0, ccount@^1.0.3: + version "1.0.5" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.0.5.tgz#ac82a944905a65ce204eb03023157edf29425c17" + integrity sha512-MOli1W+nfbPLlKEhInaxhRdp7KVLFxLN5ykwzHgLsLI3H3gs5jjFAK4Eoj3OzzcxCtumDaI8onoVDeQyWaNTkw== + +chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.1, chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.5.1.tgz#663b3a648b68b55d04690d49167aa837858f2174" + integrity sha1-Zjs6ZItotV0EaQ1JFnqoN4WPIXQ= + dependencies: + ansi-styles "^1.1.0" + escape-string-regexp "^1.0.0" + has-ansi "^0.1.0" + strip-ansi "^0.3.0" + supports-color "^0.2.0" + +chalk@^1.0.0, chalk@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + +chalk@^3.0.0, chalk@^3.0.0-beta.2: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chalk@^4.0.0, chalk@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.1.tgz#c80b3fab28bf6371e6863325eee67e618b77e6ad" + integrity sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +character-entities-html4@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-1.1.4.tgz#0e64b0a3753ddbf1fdc044c5fd01d0199a02e125" + integrity sha512-HRcDxZuZqMx3/a+qrzxdBKBPUpxWEq9xw2OPZ3a/174ihfrQKVsFhqtthBInFy1zZ9GgZyFXOatNujm8M+El3g== + +character-entities-legacy@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz#94bc1845dce70a5bb9d2ecc748725661293d8fc1" + integrity sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA== + +character-entities@^1.0.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.4.tgz#e12c3939b7eaf4e5b15e7ad4c5e28e1d48c5b16b" + integrity sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw== + +character-reference-invalid@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560" + integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg== + +chardet@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" + integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== + +check-links@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/check-links/-/check-links-1.1.8.tgz#842184178c85d9c2ab119175bcc2672681bc88a4" + integrity sha512-lxt1EeQ1CVkmiZzPfbPufperYK0t7MvhdLs3zlRH9areA6NVT1tcGymAdJONolNWQBdCFU/sek59RpeLmVHCnw== + dependencies: + got "^9.6.0" + is-relative-url "^2.0.0" + p-map "^2.0.0" + p-memoize "^2.1.0" + +check-types@^8.0.3: + version "8.0.3" + resolved "https://registry.yarnpkg.com/check-types/-/check-types-8.0.3.tgz#3356cca19c889544f2d7a95ed49ce508a0ecf552" + integrity sha512-YpeKZngUmG65rLudJ4taU7VLkOCTMhNl/u4ctNC56LQS/zJTyNH0Lrtwm1tfTsbLlwvlfsA2d1c8vCf/Kh2KwQ== + +cheerio@^0.22.0: + version "0.22.0" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-0.22.0.tgz#a9baa860a3f9b595a6b81b1a86873121ed3a269e" + integrity sha1-qbqoYKP5tZWmuBsahocxIe06Jp4= + dependencies: + css-select "~1.2.0" + dom-serializer "~0.1.0" + entities "~1.1.1" + htmlparser2 "^3.9.1" + lodash.assignin "^4.0.9" + lodash.bind "^4.1.4" + lodash.defaults "^4.0.1" + lodash.filter "^4.4.0" + lodash.flatten "^4.2.0" + lodash.foreach "^4.3.0" + lodash.map "^4.4.0" + lodash.merge "^4.4.0" + lodash.pick "^4.2.1" + lodash.reduce "^4.4.0" + lodash.reject "^4.4.0" + lodash.some "^4.4.0" + +"chokidar@>=2.0.0 <4.0.0", chokidar@^3.0.0, chokidar@^3.0.2, chokidar@^3.3.0, chokidar@^3.4.1: + version "3.4.2" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.2.tgz#38dc8e658dec3809741eb3ef7bb0a47fe424232d" + integrity sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.4.0" + optionalDependencies: + fsevents "~2.1.2" + +chokidar@^2.1.8: + version "2.1.8" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" + integrity sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg== + dependencies: + anymatch "^2.0.0" + async-each "^1.0.1" + braces "^2.3.2" + glob-parent "^3.1.0" + inherits "^2.0.3" + is-binary-path "^1.0.0" + is-glob "^4.0.0" + normalize-path "^3.0.0" + path-is-absolute "^1.0.0" + readdirp "^2.2.1" + upath "^1.1.1" + optionalDependencies: + fsevents "^1.2.7" + +chownr@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + +chownr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" + integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + +chrome-trace-event@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz#234090ee97c7d4ad1a2c4beae27505deffc608a4" + integrity sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ== + dependencies: + tslib "^1.9.0" + +ci-info@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497" + integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A== + +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + +ci-info@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.2.0.tgz#2876cb948a498797b5236f0095bc057d0dca38b6" + integrity sha512-dVqRX7fLUm8J6FgHJ418XuIgDLZDkYcDFTeL6TA2gt5WlIZUQrrH6EZrNClwT/H0FateUsZkGIOPRrLbP+PR9A== + +cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" + integrity sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +class-utils@^0.3.5: + version "0.3.6" + resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" + integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== + dependencies: + arr-union "^3.1.0" + define-property "^0.2.5" + isobject "^3.0.0" + static-extend "^0.1.1" + +classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" + integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== + +clean-css@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78" + integrity sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA== + dependencies: + source-map "~0.6.0" + +clean-deep@^3.0.2: + version "3.4.0" + resolved "https://registry.yarnpkg.com/clean-deep/-/clean-deep-3.4.0.tgz#c465c4de1003ae13a1a859e6c69366ab96069f75" + integrity sha512-Lo78NV5ItJL/jl+B5w0BycAisaieJGXK1qYi/9m4SjR8zbqmrUtO7Yhro40wEShGmmxs/aJLI/A+jNhdkXK8mw== + dependencies: + lodash.isempty "^4.4.0" + lodash.isplainobject "^4.0.6" + lodash.transform "^4.6.0" + +clean-stack@^2.0.0, clean-stack@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" + integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== + +clean-stack@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-3.0.0.tgz#a7c249369fcf0f33c7888c20ea3f3dc79620211f" + integrity sha512-RHxtgFvXsRQ+1AM7dlozLDY7ssmvUUh0XEnfnyhYgJTO6beNZHBogiaCwGM9Q3rFrUkYxOtsZRC0zAturg5bjg== + dependencies: + escape-string-regexp "4.0.0" + +cli-boxes@^2.2.0, cli-boxes@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" + integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== + +cli-cursor@^2.0.0, cli-cursor@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" + integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU= + dependencies: + restore-cursor "^2.0.0" + +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + +cli-progress@^3.4.0: + version "3.8.2" + resolved "https://registry.yarnpkg.com/cli-progress/-/cli-progress-3.8.2.tgz#abaf1fc6d6401351f16f068117a410554a0eb8c7" + integrity sha512-qRwBxLldMSfxB+YGFgNRaj5vyyHe1yMpVeDL79c+7puGujdKJHQHydgqXDcrkvQgJ5U/d3lpf6vffSoVVUftVQ== + dependencies: + colors "^1.1.2" + string-width "^4.2.0" + +cli-progress@^3.7.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/cli-progress/-/cli-progress-3.9.0.tgz#25db83447deb812e62d05bac1af9aec5387ef3d4" + integrity sha512-g7rLWfhAo/7pF+a/STFH/xPyosaL1zgADhI0OM83hl3c7S43iGvJWEAV2QuDOnQ8i6EMBj/u4+NTd0d5L+4JfA== + dependencies: + colors "^1.1.2" + string-width "^4.2.0" + +cli-spinners@^2.5.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.6.0.tgz#36c7dc98fb6a9a76bd6238ec3f77e2425627e939" + integrity sha512-t+4/y50K/+4xcCRosKkA7W4gTr1MySvLV0q+PxmG7FJ5g+66ChKurYjxBCjHggHH3HA5Hh9cy+lcUGWDqVH+4Q== + +cli-truncate@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-0.2.1.tgz#9f15cfbb0705005369216c626ac7d05ab90dd574" + integrity sha1-nxXPuwcFAFNpIWxiasfQWrkN1XQ= + dependencies: + slice-ansi "0.0.4" + string-width "^1.0.1" + +cli-ux@^4.9.0: + version "4.9.3" + resolved "https://registry.yarnpkg.com/cli-ux/-/cli-ux-4.9.3.tgz#4c3e070c1ea23eef010bbdb041192e0661be84ce" + integrity sha512-/1owvF0SZ5Gn54cgrikJ0QskgTzeg30HGjkmjFoaHDJzAqFpuX1DBpFR8aLvsE1J5s9MgeYRENQK4BFwOag5VA== + dependencies: + "@oclif/errors" "^1.2.2" + "@oclif/linewrap" "^1.0.0" + "@oclif/screen" "^1.0.3" + ansi-escapes "^3.1.0" + ansi-styles "^3.2.1" + cardinal "^2.1.1" + chalk "^2.4.1" + clean-stack "^2.0.0" + extract-stack "^1.0.0" + fs-extra "^7.0.0" + hyperlinker "^1.0.0" + indent-string "^3.2.0" + is-wsl "^1.1.0" + lodash "^4.17.11" + password-prompt "^1.0.7" + semver "^5.6.0" + strip-ansi "^5.0.0" + supports-color "^5.5.0" + supports-hyperlinks "^1.0.1" + treeify "^1.1.0" + tslib "^1.9.3" + +cli-ux@^5.2.1, cli-ux@^5.5.1: + version "5.6.2" + resolved "https://registry.yarnpkg.com/cli-ux/-/cli-ux-5.6.2.tgz#c78b953b14cdf95b4bb6aae8db0ab6745333405c" + integrity sha512-CuiamOCfPaOTjbuAQXdFsfZLQmO6XSmCDxulq4y8pIets1hZ3eaysHppPKGdrcdgLugUGUap5+bXd3IukJASBA== + dependencies: + "@oclif/command" "^1.6.0" + "@oclif/errors" "^1.2.1" + "@oclif/linewrap" "^1.0.0" + "@oclif/screen" "^1.0.3" + ansi-escapes "^4.3.0" + ansi-styles "^4.2.0" + cardinal "^2.1.1" + chalk "^4.1.0" + clean-stack "^3.0.0" + cli-progress "^3.4.0" + extract-stack "^2.0.0" + fs-extra "^8.1" + hyperlinker "^1.0.0" + indent-string "^4.0.0" + is-wsl "^2.2.0" + js-yaml "^3.13.1" + lodash "^4.17.11" + natural-orderby "^2.0.1" + object-treeify "^1.1.4" + password-prompt "^1.1.2" + semver "^7.3.2" + string-width "^4.2.0" + strip-ansi "^6.0.0" + supports-color "^8.1.0" + supports-hyperlinks "^2.1.0" + tslib "^2.0.0" + +cli-width@^2.0.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.1.tgz#b0433d0b4e9c847ef18868a4ef16fd5fc8271c48" + integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw== + +cli-width@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" + integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== + +clipboard@^2.0.0: + version "2.0.8" + resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.8.tgz#ffc6c103dd2967a83005f3f61976aa4655a4cdba" + integrity sha512-Y6WO0unAIQp5bLmk1zdThRhgJt/x3ks6f30s3oE3H1mgIEU33XyQjEf8gsf6DxC7NPX8Y1SsNWjUjL/ywLnnbQ== + dependencies: + good-listener "^1.2.2" + select "^1.1.2" + tiny-emitter "^2.0.0" + +cliui@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" + integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== + dependencies: + string-width "^3.1.0" + strip-ansi "^5.2.0" + wrap-ansi "^5.1.0" + +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +clone-deep@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-0.2.4.tgz#4e73dd09e9fb971cc38670c5dced9c1896481cc6" + integrity sha1-TnPdCen7lxzDhnDF3O2cGJZIHMY= + dependencies: + for-own "^0.1.3" + is-plain-object "^2.0.1" + kind-of "^3.0.2" + lazy-cache "^1.0.3" + shallow-clone "^0.1.2" + +clone-response@1.0.2, clone-response@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" + integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws= + dependencies: + mimic-response "^1.0.0" + +clone@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" + integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= + +clsx@^1.1.0, clsx@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" + integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA== + +co@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/co/-/co-3.1.0.tgz#4ea54ea5a08938153185e15210c68d9092bc1b78" + integrity sha1-TqVOpaCJOBUxheFSEMaNkJK8G3g= + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= + +coa@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/coa/-/coa-2.0.2.tgz#43f6c21151b4ef2bf57187db0d73de229e3e7ec3" + integrity sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA== + dependencies: + "@types/q" "^1.5.1" + chalk "^2.4.1" + q "^1.1.2" + +code-error-fragment@0.0.230: + version "0.0.230" + resolved "https://registry.yarnpkg.com/code-error-fragment/-/code-error-fragment-0.0.230.tgz#d736d75c832445342eca1d1fedbf17d9618b14d7" + integrity sha512-cadkfKp6932H8UkhzE/gcUqhRMNf8jHzkAN7+5Myabswaghu4xABTgPHDCjW+dBAJxj/SpkTYokpzDqY4pCzQw== + +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= + +collapse-white-space@^1.0.2: + version "1.0.6" + resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-1.0.6.tgz#e63629c0016665792060dbbeb79c42239d2c5287" + integrity sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ== + +collection-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" + integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= + dependencies: + map-visit "^1.0.0" + object-visit "^1.0.0" + +color-convert@^1.9.0, color-convert@^1.9.1: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +color-name@^1.0.0, color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-string@^1.5.2: + version "1.5.3" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc" + integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + +color@3.0.x: + version "3.0.0" + resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a" + integrity sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w== + dependencies: + color-convert "^1.9.1" + color-string "^1.5.2" + +color@^3.0.0, color@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/color/-/color-3.1.2.tgz#68148e7f85d41ad7649c5fa8c8106f098d229e10" + integrity sha512-vXTJhHebByxZn3lDvDJYw4lR5+uB3vuoHsuYA5AKuxRVn5wzzIfQKGLBmgdVRHKTJYeK5rvJcHnrd0Li49CFpg== + dependencies: + color-convert "^1.9.1" + color-string "^1.5.2" + +colorette@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b" + integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw== + +colorette@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" + integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== + +colors@^1.1.2, colors@^1.2.1: + version "1.4.0" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" + integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== + +colorspace@1.1.x: + version "1.1.2" + resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.2.tgz#e0128950d082b86a2168580796a0aa5d6c68d8c5" + integrity sha512-vt+OoIP2d76xLhjwbBaucYlNSpPsrJWPlBTtwCpQKIu6/CSMutyzX93O/Do0qzpH3YoHEes8YEFXyZ797rEhzQ== + dependencies: + color "3.0.x" + text-hex "1.0.x" + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +comma-separated-tokens@^1.0.0: + version "1.0.8" + resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea" + integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw== + +commander@^2.18.0, commander@^2.20.0, commander@^2.20.3, commander@^2.3.0, commander@^2.8.1: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +commander@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e" + integrity sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow== + +commander@^4.0.1, commander@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== + +commander@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" + integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== + +common-path-prefix@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/common-path-prefix/-/common-path-prefix-3.0.0.tgz#7d007a7e07c58c4b4d5f433131a19141b29f11e0" + integrity sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w== + +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= + +component-emitter@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + +compress-commons@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-4.1.1.tgz#df2a09a7ed17447642bad10a85cc9a19e5c42a7d" + integrity sha512-QLdDLCKNV2dtoTorqgxngQCMA+gWXkM/Nwu7FpeBhk/RdkzimqC3jueb/FDmaZeXh+uby1jkBqE3xArsLBE5wQ== + dependencies: + buffer-crc32 "^0.2.13" + crc32-stream "^4.0.2" + normalize-path "^3.0.0" + readable-stream "^3.6.0" + +compressible@~2.0.16: + version "2.0.18" + resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" + integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== + dependencies: + mime-db ">= 1.43.0 < 2" + +compression@^1.7.4: + version "1.7.4" + resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f" + integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ== + dependencies: + accepts "~1.3.5" + bytes "3.0.0" + compressible "~2.0.16" + debug "2.6.9" + on-headers "~1.0.2" + safe-buffer "5.1.2" + vary "~1.1.2" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +concat-stream@^1.5.0: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +concat-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" + integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.0.2" + typedarray "^0.0.6" + +concordance@^5.0.0: + version "5.0.4" + resolved "https://registry.yarnpkg.com/concordance/-/concordance-5.0.4.tgz#9896073261adced72f88d60e4d56f8efc4bbbbd2" + integrity sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw== + dependencies: + date-time "^3.1.0" + esutils "^2.0.3" + fast-diff "^1.2.0" + js-string-escape "^1.0.1" + lodash "^4.17.15" + md5-hex "^3.0.1" + semver "^7.3.2" + well-known-symbols "^2.0.0" + +configstore@^5.0.0, configstore@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96" + integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA== + dependencies: + dot-prop "^5.2.0" + graceful-fs "^4.1.2" + make-dir "^3.0.0" + unique-string "^2.0.0" + write-file-atomic "^3.0.0" + xdg-basedir "^4.0.0" + +connect-history-api-fallback@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc" + integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg== + +consola@^2.10.0: + version "2.15.0" + resolved "https://registry.yarnpkg.com/consola/-/consola-2.15.0.tgz#40fc4eefa4d2f8ef2e2806147f056ea207fcc0e9" + integrity sha512-vlcSGgdYS26mPf7qNi+dCisbhiyDnrN1zaRbw3CSuc2wGOMEGGPsp46PdRG5gqXwgtJfjxDkxRNAgRPr1B77vQ== + +console-browserify@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" + integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA== + +console-control-strings@^1.0.0, console-control-strings@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= + +"consolidated-events@^1.1.0 || ^2.0.0": + version "2.0.2" + resolved "https://registry.yarnpkg.com/consolidated-events/-/consolidated-events-2.0.2.tgz#da8d8f8c2b232831413d9e190dc11669c79f4a91" + integrity sha512-2/uRVMdRypf5z/TW/ncD/66l75P5hH2vM/GR8Jf8HLc2xnfJtmina6F6du8+v4Z2vTrMo7jC+W1tmEEuuELgkQ== + +constants-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" + integrity sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U= + +content-disposition@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" + integrity sha1-DPaLud318r55YcOoUXjLhdunjLQ= + +content-disposition@0.5.3, content-disposition@^0.5.2: + version "0.5.3" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" + integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g== + dependencies: + safe-buffer "5.1.2" + +content-type@^1.0.4, content-type@~1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" + integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== + +convert-source-map@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" + integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA== + dependencies: + safe-buffer "~5.1.1" + +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= + +cookie@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" + integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== + +cookie@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" + integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== + +copy-concurrently@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" + integrity sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A== + dependencies: + aproba "^1.1.1" + fs-write-stream-atomic "^1.0.8" + iferr "^0.1.5" + mkdirp "^0.5.1" + rimraf "^2.5.4" + run-queue "^1.0.0" + +copy-descriptor@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" + integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= + +copy-template-dir@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/copy-template-dir/-/copy-template-dir-1.4.0.tgz#cb2bd62415abe963a53bb867bb24379df3998112" + integrity sha512-xkXSJhvKz4MfLbVkZ7GyCaFo4ciB3uKI/HHzkGwj1eyTH5+7RTFxW5CE0irWAZgV5oFcO9hd6+NVXAtY9hlo7Q== + dependencies: + end-of-stream "^1.1.0" + graceful-fs "^4.1.3" + maxstache "^1.0.0" + maxstache-stream "^1.0.0" + mkdirp "^0.5.1" + noop2 "^2.0.0" + pump "^1.0.0" + readdirp "^2.0.0" + run-parallel "^1.1.4" + +copy-text-to-clipboard@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/copy-text-to-clipboard/-/copy-text-to-clipboard-2.2.0.tgz#329dd6daf8c42034c763ace567418401764579ae" + integrity sha512-WRvoIdnTs1rgPMkgA2pUOa/M4Enh2uzCwdKsOMYNAJiz/4ZvEJgmbF4OmninPmlFdAWisfeh0tH+Cpf7ni3RqQ== + +copy-webpack-plugin@^6.0.3: + version "6.4.1" + resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-6.4.1.tgz#138cd9b436dbca0a6d071720d5414848992ec47e" + integrity sha512-MXyPCjdPVx5iiWyl40Va3JGh27bKzOTNY3NjUTrosD2q7dR/cLD0013uqJ3BpFbUjyONINjb6qI7nDIJujrMbA== + dependencies: + cacache "^15.0.5" + fast-glob "^3.2.4" + find-cache-dir "^3.3.1" + glob-parent "^5.1.1" + globby "^11.0.1" + loader-utils "^2.0.0" + normalize-path "^3.0.0" + p-limit "^3.0.2" + schema-utils "^3.0.0" + serialize-javascript "^5.0.1" + webpack-sources "^1.4.3" + +core-js-compat@^3.14.0, core-js-compat@^3.15.0: + version "3.15.1" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.15.1.tgz#1afe233716d37ee021956ef097594071b2b585a7" + integrity sha512-xGhzYMX6y7oEGQGAJmP2TmtBLvR4nZmRGEcFa3ubHOq5YEp51gGN9AovVa0AoujGZIq+Wm6dISiYyGNfdflYww== + dependencies: + browserslist "^4.16.6" + semver "7.0.0" + +core-js-compat@^3.6.2: + version "3.6.5" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.6.5.tgz#2a51d9a4e25dfd6e690251aa81f99e3c05481f1c" + integrity sha512-7ItTKOhOZbznhXAQ2g/slGg1PJV5zDO/WdkTwi7UEOJmkvsE32PWvx6mKtDjiMpjnR2CNf6BAD6sSxIlv7ptng== + dependencies: + browserslist "^4.8.5" + semver "7.0.0" + +core-js-compat@^3.8.0: + version "3.8.3" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.8.3.tgz#9123fb6b9cad30f0651332dc77deba48ef9b0b3f" + integrity sha512-1sCb0wBXnBIL16pfFG1Gkvei6UzvKyTNYpiC41yrdjEv0UoJoq9E/abTMzyYJ6JpTkAj15dLjbqifIzEBDVvog== + dependencies: + browserslist "^4.16.1" + semver "7.0.0" + +core-js-pure@^3.0.0: + version "3.6.5" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813" + integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA== + +core-js@^2.6.5: + version "2.6.11" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" + integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== + +core-js@^3.15.1, core-js@^3.2.1, core-js@^3.4.1: + version "3.15.2" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.15.2.tgz#740660d2ff55ef34ce664d7e2455119c5bdd3d61" + integrity sha512-tKs41J7NJVuaya8DxIOCnl8QuPHx5/ZVbFo1oKgVl1qHFBBrDctzQGtuLjPpRdNTWmKPH6oEvgN/MUID+l485Q== + +core-util-is@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + +cosmiconfig@^5.0.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" + integrity sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA== + dependencies: + import-fresh "^2.0.0" + is-directory "^0.3.1" + js-yaml "^3.13.1" + parse-json "^4.0.0" + +cosmiconfig@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" + integrity sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg== + dependencies: + "@types/parse-json" "^4.0.0" + import-fresh "^3.1.0" + parse-json "^5.0.0" + path-type "^4.0.0" + yaml "^1.7.2" + +cp-file@^6.1.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/cp-file/-/cp-file-6.2.0.tgz#40d5ea4a1def2a9acdd07ba5c0b0246ef73dc10d" + integrity sha512-fmvV4caBnofhPe8kOcitBwSn2f39QLjnAnGq3gO9dfd75mUytzKNZB1hde6QHunW2Rt+OwuBOMc3i1tNElbszA== + dependencies: + graceful-fs "^4.1.2" + make-dir "^2.0.0" + nested-error-stacks "^2.0.0" + pify "^4.0.1" + safe-buffer "^5.0.1" + +cp-file@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/cp-file/-/cp-file-7.0.0.tgz#b9454cfd07fe3b974ab9ea0e5f29655791a9b8cd" + integrity sha512-0Cbj7gyvFVApzpK/uhCtQ/9kE9UnYpxMzaq5nQQC/Dh4iaj5fxp7iEFIullrYwzj8nf0qnsI1Qsx34hAeAebvw== + dependencies: + graceful-fs "^4.1.2" + make-dir "^3.0.0" + nested-error-stacks "^2.0.0" + p-event "^4.1.0" + +cp-file@^9.0.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/cp-file/-/cp-file-9.1.0.tgz#e98e30db72d57d47b5b1d444deb70d05e5684921" + integrity sha512-3scnzFj/94eb7y4wyXRWwvzLFaQp87yyfTnChIjlfYrVqp5lVO3E2hIJMeQIltUT0K2ZAB3An1qXcBmwGyvuwA== + dependencies: + graceful-fs "^4.1.2" + make-dir "^3.0.0" + nested-error-stacks "^2.0.0" + p-event "^4.1.0" + +cpy@^8.1.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/cpy/-/cpy-8.1.1.tgz#066ed4c6eaeed9577df96dae4db9438c1a90df62" + integrity sha512-vqHT+9o67sMwJ5hUd/BAOYeemkU+MuFRsK2c36Xc3eefQpAsp1kAsyDxEDcc5JS1+y9l/XHPrIsVTcyGGmkUUQ== + dependencies: + arrify "^2.0.1" + cp-file "^7.0.0" + globby "^9.2.0" + has-glob "^1.0.0" + junk "^3.1.0" + nested-error-stacks "^2.1.0" + p-all "^2.1.0" + p-filter "^2.1.0" + p-map "^3.0.0" + +crc-32@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.0.tgz#cb2db6e29b88508e32d9dd0ec1693e7b41a18208" + integrity sha512-1uBwHxF+Y/4yF5G48fwnKq6QsIXheor3ZLPT80yGBV1oEUwpPojlEhQbWKVw1VwcTQyMGHK1/XMmTjmlsmTTGA== + dependencies: + exit-on-epipe "~1.0.1" + printj "~1.1.0" + +crc32-stream@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-4.0.2.tgz#c922ad22b38395abe9d3870f02fa8134ed709007" + integrity sha512-DxFZ/Hk473b/muq1VJ///PMNLj0ZMnzye9thBpmjpJKCc5eMgB95aK8zCGrGfQ90cWo561Te6HK9D+j4KPdM6w== + dependencies: + crc-32 "^1.2.0" + readable-stream "^3.4.0" + +create-ecdh@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e" + integrity sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A== + dependencies: + bn.js "^4.1.0" + elliptic "^6.5.3" + +create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" + integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + md5.js "^1.3.4" + ripemd160 "^2.0.1" + sha.js "^2.4.0" + +create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" + integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== + dependencies: + cipher-base "^1.0.3" + create-hash "^1.1.0" + inherits "^2.0.1" + ripemd160 "^2.0.0" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +cross-fetch@^3.0.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.4.tgz#9723f3a3a247bf8b89039f3a380a9244e8fa2f39" + integrity sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ== + dependencies: + node-fetch "2.6.1" + +cross-spawn@7.0.3, cross-spawn@^7.0.0, cross-spawn@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +cross-spawn@^6.0.0, cross-spawn@^6.0.5: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + +crypto-browserify@^3.11.0: + version "3.12.0" + resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" + integrity sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg== + dependencies: + browserify-cipher "^1.0.0" + browserify-sign "^4.0.0" + create-ecdh "^4.0.0" + create-hash "^1.1.0" + create-hmac "^1.1.0" + diffie-hellman "^5.0.0" + inherits "^2.0.1" + pbkdf2 "^3.0.3" + public-encrypt "^4.0.0" + randombytes "^2.0.0" + randomfill "^1.0.3" + +crypto-random-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" + integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== + +css-blank-pseudo@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz#dfdefd3254bf8a82027993674ccf35483bfcb3c5" + integrity sha512-LHz35Hr83dnFeipc7oqFDmsjHdljj3TQtxGGiNWSOsTLIAubSm4TEz8qCaKFpk7idaQ1GfWscF4E6mgpBysA1w== + dependencies: + postcss "^7.0.5" + +css-color-keywords@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" + integrity sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU= + +css-color-names@0.0.4, css-color-names@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" + integrity sha1-gIrcLnnPhHOAabZGyyDsJ762KeA= + +css-declaration-sorter@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-4.0.1.tgz#c198940f63a76d7e36c1e71018b001721054cb22" + integrity sha512-BcxQSKTSEEQUftYpBVnsH4SF05NTuBokb19/sBt6asXGKZ/6VP7PLG1CBCkFDYOnhXhPh0jMhO6xZ71oYHXHBA== + dependencies: + postcss "^7.0.1" + timsort "^0.3.0" + +css-has-pseudo@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/css-has-pseudo/-/css-has-pseudo-0.10.0.tgz#3c642ab34ca242c59c41a125df9105841f6966ee" + integrity sha512-Z8hnfsZu4o/kt+AuFzeGpLVhFOGO9mluyHBaA2bA8aCGTwah5sT3WV/fTHH8UNZUytOIImuGPrl/prlb4oX4qQ== + dependencies: + postcss "^7.0.6" + postcss-selector-parser "^5.0.0-rc.4" + +css-loader@^3.4.2: + version "3.6.0" + resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.6.0.tgz#2e4b2c7e6e2d27f8c8f28f61bffcd2e6c91ef645" + integrity sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ== + dependencies: + camelcase "^5.3.1" + cssesc "^3.0.0" + icss-utils "^4.1.1" + loader-utils "^1.2.3" + normalize-path "^3.0.0" + postcss "^7.0.32" + postcss-modules-extract-imports "^2.0.0" + postcss-modules-local-by-default "^3.0.2" + postcss-modules-scope "^2.2.0" + postcss-modules-values "^3.0.0" + postcss-value-parser "^4.1.0" + schema-utils "^2.7.0" + semver "^6.3.0" + +css-prefers-color-scheme@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/css-prefers-color-scheme/-/css-prefers-color-scheme-3.1.1.tgz#6f830a2714199d4f0d0d0bb8a27916ed65cff1f4" + integrity sha512-MTu6+tMs9S3EUqzmqLXEcgNRbNkkD/TGFvowpeoWJn5Vfq7FMgsmRQs9X5NXAURiOBmOxm/lLjsDNXDE6k9bhg== + dependencies: + postcss "^7.0.5" + +css-select-base-adapter@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz#3b2ff4972cc362ab88561507a95408a1432135d7" + integrity sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w== + +css-select@^1.1.0, css-select@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" + integrity sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg= + dependencies: + boolbase "~1.0.0" + css-what "2.1" + domutils "1.5.1" + nth-check "~1.0.1" + +css-select@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-2.1.0.tgz#6a34653356635934a81baca68d0255432105dbef" + integrity sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ== + dependencies: + boolbase "^1.0.0" + css-what "^3.2.1" + domutils "^1.7.0" + nth-check "^1.0.2" + +css-to-react-native@^2.2.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/css-to-react-native/-/css-to-react-native-2.3.2.tgz#e75e2f8f7aa385b4c3611c52b074b70a002f2e7d" + integrity sha512-VOFaeZA053BqvvvqIA8c9n0+9vFppVBAHCp6JgFTtTMU3Mzi+XnelJ9XC9ul3BqFzZyQ5N+H0SnwsWT2Ebchxw== + dependencies: + camelize "^1.0.0" + css-color-keywords "^1.0.0" + postcss-value-parser "^3.3.0" + +css-tree@1.0.0-alpha.37: + version "1.0.0-alpha.37" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.37.tgz#98bebd62c4c1d9f960ec340cf9f7522e30709a22" + integrity sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg== + dependencies: + mdn-data "2.0.4" + source-map "^0.6.1" + +css-tree@1.0.0-alpha.39: + version "1.0.0-alpha.39" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.39.tgz#2bff3ffe1bb3f776cf7eefd91ee5cba77a149eeb" + integrity sha512-7UvkEYgBAHRG9Nt980lYxjsTrCyHFN53ky3wVsDkiMdVqylqRt+Zc+jm5qw7/qyOvN2dHSYtX0e4MbCCExSvnA== + dependencies: + mdn-data "2.0.6" + source-map "^0.6.1" + +css-what@2.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2" + integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== + +css-what@^3.2.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.3.0.tgz#10fec696a9ece2e591ac772d759aacabac38cd39" + integrity sha512-pv9JPyatiPaQ6pf4OvD/dbfm0o5LviWmwxNWzblYf/1u9QZd0ihV+PMwy5jdQWQ3349kZmKEx9WXuSka2dM4cg== + +cssdb@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-4.4.0.tgz#3bf2f2a68c10f5c6a08abd92378331ee803cddb0" + integrity sha512-LsTAR1JPEM9TpGhl/0p3nQecC2LJ0kD8X5YARu1hk/9I1gril5vDtMZyNxcEpxxDj34YNck/ucjuoUd66K03oQ== + +cssesc@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-2.0.0.tgz#3b13bd1bb1cb36e1bcb5a4dcd27f54c5dcb35703" + integrity sha512-MsCAG1z9lPdoO/IUMLSBWBSVxVtJ1395VGIQ+Fc2gNdkQ1hNDnQdw3YhA71WJCBW1vdwA0cAnk/DnW6bqoEUYg== + +cssesc@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" + integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== + +cssnano-preset-default@^4.0.7: + version "4.0.7" + resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76" + integrity sha512-x0YHHx2h6p0fCl1zY9L9roD7rnlltugGu7zXSKQx6k2rYw0Hi3IqxcoAGF7u9Q5w1nt7vK0ulxV8Lo+EvllGsA== + dependencies: + css-declaration-sorter "^4.0.1" + cssnano-util-raw-cache "^4.0.1" + postcss "^7.0.0" + postcss-calc "^7.0.1" + postcss-colormin "^4.0.3" + postcss-convert-values "^4.0.1" + postcss-discard-comments "^4.0.2" + postcss-discard-duplicates "^4.0.2" + postcss-discard-empty "^4.0.1" + postcss-discard-overridden "^4.0.1" + postcss-merge-longhand "^4.0.11" + postcss-merge-rules "^4.0.3" + postcss-minify-font-values "^4.0.2" + postcss-minify-gradients "^4.0.2" + postcss-minify-params "^4.0.2" + postcss-minify-selectors "^4.0.2" + postcss-normalize-charset "^4.0.1" + postcss-normalize-display-values "^4.0.2" + postcss-normalize-positions "^4.0.2" + postcss-normalize-repeat-style "^4.0.2" + postcss-normalize-string "^4.0.2" + postcss-normalize-timing-functions "^4.0.2" + postcss-normalize-unicode "^4.0.1" + postcss-normalize-url "^4.0.1" + postcss-normalize-whitespace "^4.0.2" + postcss-ordered-values "^4.1.2" + postcss-reduce-initial "^4.0.3" + postcss-reduce-transforms "^4.0.2" + postcss-svgo "^4.0.2" + postcss-unique-selectors "^4.0.1" + +cssnano-util-get-arguments@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cssnano-util-get-arguments/-/cssnano-util-get-arguments-4.0.0.tgz#ed3a08299f21d75741b20f3b81f194ed49cc150f" + integrity sha1-7ToIKZ8h11dBsg87gfGU7UnMFQ8= + +cssnano-util-get-match@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cssnano-util-get-match/-/cssnano-util-get-match-4.0.0.tgz#c0e4ca07f5386bb17ec5e52250b4f5961365156d" + integrity sha1-wOTKB/U4a7F+xeUiULT1lhNlFW0= + +cssnano-util-raw-cache@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/cssnano-util-raw-cache/-/cssnano-util-raw-cache-4.0.1.tgz#b26d5fd5f72a11dfe7a7846fb4c67260f96bf282" + integrity sha512-qLuYtWK2b2Dy55I8ZX3ky1Z16WYsx544Q0UWViebptpwn/xDBmog2TLg4f+DBMg1rJ6JDWtn96WHbOKDWt1WQA== + dependencies: + postcss "^7.0.0" + +cssnano-util-same-parent@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/cssnano-util-same-parent/-/cssnano-util-same-parent-4.0.1.tgz#574082fb2859d2db433855835d9a8456ea18bbf3" + integrity sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q== + +cssnano@^4.1.10: + version "4.1.10" + resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-4.1.10.tgz#0ac41f0b13d13d465487e111b778d42da631b8b2" + integrity sha512-5wny+F6H4/8RgNlaqab4ktc3e0/blKutmq8yNlBFXA//nSFFAqAngjNVRzUvCgYROULmZZUoosL/KSoZo5aUaQ== + dependencies: + cosmiconfig "^5.0.0" + cssnano-preset-default "^4.0.7" + is-resolvable "^1.0.0" + postcss "^7.0.0" + +csso@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/csso/-/csso-4.0.3.tgz#0d9985dc852c7cc2b2cacfbbe1079014d1a8e903" + integrity sha512-NL3spysxUkcrOgnpsT4Xdl2aiEiBG6bXswAABQVHcMrfjjBisFOKwLDOmf4wf32aPdcJws1zds2B0Rg+jqMyHQ== + dependencies: + css-tree "1.0.0-alpha.39" + +cyclist@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" + integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= + +date-fns@^1.27.2: + version "1.30.1" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" + integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== + +date-time@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/date-time/-/date-time-3.1.0.tgz#0d1e934d170579f481ed8df1e2b8ff70ee845e1e" + integrity sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg== + dependencies: + time-zone "^1.0.0" + +debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== + dependencies: + ms "2.1.2" + +debug@^3.1.1, debug@^3.2.5: + version "3.2.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" + integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== + dependencies: + ms "^2.1.1" + +decache@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/decache/-/decache-4.6.0.tgz#87026bc6e696759e82d57a3841c4e251a30356e8" + integrity sha512-PppOuLiz+DFeaUvFXEYZjLxAkKiMYH/do/b/MxpDe/8AgKBi5GhZxridoVIbBq72GDbL36e4p0Ce2jTGUwwU+w== + dependencies: + callsite "^1.0.0" + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + +decko@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decko/-/decko-1.2.0.tgz#fd43c735e967b8013306884a56fbe665996b6817" + integrity sha1-/UPHNelnuAEzBohKVvvmZZlraBc= + +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" + integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= + +decompress-response@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" + integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M= + dependencies: + mimic-response "^1.0.0" + +decompress-response@^4.2.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986" + integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw== + dependencies: + mimic-response "^2.0.0" + +decompress-response@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-5.0.0.tgz#7849396e80e3d1eba8cb2f75ef4930f76461cb0f" + integrity sha512-TLZWWybuxWgoW7Lykv+gq9xvzOsUjQ9tF09Tj6NSTYGMTCHNXzrPnD6Hi+TgZq19PyTAGH4Ll/NIM/eTGglnMw== + dependencies: + mimic-response "^2.0.0" + +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + +decompress-tar@^4.0.0, decompress-tar@^4.1.0, decompress-tar@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/decompress-tar/-/decompress-tar-4.1.1.tgz#718cbd3fcb16209716e70a26b84e7ba4592e5af1" + integrity sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ== + dependencies: + file-type "^5.2.0" + is-stream "^1.1.0" + tar-stream "^1.5.2" + +decompress-tarbz2@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz#3082a5b880ea4043816349f378b56c516be1a39b" + integrity sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A== + dependencies: + decompress-tar "^4.1.0" + file-type "^6.1.0" + is-stream "^1.1.0" + seek-bzip "^1.0.5" + unbzip2-stream "^1.0.9" + +decompress-targz@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/decompress-targz/-/decompress-targz-4.1.1.tgz#c09bc35c4d11f3de09f2d2da53e9de23e7ce1eee" + integrity sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w== + dependencies: + decompress-tar "^4.1.1" + file-type "^5.2.0" + is-stream "^1.1.0" + +decompress-unzip@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/decompress-unzip/-/decompress-unzip-4.0.1.tgz#deaaccdfd14aeaf85578f733ae8210f9b4848f69" + integrity sha1-3qrM39FK6vhVePczroIQ+bSEj2k= + dependencies: + file-type "^3.8.0" + get-stream "^2.2.0" + pify "^2.3.0" + yauzl "^2.4.2" + +decompress@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/decompress/-/decompress-4.2.1.tgz#007f55cc6a62c055afa37c07eb6a4ee1b773f118" + integrity sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ== + dependencies: + decompress-tar "^4.0.0" + decompress-tarbz2 "^4.0.0" + decompress-targz "^4.0.0" + decompress-unzip "^4.0.1" + graceful-fs "^4.1.10" + make-dir "^1.0.0" + pify "^2.3.0" + strip-dirs "^2.0.0" + +deep-equal@^1.0.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" + integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g== + dependencies: + is-arguments "^1.0.4" + is-date-object "^1.0.1" + is-regex "^1.0.4" + object-is "^1.0.1" + object-keys "^1.1.1" + regexp.prototype.flags "^1.2.0" + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +deep-is@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" + integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= + +deepmerge@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" + integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== + +default-gateway@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-4.2.0.tgz#167104c7500c2115f6dd69b0a536bb8ed720552b" + integrity sha512-h6sMrVB1VMWVrW13mSc6ia/DwYYw5MN6+exNu1OaJeFac5aSAvwM7lZ0NVfTABuSkQelr4h5oebg3KB1XPdjgA== + dependencies: + execa "^1.0.0" + ip-regex "^2.1.0" + +defaults@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d" + integrity sha1-xlYFHpgX2f8I7YgUd/P+QBnz730= + dependencies: + clone "^1.0.2" + +defer-to-connect@^1.0.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591" + integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ== + +defer-to-connect@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" + integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== + +define-properties@^1.1.2, define-properties@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" + integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== + dependencies: + object-keys "^1.0.12" + +define-property@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" + integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= + dependencies: + is-descriptor "^0.1.0" + +define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" + integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY= + dependencies: + is-descriptor "^1.0.0" + +define-property@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" + integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== + dependencies: + is-descriptor "^1.0.2" + isobject "^3.0.1" + +del@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/del/-/del-4.1.1.tgz#9e8f117222ea44a31ff3a156c049b99052a9f0b4" + integrity sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ== + dependencies: + "@types/glob" "^7.1.1" + globby "^6.1.0" + is-path-cwd "^2.0.0" + is-path-in-cwd "^2.0.0" + p-map "^2.0.0" + pify "^4.0.1" + rimraf "^2.6.3" + +del@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/del/-/del-5.1.0.tgz#d9487c94e367410e6eff2925ee58c0c84a75b3a7" + integrity sha512-wH9xOVHnczo9jN2IW68BabcecVPxacIA3g/7z6vhSU/4stOKQzeCRK0yD0A24WiAAUJmmVpWqrERcTxnLo3AnA== + dependencies: + globby "^10.0.1" + graceful-fs "^4.2.2" + is-glob "^4.0.1" + is-path-cwd "^2.2.0" + is-path-inside "^3.0.1" + p-map "^3.0.0" + rimraf "^3.0.0" + slash "^3.0.0" + +del@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/del/-/del-6.0.0.tgz#0b40d0332cea743f1614f818be4feb717714c952" + integrity sha512-1shh9DQ23L16oXSZKB2JxpL7iMy2E0S9d517ptA1P8iw0alkPtQcrKH7ru31rYtKwF499HkTu+DRzq3TCKDFRQ== + dependencies: + globby "^11.0.1" + graceful-fs "^4.2.4" + is-glob "^4.0.1" + is-path-cwd "^2.2.0" + is-path-inside "^3.0.2" + p-map "^4.0.0" + rimraf "^3.0.2" + slash "^3.0.0" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + +delegate@^3.1.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" + integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= + +depd@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= + +deprecation@^2.0.0, deprecation@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" + integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== + +des.js@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843" + integrity sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA== + dependencies: + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + +destroy@~1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" + integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= + +detab@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/detab/-/detab-2.0.4.tgz#b927892069aff405fbb9a186fe97a44a92a94b43" + integrity sha512-8zdsQA5bIkoRECvCrNKPla84lyoR7DSAyf7p0YgXzBO9PDJx8KntPUay7NS6yp+KdxdVtiE5SpHKtbp2ZQyA9g== + dependencies: + repeat-string "^1.5.4" + +detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= + +detect-node@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c" + integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw== + +detect-port-alt@1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/detect-port-alt/-/detect-port-alt-1.1.6.tgz#24707deabe932d4a3cf621302027c2b266568275" + integrity sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q== + dependencies: + address "^1.0.1" + debug "^2.6.0" + +detect-port@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/detect-port/-/detect-port-1.3.0.tgz#d9c40e9accadd4df5cac6a782aefd014d573d1f1" + integrity sha512-E+B1gzkl2gqxt1IhUzwjrxBKRqx1UzC3WLONHinn8S3T6lwV/agVCyitiFOsGJ/eYuEUBvD71MZHy3Pv1G9doQ== + dependencies: + address "^1.0.1" + debug "^2.6.0" + +detective-amd@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/detective-amd/-/detective-amd-3.1.0.tgz#92daee3214a0ca4522646cf333cac90a3fca6373" + integrity sha512-G7wGWT6f0VErjUkE2utCm7IUshT7nBh7aBBH2VBOiY9Dqy2DMens5iiOvYCuhstoIxRKLrnOvVAz4/EyPIAjnw== + dependencies: + ast-module-types "^2.7.0" + escodegen "^2.0.0" + get-amd-module-type "^3.0.0" + node-source-walk "^4.0.0" + +detective-cjs@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/detective-cjs/-/detective-cjs-3.1.1.tgz#18da3e39a002d2098a1123d45ce1de1b0d9045a0" + integrity sha512-JQtNTBgFY6h8uT6pgph5QpV3IyxDv+z3qPk/FZRDT9TlFfm5dnRtpH39WtQEr1khqsUxVqXzKjZHpdoQvQbllg== + dependencies: + ast-module-types "^2.4.0" + node-source-walk "^4.0.0" + +detective-es6@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/detective-es6/-/detective-es6-2.2.0.tgz#8f2baba3f8cd90a5cfd748f5ac436f0158ed2585" + integrity sha512-fSpNY0SLER7/sVgQZ1NxJPwmc9uCTzNgdkQDhAaj8NPYwr7Qji9QBcmbNvtMCnuuOGMuKn3O7jv0An+/WRWJZQ== + dependencies: + node-source-walk "^4.0.0" + +detective-less@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/detective-less/-/detective-less-1.0.2.tgz#a68af9ca5f69d74b7d0aa190218b211d83b4f7e3" + integrity sha512-Rps1xDkEEBSq3kLdsdnHZL1x2S4NGDcbrjmd4q+PykK5aJwDdP5MBgrJw1Xo+kyUHuv3JEzPqxr+Dj9ryeDRTA== + dependencies: + debug "^4.0.0" + gonzales-pe "^4.2.3" + node-source-walk "^4.0.0" + +detective-postcss@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/detective-postcss/-/detective-postcss-4.0.0.tgz#24e69b465e5fefe7a6afd05f7e894e34595dbf51" + integrity sha512-Fwc/g9VcrowODIAeKRWZfVA/EufxYL7XfuqJQFroBKGikKX83d2G7NFw6kDlSYGG3LNQIyVa+eWv1mqre+v4+A== + dependencies: + debug "^4.1.1" + is-url "^1.2.4" + postcss "^8.1.7" + postcss-values-parser "^2.0.1" + +detective-sass@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/detective-sass/-/detective-sass-3.0.1.tgz#496b819efd1f5c4dd3f0e19b43a8634bdd6927c4" + integrity sha512-oSbrBozRjJ+QFF4WJFbjPQKeakoaY1GiR380NPqwdbWYd5wfl5cLWv0l6LsJVqrgWfFN1bjFqSeo32Nxza8Lbw== + dependencies: + debug "^4.1.1" + gonzales-pe "^4.2.3" + node-source-walk "^4.0.0" + +detective-scss@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/detective-scss/-/detective-scss-2.0.1.tgz#06f8c21ae6dedad1fccc26d544892d968083eaf8" + integrity sha512-VveyXW4WQE04s05KlJ8K0bG34jtHQVgTc9InspqoQxvnelj/rdgSAy7i2DXAazyQNFKlWSWbS+Ro2DWKFOKTPQ== + dependencies: + debug "^4.1.1" + gonzales-pe "^4.2.3" + node-source-walk "^4.0.0" + +detective-stylus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/detective-stylus/-/detective-stylus-1.0.0.tgz#50aee7db8babb990381f010c63fabba5b58e54cd" + integrity sha1-UK7n24uruZA4HwEMY/q7pbWOVM0= + +detective-typescript@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/detective-typescript/-/detective-typescript-7.0.0.tgz#8c8917f2e51d9e4ee49821abf759ff512dd897f2" + integrity sha512-y/Ev98AleGvl43YKTNcA2Q+lyFmsmCfTTNWy4cjEJxoLkbobcXtRS0Kvx06daCgr2GdtlwLfNzL553BkktfJoA== + dependencies: + "@typescript-eslint/typescript-estree" "^4.8.2" + ast-module-types "^2.7.1" + node-source-walk "^4.2.0" + typescript "^3.9.7" + +diffie-hellman@^5.0.0: + version "5.0.3" + resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" + integrity sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg== + dependencies: + bn.js "^4.1.0" + miller-rabin "^4.0.0" + randombytes "^2.0.0" + +dir-glob@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.2.2.tgz#fa09f0694153c8918b18ba0deafae94769fc50c4" + integrity sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw== + dependencies: + path-type "^3.0.0" + +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.0.0" + +dns-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d" + integrity sha1-s55/HabrCnW6nBcySzR1PEfgZU0= + +dns-packet@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-1.3.1.tgz#12aa426981075be500b910eedcd0b47dd7deda5a" + integrity sha512-0UxfQkMhYAUaZI+xrNZOz/as5KgDU0M/fQ9b6SpkyLbk3GEswDi6PADJVaYJradtRVsRIlF1zLyOodbcTCDzUg== + dependencies: + ip "^1.1.0" + safe-buffer "^5.0.1" + +dns-packet@^5.1.2: + version "5.2.1" + resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-5.2.1.tgz#26cec0be92252a1b97ed106482921192a7e08f72" + integrity sha512-JHj2yJeKOqlxzeuYpN1d56GfhzivAxavNwHj9co3qptECel27B1rLY5PifJAvubsInX5pGLDjAHuCfCUc2Zv/w== + dependencies: + ip "^1.1.5" + +dns-socket@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/dns-socket/-/dns-socket-4.2.1.tgz#c260f46e5649d1c2476763b78e2ee48f7abef531" + integrity sha512-fNvDq86lS522+zMbh31X8cQzYQd6xumCNlxsuZF5TKxQThF/e+rJbVM6K8mmlsdcSm6yNjKJQq3Sf38viAJj8g== + dependencies: + dns-packet "^5.1.2" + +dns-txt@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/dns-txt/-/dns-txt-2.0.2.tgz#b91d806f5d27188e4ab3e7d107d881a1cc4642b6" + integrity sha1-uR2Ab10nGI5Ks+fRB9iBocxGQrY= + dependencies: + buffer-indexof "^1.0.0" + +dom-converter@^0.2: + version "0.2.0" + resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" + integrity sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA== + dependencies: + utila "~0.4" + +dom-serializer@0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" + integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g== + dependencies: + domelementtype "^2.0.1" + entities "^2.0.0" + +dom-serializer@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0" + integrity sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA== + dependencies: + domelementtype "^1.3.0" + entities "^1.1.1" + +dom-walk@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.2.tgz#0c548bef048f4d1f2a97249002236060daa3fd84" + integrity sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w== + +domain-browser@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" + integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== + +domelementtype@1, domelementtype@^1.3.0, domelementtype@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" + integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== + +domelementtype@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.0.1.tgz#1f8bdfe91f5a78063274e803b4bdcedf6e94f94d" + integrity sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ== + +domhandler@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" + integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA== + dependencies: + domelementtype "1" + +dompurify@^2.0.12, dompurify@^2.0.17: + version "2.2.6" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.2.6.tgz#54945dc5c0b45ce5ae228705777e8e59d7b2edc4" + integrity sha512-7b7ZArhhH0SP6W2R9cqK6RjaU82FZ2UPM7RO8qN1b1wyvC/NY1FNWcX1Pu00fFOAnzEORtwXe4bPaClg6pUybQ== + +domutils@1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" + integrity sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8= + dependencies: + dom-serializer "0" + domelementtype "1" + +domutils@^1.5.1, domutils@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" + integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== + dependencies: + dom-serializer "0" + domelementtype "1" + +dot-case@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.3.tgz#21d3b52efaaba2ea5fda875bb1aa8124521cf4aa" + integrity sha512-7hwEmg6RiSQfm/GwPL4AAWXKy3YNNZA3oFv2Pdiey0mwkRCPZ9x6SZbkLcn8Ma5PYeVokzoD4Twv2n7LKp5WeA== + dependencies: + no-case "^3.0.3" + tslib "^1.10.0" + +dot-prop@^5.2.0, dot-prop@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" + integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q== + dependencies: + is-obj "^2.0.0" + +dot-prop@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-6.0.1.tgz#fc26b3cf142b9e59b74dbd39ed66ce620c681083" + integrity sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA== + dependencies: + is-obj "^2.0.0" + +dotenv@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" + integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== + +dotenv@^8.2.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" + integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== + +download@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/download/-/download-8.0.0.tgz#afc0b309730811731aae9f5371c9f46be73e51b1" + integrity sha512-ASRY5QhDk7FK+XrQtQyvhpDKanLluEEQtWl/J7Lxuf/b+i8RYh997QeXvL85xitrmRKVlx9c7eTrcRdq2GS4eA== + dependencies: + archive-type "^4.0.0" + content-disposition "^0.5.2" + decompress "^4.2.1" + ext-name "^5.0.0" + file-type "^11.1.0" + filenamify "^3.0.0" + get-stream "^4.1.0" + got "^8.3.1" + make-dir "^2.1.0" + p-event "^2.1.0" + pify "^4.0.1" + +duplexer3@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" + integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= + +duplexer@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" + integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== + +duplexify@^3.4.2, duplexify@^3.6.0: + version "3.7.1" + resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309" + integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g== + dependencies: + end-of-stream "^1.0.0" + inherits "^2.0.1" + readable-stream "^2.0.0" + stream-shift "^1.0.0" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= + +ejs@^2.6.1: + version "2.7.4" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba" + integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA== + +electron-to-chromium@^1.3.562: + version "1.3.564" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.564.tgz#e9c319ae437b3eb8bbf3e3bae4bead5a21945961" + integrity sha512-fNaYN3EtKQWLQsrKXui8mzcryJXuA0LbCLoizeX6oayG2emBaS5MauKjCPAvc29NEY4FpLHIUWiP+Y0Bfrs5dg== + +electron-to-chromium@^1.3.564, electron-to-chromium@^1.3.634: + version "1.3.642" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.642.tgz#8b884f50296c2ae2a9997f024d0e3e57facc2b94" + integrity sha512-cev+jOrz/Zm1i+Yh334Hed6lQVOkkemk2wRozfMF4MtTR7pxf3r3L5Rbd7uX1zMcEqVJ7alJBnJL7+JffkC6FQ== + +electron-to-chromium@^1.3.723: + version "1.3.759" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.759.tgz#b0d652d376831470a4c230ba721da2427bfb996a" + integrity sha512-nM76xH0t2FBH5iMEZDVc3S/qbdKjGH7TThezxC8k1Q7w7WHvIAyJh8lAe2UamGfdRqBTjHfPDn82LJ0ksCiB9g== + +elegant-spinner@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" + integrity sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4= + +elf-cam@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/elf-cam/-/elf-cam-0.1.1.tgz#46883b10835ed9e417860636a870d57490ce9eda" + integrity sha512-tKSFTWOp5OwJSp6MKyQDX7umYDkvUuI8rxHXw8BuUQ63d9Trj9xLeo6SHyoTGSoZNNZVitFa+RuHHXuoAzN3Rw== + +elliptic@^6.5.3: + version "6.5.3" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6" + integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw== + dependencies: + bn.js "^4.4.0" + brorand "^1.0.1" + hash.js "^1.0.0" + hmac-drbg "^1.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.0" + +"emoji-regex@>=6.0.0 <=6.1.1": + version "6.1.1" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e" + integrity sha1-xs0OwbBkLio8Z6ETfvxeeW2k+I4= + +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emojis-list@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" + integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== + +emoticon@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/emoticon/-/emoticon-3.2.0.tgz#c008ca7d7620fac742fe1bf4af8ff8fed154ae7f" + integrity sha512-SNujglcLTTg+lDAcApPNgEdudaqQFiAbJCqzjNxJkvN9vAwCGi0uu8IUVvx+f16h+V44KCY6Y2yboroc9pilHg== + +enabled@2.0.x: + version "2.0.0" + resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" + integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= + +end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1, end-of-stream@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +enhanced-resolve@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz#2f3cfd84dbe3b487f18f2db2ef1e064a571ca5ec" + integrity sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg== + dependencies: + graceful-fs "^4.1.2" + memory-fs "^0.5.0" + tapable "^1.0.0" + +entities@^1.1.1, entities@~1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" + integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== + +entities@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f" + integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ== + +env-paths@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" + integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== + +envinfo@^7.3.1: + version "7.7.3" + resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.7.3.tgz#4b2d8622e3e7366afb8091b23ed95569ea0208cc" + integrity sha512-46+j5QxbPWza0PB1i15nZx0xQ4I/EfQxg9J8Had3b408SV63nEtor2e+oiY63amTo9KTuh2a3XLObNwduxYwwA== + +errno@^0.1.3, errno@~0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" + integrity sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg== + dependencies: + prr "~1.0.1" + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + dependencies: + is-arrayish "^0.2.1" + +error-stack-parser@^2.0.2, error-stack-parser@^2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.0.6.tgz#5a99a707bd7a4c58a797902d48d82803ede6aad8" + integrity sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ== + dependencies: + stackframe "^1.1.1" + +es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.5: + version "1.17.6" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a" + integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw== + dependencies: + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + is-callable "^1.2.0" + is-regex "^1.1.0" + object-inspect "^1.7.0" + object-keys "^1.1.1" + object.assign "^4.1.0" + string.prototype.trimend "^1.0.1" + string.prototype.trimstart "^1.0.1" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +es6-promise@^3.2.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" + integrity sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM= + +escalade@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.0.2.tgz#6a580d70edb87880f22b4c91d0d56078df6962c4" + integrity sha512-gPYAU37hYCUhW5euPeR+Y74F7BL+IBsV93j5cvGriSaD1aG6MGsqsV1yamRdrWrb2j3aiZvb0X+UBOWpx3JWtQ== + +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + +escape-goat@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" + integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== + +escape-html@^1.0.3, escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + +escape-string-regexp@2.0.0, escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + +escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +escape-string-regexp@^1.0.0, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +escodegen@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.0.0.tgz#5e32b12833e8aa8fa35e1bf0befa89380484c7dd" + integrity sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw== + dependencies: + esprima "^4.0.1" + estraverse "^5.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + +eslint-scope@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" + integrity sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg== + dependencies: + esrecurse "^4.1.0" + estraverse "^4.1.1" + +eslint-visitor-keys@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" + integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== + +esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esrecurse@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^4.1.1: + version "4.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + +estraverse@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880" + integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ== + +estree-walker@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362" + integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w== + +estree-walker@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" + integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== + +estree-walker@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +esutils@^2.0.2, esutils@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +eta@^1.1.1: + version "1.12.2" + resolved "https://registry.yarnpkg.com/eta/-/eta-1.12.2.tgz#ac4425d4f9478a8b4110c7d02c94f6d382cac517" + integrity sha512-Z05sK2DRWAfBhG/2cwAOWuMoQIYaVYJCQrz2g2O/ekUjzWHNBv9L1pnblVDoDkKSb/AZ5tWZ0N/v4iaIU4+HjA== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= + +eval@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/eval/-/eval-0.1.4.tgz#e05dbe0dab4b9330215cbb7bf4886eb24bd58700" + integrity sha512-npGsebJejyjMRnLdFu+T/97dnigqIU0Ov3IGrZ8ygd1v7RL1vGkEKtvyWZobqUH1AQgKlg0Yqqe2BtMA9/QZLw== + dependencies: + require-like ">= 0.1.1" + +eventemitter3@^4.0.0, eventemitter3@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + +events@1.1.1, events@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" + integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ= + +events@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.2.0.tgz#93b87c18f8efcd4202a461aec4dfc0556b639379" + integrity sha512-/46HWwbfCX2xTawVfkKLGxMifJYQBWMwY1mjywRtb4c9x8l5NP3KoJtnIOiL1hfdRkIuYhETxQlo62IF8tcnlg== + +eventsource@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-1.0.7.tgz#8fbc72c93fcd34088090bc0a4e64f4b5cee6d8d0" + integrity sha512-4Ln17+vVT0k8aWq+t/bF5arcS3EpT9gYtW66EPacdj/mAFevznsnyoHLPy2BA8gbIQeIHoPsvwmfBftfcG//BQ== + dependencies: + original "^1.0.0" + +evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" + integrity sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== + dependencies: + md5.js "^1.3.4" + safe-buffer "^5.1.1" + +execa@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" + integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== + dependencies: + cross-spawn "^6.0.0" + get-stream "^4.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +execa@^3.3.0, execa@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-3.4.0.tgz#c08ed4550ef65d858fac269ffc8572446f37eb89" + integrity sha512-r9vdGQk4bmCuK1yKQu1KTwcT2zwfWdbdaXfCtAh+5nU/4fSX+JAb7vZGvI5naJrQlvONrEB20jeruESI69530g== + dependencies: + cross-spawn "^7.0.0" + get-stream "^5.0.0" + human-signals "^1.1.1" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.0" + onetime "^5.1.0" + p-finally "^2.0.0" + signal-exit "^3.0.2" + strip-final-newline "^2.0.0" + +execa@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" + integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + +exif-parser@^0.1.12: + version "0.1.12" + resolved "https://registry.yarnpkg.com/exif-parser/-/exif-parser-0.1.12.tgz#58a9d2d72c02c1f6f02a0ef4a9166272b7760922" + integrity sha1-WKnS1ywCwfbwKg70qRZicrd2CSI= + +exit-on-epipe@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz#0bdd92e87d5285d267daa8171d0eb06159689692" + integrity sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw== + +expand-brackets@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" + integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI= + dependencies: + debug "^2.3.3" + define-property "^0.2.5" + extend-shallow "^2.0.1" + posix-character-classes "^0.1.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + +express-logging@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/express-logging/-/express-logging-1.1.1.tgz#62839618cbab5bb3610f1a1c1485352fe9d26c2a" + integrity sha1-YoOWGMurW7NhDxocFIU1L+nSbCo= + dependencies: + on-headers "^1.0.0" + +express@^4.16.3, express@^4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" + integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g== + dependencies: + accepts "~1.3.7" + array-flatten "1.1.1" + body-parser "1.19.0" + content-disposition "0.5.3" + content-type "~1.0.4" + cookie "0.4.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "~1.1.2" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "~1.1.2" + fresh "0.5.2" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "~2.3.0" + parseurl "~1.3.3" + path-to-regexp "0.1.7" + proxy-addr "~2.0.5" + qs "6.7.0" + range-parser "~1.2.1" + safe-buffer "5.1.2" + send "0.17.1" + serve-static "1.14.1" + setprototypeof "1.1.1" + statuses "~1.5.0" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +ext-list@^2.0.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/ext-list/-/ext-list-2.2.2.tgz#0b98e64ed82f5acf0f2931babf69212ef52ddd37" + integrity sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA== + dependencies: + mime-db "^1.28.0" + +ext-name@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ext-name/-/ext-name-5.0.0.tgz#70781981d183ee15d13993c8822045c506c8f0a6" + integrity sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ== + dependencies: + ext-list "^2.0.0" + sort-keys-length "^1.0.0" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= + dependencies: + is-extendable "^0.1.0" + +extend-shallow@^3.0.0, extend-shallow@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +external-editor@^3.0.3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" + integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== + dependencies: + chardet "^0.7.0" + iconv-lite "^0.4.24" + tmp "^0.0.33" + +extglob@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" + integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== + dependencies: + array-unique "^0.3.2" + define-property "^1.0.0" + expand-brackets "^2.1.4" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +extract-stack@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/extract-stack/-/extract-stack-1.0.0.tgz#b97acaf9441eea2332529624b732fc5a1c8165fa" + integrity sha1-uXrK+UQe6iMyUpYktzL8WhyBZfo= + +extract-stack@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/extract-stack/-/extract-stack-2.0.0.tgz#11367bc865bfcd9bc0db3123e5edb57786f11f9b" + integrity sha512-AEo4zm+TenK7zQorGK1f9mJ8L14hnTDi2ZQPR+Mub1NX8zimka1mXpV5LpH8x9HoUmFSHZCfLHqWvp0Y4FxxzQ== + +fast-deep-equal@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz#c053477817c86b51daa853c81e059b733d023614" + integrity sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ= + +fast-deep-equal@^3.1.1: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-diff@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" + integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== + +fast-equals@^1.6.0: + version "1.6.3" + resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-1.6.3.tgz#84839a1ce20627c463e1892f2ae316380c81b459" + integrity sha512-4WKW0AL5+WEqO0zWavAfYGY1qwLsBgE//DN4TTcVEN2UlINgkv9b3vm2iHicoenWKSX9mKWmGOsU/iI5IST7pQ== + +fast-equals@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-2.0.3.tgz#7039b0a039909f345a2ce53f6202a14e5f392efc" + integrity sha512-0EMw4TTUxsMDpDkCg0rXor2gsg+npVrMIHbEhvD0HZyIhUX6AktC/yasm+qKwfyswd06Qy95ZKk8p2crTo0iPA== + +fast-glob@^2.2.6: + version "2.2.7" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.7.tgz#6953857c3afa475fff92ee6015d52da70a4cd39d" + integrity sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw== + dependencies: + "@mrmlnc/readdir-enhanced" "^2.2.1" + "@nodelib/fs.stat" "^1.1.2" + glob-parent "^3.1.0" + is-glob "^4.0.0" + merge2 "^1.2.3" + micromatch "^3.1.10" + +fast-glob@^3.0.3, fast-glob@^3.1.1, fast-glob@^3.2.4: + version "3.2.6" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.6.tgz#434dd9529845176ea049acc9343e8282765c6e1a" + integrity sha512-GnLuqj/pvQ7pX8/L4J84nijv6sAnlwvSDpMkJi9i7nPmPxGtRPkBSStfvDW5l6nMdX9VWe+pkKWFTgD+vF2QSQ== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= + +fast-safe-stringify@^2.0.4, fast-safe-stringify@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743" + integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA== + +fast-stringify@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/fast-stringify/-/fast-stringify-1.1.2.tgz#f109b792d54343aec271b47882598d279402401d" + integrity sha512-SfslXjiH8km0WnRiuPfpUKwlZjW5I878qsOm+2x8x3TgqmElOOLh1rgJFb+PolNdNRK3r8urEefqx0wt7vx1dA== + +fast-url-parser@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/fast-url-parser/-/fast-url-parser-1.1.3.tgz#f4af3ea9f34d8a271cf58ad2b3759f431f0b318d" + integrity sha1-9K8+qfNNiicc9YrSs3WfQx8LMY0= + dependencies: + punycode "^1.3.2" + +fastq@^1.6.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.8.0.tgz#550e1f9f59bbc65fe185cb6a9b4d95357107f481" + integrity sha512-SMIZoZdLh/fgofivvIkmknUXyPnvxRE3DhtZ5Me3Mrsk5gyPL42F0xr51TdRXskBxHfMp+07bcYzfsYEsSQA9Q== + dependencies: + reusify "^1.0.4" + +fault@^1.0.0, fault@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/fault/-/fault-1.0.4.tgz#eafcfc0a6d214fc94601e170df29954a4f842f13" + integrity sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA== + dependencies: + format "^0.2.0" + +faye-websocket@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4" + integrity sha1-TkkvjQTftviQA1B/btvy1QHnxvQ= + dependencies: + websocket-driver ">=0.5.1" + +faye-websocket@~0.11.1: + version "0.11.3" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.3.tgz#5c0e9a8968e8912c286639fde977a8b209f2508e" + integrity sha512-D2y4bovYpzziGgbHYtGCMjlJM36vAl/y+xUyn1C+FVx8szd1E+86KwVw6XvYSzOP8iMpm1X0I4xJD+QtUb36OA== + dependencies: + websocket-driver ">=0.5.1" + +fbemitter@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/fbemitter/-/fbemitter-3.0.0.tgz#00b2a1af5411254aab416cd75f9e6289bee4bff3" + integrity sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw== + dependencies: + fbjs "^3.0.0" + +fbjs-css-vars@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz#216551136ae02fe255932c3ec8775f18e2c078b8" + integrity sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ== + +fbjs@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-3.0.0.tgz#0907067fb3f57a78f45d95f1eacffcacd623c165" + integrity sha512-dJd4PiDOFuhe7vk4F80Mba83Vr2QuK86FoxtgPmzBqEJahncp+13YCmfoa53KHCo6OnlXLG7eeMWPfB5CrpVKg== + dependencies: + cross-fetch "^3.0.4" + fbjs-css-vars "^1.0.0" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.18" + +fd-slicer@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" + integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4= + dependencies: + pend "~1.2.0" + +fecha@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.0.tgz#3ffb6395453e3f3efff850404f0a59b6747f5f41" + integrity sha512-aN3pcx/DSmtyoovUudctc8+6Hl4T+hI9GBBHLjA76jdZl7+b1sgh5g4k+u/GL3dTy1/pnYzKp69FpJ0OicE3Wg== + +fetch-node-website@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/fetch-node-website/-/fetch-node-website-5.0.3.tgz#eaafe4beca7b34b80de6f703b76bcac06eb26ec9" + integrity sha512-O86T46FUWSOq4AWON39oaT8H90QFKAbmjfOVBhgaS87AFfeW00txz73KTv7QopPWtHBbGdI1S8cIT1VK1OQYLg== + dependencies: + chalk "^4.0.0" + cli-progress "^3.7.0" + figures "^3.2.0" + filter-obj "^2.0.1" + got "^10.7.0" + jest-validate "^25.3.0" + +fibers@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/fibers/-/fibers-5.0.0.tgz#3a60e0695b3ee5f6db94e62726716fa7a59acc41" + integrity sha512-UpGv/YAZp7mhKHxDvC1tColrroGRX90sSvh8RMZV9leo+e5+EkRVgCEZPlmXeo3BUNQTZxUaVdLskq1Q2FyCPg== + dependencies: + detect-libc "^1.0.3" + +figgy-pudding@^3.5.1: + version "3.5.2" + resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e" + integrity sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw== + +figures@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" + integrity sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4= + dependencies: + escape-string-regexp "^1.0.5" + object-assign "^4.1.0" + +figures@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" + integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI= + dependencies: + escape-string-regexp "^1.0.5" + +figures@^3.0.0, figures@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" + integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== + dependencies: + escape-string-regexp "^1.0.5" + +file-loader@^6.0.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-6.2.0.tgz#baef7cf8e1840df325e4390b4484879480eebe4d" + integrity sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw== + dependencies: + loader-utils "^2.0.0" + schema-utils "^3.0.0" + +file-size@0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/file-size/-/file-size-0.0.5.tgz#057d43c3a3ed735da3f90d6052ab380f1e6d5e3b" + integrity sha1-BX1Dw6Ptc12j+Q1gUqs4Dx5tXjs= + +file-type@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-11.1.0.tgz#93780f3fed98b599755d846b99a1617a2ad063b8" + integrity sha512-rM0UO7Qm9K7TWTtA6AShI/t7H5BPjDeGVDaNyg9BjHAj3PysKy7+8C8D137R88jnR3rFJZQB/tFgydl5sN5m7g== + +file-type@^3.8.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9" + integrity sha1-JXoHg4TR24CHvESdEH1SpSZyuek= + +file-type@^4.2.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-4.4.0.tgz#1b600e5fca1fbdc6e80c0a70c71c8dba5f7906c5" + integrity sha1-G2AOX8ofvcboDApwxxyNul95BsU= + +file-type@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-5.2.0.tgz#2ddbea7c73ffe36368dfae49dc338c058c2b8ad6" + integrity sha1-LdvqfHP/42No365J3DOMBYwritY= + +file-type@^6.1.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-6.2.0.tgz#e50cd75d356ffed4e306dc4f5bcf52a79903a919" + integrity sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg== + +file-type@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-9.0.0.tgz#a68d5ad07f486414dfb2c8866f73161946714a18" + integrity sha512-Qe/5NJrgIOlwijpq3B7BEpzPFcgzggOTagZmkXQY4LA6bsXKTUstK7Wp12lEJ/mLKTpvIZxmIuRcLYWT6ov9lw== + +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + +filename-reserved-regex@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz#abf73dfab735d045440abfea2d91f389ebbfa229" + integrity sha1-q/c9+rc10EVECr/qLZHzieu/oik= + +filenamify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/filenamify/-/filenamify-3.0.0.tgz#9603eb688179f8c5d40d828626dcbb92c3a4672c" + integrity sha512-5EFZ//MsvJgXjBAFJ+Bh2YaCTRF/VP1YOmGrgt+KJ4SFRLjI87EIdwLLuT6wQX0I4F9W41xutobzczjsOKlI/g== + dependencies: + filename-reserved-regex "^2.0.0" + strip-outer "^1.0.0" + trim-repeated "^1.0.0" + +filesize@6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-6.1.0.tgz#e81bdaa780e2451d714d71c0d7a4f3238d37ad00" + integrity sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg== + +filesize@^3.6.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317" + integrity sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg== + +fill-range@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" + integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= + dependencies: + extend-shallow "^2.0.1" + is-number "^3.0.0" + repeat-string "^1.6.1" + to-regex-range "^2.1.0" + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +filter-obj@^2.0.1, filter-obj@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-2.0.2.tgz#fff662368e505d69826abb113f0f6a98f56e9d5f" + integrity sha512-lO3ttPjHZRfjMcxWKb1j1eDhTFsu4meeR3lnMcnBFhk6RuLhvEiuALu2TlfL310ph4lCYYwgF/ElIjdP739tdg== + +finalhandler@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" + integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "~2.3.0" + parseurl "~1.3.3" + statuses "~1.5.0" + unpipe "~1.0.0" + +find-cache-dir@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7" + integrity sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ== + dependencies: + commondir "^1.0.1" + make-dir "^2.0.0" + pkg-dir "^3.0.0" + +find-cache-dir@^3.0.0, find-cache-dir@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.1.tgz#89b33fad4a4670daa94f855f7fbe31d6d84fe880" + integrity sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ== + dependencies: + commondir "^1.0.1" + make-dir "^3.0.2" + pkg-dir "^4.1.0" + +find-up@4.1.0, find-up@^4.0.0, find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + dependencies: + locate-path "^3.0.0" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flatten@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.3.tgz#c1283ac9f27b368abc1e36d1ff7b04501a30356b" + integrity sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg== + +flush-write-stream@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" + integrity sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w== + dependencies: + inherits "^2.0.3" + readable-stream "^2.3.6" + +flush-write-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-2.0.0.tgz#6f58e776154f5eefacff92a6e5a681c88ac50f7c" + integrity sha512-uXClqPxT4xW0lcdSBheb2ObVU+kuqUk3Jk64EwieirEXZx9XUrVwp/JuBfKAWaM4T5Td/VL7QLDWPXp/MvGm/g== + dependencies: + inherits "^2.0.3" + readable-stream "^3.1.1" + +flux@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flux/-/flux-4.0.1.tgz#7843502b02841d4aaa534af0b373034a1f75ee5c" + integrity sha512-emk4RCvJ8RzNP2lNpphKnG7r18q8elDYNAPx7xn+bDeOIo9FFfxEfIQ2y6YbQNmnsGD3nH1noxtLE64Puz1bRQ== + dependencies: + fbemitter "^3.0.0" + fbjs "^3.0.0" + +fn.name@1.x.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" + integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== + +folder-walker@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/folder-walker/-/folder-walker-3.2.0.tgz#98e00e59773f43416a6dcf0926d4c9436f65121d" + integrity sha512-VjAQdSLsl6AkpZNyrQJfO7BXLo4chnStqb055bumZMbRUPpVuPN3a4ktsnRCmrFZjtMlYLkyXiR5rAs4WOpC4Q== + dependencies: + from2 "^2.1.0" + +follow-redirects@^1.0.0: + version "1.13.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" + integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA== + +for-in@^0.1.3: + version "0.1.8" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1" + integrity sha1-2Hc5COMSVhCZUrH9ubP6hn0ndeE= + +for-in@^1.0.1, for-in@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= + +for-own@^0.1.3: + version "0.1.5" + resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce" + integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4= + dependencies: + for-in "^1.0.1" + +foreach@^2.0.4: + version "2.0.5" + resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" + integrity sha1-C+4AUBiusmDQo6865ljdATbsG5k= + +fork-ts-checker-webpack-plugin@4.1.6: + version "4.1.6" + resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-4.1.6.tgz#5055c703febcf37fa06405d400c122b905167fc5" + integrity sha512-DUxuQaKoqfNne8iikd14SAkh5uw4+8vNifp6gmA73yYNS6ywLIWSLD/n/mBzHQRpW3J7rbATEakmiA8JvkTyZw== + dependencies: + "@babel/code-frame" "^7.5.5" + chalk "^2.4.1" + micromatch "^3.1.10" + minimatch "^3.0.4" + semver "^5.6.0" + tapable "^1.0.0" + worker-rpc "^0.1.0" + +form-data@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682" + integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +format-util@^1.0.3: + version "1.0.5" + resolved "https://registry.yarnpkg.com/format-util/-/format-util-1.0.5.tgz#1ffb450c8a03e7bccffe40643180918cc297d271" + integrity sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg== + +format@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" + integrity sha1-1hcBB+nv3E7TDJ3DkBbflCtctYs= + +forwarded@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" + integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ= + +fragment-cache@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" + integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= + dependencies: + map-cache "^0.2.2" + +fresh@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= + +from2-array@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/from2-array/-/from2-array-0.0.4.tgz#eafc16b65f6e2719bcd57fdc1869005ac1332cd6" + integrity sha1-6vwWtl9uJxm81X/cGGkAWsEzLNY= + dependencies: + from2 "^2.0.3" + +from2@^2.0.3, from2@^2.1.0, from2@^2.1.1: + version "2.3.0" + resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" + integrity sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8= + dependencies: + inherits "^2.0.1" + readable-stream "^2.0.0" + +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + +fs-extra@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" + integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== + dependencies: + graceful-fs "^4.1.2" + jsonfile "^4.0.0" + universalify "^0.1.0" + +fs-extra@^8.1, fs-extra@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" + +fs-extra@^9.0, fs-extra@^9.0.1: + version "9.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" + integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs-minipass@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + +fs-write-stream-atomic@^1.0.8: + version "1.0.10" + resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9" + integrity sha1-tH31NJPvkR33VzHnCp3tAYnbQMk= + dependencies: + graceful-fs "^4.1.2" + iferr "^0.1.5" + imurmurhash "^0.1.4" + readable-stream "1 || 2" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +fsevents@^1.2.7: + version "1.2.13" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38" + integrity sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw== + dependencies: + bindings "^1.5.0" + nan "^2.12.1" + +fsevents@~2.1.2: + version "2.1.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" + integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== + +fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + +fuzzy@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/fuzzy/-/fuzzy-0.1.3.tgz#4c76ec2ff0ac1a36a9dccf9a00df8623078d4ed8" + integrity sha1-THbsL/CsGjap3M+aAN+GIweNTtg= + +gauge@~2.7.3: + version "2.7.4" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" + integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= + dependencies: + aproba "^1.0.3" + console-control-strings "^1.0.0" + has-unicode "^2.0.0" + object-assign "^4.1.0" + signal-exit "^3.0.0" + string-width "^1.0.1" + strip-ansi "^3.0.1" + wide-align "^1.1.0" + +gensync@^1.0.0-beta.1: + version "1.0.0-beta.1" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269" + integrity sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-amd-module-type@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/get-amd-module-type/-/get-amd-module-type-3.0.0.tgz#bb334662fa04427018c937774570de495845c288" + integrity sha512-99Q7COuACPfVt18zH9N4VAMyb81S6TUgJm2NgV6ERtkh9VIkAaByZkW530wl3lLN5KTtSrK9jVLxYsoP5hQKsw== + dependencies: + ast-module-types "^2.3.2" + node-source-walk "^4.0.0" + +get-caller-file@^2.0.1, get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6" + integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + +get-own-enumerable-property-symbols@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" + integrity sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g== + +get-port@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" + integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ== + +get-stream@3.0.0, get-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" + integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ= + +get-stream@^2.2.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-2.3.1.tgz#5f38f93f346009666ee0150a054167f91bdd95de" + integrity sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4= + dependencies: + object-assign "^4.0.1" + pinkie-promise "^2.0.0" + +get-stream@^4.0.0, get-stream@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== + dependencies: + pump "^3.0.0" + +get-stream@^5.0.0, get-stream@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + +get-stream@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= + +gh-release-fetch@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/gh-release-fetch/-/gh-release-fetch-2.0.0.tgz#894ec1b528cd926e1dd0a94a7047ec77804c2a51" + integrity sha512-04eT1dMAW5dUgnb/zkOxXXHTLaQpWuZi1gM93sP6KMHtIqyNsCiALHnljh+aomm90L4t3VkSKaCdwottLgVLwQ== + dependencies: + "@types/download" "^8.0.0" + "@types/node-fetch" "^2.1.6" + "@types/semver" "^7.0.0" + download "^8.0.0" + make-dir "^3.1.0" + node-fetch "^2.3.0" + semver "^7.0.0" + +git-repo-info@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/git-repo-info/-/git-repo-info-2.1.1.tgz#220ffed8cbae74ef8a80e3052f2ccb5179aed058" + integrity sha512-8aCohiDo4jwjOwma4FmYFd3i97urZulL8XL24nIPxuE+GZnfsAyy/g2Shqx6OjUiFKUXZM+Yy+KHnOmmA3FVcg== + +gitconfiglocal@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/gitconfiglocal/-/gitconfiglocal-2.1.0.tgz#07c28685c55cc5338b27b5acbcfe34aeb92e43d1" + integrity sha512-qoerOEliJn3z+Zyn1HW2F6eoYJqKwS6MgC9cztTLUB/xLWX8gD/6T60pKn4+t/d6tP7JlybI7Z3z+I572CR/Vg== + dependencies: + ini "^1.3.2" + +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4= + +github-slugger@^1.0.0, github-slugger@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.3.0.tgz#9bd0a95c5efdfc46005e82a906ef8e2a059124c9" + integrity sha512-gwJScWVNhFYSRDvURk/8yhcFBee6aFjye2a7Lhb2bUyRulpIoek9p0I9Kt7PT67d/nUlZbFu8L9RLiA0woQN8Q== + dependencies: + emoji-regex ">=6.0.0 <=6.1.1" + +glob-parent@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" + integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4= + dependencies: + is-glob "^3.1.0" + path-dirname "^1.0.0" + +glob-parent@^5.1.1, glob-parent@~5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229" + integrity sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ== + dependencies: + is-glob "^4.0.1" + +glob-parent@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-to-regexp@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" + integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs= + +glob@^7.0.0, glob@^7.0.3, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: + version "7.1.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +global-cache-dir@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/global-cache-dir/-/global-cache-dir-1.0.1.tgz#2c0820b43bae8a6ef8adf96fd23ec6bbf52dd13c" + integrity sha512-wYGh6O3Xkx1LsMXQpObr/uu3PsFpbWhpbslgn9Xq52rbDZ6YOwJcQtU5R4lSEQgCDtXLItV9EH5X1F/VnBTAlw== + dependencies: + cachedir "^2.2.0" + make-dir "^3.0.0" + path-exists "^4.0.0" + +global-cache-dir@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/global-cache-dir/-/global-cache-dir-2.0.0.tgz#6912dc449279d1505753c0e3d08ef63fb3e686a1" + integrity sha512-30pvU3e8muclEhc9tt+jRMaywOS3QfNdURflJ5Zv0bohjhcVQpBe5bwRHghGSJORLOKW81/n+3iJvHRHs+/S1Q== + dependencies: + cachedir "^2.3.0" + path-exists "^4.0.0" + +global-dirs@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-2.0.1.tgz#acdf3bb6685bcd55cb35e8a052266569e9469201" + integrity sha512-5HqUqdhkEovj2Of/ms3IeS/EekcO54ytHRLV4PEY2rhRwrHXLQjeVEES0Lhka0xwNDtGYn58wyC4s5+MHsOO6A== + dependencies: + ini "^1.3.5" + +global-dirs@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-3.0.0.tgz#70a76fe84ea315ab37b1f5576cbde7d48ef72686" + integrity sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA== + dependencies: + ini "2.0.0" + +global-modules@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" + integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== + dependencies: + global-prefix "^3.0.0" + +global-prefix@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-3.0.0.tgz#fc85f73064df69f50421f47f883fe5b913ba9b97" + integrity sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg== + dependencies: + ini "^1.3.5" + kind-of "^6.0.2" + which "^1.3.1" + +global@~4.3.0: + version "4.3.2" + resolved "https://registry.yarnpkg.com/global/-/global-4.3.2.tgz#e76989268a6c74c38908b1305b10fc0e394e9d0f" + integrity sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8= + dependencies: + min-document "^2.19.0" + process "~0.5.1" + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +globby@11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357" + integrity sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.1.1" + ignore "^5.1.4" + merge2 "^1.3.0" + slash "^3.0.0" + +globby@^10.0.1, globby@^10.0.2: + version "10.0.2" + resolved "https://registry.yarnpkg.com/globby/-/globby-10.0.2.tgz#277593e745acaa4646c3ab411289ec47a0392543" + integrity sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg== + dependencies: + "@types/glob" "^7.1.1" + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.0.3" + glob "^7.1.3" + ignore "^5.1.1" + merge2 "^1.2.3" + slash "^3.0.0" + +globby@^11.0.1, globby@^11.0.3, globby@^11.0.4: + version "11.0.4" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.4.tgz#2cbaff77c2f2a62e71e9b2813a67b97a3a3001a5" + integrity sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.1.1" + ignore "^5.1.4" + merge2 "^1.3.0" + slash "^3.0.0" + +globby@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c" + integrity sha1-9abXDoOV4hyFj7BInWTfAkJNUGw= + dependencies: + array-union "^1.0.1" + glob "^7.0.3" + object-assign "^4.0.1" + pify "^2.0.0" + pinkie-promise "^2.0.0" + +globby@^9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-9.2.0.tgz#fd029a706c703d29bdd170f4b6db3a3f7a7cb63d" + integrity sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg== + dependencies: + "@types/glob" "^7.1.1" + array-union "^1.0.2" + dir-glob "^2.2.2" + fast-glob "^2.2.6" + glob "^7.1.3" + ignore "^4.0.3" + pify "^4.0.1" + slash "^2.0.0" + +gonzales-pe@^4.2.3: + version "4.3.0" + resolved "https://registry.yarnpkg.com/gonzales-pe/-/gonzales-pe-4.3.0.tgz#fe9dec5f3c557eead09ff868c65826be54d067b3" + integrity sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ== + dependencies: + minimist "^1.2.5" + +good-listener@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" + integrity sha1-1TswzfkxPf+33JoNR3CWqm0UXFA= + dependencies: + delegate "^3.1.2" + +got@^10.7.0: + version "10.7.0" + resolved "https://registry.yarnpkg.com/got/-/got-10.7.0.tgz#62889dbcd6cca32cd6a154cc2d0c6895121d091f" + integrity sha512-aWTDeNw9g+XqEZNcTjMMZSy7B7yE9toWOFYip7ofFTLleJhvZwUxxTxkTpKvF+p1SAA4VHmuEy7PiHTHyq8tJg== + dependencies: + "@sindresorhus/is" "^2.0.0" + "@szmarczak/http-timer" "^4.0.0" + "@types/cacheable-request" "^6.0.1" + cacheable-lookup "^2.0.0" + cacheable-request "^7.0.1" + decompress-response "^5.0.0" + duplexer3 "^0.1.4" + get-stream "^5.0.0" + lowercase-keys "^2.0.0" + mimic-response "^2.1.0" + p-cancelable "^2.0.0" + p-event "^4.0.0" + responselike "^2.0.0" + to-readable-stream "^2.0.0" + type-fest "^0.10.0" + +got@^8.3.1: + version "8.3.2" + resolved "https://registry.yarnpkg.com/got/-/got-8.3.2.tgz#1d23f64390e97f776cac52e5b936e5f514d2e937" + integrity sha512-qjUJ5U/hawxosMryILofZCkm3C84PLJS/0grRIpjAwu+Lkxxj5cxeCU25BG0/3mDSpXKTyZr8oh8wIgLaH0QCw== + dependencies: + "@sindresorhus/is" "^0.7.0" + cacheable-request "^2.1.1" + decompress-response "^3.3.0" + duplexer3 "^0.1.4" + get-stream "^3.0.0" + into-stream "^3.1.0" + is-retry-allowed "^1.1.0" + isurl "^1.0.0-alpha5" + lowercase-keys "^1.0.0" + mimic-response "^1.0.0" + p-cancelable "^0.4.0" + p-timeout "^2.0.1" + pify "^3.0.0" + safe-buffer "^5.1.1" + timed-out "^4.0.1" + url-parse-lax "^3.0.0" + url-to-options "^1.0.1" + +got@^9.6.0: + version "9.6.0" + resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85" + integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q== + dependencies: + "@sindresorhus/is" "^0.14.0" + "@szmarczak/http-timer" "^1.1.2" + cacheable-request "^6.0.0" + decompress-response "^3.3.0" + duplexer3 "^0.1.4" + get-stream "^4.1.0" + lowercase-keys "^1.0.1" + mimic-response "^1.0.1" + p-cancelable "^1.0.0" + to-readable-stream "^1.0.0" + url-parse-lax "^3.0.0" + +graceful-fs@^4.1.10, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2: + version "4.2.4" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" + integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== + +graceful-fs@^4.2.4: + version "4.2.6" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" + integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== + +grapheme-splitter@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" + integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== + +gray-matter@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/gray-matter/-/gray-matter-4.0.2.tgz#9aa379e3acaf421193fce7d2a28cebd4518ac454" + integrity sha512-7hB/+LxrOjq/dd8APlK0r24uL/67w7SkYnfwhNFwg/VDIGWGmduTDYf3WNstLW2fbbmRwrDGCVSJ2isuf2+4Hw== + dependencies: + js-yaml "^3.11.0" + kind-of "^6.0.2" + section-matter "^1.0.0" + strip-bom-string "^1.0.0" + +gzip-size@5.1.1, gzip-size@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-5.1.1.tgz#cb9bee692f87c0612b232840a873904e4c135274" + integrity sha512-FNHi6mmoHvs1mxZAds4PpdCS6QG8B4C1krxJsMutgxl5t3+GlRTzzI3NEkifXx2pVsOvJdOGSmIgDhQ55FwdPA== + dependencies: + duplexer "^0.1.1" + pify "^4.0.1" + +handle-thing@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" + integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg== + +has-ansi@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-0.1.0.tgz#84f265aae8c0e6a88a12d7022894b7568894c62e" + integrity sha1-hPJlqujA5qiKEtcCKJS3VoiUxi4= + dependencies: + ansi-regex "^0.2.0" + +has-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" + integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE= + dependencies: + ansi-regex "^2.0.0" + +has-flag@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51" + integrity sha1-6CB68cx7MNRGzHC3NLXovhj4jVE= + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-glob@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-glob/-/has-glob-1.0.0.tgz#9aaa9eedbffb1ba3990a7b0010fb678ee0081207" + integrity sha1-mqqe7b/7G6OZCnsAEPtnjuAIEgc= + dependencies: + is-glob "^3.0.0" + +has-symbol-support-x@^1.4.1: + version "1.4.2" + resolved "https://registry.yarnpkg.com/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz#1409f98bc00247da45da67cee0a36f282ff26455" + integrity sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw== + +has-symbols@^1.0.0, has-symbols@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" + integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== + +has-to-string-tag-x@^1.2.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz#a045ab383d7b4b2012a00148ab0aa5f290044d4d" + integrity sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw== + dependencies: + has-symbol-support-x "^1.4.1" + +has-unicode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= + +has-value@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" + integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= + dependencies: + get-value "^2.0.3" + has-values "^0.1.4" + isobject "^2.0.0" + +has-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" + integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= + dependencies: + get-value "^2.0.6" + has-values "^1.0.0" + isobject "^3.0.0" + +has-values@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" + integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E= + +has-values@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" + integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + +has-yarn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" + integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw== + +has@^1.0.0, has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +hasbin@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/hasbin/-/hasbin-1.2.3.tgz#78c5926893c80215c2b568ae1fd3fcab7a2696b0" + integrity sha1-eMWSaJPIAhXCtWiuH9P8q3omlrA= + dependencies: + async "~1.5" + +hash-base@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.1.0.tgz#55c381d9e06e1d2997a883b4a3fddfe7f0d3af33" + integrity sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA== + dependencies: + inherits "^2.0.4" + readable-stream "^3.6.0" + safe-buffer "^5.2.0" + +hash.js@^1.0.0, hash.js@^1.0.3: + version "1.1.7" + resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" + integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== + dependencies: + inherits "^2.0.3" + minimalistic-assert "^1.0.1" + +hasha@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/hasha/-/hasha-5.2.2.tgz#a48477989b3b327aea3c04f53096d816d97522a1" + integrity sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ== + dependencies: + is-stream "^2.0.0" + type-fest "^0.8.0" + +hast-to-hyperscript@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/hast-to-hyperscript/-/hast-to-hyperscript-9.0.0.tgz#768fb557765fe28749169c885056417342d71e83" + integrity sha512-NJvMYU3GlMLs7hN3CRbsNlMzusVNkYBogVWDGybsuuVQ336gFLiD+q9qtFZT2meSHzln3pNISZWTASWothMSMg== + dependencies: + "@types/unist" "^2.0.3" + comma-separated-tokens "^1.0.0" + property-information "^5.3.0" + space-separated-tokens "^1.0.0" + style-to-object "^0.3.0" + unist-util-is "^4.0.0" + web-namespaces "^1.0.0" + +hast-util-from-parse5@^5.0.0: + version "5.0.3" + resolved "https://registry.yarnpkg.com/hast-util-from-parse5/-/hast-util-from-parse5-5.0.3.tgz#3089dc0ee2ccf6ec8bc416919b51a54a589e097c" + integrity sha512-gOc8UB99F6eWVWFtM9jUikjN7QkWxB3nY0df5Z0Zq1/Nkwl5V4hAAsl0tmwlgWl/1shlTF8DnNYLO8X6wRV9pA== + dependencies: + ccount "^1.0.3" + hastscript "^5.0.0" + property-information "^5.0.0" + web-namespaces "^1.1.2" + xtend "^4.0.1" + +hast-util-from-parse5@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/hast-util-from-parse5/-/hast-util-from-parse5-6.0.0.tgz#b38793c81e1a99f5fd592a4a88fc2731dccd0f30" + integrity sha512-3ZYnfKenbbkhhNdmOQqgH10vnvPivTdsOJCri+APn0Kty+nRkDHArnaX9Hiaf8H+Ig+vkNptL+SRY/6RwWJk1Q== + dependencies: + "@types/parse5" "^5.0.0" + ccount "^1.0.0" + hastscript "^5.0.0" + property-information "^5.0.0" + vfile "^4.0.0" + web-namespaces "^1.0.0" + +hast-util-parse-selector@^2.0.0: + version "2.2.4" + resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-2.2.4.tgz#60c99d0b519e12ab4ed32e58f150ec3f61ed1974" + integrity sha512-gW3sxfynIvZApL4L07wryYF4+C9VvH3AUi7LAnVXV4MneGEgwOByXvFo18BgmTWnm7oHAe874jKbIB1YhHSIzA== + +hast-util-raw@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/hast-util-raw/-/hast-util-raw-6.0.1.tgz#973b15930b7529a7b66984c98148b46526885977" + integrity sha512-ZMuiYA+UF7BXBtsTBNcLBF5HzXzkyE6MLzJnL605LKE8GJylNjGc4jjxazAHUtcwT5/CEt6afRKViYB4X66dig== + dependencies: + "@types/hast" "^2.0.0" + hast-util-from-parse5 "^6.0.0" + hast-util-to-parse5 "^6.0.0" + html-void-elements "^1.0.0" + parse5 "^6.0.0" + unist-util-position "^3.0.0" + vfile "^4.0.0" + web-namespaces "^1.0.0" + xtend "^4.0.0" + zwitch "^1.0.0" + +hast-util-to-parse5@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/hast-util-to-parse5/-/hast-util-to-parse5-6.0.0.tgz#1ec44650b631d72952066cea9b1445df699f8479" + integrity sha512-Lu5m6Lgm/fWuz8eWnrKezHtVY83JeRGaNQ2kn9aJgqaxvVkFCZQBEhgodZUDUvoodgyROHDb3r5IxAEdl6suJQ== + dependencies: + hast-to-hyperscript "^9.0.0" + property-information "^5.0.0" + web-namespaces "^1.0.0" + xtend "^4.0.0" + zwitch "^1.0.0" + +hast-util-whitespace@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-1.0.4.tgz#e4fe77c4a9ae1cb2e6c25e02df0043d0164f6e41" + integrity sha512-I5GTdSfhYfAPNztx2xJRQpG8cuDSNt599/7YUn7Gx/WxNMsG+a835k97TDkFgk123cwjfwINaZknkKkphx/f2A== + +hastscript@^5.0.0: + version "5.1.2" + resolved "https://registry.yarnpkg.com/hastscript/-/hastscript-5.1.2.tgz#bde2c2e56d04c62dd24e8c5df288d050a355fb8a" + integrity sha512-WlztFuK+Lrvi3EggsqOkQ52rKbxkXL3RwB6t5lwoa8QLMemoWfBuL43eDrwOamJyR7uKQKdmKYaBH1NZBiIRrQ== + dependencies: + comma-separated-tokens "^1.0.0" + hast-util-parse-selector "^2.0.0" + property-information "^5.0.0" + space-separated-tokens "^1.0.0" + +hastscript@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/hastscript/-/hastscript-6.0.0.tgz#e8768d7eac56c3fdeac8a92830d58e811e5bf640" + integrity sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w== + dependencies: + "@types/hast" "^2.0.0" + comma-separated-tokens "^1.0.0" + hast-util-parse-selector "^2.0.0" + property-information "^5.0.0" + space-separated-tokens "^1.0.0" + +he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +hex-color-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" + integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== + +history@^4.9.0: + version "4.10.1" + resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" + integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== + dependencies: + "@babel/runtime" "^7.1.2" + loose-envify "^1.2.0" + resolve-pathname "^3.0.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + value-equal "^1.0.1" + +hmac-drbg@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" + integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= + dependencies: + hash.js "^1.0.3" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.1" + +hoist-non-react-statics@^3.1.0: + version "3.3.2" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" + integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + dependencies: + react-is "^16.7.0" + +hoopy@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d" + integrity sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ== + +hosted-git-info@^2.1.4: + version "2.8.8" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" + integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + +hosted-git-info@^3.0.0: + version "3.0.5" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-3.0.5.tgz#bea87905ef7317442e8df3087faa3c842397df03" + integrity sha512-i4dpK6xj9BIpVOTboXIlKG9+8HMKggcrMX7WA24xZtKwX0TPelq/rbaS5rCKeNX8sJXZJGdSxpnEGtta+wismQ== + dependencies: + lru-cache "^6.0.0" + +hpack.js@^2.1.6: + version "2.1.6" + resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" + integrity sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI= + dependencies: + inherits "^2.0.1" + obuf "^1.0.0" + readable-stream "^2.0.1" + wbuf "^1.1.0" + +hsl-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hsl-regex/-/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e" + integrity sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4= + +hsla-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hsla-regex/-/hsla-regex-1.0.0.tgz#c1ce7a3168c8c6614033a4b5f7877f3b225f9c38" + integrity sha1-wc56MWjIxmFAM6S194d/OyJfnDg= + +html-comment-regex@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7" + integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ== + +html-entities@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.3.1.tgz#fb9a1a4b5b14c5daba82d3e34c6ae4fe701a0e44" + integrity sha512-rhE/4Z3hIhzHAUKbW8jVcCyuT5oJCXXqhN/6mXXVCpzTmvJnoH2HL/bt3EZ6p55jbFJBeAe1ZNpL5BugLujxNA== + +html-minifier-terser@^5.0.1, html-minifier-terser@^5.0.5: + version "5.1.1" + resolved "https://registry.yarnpkg.com/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz#922e96f1f3bb60832c2634b79884096389b1f054" + integrity sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg== + dependencies: + camel-case "^4.1.1" + clean-css "^4.2.3" + commander "^4.1.1" + he "^1.2.0" + param-case "^3.0.3" + relateurl "^0.2.7" + terser "^4.6.3" + +html-tags@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.1.0.tgz#7b5e6f7e665e9fb41f30007ed9e0d41e97fb2140" + integrity sha512-1qYz89hW3lFDEazhjW0yVAV87lw8lVkrJocr72XmBkMKsoSVJCQx3W8BXsC7hO2qAt8BoVjYjtAcZ9perqGnNg== + +html-void-elements@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-1.0.5.tgz#ce9159494e86d95e45795b166c2021c2cfca4483" + integrity sha512-uE/TxKuyNIcx44cIWnjr/rfIATDH7ZaOMmstu0CwhFG1Dunhlp4OC6/NMbhiwoq5BpW0ubi303qnEk/PZj614w== + +html-webpack-plugin@^4.0.4: + version "4.5.1" + resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-4.5.1.tgz#40aaf1b5cb78f2f23a83333999625c20929cda65" + integrity sha512-yzK7RQZwv9xB+pcdHNTjcqbaaDZ+5L0zJHXfi89iWIZmb/FtzxhLk0635rmJihcQbs3ZUF27Xp4oWGx6EK56zg== + dependencies: + "@types/html-minifier-terser" "^5.0.0" + "@types/tapable" "^1.0.5" + "@types/webpack" "^4.41.8" + html-minifier-terser "^5.0.1" + loader-utils "^1.2.3" + lodash "^4.17.20" + pretty-error "^2.1.1" + tapable "^1.1.3" + util.promisify "1.0.0" + +htmlparser2@^3.3.0, htmlparser2@^3.9.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" + integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== + dependencies: + domelementtype "^1.3.1" + domhandler "^2.3.0" + domutils "^1.5.1" + entities "^1.1.1" + inherits "^2.0.1" + readable-stream "^3.1.1" + +http-cache-semantics@3.8.1: + version "3.8.1" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz#39b0e16add9b605bf0a9ef3d9daaf4843b4cacd2" + integrity sha512-5ai2iksyV8ZXmnZhHH4rWPoxxistEexSi5936zIQ1bnNTW5VnA85B6P/VpXiRM017IgRvb2kKo1a//y+0wSp3w== + +http-cache-semantics@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" + integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== + +http-call@^5.2.2: + version "5.3.0" + resolved "https://registry.yarnpkg.com/http-call/-/http-call-5.3.0.tgz#4ded815b13f423de176eb0942d69c43b25b148db" + integrity sha512-ahwimsC23ICE4kPl9xTBjKB4inbRaeLyZeRunC/1Jy/Z6X8tv22MEAjK+KBOMSVLaqXPTTmd8638waVIKLGx2w== + dependencies: + content-type "^1.0.4" + debug "^4.1.1" + is-retry-allowed "^1.1.0" + is-stream "^2.0.0" + parse-json "^4.0.0" + tunnel-agent "^0.6.0" + +http-deceiver@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" + integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc= + +http-errors@1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f" + integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg== + dependencies: + depd "~1.1.2" + inherits "2.0.3" + setprototypeof "1.1.1" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.0" + +http-errors@1.7.3, http-errors@~1.7.2: + version "1.7.3" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" + integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.1.1" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.0" + +http-errors@~1.6.2: + version "1.6.3" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" + integrity sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0= + dependencies: + depd "~1.1.2" + inherits "2.0.3" + setprototypeof "1.1.0" + statuses ">= 1.4.0 < 2" + +http-errors@~1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.0.tgz#75d1bbe497e1044f51e4ee9e704a62f28d336507" + integrity sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.0" + +http-parser-js@>=0.5.1: + version "0.5.2" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.2.tgz#da2e31d237b393aae72ace43882dd7e270a8ff77" + integrity sha512-opCO9ASqg5Wy2FNo7A0sxy71yGbbkJJXLdgMK04Tcypw9jr2MgWbyubb0+WdmDmGnFflO7fRbqbaihh/ENDlRQ== + +http-proxy-middleware@0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.19.1.tgz#183c7dc4aa1479150306498c210cdaf96080a43a" + integrity sha512-yHYTgWMQO8VvwNS22eLLloAkvungsKdKTLO8AJlftYIKNfJr3GK3zK0ZCfzDDGUBttdGc8xFy1mCitvNKQtC3Q== + dependencies: + http-proxy "^1.17.0" + is-glob "^4.0.0" + lodash "^4.17.11" + micromatch "^3.1.10" + +http-proxy-middleware@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-1.0.5.tgz#4c6e25d95a411e3d750bc79ccf66290675176dc2" + integrity sha512-CKzML7u4RdGob8wuKI//H8Ein6wNTEQR7yjVEzPbhBLGdOfkfvgTnp2HLnniKBDP9QW4eG10/724iTWLBeER3g== + dependencies: + "@types/http-proxy" "^1.17.4" + http-proxy "^1.18.1" + is-glob "^4.0.1" + lodash "^4.17.19" + micromatch "^4.0.2" + +http-proxy@^1.17.0, http-proxy@^1.18.0, http-proxy@^1.18.1: + version "1.18.1" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" + integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== + dependencies: + eventemitter3 "^4.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" + +http2-client@^1.2.5: + version "1.3.3" + resolved "https://registry.yarnpkg.com/http2-client/-/http2-client-1.3.3.tgz#90fc15d646cca86956b156d07c83947d57d659a9" + integrity sha512-nUxLymWQ9pzkzTmir24p2RtsgruLmhje7lH3hLX1IpwvyTg77fW+1brenPPP3USAR+rQ36p5sTA/x7sjCJVkAA== + +https-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" + integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= + +https-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" + integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== + dependencies: + agent-base "6" + debug "4" + +human-signals@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" + integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== + +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + +hyperlinker@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hyperlinker/-/hyperlinker-1.0.0.tgz#23dc9e38a206b208ee49bc2d6c8ef47027df0c0e" + integrity sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ== + +iconv-lite@0.4.24, iconv-lite@^0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +icss-utils@^4.0.0, icss-utils@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-4.1.1.tgz#21170b53789ee27447c2f47dd683081403f9a467" + integrity sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA== + dependencies: + postcss "^7.0.14" + +ieee754@1.1.13, ieee754@^1.1.4: + version "1.1.13" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" + integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== + +iferr@^0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" + integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= + +ignore@^4.0.3: + version "4.0.6" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" + integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== + +ignore@^5.0.0, ignore@^5.1.1, ignore@^5.1.4: + version "5.1.8" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" + integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== + +immer@7.0.9, immer@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656" + integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA== + +import-cwd@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" + integrity sha1-qmzzbnInYShcs3HsZRn1PiQ1sKk= + dependencies: + import-from "^2.1.0" + +import-fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" + integrity sha1-2BNVwVYS04bGH53dOSLUMEgipUY= + dependencies: + caller-path "^2.0.0" + resolve-from "^3.0.0" + +import-fresh@^3.1.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66" + integrity sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +import-fresh@^3.2.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +import-from@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-from/-/import-from-2.1.0.tgz#335db7f2a7affd53aaa471d4b8021dee36b7f3b1" + integrity sha1-M1238qev/VOqpHHUuAId7ja387E= + dependencies: + resolve-from "^3.0.0" + +import-lazy@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" + integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM= + +import-local@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d" + integrity sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ== + dependencies: + pkg-dir "^3.0.0" + resolve-cwd "^2.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= + +indent-string@^3.0.0, indent-string@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289" + integrity sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok= + +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + +indexes-of@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" + integrity sha1-8w9xbI4r00bHtn0985FVZqfAVgc= + +infer-owner@^1.0.3, infer-owner@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467" + integrity sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +inherits@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" + integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= + +inherits@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= + +ini@2.0.0, ini@^1.3.2, ini@^1.3.5, ini@^1.3.6, ini@~1.3.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +inline-style-parser@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1" + integrity sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q== + +inquirer-autocomplete-prompt@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/inquirer-autocomplete-prompt/-/inquirer-autocomplete-prompt-1.1.0.tgz#e7745b49122e56b483659c91328a2c3fca33ffd6" + integrity sha512-mrSeUSFGnTSid/DCKG+E+IcN4MaOnT2bW7NuSagZAguD4k3hZ0UladdYNP4EstZOwgeqv0C3M1zYa1QIIf0Oyg== + dependencies: + ansi-escapes "^4.3.1" + chalk "^4.0.0" + figures "^3.2.0" + run-async "^2.4.0" + rxjs "^6.6.2" + +inquirer@^6.5.1: + version "6.5.2" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.5.2.tgz#ad50942375d036d327ff528c08bd5fab089928ca" + integrity sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ== + dependencies: + ansi-escapes "^3.2.0" + chalk "^2.4.2" + cli-cursor "^2.1.0" + cli-width "^2.0.0" + external-editor "^3.0.3" + figures "^2.0.0" + lodash "^4.17.12" + mute-stream "0.0.7" + run-async "^2.2.0" + rxjs "^6.4.0" + string-width "^2.1.0" + strip-ansi "^5.1.0" + through "^2.3.6" + +inquirer@^7.2.0: + version "7.3.3" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003" + integrity sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA== + dependencies: + ansi-escapes "^4.2.1" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-width "^3.0.0" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.19" + mute-stream "0.0.8" + run-async "^2.4.0" + rxjs "^6.6.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + through "^2.3.6" + +internal-ip@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-4.3.0.tgz#845452baad9d2ca3b69c635a137acb9a0dad0907" + integrity sha512-S1zBo1D6zcsyuC6PMmY5+55YMILQ9av8lotMx447Bq6SAgo/sDK6y6uUKmuYhW7eacnIhFfsPmCNYdDzsnnDCg== + dependencies: + default-gateway "^4.2.0" + ipaddr.js "^1.9.0" + +interpret@^1.0.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" + integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== + +into-stream@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/into-stream/-/into-stream-3.1.0.tgz#96fb0a936c12babd6ff1752a17d05616abd094c6" + integrity sha1-lvsKk2wSur1v8XUqF9BWFqvQlMY= + dependencies: + from2 "^2.1.1" + p-is-promise "^1.1.0" + +invariant@^2.2.2, invariant@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" + integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== + dependencies: + loose-envify "^1.0.0" + +ip-regex@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" + integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= + +ip-regex@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.1.0.tgz#5ad62f685a14edb421abebc2fff8db94df67b455" + integrity sha512-pKnZpbgCTfH/1NLIlOduP/V+WRXzC2MOz3Qo8xmxk8C5GudJLgK5QyLVXOSWy3ParAH7Eemurl3xjv/WXYFvMA== + +ip@^1.1.0, ip@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" + integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= + +ipaddr.js@1.9.1, ipaddr.js@^1.9.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +is-absolute-url@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" + integrity sha1-UFMN+4T8yap9vnhS6Do3uTufKqY= + +is-absolute-url@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-3.0.3.tgz#96c6a22b6a23929b11ea0afb1836c36ad4a5d698" + integrity sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q== + +is-accessor-descriptor@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" + integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= + dependencies: + kind-of "^3.0.2" + +is-accessor-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" + integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== + dependencies: + kind-of "^6.0.0" + +is-alphabetical@1.0.4, is-alphabetical@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz#9e7d6b94916be22153745d184c298cbf986a686d" + integrity sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg== + +is-alphanumeric@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-alphanumeric/-/is-alphanumeric-1.0.0.tgz#4a9cef71daf4c001c1d81d63d140cf53fd6889f4" + integrity sha1-Spzvcdr0wAHB2B1j0UDPU/1oifQ= + +is-alphanumerical@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz#7eb9a2431f855f6b1ef1a78e326df515696c4dbf" + integrity sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A== + dependencies: + is-alphabetical "^1.0.0" + is-decimal "^1.0.0" + +is-arguments@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.0.4.tgz#3faf966c7cba0ff437fb31f6250082fcf0448cf3" + integrity sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA== + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= + +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + +is-binary-path@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" + integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg= + dependencies: + binary-extensions "^1.0.0" + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-buffer@^1.0.2, is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-buffer@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623" + integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A== + +is-callable@^1.1.4, is-callable@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.0.tgz#83336560b54a38e35e3a2df7afd0454d691468bb" + integrity sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw== + +is-ci@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + dependencies: + ci-info "^2.0.0" + +is-color-stop@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-color-stop/-/is-color-stop-1.1.0.tgz#cfff471aee4dd5c9e158598fbe12967b5cdad345" + integrity sha1-z/9HGu5N1cnhWFmPvhKWe1za00U= + dependencies: + css-color-names "^0.0.4" + hex-color-regex "^1.1.0" + hsl-regex "^1.0.0" + hsla-regex "^1.0.0" + rgb-regex "^1.0.1" + rgba-regex "^1.0.0" + +is-core-module@^2.2.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.4.0.tgz#8e9fc8e15027b011418026e98f0e6f4d86305cc1" + integrity sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A== + dependencies: + has "^1.0.3" + +is-data-descriptor@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" + integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= + dependencies: + kind-of "^3.0.2" + +is-data-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" + integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== + dependencies: + kind-of "^6.0.0" + +is-date-object@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e" + integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g== + +is-decimal@^1.0.0, is-decimal@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.4.tgz#65a3a5958a1c5b63a706e1b333d7cd9f630d3fa5" + integrity sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw== + +is-descriptor@^0.1.0: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" + integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== + dependencies: + is-accessor-descriptor "^0.1.6" + is-data-descriptor "^0.1.4" + kind-of "^5.0.0" + +is-descriptor@^1.0.0, is-descriptor@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" + integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== + dependencies: + is-accessor-descriptor "^1.0.0" + is-data-descriptor "^1.0.0" + kind-of "^6.0.2" + +is-directory@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" + integrity sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE= + +is-docker@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.1.1.tgz#4125a88e44e450d384e09047ede71adc2d144156" + integrity sha512-ZOoqiXfEwtGknTiuDEy8pN2CfE3TxMHprvNer1mXiqwkOT77Rw3YVrUQ52EqAOU3QAWDQ+bQdx7HJzrv7LS2Hw== + +is-empty@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-empty/-/is-empty-1.2.0.tgz#de9bb5b278738a05a0b09a57e1fb4d4a341a9f6b" + integrity sha1-3pu1snhzigWgsJpX4ftNSjQan2s= + +is-extendable@^0.1.0, is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= + +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== + dependencies: + is-plain-object "^2.0.4" + +is-extglob@^2.1.0, is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= + +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-function@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.2.tgz#4f097f30abf6efadac9833b17ca5dc03f8144e08" + integrity sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ== + +is-glob@^3.0.0, is-glob@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" + integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo= + dependencies: + is-extglob "^2.1.0" + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" + integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== + dependencies: + is-extglob "^2.1.1" + +is-hexadecimal@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7" + integrity sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw== + +is-installed-globally@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" + integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== + dependencies: + global-dirs "^2.0.1" + is-path-inside "^3.0.1" + +is-installed-globally@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" + integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ== + dependencies: + global-dirs "^3.0.0" + is-path-inside "^3.0.2" + +is-interactive@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" + integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== + +is-ip@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-ip/-/is-ip-3.1.0.tgz#2ae5ddfafaf05cb8008a62093cf29734f657c5d8" + integrity sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q== + dependencies: + ip-regex "^4.0.0" + +is-module@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" + integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE= + +is-natural-number@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/is-natural-number/-/is-natural-number-4.0.1.tgz#ab9d76e1db4ced51e35de0c72ebecf09f734cde8" + integrity sha1-q5124dtM7VHjXeDHLr7PCfc0zeg= + +is-npm@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-4.0.0.tgz#c90dd8380696df87a7a6d823c20d0b12bbe3c84d" + integrity sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig== + +is-npm@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-5.0.0.tgz#43e8d65cc56e1b67f8d47262cf667099193f45a8" + integrity sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA== + +is-number@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" + integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= + dependencies: + kind-of "^3.0.2" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-obj@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" + integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8= + +is-obj@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" + integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== + +is-object@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470" + integrity sha1-iVJojF7C/9awPsyF52ngKQMINHA= + +is-observable@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-observable/-/is-observable-1.1.0.tgz#b3e986c8f44de950867cab5403f5a3465005975e" + integrity sha512-NqCa4Sa2d+u7BWc6CukaObG3Fh+CU9bvixbpcXYhy2VvYS7vVGIdAgnIS5Ks3A/cqk4rebLJ9s8zBstT2aKnIA== + dependencies: + symbol-observable "^1.1.0" + +is-online@^8.2.1: + version "8.4.0" + resolved "https://registry.yarnpkg.com/is-online/-/is-online-8.4.0.tgz#8639af9657413fcfd9f81fced3263d532e0b1cee" + integrity sha512-i0qGRbtUaQEU5Z7O3LmOnH3yorhG1lnygqY2cv3InlQKKm3nx6XiGXZk49lATR3N7hyxoiuHMR0pKwRuB+s5lg== + dependencies: + got "^9.6.0" + p-any "^2.0.0" + p-timeout "^3.0.0" + public-ip "^4.0.1" + +is-path-cwd@^2.0.0, is-path-cwd@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-2.2.0.tgz#67d43b82664a7b5191fd9119127eb300048a9fdb" + integrity sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ== + +is-path-in-cwd@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz#bfe2dca26c69f397265a4009963602935a053acb" + integrity sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ== + dependencies: + is-path-inside "^2.1.0" + +is-path-inside@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-2.1.0.tgz#7c9810587d659a40d27bcdb4d5616eab059494b2" + integrity sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg== + dependencies: + path-is-inside "^1.0.2" + +is-path-inside@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.2.tgz#f5220fc82a3e233757291dddc9c5877f2a1f3017" + integrity sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg== + +is-path-inside@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +is-plain-obj@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= + +is-plain-obj@^2.0.0, is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + +is-plain-obj@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7" + integrity sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA== + +is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + +is-plain-object@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-4.1.1.tgz#1a14d6452cbd50790edc7fdaa0aed5a40a35ebb5" + integrity sha512-5Aw8LLVsDlZsETVMhoMXzqsXwQqr/0vlnBYzIXJbYo2F4yYlhLHs+Ez7Bod7IIQKWkJbJfxrWD7pA1Dw1TKrwA== + +is-plain-object@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== + +is-promise@^2.1.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" + integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== + +is-reference@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7" + integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ== + dependencies: + "@types/estree" "*" + +is-regex@^1.0.4, is-regex@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" + integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg== + dependencies: + has-symbols "^1.0.1" + +is-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" + integrity sha1-/S2INUXEa6xaYz57mgnof6LLUGk= + +is-relative-url@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-relative-url/-/is-relative-url-2.0.0.tgz#72902d7fe04b3d4792e7db15f9db84b7204c9cef" + integrity sha1-cpAtf+BLPUeS59sV+duEtyBMnO8= + dependencies: + is-absolute-url "^2.0.0" + +is-resolvable@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" + integrity sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg== + +is-retry-allowed@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4" + integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg== + +is-root@2.1.0, is-root@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-root/-/is-root-2.1.0.tgz#809e18129cf1129644302a4f8544035d51984a9c" + integrity sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg== + +is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + +is-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3" + integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== + +is-svg@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-3.0.0.tgz#9321dbd29c212e5ca99c4fa9794c714bcafa2f75" + integrity sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ== + dependencies: + html-comment-regex "^1.1.0" + +is-symbol@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937" + integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ== + dependencies: + has-symbols "^1.0.1" + +is-typedarray@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +is-url@^1.2.2, is-url@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/is-url/-/is-url-1.2.4.tgz#04a4df46d28c4cff3d73d01ff06abeb318a1aa52" + integrity sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww== + +is-what@^3.3.1: + version "3.11.2" + resolved "https://registry.yarnpkg.com/is-what/-/is-what-3.11.2.tgz#4ca0c91b236acea48dd6af0a072d6a84aec7a1d4" + integrity sha512-m7LzBsC9TqUhkBrozSmmWfVO7VYnjk9UHu0U+Y8BiJRnc1TYIK/3Qv4DteuiBpn2S4K7n3N4WNC4pe6wEx2xYg== + +is-whitespace-character@^1.0.0, is-whitespace-character@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz#0858edd94a95594c7c9dd0b5c174ec6e45ee4aa7" + integrity sha512-SDweEzfIZM0SJV0EUga669UTKlmL0Pq8Lno0QDQsPnvECB3IM2aP0gdx5TrU0A01MAPfViaZiI2V1QMZLaKK5w== + +is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== + +is-word-character@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-word-character/-/is-word-character-1.0.4.tgz#ce0e73216f98599060592f62ff31354ddbeb0230" + integrity sha512-5SMO8RVennx3nZrqtKwCGyyetPE9VDba5ugvKLaD4KopPG5kR4mQ7tNt/r7feL5yt5h3lpuBbIUmCOG2eSzXHA== + +is-wsl@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" + integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= + +is-wsl@^2.1.1, is-wsl@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + +is-yarn-global@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" + integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== + +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + +isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +iserror@0.0.2, iserror@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/iserror/-/iserror-0.0.2.tgz#bd53451fe2f668b9f2402c1966787aaa2c7c0bf5" + integrity sha1-vVNFH+L2aLnyQCwZZnh6qix8C/U= + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= + dependencies: + isarray "1.0.0" + +isobject@^3.0.0, isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= + +isurl@^1.0.0-alpha5: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67" + integrity sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w== + dependencies: + has-to-string-tag-x "^1.2.0" + is-object "^1.0.1" + +jest-get-type@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-24.9.0.tgz#1684a0c8a50f2e4901b6644ae861f579eed2ef0e" + integrity sha512-lUseMzAley4LhIcpSP9Jf+fTrQ4a1yHQwLNeeVa2cEmbCGeoZAtYPOIv8JaxLD/sUpKxetKGP+gsHl8f8TSj8Q== + +jest-get-type@^25.2.6: + version "25.2.6" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-25.2.6.tgz#0b0a32fab8908b44d508be81681487dbabb8d877" + integrity sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig== + +jest-validate@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-24.9.0.tgz#0775c55360d173cd854e40180756d4ff52def8ab" + integrity sha512-HPIt6C5ACwiqSiwi+OfSSHbK8sG7akG8eATl+IPKaeIjtPOeBUd/g3J7DghugzxrGjI93qS/+RPKe1H6PqvhRQ== + dependencies: + "@jest/types" "^24.9.0" + camelcase "^5.3.1" + chalk "^2.0.1" + jest-get-type "^24.9.0" + leven "^3.1.0" + pretty-format "^24.9.0" + +jest-validate@^25.3.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-25.5.0.tgz#fb4c93f332c2e4cf70151a628e58a35e459a413a" + integrity sha512-okUFKqhZIpo3jDdtUXUZ2LxGUZJIlfdYBvZb1aczzxrlyMlqdnnws9MOxezoLGhSaFc2XYaHNReNQfj5zPIWyQ== + dependencies: + "@jest/types" "^25.5.0" + camelcase "^5.3.1" + chalk "^3.0.0" + jest-get-type "^25.2.6" + leven "^3.1.0" + pretty-format "^25.5.0" + +jest-worker@^26.2.1: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed" + integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ== + dependencies: + "@types/node" "*" + merge-stream "^2.0.0" + supports-color "^7.0.0" + +jmespath@0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217" + integrity sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc= + +joi@^17.1.1: + version "17.2.1" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.2.1.tgz#e5140fdf07e8fecf9bc977c2832d1bdb1e3f2a0a" + integrity sha512-YT3/4Ln+5YRpacdmfEfrrKh50/kkgX3LgBltjqnlMPIYiZ4hxXZuVJcxmsvxsdeHg9soZfE3qXxHC2tMpCCBOA== + dependencies: + "@hapi/address" "^4.1.0" + "@hapi/formula" "^2.0.0" + "@hapi/hoek" "^9.0.0" + "@hapi/pinpoint" "^2.0.0" + "@hapi/topo" "^5.0.0" + +jpeg-js@^0.3.4, jpeg-js@^0.4.0: + version "0.4.3" + resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.4.3.tgz#6158e09f1983ad773813704be80680550eff977b" + integrity sha512-ru1HWKek8octvUHFHvE5ZzQ1yAsJmIvRdGWvSoKV52XKyuyYA437QWDttXT8eZXDSbuMpHlLzPDZUPd6idIz+Q== + +js-string-escape@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef" + integrity sha1-4mJbrbwNZ8dTPp7cEGjFh65BN+8= + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^3.11.0, js-yaml@^3.12.1, js-yaml@^3.13.1, js-yaml@^3.6.1: + version "3.14.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" + integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + +jsesc@~0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" + integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= + +json-buffer@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" + integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" + integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-pointer@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/json-pointer/-/json-pointer-0.6.0.tgz#8e500550a6aac5464a473377da57aa6cc22828d7" + integrity sha1-jlAFUKaqxUZKRzN32leqbMIoKNc= + dependencies: + foreach "^2.0.4" + +json-schema-ref-parser@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/json-schema-ref-parser/-/json-schema-ref-parser-6.1.0.tgz#30af34aeab5bee0431da805dac0eb21b574bf63d" + integrity sha512-pXe9H1m6IgIpXmE5JSb8epilNTGsmTb2iPohAXpOdhqGFbQjNeHHsZxU+C8w6T81GZxSPFLeUoqDJmzxx5IGuw== + dependencies: + call-me-maybe "^1.0.1" + js-yaml "^3.12.1" + ono "^4.0.11" + +json-schema-traverse@^0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340" + integrity sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A= + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +json-to-ast@^2.0.3: + version "2.1.0" + resolved "https://registry.yarnpkg.com/json-to-ast/-/json-to-ast-2.1.0.tgz#041a9fcd03c0845036acb670d29f425cea4faaf9" + integrity sha512-W9Lq347r8tA1DfMvAGn9QNcgYm4Wm7Yc+k8e6vezpMnRT+NHbtlxgNBXRVjXe9YM6eTn6+p/MKOlV/aABJcSnQ== + dependencies: + code-error-fragment "0.0.230" + grapheme-splitter "^1.0.4" + +json3@^3.3.2: + version "3.3.3" + resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81" + integrity sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA== + +json5@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" + integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow== + dependencies: + minimist "^1.2.0" + +json5@^2.0.0, json5@^2.1.2: + version "2.1.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" + integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA== + dependencies: + minimist "^1.2.5" + +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" + integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= + optionalDependencies: + graceful-fs "^4.1.6" + +jsonfile@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.0.1.tgz#98966cba214378c8c84b82e085907b40bf614179" + integrity sha512-jR2b5v7d2vIOust+w3wtFKZIfpC2pnRmFAhAC/BuweZFQR8qZzxH1OyrQ10HmdVYiXWkYUqPVsz91cG7EL2FBg== + dependencies: + universalify "^1.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +jsonpointer@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.1.0.tgz#501fb89986a2389765ba09e6053299ceb4f2c2cc" + integrity sha512-CXcRvMyTlnR53xMcKnuMzfCA5i/nfblTnnr74CZb6C4vG39eu6w51t7nKmU5MfLfbTgGItliNyjO/ciNPDqClg== + +junk@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1" + integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ== + +jwt-decode@^3.0.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" + integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== + +keep-func-props@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/keep-func-props/-/keep-func-props-3.0.1.tgz#2aa8b6f421a7e979b071dbfe747d2003a135ee34" + integrity sha512-5AsrYCiCHIUxuw/G2r7xcoTW/NTf5IFwAe1fkwf2ifM/KZzEojaTylh1Pppu60oEixww1rfcWJaRGLi3eAJsrQ== + dependencies: + mimic-fn "^3.1.0" + +keyv@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.0.0.tgz#44923ba39e68b12a7cec7df6c3268c031f2ef373" + integrity sha512-eguHnq22OE3uVoSYG0LVWNP+4ppamWr9+zWBe1bsNcovIMy6huUJFPgy4mGwCd/rnl3vOLGW1MTlu4c57CT1xA== + dependencies: + json-buffer "3.0.0" + +keyv@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" + integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA== + dependencies: + json-buffer "3.0.0" + +keyv@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.0.3.tgz#4f3aa98de254803cafcd2896734108daa35e4254" + integrity sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA== + dependencies: + json-buffer "3.0.1" + +killable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892" + integrity sha512-LzqtLKlUwirEUyl/nicirVmNiPvYs7l5n8wOPP7fyJVpUPkvCnW/vuiXGpylGUlnPDnB7311rARzAt3Mhswpjg== + +kind-of@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-2.0.1.tgz#018ec7a4ce7e3a86cb9141be519d24c8faa981b5" + integrity sha1-AY7HpM5+OobLkUG+UZ0kyPqpgbU= + dependencies: + is-buffer "^1.0.2" + +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= + dependencies: + is-buffer "^1.1.5" + +kind-of@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" + integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== + +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" + integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + +klona@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/klona/-/klona-1.1.2.tgz#a79e292518a5a5412ec8d097964bff1571a64db0" + integrity sha512-xf88rTeHiXk+XE2Vhi6yj8Wm3gMZrygGdKjJqN8HkV+PwF/t50/LdAKHoHpPcxFAlmQszTZ1CugrK25S7qDRLA== + +kuler@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" + integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== + +lambda-local@^1.7.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/lambda-local/-/lambda-local-1.7.3.tgz#3f7e8e4893184ddf95a731004ee49b75089bf534" + integrity sha512-T+iwIkuQT0JvTQhvNBTikLhpEJk3ovNoC33niE4QNmYOUrCOdo86PcPkgppOZl+NJXXHebdPHDJ40zqBJ9VMzg== + dependencies: + aws-sdk "^2.689.0" + commander "^5.1.0" + dotenv "^8.2.0" + winston "^3.2.1" + +last-call-webpack-plugin@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz#9742df0e10e3cf46e5c0381c2de90d3a7a2d7555" + integrity sha512-7KI2l2GIZa9p2spzPIVZBYyNKkN+e/SQPpnjlTiPhdbDW3F86tdKKELxKpzJ5sgU19wQWsACULZmpTPYHeWO5w== + dependencies: + lodash "^4.17.5" + webpack-sources "^1.1.0" + +latest-version@^5.0.0, latest-version@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face" + integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA== + dependencies: + package-json "^6.3.0" + +lazy-cache@^0.2.3: + version "0.2.7" + resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-0.2.7.tgz#7feddf2dcb6edb77d11ef1d117ab5ffdf0ab1b65" + integrity sha1-f+3fLctu23fRHvHRF6tf/fCrG2U= + +lazy-cache@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" + integrity sha1-odePw6UEdMuAhF07O24dpJpEbo4= + +lazystream@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4" + integrity sha1-9plf4PggOS9hOWvolGJAe7dxaOQ= + dependencies: + readable-stream "^2.0.5" + +leven@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" + integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + +levenary@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/levenary/-/levenary-1.1.1.tgz#842a9ee98d2075aa7faeedbe32679e9205f46f77" + integrity sha512-mkAdOIt79FD6irqjYSs4rdbnlT5vRonMEvBVPVb3XmevfS8kgRXwfes0dhPdEtzTWD/1eNE/Bm/G1iRt6DcnQQ== + dependencies: + leven "^3.1.0" + +levenshtein-edit-distance@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/levenshtein-edit-distance/-/levenshtein-edit-distance-1.0.0.tgz#895baf478cce8b5c1a0d27e45d7c1d978a661e49" + integrity sha1-iVuvR4zOi1waDSfkXXwdl4pmHkk= + +levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + +libnpmconfig@^1.0.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/libnpmconfig/-/libnpmconfig-1.2.1.tgz#c0c2f793a74e67d4825e5039e7a02a0044dfcbc0" + integrity sha512-9esX8rTQAHqarx6qeZqmGQKBNZR5OIbl/Ayr0qQDy3oXja2iFVQQI81R6GZ2a02bSNZ9p3YOGX1O6HHCb1X7kA== + dependencies: + figgy-pudding "^3.5.1" + find-up "^3.0.0" + ini "^1.3.5" + +lines-and-columns@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" + integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= + +listr-silent-renderer@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e" + integrity sha1-kktaN1cVN3C/Go4/v3S4u/P5JC4= + +listr-update-renderer@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/listr-update-renderer/-/listr-update-renderer-0.5.0.tgz#4ea8368548a7b8aecb7e06d8c95cb45ae2ede6a2" + integrity sha512-tKRsZpKz8GSGqoI/+caPmfrypiaq+OQCbd+CovEC24uk1h952lVj5sC7SqyFUm+OaJ5HN/a1YLt5cit2FMNsFA== + dependencies: + chalk "^1.1.3" + cli-truncate "^0.2.1" + elegant-spinner "^1.0.1" + figures "^1.7.0" + indent-string "^3.0.0" + log-symbols "^1.0.2" + log-update "^2.3.0" + strip-ansi "^3.0.1" + +listr-verbose-renderer@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/listr-verbose-renderer/-/listr-verbose-renderer-0.5.0.tgz#f1132167535ea4c1261102b9f28dac7cba1e03db" + integrity sha512-04PDPqSlsqIOaaaGZ+41vq5FejI9auqTInicFRndCBgE3bXG8D6W1I+mWhk+1nqbHmyhla/6BUrd5OSiHwKRXw== + dependencies: + chalk "^2.4.1" + cli-cursor "^2.1.0" + date-fns "^1.27.2" + figures "^2.0.0" + +listr@^0.14.3: + version "0.14.3" + resolved "https://registry.yarnpkg.com/listr/-/listr-0.14.3.tgz#2fea909604e434be464c50bddba0d496928fa586" + integrity sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA== + dependencies: + "@samverschueren/stream-to-observable" "^0.3.0" + is-observable "^1.1.0" + is-promise "^2.1.0" + is-stream "^1.1.0" + listr-silent-renderer "^1.1.1" + listr-update-renderer "^0.5.0" + listr-verbose-renderer "^0.5.0" + p-map "^2.0.0" + rxjs "^6.3.3" + +load-bmfont@^1.3.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/load-bmfont/-/load-bmfont-1.4.1.tgz#c0f5f4711a1e2ccff725a7b6078087ccfcddd3e9" + integrity sha512-8UyQoYmdRDy81Brz6aLAUhfZLwr5zV0L3taTQ4hju7m6biuwiWiJXjPhBJxbUQJA8PrkvJ/7Enqmwk2sM14soA== + dependencies: + buffer-equal "0.0.1" + mime "^1.3.4" + parse-bmfont-ascii "^1.0.3" + parse-bmfont-binary "^1.0.5" + parse-bmfont-xml "^1.1.4" + phin "^2.9.1" + xhr "^2.0.1" + xtend "^4.0.0" + +load-json-file@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-5.3.0.tgz#4d3c1e01fa1c03ea78a60ac7af932c9ce53403f3" + integrity sha512-cJGP40Jc/VXUsp8/OrnyKyTZ1y6v/dphm3bioS+RrKXjK2BB6wHUd6JptZEFDGgGahMT+InnZO5i1Ei9mpC8Bw== + dependencies: + graceful-fs "^4.1.15" + parse-json "^4.0.0" + pify "^4.0.1" + strip-bom "^3.0.0" + type-fest "^0.3.0" + +load-plugin@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/load-plugin/-/load-plugin-3.0.0.tgz#8f3ce57cf4e5111639911012487bc1c2ba3d0e6c" + integrity sha512-od7eKCCZ62ITvFf8nHHrIiYmgOHb4xVNDRDqxBWSaao5FZyyZVX8OmRCbwjDGPrSrgIulwPNyBsWCGnhiDC0oQ== + dependencies: + libnpmconfig "^1.0.0" + resolve-from "^5.0.0" + +loader-runner@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357" + integrity sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw== + +loader-utils@2.0.0, loader-utils@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.0.tgz#e4cace5b816d425a166b5f097e10cd12b36064b0" + integrity sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^2.1.2" + +loader-utils@^1.1.0, loader-utils@^1.2.3, loader-utils@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613" + integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^1.0.1" + +locate-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" + integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== + dependencies: + p-locate "^3.0.0" + path-exists "^3.0.0" + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash._reinterpolate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" + integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0= + +lodash.assignin@^4.0.9: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.assignin/-/lodash.assignin-4.2.0.tgz#ba8df5fb841eb0a3e8044232b0e263a8dc6a28a2" + integrity sha1-uo31+4QesKPoBEIysOJjqNxqKKI= + +lodash.bind@^4.1.4: + version "4.2.1" + resolved "https://registry.yarnpkg.com/lodash.bind/-/lodash.bind-4.2.1.tgz#7ae3017e939622ac31b7d7d7dcb1b34db1690d35" + integrity sha1-euMBfpOWIqwxt9fX3LGzTbFpDTU= + +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY= + +lodash.chunk@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.chunk/-/lodash.chunk-4.2.0.tgz#66e5ce1f76ed27b4303d8c6512e8d1216e8106bc" + integrity sha1-ZuXOH3btJ7QwPYxlEujRIW6BBrw= + +lodash.curry@^4.0.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.curry/-/lodash.curry-4.1.1.tgz#248e36072ede906501d75966200a86dab8b23170" + integrity sha1-JI42By7ekGUB11lmIAqG2riyMXA= + +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= + +lodash.deburr@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/lodash.deburr/-/lodash.deburr-4.1.0.tgz#ddb1bbb3ef07458c0177ba07de14422cb033ff9b" + integrity sha1-3bG7s+8HRYwBd7oH3hRCLLAz/5s= + +lodash.defaults@^4.0.1, lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw= + +lodash.difference@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c" + integrity sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw= + +lodash.filter@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.filter/-/lodash.filter-4.6.0.tgz#668b1d4981603ae1cc5a6fa760143e480b4c4ace" + integrity sha1-ZosdSYFgOuHMWm+nYBQ+SAtMSs4= + +lodash.flatmap@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.flatmap/-/lodash.flatmap-4.5.0.tgz#ef8cbf408f6e48268663345305c6acc0b778702e" + integrity sha1-74y/QI9uSCaGYzRTBcaswLd4cC4= + +lodash.flatten@^4.2.0, lodash.flatten@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" + integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= + +lodash.flow@^3.3.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/lodash.flow/-/lodash.flow-3.5.0.tgz#87bf40292b8cf83e4e8ce1a3ae4209e20071675a" + integrity sha1-h79AKSuM+D5OjOGjrkIJ4gBxZ1o= + +lodash.foreach@^4.3.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53" + integrity sha1-Gmo16s5AEoDH8G3d7DUWWrJ+PlM= + +lodash.groupby@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz#0b08a1dcf68397c397855c3239783832df7403d1" + integrity sha1-Cwih3PaDl8OXhVwyOXg4Mt90A9E= + +lodash.has@^4.5.2: + version "4.5.2" + resolved "https://registry.yarnpkg.com/lodash.has/-/lodash.has-4.5.2.tgz#d19f4dc1095058cccbe2b0cdf4ee0fe4aa37c862" + integrity sha1-0Z9NwQlQWMzL4rDN9O4P5Ko3yGI= + +lodash.isempty@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e" + integrity sha1-b4bL7di+TsmHvpqvM8loTbGzHn4= + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= + +lodash.kebabcase@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" + integrity sha1-hImxyw0p/4gZXM7KRI/21swpXDY= + +lodash.map@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.map/-/lodash.map-4.6.0.tgz#771ec7839e3473d9c4cde28b19394c3562f4f6d3" + integrity sha1-dx7Hg540c9nEzeKLGTlMNWL09tM= + +lodash.memoize@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" + integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= + +lodash.merge@^4.4.0: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash.padstart@^4.6.1: + version "4.6.1" + resolved "https://registry.yarnpkg.com/lodash.padstart/-/lodash.padstart-4.6.1.tgz#d2e3eebff0d9d39ad50f5cbd1b52a7bce6bb611b" + integrity sha1-0uPuv/DZ05rVD1y9G1KnvOa7YRs= + +lodash.pick@^4.2.1, lodash.pick@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3" + integrity sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM= + +lodash.pickby@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.pickby/-/lodash.pickby-4.6.0.tgz#7dea21d8c18d7703a27c704c15d3b84a67e33aff" + integrity sha1-feoh2MGNdwOifHBMFdO4SmfjOv8= + +lodash.reduce@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.reduce/-/lodash.reduce-4.6.0.tgz#f1ab6b839299ad48f784abbf476596f03b914d3b" + integrity sha1-8atrg5KZrUj3hKu/R2WW8DuRTTs= + +lodash.reject@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.reject/-/lodash.reject-4.6.0.tgz#80d6492dc1470864bbf583533b651f42a9f52415" + integrity sha1-gNZJLcFHCGS79YNTO2UfQqn1JBU= + +lodash.some@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.some/-/lodash.some-4.6.0.tgz#1bb9f314ef6b8baded13b549169b2a945eb68e4d" + integrity sha1-G7nzFO9ri63tE7VJFpsqlF62jk0= + +lodash.sortby@^4.6.0, lodash.sortby@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" + integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= + +lodash.template@^4.4.0, lodash.template@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab" + integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A== + dependencies: + lodash._reinterpolate "^3.0.0" + lodash.templatesettings "^4.0.0" + +lodash.templatesettings@^4.0.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz#e481310f049d3cf6d47e912ad09313b154f0fb33" + integrity sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ== + dependencies: + lodash._reinterpolate "^3.0.0" + +lodash.toarray@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz#24c4bfcd6b2fba38bfd0594db1179d8e9b656561" + integrity sha1-JMS/zWsvuji/0FlNsRedjptlZWE= + +lodash.transform@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.transform/-/lodash.transform-4.6.0.tgz#12306422f63324aed8483d3f38332b5f670547a0" + integrity sha1-EjBkIvYzJK7YSD0/ODMrX2cFR6A= + +lodash.union@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" + integrity sha1-SLtQiECfFvGCFmZkHETdGqrjzYg= + +lodash.uniq@4.5.0, lodash.uniq@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" + integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= + +lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.5.2: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-process-errors@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/log-process-errors/-/log-process-errors-5.1.2.tgz#5d0f195309d9c725a010587527ade00db1fe1646" + integrity sha512-s4kmYHrzj543xUAIxc/cpmoiGZcbFwKRqqwO49DbgH+hFoSTswi0sYZuJKjUUc73b49MRPQGl0CNl8cx98/Wtg== + dependencies: + chalk "^3.0.0-beta.2" + figures "^3.0.0" + filter-obj "^2.0.1" + jest-validate "^24.9.0" + map-obj "^4.1.0" + moize "^5.4.4" + supports-color "^7.1.0" + +log-symbols@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18" + integrity sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg= + dependencies: + chalk "^1.0.0" + +log-symbols@^4.0.0, log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +log-update@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-2.3.0.tgz#88328fd7d1ce7938b29283746f0b1bc126b24708" + integrity sha1-iDKP19HOeTiykoN0bwsbwSayRwg= + dependencies: + ansi-escapes "^3.0.0" + cli-cursor "^2.0.0" + wrap-ansi "^3.0.1" + +logform@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/logform/-/logform-2.2.0.tgz#40f036d19161fc76b68ab50fdc7fe495544492f2" + integrity sha512-N0qPlqfypFx7UHNn4B3lzS/b0uLqt2hmuoa+PpuXNYgozdJYAyauF5Ky0BWVjrxDlMWiT3qN4zPq3vVAfZy7Yg== + dependencies: + colors "^1.2.1" + fast-safe-stringify "^2.0.4" + fecha "^4.2.0" + ms "^2.1.1" + triple-beam "^1.3.0" + +loglevel@^1.6.8: + version "1.7.0" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.0.tgz#728166855a740d59d38db01cf46f042caa041bb0" + integrity sha512-i2sY04nal5jDcagM3FMfG++T69GEEM8CYuOfeOIvmXzOIcwE9a/CJPR0MFM97pYMj/u10lzz7/zd7+qwhrBTqQ== + +longest-streak@^2.0.0, longest-streak@^2.0.1: + version "2.0.4" + resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4" + integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg== + +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +lower-case@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.1.tgz#39eeb36e396115cc05e29422eaea9e692c9408c7" + integrity sha512-LiWgfDLLb1dwbFQZsSglpRj+1ctGnayXz3Uv0/WO8n558JycT5fg6zkNcnW0G68Nn0aEldTFeEfmjCfmqry/rQ== + dependencies: + tslib "^1.10.0" + +lowercase-keys@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306" + integrity sha1-TjNms55/VFfjXxMkvfb4jQv8cwY= + +lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" + integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== + +lowercase-keys@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" + integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +lunr@2.3.8: + version "2.3.8" + resolved "https://registry.yarnpkg.com/lunr/-/lunr-2.3.8.tgz#a8b89c31f30b5a044b97d2d28e2da191b6ba2072" + integrity sha512-oxMeX/Y35PNFuZoHp+jUj5OSEmLCaIH4KTFJh7a93cHBoFmpw2IoPs22VIz7vyO2YUnx2Tn9dzIwO2P/4quIRg== + +macos-release@^2.2.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.4.1.tgz#64033d0ec6a5e6375155a74b1a1eba8e509820ac" + integrity sha512-H/QHeBIN1fIGJX517pvK8IEK53yQOW7YcEI55oYtgjDdoCQQz7eJS94qt5kNrscReEyuD/JcdFCm2XBEcGOITg== + +magic-string@^0.25.3, magic-string@^0.25.5, magic-string@^0.25.7: + version "0.25.7" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" + integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA== + dependencies: + sourcemap-codec "^1.4.4" + +make-dir@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" + integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ== + dependencies: + pify "^3.0.0" + +make-dir@^2.0.0, make-dir@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" + integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA== + dependencies: + pify "^4.0.1" + semver "^5.6.0" + +make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + +map-age-cleaner@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a" + integrity sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w== + dependencies: + p-defer "^1.0.0" + +map-cache@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" + integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= + +map-obj@^4.0.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.2.1.tgz#e4ea399dbc979ae735c83c863dd31bdf364277b7" + integrity sha512-+WA2/1sPmDj1dlvvJmB5G6JKfY9dpn7EVBUL06+y6PoljPkh+6V1QihwxNkbcGxCRjt2b0F9K0taiCuo7MbdFQ== + +map-obj@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.1.0.tgz#b91221b542734b9f14256c0132c897c5d7256fd5" + integrity sha512-glc9y00wgtwcDmp7GaE/0b0OnxpNJsVf3ael/An6Fe2Q51LLwN1er6sdomLRzz5h0+yMpiYLhWYF5R7HeqVd4g== + +map-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" + integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= + dependencies: + object-visit "^1.0.0" + +mark.js@^8.11.1: + version "8.11.1" + resolved "https://registry.yarnpkg.com/mark.js/-/mark.js-8.11.1.tgz#180f1f9ebef8b0e638e4166ad52db879beb2ffc5" + integrity sha1-GA8fnr74sOY45BZq1S24eb6y/8U= + +markdown-escapes@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535" + integrity sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg== + +markdown-extensions@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/markdown-extensions/-/markdown-extensions-1.1.1.tgz#fea03b539faeaee9b4ef02a3769b455b189f7fc3" + integrity sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q== + +markdown-table@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-2.0.0.tgz#194a90ced26d31fe753d8b9434430214c011865b" + integrity sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A== + dependencies: + repeat-string "^1.0.0" + +marked@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/marked/-/marked-0.7.0.tgz#b64201f051d271b1edc10a04d1ae9b74bb8e5c0e" + integrity sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg== + +maxstache-stream@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/maxstache-stream/-/maxstache-stream-1.0.4.tgz#9c7f5cab7e5fdd2d90da86143b4e9631ea328040" + integrity sha1-nH9cq35f3S2Q2oYUO06WMeoygEA= + dependencies: + maxstache "^1.0.0" + pump "^1.0.0" + split2 "^1.0.0" + through2 "^2.0.0" + +maxstache@^1.0.0: + version "1.0.7" + resolved "https://registry.yarnpkg.com/maxstache/-/maxstache-1.0.7.tgz#2231d5180ba783d5ecfc31c45fedac7ae4276984" + integrity sha1-IjHVGAung9Xs/DHEX+2seuQnaYQ= + +md5-hex@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/md5-hex/-/md5-hex-3.0.1.tgz#be3741b510591434b2784d79e556eefc2c9a8e5c" + integrity sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw== + dependencies: + blueimp-md5 "^2.10.0" + +md5.js@^1.3.4: + version "1.3.5" + resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" + integrity sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + +mdast-comment-marker@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/mdast-comment-marker/-/mdast-comment-marker-1.1.2.tgz#5ad2e42cfcc41b92a10c1421a98c288d7b447a6d" + integrity sha512-vTFXtmbbF3rgnTh3Zl3irso4LtvwUq/jaDvT2D1JqTGAwaipcS7RpTxzi6KjoRqI9n2yuAhzLDAC8xVTF3XYVQ== + +mdast-squeeze-paragraphs@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mdast-squeeze-paragraphs/-/mdast-squeeze-paragraphs-4.0.0.tgz#7c4c114679c3bee27ef10b58e2e015be79f1ef97" + integrity sha512-zxdPn69hkQ1rm4J+2Cs2j6wDEv7O17TfXTJ33tl/+JPIoEmtV9t2ZzBM5LPHE8QlHsmVD8t3vPKCyY3oH+H8MQ== + dependencies: + unist-util-remove "^2.0.0" + +mdast-util-compact@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-2.0.1.tgz#cabc69a2f43103628326f35b1acf735d55c99490" + integrity sha512-7GlnT24gEwDrdAwEHrU4Vv5lLWrEer4KOkAiKT9nYstsTad7Oc1TwqT2zIMKRdZF7cTuaf+GA1E4Kv7jJh8mPA== + dependencies: + unist-util-visit "^2.0.0" + +mdast-util-definitions@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz#c5c1a84db799173b4dcf7643cda999e440c24db2" + integrity sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ== + dependencies: + unist-util-visit "^2.0.0" + +mdast-util-from-markdown@^0.8.0: + version "0.8.5" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.5.tgz#d1ef2ca42bc377ecb0463a987910dae89bd9a28c" + integrity sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-to-string "^2.0.0" + micromark "~2.11.0" + parse-entities "^2.0.0" + unist-util-stringify-position "^2.0.0" + +mdast-util-heading-range@^2.0.1: + version "2.1.4" + resolved "https://registry.yarnpkg.com/mdast-util-heading-range/-/mdast-util-heading-range-2.1.4.tgz#152d1c71affb6172b1bbf5fee01072bf1b058709" + integrity sha512-ea+YwoFQZiwSf5TLlk9qtKb0AUKsn1oCzdskn2SXsHylA/vW9ZxmMzuCNsFi9siWW1WS1/JSOipX2brUwisIHA== + dependencies: + mdast-util-to-string "^1.0.0" + +mdast-util-to-hast@10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-10.0.1.tgz#0cfc82089494c52d46eb0e3edb7a4eb2aea021eb" + integrity sha512-BW3LM9SEMnjf4HXXVApZMt8gLQWVNXc3jryK0nJu/rOXPOnlkUjmdkDlmxMirpbU9ILncGFIwLH/ubnWBbcdgA== + dependencies: + "@types/mdast" "^3.0.0" + "@types/unist" "^2.0.0" + mdast-util-definitions "^4.0.0" + mdurl "^1.0.0" + unist-builder "^2.0.0" + unist-util-generated "^1.0.0" + unist-util-position "^3.0.0" + unist-util-visit "^2.0.0" + +mdast-util-to-markdown@^0.6.0: + version "0.6.5" + resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-0.6.5.tgz#b33f67ca820d69e6cc527a93d4039249b504bebe" + integrity sha512-XeV9sDE7ZlOQvs45C9UKMtfTcctcaj/pGwH8YLbMHoMOXNNCn2LsqVQOqrF1+/NU8lKDAqozme9SCXWyo9oAcQ== + dependencies: + "@types/unist" "^2.0.0" + longest-streak "^2.0.0" + mdast-util-to-string "^2.0.0" + parse-entities "^2.0.0" + repeat-string "^1.0.0" + zwitch "^1.0.0" + +mdast-util-to-string@^1.0.0, mdast-util-to-string@^1.0.2, mdast-util-to-string@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz#27055500103f51637bd07d01da01eb1967a43527" + integrity sha512-jVU0Nr2B9X3MU4tSK7JP1CMkSvOj7X5l/GboG1tKRw52lLF1x2Ju92Ms9tNetCcbfX3hzlM73zYo2NKkWSfF/A== + +mdast-util-to-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz#b8cfe6a713e1091cb5b728fc48885a4767f8b97b" + integrity sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w== + +mdn-data@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b" + integrity sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA== + +mdn-data@2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.6.tgz#852dc60fcaa5daa2e8cf6c9189c440ed3e042978" + integrity sha512-rQvjv71olwNHgiTbfPZFkJtjNMciWgswYeciZhtvWLO8bmX3TnhyA62I6sTWOyZssWHJJjY6/KiWwqQsWWsqOA== + +mdurl@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" + integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4= + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= + +mem@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/mem/-/mem-4.3.0.tgz#461af497bc4ae09608cdb2e60eefb69bff744178" + integrity sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w== + dependencies: + map-age-cleaner "^0.1.1" + mimic-fn "^2.0.0" + p-is-promise "^2.0.0" + +memoize-one@^5.0.0, memoize-one@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + +memoize-one@~5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" + integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA== + +memory-fs@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" + integrity sha1-OpoguEYlI+RHz7x+i7gO1me/xVI= + dependencies: + errno "^0.1.3" + readable-stream "^2.0.1" + +memory-fs@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.5.0.tgz#324c01288b88652966d161db77838720845a8e3c" + integrity sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA== + dependencies: + errno "^0.1.3" + readable-stream "^2.0.1" + +merge-anything@^2.2.4: + version "2.4.4" + resolved "https://registry.yarnpkg.com/merge-anything/-/merge-anything-2.4.4.tgz#6226b2ac3d3d3fc5fb9e8d23aa400df25f98fdf0" + integrity sha512-l5XlriUDJKQT12bH+rVhAHjwIuXWdAIecGwsYjv2LJo+dA1AeRTmeQS+3QBpO6lEthBMDi2IUMpLC1yyRvGlwQ== + dependencies: + is-what "^3.3.1" + +merge-deep@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/merge-deep/-/merge-deep-3.0.2.tgz#f39fa100a4f1bd34ff29f7d2bf4508fbb8d83ad2" + integrity sha512-T7qC8kg4Zoti1cFd8Cr0M+qaZfOwjlPDEdZIIPPB2JZctjaPM4fX+i7HOId69tAti2fvO6X5ldfYUONDODsrkA== + dependencies: + arr-union "^3.1.0" + clone-deep "^0.2.4" + kind-of "^3.0.2" + +merge-descriptors@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" + integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= + +merge-options@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/merge-options/-/merge-options-3.0.4.tgz#84709c2aa2a4b24c1981f66c179fe5565cc6dbb7" + integrity sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ== + dependencies: + is-plain-obj "^2.1.0" + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +merge2@^1.2.3, merge2@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= + +micro-api-client@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/micro-api-client/-/micro-api-client-3.3.0.tgz#52dd567d322f10faffe63d19d4feeac4e4ffd215" + integrity sha512-y0y6CUB9RLVsy3kfgayU28746QrNMpSm9O/AYGNsBgOkJr/X/Jk0VLGoO8Ude7Bpa8adywzF+MzXNZRFRsNPhg== + +micro-memoize@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/micro-memoize/-/micro-memoize-2.1.2.tgz#0787eeb1a12b4033a0fe162dfc9df4280291cee4" + integrity sha512-COjNutiFgnDHXZEIM/jYuZPwq2h8zMUeScf6Sh6so98a+REqdlpaNS7Cb2ffGfK5I+xfgoA3Rx49NGuNJTJq3w== + +micro-memoize@^4.0.9: + version "4.0.9" + resolved "https://registry.yarnpkg.com/micro-memoize/-/micro-memoize-4.0.9.tgz#b44a38c9dffbee1cefc2fd139bc8947952268b62" + integrity sha512-Z2uZi/IUMGQDCXASdujXRqrXXEwSY0XffUrAOllhqzQI3wpUyZbiZTiE2JuYC0HSG2G7DbCS5jZmsEKEGZuemg== + +microevent.ts@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/microevent.ts/-/microevent.ts-0.1.1.tgz#70b09b83f43df5172d0205a63025bce0f7357fa0" + integrity sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g== + +micromark@~2.11.0: + version "2.11.4" + resolved "https://registry.yarnpkg.com/micromark/-/micromark-2.11.4.tgz#d13436138eea826383e822449c9a5c50ee44665a" + integrity sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA== + dependencies: + debug "^4.0.0" + parse-entities "^2.0.0" + +micromatch@^3.1.10, micromatch@^3.1.4: + version "3.1.10" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" + integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.1" + define-property "^2.0.2" + extend-shallow "^3.0.2" + extglob "^2.0.4" + fragment-cache "^0.2.1" + kind-of "^6.0.2" + nanomatch "^1.2.9" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.2" + +micromatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" + integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== + dependencies: + braces "^3.0.1" + picomatch "^2.0.5" + +micromatch@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9" + integrity sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg== + dependencies: + braces "^3.0.1" + picomatch "^2.2.3" + +miller-rabin@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" + integrity sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA== + dependencies: + bn.js "^4.0.0" + brorand "^1.0.1" + +mime-db@1.44.0, "mime-db@>= 1.43.0 < 2", mime-db@^1.28.0: + version "1.44.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" + integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== + +mime-db@1.45.0: + version "1.45.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.45.0.tgz#cceeda21ccd7c3a745eba2decd55d4b73e7879ea" + integrity sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w== + +mime-db@~1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db" + integrity sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ== + +mime-types@2.1.18: + version "2.1.18" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8" + integrity sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ== + dependencies: + mime-db "~1.33.0" + +mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.24: + version "2.1.27" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" + integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== + dependencies: + mime-db "1.44.0" + +mime-types@^2.1.27: + version "2.1.28" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.28.tgz#1160c4757eab2c5363888e005273ecf79d2a0ecd" + integrity sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ== + dependencies: + mime-db "1.45.0" + +mime@1.6.0, mime@^1.2.11, mime@^1.3.4: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +mime@^2.4.4: + version "2.4.6" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1" + integrity sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA== + +mimic-fn@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" + integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ== + +mimic-fn@^2.0.0, mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +mimic-fn@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-3.1.0.tgz#65755145bbf3e36954b949c16450427451d5ca74" + integrity sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ== + +mimic-response@^1.0.0, mimic-response@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" + integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== + +mimic-response@^2.0.0, mimic-response@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" + integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== + +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + +min-document@^2.19.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" + integrity sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU= + dependencies: + dom-walk "^0.1.0" + +mini-create-react-context@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.4.0.tgz#df60501c83151db69e28eac0ef08b4002efab040" + integrity sha512-b0TytUgFSbgFJGzJqXPKCFCBWigAjpjo+Fl7Vf7ZbKRDptszpppKxXH6DRXEABZ/gcEQczeb0iZ7JvL8e8jjCA== + dependencies: + "@babel/runtime" "^7.5.5" + tiny-warning "^1.0.3" + +mini-css-extract-plugin@^0.8.0: + version "0.8.2" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.8.2.tgz#a875e169beb27c88af77dd962771c9eedc3da161" + integrity sha512-a3Y4of27Wz+mqK3qrcd3VhYz6cU0iW5x3Sgvqzbj+XmlrSizmvu8QQMl5oMYJjgHOC4iyt+w7l4umP+dQeW3bw== + dependencies: + loader-utils "^1.1.0" + normalize-url "1.9.1" + schema-utils "^1.0.0" + webpack-sources "^1.1.0" + +minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + +minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" + integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= + +minimatch@3.0.4, minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + +minipass-collect@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617" + integrity sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA== + dependencies: + minipass "^3.0.0" + +minipass-flush@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/minipass-flush/-/minipass-flush-1.0.5.tgz#82e7135d7e89a50ffe64610a787953c4c4cbb373" + integrity sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw== + dependencies: + minipass "^3.0.0" + +minipass-pipeline@^1.2.2: + version "1.2.4" + resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz#68472f79711c084657c067c5c6ad93cddea8214c" + integrity sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A== + dependencies: + minipass "^3.0.0" + +minipass@^3.0.0, minipass@^3.1.1: + version "3.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd" + integrity sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg== + dependencies: + yallist "^4.0.0" + +minizlib@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" + integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + +mississippi@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022" + integrity sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA== + dependencies: + concat-stream "^1.5.0" + duplexify "^3.4.2" + end-of-stream "^1.1.0" + flush-write-stream "^1.0.0" + from2 "^2.1.0" + parallel-transform "^1.1.0" + pump "^3.0.0" + pumpify "^1.3.3" + stream-each "^1.1.0" + through2 "^2.0.0" + +mixin-deep@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" + integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + +mixin-object@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mixin-object/-/mixin-object-2.0.1.tgz#4fb949441dab182540f1fe035ba60e1947a5e57e" + integrity sha1-T7lJRB2rGCVA8f4DW6YOGUel5X4= + dependencies: + for-in "^0.1.3" + is-extendable "^0.1.1" + +mkdirp-classic@^0.5.2: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + +mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1: + version "0.5.5" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" + integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== + dependencies: + minimist "^1.2.5" + +mkdirp@^1.0.3, mkdirp@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + +mobx-react-lite@>=2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-2.2.1.tgz#9c05dd799005d29ec1671ae86ca30b3ab5411055" + integrity sha512-SxOuV7Q1MLdj9uRUG8N1x9lBa4cS9c+YwlcrvrBVuCTlNrgRrrHuydfBHW/0f3pALhvGhcg+JEvf1gMWOuej4A== + +mobx-react@^6.2.2: + version "6.3.0" + resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-6.3.0.tgz#7d11799f988bbdadc49e725081993b18baa20329" + integrity sha512-C14yya2nqEBRSEiJjPkhoWJLlV8pcCX3m2JRV7w1KivwANJqipoiPx9UMH4pm6QNMbqDdvJqoyl+LqNu9AhvEQ== + dependencies: + mobx-react-lite ">=2.2.0" + +mobx@^4.3.1: + version "4.15.6" + resolved "https://registry.yarnpkg.com/mobx/-/mobx-4.15.6.tgz#c3de77ab272f7074720914070370335fb68cfcda" + integrity sha512-eZVEHZLi/Fe+V4qurBBQoFHCqaGrfMuYK1Vy4t5MHYfy90f52ptAKsemHsJcYl+R5/sA3oeT3rMLiVsbB7bllA== + +module-definition@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/module-definition/-/module-definition-3.3.1.tgz#fedef71667713e36988b93d0626a4fe7b35aebfc" + integrity sha512-kLidGPwQ2yq484nSD+D3JoJp4Etc0Ox9P0L34Pu/cU4X4HcG7k7p62XI5BBuvURWMRX3RPyuhOcBHbKus+UH4A== + dependencies: + ast-module-types "^2.7.1" + node-source-walk "^4.0.0" + +moize@^5.4.4: + version "5.4.7" + resolved "https://registry.yarnpkg.com/moize/-/moize-5.4.7.tgz#bffa28806441d9f5cf1c4158b67a29413c438e83" + integrity sha512-7PZH8QFJ51cIVtDv7wfUREBd3gL59JB0v/ARA3RI9zkSRa9LyGjS1Bdldii2J1/NQXRQ/3OOVOSdnZrCcVaZlw== + dependencies: + fast-equals "^1.6.0" + fast-stringify "^1.1.0" + micro-memoize "^2.1.1" + +moize@^6.0.0: + version "6.0.3" + resolved "https://registry.yarnpkg.com/moize/-/moize-6.0.3.tgz#20881786b68678bc75215ac15ac48a010592b221" + integrity sha512-7Jz9TSiqW9G2u8HwUWnaBQMFMPLblrWKEiWN4BA/GPOfQlsnfQqq0yRnTGHckGPlKApA9Eu1HPb/eTqvK9EtKg== + dependencies: + fast-equals "^2.0.1" + micro-memoize "^4.0.9" + +move-concurrently@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" + integrity sha1-viwAX9oy4LKa8fBdfEszIUxwH5I= + dependencies: + aproba "^1.1.1" + copy-concurrently "^1.0.0" + fs-write-stream-atomic "^1.0.8" + mkdirp "^0.5.1" + rimraf "^2.5.4" + run-queue "^1.0.3" + +move-file@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/move-file/-/move-file-1.2.0.tgz#789f92d276c62511d214b1b285aa16e015c2f2fc" + integrity sha512-USHrRmxzGowUWAGBbJPdFjHzEqtxDU03pLHY0Rfqgtnq+q8FOIs8wvkkf+Udmg77SJKs47y9sI0jJvQeYsmiCA== + dependencies: + cp-file "^6.1.0" + make-dir "^3.0.0" + path-exists "^3.0.0" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + +ms@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" + integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== + +ms@2.1.2, ms@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +multicast-dns-service-types@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" + integrity sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE= + +multicast-dns@^6.0.1: + version "6.2.3" + resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-6.2.3.tgz#a0ec7bd9055c4282f790c3c82f4e28db3b31b229" + integrity sha512-ji6J5enbMyGRHIAkAOu3WdV8nggqviKCEKtXcOqfphZZtQrmHKycfynJ2V7eVPUA4NhJ6V7Wf4TmGbTwKE9B6g== + dependencies: + dns-packet "^1.3.1" + thunky "^1.0.2" + +multiparty@^4.2.1: + version "4.2.2" + resolved "https://registry.yarnpkg.com/multiparty/-/multiparty-4.2.2.tgz#bee5fb5737247628d39dab4979ffd6d57bf60ef6" + integrity sha512-NtZLjlvsjcoGrzojtwQwn/Tm90aWJ6XXtPppYF4WmOk/6ncdwMMKggFY2NlRRN9yiCEIVxpOfPWahVEG2HAG8Q== + dependencies: + http-errors "~1.8.0" + safe-buffer "5.2.1" + uid-safe "2.1.5" + +mute-stream@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" + integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= + +mute-stream@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" + integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== + +nan@^2.12.1: + version "2.14.2" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19" + integrity "sha1-9TdkAGlRaPTMaUrJOT0MlYXu6hk= sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==" + +nanoid@^3.1.23: + version "3.1.23" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81" + integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw== + +nanomatch@^1.2.9: + version "1.2.13" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" + integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^2.0.2" + extend-shallow "^3.0.2" + fragment-cache "^0.2.1" + is-windows "^1.0.2" + kind-of "^6.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +napi-build-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" + integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== + +natural-orderby@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/natural-orderby/-/natural-orderby-2.0.3.tgz#8623bc518ba162f8ff1cdb8941d74deb0fdcc016" + integrity sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q== + +negotiator@0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" + integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== + +neo-async@^2.5.0, neo-async@^2.6.1, neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + +nested-error-stacks@^2.0.0, nested-error-stacks@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.1.0.tgz#0fbdcf3e13fe4994781280524f8b96b0cdff9c61" + integrity sha512-AO81vsIO1k1sM4Zrd6Hu7regmJN1NSiAja10gc4bX3F0wd+9rQmcuHQaHVQCYIEC8iFXnE+mavh23GOt7wBgug== + +netlify-cli@^3.39.0: + version "3.39.0" + resolved "https://registry.yarnpkg.com/netlify-cli/-/netlify-cli-3.39.0.tgz#68b6e94d3426d1974fbbb2273d2e0a47ad695544" + integrity sha512-XYkFp1WuWTcO7Q3LbY79ilobfCNWoL6UEYq2vhCHHKNLICSPxZtkzjDvNP628eGVzxe0L+D+Z9ce3sLdFiitMw== + dependencies: + "@netlify/build" "^12.10.0" + "@netlify/config" "^9.1.0" + "@netlify/framework-info" "^5.4.0" + "@netlify/plugin-edge-handlers" "^1.11.19" + "@netlify/plugins-list" "^2.18.0" + "@netlify/routing-local-proxy" "^0.30.1" + "@netlify/zip-it-and-ship-it" "4.4.2" + "@oclif/command" "^1.6.1" + "@oclif/config" "^1.15.1" + "@oclif/errors" "^1.3.4" + "@oclif/parser" "^3.8.4" + "@oclif/plugin-help" "^3.0.0" + "@oclif/plugin-not-found" "^1.1.4" + "@oclif/plugin-plugins" "^1.9.3" + "@octokit/rest" "^18.0.0" + "@sindresorhus/slugify" "^1.1.0" + "@ungap/from-entries" "^0.2.1" + ansi-styles "^5.0.0" + ascii-table "0.0.9" + backoff "^2.5.0" + better-opn "^2.1.1" + body-parser "^1.19.0" + boxen "^4.1.0" + chalk "^4.0.0" + chokidar "^3.0.2" + ci-info "^3.0.0" + clean-deep "^3.0.2" + cli-ux "^5.5.1" + concordance "^5.0.0" + configstore "^5.0.0" + content-type "^1.0.4" + cookie "^0.4.0" + copy-template-dir "^1.4.0" + debug "^4.1.1" + decache "^4.6.0" + del "^6.0.0" + dot-prop "^6.0.0" + dotenv "^10.0.0" + env-paths "^2.2.0" + envinfo "^7.3.1" + execa "^5.0.0" + express "^4.17.1" + express-logging "^1.1.1" + filter-obj "^2.0.1" + find-up "^5.0.0" + flush-write-stream "^2.0.0" + folder-walker "^3.2.0" + from2-array "^0.0.4" + fuzzy "^0.1.3" + get-port "^5.1.0" + gh-release-fetch "^2.0.0" + git-repo-info "^2.1.0" + gitconfiglocal "^2.1.0" + hasbin "^1.2.3" + hasha "^5.2.2" + http-proxy "^1.18.0" + http-proxy-middleware "^1.0.0" + https-proxy-agent "^5.0.0" + inquirer "^6.5.1" + inquirer-autocomplete-prompt "^1.0.1" + is-docker "^2.0.0" + is-plain-obj "^3.0.0" + isexe "^2.0.0" + jwt-decode "^3.0.0" + lambda-local "^1.7.1" + listr "^0.14.3" + locate-path "^6.0.0" + lodash "^4.17.20" + log-symbols "^4.0.0" + make-dir "^3.0.0" + memoize-one "^5.2.1" + minimist "^1.2.5" + multiparty "^4.2.1" + netlify "^7.0.1" + netlify-redirect-parser "^8.0.0" + netlify-redirector "^0.2.1" + node-fetch "^2.6.0" + node-version-alias "^1.0.1" + oclif-plugin-completion "^0.6.0" + omit.js "^2.0.2" + open "^7.0.0" + ora "^5.0.0" + p-event "^4.2.0" + p-filter "^2.1.0" + p-map "^4.0.0" + p-wait-for "^3.0.0" + parallel-transform "^1.2.0" + parse-github-url "^1.0.2" + parse-gitignore "^1.0.1" + path-exists "^4.0.0" + path-key "^3.1.1" + path-type "^4.0.0" + prettyjson "^1.2.1" + pump "^3.0.0" + raw-body "^2.4.1" + resolve "^1.12.0" + safe-join "^0.1.3" + semver "^7.3.4" + source-map-support "^0.5.19" + static-server "^2.2.1" + strip-ansi-control-characters "^2.0.0" + tempy "^1.0.0" + through2-filter "^3.0.0" + through2-map "^3.0.0" + to-readable-stream "^2.1.0" + update-notifier "^5.0.0" + uuid "^8.0.0" + wait-port "^0.2.2" + which "^2.0.2" + winston "^3.2.1" + wrap-ansi "^7.0.0" + write-file-atomic "^3.0.0" + +netlify-redirect-parser@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/netlify-redirect-parser/-/netlify-redirect-parser-8.0.0.tgz#9a58051c3c3c40843f50b7268ad0b6de90f56298" + integrity sha512-4E7YYZHVje+aofCiwznWVZJowf7ed6nrFexlL/ogQaE7745KI8KdHj7itVXAPggpiqiTPj9Iz/LMh/av6iT1Gg== + dependencies: + filter-obj "^2.0.2" + is-plain-obj "^2.1.0" + path-exists "^4.0.0" + toml "^3.0.0" + +netlify-redirector@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/netlify-redirector/-/netlify-redirector-0.2.1.tgz#efdb761ea2c52edb3ecb5f237db0e10861f2ff0e" + integrity sha512-17vDR9p1Loanp+vd57y+b6WlKb5X+qb0LZ44oTYsKJbdonz4Md+Ybv1lzH1w1aKm5YWWXHR8LMpWyY9bjlAJKw== + +netlify@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/netlify/-/netlify-7.0.1.tgz#5fbe5addc5a43f1cb49afc710a9a5eebcb0c4646" + integrity sha512-Gd1aexpJ3RrOzkssdE8ipS67PuppOAkJNhRqQPp2in2XnJKPm5kvYonYMNVadasSFlNdmVCk9nELV3TnbAfklw== + dependencies: + "@netlify/open-api" "^2.5.0" + lodash.camelcase "^4.3.0" + micro-api-client "^3.3.0" + node-fetch "^2.6.1" + omit.js "^2.0.2" + p-wait-for "^3.2.0" + qs "^6.9.6" + +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== + +njct@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/njct/-/njct-8.0.0.tgz#7e2dd91d9eb9315cc6a2a0d7c29199f27d5f917d" + integrity sha512-TfPRCui1nHgpE6/LaApQVHekpnycXI5mFQG+7Wr9EZ1uN96UZTHKKIzyywGW9g4WnoQzCWb4cKUd/d8M4Rdoeg== + dependencies: + tslib "^1.9.0" + +no-case@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.3.tgz#c21b434c1ffe48b39087e86cfb4d2582e9df18f8" + integrity sha512-ehY/mVQCf9BL0gKfsJBvFJen+1V//U+0HQMPrWct40ixE4jnv0bfvxDbWtAHL9EcaPEOJHVVYKoQn1TlZUB8Tw== + dependencies: + lower-case "^2.0.1" + tslib "^1.10.0" + +node-abi@^2.7.0: + version "2.19.1" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-2.19.1.tgz#6aa32561d0a5e2fdb6810d8c25641b657a8cea85" + integrity sha512-HbtmIuByq44yhAzK7b9j/FelKlHYISKQn0mtvcBrU5QBkhoCMp5bu8Hv5AI34DcKfOAcJBcOEMwLlwO62FFu9A== + dependencies: + semver "^5.4.1" + +node-addon-api@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.0.0.tgz#812446a1001a54f71663bed188314bba07e09247" + integrity sha512-sSHCgWfJ+Lui/u+0msF3oyCgvdkhxDbkCS6Q8uiJquzOimkJBvX6hl5aSSA7DR1XbMpdM8r7phjcF63sF4rkKg== + +node-emoji@^1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.10.0.tgz#8886abd25d9c7bb61802a658523d1f8d2a89b2da" + integrity sha512-Yt3384If5H6BYGVHiHwTL+99OzJKHhgp82S8/dktEK73T26BazdgZ4JZh92xSVtGNJvz9UbXdNAc5hcrXV42vw== + dependencies: + lodash.toarray "^4.4.0" + +node-fetch-h2@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz#c6188325f9bd3d834020bf0f2d6dc17ced2241ac" + integrity sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg== + dependencies: + http2-client "^1.2.5" + +node-fetch@2.6.1, node-fetch@^2.3.0, node-fetch@^2.6.0, node-fetch@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + +node-forge@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" + integrity "sha1-Mt6ir7Ppkm8C7lzoeUkCaRpna/M= sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==" + +node-libs-browser@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425" + integrity sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q== + dependencies: + assert "^1.1.1" + browserify-zlib "^0.2.0" + buffer "^4.3.0" + console-browserify "^1.1.0" + constants-browserify "^1.0.0" + crypto-browserify "^3.11.0" + domain-browser "^1.1.1" + events "^3.0.0" + https-browserify "^1.0.0" + os-browserify "^0.3.0" + path-browserify "0.0.1" + process "^0.11.10" + punycode "^1.2.4" + querystring-es3 "^0.2.0" + readable-stream "^2.3.3" + stream-browserify "^2.0.1" + stream-http "^2.7.2" + string_decoder "^1.0.0" + timers-browserify "^2.0.4" + tty-browserify "0.0.0" + url "^0.11.0" + util "^0.11.0" + vm-browserify "^1.0.1" + +node-readfiles@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/node-readfiles/-/node-readfiles-0.2.0.tgz#dbbd4af12134e2e635c245ef93ffcf6f60673a5d" + integrity sha1-271K8SE04uY1wkXvk//Pb2BnOl0= + dependencies: + es6-promise "^3.2.1" + +node-releases@^1.1.60: + version "1.1.61" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.61.tgz#707b0fca9ce4e11783612ba4a2fcba09047af16e" + integrity sha512-DD5vebQLg8jLCOzwupn954fbIiZht05DAZs0k2u8NStSe6h9XdsuIQL8hSRKYiU8WUQRznmSDrKGbv3ObOmC7g== + +node-releases@^1.1.61, node-releases@^1.1.69: + version "1.1.70" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.70.tgz#66e0ed0273aa65666d7fe78febe7634875426a08" + integrity sha512-Slf2s69+2/uAD79pVVQo8uSiC34+g8GWY8UH2Qtqv34ZfhYrxpYpfzs9Js9d6O0mbDmALuxaTlplnBTnSELcrw== + +node-releases@^1.1.71: + version "1.1.73" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.73.tgz#dd4e81ddd5277ff846b80b52bb40c49edf7a7b20" + integrity sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg== + +node-source-walk@^4.0.0, node-source-walk@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/node-source-walk/-/node-source-walk-4.2.0.tgz#c2efe731ea8ba9c03c562aa0a9d984e54f27bc2c" + integrity sha512-hPs/QMe6zS94f5+jG3kk9E7TNm4P2SulrKiLWMzKszBfNZvL/V6wseHlTd7IvfW0NZWqPtK3+9yYNr+3USGteA== + dependencies: + "@babel/parser" "^7.0.0" + +node-version-alias@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/node-version-alias/-/node-version-alias-1.0.1.tgz#3c77bdb4ec8c2516644f03ec73847359f94273fd" + integrity sha512-E9EhoJkpIIZyYplB298W8ZfhcojQrnKnUPcaOgJqVqICUZwPZkuj10nTzEscwdziOOj545v4tGPvNBG3ieUbSw== + dependencies: + all-node-versions "^8.0.0" + filter-obj "^2.0.1" + jest-validate "^25.3.0" + normalize-node-version "^10.0.0" + path-exists "^4.0.0" + semver "^7.3.2" + +node-vibrant@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/node-vibrant/-/node-vibrant-3.1.5.tgz#8729bf35aabd54cd2eccbfadf22124ab4e1305b0" + integrity sha512-Gk+iyBzPSN1SF5qL818QaBtuA38206Z8iPNa0PcLUPyIbZL4+i14VmYxkGCL0n/5Q1721CRSktqtACgkx7Qodg== + dependencies: + "@jimp/custom" "^0.9.3" + "@jimp/plugin-resize" "^0.9.3" + "@jimp/types" "^0.9.3" + "@types/lodash" "^4.14.53" + "@types/node" "^10.11.7" + lodash "^4.17.4" + url "^0.11.0" + +noop-logger@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/noop-logger/-/noop-logger-0.1.1.tgz#94a2b1633c4f1317553007d8966fd0e841b6a4c2" + integrity sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI= + +noop2@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/noop2/-/noop2-2.0.0.tgz#4b636015e9882b54783c02b412f699d8c5cd0a5b" + integrity sha1-S2NgFemIK1R4PAK0EvaZ2MXNCls= + +normalize-node-version@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/normalize-node-version/-/normalize-node-version-10.0.0.tgz#5ec513f3eb292a8c2f0d35ba519f97f077cb80bc" + integrity sha512-/gVbS/qAnowVxr2fJy3F0MxmCvx8QdXJDl8XUE7HT3vsDeDjQfZkX9OiPahF+51Hgy93cKG1hP6uyBjQsMCvWQ== + dependencies: + all-node-versions "^8.0.0" + filter-obj "^2.0.1" + jest-validate "^25.3.0" + semver "^7.3.2" + +normalize-package-data@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" + integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== + dependencies: + hosted-git-info "^2.1.4" + resolve "^1.10.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + +normalize-path@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= + dependencies: + remove-trailing-separator "^1.0.1" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +normalize-range@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" + integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= + +normalize-url@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c" + integrity sha1-LMDWazHqIwNkWENuNiDYWVTGbDw= + dependencies: + object-assign "^4.0.1" + prepend-http "^1.0.0" + query-string "^4.1.0" + sort-keys "^1.0.0" + +normalize-url@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-2.0.1.tgz#835a9da1551fa26f70e92329069a23aa6574d7e6" + integrity sha512-D6MUW4K/VzoJ4rJ01JFKxDrtY1v9wrgzCX5f2qj/lzH1m/lW6MhUZFKerVsnyjOhOsYzI9Kqqak+10l4LvLpMw== + dependencies: + prepend-http "^2.0.0" + query-string "^5.0.1" + sort-keys "^2.0.0" + +normalize-url@^3.0.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" + integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== + +normalize-url@^4.1.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.0.tgz#453354087e6ca96957bd8f5baf753f5982142129" + integrity sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ== + +normalize-url@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" + integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== + +npm-normalize-package-bin@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2" + integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA== + +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= + dependencies: + path-key "^2.0.0" + +npm-run-path@^4.0.0, npm-run-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +npmlog@^4.0.1, npmlog@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" + integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== + dependencies: + are-we-there-yet "~1.1.2" + console-control-strings "~1.1.0" + gauge "~2.7.3" + set-blocking "~2.0.0" + +nprogress@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/nprogress/-/nprogress-0.2.0.tgz#cb8f34c53213d895723fcbab907e9422adbcafb1" + integrity sha1-y480xTIT2JVyP8urkH6UIq28r7E= + +nth-check@^1.0.2, nth-check@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" + integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg== + dependencies: + boolbase "~1.0.0" + +null-loader@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/null-loader/-/null-loader-3.0.0.tgz#3e2b6c663c5bda8c73a54357d8fa0708dc61b245" + integrity sha512-hf5sNLl8xdRho4UPBOOeoIwT3WhjYcMUQm0zj44EhD6UscMAz72o2udpoDFBgykucdEDGIcd6SXbc/G6zssbzw== + dependencies: + loader-utils "^1.2.3" + schema-utils "^1.0.0" + +num2fraction@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede" + integrity sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4= + +number-is-nan@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" + integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= + +oas-kit-common@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/oas-kit-common/-/oas-kit-common-1.0.8.tgz#6d8cacf6e9097967a4c7ea8bcbcbd77018e1f535" + integrity sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ== + dependencies: + fast-safe-stringify "^2.0.7" + +oas-linter@^3.1.3: + version "3.2.0" + resolved "https://registry.yarnpkg.com/oas-linter/-/oas-linter-3.2.0.tgz#82d2e586da00adc24c44e44de4b31a6f6ba5cc7e" + integrity sha512-LP5F1dhjULEJV5oGRg6ROztH2FddzttrrUEwq5J2GB2Zy938mg0vwt1+Rthn/qqDHtj4Qgq21duNGHh+Ew1wUg== + dependencies: + "@exodus/schemasafe" "^1.0.0-rc.2" + should "^13.2.1" + yaml "^1.10.0" + +oas-resolver@^2.4.3: + version "2.4.4" + resolved "https://registry.yarnpkg.com/oas-resolver/-/oas-resolver-2.4.4.tgz#d4161f0826a4ea77976608b68843d1cf8eca657b" + integrity sha512-670+SM5CXYrjI547cgUeQTdB1wJb2gyrJ7bnGywQMrmciKXhXj/EFbE/8c8wed+j1WmGAh7xS+QdQxiuIlNqlw== + dependencies: + node-fetch-h2 "^2.3.0" + oas-kit-common "^1.0.8" + reftools "^1.1.6" + yaml "^1.10.0" + yargs "^15.3.1" + +oas-schema-walker@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/oas-schema-walker/-/oas-schema-walker-1.1.5.tgz#74c3cd47b70ff8e0b19adada14455b5d3ac38a22" + integrity sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ== + +oas-validator@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/oas-validator/-/oas-validator-4.0.8.tgz#4f1a4d6bd9e030ad07db03fd7a7bc3a91aabcc7d" + integrity sha512-bIt8erTyclF7bkaySTtQ9sppqyVc+mAlPi7vPzCLVHJsL9nrivQjc/jHLX/o+eGbxHd6a6YBwuY/Vxa6wGsiuw== + dependencies: + ajv "^5.5.2" + better-ajv-errors "^0.6.7" + call-me-maybe "^1.0.1" + oas-kit-common "^1.0.8" + oas-linter "^3.1.3" + oas-resolver "^2.4.3" + oas-schema-walker "^1.1.5" + reftools "^1.1.5" + should "^13.2.1" + yaml "^1.8.3" + +object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + +object-copy@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" + integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw= + dependencies: + copy-descriptor "^0.1.0" + define-property "^0.2.5" + kind-of "^3.0.3" + +object-inspect@^1.7.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0" + integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA== + +object-inspect@^1.9.0: + version "1.10.3" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.10.3.tgz#c2aa7d2d09f50c99375704f7a0adf24c5782d369" + integrity sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw== + +object-is@^1.0.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.2.tgz#c5d2e87ff9e119f78b7a088441519e2eec1573b6" + integrity sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + +object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object-treeify@^1.1.4: + version "1.1.27" + resolved "https://registry.yarnpkg.com/object-treeify/-/object-treeify-1.1.27.tgz#4244a90df5ba3d2d92747ebd64b271369379e4f3" + integrity sha512-xRDJvP4ptfleTmJm/qPrxv/8Iifgk/xnaNeBqyczbYeQ4Yh5GCGOZaO78MlbwR5nlo9viTxM1kgThgl8/sMEHg== + +object-visit@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" + integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= + dependencies: + isobject "^3.0.0" + +object.assign@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" + integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== + dependencies: + define-properties "^1.1.2" + function-bind "^1.1.1" + has-symbols "^1.0.0" + object-keys "^1.0.11" + +object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649" + integrity sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + +object.pick@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= + dependencies: + isobject "^3.0.1" + +object.values@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.1.tgz#68a99ecde356b7e9295a3c5e0ce31dc8c953de5e" + integrity sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + function-bind "^1.1.1" + has "^1.0.3" + +obuf@^1.0.0, obuf@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" + integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== + +oclif-plugin-completion@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/oclif-plugin-completion/-/oclif-plugin-completion-0.6.0.tgz#b9828d33cce01a4577f0d4c8c23a66220705d81f" + integrity sha512-0HGaSR/E/seIhSzFxLkh0QqckuNSre4iGqSElZRUv1hVHH2YgrZ7xtQL9McwL8o1fh6HqkzykjUx0Iy2haVIUg== + dependencies: + "@oclif/command" "^1" + "@oclif/config" "^1" + tslib "^2" + +omggif@^1.0.9: + version "1.0.10" + resolved "https://registry.yarnpkg.com/omggif/-/omggif-1.0.10.tgz#ddaaf90d4a42f532e9e7cb3a95ecdd47f17c7b19" + integrity sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw== + +omit.js@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/omit.js/-/omit.js-2.0.2.tgz#dd9b8436fab947a5f3ff214cb2538631e313ec2f" + integrity sha512-hJmu9D+bNB40YpL9jYebQl4lsTW6yEHRTroJzNLqQJYHm7c+NQnJGfZmIWh8S3q3KoaxV1aLhV6B3+0N0/kyJg== + +on-finished@~2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" + integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= + dependencies: + ee-first "1.1.1" + +on-headers@^1.0.0, on-headers@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" + integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +one-time@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45" + integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g== + dependencies: + fn.name "1.x.x" + +onetime@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" + integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ= + dependencies: + mimic-fn "^1.0.0" + +onetime@^5.1.0, onetime@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +ono@^4.0.11: + version "4.0.11" + resolved "https://registry.yarnpkg.com/ono/-/ono-4.0.11.tgz#c7f4209b3e396e8a44ef43b9cedc7f5d791d221d" + integrity sha512-jQ31cORBFE6td25deYeD80wxKBMj+zBmHTrVxnc6CKhx8gho6ipmWM5zj/oeoqioZ99yqBls9Z/9Nss7J26G2g== + dependencies: + format-util "^1.0.3" + +open@^7.0.0, open@^7.0.2, open@^7.0.3: + version "7.4.2" + resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" + integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== + dependencies: + is-docker "^2.0.0" + is-wsl "^2.1.1" + +openapi-sampler@^1.0.0-beta.16: + version "1.0.0-beta.16" + resolved "https://registry.yarnpkg.com/openapi-sampler/-/openapi-sampler-1.0.0-beta.16.tgz#7813524d5b88d222efb772ceb5a809075d6d9174" + integrity sha512-05+GvwMagTY7GxoDQoWJfmAUFlxfebciiEzqKmu4iq6+MqBEn62AMUkn0CTxyKhnUGIaR2KXjTeslxIeJwVIOw== + dependencies: + json-pointer "^0.6.0" + +opener@^1.5.1: + version "1.5.2" + resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" + integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== + +opn@^5.2.0, opn@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc" + integrity sha512-PqHpggC9bLV0VeWcdKhkpxY+3JTzetLSqTCWL/z/tFIbI6G8JCjondXklT1JinczLz2Xib62sSp0T/gKT4KksA== + dependencies: + is-wsl "^1.1.0" + +optimize-css-assets-webpack-plugin@^5.0.3: + version "5.0.4" + resolved "https://registry.yarnpkg.com/optimize-css-assets-webpack-plugin/-/optimize-css-assets-webpack-plugin-5.0.4.tgz#85883c6528aaa02e30bbad9908c92926bb52dc90" + integrity sha512-wqd6FdI2a5/FdoiCNNkEvLeA//lHHfG24Ln2Xm2qqdIk4aOlsR18jwpyOihqQ8849W3qu2DX8fOYxpvTMj+93A== + dependencies: + cssnano "^4.1.10" + last-call-webpack-plugin "^3.0.0" + +optionator@^0.8.1: + version "0.8.3" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" + integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.6" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + word-wrap "~1.2.3" + +ora@^5.0.0: + version "5.4.1" + resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18" + integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== + dependencies: + bl "^4.1.0" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-spinners "^2.5.0" + is-interactive "^1.0.0" + is-unicode-supported "^0.1.0" + log-symbols "^4.1.0" + strip-ansi "^6.0.0" + wcwidth "^1.0.1" + +original@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/original/-/original-1.0.2.tgz#e442a61cffe1c5fd20a65f3261c26663b303f25f" + integrity sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg== + dependencies: + url-parse "^1.4.3" + +os-browserify@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" + integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= + +os-name@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/os-name/-/os-name-3.1.0.tgz#dec19d966296e1cd62d701a5a66ee1ddeae70801" + integrity sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg== + dependencies: + macos-release "^2.2.0" + windows-release "^3.1.0" + +os-tmpdir@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= + +p-all@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-all/-/p-all-2.1.0.tgz#91419be56b7dee8fe4c5db875d55e0da084244a0" + integrity sha512-HbZxz5FONzz/z2gJfk6bFca0BCiSRF8jU3yCsWOen/vR6lZjfPOu/e7L3uFzTW1i0H8TlC3vqQstEJPQL4/uLA== + dependencies: + p-map "^2.0.0" + +p-any@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-any/-/p-any-2.1.0.tgz#719489408e14f5f941a748f1e817f5c71cab35cb" + integrity sha512-JAERcaMBLYKMq+voYw36+x5Dgh47+/o7yuv2oQYuSSUml4YeqJEFznBrY2UeEkoSHqBua6hz518n/PsowTYLLg== + dependencies: + p-cancelable "^2.0.0" + p-some "^4.0.0" + type-fest "^0.3.0" + +p-cancelable@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.4.1.tgz#35f363d67d52081c8d9585e37bcceb7e0bbcb2a0" + integrity sha512-HNa1A8LvB1kie7cERyy21VNeHb2CWJJYqyyC2o3klWFfMGlFmWv2Z7sFgZH8ZiaYL95ydToKTFVXgMV/Os0bBQ== + +p-cancelable@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" + integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== + +p-cancelable@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.0.0.tgz#4a3740f5bdaf5ed5d7c3e34882c6fb5d6b266a6e" + integrity sha512-wvPXDmbMmu2ksjkB4Z3nZWTSkJEb9lqVdMaCKpZUGJG9TMiNp9XcbG3fn9fPKjem04fJMJnXoyFPk2FmgiaiNg== + +p-defer@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" + integrity sha1-n26xgvbJqozXQwBKfU+WsZaw+ww= + +p-event@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/p-event/-/p-event-2.3.1.tgz#596279ef169ab2c3e0cae88c1cfbb08079993ef6" + integrity sha512-NQCqOFhbpVTMX4qMe8PF8lbGtzZ+LCiN7pcNrb/413Na7+TRoe1xkKUzuWa/YEJdGQ0FvKtj35EEbDoVPO2kbA== + dependencies: + p-timeout "^2.0.1" + +p-event@^4.0.0, p-event@^4.1.0, p-event@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/p-event/-/p-event-4.2.0.tgz#af4b049c8acd91ae81083ebd1e6f5cae2044c1b5" + integrity sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ== + dependencies: + p-timeout "^3.1.0" + +p-every@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/p-every/-/p-every-2.0.0.tgz#ad940b82b1bd1da01c307b11e1dd25fe7286181a" + integrity sha512-MCz9DqD5opPC48Zsd+BHm56O/HfhYIQQtupfDzhXoVgQdg/Ux4F8/JcdRuQ+arq7zD5fB6zP3axbH3d9Nr8dlw== + dependencies: + p-map "^2.0.0" + +p-filter@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-filter/-/p-filter-2.1.0.tgz#1b1472562ae7a0f742f0f3d3d3718ea66ff9c09c" + integrity sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw== + dependencies: + p-map "^2.0.0" + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= + +p-finally@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-2.0.1.tgz#bd6fcaa9c559a096b680806f4d657b3f0f240561" + integrity sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw== + +p-is-promise@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-1.1.0.tgz#9c9456989e9f6588017b0434d56097675c3da05e" + integrity sha1-nJRWmJ6fZYgBewQ01WCXZ1w9oF4= + +p-is-promise@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.1.0.tgz#918cebaea248a62cf7ffab8e3bca8c5f882fc42e" + integrity sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg== + +p-limit@^2.0.0, p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.0.2.tgz#1664e010af3cadc681baafd3e2a437be7b0fb5fe" + integrity sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg== + dependencies: + p-try "^2.0.0" + +p-locate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" + integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== + dependencies: + p-limit "^2.0.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-map@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" + integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== + +p-map@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-3.0.0.tgz#d704d9af8a2ba684e2600d9a215983d4141a979d" + integrity sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ== + dependencies: + aggregate-error "^3.0.0" + +p-map@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" + integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== + dependencies: + aggregate-error "^3.0.0" + +p-memoize@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-memoize/-/p-memoize-2.1.0.tgz#9ac80c8cf9373c52dfece6aae1fd2e300602898a" + integrity sha512-c6+a2iV4JyX0r4+i2IBJYO0r6LZAT2fg/tcB6GQbv1uzZsfsmKT7Ej5DRT1G6Wi7XUJSV2ZiP9+YEtluvhCmkg== + dependencies: + mem "^4.0.0" + mimic-fn "^1.0.0" + +p-reduce@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-2.1.0.tgz#09408da49507c6c274faa31f28df334bc712b64a" + integrity sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw== + +p-retry@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-3.0.1.tgz#316b4c8893e2c8dc1cfa891f406c4b422bebf328" + integrity sha512-XE6G4+YTTkT2a0UWb2kjZe8xNwf8bIbnqpc/IS/idOBVhyves0mK5OJgeocjx7q5pvX/6m23xuzVPYT1uGM73w== + dependencies: + retry "^0.12.0" + +p-some@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-some/-/p-some-4.1.0.tgz#28e73bc1e0d62db54c2ed513acd03acba30d5c04" + integrity sha512-MF/HIbq6GeBqTrTIl5OJubzkGU+qfFhAFi0gnTAK6rgEIJIknEiABHOTtQu4e6JiXjIwuMPMUFQzyHh5QjCl1g== + dependencies: + aggregate-error "^3.0.0" + p-cancelable "^2.0.0" + +p-timeout@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-2.0.1.tgz#d8dd1979595d2dc0139e1fe46b8b646cb3cdf038" + integrity sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA== + dependencies: + p-finally "^1.0.0" + +p-timeout@^3.0.0, p-timeout@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +p-wait-for@^3.0.0, p-wait-for@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-wait-for/-/p-wait-for-3.2.0.tgz#640429bcabf3b0dd9f492c31539c5718cb6a3f1f" + integrity sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA== + dependencies: + p-timeout "^3.0.0" + +package-json@^6.3.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0" + integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ== + dependencies: + got "^9.6.0" + registry-auth-token "^4.0.0" + registry-url "^5.0.0" + semver "^6.2.0" + +pako@^1.0.5, pako@~1.0.5: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + +parallel-transform@^1.1.0, parallel-transform@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.2.0.tgz#9049ca37d6cb2182c3b1d2c720be94d14a5814fc" + integrity sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg== + dependencies: + cyclist "^1.0.1" + inherits "^2.0.3" + readable-stream "^2.1.5" + +param-case@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.3.tgz#4be41f8399eff621c56eebb829a5e451d9801238" + integrity sha512-VWBVyimc1+QrzappRs7waeN2YmoZFCGXWASRYX1/rGHtXqEcrGEIDm+jqIwFa2fRXNgQEwrxaYuIrX0WcAguTA== + dependencies: + dot-case "^3.0.3" + tslib "^1.10.0" + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-asn1@^5.0.0, parse-asn1@^5.1.5: + version "5.1.6" + resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.6.tgz#385080a3ec13cb62a62d39409cb3e88844cdaed4" + integrity sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw== + dependencies: + asn1.js "^5.2.0" + browserify-aes "^1.0.0" + evp_bytestokey "^1.0.0" + pbkdf2 "^3.0.3" + safe-buffer "^5.1.1" + +parse-bmfont-ascii@^1.0.3: + version "1.0.6" + resolved "https://registry.yarnpkg.com/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz#11ac3c3ff58f7c2020ab22769079108d4dfa0285" + integrity sha1-Eaw8P/WPfCAgqyJ2kHkQjU36AoU= + +parse-bmfont-binary@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz#d038b476d3e9dd9db1e11a0b0e53a22792b69006" + integrity sha1-0Di0dtPp3Z2x4RoLDlOiJ5K2kAY= + +parse-bmfont-xml@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/parse-bmfont-xml/-/parse-bmfont-xml-1.1.4.tgz#015319797e3e12f9e739c4d513872cd2fa35f389" + integrity sha512-bjnliEOmGv3y1aMEfREMBJ9tfL3WR0i0CKPj61DnSLaoxWR3nLrsQrEbCId/8rF4NyRF0cCqisSVXyQYWM+mCQ== + dependencies: + xml-parse-from-string "^1.0.0" + xml2js "^0.4.5" + +parse-entities@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-2.0.0.tgz#53c6eb5b9314a1f4ec99fa0fdf7ce01ecda0cbe8" + integrity sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ== + dependencies: + character-entities "^1.0.0" + character-entities-legacy "^1.0.0" + character-reference-invalid "^1.0.0" + is-alphanumerical "^1.0.0" + is-decimal "^1.0.0" + is-hexadecimal "^1.0.0" + +parse-github-url@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/parse-github-url/-/parse-github-url-1.0.2.tgz#242d3b65cbcdda14bb50439e3242acf6971db395" + integrity sha512-kgBf6avCbO3Cn6+RnzRGLkUsv4ZVqv/VfAYkRsyBcgkshNvVBkRn1FEZcW0Jb+npXQWm2vHPnnOqFteZxRRGNw== + +parse-gitignore@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parse-gitignore/-/parse-gitignore-1.0.1.tgz#8b9dc57f17b810d495c5dfa62eb07caffe7758c7" + integrity sha512-UGyowyjtx26n65kdAMWhm6/3uy5uSrpcuH7tt+QEVudiBoVS+eqHxD5kbi9oWVRwj7sCzXqwuM+rUGw7earl6A== + +parse-headers@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/parse-headers/-/parse-headers-2.0.3.tgz#5e8e7512383d140ba02f0c7aa9f49b4399c92515" + integrity sha512-QhhZ+DCCit2Coi2vmAKbq5RGTRcQUOE2+REgv8vdyu7MnYx2eZztegqtTx99TZ86GTIwqiy3+4nQTWZ2tgmdCA== + +parse-json@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" + integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA= + dependencies: + error-ex "^1.3.1" + json-parse-better-errors "^1.0.1" + +parse-json@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.1.0.tgz#f96088cdf24a8faa9aea9a009f2d9d942c999646" + integrity sha512-+mi/lmVVNKFNVyLXV31ERiy2CY5E1/F6QtJFEzoChPRwwngMNXRDQ9GJ5WdE2Z2P4AujsOi0/+2qHID68KwfIQ== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +parse-ms@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d" + integrity sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA== + +parse-numeric-range@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/parse-numeric-range/-/parse-numeric-range-0.0.2.tgz#b4f09d413c7adbcd987f6e9233c7b4b210c938e4" + integrity sha1-tPCdQTx6282Yf26SM8e0shDJOOQ= + +parse5@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" + integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== + +parse5@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" + integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== + +parseurl@~1.3.2, parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +pascal-case@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.1.tgz#5ac1975133ed619281e88920973d2cd1f279de5f" + integrity sha512-XIeHKqIrsquVTQL2crjq3NfJUxmdLasn3TYOU0VBM+UX2a6ztAWBlJQBePLGY7VHW8+2dRadeIPK5+KImwTxQA== + dependencies: + no-case "^3.0.3" + tslib "^1.10.0" + +pascalcase@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" + integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= + +password-prompt@^1.0.7, password-prompt@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/password-prompt/-/password-prompt-1.1.2.tgz#85b2f93896c5bd9e9f2d6ff0627fa5af3dc00923" + integrity sha512-bpuBhROdrhuN3E7G/koAju0WjVw9/uQOG5Co5mokNj0MiOSBVZS1JTwM4zl55hu0WFmIEFvO9cU9sJQiBIYeIA== + dependencies: + ansi-escapes "^3.1.0" + cross-spawn "^6.0.5" + +path-browserify@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.1.tgz#e6c4ddd7ed3aa27c68a20cc4e50e1a4ee83bbc4a" + integrity sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ== + +path-dirname@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" + integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA= + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-is-inside@1.0.2, path-is-inside@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" + integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= + +path-key@^2.0.0, path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= + +path-key@^3.0.0, path-key@^3.1.0, path-key@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" + integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + +path-to-regexp@0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" + integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= + +path-to-regexp@2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-2.2.1.tgz#90b617025a16381a879bc82a38d4e8bdeb2bcf45" + integrity sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ== + +path-to-regexp@^1.7.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" + integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== + dependencies: + isarray "0.0.1" + +path-type@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" + integrity sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg== + dependencies: + pify "^3.0.0" + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +pbkdf2@^3.0.3: + version "3.1.1" + resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.1.tgz#cb8724b0fada984596856d1a6ebafd3584654b94" + integrity sha512-4Ejy1OPxi9f2tt1rRV7Go7zmfDQ+ZectEQz3VGUQhgq62HtIRPDyG/JtnwIxs6x3uNMwo2V7q1fMvKjb+Tnpqg== + dependencies: + create-hash "^1.1.2" + create-hmac "^1.1.4" + ripemd160 "^2.0.1" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +pend@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" + integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA= + +perfect-scrollbar@^1.4.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/perfect-scrollbar/-/perfect-scrollbar-1.5.0.tgz#821d224ed8ff61990c23f26db63048cdc75b6b83" + integrity sha512-NrNHJn5mUGupSiheBTy6x+6SXCFbLlm8fVZh9moIzw/LgqElN5q4ncR4pbCBCYuCJ8Kcl9mYM0NgDxvW+b4LxA== + +phin@^2.9.1: + version "2.9.3" + resolved "https://registry.yarnpkg.com/phin/-/phin-2.9.3.tgz#f9b6ac10a035636fb65dfc576aaaa17b8743125c" + integrity sha512-CzFr90qM24ju5f88quFC/6qohjC144rehe5n6DH900lgXmUe86+xCKc10ev56gRKC4/BkHUoG4uSiQgBiIXwDA== + +picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1: + version "2.2.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" + integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== + +picomatch@^2.2.2, picomatch@^2.2.3: + version "2.3.0" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" + integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== + +pify@^2.0.0, pify@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= + +pify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" + integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= + +pify@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" + integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== + +pinkie-promise@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" + integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o= + dependencies: + pinkie "^2.0.0" + +pinkie@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" + integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA= + +pixelmatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-4.0.2.tgz#8f47dcec5011b477b67db03c243bc1f3085e8854" + integrity sha1-j0fc7FARtHe2fbA8JDvB8wheiFQ= + dependencies: + pngjs "^3.0.0" + +pkg-dir@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" + integrity sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw== + dependencies: + find-up "^3.0.0" + +pkg-dir@^4.1.0, pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + +pkg-dir@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760" + integrity sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA== + dependencies: + find-up "^5.0.0" + +pkg-up@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5" + integrity sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA== + dependencies: + find-up "^3.0.0" + +pngjs@^3.0.0, pngjs@^3.3.3: + version "3.4.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f" + integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w== + +pnp-webpack-plugin@^1.6.4: + version "1.6.4" + resolved "https://registry.yarnpkg.com/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz#c9711ac4dc48a685dabafc86f8b6dd9f8df84149" + integrity sha512-7Wjy+9E3WwLOEL30D+m8TSTF7qJJUJLONBnwQp0518siuMxUQUbgZwssaFX+QKlZkjHZcw/IpZCt/H0srrntSg== + dependencies: + ts-pnp "^1.1.6" + +polished@^3.6.5: + version "3.6.6" + resolved "https://registry.yarnpkg.com/polished/-/polished-3.6.6.tgz#91ef9eface9be5366c07672b63b736f50c151185" + integrity sha512-yiB2ims2DZPem0kCD6V0wnhcVGFEhNh0Iw0axNpKU+oSAgFt6yx6HxIT23Qg0WWvgS379cS35zT4AOyZZRzpQQ== + dependencies: + "@babel/runtime" "^7.9.2" + +portfinder@^1.0.26: + version "1.0.28" + resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" + integrity sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA== + dependencies: + async "^2.6.2" + debug "^3.1.1" + mkdirp "^0.5.5" + +posix-character-classes@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" + integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= + +postcss-attribute-case-insensitive@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-4.0.2.tgz#d93e46b504589e94ac7277b0463226c68041a880" + integrity sha512-clkFxk/9pcdb4Vkn0hAHq3YnxBQ2p0CGD1dy24jN+reBck+EWxMbxSUqN4Yj7t0w8csl87K6p0gxBe1utkJsYA== + dependencies: + postcss "^7.0.2" + postcss-selector-parser "^6.0.2" + +postcss-calc@^7.0.1: + version "7.0.4" + resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-7.0.4.tgz#5e177ddb417341e6d4a193c5d9fd8ada79094f8b" + integrity sha512-0I79VRAd1UTkaHzY9w83P39YGO/M3bG7/tNLrHGEunBolfoGM0hSjrGvjoeaj0JE/zIw5GsI2KZ0UwDJqv5hjw== + dependencies: + postcss "^7.0.27" + postcss-selector-parser "^6.0.2" + postcss-value-parser "^4.0.2" + +postcss-color-functional-notation@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/postcss-color-functional-notation/-/postcss-color-functional-notation-2.0.1.tgz#5efd37a88fbabeb00a2966d1e53d98ced93f74e0" + integrity sha512-ZBARCypjEDofW4P6IdPVTLhDNXPRn8T2s1zHbZidW6rPaaZvcnCS2soYFIQJrMZSxiePJ2XIYTlcb2ztr/eT2g== + dependencies: + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-color-gray@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/postcss-color-gray/-/postcss-color-gray-5.0.0.tgz#532a31eb909f8da898ceffe296fdc1f864be8547" + integrity sha512-q6BuRnAGKM/ZRpfDascZlIZPjvwsRye7UDNalqVz3s7GDxMtqPY6+Q871liNxsonUw8oC61OG+PSaysYpl1bnw== + dependencies: + "@csstools/convert-colors" "^1.4.0" + postcss "^7.0.5" + postcss-values-parser "^2.0.0" + +postcss-color-hex-alpha@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/postcss-color-hex-alpha/-/postcss-color-hex-alpha-5.0.3.tgz#a8d9ca4c39d497c9661e374b9c51899ef0f87388" + integrity sha512-PF4GDel8q3kkreVXKLAGNpHKilXsZ6xuu+mOQMHWHLPNyjiUBOr75sp5ZKJfmv1MCus5/DWUGcK9hm6qHEnXYw== + dependencies: + postcss "^7.0.14" + postcss-values-parser "^2.0.1" + +postcss-color-mod-function@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/postcss-color-mod-function/-/postcss-color-mod-function-3.0.3.tgz#816ba145ac11cc3cb6baa905a75a49f903e4d31d" + integrity sha512-YP4VG+xufxaVtzV6ZmhEtc+/aTXH3d0JLpnYfxqTvwZPbJhWqp8bSY3nfNzNRFLgB4XSaBA82OE4VjOOKpCdVQ== + dependencies: + "@csstools/convert-colors" "^1.4.0" + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-color-rebeccapurple@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-4.0.1.tgz#c7a89be872bb74e45b1e3022bfe5748823e6de77" + integrity sha512-aAe3OhkS6qJXBbqzvZth2Au4V3KieR5sRQ4ptb2b2O8wgvB3SJBsdG+jsn2BZbbwekDG8nTfcCNKcSfe/lEy8g== + dependencies: + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-colormin@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-4.0.3.tgz#ae060bce93ed794ac71264f08132d550956bd381" + integrity sha512-WyQFAdDZpExQh32j0U0feWisZ0dmOtPl44qYmJKkq9xFWY3p+4qnRzCHeNrkeRhwPHz9bQ3mo0/yVkaply0MNw== + dependencies: + browserslist "^4.0.0" + color "^3.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-convert-values@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-4.0.1.tgz#ca3813ed4da0f812f9d43703584e449ebe189a7f" + integrity sha512-Kisdo1y77KUC0Jmn0OXU/COOJbzM8cImvw1ZFsBgBgMgb1iL23Zs/LXRe3r+EZqM3vGYKdQ2YJVQ5VkJI+zEJQ== + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-custom-media@^7.0.8: + version "7.0.8" + resolved "https://registry.yarnpkg.com/postcss-custom-media/-/postcss-custom-media-7.0.8.tgz#fffd13ffeffad73621be5f387076a28b00294e0c" + integrity sha512-c9s5iX0Ge15o00HKbuRuTqNndsJUbaXdiNsksnVH8H4gdc+zbLzr/UasOwNG6CTDpLFekVY4672eWdiiWu2GUg== + dependencies: + postcss "^7.0.14" + +postcss-custom-properties@^8.0.11: + version "8.0.11" + resolved "https://registry.yarnpkg.com/postcss-custom-properties/-/postcss-custom-properties-8.0.11.tgz#2d61772d6e92f22f5e0d52602df8fae46fa30d97" + integrity sha512-nm+o0eLdYqdnJ5abAJeXp4CEU1c1k+eB2yMCvhgzsds/e0umabFrN6HoTy/8Q4K5ilxERdl/JD1LO5ANoYBeMA== + dependencies: + postcss "^7.0.17" + postcss-values-parser "^2.0.1" + +postcss-custom-selectors@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/postcss-custom-selectors/-/postcss-custom-selectors-5.1.2.tgz#64858c6eb2ecff2fb41d0b28c9dd7b3db4de7fba" + integrity sha512-DSGDhqinCqXqlS4R7KGxL1OSycd1lydugJ1ky4iRXPHdBRiozyMHrdu0H3o7qNOCiZwySZTUI5MV0T8QhCLu+w== + dependencies: + postcss "^7.0.2" + postcss-selector-parser "^5.0.0-rc.3" + +postcss-dir-pseudo-class@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-5.0.0.tgz#6e3a4177d0edb3abcc85fdb6fbb1c26dabaeaba2" + integrity sha512-3pm4oq8HYWMZePJY+5ANriPs3P07q+LW6FAdTlkFH2XqDdP4HeeJYMOzn0HYLhRSjBO3fhiqSwwU9xEULSrPgw== + dependencies: + postcss "^7.0.2" + postcss-selector-parser "^5.0.0-rc.3" + +postcss-discard-comments@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-4.0.2.tgz#1fbabd2c246bff6aaad7997b2b0918f4d7af4033" + integrity sha512-RJutN259iuRf3IW7GZyLM5Sw4GLTOH8FmsXBnv8Ab/Tc2k4SR4qbV4DNbyyY4+Sjo362SyDmW2DQ7lBSChrpkg== + dependencies: + postcss "^7.0.0" + +postcss-discard-duplicates@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-4.0.2.tgz#3fe133cd3c82282e550fc9b239176a9207b784eb" + integrity sha512-ZNQfR1gPNAiXZhgENFfEglF93pciw0WxMkJeVmw8eF+JZBbMD7jp6C67GqJAXVZP2BWbOztKfbsdmMp/k8c6oQ== + dependencies: + postcss "^7.0.0" + +postcss-discard-empty@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-4.0.1.tgz#c8c951e9f73ed9428019458444a02ad90bb9f765" + integrity sha512-B9miTzbznhDjTfjvipfHoqbWKwd0Mj+/fL5s1QOz06wufguil+Xheo4XpOnc4NqKYBCNqqEzgPv2aPBIJLox0w== + dependencies: + postcss "^7.0.0" + +postcss-discard-overridden@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-4.0.1.tgz#652aef8a96726f029f5e3e00146ee7a4e755ff57" + integrity sha512-IYY2bEDD7g1XM1IDEsUT4//iEYCxAmP5oDSFMVU/JVvT7gh+l4fmjciLqGgwjdWpQIdb0Che2VX00QObS5+cTg== + dependencies: + postcss "^7.0.0" + +postcss-double-position-gradients@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/postcss-double-position-gradients/-/postcss-double-position-gradients-1.0.0.tgz#fc927d52fddc896cb3a2812ebc5df147e110522e" + integrity sha512-G+nV8EnQq25fOI8CH/B6krEohGWnF5+3A6H/+JEpOncu5dCnkS1QQ6+ct3Jkaepw1NGVqqOZH6lqrm244mCftA== + dependencies: + postcss "^7.0.5" + postcss-values-parser "^2.0.0" + +postcss-env-function@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/postcss-env-function/-/postcss-env-function-2.0.2.tgz#0f3e3d3c57f094a92c2baf4b6241f0b0da5365d7" + integrity sha512-rwac4BuZlITeUbiBq60h/xbLzXY43qOsIErngWa4l7Mt+RaSkT7QBjXVGTcBHupykkblHMDrBFh30zchYPaOUw== + dependencies: + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-focus-visible@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-focus-visible/-/postcss-focus-visible-4.0.0.tgz#477d107113ade6024b14128317ade2bd1e17046e" + integrity sha512-Z5CkWBw0+idJHSV6+Bgf2peDOFf/x4o+vX/pwcNYrWpXFrSfTkQ3JQ1ojrq9yS+upnAlNRHeg8uEwFTgorjI8g== + dependencies: + postcss "^7.0.2" + +postcss-focus-within@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-focus-within/-/postcss-focus-within-3.0.0.tgz#763b8788596cee9b874c999201cdde80659ef680" + integrity sha512-W0APui8jQeBKbCGZudW37EeMCjDeVxKgiYfIIEo8Bdh5SpB9sxds/Iq8SEuzS0Q4YFOlG7EPFulbbxujpkrV2w== + dependencies: + postcss "^7.0.2" + +postcss-font-variant@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-font-variant/-/postcss-font-variant-4.0.0.tgz#71dd3c6c10a0d846c5eda07803439617bbbabacc" + integrity sha512-M8BFYKOvCrI2aITzDad7kWuXXTm0YhGdP9Q8HanmN4EF1Hmcgs1KK5rSHylt/lUJe8yLxiSwWAHdScoEiIxztg== + dependencies: + postcss "^7.0.2" + +postcss-gap-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-gap-properties/-/postcss-gap-properties-2.0.0.tgz#431c192ab3ed96a3c3d09f2ff615960f902c1715" + integrity sha512-QZSqDaMgXCHuHTEzMsS2KfVDOq7ZFiknSpkrPJY6jmxbugUPTuSzs/vuE5I3zv0WAS+3vhrlqhijiprnuQfzmg== + dependencies: + postcss "^7.0.2" + +postcss-image-set-function@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/postcss-image-set-function/-/postcss-image-set-function-3.0.1.tgz#28920a2f29945bed4c3198d7df6496d410d3f288" + integrity sha512-oPTcFFip5LZy8Y/whto91L9xdRHCWEMs3e1MdJxhgt4jy2WYXfhkng59fH5qLXSCPN8k4n94p1Czrfe5IOkKUw== + dependencies: + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-initial@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/postcss-initial/-/postcss-initial-3.0.2.tgz#f018563694b3c16ae8eaabe3c585ac6319637b2d" + integrity sha512-ugA2wKonC0xeNHgirR4D3VWHs2JcU08WAi1KFLVcnb7IN89phID6Qtg2RIctWbnvp1TM2BOmDtX8GGLCKdR8YA== + dependencies: + lodash.template "^4.5.0" + postcss "^7.0.2" + +postcss-lab-function@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/postcss-lab-function/-/postcss-lab-function-2.0.1.tgz#bb51a6856cd12289ab4ae20db1e3821ef13d7d2e" + integrity sha512-whLy1IeZKY+3fYdqQFuDBf8Auw+qFuVnChWjmxm/UhHWqNHZx+B99EwxTvGYmUBqe3Fjxs4L1BoZTJmPu6usVg== + dependencies: + "@csstools/convert-colors" "^1.4.0" + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-load-config@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-2.1.0.tgz#c84d692b7bb7b41ddced94ee62e8ab31b417b003" + integrity sha512-4pV3JJVPLd5+RueiVVB+gFOAa7GWc25XQcMp86Zexzke69mKf6Nx9LRcQywdz7yZI9n1udOxmLuAwTBypypF8Q== + dependencies: + cosmiconfig "^5.0.0" + import-cwd "^2.0.0" + +postcss-loader@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-3.0.0.tgz#6b97943e47c72d845fa9e03f273773d4e8dd6c2d" + integrity sha512-cLWoDEY5OwHcAjDnkyRQzAXfs2jrKjXpO/HQFcc5b5u/r7aa471wdmChmwfnv7x2u840iat/wi0lQ5nbRgSkUA== + dependencies: + loader-utils "^1.1.0" + postcss "^7.0.0" + postcss-load-config "^2.0.0" + schema-utils "^1.0.0" + +postcss-logical@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-logical/-/postcss-logical-3.0.0.tgz#2495d0f8b82e9f262725f75f9401b34e7b45d5b5" + integrity sha512-1SUKdJc2vuMOmeItqGuNaC+N8MzBWFWEkAnRnLpFYj1tGGa7NqyVBujfRtgNa2gXR+6RkGUiB2O5Vmh7E2RmiA== + dependencies: + postcss "^7.0.2" + +postcss-media-minmax@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-media-minmax/-/postcss-media-minmax-4.0.0.tgz#b75bb6cbc217c8ac49433e12f22048814a4f5ed5" + integrity sha512-fo9moya6qyxsjbFAYl97qKO9gyre3qvbMnkOZeZwlsW6XYFsvs2DMGDlchVLfAd8LHPZDxivu/+qW2SMQeTHBw== + dependencies: + postcss "^7.0.2" + +postcss-merge-longhand@^4.0.11: + version "4.0.11" + resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-4.0.11.tgz#62f49a13e4a0ee04e7b98f42bb16062ca2549e24" + integrity sha512-alx/zmoeXvJjp7L4mxEMjh8lxVlDFX1gqWHzaaQewwMZiVhLo42TEClKaeHbRf6J7j82ZOdTJ808RtN0ZOZwvw== + dependencies: + css-color-names "0.0.4" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + stylehacks "^4.0.0" + +postcss-merge-rules@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-4.0.3.tgz#362bea4ff5a1f98e4075a713c6cb25aefef9a650" + integrity sha512-U7e3r1SbvYzO0Jr3UT/zKBVgYYyhAz0aitvGIYOYK5CPmkNih+WDSsS5tvPrJ8YMQYlEMvsZIiqmn7HdFUaeEQ== + dependencies: + browserslist "^4.0.0" + caniuse-api "^3.0.0" + cssnano-util-same-parent "^4.0.0" + postcss "^7.0.0" + postcss-selector-parser "^3.0.0" + vendors "^1.0.0" + +postcss-minify-font-values@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-4.0.2.tgz#cd4c344cce474343fac5d82206ab2cbcb8afd5a6" + integrity sha512-j85oO6OnRU9zPf04+PZv1LYIYOprWm6IA6zkXkrJXyRveDEuQggG6tvoy8ir8ZwjLxLuGfNkCZEQG7zan+Hbtg== + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-minify-gradients@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-4.0.2.tgz#93b29c2ff5099c535eecda56c4aa6e665a663471" + integrity sha512-qKPfwlONdcf/AndP1U8SJ/uzIJtowHlMaSioKzebAXSG4iJthlWC9iSWznQcX4f66gIWX44RSA841HTHj3wK+Q== + dependencies: + cssnano-util-get-arguments "^4.0.0" + is-color-stop "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-minify-params@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-4.0.2.tgz#6b9cef030c11e35261f95f618c90036d680db874" + integrity sha512-G7eWyzEx0xL4/wiBBJxJOz48zAKV2WG3iZOqVhPet/9geefm/Px5uo1fzlHu+DOjT+m0Mmiz3jkQzVHe6wxAWg== + dependencies: + alphanum-sort "^1.0.0" + browserslist "^4.0.0" + cssnano-util-get-arguments "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + uniqs "^2.0.0" + +postcss-minify-selectors@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-4.0.2.tgz#e2e5eb40bfee500d0cd9243500f5f8ea4262fbd8" + integrity sha512-D5S1iViljXBj9kflQo4YutWnJmwm8VvIsU1GeXJGiG9j8CIg9zs4voPMdQDUmIxetUOh60VilsNzCiAFTOqu3g== + dependencies: + alphanum-sort "^1.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-selector-parser "^3.0.0" + +postcss-modules-extract-imports@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz#818719a1ae1da325f9832446b01136eeb493cd7e" + integrity sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ== + dependencies: + postcss "^7.0.5" + +postcss-modules-local-by-default@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.3.tgz#bb14e0cc78279d504dbdcbfd7e0ca28993ffbbb0" + integrity sha512-e3xDq+LotiGesympRlKNgaJ0PCzoUIdpH0dj47iWAui/kyTgh3CiAr1qP54uodmJhl6p9rN6BoNcdEDVJx9RDw== + dependencies: + icss-utils "^4.1.1" + postcss "^7.0.32" + postcss-selector-parser "^6.0.2" + postcss-value-parser "^4.1.0" + +postcss-modules-scope@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz#385cae013cc7743f5a7d7602d1073a89eaae62ee" + integrity sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ== + dependencies: + postcss "^7.0.6" + postcss-selector-parser "^6.0.0" + +postcss-modules-values@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz#5b5000d6ebae29b4255301b4a3a54574423e7f10" + integrity sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg== + dependencies: + icss-utils "^4.0.0" + postcss "^7.0.6" + +postcss-nesting@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/postcss-nesting/-/postcss-nesting-7.0.1.tgz#b50ad7b7f0173e5b5e3880c3501344703e04c052" + integrity sha512-FrorPb0H3nuVq0Sff7W2rnc3SmIcruVC6YwpcS+k687VxyxO33iE1amna7wHuRVzM8vfiYofXSBHNAZ3QhLvYg== + dependencies: + postcss "^7.0.2" + +postcss-normalize-charset@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-4.0.1.tgz#8b35add3aee83a136b0471e0d59be58a50285dd4" + integrity sha512-gMXCrrlWh6G27U0hF3vNvR3w8I1s2wOBILvA87iNXaPvSNo5uZAMYsZG7XjCUf1eVxuPfyL4TJ7++SGZLc9A3g== + dependencies: + postcss "^7.0.0" + +postcss-normalize-display-values@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-display-values/-/postcss-normalize-display-values-4.0.2.tgz#0dbe04a4ce9063d4667ed2be476bb830c825935a" + integrity sha512-3F2jcsaMW7+VtRMAqf/3m4cPFhPD3EFRgNs18u+k3lTJJlVe7d0YPO+bnwqo2xg8YiRpDXJI2u8A0wqJxMsQuQ== + dependencies: + cssnano-util-get-match "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-positions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-positions/-/postcss-normalize-positions-4.0.2.tgz#05f757f84f260437378368a91f8932d4b102917f" + integrity sha512-Dlf3/9AxpxE+NF1fJxYDeggi5WwV35MXGFnnoccP/9qDtFrTArZ0D0R+iKcg5WsUd8nUYMIl8yXDCtcrT8JrdA== + dependencies: + cssnano-util-get-arguments "^4.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-repeat-style@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-4.0.2.tgz#c4ebbc289f3991a028d44751cbdd11918b17910c" + integrity sha512-qvigdYYMpSuoFs3Is/f5nHdRLJN/ITA7huIoCyqqENJe9PvPmLhNLMu7QTjPdtnVf6OcYYO5SHonx4+fbJE1+Q== + dependencies: + cssnano-util-get-arguments "^4.0.0" + cssnano-util-get-match "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-string@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-string/-/postcss-normalize-string-4.0.2.tgz#cd44c40ab07a0c7a36dc5e99aace1eca4ec2690c" + integrity sha512-RrERod97Dnwqq49WNz8qo66ps0swYZDSb6rM57kN2J+aoyEAJfZ6bMx0sx/F9TIEX0xthPGCmeyiam/jXif0eA== + dependencies: + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-timing-functions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-4.0.2.tgz#8e009ca2a3949cdaf8ad23e6b6ab99cb5e7d28d9" + integrity sha512-acwJY95edP762e++00Ehq9L4sZCEcOPyaHwoaFOhIwWCDfik6YvqsYNxckee65JHLKzuNSSmAdxwD2Cud1Z54A== + dependencies: + cssnano-util-get-match "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-unicode@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-unicode/-/postcss-normalize-unicode-4.0.1.tgz#841bd48fdcf3019ad4baa7493a3d363b52ae1cfb" + integrity sha512-od18Uq2wCYn+vZ/qCOeutvHjB5jm57ToxRaMeNuf0nWVHaP9Hua56QyMF6fs/4FSUnVIw0CBPsU0K4LnBPwYwg== + dependencies: + browserslist "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-url@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-4.0.1.tgz#10e437f86bc7c7e58f7b9652ed878daaa95faae1" + integrity sha512-p5oVaF4+IHwu7VpMan/SSpmpYxcJMtkGppYf0VbdH5B6hN8YNmVyJLuY9FmLQTzY3fag5ESUUHDqM+heid0UVA== + dependencies: + is-absolute-url "^2.0.0" + normalize-url "^3.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-whitespace@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-whitespace/-/postcss-normalize-whitespace-4.0.2.tgz#bf1d4070fe4fcea87d1348e825d8cc0c5faa7d82" + integrity sha512-tO8QIgrsI3p95r8fyqKV+ufKlSHh9hMJqACqbv2XknufqEDhDvbguXGBBqxw9nsQoXWf0qOqppziKJKHMD4GtA== + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-ordered-values@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-4.1.2.tgz#0cf75c820ec7d5c4d280189559e0b571ebac0eee" + integrity sha512-2fCObh5UanxvSxeXrtLtlwVThBvHn6MQcu4ksNT2tsaV2Fg76R2CV98W7wNSlX+5/pFwEyaDwKLLoEV7uRybAw== + dependencies: + cssnano-util-get-arguments "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-overflow-shorthand@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-overflow-shorthand/-/postcss-overflow-shorthand-2.0.0.tgz#31ecf350e9c6f6ddc250a78f0c3e111f32dd4c30" + integrity sha512-aK0fHc9CBNx8jbzMYhshZcEv8LtYnBIRYQD5i7w/K/wS9c2+0NSR6B3OVMu5y0hBHYLcMGjfU+dmWYNKH0I85g== + dependencies: + postcss "^7.0.2" + +postcss-page-break@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-page-break/-/postcss-page-break-2.0.0.tgz#add52d0e0a528cabe6afee8b46e2abb277df46bf" + integrity sha512-tkpTSrLpfLfD9HvgOlJuigLuk39wVTbbd8RKcy8/ugV2bNBUW3xU+AIqyxhDrQr1VUj1RmyJrBn1YWrqUm9zAQ== + dependencies: + postcss "^7.0.2" + +postcss-place@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-place/-/postcss-place-4.0.1.tgz#e9f39d33d2dc584e46ee1db45adb77ca9d1dcc62" + integrity sha512-Zb6byCSLkgRKLODj/5mQugyuj9bvAAw9LqJJjgwz5cYryGeXfFZfSXoP1UfveccFmeq0b/2xxwcTEVScnqGxBg== + dependencies: + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-preset-env@^6.7.0: + version "6.7.0" + resolved "https://registry.yarnpkg.com/postcss-preset-env/-/postcss-preset-env-6.7.0.tgz#c34ddacf8f902383b35ad1e030f178f4cdf118a5" + integrity sha512-eU4/K5xzSFwUFJ8hTdTQzo2RBLbDVt83QZrAvI07TULOkmyQlnYlpwep+2yIK+K+0KlZO4BvFcleOCCcUtwchg== + dependencies: + autoprefixer "^9.6.1" + browserslist "^4.6.4" + caniuse-lite "^1.0.30000981" + css-blank-pseudo "^0.1.4" + css-has-pseudo "^0.10.0" + css-prefers-color-scheme "^3.1.1" + cssdb "^4.4.0" + postcss "^7.0.17" + postcss-attribute-case-insensitive "^4.0.1" + postcss-color-functional-notation "^2.0.1" + postcss-color-gray "^5.0.0" + postcss-color-hex-alpha "^5.0.3" + postcss-color-mod-function "^3.0.3" + postcss-color-rebeccapurple "^4.0.1" + postcss-custom-media "^7.0.8" + postcss-custom-properties "^8.0.11" + postcss-custom-selectors "^5.1.2" + postcss-dir-pseudo-class "^5.0.0" + postcss-double-position-gradients "^1.0.0" + postcss-env-function "^2.0.2" + postcss-focus-visible "^4.0.0" + postcss-focus-within "^3.0.0" + postcss-font-variant "^4.0.0" + postcss-gap-properties "^2.0.0" + postcss-image-set-function "^3.0.1" + postcss-initial "^3.0.0" + postcss-lab-function "^2.0.1" + postcss-logical "^3.0.0" + postcss-media-minmax "^4.0.0" + postcss-nesting "^7.0.0" + postcss-overflow-shorthand "^2.0.0" + postcss-page-break "^2.0.0" + postcss-place "^4.0.1" + postcss-pseudo-class-any-link "^6.0.0" + postcss-replace-overflow-wrap "^3.0.0" + postcss-selector-matches "^4.0.0" + postcss-selector-not "^4.0.0" + +postcss-pseudo-any@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/postcss-pseudo-any/-/postcss-pseudo-any-1.0.1.tgz#3cc28022b6edef3358cea5751697a7ceb45dc4c3" + integrity sha512-6uNSqSaQSAbUpcpl+XcaTqEWurD9DDY4MJU4uTccN2dSovDqvuVd9OR77z+BYFoah2ne5tOHUDzbj6uWZnV70w== + dependencies: + postcss "^7.0.18" + +postcss-pseudo-class-any-link@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-6.0.0.tgz#2ed3eed393b3702879dec4a87032b210daeb04d1" + integrity sha512-lgXW9sYJdLqtmw23otOzrtbDXofUdfYzNm4PIpNE322/swES3VU9XlXHeJS46zT2onFO7V1QFdD4Q9LiZj8mew== + dependencies: + postcss "^7.0.2" + postcss-selector-parser "^5.0.0-rc.3" + +postcss-reduce-initial@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-4.0.3.tgz#7fd42ebea5e9c814609639e2c2e84ae270ba48df" + integrity sha512-gKWmR5aUulSjbzOfD9AlJiHCGH6AEVLaM0AV+aSioxUDd16qXP1PCh8d1/BGVvpdWn8k/HiK7n6TjeoXN1F7DA== + dependencies: + browserslist "^4.0.0" + caniuse-api "^3.0.0" + has "^1.0.0" + postcss "^7.0.0" + +postcss-reduce-transforms@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-4.0.2.tgz#17efa405eacc6e07be3414a5ca2d1074681d4e29" + integrity sha512-EEVig1Q2QJ4ELpJXMZR8Vt5DQx8/mo+dGWSR7vWXqcob2gQLyQGsionYcGKATXvQzMPn6DSN1vTN7yFximdIAg== + dependencies: + cssnano-util-get-match "^4.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-replace-overflow-wrap@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-3.0.0.tgz#61b360ffdaedca84c7c918d2b0f0d0ea559ab01c" + integrity sha512-2T5hcEHArDT6X9+9dVSPQdo7QHzG4XKclFT8rU5TzJPDN7RIRTbO9c4drUISOVemLj03aezStHCR2AIcr8XLpw== + dependencies: + postcss "^7.0.2" + +postcss-selector-matches@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-selector-matches/-/postcss-selector-matches-4.0.0.tgz#71c8248f917ba2cc93037c9637ee09c64436fcff" + integrity sha512-LgsHwQR/EsRYSqlwdGzeaPKVT0Ml7LAT6E75T8W8xLJY62CE4S/l03BWIt3jT8Taq22kXP08s2SfTSzaraoPww== + dependencies: + balanced-match "^1.0.0" + postcss "^7.0.2" + +postcss-selector-not@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-selector-not/-/postcss-selector-not-4.0.0.tgz#c68ff7ba96527499e832724a2674d65603b645c0" + integrity sha512-W+bkBZRhqJaYN8XAnbbZPLWMvZD1wKTu0UxtFKdhtGjWYmxhkUneoeOhRJKdAE5V7ZTlnbHfCR+6bNwK9e1dTQ== + dependencies: + balanced-match "^1.0.0" + postcss "^7.0.2" + +postcss-selector-parser@^3.0.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-3.1.2.tgz#b310f5c4c0fdaf76f94902bbaa30db6aa84f5270" + integrity sha512-h7fJ/5uWuRVyOtkO45pnt1Ih40CEleeyCHzipqAZO2e5H20g25Y48uYnFUiShvY4rZWNJ/Bib/KVPmanaCtOhA== + dependencies: + dot-prop "^5.2.0" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss-selector-parser@^5.0.0-rc.3, postcss-selector-parser@^5.0.0-rc.4: + version "5.0.0" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-5.0.0.tgz#249044356697b33b64f1a8f7c80922dddee7195c" + integrity sha512-w+zLE5Jhg6Liz8+rQOWEAwtwkyqpfnmsinXjXg6cY7YIONZZtgvE0v2O0uhQBs0peNomOJwWRKt6JBfTdTd3OQ== + dependencies: + cssesc "^2.0.0" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz#934cf799d016c83411859e09dcecade01286ec5c" + integrity sha512-36P2QR59jDTOAiIkqEprfJDsoNrvwFei3eCqKd1Y0tUsBimsq39BLp7RD+JWny3WgB1zGhJX8XVePwm9k4wdBg== + dependencies: + cssesc "^3.0.0" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss-svgo@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-4.0.2.tgz#17b997bc711b333bab143aaed3b8d3d6e3d38258" + integrity sha512-C6wyjo3VwFm0QgBy+Fu7gCYOkCmgmClghO+pjcxvrcBKtiKt0uCF+hvbMO1fyv5BMImRK90SMb+dwUnfbGd+jw== + dependencies: + is-svg "^3.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + svgo "^1.0.0" + +postcss-unique-selectors@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-4.0.1.tgz#9446911f3289bfd64c6d680f073c03b1f9ee4bac" + integrity sha512-+JanVaryLo9QwZjKrmJgkI4Fn8SBgRO6WXQBJi7KiAVPlmxikB5Jzc4EvXMT2H0/m0RjrVVm9rGNhZddm/8Spg== + dependencies: + alphanum-sort "^1.0.0" + postcss "^7.0.0" + uniqs "^2.0.0" + +postcss-value-parser@^3.0.0, postcss-value-parser@^3.3.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" + integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== + +postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb" + integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== + +postcss-values-parser@^2.0.0, postcss-values-parser@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/postcss-values-parser/-/postcss-values-parser-2.0.1.tgz#da8b472d901da1e205b47bdc98637b9e9e550e5f" + integrity sha512-2tLuBsA6P4rYTNKCXYG/71C7j1pU6pK503suYOmn4xYrQIzW+opD+7FAFNuGSdZC/3Qfy334QbeMu7MEb8gOxg== + dependencies: + flatten "^1.0.2" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.17, postcss@^7.0.18, postcss@^7.0.2, postcss@^7.0.27, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.6: + version "7.0.32" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.32.tgz#4310d6ee347053da3433db2be492883d62cec59d" + integrity sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw== + dependencies: + chalk "^2.4.2" + source-map "^0.6.1" + supports-color "^6.1.0" + +postcss@^8.1.7: + version "8.3.5" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.5.tgz#982216b113412bc20a86289e91eb994952a5b709" + integrity sha512-NxTuJocUhYGsMiMFHDUkmjSKT3EdH4/WbGF6GCi1NDGk+vbcUTun4fpbOqaPtD8IIsztA2ilZm2DhYCuyN58gA== + dependencies: + colorette "^1.2.2" + nanoid "^3.1.23" + source-map-js "^0.6.2" + +prebuild-install@^5.3.4: + version "5.3.5" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-5.3.5.tgz#e7e71e425298785ea9d22d4f958dbaccf8bb0e1b" + integrity sha512-YmMO7dph9CYKi5IR/BzjOJlRzpxGGVo1EsLSUZ0mt/Mq0HWZIHOKHHcHdT69yG54C9m6i45GpItwRHpk0Py7Uw== + dependencies: + detect-libc "^1.0.3" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp "^0.5.1" + napi-build-utils "^1.0.1" + node-abi "^2.7.0" + noop-logger "^0.1.1" + npmlog "^4.0.1" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^3.0.3" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + which-pm-runs "^1.0.0" + +precinct@^8.0.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/precinct/-/precinct-8.1.0.tgz#6b8f2389ba2ca61c466731390b0d7e25da3fd996" + integrity sha512-oeZBR9IdER42Ef6Rz11z1oOUqicsI5J1Qffj6tYghKLhxN2UnHy7uE1axxNr0VZRevPK2HWkROk36uXrbJwHFA== + dependencies: + commander "^2.20.3" + debug "^4.3.1" + detective-amd "^3.0.1" + detective-cjs "^3.1.1" + detective-es6 "^2.2.0" + detective-less "^1.0.2" + detective-postcss "^4.0.0" + detective-sass "^3.0.1" + detective-scss "^2.0.1" + detective-stylus "^1.0.0" + detective-typescript "^7.0.0" + module-definition "^3.3.1" + node-source-walk "^4.2.0" + +precond@0.2: + version "0.2.3" + resolved "https://registry.yarnpkg.com/precond/-/precond-0.2.3.tgz#aa9591bcaa24923f1e0f4849d240f47efc1075ac" + integrity sha1-qpWRvKokkj8eD0hJ0kD0fvwQdaw= + +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= + +prepend-http@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" + integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= + +prepend-http@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" + integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= + +pretty-error@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.1.tgz#5f4f87c8f91e5ae3f3ba87ab4cf5e03b1a17f1a3" + integrity sha1-X0+HyPkeWuPzuoerTPXgOxoX8aM= + dependencies: + renderkid "^2.0.1" + utila "~0.4" + +pretty-format@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.9.0.tgz#12fac31b37019a4eea3c11aa9a959eb7628aa7c9" + integrity sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA== + dependencies: + "@jest/types" "^24.9.0" + ansi-regex "^4.0.0" + ansi-styles "^3.2.0" + react-is "^16.8.4" + +pretty-format@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-25.5.0.tgz#7873c1d774f682c34b8d48b6743a2bf2ac55791a" + integrity sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ== + dependencies: + "@jest/types" "^25.5.0" + ansi-regex "^5.0.0" + ansi-styles "^4.0.0" + react-is "^16.12.0" + +pretty-ms@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-5.1.0.tgz#b906bdd1ec9e9799995c372e2b1c34f073f95384" + integrity sha512-4gaK1skD2gwscCfkswYQRmddUb2GJZtzDGRjHWadVHtK/DIKFufa12MvES6/xu1tVbUYeia5bmLcwJtZJQUqnw== + dependencies: + parse-ms "^2.1.0" + +pretty-time@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/pretty-time/-/pretty-time-1.1.0.tgz#ffb7429afabb8535c346a34e41873adf3d74dd0e" + integrity sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA== + +prettyjson@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prettyjson/-/prettyjson-1.2.1.tgz#fcffab41d19cab4dfae5e575e64246619b12d289" + integrity sha1-/P+rQdGcq0365eV15kJGYZsS0ok= + dependencies: + colors "^1.1.2" + minimist "^1.2.0" + +printj@~1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/printj/-/printj-1.1.2.tgz#d90deb2975a8b9f600fb3a1c94e3f4c53c78a222" + integrity sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ== + +prism-react-renderer@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-1.1.1.tgz#1c1be61b1eb9446a146ca7a50b7bcf36f2a70a44" + integrity sha512-MgMhSdHuHymNRqD6KM3eGS0PNqgK9q4QF5P0yoQQvpB6jNjeSAi3jcSAz0Sua/t9fa4xDOMar9HJbLa08gl9ug== + +prismjs@^1.20.0: + version "1.23.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.23.0.tgz#d3b3967f7d72440690497652a9d40ff046067f33" + integrity sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA== + optionalDependencies: + clipboard "^2.0.0" + +process-es6@^0.11.6: + version "0.11.6" + resolved "https://registry.yarnpkg.com/process-es6/-/process-es6-0.11.6.tgz#c6bb389f9a951f82bd4eb169600105bd2ff9c778" + integrity sha1-xrs4n5qVH4K9TrFpYAEFvS/5x3g= + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= + +process@~0.5.1: + version "0.5.2" + resolved "https://registry.yarnpkg.com/process/-/process-0.5.2.tgz#1638d8a8e34c2f440a91db95ab9aeb677fc185cf" + integrity sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8= + +promise-inflight@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" + integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= + +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== + dependencies: + asap "~2.0.3" + +prompts@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.0.tgz#4aa5de0723a231d1ee9121c40fdf663df73f61d7" + integrity sha512-awZAKrk3vN6CroQukBL+R9051a4R3zCZBlJm/HBfrSZ8iTpYix3VX1vU4mveiLpiwmOJT4wokTF9m6HUk4KqWQ== + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.5" + +prop-types@^15.0.0, prop-types@^15.5.0, prop-types@^15.5.4, prop-types@^15.6.2, prop-types@^15.7.2: + version "15.7.2" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" + integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.8.1" + +property-information@^5.0.0, property-information@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.5.0.tgz#4dc075d493061a82e2b7d096f406e076ed859943" + integrity sha512-RgEbCx2HLa1chNgvChcx+rrCWD0ctBmGSE0M7lVm1yyv4UbvbrWoXp/BkVLZefzjrRBGW8/Js6uh/BnlHXFyjA== + dependencies: + xtend "^4.0.0" + +propose@0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/propose/-/propose-0.0.5.tgz#48a065d9ec7d4c8667f4050b15c4a2d85dbca56b" + integrity sha1-SKBl2ex9TIZn9AULFcSi2F28pWs= + dependencies: + levenshtein-edit-distance "^1.0.0" + +proxy-addr@~2.0.5: + version "2.0.6" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf" + integrity sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw== + dependencies: + forwarded "~0.1.2" + ipaddr.js "1.9.1" + +prr@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" + integrity sha1-0/wRS6BplaRexok/SEzrHXj19HY= + +ps-list@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/ps-list/-/ps-list-6.3.0.tgz#a2b775c2db7d547a28fbaa3a05e4c281771259be" + integrity sha512-qau0czUSB0fzSlBOQt0bo+I2v6R+xiQdj78e1BR/Qjfl5OHWJ/urXi8+ilw1eHe+5hSeDI1wrwVTgDp2wst4oA== + +public-encrypt@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0" + integrity sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q== + dependencies: + bn.js "^4.1.0" + browserify-rsa "^4.0.0" + create-hash "^1.1.0" + parse-asn1 "^5.0.0" + randombytes "^2.0.1" + safe-buffer "^5.1.2" + +public-ip@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/public-ip/-/public-ip-4.0.2.tgz#83158edd7665da6d51138ccdfa0413f0936fb7ff" + integrity sha512-ZHqUjaYT/+FuSiy5/o2gBxvj0PF7M3MXGnaLJBsJNMCyXI4jzuXXHJKrk0gDxx1apiF/jYsBwjTQOM9V8G6oCQ== + dependencies: + dns-socket "^4.2.1" + got "^9.6.0" + is-ip "^3.1.0" + +pump@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/pump/-/pump-1.0.3.tgz#5dfe8311c33bbf6fc18261f9f34702c47c08a954" + integrity sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pump@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909" + integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pumpify@^1.3.3: + version "1.5.1" + resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce" + integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ== + dependencies: + duplexify "^3.6.0" + inherits "^2.0.3" + pump "^2.0.0" + +punycode@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" + integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= + +punycode@^1.2.4, punycode@^1.3.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= + +punycode@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== + +pupa@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.0.1.tgz#dbdc9ff48ffbea4a26a069b6f9f7abb051008726" + integrity sha512-hEJH0s8PXLY/cdXh66tNEQGndDrIKNqNC5xmrysZy3i5C3oEoLna7YAOad+7u125+zH1HNXUmGEkrhb3c2VriA== + dependencies: + escape-goat "^2.0.0" + +pupa@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62" + integrity sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A== + dependencies: + escape-goat "^2.0.0" + +pure-color@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/pure-color/-/pure-color-1.3.0.tgz#1fe064fb0ac851f0de61320a8bf796836422f33e" + integrity sha1-H+Bk+wrIUfDeYTIKi/eWg2Qi8z4= + +q@^1.1.2: + version "1.5.1" + resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" + integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= + +qs@6.7.0: + version "6.7.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" + integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== + +qs@^6.9.6: + version "6.10.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a" + integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg== + dependencies: + side-channel "^1.0.4" + +query-string@^4.1.0: + version "4.3.4" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb" + integrity sha1-u7aTucqRXCMlFbIosaArYJBD2+s= + dependencies: + object-assign "^4.1.0" + strict-uri-encode "^1.0.0" + +query-string@^5.0.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-5.1.1.tgz#a78c012b71c17e05f2e3fa2319dd330682efb3cb" + integrity sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw== + dependencies: + decode-uri-component "^0.2.0" + object-assign "^4.1.0" + strict-uri-encode "^1.0.0" + +querystring-es3@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" + integrity sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM= + +querystring@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= + +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + +random-bytes@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" + integrity sha1-T2ih3Arli9P7lYSMMDJNt11kNgs= + +randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +randomfill@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/randomfill/-/randomfill-1.0.4.tgz#c92196fc86ab42be983f1bf31778224931d61458" + integrity sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw== + dependencies: + randombytes "^2.0.5" + safe-buffer "^5.1.0" + +range-parser@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" + integrity sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4= + +range-parser@^1.2.1, range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332" + integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q== + dependencies: + bytes "3.1.0" + http-errors "1.7.2" + iconv-lite "0.4.24" + unpipe "1.0.0" + +raw-body@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c" + integrity sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA== + dependencies: + bytes "3.1.0" + http-errors "1.7.3" + iconv-lite "0.4.24" + unpipe "1.0.0" + +rc@^1.2.7, rc@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +react-base16-styling@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/react-base16-styling/-/react-base16-styling-0.6.0.tgz#ef2156d66cf4139695c8a167886cb69ea660792c" + integrity sha1-7yFW1mz0E5aVyKFniGy2nqZgeSw= + dependencies: + base16 "^1.0.0" + lodash.curry "^4.0.1" + lodash.flow "^3.3.0" + pure-color "^1.2.0" + +react-dev-utils@^10.2.1, react-dev-utils@^11.0: + version "11.0.1" + resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-11.0.1.tgz#30106c2055acfd6b047d2dc478a85c356e66fe45" + integrity sha512-rlgpCupaW6qQqvu0hvv2FDv40QG427fjghV56XyPcP5aKtOAPzNAhQ7bHqk1YdS2vpW1W7aSV3JobedxuPlBAA== + dependencies: + "@babel/code-frame" "7.10.4" + address "1.1.2" + browserslist "4.14.2" + chalk "2.4.2" + cross-spawn "7.0.3" + detect-port-alt "1.1.6" + escape-string-regexp "2.0.0" + filesize "6.1.0" + find-up "4.1.0" + fork-ts-checker-webpack-plugin "4.1.6" + global-modules "2.0.0" + globby "11.0.1" + gzip-size "5.1.1" + immer "7.0.9" + is-root "2.1.0" + loader-utils "2.0.0" + open "^7.0.2" + pkg-up "3.1.0" + prompts "2.4.0" + react-error-overlay "^6.0.8" + recursive-readdir "2.2.2" + shell-quote "1.7.2" + strip-ansi "6.0.0" + text-table "0.2.0" + +react-dom@^16.8.4: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f" + integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + scheduler "^0.19.1" + +react-error-overlay@^6.0.8: + version "6.0.8" + resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.8.tgz#474ed11d04fc6bda3af643447d85e9127ed6b5de" + integrity sha512-HvPuUQnLp5H7TouGq3kzBeioJmXms1wHy9EGjz2OURWBp4qZO6AfGEcnxts1D/CbwPLRAgTMPCEgYhA3sEM4vw== + +react-fast-compare@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" + integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== + +react-helmet@^6.0.0-beta: + version "6.1.0" + resolved "https://registry.yarnpkg.com/react-helmet/-/react-helmet-6.1.0.tgz#a750d5165cb13cf213e44747502652e794468726" + integrity sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw== + dependencies: + object-assign "^4.1.1" + prop-types "^15.7.2" + react-fast-compare "^3.1.1" + react-side-effect "^2.1.0" + +react-is@^16.12.0, react-is@^16.6.0, react-is@^16.6.3, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + +react-json-view@^1.19.1: + version "1.21.3" + resolved "https://registry.yarnpkg.com/react-json-view/-/react-json-view-1.21.3.tgz#f184209ee8f1bf374fb0c41b0813cff54549c475" + integrity sha512-13p8IREj9/x/Ye4WI/JpjhoIwuzEgUAtgJZNBJckfzJt1qyh24BdTm6UQNGnyTq9dapQdrqvquZTo3dz1X6Cjw== + dependencies: + flux "^4.0.1" + react-base16-styling "^0.6.0" + react-lifecycles-compat "^3.0.4" + react-textarea-autosize "^8.3.2" + +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== + +react-loadable-ssr-addon@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/react-loadable-ssr-addon/-/react-loadable-ssr-addon-0.3.0.tgz#ae9b2d3b11721930f8d8255476d288c0e9f9290f" + integrity sha512-E+lnmDakV0k6ut6R2J77vurwCOwTKEwKlHs9S62G8ez+ujecLPcqjt3YAU8M58kIGjp2QjFlZ7F9QWkq/mr6Iw== + dependencies: + "@babel/runtime" "^7.10.3" + +react-loadable@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/react-loadable/-/react-loadable-5.5.0.tgz#582251679d3da86c32aae2c8e689c59f1196d8c4" + integrity sha512-C8Aui0ZpMd4KokxRdVAm2bQtI03k2RMRNzOB+IipV3yxFTSVICv7WoUr5L9ALB5BmKO1iHgZtWM8EvYG83otdg== + dependencies: + prop-types "^15.5.0" + +react-promise@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/react-promise/-/react-promise-3.0.2.tgz#1180f9e9d2e1a5362d005688c002366b4e9f2b67" + integrity sha512-Ez2aFel11b08H2HAWNnKf0GDV5ATGBmxK9UXHXxoKwCEoQey9manXDTwB2n3mhgOvMRzGH/YTHACdqQUjXf6Rw== + +react-router-config@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988" + integrity sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg== + dependencies: + "@babel/runtime" "^7.1.2" + +react-router-dom@^5.1.2, react-router-dom@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662" + integrity sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA== + dependencies: + "@babel/runtime" "^7.1.2" + history "^4.9.0" + loose-envify "^1.3.1" + prop-types "^15.6.2" + react-router "5.2.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + +react-router@5.2.0, react-router@^5.1.2: + version "5.2.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293" + integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw== + dependencies: + "@babel/runtime" "^7.1.2" + history "^4.9.0" + hoist-non-react-statics "^3.1.0" + loose-envify "^1.3.1" + mini-create-react-context "^0.4.0" + path-to-regexp "^1.7.0" + prop-types "^15.6.2" + react-is "^16.6.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + +react-side-effect@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.0.tgz#1ce4a8b4445168c487ed24dab886421f74d380d3" + integrity sha512-IgmcegOSi5SNX+2Snh1vqmF0Vg/CbkycU9XZbOHJlZ6kMzTmi3yc254oB1WCkgA7OQtIAoLmcSFuHTc/tlcqXg== + +react-tabs@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/react-tabs/-/react-tabs-3.1.1.tgz#b363a239f76046bb2158875a1e5921b11064052f" + integrity sha512-HpySC29NN1BkzBAnOC+ajfzPbTaVZcSWzMSjk56uAhPC/rBGtli8lTysR4CfPAyEE/hfweIzagOIoJ7nu80yng== + dependencies: + clsx "^1.1.0" + prop-types "^15.5.0" + +react-textarea-autosize@^8.3.2: + version "8.3.3" + resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.3.tgz#f70913945369da453fd554c168f6baacd1fa04d8" + integrity sha512-2XlHXK2TDxS6vbQaoPbMOfQ8GK7+irc2fVK6QFIcC8GOnH3zI/v481n+j1L0WaPVvKxwesnY93fEfH++sus2rQ== + dependencies: + "@babel/runtime" "^7.10.2" + use-composed-ref "^1.0.0" + use-latest "^1.0.0" + +react-toggle@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/react-toggle/-/react-toggle-4.1.1.tgz#2317f67bf918ea3508a96b09dd383efd9da572af" + integrity sha512-+wXlMcSpg8SmnIXauMaZiKpR+r2wp2gMUteroejp2UTSqGTVvZLN+m9EhMzFARBKEw7KpQOwzCyfzeHeAndQGw== + dependencies: + classnames "^2.2.5" + +react-waypoint@^9.0.2: + version "9.0.3" + resolved "https://registry.yarnpkg.com/react-waypoint/-/react-waypoint-9.0.3.tgz#176aa4686b33eb40d0d48d361c468f0367167958" + integrity sha512-NRmyjW8CUBNNl4WpvBqLDgBs18rFUsixeHVHrRrFlWTdOlWP7eiDjptqlR/cJAPLD6RwP5XFCm3bi9OiofN3nA== + dependencies: + consolidated-events "^1.1.0 || ^2.0.0" + prop-types "^15.0.0" + react-is "^16.6.3" + +react@^16.8.4: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" + integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + +read-package-json-fast@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/read-package-json-fast/-/read-package-json-fast-2.0.2.tgz#2dcb24d9e8dd50fb322042c8c35a954e6cc7ac9e" + integrity sha512-5fyFUyO9B799foVk4n6ylcoAktG/FbE3jwRKxvwaeSrIunaoMc0u81dzXxjeAFKOce7O5KncdfwpGvvs6r5PsQ== + dependencies: + json-parse-even-better-errors "^2.3.0" + npm-normalize-package-bin "^1.0.1" + +read-pkg-up@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" + integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg== + dependencies: + find-up "^4.1.0" + read-pkg "^5.2.0" + type-fest "^0.8.1" + +read-pkg@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc" + integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg== + dependencies: + "@types/normalize-package-data" "^2.4.0" + normalize-package-data "^2.5.0" + parse-json "^5.0.0" + type-fest "^0.6.0" + +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.0, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@^2.3.7, readable-stream@~2.3.6: + version "2.3.7" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.0.2, readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readdir-glob@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/readdir-glob/-/readdir-glob-1.1.1.tgz#f0e10bb7bf7bfa7e0add8baffdc54c3f7dbee6c4" + integrity sha512-91/k1EzZwDx6HbERR+zucygRFfiPl2zkIYZtv3Jjr6Mn7SkKcVct8aVO+sSRiGMc6fLf72du3d92/uY63YPdEA== + dependencies: + minimatch "^3.0.4" + +readdirp@^2.0.0, readdirp@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" + integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ== + dependencies: + graceful-fs "^4.1.11" + micromatch "^3.1.10" + readable-stream "^2.0.2" + +readdirp@^3.4.0, readdirp@~3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada" + integrity sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ== + dependencies: + picomatch "^2.2.1" + +rechoir@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" + integrity sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q= + dependencies: + resolve "^1.1.6" + +recursive-readdir@2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.2.tgz#9946fb3274e1628de6e36b2f6714953b4845094f" + integrity sha512-nRCcW9Sj7NuZwa2XvH9co8NPeXUBhZP7CRKJtU+cS6PW9FpCIFoI5ib0NT1ZrbNuPoRy0ylyCaUL8Gih4LSyFg== + dependencies: + minimatch "3.0.4" + +redeyed@~2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/redeyed/-/redeyed-2.1.1.tgz#8984b5815d99cb220469c99eeeffe38913e6cc0b" + integrity sha1-iYS1gV2ZyyIEacme7v/jiRPmzAs= + dependencies: + esprima "~4.0.0" + +redoc@^2.0.0-rc.31: + version "2.0.0-rc.40" + resolved "https://registry.yarnpkg.com/redoc/-/redoc-2.0.0-rc.40.tgz#4dd7d095265bd2af464069e684863a5b17f7a507" + integrity sha512-f1na5vWCr37R5+G4xhbD1TjH6j6b/he8nEMGGJOwDcIMMcDK88S0YEWuhplhdVuZdc4c61CoxZqGXqcDRp5m0w== + dependencies: + "@redocly/react-dropdown-aria" "^2.0.11" + "@types/node" "^13.11.1" + classnames "^2.2.6" + decko "^1.2.0" + dompurify "^2.0.12" + eventemitter3 "^4.0.4" + json-pointer "^0.6.0" + json-schema-ref-parser "^6.1.0" + lunr "2.3.8" + mark.js "^8.11.1" + marked "^0.7.0" + memoize-one "~5.1.1" + mobx-react "^6.2.2" + openapi-sampler "^1.0.0-beta.16" + perfect-scrollbar "^1.4.0" + polished "^3.6.5" + prismjs "^1.20.0" + prop-types "^15.7.2" + react-tabs "^3.1.1" + slugify "^1.4.4" + stickyfill "^1.1.1" + swagger2openapi "^6.2.1" + tslib "^2.0.0" + url-template "^2.0.8" + +reftools@^1.1.5, reftools@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/reftools/-/reftools-1.1.6.tgz#841b1ac241259632d63167bf708eccfbfbbba5b5" + integrity sha512-rQfJ025lvPjw9qyQuNPqE+cRs5qVs7BMrZwgRJnmuMcX/8r/eJE8f5/RCunJWViXKHmN5K2DFafYzglLOHE/tw== + +regenerate-unicode-properties@^8.2.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec" + integrity sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA== + dependencies: + regenerate "^1.4.0" + +regenerate@^1.4.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.1.tgz#cad92ad8e6b591773485fbe05a485caf4f457e6f" + integrity sha512-j2+C8+NtXQgEKWk49MMP5P/u2GhnahTtVkRIHr5R5lVRlbKvmQ+oS+A5aLKWp2ma5VkT8sh6v+v4hbH0YHR66A== + +regenerator-runtime@^0.13.4: + version "0.13.7" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" + integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== + +regenerator-transform@^0.14.2: + version "0.14.5" + resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.5.tgz#c98da154683671c9c4dcb16ece736517e1b7feb4" + integrity sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw== + dependencies: + "@babel/runtime" "^7.8.4" + +regex-not@^1.0.0, regex-not@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" + integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== + dependencies: + extend-shallow "^3.0.2" + safe-regex "^1.1.0" + +regexp.prototype.flags@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz#7aba89b3c13a64509dabcf3ca8d9fbb9bdf5cb75" + integrity sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + +regexpu-core@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.0.tgz#fcbf458c50431b0bb7b45d6967b8192d91f3d938" + integrity sha512-TQ4KXRnIn6tz6tjnrXEkD/sshygKH/j5KzK86X8MkeHyZ8qst/LZ89j3X4/8HEIfHANTFIP/AbXakeRhWIl5YQ== + dependencies: + regenerate "^1.4.0" + regenerate-unicode-properties "^8.2.0" + regjsgen "^0.5.1" + regjsparser "^0.6.4" + unicode-match-property-ecmascript "^1.0.4" + unicode-match-property-value-ecmascript "^1.2.0" + +regexpu-core@^4.7.1: + version "4.7.1" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6" + integrity sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ== + dependencies: + regenerate "^1.4.0" + regenerate-unicode-properties "^8.2.0" + regjsgen "^0.5.1" + regjsparser "^0.6.4" + unicode-match-property-ecmascript "^1.0.4" + unicode-match-property-value-ecmascript "^1.2.0" + +registry-auth-token@^4.0.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.0.tgz#1d37dffda72bbecd0f581e4715540213a65eb7da" + integrity sha512-P+lWzPrsgfN+UEpDS3U8AQKg/UjZX6mQSJueZj3EK+vNESoqBSpBUD3gmu4sF9lOsjXWjF11dQKUqemf3veq1w== + dependencies: + rc "^1.2.8" + +registry-url@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009" + integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw== + dependencies: + rc "^1.2.8" + +regjsgen@^0.5.1: + version "0.5.2" + resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.2.tgz#92ff295fb1deecbf6ecdab2543d207e91aa33733" + integrity sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A== + +regjsparser@^0.6.4: + version "0.6.4" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.4.tgz#a769f8684308401a66e9b529d2436ff4d0666272" + integrity sha512-64O87/dPDgfk8/RQqC4gkZoGyyWFIEUTTh80CU6CWuK5vkCGyekIx+oKcEIYtP/RAxSQltCZHCNu/mdd7fqlJw== + dependencies: + jsesc "~0.5.0" + +rehype-figure@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/rehype-figure/-/rehype-figure-1.0.1.tgz#6fac89f0ec6042ce8b99163b4cc796a934267be0" + integrity sha512-g7DJuK8R8xHIaPI3QJ6/OoWiKepn92RF2CV3z4dO7lRO6ZHo48Tu9X3KgnZUKK035srFHqWQx93AybBy12XqmQ== + dependencies: + hastscript "^6.0.0" + unist-util-visit "^2.0.3" + +rehype-parse@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/rehype-parse/-/rehype-parse-6.0.2.tgz#aeb3fdd68085f9f796f1d3137ae2b85a98406964" + integrity sha512-0S3CpvpTAgGmnz8kiCyFLGuW5yA4OQhyNTm/nwPopZ7+PI11WnGl1TTWTGv/2hPEe/g2jRLlhVVSsoDH8waRug== + dependencies: + hast-util-from-parse5 "^5.0.0" + parse5 "^5.0.0" + xtend "^4.0.0" + +relateurl@^0.2.7: + version "0.2.7" + resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" + integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk= + +remark-abbr@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/remark-abbr/-/remark-abbr-1.4.0.tgz#97550af7d614308bf2bbd7ed3fced199fb35a4be" + integrity sha512-BNxNWicpYvaQUL/ozF+cPTIUPeFFfLnYTjnVxZvqYE4Eu5ns9rIxDac4+Y71cDCqQmFf9/Gdkyj6n2uc9hfB/Q== + dependencies: + unist-util-visit "^2.0.1" + +remark-admonitions@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/remark-admonitions/-/remark-admonitions-1.2.1.tgz#87caa1a442aa7b4c0cafa04798ed58a342307870" + integrity sha512-Ji6p68VDvD+H1oS95Fdx9Ar5WA2wcDA4kwrrhVU7fGctC6+d3uiMICu7w7/2Xld+lnU7/gi+432+rRbup5S8ow== + dependencies: + rehype-parse "^6.0.2" + unified "^8.4.2" + unist-util-visit "^2.0.1" + +remark-cli@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/remark-cli/-/remark-cli-8.0.1.tgz#093e9f27c1d56a591f4c44c017de5749d4e79a08" + integrity sha512-UaYeFI5qUAzkthUd8/MLBQD5OKM6jLN8GRvF6v+KF7xO/i1jQ+X2VqUSQAxWFYxZ8R25gM56GVjeoKOZ0EIr8A== + dependencies: + markdown-extensions "^1.1.0" + remark "^12.0.0" + unified-args "^8.0.0" + +remark-collapse@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/remark-collapse/-/remark-collapse-0.1.2.tgz#7ebf0b0e0932f39a8599a754906e0a097906070b" + integrity sha1-fr8LDgky85qFmadUkG4KCXkGBws= + dependencies: + mdast-util-heading-range "^2.0.1" + mdast-util-to-string "^1.0.2" + +remark-emoji@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/remark-emoji/-/remark-emoji-2.1.0.tgz#69165d1181b98a54ad5d9ef811003d53d7ebc7db" + integrity sha512-lDddGsxXURV01WS9WAiS9rO/cedO1pvr9tahtLhr6qCGFhHG4yZSJW3Ha4Nw9Uk1hLNmUBtPC0+m45Ms+xEitg== + dependencies: + emoticon "^3.2.0" + node-emoji "^1.10.0" + unist-util-visit "^2.0.2" + +remark-footnotes@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/remark-footnotes/-/remark-footnotes-2.0.0.tgz#9001c4c2ffebba55695d2dd80ffb8b82f7e6303f" + integrity sha512-3Clt8ZMH75Ayjp9q4CorNeyjwIxHFcTkaektplKGl2A1jNGEUey8cKL0ZC5vJwfcD5GFGsNLImLG/NGzWIzoMQ== + +remark-images@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/remark-images/-/remark-images-2.0.0.tgz#7621a406364c3a0a6e4250c3ee63909cc14a2388" + integrity sha512-1X6XTBQZW489HSwU0k+aU3xAlVe3TyPll6N2Mt1onwINTIqcTk9QTC57937Z8NQDJ8h7gKGXy9d4TJug2dm8lg== + dependencies: + is-url "^1.2.2" + unist-util-is "^4.0.0" + unist-util-visit-parents "^3.0.0" + +remark-kbd@^1.0.21: + version "1.0.21" + resolved "https://registry.yarnpkg.com/remark-kbd/-/remark-kbd-1.0.21.tgz#2e8c1649bfae242567a78b53514dc7ee713e52f7" + integrity sha512-5NWXmImtNEe6LTurYEflExbmwYDB8HbhPpIDWo9FkskyTr+wesNaGKC3ALmEcl3b08CJT4EYpWJL6fzuPG3D0g== + dependencies: + is-whitespace-character "^1.0.4" + +remark-lint-no-dead-urls@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remark-lint-no-dead-urls/-/remark-lint-no-dead-urls-1.1.0.tgz#c3ba46ba0bf5f28e3789446295e27bcab68d4349" + integrity sha512-it3EZmMQ+hwGhUf60NkXN0mMIFuFkS0cxdbgEbhZ/Fj1PlUBpe3gDBtWJ/sqNwSNvQlNSzpvMQkNHSoAhlsVjA== + dependencies: + check-links "^1.1.8" + is-online "^8.2.1" + unified-lint-rule "^1.0.4" + unist-util-visit "^2.0.1" + +remark-lint@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/remark-lint/-/remark-lint-8.0.0.tgz#6e40894f4a39eaea31fc4dd45abfaba948bf9a09" + integrity sha512-ESI8qJQ/TIRjABDnqoFsTiZntu+FRifZ5fJ77yX63eIDijl/arvmDvT+tAf75/Nm5BFL4R2JFUtkHRGVjzYUsg== + dependencies: + remark-message-control "^6.0.0" + +remark-mark-plus@^1.0.21: + version "1.0.21" + resolved "https://registry.yarnpkg.com/remark-mark-plus/-/remark-mark-plus-1.0.21.tgz#5b1f50f4e80d2e195272bfcd3a4c164bf9e44d80" + integrity sha512-jQMoLARvb4wSP/mlSR6Fd8j2oGQadmX8nOQumsBfDZQpu/UQgvKirt14luHCcg9WD5mYtTi4YHQvFh9gDdsbog== + dependencies: + is-whitespace-character "^1.0.0" + +remark-mdx@1.6.22: + version "1.6.22" + resolved "https://registry.yarnpkg.com/remark-mdx/-/remark-mdx-1.6.22.tgz#06a8dab07dcfdd57f3373af7f86bd0e992108bbd" + integrity sha512-phMHBJgeV76uyFkH4rvzCftLfKCr2RZuF+/gmVcaKrpsihyzmhXjA0BEMDaPTXG5y8qZOKPVo83NAOX01LPnOQ== + dependencies: + "@babel/core" "7.12.9" + "@babel/helper-plugin-utils" "7.10.4" + "@babel/plugin-proposal-object-rest-spread" "7.12.1" + "@babel/plugin-syntax-jsx" "7.12.1" + "@mdx-js/util" "1.6.22" + is-alphabetical "1.0.4" + remark-parse "8.0.3" + unified "9.2.0" + +remark-message-control@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/remark-message-control/-/remark-message-control-6.0.0.tgz#955b054b38c197c9f2e35b1d88a4912949db7fc5" + integrity sha512-k9bt7BYc3G7YBdmeAhvd3VavrPa/XlKWR3CyHjr4sLO9xJyly8WHHT3Sp+8HPR8lEUv+/sZaffL7IjMLV0f6BA== + dependencies: + mdast-comment-marker "^1.0.0" + unified-message-control "^3.0.0" + +remark-parse@8.0.3, remark-parse@^8.0.0: + version "8.0.3" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-8.0.3.tgz#9c62aa3b35b79a486454c690472906075f40c7e1" + integrity sha512-E1K9+QLGgggHxCQtLt++uXltxEprmWzNfg+MxpfHsZlrddKzZ/hZyWHDbK3/Ap8HJQqYJRXP+jHczdL6q6i85Q== + dependencies: + ccount "^1.0.0" + collapse-white-space "^1.0.2" + is-alphabetical "^1.0.0" + is-decimal "^1.0.0" + is-whitespace-character "^1.0.0" + is-word-character "^1.0.0" + markdown-escapes "^1.0.0" + parse-entities "^2.0.0" + repeat-string "^1.5.4" + state-toggle "^1.0.0" + trim "0.0.1" + trim-trailing-lines "^1.0.0" + unherit "^1.0.4" + unist-util-remove-position "^2.0.0" + vfile-location "^3.0.0" + xtend "^4.0.1" + +remark-parse@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-9.0.0.tgz#4d20a299665880e4f4af5d90b7c7b8a935853640" + integrity sha512-geKatMwSzEXKHuzBNU1z676sGcDcFoChMK38TgdHJNAYfFtsfHDQG7MoJAjs6sgYMqyLduCYWDIWZIxiPeafEw== + dependencies: + mdast-util-from-markdown "^0.8.0" + +remark-sources@^1.0.3, remark-sources@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remark-sources/-/remark-sources-1.1.0.tgz#f9b060876907a5ebcd156052b21d491b52857bb0" + integrity sha512-PGk/1N2IqXCZGDZ0LBHfz1aPgz1JSNpsrqIlDP2HTyLaLeW9yDUZCtHbloqEuN2Na7XvxpxxA1c/K3qS7GNVsQ== + dependencies: + njct "^8.0.0" + unist-util-visit-children "^1.1.4" + +remark-squeeze-paragraphs@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/remark-squeeze-paragraphs/-/remark-squeeze-paragraphs-4.0.0.tgz#76eb0e085295131c84748c8e43810159c5653ead" + integrity sha512-8qRqmL9F4nuLPIgl92XUuxI3pFxize+F1H0e/W3llTk0UsjJaj01+RrirkMw7P21RKe4X6goQhYRSvNWX+70Rw== + dependencies: + mdast-squeeze-paragraphs "^4.0.0" + +remark-stringify@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-8.1.1.tgz#e2a9dc7a7bf44e46a155ec78996db896780d8ce5" + integrity sha512-q4EyPZT3PcA3Eq7vPpT6bIdokXzFGp9i85igjmhRyXWmPs0Y6/d2FYwUNotKAWyLch7g0ASZJn/KHHcHZQ163A== + dependencies: + ccount "^1.0.0" + is-alphanumeric "^1.0.0" + is-decimal "^1.0.0" + is-whitespace-character "^1.0.0" + longest-streak "^2.0.1" + markdown-escapes "^1.0.0" + markdown-table "^2.0.0" + mdast-util-compact "^2.0.0" + parse-entities "^2.0.0" + repeat-string "^1.5.4" + state-toggle "^1.0.0" + stringify-entities "^3.0.0" + unherit "^1.0.4" + xtend "^4.0.1" + +remark-stringify@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-9.0.1.tgz#576d06e910548b0a7191a71f27b33f1218862894" + integrity sha512-mWmNg3ZtESvZS8fv5PTvaPckdL4iNlCHTt8/e/8oN08nArHRHjNZMKzA/YW3+p7/lYqIw4nx1XsjCBo/AxNChg== + dependencies: + mdast-util-to-markdown "^0.6.0" + +remark-unwrap-images@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/remark-unwrap-images/-/remark-unwrap-images-2.0.0.tgz#65134065e3605c64a37d22ab665058fc5286920f" + integrity sha512-uehiJU5U1ZNQxTKUlAKOQhVyFsc+9WWvamkuoF3ILdMpASHwA1JbzTOZcRWv+annYfDWuPSuoZAWdt1r0SlrfQ== + dependencies: + hast-util-whitespace "^1.0.0" + unist-util-visit "^2.0.0" + +remark-validate-links@^10.0.4: + version "10.0.4" + resolved "https://registry.yarnpkg.com/remark-validate-links/-/remark-validate-links-10.0.4.tgz#a2711fa794f691c944faf8126767152dcfee0c47" + integrity sha512-oNGRcsoQkL35WoZKLMMBugDwvHfyu0JPA5vSYkEcvR6YBsFKBo4RedpecuokTK1wgD9l01rPxaQ9dPmRQYFhyg== + dependencies: + github-slugger "^1.0.0" + hosted-git-info "^3.0.0" + mdast-util-to-string "^1.0.0" + propose "0.0.5" + to-vfile "^6.0.0" + trough "^1.0.0" + unist-util-visit "^2.0.0" + +remark@^12.0.0: + version "12.0.1" + resolved "https://registry.yarnpkg.com/remark/-/remark-12.0.1.tgz#f1ddf68db7be71ca2bad0a33cd3678b86b9c709f" + integrity sha512-gS7HDonkdIaHmmP/+shCPejCEEW+liMp/t/QwmF0Xt47Rpuhl32lLtDV1uKWvGoq+kxr5jSgg5oAIpGuyULjUw== + dependencies: + remark-parse "^8.0.0" + remark-stringify "^8.0.0" + unified "^9.0.0" + +remark@^13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/remark/-/remark-13.0.0.tgz#d15d9bf71a402f40287ebe36067b66d54868e425" + integrity sha512-HDz1+IKGtOyWN+QgBiAT0kn+2s6ovOxHyPAFGKVE81VSzJ+mq7RwHFledEvB5F1p4iJvOah/LOKdFuzvRnNLCA== + dependencies: + remark-parse "^9.0.0" + remark-stringify "^9.0.0" + unified "^9.1.0" + +remove-trailing-separator@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= + +renderkid@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-2.0.3.tgz#380179c2ff5ae1365c522bf2fcfcff01c5b74149" + integrity sha512-z8CLQp7EZBPCwCnncgf9C4XAi3WR0dv+uWu/PjIyhhAb5d6IJ/QZqlHFprHeKT+59//V6BNUsLbvN8+2LarxGA== + dependencies: + css-select "^1.1.0" + dom-converter "^0.2" + htmlparser2 "^3.3.0" + strip-ansi "^3.0.0" + utila "^0.4.0" + +repeat-element@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" + integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== + +repeat-string@^1.0.0, repeat-string@^1.5.0, repeat-string@^1.5.4, repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= + +replace-ext@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb" + integrity sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs= + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +"require-like@>= 0.1.1": + version "0.1.2" + resolved "https://registry.yarnpkg.com/require-like/-/require-like-0.1.2.tgz#ad6f30c13becd797010c468afa775c0c0a6b47fa" + integrity sha1-rW8wwTvs15cBDEaK+ndcDAprR/o= + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + +require-package-name@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/require-package-name/-/require-package-name-2.0.1.tgz#c11e97276b65b8e2923f75dabf5fb2ef0c3841b9" + integrity sha1-wR6XJ2tluOKSP3Xav1+y7ww4Qbk= + +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= + +resolve-cwd@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" + integrity sha1-AKn3OHVW4nA46uIyyqNypqWbZlo= + dependencies: + resolve-from "^3.0.0" + +resolve-from@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" + integrity sha1-six699nWiBvItuZTM17rywoYh0g= + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve-pathname@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" + integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== + +resolve-url@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" + integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= + +resolve@^1.1.6, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.19.0, resolve@^1.3.2: + version "1.20.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" + integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== + dependencies: + is-core-module "^2.2.0" + path-parse "^1.0.6" + +resolve@^2.0.0-next.1: + version "2.0.0-next.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.1.tgz#4d96ccb89bf82d54ab037241ae053db4e92bb5f1" + integrity sha512-ZGTmuLZAW++TDjgslfUMRZcv7kXHv8z0zwxvuRWOPjnqc56HVsn1lVaqsWOZeQ8MwiilPVJLrcPVKG909QsAfA== + dependencies: + path-parse "^1.0.6" + +responselike@1.0.2, responselike@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" + integrity sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec= + dependencies: + lowercase-keys "^1.0.0" + +responselike@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.0.tgz#26391bcc3174f750f9a79eacc40a12a5c42d7723" + integrity sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw== + dependencies: + lowercase-keys "^2.0.0" + +restore-cursor@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" + integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368= + dependencies: + onetime "^2.0.0" + signal-exit "^3.0.2" + +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== + +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= + +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rgb-regex@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1" + integrity sha1-wODWiC3w4jviVKR16O3UGRX+rrE= + +rgba-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3" + integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM= + +rimraf@^2.5.4, rimraf@^2.6.3: + version "2.7.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" + integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== + dependencies: + glob "^7.1.3" + +rimraf@^3.0.0, rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +ripemd160@^2.0.0, ripemd160@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" + integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + +rollup-plugin-inject@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz#e4233855bfba6c0c12a312fd6649dff9a13ee9f4" + integrity sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w== + dependencies: + estree-walker "^0.6.1" + magic-string "^0.25.3" + rollup-pluginutils "^2.8.1" + +rollup-plugin-node-polyfills@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz#53092a2744837164d5b8a28812ba5f3ff61109fd" + integrity sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA== + dependencies: + rollup-plugin-inject "^3.0.0" + +rollup-plugin-terser@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz#e8fbba4869981b2dc35ae7e8a502d5c6c04d324d" + integrity sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ== + dependencies: + "@babel/code-frame" "^7.10.4" + jest-worker "^26.2.1" + serialize-javascript "^4.0.0" + terser "^5.0.0" + +rollup-pluginutils@^2.8.1: + version "2.8.2" + resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e" + integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ== + dependencies: + estree-walker "^0.6.1" + +rollup@^2.23.1: + version "2.52.3" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.52.3.tgz#062fc3c85f67736d6758749310cfee64836c4e2a" + integrity sha512-QF3Sju8Kl2z0osI4unyOLyUudyhOMK6G0AeqJWgfiyigqLAlnNrfBcDWDx+f1cqn+JU2iIYVkDrgQ6/KtwEfrg== + optionalDependencies: + fsevents "~2.3.2" + +run-async@^2.2.0, run-async@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" + integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== + +run-parallel@^1.1.4, run-parallel@^1.1.9: + version "1.1.9" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.9.tgz#c9dd3a7cf9f4b2c4b6244e173a6ed866e61dd679" + integrity sha512-DEqnSRTDw/Tc3FXf49zedI638Z9onwUotBMiUFKmrO2sdFKIbXamXGQ3Axd4qgphxKB4kw/qP1w5kTxnfU1B9Q== + +run-queue@^1.0.0, run-queue@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47" + integrity sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec= + dependencies: + aproba "^1.1.1" + +rx@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782" + integrity sha1-pfE/957zt0D+MKqAP7CfmIBdR4I= + +rxjs@^6.3.3: + version "6.6.7" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" + integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ== + dependencies: + tslib "^1.9.0" + +rxjs@^6.4.0, rxjs@^6.6.0, rxjs@^6.6.2: + version "6.6.3" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" + integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ== + dependencies: + tslib "^1.9.0" + +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-join@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/safe-join/-/safe-join-0.1.3.tgz#02ca7a7f2fed4f9cde3f72eb6ade4423bd73d506" + integrity sha512-Ylh1EWn4pmL57HRV/oi4Ye7ws5AxKkdGpyDdWsvZob5VLH8xnQpG8tqmHD5v4SdKlN7hyrBjYt7Jm3faeC+uJg== + +safe-json-stringify@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz#356e44bc98f1f93ce45df14bcd7c01cda86e0afd" + integrity sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg== + +safe-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" + integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4= + dependencies: + ret "~0.1.10" + +"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sass-loader@^9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-9.0.3.tgz#086adcf0bfdcc9d920413e2cdc3ba3321373d547" + integrity sha512-fOwsP98ac1VMme+V3+o0HaaMHp8Q/C9P+MUazLFVi3Jl7ORGHQXL1XeRZt3zLSGZQQPC8xE42Y2WptItvGjDQg== + dependencies: + klona "^1.1.2" + loader-utils "^2.0.0" + neo-async "^2.6.2" + schema-utils "^2.7.0" + semver "^7.3.2" + +sass@^1.26.10: + version "1.26.10" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.26.10.tgz#851d126021cdc93decbf201d1eca2a20ee434760" + integrity sha512-bzN0uvmzfsTvjz0qwccN1sPm2HxxpNI/Xa+7PlUEMS+nQvbyuEK7Y0qFqxlPHhiNHb1Ze8WQJtU31olMObkAMw== + dependencies: + chokidar ">=2.0.0 <4.0.0" + +sax@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" + integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o= + +sax@>=0.6.0, sax@~1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== + +scheduler@^0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" + integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + +schema-utils@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" + integrity sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g== + dependencies: + ajv "^6.1.0" + ajv-errors "^1.0.0" + ajv-keywords "^3.1.0" + +schema-utils@^2.0.0, schema-utils@^2.6.5, schema-utils@^2.6.6, schema-utils@^2.7.0: + version "2.7.1" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7" + integrity sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg== + dependencies: + "@types/json-schema" "^7.0.5" + ajv "^6.12.4" + ajv-keywords "^3.5.2" + +schema-utils@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.0.0.tgz#67502f6aa2b66a2d4032b4279a2944978a0913ef" + integrity sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA== + dependencies: + "@types/json-schema" "^7.0.6" + ajv "^6.12.5" + ajv-keywords "^3.5.2" + +section-matter@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/section-matter/-/section-matter-1.0.0.tgz#e9041953506780ec01d59f292a19c7b850b84167" + integrity sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA== + dependencies: + extend-shallow "^2.0.1" + kind-of "^6.0.0" + +seek-bzip@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/seek-bzip/-/seek-bzip-1.0.6.tgz#35c4171f55a680916b52a07859ecf3b5857f21c4" + integrity sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ== + dependencies: + commander "^2.8.1" + +select-hose@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" + integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= + +select@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" + integrity sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0= + +selfsigned@^1.10.7: + version "1.10.8" + resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.8.tgz#0d17208b7d12c33f8eac85c41835f27fc3d81a30" + integrity "sha1-DRcgi30Swz+OrIXEGDXyf8PYGjA= sha512-2P4PtieJeEwVgTU9QEcwIRDQ/mXJLX8/+I3ur+Pg16nS8oNbrGxEso9NyYWy8NAmXiNl4dlAp5MwoNeCWzON4w==" + dependencies: + node-forge "^0.10.0" + +semver-diff@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" + integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg== + dependencies: + semver "^6.3.0" + +"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + +semver@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" + integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== + +semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + +semver@^7.0.0, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5: + version "7.3.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" + integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== + dependencies: + lru-cache "^6.0.0" + +send@0.17.1: + version "0.17.1" + resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" + integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg== + dependencies: + debug "2.6.9" + depd "~1.1.2" + destroy "~1.0.4" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "~1.7.2" + mime "1.6.0" + ms "2.1.1" + on-finished "~2.3.0" + range-parser "~1.2.1" + statuses "~1.5.0" + +serialize-javascript@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" + integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw== + dependencies: + randombytes "^2.1.0" + +serialize-javascript@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-5.0.1.tgz#7886ec848049a462467a97d3d918ebb2aaf934f4" + integrity sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA== + dependencies: + randombytes "^2.1.0" + +serve-handler@^6.1.3: + version "6.1.3" + resolved "https://registry.yarnpkg.com/serve-handler/-/serve-handler-6.1.3.tgz#1bf8c5ae138712af55c758477533b9117f6435e8" + integrity sha512-FosMqFBNrLyeiIDvP1zgO6YoTzFYHxLDEIavhlmQ+knB2Z7l1t+kGLHkZIDN7UVWqQAmKI3D20A6F6jo3nDd4w== + dependencies: + bytes "3.0.0" + content-disposition "0.5.2" + fast-url-parser "1.1.3" + mime-types "2.1.18" + minimatch "3.0.4" + path-is-inside "1.0.2" + path-to-regexp "2.2.1" + range-parser "1.2.0" + +serve-index@^1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" + integrity sha1-03aNabHn2C5c4FD/9bRTvqEqkjk= + dependencies: + accepts "~1.3.4" + batch "0.6.1" + debug "2.6.9" + escape-html "~1.0.3" + http-errors "~1.6.2" + mime-types "~2.1.17" + parseurl "~1.3.2" + +serve-static@1.14.1: + version "1.14.1" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9" + integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.17.1" + +set-blocking@^2.0.0, set-blocking@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= + +set-value@^2.0.0, set-value@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" + integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw== + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + +setimmediate@^1.0.4, setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= + +setprototypeof@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" + integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== + +setprototypeof@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" + integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== + +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +sha.js@^2.4.0, sha.js@^2.4.8: + version "2.4.11" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" + integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +shallow-clone@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-0.1.2.tgz#5909e874ba77106d73ac414cfec1ffca87d97060" + integrity sha1-WQnodLp3EG1zrEFM/sH/yofZcGA= + dependencies: + is-extendable "^0.1.1" + kind-of "^2.0.1" + lazy-cache "^0.2.3" + mixin-object "^2.0.1" + +sharp@^0.25.2: + version "0.25.4" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.25.4.tgz#1a8e542144a07ab7e9316ab89de80182b827c363" + integrity sha512-umSzJJ1oBwIOfwFFt/fJ7JgCva9FvrEU2cbbm7u/3hSDZhXvkME8WE5qpaJqLIe2Har5msF5UG4CzYlEg5o3BQ== + dependencies: + color "^3.1.2" + detect-libc "^1.0.3" + node-addon-api "^3.0.0" + npmlog "^4.1.2" + prebuild-install "^5.3.4" + semver "^7.3.2" + simple-get "^4.0.0" + tar "^6.0.2" + tunnel-agent "^0.6.0" + +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= + dependencies: + shebang-regex "^1.0.0" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +shell-quote@1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2" + integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== + +shelljs@^0.8.4: + version "0.8.4" + resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2" + integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ== + dependencies: + glob "^7.0.0" + interpret "^1.0.0" + rechoir "^0.6.2" + +should-equal@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/should-equal/-/should-equal-2.0.0.tgz#6072cf83047360867e68e98b09d71143d04ee0c3" + integrity sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA== + dependencies: + should-type "^1.4.0" + +should-format@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/should-format/-/should-format-3.0.3.tgz#9bfc8f74fa39205c53d38c34d717303e277124f1" + integrity sha1-m/yPdPo5IFxT04w01xcwPidxJPE= + dependencies: + should-type "^1.3.0" + should-type-adaptors "^1.0.1" + +should-type-adaptors@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz#401e7f33b5533033944d5cd8bf2b65027792e27a" + integrity sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA== + dependencies: + should-type "^1.3.0" + should-util "^1.0.0" + +should-type@^1.3.0, should-type@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/should-type/-/should-type-1.4.0.tgz#0756d8ce846dfd09843a6947719dfa0d4cff5cf3" + integrity sha1-B1bYzoRt/QmEOmlHcZ36DUz/XPM= + +should-util@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/should-util/-/should-util-1.0.1.tgz#fb0d71338f532a3a149213639e2d32cbea8bcb28" + integrity sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g== + +should@^13.2.1: + version "13.2.3" + resolved "https://registry.yarnpkg.com/should/-/should-13.2.3.tgz#96d8e5acf3e97b49d89b51feaa5ae8d07ef58f10" + integrity sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ== + dependencies: + should-equal "^2.0.0" + should-format "^3.0.3" + should-type "^1.4.0" + should-type-adaptors "^1.0.1" + should-util "^1.0.0" + +side-channel@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" + integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== + dependencies: + call-bind "^1.0.0" + get-intrinsic "^1.0.2" + object-inspect "^1.9.0" + +signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" + integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== + +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^3.0.3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3" + integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA== + dependencies: + decompress-response "^4.2.0" + once "^1.3.1" + simple-concat "^1.0.0" + +simple-get@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.0.tgz#73fa628278d21de83dadd5512d2cc1f4872bd675" + integrity sha512-ZalZGexYr3TA0SwySsr5HlgOOinS4Jsa8YB2GJ6lUNAazyAu4KG/VmzMTwAt2YVXzzVj8QmefmAonZIK2BSGcQ== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo= + dependencies: + is-arrayish "^0.3.1" + +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + +sitemap@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/sitemap/-/sitemap-3.2.2.tgz#3f77c358fa97b555c879e457098e39910095c62b" + integrity sha512-TModL/WU4m2q/mQcrDgNANn0P4LwprM9MMvG4hu5zP4c6IIKs2YLTu6nXXnNr8ODW/WFtxKggiJ1EGn2W0GNmg== + dependencies: + lodash.chunk "^4.2.0" + lodash.padstart "^4.6.1" + whatwg-url "^7.0.0" + xmlbuilder "^13.0.0" + +slash@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" + integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +slice-ansi@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35" + integrity sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU= + +sliced@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/sliced/-/sliced-1.0.1.tgz#0b3a662b5d04c3177b1926bea82b03f837a2ef41" + integrity sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E= + +slugify@^1.4.4: + version "1.4.5" + resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.4.5.tgz#a7517acf5f4c02a4df41e735354b660a4ed1efcf" + integrity sha512-WpECLAgYaxHoEAJ8Q1Lo8HOs1ngn7LN7QjXgOLbmmfkcWvosyk4ZTXkTzKyhngK640USTZUlgoQJfED1kz5fnQ== + +snapdragon-node@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" + integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== + dependencies: + define-property "^1.0.0" + isobject "^3.0.0" + snapdragon-util "^3.0.1" + +snapdragon-util@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" + integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== + dependencies: + kind-of "^3.2.0" + +snapdragon@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" + integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== + dependencies: + base "^0.11.1" + debug "^2.2.0" + define-property "^0.2.5" + extend-shallow "^2.0.1" + map-cache "^0.2.2" + source-map "^0.5.6" + source-map-resolve "^0.5.0" + use "^3.1.0" + +sockjs-client@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.4.0.tgz#c9f2568e19c8fd8173b4997ea3420e0bb306c7d5" + integrity sha512-5zaLyO8/nri5cua0VtOrFXBPK1jbL4+1cebT/mmKA1E1ZXOvJrII75bPu0l0k843G/+iAbhEqzyKr0w/eCCj7g== + dependencies: + debug "^3.2.5" + eventsource "^1.0.7" + faye-websocket "~0.11.1" + inherits "^2.0.3" + json3 "^3.3.2" + url-parse "^1.4.3" + +sockjs@0.3.20: + version "0.3.20" + resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.20.tgz#b26a283ec562ef8b2687b44033a4eeceac75d855" + integrity sha512-SpmVOVpdq0DJc0qArhF3E5xsxvaiqGNb73XfgBpK1y3UD5gs8DSo8aCTsuT5pX8rssdc2NDIzANwP9eCAiSdTA== + dependencies: + faye-websocket "^0.10.0" + uuid "^3.4.0" + websocket-driver "0.6.5" + +sort-keys-length@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/sort-keys-length/-/sort-keys-length-1.0.1.tgz#9cb6f4f4e9e48155a6aa0671edd336ff1479a188" + integrity sha1-nLb09OnkgVWmqgZx7dM2/xR5oYg= + dependencies: + sort-keys "^1.0.0" + +sort-keys@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" + integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0= + dependencies: + is-plain-obj "^1.0.0" + +sort-keys@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-2.0.0.tgz#658535584861ec97d730d6cf41822e1f56684128" + integrity sha1-ZYU1WEhh7JfXMNbPQYIuH1ZoQSg= + dependencies: + is-plain-obj "^1.0.0" + +source-list-map@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" + integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== + +source-map-js@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e" + integrity sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug== + +source-map-resolve@^0.5.0: + version "0.5.3" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" + integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw== + dependencies: + atob "^2.1.2" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + +source-map-support@^0.5.19, source-map-support@~0.5.12, source-map-support@~0.5.19: + version "0.5.19" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" + integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map-url@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" + integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= + +source-map@^0.5.0, source-map@^0.5.6: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= + +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +source-map@^0.7.3, source-map@~0.7.2: + version "0.7.3" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== + +sourcemap-codec@^1.4.4: + version "1.4.8" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" + integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== + +space-separated-tokens@^1.0.0: + version "1.1.5" + resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz#85f32c3d10d9682007e917414ddc5c26d1aa6899" + integrity sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA== + +spdx-correct@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" + integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w== + dependencies: + spdx-expression-parse "^3.0.0" + spdx-license-ids "^3.0.0" + +spdx-exceptions@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" + integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== + +spdx-expression-parse@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" + integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.5" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654" + integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q== + +spdy-transport@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31" + integrity sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw== + dependencies: + debug "^4.1.0" + detect-node "^2.0.4" + hpack.js "^2.1.6" + obuf "^1.1.2" + readable-stream "^3.0.6" + wbuf "^1.7.3" + +spdy@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/spdy/-/spdy-4.0.2.tgz#b74f466203a3eda452c02492b91fb9e84a27677b" + integrity sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA== + dependencies: + debug "^4.1.0" + handle-thing "^2.0.0" + http-deceiver "^1.2.7" + select-hose "^2.0.0" + spdy-transport "^3.0.0" + +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" + integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== + dependencies: + extend-shallow "^3.0.0" + +split2@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/split2/-/split2-1.1.1.tgz#162d9b18865f02ab2f2ad9585522db9b54c481f9" + integrity sha1-Fi2bGIZfAqsvKtlYVSLbm1TEgfk= + dependencies: + through2 "~2.0.0" + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= + +ssri@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8" + integrity sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA== + dependencies: + figgy-pudding "^3.5.1" + +ssri@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.0.tgz#79ca74e21f8ceaeddfcb4b90143c458b8d988808" + integrity sha512-aq/pz989nxVYwn16Tsbj1TqFpD5LLrQxHf5zaHuieFV+R0Bbr4y8qUsOA45hXT/N4/9UNXTarBjnjVmjSOVaAA== + dependencies: + minipass "^3.1.1" + +stable@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" + integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== + +stack-generator@^2.0.3: + version "2.0.5" + resolved "https://registry.yarnpkg.com/stack-generator/-/stack-generator-2.0.5.tgz#fb00e5b4ee97de603e0773ea78ce944d81596c36" + integrity sha512-/t1ebrbHkrLrDuNMdeAcsvynWgoH/i4o8EGGfX7dEYDoTXOYVAkEpFdtshlvabzc6JlJ8Kf9YdFEoz7JkzGN9Q== + dependencies: + stackframe "^1.1.1" + +stack-trace@0.0.x: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" + integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA= + +stackframe@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.2.0.tgz#52429492d63c62eb989804c11552e3d22e779303" + integrity sha512-GrdeshiRmS1YLMYgzF16olf2jJ/IzxXY9lhKOskuVziubpTYcYqyOwYeJKzQkwy7uN0fYSsbsC4RQaXf9LCrYA== + +state-toggle@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/state-toggle/-/state-toggle-1.0.3.tgz#e123b16a88e143139b09c6852221bc9815917dfe" + integrity sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ== + +static-extend@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" + integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= + dependencies: + define-property "^0.2.5" + object-copy "^0.1.0" + +static-server@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/static-server/-/static-server-2.2.1.tgz#49e3cae2a001736b0ee9e95d21d3d843fc95efaa" + integrity sha512-j5eeW6higxYNmXMIT8iHjsdiViTpQDthg7o+SHsRtqdbxscdHqBHXwrXjHC8hL3F0Tsu34ApUpDkwzMBPBsrLw== + dependencies: + chalk "^0.5.1" + commander "^2.3.0" + file-size "0.0.5" + mime "^1.2.11" + opn "^5.2.0" + +statsd-client@0.4.7: + version "0.4.7" + resolved "https://registry.yarnpkg.com/statsd-client/-/statsd-client-0.4.7.tgz#a423894bd80bd27524c992001511530ef16933d1" + integrity sha512-+sGCE6FednJ/vI7vywErOg/mhVqmf6Zlktz7cdGRnF/cQWXD9ifMgtqU1CIIXmhSwm11SCk4zDN+bwNCvIR/Kg== + +"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@~1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= + +std-env@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-2.2.1.tgz#2ffa0fdc9e2263e0004c1211966e960948a40f6b" + integrity sha512-IjYQUinA3lg5re/YMlwlfhqNRTzMZMqE+pezevdcTaHceqx8ngEi1alX9nNCk9Sc81fy1fLDeQoaCzeiW1yBOQ== + dependencies: + ci-info "^1.6.0" + +stickyfill@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/stickyfill/-/stickyfill-1.1.1.tgz#39413fee9d025c74a7e59ceecb23784cc0f17f02" + integrity sha1-OUE/7p0CXHSn5ZzuyyN4TMDxfwI= + +stream-browserify@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b" + integrity sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg== + dependencies: + inherits "~2.0.1" + readable-stream "^2.0.2" + +stream-each@^1.1.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.3.tgz#ebe27a0c389b04fbcc233642952e10731afa9bae" + integrity sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw== + dependencies: + end-of-stream "^1.1.0" + stream-shift "^1.0.0" + +stream-http@^2.7.2: + version "2.8.3" + resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.8.3.tgz#b2d242469288a5a27ec4fe8933acf623de6514fc" + integrity sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw== + dependencies: + builtin-status-codes "^3.0.0" + inherits "^2.0.1" + readable-stream "^2.3.6" + to-arraybuffer "^1.0.0" + xtend "^4.0.0" + +stream-shift@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" + integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== + +strict-uri-encode@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" + integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= + +string-width@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.0" + +"string-width@^1.0.2 || 2", string-width@^2.1.0, string-width@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +string-width@^3.0.0, string-width@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + +string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5" + integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.0" + +string.prototype.trimend@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913" + integrity sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + +string.prototype.trimstart@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54" + integrity sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + +string_decoder@^1.0.0, string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +stringify-entities@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-3.0.1.tgz#32154b91286ab0869ab2c07696223bd23b6dbfc0" + integrity sha512-Lsk3ISA2++eJYqBMPKcr/8eby1I6L0gP0NlxF8Zja6c05yr/yCYyb2c9PwXjd08Ib3If1vn1rbs1H5ZtVuOfvQ== + dependencies: + character-entities-html4 "^1.0.0" + character-entities-legacy "^1.0.0" + is-alphanumerical "^1.0.0" + is-decimal "^1.0.2" + is-hexadecimal "^1.0.0" + +stringify-object@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629" + integrity sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw== + dependencies: + get-own-enumerable-property-symbols "^3.0.0" + is-obj "^1.0.1" + is-regexp "^1.0.0" + +strip-ansi-control-characters@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi-control-characters/-/strip-ansi-control-characters-2.0.0.tgz#8875b5ba3a859a0a44f94e1cf7d3eda8980997b9" + integrity sha512-Q0/k5orrVGeaOlIOUn1gybGU0IcAbgHQT1faLo5hik4DqClKVSaka5xOhNNoRgtfztHVxCYxi7j71mrWom0bIw== + +strip-ansi@6.0.0, strip-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" + integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== + dependencies: + ansi-regex "^5.0.0" + +strip-ansi@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-0.3.0.tgz#25f48ea22ca79187f3174a4db8759347bb126220" + integrity sha1-JfSOoiynkYfzF0pNuHWTR7sSYiA= + dependencies: + ansi-regex "^0.2.1" + +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= + dependencies: + ansi-regex "^3.0.0" + +strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + dependencies: + ansi-regex "^4.1.0" + +strip-bom-string@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-bom-string/-/strip-bom-string-1.0.0.tgz#e5211e9224369fbb81d633a2f00044dc8cedad92" + integrity sha1-5SEekiQ2n7uB1jOi8ABE3IztrZI= + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= + +strip-dirs@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/strip-dirs/-/strip-dirs-2.1.0.tgz#4987736264fc344cf20f6c34aca9d13d1d4ed6c5" + integrity sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g== + dependencies: + is-natural-number "^4.0.1" + +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= + +strip-outer@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/strip-outer/-/strip-outer-1.0.1.tgz#b2fd2abf6604b9d1e6013057195df836b8a9d631" + integrity sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg== + dependencies: + escape-string-regexp "^1.0.2" + +style-to-object@0.3.0, style-to-object@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-0.3.0.tgz#b1b790d205991cc783801967214979ee19a76e46" + integrity sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA== + dependencies: + inline-style-parser "0.1.1" + +styled-components@^4.2.0: + version "4.4.1" + resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-4.4.1.tgz#e0631e889f01db67df4de576fedaca463f05c2f2" + integrity sha512-RNqj14kYzw++6Sr38n7197xG33ipEOktGElty4I70IKzQF1jzaD1U4xQ+Ny/i03UUhHlC5NWEO+d8olRCDji6g== + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@babel/traverse" "^7.0.0" + "@emotion/is-prop-valid" "^0.8.1" + "@emotion/unitless" "^0.7.0" + babel-plugin-styled-components ">= 1" + css-to-react-native "^2.2.2" + memoize-one "^5.0.0" + merge-anything "^2.2.4" + prop-types "^15.5.4" + react-is "^16.6.0" + stylis "^3.5.0" + stylis-rule-sheet "^0.0.10" + supports-color "^5.5.0" + +stylehacks@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5" + integrity sha512-7GlLk9JwlElY4Y6a/rmbH2MhVlTyVmiJd1PfTCqFaIBEGMYNsrO/v3SeGTdhBThLg4Z+NbOk/qFMwCa+J+3p/g== + dependencies: + browserslist "^4.0.0" + postcss "^7.0.0" + postcss-selector-parser "^3.0.0" + +stylis-rule-sheet@^0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz#44e64a2b076643f4b52e5ff71efc04d8c3c4a430" + integrity sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw== + +stylis@^3.5.0: + version "3.5.4" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe" + integrity sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q== + +supports-color@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-0.2.0.tgz#d92de2694eb3f67323973d7ae3d8b55b4c22190a" + integrity sha1-2S3iaU6z9nMjlz1649i1W0wiGQo= + +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" + integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= + +supports-color@^5.0.0, supports-color@^5.3.0, supports-color@^5.4.0, supports-color@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^6.0.0, supports-color@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" + integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.0.0, supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-color@^8.1.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-hyperlinks@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-1.0.1.tgz#71daedf36cc1060ac5100c351bb3da48c29c0ef7" + integrity sha512-HHi5kVSefKaJkGYXbDuKbUGRVxqnWGn3J2e39CYcNJEfWciGq2zYtOhXLTlvrOZW1QU7VX67w7fMmWafHX9Pfw== + dependencies: + has-flag "^2.0.0" + supports-color "^5.0.0" + +supports-hyperlinks@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.1.0.tgz#f663df252af5f37c5d49bbd7eeefa9e0b9e59e47" + integrity sha512-zoE5/e+dnEijk6ASB6/qrK+oYdm2do1hjoLWrqUC/8WEIW1gbxFcKuBof7sW8ArN6e+AYvsE8HBGiVRWL/F5CA== + dependencies: + has-flag "^4.0.0" + supports-color "^7.0.0" + +svg-parser@^2.0.2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" + integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== + +svgo@^1.0.0, svgo@^1.2.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167" + integrity sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw== + dependencies: + chalk "^2.4.1" + coa "^2.0.2" + css-select "^2.0.0" + css-select-base-adapter "^0.1.1" + css-tree "1.0.0-alpha.37" + csso "^4.0.2" + js-yaml "^3.13.1" + mkdirp "~0.5.1" + object.values "^1.1.0" + sax "~1.2.4" + stable "^0.1.8" + unquote "~1.1.1" + util.promisify "~1.0.0" + +swagger2openapi@^6.2.1: + version "6.2.3" + resolved "https://registry.yarnpkg.com/swagger2openapi/-/swagger2openapi-6.2.3.tgz#4a8059f89d851aee4c9ab178f9b7190debd904e2" + integrity sha512-cUUktzLpK69UwpMbcTzjMw2ns9RZChfxh56AHv6+hTx3StPOX2foZjPgds3HlJcINbxosYYBn/D3cG8nwcCWwQ== + dependencies: + better-ajv-errors "^0.6.1" + call-me-maybe "^1.0.1" + node-fetch-h2 "^2.3.0" + node-readfiles "^0.2.0" + oas-kit-common "^1.0.8" + oas-resolver "^2.4.3" + oas-schema-walker "^1.1.5" + oas-validator "^4.0.8" + reftools "^1.1.5" + yaml "^1.8.3" + yargs "^15.3.1" + +symbol-observable@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== + +tapable@^1.0.0, tapable@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" + integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== + +tar-fs@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.0.tgz#d1cdd121ab465ee0eb9ccde2d35049d3f3daf0d5" + integrity sha512-9uW5iDvrIMCVpvasdFHW0wJPez0K4JnMZtsuIeDI7HyMGJNxmDZDOCQROr7lXyS+iL/QMpj07qcjGYTSdRFXUg== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.0.0" + +tar-stream@^1.5.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" + integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A== + dependencies: + bl "^1.0.0" + buffer-alloc "^1.2.0" + end-of-stream "^1.0.0" + fs-constants "^1.0.0" + readable-stream "^2.3.0" + to-buffer "^1.1.1" + xtend "^4.0.0" + +tar-stream@^2.0.0: + version "2.1.3" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.1.3.tgz#1e2022559221b7866161660f118255e20fa79e41" + integrity sha512-Z9yri56Dih8IaK8gncVPx4Wqt86NDmQTSh49XLZgjWpGZL9GK9HKParS2scqHCC4w6X9Gh2jwaU45V47XTKwVA== + dependencies: + bl "^4.0.1" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + +tar-stream@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + +tar@^6.0.2: + version "6.0.5" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.5.tgz#bde815086e10b39f1dcd298e89d596e1535e200f" + integrity sha512-0b4HOimQHj9nXNEAA7zWwMM91Zhhba3pspja6sQbgTpynOJf+bkjBnfybNYzbpLbnwXnbyB4LOREvlyXLkCHSg== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^3.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + +temp-dir@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-2.0.0.tgz#bde92b05bdfeb1516e804c9c00ad45177f31321e" + integrity sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg== + +tempy@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tempy/-/tempy-1.0.1.tgz#30fe901fd869cfb36ee2bd999805aa72fbb035de" + integrity sha512-biM9brNqxSc04Ee71hzFbryD11nX7VPhQQY32AdDmjFvodsRFz/3ufeoTZ6uYkRFfGo188tENcASNs3vTdsM0w== + dependencies: + del "^6.0.0" + is-stream "^2.0.0" + temp-dir "^2.0.0" + type-fest "^0.16.0" + unique-string "^2.0.0" + +term-size@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.0.tgz#1f16adedfe9bdc18800e1776821734086fcc6753" + integrity sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw== + +terser-webpack-plugin@^1.4.3: + version "1.4.5" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz#a217aefaea330e734ffacb6120ec1fa312d6040b" + integrity sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw== + dependencies: + cacache "^12.0.2" + find-cache-dir "^2.1.0" + is-wsl "^1.1.0" + schema-utils "^1.0.0" + serialize-javascript "^4.0.0" + source-map "^0.6.1" + terser "^4.1.2" + webpack-sources "^1.4.0" + worker-farm "^1.7.0" + +terser-webpack-plugin@^3.0.3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-3.1.0.tgz#91e6d39571460ed240c0cf69d295bcf30ebf98cb" + integrity sha512-cjdZte66fYkZ65rQ2oJfrdCAkkhJA7YLYk5eGOcGCSGlq0ieZupRdjedSQXYknMPo2IveQL+tPdrxUkERENCFA== + dependencies: + cacache "^15.0.5" + find-cache-dir "^3.3.1" + jest-worker "^26.2.1" + p-limit "^3.0.2" + schema-utils "^2.6.6" + serialize-javascript "^4.0.0" + source-map "^0.6.1" + terser "^4.8.0" + webpack-sources "^1.4.3" + +terser@^4.1.2, terser@^4.6.3, terser@^4.8.0: + version "4.8.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17" + integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw== + dependencies: + commander "^2.20.0" + source-map "~0.6.1" + source-map-support "~0.5.12" + +terser@^5.0.0: + version "5.7.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.7.0.tgz#a761eeec206bc87b605ab13029876ead938ae693" + integrity sha512-HP5/9hp2UaZt5fYkuhNBR8YyRcT8juw8+uFbAme53iN9hblvKnLUTKkmwJG6ocWpIKf8UK4DoeWG4ty0J6S6/g== + dependencies: + commander "^2.20.0" + source-map "~0.7.2" + source-map-support "~0.5.19" + +text-hex@1.0.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" + integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== + +text-table@0.2.0, text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= + +through2-filter@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254" + integrity sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA== + dependencies: + through2 "~2.0.0" + xtend "~4.0.0" + +through2-map@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/through2-map/-/through2-map-3.0.0.tgz#a6c3026ce63b4898a997d540506b66ffd970f271" + integrity sha1-psMCbOY7SJipl9VAUGtm/9lw8nE= + dependencies: + through2 "~2.0.0" + xtend "^4.0.0" + +through2@^2.0.0, through2@~2.0.0: + version "2.0.5" + resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" + integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== + dependencies: + readable-stream "~2.3.6" + xtend "~4.0.1" + +through@^2.3.6, through@^2.3.8: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= + +thunky@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" + integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA== + +time-zone@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/time-zone/-/time-zone-1.0.0.tgz#99c5bf55958966af6d06d83bdf3800dc82faec5d" + integrity sha1-mcW/VZWJZq9tBtg73zgA3IL67F0= + +timed-out@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f" + integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8= + +timers-browserify@^2.0.4: + version "2.0.11" + resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.11.tgz#800b1f3eee272e5bc53ee465a04d0e804c31211f" + integrity sha512-60aV6sgJ5YEbzUdn9c8kYGIqOubPoUdqQCul3SBAsRCZ40s6Y5cMcrW4dt3/k/EsbLVJNl9n6Vz3fTc+k2GeKQ== + dependencies: + setimmediate "^1.0.4" + +timm@^1.6.1: + version "1.7.0" + resolved "https://registry.yarnpkg.com/timm/-/timm-1.7.0.tgz#c538100a58d066a53cb6cadeb8fb6b0dfd66e270" + integrity sha512-oVYHPG5KiUJ3KrbBTmW2kTauIO9E1lDEUM6K92HVuwnPfTt7W8UXZG3vqOo4tVaHRI9AHToVHqhzIUUFkDN6rA== + +timsort@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" + integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= + +tiny-emitter@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" + integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== + +tiny-invariant@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" + integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== + +tiny-warning@^1.0.0, tiny-warning@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" + integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== + +tinycolor2@^1.4.1, tinycolor2@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803" + integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA== + +tmp-promise@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/tmp-promise/-/tmp-promise-3.0.2.tgz#6e933782abff8b00c3119d63589ca1fb9caaa62a" + integrity sha512-OyCLAKU1HzBjL6Ev3gxUeraJNlbNingmi8IrHHEsYH8LTmEuhvYfqvhn2F/je+mjf4N58UmZ96OMEy1JanSCpA== + dependencies: + tmp "^0.2.0" + +tmp@^0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" + integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== + dependencies: + os-tmpdir "~1.0.2" + +tmp@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" + integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== + dependencies: + rimraf "^3.0.0" + +to-arraybuffer@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" + integrity sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M= + +to-buffer@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80" + integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg== + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= + +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= + dependencies: + kind-of "^3.0.2" + +to-readable-stream@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771" + integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q== + +to-readable-stream@^2.0.0, to-readable-stream@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-2.1.0.tgz#82880316121bea662cdc226adb30addb50cb06e8" + integrity sha512-o3Qa6DGg1CEXshSdvWNX2sN4QHqg03SPq7U6jPXRahlQdl5dK8oXjkU/2/sGrnOZKeGV1zLSO8qPwyKklPPE7w== + +to-regex-range@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" + integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= + dependencies: + is-number "^3.0.0" + repeat-string "^1.6.1" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +to-regex@^3.0.1, to-regex@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" + integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== + dependencies: + define-property "^2.0.2" + extend-shallow "^3.0.2" + regex-not "^1.0.2" + safe-regex "^1.1.0" + +to-vfile@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/to-vfile/-/to-vfile-6.1.0.tgz#5f7a3f65813c2c4e34ee1f7643a5646344627699" + integrity sha512-BxX8EkCxOAZe+D/ToHdDsJcVI4HqQfmw0tCkp31zf3dNP/XWIAjU4CmeuSwsSoOzOTqHPOL0KUzyZqJplkD0Qw== + dependencies: + is-buffer "^2.0.0" + vfile "^4.0.0" + +toidentifier@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" + integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== + +toml@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/toml/-/toml-3.0.0.tgz#342160f1af1904ec9d204d03a5d61222d762c5ee" + integrity sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w== + +tomlify-j0.4@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tomlify-j0.4/-/tomlify-j0.4-3.0.0.tgz#99414d45268c3a3b8bf38be82145b7bba34b7473" + integrity sha512-2Ulkc8T7mXJ2l0W476YC/A209PR38Nw8PuaCNtk9uI3t1zzFdGQeWYGQvmj2PZkVvRC/Yoi4xQKMRnWc/N29tQ== + +tr46@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" + integrity sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk= + dependencies: + punycode "^2.1.0" + +treeify@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/treeify/-/treeify-1.1.0.tgz#4e31c6a463accd0943879f30667c4fdaff411bb8" + integrity sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A== + +trim-repeated@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/trim-repeated/-/trim-repeated-1.0.0.tgz#e3646a2ea4e891312bf7eace6cfb05380bc01c21" + integrity sha1-42RqLqTokTEr9+rObPsFOAvAHCE= + dependencies: + escape-string-regexp "^1.0.2" + +trim-trailing-lines@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/trim-trailing-lines/-/trim-trailing-lines-1.1.3.tgz#7f0739881ff76657b7776e10874128004b625a94" + integrity sha512-4ku0mmjXifQcTVfYDfR5lpgV7zVqPg6zV9rdZmwOPqq0+Zq19xDqEgagqVbc4pOOShbncuAOIs59R3+3gcF3ZA== + +trim@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd" + integrity sha1-WFhUf2spB1fulczMZm+1AITEYN0= + +triple-beam@^1.2.0, triple-beam@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" + integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw== + +trough@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406" + integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA== + +tryer@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" + integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA== + +ts-essentials@^2.0.3: + version "2.0.12" + resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-2.0.12.tgz#c9303f3d74f75fa7528c3d49b80e089ab09d8745" + integrity sha512-3IVX4nI6B5cc31/GFFE+i8ey/N2eA0CZDbo6n0yrz0zDX8ZJ8djmU1p+XRz7G3is0F3bB3pu2pAroFdAWQKU3w== + +ts-pnp@^1.1.6: + version "1.2.0" + resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92" + integrity sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw== + +tslib@^1, tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: + version "1.13.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043" + integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q== + +tslib@^2, tslib@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" + integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== + +tsutils@^3.21.0: + version "3.21.0" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" + integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== + dependencies: + tslib "^1.8.1" + +tty-browserify@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" + integrity sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY= + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= + dependencies: + safe-buffer "^5.0.1" + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= + dependencies: + prelude-ls "~1.1.2" + +type-fest@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.10.0.tgz#7f06b2b9fbfc581068d1341ffabd0349ceafc642" + integrity sha512-EUV9jo4sffrwlg8s0zDhP0T2WD3pru5Xi0+HTE3zTUmBaZNhfkite9PdSJwdXLwPVW0jnAHT56pZHIOYckPEiw== + +type-fest@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.11.0.tgz#97abf0872310fed88a5c466b25681576145e33f1" + integrity sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ== + +type-fest@^0.16.0: + version "0.16.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.16.0.tgz#3240b891a78b0deae910dbeb86553e552a148860" + integrity sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg== + +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + +type-fest@^0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.3.1.tgz#63d00d204e059474fe5e1b7c011112bbd1dc29e1" + integrity sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ== + +type-fest@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" + integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== + +type-fest@^0.8.0, type-fest@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" + integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== + +type-is@~1.6.17, type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +typedarray-to-buffer@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" + integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== + dependencies: + is-typedarray "^1.0.0" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= + +typescript@^3.9.7: + version "3.9.10" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8" + integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q== + +typescript@^4.1.5: + version "4.3.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.4.tgz#3f85b986945bcf31071decdd96cf8bfa65f9dcbc" + integrity sha512-uauPG7XZn9F/mo+7MrsRjyvbxFpzemRjKEZXS4AK83oP2KKOJPvb+9cO/gmnv8arWZvhnjVOXz7B49m1l0e9Ew== + +ua-parser-js@^0.7.18: + version "0.7.21" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777" + integrity sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ== + +uid-safe@2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" + integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA== + dependencies: + random-bytes "~1.0.0" + +unbzip2-stream@^1.0.9: + version "1.4.3" + resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" + integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg== + dependencies: + buffer "^5.2.1" + through "^2.3.8" + +unherit@^1.0.4: + version "1.1.3" + resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.3.tgz#6c9b503f2b41b262330c80e91c8614abdaa69c22" + integrity sha512-Ft16BJcnapDKp0+J/rqFC3Rrk6Y/Ng4nzsC028k2jdDII/rdZ7Wd3pPT/6+vIIxRagwRc9K0IUX0Ra4fKvw+WQ== + dependencies: + inherits "^2.0.0" + xtend "^4.0.0" + +unicode-canonical-property-names-ecmascript@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" + integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ== + +unicode-match-property-ecmascript@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c" + integrity sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg== + dependencies: + unicode-canonical-property-names-ecmascript "^1.0.4" + unicode-property-aliases-ecmascript "^1.0.4" + +unicode-match-property-value-ecmascript@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.2.0.tgz#0d91f600eeeb3096aa962b1d6fc88876e64ea531" + integrity sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ== + +unicode-property-aliases-ecmascript@^1.0.4: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4" + integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg== + +unified-args@^8.0.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/unified-args/-/unified-args-8.1.0.tgz#a27dbe996a49fbbf3d9f5c6a98008ab9b0ee6ae5" + integrity sha512-t1HPS1cQPsVvt/6EtyWIbQGurza5684WGRigNghZRvzIdHm3LPgMdXPyGx0npORKzdiy5+urkF0rF5SXM8lBuQ== + dependencies: + camelcase "^5.0.0" + chalk "^3.0.0" + chokidar "^3.0.0" + fault "^1.0.2" + json5 "^2.0.0" + minimist "^1.2.0" + text-table "^0.2.0" + unified-engine "^8.0.0" + +unified-engine@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/unified-engine/-/unified-engine-8.0.0.tgz#e3996ff6eaecc6ca3408af92b70e25691192d17d" + integrity sha512-vLUezxCnjzz+ya4pYouRQVMT8k82Rk4fIj406UidRnSFJdGXFaQyQklAnalsQHJrLqAlaYPkXPUa1upfVSHGCA== + dependencies: + concat-stream "^2.0.0" + debug "^4.0.0" + fault "^1.0.0" + figures "^3.0.0" + glob "^7.0.3" + ignore "^5.0.0" + is-buffer "^2.0.0" + is-empty "^1.0.0" + is-plain-obj "^2.0.0" + js-yaml "^3.6.1" + load-plugin "^3.0.0" + parse-json "^5.0.0" + to-vfile "^6.0.0" + trough "^1.0.0" + unist-util-inspect "^5.0.0" + vfile-reporter "^6.0.0" + vfile-statistics "^1.1.0" + +unified-lint-rule@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/unified-lint-rule/-/unified-lint-rule-1.0.6.tgz#b4ab801ff93c251faa917a8d1c10241af030de84" + integrity sha512-YPK15YBFwnsVorDFG/u0cVVQN5G2a3V8zv5/N6KN3TCG+ajKtaALcy7u14DCSrJI+gZeyYquFL9cioJXOGXSvg== + dependencies: + wrapped "^1.0.1" + +unified-message-control@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/unified-message-control/-/unified-message-control-3.0.1.tgz#7018855daea9af96082cbea35970d48c9c4dbbf2" + integrity sha512-K2Kvvp1DBzeuxYLLsumZh/gDWUTl4e2z/P3VReFirC78cfHKtQifbhnfRrSBtKtd1Uc6cvYTW0/SZIUaMAEcTg== + dependencies: + unist-util-visit "^2.0.0" + vfile-location "^3.0.0" + +unified@9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.0.tgz#67a62c627c40589edebbf60f53edfd4d822027f8" + integrity sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg== + dependencies: + bail "^1.0.0" + extend "^3.0.0" + is-buffer "^2.0.0" + is-plain-obj "^2.0.0" + trough "^1.0.0" + vfile "^4.0.0" + +unified@^8.4.2: + version "8.4.2" + resolved "https://registry.yarnpkg.com/unified/-/unified-8.4.2.tgz#13ad58b4a437faa2751a4a4c6a16f680c500fff1" + integrity sha512-JCrmN13jI4+h9UAyKEoGcDZV+i1E7BLFuG7OsaDvTXI5P0qhHX+vZO/kOhz9jn8HGENDKbwSeB0nVOg4gVStGA== + dependencies: + bail "^1.0.0" + extend "^3.0.0" + is-plain-obj "^2.0.0" + trough "^1.0.0" + vfile "^4.0.0" + +unified@^9.0.0, unified@^9.1.0: + version "9.2.1" + resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.1.tgz#ae18d5674c114021bfdbdf73865ca60f410215a3" + integrity sha512-juWjuI8Z4xFg8pJbnEZ41b5xjGUWGHqXALmBZ3FC3WX0PIx1CZBIIJ6mXbYMcf6Yw4Fi0rFUTA1cdz/BglbOhA== + dependencies: + bail "^1.0.0" + extend "^3.0.0" + is-buffer "^2.0.0" + is-plain-obj "^2.0.0" + trough "^1.0.0" + vfile "^4.0.0" + +union-value@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" + integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg== + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^2.0.1" + +uniq@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" + integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8= + +uniqs@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" + integrity sha1-/+3ks2slKQaW5uFl1KWe25mOawI= + +unique-filename@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" + integrity sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ== + dependencies: + unique-slug "^2.0.0" + +unique-slug@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.2.tgz#baabce91083fc64e945b0f3ad613e264f7cd4e6c" + integrity sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w== + dependencies: + imurmurhash "^0.1.4" + +unique-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" + integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg== + dependencies: + crypto-random-string "^2.0.0" + +unist-builder@2.0.3, unist-builder@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/unist-builder/-/unist-builder-2.0.3.tgz#77648711b5d86af0942f334397a33c5e91516436" + integrity sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw== + +unist-util-generated@^1.0.0: + version "1.1.5" + resolved "https://registry.yarnpkg.com/unist-util-generated/-/unist-util-generated-1.1.5.tgz#1e903e68467931ebfaea386dae9ea253628acd42" + integrity sha512-1TC+NxQa4N9pNdayCYA1EGUOCAO0Le3fVp7Jzns6lnua/mYgwHo0tz5WUAfrdpNch1RZLHc61VZ1SDgrtNXLSw== + +unist-util-inspect@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/unist-util-inspect/-/unist-util-inspect-5.0.1.tgz#168c8770a99902318ca268f8c391e294bcf44540" + integrity sha512-fPNWewS593JSmg49HbnE86BJKuBi1/nMWhDSccBvbARfxezEuJV85EaARR9/VplveiwCoLm2kWq+DhP8TBaDpw== + dependencies: + is-empty "^1.0.0" + +unist-util-is@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-4.0.2.tgz#c7d1341188aa9ce5b3cff538958de9895f14a5de" + integrity sha512-Ofx8uf6haexJwI1gxWMGg6I/dLnF2yE+KibhD3/diOqY2TinLcqHXCV6OI5gFVn3xQqDH+u0M625pfKwIwgBKQ== + +unist-util-position@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-3.1.0.tgz#1c42ee6301f8d52f47d14f62bbdb796571fa2d47" + integrity sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA== + +unist-util-remove-position@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/unist-util-remove-position/-/unist-util-remove-position-2.0.1.tgz#5d19ca79fdba712301999b2b73553ca8f3b352cc" + integrity sha512-fDZsLYIe2uT+oGFnuZmy73K6ZxOPG/Qcm+w7jbEjaFcJgbQ6cqjs/eSPzXhsmGpAsWPkqZM9pYjww5QTn3LHMA== + dependencies: + unist-util-visit "^2.0.0" + +unist-util-remove@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unist-util-remove/-/unist-util-remove-2.0.0.tgz#32c2ad5578802f2ca62ab808173d505b2c898488" + integrity sha512-HwwWyNHKkeg/eXRnE11IpzY8JT55JNM1YCwwU9YNCnfzk6s8GhPXrVBBZWiwLeATJbI7euvoGSzcy9M29UeW3g== + dependencies: + unist-util-is "^4.0.0" + +unist-util-stringify-position@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz#cce3bfa1cdf85ba7375d1d5b17bdc4cada9bd9da" + integrity sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g== + dependencies: + "@types/unist" "^2.0.2" + +unist-util-visit-children@^1.1.3, unist-util-visit-children@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/unist-util-visit-children/-/unist-util-visit-children-1.1.4.tgz#e8a087e58a33a2815f76ea1901c15dec2cb4b432" + integrity sha512-sA/nXwYRCQVRwZU2/tQWUqJ9JSFM1X3x7JIOsIgSzrFHcfVt6NkzDtKzyxg2cZWkCwGF9CO8x4QNZRJRMK8FeQ== + +unist-util-visit-parents@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-3.1.0.tgz#4dd262fb9dcfe44f297d53e882fc6ff3421173d5" + integrity sha512-0g4wbluTF93npyPrp/ymd3tCDTMnP0yo2akFD2FIBAYXq/Sga3lwaU1D8OYKbtpioaI6CkDcQ6fsMnmtzt7htw== + dependencies: + "@types/unist" "^2.0.0" + unist-util-is "^4.0.0" + +unist-util-visit@2.0.3, unist-util-visit@^2.0.0, unist-util-visit@^2.0.1, unist-util-visit@^2.0.2, unist-util-visit@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-2.0.3.tgz#c3703893146df47203bb8a9795af47d7b971208c" + integrity sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q== + dependencies: + "@types/unist" "^2.0.0" + unist-util-is "^4.0.0" + unist-util-visit-parents "^3.0.0" + +universal-user-agent@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee" + integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w== + +universalify@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + +universalify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" + integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug== + +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + +unixify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unixify/-/unixify-1.0.0.tgz#3a641c8c2ffbce4da683a5c70f03a462940c2090" + integrity sha1-OmQcjC/7zk2mg6XHDwOkYpQMIJA= + dependencies: + normalize-path "^2.1.1" + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= + +unquote@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/unquote/-/unquote-1.1.1.tgz#8fded7324ec6e88a0ff8b905e7c098cdc086d544" + integrity sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ= + +unset-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" + integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= + dependencies: + has-value "^0.3.1" + isobject "^3.0.0" + +upath@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" + integrity sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg== + +update-notifier@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-4.1.1.tgz#895fc8562bbe666179500f9f2cebac4f26323746" + integrity sha512-9y+Kds0+LoLG6yN802wVXoIfxYEwh3FlZwzMwpCZp62S2i1/Jzeqb9Eeeju3NSHccGGasfGlK5/vEHbAifYRDg== + dependencies: + boxen "^4.2.0" + chalk "^3.0.0" + configstore "^5.0.1" + has-yarn "^2.1.0" + import-lazy "^2.1.0" + is-ci "^2.0.0" + is-installed-globally "^0.3.1" + is-npm "^4.0.0" + is-yarn-global "^0.3.0" + latest-version "^5.0.0" + pupa "^2.0.1" + semver-diff "^3.1.1" + xdg-basedir "^4.0.0" + +update-notifier@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-5.1.0.tgz#4ab0d7c7f36a231dd7316cf7729313f0214d9ad9" + integrity sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw== + dependencies: + boxen "^5.0.0" + chalk "^4.1.0" + configstore "^5.0.1" + has-yarn "^2.1.0" + import-lazy "^2.1.0" + is-ci "^2.0.0" + is-installed-globally "^0.4.0" + is-npm "^5.0.0" + is-yarn-global "^0.3.0" + latest-version "^5.1.0" + pupa "^2.1.1" + semver "^7.3.4" + semver-diff "^3.1.1" + xdg-basedir "^4.0.0" + +uri-js@^4.2.2: + version "4.4.0" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.0.tgz#aa714261de793e8a82347a7bcc9ce74e86f28602" + integrity sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g== + dependencies: + punycode "^2.1.0" + +urix@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" + integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= + +url-loader@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-4.1.1.tgz#28505e905cae158cf07c92ca622d7f237e70a4e2" + integrity sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA== + dependencies: + loader-utils "^2.0.0" + mime-types "^2.1.27" + schema-utils "^3.0.0" + +url-parse-lax@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c" + integrity sha1-FrXK/Afb42dsGxmZF3gj1lA6yww= + dependencies: + prepend-http "^2.0.0" + +url-parse@^1.4.3: + version "1.4.7" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278" + integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + +url-template@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21" + integrity sha1-/FZaPMy/93MMd19WQflVV5FDnyE= + +url-to-options@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9" + integrity sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k= + +url@0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64" + integrity sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ= + dependencies: + punycode "1.3.2" + querystring "0.2.0" + +url@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" + integrity sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE= + dependencies: + punycode "1.3.2" + querystring "0.2.0" + +use-composed-ref@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.1.0.tgz#9220e4e94a97b7b02d7d27eaeab0b37034438bbc" + integrity sha512-my1lNHGWsSDAhhVAT4MKs6IjBUtG6ZG11uUqexPH9PptiIZDQOzaF4f5tEbJ2+7qvNbtXNBbU3SfmN+fXlWDhg== + dependencies: + ts-essentials "^2.0.3" + +use-isomorphic-layout-effect@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.1.tgz#7bb6589170cd2987a152042f9084f9effb75c225" + integrity sha512-L7Evj8FGcwo/wpbv/qvSfrkHFtOpCzvM5yl2KVyDJoylVuSvzphiiasmjgQPttIGBAy2WKiBNR98q8w7PiNgKQ== + +use-latest@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.0.tgz#a44f6572b8288e0972ec411bdd0840ada366f232" + integrity sha512-d2TEuG6nSLKQLAfW3By8mKr8HurOlTkul0sOpxbClIv4SQ4iOd7BYr7VIzdbktUCnv7dua/60xzd8igMU6jmyw== + dependencies: + use-isomorphic-layout-effect "^1.0.0" + +use@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" + integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== + +utif@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/utif/-/utif-2.0.1.tgz#9e1582d9bbd20011a6588548ed3266298e711759" + integrity sha512-Z/S1fNKCicQTf375lIP9G8Sa1H/phcysstNrrSdZKj1f9g58J4NMgb5IgiEZN9/nLMPDwF0W7hdOe9Qq2IYoLg== + dependencies: + pako "^1.0.5" + +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + +util.promisify@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.0.tgz#440f7165a459c9a16dc145eb8e72f35687097030" + integrity sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA== + dependencies: + define-properties "^1.1.2" + object.getownpropertydescriptors "^2.0.3" + +util.promisify@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.1.tgz#6baf7774b80eeb0f7520d8b81d07982a59abbaee" + integrity sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.2" + has-symbols "^1.0.1" + object.getownpropertydescriptors "^2.1.0" + +util@0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" + integrity sha1-evsa/lCAUkZInj23/g7TeTNqwPk= + dependencies: + inherits "2.0.1" + +util@^0.11.0: + version "0.11.1" + resolved "https://registry.yarnpkg.com/util/-/util-0.11.1.tgz#3236733720ec64bb27f6e26f421aaa2e1b588d61" + integrity sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ== + dependencies: + inherits "2.0.3" + +utila@^0.4.0, utila@~0.4: + version "0.4.0" + resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" + integrity sha1-ihagXURWV6Oupe7MWxKk+lN5dyw= + +utility-opentype@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/utility-opentype/-/utility-opentype-0.1.4.tgz#076be3eed1a0f5aa6123982ce41c7d055a15c22a" + integrity sha1-B2vj7tGg9aphI5gs5Bx9BVoVwio= + +utility-types@^3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b" + integrity sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg== + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= + +uuid@3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" + integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== + +uuid@^3.3.2, uuid@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + +uuid@^8.0.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.0.tgz#ab738085ca22dc9a8c92725e459b1d507df5d6ea" + integrity sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ== + +validate-npm-package-license@^3.0.1: + version "3.0.4" + resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" + integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== + dependencies: + spdx-correct "^3.0.0" + spdx-expression-parse "^3.0.0" + +validate-npm-package-name@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz#5fa912d81eb7d0c74afc140de7317f0ca7df437e" + integrity sha1-X6kS2B630MdK/BQN5zF/DKffQ34= + dependencies: + builtins "^1.0.3" + +value-equal@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" + integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== + +vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= + +vendors@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.4.tgz#e2b800a53e7a29b93506c3cf41100d16c4c4ad8e" + integrity sha512-/juG65kTL4Cy2su4P8HjtkTxk6VmJDiOPBufWniqQ6wknac6jNiXS9vU+hO3wgusiyqWlzTbVHi0dyJqRONg3w== + +vfile-location@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-3.1.0.tgz#81cd8a04b0ac935185f4fce16f270503fc2f692f" + integrity sha512-FCZ4AN9xMcjFIG1oGmZKo61PjwJHRVA+0/tPUP2ul4uIwjGGndIxavEMRpWn5p4xwm/ZsdXp9YNygf1ZyE4x8g== + +vfile-message@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-2.0.4.tgz#5b43b88171d409eae58477d13f23dd41d52c371a" + integrity sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ== + dependencies: + "@types/unist" "^2.0.0" + unist-util-stringify-position "^2.0.0" + +vfile-reporter@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/vfile-reporter/-/vfile-reporter-6.0.1.tgz#45d4dc11df2e312196ea2ceb95e42a67fc8ce814" + integrity sha512-0OppK9mo8G2XUpv+hIKLVSDsoxJrXnOy73+vIm0jQUOUFYRduqpFHX+QqAQfvRHyX9B0UFiRuNJnBOjQCIsw1g== + dependencies: + repeat-string "^1.5.0" + string-width "^4.0.0" + supports-color "^6.0.0" + unist-util-stringify-position "^2.0.0" + vfile-sort "^2.1.2" + vfile-statistics "^1.1.0" + +vfile-sort@^2.1.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/vfile-sort/-/vfile-sort-2.2.2.tgz#720fe067ce156aba0b411a01bb0dc65596aa1190" + integrity sha512-tAyUqD2R1l/7Rn7ixdGkhXLD3zsg+XLAeUDUhXearjfIcpL1Hcsj5hHpCoy/gvfK/Ws61+e972fm0F7up7hfYA== + +vfile-statistics@^1.1.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/vfile-statistics/-/vfile-statistics-1.1.4.tgz#b99fd15ecf0f44ba088cc973425d666cb7a9f245" + integrity sha512-lXhElVO0Rq3frgPvFBwahmed3X03vjPF8OcjKMy8+F1xU/3Q3QU3tKEDp743SFtb74PdF0UWpxPvtOP0GCLheA== + +vfile@^4.0.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-4.2.0.tgz#26c78ac92eb70816b01d4565e003b7e65a2a0e01" + integrity sha512-a/alcwCvtuc8OX92rqqo7PflxiCgXRFjdyoGVuYV+qbgCb0GgZJRvIgCD4+U/Kl1yhaRsaTwksF88xbPyGsgpw== + dependencies: + "@types/unist" "^2.0.0" + is-buffer "^2.0.0" + replace-ext "1.0.0" + unist-util-stringify-position "^2.0.0" + vfile-message "^2.0.0" + +vm-browserify@^1.0.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" + integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== + +wait-file@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/wait-file/-/wait-file-1.0.5.tgz#377f48795f1765046a41bb0671c142ef8e509ae6" + integrity sha512-udLpJY/eOxlrMm3+XD1RLuF2oT9B7J7wiyR5/9xrvQymS6YR6trWvVhzOldHrVbLwyiRmLj9fcvsjzpSXeZHkw== + dependencies: + "@hapi/joi" "^15.1.0" + fs-extra "^8.1.0" + rx "^4.1.0" + +wait-port@^0.2.2: + version "0.2.9" + resolved "https://registry.yarnpkg.com/wait-port/-/wait-port-0.2.9.tgz#3905cf271b5dbe37a85c03b85b418b81cb24ee55" + integrity sha512-hQ/cVKsNqGZ/UbZB/oakOGFqic00YAMM5/PEj3Bt4vKarv2jWIWzDbqlwT94qMs/exAQAsvMOq99sZblV92zxQ== + dependencies: + chalk "^2.4.2" + commander "^3.0.2" + debug "^4.1.1" + +watchpack-chokidar2@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.0.tgz#9948a1866cbbd6cb824dea13a7ed691f6c8ddff0" + integrity sha512-9TyfOyN/zLUbA288wZ8IsMZ+6cbzvsNyEzSBp6e/zkifi6xxbl8SmQ/CxQq32k8NNqrdVEVUVSEf56L4rQ/ZxA== + dependencies: + chokidar "^2.1.8" + +watchpack@^1.7.4: + version "1.7.4" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.7.4.tgz#6e9da53b3c80bb2d6508188f5b200410866cd30b" + integrity sha512-aWAgTW4MoSJzZPAicljkO1hsi1oKj/RRq/OJQh2PKI2UKL04c2Bs+MBOB+BBABHTXJpf9mCwHN7ANCvYsvY2sg== + dependencies: + graceful-fs "^4.1.2" + neo-async "^2.5.0" + optionalDependencies: + chokidar "^3.4.1" + watchpack-chokidar2 "^2.0.0" + +wbuf@^1.1.0, wbuf@^1.7.3: + version "1.7.3" + resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df" + integrity sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA== + dependencies: + minimalistic-assert "^1.0.0" + +wcwidth@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" + integrity sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g= + dependencies: + defaults "^1.0.3" + +web-namespaces@^1.0.0, web-namespaces@^1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-1.1.4.tgz#bc98a3de60dadd7faefc403d1076d529f5e030ec" + integrity sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw== + +webidl-conversions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" + integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== + +webpack-bundle-analyzer@^3.6.1: + version "3.8.0" + resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-3.8.0.tgz#ce6b3f908daf069fd1f7266f692cbb3bded9ba16" + integrity sha512-PODQhAYVEourCcOuU+NiYI7WdR8QyELZGgPvB1y2tjbUpbmcQOt5Q7jEK+ttd5se0KSBKD9SXHCEozS++Wllmw== + dependencies: + acorn "^7.1.1" + acorn-walk "^7.1.1" + bfj "^6.1.1" + chalk "^2.4.1" + commander "^2.18.0" + ejs "^2.6.1" + express "^4.16.3" + filesize "^3.6.1" + gzip-size "^5.0.0" + lodash "^4.17.15" + mkdirp "^0.5.1" + opener "^1.5.1" + ws "^6.0.0" + +webpack-dev-middleware@^3.7.2: + version "3.7.2" + resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.7.2.tgz#0019c3db716e3fa5cecbf64f2ab88a74bab331f3" + integrity sha512-1xC42LxbYoqLNAhV6YzTYacicgMZQTqRd27Sim9wn5hJrX3I5nxYy1SxSd4+gjUFsz1dQFj+yEe6zEVmSkeJjw== + dependencies: + memory-fs "^0.4.1" + mime "^2.4.4" + mkdirp "^0.5.1" + range-parser "^1.2.1" + webpack-log "^2.0.0" + +webpack-dev-server@^3.11.0: + version "3.11.0" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.11.0.tgz#8f154a3bce1bcfd1cc618ef4e703278855e7ff8c" + integrity sha512-PUxZ+oSTxogFQgkTtFndEtJIPNmml7ExwufBZ9L2/Xyyd5PnOL5UreWe5ZT7IU25DSdykL9p1MLQzmLh2ljSeg== + dependencies: + ansi-html "0.0.7" + bonjour "^3.5.0" + chokidar "^2.1.8" + compression "^1.7.4" + connect-history-api-fallback "^1.6.0" + debug "^4.1.1" + del "^4.1.1" + express "^4.17.1" + html-entities "^1.3.1" + http-proxy-middleware "0.19.1" + import-local "^2.0.0" + internal-ip "^4.3.0" + ip "^1.1.5" + is-absolute-url "^3.0.3" + killable "^1.0.1" + loglevel "^1.6.8" + opn "^5.5.0" + p-retry "^3.0.1" + portfinder "^1.0.26" + schema-utils "^1.0.0" + selfsigned "^1.10.7" + semver "^6.3.0" + serve-index "^1.9.1" + sockjs "0.3.20" + sockjs-client "1.4.0" + spdy "^4.0.2" + strip-ansi "^3.0.1" + supports-color "^6.1.0" + url "^0.11.0" + webpack-dev-middleware "^3.7.2" + webpack-log "^2.0.0" + ws "^6.2.1" + yargs "^13.3.2" + +webpack-log@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/webpack-log/-/webpack-log-2.0.0.tgz#5b7928e0637593f119d32f6227c1e0ac31e1b47f" + integrity sha512-cX8G2vR/85UYG59FgkoMamwHUIkSSlV3bBMRsbxVXVUk2j6NleCKjQ/WE9eYg9WY4w25O9w8wKP4rzNZFmUcUg== + dependencies: + ansi-colors "^3.0.0" + uuid "^3.3.2" + +webpack-merge@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-4.2.2.tgz#a27c52ea783d1398afd2087f547d7b9d2f43634d" + integrity sha512-TUE1UGoTX2Cd42j3krGYqObZbOD+xF7u28WB7tfUordytSjbWTIjK/8V0amkBfTYN4/pB/GIDlJZZ657BGG19g== + dependencies: + lodash "^4.17.15" + +webpack-sources@^1.1.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" + integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== + dependencies: + source-list-map "^2.0.0" + source-map "~0.6.1" + +webpack@^4.41.2: + version "4.46.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.46.0.tgz#bf9b4404ea20a073605e0a011d188d77cb6ad542" + integrity sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-module-context" "1.9.0" + "@webassemblyjs/wasm-edit" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" + acorn "^6.4.1" + ajv "^6.10.2" + ajv-keywords "^3.4.1" + chrome-trace-event "^1.0.2" + enhanced-resolve "^4.5.0" + eslint-scope "^4.0.3" + json-parse-better-errors "^1.0.2" + loader-runner "^2.4.0" + loader-utils "^1.2.3" + memory-fs "^0.4.1" + micromatch "^3.1.10" + mkdirp "^0.5.3" + neo-async "^2.6.1" + node-libs-browser "^2.2.1" + schema-utils "^1.0.0" + tapable "^1.1.3" + terser-webpack-plugin "^1.4.3" + watchpack "^1.7.4" + webpack-sources "^1.4.1" + +webpackbar@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/webpackbar/-/webpackbar-4.0.0.tgz#ee7a87f16077505b5720551af413c8ecd5b1f780" + integrity sha512-k1qRoSL/3BVuINzngj09nIwreD8wxV4grcuhHTD8VJgUbGcy8lQSPqv+bM00B7F+PffwIsQ8ISd4mIwRbr23eQ== + dependencies: + ansi-escapes "^4.2.1" + chalk "^2.4.2" + consola "^2.10.0" + figures "^3.0.0" + pretty-time "^1.1.0" + std-env "^2.2.1" + text-table "^0.2.0" + wrap-ansi "^6.0.0" + +websocket-driver@0.6.5: + version "0.6.5" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.6.5.tgz#5cb2556ceb85f4373c6d8238aa691c8454e13a36" + integrity sha1-XLJVbOuF9Dc8bYI4qmkchFThOjY= + dependencies: + websocket-extensions ">=0.1.1" + +websocket-driver@>=0.5.1: + version "0.7.4" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" + integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== + dependencies: + http-parser-js ">=0.5.1" + safe-buffer ">=5.1.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.4" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" + integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== + +well-known-symbols@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/well-known-symbols/-/well-known-symbols-2.0.0.tgz#e9c7c07dbd132b7b84212c8174391ec1f9871ba5" + integrity sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q== + +whatwg-url@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" + integrity sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + +which-module@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" + integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= + +which-pm-runs@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.0.0.tgz#670b3afbc552e0b55df6b7780ca74615f23ad1cb" + integrity sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs= + +which@^1.2.9, which@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +which@^2.0.1, which@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wide-align@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" + integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== + dependencies: + string-width "^1.0.2 || 2" + +widest-line@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" + integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== + dependencies: + string-width "^4.0.0" + +windows-release@^3.1.0: + version "3.3.3" + resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.3.3.tgz#1c10027c7225743eec6b89df160d64c2e0293999" + integrity sha512-OSOGH1QYiW5yVor9TtmXKQvt2vjQqbYS+DqmsZw+r7xDwLXEeT3JGW0ZppFmHx4diyXmxt238KFR3N9jzevBRg== + dependencies: + execa "^1.0.0" + +winston-transport@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.4.0.tgz#17af518daa690d5b2ecccaa7acf7b20ca7925e59" + integrity sha512-Lc7/p3GtqtqPBYYtS6KCN3c77/2QCev51DvcJKbkFPQNoj1sinkGwLGFDxkXY9J6p9+EPnYs+D90uwbnaiURTw== + dependencies: + readable-stream "^2.3.7" + triple-beam "^1.2.0" + +winston@^3.2.1: + version "3.3.3" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.3.3.tgz#ae6172042cafb29786afa3d09c8ff833ab7c9170" + integrity sha512-oEXTISQnC8VlSAKf1KYSSd7J6IWuRPQqDdo8eoRNaYKLvwSb5+79Z3Yi1lrl6KDpU6/VWaxpakDAtb1oQ4n9aw== + dependencies: + "@dabh/diagnostics" "^2.0.2" + async "^3.1.0" + is-stream "^2.0.0" + logform "^2.2.0" + one-time "^1.0.0" + readable-stream "^3.4.0" + stack-trace "0.0.x" + triple-beam "^1.3.0" + winston-transport "^4.4.0" + +word-wrap@~1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" + integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + +worker-farm@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8" + integrity sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw== + dependencies: + errno "~0.1.7" + +worker-rpc@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/worker-rpc/-/worker-rpc-0.1.1.tgz#cb565bd6d7071a8f16660686051e969ad32f54d5" + integrity sha512-P1WjMrUB3qgJNI9jfmpZ/htmBEjFh//6l/5y8SD9hg1Ef5zTTVVoRjTrTEzPrNBQvmhMxkoTsjOXN10GWU7aCg== + dependencies: + microevent.ts "~0.1.1" + +wrap-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-3.0.1.tgz#288a04d87eda5c286e060dfe8f135ce8d007f8ba" + integrity sha1-KIoE2H7aXChuBg3+jxNc6NAH+Lo= + dependencies: + string-width "^2.1.1" + strip-ansi "^4.0.0" + +wrap-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-4.0.0.tgz#b3570d7c70156159a2d42be5cc942e957f7b1131" + integrity sha512-uMTsj9rDb0/7kk1PbcbCcwvHUxp60fGDB/NNXpVa0Q+ic/e7y5+BwTxKfQ33VYgDppSwi/FBzpetYzo8s6tfbg== + dependencies: + ansi-styles "^3.2.0" + string-width "^2.1.1" + strip-ansi "^4.0.0" + +wrap-ansi@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" + integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== + dependencies: + ansi-styles "^3.2.0" + string-width "^3.0.0" + strip-ansi "^5.0.0" + +wrap-ansi@^6.0.0, wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrapped@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/wrapped/-/wrapped-1.0.1.tgz#c783d9d807b273e9b01e851680a938c87c907242" + integrity sha1-x4PZ2Aeyc+mwHoUWgKk4yHyQckI= + dependencies: + co "3.1.0" + sliced "^1.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +write-file-atomic@^3.0.0, write-file-atomic@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" + integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== + dependencies: + imurmurhash "^0.1.4" + is-typedarray "^1.0.0" + signal-exit "^3.0.2" + typedarray-to-buffer "^3.1.5" + +ws@^6.0.0, ws@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb" + integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA== + dependencies: + async-limiter "~1.0.0" + +xdg-basedir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" + integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== + +xhr@^2.0.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/xhr/-/xhr-2.5.0.tgz#bed8d1676d5ca36108667692b74b316c496e49dd" + integrity sha512-4nlO/14t3BNUZRXIXfXe+3N6w3s1KoxcJUUURctd64BLRe67E4gRwp4PjywtDY72fXpZ1y6Ch0VZQRY/gMPzzQ== + dependencies: + global "~4.3.0" + is-function "^1.0.1" + parse-headers "^2.0.0" + xtend "^4.0.0" + +xml-parse-from-string@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz#a9029e929d3dbcded169f3c6e28238d95a5d5a28" + integrity sha1-qQKekp09vN7RafPG4oI42VpdWig= + +xml2js@0.4.19: + version "0.4.19" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7" + integrity sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q== + dependencies: + sax ">=0.6.0" + xmlbuilder "~9.0.1" + +xml2js@^0.4.5: + version "0.4.23" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" + integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + +xmlbuilder@^13.0.0: + version "13.0.2" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-13.0.2.tgz#02ae33614b6a047d1c32b5389c1fdacb2bce47a7" + integrity sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ== + +xmlbuilder@~11.0.0: + version "11.0.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" + integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== + +xmlbuilder@~9.0.1: + version "9.0.7" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" + integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0= + +xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" + integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yaml@^1.10.0, yaml@^1.7.2, yaml@^1.8.3: + version "1.10.0" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e" + integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg== + +yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs@^13.3.2: + version "13.3.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.2" + +yargs@^15.3.0, yargs@^15.3.1: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2" + +yargs@^16.0.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yarn@^1.21.1: + version "1.22.5" + resolved "https://registry.yarnpkg.com/yarn/-/yarn-1.22.5.tgz#1933b7635429ca00847222dd9d38f05646e2df23" + integrity sha512-5uzKXwdMc++mYktXqkfpNYT9tY8ViWegU58Hgbo+KXzrzzhEyP1Ip+BTtXloLrXNcNlxFJbLiFKGaS9vK9ym6Q== + +yauzl@^2.4.2: + version "2.10.0" + resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" + integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk= + dependencies: + buffer-crc32 "~0.2.3" + fd-slicer "~1.1.0" + +zip-stream@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-4.1.0.tgz#51dd326571544e36aa3f756430b313576dc8fc79" + integrity sha512-zshzwQW7gG7hjpBlgeQP9RuyPGNxvJdzR8SUM3QhxCnLjWN2E7j3dOvpeDcQoETfHx0urRS7EtmVToql7YpU4A== + dependencies: + archiver-utils "^2.1.0" + compress-commons "^4.1.0" + readable-stream "^3.6.0" + +zwitch@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920" + integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw== diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/concertbot/README.md b/examples/concertbot/README.md new file mode 100644 index 0000000..ee714bd --- /dev/null +++ b/examples/concertbot/README.md @@ -0,0 +1,49 @@ +# Concertbot + +Example bot that contains only story data. + +## What’s inside this example? + +This example contains some training data and the main files needed to build an +assistant on your local machine. The `concertbot` consists of the following files: + +- **data/stories.md** contains training stories for the Core model +- **actions/actions.py** contains some custom actions +- **config.yml** contains the model configuration +- **domain.yml** contains the domain of the assistant +- **endpoints.yml** contains the webhook configuration for the custom actions + +## How to use this example? + +To train a model, run +``` +rasa train core -d domain.yml -s data/stories.md --out models -c config.yml +``` + +To create new training data using interactive learning, execute +``` +rasa interactive core -d domain.yml -m models -c config.yml --stories data +``` + +To visualize your story data, run +``` +rasa visualize +``` + +To run a Rasa server, execute +``` +rasa run actions& +rasa run -m models --endpoints endpoints.yml +``` + +To chat with your bot on the command line, run +``` +rasa run actions& +rasa shell -m models +``` + +For more information about the individual commands, please check out our +[documentation](http://rasa.com/docs/rasa/command-line-interface). + +## Encountered any issues? +Let us know about it by posting on [Rasa Community Forum](https://forum.rasa.com)! diff --git a/examples/concertbot/actions/__init__.py b/examples/concertbot/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/concertbot/actions/actions.py b/examples/concertbot/actions/actions.py new file mode 100644 index 0000000..1036536 --- /dev/null +++ b/examples/concertbot/actions/actions.py @@ -0,0 +1,67 @@ +from rasa_sdk import Action +from rasa_sdk.events import SlotSet + + +class ActionSearchConcerts(Action): + def name(self): + return "action_search_concerts" + + def run(self, dispatcher, tracker, domain): + concerts = [ + {"artist": "Foo Fighters", "reviews": 4.5}, + {"artist": "Katy Perry", "reviews": 5.0}, + ] + description = ", ".join([c["artist"] for c in concerts]) + dispatcher.utter_message(text=f"{description}") + return [SlotSet("concerts", concerts)] + + +class ActionSearchVenues(Action): + def name(self): + return "action_search_venues" + + def run(self, dispatcher, tracker, domain): + venues = [ + {"name": "Big Arena", "reviews": 4.5}, + {"name": "Rock Cellar", "reviews": 5.0}, + ] + dispatcher.utter_message(text="here are some venues I found") + description = ", ".join([c["name"] for c in venues]) + dispatcher.utter_message(text=f"{description}") + return [SlotSet("venues", venues)] + + +class ActionShowConcertReviews(Action): + def name(self): + return "action_show_concert_reviews" + + def run(self, dispatcher, tracker, domain): + concerts = tracker.get_slot("concerts") + dispatcher.utter_message(text=f"concerts from slots: {concerts}") + return [] + + +class ActionShowVenueReviews(Action): + def name(self): + return "action_show_venue_reviews" + + def run(self, dispatcher, tracker, domain): + venues = tracker.get_slot("venues") + dispatcher.utter_message(text=f"venues from slots: {venues}") + return [] + + +class ActionSetMusicPreference(Action): + def name(self): + return "action_set_music_preference" + + def run(self, dispatcher, tracker, domain): + """Sets the slot 'likes_music' to true/false dependent on whether the user + likes music""" + intent = tracker.latest_message["intent"].get("name") + + if intent == "affirm": + return [SlotSet("likes_music", True)] + elif intent == "deny": + return [SlotSet("likes_music", False)] + return [] diff --git a/examples/concertbot/config.yml b/examples/concertbot/config.yml new file mode 100644 index 0000000..3871761 --- /dev/null +++ b/examples/concertbot/config.yml @@ -0,0 +1,28 @@ +recipe: default.v1 +assistant_id: concert_bot +language: en + +pipeline: + - name: "WhitespaceTokenizer" + - name: "RegexFeaturizer" + - name: "LexicalSyntacticFeaturizer" + - name: "CountVectorsFeaturizer" + - name: "CountVectorsFeaturizer" + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: "DIETClassifier" + epochs: 100 + - name: FallbackClassifier + threshold: 0.4 + ambiguity_threshold: 0.1 + - name: "EntitySynonymMapper" + +policies: + - name: TEDPolicy + max_history: 5 + epochs: 200 + batch_size: 50 + max_training_samples: 300 + - name: MemoizationPolicy + - name: RulePolicy diff --git a/examples/concertbot/data/nlu.yml b/examples/concertbot/data/nlu.yml new file mode 100644 index 0000000..8050e18 --- /dev/null +++ b/examples/concertbot/data/nlu.yml @@ -0,0 +1,58 @@ +version: "3.1" +nlu: + - intent: greet + examples: | + - hi + - hello + - how are you + - good morning + - good evening + - hey + + - intent: goodbye + examples: | + - bye + - goodbye + - ciao + + - intent: thankyou + examples: | + - thanks + - thank you + - thanks friend + + - intent: search_concerts + examples: | + - Find me some good concerts + - Show me concerts + - search concerts + + - intent: search_venues + examples: | + - Find me some good venues + - Show me venues + - search venues + + - intent: compare_reviews + examples: | + - compare reviews + - show me a comparison of the reviews + + - intent: how_to_get_started + examples: | + - how do I get started + - what can I do + - start + + - intent: affirm + examples: | + - yes + - yeah + - yep + + - intent: deny + examples: | + - nope + - no + - absolutely not + diff --git a/examples/concertbot/data/rules.yml b/examples/concertbot/data/rules.yml new file mode 100644 index 0000000..d933273 --- /dev/null +++ b/examples/concertbot/data/rules.yml @@ -0,0 +1,23 @@ +version: "3.1" +rules: + - rule: fallback + steps: + - intent: nlu_fallback + - action: utter_default + - rule: greet + steps: + - intent: greet + - action: utter_greet + - rule: thankyou + steps: + - intent: thankyou + - action: utter_youarewelcome + - rule: goodbye + steps: + - intent: goodbye + - action: utter_goodbye + - rule: challenge + steps: + - intent: bot_challenge + - action: utter_iamabot + diff --git a/examples/concertbot/data/stories.yml b/examples/concertbot/data/stories.yml new file mode 100644 index 0000000..b95e1ea --- /dev/null +++ b/examples/concertbot/data/stories.yml @@ -0,0 +1,53 @@ +version: "3.1" +stories: + - story: search_venues + steps: + - intent: search_venues + - action: action_search_venues + - slot_was_set: + - venues: [{"name": "Big Arena", "reviews": 4.5}] + + - story: search_concerts + steps: + - intent: search_concerts + - action: action_search_concerts + - slot_was_set: + - concerts: [{"artist": "Foo Fighters", "reviews": 4.5}] + + - story: compare_reviews_venues + steps: + - intent: search_venues + - action: action_search_venues + - slot_was_set: + - venues: [{"name": "Big Arena", "reviews": 4.5}] + - intent: compare_reviews + - action: action_show_venue_reviews + + - story: compare_reviews_concerts + steps: + - intent: search_concerts + - action: action_search_concerts + - slot_was_set: + - concerts: [{"artist": "Foo Fighters", "reviews": 4.5}] + - intent: compare_reviews + - action: action_show_concert_reviews + + - story: how_to_get_started, doesn't like music + steps: + - intent: how_to_get_started + - action: utter_get_started + - intent: deny + - action: action_set_music_preference + - slot_was_set: + - likes_music: False + - action: utter_goodbye + + - story: how_to_get_started, likes music + steps: + - intent: how_to_get_started + - action: utter_get_started + - intent: affirm + - action: action_set_music_preference + - slot_was_set: + - likes_music: True + - action: utter_awesome diff --git a/examples/concertbot/domain.yml b/examples/concertbot/domain.yml new file mode 100644 index 0000000..e7ce267 --- /dev/null +++ b/examples/concertbot/domain.yml @@ -0,0 +1,61 @@ +version: "3.1" + +intents: + - affirm + - deny + - greet + - thankyou + - goodbye + - search_concerts + - search_venues + - compare_reviews + - bot_challenge + - nlu_fallback + - how_to_get_started + +entities: + - name + +slots: + concerts: + type: list + influence_conversation: false + mappings: + - type: custom + venues: + type: list + influence_conversation: false + mappings: + - type: custom + likes_music: + type: bool + influence_conversation: true + mappings: + - type: custom + +responses: + utter_greet: + - text: "Hey there!" + utter_goodbye: + - text: "Goodbye :(" + utter_default: + - text: "Sorry, I didn't get that, can you rephrase?" + utter_youarewelcome: + - text: "You're very welcome." + utter_iamabot: + - text: "I am a bot, powered by Rasa." + utter_get_started: + - text: "I can help you find concerts and venues. Do you like music?" + utter_awesome: + - text: "Awesome! You can ask me things like \"Find me some concerts\" or \"What's a good venue\"" + +actions: + - action_search_concerts + - action_search_venues + - action_show_concert_reviews + - action_show_venue_reviews + - action_set_music_preference + +session_config: + session_expiration_time: 60 # value in minutes + carry_over_slots_to_new_session: true diff --git a/examples/concertbot/endpoints.yml b/examples/concertbot/endpoints.yml new file mode 100644 index 0000000..ecea7db --- /dev/null +++ b/examples/concertbot/endpoints.yml @@ -0,0 +1,5 @@ +action_endpoint: + url: http://localhost:5055/webhook + +#nlg: +# url: http://localhost:5056/nlg diff --git a/examples/e2ebot/config.yml b/examples/e2ebot/config.yml new file mode 100644 index 0000000..52fc587 --- /dev/null +++ b/examples/e2ebot/config.yml @@ -0,0 +1,19 @@ +recipe: default.v1 +assistant_id: e2e_bot +language: en +pipeline: + - name: WhitespaceTokenizer + intent_tokenization_flag: True + - name: LanguageModelFeaturizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 200 +policies: +- name: TEDPolicy + epochs: 200 diff --git a/examples/e2ebot/data/nlu.yml b/examples/e2ebot/data/nlu.yml new file mode 100644 index 0000000..041baeb --- /dev/null +++ b/examples/e2ebot/data/nlu.yml @@ -0,0 +1,48 @@ +version: "3.1" + +nlu: +- intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon + + +- intent: affirm + examples: | + - yes + - y + - indeed + - of course + - that sounds good + - correct + - yeah + +- intent: deny + examples: | + - no + - n + - never + - I don't think so + - don't like that + - no way + - had better not + +- intent: search_restaurant + examples: | + - I'm looking for some food + - show me a place to eat + - where should I eat tonight? + - list restaurants + - food + - I'm hungry diff --git a/examples/e2ebot/data/stories.yml b/examples/e2ebot/data/stories.yml new file mode 100644 index 0000000..c82fff4 --- /dev/null +++ b/examples/e2ebot/data/stories.yml @@ -0,0 +1,38 @@ +version: "3.1" + +stories: +- story: accepts suggestion + steps: + - intent: greet + - action: utter_greet + - intent: search_restaurant + - action: utter_suggest_cuisine + - intent: affirm + - action: utter_search_restaurants + +- story: accepts 3rd suggestion + steps: + - intent: search_restaurant + - action: utter_suggest_cuisine + - intent: deny + - action: utter_suggest_cuisine + - intent: deny + - action: utter_suggest_cuisine + - intent: affirm + - action: utter_search_restaurants + +- story: accepts suggestion, indirectly + steps: + - intent: greet + - action: utter_greet + - intent: search_restaurant + - action: utter_suggest_cuisine + - user: "I can always go for sushi" + - action: utter_search_restaurants + +- story: rejects suggestion, indirectly + steps: + - intent: search_restaurant + - action: utter_suggest_cuisine + - user: "I had that yesterday" + - action: utter_suggest_cuisine diff --git a/examples/e2ebot/domain.yml b/examples/e2ebot/domain.yml new file mode 100644 index 0000000..8af105b --- /dev/null +++ b/examples/e2ebot/domain.yml @@ -0,0 +1,23 @@ +version: "3.1" + +actions: + - utter_greet + - utter_suggest_cuisine + - utter_search_restaurants + +intents: + - greet + - affirm + - deny + - search_restaurant + +responses: + utter_greet: + - text: "hi!" + utter_suggest_cuisine: + - text: "how about greek food?" + - text: "how about chinese food?" + - text: "how about italian food?" + - text: "how about sushi?" + utter_search_restaurants: + - text: "great! here's what I found ..." diff --git a/examples/e2ebot/tests/test_stories.yml b/examples/e2ebot/tests/test_stories.yml new file mode 100644 index 0000000..3270793 --- /dev/null +++ b/examples/e2ebot/tests/test_stories.yml @@ -0,0 +1,61 @@ +version: "3.1" +stories: +- story: Happy path accepts suggestion + steps: + - user: | + hi + intent: greet + - action: utter_greet + - user: | + show me a place to eat + intent: search_restaurant + - action: utter_suggest_cuisine + - user: | + that sounds good + intent: affirm + - action: utter_search_restaurants + +- story: Happy path accepts 3rd suggestion + steps: + - user: | + where should I eat tonight + intent: search_restaurant + - action: utter_suggest_cuisine + - user: | + no way + intent: deny + - action: utter_suggest_cuisine + - user: | + had better not + intent: deny + - action: utter_suggest_cuisine + - user: | + indeed + intent: affirm + - action: utter_search_restaurants + +- story: Happy path accepts suggestion indirectly + steps: + - user: | + hi + intent: greet + - action: utter_greet + - user: | + where should I eat tonight + intent: search_restaurant + - action: utter_suggest_cuisine + - user: | + I can always go for sushi + intent: affirm + - action: utter_search_restaurants + +- story: Happy path rejects suggestion, indirectly + steps: + - user: | + food + intent: search_restaurant + - action: utter_suggest_cuisine + - user: | + I had that yesterday + intent: deny + - action: utter_suggest_cuisine diff --git a/examples/formbot/README.md b/examples/formbot/README.md new file mode 100644 index 0000000..27c2d0e --- /dev/null +++ b/examples/formbot/README.md @@ -0,0 +1,50 @@ +# Formbot + +The `formbot` example is designed to help you understand how the `FormAction` works and how +to implement it in practice. Using the code and data files in this directory, you +can build a simple restaurant search assistant capable of recommending +restaurants based on user preferences. + +## What’s inside this example? + +This example contains some training data and the main files needed to build an +assistant on your local machine. The `formbot` consists of the following files: + +- **data/nlu.yml** contains training examples for the NLU model +- **data/stories.yml** contains training stories for the Core model +- **actions/actions.py** contains the implementation of a custom `FormAction` +- **config.yml** contains the model configuration +- **domain.yml** contains the domain of the assistant +- **endpoints.yml** contains the webhook configuration for the custom actions + +## How to use this example? + +Using this example you can build an actual assistant which demonstrates the +functionality of the `FormAction`. You can test the example using the following +steps: + +1. Train a Rasa model containing the Rasa NLU and Rasa Core models by running: + ``` + rasa train + ``` + The model will be stored in the `/models` directory as a zipped file. + +2. Run an instance of [duckling](https://rasa.com/docs/rasa/nlu/components/#ducklingentityextractor) + on port 8000 by either running the docker command + ``` + docker run -p 8000:8000 rasa/duckling + ``` + or [installing duckling](https://github.com/facebook/duckling#requirements) directly on your machine and starting the server. + +3. Test the assistant by running: + ``` + rasa run actions& + rasa shell -m models --endpoints endpoints.yml + ``` + This will load the assistant in your command line for you to chat. + +For more information about the individual commands, please check out our +[documentation](http://rasa.com/docs/rasa/command-line-interface). + +## Encountered any issues? +Let us know about it by posting on [Rasa Community Forum](https://forum.rasa.com)! diff --git a/examples/formbot/actions/__init__.py b/examples/formbot/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/formbot/actions/actions.py b/examples/formbot/actions/actions.py new file mode 100644 index 0000000..1a1387e --- /dev/null +++ b/examples/formbot/actions/actions.py @@ -0,0 +1,95 @@ +from typing import Dict, Text, Any, List, Union + +from rasa_sdk import Tracker +from rasa_sdk.executor import CollectingDispatcher +from rasa_sdk.forms import FormValidationAction + + +class ValidateRestaurantForm(FormValidationAction): + """Example of a form validation action.""" + + def name(self) -> Text: + return "validate_restaurant_form" + + @staticmethod + def cuisine_db() -> List[Text]: + """Database of supported cuisines.""" + + return [ + "caribbean", + "chinese", + "french", + "greek", + "indian", + "italian", + "mexican", + ] + + @staticmethod + def is_int(string: Text) -> bool: + """Check if a string is an integer.""" + + try: + int(string) + return True + except ValueError: + return False + + def validate_cuisine( + self, + value: Text, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> Dict[Text, Any]: + """Validate cuisine value.""" + + if value.lower() in self.cuisine_db(): + # validation succeeded, set the value of the "cuisine" slot to value + return {"cuisine": value} + else: + dispatcher.utter_message(response="utter_wrong_cuisine") + # validation failed, set this slot to None, meaning the + # user will be asked for the slot again + return {"cuisine": None} + + def validate_num_people( + self, + value: Text, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> Dict[Text, Any]: + """Validate num_people value.""" + + if self.is_int(value) and int(value) > 0: + return {"num_people": value} + else: + dispatcher.utter_message(response="utter_wrong_num_people") + # validation failed, set slot to None + return {"num_people": None} + + def validate_outdoor_seating( + self, + value: Text, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> Dict[Text, Any]: + """Validate outdoor_seating value.""" + + if isinstance(value, str): + if "out" in value: + # convert "out..." to True + return {"outdoor_seating": True} + elif "in" in value: + # convert "in..." to False + return {"outdoor_seating": False} + else: + dispatcher.utter_message(response="utter_wrong_outdoor_seating") + # validation failed, set slot to None + return {"outdoor_seating": None} + + else: + # affirm/deny was picked up as True/False by the from_intent mapping + return {"outdoor_seating": value} diff --git a/examples/formbot/config.yml b/examples/formbot/config.yml new file mode 100644 index 0000000..e2e5793 --- /dev/null +++ b/examples/formbot/config.yml @@ -0,0 +1,23 @@ +recipe: default.v1 +assistant_id: form_bot +language: en + +pipeline: + - name: WhitespaceTokenizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + token_pattern: (?u)\b\w+\b + - name: DucklingEntityExtractor + url: http://localhost:8000 + dimensions: + - number + - name: DIETClassifier + epochs: 1 + run_eagerly: True + - name: EntitySynonymMapper + +policies: + - name: TEDPolicy + epochs: 1 + - name: AugmentedMemoizationPolicy + - name: RulePolicy diff --git a/examples/formbot/data/nlu.yml b/examples/formbot/data/nlu.yml new file mode 100644 index 0000000..226efd7 --- /dev/null +++ b/examples/formbot/data/nlu.yml @@ -0,0 +1,317 @@ +version: "3.1" +nlu: +- intent: greet + examples: | + - Hi + - Hey + - Hi bot + - Hey bot + - Hello + - Good morning + - hi again + - hi folks + - hi Mister + - hi pal! + - hi there + - greetings + - hello everybody + - hello is anybody there + - hello robot + - hallo + - heeey + - hi hi + - hey + - hey hey + - hello there + - hi + - hello + - yo + - hola + - hi? + - hey bot! + - hello friend + +- intent: request_restaurant + examples: | + - im looking for a restaurant + - can i get [swedish](cuisine) food in any area + - a restaurant that serves [caribbean](cuisine) food + - id like a restaurant + - im looking for a restaurant that serves [mediterranean](cuisine) food + - can i find a restaurant that serves [chinese](cuisine) + - i am looking for any place that serves [indonesian](cuisine) food for three + - i need to find a restaurant + - uh im looking for a restaurant that serves [kosher](cuisine) food + - uh can i find a restaurant and it should serve [brazilian](cuisine) food + - im looking for a restaurant serving [italian](cuisine) food + - restaurant please + - i'd like to book a table for two with [spanish](cuisine) cuisine + - i need a table for 4 + - book me a table for three at the [italian](cuisine) restaurant + - can you please book a table for 5? + - I would like to book a table for 2 + - looking for a table at the [mexican](cuisine) restaurant for five + - find me a table for 7 people + - Can I get a table for four at the place which server [greek](cuisine) food? + +- intent: affirm + examples: | + - yeah a cheap restaurant serving international food + - correct + - ye + - uh yes + - let's do it + - yeah + - uh yes + - um yes + - yes knocking + - that's correct + - yes yes + - right + - yea + - yes + - yes right + - yes and i dont care + - right on + - i love that + +- intent: deny + examples: | + - no + - no new selection + - no thanks + - no thank you + - uh no + - breath no + - do you have something else + - no this does not work for me + +- intent: inform + examples: | + - [afghan](cuisine) food + - how bout [asian oriental](cuisine) + - what about [indian](cuisine) food + - uh how about [turkish](cuisine) type of food + - um [english](cuisine) + - im looking for [tuscan](cuisine) food + - id like [moroccan](cuisine) food + - [seafood](cuisine) + - [french](cuisine) food + - serves [british](cuisine) food + - id like [canapes](cuisine) + - serving [jamaican](cuisine) food + - um what about [italian](cuisine) food + - im looking for [corsica](cuisine) food + - im looking for [world](cuisine) food + - serves [french](cuisine) food + - how about [indian](cuisine) food + - can i get [chinese](cuisine) food + - [irish](cuisine) food + - [english](cuisine) food + - [spanish](cuisine) food + - how bout one that serves [portuguese](cuisine) food and is cheap + - [german](cuisine) + - [korean](cuisine) food + - im looking for [romanian](cuisine) food + - serves [canapes](cuisine) food + - [gastropub](cuisine) + - i want [french](cuisine) food + - how about [modern european](cuisine) type of food + - it should serve [scandinavian](cuisine) food + - how [european](cuisine) + - how about [european](cuisine) food + - serves [traditional](cuisine) food + - [indonesian](cuisine) food + - [modern european](cuisine) + - serves [brazilian](cuisine) + - i would like [modern european](cuisine) food + - looking for [lebanese](cuisine) food + - [portuguese](cuisine) + - [european](cuisine) + - i want [polish](cuisine) food + - id like [thai](cuisine) + - i want to find [moroccan](cuisine) food + - [afghan](cuisine) + - [scottish](cuisine) food + - how about [vietnamese](cuisine) + - hi im looking for [mexican](cuisine) food + - how about [indian](cuisine) type of food + - [polynesian](cuisine) food + - [mexican](cuisine) + - instead could it be for four people + - any [japanese](cuisine) food + - what about [thai](cuisine) food + - how about [asian oriental](cuisine) food + - im looking for [japanese](cuisine) food + - im looking for [belgian](cuisine) food + - im looking for [turkish](cuisine) food + - serving [corsica](cuisine) food + - serving [gastro pub](cuisine:gastropub) + - is there [british](cuisine) food + - [world](cuisine) food + - im looking for something serves [japanese](cuisine) food + - id like a [greek](cuisine) + - im looking for [malaysian](cuisine) food + - i want to find [world](cuisine) food + - serves [pan asian](cuisine:asian) food + - looking for [afghan](cuisine) food + - that serves [portuguese](cuisine) food + - [asian oriental](cuisine:asian) food + - [russian](cuisine) food + - [corsica](cuisine) + - [asian oriental](cuisine:asian) + - serving [basque](cuisine) food + - how about [italian](cuisine) + - looking for [spanish](cuisine) food in the center of town + - it should serve [gastropub](cuisine) food + - [welsh](cuisine) food + - i want [vegetarian](cuisine) food + - im looking for [swedish](cuisine) food + - um how about [chinese](cuisine) food + - [world](cuisine) food + - can i have a [seafood](cuisine) please + - how about [italian](cuisine) food + - how about [korean](cuisine) + - [corsica](cuisine) food + - [scandinavian](cuisine) + - [vegetarian](cuisine) food + - what about [italian](cuisine) + - how about [portuguese](cuisine) food + - serving [french](cuisine) food + - [tuscan](cuisine) food + - how about uh [gastropub](cuisine) + - im looking for [creative](cuisine) food + - im looking for [malaysian](cuisine) food + - im looking for [unusual](cuisine) food + - [danish](cuisine) food + - how about [spanish](cuisine) food + - im looking for [vietnamese](cuisine) food + - [spanish](cuisine) + - a restaurant serving [romanian](cuisine) food + - im looking for [lebanese](cuisine) food + - [italian](cuisine) food + - a restaurant with [afghan](cuisine) food + - im looking for [traditional](cuisine) food + - uh i want [cantonese](cuisine) food + - im looking for [thai](cuisine) + - i want to seat [outside](seating) + - i want to seat [inside](seating) + - i want to seat [outdoor](seating) + - i want to seat [indoor](seating) + - let's go [inside](seating) + - [inside](seating) + - [outdoor](seating) + - prefer sitting [indoors](seating) + - I would like to seat [inside](seating) please + - I prefer sitting [outside](seating) + - my feedback is [good](feedback) + - my feedback is [great](feedback) + - it was [terrible](feedback) + - i consider it [success](feedback) + - you are [awful](feedback) + - for ten people + - 2 people + - for three people + - just one person + - book for seven people + - 2 please + - nine people + +- intent: thankyou + examples: | + - um thank you good bye + - okay cool uh good bye thank you + - okay thank you good bye + - you rock + - and thats all thank you and good bye + - thank you and good bye + - sorry about my mistakes thank you good bye + - noise thank you good bye + - thank you goodbye noise + - okay thank you goodbye + - uh thank you good bye + - thank you goodbye + - thank you goodbye noise thank you goodbye + - breath thank you goodbye + - thank you + - okay thank you + - thanks goodbye + - ah thank you goodbye + - thank you noise + - thank you good bye + - breath thank you very much goodbye + - thanks + - noise thank you goodbye + - unintelligible thank you goodbye + - uh okay thank you good bye + - thank you bye + - um okay thank you good bye + +- intent: chitchat + examples: | + - can you share your boss with me? + - i want to get to know your owner + - i want to know the company which designed you + - i want to know the company which generated you + - i want to know the company which invented you + - i want to know who invented you + - May I ask who invented you? + - please tell me the company who created you + - please tell me who created you + - tell me more about your creators + - tell me more about your founders + - Ahoy matey how are you? + - are you alright + - are you having a good day + - Are you ok? + - are you okay + - Do you feel good? + - how are things going + - how are things with you? + - How are things? + - how are you + - how are you doing + - how are you doing this morning + - how are you feeling + - how are you today + - How are you? + - How is the weather today? + - What's the weather like? + - How is the weather? + - What is the weather at your place? + - Do you have good weather? + - Is it raining? + - What's it like out there? + - Is it hot or cold? + - Beautiful day, isn't it? + - What's the weather forecast? + - Is it quite breezy outside? + +- intent: stop + examples: | + - ok then you cant help me + - that was shit, you're not helping + - you can't help me + - you can't help me with what i need + - i guess you can't help me then + - ok i guess you can't help me + - that's not what i want + - ok, but that doesnt help me + - this is leading to nothing + - this conversation is not really helpful + - you cannot help me with what I want + - I think you cant help me + - hm i don't think you can do what i want + - stop + - stop go back + - do you get anything? + - and you call yourself bot company? pff + - and that's it? + - nothing else? + +- intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? diff --git a/examples/formbot/data/rules.yml b/examples/formbot/data/rules.yml new file mode 100644 index 0000000..8609f14 --- /dev/null +++ b/examples/formbot/data/rules.yml @@ -0,0 +1,36 @@ +version: "3.1" +rules: + - rule: Greet user + steps: + - intent: greet + - action: utter_greet + + - rule: Thank you + steps: + - intent: thankyou + - action: utter_noworries + + - rule: Bot challenge + steps: + - intent: bot_challenge + - action: utter_iamabot + + - rule: Chitchat + steps: + - intent: chitchat + - action: utter_chitchat + + - rule: activate restaurant form + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + + - rule: submit form + condition: + - active_loop: restaurant_form + steps: + - action: restaurant_form + - active_loop: null + - action: utter_submit + - action: utter_slots_values diff --git a/examples/formbot/data/stories.yml b/examples/formbot/data/stories.yml new file mode 100644 index 0000000..ea656dc --- /dev/null +++ b/examples/formbot/data/stories.yml @@ -0,0 +1,25 @@ +version: "3.1" +stories: + - story: stop form + continue + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - intent: stop + - action: utter_ask_continue + - intent: affirm + - action: restaurant_form + - active_loop: null + - action: utter_submit + - action: utter_slots_values + + - story: stop form + stop + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - intent: stop + - action: utter_ask_continue + - intent: deny + - action: action_deactivate_loop + - active_loop: null diff --git a/examples/formbot/domain.yml b/examples/formbot/domain.yml new file mode 100644 index 0000000..1707012 --- /dev/null +++ b/examples/formbot/domain.yml @@ -0,0 +1,136 @@ +version: "3.1" +intents: + - request_restaurant: + use_entities: [] + - chitchat: + use_entities: [] + - inform + - affirm + - deny + - stop + - thankyou + - greet + - bot_challenge + +entities: + - cuisine + - number + - feedback + - seating + +slots: + cuisine: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: cuisine + num_people: + type: float + influence_conversation: false + mappings: + - type: from_entity + entity: number + intent: [inform, request_restaurant] + outdoor_seating: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: seating + - type: from_intent + intent: affirm + value: true + conditions: + - active_loop: restaurant_form + requested_slot: outdoor_seating + - type: from_intent + intent: deny + value: false + conditions: + - active_loop: restaurant_form + requested_slot: outdoor_seating + preferences: + type: text + influence_conversation: false + mappings: + - type: from_intent + intent: deny + value: no additional preferences + conditions: + - active_loop: restaurant_form + requested_slot: preferences + - type: from_text + not_intent: deny + conditions: + - active_loop: restaurant_form + requested_slot: preferences + feedback: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: feedback + - type: from_text + conditions: + - active_loop: restaurant_form + requested_slot: feedback + +responses: + utter_ask_cuisine: + - text: "What cuisine?" + utter_ask_num_people: + - text: "How many people?" + utter_ask_outdoor_seating: + - text: "Do you want to sit outside?" + utter_ask_preferences: + - text: "Please provide additional preferences" + utter_ask_feedback: + - text: "Please give your feedback on your experience so far" + utter_submit: + - text: "All done!" + utter_slots_values: + - text: "I am going to run a restaurant search using the following parameters:\n + - cuisine: {cuisine}\n + - num_people: {num_people}\n + - outdoor_seating: {outdoor_seating}\n + - preferences: {preferences}\n + - feedback: {feedback}" + utter_noworries: + - text: "You are welcome :)" + utter_chitchat: + - text: "chitchat" + utter_ask_continue: + - text: "Do you want to continue?" + utter_wrong_cuisine: + - text: "Cuisine type is not in the database, please try again" + utter_wrong_num_people: + - text: "Number of people should be a positive integer, please try again" + utter_wrong_outdoor_seating: + - text: "Could not convert input to boolean value, please try again" + utter_default: + - text: "Sorry, I didn't understand you, please try input something else" + utter_greet: + - text: "Hello! I am restaurant search assistant! How can I help?" + utter_iamabot: + - text: "I am a bot, powered by Rasa." + utter_restart: + - text: "restarted" + +actions: +- validate_restaurant_form + +forms: + restaurant_form: + ignored_intents: + - chitchat + required_slots: + - cuisine + - num_people + - outdoor_seating + - preferences + - feedback + +session_config: + session_expiration_time: 60 # value in minutes + carry_over_slots_to_new_session: true diff --git a/examples/formbot/endpoints.yml b/examples/formbot/endpoints.yml new file mode 100644 index 0000000..e8d74ad --- /dev/null +++ b/examples/formbot/endpoints.yml @@ -0,0 +1,2 @@ +action_endpoint: + url: http://localhost:5055/webhook diff --git a/examples/formbot/tests/test_stories.yml b/examples/formbot/tests/test_stories.yml new file mode 100644 index 0000000..03006c4 --- /dev/null +++ b/examples/formbot/tests/test_stories.yml @@ -0,0 +1,67 @@ +version: "3.1" +stories: +- story: Happy path + steps: + - user: | + hi + intent: greet + - action: utter_greet + - user: | + im looking for a restaurant + intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - active_loop: null + - action: utter_submit + - action: utter_slots_values + - user: | + thanks + intent: thankyou + - action: utter_noworries + +- story: Happy path with message providing requested value + steps: + - user: | + hi + intent: greet + - action: utter_greet + - user: | + im looking for a restaurant + intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - user: | + [afghan](cuisine) food + intent: inform + - action: restaurant_form + - active_loop: null + - action: utter_submit + - action: utter_slots_values + - user: | + thanks + intent: thankyou + - action: utter_noworries + +- story: unhappy path + steps: + - user: | + hi + intent: greet + - action: utter_greet + - user: | + im looking for a restaurant + intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - user: | + can you share your boss with me? + intent: chitchat + - action: utter_chitchat + - action: restaurant_form + - active_loop: null + - action: utter_submit + - action: utter_slots_values + - user: | + thanks + intent: thankyou + - action: utter_noworries diff --git a/examples/knowledgebasebot/README.md b/examples/knowledgebasebot/README.md new file mode 100644 index 0000000..960ce42 --- /dev/null +++ b/examples/knowledgebasebot/README.md @@ -0,0 +1,40 @@ +# Knowledge Base Bot + +This example bot uses a knowledge base to answer user's requests. + +## What’s inside this example? + +This example contains some training data and the main files needed to build an +assistant on your local machine. The `knowledgebasebot` consists of the following files: + +- **data/nlu.yml** contains training examples for the NLU model +- **data/stories.yml** contains training stories for the Core model +- **actions/actions.py** contains the custom action for querying the knowledge base +- **config.yml** contains the model configuration +- **domain.yml** contains the domain of the assistant +- **endpoints.yml** contains the webhook configuration for the custom action +- **knowledge_base_data.json** contains the data for the knowledge base + +## How to use this example? + +To train your knowledge base bot, execute +``` +rasa train +``` +This will store a zipped model file in `models/`. + +Start the action server by +``` +rasa run actions +``` + +To chat with the bot on the command line, run +``` +rasa shell +``` + +For more information about the individual commands, please check out our +[documentation](http://rasa.com/docs/rasa/command-line-interface). + +## Encountered any issues? +Let us know about it by posting on [Rasa Community Forum](https://forum.rasa.com)! diff --git a/examples/knowledgebasebot/actions/__init__.py b/examples/knowledgebasebot/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/knowledgebasebot/actions/actions.py b/examples/knowledgebasebot/actions/actions.py new file mode 100644 index 0000000..ac639f0 --- /dev/null +++ b/examples/knowledgebasebot/actions/actions.py @@ -0,0 +1,16 @@ +from rasa_sdk.knowledge_base.storage import InMemoryKnowledgeBase +from rasa_sdk.knowledge_base.actions import ActionQueryKnowledgeBase + + +class ActionMyKB(ActionQueryKnowledgeBase): + def __init__(self): + # load knowledge base with data from the given file + knowledge_base = InMemoryKnowledgeBase("knowledge_base_data.json") + + # overwrite the representation function of the hotel object + # by default the representation function is just the name of the object + knowledge_base.set_representation_function_of_object( + "hotel", lambda obj: obj["name"] + " (" + obj["city"] + ")" + ) + + super().__init__(knowledge_base) diff --git a/examples/knowledgebasebot/config.yml b/examples/knowledgebasebot/config.yml new file mode 100644 index 0000000..c1f18d6 --- /dev/null +++ b/examples/knowledgebasebot/config.yml @@ -0,0 +1,20 @@ +recipe: default.v1 +assistant_id: knowledge_base_bot +language: en + +pipeline: + - name: "WhitespaceTokenizer" + - name: "RegexFeaturizer" + - name: "LexicalSyntacticFeaturizer" + - name: "CountVectorsFeaturizer" + - name: "CountVectorsFeaturizer" + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: "DIETClassifier" + epochs: 100 + - name: "EntitySynonymMapper" + +policies: + - name: RulePolicy + diff --git a/examples/knowledgebasebot/data/nlu.yml b/examples/knowledgebasebot/data/nlu.yml new file mode 100644 index 0000000..9c034a0 --- /dev/null +++ b/examples/knowledgebasebot/data/nlu.yml @@ -0,0 +1,76 @@ +version: "3.1" +nlu: + - intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? + + - intent: greet + examples: | + - hey + - hello + - hi + - good morning + - good evening + - hey there + + - intent: goodbye + examples: | + - bye + - goodbye + - see you around + - see you later + + - intent: query_knowledge_base + examples: | + - what [restaurants]{"entity": "object_type", "value": "restaurant"} can you recommend? + - list some [restaurants]{"entity": "object_type", "value": "restaurant"} + - can you name some [restaurants]{"entity": "object_type", "value": "restaurant"} please? + - can you show me some [restaurant]{"entity": "object_type", "value": "restaurant"} options + - list [German]{"entity": "cuisine"} [restaurants]{"entity": "object_type", "value": "restaurant"} + - do you have any [mexican]{"entity": "cuisine"} [restaurants]{"entity": "object_type", "value": "restaurant"}? + - do you know the [price range]{"entity": "attribute", "value": "price-range"} of [that one]{"entity": "mention"}? + - what [cuisine]{"entity": "attribute"} is [it]{"entity": "mention"}? + - do you know what [cuisine]{"entity": "attribute"} the [last one]{"entity": "mention", "value": "LAST"} has? + - does [Donath]{"entity": "restaurant"} have [outside seating]{"entity": "attribute", "value": "outside-seating"}? + - what is the [price range]{"entity": "attribute", "value": "price-range"} of [Berlin Burrito Company]{"entity": "restaurant"}? + - what is with [I due forni]{"entity": "restaurant"}? + - Do you also have any [Vietnamese]{"entity": "cuisine"} [restaurants]{"entity": "object_type", "value": "restaurant"}? + - What about any [Mexican]{"entity": "cuisine", "value": "mexican"} [restaurants]{"entity": "object_type", "value": "restaurant"}? + - Do you also know some [Italian]{"entity": "cuisine"} [restaurants]{"entity": "object_type", "value": "restaurant"}? + - can you tell me the [price range]{"entity": "attribute", "value": "price-range"} of [that restaurant]{"entity": "mention"}? + - what [cuisine]{"entity": "attribute"} do [they]{"entity": "mention"} have? + - what [hotels]{"entity": "object_type", "value": "hotel"} can you recommend? + - please list some [hotels]{"entity": "object_type", "value": "hotel"} in [Frankfurt am Main]{"entity": "city"} for me + - what [hotels]{"entity": "object_type", "value": "hotel"} do you know in [Berlin]{"entity": "city"}? + - name some [hotels]{"entity": "object_type", "value": "hotel"} in [Berlin]{"entity": "city"} + - show me some [hotels]{"entity": "object_type", "value": "hotel"} + - what are [hotels]{"entity": "object_type", "value": "hotel"} in [Berlin]{"entity": "city"} + - does the [last]{"entity": "mention", "value": "LAST"} one offer [breakfast]{"entity": "attribute", "value": "breakfast-included"}? + - does the [second one]{"entity": "mention", "value": "2"} [include breakfast]{"entity": "attribute", "value": "breakfast-included"}? + - what is the [price range]{"entity": "attribute", "value": "price-range"} of the [second]{"entity": "mention", "value": "2"} hotel? + - does the [first]{"entity": "mention", "value": "1"} one have [wifi]{"entity": "attribute", "value": "free-wifi"}? + - does the [third]{"entity": "mention", "value": "3"} one have a [swimming pool]{"entity": "attribute", "value": "swimming-pool"}? + - what is the [star rating]{"entity": "attribute", "value": "star-rating"} of [Berlin Wall Hostel]{"entity": "hotel"}? + - Does the [Hilton]{"entity": "hotel"} have a [swimming pool]{"entity": "attribute", "value": "swimming-pool"}? + + - lookup: restaurant + examples: | + - Donath + - Berlin Burrito Company + - I due forni + - Lụa Restaurant + - Pfefferberg + - Marubi Ramen + - Gong Gan + + - lookup: hotel + examples: | + - Hilton + - B&B + - Berlin Wall Hostel + - City Hotel + - Jugendherberge + - Berlin Hotel diff --git a/examples/knowledgebasebot/data/rules.yml b/examples/knowledgebasebot/data/rules.yml new file mode 100644 index 0000000..20f9e8a --- /dev/null +++ b/examples/knowledgebasebot/data/rules.yml @@ -0,0 +1,18 @@ +version: "3.1" +rules: + - rule: greet + steps: + - intent: greet + - action: utter_greet + - rule: goodbye + steps: + - intent: goodbye + - action: utter_goodbye + - rule: query knowledge base + steps: + - intent: query_knowledge_base + - action: action_query_knowledge_base + - rule: bot challenge + steps: + - intent: bot_challenge + - action: utter_iamabot diff --git a/examples/knowledgebasebot/data/stories.yml b/examples/knowledgebasebot/data/stories.yml new file mode 100644 index 0000000..a25a13d --- /dev/null +++ b/examples/knowledgebasebot/data/stories.yml @@ -0,0 +1,8 @@ +version: "3.1" +stories: + - story: greet and then query knowledge base + steps: + - intent: greet + - action: utter_greet + - intent: query_knowledge_base + - action: action_query_knowledge_base diff --git a/examples/knowledgebasebot/domain.yml b/examples/knowledgebasebot/domain.yml new file mode 100644 index 0000000..d188cab --- /dev/null +++ b/examples/knowledgebasebot/domain.yml @@ -0,0 +1,84 @@ +version: "3.1" + +intents: + - greet + - goodbye + - query_knowledge_base + - bot_challenge + +entities: + - object_type + - mention + - attribute + - hotel + - restaurant + - cuisine + - city + +slots: + object_type: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: object_type + mention: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: mention + attribute: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: attribute + hotel: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: hotel + restaurant: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: restaurant + city: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: city + cuisine: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: cuisine + +actions: +- action_query_knowledge_base + +responses: + utter_greet: + - text: "Hey!" + - text: "Hello! How can I help you?" + + utter_goodbye: + - text: "Bye" + - text: "Goodbye. See you soon." + + utter_ask_rephrase: + - text: "Sorry, I'm not sure I understand. Can you rephrase?" + - text: "Can you please rephrase? I did not got that." + + utter_iamabot: + - text: "I am a bot, powered by Rasa." + + +session_config: + session_expiration_time: 60 # value in minutes + carry_over_slots_to_new_session: true diff --git a/examples/knowledgebasebot/endpoints.yml b/examples/knowledgebasebot/endpoints.yml new file mode 100644 index 0000000..86bc425 --- /dev/null +++ b/examples/knowledgebasebot/endpoints.yml @@ -0,0 +1,2 @@ +action_endpoint: + url: "http://localhost:5055/webhook" diff --git a/examples/knowledgebasebot/knowledge_base_data.json b/examples/knowledgebasebot/knowledge_base_data.json new file mode 100644 index 0000000..88661ca --- /dev/null +++ b/examples/knowledgebasebot/knowledge_base_data.json @@ -0,0 +1,125 @@ +{ + "restaurant": [ + { + "id": 0, + "name": "Donath", + "cuisine": "Italian", + "outside-seating": true, + "price-range": "mid-range" + }, + { + "id": 1, + "name": "Berlin Burrito Company", + "cuisine": "Mexican", + "outside-seating": false, + "price-range": "cheap" + }, + { + "id": 2, + "name": "I due forni", + "cuisine": "Italian", + "outside-seating": true, + "price-range": "mid-range" + }, + { + "id": 3, + "name": "Lụa Restaurant", + "cuisine": "Vietnamese", + "outside-seating": true, + "price-range": "cheap" + }, + { + "id": 4, + "name": "Pfefferberg", + "cuisine": "German", + "outside-seating": true, + "price-range": "mid-range" + }, + { + "id": 5, + "name": "Marubi Ramen", + "cuisine": "Japanese", + "outside-seating": false, + "price-range": "cheap" + }, + { + "id": 6, + "name": "Gong Gan", + "cuisine": "Korean", + "outside-seating": true, + "price-range": "cheap" + } + ], + "hotel": [ + { + "id": 0, + "name": "Hilton", + "price-range": "expensive", + "breakfast-included": true, + "city": "Berlin", + "free-wifi": true, + "star-rating": 5, + "swimming-pool": true + }, + { + "id": 1, + "name": "Hilton", + "price-range": "expensive", + "breakfast-included": true, + "city": "Frankfurt am Main", + "free-wifi": true, + "star-rating": 4, + "swimming-pool": false + }, + { + "id": 2, + "name": "B&B", + "price-range": "mid-range", + "breakfast-included": false, + "city": "Berlin", + "free-wifi": false, + "star-rating": 1, + "swimming-pool": false + }, + { + "id": 3, + "name": "Berlin Wall Hostel", + "price-range": "cheap", + "breakfast-included": false, + "city": "Berlin", + "free-wifi": true, + "star-rating": 2, + "swimming-pool": false + }, + { + "id": 4, + "name": "City Hotel", + "price-range": "expensive", + "breakfast-included": true, + "city": "Frankfurt am Main", + "free-wifi": true, + "star-rating": 3, + "swimming-pool": false + }, + { + "id": 5, + "name": "Jugendherberge", + "price-range": "cheap", + "breakfast-included": true, + "city": "Berlin", + "free-wifi": false, + "star-rating": 2, + "swimming-pool": false + }, + { + "id": 6, + "name": "Berlin Hotel", + "price-range": "mid-range", + "breakfast-included": true, + "city": "Berlin", + "free-wifi": true, + "star-rating": 3, + "swimming-pool": true + } + ] +} diff --git a/examples/moodbot/README.md b/examples/moodbot/README.md new file mode 100644 index 0000000..69042d5 --- /dev/null +++ b/examples/moodbot/README.md @@ -0,0 +1,47 @@ +# Moodbot + +The `moodbot` example simulates how you can use your bot on different channels. + +## What’s inside this example? + +This example contains some training data and the main files needed to build an +assistant on your local machine. The `moodbot` consists of the following files: + +- **data/nlu.yml** contains training examples for the NLU model +- **data/stories.yml** contains training stories for the Core model +- **config.yml** contains the model configuration +- **domain.yml** contains the domain of the assistant +- **credentials.yml** contains credentials for the different channels + +## How to use this example? + +Using this example you can build an actual assistant and chat with it on +different channels. To do so execute the following steps: + +1. Train a Rasa model containing the Rasa NLU and Rasa Core models by running: + ``` + rasa train + ``` + The model will be stored in the `/models` directory as a zipped file. + +2. Run a Rasa server that connects, for example, to Facebook: + ``` + rasa run -m models -p 5002 --connector facebook --credentials credentials.yml + ``` + If you want to connect to a different channel, replace `facebook` with the name of the + desired channel. + All available channels are listed in the `credentials.yml` file. + For more information on the different channels read our + [documentation](http://rasa.com/docs/rasa/messaging-and-voice-channels) + + If you don't want to use any channel, you can chat with your bot + on the command line, using the following command: + ``` + rasa shell + ``` + +For more information about the individual commands, please check out our +[documentation](http://rasa.com/docs/rasa/command-line-interface). + +## Encountered any issues? +Let us know about it by posting on [Rasa Community Forum](https://forum.rasa.com)! diff --git a/examples/moodbot/config.yml b/examples/moodbot/config.yml new file mode 100644 index 0000000..12e740a --- /dev/null +++ b/examples/moodbot/config.yml @@ -0,0 +1,19 @@ +recipe: default.v1 +assistant_id: mood_bot +language: en + +pipeline: + - name: "SpacyNLP" + model: "en_core_web_md" + - name: "SpacyTokenizer" + - name: "SpacyFeaturizer" + - name: "DIETClassifier" + entity_recognition: False + epochs: 1 + +policies: + - name: TEDPolicy + max_history: 5 + epochs: 1 + - name: MemoizationPolicy + - name: RulePolicy diff --git a/examples/moodbot/credentials.yml b/examples/moodbot/credentials.yml new file mode 100644 index 0000000..8f7fe95 --- /dev/null +++ b/examples/moodbot/credentials.yml @@ -0,0 +1,33 @@ +twilio: + account_sid: "ACbc2dxxxxxxxxxxxx19d54bdcd6e41186" + auth_token: "e231c197493a7122d475b4xxxxxxxxxx" + twilio_number: "+440123456789" + +slack: + slack_token: "xoxb-xxx" + slack_channel: "#my_channel" # leave out this param to post to DMs with bot app + slack_signing_secret: "123456701ea89032asdebfe5a74518" + +telegram: + access_token: "490161424:AAGlRxinBRtKGb21_rlOEMtDFZMXBl6EC0o" + verify: "your_bot" + webhook_url: "your_url.com/webhook" + +mattermost: + url: "https://chat.example.com/api/v4" + token: "YOUR-TOKEN" + +facebook: + verify: "rasa-bot" + secret: "3e34709d01ea89032asdebfe5a74518" + page-access-token: "EAAbHPa7H9rEBAAuFk4Q3gPKbDedQnx4djJJ1JmQ7CAqO4iJKrQcNT0wtD" + +webexteams: + access_token: "ADD-YOUR-BOT-ACCESS-TOKEN" + room: "YOUR-WEBEXTEAMS-ROOM-ID" + +rocketchat: + user: "your_bot_user" + password: "you_bot_pass" + server_url: "localhost:3000" + ssl: false diff --git a/examples/moodbot/data/nlu.yml b/examples/moodbot/data/nlu.yml new file mode 100644 index 0000000..8b50a4e --- /dev/null +++ b/examples/moodbot/data/nlu.yml @@ -0,0 +1,89 @@ +version: "3.1" +nlu: +- intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon + +- intent: goodbye + examples: | + - cu + - good by + - cee you later + - good night + - bye + - goodbye + - have a nice day + - see you around + - bye bye + - see you later + +- intent: affirm + examples: | + - yes + - y + - indeed + - of course + - that sounds good + - correct + +- intent: deny + examples: | + - no + - n + - never + - I don't think so + - don't like that + - no way + +- intent: mood_great + examples: | + - perfect + - great + - amazing + - feeling like a king + - wonderful + - I am feeling very good + - I am great + - I am amazing + - I am going to save the world + - super stoked + - extremely good + - so so perfect + - so good + - so perfect + +- intent: mood_unhappy + examples: | + - my day was horrible + - I am sad + - I don't feel very well + - I am disappointed + - super sad + - I'm so sad + - sad + - very sad + - unhappy + - not good + - not very good + - extremly sad + - so saad + - so sad + +- intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? diff --git a/examples/moodbot/data/rules.yml b/examples/moodbot/data/rules.yml new file mode 100644 index 0000000..7da88a7 --- /dev/null +++ b/examples/moodbot/data/rules.yml @@ -0,0 +1,11 @@ +version: "3.1" +rules: +- rule: Say goodbye anytime the user says goodbye + steps: + - intent: goodbye + - action: utter_goodbye + +- rule: Say 'I am a bot' anytime the user challenges + steps: + - intent: bot_challenge + - action: utter_iamabot diff --git a/examples/moodbot/data/stories.yml b/examples/moodbot/data/stories.yml new file mode 100644 index 0000000..5ed5834 --- /dev/null +++ b/examples/moodbot/data/stories.yml @@ -0,0 +1,29 @@ +version: "3.1" +stories: +- story: happy path + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy + +- story: sad path 1 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + +- story: sad path 2 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: deny + - action: utter_goodbye + diff --git a/examples/moodbot/domain.yml b/examples/moodbot/domain.yml new file mode 100644 index 0000000..de67a50 --- /dev/null +++ b/examples/moodbot/domain.yml @@ -0,0 +1,39 @@ +version: "3.1" + +intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + +responses: + utter_greet: + - text: "Hey! How are you?" + buttons: + - title: "great" + payload: "/mood_great" + - title: "super sad" + payload: "/mood_unhappy" + + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" + + utter_did_that_help: + - text: "Did that help you?" + + utter_happy: + - text: "Great, carry on!" + + utter_goodbye: + - text: "Bye" + + utter_iamabot: + - text: "I am a bot, powered by Rasa." + +session_config: + session_expiration_time: 60 # value in minutes + carry_over_slots_to_new_session: true diff --git a/examples/nlg_server/__init__.py b/examples/nlg_server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/nlg_server/nlg_server.py b/examples/nlg_server/nlg_server.py new file mode 100644 index 0000000..7579c3c --- /dev/null +++ b/examples/nlg_server/nlg_server.py @@ -0,0 +1,89 @@ +import argparse +import logging +import os + +from sanic import Sanic, response + +from rasa.shared.core.domain import Domain +from rasa.core.nlg import TemplatedNaturalLanguageGenerator +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.constants import ENV_SANIC_BACKLOG, DEFAULT_SANIC_WORKERS + +logger = logging.getLogger(__name__) + +DEFAULT_SERVER_PORT = 5056 + + +def create_argument_parser(): + """Parse all the command line arguments for the nlg server script.""" + + parser = argparse.ArgumentParser(description="starts the nlg endpoint") + parser.add_argument( + "-p", + "--port", + default=DEFAULT_SERVER_PORT, + type=int, + help="port to run the server at", + ) + parser.add_argument( + "--workers", + default=DEFAULT_SANIC_WORKERS, + type=int, + help="Number of processes to spin up", + ) + parser.add_argument( + "-d", + "--domain", + type=str, + default=None, + help="path of the domain file to load utterances from", + ) + + return parser + + +async def generate_response(nlg_call, domain): + """Mock response generator. + + Generates the responses from the bot's domain file. + """ + kwargs = nlg_call.get("arguments", {}) + response = nlg_call.get("response") + sender_id = nlg_call.get("tracker", {}).get("sender_id") + events = nlg_call.get("tracker", {}).get("events") + tracker = DialogueStateTracker.from_dict(sender_id, events, domain.slots) + channel_name = nlg_call.get("channel") + + return await TemplatedNaturalLanguageGenerator(domain.responses).generate( + response, tracker, channel_name, **kwargs + ) + + +def run_server(domain, port, workers): + app = Sanic("nlg_server") + + @app.route("/nlg", methods=["POST", "OPTIONS"]) + async def nlg(request): + """Endpoint which processes the Core request for a bot response.""" + nlg_call = request.json + bot_response = await generate_response(nlg_call, domain) + + return response.json(bot_response) + + app.run( + host="0.0.0.0", + port=port, + workers=workers, + backlog=int(os.environ.get(ENV_SANIC_BACKLOG, "100")), + ) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + + # Running as standalone python application + arg_parser = create_argument_parser() + cmdline_args = arg_parser.parse_args() + _domain = Domain.load(cmdline_args.domain) + + run_server(_domain, cmdline_args.port, cmdline_args.workers) diff --git a/examples/reminderbot/README.md b/examples/reminderbot/README.md new file mode 100644 index 0000000..89019dc --- /dev/null +++ b/examples/reminderbot/README.md @@ -0,0 +1,58 @@ +# Reminderbot + +The `reminderbot` example demonstrates how your bot can respond to external events or reminders. + +## What’s inside this example? + +This example contains some training data and the main files needed to build an +assistant on your local machine. The `reminderbot` consists of the following files: + +- **data/nlu.yml** contains training examples for the NLU model +- **data/rules.yml** contains rules for the Core model +- **config.yml** contains the model configuration +- **domain.yml** contains the domain of the assistant +- **credentials.yml** contains credentials for the different channels +- **endpoints.yml** contains the different endpoints reminderbot can use +- **actions/actions.py** contains the custom actions that deal with external events and reminders + +## How to use this example? + +To train and chat with `reminderbot`, execute the following steps: + +1. Train a Rasa Open Source model containing the Rasa NLU and Rasa Core models by running: + ``` + rasa train + ``` + The model will be stored in the `/models` directory as a zipped file. + +2. Run a Rasa SDK action server with + ``` + rasa run actions + ``` + +3. To test this example, run a + [callback channel](https://rasa.com/docs/rasa/connectors/your-own-website#callbackinput). + In a separate console window from where you ran the step 2 command: + ``` + python callback_server.py + ``` + + This will run a server that prints the bot's responses to the console. + + Start your Rasa server in a third console window: + ``` + rasa run --enable-api + ``` + + You can then send messages to the bot via the callback channel endpoint: + ``` + curl -XPOST http://localhost:5005/webhooks/callback/webhook \ + -d '{"sender": "tester", "message": "hello"}' \ + -H "Content-type: application/json" + ``` + +For more information about the individual commands, please check out our +[documentation](http://rasa.com/docs/rasa/command-line-interface). + +## Encountered any issues? +Let us know about it by posting on [Rasa Community Forum](https://forum.rasa.com)! diff --git a/examples/reminderbot/actions/__init__.py b/examples/reminderbot/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/reminderbot/actions/actions.py b/examples/reminderbot/actions/actions.py new file mode 100644 index 0000000..681fddc --- /dev/null +++ b/examples/reminderbot/actions/actions.py @@ -0,0 +1,124 @@ +# This files contains your custom actions which can be used to run +# custom Python code. +# +# See this guide on how to implement these action: +# https://rasa.com/docs/rasa/custom-actions + + +# This is a simple example for an assistant that schedules reminders and +# reacts to external events. + +from typing import Any, Text, Dict, List +import datetime + +from rasa_sdk import Action, Tracker +from rasa_sdk.events import ReminderScheduled, ReminderCancelled +from rasa_sdk.executor import CollectingDispatcher + + +class ActionSetReminder(Action): + """Schedules a reminder, supplied with the last message's entities.""" + + def name(self) -> Text: + return "action_set_reminder" + + async def run( + self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> List[Dict[Text, Any]]: + + dispatcher.utter_message("I will remind you in 5 seconds.") + + date = datetime.datetime.now() + datetime.timedelta(seconds=5) + entities = tracker.latest_message.get("entities") + + reminder = ReminderScheduled( + "EXTERNAL_reminder", + trigger_date_time=date, + entities=entities, + name="my_reminder", + kill_on_user_message=False, + ) + + return [reminder] + + +class ActionReactToReminder(Action): + """Reminds the user to call someone.""" + + def name(self) -> Text: + return "action_react_to_reminder" + + async def run( + self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> List[Dict[Text, Any]]: + + name = next(tracker.get_slot("PERSON"), "someone") + dispatcher.utter_message(f"Remember to call {name}!") + + return [] + + +class ActionTellID(Action): + """Informs the user about the conversation ID.""" + + def name(self) -> Text: + return "action_tell_id" + + async def run( + self, dispatcher, tracker: Tracker, domain: Dict[Text, Any] + ) -> List[Dict[Text, Any]]: + + conversation_id = tracker.sender_id + + dispatcher.utter_message(f"The ID of this conversation is '{conversation_id}'.") + dispatcher.utter_message( + f"Trigger an intent with: \n" + f'curl -H "Content-Type: application/json" ' + f'-X POST -d \'{{"name": "EXTERNAL_dry_plant", ' + f'"entities": {{"plant": "Orchid"}}}}\' ' + f'"http://localhost:5005/conversations/{conversation_id}' + f'/trigger_intent?output_channel=latest"' + ) + + return [] + + +class ActionWarnDry(Action): + """Informs the user that a plant needs water.""" + + def name(self) -> Text: + return "action_warn_dry" + + async def run( + self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> List[Dict[Text, Any]]: + + plant = next(tracker.get_latest_entity_values("plant"), "someone") + dispatcher.utter_message(f"Your {plant} needs some water!") + + return [] + + +class ForgetReminders(Action): + """Cancels all reminders.""" + + def name(self) -> Text: + return "action_forget_reminders" + + async def run( + self, dispatcher, tracker: Tracker, domain: Dict[Text, Any] + ) -> List[Dict[Text, Any]]: + + dispatcher.utter_message("Okay, I'll cancel all your reminders.") + + # Cancel all reminders + return [ReminderCancelled()] diff --git a/examples/reminderbot/callback_server.py b/examples/reminderbot/callback_server.py new file mode 100644 index 0000000..a1206b6 --- /dev/null +++ b/examples/reminderbot/callback_server.py @@ -0,0 +1,27 @@ +from sanic import Sanic, response +from sanic.request import Request +from sanic.response import HTTPResponse + + +def create_app() -> Sanic: + + bot_app = Sanic("callback_server", configure_logging=False) + + @bot_app.post("/bot") + def print_response(request: Request) -> HTTPResponse: + """Print bot response to the console.""" + bot_response = request.json.get("text") + print(f"\n{bot_response}") + + body = {"status": "message sent"} + return response.json(body, status=200) + + return bot_app + + +if __name__ == "__main__": + app = create_app() + port = 5034 + + print(f"Starting callback server on port {port}.") + app.run("0.0.0.0", port) diff --git a/examples/reminderbot/config.yml b/examples/reminderbot/config.yml new file mode 100644 index 0000000..c2f7356 --- /dev/null +++ b/examples/reminderbot/config.yml @@ -0,0 +1,22 @@ +recipe: default.v1 +assistant_id: reminder_bot +language: en +pipeline: +- name: "WhitespaceTokenizer" +- name: "RegexFeaturizer" +- name: "LexicalSyntacticFeaturizer" +- name: "CountVectorsFeaturizer" +- name: "CountVectorsFeaturizer" + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 +- name: "DIETClassifier" + epochs: 100 +- name: SpacyNLP + model: "en_core_web_md" +- name: SpacyEntityExtractor + dimensions: ["PERSON"] +- name: "EntitySynonymMapper" + +policies: +- name: RulePolicy diff --git a/examples/reminderbot/credentials.yml b/examples/reminderbot/credentials.yml new file mode 100644 index 0000000..f3f99b6 --- /dev/null +++ b/examples/reminderbot/credentials.yml @@ -0,0 +1,37 @@ +# This file contains the credentials for the voice & chat platforms +# which your bot is using. +# https://rasa.com/docs/rasa/messaging-and-voice-channels + +# # NOTE: the rest channel does not work with external events +# rest: +# you don't need to provide anything here - this channel doesn't +# require any credentials + +callback: + # URL to which Rasa Open Source will send the bot responses + # See https://rasa.com/docs/rasa/connectors/your-own-website#callbackinput + url: "http://localhost:5034/bot" + +#facebook:cz +# verify: "<verify>" +# secret: "<your secret>" +# page-access-token: "<your page access token>" + +#slack: +# slack_token: "<your slack token>" +# slack_channel: "<the slack channel>" + +#socketio: +# user_message_evt: <event name for user message> +# bot_message_evt: <event name for bot messages> +# session_persistence: <true/false> + +#mattermost: +# url: "https://<mattermost instance>/api/v4" +# team: "<mattermost team>" +# user: "<bot username>" +# pw: "<bot token>" +# webhook_url: "<callback URL>" + +rasa: + url: "http://localhost:5002/api" diff --git a/examples/reminderbot/data/nlu.yml b/examples/reminderbot/data/nlu.yml new file mode 100644 index 0000000..8cc39fb --- /dev/null +++ b/examples/reminderbot/data/nlu.yml @@ -0,0 +1,51 @@ +version: "3.1" +nlu: + - intent: greet + examples: | + - hey + - hello + - hi + - good morning + - good evening + - hey there + + - intent: bye + examples: | + - bye + - good bye + - ciao + - see you + - see ya + + - intent: ask_remind_call + examples: | + - remind me to call John + - remind me to call Lis + - remind me to call Albert + - remind me to call Susan + - later I have to call Alan + - later I have to call Jessie + - later I have to call Alex + - Please, remind me to call Vova + - please remind me to call Tanja + - I must not forget to call Santa + - I must not forget to call Daksh + - I must not forget to call Juste + + - intent: ask_id + examples: | + - what's the conversation id? + - id + - What is the ID of this conversation? + - How do I send a POST request to this conversation? + + - intent: ask_forget_reminders + examples: | + - forget about it + - don't remind me! + - Forget about the reminder + - do not remind me + - cancel the reminder + - cancel all reminders please + - Forget reminding me + - Forget reminding me! diff --git a/examples/reminderbot/data/rules.yml b/examples/reminderbot/data/rules.yml new file mode 100644 index 0000000..34754cd --- /dev/null +++ b/examples/reminderbot/data/rules.yml @@ -0,0 +1,30 @@ +version: "3.1" +rules: +- rule: greet + steps: + - intent: greet + - action: utter_what_can_do +- rule: remind call + steps: + - intent: ask_remind_call + - action: action_set_reminder +- rule: forget reminder + steps: + - intent: ask_forget_reminders + - action: action_forget_reminders +- rule: say goodbye + steps: + - intent: bye + - action: utter_goodbye +- rule: convo action_tell_id + steps: + - intent: ask_id + - action: action_tell_id +- rule: warn dry plant + steps: + - intent: EXTERNAL_dry_plant + - action: action_warn_dry +- rule: react to reminder + steps: + - intent: EXTERNAL_reminder + - action: action_react_to_reminder diff --git a/examples/reminderbot/data/stories.yml b/examples/reminderbot/data/stories.yml new file mode 100644 index 0000000..98e34a6 --- /dev/null +++ b/examples/reminderbot/data/stories.yml @@ -0,0 +1,8 @@ +version: "3.1" +stories: + - story: Happy path + steps: + - intent: greet + - action: utter_what_can_do + - intent: ask_remind_call + - action: action_set_reminder diff --git a/examples/reminderbot/domain.yml b/examples/reminderbot/domain.yml new file mode 100644 index 0000000..f2086d0 --- /dev/null +++ b/examples/reminderbot/domain.yml @@ -0,0 +1,35 @@ +version: "3.1" + +session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true +intents: +- greet +- ask_remind_call +- ask_forget_reminders +- bye +- ask_id +- EXTERNAL_dry_plant +- EXTERNAL_reminder +- EXT_reminder +entities: +- PERSON +- plant +slots: + PERSON: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: PERSON +responses: + utter_what_can_do: + - text: What can I do for you? + utter_goodbye: + - text: Bye +actions: +- action_set_reminder +- action_forget_reminders +- action_react_to_reminder +- action_tell_id +- action_warn_dry diff --git a/examples/reminderbot/endpoints.yml b/examples/reminderbot/endpoints.yml new file mode 100644 index 0000000..5f65275 --- /dev/null +++ b/examples/reminderbot/endpoints.yml @@ -0,0 +1,42 @@ +# This file contains the different endpoints your bot can use. + +# Server where the models are pulled from. +# https://rasa.com/docs/rasa/model-storage#fetching-models-from-a-server + +#models: +# url: http://my-server.com/models/default_core@latest +# wait_time_between_pulls: 10 # [optional](default: 100) + +# Server which runs your custom actions. +# https://rasa.com/docs/rasa/custom-actions + +action_endpoint: + url: "http://localhost:5055/webhook" + +# Tracker store which is used to store the conversations. +# By default the conversations are stored in memory. +# https://rasa.com/docs/rasa/tracker-stores + +#tracker_store: +# type: redis +# url: <host of the redis instance, e.g. localhost> +# port: <port of your redis instance, usually 6379> +# db: <number of your database within redis, e.g. 0> +# password: <password used for authentication> +# use_ssl: <whether or not the communication is encrypted, default false> + +#tracker_store: +# type: mongod +# url: <url to your mongo instance, e.g. mongodb://localhost:27017> +# db: <name of the db within your mongo instance, e.g. rasa> +# username: <username used for authentication> +# password: <password used for authentication> + +# Event broker which all conversation events should be streamed to. +# https://rasa.com/docs/rasa/event-brokers + +#event_broker: +# url: localhost +# username: username +# password: password +# queue: queue diff --git a/examples/responseselectorbot/README.md b/examples/responseselectorbot/README.md new file mode 100644 index 0000000..ccdfb02 --- /dev/null +++ b/examples/responseselectorbot/README.md @@ -0,0 +1,50 @@ +# Response Selector Bot + +This example bot extends the `moodbot` example to show how you can use retrieval actions with Response Selector to +handle single-turn interactions better. + +## What’s inside this example? + +This example contains some training data and the main files needed to build an +assistant on your local machine. + +- **data/nlu.yml** contains training examples for the NLU model +- **data/stories.yml** contains training stories for the Core model +- **data/rules.yml** contains rule based behaviour +- **data/responses.yml** contains the response templates for retrieval intents +- **config.yml** contains the model configuration +- **domain.yml** contains the domain of the assistant +- **credentials.yml** contains credentials for the different channels + +## How to use this example? + +Using this example you can build an actual assistant and chat with it on +different channels. To do so execute the following steps: + +1. Train a Rasa model containing the Rasa NLU and Rasa Core models by running: + ``` + rasa train + ``` + The model will be stored in the `/models` directory as a zipped file. + +2. Run a Rasa server that connects, for example, to Facebook: + ``` + rasa run -m models -p 5002 --connector facebook --credentials credentials.yml + ``` + If you want to connect to a different channel, replace `facebook` with the name of the + desired channel. + All available channels are listed in the `credentials.yml` file. + For more information on the different channels read our + [documentation](http://x-docs.rasa.com/docs/rasa/messaging-and-voice-channels) + + If you don't want to use any channel, you can chat with your bot + on the command line, using the following command: + ``` + rasa shell + ``` + +For more information about the individual commands, please check out our +[documentation](http://rasa.com/docs/rasa/command-line-interface). + +## Encountered any issues? +Let us know about it by posting on [Rasa Community Forum](https://forum.rasa.com)! diff --git a/examples/responseselectorbot/config.yml b/examples/responseselectorbot/config.yml new file mode 100644 index 0000000..bb9a66d --- /dev/null +++ b/examples/responseselectorbot/config.yml @@ -0,0 +1,19 @@ +recipe: default.v1 +assistant_id: response_selector_bot +language: en + +pipeline: + - name: "WhitespaceTokenizer" + - name: "CountVectorsFeaturizer" + - name: "DIETClassifier" + entity_recognition: False + epochs: 50 + - name: ResponseSelector + epochs: 50 + +policies: + - name: TEDPolicy + max_history: 5 + epochs: 100 + - name: MemoizationPolicy + - name: RulePolicy diff --git a/examples/responseselectorbot/data/nlu.yml b/examples/responseselectorbot/data/nlu.yml new file mode 100644 index 0000000..42748ad --- /dev/null +++ b/examples/responseselectorbot/data/nlu.yml @@ -0,0 +1,116 @@ +version: "3.1" + +nlu: +- intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon + +- intent: goodbye + examples: | + - good afternoon + - cu + - good by + - cee you later + - good night + - bye + - goodbye + - have a nice day + - see you around + - bye bye + - see you later + +- intent: affirm + examples: | + - yes + - y + - indeed + - of course + - that sounds good + - correct + +- intent: deny + examples: | + - no + - n + - never + - I don't think so + - don't like that + - no way + +- intent: mood_great + examples: | + - perfect + - great + - amazing + - feeling like a king + - wonderful + - I am feeling very good + - I am great + - I am amazing + - I am going to save the world + - super stoked + - extremely good + - so so perfect + - so good + - so perfect + +- intent: mood_unhappy + examples: | + - my day was horrible + - I am sad + - I don't feel very well + - I am disappointed + - super sad + - I'm so sad + - sad + - very sad + - unhappy + - not good + - not very good + - extremly sad + - so saad + - so sad + +- intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? + +- intent: chitchat/ask_name + examples: | + - What is your name? + - May I know your name? + - What do people call you? + - Do you have a name for yourself? + +- intent: chitchat/ask_weather + examples: | + - What's the weather like today? + - Does it look sunny outside today? + - Oh, do you mind checking the weather for me please? + - I like sunny days in Berlin. + +responses: + utter_chitchat/ask_name: + - image: "https://i.imgur.com/zTvA58i.jpeg" + text: hello, my name is retrieval bot. + - text: Oh yeah, I am called the retrieval bot. + + utter_chitchat/ask_weather: + - text: Oh, it does look sunny right now in Berlin. + image: "https://i.imgur.com/vwv7aHN.png" + - text: I am not sure of the whole week but I can see the sun is out today. diff --git a/examples/responseselectorbot/data/rules.yml b/examples/responseselectorbot/data/rules.yml new file mode 100644 index 0000000..9f696f4 --- /dev/null +++ b/examples/responseselectorbot/data/rules.yml @@ -0,0 +1,18 @@ +version: "3.1" + +rules: + +- rule: Say goodbye anytime the user says goodbye + steps: + - intent: goodbye + - action: utter_goodbye + +- rule: Say 'I am a bot' anytime the user challenges + steps: + - intent: bot_challenge + - action: utter_iamabot + +- rule: Response with a chitchat utterance whenever user indulges in some chitchat + steps: + - intent: chitchat + - action: utter_chitchat diff --git a/examples/responseselectorbot/data/stories.yml b/examples/responseselectorbot/data/stories.yml new file mode 100644 index 0000000..57a86c8 --- /dev/null +++ b/examples/responseselectorbot/data/stories.yml @@ -0,0 +1,31 @@ +version: "3.1" + +stories: + +- story: happy path + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy + +- story: sad path 1 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + +- story: sad path 2 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: deny + - action: utter_goodbye + diff --git a/examples/responseselectorbot/domain.yml b/examples/responseselectorbot/domain.yml new file mode 100644 index 0000000..b944ba5 --- /dev/null +++ b/examples/responseselectorbot/domain.yml @@ -0,0 +1,40 @@ +version: "3.1" + +intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + - chitchat + +responses: + utter_greet: + - text: "Hey! How are you?" + buttons: + - title: "great" + payload: "/mood_great" + - title: "super sad" + payload: "/mood_unhappy" + + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" + + utter_did_that_help: + - text: "Did that help you?" + + utter_happy: + - text: "Great, carry on!" + + utter_goodbye: + - text: "Bye" + + utter_iamabot: + - text: "I am a bot, powered by Rasa." + +session_config: + session_expiration_time: 60 # value in minutes + carry_over_slots_to_new_session: true diff --git a/examples/rules/actions/__init__.py b/examples/rules/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/rules/actions/actions.py b/examples/rules/actions/actions.py new file mode 100644 index 0000000..c7e3773 --- /dev/null +++ b/examples/rules/actions/actions.py @@ -0,0 +1,30 @@ +from typing import Dict, Text, List + +from rasa_sdk import Tracker +from rasa_sdk.events import EventType +from rasa_sdk.executor import CollectingDispatcher +from rasa_sdk import Action +from rasa_sdk.events import SlotSet + + +class ActionSwitchFAQ(Action): + def name(self) -> Text: + return "action_switch_faq" + + def run( + self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict + ) -> List[EventType]: + return [SlotSet("detailed_faq", not tracker.get_slot("detailed_faq"))] + + +class ValidateSlots(Action): + def name(self) -> Text: + """Unique identifier of the form""" + + return "action_validate_loop_q_form" + + def run( + self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict + ) -> List[EventType]: + dispatcher.utter_message("validate_some_slot") + return [SlotSet("some_slot", "sdk")] diff --git a/examples/rules/config.yml b/examples/rules/config.yml new file mode 100644 index 0000000..5a35e9b --- /dev/null +++ b/examples/rules/config.yml @@ -0,0 +1,31 @@ +recipe: default.v1 +assistant_id: rules_bot +language: en + +pipeline: + - name: WhitespaceTokenizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + token_pattern: (?u)\b\w+\b + - name: DucklingEntityExtractor + url: http://localhost:8000 + dimensions: + - number + - name: DIETClassifier + epochs: 100 + - name: EntitySynonymMapper + - name: FallbackClassifier + # If the highest ranked intent has a confidence lower than the threshold than + # the NLU pipeline predicts an intent `nlu_fallback` which you can then use in + # stories / rules to implement an appropriate fallback. + threshold: 0.5 + +policies: + - name: RulePolicy + # Confidence of the prediction if no rule matched and de-facto threshold for a + # core fallback. + core_fallback_threshold: 0.3 + # Name of the action which should be predicted if no rule matched. + core_fallback_action_name: "action_default_fallback" + # If `True` `core_fallback_action_name` is predicted in case no rule matched. + enable_fallback_prediction: True diff --git a/examples/rules/data/nlu.yml b/examples/rules/data/nlu.yml new file mode 100644 index 0000000..0819d4e --- /dev/null +++ b/examples/rules/data/nlu.yml @@ -0,0 +1,317 @@ +version: "3.1" +nlu: +- intent: greet + examples: | + - Hi + - Hey + - Hi bot + - Hey bot + - Hello + - Good morning + - hi again + - hi folks + - hi Mister + - hi pal! + - hi there + - greetings + - hello everybody + - hello is anybody there + - hello robot + - hallo + - heeey + - hi hi + - hey + - hey hey + - hello there + - hi + - hello + - yo + - hola + - hi? + - hey bot! + - hello friend + +- intent: request_restaurant + examples: | + - im looking for a restaurant + - can i get [swedish](cuisine) food in any area + - a restaurant that serves [caribbean](cuisine) food + - id like a restaurant + - im looking for a restaurant that serves [mediterranean](cuisine) food + - can i find a restaurant that serves [chinese](cuisine) + - i am looking for any place that serves [indonesian](cuisine) food for three + - i need to find a restaurant + - uh im looking for a restaurant that serves [kosher](cuisine) food + - uh can i find a restaurant and it should serve [brazilian](cuisine) food + - im looking for a restaurant serving [italian](cuisine) food + - restaurant please + - i'd like to book a table for two with [spanish](cuisine) cuisine + - i need a table for 4 + - book me a table for three at the [italian](cuisine) restaurant + - can you please book a table for 5? + - I would like to book a table for 2 + - looking for a table at the [mexican](cuisine) restaurant for five + - find me a table for 7 people + - Can I get a table for four at the place which server [greek](cuisine) food? + +- intent: affirm + examples: | + - yeah a cheap restaurant serving international food + - correct + - ye + - uh yes + - let's do it + - yeah + - uh yes + - um yes + - yes knocking + - that's correct + - yes yes + - right + - yea + - yes + - yes right + - yes and i dont care + - right on + - i love that + +- intent: deny + examples: | + - no + - no new selection + - no thanks + - no thank you + - uh no + - breath no + - do you have something else + - no this does not work for me + +- intent: inform + examples: | + - [afghan](cuisine) food + - how bout [asian oriental](cuisine) + - what about [indian](cuisine) food + - uh how about [turkish](cuisine) type of food + - um [english](cuisine) + - im looking for [tuscan](cuisine) food + - id like [moroccan](cuisine) food + - [seafood](cuisine) + - [french](cuisine) food + - serves [british](cuisine) food + - id like [canapes](cuisine) + - serving [jamaican](cuisine) food + - um what about [italian](cuisine) food + - im looking for [corsica](cuisine) food + - im looking for [world](cuisine) food + - serves [french](cuisine) food + - how about [indian](cuisine) food + - can i get [chinese](cuisine) food + - [irish](cuisine) food + - [english](cuisine) food + - [spanish](cuisine) food + - how bout one that serves [portuguese](cuisine) food and is cheap + - [german](cuisine) + - [korean](cuisine) food + - im looking for [romanian](cuisine) food + - serves [canapes](cuisine) food + - [gastropub](cuisine) + - i want [french](cuisine) food + - how about [modern european](cuisine) type of food + - it should serve [scandinavian](cuisine) food + - how [european](cuisine) + - how about [european](cuisine) food + - serves [traditional](cuisine) food + - [indonesian](cuisine) food + - [modern european](cuisine) + - serves [brazilian](cuisine) + - i would like [modern european](cuisine) food + - looking for [lebanese](cuisine) food + - [portuguese](cuisine) + - [european](cuisine) + - i want [polish](cuisine) food + - id like [thai](cuisine) + - i want to find [moroccan](cuisine) food + - [afghan](cuisine) + - [scottish](cuisine) food + - how about [vietnamese](cuisine) + - hi im looking for [mexican](cuisine) food + - how about [indian](cuisine) type of food + - [polynesian](cuisine) food + - [mexican](cuisine) + - instead could it be for four people + - any [japanese](cuisine) food + - what about [thai](cuisine) food + - how about [asian oriental](cuisine) food + - im looking for [japanese](cuisine) food + - im looking for [belgian](cuisine) food + - im looking for [turkish](cuisine) food + - serving [corsica](cuisine) food + - serving [gastro pub](cuisine:gastropub) + - is there [british](cuisine) food + - [world](cuisine) food + - im looking for something serves [japanese](cuisine) food + - id like a [greek](cuisine) + - im looking for [malaysian](cuisine) food + - i want to find [world](cuisine) food + - serves [pan asian](cuisine:asian) food + - looking for [afghan](cuisine) food + - that serves [portuguese](cuisine) food + - [asian oriental](cuisine:asian) food + - [russian](cuisine) food + - [corsica](cuisine) + - [asian oriental](cuisine:asian) + - serving [basque](cuisine) food + - how about [italian](cuisine) + - looking for [spanish](cuisine) food in the center of town + - it should serve [gastropub](cuisine) food + - [welsh](cuisine) food + - i want [vegetarian](cuisine) food + - im looking for [swedish](cuisine) food + - um how about [chinese](cuisine) food + - [world](cuisine) food + - can i have a [seafood](cuisine) please + - how about [italian](cuisine) food + - how about [korean](cuisine) + - [corsica](cuisine) food + - [scandinavian](cuisine) + - [vegetarian](cuisine) food + - what about [italian](cuisine) + - how about [portuguese](cuisine) food + - serving [french](cuisine) food + - [tuscan](cuisine) food + - how about uh [gastropub](cuisine) + - im looking for [creative](cuisine) food + - im looking for [malaysian](cuisine) food + - im looking for [unusual](cuisine) food + - [danish](cuisine) food + - how about [spanish](cuisine) food + - im looking for [vietnamese](cuisine) food + - [spanish](cuisine) + - a restaurant serving [romanian](cuisine) food + - im looking for [lebanese](cuisine) food + - [italian](cuisine) food + - a restaurant with [afghan](cuisine) food + - im looking for [traditional](cuisine) food + - uh i want [cantonese](cuisine) food + - im looking for [thai](cuisine) + - i want to seat [outside](seating) + - i want to seat [inside](seating) + - i want to seat [outdoor](seating) + - i want to seat [indoor](seating) + - let's go [inside](seating) + - [inside](seating) + - [outdoor](seating) + - prefer sitting [indoors](seating) + - I would like to seat [inside](seating) please + - I prefer sitting [outside](seating) + - my feedback is [good](feedback) + - my feedback is [great](feedback) + - it was [terrible](feedback) + - i consider it [success](feedback) + - you are [awful](feedback) + - for ten people + - 2 people + - for three people + - just one person + - book for seven people + - 2 please + - nine people + +- intent: thankyou + examples: | + - um thank you good bye + - okay cool uh good bye thank you + - okay thank you good bye + - you rock + - and thats all thank you and good bye + - thank you and good bye + - sorry about my mistakes thank you good bye + - noise thank you good bye + - thank you goodbye noise + - okay thank you goodbye + - uh thank you good bye + - thank you goodbye + - thank you goodbye noise thank you goodbye + - breath thank you goodbye + - thank you + - okay thank you + - thanks goodbye + - ah thank you goodbye + - thank you noise + - thank you good bye + - breath thank you very much goodbye + - thanks + - noise thank you goodbye + - unintelligible thank you goodbye + - uh okay thank you good bye + - thank you bye + - um okay thank you good bye + +- intent: chitchat + examples: | + - can you share your boss with me? + - i want to get to know your owner + - i want to know the company which designed you + - i want to know the company which generated you + - i want to know the company which invented you + - i want to know who invented you + - May I ask who invented you? + - please tell me the company who created you + - please tell me who created you + - tell me more about your creators + - tell me more about your founders + - Ahoy matey how are you? + - are you alright + - are you having a good day + - Are you ok? + - are you okay + - Do you feel good? + - how are things going + - how are things with you? + - How are things? + - how are you + - how are you doing + - how are you doing this morning + - how are you feeling + - how are you today + - How are you? + - How is the weather today? + - What's the weather like? + - How is the weather? + - What is the weather at your place? + - Do you have good weather? + - Is it raining? + - What's it like out there? + - Is it hot or cold? + - Beautiful day, isn't it? + - What's the weather forecast? + - Is it quite breezy outside? + +- intent: stop + examples: | + - ok then you cant help me + - that was shit, you're not helping + - you can't help me + - you can't help me with what i need + - i guess you can't help me then + - ok i guess you can't help me + - that's not what i want + - ok, but that doesnt help me + - this is leading to nothing + - this conversation is not really helpful + - you cannot help me with what I want + - I think you cant help me + - hm i don't think you can do what i want + - stop + - stop go back + - do you get anything? + - and you call yourself bot company? pff + - and that's it? + - nothing else? + +- intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? diff --git a/examples/rules/data/rules.yml b/examples/rules/data/rules.yml new file mode 100644 index 0000000..17a6672 --- /dev/null +++ b/examples/rules/data/rules.yml @@ -0,0 +1,116 @@ +version: "3.1" +rules: + +- rule: Greet + # This rule only applies to the start of a session. + conversation_start: True + steps: + - intent: greet + - action: utter_greet + +- rule: Activate form 'q_form' + steps: + - intent: activate_q_form + - action: loop_q_form + - active_loop: loop_q_form + +- rule: Example of an unhappy path for the 'loop_q_form' + condition: + # Condition that form is active. + - active_loop: loop_q_form + - slot_was_set: + - requested_slot: some_slot + steps: + # This unhappy path handles the case of an intent `explain`. + - intent: explain + - action: utter_explain_some_slot + # Return to form after handling the `explain` intent + - action: loop_q_form + - active_loop: loop_q_form + +- rule: Submit form + condition: + - active_loop: loop_q_form + steps: + - action: loop_q_form + - active_loop: null + - slot_was_set: + - requested_slot: null + # The action we want to run when the form is submitted. + - action: utter_stop + +- rule: FAQ question + steps: + - intent: ask_possibilities + - action: utter_list_possibilities + +- rule: FAQ simple + condition: + - slot_was_set: + - detailed_faq: false + steps: + - intent: faq + - action: utter_faq + +- rule: FAQ detailed + condition: + - slot_was_set: + - detailed_faq: true + steps: + - intent: faq + - action: utter_faq + # Don't predict `action_listen` after running `utter_faq` + wait_for_user_input: False + +- rule: FAQ helped - continue + condition: + - slot_was_set: + - detailed_faq: true + steps: + - action: utter_faq + - action: utter_ask_did_help + - intent: affirm + - action: utter_continue + +- rule: FAQ did not help + condition: + - slot_was_set: + - detailed_faq: true + steps: + - action: utter_faq + - action: utter_ask_did_help + - intent: deny + - action: utter_detailed_faq + # Don't predict `action_listen` after running `utter_faq` + wait_for_user_input: False + +- rule: Detailed FAQ did not help + condition: + - slot_was_set: + - detailed_faq: true + steps: + - action: utter_detailed_faq + - action: utter_ask_did_help + - intent: deny + - action: utter_ask_stop + +- rule: User decides to stop + steps: + - action: utter_ask_stop + - intent: affirm + - action: utter_stop + +- rule: User decides to continue + steps: + - action: utter_ask_stop + - intent: deny + - action: utter_continue + +- rule: Implementation of the TwoStageFallbackPolicy + steps: + # This intent is automatically triggered by the `FallbackClassifier` in the NLU + # pipeline in case the intent confidence was below the specified threshold. + - intent: nlu_fallback + # The Fallback is implemented as now implemented as form. + - action: action_two_stage_fallback + - active_loop: action_two_stage_fallback diff --git a/examples/rules/domain.yml b/examples/rules/domain.yml new file mode 100644 index 0000000..a88f2f2 --- /dev/null +++ b/examples/rules/domain.yml @@ -0,0 +1,102 @@ +version: "3.1" + +intents: +- activate_q_form +- inform +- explain +- stopp +- ask_possibilities +- faq +- affirm +- deny +- greet +- switch_faq +- nlu_fallback + +entities: +- some_slot + +slots: + some_slot: + type: text + influence_conversation: false + mappings: + # Slot mappings can be defined in the domain. + # You can also implement custom slot mappings in your + # `action_validate_slot_mappings` function by returning the desired slot events. + # The slot mappings follow the same syntax as currently in the SDK implementation. + - type: from_entity + entity: some_slot + # Example of a slot mapping which extracts the slot value from the message text if + # the intent is `greet`, the active loop is `loop_q_form` and `requested_slot` is + # the same slot: + # - type: from_text + # intent: greet + # conditions: + # - active_loop: loop_q_form + # requested_slot: some_slot + # Example of a slot mapping which sets the slot to `my value` in case the message + # has an intent `greet` + # - type: from_intent + # intent: greet + # value: "my value" + detailed_faq: + type: bool + mappings: + - type: custom + +actions: +- utter_explain_some_slot +- action_stop_q_form +- utter_list_possibilities +- utter_faq +- utter_ask_did_help +- utter_continue +- utter_detailed_faq +- utter_ask_stop +- utter_stop +- utter_greet +- action_switch_faq +- utter_did_you_mean +# You can implement a custom action to validate extracted slots of your form. +# Return a `slot` event which sets the value to `None` in order to make the form request +# the slot again. You can also return `slot` events for other slots which you can +# extract as part of your custom action. +#- validate_loop_q_form + +forms: + loop_q_form: + required_slots: + - some_slot + +session_config: + session_expiration_time: 60 # value in minutes + carry_over_slots_to_new_session: true + +responses: + utter_ask_some_slot: + - text: "utter_ask_some_slot" + utter_explain_some_slot: + - text: "utter_explain_some_slot" + utter_list_possibilities: + - text: "utter_list_possibilities" + utter_faq: + - text: "utter_faq" + utter_ask_did_help: + - text: "utter_ask_did_help" + utter_continue: + - text: "utter_continue" + utter_detailed_faq: + - text: "utter_detailed_faq" + utter_ask_stop: + - text: "utter_ask_stop" + utter_stop: + - text: "utter_stop" + utter_greet: + - text: "utter_greet" + utter_did_you_mean: + - text: "utter_did_you_mean" + utter_revert_fallback_and_reapply_last_intent: + - text: "utter_revert_fallback_and_reapply_last_intent" + utter_default: + - text: "I give up." diff --git a/examples/rules/endpoints.yml b/examples/rules/endpoints.yml new file mode 100644 index 0000000..e8d74ad --- /dev/null +++ b/examples/rules/endpoints.yml @@ -0,0 +1,2 @@ +action_endpoint: + url: http://localhost:5055/webhook diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..ba2092a --- /dev/null +++ b/poetry.lock @@ -0,0 +1,7189 @@ +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + +[[package]] +name = "absl-py" +version = "1.4.0" +description = "Abseil Python Common Libraries, see https://github.com/abseil/abseil-py." +optional = false +python-versions = ">=3.6" +files = [ + {file = "absl-py-1.4.0.tar.gz", hash = "sha256:d2c244d01048ba476e7c080bd2c6df5e141d211de80223460d5b3b8a2a58433d"}, + {file = "absl_py-1.4.0-py3-none-any.whl", hash = "sha256:0d3fe606adfa4f7db64792dd4c7aee4ee0c38ab75dfd353b7a83ed3e957fcb47"}, +] + +[[package]] +name = "aio-pika" +version = "8.2.3" +description = "Wrapper for the aiormq for asyncio and humans." +optional = false +python-versions = ">3.6, <4" +files = [ + {file = "aio-pika-8.2.3.tar.gz", hash = "sha256:175b657ae022f318dd1476209bee4d5b793b0e0b298759fea7c11e1aa0cac6cd"}, + {file = "aio_pika-8.2.3-py3-none-any.whl", hash = "sha256:6378d70d85259420067a79b0f4a6ea9a867f32a0e77125dc4ed14cef599bbe9c"}, +] + +[package.dependencies] +aiormq = ">=6.4.0,<6.5.0" +yarl = "*" + +[package.extras] +develop = ["aiomisc (>=16.0,<17.0)", "coverage (!=4.3)", "coveralls", "nox", "pyflakes (<2.5)", "pylava", "pytest", "pytest-cov", "shortuuid", "sphinx", "sphinx-autobuild", "timeout-decorator", "tox (>=2.4)"] + +[[package]] +name = "aiofiles" +version = "23.1.0" +description = "File support for asyncio." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "aiofiles-23.1.0-py3-none-any.whl", hash = "sha256:9312414ae06472eb6f1d163f555e466a23aed1c8f60c30cccf7121dba2e53eb2"}, + {file = "aiofiles-23.1.0.tar.gz", hash = "sha256:edd247df9a19e0db16534d4baaf536d6609a43e1de5401d7a4c1c148753a1635"}, +] + +[[package]] +name = "aiogram" +version = "2.15" +description = "Is a pretty simple and fully asynchronous framework for Telegram Bot API" +optional = false +python-versions = "*" +files = [ + {file = "aiogram-2.15-py3-none-any.whl", hash = "sha256:5d4dae610625893fe53e07c01c9e95671fd863718caab692baac948e3746ab87"}, + {file = "aiogram-2.15.tar.gz", hash = "sha256:13c740c52ee1301af8a9905e0a412754bcff03deb83dfdf1c578d9249ab35026"}, +] + +[package.dependencies] +aiohttp = ">=3.7.2,<4.0.0" +Babel = ">=2.8.0" +certifi = ">=2020.6.20" + +[package.extras] +fast = ["ujson (>=1.35)", "uvloop (>=0.14.0,<0.15.0)"] +proxy = ["aiohttp-socks (>=0.5.3,<0.6.0)"] + +[[package]] +name = "aiohttp" +version = "3.9.3" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:939677b61f9d72a4fa2a042a5eee2a99a24001a67c13da113b2e30396567db54"}, + {file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1f5cd333fcf7590a18334c90f8c9147c837a6ec8a178e88d90a9b96ea03194cc"}, + {file = "aiohttp-3.9.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82e6aa28dd46374f72093eda8bcd142f7771ee1eb9d1e223ff0fa7177a96b4a5"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f56455b0c2c7cc3b0c584815264461d07b177f903a04481dfc33e08a89f0c26b"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bca77a198bb6e69795ef2f09a5f4c12758487f83f33d63acde5f0d4919815768"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e083c285857b78ee21a96ba1eb1b5339733c3563f72980728ca2b08b53826ca5"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab40e6251c3873d86ea9b30a1ac6d7478c09277b32e14745d0d3c6e76e3c7e29"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df822ee7feaaeffb99c1a9e5e608800bd8eda6e5f18f5cfb0dc7eeb2eaa6bbec"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:acef0899fea7492145d2bbaaaec7b345c87753168589cc7faf0afec9afe9b747"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cd73265a9e5ea618014802ab01babf1940cecb90c9762d8b9e7d2cc1e1969ec6"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a78ed8a53a1221393d9637c01870248a6f4ea5b214a59a92a36f18151739452c"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6b0e029353361f1746bac2e4cc19b32f972ec03f0f943b390c4ab3371840aabf"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7cf5c9458e1e90e3c390c2639f1017a0379a99a94fdfad3a1fd966a2874bba52"}, + {file = "aiohttp-3.9.3-cp310-cp310-win32.whl", hash = "sha256:3e59c23c52765951b69ec45ddbbc9403a8761ee6f57253250c6e1536cacc758b"}, + {file = "aiohttp-3.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:055ce4f74b82551678291473f66dc9fb9048a50d8324278751926ff0ae7715e5"}, + {file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6b88f9386ff1ad91ace19d2a1c0225896e28815ee09fc6a8932fded8cda97c3d"}, + {file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c46956ed82961e31557b6857a5ca153c67e5476972e5f7190015018760938da2"}, + {file = "aiohttp-3.9.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07b837ef0d2f252f96009e9b8435ec1fef68ef8b1461933253d318748ec1acdc"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad46e6f620574b3b4801c68255492e0159d1712271cc99d8bdf35f2043ec266"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ed3e046ea7b14938112ccd53d91c1539af3e6679b222f9469981e3dac7ba1ce"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:039df344b45ae0b34ac885ab5b53940b174530d4dd8a14ed8b0e2155b9dddccb"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7943c414d3a8d9235f5f15c22ace69787c140c80b718dcd57caaade95f7cd93b"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84871a243359bb42c12728f04d181a389718710129b36b6aad0fc4655a7647d4"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5eafe2c065df5401ba06821b9a054d9cb2848867f3c59801b5d07a0be3a380ae"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9d3c9b50f19704552f23b4eaea1fc082fdd82c63429a6506446cbd8737823da3"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:f033d80bc6283092613882dfe40419c6a6a1527e04fc69350e87a9df02bbc283"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:2c895a656dd7e061b2fd6bb77d971cc38f2afc277229ce7dd3552de8313a483e"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1f5a71d25cd8106eab05f8704cd9167b6e5187bcdf8f090a66c6d88b634802b4"}, + {file = "aiohttp-3.9.3-cp311-cp311-win32.whl", hash = "sha256:50fca156d718f8ced687a373f9e140c1bb765ca16e3d6f4fe116e3df7c05b2c5"}, + {file = "aiohttp-3.9.3-cp311-cp311-win_amd64.whl", hash = "sha256:5fe9ce6c09668063b8447f85d43b8d1c4e5d3d7e92c63173e6180b2ac5d46dd8"}, + {file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:38a19bc3b686ad55804ae931012f78f7a534cce165d089a2059f658f6c91fa60"}, + {file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:770d015888c2a598b377bd2f663adfd947d78c0124cfe7b959e1ef39f5b13869"}, + {file = "aiohttp-3.9.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee43080e75fc92bf36219926c8e6de497f9b247301bbf88c5c7593d931426679"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52df73f14ed99cee84865b95a3d9e044f226320a87af208f068ecc33e0c35b96"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc9b311743a78043b26ffaeeb9715dc360335e5517832f5a8e339f8a43581e4d"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b955ed993491f1a5da7f92e98d5dad3c1e14dc175f74517c4e610b1f2456fb11"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504b6981675ace64c28bf4a05a508af5cde526e36492c98916127f5a02354d53"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fe5571784af92b6bc2fda8d1925cccdf24642d49546d3144948a6a1ed58ca5"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ba39e9c8627edc56544c8628cc180d88605df3892beeb2b94c9bc857774848ca"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e5e46b578c0e9db71d04c4b506a2121c0cb371dd89af17a0586ff6769d4c58c1"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:938a9653e1e0c592053f815f7028e41a3062e902095e5a7dc84617c87267ebd5"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:c3452ea726c76e92f3b9fae4b34a151981a9ec0a4847a627c43d71a15ac32aa6"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff30218887e62209942f91ac1be902cc80cddb86bf00fbc6783b7a43b2bea26f"}, + {file = "aiohttp-3.9.3-cp312-cp312-win32.whl", hash = "sha256:38f307b41e0bea3294a9a2a87833191e4bcf89bb0365e83a8be3a58b31fb7f38"}, + {file = "aiohttp-3.9.3-cp312-cp312-win_amd64.whl", hash = "sha256:b791a3143681a520c0a17e26ae7465f1b6f99461a28019d1a2f425236e6eedb5"}, + {file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ed621426d961df79aa3b963ac7af0d40392956ffa9be022024cd16297b30c8c"}, + {file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7f46acd6a194287b7e41e87957bfe2ad1ad88318d447caf5b090012f2c5bb528"}, + {file = "aiohttp-3.9.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feeb18a801aacb098220e2c3eea59a512362eb408d4afd0c242044c33ad6d542"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f734e38fd8666f53da904c52a23ce517f1b07722118d750405af7e4123933511"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b40670ec7e2156d8e57f70aec34a7216407848dfe6c693ef131ddf6e76feb672"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fdd215b7b7fd4a53994f238d0f46b7ba4ac4c0adb12452beee724ddd0743ae5d"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:017a21b0df49039c8f46ca0971b3a7fdc1f56741ab1240cb90ca408049766168"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e99abf0bba688259a496f966211c49a514e65afa9b3073a1fcee08856e04425b"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:648056db9a9fa565d3fa851880f99f45e3f9a771dd3ff3bb0c048ea83fb28194"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8aacb477dc26797ee089721536a292a664846489c49d3ef9725f992449eda5a8"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:522a11c934ea660ff8953eda090dcd2154d367dec1ae3c540aff9f8a5c109ab4"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5bce0dc147ca85caa5d33debc4f4d65e8e8b5c97c7f9f660f215fa74fc49a321"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4b4af9f25b49a7be47c0972139e59ec0e8285c371049df1a63b6ca81fdd216a2"}, + {file = "aiohttp-3.9.3-cp38-cp38-win32.whl", hash = "sha256:298abd678033b8571995650ccee753d9458dfa0377be4dba91e4491da3f2be63"}, + {file = "aiohttp-3.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:69361bfdca5468c0488d7017b9b1e5ce769d40b46a9f4a2eed26b78619e9396c"}, + {file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0fa43c32d1643f518491d9d3a730f85f5bbaedcbd7fbcae27435bb8b7a061b29"}, + {file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:835a55b7ca49468aaaac0b217092dfdff370e6c215c9224c52f30daaa735c1c1"}, + {file = "aiohttp-3.9.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06a9b2c8837d9a94fae16c6223acc14b4dfdff216ab9b7202e07a9a09541168f"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abf151955990d23f84205286938796c55ff11bbfb4ccfada8c9c83ae6b3c89a3"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59c26c95975f26e662ca78fdf543d4eeaef70e533a672b4113dd888bd2423caa"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f95511dd5d0e05fd9728bac4096319f80615aaef4acbecb35a990afebe953b0e"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:595f105710293e76b9dc09f52e0dd896bd064a79346234b521f6b968ffdd8e58"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7c8b816c2b5af5c8a436df44ca08258fc1a13b449393a91484225fcb7545533"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f1088fa100bf46e7b398ffd9904f4808a0612e1d966b4aa43baa535d1b6341eb"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f59dfe57bb1ec82ac0698ebfcdb7bcd0e99c255bd637ff613760d5f33e7c81b3"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:361a1026c9dd4aba0109e4040e2aecf9884f5cfe1b1b1bd3d09419c205e2e53d"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:363afe77cfcbe3a36353d8ea133e904b108feea505aa4792dad6585a8192c55a"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e2c45c208c62e955e8256949eb225bd8b66a4c9b6865729a786f2aa79b72e9d"}, + {file = "aiohttp-3.9.3-cp39-cp39-win32.whl", hash = "sha256:f7217af2e14da0856e082e96ff637f14ae45c10a5714b63c77f26d8884cf1051"}, + {file = "aiohttp-3.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:27468897f628c627230dba07ec65dc8d0db566923c48f29e084ce382119802bc"}, + {file = "aiohttp-3.9.3.tar.gz", hash = "sha256:90842933e5d1ff760fae6caca4b2b3edba53ba8f4b71e95dacf2818a2aca06f7"}, +] + +[package.dependencies] +aiosignal = ">=1.1.2" +async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns", "brotlicffi"] + +[[package]] +name = "aiohttp-retry" +version = "2.8.3" +description = "Simple retry client for aiohttp" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiohttp_retry-2.8.3-py3-none-any.whl", hash = "sha256:3aeeead8f6afe48272db93ced9440cf4eda8b6fd7ee2abb25357b7eb28525b45"}, + {file = "aiohttp_retry-2.8.3.tar.gz", hash = "sha256:9a8e637e31682ad36e1ff9f8bcba912fcfc7d7041722bc901a4b948da4d71ea9"}, +] + +[package.dependencies] +aiohttp = "*" + +[[package]] +name = "aioresponses" +version = "0.7.6" +description = "Mock out requests made by ClientSession from aiohttp package" +optional = false +python-versions = "*" +files = [ + {file = "aioresponses-0.7.6-py2.py3-none-any.whl", hash = "sha256:d2c26defbb9b440ea2685ec132e90700907fd10bcca3e85ec2f157219f0d26f7"}, + {file = "aioresponses-0.7.6.tar.gz", hash = "sha256:f795d9dbda2d61774840e7e32f5366f45752d1adc1b74c9362afd017296c7ee1"}, +] + +[package.dependencies] +aiohttp = ">=3.3.0,<4.0.0" + +[[package]] +name = "aiormq" +version = "6.4.2" +description = "Pure python AMQP asynchronous client library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiormq-6.4.2-py3-none-any.whl", hash = "sha256:fdbf2efed73a2e07437de3af5e3591165efb155a18394dc7295cac9e80943c62"}, + {file = "aiormq-6.4.2.tar.gz", hash = "sha256:fd815d2bb9d8c950361697a74c1b067bc078726c3ef3b837e979a68a4986b148"}, +] + +[package.dependencies] +pamqp = "3.2.1" +yarl = "*" + +[package.extras] +develop = ["aiomisc (>=16.0,<17.0)", "coverage (!=4.3)", "coveralls", "pylava", "pytest", "pytest-cov", "tox (>=2.4)"] + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "analytics-python" +version = "1.4.post1" +description = "The hassle-free way to integrate analytics into any python application." +optional = false +python-versions = "*" +files = [ + {file = "analytics-python-1.4.post1.tar.gz", hash = "sha256:b083e69c149c39e7ad17067f0e5c1742fbd15fdc469ade36c4d1ad5edf31ee5e"}, + {file = "analytics_python-1.4.post1-py2.py3-none-any.whl", hash = "sha256:33ab660150d0f37bb2fefc93fd19c9e7bd85e5b17db44df5e7e1139f63c14246"}, +] + +[package.dependencies] +backoff = "1.10.0" +monotonic = ">=1.5" +python-dateutil = ">2.1" +requests = ">=2.7,<3.0" +six = ">=1.5" + +[package.extras] +test = ["flake8 (==3.7.9)", "mock (==2.0.0)", "pylint (==1.9.3)"] + +[[package]] +name = "anyio" +version = "3.7.1" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.7" +files = [ + {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, + {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, +] + +[package.dependencies] +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] +test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (<0.22)"] + +[[package]] +name = "apscheduler" +version = "3.9.1.post1" +description = "In-process task scheduler with Cron-like capabilities" +optional = false +python-versions = "!=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +files = [ + {file = "APScheduler-3.9.1.post1-py2.py3-none-any.whl", hash = "sha256:c8c618241dbb2785ed5a687504b14cb1851d6f7b5a4edf3a51e39cc6a069967a"}, + {file = "APScheduler-3.9.1.post1.tar.gz", hash = "sha256:b2bea0309569da53a7261bfa0ce19c67ddbfe151bda776a6a907579fdbd3eb2a"}, +] + +[package.dependencies] +pytz = "*" +setuptools = ">=0.7" +six = ">=1.4.0" +tzlocal = ">=2.0,<3.dev0 || >=4.dev0" + +[package.extras] +asyncio = ["trollius"] +doc = ["sphinx", "sphinx-rtd-theme"] +gevent = ["gevent"] +mongodb = ["pymongo (>=3.0)"] +redis = ["redis (>=3.0)"] +rethinkdb = ["rethinkdb (>=2.4.0)"] +sqlalchemy = ["sqlalchemy (>=0.8)"] +testing = ["mock", "pytest", "pytest-asyncio", "pytest-asyncio (<0.6)", "pytest-cov", "pytest-tornado5"] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + +[[package]] +name = "astunparse" +version = "1.6.3" +description = "An AST unparser for Python" +optional = false +python-versions = "*" +files = [ + {file = "astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8"}, + {file = "astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872"}, +] + +[package.dependencies] +six = ">=1.6.1,<2.0" +wheel = ">=0.23.0,<1.0" + +[[package]] +name = "async-generator" +version = "1.10" +description = "Async generators and context managers for Python 3.5+" +optional = false +python-versions = ">=3.5" +files = [ + {file = "async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b"}, + {file = "async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"}, +] + +[[package]] +name = "async-timeout" +version = "4.0.2" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.6" +files = [ + {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, + {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, +] + +[[package]] +name = "attrs" +version = "22.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.5" +files = [ + {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, + {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, +] + +[package.extras] +dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] +docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] +tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] +tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] + +[[package]] +name = "azure-core" +version = "1.27.1" +description = "Microsoft Azure Core Library for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "azure-core-1.27.1.zip", hash = "sha256:5975c20808fa388243f01a8b79021bfbe114f503a27c543f002c5fc8bbdd73dd"}, + {file = "azure_core-1.27.1-py3-none-any.whl", hash = "sha256:1b4b19f455eb7b4332c6f92adc2c669353ded07c2722eb436165f0c253737792"}, +] + +[package.dependencies] +requests = ">=2.18.4" +six = ">=1.11.0" +typing-extensions = ">=4.3.0" + +[package.extras] +aio = ["aiohttp (>=3.0)"] + +[[package]] +name = "azure-storage-blob" +version = "12.15.0" +description = "Microsoft Azure Blob Storage Client Library for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "azure-storage-blob-12.15.0.zip", hash = "sha256:f8b8d582492740ab16744455408342fb8e4c8897b64a8a3fc31743844722c2f2"}, + {file = "azure_storage_blob-12.15.0-py3-none-any.whl", hash = "sha256:08d8807c577c63a436740627927c1a03a97c963efc29af5c818aed906590e1cf"}, +] + +[package.dependencies] +azure-core = ">=1.26.0,<2.0.0" +cryptography = ">=2.1.4" +isodate = ">=0.6.1" +typing-extensions = ">=4.0.1" + +[package.extras] +aio = ["azure-core[aio] (>=1.26.0,<2.0.0)"] + +[[package]] +name = "babel" +version = "2.9.1" +description = "Internationalization utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, + {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, +] + +[package.dependencies] +pytz = ">=2015.7" + +[[package]] +name = "backoff" +version = "1.10.0" +description = "Function decoration for backoff and retry" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "backoff-1.10.0-py2.py3-none-any.whl", hash = "sha256:5e73e2cbe780e1915a204799dba0a01896f45f4385e636bcca7a0614d879d0cd"}, + {file = "backoff-1.10.0.tar.gz", hash = "sha256:b8fba021fac74055ac05eb7c7bfce4723aedde6cd0a504e5326bcb0bdd6d19a4"}, +] + +[[package]] +name = "backports-zoneinfo" +version = "0.2.1" +description = "Backport of the standard library zoneinfo module" +optional = false +python-versions = ">=3.6" +files = [ + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win32.whl", hash = "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win32.whl", hash = "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"}, + {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"}, +] + +[package.extras] +tzdata = ["tzdata"] + +[[package]] +name = "bandit" +version = "1.7.5" +description = "Security oriented static analyser for python code." +optional = false +python-versions = ">=3.7" +files = [ + {file = "bandit-1.7.5-py3-none-any.whl", hash = "sha256:75665181dc1e0096369112541a056c59d1c5f66f9bb74a8d686c3c362b83f549"}, + {file = "bandit-1.7.5.tar.gz", hash = "sha256:bdfc739baa03b880c2d15d0431b31c658ffc348e907fe197e54e0389dd59e11e"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} +GitPython = ">=1.0.1" +PyYAML = ">=5.3.1" +rich = "*" +stevedore = ">=1.20.0" + +[package.extras] +test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "tomli (>=1.1.0)"] +toml = ["tomli (>=1.1.0)"] +yaml = ["PyYAML"] + +[[package]] +name = "bidict" +version = "0.22.1" +description = "The bidirectional mapping library for Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "bidict-0.22.1-py3-none-any.whl", hash = "sha256:6ef212238eb884b664f28da76f33f1d28b260f665fc737b413b287d5487d1e7b"}, + {file = "bidict-0.22.1.tar.gz", hash = "sha256:1e0f7f74e4860e6d0943a05d4134c63a2fad86f3d4732fb265bd79e4e856d81d"}, +] + +[package.extras] +docs = ["furo", "sphinx", "sphinx-copybutton"] +lint = ["pre-commit"] +test = ["hypothesis", "pytest", "pytest-benchmark[histogram]", "pytest-cov", "pytest-xdist", "sortedcollections", "sortedcontainers", "sphinx"] + +[[package]] +name = "black" +version = "22.12.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.7" +files = [ + {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"}, + {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"}, + {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"}, + {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"}, + {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"}, + {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"}, + {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"}, + {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"}, + {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"}, + {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"}, + {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"}, + {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "blis" +version = "0.7.9" +description = "The Blis BLAS-like linear algebra library, as a self-contained C-extension." +optional = true +python-versions = "*" +files = [ + {file = "blis-0.7.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3ea73707a7938304c08363a0b990600e579bfb52dece7c674eafac4bf2df9f7"}, + {file = "blis-0.7.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e85993364cae82707bfe7e637bee64ec96e232af31301e5c81a351778cb394b9"}, + {file = "blis-0.7.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d205a7e69523e2bacdd67ea906b82b84034067e0de83b33bd83eb96b9e844ae3"}, + {file = "blis-0.7.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9737035636452fb6d08e7ab79e5a9904be18a0736868a129179cd9f9ab59825"}, + {file = "blis-0.7.9-cp310-cp310-win_amd64.whl", hash = "sha256:d3882b4f44a33367812b5e287c0690027092830ffb1cce124b02f64e761819a4"}, + {file = "blis-0.7.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3dbb44311029263a6f65ed55a35f970aeb1d20b18bfac4c025de5aadf7889a8c"}, + {file = "blis-0.7.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6fd5941bd5a21082b19d1dd0f6d62cd35609c25eb769aa3457d9877ef2ce37a9"}, + {file = "blis-0.7.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97ad55e9ef36e4ff06b35802d0cf7bfc56f9697c6bc9427f59c90956bb98377d"}, + {file = "blis-0.7.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7b6315d7b1ac5546bc0350f5f8d7cc064438d23db19a5c21aaa6ae7d93c1ab5"}, + {file = "blis-0.7.9-cp311-cp311-win_amd64.whl", hash = "sha256:5fd46c649acd1920482b4f5556d1c88693cba9bf6a494a020b00f14b42e1132f"}, + {file = "blis-0.7.9-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db2959560dcb34e912dad0e0d091f19b05b61363bac15d78307c01334a4e5d9d"}, + {file = "blis-0.7.9-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0521231bc95ab522f280da3bbb096299c910a62cac2376d48d4a1d403c54393"}, + {file = "blis-0.7.9-cp36-cp36m-win_amd64.whl", hash = "sha256:d811e88480203d75e6e959f313fdbf3326393b4e2b317067d952347f5c56216e"}, + {file = "blis-0.7.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5cb1db88ab629ccb39eac110b742b98e3511d48ce9caa82ca32609d9169a9c9c"}, + {file = "blis-0.7.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c399a03de4059bf8e700b921f9ff5d72b2a86673616c40db40cd0592051bdd07"}, + {file = "blis-0.7.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4eb70a79562a211bd2e6b6db63f1e2eed32c0ab3e9ef921d86f657ae8375845"}, + {file = "blis-0.7.9-cp37-cp37m-win_amd64.whl", hash = "sha256:3e3f95e035c7456a1f5f3b5a3cfe708483a00335a3a8ad2211d57ba4d5f749a5"}, + {file = "blis-0.7.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:179037cb5e6744c2e93b6b5facc6e4a0073776d514933c3db1e1f064a3253425"}, + {file = "blis-0.7.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d0e82a6e0337d5231129a4e8b36978fa7b973ad3bb0257fd8e3714a9b35ceffd"}, + {file = "blis-0.7.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d12475e588a322e66a18346a3faa9eb92523504042e665c193d1b9b0b3f0482"}, + {file = "blis-0.7.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d5755ef37a573647be62684ca1545698879d07321f1e5b89a4fd669ce355eb0"}, + {file = "blis-0.7.9-cp38-cp38-win_amd64.whl", hash = "sha256:b8a1fcd2eb267301ab13e1e4209c165d172cdf9c0c9e08186a9e234bf91daa16"}, + {file = "blis-0.7.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8275f6b6eee714b85f00bf882720f508ed6a60974bcde489715d37fd35529da8"}, + {file = "blis-0.7.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7417667c221e29fe8662c3b2ff9bc201c6a5214bbb5eb6cc290484868802258d"}, + {file = "blis-0.7.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f4691bf62013eccc167c38a85c09a0bf0c6e3e80d4c2229cdf2668c1124eb0"}, + {file = "blis-0.7.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5cec812ee47b29107eb36af9b457be7191163eab65d61775ed63538232c59d5"}, + {file = "blis-0.7.9-cp39-cp39-win_amd64.whl", hash = "sha256:d81c3f627d33545fc25c9dcb5fee66c476d89288a27d63ac16ea63453401ffd5"}, + {file = "blis-0.7.9.tar.gz", hash = "sha256:29ef4c25007785a90ffc2f0ab3d3bd3b75cd2d7856a9a482b7d0dac8d511a09d"}, +] + +[package.dependencies] +numpy = ">=1.15.0" + +[[package]] +name = "boto3" +version = "1.27.1" +description = "The AWS SDK for Python" +optional = false +python-versions = ">= 3.7" +files = [ + {file = "boto3-1.27.1-py3-none-any.whl", hash = "sha256:0085c1066953e61915b34f24fbdee7117fd2d8b5c9188b9519d47ba84510c067"}, + {file = "boto3-1.27.1.tar.gz", hash = "sha256:cf43deb4556295219d9de44d1c95921209c90ee25246673b5768aef9d46519cc"}, +] + +[package.dependencies] +botocore = ">=1.30.1,<1.31.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.6.0,<0.7.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.30.1" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">= 3.7" +files = [ + {file = "botocore-1.30.1-py3-none-any.whl", hash = "sha256:18a32a21bfa9b418b9a38ea5ef4464eba003cbb26fca2cd56e4f51098c5d1a0f"}, + {file = "botocore-1.30.1.tar.gz", hash = "sha256:4d1ac5a796c5c5c87946f25f3d98764288a0ed848e772a7a47cd134847e885e7"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = ">=1.25.4,<1.27" + +[package.extras] +crt = ["awscrt (==0.16.9)"] + +[[package]] +name = "cachecontrol" +version = "0.12.14" +description = "httplib2 caching for requests" +optional = false +python-versions = ">=3.6" +files = [ + {file = "CacheControl-0.12.14-py2.py3-none-any.whl", hash = "sha256:1c2939be362a70c4e5f02c6249462b3b7a24441e4f1ced5e9ef028172edf356a"}, + {file = "CacheControl-0.12.14.tar.gz", hash = "sha256:d1087f45781c0e00616479bfd282c78504371ca71da017b49df9f5365a95feba"}, +] + +[package.dependencies] +msgpack = ">=0.5.2" +requests = "*" + +[package.extras] +filecache = ["lockfile (>=0.9)"] +redis = ["redis (>=2.10.5)"] + +[[package]] +name = "cachetools" +version = "5.3.1" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.3.1-py3-none-any.whl", hash = "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590"}, + {file = "cachetools-5.3.1.tar.gz", hash = "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b"}, +] + +[[package]] +name = "catalogue" +version = "2.0.8" +description = "Super lightweight function registries for your library" +optional = true +python-versions = ">=3.6" +files = [ + {file = "catalogue-2.0.8-py3-none-any.whl", hash = "sha256:2d786e229d8d202b4f8a2a059858e45a2331201d831e39746732daa704b99f69"}, + {file = "catalogue-2.0.8.tar.gz", hash = "sha256:b325c77659208bfb6af1b0d93b1a1aa4112e1bb29a4c5ced816758a722f0e388"}, +] + +[[package]] +name = "certifi" +version = "2023.7.22" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, + {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, +] + +[[package]] +name = "cffi" +version = "1.15.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = "*" +files = [ + {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, + {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, + {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, + {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, + {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, + {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, + {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, + {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, + {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, + {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, + {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, + {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, + {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, + {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, + {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, + {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, + {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, + {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, + {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "3.1.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, + {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, +] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "click-default-group" +version = "1.2.2" +description = "Extends click.Group to invoke a command without explicit subcommand name" +optional = false +python-versions = "*" +files = [ + {file = "click-default-group-1.2.2.tar.gz", hash = "sha256:d9560e8e8dfa44b3562fbc9425042a0fd6d21956fcc2db0077f63f34253ab904"}, +] + +[package.dependencies] +click = "*" + +[[package]] +name = "cloudpickle" +version = "2.2.1" +description = "Extended pickling support for Python objects" +optional = false +python-versions = ">=3.6" +files = [ + {file = "cloudpickle-2.2.1-py3-none-any.whl", hash = "sha256:61f594d1f4c295fa5cd9014ceb3a1fc4a70b0de1164b94fbc2d854ccba056f9f"}, + {file = "cloudpickle-2.2.1.tar.gz", hash = "sha256:d89684b8de9e34a2a43b3460fbca07d09d6e25ce858df4d5a44240403b6178f5"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "colorclass" +version = "2.2.2" +description = "Colorful worry-free console applications for Linux, Mac OS X, and Windows." +optional = false +python-versions = ">=2.6" +files = [ + {file = "colorclass-2.2.2-py2.py3-none-any.whl", hash = "sha256:6f10c273a0ef7a1150b1120b6095cbdd68e5cf36dfd5d0fc957a2500bbf99a55"}, + {file = "colorclass-2.2.2.tar.gz", hash = "sha256:6d4fe287766166a98ca7bc6f6312daf04a0481b1eda43e7173484051c0ab4366"}, +] + +[[package]] +name = "coloredlogs" +version = "15.0.1" +description = "Colored terminal output for Python's logging module" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"}, + {file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"}, +] + +[package.dependencies] +humanfriendly = ">=9.1" + +[package.extras] +cron = ["capturer (>=2.4)"] + +[[package]] +name = "colorhash" +version = "1.2.1" +description = "Generate color based on any object" +optional = false +python-versions = ">=3.6,<4.0" +files = [ + {file = "colorhash-1.2.1-py3-none-any.whl", hash = "sha256:644f307fd09464ad2512650a382a9ce5ea8b788c8eaba083b6c54e4699fdd5eb"}, + {file = "colorhash-1.2.1.tar.gz", hash = "sha256:143d739adeeb4949d69d35274f9a973fdd5f3a77c8658be2e23f967688869c7b"}, +] + +[[package]] +name = "confection" +version = "0.1.0" +description = "The sweetest config system for Python" +optional = true +python-versions = ">=3.6" +files = [ + {file = "confection-0.1.0-py3-none-any.whl", hash = "sha256:1d6de16297efe937efaad13f83f45467dedc05acafdb0fb16074299a9c683d85"}, + {file = "confection-0.1.0.tar.gz", hash = "sha256:81c8e58fa810f4a3135c3710652c2258c45b1eec35c8557762a0f133449c75a2"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<1.11.0" +srsly = ">=2.4.0,<3.0.0" + +[[package]] +name = "confluent-kafka" +version = "2.1.1" +description = "Confluent's Python client for Apache Kafka" +optional = false +python-versions = "*" +files = [ + {file = "confluent-kafka-2.1.1.tar.gz", hash = "sha256:a665e74030a85c5f4203538cfe77e401133a849c4b92052a60225b26052a7e9d"}, + {file = "confluent_kafka-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50b80f7ed6a187f3b3b109ccbf89c4ddf16280c5717d8ee25cd9fac001baae7a"}, + {file = "confluent_kafka-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5de086a2e607f73ecab9d49b8d1c2adc2ed6a328e4087996f16742acc69062b8"}, + {file = "confluent_kafka-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22c88b63dded4c4b71e1e704f04c7209c8e04938b050e399b1883e3411899fe6"}, + {file = "confluent_kafka-2.1.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c5ce525dbccabbf8cd0f0df975237e5c823a286b9a853fdeac8141336c29b4ef"}, + {file = "confluent_kafka-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:b517bcd0cbe88abbad60a1a6176f1c8db45d512f109b1a65f0110af4d92aba7c"}, + {file = "confluent_kafka-2.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f37cdeacb94a1c43b8bc5dddbe44b0bd72b932b906597ad62795cd43ef5aa6b8"}, + {file = "confluent_kafka-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4767e049c7d76e73b3c9fc036fcab24f1cff79b98c55d78b06edb4354b6e59c1"}, + {file = "confluent_kafka-2.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c906bb753381c809dbd8576507ff3410193f8edb28a63caef85ccd0f85996b4d"}, + {file = "confluent_kafka-2.1.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7a6eb7aaced82bf0f99702e76829dfe0d318a5c504bd6a6c36a63737e4b6ff36"}, + {file = "confluent_kafka-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:82322e774f307d7fce236a47ced2830a92a43f98194c1d2a277b366587def382"}, + {file = "confluent_kafka-2.1.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c378b7d01ea1f4b2fc587ccd6f17059b1afd2d39489de797e0df87ec25ab8a67"}, + {file = "confluent_kafka-2.1.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d673820d3473406fbd30e0a38d6fbd6b7b5a6a0db36ca2c1b4a7d5b9bc079dd1"}, + {file = "confluent_kafka-2.1.1-cp36-cp36m-manylinux_2_28_aarch64.whl", hash = "sha256:9069cf9190d121e7a479dde2eb0b89f7f62aeb7ac48d56d28d7d4bc20689c233"}, + {file = "confluent_kafka-2.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:326ec2261d994d2cede682d6015d5a10f188d82f0bff9c895e07e59b9e227138"}, + {file = "confluent_kafka-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a91c34aa8f3e4d095f5ed9f5cd7edf1217bed9bb69da412efd8541ace3683860"}, + {file = "confluent_kafka-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac1be768ece728ca9a15933a2f6c8184a4f0854d81a22302c90a222ec8ca55a"}, + {file = "confluent_kafka-2.1.1-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:369dfd5d8cd84cf1d9355054e0f2c9e9b8cf3877aeab4a85aec8909e775ae4c2"}, + {file = "confluent_kafka-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:82e286f573904e141f4e56add54f35f6f407713e329a344e0589443591c68481"}, + {file = "confluent_kafka-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:51d02f06e67a316d0c4a2cd939bef925ee87488eef78f63e5a054b1b3c5c4431"}, + {file = "confluent_kafka-2.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:09c6c44b484c75e88aad802b0433793969218af925d0b9fed4caae704fcd01d6"}, + {file = "confluent_kafka-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58126c36afea4c0de58342c5da3c4de2ce46b0951f3c8addb50f9ab340bddb39"}, + {file = "confluent_kafka-2.1.1-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:cb7b2f61db54f4b16609273f42c19fb05d69f44df5af8eaeff7cc502a9b8ff8f"}, + {file = "confluent_kafka-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:7fbaeaf4d236dd62b2822bdd48f276a8b39adc0987d021874368a69ac3b78fab"}, + {file = "confluent_kafka-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ca305c3256f7943ff0127d5e9797f7e4c314838232749db4ac8920552c38c0dd"}, + {file = "confluent_kafka-2.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e30ece4692355cc61b0acfe70d319c6ed4e38110e9b16c82619b46b58485f16b"}, + {file = "confluent_kafka-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1cb88bd70cfc191cc81f6164f1f898f8bf2ec501ab7abb03629102b62b706c8a"}, + {file = "confluent_kafka-2.1.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:cb3c81563fcc9071ae799529a7a1a3aa8fb4d94770222aaa811b3e2a54e87f8a"}, + {file = "confluent_kafka-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:bea62d091cc38a305ab877b349931ae69064b9ea50f350d9f91a86d01b10d85c"}, +] + +[package.extras] +avro = ["avro (>=1.11.1,<2)", "fastavro (>=0.23.0,<1.0)", "fastavro (>=1.0)", "requests"] +dev = ["avro (>=1.11.1,<2)", "fastavro (>=0.23.0,<1.0)", "fastavro (>=1.0)", "flake8", "pytest", "pytest (==4.6.4)", "pytest-timeout", "requests"] +doc = ["avro (>=1.11.1,<2)", "fastavro (>=0.23.0,<1.0)", "fastavro (>=1.0)", "requests", "sphinx", "sphinx-rtd-theme"] +json = ["jsonschema", "pyrsistent", "pyrsistent (==0.16.1)", "requests"] +protobuf = ["protobuf", "requests"] +schema-registry = ["requests"] + +[[package]] +name = "coverage" +version = "6.5.0" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"}, + {file = "coverage-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a"}, + {file = "coverage-6.5.0-cp310-cp310-win32.whl", hash = "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32"}, + {file = "coverage-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e"}, + {file = "coverage-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b"}, + {file = "coverage-6.5.0-cp311-cp311-win32.whl", hash = "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578"}, + {file = "coverage-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b"}, + {file = "coverage-6.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f"}, + {file = "coverage-6.5.0-cp37-cp37m-win32.whl", hash = "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b"}, + {file = "coverage-6.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e"}, + {file = "coverage-6.5.0-cp38-cp38-win32.whl", hash = "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d"}, + {file = "coverage-6.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f"}, + {file = "coverage-6.5.0-cp39-cp39-win32.whl", hash = "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72"}, + {file = "coverage-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987"}, + {file = "coverage-6.5.0-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"}, + {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "coveralls" +version = "3.3.1" +description = "Show coverage stats online via coveralls.io" +optional = false +python-versions = ">= 3.5" +files = [ + {file = "coveralls-3.3.1-py2.py3-none-any.whl", hash = "sha256:f42015f31d386b351d4226389b387ae173207058832fbf5c8ec4b40e27b16026"}, + {file = "coveralls-3.3.1.tar.gz", hash = "sha256:b32a8bb5d2df585207c119d6c01567b81fba690c9c10a753bfe27a335bfc43ea"}, +] + +[package.dependencies] +coverage = ">=4.1,<6.0.dev0 || >6.1,<6.1.1 || >6.1.1,<7.0" +docopt = ">=0.6.1" +requests = ">=1.0.0" + +[package.extras] +yaml = ["PyYAML (>=3.10)"] + +[[package]] +name = "cryptography" +version = "41.0.7" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-41.0.7-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:3c78451b78313fa81607fa1b3f1ae0a5ddd8014c38a02d9db0616133987b9cdf"}, + {file = "cryptography-41.0.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:928258ba5d6f8ae644e764d0f996d61a8777559f72dfeb2eea7e2fe0ad6e782d"}, + {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a1b41bc97f1ad230a41657d9155113c7521953869ae57ac39ac7f1bb471469a"}, + {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:841df4caa01008bad253bce2a6f7b47f86dc9f08df4b433c404def869f590a15"}, + {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5429ec739a29df2e29e15d082f1d9ad683701f0ec7709ca479b3ff2708dae65a"}, + {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:43f2552a2378b44869fe8827aa19e69512e3245a219104438692385b0ee119d1"}, + {file = "cryptography-41.0.7-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:af03b32695b24d85a75d40e1ba39ffe7db7ffcb099fe507b39fd41a565f1b157"}, + {file = "cryptography-41.0.7-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:49f0805fc0b2ac8d4882dd52f4a3b935b210935d500b6b805f321addc8177406"}, + {file = "cryptography-41.0.7-cp37-abi3-win32.whl", hash = "sha256:f983596065a18a2183e7f79ab3fd4c475205b839e02cbc0efbbf9666c4b3083d"}, + {file = "cryptography-41.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:90452ba79b8788fa380dfb587cca692976ef4e757b194b093d845e8d99f612f2"}, + {file = "cryptography-41.0.7-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:079b85658ea2f59c4f43b70f8119a52414cdb7be34da5d019a77bf96d473b960"}, + {file = "cryptography-41.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:b640981bf64a3e978a56167594a0e97db71c89a479da8e175d8bb5be5178c003"}, + {file = "cryptography-41.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e3114da6d7f95d2dee7d3f4eec16dacff819740bbab931aff8648cb13c5ff5e7"}, + {file = "cryptography-41.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d5ec85080cce7b0513cfd233914eb8b7bbd0633f1d1703aa28d1dd5a72f678ec"}, + {file = "cryptography-41.0.7-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7a698cb1dac82c35fcf8fe3417a3aaba97de16a01ac914b89a0889d364d2f6be"}, + {file = "cryptography-41.0.7-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:37a138589b12069efb424220bf78eac59ca68b95696fc622b6ccc1c0a197204a"}, + {file = "cryptography-41.0.7-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:68a2dec79deebc5d26d617bfdf6e8aab065a4f34934b22d3b5010df3ba36612c"}, + {file = "cryptography-41.0.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:09616eeaef406f99046553b8a40fbf8b1e70795a91885ba4c96a70793de5504a"}, + {file = "cryptography-41.0.7-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48a0476626da912a44cc078f9893f292f0b3e4c739caf289268168d8f4702a39"}, + {file = "cryptography-41.0.7-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c7f3201ec47d5207841402594f1d7950879ef890c0c495052fa62f58283fde1a"}, + {file = "cryptography-41.0.7-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c5ca78485a255e03c32b513f8c2bc39fedb7f5c5f8535545bdc223a03b24f248"}, + {file = "cryptography-41.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d6c391c021ab1f7a82da5d8d0b3cee2f4b2c455ec86c8aebbc84837a631ff309"}, + {file = "cryptography-41.0.7.tar.gz", hash = "sha256:13f93ce9bea8016c253b34afc6bd6a75993e5c40672ed5405a9c832f0d4a00bc"}, +] + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +nox = ["nox"] +pep8test = ["black", "check-sdist", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "cycler" +version = "0.11.0" +description = "Composable style cycles" +optional = false +python-versions = ">=3.6" +files = [ + {file = "cycler-0.11.0-py3-none-any.whl", hash = "sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3"}, + {file = "cycler-0.11.0.tar.gz", hash = "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f"}, +] + +[[package]] +name = "cymem" +version = "2.0.7" +description = "Manage calls to calloc/free through Cython" +optional = true +python-versions = "*" +files = [ + {file = "cymem-2.0.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4981fc9182cc1fe54bfedf5f73bfec3ce0c27582d9be71e130c46e35958beef0"}, + {file = "cymem-2.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:42aedfd2e77aa0518a24a2a60a2147308903abc8b13c84504af58539c39e52a3"}, + {file = "cymem-2.0.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c183257dc5ab237b664f64156c743e788f562417c74ea58c5a3939fe2d48d6f6"}, + {file = "cymem-2.0.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d18250f97eeb13af2e8b19d3cefe4bf743b963d93320b0a2e729771410fd8cf4"}, + {file = "cymem-2.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:864701e626b65eb2256060564ed8eb034ebb0a8f14ce3fbef337e88352cdee9f"}, + {file = "cymem-2.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:314273be1f143da674388e0a125d409e2721fbf669c380ae27c5cbae4011e26d"}, + {file = "cymem-2.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:df543a36e7000808fe0a03d92fd6cd8bf23fa8737c3f7ae791a5386de797bf79"}, + {file = "cymem-2.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e5e1b7de7952d89508d07601b9e95b2244e70d7ef60fbc161b3ad68f22815f8"}, + {file = "cymem-2.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2aa33f1dbd7ceda37970e174c38fd1cf106817a261aa58521ba9918156868231"}, + {file = "cymem-2.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:10178e402bb512b2686b8c2f41f930111e597237ca8f85cb583ea93822ef798d"}, + {file = "cymem-2.0.7-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2971b7da5aa2e65d8fbbe9f2acfc19ff8e73f1896e3d6e1223cc9bf275a0207"}, + {file = "cymem-2.0.7-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85359ab7b490e6c897c04863704481600bd45188a0e2ca7375eb5db193e13cb7"}, + {file = "cymem-2.0.7-cp36-cp36m-win_amd64.whl", hash = "sha256:0ac45088abffbae9b7db2c597f098de51b7e3c1023cb314e55c0f7f08440cf66"}, + {file = "cymem-2.0.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26e5d5c6958855d2fe3d5629afe85a6aae5531abaa76f4bc21b9abf9caaccdfe"}, + {file = "cymem-2.0.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:011039e12d3144ac1bf3a6b38f5722b817f0d6487c8184e88c891b360b69f533"}, + {file = "cymem-2.0.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f9e63e5ad4ed6ffa21fd8db1c03b05be3fea2f32e32fdace67a840ea2702c3d"}, + {file = "cymem-2.0.7-cp37-cp37m-win_amd64.whl", hash = "sha256:5ea6b027fdad0c3e9a4f1b94d28d213be08c466a60c72c633eb9db76cf30e53a"}, + {file = "cymem-2.0.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4302df5793a320c4f4a263c7785d2fa7f29928d72cb83ebeb34d64a610f8d819"}, + {file = "cymem-2.0.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:24b779046484674c054af1e779c68cb224dc9694200ac13b22129d7fb7e99e6d"}, + {file = "cymem-2.0.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c50794c612801ed8b599cd4af1ed810a0d39011711c8224f93e1153c00e08d1"}, + {file = "cymem-2.0.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9525ad563b36dc1e30889d0087a0daa67dd7bb7d3e1530c4b61cd65cc756a5b"}, + {file = "cymem-2.0.7-cp38-cp38-win_amd64.whl", hash = "sha256:48b98da6b906fe976865263e27734ebc64f972a978a999d447ad6c83334e3f90"}, + {file = "cymem-2.0.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e156788d32ad8f7141330913c5d5d2aa67182fca8f15ae22645e9f379abe8a4c"}, + {file = "cymem-2.0.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3da89464021fe669932fce1578343fcaf701e47e3206f50d320f4f21e6683ca5"}, + {file = "cymem-2.0.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f359cab9f16e25b3098f816c40acbf1697a3b614a8d02c56e6ebcb9c89a06b3"}, + {file = "cymem-2.0.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f165d7bce55d6730930e29d8294569788aa127f1be8d1642d9550ed96223cb37"}, + {file = "cymem-2.0.7-cp39-cp39-win_amd64.whl", hash = "sha256:59a09cf0e71b1b88bfa0de544b801585d81d06ea123c1725e7c5da05b7ca0d20"}, + {file = "cymem-2.0.7.tar.gz", hash = "sha256:e6034badb5dd4e10344211c81f16505a55553a7164adc314c75bd80cf07e57a8"}, +] + +[[package]] +name = "dask" +version = "2022.10.2" +description = "Parallel PyData with Task Scheduling" +optional = false +python-versions = ">=3.8" +files = [ + {file = "dask-2022.10.2-py3-none-any.whl", hash = "sha256:928003a97b890a14c8a09a01f15320d261053bda530a8bf191d84f33db4a63b8"}, + {file = "dask-2022.10.2.tar.gz", hash = "sha256:42cb43f601709575fa46ce09e74bea83fdd464187024f56954e09d9b428ceaab"}, +] + +[package.dependencies] +click = ">=7.0" +cloudpickle = ">=1.1.1" +fsspec = ">=0.6.0" +packaging = ">=20.0" +partd = ">=0.3.10" +pyyaml = ">=5.3.1" +toolz = ">=0.8.2" + +[package.extras] +array = ["numpy (>=1.18)"] +complete = ["bokeh (>=2.4.2,<3)", "distributed (==2022.10.2)", "jinja2", "numpy (>=1.18)", "pandas (>=1.0)"] +dataframe = ["numpy (>=1.18)", "pandas (>=1.0)"] +diagnostics = ["bokeh (>=2.4.2,<3)", "jinja2"] +distributed = ["distributed (==2022.10.2)"] +test = ["pandas[test]", "pre-commit", "pytest", "pytest-rerunfailures", "pytest-xdist"] + +[[package]] +name = "databind" +version = "1.5.3" +description = "Databind is a library inspired by jackson-databind to de-/serialize Python dataclasses. The `databind` package will install the full suite of databind packages. Compatible with Python 3.7 and newer." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "databind-1.5.3-py3-none-any.whl", hash = "sha256:43d76e3b931ed9d986b5e35df60e37ccb0bcb2b3b57d6f44cce085c48abe7016"}, + {file = "databind-1.5.3.tar.gz", hash = "sha256:a1986c1a2054d28d064cf7900c2d3f03850ace124331fa026e6049f390742122"}, +] + +[package.dependencies] +"databind.core" = ">=1.5.3,<2.0.0" +"databind.json" = ">=1.5.3,<2.0.0" + +[[package]] +name = "databind-core" +version = "1.5.3" +description = "Databind is a library inspired by jackson-databind to de-/serialize Python dataclasses. Compatible with Python 3.7 and newer." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "databind.core-1.5.3-py3-none-any.whl", hash = "sha256:105665947e0b0b4709b6dab99cd3cea31d16f5c0d68fe867e4dc2f47585669fb"}, + {file = "databind.core-1.5.3.tar.gz", hash = "sha256:663dd0ccfd1b181cbcfb8315226ec8df78bcd65c1b43875ba18d89702d15efbe"}, +] + +[package.dependencies] +Deprecated = ">=1.2.12,<2.0.0" +"nr.util" = ">=0.8.3,<1.0.0" +typing-extensions = ">=3.10.0" + +[[package]] +name = "databind-json" +version = "1.5.3" +description = "De-/serialize Python dataclasses to or from JSON payloads. Compatible with Python 3.7 and newer." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "databind.json-1.5.3-py3-none-any.whl", hash = "sha256:fd6702ca9f67b15414e573935bf4a5ccb221f3f810553a054b832393c057df3d"}, + {file = "databind.json-1.5.3.tar.gz", hash = "sha256:b8785ad9689b98d524d4c96e0756bc41166da0de44cc9d14ed2fa8aea5674507"}, +] + +[package.dependencies] +"databind.core" = ">=1.5.3,<2.0.0" +"nr.util" = ">=0.8.3,<1.0.0" +typing-extensions = ">=3.10.0" + +[[package]] +name = "datadog" +version = "0.45.0" +description = "The Datadog Python library" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "datadog-0.45.0-py2.py3-none-any.whl", hash = "sha256:144fce48bda79484b102349f159c4ea4c7cd35361f9e0d031ddf931a922a38a4"}, + {file = "datadog-0.45.0.tar.gz", hash = "sha256:6bffed67448cb4bf5dff559fb2acee1c06e7da8612b8e2a734f278b50b396603"}, +] + +[package.dependencies] +requests = ">=2.6.0" + +[[package]] +name = "datadog-api-client" +version = "2.14.0" +description = "Collection of all Datadog Public endpoints" +optional = false +python-versions = ">=3.7" +files = [ + {file = "datadog-api-client-2.14.0.tar.gz", hash = "sha256:b795aec3e0fdf841e8e4eef415e17d6e354f7bf5f3f39165ee2e69500f164891"}, + {file = "datadog_api_client-2.14.0-py3-none-any.whl", hash = "sha256:f0e82da2a0b0ad58ca8265beba3d93b46f776ad809820614e92fe40e50a4727b"}, +] + +[package.dependencies] +certifi = "*" +python-dateutil = "*" +typing-extensions = "*" +urllib3 = ">=1.15,<2.0" + +[package.extras] +apm = ["ddtrace (>=1.15.0)"] +async = ["aiosonic (==0.15.1)"] +tests = ["aiosonic (==0.15.1)", "glom", "jinja2", "mypy", "pytest", "pytest-asyncio", "pytest-bdd (==6.0.1)", "pytest-randomly", "pytest-recording", "python-dateutil", "types-python-dateutil", "zstandard"] +zstandard = ["zstandard"] + +[[package]] +name = "deprecated" +version = "1.2.14" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, + {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, +] + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] + +[[package]] +name = "dnspython" +version = "2.3.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "dnspython-2.3.0-py3-none-any.whl", hash = "sha256:89141536394f909066cabd112e3e1a37e4e654db00a25308b0f130bc3152eb46"}, + {file = "dnspython-2.3.0.tar.gz", hash = "sha256:224e32b03eb46be70e12ef6d64e0be123a64e621ab4c0822ff6d450d52a540b9"}, +] + +[package.extras] +curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"] +dnssec = ["cryptography (>=2.6,<40.0)"] +doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.11.0)"] +doq = ["aioquic (>=0.9.20)"] +idna = ["idna (>=2.1,<4.0)"] +trio = ["trio (>=0.14,<0.23)"] +wmi = ["wmi (>=1.5.1,<2.0.0)"] + +[[package]] +name = "docker" +version = "6.1.3" +description = "A Python library for the Docker Engine API." +optional = false +python-versions = ">=3.7" +files = [ + {file = "docker-6.1.3-py3-none-any.whl", hash = "sha256:aecd2277b8bf8e506e484f6ab7aec39abe0038e29fa4a6d3ba86c3fe01844ed9"}, + {file = "docker-6.1.3.tar.gz", hash = "sha256:aa6d17830045ba5ef0168d5eaa34d37beeb113948c413affe1d5991fc11f9a20"}, +] + +[package.dependencies] +packaging = ">=14.0" +pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} +requests = ">=2.26.0" +urllib3 = ">=1.26.0" +websocket-client = ">=0.32.0" + +[package.extras] +ssh = ["paramiko (>=2.4.3)"] + +[[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +optional = false +python-versions = "*" +files = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] + +[[package]] +name = "docspec" +version = "2.1.2" +description = "Docspec is a JSON object specification for representing API documentation of programming languages." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "docspec-2.1.2-py3-none-any.whl", hash = "sha256:19168242b1e6ca39553feba91b6284479e2bfdf6500949ce63e10c49354d00d7"}, + {file = "docspec-2.1.2.tar.gz", hash = "sha256:0edb90f4c54edbd43170875dece7a50750a21e95a4c5faf3f7e655d01d483fa7"}, +] + +[package.dependencies] +databind = ">=1.5.0,<2.0.0" +Deprecated = ">=1.2.12,<2.0.0" + +[[package]] +name = "docspec-python" +version = "2.0.2" +description = "A parser based on lib2to3 producing docspec data from Python source code." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "docspec-python-2.0.2.tar.gz", hash = "sha256:9bb1bcc2b1e6903d8ef00107607255285d1a1f763acb111f9d83c695878cacbc"}, + {file = "docspec_python-2.0.2-py3-none-any.whl", hash = "sha256:ffb35751562d13271e2792f4fe3aba9d79aa0493f966897b77ad73ed9cd5f6a6"}, +] + +[package.dependencies] +docspec = ">=2.0.2,<3.0.0" +"nr.util" = ">=0.7.0" + +[[package]] +name = "docstring-parser" +version = "0.11" +description = "\"Parse Python docstrings in reST, Google and Numpydoc format\"" +optional = false +python-versions = ">=3.6" +files = [ + {file = "docstring_parser-0.11.tar.gz", hash = "sha256:93b3f8f481c7d24e37c5d9f30293c89e2933fa209421c8abd731dd3ef0715ecb"}, +] + +[package.extras] +test = ["black", "pytest"] + +[[package]] +name = "exceptiongroup" +version = "1.1.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.1.2-py3-none-any.whl", hash = "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"}, + {file = "exceptiongroup-1.1.2.tar.gz", hash = "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "execnet" +version = "1.9.0" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "execnet-1.9.0-py2.py3-none-any.whl", hash = "sha256:a295f7cc774947aac58dde7fdc85f4aa00c42adf5d8f5468fc630c1acf30a142"}, + {file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"}, +] + +[package.extras] +testing = ["pre-commit"] + +[[package]] +name = "fakeredis" +version = "2.16.0" +description = "Python implementation of redis API, can be used for testing purposes." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "fakeredis-2.16.0-py3-none-any.whl", hash = "sha256:188514cbd7120ff28c88f2a31e2fddd18fb1b28504478dfa3669c683134c4d82"}, + {file = "fakeredis-2.16.0.tar.gz", hash = "sha256:5abdd734de4ead9d6c7acbd3add1c4aa9b3ab35219339530472d9dd2bdf13057"}, +] + +[package.dependencies] +redis = ">=4" +sortedcontainers = ">=2,<3" + +[package.extras] +json = ["jsonpath-ng (>=1.5,<2.0)"] +lua = ["lupa (>=1.14,<2.0)"] + +[[package]] +name = "fbmessenger" +version = "6.0.0" +description = "A python library to communicate with the Facebook Messenger API's" +optional = false +python-versions = "*" +files = [ + {file = "fbmessenger-6.0.0-py2.py3-none-any.whl", hash = "sha256:82cffd6e2fe02bfcf8ed083c59bdddcfdaa594dd0040f0c49eabbaf0e58d974c"}, + {file = "fbmessenger-6.0.0.tar.gz", hash = "sha256:6e42c4588a4c942547be228886278bbc7a084e0b34799c7e6ebd786129f021e6"}, +] + +[package.dependencies] +requests = ">=2.0" + +[[package]] +name = "filelock" +version = "3.12.2" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.7" +files = [ + {file = "filelock-3.12.2-py3-none-any.whl", hash = "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec"}, + {file = "filelock-3.12.2.tar.gz", hash = "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81"}, +] + +[package.extras] +docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] + +[[package]] +name = "fire" +version = "0.5.0" +description = "A library for automatically generating command line interfaces." +optional = false +python-versions = "*" +files = [ + {file = "fire-0.5.0.tar.gz", hash = "sha256:a6b0d49e98c8963910021f92bba66f65ab440da2982b78eb1bbf95a0a34aacc6"}, +] + +[package.dependencies] +six = "*" +termcolor = "*" + +[[package]] +name = "flatbuffers" +version = "23.5.26" +description = "The FlatBuffers serialization format for Python" +optional = false +python-versions = "*" +files = [ + {file = "flatbuffers-23.5.26-py2.py3-none-any.whl", hash = "sha256:c0ff356da363087b915fde4b8b45bdda73432fc17cddb3c8157472eab1422ad1"}, + {file = "flatbuffers-23.5.26.tar.gz", hash = "sha256:9ea1144cac05ce5d86e2859f431c6cd5e66cd9c78c558317c7955fb8d4c78d89"}, +] + +[[package]] +name = "fonttools" +version = "4.40.0" +description = "Tools to manipulate font files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fonttools-4.40.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b802dcbf9bcff74672f292b2466f6589ab8736ce4dcf36f48eb994c2847c4b30"}, + {file = "fonttools-4.40.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f6e3fa3da923063c286320e728ba2270e49c73386e3a711aa680f4b0747d692"}, + {file = "fonttools-4.40.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fdf60f8a5c6bcce7d024a33f7e4bc7921f5b74e8ea13bccd204f2c8b86f3470"}, + {file = "fonttools-4.40.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91784e21a1a085fac07c6a407564f4a77feb471b5954c9ee55a4f9165151f6c1"}, + {file = "fonttools-4.40.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:05171f3c546f64d78569f10adc0de72561882352cac39ec7439af12304d8d8c0"}, + {file = "fonttools-4.40.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7449e5e306f3a930a8944c85d0cbc8429cba13503372a1a40f23124d6fb09b58"}, + {file = "fonttools-4.40.0-cp310-cp310-win32.whl", hash = "sha256:bae8c13abbc2511e9a855d2142c0ab01178dd66b1a665798f357da0d06253e0d"}, + {file = "fonttools-4.40.0-cp310-cp310-win_amd64.whl", hash = "sha256:425b74a608427499b0e45e433c34ddc350820b6f25b7c8761963a08145157a66"}, + {file = "fonttools-4.40.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:00ab569b2a3e591e00425023ade87e8fef90380c1dde61be7691cb524ca5f743"}, + {file = "fonttools-4.40.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:18ea64ac43e94c9e0c23d7a9475f1026be0e25b10dda8f236fc956188761df97"}, + {file = "fonttools-4.40.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:022c4a16b412293e7f1ce21b8bab7a6f9d12c4ffdf171fdc67122baddb973069"}, + {file = "fonttools-4.40.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:530c5d35109f3e0cea2535742d6a3bc99c0786cf0cbd7bb2dc9212387f0d908c"}, + {file = "fonttools-4.40.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5e00334c66f4e83535384cb5339526d01d02d77f142c23b2f97bd6a4f585497a"}, + {file = "fonttools-4.40.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb52c10fda31159c22c7ed85074e05f8b97da8773ea461706c273e31bcbea836"}, + {file = "fonttools-4.40.0-cp311-cp311-win32.whl", hash = "sha256:6a8d71b9a5c884c72741868e845c0e563c5d83dcaf10bb0ceeec3b4b2eb14c67"}, + {file = "fonttools-4.40.0-cp311-cp311-win_amd64.whl", hash = "sha256:15abb3d055c1b2dff9ce376b6c3db10777cb74b37b52b78f61657634fd348a0d"}, + {file = "fonttools-4.40.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:14037c31138fbd21847ad5e5441dfdde003e0a8f3feb5812a1a21fd1c255ffbd"}, + {file = "fonttools-4.40.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:94c915f6716589f78bc00fbc14c5b8de65cfd11ee335d32504f1ef234524cb24"}, + {file = "fonttools-4.40.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37467cee0f32cada2ec08bc16c9c31f9b53ea54b2f5604bf25a1246b5f50593a"}, + {file = "fonttools-4.40.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56d4d85f5374b45b08d2f928517d1e313ea71b4847240398decd0ab3ebbca885"}, + {file = "fonttools-4.40.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8c4305b171b61040b1ee75d18f9baafe58bd3b798d1670078efe2c92436bfb63"}, + {file = "fonttools-4.40.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a954b90d1473c85a22ecf305761d9fd89da93bbd31dae86e7dea436ad2cb5dc9"}, + {file = "fonttools-4.40.0-cp38-cp38-win32.whl", hash = "sha256:1bc4c5b147be8dbc5df9cc8ac5e93ee914ad030fe2a201cc8f02f499db71011d"}, + {file = "fonttools-4.40.0-cp38-cp38-win_amd64.whl", hash = "sha256:8a917828dbfdb1cbe50cf40eeae6fbf9c41aef9e535649ed8f4982b2ef65c091"}, + {file = "fonttools-4.40.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:882983279bf39afe4e945109772c2ffad2be2c90983d6559af8b75c19845a80a"}, + {file = "fonttools-4.40.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c55f1b4109dbc3aeb496677b3e636d55ef46dc078c2a5e3f3db4e90f1c6d2907"}, + {file = "fonttools-4.40.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec468c022d09f1817c691cf884feb1030ef6f1e93e3ea6831b0d8144c06480d1"}, + {file = "fonttools-4.40.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d5adf4ba114f028fc3f5317a221fd8b0f4ef7a2e5524a2b1e0fd891b093791a"}, + {file = "fonttools-4.40.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:aa83b3f151bc63970f39b2b42a06097c5a22fd7ed9f7ba008e618de4503d3895"}, + {file = "fonttools-4.40.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:97d95b8301b62bdece1af943b88bcb3680fd385f88346a4a899ee145913b414a"}, + {file = "fonttools-4.40.0-cp39-cp39-win32.whl", hash = "sha256:1a003608400dd1cca3e089e8c94973c6b51a4fb1ef00ff6d7641617b9242e637"}, + {file = "fonttools-4.40.0-cp39-cp39-win_amd64.whl", hash = "sha256:7961575221e3da0841c75da53833272c520000d76f7f71274dbf43370f8a1065"}, + {file = "fonttools-4.40.0-py3-none-any.whl", hash = "sha256:200729d12461e2038700d31f0d49ad5a7b55855dec7525074979a06b46f88505"}, + {file = "fonttools-4.40.0.tar.gz", hash = "sha256:337b6e83d7ee73c40ea62407f2ce03b07c3459e213b6f332b94a69923b9e1cb9"}, +] + +[package.extras] +all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "fs (>=2.2.0,<3)", "lxml (>=4.0,<5)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.0.0)", "xattr", "zopfli (>=0.1.4)"] +graphite = ["lz4 (>=1.7.4.2)"] +interpolatable = ["munkres", "scipy"] +lxml = ["lxml (>=4.0,<5)"] +pathops = ["skia-pathops (>=0.5.0)"] +plot = ["matplotlib"] +repacker = ["uharfbuzz (>=0.23.0)"] +symfont = ["sympy"] +type1 = ["xattr"] +ufo = ["fs (>=2.2.0,<3)"] +unicode = ["unicodedata2 (>=15.0.0)"] +woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"] + +[[package]] +name = "freezegun" +version = "1.2.2" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.6" +files = [ + {file = "freezegun-1.2.2-py3-none-any.whl", hash = "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f"}, + {file = "freezegun-1.2.2.tar.gz", hash = "sha256:cd22d1ba06941384410cd967d8a99d5ae2442f57dfafeff2fda5de8dc5c05446"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + +[[package]] +name = "frozenlist" +version = "1.3.3" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.7" +files = [ + {file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff8bf625fe85e119553b5383ba0fb6aa3d0ec2ae980295aaefa552374926b3f4"}, + {file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dfbac4c2dfcc082fcf8d942d1e49b6aa0766c19d3358bd86e2000bf0fa4a9cf0"}, + {file = "frozenlist-1.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b1c63e8d377d039ac769cd0926558bb7068a1f7abb0f003e3717ee003ad85530"}, + {file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fdfc24dcfce5b48109867c13b4cb15e4660e7bd7661741a391f821f23dfdca7"}, + {file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c926450857408e42f0bbc295e84395722ce74bae69a3b2aa2a65fe22cb14b99"}, + {file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1841e200fdafc3d51f974d9d377c079a0694a8f06de2e67b48150328d66d5483"}, + {file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f470c92737afa7d4c3aacc001e335062d582053d4dbe73cda126f2d7031068dd"}, + {file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:783263a4eaad7c49983fe4b2e7b53fa9770c136c270d2d4bbb6d2192bf4d9caf"}, + {file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:924620eef691990dfb56dc4709f280f40baee568c794b5c1885800c3ecc69816"}, + {file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ae4dc05c465a08a866b7a1baf360747078b362e6a6dbeb0c57f234db0ef88ae0"}, + {file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:bed331fe18f58d844d39ceb398b77d6ac0b010d571cba8267c2e7165806b00ce"}, + {file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:02c9ac843e3390826a265e331105efeab489ffaf4dd86384595ee8ce6d35ae7f"}, + {file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9545a33965d0d377b0bc823dcabf26980e77f1b6a7caa368a365a9497fb09420"}, + {file = "frozenlist-1.3.3-cp310-cp310-win32.whl", hash = "sha256:d5cd3ab21acbdb414bb6c31958d7b06b85eeb40f66463c264a9b343a4e238642"}, + {file = "frozenlist-1.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:b756072364347cb6aa5b60f9bc18e94b2f79632de3b0190253ad770c5df17db1"}, + {file = "frozenlist-1.3.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b4395e2f8d83fbe0c627b2b696acce67868793d7d9750e90e39592b3626691b7"}, + {file = "frozenlist-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14143ae966a6229350021384870458e4777d1eae4c28d1a7aa47f24d030e6678"}, + {file = "frozenlist-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d8860749e813a6f65bad8285a0520607c9500caa23fea6ee407e63debcdbef6"}, + {file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23d16d9f477bb55b6154654e0e74557040575d9d19fe78a161bd33d7d76808e8"}, + {file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb82dbba47a8318e75f679690190c10a5e1f447fbf9df41cbc4c3afd726d88cb"}, + {file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9309869032abb23d196cb4e4db574232abe8b8be1339026f489eeb34a4acfd91"}, + {file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a97b4fe50b5890d36300820abd305694cb865ddb7885049587a5678215782a6b"}, + {file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c188512b43542b1e91cadc3c6c915a82a5eb95929134faf7fd109f14f9892ce4"}, + {file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:303e04d422e9b911a09ad499b0368dc551e8c3cd15293c99160c7f1f07b59a48"}, + {file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0771aed7f596c7d73444c847a1c16288937ef988dc04fb9f7be4b2aa91db609d"}, + {file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:66080ec69883597e4d026f2f71a231a1ee9887835902dbe6b6467d5a89216cf6"}, + {file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:41fe21dc74ad3a779c3d73a2786bdf622ea81234bdd4faf90b8b03cad0c2c0b4"}, + {file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f20380df709d91525e4bee04746ba612a4df0972c1b8f8e1e8af997e678c7b81"}, + {file = "frozenlist-1.3.3-cp311-cp311-win32.whl", hash = "sha256:f30f1928162e189091cf4d9da2eac617bfe78ef907a761614ff577ef4edfb3c8"}, + {file = "frozenlist-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:a6394d7dadd3cfe3f4b3b186e54d5d8504d44f2d58dcc89d693698e8b7132b32"}, + {file = "frozenlist-1.3.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8df3de3a9ab8325f94f646609a66cbeeede263910c5c0de0101079ad541af332"}, + {file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0693c609e9742c66ba4870bcee1ad5ff35462d5ffec18710b4ac89337ff16e27"}, + {file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd4210baef299717db0a600d7a3cac81d46ef0e007f88c9335db79f8979c0d3d"}, + {file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:394c9c242113bfb4b9aa36e2b80a05ffa163a30691c7b5a29eba82e937895d5e"}, + {file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6327eb8e419f7d9c38f333cde41b9ae348bec26d840927332f17e887a8dcb70d"}, + {file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e24900aa13212e75e5b366cb9065e78bbf3893d4baab6052d1aca10d46d944c"}, + {file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3843f84a6c465a36559161e6c59dce2f2ac10943040c2fd021cfb70d58c4ad56"}, + {file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:84610c1502b2461255b4c9b7d5e9c48052601a8957cd0aea6ec7a7a1e1fb9420"}, + {file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:c21b9aa40e08e4f63a2f92ff3748e6b6c84d717d033c7b3438dd3123ee18f70e"}, + {file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:efce6ae830831ab6a22b9b4091d411698145cb9b8fc869e1397ccf4b4b6455cb"}, + {file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:40de71985e9042ca00b7953c4f41eabc3dc514a2d1ff534027f091bc74416401"}, + {file = "frozenlist-1.3.3-cp37-cp37m-win32.whl", hash = "sha256:180c00c66bde6146a860cbb81b54ee0df350d2daf13ca85b275123bbf85de18a"}, + {file = "frozenlist-1.3.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9bbbcedd75acdfecf2159663b87f1bb5cfc80e7cd99f7ddd9d66eb98b14a8411"}, + {file = "frozenlist-1.3.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:034a5c08d36649591be1cbb10e09da9f531034acfe29275fc5454a3b101ce41a"}, + {file = "frozenlist-1.3.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba64dc2b3b7b158c6660d49cdb1d872d1d0bf4e42043ad8d5006099479a194e5"}, + {file = "frozenlist-1.3.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:47df36a9fe24054b950bbc2db630d508cca3aa27ed0566c0baf661225e52c18e"}, + {file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:008a054b75d77c995ea26629ab3a0c0d7281341f2fa7e1e85fa6153ae29ae99c"}, + {file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:841ea19b43d438a80b4de62ac6ab21cfe6827bb8a9dc62b896acc88eaf9cecba"}, + {file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e235688f42b36be2b6b06fc37ac2126a73b75fb8d6bc66dd632aa35286238703"}, + {file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca713d4af15bae6e5d79b15c10c8522859a9a89d3b361a50b817c98c2fb402a2"}, + {file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ac5995f2b408017b0be26d4a1d7c61bce106ff3d9e3324374d66b5964325448"}, + {file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4ae8135b11652b08a8baf07631d3ebfe65a4c87909dbef5fa0cdde440444ee4"}, + {file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4ea42116ceb6bb16dbb7d526e242cb6747b08b7710d9782aa3d6732bd8d27649"}, + {file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:810860bb4bdce7557bc0febb84bbd88198b9dbc2022d8eebe5b3590b2ad6c842"}, + {file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:ee78feb9d293c323b59a6f2dd441b63339a30edf35abcb51187d2fc26e696d13"}, + {file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0af2e7c87d35b38732e810befb9d797a99279cbb85374d42ea61c1e9d23094b3"}, + {file = "frozenlist-1.3.3-cp38-cp38-win32.whl", hash = "sha256:899c5e1928eec13fd6f6d8dc51be23f0d09c5281e40d9cf4273d188d9feeaf9b"}, + {file = "frozenlist-1.3.3-cp38-cp38-win_amd64.whl", hash = "sha256:7f44e24fa70f6fbc74aeec3e971f60a14dde85da364aa87f15d1be94ae75aeef"}, + {file = "frozenlist-1.3.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2b07ae0c1edaa0a36339ec6cce700f51b14a3fc6545fdd32930d2c83917332cf"}, + {file = "frozenlist-1.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ebb86518203e12e96af765ee89034a1dbb0c3c65052d1b0c19bbbd6af8a145e1"}, + {file = "frozenlist-1.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5cf820485f1b4c91e0417ea0afd41ce5cf5965011b3c22c400f6d144296ccbc0"}, + {file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c11e43016b9024240212d2a65043b70ed8dfd3b52678a1271972702d990ac6d"}, + {file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8fa3c6e3305aa1146b59a09b32b2e04074945ffcfb2f0931836d103a2c38f936"}, + {file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:352bd4c8c72d508778cf05ab491f6ef36149f4d0cb3c56b1b4302852255d05d5"}, + {file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65a5e4d3aa679610ac6e3569e865425b23b372277f89b5ef06cf2cdaf1ebf22b"}, + {file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1e2c1185858d7e10ff045c496bbf90ae752c28b365fef2c09cf0fa309291669"}, + {file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f163d2fd041c630fed01bc48d28c3ed4a3b003c00acd396900e11ee5316b56bb"}, + {file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:05cdb16d09a0832eedf770cb7bd1fe57d8cf4eaf5aced29c4e41e3f20b30a784"}, + {file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:8bae29d60768bfa8fb92244b74502b18fae55a80eac13c88eb0b496d4268fd2d"}, + {file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:eedab4c310c0299961ac285591acd53dc6723a1ebd90a57207c71f6e0c2153ab"}, + {file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3bbdf44855ed8f0fbcd102ef05ec3012d6a4fd7c7562403f76ce6a52aeffb2b1"}, + {file = "frozenlist-1.3.3-cp39-cp39-win32.whl", hash = "sha256:efa568b885bca461f7c7b9e032655c0c143d305bf01c30caf6db2854a4532b38"}, + {file = "frozenlist-1.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:cfe33efc9cb900a4c46f91a5ceba26d6df370ffddd9ca386eb1d4f0ad97b9ea9"}, + {file = "frozenlist-1.3.3.tar.gz", hash = "sha256:58bcc55721e8a90b88332d6cd441261ebb22342e238296bb330968952fbb3a6a"}, +] + +[[package]] +name = "fsspec" +version = "2023.6.0" +description = "File-system specification" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fsspec-2023.6.0-py3-none-any.whl", hash = "sha256:1cbad1faef3e391fba6dc005ae9b5bdcbf43005c9167ce78c915549c352c869a"}, + {file = "fsspec-2023.6.0.tar.gz", hash = "sha256:d0b2f935446169753e7a5c5c55681c54ea91996cc67be93c39a154fb3a2742af"}, +] + +[package.extras] +abfs = ["adlfs"] +adl = ["adlfs"] +arrow = ["pyarrow (>=1)"] +dask = ["dask", "distributed"] +devel = ["pytest", "pytest-cov"] +dropbox = ["dropbox", "dropboxdrivefs", "requests"] +full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "dask", "distributed", "dropbox", "dropboxdrivefs", "fusepy", "gcsfs", "libarchive-c", "ocifs", "panel", "paramiko", "pyarrow (>=1)", "pygit2", "requests", "s3fs", "smbprotocol", "tqdm"] +fuse = ["fusepy"] +gcs = ["gcsfs"] +git = ["pygit2"] +github = ["requests"] +gs = ["gcsfs"] +gui = ["panel"] +hdfs = ["pyarrow (>=1)"] +http = ["aiohttp (!=4.0.0a0,!=4.0.0a1)", "requests"] +libarchive = ["libarchive-c"] +oci = ["ocifs"] +s3 = ["s3fs"] +sftp = ["paramiko"] +smb = ["smbprotocol"] +ssh = ["paramiko"] +tqdm = ["tqdm"] + +[[package]] +name = "future" +version = "0.18.3" +description = "Clean single-source support for Python 3 and 2" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "future-0.18.3.tar.gz", hash = "sha256:34a17436ed1e96697a86f9de3d15a3b0be01d8bc8de9c1dffd59fb8234ed5307"}, +] + +[[package]] +name = "gast" +version = "0.4.0" +description = "Python AST that abstracts the underlying Python version" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "gast-0.4.0-py3-none-any.whl", hash = "sha256:b7adcdd5adbebf1adf17378da5ba3f543684dbec47b1cda1f3997e573cd542c4"}, + {file = "gast-0.4.0.tar.gz", hash = "sha256:40feb7b8b8434785585ab224d1568b857edb18297e5a3047f1ba012bc83b42c1"}, +] + +[[package]] +name = "gitdb" +version = "4.0.10" +description = "Git Object Database" +optional = false +python-versions = ">=3.7" +files = [ + {file = "gitdb-4.0.10-py3-none-any.whl", hash = "sha256:c286cf298426064079ed96a9e4a9d39e7f3e9bf15ba60701e95f5492f28415c7"}, + {file = "gitdb-4.0.10.tar.gz", hash = "sha256:6eb990b69df4e15bad899ea868dc46572c3f75339735663b81de79b06f17eb9a"}, +] + +[package.dependencies] +smmap = ">=3.0.1,<6" + +[[package]] +name = "github3-py" +version = "3.2.0" +description = "Python wrapper for the GitHub API(http://developer.github.com/v3)" +optional = true +python-versions = ">=3.6" +files = [ + {file = "github3.py-3.2.0-py2.py3-none-any.whl", hash = "sha256:a9016e40609c6f5cb9954dd188d08257dafd09c4da8c0e830a033fca00054b0d"}, + {file = "github3.py-3.2.0.tar.gz", hash = "sha256:09b72be1497d346b0968cde8360a0d6af79dc206d0149a63cd3ec86c65c377cc"}, +] + +[package.dependencies] +PyJWT = {version = ">=2.3.0", extras = ["crypto"]} +python-dateutil = ">=2.6.0" +requests = ">=2.18" +uritemplate = ">=3.0.0" + +[[package]] +name = "gitpython" +version = "3.1.31" +description = "GitPython is a Python library used to interact with Git repositories" +optional = false +python-versions = ">=3.7" +files = [ + {file = "GitPython-3.1.31-py3-none-any.whl", hash = "sha256:f04893614f6aa713a60cbbe1e6a97403ef633103cdd0ef5eb6efe0deb98dbe8d"}, + {file = "GitPython-3.1.31.tar.gz", hash = "sha256:8ce3bcf69adfdf7c7d503e78fd3b1c492af782d58893b650adb2ac8912ddd573"}, +] + +[package.dependencies] +gitdb = ">=4.0.1,<5" + +[[package]] +name = "google-api-core" +version = "2.11.1" +description = "Google API client core library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google-api-core-2.11.1.tar.gz", hash = "sha256:25d29e05a0058ed5f19c61c0a78b1b53adea4d9364b464d014fbda941f6d1c9a"}, + {file = "google_api_core-2.11.1-py3-none-any.whl", hash = "sha256:d92a5a92dc36dd4f4b9ee4e55528a90e432b059f93aee6ad857f9de8cc7ae94a"}, +] + +[package.dependencies] +google-auth = ">=2.14.1,<3.0.dev0" +googleapis-common-protos = ">=1.56.2,<2.0.dev0" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" +requests = ">=2.18.0,<3.0.0.dev0" + +[package.extras] +grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev)", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0)"] +grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] +grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] + +[[package]] +name = "google-auth" +version = "2.21.0" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "google-auth-2.21.0.tar.gz", hash = "sha256:b28e8048e57727e7cf0e5bd8e7276b212aef476654a09511354aa82753b45c66"}, + {file = "google_auth-2.21.0-py2.py3-none-any.whl", hash = "sha256:da3f18d074fa0f5a7061d99b9af8cee3aa6189c987af7c1b07d94566b6b11268"}, +] + +[package.dependencies] +cachetools = ">=2.0.0,<6.0" +pyasn1-modules = ">=0.2.1" +rsa = ">=3.1.4,<5" +six = ">=1.9.0" +urllib3 = "<2.0" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] +enterprise-cert = ["cryptography (==36.0.2)", "pyopenssl (==22.0.0)"] +pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.20.0,<3.0.0.dev0)"] + +[[package]] +name = "google-auth-oauthlib" +version = "1.0.0" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "google-auth-oauthlib-1.0.0.tar.gz", hash = "sha256:e375064964820b47221a7e1b7ee1fd77051b6323c3f9e3e19785f78ab67ecfc5"}, + {file = "google_auth_oauthlib-1.0.0-py2.py3-none-any.whl", hash = "sha256:95880ca704928c300f48194d1770cf5b1462835b6e49db61445a520f793fd5fb"}, +] + +[package.dependencies] +google-auth = ">=2.15.0" +requests-oauthlib = ">=0.7.0" + +[package.extras] +tool = ["click (>=6.0.0)"] + +[[package]] +name = "google-cloud-core" +version = "2.3.3" +description = "Google Cloud API client core library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google-cloud-core-2.3.3.tar.gz", hash = "sha256:37b80273c8d7eee1ae816b3a20ae43585ea50506cb0e60f3cf5be5f87f1373cb"}, + {file = "google_cloud_core-2.3.3-py2.py3-none-any.whl", hash = "sha256:fbd11cad3e98a7e5b0343dc07cb1039a5ffd7a5bb96e1f1e27cee4bda4a90863"}, +] + +[package.dependencies] +google-api-core = ">=1.31.6,<2.0.dev0 || >2.3.0,<3.0.0dev" +google-auth = ">=1.25.0,<3.0dev" + +[package.extras] +grpc = ["grpcio (>=1.38.0,<2.0dev)"] + +[[package]] +name = "google-cloud-storage" +version = "2.10.0" +description = "Google Cloud Storage API client library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google-cloud-storage-2.10.0.tar.gz", hash = "sha256:934b31ead5f3994e5360f9ff5750982c5b6b11604dc072bc452c25965e076dc7"}, + {file = "google_cloud_storage-2.10.0-py2.py3-none-any.whl", hash = "sha256:9433cf28801671de1c80434238fb1e7e4a1ba3087470e90f70c928ea77c2b9d7"}, +] + +[package.dependencies] +google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0dev" +google-auth = ">=1.25.0,<3.0dev" +google-cloud-core = ">=2.3.0,<3.0dev" +google-resumable-media = ">=2.3.2" +requests = ">=2.18.0,<3.0.0dev" + +[package.extras] +protobuf = ["protobuf (<5.0.0dev)"] + +[[package]] +name = "google-crc32c" +version = "1.5.0" +description = "A python wrapper of the C library 'Google CRC32C'" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google-crc32c-1.5.0.tar.gz", hash = "sha256:89284716bc6a5a415d4eaa11b1726d2d60a0cd12aadf5439828353662ede9dd7"}, + {file = "google_crc32c-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:596d1f98fc70232fcb6590c439f43b350cb762fb5d61ce7b0e9db4539654cc13"}, + {file = "google_crc32c-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:be82c3c8cfb15b30f36768797a640e800513793d6ae1724aaaafe5bf86f8f346"}, + {file = "google_crc32c-1.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:461665ff58895f508e2866824a47bdee72497b091c730071f2b7575d5762ab65"}, + {file = "google_crc32c-1.5.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2096eddb4e7c7bdae4bd69ad364e55e07b8316653234a56552d9c988bd2d61b"}, + {file = "google_crc32c-1.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:116a7c3c616dd14a3de8c64a965828b197e5f2d121fedd2f8c5585c547e87b02"}, + {file = "google_crc32c-1.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5829b792bf5822fd0a6f6eb34c5f81dd074f01d570ed7f36aa101d6fc7a0a6e4"}, + {file = "google_crc32c-1.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:64e52e2b3970bd891309c113b54cf0e4384762c934d5ae56e283f9a0afcd953e"}, + {file = "google_crc32c-1.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:02ebb8bf46c13e36998aeaad1de9b48f4caf545e91d14041270d9dca767b780c"}, + {file = "google_crc32c-1.5.0-cp310-cp310-win32.whl", hash = "sha256:2e920d506ec85eb4ba50cd4228c2bec05642894d4c73c59b3a2fe20346bd00ee"}, + {file = "google_crc32c-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:07eb3c611ce363c51a933bf6bd7f8e3878a51d124acfc89452a75120bc436289"}, + {file = "google_crc32c-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cae0274952c079886567f3f4f685bcaf5708f0a23a5f5216fdab71f81a6c0273"}, + {file = "google_crc32c-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1034d91442ead5a95b5aaef90dbfaca8633b0247d1e41621d1e9f9db88c36298"}, + {file = "google_crc32c-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c42c70cd1d362284289c6273adda4c6af8039a8ae12dc451dcd61cdabb8ab57"}, + {file = "google_crc32c-1.5.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8485b340a6a9e76c62a7dce3c98e5f102c9219f4cfbf896a00cf48caf078d438"}, + {file = "google_crc32c-1.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77e2fd3057c9d78e225fa0a2160f96b64a824de17840351b26825b0848022906"}, + {file = "google_crc32c-1.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f583edb943cf2e09c60441b910d6a20b4d9d626c75a36c8fcac01a6c96c01183"}, + {file = "google_crc32c-1.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:a1fd716e7a01f8e717490fbe2e431d2905ab8aa598b9b12f8d10abebb36b04dd"}, + {file = "google_crc32c-1.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:72218785ce41b9cfd2fc1d6a017dc1ff7acfc4c17d01053265c41a2c0cc39b8c"}, + {file = "google_crc32c-1.5.0-cp311-cp311-win32.whl", hash = "sha256:66741ef4ee08ea0b2cc3c86916ab66b6aef03768525627fd6a1b34968b4e3709"}, + {file = "google_crc32c-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba1eb1843304b1e5537e1fca632fa894d6f6deca8d6389636ee5b4797affb968"}, + {file = "google_crc32c-1.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:98cb4d057f285bd80d8778ebc4fde6b4d509ac3f331758fb1528b733215443ae"}, + {file = "google_crc32c-1.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd8536e902db7e365f49e7d9029283403974ccf29b13fc7028b97e2295b33556"}, + {file = "google_crc32c-1.5.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19e0a019d2c4dcc5e598cd4a4bc7b008546b0358bd322537c74ad47a5386884f"}, + {file = "google_crc32c-1.5.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02c65b9817512edc6a4ae7c7e987fea799d2e0ee40c53ec573a692bee24de876"}, + {file = "google_crc32c-1.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6ac08d24c1f16bd2bf5eca8eaf8304812f44af5cfe5062006ec676e7e1d50afc"}, + {file = "google_crc32c-1.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3359fc442a743e870f4588fcf5dcbc1bf929df1fad8fb9905cd94e5edb02e84c"}, + {file = "google_crc32c-1.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e986b206dae4476f41bcec1faa057851f3889503a70e1bdb2378d406223994a"}, + {file = "google_crc32c-1.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:de06adc872bcd8c2a4e0dc51250e9e65ef2ca91be023b9d13ebd67c2ba552e1e"}, + {file = "google_crc32c-1.5.0-cp37-cp37m-win32.whl", hash = "sha256:d3515f198eaa2f0ed49f8819d5732d70698c3fa37384146079b3799b97667a94"}, + {file = "google_crc32c-1.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:67b741654b851abafb7bc625b6d1cdd520a379074e64b6a128e3b688c3c04740"}, + {file = "google_crc32c-1.5.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c02ec1c5856179f171e032a31d6f8bf84e5a75c45c33b2e20a3de353b266ebd8"}, + {file = "google_crc32c-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:edfedb64740750e1a3b16152620220f51d58ff1b4abceb339ca92e934775c27a"}, + {file = "google_crc32c-1.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84e6e8cd997930fc66d5bb4fde61e2b62ba19d62b7abd7a69920406f9ecca946"}, + {file = "google_crc32c-1.5.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:024894d9d3cfbc5943f8f230e23950cd4906b2fe004c72e29b209420a1e6b05a"}, + {file = "google_crc32c-1.5.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:998679bf62b7fb599d2878aa3ed06b9ce688b8974893e7223c60db155f26bd8d"}, + {file = "google_crc32c-1.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:83c681c526a3439b5cf94f7420471705bbf96262f49a6fe546a6db5f687a3d4a"}, + {file = "google_crc32c-1.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4c6fdd4fccbec90cc8a01fc00773fcd5fa28db683c116ee3cb35cd5da9ef6c37"}, + {file = "google_crc32c-1.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5ae44e10a8e3407dbe138984f21e536583f2bba1be9491239f942c2464ac0894"}, + {file = "google_crc32c-1.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:37933ec6e693e51a5b07505bd05de57eee12f3e8c32b07da7e73669398e6630a"}, + {file = "google_crc32c-1.5.0-cp38-cp38-win32.whl", hash = "sha256:fe70e325aa68fa4b5edf7d1a4b6f691eb04bbccac0ace68e34820d283b5f80d4"}, + {file = "google_crc32c-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:74dea7751d98034887dbd821b7aae3e1d36eda111d6ca36c206c44478035709c"}, + {file = "google_crc32c-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c6c777a480337ac14f38564ac88ae82d4cd238bf293f0a22295b66eb89ffced7"}, + {file = "google_crc32c-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:759ce4851a4bb15ecabae28f4d2e18983c244eddd767f560165563bf9aefbc8d"}, + {file = "google_crc32c-1.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f13cae8cc389a440def0c8c52057f37359014ccbc9dc1f0827936bcd367c6100"}, + {file = "google_crc32c-1.5.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e560628513ed34759456a416bf86b54b2476c59144a9138165c9a1575801d0d9"}, + {file = "google_crc32c-1.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1674e4307fa3024fc897ca774e9c7562c957af85df55efe2988ed9056dc4e57"}, + {file = "google_crc32c-1.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:278d2ed7c16cfc075c91378c4f47924c0625f5fc84b2d50d921b18b7975bd210"}, + {file = "google_crc32c-1.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d5280312b9af0976231f9e317c20e4a61cd2f9629b7bfea6a693d1878a264ebd"}, + {file = "google_crc32c-1.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8b87e1a59c38f275c0e3676fc2ab6d59eccecfd460be267ac360cc31f7bcde96"}, + {file = "google_crc32c-1.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7c074fece789b5034b9b1404a1f8208fc2d4c6ce9decdd16e8220c5a793e6f61"}, + {file = "google_crc32c-1.5.0-cp39-cp39-win32.whl", hash = "sha256:7f57f14606cd1dd0f0de396e1e53824c371e9544a822648cd76c034d209b559c"}, + {file = "google_crc32c-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:a2355cba1f4ad8b6988a4ca3feed5bff33f6af2d7f134852cf279c2aebfde541"}, + {file = "google_crc32c-1.5.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f314013e7dcd5cf45ab1945d92e713eec788166262ae8deb2cfacd53def27325"}, + {file = "google_crc32c-1.5.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b747a674c20a67343cb61d43fdd9207ce5da6a99f629c6e2541aa0e89215bcd"}, + {file = "google_crc32c-1.5.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8f24ed114432de109aa9fd317278518a5af2d31ac2ea6b952b2f7782b43da091"}, + {file = "google_crc32c-1.5.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8667b48e7a7ef66afba2c81e1094ef526388d35b873966d8a9a447974ed9178"}, + {file = "google_crc32c-1.5.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:1c7abdac90433b09bad6c43a43af253e688c9cfc1c86d332aed13f9a7c7f65e2"}, + {file = "google_crc32c-1.5.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6f998db4e71b645350b9ac28a2167e6632c239963ca9da411523bb439c5c514d"}, + {file = "google_crc32c-1.5.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c99616c853bb585301df6de07ca2cadad344fd1ada6d62bb30aec05219c45d2"}, + {file = "google_crc32c-1.5.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ad40e31093a4af319dadf503b2467ccdc8f67c72e4bcba97f8c10cb078207b5"}, + {file = "google_crc32c-1.5.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd67cf24a553339d5062eff51013780a00d6f97a39ca062781d06b3a73b15462"}, + {file = "google_crc32c-1.5.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:398af5e3ba9cf768787eef45c803ff9614cc3e22a5b2f7d7ae116df8b11e3314"}, + {file = "google_crc32c-1.5.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b1f8133c9a275df5613a451e73f36c2aea4fe13c5c8997e22cf355ebd7bd0728"}, + {file = "google_crc32c-1.5.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ba053c5f50430a3fcfd36f75aff9caeba0440b2d076afdb79a318d6ca245f88"}, + {file = "google_crc32c-1.5.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:272d3892a1e1a2dbc39cc5cde96834c236d5327e2122d3aaa19f6614531bb6eb"}, + {file = "google_crc32c-1.5.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:635f5d4dd18758a1fbd1049a8e8d2fee4ffed124462d837d1a02a0e009c3ab31"}, + {file = "google_crc32c-1.5.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c672d99a345849301784604bfeaeba4db0c7aae50b95be04dd651fd2a7310b93"}, +] + +[package.extras] +testing = ["pytest"] + +[[package]] +name = "google-pasta" +version = "0.2.0" +description = "pasta is an AST-based Python refactoring library" +optional = false +python-versions = "*" +files = [ + {file = "google-pasta-0.2.0.tar.gz", hash = "sha256:c9f2c8dfc8f96d0d5808299920721be30c9eec37f2389f28904f454565c8a16e"}, + {file = "google_pasta-0.2.0-py2-none-any.whl", hash = "sha256:4612951da876b1a10fe3960d7226f0c7682cf901e16ac06e473b267a5afa8954"}, + {file = "google_pasta-0.2.0-py3-none-any.whl", hash = "sha256:b32482794a366b5366a32c92a9a9201b107821889935a02b3e51f6b432ea84ed"}, +] + +[package.dependencies] +six = "*" + +[[package]] +name = "google-resumable-media" +version = "2.5.0" +description = "Utilities for Google Media Downloads and Resumable Uploads" +optional = false +python-versions = ">= 3.7" +files = [ + {file = "google-resumable-media-2.5.0.tar.gz", hash = "sha256:218931e8e2b2a73a58eb354a288e03a0fd5fb1c4583261ac6e4c078666468c93"}, + {file = "google_resumable_media-2.5.0-py2.py3-none-any.whl", hash = "sha256:da1bd943e2e114a56d85d6848497ebf9be6a14d3db23e9fc57581e7c3e8170ec"}, +] + +[package.dependencies] +google-crc32c = ">=1.0,<2.0dev" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)"] +requests = ["requests (>=2.18.0,<3.0.0dev)"] + +[[package]] +name = "googleapis-common-protos" +version = "1.59.1" +description = "Common protobufs used in Google APIs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "googleapis-common-protos-1.59.1.tar.gz", hash = "sha256:b35d530fe825fb4227857bc47ad84c33c809ac96f312e13182bdeaa2abe1178a"}, + {file = "googleapis_common_protos-1.59.1-py2.py3-none-any.whl", hash = "sha256:0cbedb6fb68f1c07e18eb4c48256320777707e7d0c55063ae56c15db3224a61e"}, +] + +[package.dependencies] +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" + +[package.extras] +grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] + +[[package]] +name = "greenlet" +version = "2.0.2" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" +files = [ + {file = "greenlet-2.0.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:bdfea8c661e80d3c1c99ad7c3ff74e6e87184895bbaca6ee8cc61209f8b9b85d"}, + {file = "greenlet-2.0.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9d14b83fab60d5e8abe587d51c75b252bcc21683f24699ada8fb275d7712f5a9"}, + {file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"}, + {file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"}, + {file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"}, + {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c"}, + {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"}, + {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"}, + {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"}, + {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470"}, + {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a"}, + {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"}, + {file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"}, + {file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"}, + {file = "greenlet-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47"}, + {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"}, + {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"}, + {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"}, + {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19"}, + {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3"}, + {file = "greenlet-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5"}, + {file = "greenlet-2.0.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:910841381caba4f744a44bf81bfd573c94e10b3045ee00de0cbf436fe50673a6"}, + {file = "greenlet-2.0.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:18a7f18b82b52ee85322d7a7874e676f34ab319b9f8cce5de06067384aa8ff43"}, + {file = "greenlet-2.0.2-cp35-cp35m-win32.whl", hash = "sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a"}, + {file = "greenlet-2.0.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4b58adb399c4d61d912c4c331984d60eb66565175cdf4a34792cd9600f21b394"}, + {file = "greenlet-2.0.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:703f18f3fda276b9a916f0934d2fb6d989bf0b4fb5a64825260eb9bfd52d78f0"}, + {file = "greenlet-2.0.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:32e5b64b148966d9cccc2c8d35a671409e45f195864560829f395a54226408d3"}, + {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dd11f291565a81d71dab10b7033395b7a3a5456e637cf997a6f33ebdf06f8db"}, + {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0f72c9ddb8cd28532185f54cc1453f2c16fb417a08b53a855c4e6a418edd099"}, + {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd021c754b162c0fb55ad5d6b9d960db667faad0fa2ff25bb6e1301b0b6e6a75"}, + {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:3c9b12575734155d0c09d6c3e10dbd81665d5c18e1a7c6597df72fd05990c8cf"}, + {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b9ec052b06a0524f0e35bd8790686a1da006bd911dd1ef7d50b77bfbad74e292"}, + {file = "greenlet-2.0.2-cp36-cp36m-win32.whl", hash = "sha256:dbfcfc0218093a19c252ca8eb9aee3d29cfdcb586df21049b9d777fd32c14fd9"}, + {file = "greenlet-2.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:9f35ec95538f50292f6d8f2c9c9f8a3c6540bbfec21c9e5b4b751e0a7c20864f"}, + {file = "greenlet-2.0.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:d5508f0b173e6aa47273bdc0a0b5ba055b59662ba7c7ee5119528f466585526b"}, + {file = "greenlet-2.0.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:f82d4d717d8ef19188687aa32b8363e96062911e63ba22a0cff7802a8e58e5f1"}, + {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9c59a2120b55788e800d82dfa99b9e156ff8f2227f07c5e3012a45a399620b7"}, + {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2780572ec463d44c1d3ae850239508dbeb9fed38e294c68d19a24d925d9223ca"}, + {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937e9020b514ceedb9c830c55d5c9872abc90f4b5862f89c0887033ae33c6f73"}, + {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:36abbf031e1c0f79dd5d596bfaf8e921c41df2bdf54ee1eed921ce1f52999a86"}, + {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:18e98fb3de7dba1c0a852731c3070cf022d14f0d68b4c87a19cc1016f3bb8b33"}, + {file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"}, + {file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"}, + {file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"}, + {file = "greenlet-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1"}, + {file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"}, + {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"}, + {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"}, + {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acd2162a36d3de67ee896c43effcd5ee3de247eb00354db411feb025aa319857"}, + {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0bf60faf0bc2468089bdc5edd10555bab6e85152191df713e2ab1fcc86382b5a"}, + {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"}, + {file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"}, + {file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"}, + {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417"}, + {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"}, + {file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"}, + {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"}, + {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b"}, + {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b"}, + {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8"}, + {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9"}, + {file = "greenlet-2.0.2-cp39-cp39-win32.whl", hash = "sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5"}, + {file = "greenlet-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564"}, + {file = "greenlet-2.0.2.tar.gz", hash = "sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0"}, +] + +[package.extras] +docs = ["Sphinx", "docutils (<0.18)"] +test = ["objgraph", "psutil"] + +[[package]] +name = "grpcio" +version = "1.56.0" +description = "HTTP/2-based RPC framework" +optional = false +python-versions = ">=3.7" +files = [ + {file = "grpcio-1.56.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:fb34ace11419f1ae321c36ccaa18d81cd3f20728cd191250be42949d6845bb2d"}, + {file = "grpcio-1.56.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:008767c0aed4899e657b50f2e0beacbabccab51359eba547f860e7c55f2be6ba"}, + {file = "grpcio-1.56.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:17f47aeb9be0da5337f9ff33ebb8795899021e6c0741ee68bd69774a7804ca86"}, + {file = "grpcio-1.56.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43c50d810cc26349b093bf2cfe86756ab3e9aba3e7e681d360930c1268e1399a"}, + {file = "grpcio-1.56.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187b8f71bad7d41eea15e0c9812aaa2b87adfb343895fffb704fb040ca731863"}, + {file = "grpcio-1.56.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:881575f240eb5db72ddca4dc5602898c29bc082e0d94599bf20588fb7d1ee6a0"}, + {file = "grpcio-1.56.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c243b158dd7585021d16c50498c4b2ec0a64a6119967440c5ff2d8c89e72330e"}, + {file = "grpcio-1.56.0-cp310-cp310-win32.whl", hash = "sha256:8b3b2c7b5feef90bc9a5fa1c7f97637e55ec3e76460c6d16c3013952ee479cd9"}, + {file = "grpcio-1.56.0-cp310-cp310-win_amd64.whl", hash = "sha256:03a80451530fd3b8b155e0c4480434f6be669daf7ecba56f73ef98f94222ee01"}, + {file = "grpcio-1.56.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:64bd3abcf9fb4a9fa4ede8d0d34686314a7075f62a1502217b227991d9ca4245"}, + {file = "grpcio-1.56.0-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:fdc3a895791af4addbb826808d4c9c35917c59bb5c430d729f44224e51c92d61"}, + {file = "grpcio-1.56.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:4f84a6fd4482e5fe73b297d4874b62a535bc75dc6aec8e9fe0dc88106cd40397"}, + {file = "grpcio-1.56.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14e70b4dda3183abea94c72d41d5930c333b21f8561c1904a372d80370592ef3"}, + {file = "grpcio-1.56.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b5ce42a5ebe3e04796246ba50357f1813c44a6efe17a37f8dc7a5c470377312"}, + {file = "grpcio-1.56.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8219f17baf069fe8e42bd8ca0b312b875595e43a70cabf397be4fda488e2f27d"}, + {file = "grpcio-1.56.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:defdd14b518e6e468466f799aaa69db0355bca8d3a5ea75fb912d28ba6f8af31"}, + {file = "grpcio-1.56.0-cp311-cp311-win32.whl", hash = "sha256:50f4daa698835accbbcc60e61e0bc29636c0156ddcafb3891c987e533a0031ba"}, + {file = "grpcio-1.56.0-cp311-cp311-win_amd64.whl", hash = "sha256:59c4e606993a47146fbeaf304b9e78c447f5b9ee5641cae013028c4cca784617"}, + {file = "grpcio-1.56.0-cp37-cp37m-linux_armv7l.whl", hash = "sha256:b1f4b6f25a87d80b28dd6d02e87d63fe1577fe6d04a60a17454e3f8077a38279"}, + {file = "grpcio-1.56.0-cp37-cp37m-macosx_10_10_universal2.whl", hash = "sha256:c2148170e01d464d41011a878088444c13413264418b557f0bdcd1bf1b674a0e"}, + {file = "grpcio-1.56.0-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:0409de787ebbf08c9d2bca2bcc7762c1efe72eada164af78b50567a8dfc7253c"}, + {file = "grpcio-1.56.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66f0369d27f4c105cd21059d635860bb2ea81bd593061c45fb64875103f40e4a"}, + {file = "grpcio-1.56.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38fdf5bd0a1c754ce6bf9311a3c2c7ebe56e88b8763593316b69e0e9a56af1de"}, + {file = "grpcio-1.56.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:79d4c5911d12a7aa671e5eb40cbb50a830396525014d2d6f254ea2ba180ce637"}, + {file = "grpcio-1.56.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5d2fc471668a7222e213f86ef76933b18cdda6a51ea1322034478df8c6519959"}, + {file = "grpcio-1.56.0-cp37-cp37m-win_amd64.whl", hash = "sha256:991224fd485e088d3cb5e34366053691a4848a6b7112b8f5625a411305c26691"}, + {file = "grpcio-1.56.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:c6f36621aabecbaff3e70c4d1d924c76c8e6a7ffec60c331893640a4af0a8037"}, + {file = "grpcio-1.56.0-cp38-cp38-macosx_10_10_universal2.whl", hash = "sha256:1eadd6de258901929223f422ffed7f8b310c0323324caf59227f9899ea1b1674"}, + {file = "grpcio-1.56.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:72836b5a1d4f508ffbcfe35033d027859cc737972f9dddbe33fb75d687421e2e"}, + {file = "grpcio-1.56.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f92a99ab0c7772fb6859bf2e4f44ad30088d18f7c67b83205297bfb229e0d2cf"}, + {file = "grpcio-1.56.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa08affbf672d051cd3da62303901aeb7042a2c188c03b2c2a2d346fc5e81c14"}, + {file = "grpcio-1.56.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e2db108b4c8e29c145e95b0226973a66d73ae3e3e7fae00329294af4e27f1c42"}, + {file = "grpcio-1.56.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8674fdbd28266d8efbcddacf4ec3643f76fe6376f73283fd63a8374c14b0ef7c"}, + {file = "grpcio-1.56.0-cp38-cp38-win32.whl", hash = "sha256:bd55f743e654fb050c665968d7ec2c33f03578a4bbb163cfce38024775ff54cc"}, + {file = "grpcio-1.56.0-cp38-cp38-win_amd64.whl", hash = "sha256:c63bc5ac6c7e646c296fed9139097ae0f0e63f36f0864d7ce431cce61fe0118a"}, + {file = "grpcio-1.56.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:c0bc9dda550785d23f4f025be614b7faa8d0293e10811f0f8536cf50435b7a30"}, + {file = "grpcio-1.56.0-cp39-cp39-macosx_10_10_universal2.whl", hash = "sha256:d596408bab632ec7b947761e83ce6b3e7632e26b76d64c239ba66b554b7ee286"}, + {file = "grpcio-1.56.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:76b6e6e1ee9bda32e6e933efd61c512e9a9f377d7c580977f090d1a9c78cca44"}, + {file = "grpcio-1.56.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7beb84ebd0a3f732625124b73969d12b7350c5d9d64ddf81ae739bbc63d5b1ed"}, + {file = "grpcio-1.56.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83ec714bbbe9b9502177c842417fde39f7a267031e01fa3cd83f1ca49688f537"}, + {file = "grpcio-1.56.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4feee75565d1b5ab09cb3a5da672b84ca7f6dd80ee07a50f5537207a9af543a4"}, + {file = "grpcio-1.56.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b4638a796778329cc8e142e4f57c705adb286b3ba64e00b0fa91eeb919611be8"}, + {file = "grpcio-1.56.0-cp39-cp39-win32.whl", hash = "sha256:437af5a7673bca89c4bc0a993382200592d104dd7bf55eddcd141cef91f40bab"}, + {file = "grpcio-1.56.0-cp39-cp39-win_amd64.whl", hash = "sha256:4241a1c2c76e748023c834995cd916570e7180ee478969c2d79a60ce007bc837"}, + {file = "grpcio-1.56.0.tar.gz", hash = "sha256:4c08ee21b3d10315b8dc26f6c13917b20ed574cdbed2d2d80c53d5508fdcc0f2"}, +] + +[package.extras] +protobuf = ["grpcio-tools (>=1.56.0)"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "h5py" +version = "3.9.0" +description = "Read and write HDF5 files from Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "h5py-3.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb7bdd5e601dd1739698af383be03f3dad0465fe67184ebd5afca770f50df9d6"}, + {file = "h5py-3.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:78e44686334cbbf2dd21d9df15823bc38663f27a3061f6a032c68a3e30c47bf7"}, + {file = "h5py-3.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f68b41efd110ce9af1cbe6fa8af9f4dcbadace6db972d30828b911949e28fadd"}, + {file = "h5py-3.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12aa556d540f11a2cae53ea7cfb94017353bd271fb3962e1296b342f6550d1b8"}, + {file = "h5py-3.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d97409e17915798029e297a84124705c8080da901307ea58f29234e09b073ddc"}, + {file = "h5py-3.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:551e358db05a874a0f827b22e95b30092f2303edc4b91bb62ad2f10e0236e1a0"}, + {file = "h5py-3.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6822a814b9d8b8363ff102f76ea8d026f0ca25850bb579d85376029ee3e73b93"}, + {file = "h5py-3.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f01202cdea754ab4227dd27014bdbd561a4bbe4b631424fd812f7c2ce9c6ac"}, + {file = "h5py-3.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64acceaf6aff92af091a4b83f6dee3cf8d3061f924a6bb3a33eb6c4658a8348b"}, + {file = "h5py-3.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:804c7fb42a34c8ab3a3001901c977a5c24d2e9c586a0f3e7c0a389130b4276fc"}, + {file = "h5py-3.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8d9492391ff5c3c80ec30ae2fe82a3f0efd1e750833739c25b0d090e3be1b095"}, + {file = "h5py-3.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9da9e7e63376c32704e37ad4cea2dceae6964cee0d8515185b3ab9cbd6b947bc"}, + {file = "h5py-3.9.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4e20897c88759cbcbd38fb45b507adc91af3e0f67722aa302d71f02dd44d286"}, + {file = "h5py-3.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbf5225543ca35ce9f61c950b73899a82be7ba60d58340e76d0bd42bf659235a"}, + {file = "h5py-3.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:36408f8c62f50007d14e000f9f3acf77e103b9e932c114cbe52a3089e50ebf94"}, + {file = "h5py-3.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:23e74b878bbe1653ab34ca49b83cac85529cd0b36b9d625516c5830cc5ca2eac"}, + {file = "h5py-3.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3f457089c5d524b7998e3649bc63240679b8fb0a3859ea53bbb06841f3d755f1"}, + {file = "h5py-3.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6284061f3214335e1eec883a6ee497dbe7a79f19e6a57fed2dd1f03acd5a8cb"}, + {file = "h5py-3.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95f7a745efd0d56076999b52e8da5fad5d30823bac98b59c68ae75588d09991a"}, + {file = "h5py-3.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:79bbca34696c6f9eeeb36a91776070c49a060b2879828e2c8fa6c58b8ed10dd1"}, + {file = "h5py-3.9.0.tar.gz", hash = "sha256:e604db6521c1e367c6bd7fad239c847f53cc46646f2d2651372d05ae5e95f817"}, +] + +[package.dependencies] +numpy = ">=1.17.3" + +[[package]] +name = "httpcore" +version = "0.16.3" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.7" +files = [ + {file = "httpcore-0.16.3-py3-none-any.whl", hash = "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0"}, + {file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"}, +] + +[package.dependencies] +anyio = ">=3.0,<5.0" +certifi = "*" +h11 = ">=0.13,<0.15" +sniffio = "==1.*" + +[package.extras] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + +[[package]] +name = "httptools" +version = "0.5.0" +description = "A collection of framework independent HTTP protocol utils." +optional = false +python-versions = ">=3.5.0" +files = [ + {file = "httptools-0.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f470c79061599a126d74385623ff4744c4e0f4a0997a353a44923c0b561ee51"}, + {file = "httptools-0.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e90491a4d77d0cb82e0e7a9cb35d86284c677402e4ce7ba6b448ccc7325c5421"}, + {file = "httptools-0.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1d2357f791b12d86faced7b5736dea9ef4f5ecdc6c3f253e445ee82da579449"}, + {file = "httptools-0.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f90cd6fd97c9a1b7fe9215e60c3bd97336742a0857f00a4cb31547bc22560c2"}, + {file = "httptools-0.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5230a99e724a1bdbbf236a1b58d6e8504b912b0552721c7c6b8570925ee0ccde"}, + {file = "httptools-0.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3a47a34f6015dd52c9eb629c0f5a8a5193e47bf2a12d9a3194d231eaf1bc451a"}, + {file = "httptools-0.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:24bb4bb8ac3882f90aa95403a1cb48465de877e2d5298ad6ddcfdebec060787d"}, + {file = "httptools-0.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e67d4f8734f8054d2c4858570cc4b233bf753f56e85217de4dfb2495904cf02e"}, + {file = "httptools-0.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7e5eefc58d20e4c2da82c78d91b2906f1a947ef42bd668db05f4ab4201a99f49"}, + {file = "httptools-0.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0297822cea9f90a38df29f48e40b42ac3d48a28637368f3ec6d15eebefd182f9"}, + {file = "httptools-0.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:557be7fbf2bfa4a2ec65192c254e151684545ebab45eca5d50477d562c40f986"}, + {file = "httptools-0.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:54465401dbbec9a6a42cf737627fb0f014d50dc7365a6b6cd57753f151a86ff0"}, + {file = "httptools-0.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4d9ebac23d2de960726ce45f49d70eb5466725c0087a078866043dad115f850f"}, + {file = "httptools-0.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:e8a34e4c0ab7b1ca17b8763613783e2458e77938092c18ac919420ab8655c8c1"}, + {file = "httptools-0.5.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f659d7a48401158c59933904040085c200b4be631cb5f23a7d561fbae593ec1f"}, + {file = "httptools-0.5.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1616b3ba965cd68e6f759eeb5d34fbf596a79e84215eeceebf34ba3f61fdc7"}, + {file = "httptools-0.5.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3625a55886257755cb15194efbf209584754e31d336e09e2ffe0685a76cb4b60"}, + {file = "httptools-0.5.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:72ad589ba5e4a87e1d404cc1cb1b5780bfcb16e2aec957b88ce15fe879cc08ca"}, + {file = "httptools-0.5.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:850fec36c48df5a790aa735417dca8ce7d4b48d59b3ebd6f83e88a8125cde324"}, + {file = "httptools-0.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f222e1e9d3f13b68ff8a835574eda02e67277d51631d69d7cf7f8e07df678c86"}, + {file = "httptools-0.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3cb8acf8f951363b617a8420768a9f249099b92e703c052f9a51b66342eea89b"}, + {file = "httptools-0.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:550059885dc9c19a072ca6d6735739d879be3b5959ec218ba3e013fd2255a11b"}, + {file = "httptools-0.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a04fe458a4597aa559b79c7f48fe3dceabef0f69f562daf5c5e926b153817281"}, + {file = "httptools-0.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7d0c1044bce274ec6711f0770fd2d5544fe392591d204c68328e60a46f88843b"}, + {file = "httptools-0.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c6eeefd4435055a8ebb6c5cc36111b8591c192c56a95b45fe2af22d9881eee25"}, + {file = "httptools-0.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:5b65be160adcd9de7a7e6413a4966665756e263f0d5ddeffde277ffeee0576a5"}, + {file = "httptools-0.5.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fe9c766a0c35b7e3d6b6939393c8dfdd5da3ac5dec7f971ec9134f284c6c36d6"}, + {file = "httptools-0.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:85b392aba273566c3d5596a0a490978c085b79700814fb22bfd537d381dd230c"}, + {file = "httptools-0.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5e3088f4ed33947e16fd865b8200f9cfae1144f41b64a8cf19b599508e096bc"}, + {file = "httptools-0.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c2a56b6aad7cc8f5551d8e04ff5a319d203f9d870398b94702300de50190f63"}, + {file = "httptools-0.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9b571b281a19762adb3f48a7731f6842f920fa71108aff9be49888320ac3e24d"}, + {file = "httptools-0.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa47ffcf70ba6f7848349b8a6f9b481ee0f7637931d91a9860a1838bfc586901"}, + {file = "httptools-0.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:bede7ee075e54b9a5bde695b4fc8f569f30185891796b2e4e09e2226801d09bd"}, + {file = "httptools-0.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:64eba6f168803a7469866a9c9b5263a7463fa8b7a25b35e547492aa7322036b6"}, + {file = "httptools-0.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4b098e4bb1174096a93f48f6193e7d9aa7071506a5877da09a783509ca5fff42"}, + {file = "httptools-0.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9423a2de923820c7e82e18980b937893f4aa8251c43684fa1772e341f6e06887"}, + {file = "httptools-0.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca1b7becf7d9d3ccdbb2f038f665c0f4857e08e1d8481cbcc1a86a0afcfb62b2"}, + {file = "httptools-0.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:50d4613025f15f4b11f1c54bbed4761c0020f7f921b95143ad6d58c151198142"}, + {file = "httptools-0.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8ffce9d81c825ac1deaa13bc9694c0562e2840a48ba21cfc9f3b4c922c16f372"}, + {file = "httptools-0.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:1af91b3650ce518d226466f30bbba5b6376dbd3ddb1b2be8b0658c6799dd450b"}, + {file = "httptools-0.5.0.tar.gz", hash = "sha256:295874861c173f9101960bba332429bb77ed4dcd8cdf5cee9922eb00e4f6bc09"}, +] + +[package.extras] +test = ["Cython (>=0.29.24,<0.30.0)"] + +[[package]] +name = "httpx" +version = "0.23.3" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.7" +files = [ + {file = "httpx-0.23.3-py3-none-any.whl", hash = "sha256:a211fcce9b1254ea24f0cd6af9869b3d29aba40154e947d2a07bb499b3e310d6"}, + {file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"}, +] + +[package.dependencies] +certifi = "*" +httpcore = ">=0.15.0,<0.17.0" +rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<13)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + +[[package]] +name = "huggingface-hub" +version = "0.27.0" +description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "huggingface_hub-0.27.0-py3-none-any.whl", hash = "sha256:8f2e834517f1f1ddf1ecc716f91b120d7333011b7485f665a9a412eacb1a2a81"}, + {file = "huggingface_hub-0.27.0.tar.gz", hash = "sha256:902cce1a1be5739f5589e560198a65a8edcfd3b830b1666f36e4b961f0454fac"}, +] + +[package.dependencies] +filelock = "*" +fsspec = ">=2023.5.0" +packaging = ">=20.9" +pyyaml = ">=5.1" +requests = "*" +tqdm = ">=4.42.1" +typing-extensions = ">=3.7.4.3" + +[package.extras] +all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio (>=4.0.0)", "jedi", "libcst (==1.4.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.5.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +cli = ["InquirerPy (==0.3.4)"] +dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio (>=4.0.0)", "jedi", "libcst (==1.4.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.5.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"] +hf-transfer = ["hf-transfer (>=0.1.4)"] +inference = ["aiohttp"] +quality = ["libcst (==1.4.0)", "mypy (==1.5.1)", "ruff (>=0.5.0)"] +tensorflow = ["graphviz", "pydot", "tensorflow"] +tensorflow-testing = ["keras (<3.0)", "tensorflow"] +testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio (>=4.0.0)", "jedi", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] +torch = ["safetensors[torch]", "torch"] +typing = ["types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"] + +[[package]] +name = "humanfriendly" +version = "10.0" +description = "Human friendly output for text interfaces using Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, + {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, +] + +[package.dependencies] +pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""} + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] + +[[package]] +name = "importlib-metadata" +version = "6.7.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, + {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] + +[[package]] +name = "importlib-resources" +version = "5.12.0" +description = "Read resources from Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "importlib_resources-5.12.0-py3-none-any.whl", hash = "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a"}, + {file = "importlib_resources-5.12.0.tar.gz", hash = "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6"}, +] + +[package.dependencies] +zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] + +[[package]] +name = "incremental" +version = "22.10.0" +description = "\"A small library that versions your Python projects.\"" +optional = false +python-versions = "*" +files = [ + {file = "incremental-22.10.0-py2.py3-none-any.whl", hash = "sha256:b864a1f30885ee72c5ac2835a761b8fe8aa9c28b9395cacf27286602688d3e51"}, + {file = "incremental-22.10.0.tar.gz", hash = "sha256:912feeb5e0f7e0188e6f42241d2f450002e11bbc0937c65865045854c24c0bd0"}, +] + +[package.extras] +mypy = ["click (>=6.0)", "mypy (==0.812)", "twisted (>=16.4.0)"] +scripts = ["click (>=6.0)", "twisted (>=16.4.0)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "isodate" +version = "0.6.1" +description = "An ISO 8601 date/time/duration parser and formatter" +optional = false +python-versions = "*" +files = [ + {file = "isodate-0.6.1-py2.py3-none-any.whl", hash = "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96"}, + {file = "isodate-0.6.1.tar.gz", hash = "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9"}, +] + +[package.dependencies] +six = "*" + +[[package]] +name = "jax" +version = "0.4.13" +description = "Differentiate, compile, and transform Numpy code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "jax-0.4.13.tar.gz", hash = "sha256:03bfe6749dfe647f16f15f6616638adae6c4a7ca7167c75c21961ecfd3a3baaa"}, +] + +[package.dependencies] +importlib_metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} +ml_dtypes = ">=0.1.0" +numpy = ">=1.21" +opt_einsum = "*" +scipy = ">=1.7" + +[package.extras] +australis = ["protobuf (>=3.13,<4)"] +ci = ["jaxlib (==0.4.12)"] +cpu = ["jaxlib (==0.4.13)"] +cuda = ["jaxlib (==0.4.13+cuda11.cudnn86)"] +cuda11-cudnn86 = ["jaxlib (==0.4.13+cuda11.cudnn86)"] +cuda11-local = ["jaxlib (==0.4.13+cuda11.cudnn86)"] +cuda11-pip = ["jaxlib (==0.4.13+cuda11.cudnn86)", "nvidia-cublas-cu11 (>=11.11)", "nvidia-cuda-cupti-cu11 (>=11.8)", "nvidia-cuda-nvcc-cu11 (>=11.8)", "nvidia-cuda-runtime-cu11 (>=11.8)", "nvidia-cudnn-cu11 (>=8.8)", "nvidia-cufft-cu11 (>=10.9)", "nvidia-cusolver-cu11 (>=11.4)", "nvidia-cusparse-cu11 (>=11.7)"] +cuda12-local = ["jaxlib (==0.4.13+cuda12.cudnn89)"] +cuda12-pip = ["jaxlib (==0.4.13+cuda12.cudnn89)", "nvidia-cublas-cu12", "nvidia-cuda-cupti-cu12", "nvidia-cuda-nvcc-cu12", "nvidia-cuda-runtime-cu12", "nvidia-cudnn-cu12 (>=8.9)", "nvidia-cufft-cu12", "nvidia-cusolver-cu12", "nvidia-cusparse-cu12"] +minimum-jaxlib = ["jaxlib (==0.4.11)"] +tpu = ["jaxlib (==0.4.13)", "libtpu-nightly (==0.1.dev20230622)"] + +[[package]] +name = "jieba" +version = "0.42.1" +description = "Chinese Words Segmentation Utilities" +optional = true +python-versions = "*" +files = [ + {file = "jieba-0.42.1.tar.gz", hash = "sha256:055ca12f62674fafed09427f176506079bc135638a14e23e25be909131928db2"}, +] + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "jmespath" +version = "1.0.1" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.7" +files = [ + {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, + {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, +] + +[[package]] +name = "joblib" +version = "1.4.2" +description = "Lightweight pipelining with Python functions" +optional = false +python-versions = ">=3.8" +files = [ + {file = "joblib-1.4.2-py3-none-any.whl", hash = "sha256:06d478d5674cbc267e7496a410ee875abd68e4340feff4490bcb7afb88060ae6"}, + {file = "joblib-1.4.2.tar.gz", hash = "sha256:2382c5816b2636fbd20a09e0f4e9dad4736765fdfb7dca582943b9c1366b3f0e"}, +] + +[[package]] +name = "jsonpickle" +version = "3.0.4" +description = "Serialize any Python object to JSON" +optional = false +python-versions = ">=3.7" +files = [ + {file = "jsonpickle-3.0.4-py3-none-any.whl", hash = "sha256:04ae7567a14269579e3af66b76bda284587458d7e8a204951ca8f71a3309952e"}, + {file = "jsonpickle-3.0.4.tar.gz", hash = "sha256:a1b14c8d6221cd8f394f2a97e735ea1d7edc927fbd135b26f2f8700657c8c62b"}, +] + +[package.extras] +docs = ["furo", "rst.linker (>=1.9)", "sphinx"] +packaging = ["build", "twine"] +testing = ["bson", "ecdsa", "feedparser", "gmpy2", "numpy", "pandas", "pymongo", "pytest (>=3.5,!=3.7.3)", "pytest-benchmark", "pytest-benchmark[histogram]", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-ruff (>=0.2.1)", "scikit-learn", "scipy", "scipy (>=1.9.3)", "simplejson", "sqlalchemy", "ujson"] + +[[package]] +name = "jsonschema" +version = "4.17.3" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "jsonschema-4.17.3-py3-none-any.whl", hash = "sha256:a870ad254da1a8ca84b6a2905cac29d265f805acc57af304784962a2aa6508f6"}, + {file = "jsonschema-4.17.3.tar.gz", hash = "sha256:0f864437ab8b6076ba6707453ef8f98a6a0d512a80e93f8abdb676f737ecb60d"}, +] + +[package.dependencies] +attrs = ">=17.4.0" +importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} +pkgutil-resolve-name = {version = ">=1.3.10", markers = "python_version < \"3.9\""} +pyrsistent = ">=0.14.0,<0.17.0 || >0.17.0,<0.17.1 || >0.17.1,<0.17.2 || >0.17.2" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=1.11)"] + +[[package]] +name = "keras" +version = "2.12.0" +description = "Deep learning for humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "keras-2.12.0-py2.py3-none-any.whl", hash = "sha256:35c39534011e909645fb93515452e98e1a0ce23727b55d4918b9c58b2308c15e"}, +] + +[[package]] +name = "kiwisolver" +version = "1.4.4" +description = "A fast implementation of the Cassowary constraint solver" +optional = false +python-versions = ">=3.7" +files = [ + {file = "kiwisolver-1.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2f5e60fabb7343a836360c4f0919b8cd0d6dbf08ad2ca6b9cf90bf0c76a3c4f6"}, + {file = "kiwisolver-1.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:10ee06759482c78bdb864f4109886dff7b8a56529bc1609d4f1112b93fe6423c"}, + {file = "kiwisolver-1.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c79ebe8f3676a4c6630fd3f777f3cfecf9289666c84e775a67d1d358578dc2e3"}, + {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:abbe9fa13da955feb8202e215c4018f4bb57469b1b78c7a4c5c7b93001699938"}, + {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7577c1987baa3adc4b3c62c33bd1118c3ef5c8ddef36f0f2c950ae0b199e100d"}, + {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8ad8285b01b0d4695102546b342b493b3ccc6781fc28c8c6a1bb63e95d22f09"}, + {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ed58b8acf29798b036d347791141767ccf65eee7f26bde03a71c944449e53de"}, + {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a68b62a02953b9841730db7797422f983935aeefceb1679f0fc85cbfbd311c32"}, + {file = "kiwisolver-1.4.4-cp310-cp310-win32.whl", hash = "sha256:e92a513161077b53447160b9bd8f522edfbed4bd9759e4c18ab05d7ef7e49408"}, + {file = "kiwisolver-1.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fe20f63c9ecee44560d0e7f116b3a747a5d7203376abeea292ab3152334d004"}, + {file = "kiwisolver-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ea21f66820452a3f5d1655f8704a60d66ba1191359b96541eaf457710a5fc6"}, + {file = "kiwisolver-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bc9db8a3efb3e403e4ecc6cd9489ea2bac94244f80c78e27c31dcc00d2790ac2"}, + {file = "kiwisolver-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d5b61785a9ce44e5a4b880272baa7cf6c8f48a5180c3e81c59553ba0cb0821ca"}, + {file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2dbb44c3f7e6c4d3487b31037b1bdbf424d97687c1747ce4ff2895795c9bf69"}, + {file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6295ecd49304dcf3bfbfa45d9a081c96509e95f4b9d0eb7ee4ec0530c4a96514"}, + {file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bd472dbe5e136f96a4b18f295d159d7f26fd399136f5b17b08c4e5f498cd494"}, + {file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf7d9fce9bcc4752ca4a1b80aabd38f6d19009ea5cbda0e0856983cf6d0023f5"}, + {file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d6601aed50c74e0ef02f4204da1816147a6d3fbdc8b3872d263338a9052c51"}, + {file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:877272cf6b4b7e94c9614f9b10140e198d2186363728ed0f701c6eee1baec1da"}, + {file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:db608a6757adabb32f1cfe6066e39b3706d8c3aa69bbc353a5b61edad36a5cb4"}, + {file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5853eb494c71e267912275e5586fe281444eb5e722de4e131cddf9d442615626"}, + {file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f0a1dbdb5ecbef0d34eb77e56fcb3e95bbd7e50835d9782a45df81cc46949750"}, + {file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:283dffbf061a4ec60391d51e6155e372a1f7a4f5b15d59c8505339454f8989e4"}, + {file = "kiwisolver-1.4.4-cp311-cp311-win32.whl", hash = "sha256:d06adcfa62a4431d404c31216f0f8ac97397d799cd53800e9d3efc2fbb3cf14e"}, + {file = "kiwisolver-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:e7da3fec7408813a7cebc9e4ec55afed2d0fd65c4754bc376bf03498d4e92686"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:62ac9cc684da4cf1778d07a89bf5f81b35834cb96ca523d3a7fb32509380cbf6"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41dae968a94b1ef1897cb322b39360a0812661dba7c682aa45098eb8e193dbdf"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02f79693ec433cb4b5f51694e8477ae83b3205768a6fb48ffba60549080e295b"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d0611a0a2a518464c05ddd5a3a1a0e856ccc10e67079bb17f265ad19ab3c7597"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:db5283d90da4174865d520e7366801a93777201e91e79bacbac6e6927cbceede"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1041feb4cda8708ce73bb4dcb9ce1ccf49d553bf87c3954bdfa46f0c3f77252c"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-win32.whl", hash = "sha256:a553dadda40fef6bfa1456dc4be49b113aa92c2a9a9e8711e955618cd69622e3"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:03baab2d6b4a54ddbb43bba1a3a2d1627e82d205c5cf8f4c924dc49284b87166"}, + {file = "kiwisolver-1.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:841293b17ad704d70c578f1f0013c890e219952169ce8a24ebc063eecf775454"}, + {file = "kiwisolver-1.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f4f270de01dd3e129a72efad823da90cc4d6aafb64c410c9033aba70db9f1ff0"}, + {file = "kiwisolver-1.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f9f39e2f049db33a908319cf46624a569b36983c7c78318e9726a4cb8923b26c"}, + {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c97528e64cb9ebeff9701e7938653a9951922f2a38bd847787d4a8e498cc83ae"}, + {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d1573129aa0fd901076e2bfb4275a35f5b7aa60fbfb984499d661ec950320b0"}, + {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad881edc7ccb9d65b0224f4e4d05a1e85cf62d73aab798943df6d48ab0cd79a1"}, + {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b428ef021242344340460fa4c9185d0b1f66fbdbfecc6c63eff4b7c29fad429d"}, + {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2e407cb4bd5a13984a6c2c0fe1845e4e41e96f183e5e5cd4d77a857d9693494c"}, + {file = "kiwisolver-1.4.4-cp38-cp38-win32.whl", hash = "sha256:75facbe9606748f43428fc91a43edb46c7ff68889b91fa31f53b58894503a191"}, + {file = "kiwisolver-1.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:5bce61af018b0cb2055e0e72e7d65290d822d3feee430b7b8203d8a855e78766"}, + {file = "kiwisolver-1.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8c808594c88a025d4e322d5bb549282c93c8e1ba71b790f539567932722d7bd8"}, + {file = "kiwisolver-1.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0a71d85ecdd570ded8ac3d1c0f480842f49a40beb423bb8014539a9f32a5897"}, + {file = "kiwisolver-1.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b533558eae785e33e8c148a8d9921692a9fe5aa516efbdff8606e7d87b9d5824"}, + {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:efda5fc8cc1c61e4f639b8067d118e742b812c930f708e6667a5ce0d13499e29"}, + {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7c43e1e1206cd421cd92e6b3280d4385d41d7166b3ed577ac20444b6995a445f"}, + {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc8d3bd6c72b2dd9decf16ce70e20abcb3274ba01b4e1c96031e0c4067d1e7cd"}, + {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ea39b0ccc4f5d803e3337dd46bcce60b702be4d86fd0b3d7531ef10fd99a1ac"}, + {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:968f44fdbf6dd757d12920d63b566eeb4d5b395fd2d00d29d7ef00a00582aac9"}, + {file = "kiwisolver-1.4.4-cp39-cp39-win32.whl", hash = "sha256:da7e547706e69e45d95e116e6939488d62174e033b763ab1496b4c29b76fabea"}, + {file = "kiwisolver-1.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:ba59c92039ec0a66103b1d5fe588fa546373587a7d68f5c96f743c3396afc04b"}, + {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:91672bacaa030f92fc2f43b620d7b337fd9a5af28b0d6ed3f77afc43c4a64b5a"}, + {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:787518a6789009c159453da4d6b683f468ef7a65bbde796bcea803ccf191058d"}, + {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da152d8cdcab0e56e4f45eb08b9aea6455845ec83172092f09b0e077ece2cf7a"}, + {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:ecb1fa0db7bf4cff9dac752abb19505a233c7f16684c5826d1f11ebd9472b871"}, + {file = "kiwisolver-1.4.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:28bc5b299f48150b5f822ce68624e445040595a4ac3d59251703779836eceff9"}, + {file = "kiwisolver-1.4.4-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:81e38381b782cc7e1e46c4e14cd997ee6040768101aefc8fa3c24a4cc58e98f8"}, + {file = "kiwisolver-1.4.4-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2a66fdfb34e05b705620dd567f5a03f239a088d5a3f321e7b6ac3239d22aa286"}, + {file = "kiwisolver-1.4.4-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872b8ca05c40d309ed13eb2e582cab0c5a05e81e987ab9c521bf05ad1d5cf5cb"}, + {file = "kiwisolver-1.4.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:70e7c2e7b750585569564e2e5ca9845acfaa5da56ac46df68414f29fea97be9f"}, + {file = "kiwisolver-1.4.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9f85003f5dfa867e86d53fac6f7e6f30c045673fa27b603c397753bebadc3008"}, + {file = "kiwisolver-1.4.4-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e307eb9bd99801f82789b44bb45e9f541961831c7311521b13a6c85afc09767"}, + {file = "kiwisolver-1.4.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1792d939ec70abe76f5054d3f36ed5656021dcad1322d1cc996d4e54165cef9"}, + {file = "kiwisolver-1.4.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6cb459eea32a4e2cf18ba5fcece2dbdf496384413bc1bae15583f19e567f3b2"}, + {file = "kiwisolver-1.4.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:36dafec3d6d6088d34e2de6b85f9d8e2324eb734162fba59d2ba9ed7a2043d5b"}, + {file = "kiwisolver-1.4.4.tar.gz", hash = "sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955"}, +] + +[[package]] +name = "langcodes" +version = "3.3.0" +description = "Tools for labeling human languages with IETF language tags" +optional = true +python-versions = ">=3.6" +files = [ + {file = "langcodes-3.3.0-py3-none-any.whl", hash = "sha256:4d89fc9acb6e9c8fdef70bcdf376113a3db09b67285d9e1d534de6d8818e7e69"}, + {file = "langcodes-3.3.0.tar.gz", hash = "sha256:794d07d5a28781231ac335a1561b8442f8648ca07cd518310aeb45d6f0807ef6"}, +] + +[package.extras] +data = ["language-data (>=1.1,<2.0)"] + +[[package]] +name = "libclang" +version = "16.0.0" +description = "Clang Python Bindings, mirrored from the official LLVM repo: https://github.com/llvm/llvm-project/tree/main/clang/bindings/python, to make the installation process easier." +optional = false +python-versions = "*" +files = [ + {file = "libclang-16.0.0-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:65258a6bb3e7dc31dc9b26f8d42f53c9d3b959643ade291fcd1aef4855303ca6"}, + {file = "libclang-16.0.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:af55a4aa86fdfe6b2ec68bc8cfe5fdac6c448d591ca7648be86ca17099b41ca8"}, + {file = "libclang-16.0.0-py2.py3-none-manylinux2010_x86_64.whl", hash = "sha256:a043138caaf2cb076ebb060c6281ec95612926645d425c691991fc9df00e8a24"}, + {file = "libclang-16.0.0-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:eb59652cb0559c0e71784ff4c8ba24c14644becc907b1446563ecfaa622d523b"}, + {file = "libclang-16.0.0-py2.py3-none-manylinux2014_armv7l.whl", hash = "sha256:7b6686b67a0daa84b4c614bcc119578329fc4fbb52b919565b7376b507c4793b"}, + {file = "libclang-16.0.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2adce42ae652f312245b8f4eda6f30b4076fb61f7619f2dfd0a0c31dee4c32b9"}, + {file = "libclang-16.0.0-py2.py3-none-win_amd64.whl", hash = "sha256:ee20bf93e3dd330f71fc50cdbf13b92ced0aec8e540be64251db53502a9b33f7"}, + {file = "libclang-16.0.0-py2.py3-none-win_arm64.whl", hash = "sha256:bf4628fc4da7a1dd06a244f9b8e121c5ec68076a763c59d6b13cbb103acc935b"}, +] + +[[package]] +name = "locket" +version = "1.0.0" +description = "File-based locks for Python on Linux and Windows" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3"}, + {file = "locket-1.0.0.tar.gz", hash = "sha256:5c0d4c052a8bbbf750e056a8e65ccd309086f4f0f18a2eac306a8dfa4112a632"}, +] + +[[package]] +name = "markdown" +version = "3.4.3" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Markdown-3.4.3-py3-none-any.whl", hash = "sha256:065fd4df22da73a625f14890dd77eb8040edcbd68794bcd35943be14490608b2"}, + {file = "Markdown-3.4.3.tar.gz", hash = "sha256:8bf101198e004dc93e84a12a7395e31aac6a9c9942848ae1d99b9d72cf9b3520"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} + +[package.extras] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "markupsafe" +version = "2.1.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, + {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, +] + +[[package]] +name = "matplotlib" +version = "3.5.3" +description = "Python plotting package" +optional = false +python-versions = ">=3.7" +files = [ + {file = "matplotlib-3.5.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a206a1b762b39398efea838f528b3a6d60cdb26fe9d58b48265787e29cd1d693"}, + {file = "matplotlib-3.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cd45a6f3e93a780185f70f05cf2a383daed13c3489233faad83e81720f7ede24"}, + {file = "matplotlib-3.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d62880e1f60e5a30a2a8484432bcb3a5056969dc97258d7326ad465feb7ae069"}, + {file = "matplotlib-3.5.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ab29589cef03bc88acfa3a1490359000c18186fc30374d8aa77d33cc4a51a4a"}, + {file = "matplotlib-3.5.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2886cc009f40e2984c083687251821f305d811d38e3df8ded414265e4583f0c5"}, + {file = "matplotlib-3.5.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c995f7d9568f18b5db131ab124c64e51b6820a92d10246d4f2b3f3a66698a15b"}, + {file = "matplotlib-3.5.3-cp310-cp310-win32.whl", hash = "sha256:6bb93a0492d68461bd458eba878f52fdc8ac7bdb6c4acdfe43dba684787838c2"}, + {file = "matplotlib-3.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:2e6d184ebe291b9e8f7e78bbab7987d269c38ea3e062eace1fe7d898042ef804"}, + {file = "matplotlib-3.5.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6ea6aef5c4338e58d8d376068e28f80a24f54e69f09479d1c90b7172bad9f25b"}, + {file = "matplotlib-3.5.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:839d47b8ead7ad9669aaacdbc03f29656dc21f0d41a6fea2d473d856c39c8b1c"}, + {file = "matplotlib-3.5.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3b4fa56159dc3c7f9250df88f653f085068bcd32dcd38e479bba58909254af7f"}, + {file = "matplotlib-3.5.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:94ff86af56a3869a4ae26a9637a849effd7643858a1a04dd5ee50e9ab75069a7"}, + {file = "matplotlib-3.5.3-cp37-cp37m-win32.whl", hash = "sha256:35a8ad4dddebd51f94c5d24bec689ec0ec66173bf614374a1244c6241c1595e0"}, + {file = "matplotlib-3.5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:43e9d3fa077bf0cc95ded13d331d2156f9973dce17c6f0c8b49ccd57af94dbd9"}, + {file = "matplotlib-3.5.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:22227c976ad4dc8c5a5057540421f0d8708c6560744ad2ad638d48e2984e1dbc"}, + {file = "matplotlib-3.5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf618a825deb6205f015df6dfe6167a5d9b351203b03fab82043ae1d30f16511"}, + {file = "matplotlib-3.5.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9befa5954cdbc085e37d974ff6053da269474177921dd61facdad8023c4aeb51"}, + {file = "matplotlib-3.5.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3840c280ebc87a48488a46f760ea1c0c0c83fcf7abbe2e6baf99d033fd35fd8"}, + {file = "matplotlib-3.5.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dacddf5bfcec60e3f26ec5c0ae3d0274853a258b6c3fc5ef2f06a8eb23e042be"}, + {file = "matplotlib-3.5.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b428076a55fb1c084c76cb93e68006f27d247169f056412607c5c88828d08f88"}, + {file = "matplotlib-3.5.3-cp38-cp38-win32.whl", hash = "sha256:874df7505ba820e0400e7091199decf3ff1fde0583652120c50cd60d5820ca9a"}, + {file = "matplotlib-3.5.3-cp38-cp38-win_amd64.whl", hash = "sha256:b28de401d928890187c589036857a270a032961411934bdac4cf12dde3d43094"}, + {file = "matplotlib-3.5.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3211ba82b9f1518d346f6309df137b50c3dc4421b4ed4815d1d7eadc617f45a1"}, + {file = "matplotlib-3.5.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6fe807e8a22620b4cd95cfbc795ba310dc80151d43b037257250faf0bfcd82bc"}, + {file = "matplotlib-3.5.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5c096363b206a3caf43773abebdbb5a23ea13faef71d701b21a9c27fdcef72f4"}, + {file = "matplotlib-3.5.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcdfcb0f976e1bac6721d7d457c17be23cf7501f977b6a38f9d38a3762841f7"}, + {file = "matplotlib-3.5.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e64ac9be9da6bfff0a732e62116484b93b02a0b4d4b19934fb4f8e7ad26ad6a"}, + {file = "matplotlib-3.5.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:73dd93dc35c85dece610cca8358003bf0760d7986f70b223e2306b4ea6d1406b"}, + {file = "matplotlib-3.5.3-cp39-cp39-win32.whl", hash = "sha256:879c7e5fce4939c6aa04581dfe08d57eb6102a71f2e202e3314d5fbc072fd5a0"}, + {file = "matplotlib-3.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:ab8d26f07fe64f6f6736d635cce7bfd7f625320490ed5bfc347f2cdb4fae0e56"}, + {file = "matplotlib-3.5.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:99482b83ebf4eb6d5fc6813d7aacdefdd480f0d9c0b52dcf9f1cc3b2c4b3361a"}, + {file = "matplotlib-3.5.3-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f814504e459c68118bf2246a530ed953ebd18213dc20e3da524174d84ed010b2"}, + {file = "matplotlib-3.5.3-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57f1b4e69f438a99bb64d7f2c340db1b096b41ebaa515cf61ea72624279220ce"}, + {file = "matplotlib-3.5.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:d2484b350bf3d32cae43f85dcfc89b3ed7bd2bcd781ef351f93eb6fb2cc483f9"}, + {file = "matplotlib-3.5.3.tar.gz", hash = "sha256:339cac48b80ddbc8bfd05daae0a3a73414651a8596904c2a881cfd1edb65f26c"}, +] + +[package.dependencies] +cycler = ">=0.10" +fonttools = ">=4.22.0" +kiwisolver = ">=1.0.1" +numpy = ">=1.17" +packaging = ">=20.0" +pillow = ">=6.2.0" +pyparsing = ">=2.2.1" +python-dateutil = ">=2.7" + +[[package]] +name = "mattermostwrapper" +version = "2.2" +description = "A mattermost api v4 wrapper to interact with api" +optional = false +python-versions = "*" +files = [ + {file = "mattermostwrapper-2.2.tar.gz", hash = "sha256:df17c4224b15c54d959addb12e83e3f1ada34bdb1fbed1048b7b9900d9cff53e"}, +] + +[package.dependencies] +requests = "*" + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "memory-profiler" +version = "0.61.0" +description = "A module for monitoring memory usage of a python program" +optional = false +python-versions = ">=3.5" +files = [ + {file = "memory_profiler-0.61.0-py3-none-any.whl", hash = "sha256:400348e61031e3942ad4d4109d18753b2fb08c2f6fb8290671c5513a34182d84"}, + {file = "memory_profiler-0.61.0.tar.gz", hash = "sha256:4e5b73d7864a1d1292fb76a03e82a3e78ef934d06828a698d9dada76da2067b0"}, +] + +[package.dependencies] +psutil = "*" + +[[package]] +name = "ml-dtypes" +version = "0.2.0" +description = "" +optional = false +python-versions = ">=3.7" +files = [ + {file = "ml_dtypes-0.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:df6a76e1c8adf484feb138ed323f9f40a7b6c21788f120f7c78bec20ac37ee81"}, + {file = "ml_dtypes-0.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc29a0524ef5e23a7fbb8d881bdecabeb3fc1d19d9db61785d077a86cb94fab2"}, + {file = "ml_dtypes-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f08c391c2794f2aad358e6f4c70785a9a7b1df980ef4c232b3ccd4f6fe39f719"}, + {file = "ml_dtypes-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:75015818a7fccf99a5e8ed18720cb430f3e71a8838388840f4cdf225c036c983"}, + {file = "ml_dtypes-0.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e70047ec2c83eaee01afdfdabee2c5b0c133804d90d0f7db4dd903360fcc537c"}, + {file = "ml_dtypes-0.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36d28b8861a8931695e5a31176cad5ae85f6504906650dea5598fbec06c94606"}, + {file = "ml_dtypes-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e85ba8e24cf48d456e564688e981cf379d4c8e644db0a2f719b78de281bac2ca"}, + {file = "ml_dtypes-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:832a019a1b6db5c4422032ca9940a990fa104eee420f643713241b3a518977fa"}, + {file = "ml_dtypes-0.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8faaf0897942c8253dd126662776ba45f0a5861968cf0f06d6d465f8a7bc298a"}, + {file = "ml_dtypes-0.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35b984cddbe8173b545a0e3334fe56ea1a5c3eb67c507f60d0cfde1d3fa8f8c2"}, + {file = "ml_dtypes-0.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:022d5a4ee6be14569c2a9d1549e16f1ec87ca949681d0dca59995445d5fcdd5b"}, + {file = "ml_dtypes-0.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:50845af3e9a601810751b55091dee6c2562403fa1cb4e0123675cf3a4fc2c17a"}, + {file = "ml_dtypes-0.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f00c71c8c63e03aff313bc6a7aeaac9a4f1483a921a6ffefa6d4404efd1af3d0"}, + {file = "ml_dtypes-0.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80d304c836d73f10605c58ccf7789c171cc229bfb678748adfb7cea2510dfd0e"}, + {file = "ml_dtypes-0.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32107e7fa9f62db9a5281de923861325211dfff87bd23faefb27b303314635ab"}, + {file = "ml_dtypes-0.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:1749b60348da71fd3c2ab303fdbc1965958dc50775ead41f5669c932a341cafd"}, + {file = "ml_dtypes-0.2.0.tar.gz", hash = "sha256:6488eb642acaaf08d8020f6de0a38acee7ac324c1e6e92ee0c0fea42422cb797"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.21.2", markers = "python_version > \"3.9\""}, + {version = ">1.20", markers = "python_version <= \"3.9\""}, +] + +[package.extras] +dev = ["absl-py", "pyink", "pylint (>=2.6.0)", "pytest", "pytest-xdist"] + +[[package]] +name = "mongomock" +version = "4.1.2" +description = "Fake pymongo stub for testing simple MongoDB-dependent code" +optional = false +python-versions = "*" +files = [ + {file = "mongomock-4.1.2-py2.py3-none-any.whl", hash = "sha256:08a24938a05c80c69b6b8b19a09888d38d8c6e7328547f94d46cadb7f47209f2"}, + {file = "mongomock-4.1.2.tar.gz", hash = "sha256:f06cd62afb8ae3ef63ba31349abd220a657ef0dd4f0243a29587c5213f931b7d"}, +] + +[package.dependencies] +packaging = "*" +sentinels = "*" + +[[package]] +name = "monotonic" +version = "1.6" +description = "An implementation of time.monotonic() for Python 2 & < 3.3" +optional = false +python-versions = "*" +files = [ + {file = "monotonic-1.6-py2.py3-none-any.whl", hash = "sha256:68687e19a14f11f26d140dd5c86f3dba4bf5df58003000ed467e0e2a69bca96c"}, + {file = "monotonic-1.6.tar.gz", hash = "sha256:3a55207bcfed53ddd5c5bae174524062935efed17792e9de2ad0205ce9ad63f7"}, +] + +[[package]] +name = "moto" +version = "4.1.12" +description = "" +optional = false +python-versions = ">=3.7" +files = [ + {file = "moto-4.1.12-py2.py3-none-any.whl", hash = "sha256:6f40141ff2f3a309c19faa169433afdf48d28733d328b08a843021ae36f005d9"}, + {file = "moto-4.1.12.tar.gz", hash = "sha256:25577e4cf55f05235f4efe78bcfeb5a7704fb75c16b426a5de2fc1e6b7b8545b"}, +] + +[package.dependencies] +boto3 = ">=1.9.201" +botocore = ">=1.12.201" +cryptography = ">=3.3.1" +Jinja2 = ">=2.10.1" +python-dateutil = ">=2.1,<3.0.0" +requests = ">=2.5" +responses = ">=0.13.0" +werkzeug = ">=0.5,<2.2.0 || >2.2.0,<2.2.1 || >2.2.1" +xmltodict = "*" + +[package.extras] +all = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "ecdsa (!=0.15)", "graphql-core", "jsondiff (>=1.1.2)", "openapi-spec-validator (>=0.2.8)", "py-partiql-parser (==0.3.3)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "setuptools", "sshpubkeys (>=3.1.0)"] +apigateway = ["PyYAML (>=5.1)", "ecdsa (!=0.15)", "openapi-spec-validator (>=0.2.8)", "python-jose[cryptography] (>=3.1.0,<4.0.0)"] +apigatewayv2 = ["PyYAML (>=5.1)"] +appsync = ["graphql-core"] +awslambda = ["docker (>=3.0.0)"] +batch = ["docker (>=3.0.0)"] +cloudformation = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "ecdsa (!=0.15)", "graphql-core", "jsondiff (>=1.1.2)", "openapi-spec-validator (>=0.2.8)", "py-partiql-parser (==0.3.3)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "setuptools", "sshpubkeys (>=3.1.0)"] +cognitoidp = ["ecdsa (!=0.15)", "python-jose[cryptography] (>=3.1.0,<4.0.0)"] +ds = ["sshpubkeys (>=3.1.0)"] +dynamodb = ["docker (>=3.0.0)", "py-partiql-parser (==0.3.3)"] +dynamodbstreams = ["docker (>=3.0.0)", "py-partiql-parser (==0.3.3)"] +ebs = ["sshpubkeys (>=3.1.0)"] +ec2 = ["sshpubkeys (>=3.1.0)"] +efs = ["sshpubkeys (>=3.1.0)"] +eks = ["sshpubkeys (>=3.1.0)"] +glue = ["pyparsing (>=3.0.7)"] +iotdata = ["jsondiff (>=1.1.2)"] +route53resolver = ["sshpubkeys (>=3.1.0)"] +s3 = ["PyYAML (>=5.1)", "py-partiql-parser (==0.3.3)"] +server = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "ecdsa (!=0.15)", "flask (!=2.2.0,!=2.2.1)", "flask-cors", "graphql-core", "jsondiff (>=1.1.2)", "openapi-spec-validator (>=0.2.8)", "py-partiql-parser (==0.3.3)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "setuptools", "sshpubkeys (>=3.1.0)"] +ssm = ["PyYAML (>=5.1)"] +xray = ["aws-xray-sdk (>=0.93,!=0.96)", "setuptools"] + +[[package]] +name = "msgpack" +version = "1.0.5" +description = "MessagePack serializer" +optional = false +python-versions = "*" +files = [ + {file = "msgpack-1.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:525228efd79bb831cf6830a732e2e80bc1b05436b086d4264814b4b2955b2fa9"}, + {file = "msgpack-1.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f8d8b3bf1ff2672567d6b5c725a1b347fe838b912772aa8ae2bf70338d5a198"}, + {file = "msgpack-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdc793c50be3f01106245a61b739328f7dccc2c648b501e237f0699fe1395b81"}, + {file = "msgpack-1.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cb47c21a8a65b165ce29f2bec852790cbc04936f502966768e4aae9fa763cb7"}, + {file = "msgpack-1.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e42b9594cc3bf4d838d67d6ed62b9e59e201862a25e9a157019e171fbe672dd3"}, + {file = "msgpack-1.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:55b56a24893105dc52c1253649b60f475f36b3aa0fc66115bffafb624d7cb30b"}, + {file = "msgpack-1.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1967f6129fc50a43bfe0951c35acbb729be89a55d849fab7686004da85103f1c"}, + {file = "msgpack-1.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20a97bf595a232c3ee6d57ddaadd5453d174a52594bf9c21d10407e2a2d9b3bd"}, + {file = "msgpack-1.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d25dd59bbbbb996eacf7be6b4ad082ed7eacc4e8f3d2df1ba43822da9bfa122a"}, + {file = "msgpack-1.0.5-cp310-cp310-win32.whl", hash = "sha256:382b2c77589331f2cb80b67cc058c00f225e19827dbc818d700f61513ab47bea"}, + {file = "msgpack-1.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:4867aa2df9e2a5fa5f76d7d5565d25ec76e84c106b55509e78c1ede0f152659a"}, + {file = "msgpack-1.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9f5ae84c5c8a857ec44dc180a8b0cc08238e021f57abdf51a8182e915e6299f0"}, + {file = "msgpack-1.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e6ca5d5699bcd89ae605c150aee83b5321f2115695e741b99618f4856c50898"}, + {file = "msgpack-1.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5494ea30d517a3576749cad32fa27f7585c65f5f38309c88c6d137877fa28a5a"}, + {file = "msgpack-1.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ab2f3331cb1b54165976a9d976cb251a83183631c88076613c6c780f0d6e45a"}, + {file = "msgpack-1.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28592e20bbb1620848256ebc105fc420436af59515793ed27d5c77a217477705"}, + {file = "msgpack-1.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe5c63197c55bce6385d9aee16c4d0641684628f63ace85f73571e65ad1c1e8d"}, + {file = "msgpack-1.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ed40e926fa2f297e8a653c954b732f125ef97bdd4c889f243182299de27e2aa9"}, + {file = "msgpack-1.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b2de4c1c0538dcb7010902a2b97f4e00fc4ddf2c8cda9749af0e594d3b7fa3d7"}, + {file = "msgpack-1.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bf22a83f973b50f9d38e55c6aade04c41ddda19b00c4ebc558930d78eecc64ed"}, + {file = "msgpack-1.0.5-cp311-cp311-win32.whl", hash = "sha256:c396e2cc213d12ce017b686e0f53497f94f8ba2b24799c25d913d46c08ec422c"}, + {file = "msgpack-1.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:6c4c68d87497f66f96d50142a2b73b97972130d93677ce930718f68828b382e2"}, + {file = "msgpack-1.0.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a2b031c2e9b9af485d5e3c4520f4220d74f4d222a5b8dc8c1a3ab9448ca79c57"}, + {file = "msgpack-1.0.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f837b93669ce4336e24d08286c38761132bc7ab29782727f8557e1eb21b2080"}, + {file = "msgpack-1.0.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1d46dfe3832660f53b13b925d4e0fa1432b00f5f7210eb3ad3bb9a13c6204a6"}, + {file = "msgpack-1.0.5-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:366c9a7b9057e1547f4ad51d8facad8b406bab69c7d72c0eb6f529cf76d4b85f"}, + {file = "msgpack-1.0.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:4c075728a1095efd0634a7dccb06204919a2f67d1893b6aa8e00497258bf926c"}, + {file = "msgpack-1.0.5-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:f933bbda5a3ee63b8834179096923b094b76f0c7a73c1cfe8f07ad608c58844b"}, + {file = "msgpack-1.0.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:36961b0568c36027c76e2ae3ca1132e35123dcec0706c4b7992683cc26c1320c"}, + {file = "msgpack-1.0.5-cp36-cp36m-win32.whl", hash = "sha256:b5ef2f015b95f912c2fcab19c36814963b5463f1fb9049846994b007962743e9"}, + {file = "msgpack-1.0.5-cp36-cp36m-win_amd64.whl", hash = "sha256:288e32b47e67f7b171f86b030e527e302c91bd3f40fd9033483f2cacc37f327a"}, + {file = "msgpack-1.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:137850656634abddfb88236008339fdaba3178f4751b28f270d2ebe77a563b6c"}, + {file = "msgpack-1.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c05a4a96585525916b109bb85f8cb6511db1c6f5b9d9cbcbc940dc6b4be944b"}, + {file = "msgpack-1.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56a62ec00b636583e5cb6ad313bbed36bb7ead5fa3a3e38938503142c72cba4f"}, + {file = "msgpack-1.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef8108f8dedf204bb7b42994abf93882da1159728a2d4c5e82012edd92c9da9f"}, + {file = "msgpack-1.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1835c84d65f46900920b3708f5ba829fb19b1096c1800ad60bae8418652a951d"}, + {file = "msgpack-1.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:e57916ef1bd0fee4f21c4600e9d1da352d8816b52a599c46460e93a6e9f17086"}, + {file = "msgpack-1.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:17358523b85973e5f242ad74aa4712b7ee560715562554aa2134d96e7aa4cbbf"}, + {file = "msgpack-1.0.5-cp37-cp37m-win32.whl", hash = "sha256:cb5aaa8c17760909ec6cb15e744c3ebc2ca8918e727216e79607b7bbce9c8f77"}, + {file = "msgpack-1.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:ab31e908d8424d55601ad7075e471b7d0140d4d3dd3272daf39c5c19d936bd82"}, + {file = "msgpack-1.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b72d0698f86e8d9ddf9442bdedec15b71df3598199ba33322d9711a19f08145c"}, + {file = "msgpack-1.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:379026812e49258016dd84ad79ac8446922234d498058ae1d415f04b522d5b2d"}, + {file = "msgpack-1.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:332360ff25469c346a1c5e47cbe2a725517919892eda5cfaffe6046656f0b7bb"}, + {file = "msgpack-1.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:476a8fe8fae289fdf273d6d2a6cb6e35b5a58541693e8f9f019bfe990a51e4ba"}, + {file = "msgpack-1.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9985b214f33311df47e274eb788a5893a761d025e2b92c723ba4c63936b69b1"}, + {file = "msgpack-1.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48296af57cdb1d885843afd73c4656be5c76c0c6328db3440c9601a98f303d87"}, + {file = "msgpack-1.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:addab7e2e1fcc04bd08e4eb631c2a90960c340e40dfc4a5e24d2ff0d5a3b3edb"}, + {file = "msgpack-1.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:916723458c25dfb77ff07f4c66aed34e47503b2eb3188b3adbec8d8aa6e00f48"}, + {file = "msgpack-1.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:821c7e677cc6acf0fd3f7ac664c98803827ae6de594a9f99563e48c5a2f27eb0"}, + {file = "msgpack-1.0.5-cp38-cp38-win32.whl", hash = "sha256:1c0f7c47f0087ffda62961d425e4407961a7ffd2aa004c81b9c07d9269512f6e"}, + {file = "msgpack-1.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:bae7de2026cbfe3782c8b78b0db9cbfc5455e079f1937cb0ab8d133496ac55e1"}, + {file = "msgpack-1.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:20c784e66b613c7f16f632e7b5e8a1651aa5702463d61394671ba07b2fc9e025"}, + {file = "msgpack-1.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:266fa4202c0eb94d26822d9bfd7af25d1e2c088927fe8de9033d929dd5ba24c5"}, + {file = "msgpack-1.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:18334484eafc2b1aa47a6d42427da7fa8f2ab3d60b674120bce7a895a0a85bdd"}, + {file = "msgpack-1.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57e1f3528bd95cc44684beda696f74d3aaa8a5e58c816214b9046512240ef437"}, + {file = "msgpack-1.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:586d0d636f9a628ddc6a17bfd45aa5b5efaf1606d2b60fa5d87b8986326e933f"}, + {file = "msgpack-1.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a740fa0e4087a734455f0fc3abf5e746004c9da72fbd541e9b113013c8dc3282"}, + {file = "msgpack-1.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3055b0455e45810820db1f29d900bf39466df96ddca11dfa6d074fa47054376d"}, + {file = "msgpack-1.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a61215eac016f391129a013c9e46f3ab308db5f5ec9f25811e811f96962599a8"}, + {file = "msgpack-1.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:362d9655cd369b08fda06b6657a303eb7172d5279997abe094512e919cf74b11"}, + {file = "msgpack-1.0.5-cp39-cp39-win32.whl", hash = "sha256:ac9dd47af78cae935901a9a500104e2dea2e253207c924cc95de149606dc43cc"}, + {file = "msgpack-1.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:06f5174b5f8ed0ed919da0e62cbd4ffde676a374aba4020034da05fab67b9164"}, + {file = "msgpack-1.0.5.tar.gz", hash = "sha256:c075544284eadc5cddc70f4757331d99dcbc16b2bbd4849d15f8aae4cf36d31c"}, +] + +[[package]] +name = "multidict" +version = "5.2.0" +description = "multidict implementation" +optional = false +python-versions = ">=3.6" +files = [ + {file = "multidict-5.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3822c5894c72e3b35aae9909bef66ec83e44522faf767c0ad39e0e2de11d3b55"}, + {file = "multidict-5.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:28e6d883acd8674887d7edc896b91751dc2d8e87fbdca8359591a13872799e4e"}, + {file = "multidict-5.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b61f85101ef08cbbc37846ac0e43f027f7844f3fade9b7f6dd087178caedeee7"}, + {file = "multidict-5.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9b668c065968c5979fe6b6fa6760bb6ab9aeb94b75b73c0a9c1acf6393ac3bf"}, + {file = "multidict-5.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:517d75522b7b18a3385726b54a081afd425d4f41144a5399e5abd97ccafdf36b"}, + {file = "multidict-5.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b4ac3ba7a97b35a5ccf34f41b5a8642a01d1e55454b699e5e8e7a99b5a3acf5"}, + {file = "multidict-5.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:df23c83398715b26ab09574217ca21e14694917a0c857e356fd39e1c64f8283f"}, + {file = "multidict-5.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e58a9b5cc96e014ddf93c2227cbdeca94b56a7eb77300205d6e4001805391747"}, + {file = "multidict-5.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f76440e480c3b2ca7f843ff8a48dc82446b86ed4930552d736c0bac507498a52"}, + {file = "multidict-5.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cfde464ca4af42a629648c0b0d79b8f295cf5b695412451716531d6916461628"}, + {file = "multidict-5.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0fed465af2e0eb6357ba95795d003ac0bdb546305cc2366b1fc8f0ad67cc3fda"}, + {file = "multidict-5.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:b70913cbf2e14275013be98a06ef4b412329fe7b4f83d64eb70dce8269ed1e1a"}, + {file = "multidict-5.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a5635bcf1b75f0f6ef3c8a1ad07b500104a971e38d3683167b9454cb6465ac86"}, + {file = "multidict-5.2.0-cp310-cp310-win32.whl", hash = "sha256:77f0fb7200cc7dedda7a60912f2059086e29ff67cefbc58d2506638c1a9132d7"}, + {file = "multidict-5.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:9416cf11bcd73c861267e88aea71e9fcc35302b3943e45e1dbb4317f91a4b34f"}, + {file = "multidict-5.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:fd77c8f3cba815aa69cb97ee2b2ef385c7c12ada9c734b0f3b32e26bb88bbf1d"}, + {file = "multidict-5.2.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98ec9aea6223adf46999f22e2c0ab6cf33f5914be604a404f658386a8f1fba37"}, + {file = "multidict-5.2.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5283c0a00f48e8cafcecadebfa0ed1dac8b39e295c7248c44c665c16dc1138b"}, + {file = "multidict-5.2.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f79c19c6420962eb17c7e48878a03053b7ccd7b69f389d5831c0a4a7f1ac0a1"}, + {file = "multidict-5.2.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e4a67f1080123de76e4e97a18d10350df6a7182e243312426d508712e99988d4"}, + {file = "multidict-5.2.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:94b117e27efd8e08b4046c57461d5a114d26b40824995a2eb58372b94f9fca02"}, + {file = "multidict-5.2.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:2e77282fd1d677c313ffcaddfec236bf23f273c4fba7cdf198108f5940ae10f5"}, + {file = "multidict-5.2.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:116347c63ba049c1ea56e157fa8aa6edaf5e92925c9b64f3da7769bdfa012858"}, + {file = "multidict-5.2.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:dc3a866cf6c13d59a01878cd806f219340f3e82eed514485e094321f24900677"}, + {file = "multidict-5.2.0-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ac42181292099d91217a82e3fa3ce0e0ddf3a74fd891b7c2b347a7f5aa0edded"}, + {file = "multidict-5.2.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:f0bb0973f42ffcb5e3537548e0767079420aefd94ba990b61cf7bb8d47f4916d"}, + {file = "multidict-5.2.0-cp36-cp36m-win32.whl", hash = "sha256:ea21d4d5104b4f840b91d9dc8cbc832aba9612121eaba503e54eaab1ad140eb9"}, + {file = "multidict-5.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:e6453f3cbeb78440747096f239d282cc57a2997a16b5197c9bc839099e1633d0"}, + {file = "multidict-5.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d3def943bfd5f1c47d51fd324df1e806d8da1f8e105cc7f1c76a1daf0f7e17b0"}, + {file = "multidict-5.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35591729668a303a02b06e8dba0eb8140c4a1bfd4c4b3209a436a02a5ac1de11"}, + {file = "multidict-5.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8cacda0b679ebc25624d5de66c705bc53dcc7c6f02a7fb0f3ca5e227d80422"}, + {file = "multidict-5.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:baf1856fab8212bf35230c019cde7c641887e3fc08cadd39d32a421a30151ea3"}, + {file = "multidict-5.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a43616aec0f0d53c411582c451f5d3e1123a68cc7b3475d6f7d97a626f8ff90d"}, + {file = "multidict-5.2.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:25cbd39a9029b409167aa0a20d8a17f502d43f2efebfe9e3ac019fe6796c59ac"}, + {file = "multidict-5.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a2cbcfbea6dc776782a444db819c8b78afe4db597211298dd8b2222f73e9cd0"}, + {file = "multidict-5.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3d2d7d1fff8e09d99354c04c3fd5b560fb04639fd45926b34e27cfdec678a704"}, + {file = "multidict-5.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a37e9a68349f6abe24130846e2f1d2e38f7ddab30b81b754e5a1fde32f782b23"}, + {file = "multidict-5.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:637c1896497ff19e1ee27c1c2c2ddaa9f2d134bbb5e0c52254361ea20486418d"}, + {file = "multidict-5.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:9815765f9dcda04921ba467957be543423e5ec6a1136135d84f2ae092c50d87b"}, + {file = "multidict-5.2.0-cp37-cp37m-win32.whl", hash = "sha256:8b911d74acdc1fe2941e59b4f1a278a330e9c34c6c8ca1ee21264c51ec9b67ef"}, + {file = "multidict-5.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:380b868f55f63d048a25931a1632818f90e4be71d2081c2338fcf656d299949a"}, + {file = "multidict-5.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e7d81ce5744757d2f05fc41896e3b2ae0458464b14b5a2c1e87a6a9d69aefaa8"}, + {file = "multidict-5.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2d1d55cdf706ddc62822d394d1df53573d32a7a07d4f099470d3cb9323b721b6"}, + {file = "multidict-5.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a4771d0d0ac9d9fe9e24e33bed482a13dfc1256d008d101485fe460359476065"}, + {file = "multidict-5.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da7d57ea65744d249427793c042094c4016789eb2562576fb831870f9c878d9e"}, + {file = "multidict-5.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdd68778f96216596218b4e8882944d24a634d984ee1a5a049b300377878fa7c"}, + {file = "multidict-5.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecc99bce8ee42dcad15848c7885197d26841cb24fa2ee6e89d23b8993c871c64"}, + {file = "multidict-5.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:067150fad08e6f2dd91a650c7a49ba65085303fcc3decbd64a57dc13a2733031"}, + {file = "multidict-5.2.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:78c106b2b506b4d895ddc801ff509f941119394b89c9115580014127414e6c2d"}, + {file = "multidict-5.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e6c4fa1ec16e01e292315ba76eb1d012c025b99d22896bd14a66628b245e3e01"}, + {file = "multidict-5.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b227345e4186809d31f22087d0265655114af7cda442ecaf72246275865bebe4"}, + {file = "multidict-5.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:06560fbdcf22c9387100979e65b26fba0816c162b888cb65b845d3def7a54c9b"}, + {file = "multidict-5.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7878b61c867fb2df7a95e44b316f88d5a3742390c99dfba6c557a21b30180cac"}, + {file = "multidict-5.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:246145bff76cc4b19310f0ad28bd0769b940c2a49fc601b86bfd150cbd72bb22"}, + {file = "multidict-5.2.0-cp38-cp38-win32.whl", hash = "sha256:c30ac9f562106cd9e8071c23949a067b10211917fdcb75b4718cf5775356a940"}, + {file = "multidict-5.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:f19001e790013ed580abfde2a4465388950728861b52f0da73e8e8a9418533c0"}, + {file = "multidict-5.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c1ff762e2ee126e6f1258650ac641e2b8e1f3d927a925aafcfde943b77a36d24"}, + {file = "multidict-5.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bd6c9c50bf2ad3f0448edaa1a3b55b2e6866ef8feca5d8dbec10ec7c94371d21"}, + {file = "multidict-5.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fc66d4016f6e50ed36fb39cd287a3878ffcebfa90008535c62e0e90a7ab713ae"}, + {file = "multidict-5.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9acb76d5f3dd9421874923da2ed1e76041cb51b9337fd7f507edde1d86535d6"}, + {file = "multidict-5.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dfc924a7e946dd3c6360e50e8f750d51e3ef5395c95dc054bc9eab0f70df4f9c"}, + {file = "multidict-5.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fdba7333eb2351fee2596b756d730d62b5827d5e1ab2f84e6cbb287cc67fe0"}, + {file = "multidict-5.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b9aad49466b8d828b96b9e3630006234879c8d3e2b0a9d99219b3121bc5cdb17"}, + {file = "multidict-5.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:93de39267c4c676c9ebb2057e98a8138bade0d806aad4d864322eee0803140a0"}, + {file = "multidict-5.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f9bef5cff994ca3026fcc90680e326d1a19df9841c5e3d224076407cc21471a1"}, + {file = "multidict-5.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:5f841c4f14331fd1e36cbf3336ed7be2cb2a8f110ce40ea253e5573387db7621"}, + {file = "multidict-5.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:38ba256ee9b310da6a1a0f013ef4e422fca30a685bcbec86a969bd520504e341"}, + {file = "multidict-5.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3bc3b1621b979621cee9f7b09f024ec76ec03cc365e638126a056317470bde1b"}, + {file = "multidict-5.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6ee908c070020d682e9b42c8f621e8bb10c767d04416e2ebe44e37d0f44d9ad5"}, + {file = "multidict-5.2.0-cp39-cp39-win32.whl", hash = "sha256:1c7976cd1c157fa7ba5456ae5d31ccdf1479680dc9b8d8aa28afabc370df42b8"}, + {file = "multidict-5.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:c9631c642e08b9fff1c6255487e62971d8b8e821808ddd013d8ac058087591ac"}, + {file = "multidict-5.2.0.tar.gz", hash = "sha256:0dd1c93edb444b33ba2274b66f63def8a327d607c6c790772f448a53b6ea59ce"}, +] + +[[package]] +name = "murmurhash" +version = "1.0.9" +description = "Cython bindings for MurmurHash" +optional = true +python-versions = ">=3.6" +files = [ + {file = "murmurhash-1.0.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:697ed01454d92681c7ae26eb1adcdc654b54062bcc59db38ed03cad71b23d449"}, + {file = "murmurhash-1.0.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ef31b5c11be2c064dbbdd0e22ab3effa9ceb5b11ae735295c717c120087dd94"}, + {file = "murmurhash-1.0.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7a2bd203377a31bbb2d83fe3f968756d6c9bbfa36c64c6ebfc3c6494fc680bc"}, + {file = "murmurhash-1.0.9-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0eb0f8e652431ea238c11bcb671fef5c03aff0544bf7e098df81ea4b6d495405"}, + {file = "murmurhash-1.0.9-cp310-cp310-win_amd64.whl", hash = "sha256:cf0b3fe54dca598f5b18c9951e70812e070ecb4c0672ad2cc32efde8a33b3df6"}, + {file = "murmurhash-1.0.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5dc41be79ba4d09aab7e9110a8a4d4b37b184b63767b1b247411667cdb1057a3"}, + {file = "murmurhash-1.0.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c0f84ecdf37c06eda0222f2f9e81c0974e1a7659c35b755ab2fdc642ebd366db"}, + {file = "murmurhash-1.0.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:241693c1c819148eac29d7882739b1099c891f1f7431127b2652c23f81722cec"}, + {file = "murmurhash-1.0.9-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f5ca56c430230d3b581dfdbc54eb3ad8b0406dcc9afdd978da2e662c71d370"}, + {file = "murmurhash-1.0.9-cp311-cp311-win_amd64.whl", hash = "sha256:660ae41fc6609abc05130543011a45b33ca5d8318ae5c70e66bbd351ca936063"}, + {file = "murmurhash-1.0.9-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01137d688a6b259bde642513506b062364ea4e1609f886d9bd095c3ae6da0b94"}, + {file = "murmurhash-1.0.9-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b70bbf55d89713873a35bd4002bc231d38e530e1051d57ca5d15f96c01fd778"}, + {file = "murmurhash-1.0.9-cp36-cp36m-win_amd64.whl", hash = "sha256:3e802fa5b0e618ee99e8c114ce99fc91677f14e9de6e18b945d91323a93c84e8"}, + {file = "murmurhash-1.0.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:213d0248e586082e1cab6157d9945b846fd2b6be34357ad5ea0d03a1931d82ba"}, + {file = "murmurhash-1.0.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94b89d02aeab5e6bad5056f9d08df03ac7cfe06e61ff4b6340feb227fda80ce8"}, + {file = "murmurhash-1.0.9-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c2e2ee2d91a87952fe0f80212e86119aa1fd7681f03e6c99b279e50790dc2b3"}, + {file = "murmurhash-1.0.9-cp37-cp37m-win_amd64.whl", hash = "sha256:8c3d69fb649c77c74a55624ebf7a0df3c81629e6ea6e80048134f015da57b2ea"}, + {file = "murmurhash-1.0.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ab78675510f83e7a3c6bd0abdc448a9a2b0b385b0d7ee766cbbfc5cc278a3042"}, + {file = "murmurhash-1.0.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0ac5530c250d2b0073ed058555847c8d88d2d00229e483d45658c13b32398523"}, + {file = "murmurhash-1.0.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69157e8fa6b25c4383645227069f6a1f8738d32ed2a83558961019ca3ebef56a"}, + {file = "murmurhash-1.0.9-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2aebe2ae016525a662ff772b72a2c9244a673e3215fcd49897f494258b96f3e7"}, + {file = "murmurhash-1.0.9-cp38-cp38-win_amd64.whl", hash = "sha256:a5952f9c18a717fa17579e27f57bfa619299546011a8378a8f73e14eece332f6"}, + {file = "murmurhash-1.0.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef79202feeac68e83971239169a05fa6514ecc2815ce04c8302076d267870f6e"}, + {file = "murmurhash-1.0.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:799fcbca5693ad6a40f565ae6b8e9718e5875a63deddf343825c0f31c32348fa"}, + {file = "murmurhash-1.0.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9b995bc82eaf9223e045210207b8878fdfe099a788dd8abd708d9ee58459a9d"}, + {file = "murmurhash-1.0.9-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b129e1c5ebd772e6ff5ef925bcce695df13169bd885337e6074b923ab6edcfc8"}, + {file = "murmurhash-1.0.9-cp39-cp39-win_amd64.whl", hash = "sha256:379bf6b414bd27dd36772dd1570565a7d69918e980457370838bd514df0d91e9"}, + {file = "murmurhash-1.0.9.tar.gz", hash = "sha256:fe7a38cb0d3d87c14ec9dddc4932ffe2dbc77d75469ab80fd5014689b0e07b58"}, +] + +[[package]] +name = "mypy" +version = "1.0.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mypy-1.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:71a808334d3f41ef011faa5a5cd8153606df5fc0b56de5b2e89566c8093a0c9a"}, + {file = "mypy-1.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:920169f0184215eef19294fa86ea49ffd4635dedfdea2b57e45cb4ee85d5ccaf"}, + {file = "mypy-1.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27a0f74a298769d9fdc8498fcb4f2beb86f0564bcdb1a37b58cbbe78e55cf8c0"}, + {file = "mypy-1.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:65b122a993d9c81ea0bfde7689b3365318a88bde952e4dfa1b3a8b4ac05d168b"}, + {file = "mypy-1.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:5deb252fd42a77add936b463033a59b8e48eb2eaec2976d76b6878d031933fe4"}, + {file = "mypy-1.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2013226d17f20468f34feddd6aae4635a55f79626549099354ce641bc7d40262"}, + {file = "mypy-1.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:48525aec92b47baed9b3380371ab8ab6e63a5aab317347dfe9e55e02aaad22e8"}, + {file = "mypy-1.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c96b8a0c019fe29040d520d9257d8c8f122a7343a8307bf8d6d4a43f5c5bfcc8"}, + {file = "mypy-1.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:448de661536d270ce04f2d7dddaa49b2fdba6e3bd8a83212164d4174ff43aa65"}, + {file = "mypy-1.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:d42a98e76070a365a1d1c220fcac8aa4ada12ae0db679cb4d910fabefc88b994"}, + {file = "mypy-1.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e64f48c6176e243ad015e995de05af7f22bbe370dbb5b32bd6988438ec873919"}, + {file = "mypy-1.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fdd63e4f50e3538617887e9aee91855368d9fc1dea30da743837b0df7373bc4"}, + {file = "mypy-1.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dbeb24514c4acbc78d205f85dd0e800f34062efcc1f4a4857c57e4b4b8712bff"}, + {file = "mypy-1.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a2948c40a7dd46c1c33765718936669dc1f628f134013b02ff5ac6c7ef6942bf"}, + {file = "mypy-1.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5bc8d6bd3b274dd3846597855d96d38d947aedba18776aa998a8d46fabdaed76"}, + {file = "mypy-1.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:17455cda53eeee0a4adb6371a21dd3dbf465897de82843751cf822605d152c8c"}, + {file = "mypy-1.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e831662208055b006eef68392a768ff83596035ffd6d846786578ba1714ba8f6"}, + {file = "mypy-1.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e60d0b09f62ae97a94605c3f73fd952395286cf3e3b9e7b97f60b01ddfbbda88"}, + {file = "mypy-1.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:0af4f0e20706aadf4e6f8f8dc5ab739089146b83fd53cb4a7e0e850ef3de0bb6"}, + {file = "mypy-1.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:24189f23dc66f83b839bd1cce2dfc356020dfc9a8bae03978477b15be61b062e"}, + {file = "mypy-1.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93a85495fb13dc484251b4c1fd7a5ac370cd0d812bbfc3b39c1bafefe95275d5"}, + {file = "mypy-1.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f546ac34093c6ce33f6278f7c88f0f147a4849386d3bf3ae193702f4fe31407"}, + {file = "mypy-1.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c6c2ccb7af7154673c591189c3687b013122c5a891bb5651eca3db8e6c6c55bd"}, + {file = "mypy-1.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:15b5a824b58c7c822c51bc66308e759243c32631896743f030daf449fe3677f3"}, + {file = "mypy-1.0.1-py3-none-any.whl", hash = "sha256:eda5c8b9949ed411ff752b9a01adda31afe7eae1e53e946dbdf9db23865e66c4"}, + {file = "mypy-1.0.1.tar.gz", hash = "sha256:28cea5a6392bb43d266782983b5a4216c25544cd7d80be681a155ddcdafd152d"}, +] + +[package.dependencies] +mypy-extensions = ">=0.4.3" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=3.10" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +python2 = ["typed-ast (>=1.4.0,<2)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "0.4.4" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +optional = false +python-versions = ">=2.7" +files = [ + {file = "mypy_extensions-0.4.4.tar.gz", hash = "sha256:c8b707883a96efe9b4bb3aaf0dcc07e7e217d7d8368eec4db4049ee9e142f4fd"}, +] + +[[package]] +name = "networkx" +version = "2.6.3" +description = "Python package for creating and manipulating graphs and networks" +optional = false +python-versions = ">=3.7" +files = [ + {file = "networkx-2.6.3-py3-none-any.whl", hash = "sha256:80b6b89c77d1dfb64a4c7854981b60aeea6360ac02c6d4e4913319e0a313abef"}, + {file = "networkx-2.6.3.tar.gz", hash = "sha256:c0946ed31d71f1b732b5aaa6da5a0388a345019af232ce2f49c766e2d6795c51"}, +] + +[package.extras] +default = ["matplotlib (>=3.3)", "numpy (>=1.19)", "pandas (>=1.1)", "scipy (>=1.5,!=1.6.1)"] +developer = ["black (==21.5b1)", "pre-commit (>=2.12)"] +doc = ["nb2plots (>=0.6)", "numpydoc (>=1.1)", "pillow (>=8.2)", "pydata-sphinx-theme (>=0.6,<1.0)", "sphinx (>=4.0,<5.0)", "sphinx-gallery (>=0.9,<1.0)", "texext (>=0.6.6)"] +extra = ["lxml (>=4.5)", "pydot (>=1.4.1)", "pygraphviz (>=1.7)"] +test = ["codecov (>=2.1)", "pytest (>=6.2)", "pytest-cov (>=2.12)"] + +[[package]] +name = "nr-util" +version = "0.8.12" +description = "General purpose Python utility library." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "nr.util-0.8.12-py3-none-any.whl", hash = "sha256:91da02ac9795eb8e015372275c1efe54bac9051231ee9b0e7e6f96b0b4e7d2bb"}, + {file = "nr.util-0.8.12.tar.gz", hash = "sha256:a4549c2033d99d2f0379b3f3d233fd2a8ade286bbf0b3ad0cc7cea16022214f4"}, +] + +[package.dependencies] +deprecated = ">=1.2.0,<2.0.0" +typing-extensions = ">=3.0.0" + +[[package]] +name = "numpy" +version = "1.22.3" +description = "NumPy is the fundamental package for array computing with Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "numpy-1.22.3-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:92bfa69cfbdf7dfc3040978ad09a48091143cffb778ec3b03fa170c494118d75"}, + {file = "numpy-1.22.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8251ed96f38b47b4295b1ae51631de7ffa8260b5b087808ef09a39a9d66c97ab"}, + {file = "numpy-1.22.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48a3aecd3b997bf452a2dedb11f4e79bc5bfd21a1d4cc760e703c31d57c84b3e"}, + {file = "numpy-1.22.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3bae1a2ed00e90b3ba5f7bd0a7c7999b55d609e0c54ceb2b076a25e345fa9f4"}, + {file = "numpy-1.22.3-cp310-cp310-win32.whl", hash = "sha256:f950f8845b480cffe522913d35567e29dd381b0dc7e4ce6a4a9f9156417d2430"}, + {file = "numpy-1.22.3-cp310-cp310-win_amd64.whl", hash = "sha256:08d9b008d0156c70dc392bb3ab3abb6e7a711383c3247b410b39962263576cd4"}, + {file = "numpy-1.22.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:201b4d0552831f7250a08d3b38de0d989d6f6e4658b709a02a73c524ccc6ffce"}, + {file = "numpy-1.22.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f8c1f39caad2c896bc0018f699882b345b2a63708008be29b1f355ebf6f933fe"}, + {file = "numpy-1.22.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:568dfd16224abddafb1cbcce2ff14f522abe037268514dd7e42c6776a1c3f8e5"}, + {file = "numpy-1.22.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ca688e1b9b95d80250bca34b11a05e389b1420d00e87a0d12dc45f131f704a1"}, + {file = "numpy-1.22.3-cp38-cp38-win32.whl", hash = "sha256:e7927a589df200c5e23c57970bafbd0cd322459aa7b1ff73b7c2e84d6e3eae62"}, + {file = "numpy-1.22.3-cp38-cp38-win_amd64.whl", hash = "sha256:07a8c89a04997625236c5ecb7afe35a02af3896c8aa01890a849913a2309c676"}, + {file = "numpy-1.22.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:2c10a93606e0b4b95c9b04b77dc349b398fdfbda382d2a39ba5a822f669a0123"}, + {file = "numpy-1.22.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fade0d4f4d292b6f39951b6836d7a3c7ef5b2347f3c420cd9820a1d90d794802"}, + {file = "numpy-1.22.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bfb1bb598e8229c2d5d48db1860bcf4311337864ea3efdbe1171fb0c5da515d"}, + {file = "numpy-1.22.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97098b95aa4e418529099c26558eeb8486e66bd1e53a6b606d684d0c3616b168"}, + {file = "numpy-1.22.3-cp39-cp39-win32.whl", hash = "sha256:fdf3c08bce27132395d3c3ba1503cac12e17282358cb4bddc25cc46b0aca07aa"}, + {file = "numpy-1.22.3-cp39-cp39-win_amd64.whl", hash = "sha256:639b54cdf6aa4f82fe37ebf70401bbb74b8508fddcf4797f9fe59615b8c5813a"}, + {file = "numpy-1.22.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c34ea7e9d13a70bf2ab64a2532fe149a9aced424cd05a2c4ba662fd989e3e45f"}, + {file = "numpy-1.22.3.zip", hash = "sha256:dbc7601a3b7472d559dc7b933b18b4b66f9aa7452c120e87dfb33d02008c8a18"}, +] + +[[package]] +name = "numpy" +version = "1.23.5" +description = "NumPy is the fundamental package for array computing with Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "numpy-1.23.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9c88793f78fca17da0145455f0d7826bcb9f37da4764af27ac945488116efe63"}, + {file = "numpy-1.23.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e9f4c4e51567b616be64e05d517c79a8a22f3606499941d97bb76f2ca59f982d"}, + {file = "numpy-1.23.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7903ba8ab592b82014713c491f6c5d3a1cde5b4a3bf116404e08f5b52f6daf43"}, + {file = "numpy-1.23.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e05b1c973a9f858c74367553e236f287e749465f773328c8ef31abe18f691e1"}, + {file = "numpy-1.23.5-cp310-cp310-win32.whl", hash = "sha256:522e26bbf6377e4d76403826ed689c295b0b238f46c28a7251ab94716da0b280"}, + {file = "numpy-1.23.5-cp310-cp310-win_amd64.whl", hash = "sha256:dbee87b469018961d1ad79b1a5d50c0ae850000b639bcb1b694e9981083243b6"}, + {file = "numpy-1.23.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ce571367b6dfe60af04e04a1834ca2dc5f46004ac1cc756fb95319f64c095a96"}, + {file = "numpy-1.23.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56e454c7833e94ec9769fa0f86e6ff8e42ee38ce0ce1fa4cbb747ea7e06d56aa"}, + {file = "numpy-1.23.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5039f55555e1eab31124a5768898c9e22c25a65c1e0037f4d7c495a45778c9f2"}, + {file = "numpy-1.23.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58f545efd1108e647604a1b5aa809591ccd2540f468a880bedb97247e72db387"}, + {file = "numpy-1.23.5-cp311-cp311-win32.whl", hash = "sha256:b2a9ab7c279c91974f756c84c365a669a887efa287365a8e2c418f8b3ba73fb0"}, + {file = "numpy-1.23.5-cp311-cp311-win_amd64.whl", hash = "sha256:0cbe9848fad08baf71de1a39e12d1b6310f1d5b2d0ea4de051058e6e1076852d"}, + {file = "numpy-1.23.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f063b69b090c9d918f9df0a12116029e274daf0181df392839661c4c7ec9018a"}, + {file = "numpy-1.23.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0aaee12d8883552fadfc41e96b4c82ee7d794949e2a7c3b3a7201e968c7ecab9"}, + {file = "numpy-1.23.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92c8c1e89a1f5028a4c6d9e3ccbe311b6ba53694811269b992c0b224269e2398"}, + {file = "numpy-1.23.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d208a0f8729f3fb790ed18a003f3a57895b989b40ea4dce4717e9cf4af62c6bb"}, + {file = "numpy-1.23.5-cp38-cp38-win32.whl", hash = "sha256:06005a2ef6014e9956c09ba07654f9837d9e26696a0470e42beedadb78c11b07"}, + {file = "numpy-1.23.5-cp38-cp38-win_amd64.whl", hash = "sha256:ca51fcfcc5f9354c45f400059e88bc09215fb71a48d3768fb80e357f3b457e1e"}, + {file = "numpy-1.23.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8969bfd28e85c81f3f94eb4a66bc2cf1dbdc5c18efc320af34bffc54d6b1e38f"}, + {file = "numpy-1.23.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a7ac231a08bb37f852849bbb387a20a57574a97cfc7b6cabb488a4fc8be176de"}, + {file = "numpy-1.23.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf837dc63ba5c06dc8797c398db1e223a466c7ece27a1f7b5232ba3466aafe3d"}, + {file = "numpy-1.23.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33161613d2269025873025b33e879825ec7b1d831317e68f4f2f0f84ed14c719"}, + {file = "numpy-1.23.5-cp39-cp39-win32.whl", hash = "sha256:af1da88f6bc3d2338ebbf0e22fe487821ea4d8e89053e25fa59d1d79786e7481"}, + {file = "numpy-1.23.5-cp39-cp39-win_amd64.whl", hash = "sha256:09b7847f7e83ca37c6e627682f145856de331049013853f344f37b0c9690e3df"}, + {file = "numpy-1.23.5-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:abdde9f795cf292fb9651ed48185503a2ff29be87770c3b8e2a14b0cd7aa16f8"}, + {file = "numpy-1.23.5-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9a909a8bae284d46bbfdefbdd4a262ba19d3bc9921b1e76126b1d21c3c34135"}, + {file = "numpy-1.23.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:01dd17cbb340bf0fc23981e52e1d18a9d4050792e8fb8363cecbf066a84b827d"}, + {file = "numpy-1.23.5.tar.gz", hash = "sha256:1b1766d6f397c18153d40015ddfc79ddb715cabadc04d2d228d4e5a8bc4ded1a"}, +] + +[[package]] +name = "oauthlib" +version = "3.2.2" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +optional = false +python-versions = ">=3.6" +files = [ + {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, + {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, +] + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + +[[package]] +name = "opt-einsum" +version = "3.3.0" +description = "Optimizing numpys einsum function" +optional = false +python-versions = ">=3.5" +files = [ + {file = "opt_einsum-3.3.0-py3-none-any.whl", hash = "sha256:2455e59e3947d3c275477df7f5205b30635e266fe6dc300e3d9f9646bfcea147"}, + {file = "opt_einsum-3.3.0.tar.gz", hash = "sha256:59f6475f77bbc37dcf7cd748519c0ec60722e91e63ca114e68821c0c54a46549"}, +] + +[package.dependencies] +numpy = ">=1.7" + +[package.extras] +docs = ["numpydoc", "sphinx (==1.2.3)", "sphinx-rtd-theme", "sphinxcontrib-napoleon"] +tests = ["pytest", "pytest-cov", "pytest-pep8"] + +[[package]] +name = "packaging" +version = "20.9" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, + {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, +] + +[package.dependencies] +pyparsing = ">=2.0.2" + +[[package]] +name = "pamqp" +version = "3.2.1" +description = "RabbitMQ Focused AMQP low-level library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pamqp-3.2.1-py2.py3-none-any.whl", hash = "sha256:15acef752356593ca569d13dfedc8ada9f17deeeb8cec4f7b77825e2b6c7de3e"}, + {file = "pamqp-3.2.1.tar.gz", hash = "sha256:22550ceb1ca50aafda65873e77e8c1c1b139fb5975e1a09860fae940cf8e970a"}, +] + +[package.extras] +codegen = ["lxml", "requests", "yapf"] +testing = ["coverage", "flake8", "flake8-comprehensions", "flake8-deprecated", "flake8-import-order", "flake8-print", "flake8-quotes", "flake8-rst-docstrings", "flake8-tuple", "yapf"] + +[[package]] +name = "partd" +version = "1.4.0" +description = "Appendable key-value storage" +optional = false +python-versions = ">=3.7" +files = [ + {file = "partd-1.4.0-py3-none-any.whl", hash = "sha256:7a63529348cf0dff14b986db641cd1b83c16b5cb9fc647c2851779db03282ef8"}, + {file = "partd-1.4.0.tar.gz", hash = "sha256:aa0ff35dbbcc807ae374db56332f4c1b39b46f67bf2975f5151e0b4186aed0d5"}, +] + +[package.dependencies] +locket = "*" +toolz = "*" + +[package.extras] +complete = ["blosc", "numpy (>=1.9.0)", "pandas (>=0.19.0)", "pyzmq"] + +[[package]] +name = "pathspec" +version = "0.11.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, + {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, +] + +[[package]] +name = "pathy" +version = "0.10.2" +description = "pathlib.Path subclasses for local and cloud bucket storage" +optional = true +python-versions = ">= 3.6" +files = [ + {file = "pathy-0.10.2-py3-none-any.whl", hash = "sha256:681bc98dbff28e7de3e50efa8246910f727e8ac254c4318c47ce341f7c1ce21d"}, + {file = "pathy-0.10.2.tar.gz", hash = "sha256:79c572ab7fed84dc46837346edae58565992d0477a789cd4691a41d8eab9917d"}, +] + +[package.dependencies] +smart-open = ">=5.2.1,<7.0.0" +typer = ">=0.3.0,<1.0.0" + +[package.extras] +all = ["azure-storage-blob", "boto3", "google-cloud-storage (>=1.26.0,<2.0.0)", "mock", "pytest", "pytest-coverage", "typer-cli"] +azure = ["azure-storage-blob"] +gcs = ["google-cloud-storage (>=1.26.0,<2.0.0)"] +s3 = ["boto3"] +test = ["mock", "pytest", "pytest-coverage", "typer-cli"] + +[[package]] +name = "pbr" +version = "5.11.1" +description = "Python Build Reasonableness" +optional = false +python-versions = ">=2.6" +files = [ + {file = "pbr-5.11.1-py2.py3-none-any.whl", hash = "sha256:567f09558bae2b3ab53cb3c1e2e33e726ff3338e7bae3db5dc954b3a44eef12b"}, + {file = "pbr-5.11.1.tar.gz", hash = "sha256:aefc51675b0b533d56bb5fd1c8c6c0522fe31896679882e1c4c63d5e4a0fccb3"}, +] + +[[package]] +name = "pep440-version-utils" +version = "0.3.0" +description = "Utilities to deal with pep440 versioning" +optional = false +python-versions = ">=3.6,<4.0" +files = [ + {file = "pep440-version-utils-0.3.0.tar.gz", hash = "sha256:ceb8c8da63b54cc555946d91829f72fe323f8d635b22fa54ef0a9800c37f50df"}, + {file = "pep440_version_utils-0.3.0-py3-none-any.whl", hash = "sha256:73780b2c31adad5ca35c89eb008f51c2a47aee0318debe31391b673b90577e1b"}, +] + +[package.dependencies] +packaging = ">=20.3,<21.0" + +[[package]] +name = "pillow" +version = "10.0.1" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "Pillow-10.0.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:8f06be50669087250f319b706decf69ca71fdecd829091a37cc89398ca4dc17a"}, + {file = "Pillow-10.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:50bd5f1ebafe9362ad622072a1d2f5850ecfa44303531ff14353a4059113b12d"}, + {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6a90167bcca1216606223a05e2cf991bb25b14695c518bc65639463d7db722d"}, + {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f11c9102c56ffb9ca87134bd025a43d2aba3f1155f508eff88f694b33a9c6d19"}, + {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:186f7e04248103482ea6354af6d5bcedb62941ee08f7f788a1c7707bc720c66f"}, + {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0462b1496505a3462d0f35dc1c4d7b54069747d65d00ef48e736acda2c8cbdff"}, + {file = "Pillow-10.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d889b53ae2f030f756e61a7bff13684dcd77e9af8b10c6048fb2c559d6ed6eaf"}, + {file = "Pillow-10.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:552912dbca585b74d75279a7570dd29fa43b6d93594abb494ebb31ac19ace6bd"}, + {file = "Pillow-10.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:787bb0169d2385a798888e1122c980c6eff26bf941a8ea79747d35d8f9210ca0"}, + {file = "Pillow-10.0.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:fd2a5403a75b54661182b75ec6132437a181209b901446ee5724b589af8edef1"}, + {file = "Pillow-10.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2d7e91b4379f7a76b31c2dda84ab9e20c6220488e50f7822e59dac36b0cd92b1"}, + {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19e9adb3f22d4c416e7cd79b01375b17159d6990003633ff1d8377e21b7f1b21"}, + {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93139acd8109edcdeffd85e3af8ae7d88b258b3a1e13a038f542b79b6d255c54"}, + {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:92a23b0431941a33242b1f0ce6c88a952e09feeea9af4e8be48236a68ffe2205"}, + {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:cbe68deb8580462ca0d9eb56a81912f59eb4542e1ef8f987405e35a0179f4ea2"}, + {file = "Pillow-10.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:522ff4ac3aaf839242c6f4e5b406634bfea002469656ae8358644fc6c4856a3b"}, + {file = "Pillow-10.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:84efb46e8d881bb06b35d1d541aa87f574b58e87f781cbba8d200daa835b42e1"}, + {file = "Pillow-10.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:898f1d306298ff40dc1b9ca24824f0488f6f039bc0e25cfb549d3195ffa17088"}, + {file = "Pillow-10.0.1-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:bcf1207e2f2385a576832af02702de104be71301c2696d0012b1b93fe34aaa5b"}, + {file = "Pillow-10.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5d6c9049c6274c1bb565021367431ad04481ebb54872edecfcd6088d27edd6ed"}, + {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28444cb6ad49726127d6b340217f0627abc8732f1194fd5352dec5e6a0105635"}, + {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de596695a75496deb3b499c8c4f8e60376e0516e1a774e7bc046f0f48cd620ad"}, + {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2872f2d7846cf39b3dbff64bc1104cc48c76145854256451d33c5faa55c04d1a"}, + {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4ce90f8a24e1c15465048959f1e94309dfef93af272633e8f37361b824532e91"}, + {file = "Pillow-10.0.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ee7810cf7c83fa227ba9125de6084e5e8b08c59038a7b2c9045ef4dde61663b4"}, + {file = "Pillow-10.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b1be1c872b9b5fcc229adeadbeb51422a9633abd847c0ff87dc4ef9bb184ae08"}, + {file = "Pillow-10.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:98533fd7fa764e5f85eebe56c8e4094db912ccbe6fbf3a58778d543cadd0db08"}, + {file = "Pillow-10.0.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:764d2c0daf9c4d40ad12fbc0abd5da3af7f8aa11daf87e4fa1b834000f4b6b0a"}, + {file = "Pillow-10.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fcb59711009b0168d6ee0bd8fb5eb259c4ab1717b2f538bbf36bacf207ef7a68"}, + {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:697a06bdcedd473b35e50a7e7506b1d8ceb832dc238a336bd6f4f5aa91a4b500"}, + {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f665d1e6474af9f9da5e86c2a3a2d2d6204e04d5af9c06b9d42afa6ebde3f21"}, + {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:2fa6dd2661838c66f1a5473f3b49ab610c98a128fc08afbe81b91a1f0bf8c51d"}, + {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:3a04359f308ebee571a3127fdb1bd01f88ba6f6fb6d087f8dd2e0d9bff43f2a7"}, + {file = "Pillow-10.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:723bd25051454cea9990203405fa6b74e043ea76d4968166dfd2569b0210886a"}, + {file = "Pillow-10.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:71671503e3015da1b50bd18951e2f9daf5b6ffe36d16f1eb2c45711a301521a7"}, + {file = "Pillow-10.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:44e7e4587392953e5e251190a964675f61e4dae88d1e6edbe9f36d6243547ff3"}, + {file = "Pillow-10.0.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:3855447d98cced8670aaa63683808df905e956f00348732448b5a6df67ee5849"}, + {file = "Pillow-10.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ed2d9c0704f2dc4fa980b99d565c0c9a543fe5101c25b3d60488b8ba80f0cce1"}, + {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5bb289bb835f9fe1a1e9300d011eef4d69661bb9b34d5e196e5e82c4cb09b37"}, + {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0d3e54ab1df9df51b914b2233cf779a5a10dfd1ce339d0421748232cea9876"}, + {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:2cc6b86ece42a11f16f55fe8903595eff2b25e0358dec635d0a701ac9586588f"}, + {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:ca26ba5767888c84bf5a0c1a32f069e8204ce8c21d00a49c90dabeba00ce0145"}, + {file = "Pillow-10.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f0b4b06da13275bc02adfeb82643c4a6385bd08d26f03068c2796f60d125f6f2"}, + {file = "Pillow-10.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bc2e3069569ea9dbe88d6b8ea38f439a6aad8f6e7a6283a38edf61ddefb3a9bf"}, + {file = "Pillow-10.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:8b451d6ead6e3500b6ce5c7916a43d8d8d25ad74b9102a629baccc0808c54971"}, + {file = "Pillow-10.0.1-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:32bec7423cdf25c9038fef614a853c9d25c07590e1a870ed471f47fb80b244db"}, + {file = "Pillow-10.0.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7cf63d2c6928b51d35dfdbda6f2c1fddbe51a6bc4a9d4ee6ea0e11670dd981e"}, + {file = "Pillow-10.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f6d3d4c905e26354e8f9d82548475c46d8e0889538cb0657aa9c6f0872a37aa4"}, + {file = "Pillow-10.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:847e8d1017c741c735d3cd1883fa7b03ded4f825a6e5fcb9378fd813edee995f"}, + {file = "Pillow-10.0.1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:7f771e7219ff04b79e231d099c0a28ed83aa82af91fd5fa9fdb28f5b8d5addaf"}, + {file = "Pillow-10.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459307cacdd4138edee3875bbe22a2492519e060660eaf378ba3b405d1c66317"}, + {file = "Pillow-10.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b059ac2c4c7a97daafa7dc850b43b2d3667def858a4f112d1aa082e5c3d6cf7d"}, + {file = "Pillow-10.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d6caf3cd38449ec3cd8a68b375e0c6fe4b6fd04edb6c9766b55ef84a6e8ddf2d"}, + {file = "Pillow-10.0.1.tar.gz", hash = "sha256:d72967b06be9300fed5cfbc8b5bafceec48bf7cdc7dab66b1d2549035287191d"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] + +[[package]] +name = "pkgutil-resolve-name" +version = "1.3.10" +description = "Resolve a name to an object." +optional = false +python-versions = ">=3.6" +files = [ + {file = "pkgutil_resolve_name-1.3.10-py3-none-any.whl", hash = "sha256:ca27cc078d25c5ad71a9de0a7a330146c4e014c2462d9af19c6b828280649c5e"}, + {file = "pkgutil_resolve_name-1.3.10.tar.gz", hash = "sha256:357d6c9e6a755653cfd78893817c0853af365dd51ec97f3d358a819373bbd174"}, +] + +[[package]] +name = "platformdirs" +version = "3.8.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.7" +files = [ + {file = "platformdirs-3.8.0-py3-none-any.whl", hash = "sha256:ca9ed98ce73076ba72e092b23d3c93ea6c4e186b3f1c3dad6edd98ff6ffcca2e"}, + {file = "platformdirs-3.8.0.tar.gz", hash = "sha256:b0cabcb11063d21a0b261d557acb0a9d2126350e63b70cdf7db6347baea456dc"}, +] + +[package.extras] +docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"] + +[[package]] +name = "pluggy" +version = "1.2.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, + {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "portalocker" +version = "2.7.0" +description = "Wraps the portalocker recipe for easy usage" +optional = false +python-versions = ">=3.5" +files = [ + {file = "portalocker-2.7.0-py2.py3-none-any.whl", hash = "sha256:a07c5b4f3985c3cf4798369631fb7011adb498e2a46d8440efc75a8f29a0f983"}, + {file = "portalocker-2.7.0.tar.gz", hash = "sha256:032e81d534a88ec1736d03f780ba073f047a06c478b06e2937486f334e955c51"}, +] + +[package.dependencies] +pywin32 = {version = ">=226", markers = "platform_system == \"Windows\""} + +[package.extras] +docs = ["sphinx (>=1.7.1)"] +redis = ["redis"] +tests = ["pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "pytest-timeout (>=2.1.0)", "redis", "sphinx (>=6.0.0)"] + +[[package]] +name = "preshed" +version = "3.0.8" +description = "Cython hash table that trusts the keys are pre-hashed" +optional = true +python-versions = ">=3.6" +files = [ + {file = "preshed-3.0.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ea4b6df8ef7af38e864235256793bc3056e9699d991afcf6256fa298858582fc"}, + {file = "preshed-3.0.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e945fc814bdc29564a2ce137c237b3a9848aa1e76a1160369b6e0d328151fdd"}, + {file = "preshed-3.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9a4833530fe53001c351974e0c8bb660211b8d0358e592af185fec1ae12b2d0"}, + {file = "preshed-3.0.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1472ee231f323b4f4368b1b5f8f08481ed43af89697d45450c6ae4af46ac08a"}, + {file = "preshed-3.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:c8a2e2931eea7e500fbf8e014b69022f3fab2e35a70da882e2fc753e5e487ae3"}, + {file = "preshed-3.0.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e1bb8701df7861af26a312225bdf7c4822ac06fcf75aeb60fe2b0a20e64c222"}, + {file = "preshed-3.0.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e9aef2b0b7687aecef48b1c6ff657d407ff24e75462877dcb888fa904c4a9c6d"}, + {file = "preshed-3.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:854d58a8913ebf3b193b0dc8064155b034e8987de25f26838dfeca09151fda8a"}, + {file = "preshed-3.0.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:135e2ac0db1a3948d6ec295598c7e182b52c394663f2fcfe36a97ae51186be21"}, + {file = "preshed-3.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:019d8fa4161035811fb2804d03214143298739e162d0ad24e087bd46c50970f5"}, + {file = "preshed-3.0.8-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a49ce52856fbb3ef4f1cc744c53f5d7e1ca370b1939620ac2509a6d25e02a50"}, + {file = "preshed-3.0.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdbc2957b36115a576c515ffe963919f19d2683f3c76c9304ae88ef59f6b5ca6"}, + {file = "preshed-3.0.8-cp36-cp36m-win_amd64.whl", hash = "sha256:09cc9da2ac1b23010ce7d88a5e20f1033595e6dd80be14318e43b9409f4c7697"}, + {file = "preshed-3.0.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e19c8069f1a1450f835f23d47724530cf716d581fcafb398f534d044f806b8c2"}, + {file = "preshed-3.0.8-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25b5ef5e387a0e17ff41202a8c1816184ab6fb3c0d0b847bf8add0ed5941eb8d"}, + {file = "preshed-3.0.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53d3e2456a085425c66af7baba62d7eaa24aa5e460e1a9e02c401a2ed59abd7b"}, + {file = "preshed-3.0.8-cp37-cp37m-win_amd64.whl", hash = "sha256:85e98a618fb36cdcc37501d8b9b8c1246651cc2f2db3a70702832523e0ae12f4"}, + {file = "preshed-3.0.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7f8837bf616335464f3713cbf562a3dcaad22c3ca9193f957018964ef871a68b"}, + {file = "preshed-3.0.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:720593baf2c2e295f855192974799e486da5f50d4548db93c44f5726a43cefb9"}, + {file = "preshed-3.0.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0ad3d860b9ce88a74cf7414bb4b1c6fd833813e7b818e76f49272c4974b19ce"}, + {file = "preshed-3.0.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd19d48440b152657966a52e627780c0ddbe9d907b8d7ee4598505e80a3c55c7"}, + {file = "preshed-3.0.8-cp38-cp38-win_amd64.whl", hash = "sha256:246e7c6890dc7fe9b10f0e31de3346b906e3862b6ef42fcbede37968f46a73bf"}, + {file = "preshed-3.0.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:67643e66691770dc3434b01671648f481e3455209ce953727ef2330b16790aaa"}, + {file = "preshed-3.0.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ae25a010c9f551aa2247ee621457f679e07c57fc99d3fd44f84cb40b925f12c"}, + {file = "preshed-3.0.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a6a7fcf7dd2e7711051b3f0432da9ec9c748954c989f49d2cd8eabf8c2d953e"}, + {file = "preshed-3.0.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5942858170c4f53d9afc6352a86bbc72fc96cc4d8964b6415492114a5920d3ed"}, + {file = "preshed-3.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:06793022a56782ef51d74f1399925a2ba958e50c5cfbc6fa5b25c4945e158a07"}, + {file = "preshed-3.0.8.tar.gz", hash = "sha256:6c74c70078809bfddda17be96483c41d06d717934b07cab7921011d81758b357"}, +] + +[package.dependencies] +cymem = ">=2.0.2,<2.1.0" +murmurhash = ">=0.28.0,<1.1.0" + +[[package]] +name = "prompt-toolkit" +version = "3.0.28" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.6.2" +files = [ + {file = "prompt_toolkit-3.0.28-py3-none-any.whl", hash = "sha256:30129d870dcb0b3b6a53efdc9d0a83ea96162ffd28ffe077e94215b233dc670c"}, + {file = "prompt_toolkit-3.0.28.tar.gz", hash = "sha256:9f1cd16b1e86c2968f2519d7fb31dd9d669916f515612c269d14e9ed52b51650"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "protobuf" +version = "4.23.3" +description = "" +optional = false +python-versions = ">=3.7" +files = [ + {file = "protobuf-4.23.3-cp310-abi3-win32.whl", hash = "sha256:514b6bbd54a41ca50c86dd5ad6488afe9505901b3557c5e0f7823a0cf67106fb"}, + {file = "protobuf-4.23.3-cp310-abi3-win_amd64.whl", hash = "sha256:cc14358a8742c4e06b1bfe4be1afbdf5c9f6bd094dff3e14edb78a1513893ff5"}, + {file = "protobuf-4.23.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:2991f5e7690dab569f8f81702e6700e7364cc3b5e572725098215d3da5ccc6ac"}, + {file = "protobuf-4.23.3-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:08fe19d267608d438aa37019236db02b306e33f6b9902c3163838b8e75970223"}, + {file = "protobuf-4.23.3-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:3b01a5274ac920feb75d0b372d901524f7e3ad39c63b1a2d55043f3887afe0c1"}, + {file = "protobuf-4.23.3-cp37-cp37m-win32.whl", hash = "sha256:aca6e86a08c5c5962f55eac9b5bd6fce6ed98645d77e8bfc2b952ecd4a8e4f6a"}, + {file = "protobuf-4.23.3-cp37-cp37m-win_amd64.whl", hash = "sha256:0149053336a466e3e0b040e54d0b615fc71de86da66791c592cc3c8d18150bf8"}, + {file = "protobuf-4.23.3-cp38-cp38-win32.whl", hash = "sha256:84ea0bd90c2fdd70ddd9f3d3fc0197cc24ecec1345856c2b5ba70e4d99815359"}, + {file = "protobuf-4.23.3-cp38-cp38-win_amd64.whl", hash = "sha256:3bcbeb2bf4bb61fe960dd6e005801a23a43578200ea8ceb726d1f6bd0e562ba1"}, + {file = "protobuf-4.23.3-cp39-cp39-win32.whl", hash = "sha256:5cb9e41188737f321f4fce9a4337bf40a5414b8d03227e1d9fbc59bc3a216e35"}, + {file = "protobuf-4.23.3-cp39-cp39-win_amd64.whl", hash = "sha256:29660574cd769f2324a57fb78127cda59327eb6664381ecfe1c69731b83e8288"}, + {file = "protobuf-4.23.3-py3-none-any.whl", hash = "sha256:447b9786ac8e50ae72cae7a2eec5c5df6a9dbf9aa6f908f1b8bda6032644ea62"}, + {file = "protobuf-4.23.3.tar.gz", hash = "sha256:7a92beb30600332a52cdadbedb40d33fd7c8a0d7f549c440347bc606fb3fe34b"}, +] + +[[package]] +name = "psutil" +version = "5.9.5" +description = "Cross-platform lib for process and system monitoring in Python." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "psutil-5.9.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:be8929ce4313f9f8146caad4272f6abb8bf99fc6cf59344a3167ecd74f4f203f"}, + {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ab8ed1a1d77c95453db1ae00a3f9c50227ebd955437bcf2a574ba8adbf6a74d5"}, + {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:4aef137f3345082a3d3232187aeb4ac4ef959ba3d7c10c33dd73763fbc063da4"}, + {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ea8518d152174e1249c4f2a1c89e3e6065941df2fa13a1ab45327716a23c2b48"}, + {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:acf2aef9391710afded549ff602b5887d7a2349831ae4c26be7c807c0a39fac4"}, + {file = "psutil-5.9.5-cp27-none-win32.whl", hash = "sha256:5b9b8cb93f507e8dbaf22af6a2fd0ccbe8244bf30b1baad6b3954e935157ae3f"}, + {file = "psutil-5.9.5-cp27-none-win_amd64.whl", hash = "sha256:8c5f7c5a052d1d567db4ddd231a9d27a74e8e4a9c3f44b1032762bd7b9fdcd42"}, + {file = "psutil-5.9.5-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3c6f686f4225553615612f6d9bc21f1c0e305f75d7d8454f9b46e901778e7217"}, + {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a7dd9997128a0d928ed4fb2c2d57e5102bb6089027939f3b722f3a210f9a8da"}, + {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89518112647f1276b03ca97b65cc7f64ca587b1eb0278383017c2a0dcc26cbe4"}, + {file = "psutil-5.9.5-cp36-abi3-win32.whl", hash = "sha256:104a5cc0e31baa2bcf67900be36acde157756b9c44017b86b2c049f11957887d"}, + {file = "psutil-5.9.5-cp36-abi3-win_amd64.whl", hash = "sha256:b258c0c1c9d145a1d5ceffab1134441c4c5113b2417fafff7315a917a026c3c9"}, + {file = "psutil-5.9.5-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:c607bb3b57dc779d55e1554846352b4e358c10fff3abf3514a7a6601beebdb30"}, + {file = "psutil-5.9.5.tar.gz", hash = "sha256:5410638e4df39c54d957fc51ce03048acd8e6d60abc0f5107af51e5fb566eb3c"}, +] + +[package.extras] +test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] + +[[package]] +name = "psycopg2-binary" +version = "2.9.6" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=3.6" +files = [ + {file = "psycopg2-binary-2.9.6.tar.gz", hash = "sha256:1f64dcfb8f6e0c014c7f55e51c9759f024f70ea572fbdef123f85318c297947c"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d26e0342183c762de3276cca7a530d574d4e25121ca7d6e4a98e4f05cb8e4df7"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c48d8f2db17f27d41fb0e2ecd703ea41984ee19362cbce52c097963b3a1b4365"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe9dc0a884a8848075e576c1de0290d85a533a9f6e9c4e564f19adf8f6e54a7"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a76e027f87753f9bd1ab5f7c9cb8c7628d1077ef927f5e2446477153a602f2c"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6460c7a99fc939b849431f1e73e013d54aa54293f30f1109019c56a0b2b2ec2f"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae102a98c547ee2288637af07393dd33f440c25e5cd79556b04e3fca13325e5f"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9972aad21f965599ed0106f65334230ce826e5ae69fda7cbd688d24fa922415e"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7a40c00dbe17c0af5bdd55aafd6ff6679f94a9be9513a4c7e071baf3d7d22a70"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:cacbdc5839bdff804dfebc058fe25684cae322987f7a38b0168bc1b2df703fb1"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7f0438fa20fb6c7e202863e0d5ab02c246d35efb1d164e052f2f3bfe2b152bd0"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-win32.whl", hash = "sha256:b6c8288bb8a84b47e07013bb4850f50538aa913d487579e1921724631d02ea1b"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-win_amd64.whl", hash = "sha256:61b047a0537bbc3afae10f134dc6393823882eb263088c271331602b672e52e9"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:964b4dfb7c1c1965ac4c1978b0f755cc4bd698e8aa2b7667c575fb5f04ebe06b"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afe64e9b8ea66866a771996f6ff14447e8082ea26e675a295ad3bdbffdd72afb"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15e2ee79e7cf29582ef770de7dab3d286431b01c3bb598f8e05e09601b890081"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dfa74c903a3c1f0d9b1c7e7b53ed2d929a4910e272add6700c38f365a6002820"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b83456c2d4979e08ff56180a76429263ea254c3f6552cd14ada95cff1dec9bb8"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0645376d399bfd64da57148694d78e1f431b1e1ee1054872a5713125681cf1be"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e99e34c82309dd78959ba3c1590975b5d3c862d6f279f843d47d26ff89d7d7e1"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4ea29fc3ad9d91162c52b578f211ff1c931d8a38e1f58e684c45aa470adf19e2"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:4ac30da8b4f57187dbf449294d23b808f8f53cad6b1fc3623fa8a6c11d176dd0"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e78e6e2a00c223e164c417628572a90093c031ed724492c763721c2e0bc2a8df"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-win32.whl", hash = "sha256:1876843d8e31c89c399e31b97d4b9725a3575bb9c2af92038464231ec40f9edb"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-win_amd64.whl", hash = "sha256:b4b24f75d16a89cc6b4cdff0eb6a910a966ecd476d1e73f7ce5985ff1328e9a6"}, + {file = "psycopg2_binary-2.9.6-cp36-cp36m-win32.whl", hash = "sha256:498807b927ca2510baea1b05cc91d7da4718a0f53cb766c154c417a39f1820a0"}, + {file = "psycopg2_binary-2.9.6-cp36-cp36m-win_amd64.whl", hash = "sha256:0d236c2825fa656a2d98bbb0e52370a2e852e5a0ec45fc4f402977313329174d"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:34b9ccdf210cbbb1303c7c4db2905fa0319391bd5904d32689e6dd5c963d2ea8"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84d2222e61f313c4848ff05353653bf5f5cf6ce34df540e4274516880d9c3763"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30637a20623e2a2eacc420059be11527f4458ef54352d870b8181a4c3020ae6b"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8122cfc7cae0da9a3077216528b8bb3629c43b25053284cc868744bfe71eb141"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38601cbbfe600362c43714482f43b7c110b20cb0f8172422c616b09b85a750c5"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c7e62ab8b332147a7593a385d4f368874d5fe4ad4e341770d4983442d89603e3"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2ab652e729ff4ad76d400df2624d223d6e265ef81bb8aa17fbd63607878ecbee"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:c83a74b68270028dc8ee74d38ecfaf9c90eed23c8959fca95bd703d25b82c88e"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d4e6036decf4b72d6425d5b29bbd3e8f0ff1059cda7ac7b96d6ac5ed34ffbacd"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-win32.whl", hash = "sha256:a8c28fd40a4226b4a84bdf2d2b5b37d2c7bd49486b5adcc200e8c7ec991dfa7e"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-win_amd64.whl", hash = "sha256:51537e3d299be0db9137b321dfb6a5022caaab275775680e0c3d281feefaca6b"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cf4499e0a83b7b7edcb8dabecbd8501d0d3a5ef66457200f77bde3d210d5debb"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7e13a5a2c01151f1208d5207e42f33ba86d561b7a89fca67c700b9486a06d0e2"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e0f754d27fddcfd74006455b6e04e6705d6c31a612ec69ddc040a5468e44b4e"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d57c3fd55d9058645d26ae37d76e61156a27722097229d32a9e73ed54819982a"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71f14375d6f73b62800530b581aed3ada394039877818b2d5f7fc77e3bb6894d"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:441cc2f8869a4f0f4bb408475e5ae0ee1f3b55b33f350406150277f7f35384fc"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:65bee1e49fa6f9cf327ce0e01c4c10f39165ee76d35c846ade7cb0ec6683e303"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:af335bac6b666cc6aea16f11d486c3b794029d9df029967f9938a4bed59b6a19"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:cfec476887aa231b8548ece2e06d28edc87c1397ebd83922299af2e051cf2827"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:65c07febd1936d63bfde78948b76cd4c2a411572a44ac50719ead41947d0f26b"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-win32.whl", hash = "sha256:4dfb4be774c4436a4526d0c554af0cc2e02082c38303852a36f6456ece7b3503"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-win_amd64.whl", hash = "sha256:02c6e3cf3439e213e4ee930308dc122d6fb4d4bea9aef4a12535fbd605d1a2fe"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e9182eb20f41417ea1dd8e8f7888c4d7c6e805f8a7c98c1081778a3da2bee3e4"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8a6979cf527e2603d349a91060f428bcb135aea2be3201dff794813256c274f1"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8338a271cb71d8da40b023a35d9c1e919eba6cbd8fa20a54b748a332c355d896"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3ed340d2b858d6e6fb5083f87c09996506af483227735de6964a6100b4e6a54"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f81e65376e52f03422e1fb475c9514185669943798ed019ac50410fb4c4df232"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfb13af3c5dd3a9588000910178de17010ebcccd37b4f9794b00595e3a8ddad3"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4c727b597c6444a16e9119386b59388f8a424223302d0c06c676ec8b4bc1f963"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4d67fbdaf177da06374473ef6f7ed8cc0a9dc640b01abfe9e8a2ccb1b1402c1f"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0892ef645c2fabb0c75ec32d79f4252542d0caec1d5d949630e7d242ca4681a3"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:02c0f3757a4300cf379eb49f543fb7ac527fb00144d39246ee40e1df684ab514"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-win32.whl", hash = "sha256:c3dba7dab16709a33a847e5cd756767271697041fbe3fe97c215b1fc1f5c9848"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-win_amd64.whl", hash = "sha256:f6a88f384335bb27812293fdb11ac6aee2ca3f51d3c7820fe03de0a304ab6249"}, +] + +[[package]] +name = "pyasn1" +version = "0.5.0" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "pyasn1-0.5.0-py2.py3-none-any.whl", hash = "sha256:87a2121042a1ac9358cabcaf1d07680ff97ee6404333bacca15f76aa8ad01a57"}, + {file = "pyasn1-0.5.0.tar.gz", hash = "sha256:97b7290ca68e62a832558ec3976f15cbf911bf5d7c7039d8b861c2a0ece69fde"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.3.0" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "pyasn1_modules-0.3.0-py2.py3-none-any.whl", hash = "sha256:d3ccd6ed470d9ffbc716be08bd90efbd44d0734bc9303818f7336070984a162d"}, + {file = "pyasn1_modules-0.3.0.tar.gz", hash = "sha256:5bd01446b736eb9d31512a30d46c1ac3395d676c6f3cafa4c03eb54b9925631c"}, +] + +[package.dependencies] +pyasn1 = ">=0.4.6,<0.6.0" + +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] + +[[package]] +name = "pydantic" +version = "1.10.9" +description = "Data validation and settings management using python type hints" +optional = true +python-versions = ">=3.7" +files = [ + {file = "pydantic-1.10.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e692dec4a40bfb40ca530e07805b1208c1de071a18d26af4a2a0d79015b352ca"}, + {file = "pydantic-1.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3c52eb595db83e189419bf337b59154bdcca642ee4b2a09e5d7797e41ace783f"}, + {file = "pydantic-1.10.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:939328fd539b8d0edf244327398a667b6b140afd3bf7e347cf9813c736211896"}, + {file = "pydantic-1.10.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b48d3d634bca23b172f47f2335c617d3fcb4b3ba18481c96b7943a4c634f5c8d"}, + {file = "pydantic-1.10.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:f0b7628fb8efe60fe66fd4adadd7ad2304014770cdc1f4934db41fe46cc8825f"}, + {file = "pydantic-1.10.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e1aa5c2410769ca28aa9a7841b80d9d9a1c5f223928ca8bec7e7c9a34d26b1d4"}, + {file = "pydantic-1.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:eec39224b2b2e861259d6f3c8b6290d4e0fbdce147adb797484a42278a1a486f"}, + {file = "pydantic-1.10.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d111a21bbbfd85c17248130deac02bbd9b5e20b303338e0dbe0faa78330e37e0"}, + {file = "pydantic-1.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e9aec8627a1a6823fc62fb96480abe3eb10168fd0d859ee3d3b395105ae19a7"}, + {file = "pydantic-1.10.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07293ab08e7b4d3c9d7de4949a0ea571f11e4557d19ea24dd3ae0c524c0c334d"}, + {file = "pydantic-1.10.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ee829b86ce984261d99ff2fd6e88f2230068d96c2a582f29583ed602ef3fc2c"}, + {file = "pydantic-1.10.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4b466a23009ff5cdd7076eb56aca537c745ca491293cc38e72bf1e0e00de5b91"}, + {file = "pydantic-1.10.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7847ca62e581e6088d9000f3c497267868ca2fa89432714e21a4fb33a04d52e8"}, + {file = "pydantic-1.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:7845b31959468bc5b78d7b95ec52fe5be32b55d0d09983a877cca6aedc51068f"}, + {file = "pydantic-1.10.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:517a681919bf880ce1dac7e5bc0c3af1e58ba118fd774da2ffcd93c5f96eaece"}, + {file = "pydantic-1.10.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67195274fd27780f15c4c372f4ba9a5c02dad6d50647b917b6a92bf00b3d301a"}, + {file = "pydantic-1.10.9-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2196c06484da2b3fded1ab6dbe182bdabeb09f6318b7fdc412609ee2b564c49a"}, + {file = "pydantic-1.10.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6257bb45ad78abacda13f15bde5886efd6bf549dd71085e64b8dcf9919c38b60"}, + {file = "pydantic-1.10.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3283b574b01e8dbc982080d8287c968489d25329a463b29a90d4157de4f2baaf"}, + {file = "pydantic-1.10.9-cp37-cp37m-win_amd64.whl", hash = "sha256:5f8bbaf4013b9a50e8100333cc4e3fa2f81214033e05ac5aa44fa24a98670a29"}, + {file = "pydantic-1.10.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b9cd67fb763248cbe38f0593cd8611bfe4b8ad82acb3bdf2b0898c23415a1f82"}, + {file = "pydantic-1.10.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f50e1764ce9353be67267e7fd0da08349397c7db17a562ad036aa7c8f4adfdb6"}, + {file = "pydantic-1.10.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73ef93e5e1d3c8e83f1ff2e7fdd026d9e063c7e089394869a6e2985696693766"}, + {file = "pydantic-1.10.9-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:128d9453d92e6e81e881dd7e2484e08d8b164da5507f62d06ceecf84bf2e21d3"}, + {file = "pydantic-1.10.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ad428e92ab68798d9326bb3e5515bc927444a3d71a93b4a2ca02a8a5d795c572"}, + {file = "pydantic-1.10.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fab81a92f42d6d525dd47ced310b0c3e10c416bbfae5d59523e63ea22f82b31e"}, + {file = "pydantic-1.10.9-cp38-cp38-win_amd64.whl", hash = "sha256:963671eda0b6ba6926d8fc759e3e10335e1dc1b71ff2a43ed2efd6996634dafb"}, + {file = "pydantic-1.10.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:970b1bdc6243ef663ba5c7e36ac9ab1f2bfecb8ad297c9824b542d41a750b298"}, + {file = "pydantic-1.10.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7e1d5290044f620f80cf1c969c542a5468f3656de47b41aa78100c5baa2b8276"}, + {file = "pydantic-1.10.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83fcff3c7df7adff880622a98022626f4f6dbce6639a88a15a3ce0f96466cb60"}, + {file = "pydantic-1.10.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0da48717dc9495d3a8f215e0d012599db6b8092db02acac5e0d58a65248ec5bc"}, + {file = "pydantic-1.10.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0a2aabdc73c2a5960e87c3ffebca6ccde88665616d1fd6d3db3178ef427b267a"}, + {file = "pydantic-1.10.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9863b9420d99dfa9c064042304868e8ba08e89081428a1c471858aa2af6f57c4"}, + {file = "pydantic-1.10.9-cp39-cp39-win_amd64.whl", hash = "sha256:e7c9900b43ac14110efa977be3da28931ffc74c27e96ee89fbcaaf0b0fe338e1"}, + {file = "pydantic-1.10.9-py3-none-any.whl", hash = "sha256:6cafde02f6699ce4ff643417d1a9223716ec25e228ddc3b436fe7e2d25a1f305"}, + {file = "pydantic-1.10.9.tar.gz", hash = "sha256:95c70da2cd3b6ddf3b9645ecaa8d98f3d80c606624b6d245558d202cd23ea3be"}, +] + +[package.dependencies] +typing-extensions = ">=4.2.0" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + +[[package]] +name = "pydoc-markdown" +version = "4.7.0" +description = "Create Python API documentation in Markdown format." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "pydoc_markdown-4.7.0-py3-none-any.whl", hash = "sha256:7dbe173920e1250e3e8a56153b06af952cc61d6c23e206113b8a6d457f013adf"}, + {file = "pydoc_markdown-4.7.0.tar.gz", hash = "sha256:d1083995e34575924a114e35d72de608a3b5763797d7218bd9b429ad5bb17e80"}, +] + +[package.dependencies] +click = ">=7.1,<9.0" +databind = ">=1.5.0,<2.0.0" +docspec = ">=2.0.0a1,<3.0.0" +docspec-python = ">=2.0.0a1,<3.0.0" +docstring-parser = ">=0.11,<0.12" +jinja2 = ">=3.0.0,<4.0.0" +"nr.util" = ">=0.7.5,<1.0.0" +PyYAML = ">=5.0,<7.0" +requests = ">=2.23.0,<3.0.0" +tomli = ">=2.0.0,<3.0.0" +tomli_w = ">=1.0.0,<2.0.0" +watchdog = "*" +yapf = ">=0.30.0" + +[[package]] +name = "pydot" +version = "1.4.2" +description = "Python interface to Graphviz's Dot" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pydot-1.4.2-py2.py3-none-any.whl", hash = "sha256:66c98190c65b8d2e2382a441b4c0edfdb4f4c025ef9cb9874de478fb0793a451"}, + {file = "pydot-1.4.2.tar.gz", hash = "sha256:248081a39bcb56784deb018977e428605c1c758f10897a339fce1dd728ff007d"}, +] + +[package.dependencies] +pyparsing = ">=2.1.4" + +[[package]] +name = "pygments" +version = "2.15.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"}, + {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"}, +] + +[package.extras] +plugins = ["importlib-metadata"] + +[[package]] +name = "pyjwt" +version = "2.7.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "PyJWT-2.7.0-py3-none-any.whl", hash = "sha256:ba2b425b15ad5ef12f200dc67dd56af4e26de2331f965c5439994dad075876e1"}, + {file = "PyJWT-2.7.0.tar.gz", hash = "sha256:bd6ca4a3c4285c1a2d4349e5a035fdf8fb94e04ccd0fcbe6ba289dae9cc3e074"}, +] + +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + +[[package]] +name = "pykwalify" +version = "1.8.0" +description = "Python lib/cli for JSON/YAML schema validation" +optional = false +python-versions = "*" +files = [ + {file = "pykwalify-1.8.0-py2.py3-none-any.whl", hash = "sha256:731dfa87338cca9f559d1fca2bdea37299116e3139b73f78ca90a543722d6651"}, + {file = "pykwalify-1.8.0.tar.gz", hash = "sha256:796b2ad3ed4cb99b88308b533fb2f559c30fa6efb4fa9fda11347f483d245884"}, +] + +[package.dependencies] +docopt = ">=0.6.2" +python-dateutil = ">=2.8.0" +"ruamel.yaml" = ">=0.16.0" + +[[package]] +name = "pymongo" +version = "4.3.3" +description = "Python driver for MongoDB <http://www.mongodb.org>" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pymongo-4.3.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:74731c9e423c93cbe791f60c27030b6af6a948cef67deca079da6cd1bb583a8e"}, + {file = "pymongo-4.3.3-cp310-cp310-manylinux1_i686.whl", hash = "sha256:66413c50d510e5bcb0afc79880d1693a2185bcea003600ed898ada31338c004e"}, + {file = "pymongo-4.3.3-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:9b87b23570565a6ddaa9244d87811c2ee9cffb02a753c8a2da9c077283d85845"}, + {file = "pymongo-4.3.3-cp310-cp310-manylinux2014_i686.whl", hash = "sha256:695939036a320f4329ccf1627edefbbb67cc7892b8222d297b0dd2313742bfee"}, + {file = "pymongo-4.3.3-cp310-cp310-manylinux2014_ppc64le.whl", hash = "sha256:ffcc8394123ea8d43fff8e5d000095fe7741ce3f8988366c5c919c4f5eb179d3"}, + {file = "pymongo-4.3.3-cp310-cp310-manylinux2014_s390x.whl", hash = "sha256:943f208840777f34312c103a2d1caab02d780c4e9be26b3714acf6c4715ba7e1"}, + {file = "pymongo-4.3.3-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:01f7cbe88d22440b6594c955e37312d932fd632ffed1a86d0c361503ca82cc9d"}, + {file = "pymongo-4.3.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdb87309de97c63cb9a69132e1cb16be470e58cffdfbad68fdd1dc292b22a840"}, + {file = "pymongo-4.3.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d86c35d94b5499689354ccbc48438a79f449481ee6300f3e905748edceed78e7"}, + {file = "pymongo-4.3.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a966d5304b7d90c45c404914e06bbf02c5bf7e99685c6c12f0047ef2aa837142"}, + {file = "pymongo-4.3.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be1d2ce7e269215c3ee9a215e296b7a744aff4f39233486d2c4d77f5f0c561a6"}, + {file = "pymongo-4.3.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:55b6163dac53ef1e5d834297810c178050bd0548a4136cd4e0f56402185916ca"}, + {file = "pymongo-4.3.3-cp310-cp310-win32.whl", hash = "sha256:dc0cff74cd36d7e1edba91baa09622c35a8a57025f2f2b7a41e3f83b1db73186"}, + {file = "pymongo-4.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:cafa52873ae12baa512a8721afc20de67a36886baae6a5f394ddef0ce9391f91"}, + {file = "pymongo-4.3.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:599d3f6fbef31933b96e2d906b0f169b3371ff79ea6aaf6ecd76c947a3508a3d"}, + {file = "pymongo-4.3.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0640b4e9d008e13956b004d1971a23377b3d45491f87082161c92efb1e6c0d6"}, + {file = "pymongo-4.3.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:341221e2f2866a5960e6f8610f4cbac0bb13097f3b1a289aa55aba984fc0d969"}, + {file = "pymongo-4.3.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7fac06a539daef4fcf5d8288d0d21b412f9b750454cd5a3cf90484665db442a"}, + {file = "pymongo-4.3.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3a51901066696c4af38c6c63a1f0aeffd5e282367ff475de8c191ec9609b56d"}, + {file = "pymongo-4.3.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3055510fdfdb1775bc8baa359783022f70bb553f2d46e153c094dfcb08578ff"}, + {file = "pymongo-4.3.3-cp311-cp311-win32.whl", hash = "sha256:524d78673518dcd352a91541ecd2839c65af92dc883321c2109ef6e5cd22ef23"}, + {file = "pymongo-4.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:b8a03af1ce79b902a43f5f694c4ca8d92c2a4195db0966f08f266549e2fc49bc"}, + {file = "pymongo-4.3.3-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:39b03045c71f761aee96a12ebfbc2f4be89e724ff6f5e31c2574c1a0e2add8bd"}, + {file = "pymongo-4.3.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6fcfbf435eebf8a1765c6d1f46821740ebe9f54f815a05c8fc30d789ef43cb12"}, + {file = "pymongo-4.3.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:7d43ac9c7eeda5100fb0a7152fab7099c9cf9e5abd3bb36928eb98c7d7a339c6"}, + {file = "pymongo-4.3.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:3b93043b14ba7eb08c57afca19751658ece1cfa2f0b7b1fb5c7a41452fbb8482"}, + {file = "pymongo-4.3.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:c09956606c08c4a7c6178a04ba2dd9388fcc5db32002ade9c9bc865ab156ab6d"}, + {file = "pymongo-4.3.3-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:b0cfe925610f2fd59555bb7fc37bd739e4b197d33f2a8b2fae7b9c0c6640318c"}, + {file = "pymongo-4.3.3-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:4d00b91c77ceb064c9b0459f0d6ea5bfdbc53ea9e17cf75731e151ef25a830c7"}, + {file = "pymongo-4.3.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:c6258a3663780ae47ba73d43eb63c79c40ffddfb764e09b56df33be2f9479837"}, + {file = "pymongo-4.3.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29e758f0e734e1e90357ae01ec9c6daf19ff60a051192fe110d8fb25c62600e"}, + {file = "pymongo-4.3.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f3621a46cdc7a9ba8080422262398a91762a581d27e0647746588d3f995c88"}, + {file = "pymongo-4.3.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:47f7aa217b25833cd6f0e72b0d224be55393c2692b4f5e0561cb3beeb10296e9"}, + {file = "pymongo-4.3.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c2fdc855149efe7cdcc2a01ca02bfa24761c640203ea94df467f3baf19078be"}, + {file = "pymongo-4.3.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5effd87c7d363890259eac16c56a4e8da307286012c076223997f8cc4a8c435b"}, + {file = "pymongo-4.3.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6dd1cf2995fdbd64fc0802313e8323f5fa18994d51af059b5b8862b73b5e53f0"}, + {file = "pymongo-4.3.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:bb869707d8e30645ed6766e44098600ca6cdf7989c22a3ea2b7966bb1d98d4b2"}, + {file = "pymongo-4.3.3-cp37-cp37m-win32.whl", hash = "sha256:49210feb0be8051a64d71691f0acbfbedc33e149f0a5d6e271fddf6a12493fed"}, + {file = "pymongo-4.3.3-cp37-cp37m-win_amd64.whl", hash = "sha256:54c377893f2cbbffe39abcff5ff2e917b082c364521fa079305f6f064e1a24a9"}, + {file = "pymongo-4.3.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c184ec5be465c0319440734491e1aa4709b5f3ba75fdfc9dbbc2ae715a7f6829"}, + {file = "pymongo-4.3.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:dca34367a4e77fcab0693e603a959878eaf2351585e7d752cac544bc6b2dee46"}, + {file = "pymongo-4.3.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:cd6a4afb20fb3c26a7bfd4611a0bbb24d93cbd746f5eb881f114b5e38fd55501"}, + {file = "pymongo-4.3.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:0c466710871d0026c190fc4141e810cf9d9affbf4935e1d273fbdc7d7cda6143"}, + {file = "pymongo-4.3.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:d07d06dba5b5f7d80f9cc45501456e440f759fe79f9895922ed486237ac378a8"}, + {file = "pymongo-4.3.3-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:711bc52cb98e7892c03e9b669bebd89c0a890a90dbc6d5bb2c47f30239bac6e9"}, + {file = "pymongo-4.3.3-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:34b040e095e1671df0c095ec0b04fc4ebb19c4c160f87c2b55c079b16b1a6b00"}, + {file = "pymongo-4.3.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4ed00f96e147f40b565fe7530d1da0b0f3ab803d5dd5b683834500fa5d195ec4"}, + {file = "pymongo-4.3.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef888f48eb9203ee1e04b9fb27429017b290fb916f1e7826c2f7808c88798394"}, + {file = "pymongo-4.3.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:316498b642c00401370b2156b5233b256f9b33799e0a8d9d0b8a7da217a20fca"}, + {file = "pymongo-4.3.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa7e202feb683dad74f00dea066690448d0cfa310f8a277db06ec8eb466601b5"}, + {file = "pymongo-4.3.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52896e22115c97f1c829db32aa2760b0d61839cfe08b168c2b1d82f31dbc5f55"}, + {file = "pymongo-4.3.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c051fe37c96b9878f37fa58906cb53ecd13dcb7341d3a85f1e2e2f6b10782d9"}, + {file = "pymongo-4.3.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5134d33286c045393c7beb51be29754647cec5ebc051cf82799c5ce9820a2ca2"}, + {file = "pymongo-4.3.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a9c2885b4a8e6e39db5662d8b02ca6dcec796a45e48c2de12552841f061692ba"}, + {file = "pymongo-4.3.3-cp38-cp38-win32.whl", hash = "sha256:a6cd6f1db75eb07332bd3710f58f5fce4967eadbf751bad653842750a61bda62"}, + {file = "pymongo-4.3.3-cp38-cp38-win_amd64.whl", hash = "sha256:d5571b6978750601f783cea07fb6b666837010ca57e5cefa389c1d456f6222e2"}, + {file = "pymongo-4.3.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:81d1a7303bd02ca1c5be4aacd4db73593f573ba8e0c543c04c6da6275fd7a47e"}, + {file = "pymongo-4.3.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:016c412118e1c23fef3a1eada4f83ae6e8844fd91986b2e066fc1b0013cdd9ae"}, + {file = "pymongo-4.3.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:8fd6e191b92a10310f5a6cfe10d6f839d79d192fb02480bda325286bd1c7b385"}, + {file = "pymongo-4.3.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:e2961b05f9c04a53da8bfc72f1910b6aec7205fcf3ac9c036d24619979bbee4b"}, + {file = "pymongo-4.3.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:b38a96b3eed8edc515b38257f03216f382c4389d022a8834667e2bc63c0c0c31"}, + {file = "pymongo-4.3.3-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:c1a70c51da9fa95bd75c167edb2eb3f3c4d27bc4ddd29e588f21649d014ec0b7"}, + {file = "pymongo-4.3.3-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:8a06a0c02f5606330e8f2e2f3b7949877ca7e4024fa2bff5a4506bec66c49ec7"}, + {file = "pymongo-4.3.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:6c2216d8b6a6d019c6f4b1ad55f890e5e77eb089309ffc05b6911c09349e7474"}, + {file = "pymongo-4.3.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eac0a143ef4f28f49670bf89cb15847eb80b375d55eba401ca2f777cd425f338"}, + {file = "pymongo-4.3.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:08fc250b5552ee97ceeae0f52d8b04f360291285fc7437f13daa516ce38fdbc6"}, + {file = "pymongo-4.3.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704d939656e21b073bfcddd7228b29e0e8a93dd27b54240eaafc0b9a631629a6"}, + {file = "pymongo-4.3.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1074f1a6f23e28b983c96142f2d45be03ec55d93035b471c26889a7ad2365db3"}, + {file = "pymongo-4.3.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b16250238de8dafca225647608dddc7bbb5dce3dd53b4d8e63c1cc287394c2f"}, + {file = "pymongo-4.3.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7761cacb8745093062695b11574effea69db636c2fd0a9269a1f0183712927b4"}, + {file = "pymongo-4.3.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:fd7bb378d82b88387dc10227cfd964f6273eb083e05299e9b97cbe075da12d11"}, + {file = "pymongo-4.3.3-cp39-cp39-win32.whl", hash = "sha256:dc24d245026a72d9b4953729d31813edd4bd4e5c13622d96e27c284942d33f24"}, + {file = "pymongo-4.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:fc28e8d85d392a06434e9a934908d97e2cf453d69488d2bcd0bfb881497fd975"}, + {file = "pymongo-4.3.3.tar.gz", hash = "sha256:34e95ffb0a68bffbc3b437f2d1f25fc916fef3df5cdeed0992da5f42fae9b807"}, +] + +[package.dependencies] +dnspython = ">=1.16.0,<3.0.0" + +[package.extras] +aws = ["pymongo-auth-aws (<2.0.0)"] +encryption = ["pymongo-auth-aws (<2.0.0)", "pymongocrypt (>=1.3.0,<2.0.0)"] +gssapi = ["pykerberos"] +ocsp = ["certifi", "pyopenssl (>=17.2.0)", "requests (<3.0.0)", "service-identity (>=18.1.0)"] +snappy = ["python-snappy"] +zstd = ["zstandard"] + +[[package]] +name = "pyparsing" +version = "3.1.0" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.6.8" +files = [ + {file = "pyparsing-3.1.0-py3-none-any.whl", hash = "sha256:d554a96d1a7d3ddaf7183104485bc19fd80543ad6ac5bdb6426719d766fb06c1"}, + {file = "pyparsing-3.1.0.tar.gz", hash = "sha256:edb662d6fe322d6e990b1594b5feaeadf806803359e3d4d42f11e295e588f0ea"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pyreadline3" +version = "3.4.1" +description = "A python implementation of GNU readline." +optional = false +python-versions = "*" +files = [ + {file = "pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb"}, + {file = "pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae"}, +] + +[[package]] +name = "pyrsistent" +version = "0.19.3" +description = "Persistent/Functional/Immutable data structures" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyrsistent-0.19.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:20460ac0ea439a3e79caa1dbd560344b64ed75e85d8703943e0b66c2a6150e4a"}, + {file = "pyrsistent-0.19.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c18264cb84b5e68e7085a43723f9e4c1fd1d935ab240ce02c0324a8e01ccb64"}, + {file = "pyrsistent-0.19.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b774f9288dda8d425adb6544e5903f1fb6c273ab3128a355c6b972b7df39dcf"}, + {file = "pyrsistent-0.19.3-cp310-cp310-win32.whl", hash = "sha256:5a474fb80f5e0d6c9394d8db0fc19e90fa540b82ee52dba7d246a7791712f74a"}, + {file = "pyrsistent-0.19.3-cp310-cp310-win_amd64.whl", hash = "sha256:49c32f216c17148695ca0e02a5c521e28a4ee6c5089f97e34fe24163113722da"}, + {file = "pyrsistent-0.19.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f0774bf48631f3a20471dd7c5989657b639fd2d285b861237ea9e82c36a415a9"}, + {file = "pyrsistent-0.19.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ab2204234c0ecd8b9368dbd6a53e83c3d4f3cab10ecaf6d0e772f456c442393"}, + {file = "pyrsistent-0.19.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e42296a09e83028b3476f7073fcb69ffebac0e66dbbfd1bd847d61f74db30f19"}, + {file = "pyrsistent-0.19.3-cp311-cp311-win32.whl", hash = "sha256:64220c429e42a7150f4bfd280f6f4bb2850f95956bde93c6fda1b70507af6ef3"}, + {file = "pyrsistent-0.19.3-cp311-cp311-win_amd64.whl", hash = "sha256:016ad1afadf318eb7911baa24b049909f7f3bb2c5b1ed7b6a8f21db21ea3faa8"}, + {file = "pyrsistent-0.19.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c4db1bd596fefd66b296a3d5d943c94f4fac5bcd13e99bffe2ba6a759d959a28"}, + {file = "pyrsistent-0.19.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aeda827381f5e5d65cced3024126529ddc4289d944f75e090572c77ceb19adbf"}, + {file = "pyrsistent-0.19.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42ac0b2f44607eb92ae88609eda931a4f0dfa03038c44c772e07f43e738bcac9"}, + {file = "pyrsistent-0.19.3-cp37-cp37m-win32.whl", hash = "sha256:e8f2b814a3dc6225964fa03d8582c6e0b6650d68a232df41e3cc1b66a5d2f8d1"}, + {file = "pyrsistent-0.19.3-cp37-cp37m-win_amd64.whl", hash = "sha256:c9bb60a40a0ab9aba40a59f68214eed5a29c6274c83b2cc206a359c4a89fa41b"}, + {file = "pyrsistent-0.19.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a2471f3f8693101975b1ff85ffd19bb7ca7dd7c38f8a81701f67d6b4f97b87d8"}, + {file = "pyrsistent-0.19.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc5d149f31706762c1f8bda2e8c4f8fead6e80312e3692619a75301d3dbb819a"}, + {file = "pyrsistent-0.19.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3311cb4237a341aa52ab8448c27e3a9931e2ee09561ad150ba94e4cfd3fc888c"}, + {file = "pyrsistent-0.19.3-cp38-cp38-win32.whl", hash = "sha256:f0e7c4b2f77593871e918be000b96c8107da48444d57005b6a6bc61fb4331b2c"}, + {file = "pyrsistent-0.19.3-cp38-cp38-win_amd64.whl", hash = "sha256:c147257a92374fde8498491f53ffa8f4822cd70c0d85037e09028e478cababb7"}, + {file = "pyrsistent-0.19.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b735e538f74ec31378f5a1e3886a26d2ca6351106b4dfde376a26fc32a044edc"}, + {file = "pyrsistent-0.19.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99abb85579e2165bd8522f0c0138864da97847875ecbd45f3e7e2af569bfc6f2"}, + {file = "pyrsistent-0.19.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a8cb235fa6d3fd7aae6a4f1429bbb1fec1577d978098da1252f0489937786f3"}, + {file = "pyrsistent-0.19.3-cp39-cp39-win32.whl", hash = "sha256:c74bed51f9b41c48366a286395c67f4e894374306b197e62810e0fdaf2364da2"}, + {file = "pyrsistent-0.19.3-cp39-cp39-win_amd64.whl", hash = "sha256:878433581fc23e906d947a6814336eee031a00e6defba224234169ae3d3d6a98"}, + {file = "pyrsistent-0.19.3-py3-none-any.whl", hash = "sha256:ccf0d6bd208f8111179f0c26fdf84ed7c3891982f2edaeae7422575f47e66b64"}, + {file = "pyrsistent-0.19.3.tar.gz", hash = "sha256:1a2994773706bbb4995c31a97bc94f1418314923bd1048c6d964837040376440"}, +] + +[[package]] +name = "pytest" +version = "7.4.0" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, + {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.20.3" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"}, + {file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"}, +] + +[package.dependencies] +pytest = ">=6.1.0" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-sanic" +version = "1.9.1" +description = "a pytest plugin for Sanic" +optional = false +python-versions = ">=3.7" +files = [] +develop = false + +[package.dependencies] +async_generator = "^1.10" +httpx = ">=0.18.1" +pytest = ">=5.2" +websockets = ">=9.1,<11.0" + +[package.source] +type = "git" +url = "https://github.com/RasaHQ/pytest-sanic" +reference = "fix_signal_issue" +resolved_reference = "4092e8005fbbdc29b892cd8b09399894d86b1ac7" + +[[package]] +name = "pytest-timeout" +version = "2.1.0" +description = "pytest plugin to abort hanging tests" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pytest-timeout-2.1.0.tar.gz", hash = "sha256:c07ca07404c612f8abbe22294b23c368e2e5104b521c1790195561f37e1ac3d9"}, + {file = "pytest_timeout-2.1.0-py3-none-any.whl", hash = "sha256:f6f50101443ce70ad325ceb4473c4255e9d74e3c7cd0ef827309dfa4c0d975c6"}, +] + +[package.dependencies] +pytest = ">=5.0.0" + +[[package]] +name = "pytest-xdist" +version = "3.3.1" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-xdist-3.3.1.tar.gz", hash = "sha256:d5ee0520eb1b7bcca50a60a518ab7a7707992812c578198f8b44fdfac78e8c93"}, + {file = "pytest_xdist-3.3.1-py3-none-any.whl", hash = "sha256:ff9daa7793569e6a68544850fd3927cd257cc03a7ef76c95e86915355e82b5f2"}, +] + +[package.dependencies] +execnet = ">=1.1" +pytest = ">=6.2.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + +[[package]] +name = "python-crfsuite" +version = "0.9.9" +description = "Python binding for CRFsuite" +optional = false +python-versions = "*" +files = [ + {file = "python-crfsuite-0.9.9.tar.gz", hash = "sha256:caa6261d6955466756f986b7fcfbd4fd50622963e3bdb5cc180c129c62b3a76d"}, + {file = "python_crfsuite-0.9.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4e91ec7e852d0b78d51950a02f4c5867cdc87b3f9709ee0b692e473cc008255"}, + {file = "python_crfsuite-0.9.9-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c2aaccc4e31ea8192c3d4a3d09008fd34e5cedf1a03566ecd51c9207a4d4bba"}, + {file = "python_crfsuite-0.9.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f381ef138c47f36b8ef7dfa0e71d575b6b447db49dee941ffece2e7f94e9f0b7"}, + {file = "python_crfsuite-0.9.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:002fb06c68667653f64bc08b53f62fe89284841109f18814c87f06ebbde1f585"}, + {file = "python_crfsuite-0.9.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:702369aa4b4e236851d2758890c4a0e53bc6ee0491ff9f2147fe0dea7184bfbb"}, + {file = "python_crfsuite-0.9.9-cp310-cp310-win32.whl", hash = "sha256:1dc3be7ff064995970dc4e39bb49053bb1fb4273ab539a8b6e9754c0f43145ba"}, + {file = "python_crfsuite-0.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:a9949e3738e5de80e21c07c0fc5f865494fb8c6b56387774eba41e47d0b8ce1b"}, + {file = "python_crfsuite-0.9.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0882cb17b6d13ca11e0681fe41ca1efbe761e1d134bf723e0d8242e20565f2a9"}, + {file = "python_crfsuite-0.9.9-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a0004560e7286ee7fdaa840cbad1864932a24540ae5efb7a3d3bbcdae5da638c"}, + {file = "python_crfsuite-0.9.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ab333b7cda21b1b5ab76f8e16e5f00654360df057e2092b273681d64850f714"}, + {file = "python_crfsuite-0.9.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ee6153ed8a26adaea645445b997c55d67505476976dfc40f4bbd46200b66de15"}, + {file = "python_crfsuite-0.9.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c32292f722e293cee87aac0d793abd8b53dc05126e4b6f0202c41e0e7d027005"}, + {file = "python_crfsuite-0.9.9-cp311-cp311-win32.whl", hash = "sha256:3e8bbacff1d86cbc18e1d52f617c85521029127a09c86ed428cd8238384a9db3"}, + {file = "python_crfsuite-0.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:83f87b652108110263aa83c220baccc36c911f04cf422cf6632a5f42121bce6a"}, + {file = "python_crfsuite-0.9.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:92f72eb554dac53218805958747b4cd417bd76039f083f66cc9881987d88e167"}, + {file = "python_crfsuite-0.9.9-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5cdd1e823fa9dfe611a573a0c1371941e887ad8b8ffbc25e2d87e4cd6d4f22af"}, + {file = "python_crfsuite-0.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54f94d6fabb14ad8106dc65b5d38665bb0abb16527d4a6aa2ba233670c3480db"}, + {file = "python_crfsuite-0.9.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:65ebfab892c49c49b5e1030318d144c559e449bdf86b12983fa7ba0e88f7abdf"}, + {file = "python_crfsuite-0.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5c1bb3d2802bb777affbe2855fc723aa32345ead2fe26ad18c7e1417bc104c53"}, + {file = "python_crfsuite-0.9.9-cp312-cp312-win32.whl", hash = "sha256:abef974a2b520c0204cb15b0b799fdbc1c1a0af4be2b9ad7548800de95975345"}, + {file = "python_crfsuite-0.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:ccc4c4f1cd47c74553d03d915c7dd7c06fc23b41310f30f35f2e5c09cdeb9297"}, + {file = "python_crfsuite-0.9.9-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:01d279f1c8225aaf66290563312708c1905dce84f70ee5e374ecfb2dec1c2343"}, + {file = "python_crfsuite-0.9.9-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f29e94fcb9e2f8f52c7323668752b34dcffef91751b0d1e0789ecbbc0069842"}, + {file = "python_crfsuite-0.9.9-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497a8b9c0df152ada10732c07853942a8725cb66fe5b6fe1a64f768ecf583291"}, + {file = "python_crfsuite-0.9.9-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:b5106ac9cb48f53bb5116052d59d9a28f6b5763b6738f861cffe407f163526fa"}, + {file = "python_crfsuite-0.9.9-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:f5c0885d3f85f9aba77ac207b2e935e8fd29801a1bb69a648856731fc25782c0"}, + {file = "python_crfsuite-0.9.9-cp36-cp36m-win32.whl", hash = "sha256:33ab9ff8bf0da71104326ea533a3b49d614a0f1950762d05e22dee14eebfde26"}, + {file = "python_crfsuite-0.9.9-cp36-cp36m-win_amd64.whl", hash = "sha256:82045e7cab9b80b54a9348abdd0b2683bf670a3b127fd5e61193a73628251838"}, + {file = "python_crfsuite-0.9.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:155c99dc3dbda336eac766479c829918ebfe4dc9af7ba55b250f5f7324b9f240"}, + {file = "python_crfsuite-0.9.9-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16af1c9bcf5d482e063d92ebf9210bc907a247fdd8ca3f8a1fee88fcc5b91437"}, + {file = "python_crfsuite-0.9.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:767bab21984de13e24175e8a3a943a054209201c1c9bd3f439150930dffa57fe"}, + {file = "python_crfsuite-0.9.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:d7c200fda5a415c81e02ed9b07ebf44f8d49405ad40a140e9b9df270f16ba212"}, + {file = "python_crfsuite-0.9.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b80f131e150d8beaaeb7e15884e4a565d6db9282db23c094a3a51be42ae848a6"}, + {file = "python_crfsuite-0.9.9-cp37-cp37m-win32.whl", hash = "sha256:97dc37721e132f45074a7bc39edaa036a23e630360108fcf7e24f2a4e1755518"}, + {file = "python_crfsuite-0.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:04f0bf9b57d6868be533d8ef140ce1f07b5d08db19b85999129fb5618307d110"}, + {file = "python_crfsuite-0.9.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fcbd0c31e920a754572b03b9c2a5c4a5a0906adb85b5834a8ed2e75ee4a532e7"}, + {file = "python_crfsuite-0.9.9-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac019d8c7e8fe765742410b0a8394723222f8cced3a90305212939159e204392"}, + {file = "python_crfsuite-0.9.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a22f29d7468f9110de598cca5c9e31ea416345bde9dc522eac9cb642e3cf17"}, + {file = "python_crfsuite-0.9.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5edb803596e419811e6b02dc35cc4f73bf852ddf45ee193f373bc870a08dc3f0"}, + {file = "python_crfsuite-0.9.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fad8949db5266f4c000c4369c556b6c6a0a1524a3fc67c766136c9557848c2c3"}, + {file = "python_crfsuite-0.9.9-cp38-cp38-win32.whl", hash = "sha256:4a8471b32b9f25750b0fd305ed3859a87b017870da07eea51ecb1130f433cc1e"}, + {file = "python_crfsuite-0.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:a1333e9019f23521aa90c68aa3f80f6f42945df427a350577d61c67b767597bc"}, + {file = "python_crfsuite-0.9.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d32c2992adac6607d391232d3859f67d989510ce2d7f6cc04692e7c7ecbded1"}, + {file = "python_crfsuite-0.9.9-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bbf887f0bf4ebfdc5fdf5de36ff06f50d58e49ae95ae74b0028e267360155605"}, + {file = "python_crfsuite-0.9.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd97cb24a273bb9abcd50974c020322838b5d910eccd663e9610aab150557cc3"}, + {file = "python_crfsuite-0.9.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ce6c13779bfbcf96b2407f8fa8956b2ef17487a18220d9d3b9148b791872cfa2"}, + {file = "python_crfsuite-0.9.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92b1f5049eb2a53826f702afaff6ff44c749e69af5a4d9bfad901802992cb57b"}, + {file = "python_crfsuite-0.9.9-cp39-cp39-win32.whl", hash = "sha256:3e93a459af7cf79e11984be2d1d28284df56720d91d8c4c59736b9b840b4a626"}, + {file = "python_crfsuite-0.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:7cc85c02a117f37384181c43e8c096054ba88063fbc3255e12ad6213ed52553e"}, +] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-engineio" +version = "4.5.1" +description = "Engine.IO server and client for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "python-engineio-4.5.1.tar.gz", hash = "sha256:b167a1b208fcdce5dbe96a61a6ca22391cfa6715d796c22de93e3adf9c07ae0c"}, + {file = "python_engineio-4.5.1-py3-none-any.whl", hash = "sha256:67a675569f3e9bb274a8077f3c2068a8fe79cbfcb111cf31ca27b968484fe6c7"}, +] + +[package.extras] +asyncio-client = ["aiohttp (>=3.4)"] +client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] +docs = ["sphinx"] + +[[package]] +name = "python-socketio" +version = "5.8.0" +description = "Socket.IO server and client for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "python-socketio-5.8.0.tar.gz", hash = "sha256:e714f4dddfaaa0cb0e37a1e2deef2bb60590a5b9fea9c343dd8ca5e688416fd9"}, + {file = "python_socketio-5.8.0-py3-none-any.whl", hash = "sha256:7adb8867aac1c2929b9c1429f1c02e12ca4c36b67c807967393e367dfbb01441"}, +] + +[package.dependencies] +bidict = ">=0.21.0" +python-engineio = ">=4.3.0" + +[package.extras] +asyncio-client = ["aiohttp (>=3.4)"] +client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] + +[[package]] +name = "pytz" +version = "2022.7.1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2022.7.1-py2.py3-none-any.whl", hash = "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"}, + {file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"}, +] + +[[package]] +name = "pywin32" +version = "306" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +files = [ + {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, + {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, + {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, + {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, + {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, + {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, + {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, + {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, + {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, + {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, + {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, + {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, + {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, + {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "questionary" +version = "1.10.0" +description = "Python library to build pretty command line user prompts ⭐️" +optional = false +python-versions = ">=3.6,<4.0" +files = [ + {file = "questionary-1.10.0-py3-none-any.whl", hash = "sha256:fecfcc8cca110fda9d561cb83f1e97ecbb93c613ff857f655818839dac74ce90"}, + {file = "questionary-1.10.0.tar.gz", hash = "sha256:600d3aefecce26d48d97eee936fdb66e4bc27f934c3ab6dd1e292c4f43946d90"}, +] + +[package.dependencies] +prompt_toolkit = ">=2.0,<4.0" + +[package.extras] +docs = ["Sphinx (>=3.3,<4.0)", "sphinx-autobuild (>=2020.9.1,<2021.0.0)", "sphinx-autodoc-typehints (>=1.11.1,<2.0.0)", "sphinx-copybutton (>=0.3.1,<0.4.0)", "sphinx-rtd-theme (>=0.5.0,<0.6.0)"] + +[[package]] +name = "randomname" +version = "0.1.5" +description = "Generate random adj-noun names like docker and github." +optional = false +python-versions = "*" +files = [ + {file = "randomname-0.1.5.tar.gz", hash = "sha256:e10d14ea10895ee5bc417bdcc6d955e0b586f3bc67094ab87afcf8dcac23ab92"}, +] + +[package.dependencies] +fire = "*" + +[[package]] +name = "rasa-sdk" +version = "3.6.2" +description = "Open source machine learning framework to automate text- and voice-based conversations: NLU, dialogue management, connect to Slack, Facebook, and more - Create chatbots and voice assistants" +optional = false +python-versions = ">=3.8,<3.11" +files = [ + {file = "rasa_sdk-3.6.2-py3-none-any.whl", hash = "sha256:13dbd7d7f65378d793a171cef913fa785f01b256675dcdba4e97614c385333ae"}, + {file = "rasa_sdk-3.6.2.tar.gz", hash = "sha256:fe69a2eb97dfe8a16e9fd3cca55f86593b1d24b5d8a117de51d152933b4bfeac"}, +] + +[package.dependencies] +coloredlogs = ">=10,<16" +pluggy = ">=1.0.0,<2.0.0" +prompt-toolkit = ">=3.0,<3.0.29" +"ruamel.yaml" = ">=0.16.5,<0.18.0" +sanic = ">=21.12.0,<22.0.0" +Sanic-Cors = ">=2.0.0,<3.0.0" +setuptools = ">=65.5.1" +typing-extensions = ">=4.1.1,<5.0.0" +websockets = ">=10.0,<11.0" +wheel = ">=0.38.1" + +[[package]] +name = "redis" +version = "4.6.0" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.7" +files = [ + {file = "redis-4.6.0-py3-none-any.whl", hash = "sha256:e2b03db868160ee4591de3cb90d40ebb50a90dd302138775937f6a42b7ed183c"}, + {file = "redis-4.6.0.tar.gz", hash = "sha256:585dc516b9eb042a619ef0a39c3d7d55fe81bdb4df09a52c9cdde0d07bf1aa7d"}, +] + +[package.dependencies] +async-timeout = {version = ">=4.0.2", markers = "python_full_version <= \"3.11.2\""} + +[package.extras] +hiredis = ["hiredis (>=1.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] + +[[package]] +name = "regex" +version = "2022.10.31" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.6" +files = [ + {file = "regex-2022.10.31-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a8ff454ef0bb061e37df03557afda9d785c905dab15584860f982e88be73015f"}, + {file = "regex-2022.10.31-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1eba476b1b242620c266edf6325b443a2e22b633217a9835a52d8da2b5c051f9"}, + {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0e5af9a9effb88535a472e19169e09ce750c3d442fb222254a276d77808620b"}, + {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d03fe67b2325cb3f09be029fd5da8df9e6974f0cde2c2ac6a79d2634e791dd57"}, + {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9d0b68ac1743964755ae2d89772c7e6fb0118acd4d0b7464eaf3921c6b49dd4"}, + {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a45b6514861916c429e6059a55cf7db74670eaed2052a648e3e4d04f070e001"}, + {file = "regex-2022.10.31-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8b0886885f7323beea6f552c28bff62cbe0983b9fbb94126531693ea6c5ebb90"}, + {file = "regex-2022.10.31-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5aefb84a301327ad115e9d346c8e2760009131d9d4b4c6b213648d02e2abe144"}, + {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:702d8fc6f25bbf412ee706bd73019da5e44a8400861dfff7ff31eb5b4a1276dc"}, + {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a3c1ebd4ed8e76e886507c9eddb1a891673686c813adf889b864a17fafcf6d66"}, + {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:50921c140561d3db2ab9f5b11c5184846cde686bb5a9dc64cae442926e86f3af"}, + {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:7db345956ecce0c99b97b042b4ca7326feeec6b75facd8390af73b18e2650ffc"}, + {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:763b64853b0a8f4f9cfb41a76a4a85a9bcda7fdda5cb057016e7706fde928e66"}, + {file = "regex-2022.10.31-cp310-cp310-win32.whl", hash = "sha256:44136355e2f5e06bf6b23d337a75386371ba742ffa771440b85bed367c1318d1"}, + {file = "regex-2022.10.31-cp310-cp310-win_amd64.whl", hash = "sha256:bfff48c7bd23c6e2aec6454aaf6edc44444b229e94743b34bdcdda2e35126cf5"}, + {file = "regex-2022.10.31-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b4b1fe58cd102d75ef0552cf17242705ce0759f9695334a56644ad2d83903fe"}, + {file = "regex-2022.10.31-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:542e3e306d1669b25936b64917285cdffcd4f5c6f0247636fec037187bd93542"}, + {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c27cc1e4b197092e50ddbf0118c788d9977f3f8f35bfbbd3e76c1846a3443df7"}, + {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8e38472739028e5f2c3a4aded0ab7eadc447f0d84f310c7a8bb697ec417229e"}, + {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76c598ca73ec73a2f568e2a72ba46c3b6c8690ad9a07092b18e48ceb936e9f0c"}, + {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c28d3309ebd6d6b2cf82969b5179bed5fefe6142c70f354ece94324fa11bf6a1"}, + {file = "regex-2022.10.31-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9af69f6746120998cd9c355e9c3c6aec7dff70d47247188feb4f829502be8ab4"}, + {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a5f9505efd574d1e5b4a76ac9dd92a12acb2b309551e9aa874c13c11caefbe4f"}, + {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5ff525698de226c0ca743bfa71fc6b378cda2ddcf0d22d7c37b1cc925c9650a5"}, + {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:4fe7fda2fe7c8890d454f2cbc91d6c01baf206fbc96d89a80241a02985118c0c"}, + {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:2cdc55ca07b4e70dda898d2ab7150ecf17c990076d3acd7a5f3b25cb23a69f1c"}, + {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:44a6c2f6374e0033873e9ed577a54a3602b4f609867794c1a3ebba65e4c93ee7"}, + {file = "regex-2022.10.31-cp311-cp311-win32.whl", hash = "sha256:d8716f82502997b3d0895d1c64c3b834181b1eaca28f3f6336a71777e437c2af"}, + {file = "regex-2022.10.31-cp311-cp311-win_amd64.whl", hash = "sha256:61edbca89aa3f5ef7ecac8c23d975fe7261c12665f1d90a6b1af527bba86ce61"}, + {file = "regex-2022.10.31-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0a069c8483466806ab94ea9068c34b200b8bfc66b6762f45a831c4baaa9e8cdd"}, + {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d26166acf62f731f50bdd885b04b38828436d74e8e362bfcb8df221d868b5d9b"}, + {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac741bf78b9bb432e2d314439275235f41656e189856b11fb4e774d9f7246d81"}, + {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75f591b2055523fc02a4bbe598aa867df9e953255f0b7f7715d2a36a9c30065c"}, + {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b30bddd61d2a3261f025ad0f9ee2586988c6a00c780a2fb0a92cea2aa702c54"}, + {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef4163770525257876f10e8ece1cf25b71468316f61451ded1a6f44273eedeb5"}, + {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7b280948d00bd3973c1998f92e22aa3ecb76682e3a4255f33e1020bd32adf443"}, + {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:d0213671691e341f6849bf33cd9fad21f7b1cb88b89e024f33370733fec58742"}, + {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:22e7ebc231d28393dfdc19b185d97e14a0f178bedd78e85aad660e93b646604e"}, + {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:8ad241da7fac963d7573cc67a064c57c58766b62a9a20c452ca1f21050868dfa"}, + {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:586b36ebda81e6c1a9c5a5d0bfdc236399ba6595e1397842fd4a45648c30f35e"}, + {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:0653d012b3bf45f194e5e6a41df9258811ac8fc395579fa82958a8b76286bea4"}, + {file = "regex-2022.10.31-cp36-cp36m-win32.whl", hash = "sha256:144486e029793a733e43b2e37df16a16df4ceb62102636ff3db6033994711066"}, + {file = "regex-2022.10.31-cp36-cp36m-win_amd64.whl", hash = "sha256:c14b63c9d7bab795d17392c7c1f9aaabbffd4cf4387725a0ac69109fb3b550c6"}, + {file = "regex-2022.10.31-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4cac3405d8dda8bc6ed499557625585544dd5cbf32072dcc72b5a176cb1271c8"}, + {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23cbb932cc53a86ebde0fb72e7e645f9a5eec1a5af7aa9ce333e46286caef783"}, + {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74bcab50a13960f2a610cdcd066e25f1fd59e23b69637c92ad470784a51b1347"}, + {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78d680ef3e4d405f36f0d6d1ea54e740366f061645930072d39bca16a10d8c93"}, + {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6910b56b700bea7be82c54ddf2e0ed792a577dfaa4a76b9af07d550af435c6"}, + {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:659175b2144d199560d99a8d13b2228b85e6019b6e09e556209dfb8c37b78a11"}, + {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1ddf14031a3882f684b8642cb74eea3af93a2be68893901b2b387c5fd92a03ec"}, + {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b683e5fd7f74fb66e89a1ed16076dbab3f8e9f34c18b1979ded614fe10cdc4d9"}, + {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2bde29cc44fa81c0a0c8686992c3080b37c488df167a371500b2a43ce9f026d1"}, + {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:4919899577ba37f505aaebdf6e7dc812d55e8f097331312db7f1aab18767cce8"}, + {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:9c94f7cc91ab16b36ba5ce476f1904c91d6c92441f01cd61a8e2729442d6fcf5"}, + {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ae1e96785696b543394a4e3f15f3f225d44f3c55dafe3f206493031419fedf95"}, + {file = "regex-2022.10.31-cp37-cp37m-win32.whl", hash = "sha256:c670f4773f2f6f1957ff8a3962c7dd12e4be54d05839b216cb7fd70b5a1df394"}, + {file = "regex-2022.10.31-cp37-cp37m-win_amd64.whl", hash = "sha256:8e0caeff18b96ea90fc0eb6e3bdb2b10ab5b01a95128dfeccb64a7238decf5f0"}, + {file = "regex-2022.10.31-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:131d4be09bea7ce2577f9623e415cab287a3c8e0624f778c1d955ec7c281bd4d"}, + {file = "regex-2022.10.31-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e613a98ead2005c4ce037c7b061f2409a1a4e45099edb0ef3200ee26ed2a69a8"}, + {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:052b670fafbe30966bbe5d025e90b2a491f85dfe5b2583a163b5e60a85a321ad"}, + {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa62a07ac93b7cb6b7d0389d8ef57ffc321d78f60c037b19dfa78d6b17c928ee"}, + {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5352bea8a8f84b89d45ccc503f390a6be77917932b1c98c4cdc3565137acc714"}, + {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20f61c9944f0be2dc2b75689ba409938c14876c19d02f7585af4460b6a21403e"}, + {file = "regex-2022.10.31-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29c04741b9ae13d1e94cf93fca257730b97ce6ea64cfe1eba11cf9ac4e85afb6"}, + {file = "regex-2022.10.31-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:543883e3496c8b6d58bd036c99486c3c8387c2fc01f7a342b760c1ea3158a318"}, + {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7a8b43ee64ca8f4befa2bea4083f7c52c92864d8518244bfa6e88c751fa8fff"}, + {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6a9a19bea8495bb419dc5d38c4519567781cd8d571c72efc6aa959473d10221a"}, + {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6ffd55b5aedc6f25fd8d9f905c9376ca44fcf768673ffb9d160dd6f409bfda73"}, + {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4bdd56ee719a8f751cf5a593476a441c4e56c9b64dc1f0f30902858c4ef8771d"}, + {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8ca88da1bd78990b536c4a7765f719803eb4f8f9971cc22d6ca965c10a7f2c4c"}, + {file = "regex-2022.10.31-cp38-cp38-win32.whl", hash = "sha256:5a260758454580f11dd8743fa98319bb046037dfab4f7828008909d0aa5292bc"}, + {file = "regex-2022.10.31-cp38-cp38-win_amd64.whl", hash = "sha256:5e6a5567078b3eaed93558842346c9d678e116ab0135e22eb72db8325e90b453"}, + {file = "regex-2022.10.31-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5217c25229b6a85049416a5c1e6451e9060a1edcf988641e309dbe3ab26d3e49"}, + {file = "regex-2022.10.31-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4bf41b8b0a80708f7e0384519795e80dcb44d7199a35d52c15cc674d10b3081b"}, + {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cf0da36a212978be2c2e2e2d04bdff46f850108fccc1851332bcae51c8907cc"}, + {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d403d781b0e06d2922435ce3b8d2376579f0c217ae491e273bab8d092727d244"}, + {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a37d51fa9a00d265cf73f3de3930fa9c41548177ba4f0faf76e61d512c774690"}, + {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4f781ffedd17b0b834c8731b75cce2639d5a8afe961c1e58ee7f1f20b3af185"}, + {file = "regex-2022.10.31-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d243b36fbf3d73c25e48014961e83c19c9cc92530516ce3c43050ea6276a2ab7"}, + {file = "regex-2022.10.31-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:370f6e97d02bf2dd20d7468ce4f38e173a124e769762d00beadec3bc2f4b3bc4"}, + {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:597f899f4ed42a38df7b0e46714880fb4e19a25c2f66e5c908805466721760f5"}, + {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7dbdce0c534bbf52274b94768b3498abdf675a691fec5f751b6057b3030f34c1"}, + {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:22960019a842777a9fa5134c2364efaed5fbf9610ddc5c904bd3a400973b0eb8"}, + {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:7f5a3ffc731494f1a57bd91c47dc483a1e10048131ffb52d901bfe2beb6102e8"}, + {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7ef6b5942e6bfc5706301a18a62300c60db9af7f6368042227ccb7eeb22d0892"}, + {file = "regex-2022.10.31-cp39-cp39-win32.whl", hash = "sha256:395161bbdbd04a8333b9ff9763a05e9ceb4fe210e3c7690f5e68cedd3d65d8e1"}, + {file = "regex-2022.10.31-cp39-cp39-win_amd64.whl", hash = "sha256:957403a978e10fb3ca42572a23e6f7badff39aa1ce2f4ade68ee452dc6807692"}, + {file = "regex-2022.10.31.tar.gz", hash = "sha256:a3a98921da9a1bf8457aeee6a551948a83601689e5ecdd736894ea9bbec77e83"}, +] + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "requests-oauthlib" +version = "1.3.1" +description = "OAuthlib authentication support for Requests." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"}, + {file = "requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + +[[package]] +name = "responses" +version = "0.22.0" +description = "A utility library for mocking out the `requests` Python library." +optional = false +python-versions = ">=3.7" +files = [ + {file = "responses-0.22.0-py3-none-any.whl", hash = "sha256:dcf294d204d14c436fddcc74caefdbc5764795a40ff4e6a7740ed8ddbf3294be"}, + {file = "responses-0.22.0.tar.gz", hash = "sha256:396acb2a13d25297789a5866b4881cf4e46ffd49cc26c43ab1117f40b973102e"}, +] + +[package.dependencies] +requests = ">=2.22.0,<3.0" +toml = "*" +types-toml = "*" +urllib3 = ">=1.25.10" + +[package.extras] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "types-requests"] + +[[package]] +name = "rfc3986" +version = "1.5.0" +description = "Validating URI References per RFC 3986" +optional = false +python-versions = "*" +files = [ + {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, + {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, +] + +[package.dependencies] +idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} + +[package.extras] +idna2008 = ["idna"] + +[[package]] +name = "rich" +version = "13.4.2" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.4.2-py3-none-any.whl", hash = "sha256:8f87bc7ee54675732fa66a05ebfe489e27264caeeff3728c945d25971b6485ec"}, + {file = "rich-13.4.2.tar.gz", hash = "sha256:d653d6bccede5844304c605d5aac802c7cf9621efd700b46c7ec2b51ea914898"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "rocketchat-api" +version = "1.30.0" +description = "Python API wrapper for Rocket.Chat" +optional = false +python-versions = "*" +files = [ + {file = "rocketchat_API-1.30.0-py3-none-any.whl", hash = "sha256:58b21b14b2186324e5d0c91b87e928822f55fbd6d3bfc14af308e4db0740fc4d"}, + {file = "rocketchat_API-1.30.0.tar.gz", hash = "sha256:1f521289598c7fd7fa5762adcd90b387b1c9ec9cb969b2ab793ec8010bd7b5fd"}, +] + +[package.dependencies] +packaging = "*" +requests = "*" + +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "ruamel-yaml" +version = "0.17.21" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +optional = false +python-versions = ">=3" +files = [ + {file = "ruamel.yaml-0.17.21-py3-none-any.whl", hash = "sha256:742b35d3d665023981bd6d16b3d24248ce5df75fdb4e2924e93a05c1f8b61ca7"}, + {file = "ruamel.yaml-0.17.21.tar.gz", hash = "sha256:8b7ce697a2f212752a35c1ac414471dc16c424c9573be4926b56ff3f5d23b7af"}, +] + +[package.dependencies] +"ruamel.yaml.clib" = {version = ">=0.2.6", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.11\""} + +[package.extras] +docs = ["ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.7" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +optional = false +python-versions = ">=3.5" +files = [ + {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5859983f26d8cd7bb5c287ef452e8aacc86501487634573d260968f753e1d71"}, + {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:debc87a9516b237d0466a711b18b6ebeb17ba9f391eb7f91c649c5c4ec5006c7"}, + {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:df5828871e6648db72d1c19b4bd24819b80a755c4541d3409f0f7acd0f335c80"}, + {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:efa08d63ef03d079dcae1dfe334f6c8847ba8b645d08df286358b1f5293d24ab"}, + {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-win32.whl", hash = "sha256:763d65baa3b952479c4e972669f679fe490eee058d5aa85da483ebae2009d231"}, + {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:d000f258cf42fec2b1bbf2863c61d7b8918d31ffee905da62dede869254d3b8a"}, + {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:045e0626baf1c52e5527bd5db361bc83180faaba2ff586e763d3d5982a876a9e"}, + {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:1a6391a7cabb7641c32517539ca42cf84b87b667bad38b78d4d42dd23e957c81"}, + {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:9c7617df90c1365638916b98cdd9be833d31d337dbcd722485597b43c4a215bf"}, + {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:41d0f1fa4c6830176eef5b276af04c89320ea616655d01327d5ce65e50575c94"}, + {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-win32.whl", hash = "sha256:f6d3d39611ac2e4f62c3128a9eed45f19a6608670c5a2f4f07f24e8de3441d38"}, + {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:da538167284de58a52109a9b89b8f6a53ff8437dd6dc26d33b57bf6699153122"}, + {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4b3a93bb9bc662fc1f99c5c3ea8e623d8b23ad22f861eb6fce9377ac07ad6072"}, + {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-macosx_12_0_arm64.whl", hash = "sha256:a234a20ae07e8469da311e182e70ef6b199d0fbeb6c6cc2901204dd87fb867e8"}, + {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:15910ef4f3e537eea7fe45f8a5d19997479940d9196f357152a09031c5be59f3"}, + {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:370445fd795706fd291ab00c9df38a0caed0f17a6fb46b0f607668ecb16ce763"}, + {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-win32.whl", hash = "sha256:ecdf1a604009bd35c674b9225a8fa609e0282d9b896c03dd441a91e5f53b534e"}, + {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-win_amd64.whl", hash = "sha256:f34019dced51047d6f70cb9383b2ae2853b7fc4dce65129a5acd49f4f9256646"}, + {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2aa261c29a5545adfef9296b7e33941f46aa5bbd21164228e833412af4c9c75f"}, + {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-macosx_12_0_arm64.whl", hash = "sha256:f01da5790e95815eb5a8a138508c01c758e5f5bc0ce4286c4f7028b8dd7ac3d0"}, + {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:40d030e2329ce5286d6b231b8726959ebbe0404c92f0a578c0e2482182e38282"}, + {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c3ca1fbba4ae962521e5eb66d72998b51f0f4d0f608d3c0347a48e1af262efa7"}, + {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-win32.whl", hash = "sha256:7bdb4c06b063f6fd55e472e201317a3bb6cdeeee5d5a38512ea5c01e1acbdd93"}, + {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:be2a7ad8fd8f7442b24323d24ba0b56c51219513cfa45b9ada3b87b76c374d4b"}, + {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:91a789b4aa0097b78c93e3dc4b40040ba55bef518f84a40d4442f713b4094acb"}, + {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:99e77daab5d13a48a4054803d052ff40780278240a902b880dd37a51ba01a307"}, + {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:3243f48ecd450eddadc2d11b5feb08aca941b5cd98c9b1db14b2fd128be8c697"}, + {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8831a2cedcd0f0927f788c5bdf6567d9dc9cc235646a434986a852af1cb54b4b"}, + {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-win32.whl", hash = "sha256:3110a99e0f94a4a3470ff67fc20d3f96c25b13d24c6980ff841e82bafe827cac"}, + {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:92460ce908546ab69770b2e576e4f99fbb4ce6ab4b245345a3869a0a0410488f"}, + {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5bc0667c1eb8f83a3752b71b9c4ba55ef7c7058ae57022dd9b29065186a113d9"}, + {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:4a4d8d417868d68b979076a9be6a38c676eca060785abaa6709c7b31593c35d1"}, + {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bf9a6bc4a0221538b1a7de3ed7bca4c93c02346853f44e1cd764be0023cd3640"}, + {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a7b301ff08055d73223058b5c46c55638917f04d21577c95e00e0c4d79201a6b"}, + {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-win32.whl", hash = "sha256:d5e51e2901ec2366b79f16c2299a03e74ba4531ddcfacc1416639c557aef0ad8"}, + {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:184faeaec61dbaa3cace407cffc5819f7b977e75360e8d5ca19461cd851a5fc5"}, + {file = "ruamel.yaml.clib-0.2.7.tar.gz", hash = "sha256:1f08fd5a2bea9c4180db71678e850b995d2a5f4537be0e94557668cf0f5f9497"}, +] + +[[package]] +name = "ruff" +version = "0.0.255" +description = "An extremely fast Python linter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.0.255-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:b2d71fb6a7e50501a2473864acffc85dee6b750c25db198f7e71fe1dbbff1aad"}, + {file = "ruff-0.0.255-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:6c97d746861a6010f941179e84bba9feb8a871815667471d9ed6beb98d45c252"}, + {file = "ruff-0.0.255-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a7fa60085079b91a298b963361be9b1b1c724582af6c84be954cbabdbd9309a"}, + {file = "ruff-0.0.255-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c089f7141496334ab5a127b54ce55e41f0d6714e68a4453a1e09d2204cdea8c3"}, + {file = "ruff-0.0.255-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0423908caa7d437a416b853214565b9c33bbd1106c4f88147982216dddcbbd96"}, + {file = "ruff-0.0.255-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:981493e92547cacbb8e0874904ec049fe744507ee890dc8736caf89a8864f9a7"}, + {file = "ruff-0.0.255-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d5193d2aedb35db180824462b374dbcfc306b2e76076245088afa6e5837df2"}, + {file = "ruff-0.0.255-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd5e00733c9d160c8a34a22e62b390da9d1e9f326676402421cb8c1236beefc3"}, + {file = "ruff-0.0.255-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:694418cf41838bd19c6229e4e1b2d04505b1e6b86fe3ab81165484fc96d36f01"}, + {file = "ruff-0.0.255-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5d0408985c9777369daebb5d3340a99e9f7294bdd7120642239261508185cf89"}, + {file = "ruff-0.0.255-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:abd6376ef9d12f370d95a8c7c98682fbb9bfedfba59f40e84a816fef8ddcb8de"}, + {file = "ruff-0.0.255-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f9b1a5df0bc09193cbef58a6f78e4a9a0b058a4f9733c0442866d078006d1bb9"}, + {file = "ruff-0.0.255-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6a25c5f4ff087445b2e1bbcb9963f2ae7c868d65e4a8d5f84c36c12f71571179"}, + {file = "ruff-0.0.255-py3-none-win32.whl", hash = "sha256:1ff87a8310354f9f1a099625e54a27fdd6756d9cd2a40b45922f2e943daf982d"}, + {file = "ruff-0.0.255-py3-none-win_amd64.whl", hash = "sha256:f3d8416be618f023f93ec4fd6ee3048585ef85dba9563b2a7e38fc7e5131d5b1"}, + {file = "ruff-0.0.255-py3-none-win_arm64.whl", hash = "sha256:8ba124819624145d7b6b53add40c367c44318893215ffc1bfe3d72e0225a1c9c"}, + {file = "ruff-0.0.255.tar.gz", hash = "sha256:f9eb1d3b2eecbeedae419fa494c4e2a5e4484baf93a1ce0f81eddb005e1919c5"}, +] + +[[package]] +name = "s3transfer" +version = "0.6.1" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">= 3.7" +files = [ + {file = "s3transfer-0.6.1-py3-none-any.whl", hash = "sha256:3c0da2d074bf35d6870ef157158641178a4204a6e689e82546083e31e0311346"}, + {file = "s3transfer-0.6.1.tar.gz", hash = "sha256:640bb492711f4c0c0905e1f62b6aaeb771881935ad27884852411f8e9cacbca9"}, +] + +[package.dependencies] +botocore = ">=1.12.36,<2.0a.0" + +[package.extras] +crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] + +[[package]] +name = "safetensors" +version = "0.4.5" +description = "" +optional = false +python-versions = ">=3.7" +files = [ + {file = "safetensors-0.4.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a63eaccd22243c67e4f2b1c3e258b257effc4acd78f3b9d397edc8cf8f1298a7"}, + {file = "safetensors-0.4.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:23fc9b4ec7b602915cbb4ec1a7c1ad96d2743c322f20ab709e2c35d1b66dad27"}, + {file = "safetensors-0.4.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6885016f34bef80ea1085b7e99b3c1f92cb1be78a49839203060f67b40aee761"}, + {file = "safetensors-0.4.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:133620f443450429322f238fda74d512c4008621227fccf2f8cf4a76206fea7c"}, + {file = "safetensors-0.4.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4fb3e0609ec12d2a77e882f07cced530b8262027f64b75d399f1504ffec0ba56"}, + {file = "safetensors-0.4.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d0f1dd769f064adc33831f5e97ad07babbd728427f98e3e1db6902e369122737"}, + {file = "safetensors-0.4.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6d156bdb26732feada84f9388a9f135528c1ef5b05fae153da365ad4319c4c5"}, + {file = "safetensors-0.4.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e347d77e2c77eb7624400ccd09bed69d35c0332f417ce8c048d404a096c593b"}, + {file = "safetensors-0.4.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9f556eea3aec1d3d955403159fe2123ddd68e880f83954ee9b4a3f2e15e716b6"}, + {file = "safetensors-0.4.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9483f42be3b6bc8ff77dd67302de8ae411c4db39f7224dec66b0eb95822e4163"}, + {file = "safetensors-0.4.5-cp310-none-win32.whl", hash = "sha256:7389129c03fadd1ccc37fd1ebbc773f2b031483b04700923c3511d2a939252cc"}, + {file = "safetensors-0.4.5-cp310-none-win_amd64.whl", hash = "sha256:e98ef5524f8b6620c8cdef97220c0b6a5c1cef69852fcd2f174bb96c2bb316b1"}, + {file = "safetensors-0.4.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:21f848d7aebd5954f92538552d6d75f7c1b4500f51664078b5b49720d180e47c"}, + {file = "safetensors-0.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bb07000b19d41e35eecef9a454f31a8b4718a185293f0d0b1c4b61d6e4487971"}, + {file = "safetensors-0.4.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09dedf7c2fda934ee68143202acff6e9e8eb0ddeeb4cfc24182bef999efa9f42"}, + {file = "safetensors-0.4.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59b77e4b7a708988d84f26de3ebead61ef1659c73dcbc9946c18f3b1786d2688"}, + {file = "safetensors-0.4.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d3bc83e14d67adc2e9387e511097f254bd1b43c3020440e708858c684cbac68"}, + {file = "safetensors-0.4.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39371fc551c1072976073ab258c3119395294cf49cdc1f8476794627de3130df"}, + {file = "safetensors-0.4.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6c19feda32b931cae0acd42748a670bdf56bee6476a046af20181ad3fee4090"}, + {file = "safetensors-0.4.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a659467495de201e2f282063808a41170448c78bada1e62707b07a27b05e6943"}, + {file = "safetensors-0.4.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bad5e4b2476949bcd638a89f71b6916fa9a5cae5c1ae7eede337aca2100435c0"}, + {file = "safetensors-0.4.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a3a315a6d0054bc6889a17f5668a73f94f7fe55121ff59e0a199e3519c08565f"}, + {file = "safetensors-0.4.5-cp311-none-win32.whl", hash = "sha256:a01e232e6d3d5cf8b1667bc3b657a77bdab73f0743c26c1d3c5dd7ce86bd3a92"}, + {file = "safetensors-0.4.5-cp311-none-win_amd64.whl", hash = "sha256:cbd39cae1ad3e3ef6f63a6f07296b080c951f24cec60188378e43d3713000c04"}, + {file = "safetensors-0.4.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:473300314e026bd1043cef391bb16a8689453363381561b8a3e443870937cc1e"}, + {file = "safetensors-0.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:801183a0f76dc647f51a2d9141ad341f9665602a7899a693207a82fb102cc53e"}, + {file = "safetensors-0.4.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1524b54246e422ad6fb6aea1ac71edeeb77666efa67230e1faf6999df9b2e27f"}, + {file = "safetensors-0.4.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3139098e3e8b2ad7afbca96d30ad29157b50c90861084e69fcb80dec7430461"}, + {file = "safetensors-0.4.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65573dc35be9059770808e276b017256fa30058802c29e1038eb1c00028502ea"}, + {file = "safetensors-0.4.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd33da8e9407559f8779c82a0448e2133737f922d71f884da27184549416bfed"}, + {file = "safetensors-0.4.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3685ce7ed036f916316b567152482b7e959dc754fcc4a8342333d222e05f407c"}, + {file = "safetensors-0.4.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dde2bf390d25f67908278d6f5d59e46211ef98e44108727084d4637ee70ab4f1"}, + {file = "safetensors-0.4.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7469d70d3de970b1698d47c11ebbf296a308702cbaae7fcb993944751cf985f4"}, + {file = "safetensors-0.4.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a6ba28118636a130ccbb968bc33d4684c48678695dba2590169d5ab03a45646"}, + {file = "safetensors-0.4.5-cp312-none-win32.whl", hash = "sha256:c859c7ed90b0047f58ee27751c8e56951452ed36a67afee1b0a87847d065eec6"}, + {file = "safetensors-0.4.5-cp312-none-win_amd64.whl", hash = "sha256:b5a8810ad6a6f933fff6c276eae92c1da217b39b4d8b1bc1c0b8af2d270dc532"}, + {file = "safetensors-0.4.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:25e5f8e2e92a74f05b4ca55686234c32aac19927903792b30ee6d7bd5653d54e"}, + {file = "safetensors-0.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:81efb124b58af39fcd684254c645e35692fea81c51627259cdf6d67ff4458916"}, + {file = "safetensors-0.4.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:585f1703a518b437f5103aa9cf70e9bd437cb78eea9c51024329e4fb8a3e3679"}, + {file = "safetensors-0.4.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b99fbf72e3faf0b2f5f16e5e3458b93b7d0a83984fe8d5364c60aa169f2da89"}, + {file = "safetensors-0.4.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b17b299ca9966ca983ecda1c0791a3f07f9ca6ab5ded8ef3d283fff45f6bcd5f"}, + {file = "safetensors-0.4.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76ded72f69209c9780fdb23ea89e56d35c54ae6abcdec67ccb22af8e696e449a"}, + {file = "safetensors-0.4.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2783956926303dcfeb1de91a4d1204cd4089ab441e622e7caee0642281109db3"}, + {file = "safetensors-0.4.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d94581aab8c6b204def4d7320f07534d6ee34cd4855688004a4354e63b639a35"}, + {file = "safetensors-0.4.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:67e1e7cb8678bb1b37ac48ec0df04faf689e2f4e9e81e566b5c63d9f23748523"}, + {file = "safetensors-0.4.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:dbd280b07e6054ea68b0cb4b16ad9703e7d63cd6890f577cb98acc5354780142"}, + {file = "safetensors-0.4.5-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:77d9b228da8374c7262046a36c1f656ba32a93df6cc51cd4453af932011e77f1"}, + {file = "safetensors-0.4.5-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:500cac01d50b301ab7bb192353317035011c5ceeef0fca652f9f43c000bb7f8d"}, + {file = "safetensors-0.4.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75331c0c746f03158ded32465b7d0b0e24c5a22121743662a2393439c43a45cf"}, + {file = "safetensors-0.4.5-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:670e95fe34e0d591d0529e5e59fd9d3d72bc77b1444fcaa14dccda4f36b5a38b"}, + {file = "safetensors-0.4.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:098923e2574ff237c517d6e840acada8e5b311cb1fa226019105ed82e9c3b62f"}, + {file = "safetensors-0.4.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13ca0902d2648775089fa6a0c8fc9e6390c5f8ee576517d33f9261656f851e3f"}, + {file = "safetensors-0.4.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0032bedc869c56f8d26259fe39cd21c5199cd57f2228d817a0e23e8370af25"}, + {file = "safetensors-0.4.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f4b15f51b4f8f2a512341d9ce3475cacc19c5fdfc5db1f0e19449e75f95c7dc8"}, + {file = "safetensors-0.4.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f6594d130d0ad933d885c6a7b75c5183cb0e8450f799b80a39eae2b8508955eb"}, + {file = "safetensors-0.4.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:60c828a27e852ded2c85fc0f87bf1ec20e464c5cd4d56ff0e0711855cc2e17f8"}, + {file = "safetensors-0.4.5-cp37-none-win32.whl", hash = "sha256:6d3de65718b86c3eeaa8b73a9c3d123f9307a96bbd7be9698e21e76a56443af5"}, + {file = "safetensors-0.4.5-cp37-none-win_amd64.whl", hash = "sha256:5a2d68a523a4cefd791156a4174189a4114cf0bf9c50ceb89f261600f3b2b81a"}, + {file = "safetensors-0.4.5-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:e7a97058f96340850da0601a3309f3d29d6191b0702b2da201e54c6e3e44ccf0"}, + {file = "safetensors-0.4.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:63bfd425e25f5c733f572e2246e08a1c38bd6f2e027d3f7c87e2e43f228d1345"}, + {file = "safetensors-0.4.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3664ac565d0e809b0b929dae7ccd74e4d3273cd0c6d1220c6430035befb678e"}, + {file = "safetensors-0.4.5-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:313514b0b9b73ff4ddfb4edd71860696dbe3c1c9dc4d5cc13dbd74da283d2cbf"}, + {file = "safetensors-0.4.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31fa33ee326f750a2f2134a6174773c281d9a266ccd000bd4686d8021f1f3dac"}, + {file = "safetensors-0.4.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:09566792588d77b68abe53754c9f1308fadd35c9f87be939e22c623eaacbed6b"}, + {file = "safetensors-0.4.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309aaec9b66cbf07ad3a2e5cb8a03205663324fea024ba391594423d0f00d9fe"}, + {file = "safetensors-0.4.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53946c5813b8f9e26103c5efff4a931cc45d874f45229edd68557ffb35ffb9f8"}, + {file = "safetensors-0.4.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:868f9df9e99ad1e7f38c52194063a982bc88fedc7d05096f4f8160403aaf4bd6"}, + {file = "safetensors-0.4.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9cc9449bd0b0bc538bd5e268221f0c5590bc5c14c1934a6ae359d44410dc68c4"}, + {file = "safetensors-0.4.5-cp38-none-win32.whl", hash = "sha256:83c4f13a9e687335c3928f615cd63a37e3f8ef072a3f2a0599fa09f863fb06a2"}, + {file = "safetensors-0.4.5-cp38-none-win_amd64.whl", hash = "sha256:b98d40a2ffa560653f6274e15b27b3544e8e3713a44627ce268f419f35c49478"}, + {file = "safetensors-0.4.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:cf727bb1281d66699bef5683b04d98c894a2803442c490a8d45cd365abfbdeb2"}, + {file = "safetensors-0.4.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96f1d038c827cdc552d97e71f522e1049fef0542be575421f7684756a748e457"}, + {file = "safetensors-0.4.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:139fbee92570ecea774e6344fee908907db79646d00b12c535f66bc78bd5ea2c"}, + {file = "safetensors-0.4.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c36302c1c69eebb383775a89645a32b9d266878fab619819ce660309d6176c9b"}, + {file = "safetensors-0.4.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d641f5b8149ea98deb5ffcf604d764aad1de38a8285f86771ce1abf8e74c4891"}, + {file = "safetensors-0.4.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b4db6a61d968de73722b858038c616a1bebd4a86abe2688e46ca0cc2d17558f2"}, + {file = "safetensors-0.4.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b75a616e02f21b6f1d5785b20cecbab5e2bd3f6358a90e8925b813d557666ec1"}, + {file = "safetensors-0.4.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:788ee7d04cc0e0e7f944c52ff05f52a4415b312f5efd2ee66389fb7685ee030c"}, + {file = "safetensors-0.4.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:87bc42bd04fd9ca31396d3ca0433db0be1411b6b53ac5a32b7845a85d01ffc2e"}, + {file = "safetensors-0.4.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4037676c86365a721a8c9510323a51861d703b399b78a6b4486a54a65a975fca"}, + {file = "safetensors-0.4.5-cp39-none-win32.whl", hash = "sha256:1500418454529d0ed5c1564bda376c4ddff43f30fce9517d9bee7bcce5a8ef50"}, + {file = "safetensors-0.4.5-cp39-none-win_amd64.whl", hash = "sha256:9d1a94b9d793ed8fe35ab6d5cea28d540a46559bafc6aae98f30ee0867000cab"}, + {file = "safetensors-0.4.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:fdadf66b5a22ceb645d5435a0be7a0292ce59648ca1d46b352f13cff3ea80410"}, + {file = "safetensors-0.4.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d42ffd4c2259f31832cb17ff866c111684c87bd930892a1ba53fed28370c918c"}, + {file = "safetensors-0.4.5-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd8a1f6d2063a92cd04145c7fd9e31a1c7d85fbec20113a14b487563fdbc0597"}, + {file = "safetensors-0.4.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:951d2fcf1817f4fb0ef0b48f6696688a4e852a95922a042b3f96aaa67eedc920"}, + {file = "safetensors-0.4.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ac85d9a8c1af0e3132371d9f2d134695a06a96993c2e2f0bbe25debb9e3f67a"}, + {file = "safetensors-0.4.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e3cec4a29eb7fe8da0b1c7988bc3828183080439dd559f720414450de076fcab"}, + {file = "safetensors-0.4.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:21742b391b859e67b26c0b2ac37f52c9c0944a879a25ad2f9f9f3cd61e7fda8f"}, + {file = "safetensors-0.4.5-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c7db3006a4915151ce1913652e907cdede299b974641a83fbc092102ac41b644"}, + {file = "safetensors-0.4.5-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f68bf99ea970960a237f416ea394e266e0361895753df06e3e06e6ea7907d98b"}, + {file = "safetensors-0.4.5-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8158938cf3324172df024da511839d373c40fbfaa83e9abf467174b2910d7b4c"}, + {file = "safetensors-0.4.5-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:540ce6c4bf6b58cb0fd93fa5f143bc0ee341c93bb4f9287ccd92cf898cc1b0dd"}, + {file = "safetensors-0.4.5-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bfeaa1a699c6b9ed514bd15e6a91e74738b71125a9292159e3d6b7f0a53d2cde"}, + {file = "safetensors-0.4.5-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:01c8f00da537af711979e1b42a69a8ec9e1d7112f208e0e9b8a35d2c381085ef"}, + {file = "safetensors-0.4.5-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a0dd565f83b30f2ca79b5d35748d0d99dd4b3454f80e03dfb41f0038e3bdf180"}, + {file = "safetensors-0.4.5-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:023b6e5facda76989f4cba95a861b7e656b87e225f61811065d5c501f78cdb3f"}, + {file = "safetensors-0.4.5-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9633b663393d5796f0b60249549371e392b75a0b955c07e9c6f8708a87fc841f"}, + {file = "safetensors-0.4.5-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78dd8adfb48716233c45f676d6e48534d34b4bceb50162c13d1f0bdf6f78590a"}, + {file = "safetensors-0.4.5-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e8deb16c4321d61ae72533b8451ec4a9af8656d1c61ff81aa49f966406e4b68"}, + {file = "safetensors-0.4.5-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:52452fa5999dc50c4decaf0c53aa28371f7f1e0fe5c2dd9129059fbe1e1599c7"}, + {file = "safetensors-0.4.5-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d5f23198821e227cfc52d50fa989813513db381255c6d100927b012f0cfec63d"}, + {file = "safetensors-0.4.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f4beb84b6073b1247a773141a6331117e35d07134b3bb0383003f39971d414bb"}, + {file = "safetensors-0.4.5-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:68814d599d25ed2fdd045ed54d370d1d03cf35e02dce56de44c651f828fb9b7b"}, + {file = "safetensors-0.4.5-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0b6453c54c57c1781292c46593f8a37254b8b99004c68d6c3ce229688931a22"}, + {file = "safetensors-0.4.5-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adaa9c6dead67e2dd90d634f89131e43162012479d86e25618e821a03d1eb1dc"}, + {file = "safetensors-0.4.5-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73e7d408e9012cd17511b382b43547850969c7979efc2bc353f317abaf23c84c"}, + {file = "safetensors-0.4.5-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:775409ce0fcc58b10773fdb4221ed1eb007de10fe7adbdf8f5e8a56096b6f0bc"}, + {file = "safetensors-0.4.5-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:834001bed193e4440c4a3950a31059523ee5090605c907c66808664c932b549c"}, + {file = "safetensors-0.4.5.tar.gz", hash = "sha256:d73de19682deabb02524b3d5d1f8b3aaba94c72f1bbfc7911b9b9d5d391c0310"}, +] + +[package.extras] +all = ["safetensors[jax]", "safetensors[numpy]", "safetensors[paddlepaddle]", "safetensors[pinned-tf]", "safetensors[quality]", "safetensors[testing]", "safetensors[torch]"] +dev = ["safetensors[all]"] +jax = ["flax (>=0.6.3)", "jax (>=0.3.25)", "jaxlib (>=0.3.25)", "safetensors[numpy]"] +mlx = ["mlx (>=0.0.9)"] +numpy = ["numpy (>=1.21.6)"] +paddlepaddle = ["paddlepaddle (>=2.4.1)", "safetensors[numpy]"] +pinned-tf = ["safetensors[numpy]", "tensorflow (==2.11.0)"] +quality = ["black (==22.3)", "click (==8.0.4)", "flake8 (>=3.8.3)", "isort (>=5.5.4)"] +tensorflow = ["safetensors[numpy]", "tensorflow (>=2.11.0)"] +testing = ["h5py (>=3.7.0)", "huggingface-hub (>=0.12.1)", "hypothesis (>=6.70.2)", "pytest (>=7.2.0)", "pytest-benchmark (>=4.0.0)", "safetensors[numpy]", "setuptools-rust (>=1.5.2)"] +torch = ["safetensors[numpy]", "torch (>=1.10)"] + +[[package]] +name = "sanic" +version = "21.12.2" +description = "A web server and web framework that's written to go fast. Build fast. Run fast." +optional = false +python-versions = ">=3.7" +files = [ + {file = "sanic-21.12.2-py3-none-any.whl", hash = "sha256:ef4edddfba46f2f8728400470f84deb91d9e5fc21cf2531512acf70638699620"}, + {file = "sanic-21.12.2.tar.gz", hash = "sha256:c426e15aac6984860c6d1221329be17e02e2dfed4ce0abf8532ab096026ee5e3"}, +] + +[package.dependencies] +aiofiles = ">=0.6.0" +httptools = ">=0.0.10" +multidict = ">=5.0,<6.0" +sanic-routing = ">=0.7,<1.0" +ujson = {version = ">=1.35", markers = "sys_platform != \"win32\" and implementation_name == \"cpython\""} +uvloop = {version = ">=0.5.3", markers = "sys_platform != \"win32\" and implementation_name == \"cpython\""} +websockets = ">=10.0" + +[package.extras] +all = ["bandit", "beautifulsoup4", "black", "chardet (==3.*)", "coverage (==5.3)", "cryptography", "docutils", "flake8", "gunicorn (==20.0.4)", "isort (>=5.0.0)", "m2r2", "mistune (<2.0.0)", "mypy (>=0.901,<0.910)", "pygments", "pytest (==6.2.5)", "pytest-benchmark", "pytest-cov", "pytest-sanic", "pytest-sugar", "sanic-testing (>=0.7.0)", "sphinx (>=2.1.2)", "sphinx-rtd-theme (>=0.4.3)", "towncrier", "tox", "types-ujson", "uvicorn (<0.15.0)"] +dev = ["bandit", "beautifulsoup4", "black", "chardet (==3.*)", "coverage (==5.3)", "cryptography", "docutils", "flake8", "gunicorn (==20.0.4)", "isort (>=5.0.0)", "mypy (>=0.901,<0.910)", "pygments", "pytest (==6.2.5)", "pytest-benchmark", "pytest-cov", "pytest-sanic", "pytest-sugar", "sanic-testing (>=0.7.0)", "towncrier", "tox", "types-ujson", "uvicorn (<0.15.0)"] +docs = ["docutils", "m2r2", "mistune (<2.0.0)", "pygments", "sphinx (>=2.1.2)", "sphinx-rtd-theme (>=0.4.3)"] +ext = ["sanic-ext"] +test = ["bandit", "beautifulsoup4", "black", "chardet (==3.*)", "coverage (==5.3)", "docutils", "flake8", "gunicorn (==20.0.4)", "isort (>=5.0.0)", "mypy (>=0.901,<0.910)", "pygments", "pytest (==6.2.5)", "pytest-benchmark", "pytest-cov", "pytest-sanic", "pytest-sugar", "sanic-testing (>=0.7.0)", "types-ujson", "uvicorn (<0.15.0)"] + +[[package]] +name = "sanic-cors" +version = "2.0.1" +description = "A Sanic extension adding a decorator for CORS support. Based on flask-cors by Cory Dolphin." +optional = false +python-versions = "*" +files = [ + {file = "Sanic-Cors-2.0.1.tar.gz", hash = "sha256:4d2f26333d49db428217814c66e89fc3df20fc62a5ab518a71fa22e2e249e19d"}, + {file = "Sanic_Cors-2.0.1-py2.py3-none-any.whl", hash = "sha256:0c8132bed394ba86f93c03bef52787183652d96b70add4ea13c25eb98f344343"}, +] + +[package.dependencies] +sanic = ">=21.9.3" + +[[package]] +name = "sanic-jwt" +version = "1.8.0" +description = "JWT oauth flow for Sanic" +optional = false +python-versions = "*" +files = [ + {file = "sanic-jwt-1.8.0.tar.gz", hash = "sha256:ae16cdaebc8cd9569dae6f633a2a876205d7b36134e5920698ae5b8e4f1b83f1"}, + {file = "sanic_jwt-1.8.0-py3-none-any.whl", hash = "sha256:5bd2a748d1bef25a330c38b574804c5ba543808f46ffaef3068001dd54b4f9c7"}, +] + +[package.dependencies] +pyjwt = ">=2.1.0,<3.0.0" + +[package.extras] +all = ["Jinja2 (<3.1)", "Sphinx"] +docs = ["Jinja2 (<3.1)", "Sphinx"] + +[[package]] +name = "sanic-routing" +version = "0.7.2" +description = "Core routing component for Sanic" +optional = false +python-versions = "*" +files = [ + {file = "sanic-routing-0.7.2.tar.gz", hash = "sha256:139ce88b3f054e7aa336e2ecc8459837092b103b275d3a97609a34092c55374d"}, + {file = "sanic_routing-0.7.2-py3-none-any.whl", hash = "sha256:523034ffd07aca056040e08de438269c9a880722eee1ace3a32e4f74b394d9aa"}, +] + +[[package]] +name = "sanic-testing" +version = "22.6.0" +description = "Core testing clients for Sanic" +optional = false +python-versions = "*" +files = [ + {file = "sanic-testing-22.6.0.tar.gz", hash = "sha256:8f006d2332106539cd6f3da8a5c0d1f31472261f3293e43e2c9bbad605e72c5b"}, + {file = "sanic_testing-22.6.0-py3-none-any.whl", hash = "sha256:d84303e83066de7f18e8c3a0cd04512ba1517dbc31123f14e8aec318b22c008c"}, +] + +[package.dependencies] +httpx = ">=0.18,<0.24" + +[[package]] +name = "scikit-learn" +version = "1.1.3" +description = "A set of python modules for machine learning and data mining" +optional = false +python-versions = ">=3.8" +files = [ + {file = "scikit-learn-1.1.3.tar.gz", hash = "sha256:bef51978a51ec19977700fe7b86aecea49c825884f3811756b74a3b152bb4e35"}, + {file = "scikit_learn-1.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8e9dd76c7274055d1acf4526b8efb16a3531c26dcda714a0c16da99bf9d41900"}, + {file = "scikit_learn-1.1.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ee47f68d973cee7009f06edb956f2f5588a0f230f24a2a70175fd0ecf36e2653"}, + {file = "scikit_learn-1.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da5a2e95fef9805b1750e4abda4e834bf8835d26fc709a391543b53feee7bd0e"}, + {file = "scikit_learn-1.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:701181792a28c82fecae12adb5d15d0ecf57bffab7cf4bdbb52c7b3fd428d540"}, + {file = "scikit_learn-1.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:30e27721adc308e8fd9f419f43068e43490005f911edf4476a9e585059fa8a83"}, + {file = "scikit_learn-1.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5699cded6c0685426433c7e5afe0fecad80ec831ec7fa264940e50c796775cc5"}, + {file = "scikit_learn-1.1.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:2ee2c649f2231b68511aabb0dc827edd8936aad682acc6263c34aed11bc95dac"}, + {file = "scikit_learn-1.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d1c1394e38a3319ace620381f6f23cc807d8780e9915c152449a86fc8f1db21"}, + {file = "scikit_learn-1.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:250da993701da88bf475e7c5746abf1285ea0ae47e4d0917cd13afd6600bb162"}, + {file = "scikit_learn-1.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:fd3ee69d36d42a7dcbb17e355a5653af5fd241a7dfd9133080b3dde8d9e2aafb"}, + {file = "scikit_learn-1.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f5644663987ee221f5d1f47a593271b966c271c236fe05634e6bdc06041b5a2b"}, + {file = "scikit_learn-1.1.3-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:748f2bd632d6993e8918d43f1a26c380aeda4e122a88840d4c3a9af99d4239fe"}, + {file = "scikit_learn-1.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd55c6fbef7608dbce1f22baf289dfcc6eb323247daa3c3542f73d389c724786"}, + {file = "scikit_learn-1.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38814f66285318f2e241305cca545eaa9b4126c65aa5dd78c69371f235f78e2b"}, + {file = "scikit_learn-1.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:f4931f2a6c06e02c6c17a05f8ae397e2545965bc7a0a6cb38c8cd7d4fba8624d"}, + {file = "scikit_learn-1.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6785b8a3093329bf90ac01801be5525551728ae73edb11baa175df660820add4"}, + {file = "scikit_learn-1.1.3-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:28b2bd6a1419acd522ff45d282c8ba23dbccb5338802ab0ee12baa4ade0aba4c"}, + {file = "scikit_learn-1.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23fb9e74b813cc2528b5167d82ed08950b11106ccf50297161875e45152fb311"}, + {file = "scikit_learn-1.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5d4231af7199531e77da1b78a4cc6b3d960a00b1ec672578ac818aae2b9c35d"}, + {file = "scikit_learn-1.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:4d3a19166d4e1cdfcab975c68f471e046ce01e74c42a9a33fa89a14c2fcedf60"}, +] + +[package.dependencies] +joblib = ">=1.0.0" +numpy = ">=1.17.3" +scipy = ">=1.3.2" +threadpoolctl = ">=2.0.0" + +[package.extras] +benchmark = ["matplotlib (>=3.1.2)", "memory-profiler (>=0.57.0)", "pandas (>=1.0.5)"] +docs = ["Pillow (>=7.1.2)", "matplotlib (>=3.1.2)", "memory-profiler (>=0.57.0)", "numpydoc (>=1.2.0)", "pandas (>=1.0.5)", "scikit-image (>=0.16.2)", "seaborn (>=0.9.0)", "sphinx (>=4.0.1)", "sphinx-gallery (>=0.7.0)", "sphinx-prompt (>=1.3.0)", "sphinxext-opengraph (>=0.4.2)"] +examples = ["matplotlib (>=3.1.2)", "pandas (>=1.0.5)", "scikit-image (>=0.16.2)", "seaborn (>=0.9.0)"] +tests = ["black (>=22.3.0)", "flake8 (>=3.8.2)", "matplotlib (>=3.1.2)", "mypy (>=0.961)", "numpydoc (>=1.2.0)", "pandas (>=1.0.5)", "pyamg (>=4.0.0)", "pytest (>=5.0.1)", "pytest-cov (>=2.9.0)", "scikit-image (>=0.16.2)"] + +[[package]] +name = "scipy" +version = "1.10.1" +description = "Fundamental algorithms for scientific computing in Python" +optional = false +python-versions = "<3.12,>=3.8" +files = [ + {file = "scipy-1.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e7354fd7527a4b0377ce55f286805b34e8c54b91be865bac273f527e1b839019"}, + {file = "scipy-1.10.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:4b3f429188c66603a1a5c549fb414e4d3bdc2a24792e061ffbd607d3d75fd84e"}, + {file = "scipy-1.10.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1553b5dcddd64ba9a0d95355e63fe6c3fc303a8fd77c7bc91e77d61363f7433f"}, + {file = "scipy-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c0ff64b06b10e35215abce517252b375e580a6125fd5fdf6421b98efbefb2d2"}, + {file = "scipy-1.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:fae8a7b898c42dffe3f7361c40d5952b6bf32d10c4569098d276b4c547905ee1"}, + {file = "scipy-1.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f1564ea217e82c1bbe75ddf7285ba0709ecd503f048cb1236ae9995f64217bd"}, + {file = "scipy-1.10.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:d925fa1c81b772882aa55bcc10bf88324dadb66ff85d548c71515f6689c6dac5"}, + {file = "scipy-1.10.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaea0a6be54462ec027de54fca511540980d1e9eea68b2d5c1dbfe084797be35"}, + {file = "scipy-1.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15a35c4242ec5f292c3dd364a7c71a61be87a3d4ddcc693372813c0b73c9af1d"}, + {file = "scipy-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:43b8e0bcb877faf0abfb613d51026cd5cc78918e9530e375727bf0625c82788f"}, + {file = "scipy-1.10.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5678f88c68ea866ed9ebe3a989091088553ba12c6090244fdae3e467b1139c35"}, + {file = "scipy-1.10.1-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:39becb03541f9e58243f4197584286e339029e8908c46f7221abeea4b749fa88"}, + {file = "scipy-1.10.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bce5869c8d68cf383ce240e44c1d9ae7c06078a9396df68ce88a1230f93a30c1"}, + {file = "scipy-1.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07c3457ce0b3ad5124f98a86533106b643dd811dd61b548e78cf4c8786652f6f"}, + {file = "scipy-1.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:049a8bbf0ad95277ffba9b3b7d23e5369cc39e66406d60422c8cfef40ccc8415"}, + {file = "scipy-1.10.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cd9f1027ff30d90618914a64ca9b1a77a431159df0e2a195d8a9e8a04c78abf9"}, + {file = "scipy-1.10.1-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:79c8e5a6c6ffaf3a2262ef1be1e108a035cf4f05c14df56057b64acc5bebffb6"}, + {file = "scipy-1.10.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51af417a000d2dbe1ec6c372dfe688e041a7084da4fdd350aeb139bd3fb55353"}, + {file = "scipy-1.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b4735d6c28aad3cdcf52117e0e91d6b39acd4272f3f5cd9907c24ee931ad601"}, + {file = "scipy-1.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:7ff7f37b1bf4417baca958d254e8e2875d0cc23aaadbe65b3d5b3077b0eb23ea"}, + {file = "scipy-1.10.1.tar.gz", hash = "sha256:2cf9dfb80a7b4589ba4c40ce7588986d6d5cebc5457cad2c2880f6bc2d42f3a5"}, +] + +[package.dependencies] +numpy = ">=1.19.5,<1.27.0" + +[package.extras] +dev = ["click", "doit (>=0.36.0)", "flake8", "mypy", "pycodestyle", "pydevtool", "rich-click", "typing_extensions"] +doc = ["matplotlib (>2)", "numpydoc", "pydata-sphinx-theme (==0.9.0)", "sphinx (!=4.1.0)", "sphinx-design (>=0.2.0)"] +test = ["asv", "gmpy2", "mpmath", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] + +[[package]] +name = "sentencepiece" +version = "0.1.99" +description = "SentencePiece python wrapper" +optional = true +python-versions = "*" +files = [ + {file = "sentencepiece-0.1.99-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0eb528e70571b7c02723e5804322469b82fe7ea418c96051d0286c0fa028db73"}, + {file = "sentencepiece-0.1.99-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:77d7fafb2c4e4659cbdf303929503f37a26eabc4ff31d3a79bf1c5a1b338caa7"}, + {file = "sentencepiece-0.1.99-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:be9cf5b9e404c245aeb3d3723c737ba7a8f5d4ba262ef233a431fa6c45f732a0"}, + {file = "sentencepiece-0.1.99-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baed1a26464998f9710d20e52607c29ffd4293e7c71c6a1f83f51ad0911ec12c"}, + {file = "sentencepiece-0.1.99-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9832f08bb372d4c8b567612f8eab9e36e268dff645f1c28f9f8e851be705f6d1"}, + {file = "sentencepiece-0.1.99-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:019e7535108e309dae2b253a75834fc3128240aa87c00eb80732078cdc182588"}, + {file = "sentencepiece-0.1.99-cp310-cp310-win32.whl", hash = "sha256:fa16a830416bb823fa2a52cbdd474d1f7f3bba527fd2304fb4b140dad31bb9bc"}, + {file = "sentencepiece-0.1.99-cp310-cp310-win_amd64.whl", hash = "sha256:14b0eccb7b641d4591c3e12ae44cab537d68352e4d3b6424944f0c447d2348d5"}, + {file = "sentencepiece-0.1.99-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6d3c56f24183a1e8bd61043ff2c58dfecdc68a5dd8955dc13bab83afd5f76b81"}, + {file = "sentencepiece-0.1.99-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ed6ea1819fd612c989999e44a51bf556d0ef6abfb553080b9be3d347e18bcfb7"}, + {file = "sentencepiece-0.1.99-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2a0260cd1fb7bd8b4d4f39dc2444a8d5fd4e0a0c4d5c899810ef1abf99b2d45"}, + {file = "sentencepiece-0.1.99-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a1abff4d1ff81c77cac3cc6fefa34fa4b8b371e5ee51cb7e8d1ebc996d05983"}, + {file = "sentencepiece-0.1.99-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:004e6a621d4bc88978eecb6ea7959264239a17b70f2cbc348033d8195c9808ec"}, + {file = "sentencepiece-0.1.99-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db361e03342c41680afae5807590bc88aa0e17cfd1a42696a160e4005fcda03b"}, + {file = "sentencepiece-0.1.99-cp311-cp311-win32.whl", hash = "sha256:2d95e19168875b70df62916eb55428a0cbcb834ac51d5a7e664eda74def9e1e0"}, + {file = "sentencepiece-0.1.99-cp311-cp311-win_amd64.whl", hash = "sha256:f90d73a6f81248a909f55d8e6ef56fec32d559e1e9af045f0b0322637cb8e5c7"}, + {file = "sentencepiece-0.1.99-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:62e24c81e74bd87a6e0d63c51beb6527e4c0add67e1a17bac18bcd2076afcfeb"}, + {file = "sentencepiece-0.1.99-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57efcc2d51caff20d9573567d9fd3f854d9efe613ed58a439c78c9f93101384a"}, + {file = "sentencepiece-0.1.99-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a904c46197993bd1e95b93a6e373dca2f170379d64441041e2e628ad4afb16f"}, + {file = "sentencepiece-0.1.99-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d89adf59854741c0d465f0e1525b388c0d174f611cc04af54153c5c4f36088c4"}, + {file = "sentencepiece-0.1.99-cp36-cp36m-win32.whl", hash = "sha256:47c378146928690d1bc106fdf0da768cebd03b65dd8405aa3dd88f9c81e35dba"}, + {file = "sentencepiece-0.1.99-cp36-cp36m-win_amd64.whl", hash = "sha256:9ba142e7a90dd6d823c44f9870abdad45e6c63958eb60fe44cca6828d3b69da2"}, + {file = "sentencepiece-0.1.99-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b7b1a9ae4d7c6f1f867e63370cca25cc17b6f4886729595b885ee07a58d3cec3"}, + {file = "sentencepiece-0.1.99-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0f644c9d4d35c096a538507b2163e6191512460035bf51358794a78515b74f7"}, + {file = "sentencepiece-0.1.99-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8843d23a0f686d85e569bd6dcd0dd0e0cbc03731e63497ca6d5bacd18df8b85"}, + {file = "sentencepiece-0.1.99-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e6f690a1caebb4867a2e367afa1918ad35be257ecdb3455d2bbd787936f155"}, + {file = "sentencepiece-0.1.99-cp37-cp37m-win32.whl", hash = "sha256:8a321866c2f85da7beac74a824b4ad6ddc2a4c9bccd9382529506d48f744a12c"}, + {file = "sentencepiece-0.1.99-cp37-cp37m-win_amd64.whl", hash = "sha256:c42f753bcfb7661c122a15b20be7f684b61fc8592c89c870adf52382ea72262d"}, + {file = "sentencepiece-0.1.99-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:85b476406da69c70586f0bb682fcca4c9b40e5059814f2db92303ea4585c650c"}, + {file = "sentencepiece-0.1.99-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cfbcfe13c69d3f87b7fcd5da168df7290a6d006329be71f90ba4f56bc77f8561"}, + {file = "sentencepiece-0.1.99-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:445b0ec381af1cd4eef95243e7180c63d9c384443c16c4c47a28196bd1cda937"}, + {file = "sentencepiece-0.1.99-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6890ea0f2b4703f62d0bf27932e35808b1f679bdb05c7eeb3812b935ba02001"}, + {file = "sentencepiece-0.1.99-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb71af492b0eefbf9f2501bec97bcd043b6812ab000d119eaf4bd33f9e283d03"}, + {file = "sentencepiece-0.1.99-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b866b5bd3ddd54166bbcbf5c8d7dd2e0b397fac8537991c7f544220b1f67bc"}, + {file = "sentencepiece-0.1.99-cp38-cp38-win32.whl", hash = "sha256:b133e8a499eac49c581c3c76e9bdd08c338cc1939e441fee6f92c0ccb5f1f8be"}, + {file = "sentencepiece-0.1.99-cp38-cp38-win_amd64.whl", hash = "sha256:0eaf3591dd0690a87f44f4df129cf8d05d8a4029b5b6709b489b8e27f9a9bcff"}, + {file = "sentencepiece-0.1.99-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38efeda9bbfb55052d482a009c6a37e52f42ebffcea9d3a98a61de7aee356a28"}, + {file = "sentencepiece-0.1.99-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6c030b081dc1e1bcc9fadc314b19b740715d3d566ad73a482da20d7d46fd444c"}, + {file = "sentencepiece-0.1.99-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:84dbe53e02e4f8a2e45d2ac3e430d5c83182142658e25edd76539b7648928727"}, + {file = "sentencepiece-0.1.99-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b0f55d0a0ee1719b4b04221fe0c9f0c3461dc3dabd77a035fa2f4788eb3ef9a"}, + {file = "sentencepiece-0.1.99-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18e800f206cd235dc27dc749299e05853a4e4332e8d3dfd81bf13d0e5b9007d9"}, + {file = "sentencepiece-0.1.99-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ae1c40cda8f9d5b0423cfa98542735c0235e7597d79caf318855cdf971b2280"}, + {file = "sentencepiece-0.1.99-cp39-cp39-win32.whl", hash = "sha256:c84ce33af12ca222d14a1cdd37bd76a69401e32bc68fe61c67ef6b59402f4ab8"}, + {file = "sentencepiece-0.1.99-cp39-cp39-win_amd64.whl", hash = "sha256:350e5c74d739973f1c9643edb80f7cc904dc948578bcb1d43c6f2b173e5d18dd"}, + {file = "sentencepiece-0.1.99.tar.gz", hash = "sha256:189c48f5cb2949288f97ccdb97f0473098d9c3dcf5a3d99d4eabe719ec27297f"}, +] + +[[package]] +name = "sentinels" +version = "1.0.0" +description = "Various objects to denote special meanings in python" +optional = false +python-versions = "*" +files = [ + {file = "sentinels-1.0.0.tar.gz", hash = "sha256:7be0704d7fe1925e397e92d18669ace2f619c92b5d4eb21a89f31e026f9ff4b1"}, +] + +[[package]] +name = "sentry-sdk" +version = "1.14.0" +description = "Python client for Sentry (https://sentry.io)" +optional = false +python-versions = "*" +files = [ + {file = "sentry-sdk-1.14.0.tar.gz", hash = "sha256:273fe05adf052b40fd19f6d4b9a5556316807246bd817e5e3482930730726bb0"}, + {file = "sentry_sdk-1.14.0-py2.py3-none-any.whl", hash = "sha256:72c00322217d813cf493fe76590b23a757e063ff62fec59299f4af7201dd4448"}, +] + +[package.dependencies] +certifi = "*" +urllib3 = {version = ">=1.26.11", markers = "python_version >= \"3.6\""} + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +beam = ["apache-beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +chalice = ["chalice (>=1.16.0)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +fastapi = ["fastapi (>=0.79.0)"] +flask = ["blinker (>=1.1)", "flask (>=0.11)"] +httpx = ["httpx (>=0.16.0)"] +opentelemetry = ["opentelemetry-distro (>=0.35b0)"] +pure-eval = ["asttokens", "executing", "pure-eval"] +pymongo = ["pymongo (>=3.1)"] +pyspark = ["pyspark (>=2.4.4)"] +quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] +rq = ["rq (>=0.6)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +starlette = ["starlette (>=0.19.1)"] +starlite = ["starlite (>=1.48)"] +tornado = ["tornado (>=5)"] + +[[package]] +name = "setuptools" +version = "70.3.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc"}, + {file = "setuptools-70.3.0.tar.gz", hash = "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5"}, +] + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "sklearn-crfsuite" +version = "0.3.6" +description = "CRFsuite (python-crfsuite) wrapper which provides interface simlar to scikit-learn" +optional = false +python-versions = "*" +files = [ + {file = "sklearn-crfsuite-0.3.6.tar.gz", hash = "sha256:2f59aad3055e01a778a79a6352891cac04788e8b52688aa5bc8b11be7717861e"}, + {file = "sklearn_crfsuite-0.3.6-py2.py3-none-any.whl", hash = "sha256:6e9a42bc3de96941d5f7262335130955b8c380b1356147622368f385075705d9"}, +] + +[package.dependencies] +python-crfsuite = ">=0.8.3" +six = "*" +tabulate = "*" +tqdm = ">=2.0" + +[[package]] +name = "skops" +version = "0.9.0" +description = "A set of tools to push scikit-learn based models to and pull from Hugging Face Hub" +optional = false +python-versions = ">=3.8" +files = [ + {file = "skops-0.9.0-py3-none-any.whl", hash = "sha256:05645199bf6976e1f6dbba4a0704799cd5d2fcef18a98b069b4c84744e1a80a1"}, + {file = "skops-0.9.0.tar.gz", hash = "sha256:3e39333d65f26d5863ad44db5001b4cfe6a29642274ac37af54fb834813aee3f"}, +] + +[package.dependencies] +huggingface-hub = ">=0.17.0" +packaging = ">=17.0" +scikit-learn = ">=0.24" +tabulate = ">=0.8.8" + +[package.extras] +docs = ["fairlearn (>=0.7.0)", "matplotlib (>=3.3)", "numpydoc (>=1.0.0)", "pandas (>=1)", "scikit-learn-intelex (>=2021.7.1)", "sphinx (>=3.2.0)", "sphinx-gallery (>=0.7.0)", "sphinx-issues (>=1.2.0)", "sphinx-prompt (>=1.3.0)", "sphinx-rtd-theme (>=1)"] +rich = ["rich (>=12)"] +tests = ["catboost (>=1.0)", "fairlearn (>=0.7.0)", "flake8 (>=3.8.2)", "flaky (>=3.7.0)", "lightgbm (>=3)", "matplotlib (>=3.3)", "pandas (>=1)", "pytest (>=5.0.1)", "pytest-cov (>=2.9.0)", "quantile-forest (>=1.0.0)", "rich (>=12)", "types-requests (>=2.28.5)", "xgboost (>=1.6)"] + +[[package]] +name = "slack-sdk" +version = "3.21.3" +description = "The Slack API Platform SDK for Python" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "slack_sdk-3.21.3-py2.py3-none-any.whl", hash = "sha256:de3c07b92479940b61cd68c566f49fbc9974c8f38f661d26244078f3903bb9cc"}, + {file = "slack_sdk-3.21.3.tar.gz", hash = "sha256:20829bdc1a423ec93dac903470975ebf3bc76fd3fd91a4dadc0eeffc940ecb0c"}, +] + +[package.extras] +optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=10,<11)"] +testing = ["Flask (>=1,<2)", "Flask-Sockets (>=0.2,<1)", "Jinja2 (==3.0.3)", "Werkzeug (<2)", "black (==22.8.0)", "boto3 (<=2)", "click (==8.0.4)", "databases (>=0.5)", "flake8 (>=5,<6)", "itsdangerous (==1.1.0)", "moto (>=3,<4)", "psutil (>=5,<6)", "pytest (>=6.2.5,<7)", "pytest-asyncio (<1)", "pytest-cov (>=2,<3)"] + +[[package]] +name = "smart-open" +version = "6.3.0" +description = "Utils for streaming large files (S3, HDFS, GCS, Azure Blob Storage, gzip, bz2...)" +optional = true +python-versions = ">=3.6,<4.0" +files = [ + {file = "smart_open-6.3.0-py3-none-any.whl", hash = "sha256:b4c9ae193ad6d3e7add50944b86afa0d150bd821ab8ec21edb26d9a06b66f6a8"}, + {file = "smart_open-6.3.0.tar.gz", hash = "sha256:d5238825fe9a9340645fac3d75b287c08fbb99fb2b422477de781c9f5f09e019"}, +] + +[package.extras] +all = ["azure-common", "azure-core", "azure-storage-blob", "boto3", "google-cloud-storage (>=2.6.0)", "paramiko", "requests"] +azure = ["azure-common", "azure-core", "azure-storage-blob"] +gcs = ["google-cloud-storage (>=2.6.0)"] +http = ["requests"] +s3 = ["boto3"] +ssh = ["paramiko"] +test = ["azure-common", "azure-core", "azure-storage-blob", "boto3", "google-cloud-storage (>=2.6.0)", "moto[server]", "paramiko", "pytest", "pytest-rerunfailures", "requests", "responses"] +webhdfs = ["requests"] + +[[package]] +name = "smmap" +version = "5.0.0" +description = "A pure Python implementation of a sliding window memory map manager" +optional = false +python-versions = ">=3.6" +files = [ + {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, + {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, +] + +[[package]] +name = "sniffio" +version = "1.3.0" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + +[[package]] +name = "spacy" +version = "3.4.4" +description = "Industrial-strength Natural Language Processing (NLP) in Python" +optional = true +python-versions = ">=3.6" +files = [ + {file = "spacy-3.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:07a10999a3e37f896758a92c2eed263638bcbf2747dc3a4aeea929aaa20ea28c"}, + {file = "spacy-3.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e6d98511dc8a88d3a96bcae13971a284459362076738c85053d1a3791f6cde92"}, + {file = "spacy-3.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2cad9c5543f03b3375c252e4dd45670ee8ed99c925dca15eadab5084fd1b033"}, + {file = "spacy-3.4.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ade19c1e676cac2546f268db22bc5eba08d12beafabe80f1b9f06028b3a0b52"}, + {file = "spacy-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:e782c8a7c4805cc1b34ed2b11f72a5cf2b9851e20f7afe3e97caf206f19f761b"}, + {file = "spacy-3.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa027e69ef9fe42c8b02b940872e5bde0ce1bf66b6bf488c6493e3ce660c4b3a"}, + {file = "spacy-3.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddeb5d725b6fa9c9009b1ff645db8f5caab9ed8956ee3a84b8379951caad1d36"}, + {file = "spacy-3.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29d6bb428a6bb19e026d8bbb9d4385c25b21e1ce51fcaabadfb5599b2390a79c"}, + {file = "spacy-3.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21187ad4c44e166dc3deed23992ea1a74d731c9a6bdd9fca306d455181577fa"}, + {file = "spacy-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:10643c6d335a02805f6676738a3e992323cfd9438115cc253435e5053dc93824"}, + {file = "spacy-3.4.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:486228cfa7ced18ec99008388028bd2329262ab8108e7c19252c1a67b2801909"}, + {file = "spacy-3.4.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcb7a213178c298b95532075d6dddfb374bbe56ef8d2687212763b4583048da2"}, + {file = "spacy-3.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:15e5c41d408d1d30d8f3dd8e4eed9ed28e6174e011b8d61c1345981562e2e8f5"}, + {file = "spacy-3.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8979dbd3594c5c268cedad53f456a3ec3a0a2b78a1199788aacedcd68eef3a00"}, + {file = "spacy-3.4.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f4736fea2630e696422dfe38bfb3d0a7864bc6a9072d6e49a906af46870e36e"}, + {file = "spacy-3.4.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:498bf01e8c7ab601c3f8d6c51497817b40a3322a3967c032536b18ce9ea26d0a"}, + {file = "spacy-3.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:95f880c6fea57d51c448ad84f96d79d8758e5e18bdbaaee060c15af11641079b"}, + {file = "spacy-3.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9ccbede9be470c5d795168bf3be41fc86e18892a9247a742b394ba866c005391"}, + {file = "spacy-3.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2f1edbecfde9c11b17e87768bb5f2c33948fb1e3bf54b2197031ff9053607277"}, + {file = "spacy-3.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:66eaf4764e95699934cbd8f38717b283db185c896cfd3d1fb1ad5c6552e8b3c9"}, + {file = "spacy-3.4.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0bb7d53f1a780bb8cc1b27a81e02e8b9bc71abb959f4dc13c21af4041fdd2c7a"}, + {file = "spacy-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:c1a5ce5c9b19cdfb4469079e710e72bb09c3cab855f21ef6a614b84c765e0311"}, + {file = "spacy-3.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7044dca3542579ea1e3ac6cdd821640c2f65dd0c56230688f36e15aca1b8217"}, + {file = "spacy-3.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8a495b0fc00910fb5c1fbe64fdbfe1d3c11b09f421d1ae4e30cdb4c2388a91e4"}, + {file = "spacy-3.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31e9a637960b60c1bb7a36a187271425717e97c14e9d1df613dc4efeffefcbec"}, + {file = "spacy-3.4.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f9449ffadef85b048c9735ee235da5dca9d0a87038dba6d4ed20c5188e0f13"}, + {file = "spacy-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:1b7791a6c0592615b0566001596cc48c72325d1b97e46e574c91bff34f4e3f4c"}, + {file = "spacy-3.4.4.tar.gz", hash = "sha256:e500cf2cb5f1849461a7928fa269703756069bdfb71559065240af6d0208b08c"}, +] + +[package.dependencies] +catalogue = ">=2.0.6,<2.1.0" +cymem = ">=2.0.2,<2.1.0" +jinja2 = "*" +langcodes = ">=3.2.0,<4.0.0" +murmurhash = ">=0.28.0,<1.1.0" +numpy = ">=1.15.0" +packaging = ">=20.0" +pathy = ">=0.3.5" +preshed = ">=3.0.2,<3.1.0" +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<1.11.0" +requests = ">=2.13.0,<3.0.0" +setuptools = "*" +smart-open = ">=5.2.1,<7.0.0" +spacy-legacy = ">=3.0.10,<3.1.0" +spacy-loggers = ">=1.0.0,<2.0.0" +srsly = ">=2.4.3,<3.0.0" +thinc = ">=8.1.0,<8.2.0" +tqdm = ">=4.38.0,<5.0.0" +typer = ">=0.3.0,<0.8.0" +wasabi = ">=0.9.1,<1.1.0" + +[package.extras] +apple = ["thinc-apple-ops (>=0.1.0.dev0,<1.0.0)"] +cuda = ["cupy (>=5.0.0b4,<12.0.0)"] +cuda-autodetect = ["cupy-wheel (>=11.0.0,<12.0.0)"] +cuda100 = ["cupy-cuda100 (>=5.0.0b4,<12.0.0)"] +cuda101 = ["cupy-cuda101 (>=5.0.0b4,<12.0.0)"] +cuda102 = ["cupy-cuda102 (>=5.0.0b4,<12.0.0)"] +cuda110 = ["cupy-cuda110 (>=5.0.0b4,<12.0.0)"] +cuda111 = ["cupy-cuda111 (>=5.0.0b4,<12.0.0)"] +cuda112 = ["cupy-cuda112 (>=5.0.0b4,<12.0.0)"] +cuda113 = ["cupy-cuda113 (>=5.0.0b4,<12.0.0)"] +cuda114 = ["cupy-cuda114 (>=5.0.0b4,<12.0.0)"] +cuda115 = ["cupy-cuda115 (>=5.0.0b4,<12.0.0)"] +cuda116 = ["cupy-cuda116 (>=5.0.0b4,<12.0.0)"] +cuda117 = ["cupy-cuda117 (>=5.0.0b4,<12.0.0)"] +cuda11x = ["cupy-cuda11x (>=11.0.0,<12.0.0)"] +cuda80 = ["cupy-cuda80 (>=5.0.0b4,<12.0.0)"] +cuda90 = ["cupy-cuda90 (>=5.0.0b4,<12.0.0)"] +cuda91 = ["cupy-cuda91 (>=5.0.0b4,<12.0.0)"] +cuda92 = ["cupy-cuda92 (>=5.0.0b4,<12.0.0)"] +ja = ["sudachidict-core (>=20211220)", "sudachipy (>=0.5.2,!=0.6.1)"] +ko = ["natto-py (>=0.9.0)"] +lookups = ["spacy-lookups-data (>=1.0.3,<1.1.0)"] +ray = ["spacy-ray (>=0.1.0,<1.0.0)"] +th = ["pythainlp (>=2.0)"] +transformers = ["spacy-transformers (>=1.1.2,<1.2.0)"] + +[[package]] +name = "spacy" +version = "3.5.4" +description = "Industrial-strength Natural Language Processing (NLP) in Python" +optional = true +python-versions = ">=3.6" +files = [ + {file = "spacy-3.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39209f73508027a99ddf2a615ae99ceb6db84f9f10c0050c7dc0c78cd8d662e9"}, + {file = "spacy-3.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:abc2e347fa2217c97c602a591cd4202f3bea546e3beafe2b92dd4d2984b68299"}, + {file = "spacy-3.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d97294c588fcd05d0c644303dd54c8aa437bfd895b1c5e57f51ac0af8304181"}, + {file = "spacy-3.5.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e7992c6424fd28187064ee32c98998db6194d65e017e958993dd16f6953c1c1"}, + {file = "spacy-3.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:64cac9da114a2b98794a40e20ff2f8547dec01d44660c8d0dd64b2a5b32bf929"}, + {file = "spacy-3.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2796778a91f2d690864124a98f2fa4d3a82db6585244137d9283b4fbce21ef89"}, + {file = "spacy-3.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97aea4aceb7d8a5a4183bad59957d6154d95e80d0b8a25690305fe5d4a8b8cb6"}, + {file = "spacy-3.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2aeb5f25ffb469c7c1f93a730c8810efe69ce65bb60318ae0e65b5106108df0c"}, + {file = "spacy-3.5.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0f7166d8f20c6332d0ed89a1bc32b3030f223c178cc26597b094190c853a7ed"}, + {file = "spacy-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:35dec614492c849f6c6b29dc0a424502dc193f6775d4f55573ad7d8f55e06561"}, + {file = "spacy-3.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0240874ed34d9e00df68cdbc3f1ca3741232233dc1194f24c18f73ae7dac7644"}, + {file = "spacy-3.5.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d1eb72163c8e8cb070bdafcfb8fb3c88f50a5b688500e8ef788fb4fb79e9997"}, + {file = "spacy-3.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:a4c7ba041aaffc9ecd0a3f9dff86f392939045221315f52e3044fe1453fc5d48"}, + {file = "spacy-3.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:61ab38c6732be402063f55b8b004b451b17dd20ccad966ab3abce9738e3859e4"}, + {file = "spacy-3.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b49807f1c47430f02365e7b0f25d2bddaaa917430e3dc3fbf0d60e0bffd5a06e"}, + {file = "spacy-3.5.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b59bdd41b372c52b639c6bb3b2e4d37cc5e6175b1d187f25c33a6b56c1d3d08c"}, + {file = "spacy-3.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ab802c2e06ba14556ea4c160309a8369fad4bd847895e341e8b0bfe7c0e1bfcf"}, + {file = "spacy-3.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:406d09abc7c061ce1f461311557495608e25be5fc405f6a840e14a9a044f84bd"}, + {file = "spacy-3.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0e9e0f9d95c6fbdc25f38e6d3bdad7d85723bcc8854333cc5f906d9a4db2b76a"}, + {file = "spacy-3.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1476db25cff811a43a19b79d12ce5b2a38dcbdc378fb9923f66aeb31c7f528c8"}, + {file = "spacy-3.5.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fff8986c3b9aa9b5a99a1ad57e842985f71b450102d1e102d4ac951f595688c"}, + {file = "spacy-3.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:d9b0d87f50a8e7592da2a7480956abd418ac143327b1c56244eca3c226c7332e"}, + {file = "spacy-3.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abf05e7f64c9136602ec7cec54ff616c79dd89634ded5575587c619da9367db9"}, + {file = "spacy-3.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c270d2b37e6896b7959d493e56ed4d37146d7eec732253c91f07379685c08dd6"}, + {file = "spacy-3.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af50c9838bf2ffa80397fb20f02127b0b66f1b26dcdcee86185292199c803041"}, + {file = "spacy-3.5.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed28a237c57f95a36b891d3b60773b8efb81f6c470f48fea7e4ec71adb8b85a5"}, + {file = "spacy-3.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:ad83768225e0ab2ee259ff5c1c759adb5c76649fb343ebd3bd777a3ec3742004"}, + {file = "spacy-3.5.4.tar.gz", hash = "sha256:9a9c167e9dcebfefacc75dac34a8e72becbe348eb45bbf06a6c0523ae05ac425"}, +] + +[package.dependencies] +catalogue = ">=2.0.6,<2.1.0" +cymem = ">=2.0.2,<2.1.0" +jinja2 = "*" +langcodes = ">=3.2.0,<4.0.0" +murmurhash = ">=0.28.0,<1.1.0" +numpy = ">=1.15.0" +packaging = ">=20.0" +pathy = ">=0.10.0" +preshed = ">=3.0.2,<3.1.0" +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<1.11.0" +requests = ">=2.13.0,<3.0.0" +setuptools = "*" +smart-open = ">=5.2.1,<7.0.0" +spacy-legacy = ">=3.0.11,<3.1.0" +spacy-loggers = ">=1.0.0,<2.0.0" +srsly = ">=2.4.3,<3.0.0" +thinc = ">=8.1.8,<8.2.0" +tqdm = ">=4.38.0,<5.0.0" +typer = ">=0.3.0,<0.10.0" +wasabi = ">=0.9.1,<1.2.0" + +[package.extras] +apple = ["thinc-apple-ops (>=0.1.0.dev0,<1.0.0)"] +cuda = ["cupy (>=5.0.0b4,<13.0.0)"] +cuda-autodetect = ["cupy-wheel (>=11.0.0,<13.0.0)"] +cuda100 = ["cupy-cuda100 (>=5.0.0b4,<13.0.0)"] +cuda101 = ["cupy-cuda101 (>=5.0.0b4,<13.0.0)"] +cuda102 = ["cupy-cuda102 (>=5.0.0b4,<13.0.0)"] +cuda110 = ["cupy-cuda110 (>=5.0.0b4,<13.0.0)"] +cuda111 = ["cupy-cuda111 (>=5.0.0b4,<13.0.0)"] +cuda112 = ["cupy-cuda112 (>=5.0.0b4,<13.0.0)"] +cuda113 = ["cupy-cuda113 (>=5.0.0b4,<13.0.0)"] +cuda114 = ["cupy-cuda114 (>=5.0.0b4,<13.0.0)"] +cuda115 = ["cupy-cuda115 (>=5.0.0b4,<13.0.0)"] +cuda116 = ["cupy-cuda116 (>=5.0.0b4,<13.0.0)"] +cuda117 = ["cupy-cuda117 (>=5.0.0b4,<13.0.0)"] +cuda11x = ["cupy-cuda11x (>=11.0.0,<13.0.0)"] +cuda80 = ["cupy-cuda80 (>=5.0.0b4,<13.0.0)"] +cuda90 = ["cupy-cuda90 (>=5.0.0b4,<13.0.0)"] +cuda91 = ["cupy-cuda91 (>=5.0.0b4,<13.0.0)"] +cuda92 = ["cupy-cuda92 (>=5.0.0b4,<13.0.0)"] +ja = ["sudachidict-core (>=20211220)", "sudachipy (>=0.5.2,!=0.6.1)"] +ko = ["natto-py (>=0.9.0)"] +lookups = ["spacy-lookups-data (>=1.0.3,<1.1.0)"] +ray = ["spacy-ray (>=0.1.0,<1.0.0)"] +th = ["pythainlp (>=2.0)"] +transformers = ["spacy-transformers (>=1.1.2,<1.3.0)"] + +[[package]] +name = "spacy-legacy" +version = "3.0.12" +description = "Legacy registered functions for spaCy backwards compatibility" +optional = true +python-versions = ">=3.6" +files = [ + {file = "spacy-legacy-3.0.12.tar.gz", hash = "sha256:b37d6e0c9b6e1d7ca1cf5bc7152ab64a4c4671f59c85adaf7a3fcb870357a774"}, + {file = "spacy_legacy-3.0.12-py2.py3-none-any.whl", hash = "sha256:476e3bd0d05f8c339ed60f40986c07387c0a71479245d6d0f4298dbd52cda55f"}, +] + +[[package]] +name = "spacy-loggers" +version = "1.0.4" +description = "Logging utilities for SpaCy" +optional = true +python-versions = ">=3.6" +files = [ + {file = "spacy-loggers-1.0.4.tar.gz", hash = "sha256:e6f983bf71230091d5bb7b11bf64bd54415eca839108d5f83d9155d0ba93bf28"}, + {file = "spacy_loggers-1.0.4-py3-none-any.whl", hash = "sha256:e050bf2e63208b2f096b777e494971c962ad7c1dc997641c8f95c622550044ae"}, +] + +[[package]] +name = "sqlalchemy" +version = "1.4.49" +description = "Database Abstraction Library" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "SQLAlchemy-1.4.49-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2e126cf98b7fd38f1e33c64484406b78e937b1a280e078ef558b95bf5b6895f6"}, + {file = "SQLAlchemy-1.4.49-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:03db81b89fe7ef3857b4a00b63dedd632d6183d4ea5a31c5d8a92e000a41fc71"}, + {file = "SQLAlchemy-1.4.49-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:95b9df9afd680b7a3b13b38adf6e3a38995da5e162cc7524ef08e3be4e5ed3e1"}, + {file = "SQLAlchemy-1.4.49-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a63e43bf3f668c11bb0444ce6e809c1227b8f067ca1068898f3008a273f52b09"}, + {file = "SQLAlchemy-1.4.49-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca46de16650d143a928d10842939dab208e8d8c3a9a8757600cae9b7c579c5cd"}, + {file = "SQLAlchemy-1.4.49-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f835c050ebaa4e48b18403bed2c0fda986525896efd76c245bdd4db995e51a4c"}, + {file = "SQLAlchemy-1.4.49-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c21b172dfb22e0db303ff6419451f0cac891d2e911bb9fbf8003d717f1bcf91"}, + {file = "SQLAlchemy-1.4.49-cp310-cp310-win32.whl", hash = "sha256:5fb1ebdfc8373b5a291485757bd6431de8d7ed42c27439f543c81f6c8febd729"}, + {file = "SQLAlchemy-1.4.49-cp310-cp310-win_amd64.whl", hash = "sha256:f8a65990c9c490f4651b5c02abccc9f113a7f56fa482031ac8cb88b70bc8ccaa"}, + {file = "SQLAlchemy-1.4.49-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8923dfdf24d5aa8a3adb59723f54118dd4fe62cf59ed0d0d65d940579c1170a4"}, + {file = "SQLAlchemy-1.4.49-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9ab2c507a7a439f13ca4499db6d3f50423d1d65dc9b5ed897e70941d9e135b0"}, + {file = "SQLAlchemy-1.4.49-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5debe7d49b8acf1f3035317e63d9ec8d5e4d904c6e75a2a9246a119f5f2fdf3d"}, + {file = "SQLAlchemy-1.4.49-cp311-cp311-win32.whl", hash = "sha256:82b08e82da3756765c2e75f327b9bf6b0f043c9c3925fb95fb51e1567fa4ee87"}, + {file = "SQLAlchemy-1.4.49-cp311-cp311-win_amd64.whl", hash = "sha256:171e04eeb5d1c0d96a544caf982621a1711d078dbc5c96f11d6469169bd003f1"}, + {file = "SQLAlchemy-1.4.49-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f23755c384c2969ca2f7667a83f7c5648fcf8b62a3f2bbd883d805454964a800"}, + {file = "SQLAlchemy-1.4.49-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8396e896e08e37032e87e7fbf4a15f431aa878c286dc7f79e616c2feacdb366c"}, + {file = "SQLAlchemy-1.4.49-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66da9627cfcc43bbdebd47bfe0145bb662041472393c03b7802253993b6b7c90"}, + {file = "SQLAlchemy-1.4.49-cp312-cp312-win32.whl", hash = "sha256:9a06e046ffeb8a484279e54bda0a5abfd9675f594a2e38ef3133d7e4d75b6214"}, + {file = "SQLAlchemy-1.4.49-cp312-cp312-win_amd64.whl", hash = "sha256:7cf8b90ad84ad3a45098b1c9f56f2b161601e4670827d6b892ea0e884569bd1d"}, + {file = "SQLAlchemy-1.4.49-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:36e58f8c4fe43984384e3fbe6341ac99b6b4e083de2fe838f0fdb91cebe9e9cb"}, + {file = "SQLAlchemy-1.4.49-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b31e67ff419013f99ad6f8fc73ee19ea31585e1e9fe773744c0f3ce58c039c30"}, + {file = "SQLAlchemy-1.4.49-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebc22807a7e161c0d8f3da34018ab7c97ef6223578fcdd99b1d3e7ed1100a5db"}, + {file = "SQLAlchemy-1.4.49-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c14b29d9e1529f99efd550cd04dbb6db6ba5d690abb96d52de2bff4ed518bc95"}, + {file = "SQLAlchemy-1.4.49-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c40f3470e084d31247aea228aa1c39bbc0904c2b9ccbf5d3cfa2ea2dac06f26d"}, + {file = "SQLAlchemy-1.4.49-cp36-cp36m-win32.whl", hash = "sha256:706bfa02157b97c136547c406f263e4c6274a7b061b3eb9742915dd774bbc264"}, + {file = "SQLAlchemy-1.4.49-cp36-cp36m-win_amd64.whl", hash = "sha256:a7f7b5c07ae5c0cfd24c2db86071fb2a3d947da7bd487e359cc91e67ac1c6d2e"}, + {file = "SQLAlchemy-1.4.49-cp37-cp37m-macosx_11_0_x86_64.whl", hash = "sha256:4afbbf5ef41ac18e02c8dc1f86c04b22b7a2125f2a030e25bbb4aff31abb224b"}, + {file = "SQLAlchemy-1.4.49-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24e300c0c2147484a002b175f4e1361f102e82c345bf263242f0449672a4bccf"}, + {file = "SQLAlchemy-1.4.49-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:393cd06c3b00b57f5421e2133e088df9cabcececcea180327e43b937b5a7caa5"}, + {file = "SQLAlchemy-1.4.49-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:201de072b818f8ad55c80d18d1a788729cccf9be6d9dc3b9d8613b053cd4836d"}, + {file = "SQLAlchemy-1.4.49-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7653ed6817c710d0c95558232aba799307d14ae084cc9b1f4c389157ec50df5c"}, + {file = "SQLAlchemy-1.4.49-cp37-cp37m-win32.whl", hash = "sha256:647e0b309cb4512b1f1b78471fdaf72921b6fa6e750b9f891e09c6e2f0e5326f"}, + {file = "SQLAlchemy-1.4.49-cp37-cp37m-win_amd64.whl", hash = "sha256:ab73ed1a05ff539afc4a7f8cf371764cdf79768ecb7d2ec691e3ff89abbc541e"}, + {file = "SQLAlchemy-1.4.49-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:37ce517c011560d68f1ffb28af65d7e06f873f191eb3a73af5671e9c3fada08a"}, + {file = "SQLAlchemy-1.4.49-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1878ce508edea4a879015ab5215546c444233881301e97ca16fe251e89f1c55"}, + {file = "SQLAlchemy-1.4.49-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95ab792ca493891d7a45a077e35b418f68435efb3e1706cb8155e20e86a9013c"}, + {file = "SQLAlchemy-1.4.49-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0e8e608983e6f85d0852ca61f97e521b62e67969e6e640fe6c6b575d4db68557"}, + {file = "SQLAlchemy-1.4.49-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ccf956da45290df6e809ea12c54c02ace7f8ff4d765d6d3dfb3655ee876ce58d"}, + {file = "SQLAlchemy-1.4.49-cp38-cp38-win32.whl", hash = "sha256:f167c8175ab908ce48bd6550679cc6ea20ae169379e73c7720a28f89e53aa532"}, + {file = "SQLAlchemy-1.4.49-cp38-cp38-win_amd64.whl", hash = "sha256:45806315aae81a0c202752558f0df52b42d11dd7ba0097bf71e253b4215f34f4"}, + {file = "SQLAlchemy-1.4.49-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:b6d0c4b15d65087738a6e22e0ff461b407533ff65a73b818089efc8eb2b3e1de"}, + {file = "SQLAlchemy-1.4.49-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a843e34abfd4c797018fd8d00ffffa99fd5184c421f190b6ca99def4087689bd"}, + {file = "SQLAlchemy-1.4.49-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:738d7321212941ab19ba2acf02a68b8ee64987b248ffa2101630e8fccb549e0d"}, + {file = "SQLAlchemy-1.4.49-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1c890421651b45a681181301b3497e4d57c0d01dc001e10438a40e9a9c25ee77"}, + {file = "SQLAlchemy-1.4.49-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d26f280b8f0a8f497bc10573849ad6dc62e671d2468826e5c748d04ed9e670d5"}, + {file = "SQLAlchemy-1.4.49-cp39-cp39-win32.whl", hash = "sha256:ec2268de67f73b43320383947e74700e95c6770d0c68c4e615e9897e46296294"}, + {file = "SQLAlchemy-1.4.49-cp39-cp39-win_amd64.whl", hash = "sha256:bbdf16372859b8ed3f4d05f925a984771cd2abd18bd187042f24be4886c2a15f"}, + {file = "SQLAlchemy-1.4.49.tar.gz", hash = "sha256:06ff25cbae30c396c4b7737464f2a7fc37a67b7da409993b182b024cec80aed9"}, +] + +[package.dependencies] +greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} + +[package.extras] +aiomysql = ["aiomysql", "greenlet (!=0.4.17)"] +aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing-extensions (!=3.10.0.1)"] +asyncio = ["greenlet (!=0.4.17)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4)", "greenlet (!=0.4.17)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)", "sqlalchemy2-stubs"] +mysql = ["mysqlclient (>=1.4.0)", "mysqlclient (>=1.4.0,<2)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx-oracle (>=7)", "cx-oracle (>=7,<8)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] +postgresql-pg8000 = ["pg8000 (>=1.16.6,!=1.29.0)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +pymysql = ["pymysql", "pymysql (<1)"] +sqlcipher = ["sqlcipher3-binary"] + +[[package]] +name = "srsly" +version = "2.4.6" +description = "Modern high-performance serialization utilities for Python" +optional = true +python-versions = ">=3.6" +files = [ + {file = "srsly-2.4.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b96569976420be2ac3716db9ac05b06bf4cd7a358953879ba34f03c9533c123"}, + {file = "srsly-2.4.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5a9833c0a870e17c67a9452ed107b3ec033fa5b7addead51af5977fdafd32452"}, + {file = "srsly-2.4.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0355d57e89382bc0852d30b000f1d04f0bf1da2a739f60f0427a00b6ea1cd146"}, + {file = "srsly-2.4.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2261ef76f6b8d4029b9d2fc4a65ac505a760d2ea1de0132fc4b423883f7df52e"}, + {file = "srsly-2.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:02ab79c59e4b0eba4ba44d64b4aeccb5df1379270f3970dc7e30f1eef6cd3851"}, + {file = "srsly-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73acd407c66fa943bbaa8d473c30ea548b31ba4079b51483eda65df94910b61f"}, + {file = "srsly-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99131465fea74aa5e80dbba6effad10ae661bee2c3fbc1fd6da8a1e954e031d0"}, + {file = "srsly-2.4.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4a0152f766930aa41f45bf571b7f6e99206a02810d964cc7bcbd81685e3b624"}, + {file = "srsly-2.4.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9742e5f4205c5484cea925ff24b1bd850f1e9291bd0ada6dfe1ec2b715e732b5"}, + {file = "srsly-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:73ce7c532fecbd8d7ab946fd2b5fa1d767d554526e330e55d7704bcf522c9f66"}, + {file = "srsly-2.4.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5c5074628249385640f4fe4ac237fd93631a023938476ea258139d12abb17f9"}, + {file = "srsly-2.4.6-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12b9e6d5a87c64e1d4a4a43fd6c94f98b5c48076c569804072e5fe45f1703c32"}, + {file = "srsly-2.4.6-cp36-cp36m-win_amd64.whl", hash = "sha256:bac2b2fa1f315c8a50e7807002a064e892be21c95735334f39d2ec104c00a8f0"}, + {file = "srsly-2.4.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3a6811eb797101b549fece201c03ba794ed731e9e2d58b81ea56eb3219ed2c8e"}, + {file = "srsly-2.4.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03477c648b76571a5ab0723423fc03ada74e747c4354357feef92c098853246f"}, + {file = "srsly-2.4.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fb1426313af7c560c728fbe8c3cc8e7cc443f5aa489b04a26adc73645214b91"}, + {file = "srsly-2.4.6-cp37-cp37m-win_amd64.whl", hash = "sha256:f1fb1ca8e2415bfd9ce1e3d8612dbbd85dd06c574a0a96a0223265c382950b5a"}, + {file = "srsly-2.4.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:02b0708878f6a1e344284ae7c65b36a9ad8178eeff71583cd212d2d379f0e2ce"}, + {file = "srsly-2.4.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:720715be0efb9646ab64850185ecd22fe6ace93027d02f6367bdc8842450b369"}, + {file = "srsly-2.4.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1da8ac70f994644069451b4ab5fe5d2649218871409ab89f8421e79b0eace76b"}, + {file = "srsly-2.4.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7dc1c3877618d67a44ec74830510cd72d54fcfb32339388f2c6cbd559d27d20e"}, + {file = "srsly-2.4.6-cp38-cp38-win_amd64.whl", hash = "sha256:0ca1b1065edeca0cbc4a75ef15e915189bfd4b87c8256d542ec662168dd17627"}, + {file = "srsly-2.4.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0522d9aeaf58c6d58ee0cec247653a460545422d3266b2d970df7af1530f3dcc"}, + {file = "srsly-2.4.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52e3a0a760fb7723c74e566d0c064da78e5707d65d8f69b1d3c2e05b72e3efb2"}, + {file = "srsly-2.4.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da8d393ac59cba12b92c27c53550417200601d0f2a9aa50c1559cf5ce9cb9473"}, + {file = "srsly-2.4.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e5725f18a76971fc00e788a254bc2da6e119d69d491a811a6d387de77b72ca2"}, + {file = "srsly-2.4.6-cp39-cp39-win_amd64.whl", hash = "sha256:52a3b4d2949d9b7623b459054526bc3df04cbd9a8243c4786f13e3c956faf251"}, + {file = "srsly-2.4.6.tar.gz", hash = "sha256:47b41f323aba4c9c3311abf60e443c03a9efe9c69f65dc402d173c32f7744a6f"}, +] + +[package.dependencies] +catalogue = ">=2.0.3,<2.1.0" + +[[package]] +name = "stevedore" +version = "5.1.0" +description = "Manage dynamic plugins for Python applications" +optional = false +python-versions = ">=3.8" +files = [ + {file = "stevedore-5.1.0-py3-none-any.whl", hash = "sha256:8cc040628f3cea5d7128f2e76cf486b2251a4e543c7b938f58d9a377f6694a2d"}, + {file = "stevedore-5.1.0.tar.gz", hash = "sha256:a54534acf9b89bc7ed264807013b505bf07f74dbe4bcfa37d32bd063870b087c"}, +] + +[package.dependencies] +pbr = ">=2.0.0,<2.1.0 || >2.1.0" + +[[package]] +name = "structlog" +version = "23.1.0" +description = "Structured Logging for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "structlog-23.1.0-py3-none-any.whl", hash = "sha256:79b9e68e48b54e373441e130fa447944e6f87a05b35de23138e475c05d0f7e0e"}, + {file = "structlog-23.1.0.tar.gz", hash = "sha256:270d681dd7d163c11ba500bc914b2472d2b50a8ef00faa999ded5ff83a2f906b"}, +] + +[package.extras] +dev = ["structlog[docs,tests,typing]"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-mermaid", "twisted"] +tests = ["coverage[toml]", "freezegun (>=0.2.8)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "simplejson"] +typing = ["mypy", "rich", "twisted"] + +[[package]] +name = "structlog-sentry" +version = "2.0.3" +description = "Sentry integration for structlog" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "structlog_sentry-2.0.3-py3-none-any.whl", hash = "sha256:2c83585ab92c97111682f78edf65d3c9a0e2bfa9b2c68f2e59fcdfe1c725fd1b"}, + {file = "structlog_sentry-2.0.3.tar.gz", hash = "sha256:894a48df2121f2797d0b6a28b158c3fee9be7447d41b7645db739696fd64be42"}, +] + +[package.dependencies] +sentry-sdk = "*" +structlog = "*" + +[[package]] +name = "tabulate" +version = "0.9.0" +description = "Pretty-print tabular data" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"}, + {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"}, +] + +[package.extras] +widechars = ["wcwidth"] + +[[package]] +name = "tarsafe" +version = "0.0.4" +description = "A safe subclass of the TarFile class for interacting with tar files. Can be used as a direct drop-in replacement for safe usage of extractall()" +optional = false +python-versions = ">=3.6" +files = [ + {file = "tarsafe-0.0.4-py3-none-any.whl", hash = "sha256:12903a81f2612c09d22117115301ea510944af5caa1e358636e0fc1d0e6134df"}, + {file = "tarsafe-0.0.4.tar.gz", hash = "sha256:a376f4138005298c11c30cb60a5081fa2c09f44384c966106fbaeee3059e9ec5"}, +] + +[[package]] +name = "tensorboard" +version = "2.12.3" +description = "TensorBoard lets you watch Tensors Flow" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tensorboard-2.12.3-py3-none-any.whl", hash = "sha256:b4a69366784bc347e02fbe7d847e01896a649ca52f8948a11005e205dcf724fb"}, +] + +[package.dependencies] +absl-py = ">=0.4" +google-auth = ">=1.6.3,<3" +google-auth-oauthlib = ">=0.5,<1.1" +grpcio = ">=1.48.2" +markdown = ">=2.6.8" +numpy = ">=1.12.0" +protobuf = ">=3.19.6" +requests = ">=2.21.0,<3" +setuptools = ">=41.0.0" +tensorboard-data-server = ">=0.7.0,<0.8.0" +werkzeug = ">=1.0.1" +wheel = ">=0.26" + +[[package]] +name = "tensorboard-data-server" +version = "0.7.1" +description = "Fast data loading for TensorBoard" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tensorboard_data_server-0.7.1-py3-none-any.whl", hash = "sha256:9938bd39f5041797b33921066fba0eab03a0dd10d1887a05e62ae58841ad4c3f"}, + {file = "tensorboard_data_server-0.7.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:be8d016a1aa394e6198280d4a3dc37898f56467310c5f5e617cac10a783e055a"}, + {file = "tensorboard_data_server-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:255c02b7f5b03dd5c0a88c928e563441ff39e1d4b4a234cdbe09f016e53d9594"}, +] + +[[package]] +name = "tensorflow" +version = "2.12.0" +description = "TensorFlow is an open source machine learning framework for everyone." +optional = false +python-versions = ">=3.8" +files = [ + {file = "tensorflow-2.12.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:be4ac0dfcc7a16f6df2bc19bd322e312235ab3f7b0c7297f96c92c44bb14d2a1"}, + {file = "tensorflow-2.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5193ddb3bb5120cb445279beb08ed9e74a85a4eeb2485550d6fb707a89d9a88"}, + {file = "tensorflow-2.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357d9d2851188a8d27ee195345b4d175cad970150d1344ba9d9fcc4bf2b68336"}, + {file = "tensorflow-2.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:c8001210df7202ef6267150865b0b79f834c3ca69ee3132277de8eeb994dffde"}, + {file = "tensorflow-2.12.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:91dccda42c03569d8c787190482a11ecae3b9b173aaa9166f0ab20cecc9c31f4"}, + {file = "tensorflow-2.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31f81eb8adaeb558963f5d8b47dbfcc398d898f0857bf3de6b6484350236b7b5"}, + {file = "tensorflow-2.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ec4a2934ea19e92f27a9668ece43025ed5efe14b5d19be53b07692bc8a4189d"}, + {file = "tensorflow-2.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e7641e2a6e32f31ff233495478a9cc86b7c038140eab714a61eeddbbbb327c3"}, + {file = "tensorflow-2.12.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:a7194e744c5a7f3e759ecb949527b4a07718a6d1110e6e82fd4ce0c5586a7d4a"}, + {file = "tensorflow-2.12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4afc2dd57435f29ebe249eb5f595d89b0e73be94922eeb7110aa6280a332837c"}, + {file = "tensorflow-2.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23850332f1f9f778d697c9dba63ca52be72cb73363e75ad358f07ddafef63c01"}, + {file = "tensorflow-2.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:e29fcf6cfd069aefb4b44f357cccbb4415a5a3d7b5b516eaf4450062fe40021e"}, + {file = "tensorflow-2.12.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:42fc2635e9420faee781a16bd393126f29cd39aa2b9d02901f24d8497bd6f958"}, + {file = "tensorflow-2.12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76414355e420edb9154b4e72113eef5813ccb71701fda959afbbc1eebe3099bd"}, + {file = "tensorflow-2.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:020d6a54cb26020bdc71a7bae8ee35be05096f63e773dc517f6e87c49de62c50"}, + {file = "tensorflow-2.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:9f70a8f9ab46e5ed436850aa60d1cd40645f5c669e14bcad48915dc1f597dda2"}, +] + +[package.dependencies] +absl-py = ">=1.0.0" +astunparse = ">=1.6.0" +flatbuffers = ">=2.0" +gast = ">=0.2.1,<=0.4.0" +google-pasta = ">=0.1.1" +grpcio = ">=1.24.3,<2.0" +h5py = ">=2.9.0" +jax = ">=0.3.15" +keras = ">=2.12.0,<2.13" +libclang = ">=13.0.0" +numpy = ">=1.22,<1.24" +opt-einsum = ">=2.3.2" +packaging = "*" +protobuf = ">=3.20.3,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" +setuptools = "*" +six = ">=1.12.0" +tensorboard = ">=2.12,<2.13" +tensorflow-estimator = ">=2.12.0,<2.13" +tensorflow-io-gcs-filesystem = {version = ">=0.23.1", markers = "platform_machine != \"arm64\" or platform_system != \"Darwin\""} +termcolor = ">=1.1.0" +typing-extensions = ">=3.6.6" +wrapt = ">=1.11.0,<1.15" + +[[package]] +name = "tensorflow-cpu-aws" +version = "2.12.0" +description = "TensorFlow is an open source machine learning framework for everyone." +optional = false +python-versions = ">=3.8" +files = [ + {file = "tensorflow_cpu_aws-2.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3d5f0cb1bdec07157220f95620af0a25b170273cec9a9b26b3f51229220b965"}, + {file = "tensorflow_cpu_aws-2.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92cc4ff90710a2dfa901994503a65545a75ea7a7ffe830344c201d4a5eee1190"}, + {file = "tensorflow_cpu_aws-2.12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b527847d00ec39e10cd3f92fc925cf89ae51944edcba46eea26bb6af276ff35"}, + {file = "tensorflow_cpu_aws-2.12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb8c4d34161cb2af4adebdb71edb25a390d4610c5254e39ac5a790eff2c7ba8"}, +] + +[package.dependencies] +absl-py = ">=1.0.0" +astunparse = ">=1.6.0" +flatbuffers = ">=2.0" +gast = ">=0.2.1,<=0.4.0" +google-pasta = ">=0.1.1" +grpcio = ">=1.24.3,<2.0" +h5py = ">=2.9.0" +jax = ">=0.3.15" +keras = ">=2.12.0,<2.13" +libclang = ">=13.0.0" +numpy = ">=1.22,<1.24" +opt-einsum = ">=2.3.2" +packaging = "*" +protobuf = ">=3.20.3,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" +setuptools = "*" +six = ">=1.12.0" +tensorboard = ">=2.12,<2.13" +tensorflow-estimator = ">=2.12.0,<2.13" +tensorflow-io-gcs-filesystem = {version = ">=0.23.1", markers = "platform_machine != \"arm64\" or platform_system != \"Darwin\""} +termcolor = ">=1.1.0" +typing-extensions = ">=3.6.6" +wrapt = ">=1.11.0,<1.15" + +[[package]] +name = "tensorflow-estimator" +version = "2.12.0" +description = "TensorFlow Estimator." +optional = false +python-versions = ">=3.7" +files = [ + {file = "tensorflow_estimator-2.12.0-py2.py3-none-any.whl", hash = "sha256:59b191bead4883822de3d63ac02ace11a83bfe6c10d64d0c4dfde75a50e60ca1"}, +] + +[[package]] +name = "tensorflow-hub" +version = "0.13.0" +description = "TensorFlow Hub is a library to foster the publication, discovery, and consumption of reusable parts of machine learning models." +optional = false +python-versions = "*" +files = [ + {file = "tensorflow_hub-0.13.0-py2.py3-none-any.whl", hash = "sha256:3544f4fd9fd99e4eeb6da1b5b5320e4a2dbdef7f9bb778f66f76d6790f32dd65"}, +] + +[package.dependencies] +numpy = ">=1.12.0" +protobuf = ">=3.19.6" + +[package.extras] +make-image-classifier = ["keras-preprocessing[image]"] +make-nearest-neighbour-index = ["annoy", "apache-beam"] + +[[package]] +name = "tensorflow-intel" +version = "2.12.0" +description = "TensorFlow is an open source machine learning framework for everyone." +optional = false +python-versions = ">=3.8" +files = [ + {file = "tensorflow_intel-2.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:816a6b9018d1ae0defe94508aab2512228fb6a54ab51f5f4f025c857cfe5c7e5"}, + {file = "tensorflow_intel-2.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:2789b97028ffbfa64846a1736da0021b7be22567d2cdfff826583e63c6d21485"}, + {file = "tensorflow_intel-2.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:a1eaab8edc87207c9510799aada88c55019055095ae0de0fea0f21a13fae4424"}, + {file = "tensorflow_intel-2.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:2c3ece439d589362374d6e9f7775d2fac4591e0d9fc22a431f2eeaa4a0d2994a"}, +] + +[package.dependencies] +absl-py = ">=1.0.0" +astunparse = ">=1.6.0" +flatbuffers = ">=2.0" +gast = ">=0.2.1,<=0.4.0" +google-pasta = ">=0.1.1" +grpcio = ">=1.24.3,<2.0" +h5py = ">=2.9.0" +jax = ">=0.3.15" +keras = ">=2.12.0,<2.13" +libclang = ">=13.0.0" +numpy = ">=1.22,<1.24" +opt-einsum = ">=2.3.2" +packaging = "*" +protobuf = ">=3.20.3,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" +setuptools = "*" +six = ">=1.12.0" +tensorboard = ">=2.12,<2.13" +tensorflow-estimator = ">=2.12.0,<2.13" +tensorflow-io-gcs-filesystem = {version = ">=0.23.1", markers = "platform_machine != \"arm64\" or platform_system != \"Darwin\""} +termcolor = ">=1.1.0" +typing-extensions = ">=3.6.6" +wrapt = ">=1.11.0,<1.15" + +[[package]] +name = "tensorflow-io-gcs-filesystem" +version = "0.31.0" +description = "TensorFlow IO" +optional = false +python-versions = ">=3.7, <3.12" +files = [ + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:a71421f8d75a093b6aac65b4c8c8d2f768c3ca6215307cf8c16192e62d992bcf"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:359134ecbd3bf938bb0cf65be4526106c30da461b2e2ce05446a229ed35f6832"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b658b33567552f155af2ed848130f787bfda29381fa78cd905d5ee8254364f3c"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:961353b38c76471fa296bb7d883322c66b91415e7d47087236a6706db3ab2758"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:8909c4344b0e96aa356230ab460ffafe5900c33c1aaced65fafae71d177a1966"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp311-cp311-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e417faf8755aafe52d8f8c6b5ae5bae6e4fae8326ee3acd5e9181b83bbfbae87"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37c40e3c4ee1f8dda3b545deea6b8839192c82037d8021db9f589908034ad975"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:4bb37d23f21c434687b11059cb7ffd094d52a7813368915ba1b7057e3c16e414"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:a7e8d4bd0a25de7637e562997c011294d7ea595a76f315427a5dd522d56e9d49"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fbcfb4aa2eaa9a3038d2487e570ff93feb1dbe51c3a4663d7d9ab9f9a9f9a9d8"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e3933059b1c53e062075de2e355ec136b655da5883c3c26736c45dfeb1901945"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:f0adfbcd264262797d429311843733da2d5c1ffb119fbfa6339269b6c0414113"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:20e3ee5df01f2bd81d37fc715816c329b7533ccca967c47946eb458a5b7a7280"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd628609b77aee0e385eadf1628222486f19b8f1d81b5f0a344f2470204df116"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp38-cp38-win_amd64.whl", hash = "sha256:b4ebb30ad7ce5f3769e3d959ea99bd95d80a44099bcf94da6042f9755ac6e850"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:68b89ef9f63f297de1cd9d545bc45dddc7d8fe12bcda4266279b244e8cf3b7c0"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e6d8cc7b14ade870168b9704ee44f9c55b468b9a00ed40e12d20fffd321193b5"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97ebb9a8001a38f615aa1f90d2e998b7bd6eddae7aafc92897833610b039401b"}, + {file = "tensorflow_io_gcs_filesystem-0.31.0-cp39-cp39-win_amd64.whl", hash = "sha256:cb7459c15608fe42973a78e4d3ad7ac79cfc7adae1ccb1b1846db3165fbc081a"}, +] + +[package.extras] +tensorflow = ["tensorflow (>=2.11.0,<2.12.0)"] +tensorflow-aarch64 = ["tensorflow-aarch64 (>=2.11.0,<2.12.0)"] +tensorflow-cpu = ["tensorflow-cpu (>=2.11.0,<2.12.0)"] +tensorflow-gpu = ["tensorflow-gpu (>=2.11.0,<2.12.0)"] +tensorflow-rocm = ["tensorflow-rocm (>=2.11.0,<2.12.0)"] + +[[package]] +name = "tensorflow-io-gcs-filesystem" +version = "0.32.0" +description = "TensorFlow IO" +optional = false +python-versions = ">=3.7, <3.12" +files = [ + {file = "tensorflow_io_gcs_filesystem-0.32.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:74a7e25e83d4117a7ebb09a3f247553a5497393ab48c3ee0cf0d17b405026817"}, + {file = "tensorflow_io_gcs_filesystem-0.32.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:045d51bba586390d0545fcd8a18727d62b175eb142f6f4c6d719d39de40774cd"}, + {file = "tensorflow_io_gcs_filesystem-0.32.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db682e9a510c27dd35710ba5a2c62c371e25b727741b2fe3a920355fa501e947"}, + {file = "tensorflow_io_gcs_filesystem-0.32.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:7f15fd22e592661b10de317be2f42a0f84be7bfc5e6a565fcfcb04b60d625b78"}, + {file = "tensorflow_io_gcs_filesystem-0.32.0-cp311-cp311-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:336d9b3fe6b55aea149c4f6aa1fd6ffaf27d4e5c37e55a182340b47caba38846"}, + {file = "tensorflow_io_gcs_filesystem-0.32.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:842f5f09cd756bdb3b4d0b5571b3a6f72fd534d42da938b9acf0ef462995eada"}, + {file = "tensorflow_io_gcs_filesystem-0.32.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:1ce80e1555d6ee88dda67feddf366cc8b30252b5837a7a17303df7b06a71fc2e"}, + {file = "tensorflow_io_gcs_filesystem-0.32.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:05e65d3cb6c93a7929b384d86c6369c63cbbab8a770440a3d95e094878403f9f"}, + {file = "tensorflow_io_gcs_filesystem-0.32.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:21de7dcc06eb1e7de3c022b0072d90ba35ef886578149663437aa7a6fb5bf6b3"}, + {file = "tensorflow_io_gcs_filesystem-0.32.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:79fdd02103b8ae9f8b89af41f744c013fa1caaea709de19833917795e3063857"}, + {file = "tensorflow_io_gcs_filesystem-0.32.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5635df0bbe40f971dc1b946e3372744b0bdfda45c38ffcd28ef53a32bb8da4da"}, + {file = "tensorflow_io_gcs_filesystem-0.32.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:122be149e5f6a030f5c2901be0cc3cb07619232f7b03889e2cdf3da1c0d4f92f"}, + {file = "tensorflow_io_gcs_filesystem-0.32.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8214cdf85bea694160f9035ff395221c1e25e119784ccb4c104919b1f5dec84e"}, + {file = "tensorflow_io_gcs_filesystem-0.32.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28202492d904a6e280cf27560791e87ac1c7566000db82065d63a70c27008af2"}, +] + +[package.extras] +tensorflow = ["tensorflow (>=2.12.0,<2.13.0)"] +tensorflow-aarch64 = ["tensorflow-aarch64 (>=2.12.0,<2.13.0)"] +tensorflow-cpu = ["tensorflow-cpu (>=2.12.0,<2.13.0)"] +tensorflow-gpu = ["tensorflow-gpu (>=2.12.0,<2.13.0)"] +tensorflow-rocm = ["tensorflow-rocm (>=2.12.0,<2.13.0)"] + +[[package]] +name = "tensorflow-macos" +version = "2.12.0" +description = "TensorFlow is an open source machine learning framework for everyone." +optional = false +python-versions = ">=3.8" +files = [ + {file = "tensorflow_macos-2.12.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:db464c88e10e927725997f9b872a21c9d057789d3b7e9a26e4ef1af41d0bcc8c"}, + {file = "tensorflow_macos-2.12.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:172277c33cb1ae0da19f98c5bcd4946149cfa73c8ea05c6ba18365d58dd3c6f2"}, + {file = "tensorflow_macos-2.12.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:9c9b14fbb73ec4cb0f209722a1489020fd8614c92ae22589f2309c48cefdf21f"}, + {file = "tensorflow_macos-2.12.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:6a54539bd076746f69ae8bef7282f981674fe4dbf59c3a84c4af86ae6bae9d5c"}, + {file = "tensorflow_macos-2.12.0-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:e3fa53e63672fd71998bbd71cc5478c74dbe5a2d9291d1801c575358c28403c2"}, + {file = "tensorflow_macos-2.12.0-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:5499312c21ed3ed47cc6b4cf861896e9564c2c32d8d3c2ef1437c5ca31adfc73"}, + {file = "tensorflow_macos-2.12.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:84cb873c90be63efabfecca53fdc48b734a037d0750532b55cb7ce7c343b5cac"}, + {file = "tensorflow_macos-2.12.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:85d9451a691324490e1d644b1051972e14edc249004eef5831b3510df9e36515"}, +] + +[package.dependencies] +absl-py = ">=1.0.0" +astunparse = ">=1.6.0" +flatbuffers = ">=2.0" +gast = ">=0.2.1,<=0.4.0" +google-pasta = ">=0.1.1" +grpcio = ">=1.24.3,<2.0" +h5py = ">=2.9.0" +jax = ">=0.3.15" +keras = ">=2.12.0,<2.13" +libclang = ">=13.0.0" +numpy = ">=1.22,<1.24" +opt-einsum = ">=2.3.2" +packaging = "*" +protobuf = ">=3.20.3,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" +setuptools = "*" +six = ">=1.12.0" +tensorboard = ">=2.12,<2.13" +tensorflow-estimator = ">=2.12.0,<2.13" +tensorflow-io-gcs-filesystem = {version = ">=0.23.1", markers = "platform_machine != \"arm64\" or platform_system != \"Darwin\""} +termcolor = ">=1.1.0" +typing-extensions = ">=3.6.6" +wrapt = ">=1.11.0,<1.15" + +[[package]] +name = "tensorflow-metal" +version = "0.8.0" +description = "TensorFlow acceleration for Mac GPUs." +optional = true +python-versions = "*" +files = [ + {file = "tensorflow_metal-0.8.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:bbbb28df62e20d707420f4c52d04bf87f5e5990fa36ad3ad49cf43883b2a2e15"}, + {file = "tensorflow_metal-0.8.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:cafbfc62cd24cc6fbde64ab29a2981b87eee489534ba8f01e8609582312e62bd"}, + {file = "tensorflow_metal-0.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:2a6a11a2f702153bb0b6bf44288ba7095b8e0999aba5c9aad219931a15ecf860"}, + {file = "tensorflow_metal-0.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:6b75d4e519e763d710ed6eb6a20adf98034f1399251df288a410462f0712da32"}, + {file = "tensorflow_metal-0.8.0-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:b6e6b0b516be4d52c9149436bdd99df85f1e6a3453ff811d59b5eef1e2368834"}, + {file = "tensorflow_metal-0.8.0-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:15d5fa9fec83c96940e8588e6b4140cda53f859778af713c617dc8be86025636"}, + {file = "tensorflow_metal-0.8.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:8a4ef5f4804d4becf9c59d376512b1f160477e408c5d00cd4f647c6d64728531"}, + {file = "tensorflow_metal-0.8.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:6172d4deed9ab87146d7392ccba5c0ff5511deb51c8af2de89cad31720380741"}, +] + +[package.dependencies] +six = ">=1.15.0" +wheel = ">=0.35,<1.0" + +[[package]] +name = "tensorflow-text" +version = "2.12.0" +description = "TF.Text is a TensorFlow library of text related ops, modules, and subgraphs." +optional = false +python-versions = "*" +files = [ + {file = "tensorflow_text-2.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6eedfb6a57c2b7dd320aa2b1dd3662c85de3c62fa74cec17a619836ae38e823a"}, + {file = "tensorflow_text-2.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a101244702c1a618ab29d1e034ccadccd290538670f08b3a845d8160a313fd39"}, + {file = "tensorflow_text-2.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f88a3f57b0ba2561d979038420d12ac1d3892a0c89f9918df686fe041afec61c"}, + {file = "tensorflow_text-2.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ac0e01501165e2eaf70d90c054069ac74c24f311ac576c616ef925408918904"}, + {file = "tensorflow_text-2.12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c7444166a76b993c0918909e7c4378c61148a079f347a3fba8017f6b820a8fcf"}, + {file = "tensorflow_text-2.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e029bdbfe3678054463a58a5d76e9d7f78052848369f32316ba1bd5e6fd632a"}, + {file = "tensorflow_text-2.12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1562c3ae0893402ca5175c3c6002a4d6cbac6fbce4eee6c7798a30c12d0ffcc0"}, + {file = "tensorflow_text-2.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:332a65951576c174183df2c25df4e9a8a34e254cc5862fd8c27401eb17658bb3"}, +] + +[package.dependencies] +tensorflow = {version = ">=2.12.0,<2.13", markers = "platform_machine != \"arm64\" or platform_system != \"Darwin\""} +tensorflow-hub = ">=0.8.0" + +[package.extras] +tensorflow-cpu = ["tensorflow-cpu (>=2.12.0,<2.13)"] +tests = ["absl-py", "pytest", "tensorflow-datasets (>=3.2.0)"] + +[[package]] +name = "termcolor" +version = "2.3.0" +description = "ANSI color formatting for output in terminal" +optional = false +python-versions = ">=3.7" +files = [ + {file = "termcolor-2.3.0-py3-none-any.whl", hash = "sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475"}, + {file = "termcolor-2.3.0.tar.gz", hash = "sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a"}, +] + +[package.extras] +tests = ["pytest", "pytest-cov"] + +[[package]] +name = "terminaltables" +version = "3.1.10" +description = "Generate simple tables in terminals from a nested list of strings." +optional = false +python-versions = ">=2.6" +files = [ + {file = "terminaltables-3.1.10-py2.py3-none-any.whl", hash = "sha256:e4fdc4179c9e4aab5f674d80f09d76fa436b96fdc698a8505e0a36bf0804a874"}, + {file = "terminaltables-3.1.10.tar.gz", hash = "sha256:ba6eca5cb5ba02bba4c9f4f985af80c54ec3dccf94cfcd190154386255e47543"}, +] + +[[package]] +name = "thinc" +version = "8.1.10" +description = "A refreshing functional take on deep learning, compatible with your favorite libraries" +optional = true +python-versions = ">=3.6" +files = [ + {file = "thinc-8.1.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dbd1dc4394352d80af22131e1a238238eded59de19b55f77e6237436f4865b2c"}, + {file = "thinc-8.1.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:524e6eb2436084968db1a713cfb5ea99b1b2e3363330d4aac8a403487a16d7c2"}, + {file = "thinc-8.1.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea3da2c0fb9012b6bff8b43d86dc34fd2db463f5b5e5fa725e2f5c49d29620b5"}, + {file = "thinc-8.1.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9bee276fb1f820b9a5f80c08655eb78dc2f368f3c22fd33e958e0fedeaac09b"}, + {file = "thinc-8.1.10-cp310-cp310-win_amd64.whl", hash = "sha256:e5b2232e737c25fef3116597d1458fef38ddb7237649747686ce4d4531bb84a3"}, + {file = "thinc-8.1.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:575b7dbe3a5d773c12f5dd6e366d942ad3c3ef7a5381332ba842bdbaf4d3e820"}, + {file = "thinc-8.1.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0bdf3f4e4a2fc0a4c5887e9114340ddb60ccc7b85f2cf92affdc68da82430575"}, + {file = "thinc-8.1.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c9cf2c9d8e44e1edeffe878cb137cbfe5ae1540621b5878be8e5e8d4924d757"}, + {file = "thinc-8.1.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd1aa467f445860ae8f0943ab80e41be9b64243522c165bea452ad39d4ff46f"}, + {file = "thinc-8.1.10-cp311-cp311-win_amd64.whl", hash = "sha256:108dcfef6ad1bef46d00ad31edc5fd3ab4d36c0cadb92cfbdb2f92d060acd8a0"}, + {file = "thinc-8.1.10-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5af0392bdc63c621ba1def80ec98d753be9a27ebe1cf812bed2760371f20456"}, + {file = "thinc-8.1.10-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83da33e05fda126e85e385aaeb2eb8d1ae19368c5bc67f23b88bc2927738b5cf"}, + {file = "thinc-8.1.10-cp36-cp36m-win_amd64.whl", hash = "sha256:bc321d0fbb8e146de4c152d36ea6000de0669fe081fd9777c8768ad9b4478839"}, + {file = "thinc-8.1.10-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bd9b678bcbf3f3a21260b2f55a65742aeeb7f5442c3ceb475378d95e0e99dc44"}, + {file = "thinc-8.1.10-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:042be0f014d896b826d8c0891b7bc8772464a91661938c61cdd7296cef19280d"}, + {file = "thinc-8.1.10-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a65a1e824711b30e0c35ebfb833681b64c6cb2762364548a210c3740838b9d91"}, + {file = "thinc-8.1.10-cp37-cp37m-win_amd64.whl", hash = "sha256:d63fa0bd3e60931c76617e993042deef875f57b1679354ac2f0072e621e106d1"}, + {file = "thinc-8.1.10-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ee75162bfb8aab24bd59604c01935abe1602bbd478064a4a6199d3506cb57679"}, + {file = "thinc-8.1.10-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:715ed60ddf1ddf5f98b454b2495fddbbfdb947d77bd47a241d1981d3f58ac9a0"}, + {file = "thinc-8.1.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b432bf27e4724e2f470e5f36455530906d86a81505a3b406f2f4f5b4644f77d8"}, + {file = "thinc-8.1.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d31f6834f1b1c428718a9668b7a06b74854a9217ba1d8186b41e48146d487fa3"}, + {file = "thinc-8.1.10-cp38-cp38-win_amd64.whl", hash = "sha256:21a41c90122e9b8a6b33d5ba05913fd8a763757a2b49e0243eed0bce7722d661"}, + {file = "thinc-8.1.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0bf181b47d88c60a961e0cd05eec1143d949dd8e7e3523e13f4e8f1ea32f0004"}, + {file = "thinc-8.1.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:18380a440d617fa704daa5018ed5e7d5a50efd9c237ad536a84047be3bcb767c"}, + {file = "thinc-8.1.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50271826c3737168cd9409620c9fcd3f6315136d2fff08279c213a21a5c438e8"}, + {file = "thinc-8.1.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d08eb7c15592d4212cd729d782b8be1daa2ed5248a8169991c4f63659bc6266"}, + {file = "thinc-8.1.10-cp39-cp39-win_amd64.whl", hash = "sha256:c245e6a5fcb71fcf23cb329f296349a4925b176fad5713571bb4f0fc8787ad7c"}, + {file = "thinc-8.1.10.tar.gz", hash = "sha256:6c4a48d7da07e044e84a68cbb9b22f32f8490995a2bab0bfc60e412d14afb991"}, +] + +[package.dependencies] +blis = ">=0.7.8,<0.8.0" +catalogue = ">=2.0.4,<2.1.0" +confection = ">=0.0.1,<1.0.0" +cymem = ">=2.0.2,<2.1.0" +murmurhash = ">=1.0.2,<1.1.0" +numpy = ">=1.15.0" +packaging = ">=20.0" +preshed = ">=3.0.2,<3.1.0" +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<1.11.0" +setuptools = "*" +srsly = ">=2.4.0,<3.0.0" +wasabi = ">=0.8.1,<1.2.0" + +[package.extras] +cuda = ["cupy (>=5.0.0b4)"] +cuda-autodetect = ["cupy-wheel (>=11.0.0)"] +cuda100 = ["cupy-cuda100 (>=5.0.0b4)"] +cuda101 = ["cupy-cuda101 (>=5.0.0b4)"] +cuda102 = ["cupy-cuda102 (>=5.0.0b4)"] +cuda110 = ["cupy-cuda110 (>=5.0.0b4)"] +cuda111 = ["cupy-cuda111 (>=5.0.0b4)"] +cuda112 = ["cupy-cuda112 (>=5.0.0b4)"] +cuda113 = ["cupy-cuda113 (>=5.0.0b4)"] +cuda114 = ["cupy-cuda114 (>=5.0.0b4)"] +cuda115 = ["cupy-cuda115 (>=5.0.0b4)"] +cuda116 = ["cupy-cuda116 (>=5.0.0b4)"] +cuda117 = ["cupy-cuda117 (>=5.0.0b4)"] +cuda11x = ["cupy-cuda11x (>=11.0.0)"] +cuda80 = ["cupy-cuda80 (>=5.0.0b4)"] +cuda90 = ["cupy-cuda90 (>=5.0.0b4)"] +cuda91 = ["cupy-cuda91 (>=5.0.0b4)"] +cuda92 = ["cupy-cuda92 (>=5.0.0b4)"] +datasets = ["ml-datasets (>=0.2.0,<0.3.0)"] +mxnet = ["mxnet (>=1.5.1,<1.6.0)"] +tensorflow = ["tensorflow (>=2.0.0,<2.6.0)"] +torch = ["torch (>=1.6.0)"] + +[[package]] +name = "threadpoolctl" +version = "3.1.0" +description = "threadpoolctl" +optional = false +python-versions = ">=3.6" +files = [ + {file = "threadpoolctl-3.1.0-py3-none-any.whl", hash = "sha256:8b99adda265feb6773280df41eece7b2e6561b772d21ffd52e372f999024907b"}, + {file = "threadpoolctl-3.1.0.tar.gz", hash = "sha256:a335baacfaa4400ae1f0d8e3a58d6674d2f8828e3716bb2802c44955ad391380"}, +] + +[[package]] +name = "tokenizers" +version = "0.15.2" +description = "" +optional = true +python-versions = ">=3.7" +files = [ + {file = "tokenizers-0.15.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:52f6130c9cbf70544287575a985bf44ae1bda2da7e8c24e97716080593638012"}, + {file = "tokenizers-0.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:054c1cc9c6d68f7ffa4e810b3d5131e0ba511b6e4be34157aa08ee54c2f8d9ee"}, + {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9b9b070fdad06e347563b88c278995735292ded1132f8657084989a4c84a6d5"}, + {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea621a7eef4b70e1f7a4e84dd989ae3f0eeb50fc8690254eacc08acb623e82f1"}, + {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf7fd9a5141634fa3aa8d6b7be362e6ae1b4cda60da81388fa533e0b552c98fd"}, + {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44f2a832cd0825295f7179eaf173381dc45230f9227ec4b44378322d900447c9"}, + {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b9ec69247a23747669ec4b0ca10f8e3dfb3545d550258129bd62291aabe8605"}, + {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b6a4c78da863ff26dbd5ad9a8ecc33d8a8d97b535172601cf00aee9d7ce9ce"}, + {file = "tokenizers-0.15.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5ab2a4d21dcf76af60e05af8063138849eb1d6553a0d059f6534357bce8ba364"}, + {file = "tokenizers-0.15.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a47acfac7e511f6bbfcf2d3fb8c26979c780a91e06fb5b9a43831b2c0153d024"}, + {file = "tokenizers-0.15.2-cp310-none-win32.whl", hash = "sha256:064ff87bb6acdbd693666de9a4b692add41308a2c0ec0770d6385737117215f2"}, + {file = "tokenizers-0.15.2-cp310-none-win_amd64.whl", hash = "sha256:3b919afe4df7eb6ac7cafd2bd14fb507d3f408db7a68c43117f579c984a73843"}, + {file = "tokenizers-0.15.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:89cd1cb93e4b12ff39bb2d626ad77e35209de9309a71e4d3d4672667b4b256e7"}, + {file = "tokenizers-0.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfed5c64e5be23d7ee0f0e98081a25c2a46b0b77ce99a4f0605b1ec43dd481fa"}, + {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a907d76dcfda37023ba203ab4ceeb21bc5683436ebefbd895a0841fd52f6f6f2"}, + {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20ea60479de6fc7b8ae756b4b097572372d7e4032e2521c1bbf3d90c90a99ff0"}, + {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48e2b9335be2bc0171df9281385c2ed06a15f5cf121c44094338306ab7b33f2c"}, + {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:112a1dd436d2cc06e6ffdc0b06d55ac019a35a63afd26475205cb4b1bf0bfbff"}, + {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4620cca5c2817177ee8706f860364cc3a8845bc1e291aaf661fb899e5d1c45b0"}, + {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ccd73a82751c523b3fc31ff8194702e4af4db21dc20e55b30ecc2079c5d43cb7"}, + {file = "tokenizers-0.15.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:107089f135b4ae7817affe6264f8c7a5c5b4fd9a90f9439ed495f54fcea56fb4"}, + {file = "tokenizers-0.15.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0ff110ecc57b7aa4a594396525a3451ad70988e517237fe91c540997c4e50e29"}, + {file = "tokenizers-0.15.2-cp311-none-win32.whl", hash = "sha256:6d76f00f5c32da36c61f41c58346a4fa7f0a61be02f4301fd30ad59834977cc3"}, + {file = "tokenizers-0.15.2-cp311-none-win_amd64.whl", hash = "sha256:cc90102ed17271cf0a1262babe5939e0134b3890345d11a19c3145184b706055"}, + {file = "tokenizers-0.15.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f86593c18d2e6248e72fb91c77d413a815153b8ea4e31f7cd443bdf28e467670"}, + {file = "tokenizers-0.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0774bccc6608eca23eb9d620196687c8b2360624619623cf4ba9dc9bd53e8b51"}, + {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d0222c5b7c9b26c0b4822a82f6a7011de0a9d3060e1da176f66274b70f846b98"}, + {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3835738be1de66624fff2f4f6f6684775da4e9c00bde053be7564cbf3545cc66"}, + {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0143e7d9dcd811855c1ce1ab9bf5d96d29bf5e528fd6c7824d0465741e8c10fd"}, + {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db35825f6d54215f6b6009a7ff3eedee0848c99a6271c870d2826fbbedf31a38"}, + {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3f5e64b0389a2be47091d8cc53c87859783b837ea1a06edd9d8e04004df55a5c"}, + {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e0480c452217edd35eca56fafe2029fb4d368b7c0475f8dfa3c5c9c400a7456"}, + {file = "tokenizers-0.15.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a33ab881c8fe70474980577e033d0bc9a27b7ab8272896e500708b212995d834"}, + {file = "tokenizers-0.15.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a308a607ca9de2c64c1b9ba79ec9a403969715a1b8ba5f998a676826f1a7039d"}, + {file = "tokenizers-0.15.2-cp312-none-win32.whl", hash = "sha256:b8fcfa81bcb9447df582c5bc96a031e6df4da2a774b8080d4f02c0c16b42be0b"}, + {file = "tokenizers-0.15.2-cp312-none-win_amd64.whl", hash = "sha256:38d7ab43c6825abfc0b661d95f39c7f8af2449364f01d331f3b51c94dcff7221"}, + {file = "tokenizers-0.15.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:38bfb0204ff3246ca4d5e726e8cc8403bfc931090151e6eede54d0e0cf162ef0"}, + {file = "tokenizers-0.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c861d35e8286a53e06e9e28d030b5a05bcbf5ac9d7229e561e53c352a85b1fc"}, + {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:936bf3842db5b2048eaa53dade907b1160f318e7c90c74bfab86f1e47720bdd6"}, + {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:620beacc3373277700d0e27718aa8b25f7b383eb8001fba94ee00aeea1459d89"}, + {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2735ecbbf37e52db4ea970e539fd2d450d213517b77745114f92867f3fc246eb"}, + {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:473c83c5e2359bb81b0b6fde870b41b2764fcdd36d997485e07e72cc3a62264a"}, + {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:968fa1fb3c27398b28a4eca1cbd1e19355c4d3a6007f7398d48826bbe3a0f728"}, + {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:865c60ae6eaebdde7da66191ee9b7db52e542ed8ee9d2c653b6d190a9351b980"}, + {file = "tokenizers-0.15.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7c0d8b52664ab2d4a8d6686eb5effc68b78608a9008f086a122a7b2996befbab"}, + {file = "tokenizers-0.15.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f33dfbdec3784093a9aebb3680d1f91336c56d86cc70ddf88708251da1fe9064"}, + {file = "tokenizers-0.15.2-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:d44ba80988ff9424e33e0a49445072ac7029d8c0e1601ad25a0ca5f41ed0c1d6"}, + {file = "tokenizers-0.15.2-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:dce74266919b892f82b1b86025a613956ea0ea62a4843d4c4237be2c5498ed3a"}, + {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0ef06b9707baeb98b316577acb04f4852239d856b93e9ec3a299622f6084e4be"}, + {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c73e2e74bbb07910da0d37c326869f34113137b23eadad3fc00856e6b3d9930c"}, + {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4eeb12daf02a59e29f578a865f55d87cd103ce62bd8a3a5874f8fdeaa82e336b"}, + {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ba9f6895af58487ca4f54e8a664a322f16c26bbb442effd01087eba391a719e"}, + {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ccec77aa7150e38eec6878a493bf8c263ff1fa8a62404e16c6203c64c1f16a26"}, + {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3f40604f5042ff210ba82743dda2b6aa3e55aa12df4e9f2378ee01a17e2855e"}, + {file = "tokenizers-0.15.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:5645938a42d78c4885086767c70923abad047163d809c16da75d6b290cb30bbe"}, + {file = "tokenizers-0.15.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:05a77cbfebe28a61ab5c3891f9939cc24798b63fa236d84e5f29f3a85a200c00"}, + {file = "tokenizers-0.15.2-cp37-none-win32.whl", hash = "sha256:361abdc068e8afe9c5b818769a48624687fb6aaed49636ee39bec4e95e1a215b"}, + {file = "tokenizers-0.15.2-cp37-none-win_amd64.whl", hash = "sha256:7ef789f83eb0f9baeb4d09a86cd639c0a5518528f9992f38b28e819df397eb06"}, + {file = "tokenizers-0.15.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4fe1f74a902bee74a3b25aff180fbfbf4f8b444ab37c4d496af7afd13a784ed2"}, + {file = "tokenizers-0.15.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c4b89038a684f40a6b15d6b09f49650ac64d951ad0f2a3ea9169687bbf2a8ba"}, + {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d05a1b06f986d41aed5f2de464c003004b2df8aaf66f2b7628254bcbfb72a438"}, + {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:508711a108684111ec8af89d3a9e9e08755247eda27d0ba5e3c50e9da1600f6d"}, + {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:daa348f02d15160cb35439098ac96e3a53bacf35885072611cd9e5be7d333daa"}, + {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:494fdbe5932d3416de2a85fc2470b797e6f3226c12845cadf054dd906afd0442"}, + {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2d60f5246f4da9373f75ff18d64c69cbf60c3bca597290cea01059c336d2470"}, + {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93268e788825f52de4c7bdcb6ebc1fcd4a5442c02e730faa9b6b08f23ead0e24"}, + {file = "tokenizers-0.15.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6fc7083ab404019fc9acafe78662c192673c1e696bd598d16dc005bd663a5cf9"}, + {file = "tokenizers-0.15.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:41e39b41e5531d6b2122a77532dbea60e171ef87a3820b5a3888daa847df4153"}, + {file = "tokenizers-0.15.2-cp38-none-win32.whl", hash = "sha256:06cd0487b1cbfabefb2cc52fbd6b1f8d4c37799bd6c6e1641281adaa6b2504a7"}, + {file = "tokenizers-0.15.2-cp38-none-win_amd64.whl", hash = "sha256:5179c271aa5de9c71712e31cb5a79e436ecd0d7532a408fa42a8dbfa4bc23fd9"}, + {file = "tokenizers-0.15.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:82f8652a74cc107052328b87ea8b34291c0f55b96d8fb261b3880216a9f9e48e"}, + {file = "tokenizers-0.15.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:02458bee6f5f3139f1ebbb6d042b283af712c0981f5bc50edf771d6b762d5e4f"}, + {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c9a09cd26cca2e1c349f91aa665309ddb48d71636370749414fbf67bc83c5343"}, + {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158be8ea8554e5ed69acc1ce3fbb23a06060bd4bbb09029431ad6b9a466a7121"}, + {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ddba9a2b0c8c81633eca0bb2e1aa5b3a15362b1277f1ae64176d0f6eba78ab1"}, + {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ef5dd1d39797044642dbe53eb2bc56435308432e9c7907728da74c69ee2adca"}, + {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:454c203164e07a860dbeb3b1f4a733be52b0edbb4dd2e5bd75023ffa8b49403a"}, + {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cf6b7f1d4dc59af960e6ffdc4faffe6460bbfa8dce27a58bf75755ffdb2526d"}, + {file = "tokenizers-0.15.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2ef09bbc16519f6c25d0c7fc0c6a33a6f62923e263c9d7cca4e58b8c61572afb"}, + {file = "tokenizers-0.15.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c9a2ebdd2ad4ec7a68e7615086e633857c85e2f18025bd05d2a4399e6c5f7169"}, + {file = "tokenizers-0.15.2-cp39-none-win32.whl", hash = "sha256:918fbb0eab96fe08e72a8c2b5461e9cce95585d82a58688e7f01c2bd546c79d0"}, + {file = "tokenizers-0.15.2-cp39-none-win_amd64.whl", hash = "sha256:524e60da0135e106b254bd71f0659be9f89d83f006ea9093ce4d1fab498c6d0d"}, + {file = "tokenizers-0.15.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6a9b648a58281c4672212fab04e60648fde574877d0139cd4b4f93fe28ca8944"}, + {file = "tokenizers-0.15.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:7c7d18b733be6bbca8a55084027f7be428c947ddf871c500ee603e375013ffba"}, + {file = "tokenizers-0.15.2-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:13ca3611de8d9ddfbc4dc39ef54ab1d2d4aaa114ac8727dfdc6a6ec4be017378"}, + {file = "tokenizers-0.15.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:237d1bf3361cf2e6463e6c140628e6406766e8b27274f5fcc62c747ae3c6f094"}, + {file = "tokenizers-0.15.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67a0fe1e49e60c664915e9fb6b0cb19bac082ab1f309188230e4b2920230edb3"}, + {file = "tokenizers-0.15.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4e022fe65e99230b8fd89ebdfea138c24421f91c1a4f4781a8f5016fd5cdfb4d"}, + {file = "tokenizers-0.15.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d857be2df69763362ac699f8b251a8cd3fac9d21893de129bc788f8baaef2693"}, + {file = "tokenizers-0.15.2-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:708bb3e4283177236309e698da5fcd0879ce8fd37457d7c266d16b550bcbbd18"}, + {file = "tokenizers-0.15.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:64c35e09e9899b72a76e762f9854e8750213f67567787d45f37ce06daf57ca78"}, + {file = "tokenizers-0.15.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1257f4394be0d3b00de8c9e840ca5601d0a4a8438361ce9c2b05c7d25f6057b"}, + {file = "tokenizers-0.15.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02272fe48280e0293a04245ca5d919b2c94a48b408b55e858feae9618138aeda"}, + {file = "tokenizers-0.15.2-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:dc3ad9ebc76eabe8b1d7c04d38be884b8f9d60c0cdc09b0aa4e3bcf746de0388"}, + {file = "tokenizers-0.15.2-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:32e16bdeffa7c4f46bf2152172ca511808b952701d13e7c18833c0b73cb5c23f"}, + {file = "tokenizers-0.15.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:fb16ba563d59003028b678d2361a27f7e4ae0ab29c7a80690efa20d829c81fdb"}, + {file = "tokenizers-0.15.2-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:2277c36d2d6cdb7876c274547921a42425b6810d38354327dd65a8009acf870c"}, + {file = "tokenizers-0.15.2-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1cf75d32e8d250781940d07f7eece253f2fe9ecdb1dc7ba6e3833fa17b82fcbc"}, + {file = "tokenizers-0.15.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1b3b31884dc8e9b21508bb76da80ebf7308fdb947a17affce815665d5c4d028"}, + {file = "tokenizers-0.15.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10122d8d8e30afb43bb1fe21a3619f62c3e2574bff2699cf8af8b0b6c5dc4a3"}, + {file = "tokenizers-0.15.2-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d88b96ff0fe8e91f6ef01ba50b0d71db5017fa4e3b1d99681cec89a85faf7bf7"}, + {file = "tokenizers-0.15.2-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:37aaec5a52e959892870a7c47cef80c53797c0db9149d458460f4f31e2fb250e"}, + {file = "tokenizers-0.15.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e2ea752f2b0fe96eb6e2f3adbbf4d72aaa1272079b0dfa1145507bd6a5d537e6"}, + {file = "tokenizers-0.15.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4b19a808d8799fda23504a5cd31d2f58e6f52f140380082b352f877017d6342b"}, + {file = "tokenizers-0.15.2-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:64c86e5e068ac8b19204419ed8ca90f9d25db20578f5881e337d203b314f4104"}, + {file = "tokenizers-0.15.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de19c4dc503c612847edf833c82e9f73cd79926a384af9d801dcf93f110cea4e"}, + {file = "tokenizers-0.15.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea09acd2fe3324174063d61ad620dec3bcf042b495515f27f638270a7d466e8b"}, + {file = "tokenizers-0.15.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cf27fd43472e07b57cf420eee1e814549203d56de00b5af8659cb99885472f1f"}, + {file = "tokenizers-0.15.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:7ca22bd897537a0080521445d91a58886c8c04084a6a19e6c78c586e0cfa92a5"}, + {file = "tokenizers-0.15.2.tar.gz", hash = "sha256:e6e9c6e019dd5484be5beafc775ae6c925f4c69a3487040ed09b45e13df2cb91"}, +] + +[package.dependencies] +huggingface_hub = ">=0.16.4,<1.0" + +[package.extras] +dev = ["tokenizers[testing]"] +docs = ["setuptools_rust", "sphinx", "sphinx_rtd_theme"] +testing = ["black (==22.3)", "datasets", "numpy", "pytest", "requests"] + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "tomli-w" +version = "1.0.0" +description = "A lil' TOML writer" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli_w-1.0.0-py3-none-any.whl", hash = "sha256:9f2a07e8be30a0729e533ec968016807069991ae2fd921a78d42f429ae5f4463"}, + {file = "tomli_w-1.0.0.tar.gz", hash = "sha256:f463434305e0336248cac9c2dc8076b707d8a12d019dd349f5c1e382dd1ae1b9"}, +] + +[[package]] +name = "toolz" +version = "0.12.0" +description = "List processing tools and functional utilities" +optional = false +python-versions = ">=3.5" +files = [ + {file = "toolz-0.12.0-py3-none-any.whl", hash = "sha256:2059bd4148deb1884bb0eb770a3cde70e7f954cfbbdc2285f1f2de01fd21eb6f"}, + {file = "toolz-0.12.0.tar.gz", hash = "sha256:88c570861c440ee3f2f6037c4654613228ff40c93a6c25e0eba70d17282c6194"}, +] + +[[package]] +name = "towncrier" +version = "22.12.0" +description = "Building newsfiles for your project." +optional = false +python-versions = ">=3.7" +files = [ + {file = "towncrier-22.12.0-py3-none-any.whl", hash = "sha256:9767a899a4d6856950f3598acd9e8f08da2663c49fdcda5ea0f9e6ba2afc8eea"}, + {file = "towncrier-22.12.0.tar.gz", hash = "sha256:9c49d7e75f646a9aea02ae904c0bc1639c8fd14a01292d2b123b8d307564034d"}, +] + +[package.dependencies] +click = "*" +click-default-group = "*" +incremental = "*" +jinja2 = "*" +setuptools = "*" +tomli = {version = "*", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["furo", "packaging", "sphinx (>=5)", "twisted"] + +[[package]] +name = "tqdm" +version = "4.65.0" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tqdm-4.65.0-py3-none-any.whl", hash = "sha256:c4f53a17fe37e132815abceec022631be8ffe1b9381c2e6e30aa70edc99e9671"}, + {file = "tqdm-4.65.0.tar.gz", hash = "sha256:1871fb68a86b8fb3b59ca4cdd3dcccbc7e6d613eeed31f4c332531977b89beb5"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["py-make (>=0.1.0)", "twine", "wheel"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "transformers" +version = "4.36.2" +description = "State-of-the-art Machine Learning for JAX, PyTorch and TensorFlow" +optional = true +python-versions = ">=3.8.0" +files = [ + {file = "transformers-4.36.2-py3-none-any.whl", hash = "sha256:462066c4f74ee52516f12890dcc9ec71d1a5e97998db621668455117a54330f6"}, + {file = "transformers-4.36.2.tar.gz", hash = "sha256:d8068e897e47793281501e547d2bbdfc5b8556409c2cb6c3d9e2ca77d4c0b4ec"}, +] + +[package.dependencies] +filelock = "*" +huggingface-hub = ">=0.19.3,<1.0" +numpy = ">=1.17" +packaging = ">=20.0" +pyyaml = ">=5.1" +regex = "!=2019.12.17" +requests = "*" +safetensors = ">=0.3.1" +tokenizers = ">=0.14,<0.19" +tqdm = ">=4.27" + +[package.extras] +accelerate = ["accelerate (>=0.21.0)"] +agents = ["Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.21.0)", "datasets (!=2.5.0)", "diffusers", "opencv-python", "sentencepiece (>=0.1.91,!=0.1.92)", "torch (>=1.10,!=1.12.0)"] +all = ["Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.21.0)", "av (==9.2.0)", "codecarbon (==1.2.0)", "decord (==0.6.0)", "flax (>=0.4.1,<=0.7.0)", "jax (>=0.4.1,<=0.4.13)", "jaxlib (>=0.4.1,<=0.4.13)", "kenlm", "keras-nlp (>=0.3.1)", "librosa", "onnxconverter-common", "optax (>=0.0.8,<=0.1.4)", "optuna", "phonemizer", "protobuf", "pyctcdecode (>=0.4.0)", "ray[tune] (>=2.7.0)", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "tensorflow (>=2.6,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timm", "tokenizers (>=0.14,<0.19)", "torch (>=1.10,!=1.12.0)", "torchaudio", "torchvision"] +audio = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)"] +codecarbon = ["codecarbon (==1.2.0)"] +deepspeed = ["accelerate (>=0.21.0)", "deepspeed (>=0.9.3)"] +deepspeed-testing = ["GitPython (<3.1.19)", "accelerate (>=0.21.0)", "beautifulsoup4", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "deepspeed (>=0.9.3)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "hf-doc-builder (>=0.3.0)", "nltk", "optuna", "parameterized", "protobuf", "psutil", "pydantic (<2)", "pytest (>=7.2.0)", "pytest-timeout", "pytest-xdist", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.1.5)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "sentencepiece (>=0.1.91,!=0.1.92)", "tensorboard", "timeout-decorator"] +dev = ["GitPython (<3.1.19)", "Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.21.0)", "av (==9.2.0)", "beautifulsoup4", "codecarbon (==1.2.0)", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "decord (==0.6.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "flax (>=0.4.1,<=0.7.0)", "fugashi (>=1.0)", "hf-doc-builder", "hf-doc-builder (>=0.3.0)", "ipadic (>=1.0.0,<2.0)", "isort (>=5.5.4)", "jax (>=0.4.1,<=0.4.13)", "jaxlib (>=0.4.1,<=0.4.13)", "kenlm", "keras-nlp (>=0.3.1)", "librosa", "nltk", "onnxconverter-common", "optax (>=0.0.8,<=0.1.4)", "optuna", "parameterized", "phonemizer", "protobuf", "psutil", "pyctcdecode (>=0.4.0)", "pydantic (<2)", "pytest (>=7.2.0)", "pytest-timeout", "pytest-xdist", "ray[tune] (>=2.7.0)", "rhoknp (>=1.1.0,<1.3.1)", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.1.5)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "scikit-learn", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "sudachidict-core (>=20220729)", "sudachipy (>=0.6.6)", "tensorboard", "tensorflow (>=2.6,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timeout-decorator", "timm", "tokenizers (>=0.14,<0.19)", "torch (>=1.10,!=1.12.0)", "torchaudio", "torchvision", "unidic (>=1.0.2)", "unidic-lite (>=1.0.7)", "urllib3 (<2.0.0)"] +dev-tensorflow = ["GitPython (<3.1.19)", "Pillow (>=10.0.1,<=15.0)", "beautifulsoup4", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "hf-doc-builder", "hf-doc-builder (>=0.3.0)", "isort (>=5.5.4)", "kenlm", "keras-nlp (>=0.3.1)", "librosa", "nltk", "onnxconverter-common", "onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)", "parameterized", "phonemizer", "protobuf", "psutil", "pyctcdecode (>=0.4.0)", "pydantic (<2)", "pytest (>=7.2.0)", "pytest-timeout", "pytest-xdist", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.1.5)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "scikit-learn", "sentencepiece (>=0.1.91,!=0.1.92)", "tensorboard", "tensorflow (>=2.6,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timeout-decorator", "tokenizers (>=0.14,<0.19)", "urllib3 (<2.0.0)"] +dev-torch = ["GitPython (<3.1.19)", "Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.21.0)", "beautifulsoup4", "codecarbon (==1.2.0)", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "fugashi (>=1.0)", "hf-doc-builder", "hf-doc-builder (>=0.3.0)", "ipadic (>=1.0.0,<2.0)", "isort (>=5.5.4)", "kenlm", "librosa", "nltk", "onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)", "optuna", "parameterized", "phonemizer", "protobuf", "psutil", "pyctcdecode (>=0.4.0)", "pydantic (<2)", "pytest (>=7.2.0)", "pytest-timeout", "pytest-xdist", "ray[tune] (>=2.7.0)", "rhoknp (>=1.1.0,<1.3.1)", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.1.5)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "scikit-learn", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "sudachidict-core (>=20220729)", "sudachipy (>=0.6.6)", "tensorboard", "timeout-decorator", "timm", "tokenizers (>=0.14,<0.19)", "torch (>=1.10,!=1.12.0)", "torchaudio", "torchvision", "unidic (>=1.0.2)", "unidic-lite (>=1.0.7)", "urllib3 (<2.0.0)"] +docs = ["Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.21.0)", "av (==9.2.0)", "codecarbon (==1.2.0)", "decord (==0.6.0)", "flax (>=0.4.1,<=0.7.0)", "hf-doc-builder", "jax (>=0.4.1,<=0.4.13)", "jaxlib (>=0.4.1,<=0.4.13)", "kenlm", "keras-nlp (>=0.3.1)", "librosa", "onnxconverter-common", "optax (>=0.0.8,<=0.1.4)", "optuna", "phonemizer", "protobuf", "pyctcdecode (>=0.4.0)", "ray[tune] (>=2.7.0)", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "tensorflow (>=2.6,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timm", "tokenizers (>=0.14,<0.19)", "torch (>=1.10,!=1.12.0)", "torchaudio", "torchvision"] +docs-specific = ["hf-doc-builder"] +flax = ["flax (>=0.4.1,<=0.7.0)", "jax (>=0.4.1,<=0.4.13)", "jaxlib (>=0.4.1,<=0.4.13)", "optax (>=0.0.8,<=0.1.4)"] +flax-speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)"] +ftfy = ["ftfy"] +integrations = ["optuna", "ray[tune] (>=2.7.0)", "sigopt"] +ja = ["fugashi (>=1.0)", "ipadic (>=1.0.0,<2.0)", "rhoknp (>=1.1.0,<1.3.1)", "sudachidict-core (>=20220729)", "sudachipy (>=0.6.6)", "unidic (>=1.0.2)", "unidic-lite (>=1.0.7)"] +modelcreation = ["cookiecutter (==1.7.3)"] +natten = ["natten (>=0.14.6)"] +onnx = ["onnxconverter-common", "onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)", "tf2onnx"] +onnxruntime = ["onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)"] +optuna = ["optuna"] +quality = ["GitPython (<3.1.19)", "datasets (!=2.5.0)", "hf-doc-builder (>=0.3.0)", "isort (>=5.5.4)", "ruff (==0.1.5)", "urllib3 (<2.0.0)"] +ray = ["ray[tune] (>=2.7.0)"] +retrieval = ["datasets (!=2.5.0)", "faiss-cpu"] +sagemaker = ["sagemaker (>=2.31.0)"] +sentencepiece = ["protobuf", "sentencepiece (>=0.1.91,!=0.1.92)"] +serving = ["fastapi", "pydantic (<2)", "starlette", "uvicorn"] +sigopt = ["sigopt"] +sklearn = ["scikit-learn"] +speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)", "torchaudio"] +testing = ["GitPython (<3.1.19)", "beautifulsoup4", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "hf-doc-builder (>=0.3.0)", "nltk", "parameterized", "protobuf", "psutil", "pydantic (<2)", "pytest (>=7.2.0)", "pytest-timeout", "pytest-xdist", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.1.5)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "tensorboard", "timeout-decorator"] +tf = ["keras-nlp (>=0.3.1)", "onnxconverter-common", "tensorflow (>=2.6,<2.16)", "tensorflow-text (<2.16)", "tf2onnx"] +tf-cpu = ["keras-nlp (>=0.3.1)", "onnxconverter-common", "tensorflow-cpu (>=2.6,<2.16)", "tensorflow-text (<2.16)", "tf2onnx"] +tf-speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)"] +timm = ["timm"] +tokenizers = ["tokenizers (>=0.14,<0.19)"] +torch = ["accelerate (>=0.21.0)", "torch (>=1.10,!=1.12.0)"] +torch-speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)", "torchaudio"] +torch-vision = ["Pillow (>=10.0.1,<=15.0)", "torchvision"] +torchhub = ["filelock", "huggingface-hub (>=0.19.3,<1.0)", "importlib-metadata", "numpy (>=1.17)", "packaging (>=20.0)", "protobuf", "regex (!=2019.12.17)", "requests", "sentencepiece (>=0.1.91,!=0.1.92)", "tokenizers (>=0.14,<0.19)", "torch (>=1.10,!=1.12.0)", "tqdm (>=4.27)"] +video = ["av (==9.2.0)", "decord (==0.6.0)"] +vision = ["Pillow (>=10.0.1,<=15.0)"] + +[[package]] +name = "twilio" +version = "8.2.2" +description = "Twilio API client and TwiML generator" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "twilio-8.2.2-py2.py3-none-any.whl", hash = "sha256:fa39d61757730a137d3a9c6ef84428fb3237616f21ff2b1c98116eee828d54e8"}, + {file = "twilio-8.2.2.tar.gz", hash = "sha256:6470a8bb6b1e240dd48c77f17e29fc1ee9041b75707bf437f880a585b6c722bc"}, +] + +[package.dependencies] +aiohttp = ">=3.8.4" +aiohttp-retry = ">=2.8.3" +PyJWT = ">=2.0.0,<3.0.0" +pytz = "*" +requests = ">=2.0.0" + +[[package]] +name = "typer" +version = "0.7.0" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = true +python-versions = ">=3.6" +files = [ + {file = "typer-0.7.0-py3-none-any.whl", hash = "sha256:b5e704f4e48ec263de1c0b3a2387cd405a13767d2f907f44c1a08cbad96f606d"}, + {file = "typer-0.7.0.tar.gz", hash = "sha256:ff797846578a9f2a201b53442aedeb543319466870fbe1c701eab66dd7681165"}, +] + +[package.dependencies] +click = ">=7.1.1,<9.0.0" + +[package.extras] +all = ["colorama (>=0.4.3,<0.5.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"] +dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"] +doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"] +test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"] + +[[package]] +name = "typer" +version = "0.9.0" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = true +python-versions = ">=3.6" +files = [ + {file = "typer-0.9.0-py3-none-any.whl", hash = "sha256:5d96d986a21493606a358cae4461bd8cdf83cbf33a5aa950ae629ca3b51467ee"}, + {file = "typer-0.9.0.tar.gz", hash = "sha256:50922fd79aea2f4751a8e0408ff10d2662bd0c8bbfa84755a699f3bada2978b2"}, +] + +[package.dependencies] +click = ">=7.1.1,<9.0.0" +typing-extensions = ">=3.7.4.3" + +[package.extras] +all = ["colorama (>=0.4.3,<0.5.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"] +dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"] +doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"] +test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"] + +[[package]] +name = "types-pyopenssl" +version = "23.2.0.1" +description = "Typing stubs for pyOpenSSL" +optional = false +python-versions = "*" +files = [ + {file = "types-pyOpenSSL-23.2.0.1.tar.gz", hash = "sha256:beeb5d22704c625a1e4b6dc756355c5b4af0b980138b702a9d9f932acf020903"}, + {file = "types_pyOpenSSL-23.2.0.1-py3-none-any.whl", hash = "sha256:0568553f104466f1b8e0db3360fbe6770137d02e21a1a45c209bf2b1b03d90d4"}, +] + +[package.dependencies] +cryptography = ">=35.0.0" + +[[package]] +name = "types-python-dateutil" +version = "2.8.19.13" +description = "Typing stubs for python-dateutil" +optional = false +python-versions = "*" +files = [ + {file = "types-python-dateutil-2.8.19.13.tar.gz", hash = "sha256:09a0275f95ee31ce68196710ed2c3d1b9dc42e0b61cc43acc369a42cb939134f"}, + {file = "types_python_dateutil-2.8.19.13-py3-none-any.whl", hash = "sha256:0b0e7c68e7043b0354b26a1e0225cb1baea7abb1b324d02b50e2d08f1221043f"}, +] + +[[package]] +name = "types-pytz" +version = "2022.7.1.2" +description = "Typing stubs for pytz" +optional = false +python-versions = "*" +files = [ + {file = "types-pytz-2022.7.1.2.tar.gz", hash = "sha256:487d3e8e9f4071eec8081746d53fa982bbc05812e719dcbf2ebf3d55a1a4cd28"}, + {file = "types_pytz-2022.7.1.2-py3-none-any.whl", hash = "sha256:40ca448a928d566f7d44ddfde0066e384f7ffbd4da2778e42a4570eaca572446"}, +] + +[[package]] +name = "types-redis" +version = "4.6.0.2" +description = "Typing stubs for redis" +optional = false +python-versions = "*" +files = [ + {file = "types-redis-4.6.0.2.tar.gz", hash = "sha256:d0efcd96f65fd2036437c29d8c12566cfdc549345d73eddacb0488b81aff9f9e"}, + {file = "types_redis-4.6.0.2-py3-none-any.whl", hash = "sha256:a98f3386f44d045057696f3efc8869c53dda0060610e0fe3d8a4d391e2a8916a"}, +] + +[package.dependencies] +cryptography = ">=35.0.0" +types-pyOpenSSL = "*" + +[[package]] +name = "types-requests" +version = "2.31.0.1" +description = "Typing stubs for requests" +optional = false +python-versions = "*" +files = [ + {file = "types-requests-2.31.0.1.tar.gz", hash = "sha256:3de667cffa123ce698591de0ad7db034a5317457a596eb0b4944e5a9d9e8d1ac"}, + {file = "types_requests-2.31.0.1-py3-none-any.whl", hash = "sha256:afb06ef8f25ba83d59a1d424bd7a5a939082f94b94e90ab5e6116bd2559deaa3"}, +] + +[package.dependencies] +types-urllib3 = "*" + +[[package]] +name = "types-setuptools" +version = "67.8.0.0" +description = "Typing stubs for setuptools" +optional = false +python-versions = "*" +files = [ + {file = "types-setuptools-67.8.0.0.tar.gz", hash = "sha256:95c9ed61871d6c0e258433373a4e1753c0a7c3627a46f4d4058c7b5a08ab844f"}, + {file = "types_setuptools-67.8.0.0-py3-none-any.whl", hash = "sha256:6df73340d96b238a4188b7b7668814b37e8018168aef1eef94a3b1872e3f60ff"}, +] + +[[package]] +name = "types-toml" +version = "0.10.8.6" +description = "Typing stubs for toml" +optional = false +python-versions = "*" +files = [ + {file = "types-toml-0.10.8.6.tar.gz", hash = "sha256:6d3ac79e36c9ee593c5d4fb33a50cca0e3adceb6ef5cff8b8e5aef67b4c4aaf2"}, + {file = "types_toml-0.10.8.6-py3-none-any.whl", hash = "sha256:de7b2bb1831d6f7a4b554671ffe5875e729753496961b3e9b202745e4955dafa"}, +] + +[[package]] +name = "types-urllib3" +version = "1.26.25.13" +description = "Typing stubs for urllib3" +optional = false +python-versions = "*" +files = [ + {file = "types-urllib3-1.26.25.13.tar.gz", hash = "sha256:3300538c9dc11dad32eae4827ac313f5d986b8b21494801f1bf97a1ac6c03ae5"}, + {file = "types_urllib3-1.26.25.13-py3-none-any.whl", hash = "sha256:5dbd1d2bef14efee43f5318b5d36d805a489f6600252bb53626d4bfafd95e27c"}, +] + +[[package]] +name = "typing-extensions" +version = "4.7.1" +description = "Backported and Experimental Type Hints for Python 3.7+" +optional = false +python-versions = ">=3.7" +files = [ + {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, + {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, +] + +[[package]] +name = "typing-utils" +version = "0.1.0" +description = "utils to inspect Python type annotations" +optional = false +python-versions = ">=3.6.1" +files = [ + {file = "typing_utils-0.1.0-py3-none-any.whl", hash = "sha256:6bd26f3d38a5dd526ca3a59f0a451ccb59bcee9dc829c872dd6c0aae4ec8bbef"}, + {file = "typing_utils-0.1.0.tar.gz", hash = "sha256:8ff6b6705414b82575ad5ae0925ac414a9650fb8c5718289b1327dec61252f65"}, +] + +[package.extras] +test = ["pytest"] + +[[package]] +name = "tzdata" +version = "2023.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"}, + {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, +] + +[[package]] +name = "tzlocal" +version = "5.0.1" +description = "tzinfo object for the local timezone" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tzlocal-5.0.1-py3-none-any.whl", hash = "sha256:f3596e180296aaf2dbd97d124fe76ae3a0e3d32b258447de7b939b3fd4be992f"}, + {file = "tzlocal-5.0.1.tar.gz", hash = "sha256:46eb99ad4bdb71f3f72b7d24f4267753e240944ecfc16f25d2719ba89827a803"}, +] + +[package.dependencies] +"backports.zoneinfo" = {version = "*", markers = "python_version < \"3.9\""} +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["black", "check-manifest", "flake8", "pyroma", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] + +[[package]] +name = "ujson" +version = "5.8.0" +description = "Ultra fast JSON encoder and decoder for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "ujson-5.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4511560d75b15ecb367eef561554959b9d49b6ec3b8d5634212f9fed74a6df1"}, + {file = "ujson-5.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9399eaa5d1931a0ead49dce3ffacbea63f3177978588b956036bfe53cdf6af75"}, + {file = "ujson-5.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4e7bb7eba0e1963f8b768f9c458ecb193e5bf6977090182e2b4f4408f35ac76"}, + {file = "ujson-5.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40931d7c08c4ce99adc4b409ddb1bbb01635a950e81239c2382cfe24251b127a"}, + {file = "ujson-5.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d53039d39de65360e924b511c7ca1a67b0975c34c015dd468fca492b11caa8f7"}, + {file = "ujson-5.8.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bdf04c6af3852161be9613e458a1fb67327910391de8ffedb8332e60800147a2"}, + {file = "ujson-5.8.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a70f776bda2e5072a086c02792c7863ba5833d565189e09fabbd04c8b4c3abba"}, + {file = "ujson-5.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f26629ac531d712f93192c233a74888bc8b8212558bd7d04c349125f10199fcf"}, + {file = "ujson-5.8.0-cp310-cp310-win32.whl", hash = "sha256:7ecc33b107ae88405aebdb8d82c13d6944be2331ebb04399134c03171509371a"}, + {file = "ujson-5.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:3b27a8da7a080add559a3b73ec9ebd52e82cc4419f7c6fb7266e62439a055ed0"}, + {file = "ujson-5.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:193349a998cd821483a25f5df30b44e8f495423840ee11b3b28df092ddfd0f7f"}, + {file = "ujson-5.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ddeabbc78b2aed531f167d1e70387b151900bc856d61e9325fcdfefb2a51ad8"}, + {file = "ujson-5.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ce24909a9c25062e60653073dd6d5e6ec9d6ad7ed6e0069450d5b673c854405"}, + {file = "ujson-5.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27a2a3c7620ebe43641e926a1062bc04e92dbe90d3501687957d71b4bdddaec4"}, + {file = "ujson-5.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b852bdf920fe9f84e2a2c210cc45f1b64f763b4f7d01468b33f7791698e455e"}, + {file = "ujson-5.8.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:20768961a6a706170497129960762ded9c89fb1c10db2989c56956b162e2a8a3"}, + {file = "ujson-5.8.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e0147d41e9fb5cd174207c4a2895c5e24813204499fd0839951d4c8784a23bf5"}, + {file = "ujson-5.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e3673053b036fd161ae7a5a33358ccae6793ee89fd499000204676baafd7b3aa"}, + {file = "ujson-5.8.0-cp311-cp311-win32.whl", hash = "sha256:a89cf3cd8bf33a37600431b7024a7ccf499db25f9f0b332947fbc79043aad879"}, + {file = "ujson-5.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3659deec9ab9eb19e8646932bfe6fe22730757c4addbe9d7d5544e879dc1b721"}, + {file = "ujson-5.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:102bf31c56f59538cccdfec45649780ae00657e86247c07edac434cb14d5388c"}, + {file = "ujson-5.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:299a312c3e85edee1178cb6453645217ba23b4e3186412677fa48e9a7f986de6"}, + {file = "ujson-5.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2e385a7679b9088d7bc43a64811a7713cc7c33d032d020f757c54e7d41931ae"}, + {file = "ujson-5.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad24ec130855d4430a682c7a60ca0bc158f8253ec81feed4073801f6b6cb681b"}, + {file = "ujson-5.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16fde596d5e45bdf0d7de615346a102510ac8c405098e5595625015b0d4b5296"}, + {file = "ujson-5.8.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6d230d870d1ce03df915e694dcfa3f4e8714369cce2346686dbe0bc8e3f135e7"}, + {file = "ujson-5.8.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9571de0c53db5cbc265945e08f093f093af2c5a11e14772c72d8e37fceeedd08"}, + {file = "ujson-5.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7cba16b26efe774c096a5e822e4f27097b7c81ed6fb5264a2b3f5fd8784bab30"}, + {file = "ujson-5.8.0-cp312-cp312-win32.whl", hash = "sha256:48c7d373ff22366eecfa36a52b9b55b0ee5bd44c2b50e16084aa88b9de038916"}, + {file = "ujson-5.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:5ac97b1e182d81cf395ded620528c59f4177eee024b4b39a50cdd7b720fdeec6"}, + {file = "ujson-5.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2a64cc32bb4a436e5813b83f5aab0889927e5ea1788bf99b930fad853c5625cb"}, + {file = "ujson-5.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e54578fa8838ddc722539a752adfce9372474114f8c127bb316db5392d942f8b"}, + {file = "ujson-5.8.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9721cd112b5e4687cb4ade12a7b8af8b048d4991227ae8066d9c4b3a6642a582"}, + {file = "ujson-5.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d9707e5aacf63fb919f6237d6490c4e0244c7f8d3dc2a0f84d7dec5db7cb54c"}, + {file = "ujson-5.8.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0be81bae295f65a6896b0c9030b55a106fb2dec69ef877253a87bc7c9c5308f7"}, + {file = "ujson-5.8.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ae7f4725c344bf437e9b881019c558416fe84ad9c6b67426416c131ad577df67"}, + {file = "ujson-5.8.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9ab282d67ef3097105552bf151438b551cc4bedb3f24d80fada830f2e132aeb9"}, + {file = "ujson-5.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:94c7bd9880fa33fcf7f6d7f4cc032e2371adee3c5dba2922b918987141d1bf07"}, + {file = "ujson-5.8.0-cp38-cp38-win32.whl", hash = "sha256:bf5737dbcfe0fa0ac8fa599eceafae86b376492c8f1e4b84e3adf765f03fb564"}, + {file = "ujson-5.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:11da6bed916f9bfacf13f4fc6a9594abd62b2bb115acfb17a77b0f03bee4cfd5"}, + {file = "ujson-5.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:69b3104a2603bab510497ceabc186ba40fef38ec731c0ccaa662e01ff94a985c"}, + {file = "ujson-5.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9249fdefeb021e00b46025e77feed89cd91ffe9b3a49415239103fc1d5d9c29a"}, + {file = "ujson-5.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2873d196725a8193f56dde527b322c4bc79ed97cd60f1d087826ac3290cf9207"}, + {file = "ujson-5.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a4dafa9010c366589f55afb0fd67084acd8added1a51251008f9ff2c3e44042"}, + {file = "ujson-5.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a42baa647a50fa8bed53d4e242be61023bd37b93577f27f90ffe521ac9dc7a3"}, + {file = "ujson-5.8.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f3554eaadffe416c6f543af442066afa6549edbc34fe6a7719818c3e72ebfe95"}, + {file = "ujson-5.8.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:fb87decf38cc82bcdea1d7511e73629e651bdec3a43ab40985167ab8449b769c"}, + {file = "ujson-5.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:407d60eb942c318482bbfb1e66be093308bb11617d41c613e33b4ce5be789adc"}, + {file = "ujson-5.8.0-cp39-cp39-win32.whl", hash = "sha256:0fe1b7edaf560ca6ab023f81cbeaf9946a240876a993b8c5a21a1c539171d903"}, + {file = "ujson-5.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:3f9b63530a5392eb687baff3989d0fb5f45194ae5b1ca8276282fb647f8dcdb3"}, + {file = "ujson-5.8.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:efeddf950fb15a832376c0c01d8d7713479fbeceaed1eaecb2665aa62c305aec"}, + {file = "ujson-5.8.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d8283ac5d03e65f488530c43d6610134309085b71db4f675e9cf5dff96a8282"}, + {file = "ujson-5.8.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb0142f6f10f57598655340a3b2c70ed4646cbe674191da195eb0985a9813b83"}, + {file = "ujson-5.8.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07d459aca895eb17eb463b00441986b021b9312c6c8cc1d06880925c7f51009c"}, + {file = "ujson-5.8.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d524a8c15cfc863705991d70bbec998456a42c405c291d0f84a74ad7f35c5109"}, + {file = "ujson-5.8.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d6f84a7a175c75beecde53a624881ff618e9433045a69fcfb5e154b73cdaa377"}, + {file = "ujson-5.8.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b748797131ac7b29826d1524db1cc366d2722ab7afacc2ce1287cdafccddbf1f"}, + {file = "ujson-5.8.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e72ba76313d48a1a3a42e7dc9d1db32ea93fac782ad8dde6f8b13e35c229130"}, + {file = "ujson-5.8.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f504117a39cb98abba4153bf0b46b4954cc5d62f6351a14660201500ba31fe7f"}, + {file = "ujson-5.8.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a8c91b6f4bf23f274af9002b128d133b735141e867109487d17e344d38b87d94"}, + {file = "ujson-5.8.0.tar.gz", hash = "sha256:78e318def4ade898a461b3d92a79f9441e7e0e4d2ad5419abed4336d702c7425"}, +] + +[[package]] +name = "uritemplate" +version = "4.1.1" +description = "Implementation of RFC 6570 URI Templates" +optional = true +python-versions = ">=3.6" +files = [ + {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, + {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, +] + +[[package]] +name = "urllib3" +version = "1.26.16" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +files = [ + {file = "urllib3-1.26.16-py2.py3-none-any.whl", hash = "sha256:8d36afa7616d8ab714608411b4a3b13e58f463aee519024578e062e141dce20f"}, + {file = "urllib3-1.26.16.tar.gz", hash = "sha256:8f135f6502756bde6b2a9b28989df5fbe87c9970cecaa69041edcce7f0589b14"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "uvloop" +version = "0.17.0" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.7" +files = [ + {file = "uvloop-0.17.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce9f61938d7155f79d3cb2ffa663147d4a76d16e08f65e2c66b77bd41b356718"}, + {file = "uvloop-0.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:68532f4349fd3900b839f588972b3392ee56042e440dd5873dfbbcd2cc67617c"}, + {file = "uvloop-0.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0949caf774b9fcefc7c5756bacbbbd3fc4c05a6b7eebc7c7ad6f825b23998d6d"}, + {file = "uvloop-0.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff3d00b70ce95adce264462c930fbaecb29718ba6563db354608f37e49e09024"}, + {file = "uvloop-0.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a5abddb3558d3f0a78949c750644a67be31e47936042d4f6c888dd6f3c95f4aa"}, + {file = "uvloop-0.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8efcadc5a0003d3a6e887ccc1fb44dec25594f117a94e3127954c05cf144d811"}, + {file = "uvloop-0.17.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3378eb62c63bf336ae2070599e49089005771cc651c8769aaad72d1bd9385a7c"}, + {file = "uvloop-0.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6aafa5a78b9e62493539456f8b646f85abc7093dd997f4976bb105537cf2635e"}, + {file = "uvloop-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c686a47d57ca910a2572fddfe9912819880b8765e2f01dc0dd12a9bf8573e539"}, + {file = "uvloop-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:864e1197139d651a76c81757db5eb199db8866e13acb0dfe96e6fc5d1cf45fc4"}, + {file = "uvloop-0.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2a6149e1defac0faf505406259561bc14b034cdf1d4711a3ddcdfbaa8d825a05"}, + {file = "uvloop-0.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6708f30db9117f115eadc4f125c2a10c1a50d711461699a0cbfaa45b9a78e376"}, + {file = "uvloop-0.17.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:23609ca361a7fc587031429fa25ad2ed7242941adec948f9d10c045bfecab06b"}, + {file = "uvloop-0.17.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2deae0b0fb00a6af41fe60a675cec079615b01d68beb4cc7b722424406b126a8"}, + {file = "uvloop-0.17.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45cea33b208971e87a31c17622e4b440cac231766ec11e5d22c76fab3bf9df62"}, + {file = "uvloop-0.17.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:9b09e0f0ac29eee0451d71798878eae5a4e6a91aa275e114037b27f7db72702d"}, + {file = "uvloop-0.17.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dbbaf9da2ee98ee2531e0c780455f2841e4675ff580ecf93fe5c48fe733b5667"}, + {file = "uvloop-0.17.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a4aee22ece20958888eedbad20e4dbb03c37533e010fb824161b4f05e641f738"}, + {file = "uvloop-0.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:307958f9fc5c8bb01fad752d1345168c0abc5d62c1b72a4a8c6c06f042b45b20"}, + {file = "uvloop-0.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ebeeec6a6641d0adb2ea71dcfb76017602ee2bfd8213e3fcc18d8f699c5104f"}, + {file = "uvloop-0.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1436c8673c1563422213ac6907789ecb2b070f5939b9cbff9ef7113f2b531595"}, + {file = "uvloop-0.17.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8887d675a64cfc59f4ecd34382e5b4f0ef4ae1da37ed665adba0c2badf0d6578"}, + {file = "uvloop-0.17.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3db8de10ed684995a7f34a001f15b374c230f7655ae840964d51496e2f8a8474"}, + {file = "uvloop-0.17.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7d37dccc7ae63e61f7b96ee2e19c40f153ba6ce730d8ba4d3b4e9738c1dccc1b"}, + {file = "uvloop-0.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cbbe908fda687e39afd6ea2a2f14c2c3e43f2ca88e3a11964b297822358d0e6c"}, + {file = "uvloop-0.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d97672dc709fa4447ab83276f344a165075fd9f366a97b712bdd3fee05efae8"}, + {file = "uvloop-0.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1e507c9ee39c61bfddd79714e4f85900656db1aec4d40c6de55648e85c2799c"}, + {file = "uvloop-0.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c092a2c1e736086d59ac8e41f9c98f26bbf9b9222a76f21af9dfe949b99b2eb9"}, + {file = "uvloop-0.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:30babd84706115626ea78ea5dbc7dd8d0d01a2e9f9b306d24ca4ed5796c66ded"}, + {file = "uvloop-0.17.0.tar.gz", hash = "sha256:0ddf6baf9cf11a1a22c71487f39f15b2cf78eb5bde7e5b45fbb99e8a9d91b9e1"}, +] + +[package.extras] +dev = ["Cython (>=0.29.32,<0.30.0)", "Sphinx (>=4.1.2,<4.2.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=22.0.0,<22.1.0)", "pycodestyle (>=2.7.0,<2.8.0)", "pytest (>=3.6.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["Cython (>=0.29.32,<0.30.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=22.0.0,<22.1.0)", "pycodestyle (>=2.7.0,<2.8.0)"] + +[[package]] +name = "wasabi" +version = "0.10.1" +description = "A lightweight console printing and formatting toolkit" +optional = true +python-versions = "*" +files = [ + {file = "wasabi-0.10.1-py3-none-any.whl", hash = "sha256:fe862cc24034fbc9f04717cd312ab884f71f51a8ecabebc3449b751c2a649d83"}, + {file = "wasabi-0.10.1.tar.gz", hash = "sha256:c8e372781be19272942382b14d99314d175518d7822057cb7a97010c4259d249"}, +] + +[[package]] +name = "wasabi" +version = "1.1.2" +description = "A lightweight console printing and formatting toolkit" +optional = true +python-versions = ">=3.6" +files = [ + {file = "wasabi-1.1.2-py3-none-any.whl", hash = "sha256:0a3f933c4bf0ed3f93071132c1b87549733256d6c8de6473c5f7ed2e171b5cf9"}, + {file = "wasabi-1.1.2.tar.gz", hash = "sha256:1aaef3aceaa32edb9c91330d29d3936c0c39fdb965743549c173cb54b16c30b5"}, +] + +[[package]] +name = "watchdog" +version = "3.0.0" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.7" +files = [ + {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"}, + {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"}, + {file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"}, + {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae"}, + {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9"}, + {file = "watchdog-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7"}, + {file = "watchdog-3.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674"}, + {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f"}, + {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc"}, + {file = "watchdog-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3"}, + {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3"}, + {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0"}, + {file = "watchdog-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8"}, + {file = "watchdog-3.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100"}, + {file = "watchdog-3.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346"}, + {file = "watchdog-3.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"}, + {file = "watchdog-3.0.0-py3-none-win32.whl", hash = "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f"}, + {file = "watchdog-3.0.0-py3-none-win_amd64.whl", hash = "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c"}, + {file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"}, + {file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[[package]] +name = "wcwidth" +version = "0.2.6" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.6-py2.py3-none-any.whl", hash = "sha256:795b138f6875577cd91bba52baf9e445cd5118fd32723b460e30a0af30ea230e"}, + {file = "wcwidth-0.2.6.tar.gz", hash = "sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0"}, +] + +[[package]] +name = "webexteamssdk" +version = "1.6.1" +description = "Community-developed Python SDK for the Webex Teams APIs" +optional = false +python-versions = "*" +files = [ + {file = "webexteamssdk-1.6.1-py3-none-any.whl", hash = "sha256:52a7f9d515cd3d53a853e679e16572ec6ca036a223e35b14fea14c99f492a6a4"}, + {file = "webexteamssdk-1.6.1.tar.gz", hash = "sha256:bbc7672f381b26fb22d0d03f87d131a2fa1e7d54c2f37f2e4cd28d725b8b5dfb"}, +] + +[package.dependencies] +future = "*" +PyJWT = "*" +requests = ">=2.4.2" +requests-toolbelt = "*" + +[[package]] +name = "websocket-client" +version = "1.6.1" +description = "WebSocket client for Python with low level API options" +optional = false +python-versions = ">=3.7" +files = [ + {file = "websocket-client-1.6.1.tar.gz", hash = "sha256:c951af98631d24f8df89ab1019fc365f2227c0892f12fd150e935607c79dd0dd"}, + {file = "websocket_client-1.6.1-py3-none-any.whl", hash = "sha256:f1f9f2ad5291f0225a49efad77abf9e700b6fef553900623060dad6e26503b9d"}, +] + +[package.extras] +docs = ["Sphinx (>=3.4)", "sphinx-rtd-theme (>=0.5)"] +optional = ["python-socks", "wsaccel"] +test = ["websockets"] + +[[package]] +name = "websockets" +version = "10.4" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "websockets-10.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d58804e996d7d2307173d56c297cf7bc132c52df27a3efaac5e8d43e36c21c48"}, + {file = "websockets-10.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc0b82d728fe21a0d03e65f81980abbbcb13b5387f733a1a870672c5be26edab"}, + {file = "websockets-10.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ba089c499e1f4155d2a3c2a05d2878a3428cf321c848f2b5a45ce55f0d7d310c"}, + {file = "websockets-10.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33d69ca7612f0ddff3316b0c7b33ca180d464ecac2d115805c044bf0a3b0d032"}, + {file = "websockets-10.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62e627f6b6d4aed919a2052efc408da7a545c606268d5ab5bfab4432734b82b4"}, + {file = "websockets-10.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38ea7b82bfcae927eeffc55d2ffa31665dc7fec7b8dc654506b8e5a518eb4d50"}, + {file = "websockets-10.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e0cb5cc6ece6ffa75baccfd5c02cffe776f3f5c8bf486811f9d3ea3453676ce8"}, + {file = "websockets-10.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ae5e95cfb53ab1da62185e23b3130e11d64431179debac6dc3c6acf08760e9b1"}, + {file = "websockets-10.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7c584f366f46ba667cfa66020344886cf47088e79c9b9d39c84ce9ea98aaa331"}, + {file = "websockets-10.4-cp310-cp310-win32.whl", hash = "sha256:b029fb2032ae4724d8ae8d4f6b363f2cc39e4c7b12454df8df7f0f563ed3e61a"}, + {file = "websockets-10.4-cp310-cp310-win_amd64.whl", hash = "sha256:8dc96f64ae43dde92530775e9cb169979f414dcf5cff670455d81a6823b42089"}, + {file = "websockets-10.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47a2964021f2110116cc1125b3e6d87ab5ad16dea161949e7244ec583b905bb4"}, + {file = "websockets-10.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e789376b52c295c4946403bd0efecf27ab98f05319df4583d3c48e43c7342c2f"}, + {file = "websockets-10.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3f0b61c45c3fa9a349cf484962c559a8a1d80dae6977276df8fd1fa5e3cb8c"}, + {file = "websockets-10.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f55b5905705725af31ccef50e55391621532cd64fbf0bc6f4bac935f0fccec46"}, + {file = "websockets-10.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00c870522cdb69cd625b93f002961ffb0c095394f06ba8c48f17eef7c1541f96"}, + {file = "websockets-10.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f38706e0b15d3c20ef6259fd4bc1700cd133b06c3c1bb108ffe3f8947be15fa"}, + {file = "websockets-10.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f2c38d588887a609191d30e902df2a32711f708abfd85d318ca9b367258cfd0c"}, + {file = "websockets-10.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fe10ddc59b304cb19a1bdf5bd0a7719cbbc9fbdd57ac80ed436b709fcf889106"}, + {file = "websockets-10.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:90fcf8929836d4a0e964d799a58823547df5a5e9afa83081761630553be731f9"}, + {file = "websockets-10.4-cp311-cp311-win32.whl", hash = "sha256:b9968694c5f467bf67ef97ae7ad4d56d14be2751000c1207d31bf3bb8860bae8"}, + {file = "websockets-10.4-cp311-cp311-win_amd64.whl", hash = "sha256:a7a240d7a74bf8d5cb3bfe6be7f21697a28ec4b1a437607bae08ac7acf5b4882"}, + {file = "websockets-10.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:74de2b894b47f1d21cbd0b37a5e2b2392ad95d17ae983e64727e18eb281fe7cb"}, + {file = "websockets-10.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3a686ecb4aa0d64ae60c9c9f1a7d5d46cab9bfb5d91a2d303d00e2cd4c4c5cc"}, + {file = "websockets-10.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d15c968ea7a65211e084f523151dbf8ae44634de03c801b8bd070b74e85033"}, + {file = "websockets-10.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00213676a2e46b6ebf6045bc11d0f529d9120baa6f58d122b4021ad92adabd41"}, + {file = "websockets-10.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:e23173580d740bf8822fd0379e4bf30aa1d5a92a4f252d34e893070c081050df"}, + {file = "websockets-10.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:dd500e0a5e11969cdd3320935ca2ff1e936f2358f9c2e61f100a1660933320ea"}, + {file = "websockets-10.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4239b6027e3d66a89446908ff3027d2737afc1a375f8fd3eea630a4842ec9a0c"}, + {file = "websockets-10.4-cp37-cp37m-win32.whl", hash = "sha256:8a5cc00546e0a701da4639aa0bbcb0ae2bb678c87f46da01ac2d789e1f2d2038"}, + {file = "websockets-10.4-cp37-cp37m-win_amd64.whl", hash = "sha256:a9f9a735deaf9a0cadc2d8c50d1a5bcdbae8b6e539c6e08237bc4082d7c13f28"}, + {file = "websockets-10.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5c1289596042fad2cdceb05e1ebf7aadf9995c928e0da2b7a4e99494953b1b94"}, + {file = "websockets-10.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0cff816f51fb33c26d6e2b16b5c7d48eaa31dae5488ace6aae468b361f422b63"}, + {file = "websockets-10.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dd9becd5fe29773d140d68d607d66a38f60e31b86df75332703757ee645b6faf"}, + {file = "websockets-10.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45ec8e75b7dbc9539cbfafa570742fe4f676eb8b0d3694b67dabe2f2ceed8aa6"}, + {file = "websockets-10.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f72e5cd0f18f262f5da20efa9e241699e0cf3a766317a17392550c9ad7b37d8"}, + {file = "websockets-10.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:185929b4808b36a79c65b7865783b87b6841e852ef5407a2fb0c03381092fa3b"}, + {file = "websockets-10.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7d27a7e34c313b3a7f91adcd05134315002aaf8540d7b4f90336beafaea6217c"}, + {file = "websockets-10.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:884be66c76a444c59f801ac13f40c76f176f1bfa815ef5b8ed44321e74f1600b"}, + {file = "websockets-10.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:931c039af54fc195fe6ad536fde4b0de04da9d5916e78e55405436348cfb0e56"}, + {file = "websockets-10.4-cp38-cp38-win32.whl", hash = "sha256:db3c336f9eda2532ec0fd8ea49fef7a8df8f6c804cdf4f39e5c5c0d4a4ad9a7a"}, + {file = "websockets-10.4-cp38-cp38-win_amd64.whl", hash = "sha256:48c08473563323f9c9debac781ecf66f94ad5a3680a38fe84dee5388cf5acaf6"}, + {file = "websockets-10.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:40e826de3085721dabc7cf9bfd41682dadc02286d8cf149b3ad05bff89311e4f"}, + {file = "websockets-10.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:56029457f219ade1f2fc12a6504ea61e14ee227a815531f9738e41203a429112"}, + {file = "websockets-10.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5fc088b7a32f244c519a048c170f14cf2251b849ef0e20cbbb0fdf0fdaf556f"}, + {file = "websockets-10.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fc8709c00704194213d45e455adc106ff9e87658297f72d544220e32029cd3d"}, + {file = "websockets-10.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0154f7691e4fe6c2b2bc275b5701e8b158dae92a1ab229e2b940efe11905dff4"}, + {file = "websockets-10.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c6d2264f485f0b53adf22697ac11e261ce84805c232ed5dbe6b1bcb84b00ff0"}, + {file = "websockets-10.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9bc42e8402dc5e9905fb8b9649f57efcb2056693b7e88faa8fb029256ba9c68c"}, + {file = "websockets-10.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:edc344de4dac1d89300a053ac973299e82d3db56330f3494905643bb68801269"}, + {file = "websockets-10.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:84bc2a7d075f32f6ed98652db3a680a17a4edb21ca7f80fe42e38753a58ee02b"}, + {file = "websockets-10.4-cp39-cp39-win32.whl", hash = "sha256:c94ae4faf2d09f7c81847c63843f84fe47bf6253c9d60b20f25edfd30fb12588"}, + {file = "websockets-10.4-cp39-cp39-win_amd64.whl", hash = "sha256:bbccd847aa0c3a69b5f691a84d2341a4f8a629c6922558f2a70611305f902d74"}, + {file = "websockets-10.4-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:82ff5e1cae4e855147fd57a2863376ed7454134c2bf49ec604dfe71e446e2193"}, + {file = "websockets-10.4-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d210abe51b5da0ffdbf7b43eed0cfdff8a55a1ab17abbec4301c9ff077dd0342"}, + {file = "websockets-10.4-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:942de28af58f352a6f588bc72490ae0f4ccd6dfc2bd3de5945b882a078e4e179"}, + {file = "websockets-10.4-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9b27d6c1c6cd53dc93614967e9ce00ae7f864a2d9f99fe5ed86706e1ecbf485"}, + {file = "websockets-10.4-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:3d3cac3e32b2c8414f4f87c1b2ab686fa6284a980ba283617404377cd448f631"}, + {file = "websockets-10.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:da39dd03d130162deb63da51f6e66ed73032ae62e74aaccc4236e30edccddbb0"}, + {file = "websockets-10.4-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389f8dbb5c489e305fb113ca1b6bdcdaa130923f77485db5b189de343a179393"}, + {file = "websockets-10.4-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09a1814bb15eff7069e51fed0826df0bc0702652b5cb8f87697d469d79c23576"}, + {file = "websockets-10.4-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff64a1d38d156d429404aaa84b27305e957fd10c30e5880d1765c9480bea490f"}, + {file = "websockets-10.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b343f521b047493dc4022dd338fc6db9d9282658862756b4f6fd0e996c1380e1"}, + {file = "websockets-10.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:932af322458da7e4e35df32f050389e13d3d96b09d274b22a7aa1808f292fee4"}, + {file = "websockets-10.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a4162139374a49eb18ef5b2f4da1dd95c994588f5033d64e0bbfda4b6b6fcf"}, + {file = "websockets-10.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c57e4c1349fbe0e446c9fa7b19ed2f8a4417233b6984277cce392819123142d3"}, + {file = "websockets-10.4-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b627c266f295de9dea86bd1112ed3d5fafb69a348af30a2422e16590a8ecba13"}, + {file = "websockets-10.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:05a7233089f8bd355e8cbe127c2e8ca0b4ea55467861906b80d2ebc7db4d6b72"}, + {file = "websockets-10.4.tar.gz", hash = "sha256:eef610b23933c54d5d921c92578ae5f89813438fded840c2e9809d378dc765d3"}, +] + +[[package]] +name = "werkzeug" +version = "2.3.6" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Werkzeug-2.3.6-py3-none-any.whl", hash = "sha256:935539fa1413afbb9195b24880778422ed620c0fc09670945185cce4d91a8890"}, + {file = "Werkzeug-2.3.6.tar.gz", hash = "sha256:98c774df2f91b05550078891dee5f0eb0cb797a522c757a2452b9cee5b202330"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + +[[package]] +name = "wheel" +version = "0.40.0" +description = "A built-package format for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "wheel-0.40.0-py3-none-any.whl", hash = "sha256:d236b20e7cb522daf2390fa84c55eea81c5c30190f90f29ae2ca1ad8355bf247"}, + {file = "wheel-0.40.0.tar.gz", hash = "sha256:cd1196f3faee2b31968d626e1731c94f99cbdb67cf5a46e4f5656cbee7738873"}, +] + +[package.extras] +test = ["pytest (>=6.0.0)"] + +[[package]] +name = "wrapt" +version = "1.14.1" +description = "Module for decorators, wrappers and monkey patching." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +files = [ + {file = "wrapt-1.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1b376b3f4896e7930f1f772ac4b064ac12598d1c38d04907e696cc4d794b43d3"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:903500616422a40a98a5a3c4ff4ed9d0066f3b4c951fa286018ecdf0750194ef"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5a9a0d155deafd9448baff28c08e150d9b24ff010e899311ddd63c45c2445e28"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ddaea91abf8b0d13443f6dac52e89051a5063c7d014710dcb4d4abb2ff811a59"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:36f582d0c6bc99d5f39cd3ac2a9062e57f3cf606ade29a0a0d6b323462f4dd87"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7ef58fb89674095bfc57c4069e95d7a31cfdc0939e2a579882ac7d55aadfd2a1"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e2f83e18fe2f4c9e7db597e988f72712c0c3676d337d8b101f6758107c42425b"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ee2b1b1769f6707a8a445162ea16dddf74285c3964f605877a20e38545c3c462"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:833b58d5d0b7e5b9832869f039203389ac7cbf01765639c7309fd50ef619e0b1"}, + {file = "wrapt-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:80bb5c256f1415f747011dc3604b59bc1f91c6e7150bd7db03b19170ee06b320"}, + {file = "wrapt-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07f7a7d0f388028b2df1d916e94bbb40624c59b48ecc6cbc232546706fac74c2"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02b41b633c6261feff8ddd8d11c711df6842aba629fdd3da10249a53211a72c4"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fe803deacd09a233e4762a1adcea5db5d31e6be577a43352936179d14d90069"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:257fd78c513e0fb5cdbe058c27a0624c9884e735bbd131935fd49e9fe719d310"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4fcc4649dc762cddacd193e6b55bc02edca674067f5f98166d7713b193932b7f"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:11871514607b15cfeb87c547a49bca19fde402f32e2b1c24a632506c0a756656"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8ad85f7f4e20964db4daadcab70b47ab05c7c1cf2a7c1e51087bfaa83831854c"}, + {file = "wrapt-1.14.1-cp310-cp310-win32.whl", hash = "sha256:a9a52172be0b5aae932bef82a79ec0a0ce87288c7d132946d645eba03f0ad8a8"}, + {file = "wrapt-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:6d323e1554b3d22cfc03cd3243b5bb815a51f5249fdcbb86fda4bf62bab9e164"}, + {file = "wrapt-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ecee4132c6cd2ce5308e21672015ddfed1ff975ad0ac8d27168ea82e71413f55"}, + {file = "wrapt-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2020f391008ef874c6d9e208b24f28e31bcb85ccff4f335f15a3251d222b92d9"}, + {file = "wrapt-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2feecf86e1f7a86517cab34ae6c2f081fd2d0dac860cb0c0ded96d799d20b335"}, + {file = "wrapt-1.14.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:240b1686f38ae665d1b15475966fe0472f78e71b1b4903c143a842659c8e4cb9"}, + {file = "wrapt-1.14.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9008dad07d71f68487c91e96579c8567c98ca4c3881b9b113bc7b33e9fd78b8"}, + {file = "wrapt-1.14.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6447e9f3ba72f8e2b985a1da758767698efa72723d5b59accefd716e9e8272bf"}, + {file = "wrapt-1.14.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:acae32e13a4153809db37405f5eba5bac5fbe2e2ba61ab227926a22901051c0a"}, + {file = "wrapt-1.14.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49ef582b7a1152ae2766557f0550a9fcbf7bbd76f43fbdc94dd3bf07cc7168be"}, + {file = "wrapt-1.14.1-cp311-cp311-win32.whl", hash = "sha256:358fe87cc899c6bb0ddc185bf3dbfa4ba646f05b1b0b9b5a27c2cb92c2cea204"}, + {file = "wrapt-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:26046cd03936ae745a502abf44dac702a5e6880b2b01c29aea8ddf3353b68224"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:43ca3bbbe97af00f49efb06e352eae40434ca9d915906f77def219b88e85d907"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6b1a564e6cb69922c7fe3a678b9f9a3c54e72b469875aa8018f18b4d1dd1adf3"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:00b6d4ea20a906c0ca56d84f93065b398ab74b927a7a3dbd470f6fc503f95dc3"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:a85d2b46be66a71bedde836d9e41859879cc54a2a04fad1191eb50c2066f6e9d"}, + {file = "wrapt-1.14.1-cp35-cp35m-win32.whl", hash = "sha256:dbcda74c67263139358f4d188ae5faae95c30929281bc6866d00573783c422b7"}, + {file = "wrapt-1.14.1-cp35-cp35m-win_amd64.whl", hash = "sha256:b21bb4c09ffabfa0e85e3a6b623e19b80e7acd709b9f91452b8297ace2a8ab00"}, + {file = "wrapt-1.14.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9e0fd32e0148dd5dea6af5fee42beb949098564cc23211a88d799e434255a1f4"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9736af4641846491aedb3c3f56b9bc5568d92b0692303b5a305301a95dfd38b1"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b02d65b9ccf0ef6c34cba6cf5bf2aab1bb2f49c6090bafeecc9cd81ad4ea1c1"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21ac0156c4b089b330b7666db40feee30a5d52634cc4560e1905d6529a3897ff"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:9f3e6f9e05148ff90002b884fbc2a86bd303ae847e472f44ecc06c2cd2fcdb2d"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:6e743de5e9c3d1b7185870f480587b75b1cb604832e380d64f9504a0535912d1"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:d79d7d5dc8a32b7093e81e97dad755127ff77bcc899e845f41bf71747af0c569"}, + {file = "wrapt-1.14.1-cp36-cp36m-win32.whl", hash = "sha256:81b19725065dcb43df02b37e03278c011a09e49757287dca60c5aecdd5a0b8ed"}, + {file = "wrapt-1.14.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b014c23646a467558be7da3d6b9fa409b2c567d2110599b7cf9a0c5992b3b471"}, + {file = "wrapt-1.14.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88bd7b6bd70a5b6803c1abf6bca012f7ed963e58c68d76ee20b9d751c74a3248"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5901a312f4d14c59918c221323068fad0540e34324925c8475263841dbdfe68"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d77c85fedff92cf788face9bfa3ebaa364448ebb1d765302e9af11bf449ca36d"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d649d616e5c6a678b26d15ece345354f7c2286acd6db868e65fcc5ff7c24a77"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7d2872609603cb35ca513d7404a94d6d608fc13211563571117046c9d2bcc3d7"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ee6acae74a2b91865910eef5e7de37dc6895ad96fa23603d1d27ea69df545015"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2b39d38039a1fdad98c87279b48bc5dce2c0ca0d73483b12cb72aa9609278e8a"}, + {file = "wrapt-1.14.1-cp37-cp37m-win32.whl", hash = "sha256:60db23fa423575eeb65ea430cee741acb7c26a1365d103f7b0f6ec412b893853"}, + {file = "wrapt-1.14.1-cp37-cp37m-win_amd64.whl", hash = "sha256:709fe01086a55cf79d20f741f39325018f4df051ef39fe921b1ebe780a66184c"}, + {file = "wrapt-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c0ce1e99116d5ab21355d8ebe53d9460366704ea38ae4d9f6933188f327b456"}, + {file = "wrapt-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e3fb1677c720409d5f671e39bac6c9e0e422584e5f518bfd50aa4cbbea02433f"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:642c2e7a804fcf18c222e1060df25fc210b9c58db7c91416fb055897fc27e8cc"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b7c050ae976e286906dd3f26009e117eb000fb2cf3533398c5ad9ccc86867b1"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef3f72c9666bba2bab70d2a8b79f2c6d2c1a42a7f7e2b0ec83bb2f9e383950af"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:01c205616a89d09827986bc4e859bcabd64f5a0662a7fe95e0d359424e0e071b"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5a0f54ce2c092aaf439813735584b9537cad479575a09892b8352fea5e988dc0"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2cf71233a0ed05ccdabe209c606fe0bac7379fdcf687f39b944420d2a09fdb57"}, + {file = "wrapt-1.14.1-cp38-cp38-win32.whl", hash = "sha256:aa31fdcc33fef9eb2552cbcbfee7773d5a6792c137b359e82879c101e98584c5"}, + {file = "wrapt-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:d1967f46ea8f2db647c786e78d8cc7e4313dbd1b0aca360592d8027b8508e24d"}, + {file = "wrapt-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3232822c7d98d23895ccc443bbdf57c7412c5a65996c30442ebe6ed3df335383"}, + {file = "wrapt-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:988635d122aaf2bdcef9e795435662bcd65b02f4f4c1ae37fbee7401c440b3a7"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cca3c2cdadb362116235fdbd411735de4328c61425b0aa9f872fd76d02c4e86"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d52a25136894c63de15a35bc0bdc5adb4b0e173b9c0d07a2be9d3ca64a332735"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40e7bc81c9e2b2734ea4bc1aceb8a8f0ceaac7c5299bc5d69e37c44d9081d43b"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b9b7a708dd92306328117d8c4b62e2194d00c365f18eff11a9b53c6f923b01e3"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6a9a25751acb379b466ff6be78a315e2b439d4c94c1e99cb7266d40a537995d3"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:34aa51c45f28ba7f12accd624225e2b1e5a3a45206aa191f6f9aac931d9d56fe"}, + {file = "wrapt-1.14.1-cp39-cp39-win32.whl", hash = "sha256:dee0ce50c6a2dd9056c20db781e9c1cfd33e77d2d569f5d1d9321c641bb903d5"}, + {file = "wrapt-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:dee60e1de1898bde3b238f18340eec6148986da0455d8ba7848d50470a7a32fb"}, + {file = "wrapt-1.14.1.tar.gz", hash = "sha256:380a85cf89e0e69b7cfbe2ea9f765f004ff419f34194018a6827ac0e3edfed4d"}, +] + +[[package]] +name = "xmltodict" +version = "0.13.0" +description = "Makes working with XML feel like you are working with JSON" +optional = false +python-versions = ">=3.4" +files = [ + {file = "xmltodict-0.13.0-py2.py3-none-any.whl", hash = "sha256:aa89e8fd76320154a40d19a0df04a4695fb9dc5ba977cbb68ab3e4eb225e7852"}, + {file = "xmltodict-0.13.0.tar.gz", hash = "sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56"}, +] + +[[package]] +name = "yapf" +version = "0.40.1" +description = "A formatter for Python code." +optional = false +python-versions = ">=3.7" +files = [ + {file = "yapf-0.40.1-py3-none-any.whl", hash = "sha256:b8bfc1f280949153e795181768ca14ef43d7312629a06c43e7abd279323af313"}, + {file = "yapf-0.40.1.tar.gz", hash = "sha256:958587eb5c8ec6c860119a9c25d02addf30a44f75aa152a4220d30e56a98037c"}, +] + +[package.dependencies] +importlib-metadata = ">=6.6.0" +platformdirs = ">=3.5.1" +tomli = ">=2.0.1" + +[[package]] +name = "yarl" +version = "1.9.2" +description = "Yet another URL library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "yarl-1.9.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8c2ad583743d16ddbdf6bb14b5cd76bf43b0d0006e918809d5d4ddf7bde8dd82"}, + {file = "yarl-1.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:82aa6264b36c50acfb2424ad5ca537a2060ab6de158a5bd2a72a032cc75b9eb8"}, + {file = "yarl-1.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c0c77533b5ed4bcc38e943178ccae29b9bcf48ffd1063f5821192f23a1bd27b9"}, + {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee4afac41415d52d53a9833ebae7e32b344be72835bbb589018c9e938045a560"}, + {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9bf345c3a4f5ba7f766430f97f9cc1320786f19584acc7086491f45524a551ac"}, + {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a96c19c52ff442a808c105901d0bdfd2e28575b3d5f82e2f5fd67e20dc5f4ea"}, + {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:891c0e3ec5ec881541f6c5113d8df0315ce5440e244a716b95f2525b7b9f3608"}, + {file = "yarl-1.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c3a53ba34a636a256d767c086ceb111358876e1fb6b50dfc4d3f4951d40133d5"}, + {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:566185e8ebc0898b11f8026447eacd02e46226716229cea8db37496c8cdd26e0"}, + {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:2b0738fb871812722a0ac2154be1f049c6223b9f6f22eec352996b69775b36d4"}, + {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:32f1d071b3f362c80f1a7d322bfd7b2d11e33d2adf395cc1dd4df36c9c243095"}, + {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:e9fdc7ac0d42bc3ea78818557fab03af6181e076a2944f43c38684b4b6bed8e3"}, + {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:56ff08ab5df8429901ebdc5d15941b59f6253393cb5da07b4170beefcf1b2528"}, + {file = "yarl-1.9.2-cp310-cp310-win32.whl", hash = "sha256:8ea48e0a2f931064469bdabca50c2f578b565fc446f302a79ba6cc0ee7f384d3"}, + {file = "yarl-1.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:50f33040f3836e912ed16d212f6cc1efb3231a8a60526a407aeb66c1c1956dde"}, + {file = "yarl-1.9.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:646d663eb2232d7909e6601f1a9107e66f9791f290a1b3dc7057818fe44fc2b6"}, + {file = "yarl-1.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aff634b15beff8902d1f918012fc2a42e0dbae6f469fce134c8a0dc51ca423bb"}, + {file = "yarl-1.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a83503934c6273806aed765035716216cc9ab4e0364f7f066227e1aaea90b8d0"}, + {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b25322201585c69abc7b0e89e72790469f7dad90d26754717f3310bfe30331c2"}, + {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:22a94666751778629f1ec4280b08eb11815783c63f52092a5953faf73be24191"}, + {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ec53a0ea2a80c5cd1ab397925f94bff59222aa3cf9c6da938ce05c9ec20428d"}, + {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:159d81f22d7a43e6eabc36d7194cb53f2f15f498dbbfa8edc8a3239350f59fe7"}, + {file = "yarl-1.9.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:832b7e711027c114d79dffb92576acd1bd2decc467dec60e1cac96912602d0e6"}, + {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:95d2ecefbcf4e744ea952d073c6922e72ee650ffc79028eb1e320e732898d7e8"}, + {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d4e2c6d555e77b37288eaf45b8f60f0737c9efa3452c6c44626a5455aeb250b9"}, + {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:783185c75c12a017cc345015ea359cc801c3b29a2966c2655cd12b233bf5a2be"}, + {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:b8cc1863402472f16c600e3e93d542b7e7542a540f95c30afd472e8e549fc3f7"}, + {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:822b30a0f22e588b32d3120f6d41e4ed021806418b4c9f0bc3048b8c8cb3f92a"}, + {file = "yarl-1.9.2-cp311-cp311-win32.whl", hash = "sha256:a60347f234c2212a9f0361955007fcf4033a75bf600a33c88a0a8e91af77c0e8"}, + {file = "yarl-1.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:be6b3fdec5c62f2a67cb3f8c6dbf56bbf3f61c0f046f84645cd1ca73532ea051"}, + {file = "yarl-1.9.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:38a3928ae37558bc1b559f67410df446d1fbfa87318b124bf5032c31e3447b74"}, + {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac9bb4c5ce3975aeac288cfcb5061ce60e0d14d92209e780c93954076c7c4367"}, + {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3da8a678ca8b96c8606bbb8bfacd99a12ad5dd288bc6f7979baddd62f71c63ef"}, + {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13414591ff516e04fcdee8dc051c13fd3db13b673c7a4cb1350e6b2ad9639ad3"}, + {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf74d08542c3a9ea97bb8f343d4fcbd4d8f91bba5ec9d5d7f792dbe727f88938"}, + {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e7221580dc1db478464cfeef9b03b95c5852cc22894e418562997df0d074ccc"}, + {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:494053246b119b041960ddcd20fd76224149cfea8ed8777b687358727911dd33"}, + {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:52a25809fcbecfc63ac9ba0c0fb586f90837f5425edfd1ec9f3372b119585e45"}, + {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:e65610c5792870d45d7b68c677681376fcf9cc1c289f23e8e8b39c1485384185"}, + {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:1b1bba902cba32cdec51fca038fd53f8beee88b77efc373968d1ed021024cc04"}, + {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:662e6016409828ee910f5d9602a2729a8a57d74b163c89a837de3fea050c7582"}, + {file = "yarl-1.9.2-cp37-cp37m-win32.whl", hash = "sha256:f364d3480bffd3aa566e886587eaca7c8c04d74f6e8933f3f2c996b7f09bee1b"}, + {file = "yarl-1.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6a5883464143ab3ae9ba68daae8e7c5c95b969462bbe42e2464d60e7e2698368"}, + {file = "yarl-1.9.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5610f80cf43b6202e2c33ba3ec2ee0a2884f8f423c8f4f62906731d876ef4fac"}, + {file = "yarl-1.9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b9a4e67ad7b646cd6f0938c7ebfd60e481b7410f574c560e455e938d2da8e0f4"}, + {file = "yarl-1.9.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:83fcc480d7549ccebe9415d96d9263e2d4226798c37ebd18c930fce43dfb9574"}, + {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fcd436ea16fee7d4207c045b1e340020e58a2597301cfbcfdbe5abd2356c2fb"}, + {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84e0b1599334b1e1478db01b756e55937d4614f8654311eb26012091be109d59"}, + {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3458a24e4ea3fd8930e934c129b676c27452e4ebda80fbe47b56d8c6c7a63a9e"}, + {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:838162460b3a08987546e881a2bfa573960bb559dfa739e7800ceeec92e64417"}, + {file = "yarl-1.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f4e2d08f07a3d7d3e12549052eb5ad3eab1c349c53ac51c209a0e5991bbada78"}, + {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:de119f56f3c5f0e2fb4dee508531a32b069a5f2c6e827b272d1e0ff5ac040333"}, + {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:149ddea5abf329752ea5051b61bd6c1d979e13fbf122d3a1f9f0c8be6cb6f63c"}, + {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:674ca19cbee4a82c9f54e0d1eee28116e63bc6fd1e96c43031d11cbab8b2afd5"}, + {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:9b3152f2f5677b997ae6c804b73da05a39daa6a9e85a512e0e6823d81cdad7cc"}, + {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5415d5a4b080dc9612b1b63cba008db84e908b95848369aa1da3686ae27b6d2b"}, + {file = "yarl-1.9.2-cp38-cp38-win32.whl", hash = "sha256:f7a3d8146575e08c29ed1cd287068e6d02f1c7bdff8970db96683b9591b86ee7"}, + {file = "yarl-1.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:63c48f6cef34e6319a74c727376e95626f84ea091f92c0250a98e53e62c77c72"}, + {file = "yarl-1.9.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:75df5ef94c3fdc393c6b19d80e6ef1ecc9ae2f4263c09cacb178d871c02a5ba9"}, + {file = "yarl-1.9.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c027a6e96ef77d401d8d5a5c8d6bc478e8042f1e448272e8d9752cb0aff8b5c8"}, + {file = "yarl-1.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3b078dbe227f79be488ffcfc7a9edb3409d018e0952cf13f15fd6512847f3f7"}, + {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59723a029760079b7d991a401386390c4be5bfec1e7dd83e25a6a0881859e716"}, + {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b03917871bf859a81ccb180c9a2e6c1e04d2f6a51d953e6a5cdd70c93d4e5a2a"}, + {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1012fa63eb6c032f3ce5d2171c267992ae0c00b9e164efe4d73db818465fac3"}, + {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a74dcbfe780e62f4b5a062714576f16c2f3493a0394e555ab141bf0d746bb955"}, + {file = "yarl-1.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c56986609b057b4839968ba901944af91b8e92f1725d1a2d77cbac6972b9ed1"}, + {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2c315df3293cd521033533d242d15eab26583360b58f7ee5d9565f15fee1bef4"}, + {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:b7232f8dfbd225d57340e441d8caf8652a6acd06b389ea2d3222b8bc89cbfca6"}, + {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:53338749febd28935d55b41bf0bcc79d634881195a39f6b2f767870b72514caf"}, + {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:066c163aec9d3d073dc9ffe5dd3ad05069bcb03fcaab8d221290ba99f9f69ee3"}, + {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8288d7cd28f8119b07dd49b7230d6b4562f9b61ee9a4ab02221060d21136be80"}, + {file = "yarl-1.9.2-cp39-cp39-win32.whl", hash = "sha256:b124e2a6d223b65ba8768d5706d103280914d61f5cae3afbc50fc3dfcc016623"}, + {file = "yarl-1.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:61016e7d582bc46a5378ffdd02cd0314fb8ba52f40f9cf4d9a5e7dbef88dee18"}, + {file = "yarl-1.9.2.tar.gz", hash = "sha256:04ab9d4b9f587c06d801c2abfe9317b77cdf996c65a90d5e84ecc45010823571"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" + +[[package]] +name = "zipp" +version = "3.15.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.7" +files = [ + {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, + {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] + +[extras] +full = ["jieba", "sentencepiece", "spacy", "spacy", "transformers"] +gh-release-notes = ["github3.py"] +jieba = ["jieba"] +metal = ["tensorflow-metal"] +spacy = ["spacy", "spacy"] +transformers = ["sentencepiece", "transformers"] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.8,<3.11" +content-hash = "c1c51259ab3b886039dcf7eb746a45815d4b8afcaa4bdbe179c891810aee553f" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c37977e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,344 @@ +[build-system] +requires = [ "poetry-core>=1.0.4",] +build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 88 +target-version = [ "py38", "py39", "py310",] +exclude = "((.eggs | .git | .pytest_cache | build | dist))" + +[tool.poetry] +name = "rasa" +version = "3.6.21" +description = "Open source machine learning framework to automate text- and voice-based conversations: NLU, dialogue management, connect to Slack, Facebook, and more - Create chatbots and voice assistants" +authors = [ "Rasa Technologies GmbH <hi@rasa.com>",] +maintainers = [ "Tom Bocklisch <tom@rasa.com>",] +homepage = "https://rasa.com" +repository = "https://github.com/rasahq/rasa" +documentation = "https://rasa.com/docs" +classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Topic :: Software Development :: Libraries",] +keywords = [ "nlp", "machine-learning", "machine-learning-library", "bot", "bots", "botkit", "rasa conversational-agents", "conversational-ai", "chatbot", "chatbot-framework", "bot-framework",] +include = [ "LICENSE.txt", "README.md", "rasa/shared/core/training_data/visualization.html", "rasa/cli/default_config.yml", "rasa/shared/importers/*", "rasa/utils/schemas/*", "rasa/keys",] +readme = "README.md" +license = "Apache-2.0" +[[tool.poetry.source]] +name = "internal repository mirroring psycopg binary for macos" +url = "https://europe-west3-python.pkg.dev/rasa-releases/psycopg-binary/simple/" +priority = "supplemental" + +[tool.towncrier] +package = "rasa" +package_dir = "rasa" +filename = "CHANGELOG.mdx" +directory = "./changelog" +underlines = " " +title_format = "## [{version}] - {project_date}" +template = "./changelog/_template.md.jinja2" +start_string = "<!-- TOWNCRIER -->\n" +issue_format = "[#{issue}](https://github.com/rasahq/rasa/issues/{issue})" +[[tool.towncrier.type]] +directory = "removal" +name = "Deprecations and Removals" +showcontent = true + +[[tool.towncrier.type]] +directory = "feature" +name = "Features" +showcontent = true + +[[tool.towncrier.type]] +directory = "improvement" +name = "Improvements" +showcontent = true + +[[tool.towncrier.type]] +directory = "bugfix" +name = "Bugfixes" +showcontent = true + +[[tool.towncrier.type]] +directory = "doc" +name = "Improved Documentation" +showcontent = true + +[[tool.towncrier.type]] +directory = "misc" +name = "Miscellaneous internal changes" +showcontent = false + +[tool.mypy] +mypy_path = "./stubs" +ignore_missing_imports = true +show_error_codes = true +warn_redundant_casts = true +warn_unused_ignores = true +disallow_untyped_calls = true +disallow_incomplete_defs = true +disallow_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = false +disable_error_code = [ "arg-type",] + +[tool.bandit] +skips = [ "B104", "B301",] + +[tool.ruff] +ignore = [ "D100", "D104", "D105", "RUF001", "RUF002", "RUF003", "RUF005",] +line-length = 88 +select = [ "D", "E", "F", "W", "RUF",] + +[tool.poetry.dependencies] +python = ">=3.8,<3.11" +boto3 = "^1.26.136" +requests = "^2.23" +matplotlib = ">=3.1,<3.6" +attrs = ">=19.3,<22.2" +jsonpickle = ">=1.3,<3.1" +redis = ">=4.5.3, <5.0" +absl-py = ">=0.9,<1.5" +apscheduler = ">=3.6,<3.10" +tqdm = "^4.31" +networkx = ">=2.4,<2.7" +fbmessenger = "~6.0.0" +pykwalify = ">=1.7,<1.9" +coloredlogs = ">=10,<16" +"ruamel.yaml" = ">=0.16.5,<0.17.22" +pyyaml = ">=6.0" +twilio = ">=6.26,<8.3" +webexteamssdk = ">=1.1.1,<1.7.0" +mattermostwrapper = "~2.2" +rocketchat_API = ">=0.6.31,<1.31.0" +colorhash = ">=1.0.2,<1.3.0" +jsonschema = ">=3.2,<4.18" +packaging = ">=20.0,<21.0" +pytz = ">=2019.1,<2023.0" +rasa-sdk = "~3.6.2" +colorclass = "~2.2" +terminaltables = "~3.1.0" +sanic = "~21.12" +sanic-cors = "~2.0.0" +sanic-jwt = "^1.6.0" +sanic-routing = "^0.7.2" +websockets = ">=10.0,<11.0" +aiohttp = ">=3.9.0,<3.10" +questionary = ">=1.5.1,<1.11.0" +prompt-toolkit = "^3.0,<3.0.29" +python-socketio = ">=4.4,<6" +python-engineio = ">=4,<6,!=5.0.0" +pydot = "~1.4" +SQLAlchemy = ">=1.4.0,<1.5.0" +sklearn-crfsuite = "~0.3" +psycopg2-binary = ">=2.8.2,<2.10.0" +python-dateutil = "~2.8" +protobuf = ">=4.23.3,< 4.23.4" +tensorflow_hub = "^0.13.0" +setuptools = "~70.3.0" +ujson = ">=1.35,<6.0" +regex = ">=2020.6,<2022.11" +sentry-sdk = ">=0.17.0,<1.15.0" +aio-pika = ">=6.7.1,<8.2.4" +aiogram = "<2.26" +typing-extensions = ">=4.1.1,<5.0.0" +typing-utils = "^0.1.0" +tarsafe = ">=0.0.3,<0.0.5" +google-auth = "<3" +CacheControl = "^0.12.9" +randomname = "^0.1.5" +pluggy = "^1.0.0" +slack-sdk = "^3.19.2" +confluent-kafka = ">=1.9.2,<3.0.0" +portalocker = "^2.7.0" +structlog = "^23.1.0" +structlog-sentry = "^2.0.2" +dnspython = "2.3.0" +wheel = ">=0.38.1" +certifi = ">=2023.7.22" +cryptography = ">=41.0.7" +skops = "0.9.0" +safetensors = "~0.4.5" +[[tool.poetry.dependencies.tensorflow-io-gcs-filesystem]] +version = "==0.31" +markers = "sys_platform == 'win32'" + +[[tool.poetry.dependencies.tensorflow-io-gcs-filesystem]] +version = "==0.32" +markers = "sys_platform == 'linux'" + +[[tool.poetry.dependencies.tensorflow-io-gcs-filesystem]] +version = "==0.32" +markers = "sys_platform == 'darwin' and platform_machine != 'arm64'" + +[[tool.poetry.dependencies.dask]] +version = "2022.2.0" +python = "~=3.7.0" + +[[tool.poetry.dependencies.dask]] +version = "2022.10.2" +python = ">=3.8,<3.11" + +[[tool.poetry.dependencies.numpy]] +version = ">=1.19.2,<1.22.0" +python = "~=3.7.0" + +[[tool.poetry.dependencies.numpy]] +version = ">=1.19.2,<1.25.0" +python = ">=3.8,<3.11" + +[[tool.poetry.dependencies.numpy]] +version = "1.22.3" +markers = "sys_platform =='Windows' and platform_python_implementation != 'PyPy'" +python = "3.10" + +[[tool.poetry.dependencies.scipy]] +version = ">=1.4.1,<1.7.3" +python = "~=3.7.0" + +[[tool.poetry.dependencies.scipy]] +version = ">=1.10.0,<1.11.0" +python = ">=3.8,<3.11" + +[[tool.poetry.dependencies.scikit-learn]] +version = ">=0.22,<1.1" +python = "~=3.7.0" + +[[tool.poetry.dependencies.scikit-learn]] +version = ">=0.22,<1.2" +python = ">=3.8,<3.11" + +[[tool.poetry.dependencies.spacy]] +version = "^3.4" +markers = "sys_platform == 'darwin' and platform_machine == 'arm64'" +optional = true + +[[tool.poetry.dependencies.spacy]] +version = ">=3.1,<3.5" +markers = "sys_platform != 'darwin' or platform_machine != 'arm64'" +optional = true + +[[tool.poetry.dependencies.pydantic]] +version = "<1.10.10" +optional = true + +[tool.poetry.extras] +spacy = [ "spacy",] +jieba = [ "jieba",] +transformers = [ "transformers", "sentencepiece",] +full = [ "spacy", "transformers", "sentencepiece", "jieba",] +gh-release-notes = [ "github3.py",] +metal = [ "tensorflow-metal",] + +[tool.poetry.scripts] +rasa = "rasa.__main__:main" + +[tool.ruff.pydocstyle] +convention = "google" + +[tool.ruff.per-file-ignores] +"tests/test_telemetry.py" = [ "E501",] +"tests/shared/core/test_domain.py" = [ "E501",] + +[tool.pytest.ini_options] +python_functions = "test_" +filterwarnings = [ "ignore::ResourceWarning:ruamel[.*]",] +log_cli_level = "WARNING" +log_cli = true +markers = [ "skip_on_windows", "skip_on_ci", "sequential", "category_cli", "category_core_featurizers", "category_policies", "category_nlu_featurizers", "category_nlu_predictors", "category_full_model_training", "category_other_unit_tests", "category_performance", "flaky",] +timeout = 60 +timeout_func_only = true +asyncio_mode = "auto" + +[tool.poetry.dependencies.tensorflow] +version = "2.12.0" +markers = "sys_platform != 'darwin' or platform_machine != 'arm64'" + +[tool.poetry.dependencies.tensorflow-intel] +version = "2.12.0" +markers = "sys_platform == 'win32'" + +[tool.poetry.dependencies.tensorflow-cpu-aws] +version = "2.12.0" +markers = "sys_platform == 'linux' and (platform_machine == 'arm64' or platform_machine == 'aarch64')" + +[tool.poetry.dependencies.tensorflow-macos] +version = "2.12.0" +markers = "sys_platform == 'darwin' and platform_machine == 'arm64'" + +[tool.poetry.dependencies.PyJWT] +version = "^2.0.0" +extras = [ "crypto",] + +[tool.poetry.dependencies.colorama] +version = "^0.4.4" +markers = "sys_platform == 'win32'" + +[tool.poetry.dependencies.tensorflow-metal] +version = "0.8.0" +markers = "sys_platform == 'darwin' and platform_machine == 'arm64'" +optional = true + +[tool.poetry.dependencies.tensorflow-text] +version = "2.12.0" +markers = "sys_platform != 'win32' and platform_machine != 'arm64' and platform_machine != 'aarch64'" + +[tool.poetry.dependencies."github3.py"] +version = "~3.2.0" +optional = true + +[tool.poetry.dependencies.transformers] +version = "~4.36.2" +optional = true + +[tool.poetry.dependencies.sentencepiece] +version = "~0.1.99" +optional = true +extras = [ "sentencepiece",] + +[tool.poetry.dependencies.jieba] +version = ">=0.39, <0.43" +optional = true + +[tool.poetry.dependencies.pymongo] +version = ">=3.8,<4.4" +extras = [ "tls", "srv",] + +[tool.poetry.dev-dependencies.pytest-sanic] +git = "https://github.com/RasaHQ/pytest-sanic" +branch = "fix_signal_issue" + +[tool.poetry.group.dev.dependencies] +ruff = ">=0.0.255,<0.0.256" +docker = "^6.0.1" +pytest-cov = "^4.0.0" +pytest-asyncio = "^0.20.0" +pytest-xdist = "^3.2.1" +pytest = "^7.1.3" +freezegun = "^1.0.0" +responses = "^0.22.0" +aioresponses = "^0.7.6" +moto = "~=4.1.2" +fakeredis = "^2.11.2" +mongomock = "^4.1.2" +black = "^22.10.0" +google-cloud-storage = "^2.4.0" +azure-storage-blob = "<12.16.0" +coveralls = "^3.0.1" +towncrier = "^22.8.0" +toml = "^0.10.0" +pep440-version-utils = "^0.3.0" +pydoc-markdown = "^4.5.1" +pytest-timeout = "^2.1.0" +mypy = "^1.0.0" +bandit = "^1.6.3" +types-pytz = "^2022.1.1" +types-python-dateutil = "^2.8.19" +types-requests = "^2.25.0" +types-setuptools = "^67.2.0" +memory-profiler = "^0.61.0" +psutil = "^5.8.0" +mypy-extensions = "^0.4.3" +sanic-testing = ">=21.12.0,<22.9.0" +analytics-python = "^1.4.0" +datadog-api-client = "^2.0.0" +datadog = "^0.45.0" +types-redis = "^4.3.20" +httpx = "0.23.3" diff --git a/rasa/__init__.py b/rasa/__init__.py new file mode 100644 index 0000000..36ff235 --- /dev/null +++ b/rasa/__init__.py @@ -0,0 +1,10 @@ +import logging + +from rasa import version, plugin # noqa: F401 +from rasa.api import run, train, test # noqa: F401 + +# define the version before the other imports since these need it +__version__ = version.__version__ + + +logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/rasa/__main__.py b/rasa/__main__.py new file mode 100644 index 0000000..2132e90 --- /dev/null +++ b/rasa/__main__.py @@ -0,0 +1,151 @@ +import argparse +import logging +import os +import platform +import sys + +from rasa_sdk import __version__ as rasa_sdk_version +from rasa.constants import MINIMUM_COMPATIBLE_VERSION +from rasa.utils.log_utils import configure_structlog + +import rasa.telemetry +import rasa.utils.io +import rasa.utils.tensorflow.environment as tf_env +from rasa import version +from rasa.cli import ( + data, + export, + interactive, + run, + scaffold, + shell, + telemetry, + test, + train, + visualize, + x, + evaluate, +) +from rasa.cli.arguments.default_arguments import add_logging_options +from rasa.cli.utils import parse_last_positional_argument_as_model_path +from rasa.plugin import plugin_manager +from rasa.shared.exceptions import RasaException +from rasa.shared.utils.cli import print_error +from rasa.utils.common import configure_logging_and_warnings + +logger = logging.getLogger(__name__) + + +def create_argument_parser() -> argparse.ArgumentParser: + """Parse all the command line arguments for the training script.""" + parser = argparse.ArgumentParser( + prog="rasa", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + description="Rasa command line interface. Rasa allows you to build " + "your own conversational assistants 🤖. The 'rasa' command " + "allows you to easily run most common commands like " + "creating a new bot, training or evaluating models.", + ) + + parser.add_argument( + "--version", + action="store_true", + default=argparse.SUPPRESS, + help="Print installed Rasa version", + ) + + parent_parser = argparse.ArgumentParser(add_help=False) + add_logging_options(parent_parser) + parent_parsers = [parent_parser] + + subparsers = parser.add_subparsers(help="Rasa commands") + + scaffold.add_subparser(subparsers, parents=parent_parsers) + run.add_subparser(subparsers, parents=parent_parsers) + shell.add_subparser(subparsers, parents=parent_parsers) + train.add_subparser(subparsers, parents=parent_parsers) + interactive.add_subparser(subparsers, parents=parent_parsers) + telemetry.add_subparser(subparsers, parents=parent_parsers) + test.add_subparser(subparsers, parents=parent_parsers) + visualize.add_subparser(subparsers, parents=parent_parsers) + data.add_subparser(subparsers, parents=parent_parsers) + export.add_subparser(subparsers, parents=parent_parsers) + x.add_subparser(subparsers, parents=parent_parsers) + evaluate.add_subparser(subparsers, parents=parent_parsers) + plugin_manager().hook.refine_cli( + subparsers=subparsers, parent_parsers=parent_parsers + ) + + return parser + + +def print_version() -> None: + """Prints version information of rasa tooling and python.""" + print(f"Rasa Version : {version.__version__}") + print(f"Minimum Compatible Version: {MINIMUM_COMPATIBLE_VERSION}") + print(f"Rasa SDK Version : {rasa_sdk_version}") + print(f"Python Version : {platform.python_version()}") + print(f"Operating System : {platform.platform()}") + print(f"Python Path : {sys.executable}") + + result = plugin_manager().hook.get_version_info() + if result: + print(f"\t{result[0][0]} : {result[0][1]}") + + +def main() -> None: + """Run as standalone python application.""" + parse_last_positional_argument_as_model_path() + arg_parser = create_argument_parser() + cmdline_arguments = arg_parser.parse_args() + + log_level = getattr(cmdline_arguments, "loglevel", None) + logging_config_file = getattr(cmdline_arguments, "logging_config_file", None) + configure_logging_and_warnings( + log_level, logging_config_file, warn_only_once=True, filter_repeated_logs=True + ) + + tf_env.setup_tf_environment() + tf_env.check_deterministic_ops() + + # insert current path in syspath so custom modules are found + sys.path.insert(1, os.getcwd()) + + try: + if hasattr(cmdline_arguments, "func"): + rasa.utils.io.configure_colored_logging(log_level) + + result = plugin_manager().hook.configure_commandline( + cmdline_arguments=cmdline_arguments + ) + endpoints_file = result[0] if result else None + + rasa.telemetry.initialize_telemetry() + rasa.telemetry.initialize_error_reporting() + plugin_manager().hook.init_telemetry(endpoints_file=endpoints_file) + plugin_manager().hook.init_managers(endpoints_file=endpoints_file) + plugin_manager().hook.init_anonymization_pipeline( + endpoints_file=endpoints_file + ) + # configure structlog + configure_structlog(log_level) + + cmdline_arguments.func(cmdline_arguments) + elif hasattr(cmdline_arguments, "version"): + print_version() + else: + # user has not provided a subcommand, let's print the help + logger.error("No command specified.") + arg_parser.print_help() + sys.exit(1) + except RasaException as e: + # these are exceptions we expect to happen (e.g. invalid training data format) + # it doesn't make sense to print a stacktrace for these if we are not in + # debug mode + logger.debug("Failed to run CLI command due to an exception.", exc_info=e) + print_error(f"{e.__class__.__name__}: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/rasa/api.py b/rasa/api.py new file mode 100644 index 0000000..d946f63 --- /dev/null +++ b/rasa/api.py @@ -0,0 +1,144 @@ +from typing import Any, Text, Dict, Union, List, Optional, TYPE_CHECKING + +import rasa.shared.constants + +# WARNING: Be careful about adding any top level imports at this place! +# These functions are imported in `rasa.__init__` and any top level import +# added here will get executed as soon as someone runs `import rasa`. +# Some imports are very slow (e.g. `tensorflow`) and we want them to get +# imported when running `import rasa`. If you add more imports here, +# please check that in the chain you are importing, no slow packages +# are getting imported. + +if TYPE_CHECKING: + from rasa.model_training import TrainingResult + + +def run( + model: "Text", + endpoints: "Text", + connector: "Text" = None, + credentials: "Text" = None, + **kwargs: "Dict[Text, Any]", +) -> None: + """Runs a Rasa model. + + Args: + model: Path to model archive. + endpoints: Path to endpoints file. + connector: Connector which should be use (overwrites `credentials` + field). + credentials: Path to channel credentials file. + **kwargs: Additional arguments which are passed to + `rasa.core.run.serve_application`. + + """ + import rasa.core.run + from rasa.core.utils import AvailableEndpoints + from rasa.shared.utils.cli import print_warning + import rasa.shared.utils.common + from rasa.shared.constants import DOCS_BASE_URL + + _endpoints = AvailableEndpoints.read_endpoints(endpoints) + + if not connector and not credentials: + connector = "rest" + + print_warning( + f"No chat connector configured, falling back to the " + f"REST input channel. To connect your bot to another channel, " + f"read the docs here: {DOCS_BASE_URL}/messaging-and-voice-channels" + ) + + kwargs = rasa.shared.utils.common.minimal_kwargs( + kwargs, rasa.core.run.serve_application + ) + rasa.core.run.serve_application( + model, + channel=connector, + credentials=credentials, + endpoints=_endpoints, + **kwargs, + ) + + +def train( + domain: "Text", + config: "Text", + training_files: "Union[Text, List[Text]]", + output: "Text" = rasa.shared.constants.DEFAULT_MODELS_PATH, + dry_run: bool = False, + force_training: bool = False, + fixed_model_name: "Optional[Text]" = None, + persist_nlu_training_data: bool = False, + core_additional_arguments: "Optional[Dict]" = None, + nlu_additional_arguments: "Optional[Dict]" = None, + model_to_finetune: "Optional[Text]" = None, + finetuning_epoch_fraction: float = 1.0, +) -> "TrainingResult": + """Runs Rasa Core and NLU training in `async` loop. + + Args: + domain: Path to the domain file. + config: Path to the config for Core and NLU. + training_files: Paths to the training data for Core and NLU. + output: Output path. + dry_run: If `True` then no training will be done, and the information about + whether the training needs to be done will be printed. + force_training: If `True` retrain model even if data has not changed. + fixed_model_name: Name of model to be stored. + persist_nlu_training_data: `True` if the NLU training data should be persisted + with the model. + core_additional_arguments: Additional training parameters for core training. + nlu_additional_arguments: Additional training parameters forwarded to training + method of each NLU component. + model_to_finetune: Optional path to a model which should be finetuned or + a directory in case the latest trained model should be used. + finetuning_epoch_fraction: The fraction currently specified training epochs + in the model configuration which should be used for finetuning. + + Returns: + An instance of `TrainingResult`. + """ + from rasa.model_training import train + + return train( + domain=domain, + config=config, + training_files=training_files, + output=output, + dry_run=dry_run, + force_training=force_training, + fixed_model_name=fixed_model_name, + persist_nlu_training_data=persist_nlu_training_data, + core_additional_arguments=core_additional_arguments, + nlu_additional_arguments=nlu_additional_arguments, + model_to_finetune=model_to_finetune, + finetuning_epoch_fraction=finetuning_epoch_fraction, + ) + + +def test( + model: "Text", + stories: "Text", + nlu_data: "Text", + output: "Text" = rasa.shared.constants.DEFAULT_RESULTS_PATH, + additional_arguments: "Optional[Dict]" = None, +) -> None: + """Test a Rasa model against a set of test data. + + Args: + model: model to test + stories: path to the dialogue test data + nlu_data: path to the NLU test data + output: path to folder where all output will be stored + additional_arguments: additional arguments for the test call + """ + from rasa.model_testing import test_core + from rasa.model_testing import test_nlu + + if additional_arguments is None: + additional_arguments = {} + + test_core(model, stories, output, additional_arguments) # type: ignore[unused-coroutine] # noqa: E501 + test_nlu(model, nlu_data, output, additional_arguments) # type: ignore[unused-coroutine] # noqa: E501 diff --git a/rasa/cli/__init__.py b/rasa/cli/__init__.py new file mode 100644 index 0000000..8330f4e --- /dev/null +++ b/rasa/cli/__init__.py @@ -0,0 +1,5 @@ +import argparse + +# skipcq:PYL-W0212 +# noinspection PyProtectedMember +SubParsersAction = argparse._SubParsersAction diff --git a/rasa/cli/arguments/__init__.py b/rasa/cli/arguments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/cli/arguments/data.py b/rasa/cli/arguments/data.py new file mode 100644 index 0000000..20df03d --- /dev/null +++ b/rasa/cli/arguments/data.py @@ -0,0 +1,81 @@ +import argparse +from typing import Text + +from rasa.cli.arguments.default_arguments import ( + add_nlu_data_param, + add_out_param, + add_data_param, + add_domain_param, +) +from rasa.shared.constants import DEFAULT_CONVERTED_DATA_PATH + + +def set_convert_arguments(parser: argparse.ArgumentParser, data_type: Text) -> None: + """Sets convert command arguments.""" + parser.add_argument( + "-f", + "--format", + default="yaml", + choices=["json", "yaml"], + help="Output format the training data should be converted into.", + ) + + add_data_param(parser, required=True, data_type=data_type) + + add_out_param( + parser, + default=DEFAULT_CONVERTED_DATA_PATH, + help_text="File (for `json`) or existing path (for `yaml`) " + "where to save training data in Rasa format.", + ) + + parser.add_argument("-l", "--language", default="en", help="Language of data.") + + +def set_split_arguments(parser: argparse.ArgumentParser) -> None: + add_nlu_data_param(parser, help_text="File or folder containing your NLU data.") + + parser.add_argument( + "--training-fraction", + type=float, + default=0.8, + help="Percentage of the data which should be in the training data.", + ) + + parser.add_argument( + "--random-seed", + type=int, + default=None, + help="Seed to generate the same train/test split.", + ) + + add_out_param( + parser, + default="train_test_split", + help_text="Directory where the split files should be stored.", + ) + + +def set_validator_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--fail-on-warnings", + default=False, + action="store_true", + help="Fail validation on warnings and errors. " + "If omitted only errors will result in a non zero exit code.", + ) + add_domain_param(parser) + add_data_param(parser) + + +def set_migrate_arguments(parser: argparse.ArgumentParser) -> None: + """Sets migrate command arguments.""" + add_domain_param(parser) + + add_out_param( + parser, + default=None, + help_text="Path (for `yaml`) where to save migrated domain in Rasa 3.0 format." + "If none is specified, either a `new_domain.yml` file or `new_domain` folder " + "will be created in the folder that contains the given domain.", + ) diff --git a/rasa/cli/arguments/default_arguments.py b/rasa/cli/arguments/default_arguments.py new file mode 100644 index 0000000..1190b6e --- /dev/null +++ b/rasa/cli/arguments/default_arguments.py @@ -0,0 +1,165 @@ +import argparse +import logging +from typing import Text, Union, Optional + +from rasa.shared.constants import ( + DEFAULT_CONFIG_PATH, + DEFAULT_DOMAIN_PATH, + DEFAULT_MODELS_PATH, + DEFAULT_DATA_PATH, + DEFAULT_ENDPOINTS_PATH, +) + + +def add_model_param( + parser: argparse.ArgumentParser, + model_name: Text = "Rasa", + add_positional_arg: bool = True, + default: Optional[Text] = DEFAULT_MODELS_PATH, +) -> None: + help_text = ( + "Path to a trained {} model. If a directory is specified, it will " + "use the latest model in this directory.".format(model_name) + ) + parser.add_argument("-m", "--model", type=str, default=default, help=help_text) + if add_positional_arg: + parser.add_argument( + "model-as-positional-argument", nargs="?", type=str, help=help_text + ) + + +def add_stories_param( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer], + stories_name: Text = "training", +) -> None: + parser.add_argument( + "-s", + "--stories", + type=str, + default=DEFAULT_DATA_PATH, + help=f"File or folder containing your {stories_name} stories.", + ) + + +def add_nlu_data_param( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer], + help_text: Text, + default: Optional[Text] = DEFAULT_DATA_PATH, +) -> None: + parser.add_argument("-u", "--nlu", type=str, default=default, help=help_text) + + +def add_domain_param( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer], + default: Optional[Text] = DEFAULT_DOMAIN_PATH, +) -> None: + parser.add_argument( + "-d", + "--domain", + type=str, + default=default, + help="Domain specification. This can be a single YAML file, or a directory " + "that contains several files with domain specifications in it. The content " + "of these files will be read and merged together.", + ) + + +def add_config_param( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer], + default: Optional[Text] = DEFAULT_CONFIG_PATH, +) -> None: + parser.add_argument( + "-c", + "--config", + type=str, + default=default, + help="The policy and NLU pipeline configuration of your bot.", + ) + + +def add_out_param( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer], + help_text: Text, + default: Optional[Text] = DEFAULT_MODELS_PATH, + required: bool = False, +) -> None: + parser.add_argument( + "--out", + type=str, + default=default, + help=help_text, + # The desired behaviour is that required indicates if this argument must + # have a value, but argparse interprets it as "must have a value + # from user input", so we toggle it only if our default is not set + required=required and default is None, + ) + + +def add_endpoint_param( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer], + help_text: Text, + default: Optional[Text] = DEFAULT_ENDPOINTS_PATH, +) -> None: + """Adds an option to an argument parser to configure endpoints path.""" + parser.add_argument("--endpoints", type=str, default=default, help=help_text) + + +def add_data_param( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer], + default: Optional[Text] = DEFAULT_DATA_PATH, + required: bool = False, + data_type: Text = "Rasa ", +) -> None: + parser.add_argument( + "--data", + default=default, + nargs="+", + type=str, + help=f"Paths to the files or directories containing {data_type} data.", + # The desired behaviour is that required indicates if this argument must + # have a value, but argparse interprets it as "must have a value + # from user input", so we toggle it only if our default is not set + required=required and default is None, + ) + + +def add_logging_options(parser: argparse.ArgumentParser) -> None: + """Add options to an argument parser to configure logging levels.""" + logging_arguments = parser.add_argument_group( + "Python Logging Options", + "You can control level of log messages printed. " + "In addition to these arguments, a more fine grained configuration can be " + "achieved with environment variables. See online documentation for more info.", + ) + + # arguments for logging configuration + logging_arguments.add_argument( + "-v", + "--verbose", + help="Be verbose. Sets logging level to INFO.", + action="store_const", + dest="loglevel", + const=logging.INFO, + ) + logging_arguments.add_argument( + "-vv", + "--debug", + help="Print lots of debugging statements. Sets logging level to DEBUG.", + action="store_const", + dest="loglevel", + const=logging.DEBUG, + ) + logging_arguments.add_argument( + "--quiet", + help="Be quiet! Sets logging level to WARNING.", + action="store_const", + dest="loglevel", + const=logging.WARNING, + ) + + logging_arguments.add_argument( + "--logging-config-file", + type=str, + help="If set, the name of the logging configuration file will be set " + "to the given name.", + ) diff --git a/rasa/cli/arguments/evaluate.py b/rasa/cli/arguments/evaluate.py new file mode 100644 index 0000000..189f486 --- /dev/null +++ b/rasa/cli/arguments/evaluate.py @@ -0,0 +1,65 @@ +import argparse +from pathlib import Path +from rasa.cli.arguments.default_arguments import add_endpoint_param, add_domain_param + + +def set_markers_arguments(parser: argparse.ArgumentParser) -> None: + """Specifies arguments for `rasa evaluate markers`.""" + parser.add_argument( + "output_filename", + type=Path, + help="The filename to write the extracted markers to (CSV format).", + ) + + parser.add_argument( + "--config", + default="markers.yml", + type=Path, + help="The marker configuration file(s) containing marker definitions. " + "This can be a single YAML file, or a directory that contains several " + "files with marker definitions in it. The content of these files will " + "be read and merged together.", + ) + + stats = parser.add_mutually_exclusive_group() + + stats.add_argument( + "--no-stats", + action="store_false", + dest="stats", + help="Do not compute summary statistics.", + ) + + stats.add_argument( + "--stats-file-prefix", + default="stats", + nargs="?", + type=Path, + help="The common file prefix of the files where we write out the compute " + "statistics. More precisely, the file prefix must consist of a common " + "path plus a common file prefix, to which suffixes `-overall.csv` and " + "`-per-session.csv` will be added automatically.", + ) + + add_endpoint_param( + parser, help_text="Configuration file for the tracker store as a yml file." + ) + + add_domain_param(parser) + + +def set_markers_first_n_arguments(parser: argparse.ArgumentParser) -> None: + """Specifies arguments for `rasa evaluate markers first_n`.""" + parser.add_argument( + "count", type=int, help="The number of trackers to extract markers from" + ) + + +def set_markers_sample_arguments(parser: argparse.ArgumentParser) -> None: + """Specifies arguments for `rasa evaluate markers sample_n`.""" + parser.add_argument( + "--seed", type=int, help="Seed to use if selecting trackers by 'sample_n'" + ) + parser.add_argument( + "count", type=int, help="The number of trackers to extract markers from" + ) diff --git a/rasa/cli/arguments/export.py b/rasa/cli/arguments/export.py new file mode 100644 index 0000000..bb3a0e5 --- /dev/null +++ b/rasa/cli/arguments/export.py @@ -0,0 +1,51 @@ +import argparse + +from rasa.cli.arguments import default_arguments +from rasa.shared.constants import DEFAULT_ENDPOINTS_PATH + + +def set_export_arguments(parser: argparse.ArgumentParser) -> None: + default_arguments.add_endpoint_param( + parser, + default=DEFAULT_ENDPOINTS_PATH, + help_text=( + "Endpoint configuration file specifying the tracker store " + "and event broker." + ), + ) + + parser.add_argument( + "--minimum-timestamp", + type=float, + help=( + "Minimum timestamp of events to be exported. The constraint is applied " + "in a 'greater than or equal' comparison." + ), + ) + + parser.add_argument( + "--maximum-timestamp", + type=float, + help=( + "Maximum timestamp of events to be exported. The constraint is " + "applied in a 'less than' comparison." + ), + ) + + parser.add_argument( + "--offset-timestamps-by-seconds", + type=int, + help=( + "Offset all event timestamps by the specified amount of seconds. " + "This won't modify the stored events in the tracker store, but only " + "change the timestamps in the events exported to the broker." + ), + ) + + parser.add_argument( + "--conversation-ids", + help=( + "Comma-separated list of conversation IDs to migrate. If unset, " + "all available conversation IDs will be exported." + ), + ) diff --git a/rasa/cli/arguments/interactive.py b/rasa/cli/arguments/interactive.py new file mode 100644 index 0000000..dbcc5a3 --- /dev/null +++ b/rasa/cli/arguments/interactive.py @@ -0,0 +1,74 @@ +import argparse +import uuid + +import rasa.cli.arguments.default_arguments +import rasa.cli.arguments.train +import rasa.cli.arguments.run + + +def set_interactive_arguments(parser: argparse.ArgumentParser) -> None: + """Specifies arguments for `rasa interactive`.""" + parser.add_argument( + "--e2e", + action="store_true", + help="Save story files in e2e format. In this format user messages " + "will be included in the stories.", + ) + rasa.cli.arguments.run.add_port_argument(parser) + + rasa.cli.arguments.default_arguments.add_model_param(parser, default=None) + rasa.cli.arguments.train.add_data_param(parser) + + _add_common_params(parser) + train_arguments = _add_training_arguments(parser) + + rasa.cli.arguments.train.add_force_param(train_arguments) + rasa.cli.arguments.train.add_persist_nlu_data_param(train_arguments) + + +def set_interactive_core_arguments(parser: argparse.ArgumentParser) -> None: + """Specifies arguments for `rasa interactive core`.""" + rasa.cli.arguments.default_arguments.add_model_param( + parser, model_name="Rasa Core", default=None + ) + rasa.cli.arguments.default_arguments.add_stories_param(parser) + + _add_common_params(parser) + _add_training_arguments(parser) + rasa.cli.arguments.run.add_port_argument(parser) + + +def _add_common_params(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--skip-visualization", + default=False, + action="store_true", + help="Disable plotting the visualization during interactive learning.", + ) + + parser.add_argument( + "--conversation-id", + default=uuid.uuid4().hex, + help="Specify the id of the conversation the messages are in. Defaults to a " + "UUID that will be randomly generated.", + ) + + rasa.cli.arguments.default_arguments.add_endpoint_param( + parser, + help_text="Configuration file for the model server " + "and the connectors as a yml file.", + ) + + +# noinspection PyProtectedMember +def _add_training_arguments(parser: argparse.ArgumentParser) -> argparse._ArgumentGroup: + train_arguments = parser.add_argument_group("Train Arguments") + rasa.cli.arguments.train.add_config_param(train_arguments) + rasa.cli.arguments.default_arguments.add_domain_param(train_arguments) + rasa.cli.arguments.train.add_out_param( + train_arguments, help_text="Directory where your models should be stored." + ) + rasa.cli.arguments.train.add_augmentation_param(train_arguments) + rasa.cli.arguments.train.add_debug_plots_param(train_arguments) + rasa.cli.arguments.train.add_finetune_params(train_arguments) + return train_arguments diff --git a/rasa/cli/arguments/run.py b/rasa/cli/arguments/run.py new file mode 100644 index 0000000..f982672 --- /dev/null +++ b/rasa/cli/arguments/run.py @@ -0,0 +1,175 @@ +import argparse +from typing import Union + +from rasa.cli.arguments.default_arguments import add_model_param, add_endpoint_param +from rasa.core import constants + + +def set_run_arguments(parser: argparse.ArgumentParser) -> None: + """Arguments for running Rasa directly using `rasa run`.""" + add_model_param(parser) + add_server_arguments(parser) + + +def set_run_action_arguments(parser: argparse.ArgumentParser) -> None: + """Set arguments for running Rasa SDK.""" + import rasa_sdk.cli.arguments as sdk + + sdk.add_endpoint_arguments(parser) + + +def add_interface_argument( + parser: Union[argparse.ArgumentParser, argparse._ArgumentGroup] +) -> None: + """Binds the RASA process to a network interface.""" + parser.add_argument( + "-i", + "--interface", + default=constants.DEFAULT_SERVER_INTERFACE, + type=str, + help="Network interface to run the server on.", + ) + + +# noinspection PyProtectedMember +def add_port_argument( + parser: Union[argparse.ArgumentParser, argparse._ArgumentGroup] +) -> None: + """Add an argument for port.""" + parser.add_argument( + "-p", + "--port", + default=constants.DEFAULT_SERVER_PORT, + type=int, + help="Port to run the server at.", + ) + + +def add_server_arguments(parser: argparse.ArgumentParser) -> None: + """Add arguments for running API endpoint.""" + parser.add_argument( + "--log-file", + type=str, + # Rasa should not log to a file by default, otherwise there will be problems + # when running on OpenShift + default=None, + help="Store logs in specified file.", + ) + parser.add_argument( + "--use-syslog", action="store_true", help="Add syslog as a log handler" + ) + parser.add_argument( + "--syslog-address", + type=str, + default=constants.DEFAULT_SYSLOG_HOST, + help="Address of the syslog server. --use-sylog flag is required", + ) + parser.add_argument( + "--syslog-port", + type=int, + default=constants.DEFAULT_SYSLOG_PORT, + help="Port of the syslog server. --use-sylog flag is required", + ) + parser.add_argument( + "--syslog-protocol", + type=str, + default=constants.DEFAULT_PROTOCOL, + help="Protocol used with the syslog server. Can be UDP (default) or TCP ", + ) + add_endpoint_param( + parser, + help_text="Configuration file for the model server and the connectors as a " + "yml file.", + ) + + server_arguments = parser.add_argument_group("Server Settings") + + add_interface_argument(server_arguments) + + add_port_argument(server_arguments) + + server_arguments.add_argument( + "-t", + "--auth-token", + type=str, + help="Enable token based authentication. Requests need to provide " + "the token to be accepted.", + ) + server_arguments.add_argument( + "--cors", + nargs="*", + type=str, + help="Enable CORS for the passed origin. Use * to whitelist all origins.", + ) + server_arguments.add_argument( + "--enable-api", + action="store_true", + help="Start the web server API in addition to the input channel.", + ) + server_arguments.add_argument( + "--response-timeout", + default=constants.DEFAULT_RESPONSE_TIMEOUT, + type=int, + help="Maximum time a response can take to process (sec).", + ) + server_arguments.add_argument( + "--request-timeout", + default=constants.DEFAULT_REQUEST_TIMEOUT, + type=int, + help="Maximum time a request can take to process (sec).", + ) + server_arguments.add_argument( + "--remote-storage", + help="Set the remote location where your Rasa model is stored, e.g. on AWS.", + ) + server_arguments.add_argument( + "--ssl-certificate", + help="Set the SSL Certificate to create a TLS secured server.", + ) + server_arguments.add_argument( + "--ssl-keyfile", help="Set the SSL Keyfile to create a TLS secured server." + ) + server_arguments.add_argument( + "--ssl-ca-file", + help="If your SSL certificate needs to be verified, " + "you can specify the CA file " + "using this parameter.", + ) + server_arguments.add_argument( + "--ssl-password", + help="If your ssl-keyfile is protected by a password, you can specify it " + "using this paramer.", + ) + channel_arguments = parser.add_argument_group("Channels") + channel_arguments.add_argument( + "--credentials", + default=None, + help="Authentication credentials for the connector as a yml file.", + ) + channel_arguments.add_argument( + "--connector", type=str, help="Service to connect to." + ) + + jwt_auth = parser.add_argument_group("JWT Authentication") + jwt_auth.add_argument( + "--jwt-secret", + type=str, + help="Public key for asymmetric JWT methods or shared secret" + "for symmetric methods. Please also make sure to use " + "--jwt-method to select the method of the signature, " + "otherwise this argument will be ignored." + "Note that this key is meant for securing the HTTP API.", + ) + jwt_auth.add_argument( + "--jwt-method", + type=str, + default="HS256", + help="Method used for the signature of the JWT authentication payload.", + ) + jwt_auth.add_argument( + "--jwt-private-key", + type=str, + help="A private key used for generating web tokens, dependent upon " + "which hashing algorithm is used. It must be used together with " + "--jwt-secret for providing the public key.", + ) diff --git a/rasa/cli/arguments/shell.py b/rasa/cli/arguments/shell.py new file mode 100644 index 0000000..fc1af67 --- /dev/null +++ b/rasa/cli/arguments/shell.py @@ -0,0 +1,13 @@ +import argparse + +from rasa.cli.arguments.default_arguments import add_model_param +from rasa.cli.arguments.run import add_server_arguments + + +def set_shell_arguments(parser: argparse.ArgumentParser) -> None: + add_model_param(parser) + add_server_arguments(parser) + + +def set_shell_nlu_arguments(parser: argparse.ArgumentParser) -> None: + add_model_param(parser) diff --git a/rasa/cli/arguments/test.py b/rasa/cli/arguments/test.py new file mode 100644 index 0000000..caf3ec6 --- /dev/null +++ b/rasa/cli/arguments/test.py @@ -0,0 +1,211 @@ +import argparse +from typing import Union + +from rasa.shared.constants import DEFAULT_MODELS_PATH, DEFAULT_RESULTS_PATH + +from rasa.cli.arguments.default_arguments import ( + add_stories_param, + add_model_param, + add_nlu_data_param, + add_endpoint_param, + add_out_param, +) +from rasa.model import get_latest_model +from rasa.shared.constants import DEFAULT_DOMAIN_PATH + + +def set_test_arguments(parser: argparse.ArgumentParser) -> None: + """Sets test arguments for a parser.""" + add_model_param(parser, add_positional_arg=False) + + core_arguments = parser.add_argument_group("Core Test Arguments") + add_test_core_argument_group(core_arguments) + + nlu_arguments = parser.add_argument_group("NLU Test Arguments") + add_test_nlu_argument_group(nlu_arguments) + + add_no_plot_param(parser) + add_errors_success_params(parser) + add_out_param( + parser, + default=DEFAULT_RESULTS_PATH, + help_text="Output path for any files created during the evaluation.", + ) + + +def set_test_core_arguments(parser: argparse.ArgumentParser) -> None: + add_test_core_model_param(parser) + add_test_core_argument_group(parser, include_e2e_argument=True) + + +def set_test_nlu_arguments(parser: argparse.ArgumentParser) -> None: + add_model_param(parser, add_positional_arg=False) + add_test_nlu_argument_group(parser) + + +def add_test_core_argument_group( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer], + include_e2e_argument: bool = False, +) -> None: + add_stories_param(parser, "test") + parser.add_argument( + "--max-stories", type=int, help="Maximum number of stories to test on." + ) + add_out_param( + parser, + default=DEFAULT_RESULTS_PATH, + help_text="Output path for any files created during the evaluation.", + ) + if include_e2e_argument: + parser.add_argument( + "--e2e", + "--end-to-end", + action="store_true", + help="Run an end-to-end evaluation for combined action and " + "intent prediction. Requires a story file in end-to-end " + "format.", + ) + add_endpoint_param( + parser, help_text="Configuration file for the connectors as a yml file." + ) + parser.add_argument( + "--fail-on-prediction-errors", + action="store_true", + help="If a prediction error is encountered, an exception " + "is thrown. This can be used to validate stories during " + "tests, e.g. on travis.", + ) + parser.add_argument( + "--url", + type=str, + help="If supplied, downloads a story file from a URL and " + "trains on it. Fetches the data by sending a GET request " + "to the supplied URL.", + ) + parser.add_argument( + "--evaluate-model-directory", + default=False, + action="store_true", + help="Should be set to evaluate models trained via " + "'rasa train core --config <config-1> <config-2>'. " + "All models in the provided directory are evaluated " + "and compared against each other.", + ) + add_no_plot_param(parser) + add_errors_success_params(parser) + + +def add_test_nlu_argument_group( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer] +) -> None: + add_nlu_data_param(parser, help_text="File or folder containing your NLU data.") + + add_out_param( + parser, + default=DEFAULT_RESULTS_PATH, + help_text="Output path for any files created during the evaluation.", + ) + parser.add_argument( + "-c", + "--config", + nargs="+", + default=None, + help="Model configuration file. If a single file is passed and cross " + "validation mode is chosen, cross-validation is performed, if " + "multiple configs or a folder of configs are passed, models " + "will be trained and compared directly.", + ) + + parser.add_argument( + "-d", + "--domain", + type=str, + default=DEFAULT_DOMAIN_PATH, + help="Domain specification. This can be a single YAML file, or a directory " + "that contains several files with domain specifications in it. The content " + "of these files will be read and merged together.", + ) + + cross_validation_arguments = parser.add_argument_group("Cross Validation") + cross_validation_arguments.add_argument( + "--cross-validation", + action="store_true", + default=False, + help="Switch on cross validation mode. Any provided model will be ignored.", + ) + cross_validation_arguments.add_argument( + "-f", + "--folds", + required=False, + default=5, + help="Number of cross validation folds (cross validation only).", + ) + comparison_arguments = parser.add_argument_group("Comparison Mode") + comparison_arguments.add_argument( + "-r", + "--runs", + required=False, + default=3, + type=int, + help="Number of comparison runs to make.", + ) + comparison_arguments.add_argument( + "-p", + "--percentages", + required=False, + nargs="+", + type=int, + default=[0, 25, 50, 75], + help="Percentages of training data to exclude during comparison.", + ) + + add_no_plot_param(parser) + add_errors_success_params(parser) + + +def add_test_core_model_param(parser: argparse.ArgumentParser) -> None: + default_path = get_latest_model(DEFAULT_MODELS_PATH) + parser.add_argument( + "-m", + "--model", + nargs="+", + default=[default_path], + help="Path to a pre-trained model. If it is a 'tar.gz' file that model file " + "will be used. If it is a directory, the latest model in that directory " + "will be used (exception: '--evaluate-model-directory' flag is set). " + "If multiple 'tar.gz' files are provided, all those models will be compared.", + ) + + +def add_no_plot_param( + parser: argparse.ArgumentParser, default: bool = False, required: bool = False +) -> None: + parser.add_argument( + "--no-plot", + dest="disable_plotting", + action="store_true", + default=default, + help="Don't render evaluation plots.", + required=required, + ) + + +def add_errors_success_params(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--successes", + action="store_true", + default=False, + help="If set successful predictions will be written to a file.", + ) + parser.add_argument( + "--no-errors", + action="store_true", + default=False, + help="If set incorrect predictions will NOT be written to a file.", + ) + parser.add_argument( + "--no-warnings", + action="store_true", + default=False, + help="If set prediction warnings will NOT be written to a file.", + ) diff --git a/rasa/cli/arguments/train.py b/rasa/cli/arguments/train.py new file mode 100644 index 0000000..a3e1f6c --- /dev/null +++ b/rasa/cli/arguments/train.py @@ -0,0 +1,263 @@ +import argparse +from typing import Union + +from rasa.cli.arguments.default_arguments import ( + add_config_param, + add_stories_param, + add_nlu_data_param, + add_out_param, + add_domain_param, + add_endpoint_param, +) +from rasa.graph_components.providers.training_tracker_provider import ( + TrainingTrackerProvider, +) +from rasa.shared.constants import DEFAULT_CONFIG_PATH, DEFAULT_DATA_PATH + +USE_LATEST_MODEL_FOR_FINE_TUNING = True + + +def set_train_arguments(parser: argparse.ArgumentParser) -> None: + """Specifies CLI arguments for `rasa train`.""" + add_data_param(parser) + add_config_param(parser) + add_domain_param(parser) + add_out_param(parser, help_text="Directory where your models should be stored.") + + add_dry_run_param(parser) + add_validate_before_train(parser) + add_augmentation_param(parser) + add_debug_plots_param(parser) + + _add_num_threads_param(parser) + + _add_model_name_param(parser) + add_persist_nlu_data_param(parser) + add_force_param(parser) + add_finetune_params(parser) + add_endpoint_param( + parser, help_text="Configuration file for the connectors as a yml file." + ) + + +def set_train_core_arguments(parser: argparse.ArgumentParser) -> None: + """Specifies CLI arguments for `rasa train core`.""" + add_stories_param(parser) + add_domain_param(parser) + _add_core_config_param(parser) + add_out_param(parser, help_text="Directory where your models should be stored.") + + add_augmentation_param(parser) + add_debug_plots_param(parser) + + add_force_param(parser) + + _add_model_name_param(parser) + + compare_arguments = parser.add_argument_group("Comparison Arguments") + _add_compare_params(compare_arguments) + add_finetune_params(parser) + + +def set_train_nlu_arguments(parser: argparse.ArgumentParser) -> None: + """Specifies CLI arguments for `rasa train nlu`.""" + add_config_param(parser) + add_domain_param(parser, default=None) + add_out_param(parser, help_text="Directory where your models should be stored.") + + add_nlu_data_param(parser, help_text="File or folder containing your NLU data.") + + _add_num_threads_param(parser) + + _add_model_name_param(parser) + add_persist_nlu_data_param(parser) + add_finetune_params(parser) + + +def add_force_param( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer] +) -> None: + """Specifies if the model should be trained from scratch.""" + parser.add_argument( + "--force", + action="store_true", + help="Force a model training even if the data has not changed.", + ) + + +def add_data_param( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer] +) -> None: + """Specifies path to training data.""" + parser.add_argument( + "--data", + default=[DEFAULT_DATA_PATH], + nargs="+", + help="Paths to the Core and NLU data files.", + ) + + +def _add_core_config_param(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "-c", + "--config", + nargs="+", + default=[DEFAULT_CONFIG_PATH], + help="The policy and NLU pipeline configuration of your bot. " + "If multiple configuration files are provided, multiple Rasa Core " + "models are trained to compare policies.", + ) + + +def _add_compare_params( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer] +) -> None: + parser.add_argument( + "--percentages", + nargs="*", + type=int, + default=[0, 25, 50, 75], + help="Range of exclusion percentages.", + ) + parser.add_argument( + "--runs", type=int, default=3, help="Number of runs for experiments." + ) + + +def add_dry_run_param( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer] +) -> None: + """Adds `--dry-run` argument to a specified `parser`. + + Args: + parser: An instance of `ArgumentParser` or `_ActionsContainer`. + """ + parser.add_argument( + "--dry-run", + default=False, + action="store_true", + help="If enabled, no actual training will be performed. Instead, " + "it will be determined whether a model should be re-trained " + "and this information will be printed as the output. The return " + "code is a 4-bit bitmask that can also be used to determine what exactly needs " + "to be retrained:\n" + "- 0 means that no extensive training is required (note that the responses " + "still might require updating by running 'rasa train').\n" + "- 1 means the model needs to be retrained\n" + "- 8 means the training was forced (--force argument is specified)", + ) + + +def add_validate_before_train( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer] +) -> None: + """Adds parameters for validating the domain and data files before training. + + Args: + parser: An instance of `ArgumentParser` or `_ActionsContainer`. + """ + parser.add_argument( + "--skip-validation", + default=False, + action="store_true", + help="Skip validation step before training.", + ) + + parser.add_argument( + "--fail-on-validation-warnings", + default=False, + action="store_true", + help="Fail on validation warnings. " + "If omitted only errors will exit with a non zero status code", + ) + + parser.add_argument( + "--validation-max-history", + type=int, + default=None, + help="Number of turns taken into account for story structure validation.", + ) + + +def add_augmentation_param( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer] +) -> None: + """Sets the augmentation factor for the Core training. + + Args: + parser: An instance of `ArgumentParser` or `_ActionsContainer`. + """ + parser.add_argument( + "--augmentation", + type=int, + default=TrainingTrackerProvider.get_default_config()["augmentation_factor"], + help="How much data augmentation to use during training.", + ) + + +def add_debug_plots_param( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer] +) -> None: + """Specifies if conversation flow should be visualized.""" + parser.add_argument( + "--debug-plots", + default=TrainingTrackerProvider.get_default_config()["debug_plots"], + action="store_true", + help="If enabled, will create plots showing checkpoints " + "and their connections between story blocks in a " + "file called `story_blocks_connections.html`.", + ) + + +def _add_num_threads_param( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer] +) -> None: + parser.add_argument( + "--num-threads", + type=int, + help="Maximum amount of threads to use when training.", + ) + + +def _add_model_name_param(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--fixed-model-name", + type=str, + help="If set, the name of the model file/directory will be set to the given " + "name.", + ) + + +def add_persist_nlu_data_param( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer] +) -> None: + """Adds parameters for persisting the NLU training data with the model.""" + parser.add_argument( + "--persist-nlu-data", + action="store_true", + help="Persist the NLU training data in the saved model.", + ) + + +def add_finetune_params( + parser: Union[argparse.ArgumentParser, argparse._ActionsContainer] +) -> None: + """Adds parameters for model finetuning.""" + parser.add_argument( + "--finetune", + nargs="?", + # If the user doesn't specify `--finetune` at all + default=None, + # If the user only specifies `--finetune` without an additional path + const=USE_LATEST_MODEL_FOR_FINE_TUNING, + help="Fine-tune a previously trained model. If no model path is provided, Rasa " + "Open Source will try to finetune the latest trained model from the " + "model directory specified via '--out'.", + ) + + parser.add_argument( + "--epoch-fraction", + type=float, + help="Fraction of epochs which are currently specified in the model " + "configuration which should be used when finetuning a model.", + ) diff --git a/rasa/cli/arguments/visualize.py b/rasa/cli/arguments/visualize.py new file mode 100644 index 0000000..2f7853e --- /dev/null +++ b/rasa/cli/arguments/visualize.py @@ -0,0 +1,34 @@ +import argparse + +from rasa.cli.arguments.default_arguments import ( + add_domain_param, + add_stories_param, + add_out_param, + add_nlu_data_param, +) + + +def set_visualize_stories_arguments(parser: argparse.ArgumentParser) -> None: + """Sets the CLI arguments for `rasa data visualize.""" + add_domain_param(parser) + add_stories_param(parser) + + add_out_param( + parser, + default="graph.html", + help_text="Filename of the output path, e.g. 'graph.html'.", + ) + + parser.add_argument( + "--max-history", + default=2, + type=int, + help="Max history to consider when merging paths in the output graph.", + ) + + add_nlu_data_param( + parser, + default=None, + help_text="File or folder containing your NLU data, " + "used to insert example messages into the graph.", + ) diff --git a/rasa/cli/arguments/x.py b/rasa/cli/arguments/x.py new file mode 100644 index 0000000..133cce2 --- /dev/null +++ b/rasa/cli/arguments/x.py @@ -0,0 +1,30 @@ +import argparse +from rasa.cli.arguments import default_arguments +from rasa.cli.arguments.run import add_server_arguments + + +def set_x_arguments(parser: argparse.ArgumentParser) -> None: + """Add CLI arguments for running rasa x --production.""" + parser.add_argument( + "--production", + action="store_true", + help="Run a Rasa server in a mode that allows connecting " + "to Rasa Enterprise as the config endpoint", + ) + + parser.add_argument( + "--config-endpoint", + type=str, + help="Rasa X endpoint URL from which to pull the runtime config. This URL " + "typically contains the Rasa X token for authentication. Example: " + "https://example.com/api/config?token=my_rasa_x_token", + ) + + parser.add_argument( + "--no-prompt", + action="store_true", + help="Automatic yes or default options to prompts and oppressed warnings.", + ) + + default_arguments.add_model_param(parser, add_positional_arg=False) + add_server_arguments(parser) diff --git a/rasa/cli/data.py b/rasa/cli/data.py new file mode 100644 index 0000000..06f9121 --- /dev/null +++ b/rasa/cli/data.py @@ -0,0 +1,275 @@ +import argparse +import logging +import pathlib +from typing import List + +import rasa.shared.core.domain +from rasa import telemetry +from rasa.cli import SubParsersAction +from rasa.cli.arguments import data as arguments +from rasa.cli.arguments import default_arguments +import rasa.cli.utils +from rasa.shared.constants import ( + DEFAULT_DATA_PATH, + DEFAULT_CONFIG_PATH, + DEFAULT_DOMAIN_PATH, +) +import rasa.shared.data +from rasa.shared.importers.importer import TrainingDataImporter +import rasa.shared.nlu.training_data.loading +import rasa.shared.nlu.training_data.util +import rasa.shared.utils.cli +import rasa.utils.common +import rasa.shared.utils.io + +logger = logging.getLogger(__name__) + + +def add_subparser( + subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + """Add all data parsers. + + Args: + subparsers: subparser we are going to attach to + parents: Parent parsers, needed to ensure tree structure in argparse + """ + data_parser = subparsers.add_parser( + "data", + conflict_handler="resolve", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + parents=parents, + help="Utils for the Rasa training files.", + ) + data_parser.set_defaults(func=lambda _: data_parser.print_help(None)) + + data_subparsers = data_parser.add_subparsers() + + _add_data_convert_parsers(data_subparsers, parents) + _add_data_split_parsers(data_subparsers, parents) + _add_data_validate_parsers(data_subparsers, parents) + _add_data_migrate_parsers(data_subparsers, parents) + + +def _add_data_convert_parsers( + data_subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + convert_parser = data_subparsers.add_parser( + "convert", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + parents=parents, + help="Converts Rasa data between different formats.", + ) + convert_parser.set_defaults(func=lambda _: convert_parser.print_help(None)) + + convert_subparsers = convert_parser.add_subparsers() + convert_nlu_parser = convert_subparsers.add_parser( + "nlu", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + parents=parents, + help="Converts NLU data between formats.", + ) + convert_nlu_parser.set_defaults(func=_convert_nlu_data) + arguments.set_convert_arguments(convert_nlu_parser, data_type="Rasa NLU") + + +def _add_data_split_parsers( + data_subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + split_parser = data_subparsers.add_parser( + "split", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + parents=parents, + help="Splits Rasa data into training and test data.", + ) + split_parser.set_defaults(func=lambda _: split_parser.print_help(None)) + + split_subparsers = split_parser.add_subparsers() + nlu_split_parser = split_subparsers.add_parser( + "nlu", + parents=parents, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Performs a split of your NLU data into training and test data " + "according to the specified percentages.", + ) + nlu_split_parser.set_defaults(func=split_nlu_data) + + arguments.set_split_arguments(nlu_split_parser) + + stories_split_parser = split_subparsers.add_parser( + "stories", + parents=parents, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Performs a split of your stories into training and test data " + "according to the specified percentages.", + ) + stories_split_parser.set_defaults(func=split_stories_data) + + arguments.set_split_arguments(stories_split_parser) + + +def _add_data_validate_parsers( + data_subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + validate_parser = data_subparsers.add_parser( + "validate", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + parents=parents, + help="Validates domain and data files to check for possible mistakes.", + ) + _append_story_structure_arguments(validate_parser) + validate_parser.set_defaults( + func=lambda args: rasa.cli.utils.validate_files( + args.fail_on_warnings, args.max_history, _build_training_data_importer(args) + ) + ) + arguments.set_validator_arguments(validate_parser) + + validate_subparsers = validate_parser.add_subparsers() + story_structure_parser = validate_subparsers.add_parser( + "stories", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + parents=parents, + help="Checks for inconsistencies in the story files.", + ) + _append_story_structure_arguments(story_structure_parser) + + story_structure_parser.set_defaults( + func=lambda args: rasa.cli.utils.validate_files( + args.fail_on_warnings, + args.max_history, + _build_training_data_importer(args), + stories_only=True, + ) + ) + arguments.set_validator_arguments(story_structure_parser) + + +def _build_training_data_importer(args: argparse.Namespace) -> "TrainingDataImporter": + config = rasa.cli.utils.get_validated_path( + args.config, "config", DEFAULT_CONFIG_PATH, none_is_valid=True + ) + + # Exit the validation if the domain path is invalid + domain = rasa.cli.utils.get_validated_path( + args.domain, "domain", DEFAULT_DOMAIN_PATH, none_is_valid=False + ) + + return TrainingDataImporter.load_from_config( + domain_path=domain, training_data_paths=args.data, config_path=config + ) + + +def _append_story_structure_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--max-history", + type=int, + default=None, + help="Number of turns taken into account for story structure validation.", + ) + default_arguments.add_config_param(parser) + + +def split_nlu_data(args: argparse.Namespace) -> None: + """Load data from a file path and split the NLU data into test and train examples. + + Args: + args: Commandline arguments + """ + data_path = rasa.cli.utils.get_validated_path(args.nlu, "nlu", DEFAULT_DATA_PATH) + data_path = rasa.shared.data.get_nlu_directory(data_path) + + nlu_data = rasa.shared.nlu.training_data.loading.load_data(data_path) + extension = rasa.shared.nlu.training_data.util.get_file_format_extension(data_path) + + train, test = nlu_data.train_test_split(args.training_fraction, args.random_seed) + + train.persist(args.out, filename=f"training_data{extension}") + test.persist(args.out, filename=f"test_data{extension}") + + telemetry.track_data_split(args.training_fraction, "nlu") + + +def split_stories_data(args: argparse.Namespace) -> None: + """Load data from a file path and split stories into test and train examples. + + Args: + args: Commandline arguments + """ + from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, + KEY_STORIES, + ) + from sklearn.model_selection import train_test_split + + data_path = rasa.cli.utils.get_validated_path(args.nlu, "nlu", DEFAULT_DATA_PATH) + data_files = rasa.shared.data.get_data_files( + data_path, YAMLStoryReader.is_stories_file + ) + out_path = pathlib.Path(args.out) + out_path.mkdir(parents=True, exist_ok=True) + + # load Yaml stories data + for file_name in data_files: + file_data = rasa.shared.utils.io.read_yaml_file(file_name) + assert isinstance(file_data, dict) + stories = file_data.get(KEY_STORIES, []) + if not stories: + logger.info(f"File {file_name} has no stories, skipped") + continue + + file_path = pathlib.Path(file_name) + + # everything besides stories are going into the training data + train, test = train_test_split( + stories, test_size=1 - args.training_fraction, random_state=args.random_seed + ) + out_file_train = out_path / ("train_" + file_path.name) + out_file_test = out_path / ("test_" + file_path.name) + + # train file contains everything else from the file + train stories + file_data[KEY_STORIES] = train + rasa.shared.utils.io.write_yaml(file_data, out_file_train) + + # test file contains just test stories + rasa.shared.utils.io.write_yaml({KEY_STORIES: test}, out_file_test) + logger.info( + f"From {file_name} we produced {out_file_train} " + f"with {len(train)} stories and {out_file_test} " + f"with {len(test)} stories" + ) + + +def _convert_nlu_data(args: argparse.Namespace) -> None: + import rasa.nlu.convert + + if args.format in ["json", "yaml"]: + rasa.nlu.convert.convert_training_data( + args.data, args.out, args.format, args.language + ) + telemetry.track_data_convert(args.format, "nlu") + else: + rasa.shared.utils.cli.print_error_and_exit( + "Could not recognize output format. Supported output formats: 'json' " + "and 'yaml'. Specify the desired output format with '--format'." + ) + + +def _add_data_migrate_parsers( + data_subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + migrate_parser = data_subparsers.add_parser( + "migrate", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + parents=parents, + help="Converts Rasa domain 2.0 format to required format for 3.0.", + ) + migrate_parser.set_defaults(func=_migrate_domain) + + arguments.set_migrate_arguments(migrate_parser) + + +def _migrate_domain(args: argparse.Namespace) -> None: + import rasa.core.migrate + + rasa.core.migrate.migrate_domain_format(args.domain, args.out) diff --git a/rasa/cli/evaluate.py b/rasa/cli/evaluate.py new file mode 100644 index 0000000..c558cbc --- /dev/null +++ b/rasa/cli/evaluate.py @@ -0,0 +1,222 @@ +import argparse +from typing import List, Text, Optional +from pathlib import Path + +from rasa import telemetry +from rasa.core.utils import AvailableEndpoints +from rasa.core.tracker_store import TrackerStore +from rasa.core.evaluation.marker_tracker_loader import MarkerTrackerLoader +from rasa.core.evaluation.marker_base import Marker, OperatorMarker +from rasa.shared.core.domain import Domain +from rasa.cli import SubParsersAction +import rasa.cli.arguments.evaluate as arguments +import rasa.shared.utils.cli + +STATS_OVERALL_SUFFIX = "-overall.csv" +STATS_SESSION_SUFFIX = "-per-session.csv" + + +def add_subparser( + subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + """Add all evaluate parsers. + + Args: + subparsers: subparser we are going to attach to + parents: Parent parsers, needed to ensure tree structure in argparse + """ + evaluate_parser = subparsers.add_parser( + "evaluate", + parents=parents, + conflict_handler="resolve", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Tools for evaluating models.", + ) + + evaluate_subparsers = evaluate_parser.add_subparsers() + + marker_parser = evaluate_subparsers.add_parser( + "markers", + parents=parents, + conflict_handler="resolve", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Applies marker conditions to existing trackers.", + ) + + markers_subparser = marker_parser.add_subparsers(dest="strategy") + + markers_first_n_subparser = markers_subparser.add_parser( + "first_n", + parents=parents, + conflict_handler="resolve", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Select trackers sequentially until N are taken.", + ) + arguments.set_markers_first_n_arguments(markers_first_n_subparser) + + arguments.set_markers_arguments(markers_first_n_subparser) + + markers_sample_subparser = markers_subparser.add_parser( + "sample_n", + parents=parents, + conflict_handler="resolve", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Select trackers by sampling N.", + ) + arguments.set_markers_sample_arguments(markers_sample_subparser) + + arguments.set_markers_arguments(markers_sample_subparser) + + markers_all_subparser = markers_subparser.add_parser( + "all", + parents=parents, + conflict_handler="resolve", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Select all trackers.", + ) + + arguments.set_markers_arguments(markers_all_subparser) + + marker_parser.set_defaults(func=_run_markers_cli) + + +def _run_markers_cli(args: argparse.Namespace) -> None: + """Run markers algorithm using parameters from CLI. + + Args: + args: The arguments passed in from the CLI. + """ + seed = args.seed if "seed" in args else None + count = args.count if "count" in args else None + + stats_file_prefix = args.stats_file_prefix if args.stats else None + + _run_markers( + seed, + count, + args.endpoints, + args.domain, + args.strategy, + args.config, + args.output_filename, + stats_file_prefix, + ) + + +def _run_markers( + seed: Optional[int], + count: Optional[int], + endpoint_config: Path, + domain_path: Optional[Text], + strategy: Text, + config: Path, + output_filename: Path, + stats_file_prefix: Optional[Path] = None, +) -> None: + """Run markers algorithm over specified config and tracker store. + + Args: + seed: (Optional) The seed to initialise the random number generator for + use with the 'sample' strategy. + count: (Optional) Number of trackers to extract from (for any strategy + except 'all'). + endpoint_config: Path to the endpoint configuration defining the tracker + store to use. + domain_path: Path to the domain specification to use when validating the + marker definitions. + strategy: Strategy to use when selecting trackers to extract from. + config: Path to the markers definition file to use. + output_filename: Path to write out the extracted markers. + stats_file_prefix: (Optional) A prefix used to create paths where files with + statistics on the marker extraction results will be written. + It must consists of the path to the where those files should be stored + and the common file prefix, e.g. '<path-to-stats-folder>/statistics'. + Statistics derived from all marker extractions will be stored in + '<path-to-stats-folder>/statistics-overall.csv', while the statistics + computed per session will be stored in + '<path-to-stats-folder>/statistics-per-session.csv'. + """ + telemetry.track_markers_extraction_initiated( + strategy=strategy, + only_extract=stats_file_prefix is not None, + seed=seed is not None, + count=count, + ) + + domain = Domain.load(domain_path) if domain_path else None + markers = Marker.from_path(config) + if domain and not markers.validate_against_domain(domain): + rasa.shared.utils.cli.print_error_and_exit( + "Validation errors were found in the markers definition. " + "Please see errors listed above and fix before running again." + ) + + # Calculate telemetry + # All loaded markers are combined with one virtual OR over all markers + num_markers = len(markers.sub_markers) + max_depth = markers.max_depth() - 1 + # Find maximum branching of marker + branching_factor = max( + ( + len(sub_marker.sub_markers) + for marker in markers.sub_markers + for sub_marker in marker.flatten() + if isinstance(sub_marker, OperatorMarker) + ), + default=0, + ) + + telemetry.track_markers_parsed_count(num_markers, max_depth, branching_factor) + + tracker_loader = _create_tracker_loader( + endpoint_config, strategy, domain, count, seed + ) + + def _append_suffix(path: Optional[Path], suffix: Text) -> Optional[Path]: + return path.parent / (path.name + suffix) if path else None + + try: + import asyncio + + asyncio.run( + markers.evaluate_trackers( + trackers=tracker_loader.load(), + output_file=output_filename, + session_stats_file=_append_suffix( + stats_file_prefix, STATS_SESSION_SUFFIX + ), + overall_stats_file=_append_suffix( + stats_file_prefix, STATS_OVERALL_SUFFIX + ), + ) + ) + except (FileExistsError, NotADirectoryError) as e: + rasa.shared.utils.cli.print_error_and_exit(message=str(e)) + + +def _create_tracker_loader( + endpoint_config: Text, + strategy: Text, + domain: Domain, + count: Optional[int], + seed: Optional[int], +) -> MarkerTrackerLoader: + """Create a tracker loader against the configured tracker store. + + Args: + endpoint_config: Path to the endpoint configuration defining the tracker + store to use. + strategy: Strategy to use when selecting trackers to extract from. + domain: The domain to use when connecting to the tracker store. + count: (Optional) Number of trackers to extract from (for any strategy + except 'all'). + seed: (Optional) The seed to initialise the random number generator for + use with the 'sample_n' strategy. + + Returns: + A MarkerTrackerLoader object configured with the specified strategy against + the configured tracker store. + """ + endpoints = AvailableEndpoints.read_endpoints(endpoint_config) + tracker_store = TrackerStore.create(endpoints.tracker_store, domain=domain) + return MarkerTrackerLoader(tracker_store, strategy, count, seed) diff --git a/rasa/cli/export.py b/rasa/cli/export.py new file mode 100644 index 0000000..8c97b73 --- /dev/null +++ b/rasa/cli/export.py @@ -0,0 +1,251 @@ +import argparse +import asyncio +import logging +import typing +from typing import List, Text, Optional + +from rasa import telemetry +from rasa.cli import SubParsersAction +import rasa.core.utils +import rasa.shared.utils.cli +import rasa.utils.common +from rasa.cli.arguments import export as arguments +from rasa.shared.constants import DOCS_URL_EVENT_BROKERS, DOCS_URL_TRACKER_STORES +from rasa.exceptions import PublishingError +from rasa.shared.exceptions import RasaException +from rasa.core.brokers.pika import PikaEventBroker + +if typing.TYPE_CHECKING: + from rasa.core.brokers.broker import EventBroker + from rasa.core.tracker_store import TrackerStore + from rasa.core.exporter import Exporter + from rasa.core.utils import AvailableEndpoints + +logger = logging.getLogger(__name__) + + +def add_subparser( + subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + """Add subparser for `rasa export`. + + Args: + subparsers: Subparsers action object to which `argparse.ArgumentParser` + objects can be added. + parents: `argparse.ArgumentParser` objects whose arguments should also be + included. + """ + export_parser = subparsers.add_parser( + "export", + parents=parents, + conflict_handler="resolve", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Export conversations using an event broker.", + ) + export_parser.set_defaults(func=export_trackers) + + arguments.set_export_arguments(export_parser) + + +def _get_tracker_store(endpoints: "AvailableEndpoints") -> "TrackerStore": + """Get `TrackerStore` from `endpoints`. + + Prints an error and exits if no tracker store could be loaded. + + Args: + endpoints: `AvailableEndpoints` to initialize the tracker store from. + + Returns: + Initialized tracker store. + + """ + if not endpoints.tracker_store: + rasa.shared.utils.cli.print_error_and_exit( + f"Could not find a `tracker_store` section in the supplied " + f"endpoints file. Instructions on how to configure a tracker store " + f"can be found here: {DOCS_URL_TRACKER_STORES}. " + f"Exiting. " + ) + + from rasa.core.tracker_store import TrackerStore + + return TrackerStore.create(endpoints.tracker_store) + + +async def _get_event_broker(endpoints: "AvailableEndpoints") -> "EventBroker": + """Get `EventBroker` from `endpoints`. + + Prints an error and exits if no event broker could be loaded. + + Args: + endpoints: `AvailableEndpoints` to initialize the event broker from. + + Returns: + Initialized event broker. + + """ + from rasa.core.brokers.broker import EventBroker + + broker = await EventBroker.create(endpoints.event_broker) + + if not broker: + rasa.shared.utils.cli.print_error_and_exit( + f"Could not find an `event_broker` section in the supplied " + f"endpoints file. Instructions on how to configure an event broker " + f"can be found here: {DOCS_URL_EVENT_BROKERS}. Exiting." + ) + return broker + + +def _get_requested_conversation_ids( + conversation_ids_arg: Optional[Text] = None, +) -> Optional[List[Text]]: + """Get list of conversation IDs requested as a command-line argument. + + Args: + conversation_ids_arg: Value of `--conversation-ids` command-line argument. + If provided, this is a string of comma-separated conversation IDs. + + Return: + List of conversation IDs requested as a command-line argument. + `None` if that argument was left unspecified. + + """ + if not conversation_ids_arg: + return None + + return conversation_ids_arg.split(",") + + +def _assert_max_timestamp_is_greater_than_min_timestamp( + args: argparse.Namespace, +) -> None: + """Inspect CLI timestamp parameters. + + Prints an error and exits if a maximum timestamp is provided that is smaller + than the provided minimum timestamp. + + Args: + args: Command-line arguments to process. + + """ + min_timestamp = args.minimum_timestamp + max_timestamp = args.maximum_timestamp + + if ( + min_timestamp is not None + and max_timestamp is not None + and max_timestamp < min_timestamp + ): + rasa.shared.utils.cli.print_error_and_exit( + f"Maximum timestamp '{max_timestamp}' is smaller than minimum " + f"timestamp '{min_timestamp}'. Exiting." + ) + + +def _prepare_event_broker(event_broker: "EventBroker") -> None: + """Prepares event broker to export tracker events. + + Sets `should_keep_unpublished_messages` flag to `False` if + `self.event_broker` is a `PikaEventBroker`. + + If publishing of events fails, the `PikaEventBroker` instance should not keep a + list of unpublished messages, so we can retry publishing them. This is because + the instance is launched as part of this short-lived export script, meaning the + object is destroyed before it might be published. + + In addition, wait until the event broker reports a `ready` state. + + """ + if isinstance(event_broker, PikaEventBroker): + event_broker.should_keep_unpublished_messages = False + event_broker.raise_on_failure = True + + if not event_broker.is_ready(): + rasa.shared.utils.cli.print_error_and_exit( + f"Event broker of type '{type(event_broker)}' is not ready. Exiting." + ) + + +def export_trackers(args: argparse.Namespace) -> None: + """Export events for a connected tracker store using an event broker. + + Args: + args: Command-line arguments to process. + """ + asyncio.run(_export_trackers(args)) + + +async def _export_trackers(args: argparse.Namespace) -> None: + + _assert_max_timestamp_is_greater_than_min_timestamp(args) + + endpoints = rasa.core.utils.read_endpoints_from_path(args.endpoints) + tracker_store = _get_tracker_store(endpoints) + event_broker = await _get_event_broker(endpoints) + _prepare_event_broker(event_broker) + requested_conversation_ids = _get_requested_conversation_ids(args.conversation_ids) + + from rasa.core.exporter import Exporter + + exporter = Exporter( + tracker_store, + event_broker, + args.endpoints, + requested_conversation_ids, + args.minimum_timestamp, + args.maximum_timestamp, + args.offset_timestamps_by_seconds, + ) + + try: + published_events = await exporter.publish_events() + telemetry.track_tracker_export(published_events, tracker_store, event_broker) + rasa.shared.utils.cli.print_success( + f"Done! Successfully published {published_events} events 🎉" + ) + + except PublishingError as e: + command = _get_continuation_command(exporter, e.timestamp) + rasa.shared.utils.cli.print_error_and_exit( + f"Encountered error while publishing event with timestamp '{e}'. To " + f"continue where I left off, run the following command:" + f"\n\n\t{command}\n\nExiting." + ) + + except RasaException as e: + rasa.shared.utils.cli.print_error_and_exit(str(e)) + + +def _get_continuation_command(exporter: "Exporter", timestamp: float) -> Text: + """Build CLI command to continue 'rasa export' where it was interrupted. + + Called when event publishing stops due to an error. + + Args: + exporter: Exporter object containing objects relevant for this export. + timestamp: Timestamp of the last event attempted to be published. + + """ + # build CLI command command based on supplied timestamp and options + command = "rasa export" + + if exporter.endpoints_path is not None: + command += f" --endpoints {exporter.endpoints_path}" + + command += f" --minimum-timestamp {timestamp}" + + if exporter.maximum_timestamp is not None: + command += f" --maximum-timestamp {exporter.maximum_timestamp}" + + if exporter.requested_conversation_ids: + command += ( + f" --conversation-ids {','.join(exporter.requested_conversation_ids)}" + ) + + if exporter.offset_timestamps_by_seconds is not None: + command += ( + f" --offset-timestamps-by-seconds {exporter.offset_timestamps_by_seconds}" + ) + + return command diff --git a/rasa/cli/initial_project/actions/__init__.py b/rasa/cli/initial_project/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/cli/initial_project/actions/actions.py b/rasa/cli/initial_project/actions/actions.py new file mode 100644 index 0000000..8bf1f75 --- /dev/null +++ b/rasa/cli/initial_project/actions/actions.py @@ -0,0 +1,27 @@ +# This files contains your custom actions which can be used to run +# custom Python code. +# +# See this guide on how to implement these action: +# https://rasa.com/docs/rasa/custom-actions + + +# This is a simple example for a custom action which utters "Hello World!" + +# from typing import Any, Text, Dict, List +# +# from rasa_sdk import Action, Tracker +# from rasa_sdk.executor import CollectingDispatcher +# +# +# class ActionHelloWorld(Action): +# +# def name(self) -> Text: +# return "action_hello_world" +# +# def run(self, dispatcher: CollectingDispatcher, +# tracker: Tracker, +# domain: Dict[Text, Any]) -> List[Dict[Text, Any]]: +# +# dispatcher.utter_message(text="Hello World!") +# +# return [] diff --git a/rasa/cli/initial_project/config.yml b/rasa/cli/initial_project/config.yml new file mode 100644 index 0000000..eb54e60 --- /dev/null +++ b/rasa/cli/initial_project/config.yml @@ -0,0 +1,44 @@ +# The config recipe. +# https://rasa.com/docs/rasa/model-configuration/ +recipe: default.v1 + +# The assistant project unique identifier +# This default value must be replaced with a unique assistant name within your deployment +assistant_id: placeholder_default + +# Configuration for Rasa NLU. +# https://rasa.com/docs/rasa/nlu/components/ +language: en + +pipeline: +# # No configuration for the NLU pipeline was provided. The following default pipeline was used to train your model. +# # If you'd like to customize it, uncomment and adjust the pipeline. +# # See https://rasa.com/docs/rasa/tuning-your-model for more information. +# - name: WhitespaceTokenizer +# - name: RegexFeaturizer +# - name: LexicalSyntacticFeaturizer +# - name: CountVectorsFeaturizer +# - name: CountVectorsFeaturizer +# analyzer: char_wb +# min_ngram: 1 +# max_ngram: 4 +# - name: DIETClassifier +# epochs: 100 +# - name: EntitySynonymMapper +# - name: ResponseSelector +# epochs: 100 +# - name: FallbackClassifier +# threshold: 0.3 +# ambiguity_threshold: 0.1 + +# Configuration for Rasa Core. +# https://rasa.com/docs/rasa/core/policies/ +policies: +# # No configuration for policies was provided. The following default policies were used to train your model. +# # If you'd like to customize them, uncomment and adjust the policies. +# # See https://rasa.com/docs/rasa/policies for more information. +# - name: MemoizationPolicy +# - name: TEDPolicy +# max_history: 5 +# epochs: 100 +# - name: RulePolicy diff --git a/rasa/cli/initial_project/credentials.yml b/rasa/cli/initial_project/credentials.yml new file mode 100644 index 0000000..e9f1291 --- /dev/null +++ b/rasa/cli/initial_project/credentials.yml @@ -0,0 +1,33 @@ +# This file contains the credentials for the voice & chat platforms +# which your bot is using. +# https://rasa.com/docs/rasa/messaging-and-voice-channels + +rest: +# # you don't need to provide anything here - this channel doesn't +# # require any credentials + + +#facebook: +# verify: "<verify>" +# secret: "<your secret>" +# page-access-token: "<your page access token>" + +#slack: +# slack_token: "<your slack token>" +# slack_channel: "<the slack channel>" +# slack_signing_secret: "<your slack signing secret>" + +#socketio: +# user_message_evt: <event name for user message> +# bot_message_evt: <event name for bot messages> +# session_persistence: <true/false> + +#mattermost: +# url: "https://<mattermost instance>/api/v4" +# token: "<bot token>" +# webhook_url: "<callback URL>" + +# This entry is needed if you are using Rasa Enterprise. The entry represents credentials +# for the Rasa Enterprise "channel", i.e. Talk to your bot and Share with guest testers. +rasa: + url: "http://localhost:5002/api" diff --git a/rasa/cli/initial_project/data/nlu.yml b/rasa/cli/initial_project/data/nlu.yml new file mode 100644 index 0000000..2f6c3f8 --- /dev/null +++ b/rasa/cli/initial_project/data/nlu.yml @@ -0,0 +1,91 @@ +version: "3.1" + +nlu: +- intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon + +- intent: goodbye + examples: | + - cu + - good by + - cee you later + - good night + - bye + - goodbye + - have a nice day + - see you around + - bye bye + - see you later + +- intent: affirm + examples: | + - yes + - y + - indeed + - of course + - that sounds good + - correct + +- intent: deny + examples: | + - no + - n + - never + - I don't think so + - don't like that + - no way + - not really + +- intent: mood_great + examples: | + - perfect + - great + - amazing + - feeling like a king + - wonderful + - I am feeling very good + - I am great + - I am amazing + - I am going to save the world + - super stoked + - extremely good + - so so perfect + - so good + - so perfect + +- intent: mood_unhappy + examples: | + - my day was horrible + - I am sad + - I don't feel very well + - I am disappointed + - super sad + - I'm so sad + - sad + - very sad + - unhappy + - not good + - not very good + - extremly sad + - so saad + - so sad + +- intent: bot_challenge + examples: | + - are you a bot? + - are you a human? + - am I talking to a bot? + - am I talking to a human? diff --git a/rasa/cli/initial_project/data/rules.yml b/rasa/cli/initial_project/data/rules.yml new file mode 100644 index 0000000..a9987ee --- /dev/null +++ b/rasa/cli/initial_project/data/rules.yml @@ -0,0 +1,13 @@ +version: "3.1" + +rules: + +- rule: Say goodbye anytime the user says goodbye + steps: + - intent: goodbye + - action: utter_goodbye + +- rule: Say 'I am a bot' anytime the user challenges + steps: + - intent: bot_challenge + - action: utter_iamabot diff --git a/rasa/cli/initial_project/data/stories.yml b/rasa/cli/initial_project/data/stories.yml new file mode 100644 index 0000000..6ff78ee --- /dev/null +++ b/rasa/cli/initial_project/data/stories.yml @@ -0,0 +1,30 @@ +version: "3.1" + +stories: + +- story: happy path + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy + +- story: sad path 1 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + +- story: sad path 2 + steps: + - intent: greet + - action: utter_greet + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: deny + - action: utter_goodbye diff --git a/rasa/cli/initial_project/domain.yml b/rasa/cli/initial_project/domain.yml new file mode 100644 index 0000000..932195f --- /dev/null +++ b/rasa/cli/initial_project/domain.yml @@ -0,0 +1,34 @@ +version: "3.1" + +intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + +responses: + utter_greet: + - text: "Hey! How are you?" + + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" + + utter_did_that_help: + - text: "Did that help you?" + + utter_happy: + - text: "Great, carry on!" + + utter_goodbye: + - text: "Bye" + + utter_iamabot: + - text: "I am a bot, powered by Rasa." + +session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true diff --git a/rasa/cli/initial_project/endpoints.yml b/rasa/cli/initial_project/endpoints.yml new file mode 100644 index 0000000..1128e1d --- /dev/null +++ b/rasa/cli/initial_project/endpoints.yml @@ -0,0 +1,42 @@ +# This file contains the different endpoints your bot can use. + +# Server where the models are pulled from. +# https://rasa.com/docs/rasa/model-storage#fetching-models-from-a-server + +#models: +# url: http://my-server.com/models/default_core@latest +# wait_time_between_pulls: 10 # [optional](default: 100) + +# Server which runs your custom actions. +# https://rasa.com/docs/rasa/custom-actions + +#action_endpoint: +# url: "http://localhost:5055/webhook" + +# Tracker store which is used to store the conversations. +# By default the conversations are stored in memory. +# https://rasa.com/docs/rasa/tracker-stores + +#tracker_store: +# type: redis +# url: <host of the redis instance, e.g. localhost> +# port: <port of your redis instance, usually 6379> +# db: <number of your database within redis, e.g. 0> +# password: <password used for authentication> +# use_ssl: <whether or not the communication is encrypted, default false> + +#tracker_store: +# type: mongod +# url: <url to your mongo instance, e.g. mongodb://localhost:27017> +# db: <name of the db within your mongo instance, e.g. rasa> +# username: <username used for authentication> +# password: <password used for authentication> + +# Event broker which all conversation events should be streamed to. +# https://rasa.com/docs/rasa/event-brokers + +#event_broker: +# url: localhost +# username: username +# password: password +# queue: queue diff --git a/rasa/cli/initial_project/tests/test_stories.yml b/rasa/cli/initial_project/tests/test_stories.yml new file mode 100644 index 0000000..d46e39b --- /dev/null +++ b/rasa/cli/initial_project/tests/test_stories.yml @@ -0,0 +1,91 @@ +#### This file contains tests to evaluate that your bot behaves as expected. +#### If you want to learn more, please see the docs: https://rasa.com/docs/rasa/testing-your-assistant + +stories: +- story: happy path 1 + steps: + - user: | + hello there! + intent: greet + - action: utter_greet + - user: | + amazing + intent: mood_great + - action: utter_happy + +- story: happy path 2 + steps: + - user: | + hello there! + intent: greet + - action: utter_greet + - user: | + amazing + intent: mood_great + - action: utter_happy + - user: | + bye-bye! + intent: goodbye + - action: utter_goodbye + +- story: sad path 1 + steps: + - user: | + hello + intent: greet + - action: utter_greet + - user: | + not good + intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - user: | + yes + intent: affirm + - action: utter_happy + +- story: sad path 2 + steps: + - user: | + hello + intent: greet + - action: utter_greet + - user: | + not good + intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - user: | + not really + intent: deny + - action: utter_goodbye + +- story: sad path 3 + steps: + - user: | + hi + intent: greet + - action: utter_greet + - user: | + very terrible + intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - user: | + no + intent: deny + - action: utter_goodbye + +- story: say goodbye + steps: + - user: | + bye-bye! + intent: goodbye + - action: utter_goodbye + +- story: bot challenge + steps: + - user: | + are you a bot? + intent: bot_challenge + - action: utter_iamabot diff --git a/rasa/cli/interactive.py b/rasa/cli/interactive.py new file mode 100644 index 0000000..f0c2207 --- /dev/null +++ b/rasa/cli/interactive.py @@ -0,0 +1,164 @@ +import argparse +import logging +import os +from pathlib import Path +from typing import List, Optional, Text, Union + +from rasa import model +from rasa.cli import SubParsersAction +from rasa.cli.arguments import interactive as arguments +import rasa.cli.train as train +import rasa.cli.utils +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.shared.constants import ( + ASSISTANT_ID_DEFAULT_VALUE, + ASSISTANT_ID_KEY, + DEFAULT_ENDPOINTS_PATH, + DEFAULT_MODELS_PATH, +) +from rasa.shared.data import TrainingType +from rasa.shared.importers.importer import TrainingDataImporter +import rasa.shared.utils.cli +import rasa.utils.common + + +logger = logging.getLogger(__name__) + + +def add_subparser( + subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + """Add all interactive cli parsers. + + Args: + subparsers: subparser we are going to attach to + parents: Parent parsers, needed to ensure tree structure in argparse + """ + interactive_parser = subparsers.add_parser( + "interactive", + conflict_handler="resolve", + parents=parents, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Starts an interactive learning session to create new training data for a " + "Rasa model by chatting.", + ) + interactive_parser.set_defaults(func=interactive, core_only=False) + + interactive_subparsers = interactive_parser.add_subparsers() + interactive_core_parser = interactive_subparsers.add_parser( + "core", + conflict_handler="resolve", + parents=parents, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Starts an interactive learning session model to create new training data " + "for a Rasa Core model by chatting. Uses the 'RegexMessageHandler', i.e. " + "`/<intent>` input format.", + ) + interactive_core_parser.set_defaults(func=interactive, core_only=True) + + arguments.set_interactive_arguments(interactive_parser) + arguments.set_interactive_core_arguments(interactive_core_parser) + + +def interactive(args: argparse.Namespace) -> None: + _set_not_required_args(args) + file_importer = TrainingDataImporter.load_from_config( + args.config, args.domain, args.data if not args.core_only else [args.stories] + ) + + if args.model is None: + story_graph = file_importer.get_stories() + if not story_graph or story_graph.is_empty(): + rasa.shared.utils.cli.print_error_and_exit( + "Could not run interactive learning without either core " + "data or a model containing core data." + ) + + zipped_model: Optional[Union[Text, Path]] = ( + train.run_core_training(args) + if args.core_only + else train.run_training(args) + ) + if not zipped_model: + rasa.shared.utils.cli.print_error_and_exit( + "Could not train an initial model. Either pass paths " + "to the relevant training files (`--data`, `--config`, `--domain`), " + "or use 'rasa train' to train a model." + ) + else: + validate_assistant_id_key_in_config(file_importer) + + zipped_model = get_provided_model(args.model) + if not (zipped_model and os.path.exists(zipped_model)): + rasa.shared.utils.cli.print_error_and_exit( + f"Interactive learning process cannot be started as no " + f"initial model was found at path '{args.model}'. " + f"Use 'rasa train' to train a model." + ) + if not args.skip_visualization: + logger.info(f"Loading visualization data from {args.data}.") + + perform_interactive_learning(args, zipped_model, file_importer) + + +def _set_not_required_args(args: argparse.Namespace) -> None: + args.fixed_model_name = None + args.store_uncompressed = False + args.dry_run = False + args.skip_validation = True + args.fail_on_validation_warnings = False + args.validation_max_history = None + + +def perform_interactive_learning( + args: argparse.Namespace, + zipped_model: Union[Text, "Path"], + file_importer: TrainingDataImporter, +) -> None: + """Performs interactive learning. + + Args: + args: Namespace arguments. + zipped_model: Path to zipped model. + file_importer: File importer which provides the training data and model config. + """ + from rasa.core.train import do_interactive_learning + + args.model = str(zipped_model) + + metadata = LocalModelStorage.metadata_from_archive(zipped_model) + if metadata.training_type == TrainingType.NLU: + rasa.shared.utils.cli.print_error_and_exit( + "Can not run interactive learning on an NLU-only model." + ) + + args.endpoints = rasa.cli.utils.get_validated_path( + args.endpoints, "endpoints", DEFAULT_ENDPOINTS_PATH, True + ) + + do_interactive_learning(args, file_importer) + + +def get_provided_model(arg_model: Text) -> Optional[Union[Text, Path]]: + """Checks model path input and selects model from it.""" + model_path = rasa.cli.utils.get_validated_path( + arg_model, "model", DEFAULT_MODELS_PATH + ) + + return ( + model.get_latest_model(model_path) if os.path.isdir(model_path) else model_path + ) + + +def validate_assistant_id_key_in_config(file_importer: TrainingDataImporter) -> None: + """Verifies that config contains a unique value for assistant identifier.""" + config_data = file_importer.get_config() + assistant_id = config_data.get(ASSISTANT_ID_KEY) + + if assistant_id is None or assistant_id == ASSISTANT_ID_DEFAULT_VALUE: + rasa.shared.utils.cli.print_error_and_exit( + f"The '{ASSISTANT_ID_KEY}' key in the config file is either missing or " + f"is set to the default value. Please replace the placeholder default " + f"value and re-train the model." + ) + return None diff --git a/rasa/cli/run.py b/rasa/cli/run.py new file mode 100644 index 0000000..d27fe6c --- /dev/null +++ b/rasa/cli/run.py @@ -0,0 +1,136 @@ +import argparse +import logging +import os +from typing import List, Text + +from rasa.cli import SubParsersAction +from rasa.cli.arguments import run as arguments +from rasa.shared.constants import ( + DOCS_BASE_URL, + DEFAULT_ENDPOINTS_PATH, + DEFAULT_CREDENTIALS_PATH, + DEFAULT_ACTIONS_PATH, + DEFAULT_MODELS_PATH, +) +from rasa.exceptions import ModelNotFound + +logger = logging.getLogger(__name__) + + +def add_subparser( + subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + """Add all run parsers. + + Args: + subparsers: subparser we are going to attach to + parents: Parent parsers, needed to ensure tree structure in argparse + """ + run_parser = subparsers.add_parser( + "run", + parents=parents, + conflict_handler="resolve", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Starts a Rasa server with your trained model.", + ) + run_parser.set_defaults(func=run) + + run_subparsers = run_parser.add_subparsers() + sdk_subparser = run_subparsers.add_parser( + "actions", + parents=parents, + conflict_handler="resolve", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Runs the action server.", + ) + sdk_subparser.set_defaults(func=run_actions) + + arguments.set_run_arguments(run_parser) + arguments.set_run_action_arguments(sdk_subparser) + + +def run_actions(args: argparse.Namespace) -> None: + import rasa_sdk.__main__ as sdk + + args.actions = args.actions or DEFAULT_ACTIONS_PATH + + sdk.main_from_args(args) + + +def _validate_model_path(model_path: Text, parameter: Text, default: Text) -> Text: + + if model_path is not None and not os.path.exists(model_path): + reason_str = f"'{model_path}' not found." + if model_path is None: + reason_str = f"Parameter '{parameter}' not set." + + logger.debug(f"{reason_str} Using default location '{default}' instead.") + + os.makedirs(default, exist_ok=True) + model_path = default + + return model_path + + +def run(args: argparse.Namespace) -> None: + """Entrypoint for `rasa run`. + + Args: + args: The CLI arguments. + """ + import rasa + + args.endpoints = rasa.cli.utils.get_validated_path( + args.endpoints, "endpoints", DEFAULT_ENDPOINTS_PATH, True + ) + args.credentials = rasa.cli.utils.get_validated_path( + args.credentials, "credentials", DEFAULT_CREDENTIALS_PATH, True + ) + + if args.enable_api: + if not args.remote_storage: + args.model = _validate_model_path(args.model, "model", DEFAULT_MODELS_PATH) + rasa.run(**vars(args)) + return + + # if the API is not enable you cannot start without a model + # make sure either a model server, a remote storage, or a local model is + # configured + + import rasa.model + from rasa.core.utils import AvailableEndpoints + + # start server if remote storage is configured + if args.remote_storage is not None: + rasa.run(**vars(args)) + return + + # start server if model server is configured + endpoints = AvailableEndpoints.read_endpoints(args.endpoints) + model_server = endpoints.model if endpoints and endpoints.model else None + if model_server is not None: + rasa.run(**vars(args)) + return + + # start server if local model found + args.model = _validate_model_path(args.model, "model", DEFAULT_MODELS_PATH) + local_model_set = True + try: + rasa.model.get_local_model(args.model) + except ModelNotFound: + local_model_set = False + + if local_model_set: + rasa.run(**vars(args)) + return + + rasa.shared.utils.cli.print_error( + f"No model found. You have three options to provide a model:\n" + f"1. Configure a model server in the endpoint configuration and provide " + f"the configuration via '--endpoints'.\n" + f"2. Specify a remote storage via '--remote-storage' to load the model " + f"from.\n" + f"3. Train a model before running the server using `rasa train` and " + f"use '--model' to provide the model path.\n" + f"For more information check {DOCS_BASE_URL}/model-storage." + ) diff --git a/rasa/cli/scaffold.py b/rasa/cli/scaffold.py new file mode 100644 index 0000000..b5f0f1e --- /dev/null +++ b/rasa/cli/scaffold.py @@ -0,0 +1,238 @@ +import argparse +import os +import sys +from typing import List, Text + +from rasa import telemetry +from rasa.cli import SubParsersAction +from rasa.cli.shell import shell +from rasa.shared.utils.cli import print_success, print_error_and_exit +from rasa.shared.constants import ( + DOCS_BASE_URL, + DEFAULT_CONFIG_PATH, + DEFAULT_DOMAIN_PATH, + DEFAULT_DATA_PATH, + DEFAULT_MODELS_PATH, +) + + +def add_subparser( + subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + """Add all init parsers. + + Args: + subparsers: subparser we are going to attach to + parents: Parent parsers, needed to ensure tree structure in argparse + """ + scaffold_parser = subparsers.add_parser( + "init", + parents=parents, + help="Creates a new project, with example training data, " + "actions, and config files.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + scaffold_parser.add_argument( + "--no-prompt", + action="store_true", + help="Automatically choose default options for prompts and suppress warnings.", + ) + scaffold_parser.add_argument( + "--init-dir", + default=None, + help="Directory where your project should be initialized.", + ) + + scaffold_parser.set_defaults(func=run) + + +def print_train_or_instructions(args: argparse.Namespace) -> None: + """Train a model if the user wants to.""" + import questionary + import rasa + + print_success("Finished creating project structure.") + + should_train = ( + questionary.confirm("Do you want to train an initial model? 💪🏽") + .skip_if(args.no_prompt, default=True) + .ask() + ) + + if should_train: + print_success("Training an initial model...") + training_result = rasa.train( + DEFAULT_DOMAIN_PATH, + DEFAULT_CONFIG_PATH, + DEFAULT_DATA_PATH, + DEFAULT_MODELS_PATH, + ) + args.model = training_result.model + + print_run_or_instructions(args) + + else: + print_success( + "No problem 👍🏼. You can also train a model later by going " + "to the project directory and running 'rasa train'." + ) + + +def print_run_or_instructions(args: argparse.Namespace) -> None: + from rasa.core import constants + import questionary + + should_run = ( + questionary.confirm( + "Do you want to speak to the trained assistant on the command line? 🤖" + ) + .skip_if(args.no_prompt, default=False) + .ask() + ) + + if should_run: + # provide defaults for command line arguments + attributes = [ + "endpoints", + "credentials", + "cors", + "auth_token", + "jwt_secret", + "jwt_method", + "enable_api", + "remote_storage", + ] + for a in attributes: + setattr(args, a, None) + + args.port = constants.DEFAULT_SERVER_PORT + + shell(args) + else: + if args.no_prompt: + print( + "If you want to speak to the assistant, " + "run 'rasa shell' at any time inside " + "the project directory." + ) + else: + print_success( + "Ok 👍🏼. " + "If you want to speak to the assistant, " + "run 'rasa shell' at any time inside " + "the project directory." + ) + + +def init_project(args: argparse.Namespace, path: Text) -> None: + """Inits project.""" + os.chdir(path) + create_initial_project(".") + print(f"Created project directory at '{os.getcwd()}'.") + print_train_or_instructions(args) + + +def create_initial_project(path: Text) -> None: + """Creates directory structure and templates for initial project.""" + from distutils.dir_util import copy_tree + + copy_tree(scaffold_path(), path) + + +def scaffold_path() -> Text: + import pkg_resources + + return pkg_resources.resource_filename(__name__, "initial_project") + + +def print_cancel() -> None: + print_success("Ok. You can continue setting up by running 'rasa init' 🙋🏽‍♀️") + sys.exit(0) + + +def _ask_create_path(path: Text) -> None: + import questionary + + should_create = questionary.confirm( + f"Path '{path}' does not exist 🧐. Create path?" + ).ask() + + if should_create: + try: + os.makedirs(path) + except (PermissionError, OSError, FileExistsError) as e: + print_error_and_exit( + f"Failed to create project path at '{path}'. " f"Error: {e}" + ) + else: + print_success( + "Ok, will exit for now. You can continue setting up by " + "running 'rasa init' again 🙋🏽‍♀️" + ) + sys.exit(0) + + +def _ask_overwrite(path: Text) -> None: + import questionary + + overwrite = questionary.confirm( + "Directory '{}' is not empty. Continue?".format(os.path.abspath(path)) + ).ask() + if not overwrite: + print_cancel() + + +def run(args: argparse.Namespace) -> None: + import questionary + + print_success("Welcome to Rasa! 🤖\n") + if args.no_prompt: + print( + f"To get started quickly, an " + f"initial project will be created.\n" + f"If you need some help, check out " + f"the documentation at {DOCS_BASE_URL}.\n" + ) + else: + print( + f"To get started quickly, an " + f"initial project will be created.\n" + f"If you need some help, check out " + f"the documentation at {DOCS_BASE_URL}.\n" + f"Now let's start! 👇🏽\n" + ) + + if args.init_dir is not None: + path = args.init_dir + else: + path = ( + questionary.text( + "Please enter a path where the project will be " + "created [default: current directory]" + ) + .skip_if(args.no_prompt, default="") + .ask() + ) + # set the default directory. we can't use the `default` property + # in questionary as we want to avoid showing the "." in the prompt as the + # initial value. users tend to overlook it and it leads to invalid + # paths like: ".C:\mydir". + # Can't use `if not path` either, as `None` will be handled differently (abort) + if path == "": + path = "." + + if args.no_prompt and not os.path.isdir(path): + print_error_and_exit(f"Project init path '{path}' not found.") + + if path and not os.path.isdir(path): + _ask_create_path(path) + + if path is None or not os.path.isdir(path): + print_cancel() + + if not args.no_prompt and len(os.listdir(path)) > 0: + _ask_overwrite(path) + + telemetry.track_project_init(path) + + init_project(args, path) diff --git a/rasa/cli/shell.py b/rasa/cli/shell.py new file mode 100644 index 0000000..ddad2a7 --- /dev/null +++ b/rasa/cli/shell.py @@ -0,0 +1,141 @@ +import argparse +import logging +import uuid + +from typing import List + +from rasa import telemetry +from rasa.cli import SubParsersAction +from rasa.cli.arguments import shell as arguments +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.model import get_local_model +from rasa.shared.constants import ASSISTANT_ID_KEY +from rasa.shared.data import TrainingType +from rasa.shared.utils.cli import print_error +from rasa.exceptions import ModelNotFound + +logger = logging.getLogger(__name__) + + +def add_subparser( + subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + """Add all shell parsers. + + Args: + subparsers: subparser we are going to attach to + parents: Parent parsers, needed to ensure tree structure in argparse + """ + shell_parser = subparsers.add_parser( + "shell", + parents=parents, + conflict_handler="resolve", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help=( + "Loads your trained model and lets you talk to your " + "assistant on the command line." + ), + ) + shell_parser.set_defaults(func=shell) + + shell_parser.add_argument( + "--conversation-id", + default=uuid.uuid4().hex, + required=False, + help="Set the conversation ID.", + ) + + run_subparsers = shell_parser.add_subparsers() + + shell_nlu_subparser = run_subparsers.add_parser( + "nlu", + parents=parents, + conflict_handler="resolve", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Interprets messages on the command line using your NLU model.", + ) + + shell_nlu_subparser.set_defaults(func=shell_nlu) + + arguments.set_shell_arguments(shell_parser) + arguments.set_shell_nlu_arguments(shell_nlu_subparser) + + +def shell_nlu(args: argparse.Namespace) -> None: + """Talk with an NLU only bot though the command line.""" + from rasa.cli.utils import get_validated_path + from rasa.shared.constants import DEFAULT_MODELS_PATH + import rasa.nlu.run + + args.connector = "cmdline" + + model_path = get_validated_path(args.model, "model", DEFAULT_MODELS_PATH) + + try: + model = get_local_model(model_path) + except ModelNotFound: + print_error( + "No model found. Train a model before running the " + "server using `rasa train nlu`." + ) + return + + metadata = LocalModelStorage.metadata_from_archive(model) + + if metadata.assistant_id is None: + print_error( + f"The model metadata does not contain a value for the '{ASSISTANT_ID_KEY}' " + f"attribute. Check that 'config.yml' file contains a value for " + f"the '{ASSISTANT_ID_KEY}' key and re-train the model.", + ) + + if metadata.training_type == TrainingType.CORE: + print_error( + "No NLU model found. Train a model before running the " + "server using `rasa train nlu`." + ) + return + + telemetry.track_shell_started("nlu") + rasa.nlu.run.run_cmdline(model) + + +def shell(args: argparse.Namespace) -> None: + """Talk with a bot though the command line.""" + from rasa.cli.utils import get_validated_path + from rasa.shared.constants import DEFAULT_MODELS_PATH + + args.connector = "cmdline" + + model = get_validated_path(args.model, "model", DEFAULT_MODELS_PATH) + + try: + model = get_local_model(model) + except ModelNotFound: + print_error( + "No model found. Train a model before running the " + "server using `rasa train`." + ) + return + + metadata = LocalModelStorage.metadata_from_archive(model) + + if metadata.assistant_id is None: + print_error( + f"The model metadata does not contain a value for the '{ASSISTANT_ID_KEY}' " + f"attribute. Check that 'config.yml' file contains a value for " + f"the '{ASSISTANT_ID_KEY}' key and re-train the model.", + ) + + if metadata.training_type == TrainingType.NLU: + import rasa.nlu.run + + telemetry.track_shell_started("nlu") + + rasa.nlu.run.run_cmdline(model) + else: + import rasa.cli.run + + telemetry.track_shell_started("rasa") + + rasa.cli.run.run(args) diff --git a/rasa/cli/telemetry.py b/rasa/cli/telemetry.py new file mode 100644 index 0000000..f5b810f --- /dev/null +++ b/rasa/cli/telemetry.py @@ -0,0 +1,90 @@ +import argparse +import textwrap +from typing import List + +from rasa import telemetry +from rasa.cli import SubParsersAction +import rasa.cli.utils +from rasa.shared.constants import DOCS_URL_TELEMETRY +import rasa.shared.utils.cli + + +def add_subparser( + subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + """Add all telemetry tracking parsers. + + Args: + subparsers: subparser we are going to attach to + parents: Parent parsers, needed to ensure tree structure in argparse + """ + telemetry_parser = subparsers.add_parser( + "telemetry", + parents=parents, + help="Configuration of Rasa Open Source telemetry reporting.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + telemetry_subparsers = telemetry_parser.add_subparsers() + telemetry_disable_parser = telemetry_subparsers.add_parser( + "disable", + parents=parents, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Disable Rasa Open Source Telemetry reporting.", + ) + telemetry_disable_parser.set_defaults(func=disable_telemetry) + + telemetry_enable_parser = telemetry_subparsers.add_parser( + "enable", + parents=parents, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Enable Rasa Open Source Telemetry reporting.", + ) + telemetry_enable_parser.set_defaults(func=enable_telemetry) + telemetry_parser.set_defaults(func=inform_about_telemetry) + + +def inform_about_telemetry(_: argparse.Namespace) -> None: + """Inform user about telemetry tracking.""" + is_enabled = telemetry.is_telemetry_enabled() + if is_enabled: + rasa.shared.utils.cli.print_success( + "Telemetry reporting is currently enabled for this installation." + ) + else: + rasa.shared.utils.cli.print_success( + "Telemetry reporting is currently disabled for this installation." + ) + + print( + textwrap.dedent( + """ + Rasa uses telemetry to report anonymous usage information. This information + is essential to help improve Rasa Open Source for all users.""" + ) + ) + + if not is_enabled: + print("\nYou can enable telemetry reporting using") + rasa.shared.utils.cli.print_info("\n\trasa telemetry enable") + else: + print("\nYou can disable telemetry reporting using:") + rasa.shared.utils.cli.print_info("\n\trasa telemetry disable") + + rasa.shared.utils.cli.print_success( + "\nYou can find more information about telemetry reporting at " + "" + DOCS_URL_TELEMETRY + ) + + +def disable_telemetry(_: argparse.Namespace) -> None: + """Disable telemetry tracking.""" + telemetry.track_telemetry_disabled() + telemetry.toggle_telemetry_reporting(is_enabled=False) + rasa.shared.utils.cli.print_success("Disabled telemetry reporting.") + + +def enable_telemetry(_: argparse.Namespace) -> None: + """Enable telemetry tracking.""" + telemetry.toggle_telemetry_reporting(is_enabled=True) + rasa.shared.utils.cli.print_success("Enabled telemetry reporting.") diff --git a/rasa/cli/test.py b/rasa/cli/test.py new file mode 100644 index 0000000..f0ba986 --- /dev/null +++ b/rasa/cli/test.py @@ -0,0 +1,280 @@ +import argparse +import asyncio +import logging +import os +from typing import List, Optional, Text, Dict, Union, Any + +from rasa.cli import SubParsersAction +import rasa.shared.data +from rasa.shared.exceptions import YamlException +import rasa.shared.utils.io +import rasa.shared.utils.cli +from rasa.cli.arguments import test as arguments +from rasa.core.constants import ( + FAILED_STORIES_FILE, + SUCCESSFUL_STORIES_FILE, + STORIES_WITH_WARNINGS_FILE, +) +from rasa.shared.constants import ( + CONFIG_SCHEMA_FILE, + DEFAULT_E2E_TESTS_PATH, + DEFAULT_CONFIG_PATH, + DEFAULT_MODELS_PATH, + DEFAULT_DATA_PATH, + DEFAULT_RESULTS_PATH, +) +import rasa.shared.utils.validation as validation_utils +import rasa.cli.utils +import rasa.utils.common +from rasa.shared.importers.importer import TrainingDataImporter + +logger = logging.getLogger(__name__) + + +def add_subparser( + subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + """Add all test parsers. + + Args: + subparsers: subparser we are going to attach to + parents: Parent parsers, needed to ensure tree structure in argparse + """ + test_parser = subparsers.add_parser( + "test", + parents=parents, + conflict_handler="resolve", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Tests Rasa models using your test NLU data and stories.", + ) + + arguments.set_test_arguments(test_parser) + + test_subparsers = test_parser.add_subparsers() + test_core_parser = test_subparsers.add_parser( + "core", + parents=parents, + conflict_handler="resolve", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Tests Rasa Core models using your test stories.", + ) + arguments.set_test_core_arguments(test_core_parser) + + test_nlu_parser = test_subparsers.add_parser( + "nlu", + parents=parents, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Tests Rasa NLU models using your test NLU data.", + ) + arguments.set_test_nlu_arguments(test_nlu_parser) + + test_core_parser.set_defaults(func=run_core_test) + test_nlu_parser.set_defaults(func=run_nlu_test) + test_parser.set_defaults(func=test, stories=DEFAULT_E2E_TESTS_PATH) + + +def _print_core_test_execution_info(args: argparse.Namespace) -> None: + output = args.out or DEFAULT_RESULTS_PATH + + if args.successes: + rasa.shared.utils.cli.print_info( + f"Successful stories written to " + f"'{os.path.join(output, SUCCESSFUL_STORIES_FILE)}'" + ) + if not args.no_errors: + rasa.shared.utils.cli.print_info( + f"Failed stories written to '{os.path.join(output, FAILED_STORIES_FILE)}'" + ) + if not args.no_warnings: + rasa.shared.utils.cli.print_info( + f"Stories with prediction warnings written to " + f"'{os.path.join(output, STORIES_WITH_WARNINGS_FILE)}'" + ) + + +async def run_core_test_async(args: argparse.Namespace) -> None: + """Run core tests.""" + from rasa.model_testing import ( + test_core_models_in_directory, + test_core, + test_core_models, + ) + + stories = rasa.cli.utils.get_validated_path( + args.stories, "stories", DEFAULT_DATA_PATH + ) + + output = args.out or DEFAULT_RESULTS_PATH + args.errors = not args.no_errors + args.warnings = not args.no_warnings + + rasa.shared.utils.io.create_directory(output) + + if isinstance(args.model, list) and len(args.model) == 1: + args.model = args.model[0] + + if args.model is None: + rasa.shared.utils.cli.print_error( + "No model provided. Please make sure to specify " + "the model to test with '--model'." + ) + return + + if isinstance(args.model, str): + model_path = rasa.cli.utils.get_validated_path( + args.model, "model", DEFAULT_MODELS_PATH + ) + + if args.evaluate_model_directory: + await test_core_models_in_directory( + args.model, stories, output, use_conversation_test_files=args.e2e + ) + else: + await test_core( + model=model_path, + stories=stories, + output=output, + additional_arguments=vars(args), + use_conversation_test_files=args.e2e, + ) + + else: + await test_core_models( + args.model, stories, output, use_conversation_test_files=args.e2e + ) + + _print_core_test_execution_info(args) + + +async def run_nlu_test_async( + config: Optional[Union[Text, List[Text]]], + data_path: Text, + models_path: Text, + output_dir: Text, + cross_validation: bool, + percentages: List[int], + runs: int, + no_errors: bool, + domain_path: Text, + all_args: Dict[Text, Any], +) -> None: + """Runs NLU tests. + + Args: + all_args: all arguments gathered in a Dict so we can pass it as one argument + to other functions. + config: it refers to the model configuration file. It can be a single file or + a list of multiple files or a folder with multiple config files inside. + data_path: path for the nlu data. + models_path: path to a trained Rasa model. + output_dir: output path for any files created during the evaluation. + cross_validation: indicates if it should test the model using cross validation + or not. + percentages: defines the exclusion percentage of the training data. + runs: number of comparison runs to make. + domain_path: path to domain. + no_errors: indicates if incorrect predictions should be written to a file + or not. + """ + from rasa.model_testing import ( + compare_nlu_models, + perform_nlu_cross_validation, + test_nlu, + ) + + data_path = str( + rasa.cli.utils.get_validated_path(data_path, "nlu", DEFAULT_DATA_PATH) + ) + test_data_importer = TrainingDataImporter.load_from_dict( + training_data_paths=[data_path], domain_path=domain_path + ) + nlu_data = test_data_importer.get_nlu_data() + + output = output_dir or DEFAULT_RESULTS_PATH + all_args["errors"] = not no_errors + rasa.shared.utils.io.create_directory(output) + + if config is not None and len(config) == 1: + config = os.path.abspath(config[0]) + if os.path.isdir(config): + config = rasa.shared.utils.io.list_files(config) + + if isinstance(config, list): + logger.info( + "Multiple configuration files specified, running nlu comparison mode." + ) + + config_files = [] + for file in config: + try: + validation_utils.validate_yaml_schema( + rasa.shared.utils.io.read_file(file), CONFIG_SCHEMA_FILE + ) + config_files.append(file) + except YamlException: + rasa.shared.utils.io.raise_warning( + f"Ignoring file '{file}' as it is not a valid config file." + ) + continue + await compare_nlu_models( + configs=config_files, + test_data=nlu_data, + output=output, + runs=runs, + exclusion_percentages=percentages, + ) + elif cross_validation: + logger.info("Test model using cross validation.") + # FIXME: supporting Union[Path, Text] down the chain + # is the proper fix and needs more work + config = str( + rasa.cli.utils.get_validated_path(config, "config", DEFAULT_CONFIG_PATH) + ) + config_importer = TrainingDataImporter.load_from_dict(config_path=config) + + config_dict = config_importer.get_config() + await perform_nlu_cross_validation(config_dict, nlu_data, output, all_args) + else: + model_path = rasa.cli.utils.get_validated_path( + models_path, "model", DEFAULT_MODELS_PATH + ) + + await test_nlu(model_path, data_path, output, all_args, domain_path=domain_path) + + +def run_nlu_test(args: argparse.Namespace) -> None: + """Runs NLU tests. + + Args: + args: the parsed CLI arguments for 'rasa test nlu'. + """ + asyncio.run( + run_nlu_test_async( + args.config, + args.nlu, + args.model, + args.out, + args.cross_validation, + args.percentages, + args.runs, + args.no_errors, + args.domain, + vars(args), + ) + ) + + +def run_core_test(args: argparse.Namespace) -> None: + """Runs Core tests. + + Args: + args: the parsed CLI arguments for 'rasa test core'. + """ + asyncio.run(run_core_test_async(args)) + + +def test(args: argparse.Namespace) -> None: + """Run end-to-end tests.""" + setattr(args, "e2e", True) + run_core_test(args) + run_nlu_test(args) diff --git a/rasa/cli/train.py b/rasa/cli/train.py new file mode 100644 index 0000000..311ec99 --- /dev/null +++ b/rasa/cli/train.py @@ -0,0 +1,240 @@ +import argparse +import logging +import sys +from typing import Dict, List, Optional, Text + +from rasa.cli import SubParsersAction +import rasa.cli.arguments.train as train_arguments + +import rasa.cli.utils +from rasa.shared.importers.importer import TrainingDataImporter +import rasa.utils.common +from rasa.core.train import do_compare_training +from rasa.plugin import plugin_manager +from rasa.shared.constants import ( + CONFIG_MANDATORY_KEYS_CORE, + CONFIG_MANDATORY_KEYS_NLU, + CONFIG_MANDATORY_KEYS, + DEFAULT_DOMAIN_PATH, + DEFAULT_DATA_PATH, +) + +logger = logging.getLogger(__name__) + + +def add_subparser( + subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + """Add all training parsers. + + Args: + subparsers: subparser we are going to attach to + parents: Parent parsers, needed to ensure tree structure in argparse + """ + train_parser = subparsers.add_parser( + "train", + help="Trains a Rasa model using your NLU data and stories.", + parents=parents, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + train_arguments.set_train_arguments(train_parser) + + train_subparsers = train_parser.add_subparsers() + train_core_parser = train_subparsers.add_parser( + "core", + parents=parents, + conflict_handler="resolve", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Trains a Rasa Core model using your stories.", + ) + train_core_parser.set_defaults(func=run_core_training) + + train_nlu_parser = train_subparsers.add_parser( + "nlu", + parents=parents, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Trains a Rasa NLU model using your NLU data.", + ) + train_nlu_parser.set_defaults(func=run_nlu_training) + + train_parser.set_defaults(func=lambda args: run_training(args, can_exit=True)) + + train_arguments.set_train_core_arguments(train_core_parser) + train_arguments.set_train_nlu_arguments(train_nlu_parser) + + +def run_training(args: argparse.Namespace, can_exit: bool = False) -> Optional[Text]: + """Trains a model. + + Args: + args: Namespace arguments. + can_exit: If `True`, the operation can send `sys.exit` in the case + training was not successful. + + Returns: + Path to a trained model or `None` if training was not successful. + """ + from rasa import train as train_all + + domain = rasa.cli.utils.get_validated_path( + args.domain, "domain", DEFAULT_DOMAIN_PATH, none_is_valid=True + ) + config = rasa.cli.utils.get_validated_config(args.config, CONFIG_MANDATORY_KEYS) + + training_files = [ + rasa.cli.utils.get_validated_path( + f, "data", DEFAULT_DATA_PATH, none_is_valid=True + ) + for f in args.data + ] + + if not args.skip_validation: + logger.info("Started validating domain and training data...") + importer = TrainingDataImporter.load_from_config( + domain_path=args.domain, training_data_paths=args.data, config_path=config + ) + rasa.cli.utils.validate_files( + args.fail_on_validation_warnings, args.validation_max_history, importer + ) + + training_result = train_all( + domain=domain, + config=config, + training_files=training_files, + output=args.out, + dry_run=args.dry_run, + force_training=args.force, + fixed_model_name=args.fixed_model_name, + persist_nlu_training_data=args.persist_nlu_data, + core_additional_arguments={ + **extract_core_additional_arguments(args), + **_extract_additional_arguments(args), + }, + nlu_additional_arguments=extract_nlu_additional_arguments(args), + model_to_finetune=_model_for_finetuning(args), + finetuning_epoch_fraction=args.epoch_fraction, + ) + if training_result.code != 0 and can_exit: + sys.exit(training_result.code) + + return training_result.model + + +def _model_for_finetuning(args: argparse.Namespace) -> Optional[Text]: + if args.finetune == train_arguments.USE_LATEST_MODEL_FOR_FINE_TUNING: + # We use this constant to signal that the user specified `--finetune` but + # didn't provide a path to a model. In this case we try to load the latest + # model from the output directory (that's usually models/). + return args.out + else: + return args.finetune + + +def run_core_training(args: argparse.Namespace) -> Optional[Text]: + """Trains a Rasa Core model only. + + Args: + args: Command-line arguments to configure training. + + Returns: + Path to a trained model or `None` if training was not successful. + """ + from rasa.model_training import train_core + + args.domain = rasa.cli.utils.get_validated_path( + args.domain, "domain", DEFAULT_DOMAIN_PATH, none_is_valid=True + ) + story_file = rasa.cli.utils.get_validated_path( + args.stories, "stories", DEFAULT_DATA_PATH, none_is_valid=True + ) + additional_arguments = { + **extract_core_additional_arguments(args), + **_extract_additional_arguments(args), + } + + # Policies might be a list for the compare training. Do normal training + # if only list item was passed. + if not isinstance(args.config, list) or len(args.config) == 1: + if isinstance(args.config, list): + args.config = args.config[0] + + config = rasa.cli.utils.get_validated_config( + args.config, CONFIG_MANDATORY_KEYS_CORE + ) + + return train_core( + domain=args.domain, + config=config, + stories=story_file, + output=args.out, + fixed_model_name=args.fixed_model_name, + additional_arguments=additional_arguments, + model_to_finetune=_model_for_finetuning(args), + finetuning_epoch_fraction=args.epoch_fraction, + ) + else: + do_compare_training(args, story_file, additional_arguments) + return None + + +def run_nlu_training(args: argparse.Namespace) -> Optional[Text]: + """Trains an NLU model. + + Args: + args: Namespace arguments. + + Returns: + Path to a trained model or `None` if training was not successful. + """ + from rasa.model_training import train_nlu + + config = rasa.cli.utils.get_validated_config(args.config, CONFIG_MANDATORY_KEYS_NLU) + nlu_data = rasa.cli.utils.get_validated_path( + args.nlu, "nlu", DEFAULT_DATA_PATH, none_is_valid=True + ) + + if args.domain: + args.domain = rasa.cli.utils.get_validated_path( + args.domain, "domain", DEFAULT_DOMAIN_PATH, none_is_valid=True + ) + + return train_nlu( + config=config, + nlu_data=nlu_data, + output=args.out, + fixed_model_name=args.fixed_model_name, + persist_nlu_training_data=args.persist_nlu_data, + additional_arguments={ + **extract_nlu_additional_arguments(args), + **_extract_additional_arguments(args), + }, + domain=args.domain, + model_to_finetune=_model_for_finetuning(args), + finetuning_epoch_fraction=args.epoch_fraction, + ) + + +def extract_core_additional_arguments(args: argparse.Namespace) -> Dict: + arguments = {} + + if "augmentation" in args: + arguments["augmentation_factor"] = args.augmentation + if "debug_plots" in args: + arguments["debug_plots"] = args.debug_plots + + return arguments + + +def extract_nlu_additional_arguments(args: argparse.Namespace) -> Dict: + arguments = {} + + if "num_threads" in args: + arguments["num_threads"] = args.num_threads + + return arguments + + +def _extract_additional_arguments(args: argparse.Namespace) -> Dict: + space = plugin_manager().hook.handle_space_args(args=args) + return space or {} diff --git a/rasa/cli/utils.py b/rasa/cli/utils.py new file mode 100644 index 0000000..b205c20 --- /dev/null +++ b/rasa/cli/utils.py @@ -0,0 +1,392 @@ +import json +import argparse +import logging +import os +import sys +import time +from types import FrameType +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Text, Union, overload + +import randomname + +import rasa.shared.utils.cli +import rasa.shared.utils.io +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.constants import ( + ASSISTANT_ID_DEFAULT_VALUE, + ASSISTANT_ID_KEY, + DEFAULT_CONFIG_PATH, +) +from rasa.shared.utils.cli import print_error +from rasa import telemetry + +if TYPE_CHECKING: + from pathlib import Path + + from questionary import Question + from typing_extensions import Literal + from rasa.validator import Validator + +logger = logging.getLogger(__name__) + +FREE_TEXT_INPUT_PROMPT = "Type out your own message..." + + +@overload +def get_validated_path( + current: Optional[Union["Path", Text]], + parameter: Text, + default: Optional[Union["Path", Text]] = ..., + none_is_valid: "Literal[False]" = ..., +) -> Union["Path", Text]: + ... + + +@overload +def get_validated_path( + current: Optional[Union["Path", Text]], + parameter: Text, + default: Optional[Union["Path", Text]] = ..., + none_is_valid: "Literal[True]" = ..., +) -> Optional[Union["Path", Text]]: + ... + + +def get_validated_path( + current: Optional[Union["Path", Text]], + parameter: Text, + default: Optional[Union["Path", Text]] = None, + none_is_valid: bool = False, +) -> Optional[Union["Path", Text]]: + """Checks whether a file path or its default value is valid and returns it. + + Args: + current: The parsed value. + parameter: The name of the parameter. + default: The default value of the parameter. + none_is_valid: `True` if `None` is valid value for the path, + else `False`` + + Returns: + The current value if it was valid, else the default value of the + argument if it is valid, else `None`. + """ + if current is None or current is not None and not os.path.exists(current): + if default is not None and os.path.exists(default): + reason_str = f"'{current}' not found." + if current is None: + reason_str = f"Parameter '{parameter}' not set." + else: + rasa.shared.utils.io.raise_warning( + f"The path '{current}' does not seem to exist. Using the " + f"default value '{default}' instead." + ) + + logger.debug(f"{reason_str} Using default location '{default}' instead.") + current = default + elif none_is_valid: + current = None + else: + cancel_cause_not_found(current, parameter, default) + + return current + + +def missing_config_keys( + path: Union["Path", Text], mandatory_keys: List[Text] +) -> List[Text]: + """Checks whether the config file at `path` contains the `mandatory_keys`. + + Args: + path: The path to the config file. + mandatory_keys: A list of mandatory config keys. + + Returns: + The list of missing config keys. + """ + import rasa.utils.io + + if not os.path.exists(path): + return mandatory_keys + + config_data = rasa.shared.utils.io.read_config_file(path) + + return [k for k in mandatory_keys if k not in config_data or config_data[k] is None] + + +def validate_assistant_id_in_config(config_file: Union["Path", Text]) -> None: + """Verifies that the assistant_id key exists and has a unique value in config. + + Issues a warning if the key does not exist or has the default value and replaces it + with a pseudo-random string value. + """ + config_data = rasa.shared.utils.io.read_config_file( + config_file, reader_type=["safe", "rt"] + ) + assistant_id = config_data.get(ASSISTANT_ID_KEY) + + if assistant_id is None or assistant_id == ASSISTANT_ID_DEFAULT_VALUE: + rasa.shared.utils.io.raise_warning( + f"The config file '{str(config_file)}' is missing a unique value for the " + f"'{ASSISTANT_ID_KEY}' mandatory key. Proceeding with generating a random " + f"value and overwriting the '{ASSISTANT_ID_KEY}' in the config file." + ) + + # add random value for assistant id, overwrite config file + time_format = "%Y%m%d-%H%M%S" + config_data[ + ASSISTANT_ID_KEY + ] = f"{time.strftime(time_format)}-{randomname.get_name()}" + + rasa.shared.utils.io.write_yaml( + data=config_data, target=config_file, should_preserve_key_order=True + ) + + return + + +def validate_config_path( + config: Optional[Union[Text, "Path"]], + default_config: Text = DEFAULT_CONFIG_PATH, +) -> Text: + """Verifies that the config path exists. + + Exit if the config file does not exist. + + Args: + config: Path to the config file. + default_config: default config to use if the file at `config` doesn't exist. + + Returns: The path to the config file. + """ + config = rasa.cli.utils.get_validated_path(config, "config", default_config) + + if not config or not os.path.exists(config): + print_error( + "The config file '{}' does not exist. Use '--config' to specify a " + "valid config file." + "".format(config) + ) + sys.exit(1) + + return str(config) + + +def validate_mandatory_config_keys( + config: Union[Text, "Path"], + mandatory_keys: List[Text], +) -> Text: + """Get a config from a config file and check if it is valid. + + Exit if the config isn't valid. + + Args: + config: Path to the config file. + mandatory_keys: The keys that have to be specified in the config file. + + Returns: The path to the config file if the config is valid. + """ + missing_keys = set(rasa.cli.utils.missing_config_keys(config, mandatory_keys)) + if missing_keys: + print_error( + "The config file '{}' is missing mandatory parameters: " + "'{}'. Add missing parameters to config file and try again." + "".format(config, "', '".join(missing_keys)) + ) + sys.exit(1) + + return str(config) + + +def get_validated_config( + config: Optional[Union[Text, "Path"]], + mandatory_keys: List[Text], + default_config: Text = DEFAULT_CONFIG_PATH, +) -> Text: + """Validates config and returns path to validated config file.""" + config = validate_config_path(config, default_config) + validate_assistant_id_in_config(config) + + config = validate_mandatory_config_keys(config, mandatory_keys) + + return config + + +def validate_files( + fail_on_warnings: bool, + max_history: Optional[int], + importer: TrainingDataImporter, + stories_only: bool = False, +) -> None: + """Validates either the story structure or the entire project. + + Args: + fail_on_warnings: `True` if the process should exit with a non-zero status + max_history: The max history to use when validating the story structure. + importer: The `TrainingDataImporter` to use to load the training data. + stories_only: If `True`, only the story structure is validated. + """ + from rasa.validator import Validator + + validator = Validator.from_importer(importer) + + if stories_only: + all_good = _validate_story_structure(validator, max_history, fail_on_warnings) + else: + if importer.get_domain().is_empty(): + rasa.shared.utils.cli.print_error_and_exit( + "Encountered empty domain during validation." + ) + + valid_domain = _validate_domain(validator) + valid_nlu = _validate_nlu(validator, fail_on_warnings) + valid_stories = _validate_story_structure( + validator, max_history, fail_on_warnings + ) + + all_good = valid_domain and valid_nlu and valid_stories + + validator.warn_if_config_mandatory_keys_are_not_set() + + telemetry.track_validate_files(all_good) + if not all_good: + rasa.shared.utils.cli.print_error_and_exit( + "Project validation completed with errors." + ) + + +def _validate_domain(validator: "Validator") -> bool: + valid_domain_validity = validator.verify_domain_validity() + valid_actions_in_stories_rules = validator.verify_actions_in_stories_rules() + valid_forms_in_stories_rules = validator.verify_forms_in_stories_rules() + valid_form_slots = validator.verify_form_slots() + valid_slot_mappings = validator.verify_slot_mappings() + return ( + valid_domain_validity + and valid_actions_in_stories_rules + and valid_forms_in_stories_rules + and valid_form_slots + and valid_slot_mappings + ) + + +def _validate_nlu(validator: "Validator", fail_on_warnings: bool) -> bool: + return validator.verify_nlu(not fail_on_warnings) + + +def _validate_story_structure( + validator: "Validator", max_history: Optional[int], fail_on_warnings: bool +) -> bool: + # Check if a valid setting for `max_history` was given + if isinstance(max_history, int) and max_history < 1: + raise argparse.ArgumentTypeError( + f"The value of `--max-history {max_history}` " f"is not a positive integer." + ) + + return validator.verify_story_structure( + not fail_on_warnings, max_history=max_history + ) + + +def cancel_cause_not_found( + current: Optional[Union["Path", Text]], + parameter: Text, + default: Optional[Union["Path", Text]], +) -> None: + """Exits with an error because the given path was not valid. + + Args: + current: The path given by the user. + parameter: The name of the parameter. + default: The default value of the parameter. + + """ + default_clause = "" + if default: + default_clause = f"use the default location ('{default}') or " + rasa.shared.utils.cli.print_error( + "The path '{}' does not exist. Please make sure to {}specify it" + " with '--{}'.".format(current, default_clause, parameter) + ) + sys.exit(1) + + +def parse_last_positional_argument_as_model_path() -> None: + """Fixes the parsing of a potential positional model path argument.""" + if ( + len(sys.argv) >= 2 + # support relevant commands ... + and sys.argv[1] in ["run", "shell", "interactive"] + # but avoid interpreting subparser commands as model paths + and sys.argv[1:] != ["run", "actions"] + and not sys.argv[-2].startswith("-") + and os.path.exists(sys.argv[-1]) + ): + sys.argv.append(sys.argv[-1]) + sys.argv[-2] = "--model" + + +def button_to_string(button: Dict[Text, Any], idx: int = 0) -> Text: + """Create a string representation of a button.""" + title = button.pop("title", "") + + if "payload" in button: + payload = " ({})".format(button.pop("payload")) + else: + payload = "" + + # if there are any additional attributes, we append them to the output + if button: + details = " - {}".format(json.dumps(button, sort_keys=True)) + else: + details = "" + + button_string = "{idx}: {title}{payload}{details}".format( + idx=idx + 1, title=title, payload=payload, details=details + ) + + return button_string + + +def element_to_string(element: Dict[Text, Any], idx: int = 0) -> Text: + """Create a string representation of an element.""" + title = element.pop("title", "") + + element_string = "{idx}: {title} - {element}".format( + idx=idx + 1, title=title, element=json.dumps(element, sort_keys=True) + ) + + return element_string + + +def button_choices_from_message_data( + message: Dict[Text, Any], allow_free_text_input: bool = True +) -> List[Text]: + """Return list of choices to present to the user. + + If allow_free_text_input is True, an additional option is added + at the end along with the response buttons that allows the user + to type in free text. + """ + choices = [ + button_to_string(button, idx) + for idx, button in enumerate(message.get("buttons")) + ] + if allow_free_text_input: + choices.append(FREE_TEXT_INPUT_PROMPT) + return choices + + +async def payload_from_button_question(button_question: "Question") -> Text: + """Prompt user with a button question and returns the nlu payload.""" + response = await button_question.ask_async() + if response != FREE_TEXT_INPUT_PROMPT: + # Extract intent slash command if it's a button + response = response[response.rfind("(") + 1 : response.rfind(")")] + return response + + +def signal_handler(_: int, __: FrameType) -> None: + """Kills Rasa when OS signal is received.""" + print("Goodbye 👋") + sys.exit(0) diff --git a/rasa/cli/visualize.py b/rasa/cli/visualize.py new file mode 100644 index 0000000..902121f --- /dev/null +++ b/rasa/cli/visualize.py @@ -0,0 +1,40 @@ +import argparse +import os +from typing import List + +from rasa.cli import SubParsersAction +from rasa.cli.arguments import visualize as arguments +from rasa.shared.constants import DEFAULT_DATA_PATH + + +def add_subparser( + subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + """Add all visualization parsers. + + Args: + subparsers: subparser we are going to attach to + parents: Parent parsers, needed to ensure tree structure in argparse + """ + visualize_parser = subparsers.add_parser( + "visualize", + parents=parents, + conflict_handler="resolve", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + help="Visualize stories.", + ) + visualize_parser.set_defaults(func=visualize_stories) + + arguments.set_visualize_stories_arguments(visualize_parser) + + +def visualize_stories(args: argparse.Namespace) -> None: + import rasa.core.visualize + + args.stories = rasa.shared.data.get_core_directory(args.stories) + if args.nlu is None and os.path.exists(DEFAULT_DATA_PATH): + args.nlu = rasa.shared.data.get_nlu_directory(DEFAULT_DATA_PATH) + + rasa.core.visualize.visualize( + args.domain, args.stories, args.nlu, args.out, args.max_history + ) diff --git a/rasa/cli/x.py b/rasa/cli/x.py new file mode 100644 index 0000000..8d59a70 --- /dev/null +++ b/rasa/cli/x.py @@ -0,0 +1,204 @@ +import argparse +import asyncio +import logging +from pathlib import Path +import signal +from typing import Iterable, List, Optional, Text, Tuple, Union + +import aiohttp +import ruamel.yaml as yaml + +from rasa.cli import SubParsersAction +from rasa.cli.arguments import x as arguments +import rasa.cli.utils +from rasa.shared.constants import ( + DEFAULT_CREDENTIALS_PATH, + DEFAULT_ENDPOINTS_PATH, +) +from rasa.core.utils import AvailableEndpoints +import rasa.shared.utils.cli +import rasa.shared.utils.io +import rasa.utils.common +import rasa.utils.io + +logger = logging.getLogger(__name__) + + +def add_subparser( + subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] +) -> None: + """Add all rasa x parsers. + + Args: + subparsers: subparser we are going to attach to + parents: Parent parsers, needed to ensure tree structure in argparse + """ + x_parser_args = { + "parents": parents, + "conflict_handler": "resolve", + "formatter_class": argparse.ArgumentDefaultsHelpFormatter, + } + + x_parser_args["help"] = ( + "Run a Rasa server in a mode that enables connecting " + "to Rasa Enterprise as the config endpoint." + ) + + shell_parser = subparsers.add_parser("x", **x_parser_args) + shell_parser.set_defaults(func=rasa_x) + + arguments.set_x_arguments(shell_parser) + + +def _rasa_service( + args: argparse.Namespace, + endpoints: AvailableEndpoints, + rasa_x_url: Optional[Text] = None, + credentials_path: Optional[Text] = None, +) -> None: + """Starts the Rasa application.""" + from rasa.core.run import serve_application + + # needs separate logging configuration as it is started in its own process + rasa.utils.common.configure_logging_and_warnings(args.loglevel) + rasa.utils.io.configure_colored_logging(args.loglevel) + + if not credentials_path: + credentials_path = _prepare_credentials_for_rasa_x( + args.credentials, rasa_x_url=rasa_x_url + ) + + serve_application( + endpoints=endpoints, + port=args.port, + credentials=credentials_path, + cors=args.cors, + auth_token=args.auth_token, + enable_api=True, + jwt_secret=args.jwt_secret, + jwt_method=args.jwt_method, + ssl_certificate=args.ssl_certificate, + ssl_keyfile=args.ssl_keyfile, + ssl_ca_file=args.ssl_ca_file, + ssl_password=args.ssl_password, + ) + + +def _prepare_credentials_for_rasa_x( + credentials_path: Optional[Text], rasa_x_url: Optional[Text] = None +) -> Text: + if credentials_path: + credentials_path = str( + rasa.cli.utils.get_validated_path( + credentials_path, "credentials", DEFAULT_CREDENTIALS_PATH, True + ) + ) + credentials = rasa.shared.utils.io.read_config_file(credentials_path) + else: + credentials = {} + + # this makes sure the Rasa X is properly configured no matter what + if rasa_x_url: + credentials["rasa"] = {"url": rasa_x_url} + dumped_credentials = yaml.dump(credentials, default_flow_style=False) + tmp_credentials = rasa.utils.io.create_temporary_file(dumped_credentials, "yml") + + return tmp_credentials + + +def rasa_x(args: argparse.Namespace) -> None: + """Run Rasa with the `x` subcommand.""" + from rasa.cli.utils import signal_handler + + signal.signal(signal.SIGINT, signal_handler) + + if args.production: + run_in_enterprise_connection_mode(args) + else: + rasa.shared.utils.io.raise_warning( + "Running Rasa X in local mode is no longer supported as Rasa has " + "stopped supporting the Community Edition (free version) of ‘Rasa X’." + "For more information please see " + "https://rasa.com/blog/rasa-x-community-edition-changes/", + UserWarning, + ) + exit() + + +async def _pull_runtime_config_from_server( + config_endpoint: Optional[Text], + attempts: int = 60, + wait_time_between_pulls: float = 5, + keys: Iterable[Text] = ("endpoints", "credentials"), +) -> List[Text]: + """Pull runtime config from `config_endpoint`. + + Returns a list of paths to yaml dumps, each containing the contents of one of + `keys`. + """ + while attempts: + try: + async with aiohttp.ClientSession() as session: + async with session.get(config_endpoint) as resp: + if resp.status == 200: + rjs = await resp.json() + try: + return [ + rasa.utils.io.create_temporary_file(rjs[k]) + for k in keys + ] + except KeyError as e: + rasa.shared.utils.cli.print_error_and_exit( + "Failed to find key '{}' in runtime config. " + "Exiting.".format(e) + ) + else: + logger.debug( + "Failed to get a proper response from remote " + "server. Status Code: {}. Response: '{}'" + "".format(resp.status, await resp.text()) + ) + except aiohttp.ClientError as e: + logger.debug(f"Failed to connect to server. Retrying. {e}") + + await asyncio.sleep(wait_time_between_pulls) + attempts -= 1 + + rasa.shared.utils.cli.print_error_and_exit( + "Could not fetch runtime config from server at '{}'. " + "Exiting.".format(config_endpoint) + ) + + +def run_in_enterprise_connection_mode(args: argparse.Namespace) -> None: + """Run Rasa in a mode that enables using Rasa X as the config endpoint.""" + from rasa.shared.utils.cli import print_success + + print_success("Starting a Rasa server in Rasa Enterprise connection mode... 🚀") + + credentials_path, endpoints_path = _get_credentials_and_endpoints_paths(args) + endpoints = AvailableEndpoints.read_endpoints(endpoints_path) + + _rasa_service(args, endpoints, None, credentials_path) + + +def _get_credentials_and_endpoints_paths( + args: argparse.Namespace, +) -> Tuple[Optional[Text], Optional[Text]]: + config_endpoint = args.config_endpoint + endpoints_config_path: Optional[Union[Path, Text]] + + if config_endpoint: + endpoints_config_path, credentials_path = asyncio.run( + _pull_runtime_config_from_server(config_endpoint) + ) + else: + endpoints_config_path = rasa.cli.utils.get_validated_path( + args.endpoints, "endpoints", DEFAULT_ENDPOINTS_PATH, True + ) + credentials_path = None + + return ( + credentials_path, + str(endpoints_config_path) if endpoints_config_path else None, + ) diff --git a/rasa/constants.py b/rasa/constants.py new file mode 100644 index 0000000..ba883c6 --- /dev/null +++ b/rasa/constants.py @@ -0,0 +1,37 @@ +import os + +TEST_DATA_FILE = "test.yml" +TRAIN_DATA_FILE = "train.yml" +NLG_DATA_FILE = "responses.yml" +RESULTS_FILE = "results.json" +NUMBER_OF_TRAINING_STORIES_FILE = "num_stories.json" +PERCENTAGE_KEY = "__percentage__" + +PACKAGE_NAME = "rasa" + +DEFAULT_RASA_PORT = 5005 + +# Key in global config file which contains whether the user agreed to telemetry +# reporting. These are reused in Rasa X. Keep this in mind when changing their names. +CONFIG_FILE_TELEMETRY_KEY = "metrics" +CONFIG_TELEMETRY_ID = "rasa_user_id" +CONFIG_TELEMETRY_ENABLED = "enabled" +CONFIG_TELEMETRY_DATE = "date" + +MINIMUM_COMPATIBLE_VERSION = "3.6.21" + +GLOBAL_USER_CONFIG_PATH = os.path.expanduser("~/.config/rasa/global.yml") + +DEFAULT_LOG_LEVEL_LIBRARIES = "ERROR" +ENV_LOG_LEVEL_LIBRARIES = "LOG_LEVEL_LIBRARIES" +ENV_LOG_LEVEL_MATPLOTLIB = "LOG_LEVEL_MATPLOTLIB" +ENV_LOG_LEVEL_RABBITMQ = "LOG_LEVEL_RABBITMQ" +ENV_LOG_LEVEL_KAFKA = "LOG_LEVEL_KAFKA" + +DEFAULT_SANIC_WORKERS = 1 +ENV_SANIC_WORKERS = "SANIC_WORKERS" +ENV_SANIC_BACKLOG = "SANIC_BACKLOG" + +ENV_GPU_CONFIG = "TF_GPU_MEMORY_ALLOC" +ENV_CPU_INTER_OP_CONFIG = "TF_INTER_OP_PARALLELISM_THREADS" +ENV_CPU_INTRA_OP_CONFIG = "TF_INTRA_OP_PARALLELISM_THREADS" diff --git a/rasa/core/__init__.py b/rasa/core/__init__.py new file mode 100644 index 0000000..4defa9b --- /dev/null +++ b/rasa/core/__init__.py @@ -0,0 +1,7 @@ +import logging + +import rasa + +logging.getLogger(__name__).addHandler(logging.NullHandler()) + +__version__ = rasa.__version__ diff --git a/rasa/core/actions/__init__.py b/rasa/core/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/core/actions/action.py b/rasa/core/actions/action.py new file mode 100644 index 0000000..8c51110 --- /dev/null +++ b/rasa/core/actions/action.py @@ -0,0 +1,1364 @@ +import copy +import json +import logging +from typing import ( + List, + Text, + Optional, + Dict, + Any, + TYPE_CHECKING, + Tuple, + Set, + cast, +) + +import aiohttp +import rasa.core +from rasa.core.actions.constants import DEFAULT_SELECTIVE_DOMAIN, SELECTIVE_DOMAIN +from rasa.core.constants import ( + DEFAULT_REQUEST_TIMEOUT, + COMPRESS_ACTION_SERVER_REQUEST_ENV_NAME, + DEFAULT_COMPRESS_ACTION_SERVER_REQUEST, +) +from rasa.core.policies.policy import PolicyPrediction +from rasa.nlu.constants import ( + RESPONSE_SELECTOR_DEFAULT_INTENT, + RESPONSE_SELECTOR_PROPERTY_NAME, + RESPONSE_SELECTOR_PREDICTION_KEY, + RESPONSE_SELECTOR_UTTER_ACTION_KEY, +) +from rasa.plugin import plugin_manager +from rasa.shared.constants import ( + DOCS_BASE_URL, + DEFAULT_NLU_FALLBACK_INTENT_NAME, + UTTER_PREFIX, +) +from rasa.shared.core import events +from rasa.shared.core.constants import ( + USER_INTENT_OUT_OF_SCOPE, + ACTION_LISTEN_NAME, + ACTION_RESTART_NAME, + ACTION_SESSION_START_NAME, + ACTION_DEFAULT_FALLBACK_NAME, + ACTION_DEACTIVATE_LOOP_NAME, + ACTION_REVERT_FALLBACK_EVENTS_NAME, + ACTION_DEFAULT_ASK_AFFIRMATION_NAME, + ACTION_DEFAULT_ASK_REPHRASE_NAME, + ACTION_UNLIKELY_INTENT_NAME, + ACTION_BACK_NAME, + REQUESTED_SLOT, + ACTION_EXTRACT_SLOTS, + DEFAULT_SLOT_NAMES, + MAPPING_CONDITIONS, + ACTIVE_LOOP, + ACTION_VALIDATE_SLOT_MAPPINGS, + MAPPING_TYPE, + SlotMappingType, +) +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + UserUtteranceReverted, + UserUttered, + ActionExecuted, + Event, + BotUttered, + SlotSet, + ActiveLoop, + Restarted, + SessionStarted, +) +from rasa.shared.core.slot_mappings import SlotMapping +from rasa.shared.core.slots import ListSlot +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.exceptions import RasaException +from rasa.shared.nlu.constants import ( + INTENT_NAME_KEY, + INTENT_RANKING_KEY, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_ROLE, + ENTITY_ATTRIBUTE_GROUP, +) +from rasa.shared.utils.schemas.events import EVENTS_SCHEMA +import rasa.shared.utils.io +from rasa.utils.common import get_bool_env_variable +from rasa.utils.endpoints import EndpointConfig, ClientResponseError + +if TYPE_CHECKING: + from rasa.core.nlg import NaturalLanguageGenerator + from rasa.core.channels.channel import OutputChannel + from rasa.shared.core.events import IntentPrediction + +logger = logging.getLogger(__name__) + + +def default_actions(action_endpoint: Optional[EndpointConfig] = None) -> List["Action"]: + """List default actions.""" + from rasa.core.actions.two_stage_fallback import TwoStageFallbackAction + + return [ + ActionListen(), + ActionRestart(), + ActionSessionStart(), + ActionDefaultFallback(), + ActionDeactivateLoop(), + ActionRevertFallbackEvents(), + ActionDefaultAskAffirmation(), + ActionDefaultAskRephrase(), + TwoStageFallbackAction(action_endpoint), + ActionUnlikelyIntent(), + ActionBack(), + ActionExtractSlots(action_endpoint), + ] + + +def action_for_index( + index: int, domain: Domain, action_endpoint: Optional[EndpointConfig] +) -> "Action": + """Get an action based on its index in the list of available actions. + + Args: + index: The index of the action. This is usually used by `Policy`s as they + predict the action index instead of the name. + domain: The `Domain` of the current model. The domain contains the actions + provided by the user + the default actions. + action_endpoint: Can be used to run `custom_actions` + (e.g. using the `rasa-sdk`). + + Returns: + The instantiated `Action` or `None` if no `Action` was found for the given + index. + """ + if domain.num_actions <= index or index < 0: + raise IndexError( + f"Cannot access action at index {index}. " + f"Domain has {domain.num_actions} actions." + ) + + return action_for_name_or_text( + domain.action_names_or_texts[index], domain, action_endpoint + ) + + +def is_retrieval_action(action_name: Text, retrieval_intents: List[Text]) -> bool: + """Check if an action name is a retrieval action. + + The name for a retrieval action has an extra `utter_` prefix added to + the corresponding retrieval intent name. + + Args: + action_name: Name of the action. + retrieval_intents: List of retrieval intents defined in the NLU training data. + + Returns: + `True` if the resolved intent name is present in the list of retrieval + intents, `False` otherwise. + """ + return ( + ActionRetrieveResponse.intent_name_from_action(action_name) in retrieval_intents + ) + + +def action_for_name_or_text( + action_name_or_text: Text, domain: Domain, action_endpoint: Optional[EndpointConfig] +) -> "Action": + """Retrieves an action by its name or by its text in case it's an end-to-end action. + + Args: + action_name_or_text: The name of the action. + domain: The current model domain. + action_endpoint: The endpoint to execute custom actions. + + Raises: + ActionNotFoundException: If action not in current domain. + + Returns: + The instantiated action. + """ + if action_name_or_text not in domain.action_names_or_texts: + domain.raise_action_not_found_exception(action_name_or_text) + + defaults = {a.name(): a for a in default_actions(action_endpoint)} + + if ( + action_name_or_text in defaults + and action_name_or_text not in domain.user_actions_and_forms + ): + return defaults[action_name_or_text] + + if action_name_or_text.startswith(UTTER_PREFIX) and is_retrieval_action( + action_name_or_text, domain.retrieval_intents + ): + return ActionRetrieveResponse(action_name_or_text) + + if action_name_or_text in domain.action_texts: + return ActionEndToEndResponse(action_name_or_text) + + if action_name_or_text.startswith(UTTER_PREFIX): + return ActionBotResponse(action_name_or_text) + + is_form = action_name_or_text in domain.form_names + # Users can override the form by defining an action with the same name as the form + user_overrode_form_action = is_form and action_name_or_text in domain.user_actions + if is_form and not user_overrode_form_action: + from rasa.core.actions.forms import FormAction + + return FormAction(action_name_or_text, action_endpoint) + + return RemoteAction(action_name_or_text, action_endpoint) + + +def create_bot_utterance(message: Dict[Text, Any]) -> BotUttered: + """Create BotUttered event from message.""" + bot_message = BotUttered( + text=message.pop("text", None), + data={ + "elements": message.pop("elements", None), + "quick_replies": message.pop("quick_replies", None), + "buttons": message.pop("buttons", None), + # for legacy / compatibility reasons we need to set the image + # to be the attachment if there is no other attachment (the + # `.get` is intentional - no `pop` as we still need the image` + # property to set it in the following line) + "attachment": message.pop("attachment", None) or message.get("image", None), + "image": message.pop("image", None), + "custom": message.pop("custom", None), + }, + metadata=message, + ) + return bot_message + + +class Action: + """Next action to be taken in response to a dialogue state.""" + + def name(self) -> Text: + """Unique identifier of this simple action.""" + raise NotImplementedError + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Execute the side effects of this action. + + Args: + nlg: which ``nlg`` to use for response generation + output_channel: ``output_channel`` to which to send the resulting message. + tracker (DialogueStateTracker): the state tracker for the current + user. You can access slot values using + ``tracker.get_slot(slot_name)`` and the most recent user + message is ``tracker.latest_message.text``. + domain (Domain): the bot's domain + + Returns: + A list of :class:`rasa.core.events.Event` instances + """ + raise NotImplementedError + + def __str__(self) -> Text: + """Returns text representation of form.""" + return f"{self.__class__.__name__}('{self.name()}')" + + def event_for_successful_execution( + self, prediction: PolicyPrediction + ) -> ActionExecuted: + """Event which should be logged for the successful execution of this action. + + Args: + prediction: Prediction which led to the execution of this event. + + Returns: + Event which should be logged onto the tracker. + """ + return ActionExecuted( + self.name(), + prediction.policy_name, + prediction.max_confidence, + hide_rule_turn=prediction.hide_rule_turn, + metadata=prediction.action_metadata, + ) + + +class ActionBotResponse(Action): + """An action which only effect is to utter a response when it is run.""" + + def __init__(self, name: Text, silent_fail: Optional[bool] = False) -> None: + """Creates action. + + Args: + name: Name of the action. + silent_fail: `True` if the action should fail silently in case no response + was defined for this action. + """ + self.utter_action = name + self.silent_fail = silent_fail + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Simple run implementation uttering a (hopefully defined) response.""" + kwargs = { + "domain_responses": domain.responses, + } + + message = await nlg.generate( + self.utter_action, + tracker, + output_channel.name(), + **kwargs, + ) + if message is None: + if not self.silent_fail: + logger.error( + "Couldn't create message for response '{}'." + "".format(self.utter_action) + ) + return [] + message["utter_action"] = self.utter_action + + return [create_bot_utterance(message)] + + def name(self) -> Text: + """Returns action name.""" + return self.utter_action + + +class ActionEndToEndResponse(Action): + """Action to utter end-to-end responses to the user.""" + + def __init__(self, action_text: Text) -> None: + """Creates action. + + Args: + action_text: Text of end-to-end bot response. + """ + self.action_text = action_text + + def name(self) -> Text: + """Returns action name.""" + # In case of an end-to-end action there is no label (aka name) for the action. + # We fake a name by returning the text which the bot sends back to the user. + return self.action_text + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Runs action (see parent class for full docstring).""" + message = {"text": self.action_text} + return [create_bot_utterance(message)] + + def event_for_successful_execution( + self, prediction: PolicyPrediction + ) -> ActionExecuted: + """Event which should be logged for the successful execution of this action. + + Args: + prediction: Prediction which led to the execution of this event. + + Returns: + Event which should be logged onto the tracker. + """ + return ActionExecuted( + policy=prediction.policy_name, + confidence=prediction.max_confidence, + action_text=self.action_text, + hide_rule_turn=prediction.hide_rule_turn, + metadata=prediction.action_metadata, + ) + + +class ActionRetrieveResponse(ActionBotResponse): + """An action which queries the Response Selector for the appropriate response.""" + + def __init__(self, name: Text, silent_fail: Optional[bool] = False) -> None: + """Creates action. See docstring of parent class.""" + super().__init__(name, silent_fail) + self.action_name = name + self.silent_fail = silent_fail + + @staticmethod + def intent_name_from_action(action_name: Text) -> Text: + """Resolve the name of the intent from the action name.""" + return action_name.split(UTTER_PREFIX)[1] + + def get_full_retrieval_name( + self, tracker: "DialogueStateTracker" + ) -> Optional[Text]: + """Returns full retrieval name for the action. + + Extracts retrieval intent from response selector and + returns complete action utterance name. + + Args: + tracker: Tracker containing past conversation events. + + Returns: + Full retrieval name of the action if the last user utterance + contains a response selector output, `None` otherwise. + """ + latest_message = tracker.latest_message + + if latest_message is None: + return None + + if RESPONSE_SELECTOR_PROPERTY_NAME not in latest_message.parse_data: + return None + + response_selector_properties = latest_message.parse_data[ + RESPONSE_SELECTOR_PROPERTY_NAME # type: ignore[literal-required] + ] + + if ( + self.intent_name_from_action(self.action_name) + in response_selector_properties + ): + query_key = self.intent_name_from_action(self.action_name) + elif RESPONSE_SELECTOR_DEFAULT_INTENT in response_selector_properties: + query_key = RESPONSE_SELECTOR_DEFAULT_INTENT + else: + return None + + selected = response_selector_properties[query_key] + full_retrieval_utter_action = selected[RESPONSE_SELECTOR_PREDICTION_KEY][ + RESPONSE_SELECTOR_UTTER_ACTION_KEY + ] + return full_retrieval_utter_action + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Query the appropriate response and create a bot utterance with that.""" + latest_message = tracker.latest_message + + if latest_message is None: + return [] + + response_selector_properties = latest_message.parse_data[ + RESPONSE_SELECTOR_PROPERTY_NAME # type: ignore[literal-required] + ] + + if ( + self.intent_name_from_action(self.action_name) + in response_selector_properties + ): + query_key = self.intent_name_from_action(self.action_name) + elif RESPONSE_SELECTOR_DEFAULT_INTENT in response_selector_properties: + query_key = RESPONSE_SELECTOR_DEFAULT_INTENT + else: + if not self.silent_fail: + logger.error( + "Couldn't create message for response action '{}'." + "".format(self.action_name) + ) + return [] + + logger.debug(f"Picking response from selector of type {query_key}") + selected = response_selector_properties[query_key] + + # Override utter action of ActionBotResponse + # with the complete utter action retrieved from + # the output of response selector. + self.utter_action = selected[RESPONSE_SELECTOR_PREDICTION_KEY][ + RESPONSE_SELECTOR_UTTER_ACTION_KEY + ] + + return await super().run(output_channel, nlg, tracker, domain) + + def name(self) -> Text: + """Returns action name.""" + return self.action_name + + +class ActionBack(ActionBotResponse): + """Revert the tracker state by two user utterances.""" + + def name(self) -> Text: + """Returns action back name.""" + return ACTION_BACK_NAME + + def __init__(self) -> None: + """Initializes action back.""" + super().__init__("utter_back", silent_fail=True) + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Runs action. Please see parent class for the full docstring.""" + # only utter the response if it is available + evts = await super().run(output_channel, nlg, tracker, domain) + + return evts + [UserUtteranceReverted(), UserUtteranceReverted()] + + +class ActionListen(Action): + """The first action in any turn - bot waits for a user message. + + The bot should stop taking further actions and wait for the user to say + something. + """ + + def name(self) -> Text: + """Returns action listen name.""" + return ACTION_LISTEN_NAME + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Runs action. Please see parent class for the full docstring.""" + return [] + + +class ActionRestart(ActionBotResponse): + """Resets the tracker to its initial state. + + Utters the restart response if available. + """ + + def name(self) -> Text: + """Returns action restart name.""" + return ACTION_RESTART_NAME + + def __init__(self) -> None: + """Initializes action restart.""" + super().__init__("utter_restart", silent_fail=True) + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Runs action. Please see parent class for the full docstring.""" + # only utter the response if it is available + evts = await super().run(output_channel, nlg, tracker, domain) + + return evts + [Restarted()] + + +class ActionSessionStart(Action): + """Applies a conversation session start. + + Takes all `SlotSet` events from the previous session and applies them to the new + session. + """ + + def name(self) -> Text: + """Returns action start name.""" + return ACTION_SESSION_START_NAME + + @staticmethod + def _slot_set_events_from_tracker( + tracker: "DialogueStateTracker", + ) -> List["SlotSet"]: + """Fetch SlotSet events from tracker and carry over key, value and metadata.""" + return [ + SlotSet(key=event.key, value=event.value, metadata=event.metadata) + for event in tracker.applied_events() + if isinstance(event, SlotSet) + ] + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Runs action. Please see parent class for the full docstring.""" + _events: List[Event] = [SessionStarted()] + + if domain.session_config.carry_over_slots: + _events.extend(self._slot_set_events_from_tracker(tracker)) + + _events.append(ActionExecuted(ACTION_LISTEN_NAME)) + + return _events + + +class ActionDefaultFallback(ActionBotResponse): + """Executes the fallback action and goes back to the prev state of the dialogue.""" + + def name(self) -> Text: + """Returns action default fallback name.""" + return ACTION_DEFAULT_FALLBACK_NAME + + def __init__(self) -> None: + """Initializes action default fallback.""" + super().__init__("utter_default", silent_fail=True) + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Runs action. Please see parent class for the full docstring.""" + # only utter the response if it is available + evts = await super().run(output_channel, nlg, tracker, domain) + + return evts + [UserUtteranceReverted()] + + +class ActionDeactivateLoop(Action): + """Deactivates an active loop.""" + + def name(self) -> Text: + return ACTION_DEACTIVATE_LOOP_NAME + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Runs action. Please see parent class for the full docstring.""" + return [ActiveLoop(None), SlotSet(REQUESTED_SLOT, None)] + + +class RemoteAction(Action): + def __init__(self, name: Text, action_endpoint: Optional[EndpointConfig]) -> None: + + self._name = name + self.action_endpoint = action_endpoint + + def _action_call_format( + self, + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> Dict[Text, Any]: + """Create the request json send to the action server.""" + from rasa.shared.core.trackers import EventVerbosity + + tracker_state = tracker.current_state(EventVerbosity.ALL) + + result = { + "next_action": self._name, + "sender_id": tracker.sender_id, + "tracker": tracker_state, + "version": rasa.__version__, + } + + if ( + not self._is_selective_domain_enabled() + or domain.does_custom_action_explicitly_need_domain(self.name()) + ): + result["domain"] = domain.as_dict() + + return result + + def _is_selective_domain_enabled(self) -> bool: + if self.action_endpoint is None: + return False + return bool( + self.action_endpoint.kwargs.get(SELECTIVE_DOMAIN, DEFAULT_SELECTIVE_DOMAIN) + ) + + @staticmethod + def action_response_format_spec() -> Dict[Text, Any]: + """Expected response schema for an Action endpoint. + + Used for validation of the response returned from the + Action endpoint. + """ + schema = { + "type": "object", + "properties": { + "events": EVENTS_SCHEMA, + "responses": {"type": "array", "items": {"type": "object"}}, + }, + } + return schema + + def _validate_action_result(self, result: Dict[Text, Any]) -> bool: + from jsonschema import validate + from jsonschema import ValidationError + + try: + validate(result, self.action_response_format_spec()) + return True + except ValidationError as e: + e.message += ( + f". Failed to validate Action server response from API, " + f"make sure your response from the Action endpoint is valid. " + f"For more information about the format visit " + f"{DOCS_BASE_URL}/custom-actions" + ) + raise e + + @staticmethod + async def _utter_responses( + responses: List[Dict[Text, Any]], + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + ) -> List[BotUttered]: + """Use the responses generated by the action endpoint and utter them.""" + bot_messages = [] + for response in responses: + generated_response = response.pop("response", None) + if generated_response: + draft = await nlg.generate( + generated_response, tracker, output_channel.name(), **response + ) + if not draft: + continue + draft["utter_action"] = generated_response + else: + draft = {} + + buttons = response.pop("buttons", []) or [] + if buttons: + draft.setdefault("buttons", []) + draft["buttons"].extend(buttons) + + # Avoid overwriting `draft` values with empty values + response = {k: v for k, v in response.items() if v} + draft.update(response) + bot_messages.append(create_bot_utterance(draft)) + + return bot_messages + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Runs action. Please see parent class for the full docstring.""" + json_body = self._action_call_format(tracker, domain) + if not self.action_endpoint: + raise RasaException( + f"Failed to execute custom action '{self.name()}' " + f"because no endpoint is configured to run this " + f"custom action. Please take a look at " + f"the docs and set an endpoint configuration via the " + f"--endpoints flag. " + f"{DOCS_BASE_URL}/custom-actions" + ) + + try: + logger.debug( + "Calling action endpoint to run action '{}'.".format(self.name()) + ) + + should_compress = get_bool_env_variable( + COMPRESS_ACTION_SERVER_REQUEST_ENV_NAME, + DEFAULT_COMPRESS_ACTION_SERVER_REQUEST, + ) + + modified_json = plugin_manager().hook.prefix_stripping_for_custom_actions( + json_body=json_body + ) + response: Any = await self.action_endpoint.request( + json=modified_json if modified_json else json_body, + method="post", + timeout=DEFAULT_REQUEST_TIMEOUT, + compress=should_compress, + ) + if modified_json: + plugin_manager().hook.prefixing_custom_actions_response( + json_body=json_body, response=response + ) + self._validate_action_result(response) + + events_json = response.get("events", []) + responses = response.get("responses", []) + bot_messages = await self._utter_responses( + responses, output_channel, nlg, tracker + ) + + evts = events.deserialise_events(events_json) + return cast(List[Event], bot_messages) + evts + + except ClientResponseError as e: + if e.status == 400: + response_data = json.loads(e.text) + exception = ActionExecutionRejection( + response_data["action_name"], response_data.get("error") + ) + logger.error(exception.message) + raise exception + else: + raise RasaException( + f"Failed to execute custom action '{self.name()}'" + ) from e + + except aiohttp.ClientConnectionError as e: + logger.error( + f"Failed to run custom action '{self.name()}'. Couldn't connect " + f"to the server at '{self.action_endpoint.url}'. " + f"Is the server running? " + f"Error: {e}" + ) + raise RasaException( + f"Failed to execute custom action '{self.name()}'. Couldn't connect " + f"to the server at '{self.action_endpoint.url}." + ) + + except aiohttp.ClientError as e: + # not all errors have a status attribute, but + # helpful to log if they got it + + # noinspection PyUnresolvedReferences + status = getattr(e, "status", None) + raise RasaException( + "Failed to run custom action '{}'. Action server " + "responded with a non 200 status code of {}. " + "Make sure your action server properly runs actions " + "and returns a 200 once the action is executed. " + "Error: {}".format(self.name(), status, e) + ) + + def name(self) -> Text: + return self._name + + +class ActionExecutionRejection(RasaException): + """Raising this exception will allow other policies + to predict a different action. + """ + + def __init__(self, action_name: Text, message: Optional[Text] = None) -> None: + """Create a new ActionExecutionRejection exception.""" + self.action_name = action_name + self.message = message or "Custom action '{}' rejected to run".format( + action_name + ) + super(ActionExecutionRejection, self).__init__() + + def __str__(self) -> Text: + return self.message + + +class ActionRevertFallbackEvents(Action): + """Reverts events which were done during the `TwoStageFallbackPolicy`. + + This reverts user messages and bot utterances done during a fallback + of the `TwoStageFallbackPolicy`. By doing so it is not necessary to + write custom stories for the different paths, but only of the happy + path. This is deprecated and can be removed once the + `TwoStageFallbackPolicy` is removed. + """ + + def name(self) -> Text: + return ACTION_REVERT_FALLBACK_EVENTS_NAME + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Runs action. Please see parent class for the full docstring.""" + from rasa.core.policies.two_stage_fallback import has_user_rephrased + + # User rephrased + if has_user_rephrased(tracker): + return _revert_successful_rephrasing(tracker) + # User affirmed + elif has_user_affirmed(tracker): + return _revert_affirmation_events(tracker) + else: + return [] + + +class ActionUnlikelyIntent(Action): + """An action that indicates that the intent predicted by NLU is unexpected. + + This action can be predicted by `UnexpecTEDIntentPolicy`. + """ + + def name(self) -> Text: + """Returns the name of the action.""" + return ACTION_UNLIKELY_INTENT_NAME + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Runs action. Please see parent class for the full docstring.""" + return [] + + +def has_user_affirmed(tracker: "DialogueStateTracker") -> bool: + """Indicates if the last executed action is `action_default_ask_affirmation`.""" + return tracker.last_executed_action_has(ACTION_DEFAULT_ASK_AFFIRMATION_NAME) + + +def _revert_affirmation_events(tracker: "DialogueStateTracker") -> List[Event]: + revert_events = _revert_single_affirmation_events() + + # User affirms the rephrased intent + rephrased_intent = tracker.last_executed_action_has( + name=ACTION_DEFAULT_ASK_REPHRASE_NAME, skip=1 + ) + if rephrased_intent: + revert_events += _revert_rephrasing_events() + + last_user_event = tracker.get_last_event_for(UserUttered) + if not last_user_event: + raise TypeError("Cannot find last event to revert to.") + + last_user_event = copy.deepcopy(last_user_event) + # FIXME: better type annotation for `parse_data` would require + # a larger refactoring (e.g. switch to dataclass) + last_user_event.parse_data["intent"]["confidence"] = 1.0 # type: ignore[typeddict-item] # noqa: E501 + + return revert_events + [last_user_event] + + +def _revert_single_affirmation_events() -> List[Event]: + return [ + UserUtteranceReverted(), # revert affirmation and request + # revert original intent (has to be re-added later) + UserUtteranceReverted(), + # add action listen intent + ActionExecuted(action_name=ACTION_LISTEN_NAME), + ] + + +def _revert_successful_rephrasing(tracker: "DialogueStateTracker") -> List[Event]: + last_user_event = tracker.get_last_event_for(UserUttered) + if not last_user_event: + raise TypeError("Cannot find last event to revert to.") + + last_user_event = copy.deepcopy(last_user_event) + return _revert_rephrasing_events() + [last_user_event] + + +def _revert_rephrasing_events() -> List[Event]: + return [ + UserUtteranceReverted(), # remove rephrasing + # remove feedback and rephrase request + UserUtteranceReverted(), + # remove affirmation request and false intent + UserUtteranceReverted(), + # replace action with action listen + ActionExecuted(action_name=ACTION_LISTEN_NAME), + ] + + +class ActionDefaultAskAffirmation(Action): + """Default implementation which asks the user to affirm his intent. + + It is suggested to overwrite this default action with a custom action + to have more meaningful prompts for the affirmations. E.g. have a + description of the intent instead of its identifier name. + """ + + def name(self) -> Text: + return ACTION_DEFAULT_ASK_AFFIRMATION_NAME + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Runs action. Please see parent class for the full docstring.""" + latest_message = tracker.latest_message + if latest_message is None: + raise TypeError( + "Cannot find last user message for detecting fallback affirmation." + ) + + intent_to_affirm = latest_message.intent.get(INTENT_NAME_KEY) + + # FIXME: better type annotation for `parse_data` would require + # a larger refactoring (e.g. switch to dataclass) + intent_ranking = cast( + List["IntentPrediction"], + latest_message.parse_data.get(INTENT_RANKING_KEY) or [], + ) + if ( + intent_to_affirm == DEFAULT_NLU_FALLBACK_INTENT_NAME + and len(intent_ranking) > 1 + ): + intent_to_affirm = intent_ranking[1][INTENT_NAME_KEY] # type: ignore[literal-required] # noqa: E501 + + affirmation_message = f"Did you mean '{intent_to_affirm}'?" + + message = { + "text": affirmation_message, + "buttons": [ + {"title": "Yes", "payload": f"/{intent_to_affirm}"}, + {"title": "No", "payload": f"/{USER_INTENT_OUT_OF_SCOPE}"}, + ], + "utter_action": self.name(), + } + + return [create_bot_utterance(message)] + + +class ActionDefaultAskRephrase(ActionBotResponse): + """Default implementation which asks the user to rephrase his intent.""" + + def name(self) -> Text: + """Returns action default ask rephrase name.""" + return ACTION_DEFAULT_ASK_REPHRASE_NAME + + def __init__(self) -> None: + """Initializes action default ask rephrase.""" + super().__init__("utter_ask_rephrase", silent_fail=True) + + +class ActionExtractSlots(Action): + """Default action that runs after each user turn. + + Action is executed automatically in MessageProcessor.handle_message(...) + before the next predicted action is run. + + Set slots to extracted values from user message + according to assigned slot mappings. + """ + + def __init__(self, action_endpoint: Optional[EndpointConfig]) -> None: + """Initializes default action extract slots.""" + self._action_endpoint = action_endpoint + + def name(self) -> Text: + """Returns action_extract_slots name.""" + return ACTION_EXTRACT_SLOTS + + @staticmethod + def _matches_mapping_conditions( + mapping: Dict[Text, Any], tracker: "DialogueStateTracker", slot_name: Text + ) -> bool: + slot_mapping_conditions = mapping.get(MAPPING_CONDITIONS) + + if not slot_mapping_conditions: + return True + + if ( + tracker.is_active_loop_rejected + and tracker.get_slot(REQUESTED_SLOT) == slot_name + ): + return False + + # check if found mapping conditions matches form + for condition in slot_mapping_conditions: + active_loop = condition.get(ACTIVE_LOOP) + + if active_loop and active_loop == tracker.active_loop_name: + condition_requested_slot = condition.get(REQUESTED_SLOT) + if not condition_requested_slot: + return True + if condition_requested_slot == tracker.get_slot(REQUESTED_SLOT): + return True + + if active_loop is None and tracker.active_loop_name is None: + return True + + return False + + @staticmethod + def _verify_mapping_conditions( + mapping: Dict[Text, Any], tracker: "DialogueStateTracker", slot_name: Text + ) -> bool: + if mapping.get(MAPPING_CONDITIONS) and mapping[MAPPING_TYPE] != str( + SlotMappingType.FROM_TRIGGER_INTENT + ): + if not ActionExtractSlots._matches_mapping_conditions( + mapping, tracker, slot_name + ): + return False + + return True + + async def _run_custom_action( + self, + custom_action: Text, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + slot_events: List[Event] = [] + remote_action = RemoteAction(custom_action, self._action_endpoint) + disallowed_types = set() + + try: + custom_events = await remote_action.run( + output_channel, nlg, tracker, domain + ) + for event in custom_events: + if isinstance(event, SlotSet): + slot_events.append(event) + elif isinstance(event, BotUttered): + slot_events.append(event) + else: + disallowed_types.add(event.type_name) + except (RasaException, ClientResponseError) as e: + logger.warning( + f"Failed to execute custom action '{custom_action}' " + f"as a result of error '{str(e)}'. The default action " + f"'{self.name()}' failed to fill slots with custom " + f"mappings." + ) + + for type_name in disallowed_types: + logger.info( + f"Running custom action '{custom_action}' has resulted " + f"in an event of type '{type_name}'. This is " + f"disallowed and the tracker will not be " + f"updated with this event." + ) + + return slot_events + + async def _execute_custom_action( + self, + mapping: Dict[Text, Any], + executed_custom_actions: Set[Text], + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> Tuple[List[Event], Set[Text]]: + custom_action = mapping.get("action") + + if not custom_action or custom_action in executed_custom_actions: + return [], executed_custom_actions + + slot_events = await self._run_custom_action( + custom_action, output_channel, nlg, tracker, domain + ) + + executed_custom_actions.add(custom_action) + + return slot_events, executed_custom_actions + + async def _execute_validation_action( + self, + extraction_events: List[Event], + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + slot_events: List[SlotSet] = [ + event for event in extraction_events if isinstance(event, SlotSet) + ] + + slot_candidates = "\n".join([e.key for e in slot_events]) + logger.debug(f"Validating extracted slots: {slot_candidates}") + + if ACTION_VALIDATE_SLOT_MAPPINGS not in domain.user_actions: + return cast(List[Event], slot_events) + + _tracker = DialogueStateTracker.from_events( + tracker.sender_id, + tracker.events_after_latest_restart() + cast(List[Event], slot_events), + slots=domain.slots, + ) + validate_events = await self._run_custom_action( + ACTION_VALIDATE_SLOT_MAPPINGS, output_channel, nlg, _tracker, domain + ) + validated_slot_names = [ + event.key for event in validate_events if isinstance(event, SlotSet) + ] + + # If the custom action doesn't return a SlotSet event for an extracted slot + # candidate we assume that it was valid. The custom action has to return a + # SlotSet(slot_name, None) event to mark a Slot as invalid. + return validate_events + [ + event for event in slot_events if event.key not in validated_slot_names + ] + + def _fails_unique_entity_mapping_check( + self, + slot_name: Text, + mapping: Dict[Text, Any], + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> bool: + from rasa.core.actions.forms import FormAction + + if mapping[MAPPING_TYPE] != str(SlotMappingType.FROM_ENTITY): + return False + + form_name = tracker.active_loop_name + + if not form_name: + return False + + if tracker.get_slot(REQUESTED_SLOT) == slot_name: + return False + + form = FormAction(form_name, self._action_endpoint) + + if slot_name not in form.required_slots(domain): + return False + + if form.entity_mapping_is_unique(mapping, domain): + return False + + return True + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Runs action. Please see parent class for the full docstring.""" + slot_events: List[Event] = [] + executed_custom_actions: Set[Text] = set() + + user_slots = [ + slot for slot in domain.slots if slot.name not in DEFAULT_SLOT_NAMES + ] + + for slot in user_slots: + for mapping in slot.mappings: + mapping_type = SlotMappingType(mapping.get(MAPPING_TYPE)) + + if not SlotMapping.check_mapping_validity( + slot_name=slot.name, + mapping_type=mapping_type, + mapping=mapping, + domain=domain, + ): + continue + + intent_is_desired = SlotMapping.intent_is_desired( + mapping, tracker, domain + ) + + if not intent_is_desired: + continue + + if not ActionExtractSlots._verify_mapping_conditions( + mapping, tracker, slot.name + ): + continue + + if self._fails_unique_entity_mapping_check( + slot.name, mapping, tracker, domain + ): + continue + + if mapping_type.is_predefined_type(): + value = extract_slot_value_from_predefined_mapping( + mapping_type, mapping, tracker + ) + else: + value = None + + if value: + if not isinstance(slot, ListSlot): + value = value[-1] + + if value is not None or tracker.get_slot(slot.name) is not None: + slot_events.append(SlotSet(slot.name, value)) + break + + should_fill_custom_slot = mapping_type == SlotMappingType.CUSTOM + + if should_fill_custom_slot: + ( + custom_evts, + executed_custom_actions, + ) = await self._execute_custom_action( + mapping, + executed_custom_actions, + output_channel, + nlg, + tracker, + domain, + ) + slot_events.extend(custom_evts) + + validated_events = await self._execute_validation_action( + slot_events, output_channel, nlg, tracker, domain + ) + return validated_events + + +def extract_slot_value_from_predefined_mapping( + mapping_type: SlotMappingType, + mapping: Dict[Text, Any], + tracker: "DialogueStateTracker", +) -> List[Any]: + """Extracts slot value if slot has an applicable predefined mapping.""" + should_fill_entity_slot = ( + mapping_type == SlotMappingType.FROM_ENTITY + and SlotMapping.entity_is_desired(mapping, tracker) + ) + + should_fill_intent_slot = mapping_type == SlotMappingType.FROM_INTENT + + should_fill_text_slot = mapping_type == SlotMappingType.FROM_TEXT + + active_loops_in_mapping_conditions = [ + active_loop.get(ACTIVE_LOOP) + for active_loop in mapping.get(MAPPING_CONDITIONS, []) + ] + + trigger_mapping_condition_met = True + + if tracker.active_loop_name is None: + trigger_mapping_condition_met = False + elif ( + active_loops_in_mapping_conditions + and tracker.active_loop_name is not None + and (tracker.active_loop_name not in active_loops_in_mapping_conditions) + ): + trigger_mapping_condition_met = False + + should_fill_trigger_slot = ( + mapping_type == SlotMappingType.FROM_TRIGGER_INTENT + and trigger_mapping_condition_met + ) + + value: List[Any] = [] + if should_fill_entity_slot: + value = list( + tracker.get_latest_entity_values( + mapping.get(ENTITY_ATTRIBUTE_TYPE), + mapping.get(ENTITY_ATTRIBUTE_ROLE), + mapping.get(ENTITY_ATTRIBUTE_GROUP), + ) + ) + elif should_fill_intent_slot or should_fill_trigger_slot: + value = [mapping.get("value")] + elif should_fill_text_slot: + value = [ + tracker.latest_message.text if tracker.latest_message is not None else None + ] + + return value diff --git a/rasa/core/actions/constants.py b/rasa/core/actions/constants.py new file mode 100644 index 0000000..cb51775 --- /dev/null +++ b/rasa/core/actions/constants.py @@ -0,0 +1,2 @@ +DEFAULT_SELECTIVE_DOMAIN = False +SELECTIVE_DOMAIN = "enable_selective_domain" diff --git a/rasa/core/actions/forms.py b/rasa/core/actions/forms.py new file mode 100644 index 0000000..4e14e0f --- /dev/null +++ b/rasa/core/actions/forms.py @@ -0,0 +1,737 @@ +import copy +from typing import Text, List, Optional, Union, Any, Dict, Set +import itertools +import logging +import structlog +import json + +from rasa.core.actions import action +from rasa.core.actions.loops import LoopAction +from rasa.core.channels import OutputChannel +from rasa.shared.core.domain import Domain, KEY_SLOTS +from rasa.shared.core.constants import SlotMappingType, SLOT_MAPPINGS, MAPPING_TYPE + +from rasa.core.actions.action import ActionExecutionRejection, RemoteAction +from rasa.shared.core.constants import ( + ACTION_EXTRACT_SLOTS, + ACTION_LISTEN_NAME, + REQUESTED_SLOT, +) +from rasa.shared.constants import UTTER_PREFIX +from rasa.shared.core.events import ( + Event, + SlotSet, + ActionExecuted, + ActiveLoop, + ActionExecutionRejected, + Restarted, +) +from rasa.core.nlg import NaturalLanguageGenerator +from rasa.shared.core.slot_mappings import SlotMapping +from rasa.shared.core.slots import ListSlot +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.utils.endpoints import EndpointConfig + +logger = logging.getLogger(__name__) +structlogger = structlog.get_logger() + + +class FormAction(LoopAction): + """Action which implements and executes the form logic.""" + + def __init__( + self, form_name: Text, action_endpoint: Optional[EndpointConfig] + ) -> None: + """Creates a `FormAction`. + + Args: + form_name: Name of the form. + action_endpoint: Endpoint to execute custom actions. + """ + self._form_name = form_name + self.action_endpoint = action_endpoint + # creating it requires domain, which we don't have in init + # we'll create it on the first call + self._unique_entity_mappings: Set[Text] = set() + self._have_unique_entity_mappings_been_initialized = False + + def name(self) -> Text: + """Return the form name.""" + return self._form_name + + def required_slots(self, domain: Domain) -> List[Text]: + """A list of required slots that the form has to fill. + + Returns: + A list of slot names. + """ + return domain.required_slots_for_form(self.name()) + + def from_entity( + self, + entity: Text, + intent: Optional[Union[Text, List[Text]]] = None, + not_intent: Optional[Union[Text, List[Text]]] = None, + role: Optional[Text] = None, + group: Optional[Text] = None, + ) -> Dict[Text, Any]: + """A dictionary for slot mapping to extract slot value. + + From: + - an extracted entity + - conditioned on + - intent if it is not None + - not_intent if it is not None, + meaning user intent should not be this intent + - role if it is not None + - group if it is not None + """ + intent, not_intent = ( + SlotMapping.to_list(intent), + SlotMapping.to_list(not_intent), + ) + + return { + "type": str(SlotMappingType.FROM_ENTITY), + "entity": entity, + "intent": intent, + "not_intent": not_intent, + "role": role, + "group": group, + } + + def get_mappings_for_slot( + self, slot_to_fill: Text, domain: Domain + ) -> List[Dict[Text, Any]]: + """Get mappings for requested slot. + + If None, map requested slot to an entity with the same name + """ + domain_slots = domain.as_dict().get(KEY_SLOTS, {}) + requested_slot_mappings = domain_slots.get(slot_to_fill, {}).get("mappings", []) + + # check provided slot mappings + for requested_slot_mapping in requested_slot_mappings: + if ( + not isinstance(requested_slot_mapping, dict) + or requested_slot_mapping.get("type") is None + ): + raise TypeError("Provided incompatible slot mapping") + + return requested_slot_mappings + + def _create_unique_entity_mappings(self, domain: Domain) -> Set[Text]: + """Finds mappings of type `from_entity` that uniquely set a slot. + + For example in the following form: + some_form: + departure_city: + - type: from_entity + entity: city + role: from + - type: from_entity + entity: city + arrival_city: + - type: from_entity + entity: city + role: to + - type: from_entity + entity: city + + An entity `city` with a role `from` uniquely sets the slot `departure_city` + and an entity `city` with a role `to` uniquely sets the slot `arrival_city`, + so corresponding mappings are unique. + But an entity `city` without a role can fill both `departure_city` + and `arrival_city`, so corresponding mapping is not unique. + + Args: + domain: The domain. + + Returns: + A set of json dumps of unique mappings of type `from_entity`. + """ + unique_entity_slot_mappings: Set[Text] = set() + duplicate_entity_slot_mappings: Set[Text] = set() + domain_slots = domain.as_dict().get(KEY_SLOTS, {}) + for slot in domain.required_slots_for_form(self.name()): + for slot_mapping in domain_slots.get(slot, {}).get(SLOT_MAPPINGS, []): + if slot_mapping.get(MAPPING_TYPE) == str(SlotMappingType.FROM_ENTITY): + mapping_as_string = json.dumps(slot_mapping, sort_keys=True) + if mapping_as_string in unique_entity_slot_mappings: + unique_entity_slot_mappings.remove(mapping_as_string) + duplicate_entity_slot_mappings.add(mapping_as_string) + elif mapping_as_string not in duplicate_entity_slot_mappings: + unique_entity_slot_mappings.add(mapping_as_string) + + return unique_entity_slot_mappings + + def entity_mapping_is_unique( + self, slot_mapping: Dict[Text, Any], domain: Domain + ) -> bool: + """Verifies if the from_entity mapping is unique.""" + if not self._have_unique_entity_mappings_been_initialized: + # create unique entity mappings on the first call + self._unique_entity_mappings = self._create_unique_entity_mappings(domain) + self._have_unique_entity_mappings_been_initialized = True + + mapping_as_string = json.dumps(slot_mapping, sort_keys=True) + return mapping_as_string in self._unique_entity_mappings + + @staticmethod + def get_entity_value_for_slot( + name: Text, + tracker: "DialogueStateTracker", + slot_to_be_filled: Text, + role: Optional[Text] = None, + group: Optional[Text] = None, + ) -> Any: + """Extract entities for given name and optional role and group. + + Args: + name: entity type (name) of interest + tracker: the tracker + slot_to_be_filled: Slot which is supposed to be filled by this entity. + role: optional entity role of interest + group: optional entity group of interest + + Returns: + Value of entity. + """ + # list is used to cover the case of list slot type + value = list( + tracker.get_latest_entity_values(name, entity_group=group, entity_role=role) + ) + + if isinstance(tracker.slots.get(slot_to_be_filled), ListSlot): + return value + + if len(value) == 0: + return None + + if len(value) == 1: + return value[0] + + return value + + def get_slot_to_fill(self, tracker: "DialogueStateTracker") -> Optional[str]: + """Gets the name of the slot which should be filled next. + + When switching to another form, the requested slot setting is still from the + previous form and must be ignored. + + Returns: + The slot name or `None` + """ + return ( + tracker.get_slot(REQUESTED_SLOT) + if tracker.active_loop_name == self.name() + else None + ) + + async def validate_slots( + self, + slot_candidates: Dict[Text, Any], + tracker: "DialogueStateTracker", + domain: Domain, + output_channel: OutputChannel, + nlg: NaturalLanguageGenerator, + ) -> List[Union[SlotSet, Event]]: + """Validate the extracted slots. + + If a custom action is available for validating the slots, we call it to validate + them. Otherwise there is no validation. + + Args: + slot_candidates: Extracted slots which are candidates to fill the slots + required by the form. + tracker: The current conversation tracker. + domain: The current model domain. + output_channel: The output channel which can be used to send messages + to the user. + nlg: `NaturalLanguageGenerator` to use for response generation. + + Returns: + The validation events including potential bot messages and `SlotSet` events + for the validated slots, if the custom form validation action is present in + domain actions. + Otherwise, returns empty list since the extracted slots already have + corresponding `SlotSet` events in the tracker. + """ + structlogger.debug( + "forms.slots.validate", slot_candidates=copy.deepcopy(slot_candidates) + ) + events: List[Union[SlotSet, Event]] = [ + SlotSet(slot_name, value) for slot_name, value in slot_candidates.items() + ] + + validate_name = f"validate_{self.name()}" + + if validate_name not in domain.action_names_or_texts: + return [] + + # create temporary tracker with only the SlotSet events added + # since last user utterance + _tracker = self._temporary_tracker(tracker, events, domain) + + _action = RemoteAction(validate_name, self.action_endpoint) + validate_events = await _action.run(output_channel, nlg, _tracker, domain) + + # Only return the validated SlotSet events by the custom form validation action + # to avoid adding duplicate SlotSet events for slots that are already valid. + return validate_events + + def _temporary_tracker( + self, + current_tracker: DialogueStateTracker, + additional_events: List[Event], + domain: Domain, + ) -> DialogueStateTracker: + return DialogueStateTracker.from_events( + current_tracker.sender_id, + current_tracker.events_after_latest_restart() + # Insert SlotSet event to make sure REQUESTED_SLOT belongs to active form. + + [SlotSet(REQUESTED_SLOT, self.get_slot_to_fill(current_tracker))] + # Insert form execution event so that it's clearly distinguishable which + # events were newly added. + + [ActionExecuted(self.name())] + additional_events, + slots=domain.slots, + ) + + def _user_rejected_manually(self, validation_events: List[Event]) -> bool: + """Checks if user rejected the form execution during a slot_validation. + + Args: + validation_events: Events returned by the custom slot_validation action + + Returns: + True if the validation_events include an ActionExecutionRejected event, + else False. + """ + return any( + isinstance(event, ActionExecutionRejected) for event in validation_events + ) + + @staticmethod + def _get_events_since_last_user_uttered( + tracker: "DialogueStateTracker", + ) -> List[SlotSet]: + # TODO: Better way to get this latest_message index is through an instance + # variable, eg. tracker.latest_message_index + index_from_end = next( + ( + i + for i, event in enumerate(reversed(tracker.events)) + if event == Restarted() or event == tracker.latest_message + ), + len(tracker.events) - 1, + ) + index = len(tracker.events) - index_from_end - 1 + events_since_last_user_uttered = [ + event + for event in itertools.islice(tracker.events, index, None) + if isinstance(event, SlotSet) + ] + + return events_since_last_user_uttered + + def _update_slot_values( + self, + event: SlotSet, + tracker: "DialogueStateTracker", + domain: Domain, + slot_values: Dict[Text, Any], + ) -> Dict[Text, Any]: + slot_values[event.key] = event.value + + return slot_values + + def _add_dynamic_slots_requested_by_dynamic_forms( + self, tracker: "DialogueStateTracker", domain: Domain + ) -> Set[Text]: + required_slots = set(self.required_slots(domain)) + requested_slot = self.get_slot_to_fill(tracker) + + if requested_slot: + required_slots.add(requested_slot) + + return required_slots + + def _get_slot_extractions( + self, tracker: "DialogueStateTracker", domain: Domain + ) -> Dict[Text, Any]: + events_since_last_user_uttered = FormAction._get_events_since_last_user_uttered( + tracker + ) + slot_values: Dict[Text, Any] = {} + + required_slots = self._add_dynamic_slots_requested_by_dynamic_forms( + tracker, domain + ) + + for event in events_since_last_user_uttered: + if event.key not in required_slots: + continue + + slot_values = self._update_slot_values(event, tracker, domain, slot_values) + + return slot_values + + async def validate( + self, + tracker: "DialogueStateTracker", + domain: Domain, + output_channel: OutputChannel, + nlg: NaturalLanguageGenerator, + ) -> List[Union[SlotSet, Event]]: + """Extract and validate value of requested slot and other slots. + + Returns: + The new validation events created by the custom form validation action + + Raises: + ActionExecutionRejection exception to reject execution of form action + if nothing was extracted. + + Subclass this method to add custom validation and rejection logic. + """ + extracted_slot_values = self._get_slot_extractions(tracker, domain) + + validation_events = await self.validate_slots( + extracted_slot_values, tracker, domain, output_channel, nlg + ) + + some_slots_were_validated = any( + isinstance(event, SlotSet) and not event.key == REQUESTED_SLOT + for event in validation_events + # Ignore `SlotSet`s for `REQUESTED_SLOT` as that's not a slot which needs + # to be filled by the user. + ) + + # extract requested slot + slot_to_fill = self.get_slot_to_fill(tracker) + + if ( + slot_to_fill + and not extracted_slot_values + and not some_slots_were_validated + and not self._user_rejected_manually(validation_events) + ): + # reject to execute the form action + # if some slot was requested but nothing was extracted + # it will allow other policies to predict another action + # + # don't raise it here if the user rejected manually, to allow slots other + # than the requested slot to be filled. + # + raise ActionExecutionRejection( + self.name(), + f"Failed to extract slot {slot_to_fill} with action {self.name()}", + ) + return validation_events + + async def request_next_slot( + self, + tracker: "DialogueStateTracker", + domain: Domain, + output_channel: OutputChannel, + nlg: NaturalLanguageGenerator, + events_so_far: List[Event], + ) -> List[Union[SlotSet, Event]]: + """Request the next slot and response if needed, else return `None`.""" + request_slot_events: List[Event] = [] + + if await self.is_done(output_channel, nlg, tracker, domain, events_so_far): + # The custom action for slot validation decided to stop the form early + return [SlotSet(REQUESTED_SLOT, None)] + + slot_to_request = next( + ( + event.value + for event in events_so_far + if isinstance(event, SlotSet) and event.key == REQUESTED_SLOT + ), + None, + ) + + temp_tracker = self._temporary_tracker(tracker, events_so_far, domain) + + if not slot_to_request: + slot_to_request = self._find_next_slot_to_request(temp_tracker, domain) + request_slot_events.append(SlotSet(REQUESTED_SLOT, slot_to_request)) + + if slot_to_request: + bot_message_events = await self._ask_for_slot( + domain, nlg, output_channel, slot_to_request, temp_tracker + ) + return request_slot_events + bot_message_events + + # no more required slots to fill + return [SlotSet(REQUESTED_SLOT, None)] + + def _find_next_slot_to_request( + self, tracker: DialogueStateTracker, domain: Domain + ) -> Optional[Text]: + return next( + ( + slot + for slot in self.required_slots(domain) + if self._should_request_slot(tracker, slot) + ), + None, + ) + + def _name_of_utterance(self, domain: Domain, slot_name: Text) -> Optional[Text]: + search_path = [ + f"action_ask_{self._form_name}_{slot_name}", + f"{UTTER_PREFIX}ask_{self._form_name}_{slot_name}", + f"action_ask_{slot_name}", + f"{UTTER_PREFIX}ask_{slot_name}", + ] + + found_actions = ( + action_name + for action_name in search_path + if action_name in domain.action_names_or_texts + ) + + return next(found_actions, None) + + async def _ask_for_slot( + self, + domain: Domain, + nlg: NaturalLanguageGenerator, + output_channel: OutputChannel, + slot_name: Text, + tracker: DialogueStateTracker, + ) -> List[Event]: + logger.debug(f"Request next slot '{slot_name}'") + + action_name_to_ask_for_next_slot = self._name_of_utterance(domain, slot_name) + if not action_name_to_ask_for_next_slot: + # Use a debug log as the user might have asked as part of a custom action + logger.debug( + f"There was no action found to ask for slot '{slot_name}' " + f"name to be filled." + ) + return [] + + action_to_ask_for_next_slot = action.action_for_name_or_text( + action_name_to_ask_for_next_slot, domain, self.action_endpoint + ) + return await action_to_ask_for_next_slot.run( + output_channel, nlg, tracker, domain + ) + + async def _validate_if_required( + self, + tracker: "DialogueStateTracker", + domain: Domain, + output_channel: OutputChannel, + nlg: NaturalLanguageGenerator, + ) -> List[Event]: + """Return a list of events from `self.validate(...)`. + + Validation is required if: + - the form is active + - the form is called after `action_listen` + - form validation was not cancelled + """ + # no active_loop means that it is called during activation + needs_validation = not tracker.active_loop or ( + tracker.latest_action_name == ACTION_LISTEN_NAME + and not tracker.is_active_loop_interrupted + ) + + if needs_validation: + structlogger.debug( + "forms.validation.required", + tracker_latest_message=copy.deepcopy(tracker.latest_message), + ) + return await self.validate(tracker, domain, output_channel, nlg) + else: + # Needed to determine which slots to request although there are no slots + # to actually validate, which happens when coming back to the form after + # an unhappy path + return await self.validate_slots({}, tracker, domain, output_channel, nlg) + + @staticmethod + def _should_request_slot(tracker: "DialogueStateTracker", slot_name: Text) -> bool: + """Check whether form action should request given slot.""" + return tracker.get_slot(slot_name) is None + + async def activate( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + """Activate form if the form is called for the first time. + + If activating, run action_extract_slots to fill slots with + mapping conditions from trigger intents. + Validate any required slots that can be filled, and return any `SlotSet` + events from the extraction and validation of these pre-filled slots. + + Args: + output_channel: The output channel which can be used to send messages + to the user. + nlg: `NaturalLanguageGenerator` to use for response generation. + tracker: Current conversation tracker of the user. + domain: Current model domain. + + Returns: + Events from the activation. + """ + logger.debug(f"Activated the form '{self.name()}'.") + # collect values of required slots filled before activation + prefilled_slots = {} + + action_extract_slots = action.action_for_name_or_text( + ACTION_EXTRACT_SLOTS, domain, self.action_endpoint + ) + + logger.debug( + f"Executing default action '{ACTION_EXTRACT_SLOTS}' at form activation." + ) + + extraction_events = await action_extract_slots.run( + output_channel, nlg, tracker, domain + ) + + events_as_str = "\n".join(str(e) for e in extraction_events) + logger.debug( + f"The execution of '{ACTION_EXTRACT_SLOTS}' resulted in " + f"these events: {events_as_str}." + ) + + tracker.update_with_events(extraction_events, domain) + + for slot_name in self.required_slots(domain): + if not self._should_request_slot(tracker, slot_name): + prefilled_slots[slot_name] = tracker.get_slot(slot_name) + + if not prefilled_slots: + logger.debug("No pre-filled required slots to validate.") + else: + structlogger.debug( + "forms.validate.prefilled_slots", + prefilled_slots=copy.deepcopy(prefilled_slots), + ) + + validate_name = f"validate_{self.name()}" + + if validate_name not in domain.action_names_or_texts: + logger.debug( + f"There is no validation action '{validate_name}' " + f"to execute at form activation." + ) + return [event for event in extraction_events if isinstance(event, SlotSet)] + + logger.debug( + f"Executing validation action '{validate_name}' at form activation." + ) + + validated_events = await self.validate_slots( + prefilled_slots, tracker, domain, output_channel, nlg + ) + + validated_slot_names = [ + event.key for event in validated_events if isinstance(event, SlotSet) + ] + + return validated_events + [ + event + for event in extraction_events + if isinstance(event, SlotSet) and event.key not in validated_slot_names + ] + + async def do( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + events_so_far: List[Event], + ) -> List[Event]: + """Executes form loop after activation.""" + events: List[Event] = [] + """ + Call to validation is not required when the slots are already validated + at the time of form activation. + events_so_far: + - empty when slots have not been validated. + - has SlotSet objects when already validated. + - ActiveLoop object when events have not been validated. + Hence the events are filtered to remove ActiveLoop object that was added + at the time of form activation. + """ + filtered_events = [ + event for event in events_so_far if not isinstance(event, ActiveLoop) + ] + if not filtered_events: + events = await self._validate_if_required( + tracker, domain, output_channel, nlg + ) + + if not self._user_rejected_manually(events): + events += await self.request_next_slot( + tracker, domain, output_channel, nlg, events_so_far + events + ) + + return events + + async def is_done( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + events_so_far: List[Event], + ) -> bool: + """Checks if loop can be terminated.""" + if any(isinstance(event, ActionExecutionRejected) for event in events_so_far): + return False + + # Custom validation actions can decide to terminate the loop early by + # setting the requested slot to `None` or setting `ActiveLoop(None)`. + # We explicitly check only the last occurrences for each possible termination + # event instead of doing `return event in events_so_far` to make it possible + # to override termination events which were returned earlier. + return next( + ( + event + for event in reversed(events_so_far) + if isinstance(event, SlotSet) and event.key == REQUESTED_SLOT + ), + None, + ) == SlotSet(REQUESTED_SLOT, None) or next( + ( + event + for event in reversed(events_so_far) + if isinstance(event, ActiveLoop) + ), + None, + ) == ActiveLoop( + None + ) + + async def deactivate(self, *args: Any, **kwargs: Any) -> List[Event]: + """Deactivates form.""" + logger.debug(f"Deactivating the form '{self.name()}'") + return [] + + async def _activate_loop( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + events = self._default_activation_events() + + temp_tracker = tracker.copy() + temp_tracker.update_with_events(events, domain) + events += await self.activate(output_channel, nlg, temp_tracker, domain) + + return events diff --git a/rasa/core/actions/loops.py b/rasa/core/actions/loops.py new file mode 100644 index 0000000..ccaa704 --- /dev/null +++ b/rasa/core/actions/loops.py @@ -0,0 +1,110 @@ +from abc import ABC +from typing import List, TYPE_CHECKING + +from rasa.core.actions.action import Action +from rasa.shared.core.events import Event, ActiveLoop + +if TYPE_CHECKING: + from rasa.core.channels import OutputChannel + from rasa.shared.core.domain import Domain + from rasa.core.nlg import NaturalLanguageGenerator + from rasa.shared.core.trackers import DialogueStateTracker + + +class LoopAction(Action, ABC): + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + events: List[Event] = [] + + if not await self.is_activated(output_channel, nlg, tracker, domain): + events += await self._activate_loop( + output_channel, + nlg, + tracker, + domain, + ) + + if not await self.is_done(output_channel, nlg, tracker, domain, events): + events += await self.do(output_channel, nlg, tracker, domain, events) + + if await self.is_done(output_channel, nlg, tracker, domain, events): + events += self._default_deactivation_events() + events += await self.deactivate( + output_channel, nlg, tracker, domain, events + ) + + return events + + async def is_activated( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> bool: + return tracker.active_loop_name == self.name() + + # default implementation checks if form active + def _default_activation_events(self) -> List[Event]: + return [ActiveLoop(self.name())] + + async def activate( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + # can be overwritten + return [] + + async def do( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + events_so_far: List[Event], + ) -> List[Event]: + raise NotImplementedError() + + async def is_done( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + events_so_far: List[Event], + ) -> bool: + raise NotImplementedError() + + def _default_deactivation_events(self) -> List[Event]: + return [ActiveLoop(None)] + + async def deactivate( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + events_so_far: List[Event], + ) -> List[Event]: + # can be overwritten + return [] + + async def _activate_loop( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + events = self._default_activation_events() + events += await self.activate(output_channel, nlg, tracker, domain) + + return events diff --git a/rasa/core/actions/two_stage_fallback.py b/rasa/core/actions/two_stage_fallback.py new file mode 100644 index 0000000..669b751 --- /dev/null +++ b/rasa/core/actions/two_stage_fallback.py @@ -0,0 +1,186 @@ +import copy +import time +from typing import List, Text, Optional + +from rasa.core.actions import action +from rasa.core.actions.loops import LoopAction +from rasa.core.channels import OutputChannel +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + Event, + UserUtteranceReverted, + ActionExecuted, + UserUttered, + ActiveLoop, +) +from rasa.core.nlg import NaturalLanguageGenerator +from rasa.shared.core.trackers import DialogueStateTracker, EventVerbosity +from rasa.shared.constants import DEFAULT_NLU_FALLBACK_INTENT_NAME +from rasa.shared.core.constants import ( + USER_INTENT_OUT_OF_SCOPE, + ACTION_LISTEN_NAME, + ACTION_DEFAULT_FALLBACK_NAME, + ACTION_DEFAULT_ASK_AFFIRMATION_NAME, + ACTION_DEFAULT_ASK_REPHRASE_NAME, + ACTION_TWO_STAGE_FALLBACK_NAME, +) +from rasa.shared.nlu.constants import INTENT, PREDICTED_CONFIDENCE_KEY +from rasa.utils.endpoints import EndpointConfig + + +class TwoStageFallbackAction(LoopAction): + def __init__(self, action_endpoint: Optional[EndpointConfig] = None) -> None: + self._action_endpoint = action_endpoint + + def name(self) -> Text: + return ACTION_TWO_STAGE_FALLBACK_NAME + + async def do( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + events_so_far: List[Event], + ) -> List[Event]: + if _user_should_affirm(tracker, events_so_far): + return await self._ask_affirm(output_channel, nlg, tracker, domain) + + return await self._ask_rephrase(output_channel, nlg, tracker, domain) + + async def _ask_affirm( + self, + output_channel: OutputChannel, + nlg: NaturalLanguageGenerator, + tracker: DialogueStateTracker, + domain: Domain, + ) -> List[Event]: + affirm_action = action.action_for_name_or_text( + ACTION_DEFAULT_ASK_AFFIRMATION_NAME, domain, self._action_endpoint + ) + + return await affirm_action.run(output_channel, nlg, tracker, domain) + + async def _ask_rephrase( + self, + output_channel: OutputChannel, + nlg: NaturalLanguageGenerator, + tracker: DialogueStateTracker, + domain: Domain, + ) -> List[Event]: + rephrase = action.action_for_name_or_text( + ACTION_DEFAULT_ASK_REPHRASE_NAME, domain, self._action_endpoint + ) + + return await rephrase.run(output_channel, nlg, tracker, domain) + + async def is_done( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + events_so_far: List[Event], + ) -> bool: + _user_clarified = _last_intent_name(tracker) not in [ + DEFAULT_NLU_FALLBACK_INTENT_NAME, + USER_INTENT_OUT_OF_SCOPE, + ] + return ( + _user_clarified + or _two_fallbacks_in_a_row(tracker) + or _second_affirmation_failed(tracker) + ) + + async def deactivate( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + events_so_far: List[Event], + ) -> List[Event]: + if _two_fallbacks_in_a_row(tracker) or _second_affirmation_failed(tracker): + return await self._give_up(output_channel, nlg, tracker, domain) + + # revert fallback events + reverted_event: List[Event] = [UserUtteranceReverted()] + return reverted_event + _message_clarification(tracker) + + async def _give_up( + self, + output_channel: OutputChannel, + nlg: NaturalLanguageGenerator, + tracker: DialogueStateTracker, + domain: Domain, + ) -> List[Event]: + fallback = action.action_for_name_or_text( + ACTION_DEFAULT_FALLBACK_NAME, domain, self._action_endpoint + ) + + return await fallback.run(output_channel, nlg, tracker, domain) + + +def _last_intent_name(tracker: DialogueStateTracker) -> Optional[Text]: + last_message = tracker.latest_message + if not last_message: + return None + + return last_message.intent_name + + +def _two_fallbacks_in_a_row(tracker: DialogueStateTracker) -> bool: + return _last_n_intent_names(tracker, 2) == [ + DEFAULT_NLU_FALLBACK_INTENT_NAME, + DEFAULT_NLU_FALLBACK_INTENT_NAME, + ] + + +def _last_n_intent_names( + tracker: DialogueStateTracker, number_of_last_intent_names: int +) -> List[Optional[Text]]: + intent_names: List[Optional[Text]] = [] + for i in range(number_of_last_intent_names): + message = tracker.get_last_event_for( + (UserUttered, UserUtteranceReverted), + skip=i, + event_verbosity=EventVerbosity.AFTER_RESTART, + ) + if isinstance(message, UserUttered): + intent_names.append(message.intent.get("name")) + + return intent_names + + +def _user_should_affirm( + tracker: DialogueStateTracker, events_so_far: List[Event] +) -> bool: + fallback_was_just_activated = any( + isinstance(event, ActiveLoop) for event in events_so_far + ) + if fallback_was_just_activated: + return True + + return _last_intent_name(tracker) == DEFAULT_NLU_FALLBACK_INTENT_NAME + + +def _second_affirmation_failed(tracker: DialogueStateTracker) -> bool: + return _last_n_intent_names(tracker, 3) == [ + USER_INTENT_OUT_OF_SCOPE, + DEFAULT_NLU_FALLBACK_INTENT_NAME, + USER_INTENT_OUT_OF_SCOPE, + ] + + +def _message_clarification(tracker: DialogueStateTracker) -> List[Event]: + latest_message = tracker.latest_message + if not latest_message: + raise TypeError( + "Cannot issue message clarification because " + "latest message is not on tracker." + ) + + clarification = copy.deepcopy(latest_message) + clarification.parse_data[INTENT][PREDICTED_CONFIDENCE_KEY] = 1.0 # type: ignore[literal-required] # noqa E501 + clarification.timestamp = time.time() + return [ActionExecuted(ACTION_LISTEN_NAME), clarification] diff --git a/rasa/core/agent.py b/rasa/core/agent.py new file mode 100644 index 0000000..47e3360 --- /dev/null +++ b/rasa/core/agent.py @@ -0,0 +1,551 @@ +from __future__ import annotations +from asyncio import AbstractEventLoop, CancelledError +import functools +import logging +import os +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Text, Union +import uuid + +import aiohttp +from aiohttp import ClientError + +from rasa.core import jobs +from rasa.core.channels.channel import OutputChannel, UserMessage +from rasa.core.constants import DEFAULT_REQUEST_TIMEOUT +from rasa.core.http_interpreter import RasaNLUHttpInterpreter +from rasa.shared.core.domain import Domain +from rasa.core.exceptions import AgentNotReady +from rasa.shared.constants import DEFAULT_SENDER_ID +from rasa.core.lock_store import InMemoryLockStore, LockStore +from rasa.core.nlg import NaturalLanguageGenerator, TemplatedNaturalLanguageGenerator +from rasa.core.policies.policy import PolicyPrediction +from rasa.core.processor import MessageProcessor +from rasa.core.tracker_store import FailSafeTrackerStore, InMemoryTrackerStore +from rasa.shared.core.trackers import DialogueStateTracker, EventVerbosity +from rasa.exceptions import ModelNotFound +from rasa.nlu.utils import is_url +from rasa.shared.exceptions import RasaException +import rasa.shared.utils.io +from rasa.utils.common import TempDirectoryPath, get_temp_dir_name +from rasa.utils.endpoints import EndpointConfig + +from rasa.core.tracker_store import TrackerStore +from rasa.core.utils import AvailableEndpoints + +logger = logging.getLogger(__name__) + + +async def load_from_server(agent: Agent, model_server: EndpointConfig) -> Agent: + """Load a persisted model from a server.""" + # We are going to pull the model once first, and then schedule a recurring + # job. the benefit of this approach is that we can be sure that there + # is a model after this function completes -> allows to do proper + # "is alive" check on a startup server's `/status` endpoint. If the server + # is started, we can be sure that it also already loaded (or tried to) + # a model. + await _update_model_from_server(model_server, agent) + + wait_time_between_pulls = model_server.kwargs.get("wait_time_between_pulls", 100) + + if wait_time_between_pulls: + # continuously pull the model every `wait_time_between_pulls` seconds + await _schedule_model_pulling(model_server, int(wait_time_between_pulls), agent) + + return agent + + +def _load_and_set_updated_model( + agent: Agent, model_directory: Text, fingerprint: Text +) -> None: + """Load the persisted model into memory and set the model on the agent. + + Args: + agent: Instance of `Agent` to update with the new model. + model_directory: Rasa model directory. + fingerprint: Fingerprint of the supplied model at `model_directory`. + """ + logger.debug(f"Found new model with fingerprint {fingerprint}. Loading...") + agent.load_model(model_directory, fingerprint) + + logger.debug("Finished updating agent to new model.") + + +async def _update_model_from_server(model_server: EndpointConfig, agent: Agent) -> None: + """Load a zipped Rasa Core model from a URL and update the passed agent.""" + if not is_url(model_server.url): + raise aiohttp.InvalidURL(model_server.url) + + with TempDirectoryPath(get_temp_dir_name()) as temporary_directory: + try: + new_fingerprint = await _pull_model_and_fingerprint( + model_server, agent.fingerprint, temporary_directory + ) + + if new_fingerprint: + _load_and_set_updated_model(agent, temporary_directory, new_fingerprint) + else: + logger.debug(f"No new model found at URL {model_server.url}") + except Exception: # skipcq: PYL-W0703 + # TODO: Make this exception more specific, possibly print different log + # for each one. + logger.exception( + "Failed to update model. The previous model will stay loaded instead." + ) + + +async def _pull_model_and_fingerprint( + model_server: EndpointConfig, fingerprint: Optional[Text], model_directory: Text +) -> Optional[Text]: + """Queries the model server. + + Args: + model_server: Model server endpoint information. + fingerprint: Current model fingerprint. + model_directory: Directory where to download model to. + + Returns: + Value of the response's <ETag> header which contains the model + hash. Returns `None` if no new model is found. + """ + headers = {"If-None-Match": fingerprint} + + logger.debug(f"Requesting model from server {model_server.url}...") + + async with model_server.session() as session: + try: + params = model_server.combine_parameters() + async with session.request( + "GET", + model_server.url, + timeout=DEFAULT_REQUEST_TIMEOUT, + headers=headers, + params=params, + ) as resp: + + if resp.status in [204, 304]: + logger.debug( + "Model server returned {} status code, " + "indicating that no new model is available. " + "Current fingerprint: {}" + "".format(resp.status, fingerprint) + ) + return None + elif resp.status == 404: + logger.debug( + "Model server could not find a model at the requested " + "endpoint '{}'. It's possible that no model has been " + "trained, or that the requested tag hasn't been " + "assigned.".format(model_server.url) + ) + return None + elif resp.status != 200: + logger.debug( + "Tried to fetch model from server, but server response " + "status code is {}. We'll retry later..." + "".format(resp.status) + ) + return None + + model_path = Path(model_directory) / resp.headers.get( + "filename", "model.tar.gz" + ) + with open(model_path, "wb") as file: + file.write(await resp.read()) + + logger.debug("Saved model to '{}'".format(os.path.abspath(model_path))) + + # return the new fingerprint + return resp.headers.get("ETag") + + except aiohttp.ClientError as e: + logger.debug( + "Tried to fetch model from server, but " + "couldn't reach server. We'll retry later... " + "Error: {}.".format(e) + ) + return None + + +async def _run_model_pulling_worker(model_server: EndpointConfig, agent: Agent) -> None: + # noinspection PyBroadException + try: + await _update_model_from_server(model_server, agent) + except CancelledError: + logger.warning("Stopping model pulling (cancelled).") + except ClientError: + logger.exception( + "An exception was raised while fetching a model. Continuing anyways..." + ) + + +async def _schedule_model_pulling( + model_server: EndpointConfig, wait_time_between_pulls: int, agent: Agent +) -> None: + (await jobs.scheduler()).add_job( + _run_model_pulling_worker, + "interval", + seconds=wait_time_between_pulls, + args=[model_server, agent], + id="pull-model-from-server", + replace_existing=True, + ) + + +async def load_agent( + model_path: Optional[Text] = None, + model_server: Optional[EndpointConfig] = None, + remote_storage: Optional[Text] = None, + endpoints: Optional[AvailableEndpoints] = None, + loop: Optional[AbstractEventLoop] = None, +) -> Agent: + """Loads agent from server, remote storage or disk. + + Args: + model_path: Path to the model if it's on disk. + model_server: Configuration for a potential server which serves the model. + remote_storage: URL of remote storage for model. + endpoints: Endpoint configuration. + loop: Optional async loop to pass to broker creation. + + Returns: + The instantiated `Agent` or `None`. + """ + from rasa.core.tracker_store import TrackerStore + from rasa.core.brokers.broker import EventBroker + + tracker_store = None + lock_store = None + generator = None + action_endpoint = None + http_interpreter = None + + if endpoints: + broker = await EventBroker.create(endpoints.event_broker, loop=loop) + tracker_store = TrackerStore.create( + endpoints.tracker_store, event_broker=broker + ) + lock_store = LockStore.create(endpoints.lock_store) + generator = endpoints.nlg + action_endpoint = endpoints.action + model_server = endpoints.model if endpoints.model else model_server + if endpoints.nlu: + http_interpreter = RasaNLUHttpInterpreter(endpoints.nlu) + + agent = Agent( + generator=generator, + tracker_store=tracker_store, + lock_store=lock_store, + action_endpoint=action_endpoint, + model_server=model_server, + remote_storage=remote_storage, + http_interpreter=http_interpreter, + ) + + try: + if model_server is not None: + return await load_from_server(agent, model_server) + + elif remote_storage is not None: + agent.load_model_from_remote_storage(model_path) + + elif model_path is not None and os.path.exists(model_path): + try: + agent.load_model(model_path) + except ModelNotFound: + rasa.shared.utils.io.raise_warning( + f"No valid model found at {model_path}!" + ) + else: + rasa.shared.utils.io.raise_warning( + "No valid configuration given to load agent. " + "Agent loaded with no model!" + ) + return agent + + except Exception as e: + logger.error(f"Could not load model due to {e}.", exc_info=True) + return agent + + +def agent_must_be_ready(f: Callable[..., Any]) -> Callable[..., Any]: + """Any Agent method decorated with this will raise if the agent is not ready.""" + + @functools.wraps(f) + def decorated(self: Agent, *args: Any, **kwargs: Any) -> Any: + if not self.is_ready(): + raise AgentNotReady( + "Agent needs to be prepared before usage. You need to set a " + "processor and a tracker store." + ) + return f(self, *args, **kwargs) + + return decorated + + +class Agent: + """The Agent class provides an interface for the most important Rasa functionality. + + This includes training, handling messages, loading a dialogue model, + getting the next action, and handling a channel. + """ + + def __init__( + self, + domain: Optional[Domain] = None, + generator: Union[EndpointConfig, NaturalLanguageGenerator, None] = None, + tracker_store: Optional[TrackerStore] = None, + lock_store: Optional[LockStore] = None, + action_endpoint: Optional[EndpointConfig] = None, + fingerprint: Optional[Text] = None, + model_server: Optional[EndpointConfig] = None, + remote_storage: Optional[Text] = None, + http_interpreter: Optional[RasaNLUHttpInterpreter] = None, + ): + """Initializes an `Agent`.""" + self.domain = domain + self.processor: Optional[MessageProcessor] = None + + self.nlg = NaturalLanguageGenerator.create(generator, self.domain) + self.tracker_store = self._create_tracker_store(tracker_store, self.domain) + self.lock_store = self._create_lock_store(lock_store) + self.action_endpoint = action_endpoint + self.http_interpreter = http_interpreter + + self._set_fingerprint(fingerprint) + self.model_server = model_server + self.remote_storage = remote_storage + + @classmethod + def load( + cls, + model_path: Union[Text, Path], + domain: Optional[Domain] = None, + generator: Union[EndpointConfig, NaturalLanguageGenerator, None] = None, + tracker_store: Optional[TrackerStore] = None, + lock_store: Optional[LockStore] = None, + action_endpoint: Optional[EndpointConfig] = None, + fingerprint: Optional[Text] = None, + model_server: Optional[EndpointConfig] = None, + remote_storage: Optional[Text] = None, + http_interpreter: Optional[RasaNLUHttpInterpreter] = None, + ) -> Agent: + """Constructs a new agent and loads the processer and model.""" + agent = Agent( + domain=domain, + generator=generator, + tracker_store=tracker_store, + lock_store=lock_store, + action_endpoint=action_endpoint, + fingerprint=fingerprint, + model_server=model_server, + remote_storage=remote_storage, + http_interpreter=http_interpreter, + ) + agent.load_model(model_path=model_path, fingerprint=fingerprint) + return agent + + def load_model( + self, model_path: Union[Text, Path], fingerprint: Optional[Text] = None + ) -> None: + """Loads the agent's model and processor given a new model path.""" + self.processor = MessageProcessor( + model_path=model_path, + tracker_store=self.tracker_store, + lock_store=self.lock_store, + action_endpoint=self.action_endpoint, + generator=self.nlg, + http_interpreter=self.http_interpreter, + ) + self.domain = self.processor.domain + + self._set_fingerprint(fingerprint) + + # update domain on all instances + self.tracker_store.domain = self.domain + if isinstance(self.nlg, TemplatedNaturalLanguageGenerator): + self.nlg.responses = self.domain.responses if self.domain else {} + + @property + def model_id(self) -> Optional[Text]: + """Returns the model_id from processor's model_metadata.""" + return self.processor.model_metadata.model_id if self.processor else None + + @property + def model_name(self) -> Optional[Text]: + """Returns the model name from processor's model_path.""" + return self.processor.model_path.name if self.processor else None + + def is_ready(self) -> bool: + """Check if all necessary components are instantiated to use agent.""" + return self.tracker_store is not None and self.processor is not None + + @agent_must_be_ready + async def parse_message(self, message_data: Text) -> Dict[Text, Any]: + """Handles message text and intent payload input messages. + + The return value of this function is parsed_data. + + Args: + message_data (Text): Contain the received message in text or\ + intent payload format. + + Returns: + The parsed message. + + Example: + {\ + "text": '/greet{"name":"Rasa"}',\ + "intent": {"name": "greet", "confidence": 1.0},\ + "intent_ranking": [{"name": "greet", "confidence": 1.0}],\ + "entities": [{"entity": "name", "start": 6,\ + "end": 21, "value": "Rasa"}],\ + } + + """ + message = UserMessage(message_data) + + return await self.processor.parse_message(message) # type: ignore[union-attr] + + async def handle_message( + self, message: UserMessage + ) -> Optional[List[Dict[Text, Any]]]: + """Handle a single message.""" + if not self.is_ready(): + logger.info("Ignoring message as there is no agent to handle it.") + return None + + async with self.lock_store.lock(message.sender_id): + return await self.processor.handle_message( # type: ignore[union-attr] + message + ) + + @agent_must_be_ready + async def predict_next_for_sender_id( + self, sender_id: Text + ) -> Optional[Dict[Text, Any]]: + """Predict the next action for a sender id.""" + return await self.processor.predict_next_for_sender_id( # type: ignore[union-attr] # noqa:E501 + sender_id + ) + + @agent_must_be_ready + def predict_next_with_tracker( + self, + tracker: DialogueStateTracker, + verbosity: EventVerbosity = EventVerbosity.AFTER_RESTART, + ) -> Optional[Dict[Text, Any]]: + """Predicts the next action.""" + return self.processor.predict_next_with_tracker( # type: ignore[union-attr] + tracker, verbosity + ) + + @agent_must_be_ready + async def log_message(self, message: UserMessage) -> DialogueStateTracker: + """Append a message to a dialogue - does not predict actions.""" + return await self.processor.log_message(message) # type: ignore[union-attr] + + @agent_must_be_ready + async def execute_action( + self, + sender_id: Text, + action: Text, + output_channel: OutputChannel, + policy: Optional[Text], + confidence: Optional[float], + ) -> Optional[DialogueStateTracker]: + """Executes an action.""" + prediction = PolicyPrediction.for_action_name( + self.domain, action, policy, confidence or 0.0 + ) + return await self.processor.execute_action( # type: ignore[union-attr] + sender_id, action, output_channel, self.nlg, prediction + ) + + @agent_must_be_ready + async def trigger_intent( + self, + intent_name: Text, + entities: List[Dict[Text, Any]], + output_channel: OutputChannel, + tracker: DialogueStateTracker, + ) -> None: + """Trigger a user intent, e.g. triggered by an external event.""" + await self.processor.trigger_external_user_uttered( # type: ignore[union-attr] + intent_name, entities, tracker, output_channel + ) + + @agent_must_be_ready + async def handle_text( + self, + text_message: Union[Text, Dict[Text, Any]], + output_channel: Optional[OutputChannel] = None, + sender_id: Optional[Text] = DEFAULT_SENDER_ID, + ) -> Optional[List[Dict[Text, Any]]]: + """Handle a single message. + + If a message preprocessor is passed, the message will be passed to that + function first and the return value is then used as the + input for the dialogue engine. + + The return value of this function depends on the ``output_channel``. If + the output channel is not set, set to ``None``, or set + to ``CollectingOutputChannel`` this function will return the messages + the bot wants to respond. + + :Example: + + >>> from rasa.core.agent import Agent + >>> agent = Agent.load("examples/moodbot/models") + >>> await agent.handle_text("hello") + [u'how can I help you?'] + + """ + if isinstance(text_message, str): + text_message = {"text": text_message} + + msg = UserMessage(text_message.get("text"), output_channel, sender_id) + + return await self.handle_message(msg) + + def _set_fingerprint(self, fingerprint: Optional[Text] = None) -> None: + + if fingerprint: + self.fingerprint = fingerprint + else: + self.fingerprint = uuid.uuid4().hex + + @staticmethod + def _create_tracker_store( + store: Optional[TrackerStore], domain: Domain + ) -> TrackerStore: + if store is not None: + store.domain = domain + tracker_store = store + else: + tracker_store = InMemoryTrackerStore(domain) + + return FailSafeTrackerStore(tracker_store) + + @staticmethod + def _create_lock_store(store: Optional[LockStore]) -> LockStore: + if store is not None: + return store + + return InMemoryLockStore() + + def load_model_from_remote_storage(self, model_name: Text) -> None: + """Loads an Agent from remote storage.""" + from rasa.nlu.persistor import get_persistor + + persistor = get_persistor(self.remote_storage) + + if persistor is not None: + with TempDirectoryPath(get_temp_dir_name()) as temporary_directory: + persistor.retrieve(model_name, temporary_directory) + self.load_model(temporary_directory) + + else: + raise RasaException( + f"Persistor not found for remote storage: '{self.remote_storage}'." + ) diff --git a/rasa/core/brokers/__init__.py b/rasa/core/brokers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/core/brokers/broker.py b/rasa/core/brokers/broker.py new file mode 100644 index 0000000..921f95c --- /dev/null +++ b/rasa/core/brokers/broker.py @@ -0,0 +1,126 @@ +from __future__ import annotations +import logging +from asyncio import AbstractEventLoop +from typing import Any, Dict, Text, Optional, Union, TypeVar, Type + +import aiormq + +import rasa.shared.utils.common +import rasa.shared.utils.io +from rasa.shared.exceptions import ConnectionException +from rasa.utils.endpoints import EndpointConfig + +logger = logging.getLogger(__name__) + + +EB = TypeVar("EB", bound="EventBroker") + + +class EventBroker: + """Base class for any event broker implementation.""" + + @staticmethod + async def create( + obj: Union[EventBroker, EndpointConfig, None], + loop: Optional[AbstractEventLoop] = None, + ) -> Optional[EventBroker]: + """Factory to create an event broker.""" + if isinstance(obj, EventBroker): + return obj + + import aio_pika.exceptions + import sqlalchemy.exc + + try: + return await _create_from_endpoint_config(obj, loop) + except ( + sqlalchemy.exc.OperationalError, + aio_pika.exceptions.AMQPConnectionError, + aiormq.exceptions.ChannelNotFoundEntity, + *aio_pika.exceptions.CONNECTION_EXCEPTIONS, + ) as error: + raise ConnectionException("Cannot connect to event broker.") from error + + @classmethod + async def from_endpoint_config( + cls: Type[EB], + broker_config: EndpointConfig, + event_loop: Optional[AbstractEventLoop] = None, + ) -> Optional[EB]: + """Creates an `EventBroker` from the endpoint configuration. + + Args: + broker_config: The configuration for the broker. + event_loop: The current event loop or `None`. + + Returns: + An `EventBroker` object. + """ + raise NotImplementedError( + "Event broker must implement the `from_endpoint_config` method." + ) + + def publish(self, event: Dict[Text, Any]) -> None: + """Publishes a json-formatted Rasa Core event into an event queue.""" + raise NotImplementedError("Event broker must implement the `publish` method.") + + def is_ready(self) -> bool: + """Determine whether or not the event broker is ready. + + Returns: + `True` by default, but this may be overridden by subclasses. + """ + return True + + async def close(self) -> None: + """Close the connection to an event broker.""" + # default implementation does nothing + pass + + +async def _create_from_endpoint_config( + endpoint_config: Optional[EndpointConfig], event_loop: Optional[AbstractEventLoop] +) -> Optional[EventBroker]: + """Instantiate an event broker based on its configuration.""" + if endpoint_config is None: + broker: Optional[EventBroker] = None + elif endpoint_config.type is None or endpoint_config.type.lower() == "pika": + from rasa.core.brokers.pika import PikaEventBroker + + # default broker if no type is set + broker = await PikaEventBroker.from_endpoint_config(endpoint_config, event_loop) + elif endpoint_config.type.lower() == "sql": + from rasa.core.brokers.sql import SQLEventBroker + + broker = await SQLEventBroker.from_endpoint_config(endpoint_config) + elif endpoint_config.type.lower() == "file": + from rasa.core.brokers.file import FileEventBroker + + broker = await FileEventBroker.from_endpoint_config(endpoint_config) + elif endpoint_config.type.lower() == "kafka": + from rasa.core.brokers.kafka import KafkaEventBroker + + broker = await KafkaEventBroker.from_endpoint_config(endpoint_config) + else: + broker = await _load_from_module_name_in_endpoint_config(endpoint_config) + + if broker: + logger.debug(f"Instantiated event broker to '{broker.__class__.__name__}'.") + return broker + + +async def _load_from_module_name_in_endpoint_config( + broker_config: EndpointConfig, +) -> Optional[EventBroker]: + """Instantiate an event broker based on its class name.""" + try: + event_broker_class = rasa.shared.utils.common.class_from_module_path( + broker_config.type + ) + return await event_broker_class.from_endpoint_config(broker_config) + except (AttributeError, ImportError) as e: + logger.warning( + f"The `EventBroker` type '{broker_config.type}' could not be found. " + f"Not using any event broker. Error: {e}" + ) + return None diff --git a/rasa/core/brokers/file.py b/rasa/core/brokers/file.py new file mode 100644 index 0000000..aa6495c --- /dev/null +++ b/rasa/core/brokers/file.py @@ -0,0 +1,59 @@ +import json +import logging +import typing +from asyncio import AbstractEventLoop +from typing import Optional, Text, Dict + +from rasa.core.brokers.broker import EventBroker + +if typing.TYPE_CHECKING: + from rasa.utils.endpoints import EndpointConfig + +logger = logging.getLogger(__name__) + + +class FileEventBroker(EventBroker): + """Log events to a file in json format. + + There will be one event per line and each event is stored as json.""" + + DEFAULT_LOG_FILE_NAME = "rasa_event.log" + + def __init__(self, path: Optional[Text] = None) -> None: + self.path = path or self.DEFAULT_LOG_FILE_NAME + self.event_logger = self._event_logger() + + @classmethod + async def from_endpoint_config( + cls, + broker_config: Optional["EndpointConfig"], + event_loop: Optional[AbstractEventLoop] = None, + ) -> Optional["FileEventBroker"]: + """Creates broker. See the parent class for more information.""" + if broker_config is None: + return None + + # noinspection PyArgumentList + return cls(**broker_config.kwargs) + + def _event_logger(self) -> logging.Logger: + """Instantiate the file logger.""" + + logger_file = self.path + # noinspection PyTypeChecker + query_logger = logging.getLogger("event-logger") + query_logger.setLevel(logging.INFO) + handler = logging.FileHandler(logger_file) + handler.setFormatter(logging.Formatter("%(message)s")) + query_logger.propagate = False + query_logger.addHandler(handler) + + logger.info(f"Logging events to '{logger_file}'.") + + return query_logger + + def publish(self, event: Dict) -> None: + """Write event to file.""" + + self.event_logger.info(json.dumps(event)) + self.event_logger.handlers[0].flush() diff --git a/rasa/core/brokers/kafka.py b/rasa/core/brokers/kafka.py new file mode 100644 index 0000000..66e77c2 --- /dev/null +++ b/rasa/core/brokers/kafka.py @@ -0,0 +1,318 @@ +import asyncio +import os +import json +import logging +import structlog +import threading +from asyncio import AbstractEventLoop +from typing import Any, Text, List, Optional, Union, Dict, TYPE_CHECKING +import time + +from rasa.core.brokers.broker import EventBroker +from rasa.core.exceptions import KafkaProducerInitializationError +from rasa.shared.utils.io import DEFAULT_ENCODING +from rasa.utils.endpoints import EndpointConfig +import rasa.shared.utils.common + +if TYPE_CHECKING: + from confluent_kafka import KafkaError, Producer, Message + +logger = logging.getLogger(__name__) +structlogger = structlog.get_logger() + + +class KafkaEventBroker(EventBroker): + """Kafka event broker.""" + + def __init__( + self, + url: Union[Text, List[Text], None], + topic: Text = "rasa_core_events", + client_id: Optional[Text] = None, + partition_by_sender: bool = False, + sasl_username: Optional[Text] = None, + sasl_password: Optional[Text] = None, + sasl_mechanism: Optional[Text] = "PLAIN", + ssl_cafile: Optional[Text] = None, + ssl_certfile: Optional[Text] = None, + ssl_keyfile: Optional[Text] = None, + ssl_check_hostname: bool = False, + security_protocol: Text = "SASL_PLAINTEXT", + **kwargs: Any, + ) -> None: + """Kafka event broker. + + Args: + url: 'url[:port]' string (or list of 'url[:port]' + strings) that the producer should contact to bootstrap initial + cluster metadata. This does not have to be the full node list. + It just needs to have at least one broker that will respond to a + Metadata API Request. + topic: Topics to subscribe to. + client_id: A name for this client. This string is passed in each request + to servers and can be used to identify specific server-side log entries + that correspond to this client. Also submitted to `GroupCoordinator` for + logging with respect to producer group administration. + partition_by_sender: Flag to configure whether messages are partitioned by + sender_id or not + sasl_username: Username for plain authentication. + sasl_password: Password for plain authentication. + sasl_mechanism: Authentication mechanism when security_protocol is + configured for SASL_PLAINTEXT or SASL_SSL. + Valid values are: PLAIN, GSSAPI, OAUTHBEARER, SCRAM-SHA-256, + SCRAM-SHA-512. Default: `PLAIN` + ssl_cafile: Optional filename of ca file to use in certificate + verification. + ssl_certfile: Optional filename of file in pem format containing + the client certificate, as well as any ca certificates needed to + establish the certificate's authenticity. + ssl_keyfile: Optional filename containing the client private key. + ssl_check_hostname: Flag to configure whether ssl handshake + should verify that the certificate matches the broker's hostname. + security_protocol: Protocol used to communicate with brokers. + Valid values are: PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL. + """ + self.producer: Optional[Producer] = None + self.url = url + self.topic = topic + self.client_id = client_id + self.partition_by_sender = partition_by_sender + self.security_protocol = security_protocol.upper() + self.sasl_username = sasl_username + self.sasl_password = sasl_password + self.sasl_mechanism = sasl_mechanism + self.ssl_cafile = ssl_cafile + self.ssl_certfile = ssl_certfile + self.ssl_keyfile = ssl_keyfile + self.queue_size = kwargs.get("queue_size") + self.ssl_check_hostname = "https" if ssl_check_hostname else None + + # Async producer implementation followed from confluent-kafka asyncio example: + # https://github.com/confluentinc/confluent-kafka-python/blob/master/examples/asyncio_example.py#L88 # noqa: E501 + self._loop = asyncio.get_event_loop() + self._cancelled = False + self._poll_thread = threading.Thread(target=self._poll_loop) + self._poll_thread.start() + + @classmethod + async def from_endpoint_config( + cls, + broker_config: EndpointConfig, + event_loop: Optional[AbstractEventLoop] = None, + ) -> Optional["KafkaEventBroker"]: + """Creates broker. See the parent class for more information.""" + if broker_config is None: + return None + + return cls(broker_config.url, **broker_config.kwargs) + + def publish( + self, + event: Dict[Text, Any], + retries: int = 60, + retry_delay_in_seconds: float = 5, + ) -> None: + """Publishes events.""" + from confluent_kafka import KafkaException + + if retries == 1: + retries = 2 + + if self.producer is None: + self.producer = self._create_producer() + try: + self._check_kafka_connection() + logger.debug("Connection to kafka successful.") + except KafkaException: + logger.debug("Failed to connect kafka.") + return + while retries: + try: + self._publish(event) + return + except BufferError as e: + logger.error( + f"Could not publish message to kafka url '{self.url}'. " + f"Failed with error: {e}" + ) + self.producer.poll(1) + retries -= 1 + except Exception as e: + logger.error( + f"Could not publish message to kafka url '{self.url}'. " + f"Failed with error: {e}" + ) + try: + self._check_kafka_connection() + except KafkaException: + logger.debug("Connection to kafka lost, reconnecting...") + self.producer = self._create_producer() + try: + self._check_kafka_connection() + logger.debug("Reconnection to kafka successful") + self._publish(event) + return + except KafkaException: + pass + retries -= 1 + time.sleep(retry_delay_in_seconds) + + logger.error("Failed to publish Kafka event.") + + def _check_kafka_connection(self) -> None: + """Verifies connection with Kafka. + + Raises: + KafkaException: if Kafka is disconnected. + """ + if self.producer is not None: + self.producer.list_topics(timeout=5) + + def _get_kafka_config(self) -> Dict[Text, Any]: + config = { + "client.id": self.client_id, + "bootstrap.servers": self.url, + "error_cb": kafka_error_callback, + } + if self.queue_size: + config["queue.buffering.max.messages"] = self.queue_size + + if self.security_protocol == "PLAINTEXT": + authentication_params: Dict[Text, Any] = { + "security.protocol": self.security_protocol, + } + elif self.security_protocol == "SASL_PLAINTEXT": + authentication_params = { + "sasl.username": self.sasl_username, + "sasl.password": self.sasl_password, + "sasl.mechanism": self.sasl_mechanism, + "security.protocol": self.security_protocol, + } + elif self.security_protocol == "SSL": + authentication_params = { + "ssl.ca.location": self.ssl_cafile, + "ssl.certificate.location": self.ssl_certfile, + "ssl.key.location": self.ssl_keyfile, + "security.protocol": self.security_protocol, + } + elif self.security_protocol == "SASL_SSL": + authentication_params = { + "sasl.username": self.sasl_username, + "sasl.password": self.sasl_password, + "ssl.ca.location": self.ssl_cafile, + "ssl.certificate.location": self.ssl_certfile, + "ssl.key.location": self.ssl_keyfile, + "ssl.endpoint.identification.algorithm": self.ssl_check_hostname, + "security.protocol": self.security_protocol, + "sasl.mechanism": self.sasl_mechanism, + } + else: + raise ValueError( + f"Cannot initialise `KafkaEventBroker`: " + f"Invalid `security_protocol` ('{self.security_protocol}')." + ) + + return {**config, **authentication_params} + + def _create_producer(self) -> "Producer": + import confluent_kafka + + try: + return confluent_kafka.Producer(self._get_kafka_config()) + except confluent_kafka.KafkaException as e: + raise KafkaProducerInitializationError( + f"Cannot initialise `KafkaEventBroker`: {e}" + ) + + def _publish(self, event: Dict[Text, Any]) -> None: + if self.partition_by_sender: + partition_key = bytes(event.get("sender_id"), encoding=DEFAULT_ENCODING) + else: + partition_key = None + + headers = [] + if self.rasa_environment: + headers = [ + ( + "RASA_ENVIRONMENT", + bytes(self.rasa_environment, encoding=DEFAULT_ENCODING), + ) + ] + + reduced_event = rasa.shared.core.events.remove_parse_data(event) + structlogger.debug( + "kafka.publish.event", + event_info="Logging a reduced version of the Kafka event", + topic=self.topic, + rasa_event=reduced_event, + partition_key=partition_key, + headers=headers, + ) + + serialized_event = json.dumps(event).encode(DEFAULT_ENCODING) + + if self.producer is not None: + self.producer.produce( + self.topic, + value=serialized_event, + key=partition_key, + headers=headers, + on_delivery=delivery_report, + ) + + async def close(self) -> None: + self._cancelled = True + self._poll_thread.join() + if self.producer: + self.producer.flush() + + @rasa.shared.utils.common.lazy_property + def rasa_environment(self) -> Optional[Text]: + """Get value of the `RASA_ENVIRONMENT` environment variable.""" + return os.environ.get("RASA_ENVIRONMENT", "RASA_ENVIRONMENT_NOT_SET") + + def _poll_loop(self) -> None: + """Polls the producer for events. + + Required to trigger the on_delivery callback passed to produce method. + """ + if self.producer is not None: + while not self._cancelled: + self.producer.poll(0.1) + + +def kafka_error_callback(err: "KafkaError") -> None: + """Callback for Kafka errors. + + Any exception raised from this callback will be re-raised from the + triggering flush() call. + """ + from confluent_kafka import KafkaException, KafkaError + + # handle authentication / connection related issues, likely pointing + # to a configuration error + if ( + err.code() == KafkaError._ALL_BROKERS_DOWN + or err.code() == KafkaError._AUTHENTICATION + or err.code() == KafkaError._MAX_POLL_EXCEEDED + ): + raise KafkaException(err) + else: + logger.warning("A KafkaError has been raised.", exc_info=True) + + +def delivery_report(err: Exception, msg: "Message") -> None: + """Reports the failure or success of a message delivery. + + Args: + err (KafkaError): The error that occurred on None on success. + msg (Message): The message that was produced or failed. + """ + if err is not None: + logger.error(f"Delivery failed for User record {msg.key()}: {err}") + return + + logger.info( + f"User record {msg.key()} successfully produced to " + f"{msg.topic()} [{msg.partition()}] at offset {msg.offset()}." + ) diff --git a/rasa/core/brokers/pika.py b/rasa/core/brokers/pika.py new file mode 100644 index 0000000..52e2d9f --- /dev/null +++ b/rasa/core/brokers/pika.py @@ -0,0 +1,387 @@ +import asyncio +import json +import logging +import structlog +import os +import ssl +from asyncio import AbstractEventLoop +from collections import deque +from typing import Deque, Dict, Optional, Text, Union, Any, List, Set, Tuple, cast +from urllib.parse import urlparse + +import aio_pika + +from rasa.shared.exceptions import RasaException +from rasa.shared.constants import DOCS_URL_PIKA_EVENT_BROKER +from rasa.core.brokers.broker import EventBroker +import rasa.shared.utils.io +from rasa.utils.endpoints import EndpointConfig +from rasa.shared.utils.io import DEFAULT_ENCODING +import rasa.shared.utils.common + +logger = logging.getLogger(__name__) +structlogger = structlog.get_logger() + +RABBITMQ_EXCHANGE = "rasa-exchange" +DEFAULT_QUEUE_NAME = "rasa_core_events" + + +class PikaEventBroker(EventBroker): + """Pika-based event broker for publishing messages to RabbitMQ.""" + + def __init__( + self, + host: Text, + username: Text, + password: Text, + port: Union[int, Text] = 5672, + queues: Union[List[Text], Tuple[Text, ...], Text, None] = None, + should_keep_unpublished_messages: bool = True, + raise_on_failure: bool = False, + event_loop: Optional[AbstractEventLoop] = None, + connection_attempts: int = 20, + retry_delay_in_seconds: float = 5, + exchange_name: Text = RABBITMQ_EXCHANGE, + **kwargs: Any, + ): + """Initialise RabbitMQ event broker. + + Args: + host: Pika host. + username: Username for authentication with Pika host. + password: Password for authentication with Pika host. + port: port of the Pika host. + queues: Pika queues to declare and publish to. + should_keep_unpublished_messages: Whether or not the event broker should + maintain a queue of unpublished messages to be published later in + case of errors. + raise_on_failure: Whether to raise an exception if publishing fails. If + `False`, keep retrying. + event_loop: The event loop which will be used to run `async` functions. If + `None` `asyncio.get_event_loop()` is used to get a loop. + connection_attempts: Number of attempts for connecting to RabbitMQ before + an exception is thrown. + retry_delay_in_seconds: Time in seconds between connection attempts. + exchange_name: Exchange name to which the queues binds to. + If nothing is mentioned then the default exchange name would be used. + """ + super().__init__() + + self.host = host + self.username = username + self.password = password + + try: + self.port = int(port) + except ValueError as e: + raise RasaException("Port could not be converted to integer.") from e + + self.queues = self._get_queues_from_args(queues) + self.raise_on_failure = raise_on_failure + self._connection_attempts = connection_attempts + self._retry_delay_in_seconds = retry_delay_in_seconds + self.exchange_name = exchange_name + + # Unpublished messages which hopefully will be published later 🤞 + self._unpublished_events: Deque[Dict[Text, Any]] = deque() + self.should_keep_unpublished_messages = should_keep_unpublished_messages + + self._loop = event_loop or asyncio.get_event_loop() + self._background_tasks: Set[asyncio.Task] = set() + + self._connection: Optional[aio_pika.abc.AbstractRobustConnection] = None + self._exchange: Optional[aio_pika.RobustExchange] = None + + @staticmethod + def _get_queues_from_args( + queues_arg: Union[List[Text], Tuple[Text, ...], Text, None] + ) -> Union[List[Text], Tuple[Text, ...]]: + """Get queues for this event broker. + + The preferred argument defining the RabbitMQ queues the `PikaEventBroker` should + publish to is `queues` (as of Rasa Open Source version 1.8.2). This method + can be removed in the future, and `self.queues` should just receive the value of + the `queues` kwarg in the constructor. + + Args: + queues_arg: Value of the supplied `queues` argument. + + Returns: + Queues this event broker publishes to. + + Raises: + `ValueError` if no valid `queues` argument was found. + """ + if queues_arg and isinstance(queues_arg, (list, tuple)): + return queues_arg + + if queues_arg and isinstance(queues_arg, str): + logger.debug( + f"Found a string value under the `queues` key of the Pika event broker " + f"config. Please supply a list of queues under this key, even if it is " + f"just a single one. See {DOCS_URL_PIKA_EVENT_BROKER}" + ) + return [queues_arg] + + rasa.shared.utils.io.raise_warning( + f"No `queues` argument provided. It is suggested to " + f"explicitly specify a queue as described in " + f"{DOCS_URL_PIKA_EVENT_BROKER}. " + f"Using the default queue '{DEFAULT_QUEUE_NAME}' for now." + ) + + return [DEFAULT_QUEUE_NAME] + + @classmethod + async def from_endpoint_config( + cls, + broker_config: Optional["EndpointConfig"], + event_loop: Optional[AbstractEventLoop] = None, + ) -> Optional["PikaEventBroker"]: + """Creates broker. See the parent class for more information.""" + if broker_config is None: + return None + + broker = cls(broker_config.url, **broker_config.kwargs, event_loop=event_loop) + await broker.connect() + + return broker + + async def connect(self) -> None: + """Connects to RabbitMQ.""" + self._connection = await self._connect() + self._connection.reconnect_callbacks.add(self._publish_unpublished_messages) + logger.info(f"RabbitMQ connection to '{self.host}' was established.") + + channel = await self._connection.channel() + logger.debug( + f"RabbitMQ channel was opened. " + f"Declaring fanout exchange '{self.exchange_name}'." + ) + + self._exchange = await self._set_up_exchange(channel) + + def _configure_url(self) -> Optional[Text]: + """Configures the URL to connect to RabbitMQ.""" + url = None + + if self.host.startswith("amqp"): + + parsed_host = urlparse(self.host) + + amqp_user = f"{self.username}:{self.password}" + if amqp_user not in parsed_host.netloc: + url = f"{parsed_host.scheme}://{amqp_user}@{parsed_host.netloc}" + else: + url = f"{parsed_host.scheme}://{parsed_host.netloc}" + + if str(self.port) not in url: + url = f"{url}:{self.port}" + + if parsed_host.path: + url = f"{url}{parsed_host.path}" + + if parsed_host.query: + url = f"{url}?{parsed_host.query}" + + return url + + async def _connect(self) -> aio_pika.abc.AbstractRobustConnection: + # The `url` parameter will take precedence over parameters like `login` or + # `password`. + url = self._configure_url() + + ssl_options = _create_rabbitmq_ssl_options(self.host) + logger.info("Connecting to RabbitMQ ...") + + last_exception: Optional[Exception] = None + for _ in range(self._connection_attempts): + try: + return await aio_pika.connect_robust( + url=url, + host=self.host, + port=self.port, + password=self.password, + login=self.username, + loop=self._loop, + ssl=ssl_options is not None, + ssl_options=ssl_options, + ) + # All sorts of exception can happen until RabbitMQ is in a stable state + except Exception as e: + last_exception = e + logger.debug( + f"Connecting to '{self.host}' failed with error '{e}'. " + f"Trying again." + ) + await asyncio.sleep(self._retry_delay_in_seconds) + + last_exception = cast(Exception, last_exception) + logger.error( + f"Connecting to '{self.host}' failed with error '{last_exception}'." + ) + raise last_exception + + def _publish_unpublished_messages(self, *_: Any, **__: Any) -> None: + while self._unpublished_events: + # Send unpublished messages + message = self._unpublished_events.popleft() + self.publish(message) + logger.debug( + f"Published message from queue of unpublished messages. " + f"Remaining unpublished messages: {len(self._unpublished_events)}." + ) + + async def _set_up_exchange( + self, channel: aio_pika.RobustChannel + ) -> aio_pika.RobustExchange: + exchange = await channel.declare_exchange( + self.exchange_name, type=aio_pika.ExchangeType.FANOUT + ) + + await asyncio.gather( + *[ + self._bind_queue(queue_name, channel, exchange) + for queue_name in self.queues + ] + ) + + return exchange + + @staticmethod + async def _bind_queue( + queue_name: Text, channel: aio_pika.RobustChannel, exchange: aio_pika.Exchange + ) -> None: + queue = await channel.declare_queue(queue_name, durable=True) + + await queue.bind(exchange, "") + + async def close(self) -> None: + """Closes connection to RabbitMQ.""" + if not self._connection: + return + + # Entering the context manager does nothing. Exiting closes the channels and + # the connection. + async with self._connection: + logger.debug("Closing RabbitMQ connection.") + + def is_ready(self) -> bool: + """Return `True` if a connection was established.""" + if self._connection is None: + return False + + return not self._connection.is_closed + + def publish( + self, event: Dict[Text, Any], headers: Optional[Dict[Text, Text]] = None + ) -> None: + """Publishes `event` to Pika queues. + + Args: + event: Serialised event to be published. + headers: Message headers to append to the published message. The headers + can be retrieved in the consumer from the `headers` attribute of the + message's `BasicProperties`. + """ + # We need to save a reference to this background task to + # make sure it doesn't disappear. See: + # https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task + task: asyncio.Task = self._loop.create_task(self._publish(event, headers)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + async def _publish( + self, event: Dict[Text, Any], headers: Optional[Dict[Text, Text]] = None + ) -> None: + if self._exchange is None: + return + + reduced_event = rasa.shared.core.events.remove_parse_data(event) + + try: + await self._exchange.publish(self._message(event, headers), "") + + structlogger.debug( + "pika.events.publish", + event_info="Logging a reduced version of the Pika event", + rabbitmq_exchange=RABBITMQ_EXCHANGE, + host=self.host, + rasa_event=reduced_event, + ) + except Exception as e: + structlogger.error( + "pika.events.publish.failed", + event_info="Logging a reduced version of the failed Pika event", + host=self.host, + rasa_event=reduced_event, + ) + if self.should_keep_unpublished_messages: + self._unpublished_events.append(event) + + if self.raise_on_failure: + self.close() # type: ignore[unused-coroutine] + raise e + + def _message( + self, event: Dict[Text, Any], headers: Optional[Dict[Text, Text]] + ) -> aio_pika.Message: + body = json.dumps(event) + return aio_pika.Message( + bytes(body, DEFAULT_ENCODING), + headers=headers, + app_id=self.rasa_environment, + delivery_mode=aio_pika.DeliveryMode.PERSISTENT, + ) + + @rasa.shared.utils.common.lazy_property + def rasa_environment(self) -> Optional[Text]: + """Get value of the `RASA_ENVIRONMENT` environment variable.""" + return os.environ.get("RASA_ENVIRONMENT") + + +def _create_rabbitmq_ssl_options( + rabbitmq_host: Optional[Text] = None, +) -> Optional[Dict]: + """Create RabbitMQ SSL options. + + Requires the following environment variables to be set: + + RABBITMQ_SSL_CLIENT_CERTIFICATE - path to the SSL client certificate (required) + RABBITMQ_SSL_CLIENT_KEY - path to the SSL client key (required) + + Details on how to enable RabbitMQ TLS support can be found here: + https://www.rabbitmq.com/ssl.html#enabling-tls + + Args: + rabbitmq_host: RabbitMQ hostname. + + Returns: + Optional SSL arguments for the RabbitMQ connection. + """ + client_certificate_path = os.environ.get("RABBITMQ_SSL_CLIENT_CERTIFICATE") + client_key_path = os.environ.get("RABBITMQ_SSL_CLIENT_KEY") + + if os.environ.get("RABBITMQ_SSL_CA_FILE"): + rasa.shared.utils.io.raise_warning( + "Specifying 'RABBITMQ_SSL_CA_FILE' via " + "environment variables is no longer supported. Please specify this " + "through the RabbitMQ URL parameter 'cacertfile' as described here: " + "https://www.rabbitmq.com/uri-query-parameters.html " + ) + + if os.environ.get("RABBITMQ_SSL_KEY_PASSWORD"): + rasa.shared.utils.io.raise_warning( + "Specifying 'RABBITMQ_SSL_KEY_PASSWORD' via environment variables is no " + "longer supported. Please use an unencrypted key file." + ) + + if client_certificate_path and client_key_path: + logger.debug(f"Configuring SSL context for RabbitMQ host '{rabbitmq_host}'.") + return { + "certfile": client_certificate_path, + "client_key_path": client_key_path, + "cert_reqs": ssl.CERT_REQUIRED, + } + + return None diff --git a/rasa/core/brokers/sql.py b/rasa/core/brokers/sql.py new file mode 100644 index 0000000..0517682 --- /dev/null +++ b/rasa/core/brokers/sql.py @@ -0,0 +1,84 @@ +import contextlib +import json +import logging +from asyncio import AbstractEventLoop +from typing import Any, Dict, Optional, Text, Generator + +from sqlalchemy.orm import Session +from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta +from sqlalchemy import Column, Integer, String +from sqlalchemy import Text as SqlAlchemyText # to avoid name clash with typing.Text + +from rasa.core.brokers.broker import EventBroker +from rasa.utils.endpoints import EndpointConfig + +logger = logging.getLogger(__name__) + + +class SQLEventBroker(EventBroker): + """Save events into an SQL database. + + All events will be stored in a table called `events`. + + """ + + Base: DeclarativeMeta = declarative_base() + + class SQLBrokerEvent(Base): + """ORM which represents a row in the `events` table.""" + + __tablename__ = "events" + id = Column(Integer, primary_key=True) + sender_id = Column(String(255)) + data = Column(SqlAlchemyText) + + def __init__( + self, + dialect: Text = "sqlite", + host: Optional[Text] = None, + port: Optional[int] = None, + db: Text = "events.db", + username: Optional[Text] = None, + password: Optional[Text] = None, + ) -> None: + """Initializes `SQLBrokerEvent`.""" + from rasa.core.tracker_store import SQLTrackerStore + import sqlalchemy.orm + + engine_url = SQLTrackerStore.get_db_url( + dialect, host, port, db, username, password + ) + + logger.debug(f"SQLEventBroker: Connecting to database: '{engine_url}'.") + + self.engine = sqlalchemy.create_engine(engine_url) + self.Base.metadata.create_all(self.engine) + self.sessionmaker = sqlalchemy.orm.sessionmaker(bind=self.engine) + + @classmethod + async def from_endpoint_config( + cls, + broker_config: EndpointConfig, + event_loop: Optional[AbstractEventLoop] = None, + ) -> "SQLEventBroker": + """Creates broker. See the parent class for more information.""" + return cls(host=broker_config.url, **broker_config.kwargs) + + @contextlib.contextmanager + def session_scope(self) -> Generator[Session, None, None]: + """Provide a transactional scope around a series of operations.""" + session = self.sessionmaker() + try: + yield session + finally: + session.close() + + def publish(self, event: Dict[Text, Any]) -> None: + """Publishes a json-formatted Rasa Core event into an event queue.""" + with self.session_scope() as session: + session.add( + self.SQLBrokerEvent( + sender_id=event.get("sender_id"), data=json.dumps(event) + ) + ) + session.commit() diff --git a/rasa/core/channels/__init__.py b/rasa/core/channels/__init__.py new file mode 100644 index 0000000..98a91ed --- /dev/null +++ b/rasa/core/channels/__init__.py @@ -0,0 +1,49 @@ +from typing import Text, Dict, List, Type + +from rasa.core.channels.channel import ( # noqa: F401 + InputChannel, + OutputChannel, + UserMessage, + CollectingOutputChannel, +) + +# this prevents IDE's from optimizing the imports - we need to import the +# above first, otherwise we will run into import cycles +from rasa.core.channels.socketio import SocketIOInput +from rasa.core.channels.botframework import BotFrameworkInput +from rasa.core.channels.callback import CallbackInput +from rasa.core.channels.console import CmdlineInput +from rasa.core.channels.facebook import FacebookInput +from rasa.core.channels.mattermost import MattermostInput +from rasa.core.channels.rasa_chat import RasaChatInput +from rasa.core.channels.rest import RestInput +from rasa.core.channels.rocketchat import RocketChatInput +from rasa.core.channels.slack import SlackInput +from rasa.core.channels.telegram import TelegramInput +from rasa.core.channels.twilio import TwilioInput +from rasa.core.channels.twilio_voice import TwilioVoiceInput +from rasa.core.channels.webexteams import WebexTeamsInput +from rasa.core.channels.hangouts import HangoutsInput + +input_channel_classes: List[Type[InputChannel]] = [ + CmdlineInput, + FacebookInput, + SlackInput, + TelegramInput, + MattermostInput, + TwilioInput, + TwilioVoiceInput, + RasaChatInput, + BotFrameworkInput, + RocketChatInput, + CallbackInput, + RestInput, + SocketIOInput, + WebexTeamsInput, + HangoutsInput, +] + +# Mapping from an input channel name to its class to allow name based lookup. +BUILTIN_CHANNELS: Dict[Text, Type[InputChannel]] = { + c.name(): c for c in input_channel_classes +} diff --git a/rasa/core/channels/botframework.py b/rasa/core/channels/botframework.py new file mode 100644 index 0000000..e71010b --- /dev/null +++ b/rasa/core/channels/botframework.py @@ -0,0 +1,339 @@ +import datetime +import json +import logging +import re +from http import HTTPStatus +from typing import Text, Dict, Any, List, Iterable, Callable, Awaitable, Optional + +import jwt +import requests +from jwt import InvalidKeyError, PyJWTError +from jwt.algorithms import RSAAlgorithm +from requests import HTTPError +from sanic import Blueprint, response +from sanic.request import Request +from sanic.response import HTTPResponse + +from rasa.core.channels.channel import UserMessage, OutputChannel, InputChannel + +MICROSOFT_OPEN_ID_URI = ( + "https://login.botframework.com/v1/.well-known/openidconfiguration" +) + +BEARER_REGEX = re.compile(r"Bearer\s+(.*)") + +logger = logging.getLogger(__name__) + +MICROSOFT_OAUTH2_URL = "https://login.microsoftonline.com" + +MICROSOFT_OAUTH2_PATH = "botframework.com/oauth2/v2.0/token" + + +class BotFramework(OutputChannel): + """A Microsoft Bot Framework communication channel.""" + + token_expiration_date = datetime.datetime.now() + + headers = None + + @classmethod + def name(cls) -> Text: + return "botframework" + + def __init__( + self, + app_id: Text, + app_password: Text, + conversation: Dict[Text, Any], + bot: Text, + service_url: Text, + ) -> None: + + service_url = ( + f"{service_url}/" if not service_url.endswith("/") else service_url + ) + + self.app_id = app_id + self.app_password = app_password + self.conversation = conversation + self.global_uri = f"{service_url}v3/" + self.bot = bot + + async def _get_headers(self) -> Optional[Dict[Text, Any]]: + if BotFramework.token_expiration_date < datetime.datetime.now(): + uri = f"{MICROSOFT_OAUTH2_URL}/{MICROSOFT_OAUTH2_PATH}" + grant_type = "client_credentials" + scope = "https://api.botframework.com/.default" + payload = { + "client_id": self.app_id, + "client_secret": self.app_password, + "grant_type": grant_type, + "scope": scope, + } + + token_response = requests.post(uri, data=payload) + + if token_response.ok: + token_data = token_response.json() + access_token = token_data["access_token"] + token_expiration = token_data["expires_in"] + + delta = datetime.timedelta(seconds=int(token_expiration)) + BotFramework.token_expiration_date = datetime.datetime.now() + delta + + BotFramework.headers = { + "content-type": "application/json", + "Authorization": "Bearer %s" % access_token, + } + return BotFramework.headers + else: + logger.error("Could not get BotFramework token") + return None + else: + return BotFramework.headers + + def prepare_message( + self, recipient_id: Text, message_data: Dict[Text, Any] + ) -> Dict[Text, Any]: + data = { + "type": "message", + "recipient": {"id": recipient_id}, + "from": self.bot, + "channelData": {"notification": {"alert": "true"}}, + "text": "", + } + data.update(message_data) + return data + + async def send(self, message_data: Dict[Text, Any]) -> None: + post_message_uri = "{}conversations/{}/activities".format( + self.global_uri, self.conversation["id"] + ) + headers = await self._get_headers() + send_response = requests.post( + post_message_uri, headers=headers, data=json.dumps(message_data) + ) + + if not send_response.ok: + logger.error( + "Error trying to send botframework messge. Response: %s", + send_response.text, + ) + + async def send_text_message( + self, recipient_id: Text, text: Text, **kwargs: Any + ) -> None: + for message_part in text.strip().split("\n\n"): + text_message = {"text": message_part} + message = self.prepare_message(recipient_id, text_message) + await self.send(message) + + async def send_image_url( + self, recipient_id: Text, image: Text, **kwargs: Any + ) -> None: + hero_content = { + "contentType": "application/vnd.microsoft.card.hero", + "content": {"images": [{"url": image}]}, + } + + image_message = {"attachments": [hero_content]} + message = self.prepare_message(recipient_id, image_message) + await self.send(message) + + async def send_text_with_buttons( + self, + recipient_id: Text, + text: Text, + buttons: List[Dict[Text, Any]], + **kwargs: Any, + ) -> None: + hero_content = { + "contentType": "application/vnd.microsoft.card.hero", + "content": {"subtitle": text, "buttons": buttons}, + } + + buttons_message = {"attachments": [hero_content]} + message = self.prepare_message(recipient_id, buttons_message) + await self.send(message) + + async def send_elements( + self, recipient_id: Text, elements: Iterable[Dict[Text, Any]], **kwargs: Any + ) -> None: + for e in elements: + message = self.prepare_message(recipient_id, e) + await self.send(message) + + async def send_custom_json( + self, recipient_id: Text, json_message: Dict[Text, Any], **kwargs: Any + ) -> None: + json_message.setdefault("type", "message") + json_message.setdefault("recipient", {}).setdefault("id", recipient_id) + json_message.setdefault("from", self.bot) + json_message.setdefault("channelData", {}).setdefault( + "notification", {} + ).setdefault("alert", "true") + json_message.setdefault("text", "") + await self.send(json_message) + + +class BotFrameworkInput(InputChannel): + """Bot Framework input channel implementation.""" + + @classmethod + def name(cls) -> Text: + return "botframework" + + @classmethod + def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel: + if not credentials: + cls.raise_missing_credentials_exception() + + return cls(credentials.get("app_id"), credentials.get("app_password")) + + def __init__(self, app_id: Text, app_password: Text) -> None: + """Create a Bot Framework input channel. + + Args: + app_id: Bot Framework's API id + app_password: Bot Framework application secret + """ + self.app_id = app_id + self.app_password = app_password + + self.jwt_keys: Dict[Text, Any] = {} + self.jwt_update_time = datetime.datetime.fromtimestamp(0) + + self._update_cached_jwk_keys() + + def _update_cached_jwk_keys(self) -> None: + logger.debug("Updating JWT keys for the Botframework.") + response = requests.get(MICROSOFT_OPEN_ID_URI) + response.raise_for_status() + conf = response.json() + + jwks_uri = conf["jwks_uri"] + + keys_request = requests.get(jwks_uri) + keys_request.raise_for_status() + keys_list = keys_request.json() + self.jwt_keys = {key["kid"]: key for key in keys_list["keys"]} + self.jwt_update_time = datetime.datetime.now() + + def _validate_jwt_token(self, jwt_token: Text) -> None: + jwt_header = jwt.get_unverified_header(jwt_token) + key_id = jwt_header["kid"] + if key_id not in self.jwt_keys: + raise InvalidKeyError(f"JWT Key with ID {key_id} not found.") + + key_json = self.jwt_keys[key_id] + public_key = RSAAlgorithm.from_jwk(key_json) + jwt.decode( + jwt_token, + key=public_key, + audience=self.app_id, + algorithms=jwt_header["alg"], + ) + + def _validate_auth(self, auth_header: Optional[Text]) -> Optional[HTTPResponse]: + if not auth_header: + return response.text( + "No authorization header provided.", status=HTTPStatus.UNAUTHORIZED + ) + + # Update the JWT keys daily + if datetime.datetime.now() - self.jwt_update_time > datetime.timedelta(days=1): + try: + self._update_cached_jwk_keys() + except HTTPError as error: + logger.warning( + f"Could not update JWT keys from {MICROSOFT_OPEN_ID_URI}." + ) + logger.exception(error, exc_info=True) + + auth_match = BEARER_REGEX.match(auth_header) + if not auth_match: + return response.text( + "No Bearer token provided in Authorization header.", + status=HTTPStatus.UNAUTHORIZED, + ) + + (jwt_token,) = auth_match.groups() + + try: + self._validate_jwt_token(jwt_token) + except PyJWTError as error: + logger.error("Bot framework JWT token could not be verified.") + logger.exception(error, exc_info=True) + return response.text( + "Could not validate JWT token.", status=HTTPStatus.UNAUTHORIZED + ) + + return None + + @staticmethod + def add_attachments_to_metadata( + postdata: Dict[Text, Any], metadata: Optional[Dict[Text, Any]] + ) -> Optional[Dict[Text, Any]]: + """Merge the values of `postdata['attachments']` with `metadata`.""" + if postdata.get("attachments"): + attachments = {"attachments": postdata["attachments"]} + if metadata: + metadata.update(attachments) + else: + metadata = attachments + return metadata + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[Any]] + ) -> Blueprint: + """Defines the Sanic blueprint for the bot framework integration.""" + botframework_webhook = Blueprint("botframework_webhook", __name__) + + @botframework_webhook.route("/", methods=["GET"]) + async def health(request: Request) -> HTTPResponse: + return response.json({"status": "ok"}) + + @botframework_webhook.route("/webhook", methods=["POST"]) + async def webhook(request: Request) -> HTTPResponse: + validation_response = self._validate_auth( + request.headers.get("Authorization") + ) + if validation_response: + return validation_response + + postdata = request.json + metadata = self.get_metadata(request) + + metadata_with_attachments = self.add_attachments_to_metadata( + postdata, metadata + ) + + try: + if postdata["type"] == "message": + out_channel = BotFramework( + self.app_id, + self.app_password, + postdata["conversation"], + postdata["recipient"], + postdata["serviceUrl"], + ) + + user_msg = UserMessage( + text=postdata.get("text", ""), + output_channel=out_channel, + sender_id=postdata["from"]["id"], + input_channel=self.name(), + metadata=metadata_with_attachments, + ) + + await on_new_message(user_msg) + else: + logger.info("Not received message type") + except Exception as e: + logger.error(f"Exception when trying to handle message.{e}") + logger.debug(e, exc_info=True) + pass + + return response.text("success") + + return botframework_webhook diff --git a/rasa/core/channels/callback.py b/rasa/core/channels/callback.py new file mode 100644 index 0000000..de33772 --- /dev/null +++ b/rasa/core/channels/callback.py @@ -0,0 +1,84 @@ +import logging +from typing import Text, Dict, Optional, Callable, Awaitable, Any + +from sanic import Blueprint, response +from sanic.request import Request + +from rasa.core.channels.channel import ( + CollectingOutputChannel, + UserMessage, + InputChannel, +) +from rasa.core.channels.rest import RestInput +from rasa.utils.endpoints import EndpointConfig, ClientResponseError +from sanic.response import HTTPResponse + +logger = logging.getLogger(__name__) + + +class CallbackOutput(CollectingOutputChannel): + @classmethod + def name(cls) -> Text: + return "callback" + + def __init__(self, endpoint: EndpointConfig) -> None: + + self.callback_endpoint = endpoint + super().__init__() + + async def _persist_message(self, message: Dict[Text, Any]) -> None: + await super()._persist_message(message) + + try: + await self.callback_endpoint.request( + "post", content_type="application/json", json=message + ) + except ClientResponseError as e: + logger.error( + "Failed to send output message to callback. " + "Status: {} Response: {}" + "".format(e.status, e.text) + ) + + +class CallbackInput(RestInput): + """A custom REST http input channel that responds using a callback server. + + Incoming messages are received through a REST interface. Responses + are sent asynchronously by calling a configured external REST endpoint.""" + + @classmethod + def name(cls) -> Text: + return "callback" + + @classmethod + def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel: + return cls(EndpointConfig.from_dict(credentials)) + + def __init__(self, endpoint: EndpointConfig) -> None: + self.callback_endpoint = endpoint + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[Any]] + ) -> Blueprint: + callback_webhook = Blueprint("callback_webhook", __name__) + + @callback_webhook.route("/", methods=["GET"]) + async def health(_: Request) -> HTTPResponse: + return response.json({"status": "ok"}) + + @callback_webhook.route("/webhook", methods=["POST"]) + async def webhook(request: Request) -> HTTPResponse: + sender_id = await self._extract_sender(request) + text = self._extract_message(request) + + collector = self.get_output_channel() + await on_new_message( + UserMessage(text, collector, sender_id, input_channel=self.name()) + ) + return response.text("success") + + return callback_webhook + + def get_output_channel(self) -> CollectingOutputChannel: + return CallbackOutput(self.callback_endpoint) diff --git a/rasa/core/channels/channel.py b/rasa/core/channels/channel.py new file mode 100644 index 0000000..2efd680 --- /dev/null +++ b/rasa/core/channels/channel.py @@ -0,0 +1,407 @@ +import json +import logging +import uuid +import jwt +from sanic import Sanic, Blueprint +from sanic.request import Request +from typing import ( + Text, + List, + Dict, + Any, + Optional, + Callable, + Iterable, + Awaitable, + NoReturn, +) + +from rasa.cli import utils as cli_utils +from rasa.shared.constants import DOCS_BASE_URL, DEFAULT_SENDER_ID +from rasa.core.constants import BEARER_TOKEN_PREFIX +from rasa.shared.exceptions import RasaException + +try: + from urlparse import urljoin +except ImportError: + from urllib.parse import urljoin + +logger = logging.getLogger(__name__) + + +class UserMessage: + """Represents an incoming message. + + Includes the channel the responses should be sent to.""" + + def __init__( + self, + text: Optional[Text] = None, + output_channel: Optional["OutputChannel"] = None, + sender_id: Optional[Text] = None, + parse_data: Dict[Text, Any] = None, + input_channel: Optional[Text] = None, + message_id: Optional[Text] = None, + metadata: Optional[Dict] = None, + ) -> None: + """Creates a ``UserMessage`` object. + + Args: + text: the message text content. + output_channel: the output channel which should be used to send + bot responses back to the user. + sender_id: the message owner ID. + parse_data: rasa data about the message. + input_channel: the name of the channel which received this message. + message_id: ID of the message. + metadata: additional metadata for this message. + + """ + self.text = text.strip() if text else text + + if message_id is not None: + self.message_id = str(message_id) + else: + self.message_id = uuid.uuid4().hex + + if output_channel is not None: + self.output_channel = output_channel + else: + self.output_channel = CollectingOutputChannel() + + if sender_id is not None: + self.sender_id = str(sender_id) + else: + self.sender_id = DEFAULT_SENDER_ID + + self.input_channel = input_channel + + self.parse_data = parse_data + self.metadata = metadata + + +def register( + input_channels: List["InputChannel"], app: Sanic, route: Optional[Text] +) -> None: + """Registers input channel blueprints with Sanic.""" + + async def handler(message: UserMessage) -> None: + await app.ctx.agent.handle_message(message) + + for channel in input_channels: + if route: + p = urljoin(route, channel.url_prefix()) + else: + p = None + app.blueprint(channel.blueprint(handler), url_prefix=p) + + app.ctx.input_channels = input_channels + + +class InputChannel: + """Input channel base class.""" + + @classmethod + def name(cls) -> Text: + """Every input channel needs a name to identify it.""" + return cls.__name__ + + @classmethod + def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> "InputChannel": + return cls() + + def url_prefix(self) -> Text: + return self.name() + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[Any]] + ) -> Blueprint: + """Defines a Sanic blueprint. + + The blueprint will be attached to a running sanic server and handle + incoming routes it registered for.""" + raise NotImplementedError("Component listener needs to provide blueprint.") + + @classmethod + def raise_missing_credentials_exception(cls) -> NoReturn: + raise RasaException( + f"To use the {cls.name()} input channel, you need to " + f"pass a credentials file using '--credentials'. " + f"The argument should be a file path pointing to " + f"a yml file containing the {cls.name()} authentication " + f"information. Details in the docs: " + f"{DOCS_BASE_URL}/messaging-and-voice-channels/" + ) + + def get_output_channel(self) -> Optional["OutputChannel"]: + """Create ``OutputChannel`` based on information provided by the input channel. + + Implementing this function is not required. If this function returns a valid + ``OutputChannel`` this can be used by Rasa to send bot responses to the user + without the user initiating an interaction. + + Returns: + ``OutputChannel`` instance or ``None`` in case creating an output channel + only based on the information present in the ``InputChannel`` is not + possible. + """ + pass + + def get_metadata(self, request: Request) -> Optional[Dict[Text, Any]]: + """Extracts additional information from the incoming request. + + Implementing this function is not required. However, it can be used to extract + metadata from the request. The return value is passed on to the + ``UserMessage`` object and stored in the conversation tracker. + + Args: + request: incoming request with the message of the user + + Returns: + Metadata which was extracted from the request. + """ + pass + + +def decode_jwt(bearer_token: Text, jwt_key: Text, jwt_algorithm: Text) -> Dict: + """Decodes a Bearer Token using the specific JWT key and algorithm. + + Args: + bearer_token: Encoded Bearer token + jwt_key: Public JWT key for decoding the Bearer token + jwt_algorithm: JWT algorithm used for decoding the Bearer token + + Returns: + `Dict` containing the decoded payload if successful or an exception + if unsuccessful + """ + authorization_header_value = bearer_token.replace(BEARER_TOKEN_PREFIX, "") + return jwt.decode(authorization_header_value, jwt_key, algorithms=jwt_algorithm) + + +def decode_bearer_token( + bearer_token: Text, jwt_key: Text, jwt_algorithm: Text +) -> Optional[Dict]: + """Decodes a Bearer Token using the specific JWT key and algorithm. + + Args: + bearer_token: Encoded Bearer token + jwt_key: Public JWT key for decoding the Bearer token + jwt_algorithm: JWT algorithm used for decoding the Bearer token + + Returns: + `Dict` containing the decoded payload if successful or `None` if unsuccessful + """ + # noinspection PyBroadException + try: + return decode_jwt(bearer_token, jwt_key, jwt_algorithm) + except jwt.exceptions.InvalidSignatureError: + logger.error("JWT public key invalid.") + except Exception: + logger.exception("Failed to decode bearer token.") + + return None + + +class OutputChannel: + """Output channel base class. + + Provides sane implementation of the send methods + for text only output channels. + """ + + @classmethod + def name(cls) -> Text: + """Every output channel needs a name to identify it.""" + return cls.__name__ + + async def send_response(self, recipient_id: Text, message: Dict[Text, Any]) -> None: + """Send a message to the client.""" + + if message.get("quick_replies"): + await self.send_quick_replies( + recipient_id, + message.pop("text"), + message.pop("quick_replies"), + **message, + ) + elif message.get("buttons"): + await self.send_text_with_buttons( + recipient_id, message.pop("text"), message.pop("buttons"), **message + ) + elif message.get("text"): + await self.send_text_message(recipient_id, message.pop("text"), **message) + + if message.get("custom"): + await self.send_custom_json(recipient_id, message.pop("custom"), **message) + + # if there is an image we handle it separately as an attachment + if message.get("image"): + await self.send_image_url(recipient_id, message.pop("image"), **message) + + if message.get("attachment"): + await self.send_attachment( + recipient_id, message.pop("attachment"), **message + ) + + if message.get("elements"): + await self.send_elements(recipient_id, message.pop("elements"), **message) + + async def send_text_message( + self, recipient_id: Text, text: Text, **kwargs: Any + ) -> None: + """Send a message through this channel.""" + + raise NotImplementedError( + "Output channel needs to implement a send message for simple texts." + ) + + async def send_image_url( + self, recipient_id: Text, image: Text, **kwargs: Any + ) -> None: + """Sends an image. Default will just post the url as a string.""" + + await self.send_text_message(recipient_id, f"Image: {image}") + + async def send_attachment( + self, recipient_id: Text, attachment: Text, **kwargs: Any + ) -> None: + """Sends an attachment. Default will just post as a string.""" + + await self.send_text_message(recipient_id, f"Attachment: {attachment}") + + async def send_text_with_buttons( + self, + recipient_id: Text, + text: Text, + buttons: List[Dict[Text, Any]], + **kwargs: Any, + ) -> None: + """Sends buttons to the output. + + Default implementation will just post the buttons as a string.""" + + await self.send_text_message(recipient_id, text) + for idx, button in enumerate(buttons): + button_msg = cli_utils.button_to_string(button, idx) + await self.send_text_message(recipient_id, button_msg) + + async def send_quick_replies( + self, + recipient_id: Text, + text: Text, + quick_replies: List[Dict[Text, Any]], + **kwargs: Any, + ) -> None: + """Sends quick replies to the output. + + Default implementation will just send as buttons.""" + + await self.send_text_with_buttons(recipient_id, text, quick_replies) + + async def send_elements( + self, recipient_id: Text, elements: Iterable[Dict[Text, Any]], **kwargs: Any + ) -> None: + """Sends elements to the output. + + Default implementation will just post the elements as a string.""" + + for element in elements: + element_msg = "{title} : {subtitle}".format( + title=element.get("title", ""), subtitle=element.get("subtitle", "") + ) + await self.send_text_with_buttons( + recipient_id, element_msg, element.get("buttons", []) + ) + + async def send_custom_json( + self, recipient_id: Text, json_message: Dict[Text, Any], **kwargs: Any + ) -> None: + """Sends json dict to the output channel. + + Default implementation will just post the json contents as a string.""" + + await self.send_text_message(recipient_id, json.dumps(json_message)) + + +class CollectingOutputChannel(OutputChannel): + """Output channel that collects send messages in a list + + (doesn't send them anywhere, just collects them).""" + + def __init__(self) -> None: + """Initialise list to collect messages.""" + self.messages: List[Dict[Text, Any]] = [] + + @classmethod + def name(cls) -> Text: + """Name of the channel.""" + return "collector" + + @staticmethod + def _message( + recipient_id: Text, + text: Text = None, + image: Text = None, + buttons: List[Dict[Text, Any]] = None, + attachment: Text = None, + custom: Dict[Text, Any] = None, + ) -> Dict: + """Create a message object that will be stored.""" + + obj = { + "recipient_id": recipient_id, + "text": text, + "image": image, + "buttons": buttons, + "attachment": attachment, + "custom": custom, + } + + # filter out any values that are `None` + return {k: v for k, v in obj.items() if v is not None} + + def latest_output(self) -> Optional[Dict[Text, Any]]: + if self.messages: + return self.messages[-1] + else: + return None + + async def _persist_message(self, message: Dict[Text, Any]) -> None: + self.messages.append(message) + + async def send_text_message( + self, recipient_id: Text, text: Text, **kwargs: Any + ) -> None: + for message_part in text.strip().split("\n\n"): + await self._persist_message(self._message(recipient_id, text=message_part)) + + async def send_image_url( + self, recipient_id: Text, image: Text, **kwargs: Any + ) -> None: + """Sends an image. Default will just post the url as a string.""" + + await self._persist_message(self._message(recipient_id, image=image)) + + async def send_attachment( + self, recipient_id: Text, attachment: Text, **kwargs: Any + ) -> None: + """Sends an attachment. Default will just post as a string.""" + + await self._persist_message(self._message(recipient_id, attachment=attachment)) + + async def send_text_with_buttons( + self, + recipient_id: Text, + text: Text, + buttons: List[Dict[Text, Any]], + **kwargs: Any, + ) -> None: + await self._persist_message( + self._message(recipient_id, text=text, buttons=buttons) + ) + + async def send_custom_json( + self, recipient_id: Text, json_message: Dict[Text, Any], **kwargs: Any + ) -> None: + await self._persist_message(self._message(recipient_id, custom=json_message)) diff --git a/rasa/core/channels/console.py b/rasa/core/channels/console.py new file mode 100644 index 0000000..5d3b8bd --- /dev/null +++ b/rasa/core/channels/console.py @@ -0,0 +1,243 @@ +# this builtin is needed so we can overwrite in test +import asyncio +import json +import logging +import os + +from typing import ( + Any, + AsyncGenerator, + Dict, + List, + Optional, + Text, + overload, +) + +import aiohttp +import questionary +from aiohttp import ClientTimeout +from prompt_toolkit.styles import Style + +import rasa.shared.utils.cli +import rasa.shared.utils.io +from rasa.cli import utils as cli_utils +from rasa.core import utils +from rasa.core.channels.rest import RestInput +from rasa.core.constants import DEFAULT_SERVER_URL, DEFAULT_STREAM_READING_TIMEOUT +from rasa.shared.constants import INTENT_MESSAGE_PREFIX +from rasa.shared.utils.io import DEFAULT_ENCODING + +logger = logging.getLogger(__name__) + +STREAM_READING_TIMEOUT_ENV = "RASA_SHELL_STREAM_READING_TIMEOUT_IN_SECONDS" + + +def print_buttons( + message: Dict[Text, Any], + is_latest_message: bool = False, + color: Text = rasa.shared.utils.io.bcolors.OKBLUE, +) -> Optional[questionary.Question]: + """Create CLI buttons from message data.""" + if is_latest_message: + choices = cli_utils.button_choices_from_message_data( + message, allow_free_text_input=True + ) + question = questionary.select( + message.get("text"), + choices, + style=Style([("qmark", "#6d91d3"), ("", "#6d91d3"), ("answer", "#b373d6")]), + ) + return question + else: + rasa.shared.utils.cli.print_color("Buttons:", color=color) + for idx, button in enumerate(message.get("buttons")): + rasa.shared.utils.cli.print_color( + cli_utils.button_to_string(button, idx), color=color + ) + return None + + +def _print_bot_output( + message: Dict[Text, Any], + is_latest_message: bool = False, + color: Text = rasa.shared.utils.io.bcolors.OKBLUE, +) -> Optional[questionary.Question]: + if "buttons" in message: + question = print_buttons(message, is_latest_message, color) + if question: + return question + + if "text" in message: + rasa.shared.utils.cli.print_color(message["text"], color=color) + + if "image" in message: + rasa.shared.utils.cli.print_color("Image: " + message["image"], color=color) + + if "attachment" in message: + rasa.shared.utils.cli.print_color( + "Attachment: " + message["attachment"], color=color + ) + + if "elements" in message: + rasa.shared.utils.cli.print_color("Elements:", color=color) + for idx, element in enumerate(message["elements"]): + rasa.shared.utils.cli.print_color( + cli_utils.element_to_string(element, idx), color=color + ) + + if "quick_replies" in message: + rasa.shared.utils.cli.print_color("Quick Replies:", color=color) + for idx, element in enumerate(message["quick_replies"]): + rasa.shared.utils.cli.print_color( + cli_utils.button_to_string(element, idx), color=color + ) + + if "custom" in message: + rasa.shared.utils.cli.print_color("Custom json:", color=color) + rasa.shared.utils.cli.print_color( + rasa.shared.utils.io.json_to_string(message["custom"]), color=color + ) + + return None + + +@overload +async def _get_user_input(previous_response: None) -> Text: + ... + + +@overload +async def _get_user_input(previous_response: Dict[str, Any]) -> Optional[Text]: + ... + + +async def _get_user_input( + previous_response: Optional[Dict[str, Any]] +) -> Optional[Text]: + button_response = None + if previous_response is not None: + button_response = _print_bot_output(previous_response, is_latest_message=True) + + if button_response is not None: + response = await cli_utils.payload_from_button_question(button_response) + if response == cli_utils.FREE_TEXT_INPUT_PROMPT: + # Re-prompt user with a free text input + response = await _get_user_input(None) + else: + question = questionary.text( + "", + qmark="Your input ->", + style=Style([("qmark", "#b373d6"), ("", "#b373d6")]), + ) + response = await question.ask_async() + return response.strip() if response is not None else None + + +async def send_message_receive_block( + server_url: Text, auth_token: Text, sender_id: Text, message: Text +) -> List[Dict[Text, Any]]: + """Posts message and returns response.""" + payload = {"sender": sender_id, "message": message} + + url = f"{server_url}/webhooks/rest/webhook?token={auth_token}" + async with aiohttp.ClientSession() as session: + async with session.post(url, json=payload, raise_for_status=True) as resp: + return await resp.json() + + +async def _send_message_receive_stream( + server_url: Text, + auth_token: Text, + sender_id: Text, + message: Text, + request_timeout: Optional[int] = None, +) -> AsyncGenerator[Dict[Text, Any], None]: + payload = {"sender": sender_id, "message": message} + + url = f"{server_url}/webhooks/rest/webhook?stream=true&token={auth_token}" + + # Define timeout to not keep reading in case the server crashed in between + timeout = _get_stream_reading_timeout(request_timeout) + + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=payload, raise_for_status=True) as resp: + + async for line in resp.content: + if line: + yield json.loads(line.decode(DEFAULT_ENCODING)) + + +def _get_stream_reading_timeout(request_timeout: Optional[int] = None) -> ClientTimeout: + """Define the ClientTimeout with fallbacks. + + First use the `request_timeout` function parameter if available, this comes from the + `--request-timeout` command line argument. + If that fails fallback `STREAM_READING_TIMEOUT_ENV`, a commandline argument. + Lastly fallback to `DEFAULT_STREAM_READING_TIMEOUT` from rasa.core.constants + """ + timeout_str = ( + request_timeout + if request_timeout is not None + else os.environ.get(STREAM_READING_TIMEOUT_ENV, DEFAULT_STREAM_READING_TIMEOUT) + ) + + return ClientTimeout(int(timeout_str)) + + +async def record_messages( + sender_id: Text, + server_url: Text = DEFAULT_SERVER_URL, + auth_token: Text = "", + max_message_limit: Optional[int] = None, + use_response_stream: bool = True, + request_timeout: Optional[int] = None, +) -> int: + """Read messages from the command line and print bot responses.""" + exit_text = INTENT_MESSAGE_PREFIX + "stop" + + rasa.shared.utils.cli.print_success( + "Bot loaded. Type a message and press enter " + "(use '{}' to exit): ".format(exit_text) + ) + + num_messages = 0 + previous_response = None + await asyncio.sleep(0.5) # Wait for server to start + while not utils.is_limit_reached(num_messages, max_message_limit): + text = await _get_user_input(previous_response) + + if text == exit_text or text is None: + break + + if use_response_stream: + bot_responses_stream = _send_message_receive_stream( + server_url, auth_token, sender_id, text, request_timeout=request_timeout + ) + previous_response = None + async for response in bot_responses_stream: + if previous_response is not None: + _print_bot_output(previous_response) + previous_response = response + else: + bot_responses = await send_message_receive_block( + server_url, auth_token, sender_id, text + ) + previous_response = None + for response in bot_responses: + if previous_response is not None: + _print_bot_output(previous_response) + previous_response = response + + num_messages += 1 + await asyncio.sleep(0) # Yield event loop for others coroutines + return num_messages + + +class CmdlineInput(RestInput): + @classmethod + def name(cls) -> Text: + return "cmdline" + + def url_prefix(self) -> Text: + return RestInput.name() diff --git a/rasa/core/channels/facebook.py b/rasa/core/channels/facebook.py new file mode 100644 index 0000000..2c7c5e5 --- /dev/null +++ b/rasa/core/channels/facebook.py @@ -0,0 +1,422 @@ +import copy +import hashlib +import hmac +import logging +import structlog +from fbmessenger import MessengerClient +from fbmessenger.attachments import Image +from fbmessenger.elements import Text as FBText +from fbmessenger.quick_replies import QuickReplies, QuickReply +from fbmessenger.sender_actions import SenderAction + +import rasa.shared.utils.io +from sanic import Blueprint, response +from sanic.request import Request +from typing import Text, List, Dict, Any, Callable, Awaitable, Iterable, Optional, Union + +from rasa.core.channels.channel import UserMessage, OutputChannel, InputChannel +from sanic.response import HTTPResponse + +logger = logging.getLogger(__name__) +structlogger = structlog.get_logger() + + +class Messenger: + """Implement a fbmessenger to parse incoming webhooks and send msgs.""" + + @classmethod + def name(cls) -> Text: + return "facebook" + + def __init__( + self, + page_access_token: Text, + on_new_message: Callable[[UserMessage], Awaitable[Any]], + ) -> None: + + self.on_new_message = on_new_message + self.client = MessengerClient(page_access_token) + self.last_message: Dict[Text, Any] = {} + + def get_user_id(self) -> Text: + return self.last_message.get("sender", {}).get("id", "") + + @staticmethod + def _is_audio_message(message: Dict[Text, Any]) -> bool: + """Check if the users message is a recorded voice message.""" + return ( + "message" in message + and "attachments" in message["message"] + and message["message"]["attachments"][0]["type"] == "audio" + ) + + @staticmethod + def _is_image_message(message: Dict[Text, Any]) -> bool: + """Check if the users message is an image.""" + return ( + "message" in message + and "attachments" in message["message"] + and message["message"]["attachments"][0]["type"] == "image" + ) + + @staticmethod + def _is_video_message(message: Dict[Text, Any]) -> bool: + """Check if the users message is a video.""" + return ( + "message" in message + and "attachments" in message["message"] + and message["message"]["attachments"][0]["type"] == "video" + ) + + @staticmethod + def _is_file_message(message: Dict[Text, Any]) -> bool: + """Check if the users message is a file.""" + return ( + "message" in message + and "attachments" in message["message"] + and message["message"]["attachments"][0]["type"] == "file" + ) + + @staticmethod + def _is_user_message(message: Dict[Text, Any]) -> bool: + """Check if the message is a message from the user.""" + return ( + "message" in message + and "text" in message["message"] + and not message["message"].get("is_echo") + ) + + @staticmethod + def _is_quick_reply_message(message: Dict[Text, Any]) -> bool: + """Check if the message is a quick reply message.""" + return ( + message.get("message") is not None + and message["message"].get("quick_reply") is not None + and message["message"]["quick_reply"].get("payload") + ) + + async def handle(self, payload: Dict, metadata: Optional[Dict[Text, Any]]) -> None: + for entry in payload["entry"]: + for message in entry["messaging"]: + self.last_message = message + if message.get("message"): + return await self.message(message, metadata) + elif message.get("postback"): + return await self.postback(message, metadata) + + async def message( + self, message: Dict[Text, Any], metadata: Optional[Dict[Text, Any]] + ) -> None: + """Handle an incoming event from the fb webhook.""" + # quick reply and user message both share 'text' attribute + # so quick reply should be checked first + if self._is_quick_reply_message(message): + text = message["message"]["quick_reply"]["payload"] + elif self._is_user_message(message): + text = message["message"]["text"] + elif self._is_audio_message(message): + attachment = message["message"]["attachments"][0] + text = attachment["payload"]["url"] + elif self._is_image_message(message): + attachment = message["message"]["attachments"][0] + text = attachment["payload"]["url"] + elif self._is_video_message(message): + attachment = message["message"]["attachments"][0] + text = attachment["payload"]["url"] + elif self._is_file_message(message): + attachment = message["message"]["attachments"][0] + text = attachment["payload"]["url"] + else: + structlogger.warning( + "facebook.message.cannot.handle", message=copy.deepcopy(message) + ) + return + + await self._handle_user_message(text, self.get_user_id(), metadata) + + async def postback( + self, message: Dict[Text, Any], metadata: Optional[Dict[Text, Any]] + ) -> None: + """Handle a postback (e.g. quick reply button).""" + text = message["postback"]["payload"] + await self._handle_user_message(text, self.get_user_id(), metadata) + + async def _handle_user_message( + self, text: Text, sender_id: Text, metadata: Optional[Dict[Text, Any]] + ) -> None: + """Pass on the text to the dialogue engine for processing.""" + out_channel = MessengerBot(self.client) + await out_channel.send_action(sender_id, sender_action="mark_seen") + + user_msg = UserMessage( + text, out_channel, sender_id, input_channel=self.name(), metadata=metadata + ) + await out_channel.send_action(sender_id, sender_action="typing_on") + # noinspection PyBroadException + try: + await self.on_new_message(user_msg) + except Exception: + logger.exception( + "Exception when trying to handle webhook for facebook message." + ) + pass + finally: + await out_channel.send_action(sender_id, sender_action="typing_off") + + +class MessengerBot(OutputChannel): + """A bot that uses fb-messenger to communicate.""" + + @classmethod + def name(cls) -> Text: + return "facebook" + + def __init__(self, messenger_client: MessengerClient) -> None: + + self.messenger_client = messenger_client + super().__init__() + + def send(self, recipient_id: Text, element: Any) -> None: + """Sends a message to the recipient using the messenger client.""" + # this is a bit hacky, but the client doesn't have a proper API to + # send messages but instead expects the incoming sender to be present + # which we don't have as it is stored in the input channel. + self.messenger_client.send(element.to_dict(), recipient_id, "RESPONSE") + + async def send_text_message( + self, recipient_id: Text, text: Text, **kwargs: Any + ) -> None: + """Send a message through this channel.""" + for message_part in text.strip().split("\n\n"): + self.send(recipient_id, FBText(text=message_part)) + + async def send_image_url( + self, recipient_id: Text, image: Text, **kwargs: Any + ) -> None: + """Sends an image. Default will just post the url as a string.""" + self.send(recipient_id, Image(url=image)) + + async def send_action(self, recipient_id: Text, sender_action: Text) -> None: + """Sends a sender action to facebook (e.g. "typing_on"). + + Args: + recipient_id: recipient + sender_action: action to send, e.g. "typing_on" or "mark_seen" + """ + self.messenger_client.send_action( + SenderAction(sender_action).to_dict(), recipient_id + ) + + async def send_text_with_buttons( + self, + recipient_id: Text, + text: Text, + buttons: List[Dict[Text, Any]], + **kwargs: Any, + ) -> None: + """Sends buttons to the output.""" + # buttons is a list of tuples: [(option_name,payload)] + if len(buttons) > 3: + rasa.shared.utils.io.raise_warning( + "Facebook API currently allows only up to 3 buttons. " + "If you add more, all will be ignored." + ) + await self.send_text_message(recipient_id, text, **kwargs) + else: + self._add_postback_info(buttons) + + # Currently there is no predefined way to create a message with + # buttons in the fbmessenger framework - so we need to create the + # payload on our own + payload = { + "attachment": { + "type": "template", + "payload": { + "template_type": "button", + "text": text, + "buttons": buttons, + }, + } + } + self.messenger_client.send(payload, recipient_id, "RESPONSE") + + async def send_quick_replies( + self, + recipient_id: Text, + text: Text, + quick_replies: List[Dict[Text, Any]], + **kwargs: Any, + ) -> None: + """Sends quick replies to the output.""" + quick_replies = self._convert_to_quick_reply(quick_replies) + self.send(recipient_id, FBText(text=text, quick_replies=quick_replies)) + + async def send_elements( + self, recipient_id: Text, elements: Iterable[Dict[Text, Any]], **kwargs: Any + ) -> None: + """Sends elements to the output.""" + for element in elements: + if "buttons" in element: + self._add_postback_info(element["buttons"]) + + payload = { + "attachment": { + "type": "template", + "payload": {"template_type": "generic", "elements": elements}, + } + } + self.messenger_client.send(payload, recipient_id, "RESPONSE") + + async def send_custom_json( + self, + recipient_id: Text, + json_message: Union[List, Dict[Text, Any]], + **kwargs: Any, + ) -> None: + """Sends custom json data to the output.""" + if isinstance(json_message, dict) and "sender" in json_message.keys(): + recipient_id = json_message.pop("sender", {}).pop("id", recipient_id) + elif isinstance(json_message, list): + for message in json_message: + if "sender" in message.keys(): + recipient_id = message.pop("sender", {}).pop("id", recipient_id) + break + + self.messenger_client.send(json_message, recipient_id, "RESPONSE") + + @staticmethod + def _add_postback_info(buttons: List[Dict[Text, Any]]) -> None: + """Make sure every button has a type. Modifications happen in place.""" + for button in buttons: + if "type" not in button: + button["type"] = "postback" + + @staticmethod + def _convert_to_quick_reply(quick_replies: List[Dict[Text, Any]]) -> QuickReplies: + """Convert quick reply dictionary to FB QuickReplies object.""" + fb_quick_replies = [] + for quick_reply in quick_replies: + try: + fb_quick_replies.append( + QuickReply( + title=quick_reply["title"], + payload=quick_reply["payload"], + content_type=quick_reply.get("content_type"), + ) + ) + except KeyError as e: + raise ValueError( + 'Facebook quick replies must define a "{}" field.'.format(e.args[0]) + ) + + return QuickReplies(quick_replies=fb_quick_replies) + + +class FacebookInput(InputChannel): + """Facebook input channel implementation. Based on the HTTPInputChannel.""" + + @classmethod + def name(cls) -> Text: + return "facebook" + + @classmethod + def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel: + if not credentials: + cls.raise_missing_credentials_exception() + + return cls( + credentials.get("verify"), + credentials.get("secret"), + credentials.get("page-access-token"), + ) + + def __init__(self, fb_verify: Text, fb_secret: Text, fb_access_token: Text) -> None: + """Create a facebook input channel. + + Needs a couple of settings to properly authenticate and validate + messages. Details to setup: + + https://github.com/rehabstudio/fbmessenger#facebook-app-setup + + Args: + fb_verify: FB Verification string + (can be chosen by yourself on webhook creation) + fb_secret: facebook application secret + fb_access_token: access token to post in the name of the FB page + """ + self.fb_verify = fb_verify + self.fb_secret = fb_secret + self.fb_access_token = fb_access_token + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[Any]] + ) -> Blueprint: + + fb_webhook = Blueprint("fb_webhook", __name__) + + # noinspection PyUnusedLocal + @fb_webhook.route("/", methods=["GET"]) + async def health(request: Request) -> HTTPResponse: + return response.json({"status": "ok"}) + + @fb_webhook.route("/webhook", methods=["GET"]) + async def token_verification(request: Request) -> HTTPResponse: + if request.args.get("hub.verify_token") == self.fb_verify: + return response.text(request.args.get("hub.challenge")) + else: + logger.warning( + "Invalid fb verify token! Make sure this matches " + "your webhook settings on the facebook app." + ) + return response.text("failure, invalid token") + + @fb_webhook.route("/webhook", methods=["POST"]) + async def webhook(request: Request) -> HTTPResponse: + signature = request.headers.get("X-Hub-Signature") or "" + if not self.validate_hub_signature(self.fb_secret, request.body, signature): + logger.warning( + "Wrong fb secret! Make sure this matches the " + "secret in your facebook app settings" + ) + return response.text("not validated") + + messenger = Messenger(self.fb_access_token, on_new_message) + + metadata = self.get_metadata(request) + await messenger.handle(request.json, metadata) + return response.text("success") + + return fb_webhook + + @staticmethod + def validate_hub_signature( + app_secret: Text, request_payload: bytes, hub_signature_header: Text + ) -> bool: + """Make sure the incoming webhook requests are properly signed. + + Args: + app_secret: Secret Key for application + request_payload: request body + hub_signature_header: X-Hub-Signature header sent with request + + Returns: + bool: indicated that hub signature is validated + """ + # noinspection PyBroadException + try: + hash_method, hub_signature = hub_signature_header.split("=") + except Exception: + pass + else: + digest_module = getattr(hashlib, hash_method) + hmac_object = hmac.new( + bytearray(app_secret, "utf8"), request_payload, digest_module + ) + generated_hash = hmac_object.hexdigest() + if hub_signature == generated_hash: + return True + return False + + def get_output_channel(self) -> OutputChannel: + client = MessengerClient(self.fb_access_token) + return MessengerBot(client) diff --git a/rasa/core/channels/hangouts.py b/rasa/core/channels/hangouts.py new file mode 100644 index 0000000..ef62a93 --- /dev/null +++ b/rasa/core/channels/hangouts.py @@ -0,0 +1,335 @@ +import copy +import logging +import structlog +import google.auth.transport.requests +import cachecontrol +import requests + +from asyncio import CancelledError +from sanic import Blueprint, response +from sanic.request import Request +from typing import Text, List, Dict, Any, Optional, Callable, Iterable, Awaitable, Union + +from google.oauth2 import id_token +from sanic.response import HTTPResponse +from sanic.exceptions import SanicException + +from rasa.core.channels.channel import InputChannel, OutputChannel, UserMessage + +logger = logging.getLogger(__name__) +structlogger = structlog.get_logger() + +CHANNEL_NAME = "hangouts" +CERTS_URL = ( + "https://www.googleapis.com/service_accounts/" + "v1/metadata/x509/chat@system.gserviceaccount.com" +) + + +class HangoutsOutput(OutputChannel): + """A Hangouts communication channel.""" + + @classmethod + def name(cls) -> Text: + """Return channel name.""" + return CHANNEL_NAME + + def __init__(self) -> None: + """Starts messages as empty dictionary.""" + self.messages: Dict[Text, Any] = {} + + @staticmethod + def _text_card(message: Dict[Text, Any]) -> Dict: + + card = { + "cards": [ + { + "sections": [ + {"widgets": [{"textParagraph": {"text": message["text"]}}]} + ] + } + ] + } + return card + + @staticmethod + def _image_card(image: Text) -> Dict: + card = { + "cards": [{"sections": [{"widgets": [{"image": {"imageUrl": image}}]}]}] + } + return card + + @staticmethod + def _text_button_card(text: Text, buttons: List) -> Union[Dict, None]: + hangouts_buttons = [] + for b in buttons: + try: + b_txt, b_pl = b["title"], b["payload"] + except KeyError: + logger.error( + "Buttons must be a list of dicts with 'title' and 'payload' as keys" + ) + return None + + hangouts_buttons.append( + { + "textButton": { + "text": b_txt, + "onClick": {"action": {"actionMethodName": b_pl}}, + } + } + ) + + card = { + "cards": [ + { + "sections": [ + { + "widgets": [ + {"textParagraph": {"text": text}}, + {"buttons": hangouts_buttons}, + ] + } + ] + } + ] + } + return card + + @staticmethod + def _combine_cards(c1: Dict, c2: Dict) -> Dict: + return {"cards": [*c1["cards"], *c2["cards"]]} + + async def _persist_message(self, message: Dict) -> None: + """Google Hangouts only accepts single dict with single key 'text' + for simple text messages. All other responses must be sent as cards. + + In case the bot sends multiple messages, all are transformed to either + cards or text output + """ + # check whether current and previous message will send 'text' or 'card' + if self.messages.get("text"): + msg_state = "text" + elif self.messages.get("cards"): + msg_state = "cards" + else: + msg_state = None + + if message.get("text"): + msg_new = "text" + elif message.get("cards"): + msg_new = "cards" + else: + raise Exception( + "Your message to Hangouts channel must either contain 'text' or " + "'cards'!" + ) + + # depending on above outcome, convert messages into same type and combine + if msg_new == msg_state == "text": + # two text messages are simply appended + new_text = " ".join([self.messages.get("text", ""), message["text"]]) + new_messages = {"text": new_text} + + elif msg_new == msg_state == "cards": + # two cards are combined into one + new_messages = self._combine_cards(self.messages, message) + + elif msg_state == "cards" and msg_new == "text": + # if any message is card, turn text message into TextParagraph card + # and combine cards + text_card = self._text_card(message) + new_messages = self._combine_cards(self.messages, text_card) + + elif msg_state == "text" and msg_new == "cards": + text_card = self._text_card(self.messages) + new_messages = self._combine_cards(text_card, message) + + elif msg_new == "text": + new_messages = {"text": message["text"]} + else: + new_messages = message + + self.messages = new_messages + + async def send_text_message( + self, recipient_id: Text, text: Text, **kwargs: Any + ) -> None: + await self._persist_message({"text": text}) + + async def send_image_url( + self, recipient_id: Text, image: Text, **kwargs: Any + ) -> None: + await self._persist_message(self._image_card(image)) + + async def send_text_with_buttons( + self, recipient_id: Text, text: Text, buttons: List, **kwargs: Any + ) -> None: + await self._persist_message(self._text_button_card(text, buttons)) + + async def send_attachment( + self, recipient_id: Text, attachment: Text, **kwargs: Any + ) -> None: + await self.send_text_message(recipient_id, attachment) + + async def send_elements( + self, recipient_id: Text, elements: Iterable[Dict[Text, Any]], **kwargs: Any + ) -> None: + raise NotImplementedError + + async def send_custom_json( + self, recipient_id: Text, json_message: Dict, **kwargs: Any + ) -> None: + """Custom json payload is simply forwarded to Google Hangouts without + any modifications. Use this for more complex cards, which can be created + in actions.py. + """ + await self._persist_message(json_message) + + +# Google Hangouts input channel +class HangoutsInput(InputChannel): + """Channel that uses Google Hangouts Chat API to communicate.""" + + @classmethod + def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel: + + if credentials: + return cls(credentials.get("project_id")) + + return cls() + + def __init__( + self, + project_id: Optional[Text] = None, + hangouts_user_added_intent_name: Optional[Text] = "/user_added", + hangouts_room_added_intent_name: Optional[Text] = "/room_added", + hangouts_removed_intent_name: Optional[Text] = "/bot_removed", + ) -> None: + + self.project_id = project_id + self.hangouts_user_added_intent_name = hangouts_user_added_intent_name + self.hangouts_room_added_intent_name = hangouts_room_added_intent_name + self.hangouts_user_added_intent_name = hangouts_removed_intent_name + + # Google's Request obj (this is used to make HTTP requests) uses cached + # session to fetch Google's service certs. Certs don't change frequently, + # so it makes sense to cache request body, rather than getting it again + # every message. Actual caching depends on response headers. + # see: https://github.com/googleapis/google-auth-library-python/blob/main/google/oauth2/id_token.py#L15 # noqa: E501 + cached_session = cachecontrol.CacheControl(requests.session()) + self.google_request = google.auth.transport.requests.Request( + session=cached_session + ) + + @classmethod + def name(cls) -> Text: + """Returns channel name.""" + return CHANNEL_NAME + + @staticmethod + def _extract_sender(req: Request) -> Text: + + if req.json["type"] == "MESSAGE": + return req.json["message"]["sender"]["displayName"] + + return req.json["user"]["displayName"] + + # noinspection PyMethodMayBeStatic + def _extract_message(self, req: Request) -> Text: + + if req.json["type"] == "MESSAGE": + message = req.json["message"]["text"] + + elif req.json["type"] == "CARD_CLICKED": + message = req.json["action"]["actionMethodName"] + + elif req.json["type"] == "ADDED_TO_SPACE": + if self._extract_room(req) and self.hangouts_room_added_intent_name: + message = self.hangouts_room_added_intent_name + elif not self._extract_room(req) and self.hangouts_user_added_intent_name: + message = self.hangouts_user_added_intent_name + + elif ( + req.json["type"] == "REMOVED_FROM_SPACE" + and self.hangouts_user_added_intent_name + ): + message = self.hangouts_user_added_intent_name + else: + message = "" + + return message + + @staticmethod + def _extract_room(req: Request) -> Union[Text, None]: + if req.json["space"]["type"] == "ROOM": + return req.json["space"]["displayName"] + + return None + + def _extract_input_channel(self) -> Text: + return self.name() + + def _check_token(self, bot_token: Text) -> None: + # see https://developers.google.com/chat/how-tos/bots-develop#verifying_bot_authenticity # noqa: E501 + # and https://google-auth.readthedocs.io/en/latest/user-guide.html#identity-tokens # noqa: E501 + try: + decoded_token = id_token.verify_token( + bot_token, + self.google_request, + audience=self.project_id, + certs_url=CERTS_URL, + ) + except ValueError: + raise SanicException(status_code=401) + if decoded_token["iss"] != "chat@system.gserviceaccount.com": + raise SanicException(status_code=401) + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[None]] + ) -> Blueprint: + """API configuration for the channel webhook.""" + custom_webhook = Blueprint("hangouts_webhook", __name__) + + @custom_webhook.route("/", methods=["GET"]) + async def health(request: Request) -> HTTPResponse: + return response.json({"status": "ok"}) + + @custom_webhook.route("/webhook", methods=["POST"]) + async def receive(request: Request) -> HTTPResponse: + + if self.project_id: + token = request.headers.get("Authorization", "").replace("Bearer ", "") + self._check_token(token) + + sender_id = self._extract_sender(request) + room_name = self._extract_room(request) + text = self._extract_message(request) + if text is None: + return response.text("OK") + input_channel = self._extract_input_channel() + + collector = HangoutsOutput() + + try: + await on_new_message( + UserMessage( + text, + collector, + sender_id, + input_channel=input_channel, + metadata={"room": room_name}, + ) + ) + except CancelledError: + structlogger.error( + "hangouts.message.blueprint.timeout", text=copy.deepcopy(text) + ) + except Exception: + structlogger.exception( + "hangouts.message.blueprint.failure", text=copy.deepcopy(text) + ) + + return response.json(collector.messages) + + return custom_webhook diff --git a/rasa/core/channels/mattermost.py b/rasa/core/channels/mattermost.py new file mode 100644 index 0000000..508a18b --- /dev/null +++ b/rasa/core/channels/mattermost.py @@ -0,0 +1,229 @@ +import json + +import logging +import requests +from requests import Response +from sanic import Blueprint, response +from sanic.request import Request +from typing import Text, Dict, Any, List, Callable, Awaitable, Optional + +from rasa.core.channels.channel import UserMessage, OutputChannel, InputChannel +from sanic.response import HTTPResponse + +logger = logging.getLogger(__name__) + + +class MattermostBot(OutputChannel): + """A Mattermost communication channel.""" + + @classmethod + def name(cls) -> Text: + return "mattermost" + + @classmethod + def token_from_login(cls, url: Text, user: Text, password: Text) -> Optional[Text]: + """Retrieve access token for mattermost user.""" + data = {"login_id": user, "password": password} + r = requests.post(url + "/users/login", data=json.dumps(data)) + if r.status_code == 200: + return r.headers["Token"] + else: + logger.error(f"Failed to login mattermost user {user}. Response: {r}") + return None + + def __init__( + self, url: Text, token: Text, bot_channel: Text, webhook_url: Optional[Text] + ) -> None: + self.url = url + self.token = token + self.bot_channel = bot_channel + self.webhook_url = webhook_url + + super(MattermostBot, self).__init__() + + def _post_message_to_channel(self, channel_id: Text, message: Text) -> Response: + return self._post_data_to_channel( + {"channel_id": channel_id, "message": message} + ) + + def _post_data_to_channel(self, data: Dict[Text, Any]) -> Response: + """Send a message to a mattermost channel.""" + headers = {"Authorization": "Bearer " + self.token} + r = requests.post(self.url + "/posts", headers=headers, data=json.dumps(data)) + if not r.status_code == 200: + logger.error( + f"Failed to send message to mattermost channel " + f"{data.get('channel_id')}. Response: {r}" + ) + return r + + async def send_text_message( + self, recipient_id: Text, text: Text, **kwargs: Any + ) -> None: + for message_part in text.strip().split("\n\n"): + self._post_message_to_channel(self.bot_channel, message_part) + + async def send_custom_json( + self, recipient_id: Text, json_message: Dict[Text, Any], **kwargs: Any + ) -> None: + json_message.setdefault("channel_id", self.bot_channel) + json_message.setdefault("message", "") + + self._post_data_to_channel(json_message) + + async def send_image_url( + self, recipient_id: Text, image: Text, **kwargs: Any + ) -> None: + """Sends an image.""" + self._post_data_to_channel( + { + "channel_id": self.bot_channel, + "props": {"attachments": [{"image_url": image}]}, + } + ) + + async def send_text_with_buttons( + self, + recipient_id: Text, + text: Text, + buttons: List[Dict[Text, Any]], + **kwargs: Any, + ) -> None: + """Sends buttons to the output.""" + # buttons are a list of objects: [(option_name, payload)] + # See https://docs.mattermost.com/developer/interactive-messages.html#message-buttons # noqa: E501 + + actions = [ + { + "name": button["title"], + "integration": { + "url": self.webhook_url, + "context": {"action": button["payload"]}, + }, + } + for button in buttons + ] + + props = {"attachments": [{"actions": actions}]} + + self._post_data_to_channel( + {"channel_id": self.bot_channel, "message": text, "props": props} + ) + + +class MattermostInput(InputChannel): + """Mattermost input channel implemenation.""" + + @classmethod + def name(cls) -> Text: + return "mattermost" + + @classmethod + def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel: + if credentials is None: + cls.raise_missing_credentials_exception() + + token = credentials.get("token") + + return cls(credentials.get("url"), token, credentials.get("webhook_url")) + + def __init__(self, url: Text, token: Text, webhook_url: Text) -> None: + """Create a Mattermost input channel. + Needs a couple of settings to properly authenticate and validate + messages. + + Args: + url: Your Mattermost team url including /v4 example + https://mysite.example.com/api/v4 + token: Your mattermost bot token + webhook_url: The mattermost callback url as specified + in the outgoing webhooks in mattermost example + https://mysite.example.com/webhooks/mattermost/webhook + """ + self.url = url + self.token = token + self.webhook_url = webhook_url + + async def message_with_trigger_word( + self, + on_new_message: Callable[[UserMessage], Awaitable[None]], + output: Dict[Text, Any], + metadata: Optional[Dict], + ) -> None: + # splitting to get rid of the @botmention + # trigger we are using for this + split_message = output["text"].split(" ", 1) + if len(split_message) >= 2: + message = split_message[1] + else: + message = output["text"] + + await self._handle_message( + message, output["user_id"], output["channel_id"], metadata, on_new_message + ) + + async def action_from_button( + self, + on_new_message: Callable[[UserMessage], Awaitable[None]], + output: Dict[Text, Any], + metadata: Optional[Dict], + ) -> None: + # get the action, the buttons triggers + action = output["context"]["action"] + + await self._handle_message( + action, output["user_id"], output["channel_id"], metadata, on_new_message + ) + + async def _handle_message( + self, + message: Text, + sender_id: Text, + bot_channel: Text, + metadata: Optional[Dict], + on_new_message: Callable[[UserMessage], Awaitable[None]], + ) -> None: + try: + out_channel = MattermostBot( + self.url, self.token, bot_channel, self.webhook_url + ) + user_msg = UserMessage( + message, + out_channel, + sender_id, + input_channel=self.name(), + metadata=metadata, + ) + await on_new_message(user_msg) + except Exception as e: + logger.error(f"Exception when trying to handle message.{e}") + logger.debug(e, exc_info=True) + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[None]] + ) -> Blueprint: + mattermost_webhook = Blueprint("mattermost_webhook", __name__) + + @mattermost_webhook.route("/", methods=["GET"]) + async def health(_: Request) -> HTTPResponse: + return response.json({"status": "ok"}) + + @mattermost_webhook.route("/webhook", methods=["POST"]) + async def webhook(request: Request) -> HTTPResponse: + output = request.json + + if not output: + return response.text("") + + metadata = self.get_metadata(request) + # handle normal message with trigger_word + if "trigger_word" in output: + await self.message_with_trigger_word(on_new_message, output, metadata) + + # handle context actions from buttons + elif "context" in output: + await self.action_from_button(on_new_message, output, metadata) + + return response.text("success") + + return mattermost_webhook diff --git a/rasa/core/channels/rasa_chat.py b/rasa/core/channels/rasa_chat.py new file mode 100644 index 0000000..3b8235b --- /dev/null +++ b/rasa/core/channels/rasa_chat.py @@ -0,0 +1,126 @@ +import json +from typing import Text, Optional, Dict, Any + +import aiohttp +import logging +from sanic.exceptions import SanicException +import jwt +import jwt.exceptions + +import rasa.core.channels.channel +from rasa.core.channels.channel import InputChannel +from rasa.core.channels.rest import RestInput +from rasa.core.constants import DEFAULT_REQUEST_TIMEOUT +from sanic.request import Request + +logger = logging.getLogger(__name__) + +CONVERSATION_ID_KEY = "conversation_id" +JWT_USERNAME_KEY = "username" +INTERACTIVE_LEARNING_PERMISSION = "clientEvents:create" + + +class RasaChatInput(RestInput): + """Chat input channel for Rasa Enterprise.""" + + @classmethod + def name(cls) -> Text: + """Name of the channel.""" + return "rasa" + + @classmethod + def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel: + if not credentials: + cls.raise_missing_credentials_exception() + + return cls(credentials.get("url")) + + def __init__(self, url: Optional[Text]) -> None: + """Initialise the channel with attributes.""" + self.base_url = url + self.jwt_key: Optional[Text] = None + self.jwt_algorithm = None + + async def _fetch_public_key(self) -> None: + public_key_url = f"{self.base_url}/version" + async with aiohttp.ClientSession() as session: + async with session.get( + public_key_url, timeout=DEFAULT_REQUEST_TIMEOUT + ) as resp: + status_code = resp.status + if status_code != 200: + logger.error( + "Failed to fetch JWT public key from URL '{}' with " + "status code {}: {}" + "".format(public_key_url, status_code, await resp.text()) + ) + return + rjs = await resp.json() + public_key_field = "keys" + if public_key_field in rjs: + self.jwt_key = rjs["keys"][0]["key"] + self.jwt_algorithm = rjs["keys"][0]["alg"] + logger.debug( + "Fetched JWT public key from URL '{}' for algorithm '{}':\n{}" + "".format(public_key_url, self.jwt_algorithm, self.jwt_key) + ) + else: + logger.error( + "Retrieved json response from URL '{}' but could not find " + "'{}' field containing the JWT public key. Please make sure " + "you use an up-to-date version of Rasa Enterprise (>= 0.20.2). " + "Response was: {}" + "".format(public_key_url, public_key_field, json.dumps(rjs)) + ) + + async def _decode_bearer_token(self, bearer_token: Text) -> Optional[Dict]: + if self.jwt_key is None: + await self._fetch_public_key() + + try: + return rasa.core.channels.channel.decode_jwt( + bearer_token, self.jwt_key, self.jwt_algorithm + ) + except jwt.InvalidSignatureError: + logger.error("JWT public key invalid, fetching new one.") + await self._fetch_public_key() + return rasa.core.channels.channel.decode_jwt( + bearer_token, self.jwt_key, self.jwt_algorithm + ) + + async def _extract_sender(self, req: Request) -> Optional[Text]: + """Fetch user from the Rasa Enterprise Admin API.""" + jwt_payload = None + if req.headers.get("Authorization"): + jwt_payload = await self._decode_bearer_token(req.headers["Authorization"]) + + if not jwt_payload: + jwt_payload = await self._decode_bearer_token(req.args.get("token")) + + if not jwt_payload: + raise SanicException(status_code=401) + + if CONVERSATION_ID_KEY in req.json: + if self._has_user_permission_to_send_messages_to_conversation( + jwt_payload, req.json + ): + return req.json[CONVERSATION_ID_KEY] + else: + logger.error( + "User '{}' does not have permissions to send messages to " + "conversation '{}'.".format( + jwt_payload[JWT_USERNAME_KEY], req.json[CONVERSATION_ID_KEY] + ) + ) + raise SanicException(status_code=401) + + return jwt_payload[JWT_USERNAME_KEY] + + @staticmethod + def _has_user_permission_to_send_messages_to_conversation( + jwt_payload: Dict, message: Dict + ) -> bool: + user_scopes = jwt_payload.get("scopes", []) + return INTERACTIVE_LEARNING_PERMISSION in user_scopes or message[ + CONVERSATION_ID_KEY + ] == jwt_payload.get(JWT_USERNAME_KEY) diff --git a/rasa/core/channels/rest.py b/rasa/core/channels/rest.py new file mode 100644 index 0000000..764e920 --- /dev/null +++ b/rasa/core/channels/rest.py @@ -0,0 +1,209 @@ +import asyncio +import copy +import inspect +import json +import logging +import structlog +from asyncio import Queue, CancelledError +from sanic import Blueprint, response +from sanic.request import Request +from sanic.response import HTTPResponse, ResponseStream +from typing import Text, Dict, Any, Optional, Callable, Awaitable, NoReturn, Union + +import rasa.utils.endpoints +from rasa.core.channels.channel import ( + InputChannel, + CollectingOutputChannel, + UserMessage, +) + + +logger = logging.getLogger(__name__) +structlogger = structlog.get_logger() + + +class RestInput(InputChannel): + """A custom http input channel. + + This implementation is the basis for a custom implementation of a chat + frontend. You can customize this to send messages to Rasa and + retrieve responses from the assistant. + """ + + @classmethod + def name(cls) -> Text: + return "rest" + + @staticmethod + async def on_message_wrapper( + on_new_message: Callable[[UserMessage], Awaitable[Any]], + text: Text, + queue: Queue, + sender_id: Text, + input_channel: Text, + metadata: Optional[Dict[Text, Any]], + ) -> None: + collector = QueueOutputChannel(queue) + + message = UserMessage( + text, collector, sender_id, input_channel=input_channel, metadata=metadata + ) + await on_new_message(message) + + await queue.put("DONE") + + async def _extract_sender(self, req: Request) -> Optional[Text]: + return req.json.get("sender", None) + + # noinspection PyMethodMayBeStatic + def _extract_message(self, req: Request) -> Optional[Text]: + return req.json.get("message", None) + + def _extract_input_channel(self, req: Request) -> Text: + return req.json.get("input_channel") or self.name() + + def get_metadata(self, request: Request) -> Optional[Dict[Text, Any]]: + """Extracts additional information from the incoming request. + + Implementing this function is not required. However, it can be used to extract + metadata from the request. The return value is passed on to the + ``UserMessage`` object and stored in the conversation tracker. + + Args: + request: incoming request with the message of the user + + Returns: + Metadata which was extracted from the request. + """ + return request.json.get("metadata", None) + + def stream_response( + self, + on_new_message: Callable[[UserMessage], Awaitable[None]], + text: Text, + sender_id: Text, + input_channel: Text, + metadata: Optional[Dict[Text, Any]], + ) -> Callable[[Any], Awaitable[None]]: + """Streams response to the client. + + If the stream option is enabled, this method will be called to + stream the response to the client + + Args: + on_new_message: sanic event + text: message text + sender_id: message sender_id + input_channel: input channel name + metadata: optional metadata sent with the message + + Returns: + Sanic stream + """ + + async def stream(resp: Any) -> None: + q: Queue = Queue() + task = asyncio.ensure_future( + self.on_message_wrapper( + on_new_message, text, q, sender_id, input_channel, metadata + ) + ) + while True: + result = await q.get() + if result == "DONE": + break + else: + await resp.write(json.dumps(result) + "\n") + await task + + return stream + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[None]] + ) -> Blueprint: + """Groups the collection of endpoints used by rest channel.""" + module_type = inspect.getmodule(self) + if module_type is not None: + module_name = module_type.__name__ + else: + module_name = None + + custom_webhook = Blueprint( + "custom_webhook_{}".format(type(self).__name__), + module_name, + ) + + # noinspection PyUnusedLocal + @custom_webhook.route("/", methods=["GET"]) + async def health(request: Request) -> HTTPResponse: + return response.json({"status": "ok"}) + + @custom_webhook.route("/webhook", methods=["POST"]) + async def receive(request: Request) -> Union[ResponseStream, HTTPResponse]: + sender_id = await self._extract_sender(request) + text = self._extract_message(request) + should_use_stream = rasa.utils.endpoints.bool_arg( + request, "stream", default=False + ) + input_channel = self._extract_input_channel(request) + metadata = self.get_metadata(request) + + if should_use_stream: + return response.stream( + self.stream_response( + on_new_message, text, sender_id, input_channel, metadata + ), + content_type="text/event-stream", + ) + else: + collector = CollectingOutputChannel() + # noinspection PyBroadException + try: + await on_new_message( + UserMessage( + text, + collector, + sender_id, + input_channel=input_channel, + metadata=metadata, + ) + ) + except CancelledError: + structlogger.error( + "rest.message.received.timeout", text=copy.deepcopy(text) + ) + except Exception: + structlogger.exception( + "rest.message.received.failure", text=copy.deepcopy(text) + ) + + return response.json(collector.messages) + + return custom_webhook + + +class QueueOutputChannel(CollectingOutputChannel): + """Output channel that collects send messages in a list. + + (doesn't send them anywhere, just collects them). + """ + + # FIXME: this is breaking Liskov substitution principle + # and would require some user-facing refactoring to address + messages: Queue # type: ignore[assignment] + + @classmethod + def name(cls) -> Text: + """Name of QueueOutputChannel.""" + return "queue" + + # noinspection PyMissingConstructor + def __init__(self, message_queue: Optional[Queue] = None) -> None: + super().__init__() + self.messages = Queue() if not message_queue else message_queue + + def latest_output(self) -> NoReturn: + raise NotImplementedError("A queue doesn't allow to peek at messages.") + + async def _persist_message(self, message: Dict[Text, Any]) -> None: + await self.messages.put(message) diff --git a/rasa/core/channels/rocketchat.py b/rasa/core/channels/rocketchat.py new file mode 100644 index 0000000..3540e2d --- /dev/null +++ b/rasa/core/channels/rocketchat.py @@ -0,0 +1,176 @@ +import logging +from sanic import Blueprint, response +from sanic.request import Request +from typing import Text, Dict, Any, List, Iterable, Optional, Callable, Awaitable + +from rasa.core.channels.channel import UserMessage, OutputChannel, InputChannel +from sanic.response import HTTPResponse + +logger = logging.getLogger(__name__) + + +class RocketChatBot(OutputChannel): + @classmethod + def name(cls) -> Text: + return "rocketchat" + + def __init__(self, user: Text, password: Text, server_url: Text) -> None: + from rocketchat_API.rocketchat import RocketChat + + self.rocket = RocketChat(user, password, server_url=server_url) + + @staticmethod + def _convert_to_rocket_buttons(buttons: List[Dict]) -> List[Dict]: + return [ + { + "text": b["title"], + "msg": b["payload"], + "type": "button", + "msg_in_chat_window": True, + } + for b in buttons + ] + + async def send_text_message( + self, recipient_id: Text, text: Text, **kwargs: Any + ) -> None: + """Send message to output channel""" + + for message_part in text.strip().split("\n\n"): + self.rocket.chat_post_message(message_part, room_id=recipient_id) + + async def send_image_url( + self, recipient_id: Text, image: Text, **kwargs: Any + ) -> None: + image_attachment = [{"image_url": image, "collapsed": False}] + + return self.rocket.chat_post_message( + None, room_id=recipient_id, attachments=image_attachment + ) + + async def send_attachment( + self, recipient_id: Text, attachment: Text, **kwargs: Any + ) -> None: + return self.rocket.chat_post_message( + None, room_id=recipient_id, attachments=[attachment] + ) + + async def send_text_with_buttons( + self, + recipient_id: Text, + text: Text, + buttons: List[Dict[Text, Any]], + **kwargs: Any, + ) -> None: + # implementation is based on + # https://github.com/RocketChat/Rocket.Chat/pull/11473 + # should work in rocket chat >= 0.69.0 + button_attachment = [{"actions": self._convert_to_rocket_buttons(buttons)}] + + return self.rocket.chat_post_message( + text, room_id=recipient_id, attachments=button_attachment + ) + + async def send_elements( + self, recipient_id: Text, elements: Iterable[Dict[Text, Any]], **kwargs: Any + ) -> None: + return self.rocket.chat_post_message( + None, room_id=recipient_id, attachments=elements + ) + + async def send_custom_json( + self, recipient_id: Text, json_message: Dict[Text, Any], **kwargs: Any + ) -> None: + text = json_message.pop("text") + + if json_message.get("channel"): + if json_message.get("room_id"): + logger.warning( + "Only one of `channel` or `room_id` can be passed to a RocketChat " + "message post. Defaulting to `channel`." + ) + del json_message["room_id"] + return self.rocket.chat_post_message(text, **json_message) + else: + json_message.setdefault("room_id", recipient_id) + return self.rocket.chat_post_message(text, **json_message) + + +class RocketChatInput(InputChannel): + """RocketChat input channel implementation.""" + + @classmethod + def name(cls) -> Text: + return "rocketchat" + + @classmethod + def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel: + if not credentials: + cls.raise_missing_credentials_exception() + + return cls( + credentials.get("user"), + credentials.get("password"), + credentials.get("server_url"), + ) + + def __init__(self, user: Text, password: Text, server_url: Text) -> None: + + self.user = user + self.password = password + self.server_url = server_url + + async def send_message( + self, + text: Optional[Text], + sender_name: Optional[Text], + recipient_id: Optional[Text], + on_new_message: Callable[[UserMessage], Awaitable[Any]], + metadata: Optional[Dict], + ) -> None: + if sender_name != self.user: + output_channel = self.get_output_channel() + + user_msg = UserMessage( + text, + output_channel, + recipient_id, + input_channel=self.name(), + metadata=metadata, + ) + await on_new_message(user_msg) + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[Any]] + ) -> Blueprint: + rocketchat_webhook = Blueprint("rocketchat_webhook", __name__) + + @rocketchat_webhook.route("/", methods=["GET"]) + async def health(_: Request) -> HTTPResponse: + return response.json({"status": "ok"}) + + @rocketchat_webhook.route("/webhook", methods=["GET", "POST"]) + async def webhook(request: Request) -> HTTPResponse: + output = request.json + metadata = self.get_metadata(request) + if output: + if "visitor" not in output: + sender_name = output.get("user_name", None) + text = output.get("text", None) + recipient_id = output.get("channel_id", None) + else: + messages_list = output.get("messages", None) + text = messages_list[0].get("msg", None) + sender_name = messages_list[0].get("username", None) + recipient_id = output.get("_id") + + await self.send_message( + text, sender_name, recipient_id, on_new_message, metadata + ) + + return response.text("") + + return rocketchat_webhook + + def get_output_channel(self) -> OutputChannel: + return RocketChatBot(self.user, self.password, self.server_url) diff --git a/rasa/core/channels/slack.py b/rasa/core/channels/slack.py new file mode 100644 index 0000000..7e69da5 --- /dev/null +++ b/rasa/core/channels/slack.py @@ -0,0 +1,620 @@ +import asyncio +import hashlib +import hmac +from http import HTTPStatus +import json +import logging +import re +import time +from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Text + +from rasa.core.channels.channel import InputChannel, OutputChannel, UserMessage +from rasa.shared.constants import DOCS_URL_CONNECTORS_SLACK +from rasa.shared.exceptions import InvalidConfigException +import rasa.shared.utils.io +from sanic import Blueprint, response +from sanic.request import Request +from sanic.response import HTTPResponse +from slack_sdk.web.async_client import AsyncWebClient + +logger = logging.getLogger(__name__) + + +class SlackBot(OutputChannel): + """A Slack communication channel.""" + + @classmethod + def name(cls) -> Text: + return "slack" + + def __init__( + self, + token: Text, + slack_channel: Optional[Text] = None, + thread_id: Optional[Text] = None, + proxy: Optional[Text] = None, + ) -> None: + self.slack_channel = slack_channel + self.thread_id = thread_id + self.proxy = proxy + self.client = AsyncWebClient(token, proxy=proxy) + super().__init__() + + async def _post_message(self, channel: Text, **kwargs: Any) -> None: + # type issues below are ignored because the `run_async` paramenter + # above ensures chat_postMessage is await-able. mypy complains + # because the type annotations are not precise enough in Slack + if self.thread_id: + await self.client.chat_postMessage( + channel=channel, **kwargs, thread_ts=self.thread_id + ) + else: + await self.client.chat_postMessage(channel=channel, **kwargs) + + async def send_text_message( + self, recipient_id: Text, text: Text, **kwargs: Any + ) -> None: + """Send text message to Slack API.""" + recipient = self.slack_channel or recipient_id + for message_part in text.strip().split("\n\n"): + await self._post_message( + channel=recipient, as_user=True, text=message_part, type="mrkdwn" + ) + + async def send_image_url( + self, recipient_id: Text, image: Text, **kwargs: Any + ) -> None: + recipient = self.slack_channel or recipient_id + image_block = {"type": "image", "image_url": image, "alt_text": image} + + await self._post_message( + channel=recipient, as_user=True, text=image, blocks=[image_block] + ) + + async def send_attachment( # type: ignore[override] + self, recipient_id: Text, attachment: Dict[Text, Any], **kwargs: Any + ) -> None: + """Sends message with attachment.""" + recipient = self.slack_channel or recipient_id + await self._post_message( + channel=recipient, as_user=True, attachments=[attachment], **kwargs + ) + + async def send_text_with_buttons( + self, + recipient_id: Text, + text: Text, + buttons: List[Dict[Text, Any]], + **kwargs: Any, + ) -> None: + recipient = self.slack_channel or recipient_id + + text_block = {"type": "section", "text": {"type": "plain_text", "text": text}} + + if len(buttons) > 5: + rasa.shared.utils.io.raise_warning( + "Slack API currently allows only up to 5 buttons. " + "Since you added more than 5, slack will ignore all of them." + ) + return await self.send_text_message(recipient, text, **kwargs) + + button_block = { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": button["title"]}, + "value": button["payload"], + } + for button in buttons + ], + } + + await self._post_message( + channel=recipient, + as_user=True, + text=text, + blocks=[text_block, button_block], + ) + + async def send_custom_json( + self, recipient_id: Text, json_message: Dict[Text, Any], **kwargs: Any + ) -> None: + channel = json_message.get("channel", self.slack_channel or recipient_id) + json_message.setdefault("as_user", True) + await self._post_message(channel=channel, **json_message) + + +class SlackInput(InputChannel): + """Slack input channel implementation. Based on the HTTPInputChannel.""" + + @classmethod + def name(cls) -> Text: + return "slack" + + @classmethod + def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel: + if not credentials: + cls.raise_missing_credentials_exception() + + return cls( + credentials.get("slack_token"), + credentials.get("slack_channel"), + credentials.get("proxy"), + credentials.get("slack_retry_reason_header", "x-slack-retry-reason"), + credentials.get("slack_retry_number_header", "x-slack-retry-num"), + credentials.get("errors_ignore_retry", None), + credentials.get("use_threads", False), + credentials.get("slack_signing_secret", ""), + credentials.get("conversation_granularity", "sender"), + ) + + def __init__( + self, + slack_token: Text, + slack_channel: Optional[Text] = None, + proxy: Optional[Text] = None, + slack_retry_reason_header: Optional[Text] = None, + slack_retry_number_header: Optional[Text] = None, + errors_ignore_retry: Optional[List[Text]] = None, + use_threads: Optional[bool] = False, + slack_signing_secret: Text = "", + conversation_granularity: Optional[Text] = "sender", + ) -> None: + """Create a Slack input channel. + + Needs a couple of settings to properly authenticate and validate + messages. Details to setup: + + https://github.com/slackapi/python-slackclient + + Args: + slack_token: Your Slack Authentication token. You can create a + Slack app and get your Bot User OAuth Access Token + `here <https://api.slack.com/slack-apps>`_. + slack_channel: the string identifier for a channel to which + the bot posts, or channel name (e.g. '#bot-test') + If not set, messages will be sent back + to the "App" DM channel of your bot's name. + proxy: A Proxy Server to route your traffic through + slack_retry_reason_header: Slack HTTP header name indicating reason + that slack send retry request. + slack_retry_number_header: Slack HTTP header name indicating + the attempt number. + errors_ignore_retry: Any error codes given by Slack + included in this list will be ignored. + Error codes are listed + `here <https://api.slack.com/events-api#errors>`_. + use_threads: If set to `True`, your bot will send responses in Slack as + a threaded message. Responses will appear as a normal Slack message + if set to `False`. + slack_signing_secret: Slack creates a unique string for your app and + shares it with you. This allows us to verify requests from Slack + with confidence by verifying signatures using your signing secret. + conversation_granularity: conversation granularity for slack conversations. + sender allows 1 conversation per user (across channels) + channel allows 1 conversation per user per channel + thread allows 1 conversation per user per thread + """ + self.slack_token = slack_token + self.slack_channel = slack_channel + self.proxy = proxy + self.errors_ignore_retry = errors_ignore_retry or ("http_timeout",) + self.retry_reason_header = slack_retry_reason_header + self.retry_num_header = slack_retry_number_header + self.use_threads = use_threads + self.slack_signing_secret = slack_signing_secret + self.conversation_granularity = conversation_granularity + self._background_tasks: Set[asyncio.Task] = set() + + self._validate_credentials() + + def _validate_credentials(self) -> None: + """Raises exceptions if the connector is not properly configured.""" + if not self.slack_signing_secret: + raise InvalidConfigException( + f"Your slack bot is missing a configured signing secret. Running a " + f"bot without a signing secret is insecure and was removed. " + f"You need to add a `slack_signing_secret` parameter to your channel " + f"configuration. " + f"More info at {DOCS_URL_CONNECTORS_SLACK} ." + ) + + @staticmethod + def _is_app_mention(slack_event: Dict) -> bool: + try: + return slack_event["event"]["type"] == "app_mention" + except KeyError: + return False + + @staticmethod + def _is_direct_message(slack_event: Dict) -> bool: + try: + return slack_event["event"]["channel_type"] == "im" + except KeyError: + return False + + @staticmethod + def _is_user_message(slack_event: Dict[Text, Any]) -> bool: + return ( + slack_event.get("event") is not None + and ( + slack_event.get("event", {}).get("type") == "message" + or slack_event.get("event", {}).get("type") == "app_mention" + ) + and slack_event.get("event", {}).get("text") + and not slack_event.get("event", {}).get("bot_id") + ) + + @staticmethod + def _sanitize_user_message( + text: Text, uids_to_remove: Optional[List[Text]] + ) -> Text: + """Remove superfluous/wrong/problematic tokens from a message. + + Probably a good starting point for pre-formatting of user-provided text + to make NLU's life easier in case they go funky to the power of extreme + + In the current state will just drop self-mentions of bot itself + + Args: + text: raw message as sent from slack + uids_to_remove: a list of user ids to remove from the content + + Returns: + str: parsed and cleaned version of the input text + """ + uids_to_remove = uids_to_remove or [] + + for uid_to_remove in uids_to_remove: + escaped_uid = re.escape(str(uid_to_remove)) + + # heuristic to format majority cases OK + # can be adjusted to taste later if needed, + # but is a good first approximation + for regex, replacement in [ + (rf"<@{escaped_uid}>\s", ""), + (rf"\s<@{escaped_uid}>", ""), # a bit arbitrary but probably OK + (rf"<@{escaped_uid}>", " "), + ]: + text = re.sub(regex, replacement, text) + + # Find multiple mailto or http links like + # <mailto:xyz@rasa.com|xyz@rasa.com> or + # <http://url.com|url.com> in text and substitute + # it with original content + pattern = r"(\<(?:mailto|https?):\/\/.*?\|.*?\>)" + match = re.findall(pattern, text) + + if match: + for remove in match: + replacement = remove.split("|")[1] + replacement = replacement.replace(">", "") + text = text.replace(remove, replacement) + return text.strip() + + @staticmethod + def _is_interactive_message(payload: Dict) -> bool: + """Check wheter the input is a supported interactive input type.""" + supported = [ + "button", + "select", + "static_select", + "external_select", + "conversations_select", + "users_select", + "channels_select", + "overflow", + "datepicker", + ] + if payload.get("actions"): + action_type = payload["actions"][0].get("type") + if action_type in supported: + return True + elif action_type: + logger.warning( + f"Received input from a Slack interactive component of type " + f"'{payload['actions'][0]['type']}', " + f"for which payload parsing is not yet supported." + ) + return False + + @staticmethod + def _get_interactive_response(action: Dict) -> Optional[Text]: + """Parse the payload for the response value.""" + if action["type"] == "button": + return action.get("value") + elif action["type"] == "select": + return action.get("selected_options", [{}])[0].get("value") + elif action["type"] == "static_select": + return action.get("selected_option", {}).get("value") + elif action["type"] == "external_select": + return action.get("selected_option", {}).get("value") + elif action["type"] == "conversations_select": + return action.get("selected_conversation") + elif action["type"] == "users_select": + return action.get("selected_user") + elif action["type"] == "channels_select": + return action.get("selected_channel") + elif action["type"] == "overflow": + return action.get("selected_option", {}).get("value") + elif action["type"] == "datepicker": + return action.get("selected_date") + + return None + + async def process_message( + self, + request: Request, + on_new_message: Callable[[UserMessage], Awaitable[Any]], + text: Text, + sender_id: Optional[Text], + metadata: Optional[Dict], + ) -> Any: + """Slack retries to post messages up to 3 times based on + failure conditions defined here: + https://api.slack.com/events-api#failure_conditions. + """ + retry_reason = request.headers.get(self.retry_reason_header) + retry_count = request.headers.get(self.retry_num_header) + if retry_count and retry_reason in self.errors_ignore_retry: + logger.warning( + f"Received retry #{retry_count} request from slack" + f" due to {retry_reason}." + ) + + return response.text( + "", status=HTTPStatus.CREATED, headers={"X-Slack-No-Retry": "1"} + ) + + if metadata is not None: + output_channel = metadata.get("out_channel") + if self.use_threads: + thread_id = metadata.get("thread_id") + else: + thread_id = None + else: + output_channel = None + thread_id = None + + try: + user_msg = UserMessage( + text, + self.get_output_channel(output_channel, thread_id), + sender_id, + input_channel=self.name(), + metadata=metadata, + ) + + # We need to save a reference to this background task to + # make sure it doesn't disappear. See: + # https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task + task: asyncio.Task = asyncio.create_task(on_new_message(user_msg)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + except Exception as e: + logger.error(f"Exception when trying to handle message.{e}") + logger.error(str(e), exc_info=True) + + return response.text("") + + def get_metadata(self, request: Request) -> Dict[Text, Any]: + """Extracts the metadata from a slack API event. + + Slack Documentation: https://api.slack.com/types/event + + Args: + request: A `Request` object that contains a slack API event in the body. + + Returns: + Metadata extracted from the sent event payload. This includes the output + channel for the response, and users that have installed the bot. + """ + content_type = request.headers.get("content-type") + + # Slack API sends either a JSON-encoded or a URL-encoded body depending on the + # content + if content_type == "application/json": + # if JSON-encoded message is received + slack_event = request.json + event = slack_event.get("event", {}) + thread_id = event.get("thread_ts", event.get("ts")) + + users = [] + if "authed_users" in slack_event: + users = slack_event.get("authed_users") + elif ( + "authorizations" in slack_event + and len(slack_event.get("authorizations")) > 0 + ): + users.append(slack_event.get("authorizations")[0].get("user_id")) + + return { + "out_channel": event.get("channel"), + "thread_id": thread_id, + "users": users, + } + + if content_type == "application/x-www-form-urlencoded": + # if URL-encoded message is received + output = request.form + payload = json.loads(output["payload"][0]) + message = payload.get("message", {}) + thread_id = message.get("thread_ts", message.get("ts")) + + users = [] + if payload.get("user", {}).get("id"): + users.append(payload.get("user", {}).get("id")) + + return { + "out_channel": payload.get("channel", {}).get("id"), + "thread_id": thread_id, + "users": users, + } + + return {} + + def is_request_from_slack_authentic(self, request: Request) -> bool: + """Validate a request from Slack for its authenticity. + + Checks if the signature matches the one we expect from Slack. Ensures + we don't process request from a third-party disguising as slack. + + Args: + request: incoming request to be checked + + Returns: + `True` if the request came from Slack. + """ + try: + slack_signing_secret = bytes(self.slack_signing_secret, "utf-8") + + slack_signature = request.headers.get("X-Slack-Signature", "") + slack_request_timestamp = request.headers.get( + "X-Slack-Request-Timestamp", "0" + ) + + if abs(time.time() - int(slack_request_timestamp)) > 60 * 5: + # The request timestamp is more than five minutes from local time. + # It could be a replay attack, so let's ignore it. + return False + + prefix = f"v0:{slack_request_timestamp}:".encode("utf-8") + basestring = prefix + request.body + digest = hmac.new( + slack_signing_secret, basestring, hashlib.sha256 + ).hexdigest() + computed_signature = f"v0={digest}" + + return hmac.compare_digest(computed_signature, slack_signature) + except Exception as e: + logger.error( + f"Failed to validate slack request authenticity. " + f"Assuming invalid request. Error: {e}" + ) + return False + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[Any]] + ) -> Blueprint: + slack_webhook = Blueprint("slack_webhook", __name__) + + @slack_webhook.route("/", methods=["GET"]) + async def health(_: Request) -> HTTPResponse: + return response.json({"status": "ok"}) + + @slack_webhook.route("/webhook", methods=["GET", "POST"]) + async def webhook(request: Request) -> HTTPResponse: + content_type = request.headers.get("content-type") + + if not self.is_request_from_slack_authentic(request): + return response.text( + "Message is not properly signed with a valid " + "X-Slack-Signature header", + status=HTTPStatus.BAD_REQUEST, + ) + + # Slack API sends either a JSON-encoded or a URL-encoded body + # depending on the content + + if content_type == "application/json": + # if JSON-encoded message is received + output = request.json + event = output.get("event", {}) + user_message = event.get("text", "") + sender_id = event.get("user", "") + metadata = self.get_metadata(request) + channel_id = metadata.get("out_channel") + thread_id = metadata.get("thread_id") + conversation_id = self._get_conversation_id( + sender_id, channel_id, thread_id + ) + + if "challenge" in output: + return response.json(output.get("challenge")) + + if not self._is_user_message(output): + logger.debug( + "Received message from Slack which doesn't look like " + "a user message. Skipping message." + ) + return response.text("Bot message delivered.") + + if not self._is_supported_channel(output, metadata): + logger.warning( + f"Received message on unsupported " + f"channel: {metadata['out_channel']}" + ) + return response.text("channel not supported.") + + return await self.process_message( + request, + on_new_message, + text=self._sanitize_user_message(user_message, metadata["users"]), + sender_id=conversation_id, + metadata=metadata, + ) + elif content_type == "application/x-www-form-urlencoded": + # if URL-encoded message is received + output = request.form + payload = json.loads(output["payload"][0]) + + if self._is_interactive_message(payload): + sender_id = payload["user"]["id"] + text = self._get_interactive_response(payload["actions"][0]) + if text is not None: + metadata = self.get_metadata(request) + channel_id = metadata.get("out_channel") + thread_id = metadata.get("thread_id") + conversation_id = self._get_conversation_id( + sender_id, channel_id, thread_id + ) + return await self.process_message( + request, on_new_message, text, conversation_id, metadata + ) + if payload["actions"][0]["type"] == "button": + # link buttons don't have "value", don't send their clicks to + # bot + return response.text("User clicked link button") + return response.text( + "The input message could not be processed.", + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return response.text("Bot message delivered.") + + return slack_webhook + + def _get_conversation_id( + self, + sender_id: Optional[Text], + channel_id: Optional[Text], + thread_id: Optional[Text], + ) -> Optional[Text]: + conversation_id = sender_id + if self.conversation_granularity == "channel" and sender_id and channel_id: + conversation_id = sender_id + "_" + channel_id + if ( + self.conversation_granularity == "thread" + and sender_id + and channel_id + and thread_id + ): + conversation_id = sender_id + "_" + channel_id + "_" + thread_id + return conversation_id + + def _is_supported_channel(self, slack_event: Dict, metadata: Dict) -> bool: + return ( + self._is_direct_message(slack_event) + or self._is_app_mention(slack_event) + or metadata["out_channel"] == self.slack_channel + ) + + def get_output_channel( + self, channel: Optional[Text] = None, thread_id: Optional[Text] = None + ) -> OutputChannel: + channel = channel or self.slack_channel + return SlackBot(self.slack_token, channel, thread_id, self.proxy) + + def set_output_channel(self, channel: Text) -> None: + self.slack_channel = channel diff --git a/rasa/core/channels/socketio.py b/rasa/core/channels/socketio.py new file mode 100644 index 0000000..c268447 --- /dev/null +++ b/rasa/core/channels/socketio.py @@ -0,0 +1,270 @@ +import logging +import uuid +import json +from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Text + +import rasa.core.channels.channel +from rasa.core.channels.channel import InputChannel, OutputChannel, UserMessage +import rasa.shared.utils.io +from sanic import Blueprint, response, Sanic +from sanic.request import Request +from sanic.response import HTTPResponse +from socketio import AsyncServer + +logger = logging.getLogger(__name__) + + +class SocketBlueprint(Blueprint): + def __init__( + self, sio: AsyncServer, socketio_path: Text, *args: Any, **kwargs: Any + ) -> None: + """Creates a :class:`sanic.Blueprint` for routing socketio connenctions. + + :param sio: Instance of :class:`socketio.AsyncServer` class + :param socketio_path: string indicating the route to accept requests on. + """ + super().__init__(*args, **kwargs) + self.ctx.sio = sio + self.ctx.socketio_path = socketio_path + + def register(self, app: Sanic, options: Dict[Text, Any]) -> None: + """Attach the Socket.IO webserver to the given Sanic instance. + + :param app: Instance of :class:`sanic.app.Sanic` class + :param options: Options to be used while registering the + blueprint into the app. + """ + self.ctx.sio.attach(app, self.ctx.socketio_path) + super().register(app, options) + + +class SocketIOOutput(OutputChannel): + @classmethod + def name(cls) -> Text: + return "socketio" + + def __init__(self, sio: AsyncServer, bot_message_evt: Text) -> None: + self.sio = sio + self.bot_message_evt = bot_message_evt + + async def _send_message(self, socket_id: Text, response: Any) -> None: + """Sends a message to the recipient using the bot event.""" + + await self.sio.emit(self.bot_message_evt, response, room=socket_id) + + async def send_text_message( + self, recipient_id: Text, text: Text, **kwargs: Any + ) -> None: + """Send a message through this channel.""" + + for message_part in text.strip().split("\n\n"): + await self._send_message(recipient_id, {"text": message_part}) + + async def send_image_url( + self, recipient_id: Text, image: Text, **kwargs: Any + ) -> None: + """Sends an image to the output""" + + message = {"attachment": {"type": "image", "payload": {"src": image}}} + await self._send_message(recipient_id, message) + + async def send_text_with_buttons( + self, + recipient_id: Text, + text: Text, + buttons: List[Dict[Text, Any]], + **kwargs: Any, + ) -> None: + """Sends buttons to the output.""" + + # split text and create a message for each text fragment + # the `or` makes sure there is at least one message we can attach the quick + # replies to + message_parts = text.strip().split("\n\n") or [text] + messages: List[Dict[Text, Any]] = [ + {"text": message, "quick_replies": []} for message in message_parts + ] + + # attach all buttons to the last text fragment + messages[-1]["quick_replies"] = [ + { + "content_type": "text", + "title": button["title"], + "payload": button["payload"], + } + for button in buttons + ] + + for message in messages: + await self._send_message(recipient_id, message) + + async def send_elements( + self, recipient_id: Text, elements: Iterable[Dict[Text, Any]], **kwargs: Any + ) -> None: + """Sends elements to the output.""" + + for element in elements: + message = { + "attachment": { + "type": "template", + "payload": {"template_type": "generic", "elements": element}, + } + } + + await self._send_message(recipient_id, message) + + async def send_custom_json( + self, recipient_id: Text, json_message: Dict[Text, Any], **kwargs: Any + ) -> None: + """Sends custom json to the output""" + + json_message.setdefault("room", recipient_id) + + await self.sio.emit(self.bot_message_evt, **json_message) + + async def send_attachment( # type: ignore[override] + self, recipient_id: Text, attachment: Dict[Text, Any], **kwargs: Any + ) -> None: + """Sends an attachment to the user.""" + await self._send_message(recipient_id, {"attachment": attachment}) + + +class SocketIOInput(InputChannel): + """A socket.io input channel.""" + + @classmethod + def name(cls) -> Text: + return "socketio" + + @classmethod + def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel: + credentials = credentials or {} + return cls( + credentials.get("user_message_evt", "user_uttered"), + credentials.get("bot_message_evt", "bot_uttered"), + credentials.get("namespace"), + credentials.get("session_persistence", False), + credentials.get("socketio_path", "/socket.io"), + credentials.get("jwt_key"), + credentials.get("jwt_method", "HS256"), + credentials.get("metadata_key", "metadata"), + ) + + def __init__( + self, + user_message_evt: Text = "user_uttered", + bot_message_evt: Text = "bot_uttered", + namespace: Optional[Text] = None, + session_persistence: bool = False, + socketio_path: Optional[Text] = "/socket.io", + jwt_key: Optional[Text] = None, + jwt_method: Optional[Text] = "HS256", + metadata_key: Optional[Text] = "metadata", + ): + """Creates a ``SocketIOInput`` object.""" + self.bot_message_evt = bot_message_evt + self.session_persistence = session_persistence + self.user_message_evt = user_message_evt + self.namespace = namespace + self.socketio_path = socketio_path + self.sio: Optional[AsyncServer] = None + self.metadata_key = metadata_key + + self.jwt_key = jwt_key + self.jwt_algorithm = jwt_method + + def get_output_channel(self) -> Optional["OutputChannel"]: + """Creates socket.io output channel object.""" + if self.sio is None: + rasa.shared.utils.io.raise_warning( + "SocketIO output channel cannot be recreated. " + "This is expected behavior when using multiple Sanic " + "workers or multiple Rasa Open Source instances. " + "Please use a different channel for external events in these " + "scenarios." + ) + return None + return SocketIOOutput(self.sio, self.bot_message_evt) + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[Any]] + ) -> Blueprint: + """Defines a Sanic blueprint.""" + # Workaround so that socketio works with requests from other origins. + # https://github.com/miguelgrinberg/python-socketio/issues/205#issuecomment-493769183 + sio = AsyncServer(async_mode="sanic", cors_allowed_origins=[]) + socketio_webhook = SocketBlueprint( + sio, self.socketio_path, "socketio_webhook", __name__ + ) + + # make sio object static to use in get_output_channel + self.sio = sio + + @socketio_webhook.route("/", methods=["GET"]) + async def health(_: Request) -> HTTPResponse: + return response.json({"status": "ok"}) + + @sio.on("connect", namespace=self.namespace) + async def connect(sid: Text, environ: Dict, auth: Optional[Dict]) -> bool: + if self.jwt_key: + jwt_payload = None + if auth and auth.get("token"): + jwt_payload = rasa.core.channels.channel.decode_bearer_token( + auth.get("token"), self.jwt_key, self.jwt_algorithm + ) + + if jwt_payload: + logger.debug(f"User {sid} connected to socketIO endpoint.") + return True + else: + return False + else: + logger.debug(f"User {sid} connected to socketIO endpoint.") + return True + + @sio.on("disconnect", namespace=self.namespace) + async def disconnect(sid: Text) -> None: + logger.debug(f"User {sid} disconnected from socketIO endpoint.") + + @sio.on("session_request", namespace=self.namespace) + async def session_request(sid: Text, data: Optional[Dict]) -> None: + if data is None: + data = {} + if "session_id" not in data or data["session_id"] is None: + data["session_id"] = uuid.uuid4().hex + if self.session_persistence: + sio.enter_room(sid, data["session_id"]) + await sio.emit("session_confirm", data["session_id"], room=sid) + logger.debug(f"User {sid} connected to socketIO endpoint.") + + @sio.on(self.user_message_evt, namespace=self.namespace) + async def handle_message(sid: Text, data: Dict) -> None: + output_channel = SocketIOOutput(sio, self.bot_message_evt) + + if self.session_persistence: + if not data.get("session_id"): + rasa.shared.utils.io.raise_warning( + "A message without a valid session_id " + "was received. This message will be " + "ignored. Make sure to set a proper " + "session id using the " + "`session_request` socketIO event." + ) + return + sender_id = data["session_id"] + else: + sender_id = sid + + metadata = data.get(self.metadata_key, {}) + if isinstance(metadata, Text): + metadata = json.loads(metadata) + message = UserMessage( + data.get("message", ""), + output_channel, + sender_id, + input_channel=self.name(), + metadata=metadata, + ) + await on_new_message(message) + + return socketio_webhook diff --git a/rasa/core/channels/telegram.py b/rasa/core/channels/telegram.py new file mode 100644 index 0000000..2800edf --- /dev/null +++ b/rasa/core/channels/telegram.py @@ -0,0 +1,298 @@ +import asyncio +import json +import logging +from copy import deepcopy +from sanic import Blueprint, response +from sanic.request import Request +from sanic.response import HTTPResponse +from aiogram import Bot +from aiogram.types import ( + InlineKeyboardButton, + Update, + InlineKeyboardMarkup, + KeyboardButton, + ReplyKeyboardMarkup, + Message, +) +from aiogram.utils.exceptions import TelegramAPIError +from typing import Dict, Text, Any, List, Optional, Callable, Awaitable + +from rasa.core.channels.channel import InputChannel, UserMessage, OutputChannel +from rasa.shared.constants import INTENT_MESSAGE_PREFIX +from rasa.shared.core.constants import USER_INTENT_RESTART +from rasa.shared.exceptions import RasaException + +logger = logging.getLogger(__name__) + + +class TelegramOutput(Bot, OutputChannel): + """Output channel for Telegram.""" + + # skipcq: PYL-W0236 + @classmethod + def name(cls) -> Text: + return "telegram" + + def __init__(self, access_token: Optional[Text]) -> None: + super().__init__(access_token) + + async def send_text_message( + self, recipient_id: Text, text: Text, **kwargs: Any + ) -> None: + """Sends text message.""" + for message_part in text.strip().split("\n\n"): + await self.send_message(recipient_id, message_part) + + async def send_image_url( + self, recipient_id: Text, image: Text, **kwargs: Any + ) -> None: + """Sends an image.""" + await self.send_photo(recipient_id, image) + + async def send_text_with_buttons( + self, + recipient_id: Text, + text: Text, + buttons: List[Dict[Text, Any]], + button_type: Optional[Text] = "inline", + **kwargs: Any, + ) -> None: + """Sends a message with keyboard. + + For more information: https://core.telegram.org/bots#keyboards + + :button_type inline: horizontal inline keyboard + + :button_type vertical: vertical inline keyboard + + :button_type reply: reply keyboard + """ + if button_type == "inline": + reply_markup = InlineKeyboardMarkup() + button_list = [ + InlineKeyboardButton(s["title"], callback_data=s["payload"]) + for s in buttons + ] + reply_markup.row(*button_list) + + elif button_type == "vertical": + reply_markup = InlineKeyboardMarkup() + [ + reply_markup.row( + InlineKeyboardButton(s["title"], callback_data=s["payload"]) + ) + for s in buttons + ] + + elif button_type == "reply": + reply_markup = ReplyKeyboardMarkup( + resize_keyboard=False, one_time_keyboard=True + ) + # drop button_type from button_list + button_list = [b for b in buttons if b.get("title")] + for idx, button in enumerate(buttons): + if isinstance(button, list): + reply_markup.add(KeyboardButton(s["title"]) for s in button) + else: + reply_markup.add(KeyboardButton(button["title"])) + else: + logger.error( + "Trying to send text with buttons for unknown " + "button type {}".format(button_type) + ) + return + + await self.send_message(recipient_id, text, reply_markup=reply_markup) + + async def send_custom_json( + self, recipient_id: Text, json_message: Dict[Text, Any], **kwargs: Any + ) -> None: + """Sends a message with a custom json payload.""" + json_message = deepcopy(json_message) + + recipient_id = json_message.pop("chat_id", recipient_id) + + send_functions = { + ("text",): "send_message", + ("photo",): "send_photo", + ("audio",): "send_audio", + ("document",): "send_document", + ("sticker",): "send_sticker", + ("video",): "send_video", + ("video_note",): "send_video_note", + ("animation",): "send_animation", + ("voice",): "send_voice", + ("media",): "send_media_group", + ("latitude", "longitude", "title", "address"): "send_venue", + ("latitude", "longitude"): "send_location", + ("phone_number", "first_name"): "send_contact", + ("game_short_name",): "send_game", + ("action",): "send_chat_action", + ( + "title", + "decription", + "payload", + "provider_token", + "start_parameter", + "currency", + "prices", + ): "send_invoice", + } + + for params in send_functions.keys(): + if all(json_message.get(p) is not None for p in params): + args = [json_message.pop(p) for p in params] + api_call = getattr(self, send_functions[params]) + await api_call(recipient_id, *args, **json_message) + + +class TelegramInput(InputChannel): + """Telegram input channel""" + + @classmethod + def name(cls) -> Text: + return "telegram" + + @classmethod + def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel: + if not credentials: + cls.raise_missing_credentials_exception() + + return cls( + credentials.get("access_token"), + credentials.get("verify"), + credentials.get("webhook_url"), + ) + + def __init__( + self, + access_token: Optional[Text], + verify: Optional[Text], + webhook_url: Optional[Text], + debug_mode: bool = True, + ) -> None: + self.access_token = access_token + self.verify = verify + self.webhook_url = webhook_url + self.debug_mode = debug_mode + + @staticmethod + def _is_location(message: Message) -> bool: + return message.location is not None + + @staticmethod + def _is_user_message(message: Message) -> bool: + return message.text is not None + + @staticmethod + def _is_edited_message(message: Update) -> bool: + return message.edited_message is not None + + @staticmethod + def _is_button(message: Update) -> bool: + return message.callback_query is not None + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[Any]] + ) -> Blueprint: + telegram_webhook = Blueprint("telegram_webhook", __name__) + out_channel = self.get_output_channel() + + @telegram_webhook.route("/", methods=["GET"]) + async def health(_: Request) -> HTTPResponse: + return response.json({"status": "ok"}) + + @telegram_webhook.route("/set_webhook", methods=["GET", "POST"]) + async def set_webhook(_: Request) -> HTTPResponse: + s = await out_channel.set_webhook(self.webhook_url) + if s: + logger.info("Webhook Setup Successful") + return response.text("Webhook setup successful") + else: + logger.warning("Webhook Setup Failed") + return response.text("Invalid webhook") + + @telegram_webhook.route("/webhook", methods=["GET", "POST"]) + async def message(request: Request) -> Any: + if request.method == "POST": + + request_dict = request.json + if isinstance(request_dict, Text): + request_dict = json.loads(request_dict) + update = Update(**request_dict) + credentials = await out_channel.get_me() + if not credentials.username == self.verify: + logger.debug("Invalid access token, check it matches Telegram") + return response.text("failed") + + if self._is_button(update): + msg = update.callback_query.message + text = update.callback_query.data + elif self._is_edited_message(update): + msg = update.edited_message + text = update.edited_message.text + else: + msg = update.message + if self._is_user_message(msg): + text = msg.text.replace("/bot", "") + elif self._is_location(msg): + text = '{{"lng":{0}, "lat":{1}}}'.format( + msg.location.longitude, msg.location.latitude + ) + else: + return response.text("success") + sender_id = msg.chat.id + metadata = self.get_metadata(request) + try: + if text == (INTENT_MESSAGE_PREFIX + USER_INTENT_RESTART): + await on_new_message( + UserMessage( + text, + out_channel, + sender_id, + input_channel=self.name(), + metadata=metadata, + ) + ) + await on_new_message( + UserMessage( + "/start", + out_channel, + sender_id, + input_channel=self.name(), + metadata=metadata, + ) + ) + else: + await on_new_message( + UserMessage( + text, + out_channel, + sender_id, + input_channel=self.name(), + metadata=metadata, + ) + ) + except Exception as e: + logger.error(f"Exception when trying to handle message.{e}") + logger.debug(e, exc_info=True) + if self.debug_mode: + raise + pass + + return response.text("success") + + return telegram_webhook + + def get_output_channel(self) -> TelegramOutput: + """Loads the telegram channel.""" + channel = TelegramOutput(self.access_token) + + try: + asyncio.run(channel.set_webhook(url=self.webhook_url)) + except TelegramAPIError as error: + raise RasaException( + "Failed to set channel webhook: " + str(error) + ) from error + + return channel diff --git a/rasa/core/channels/twilio.py b/rasa/core/channels/twilio.py new file mode 100644 index 0000000..54f445e --- /dev/null +++ b/rasa/core/channels/twilio.py @@ -0,0 +1,169 @@ +import logging +from sanic import Blueprint, response +from sanic.request import Request +from sanic.response import HTTPResponse +from twilio.base.exceptions import TwilioRestException +from twilio.rest import Client +from typing import Dict, Text, Any, Callable, Awaitable, Optional, TYPE_CHECKING + +from rasa.core.channels.channel import InputChannel +from rasa.core.channels.channel import UserMessage, OutputChannel + +if TYPE_CHECKING: + from twilio.rest.api.v2010.account.message import MessageInstance + +logger = logging.getLogger(__name__) + + +class TwilioOutput(Client, OutputChannel): + """Output channel for Twilio.""" + + @classmethod + def name(cls) -> Text: + return "twilio" + + def __init__( + self, + account_sid: Optional[Text], + auth_token: Optional[Text], + twilio_number: Optional[Text], + ) -> None: + super().__init__(account_sid, auth_token) + self.twilio_number = twilio_number + self.send_retry = 0 + self.max_retry = 5 + + async def _send_message(self, message_data: Dict[Text, Any]) -> "MessageInstance": + message = None + try: + while not message and self.send_retry < self.max_retry: + message = self.messages.create(**message_data) + self.send_retry += 1 + except TwilioRestException as e: + logger.error("Something went wrong " + repr(e.msg)) + finally: + self.send_retry = 0 + + if not message and self.send_retry == self.max_retry: + logger.error("Failed to send message. Max number of retires exceeded.") + + return message + + async def send_text_message( + self, recipient_id: Text, text: Text, **kwargs: Any + ) -> None: + """Sends text message.""" + message_data = {"to": recipient_id, "from_": self.twilio_number} + for message_part in text.strip().split("\n\n"): + message_data.update({"body": message_part}) + await self._send_message(message_data) + + async def send_image_url( + self, recipient_id: Text, image: Text, **kwargs: Any + ) -> None: + """Sends an image.""" + message_data = { + "to": recipient_id, + "from_": self.twilio_number, + "media_url": [image], + } + await self._send_message(message_data) + + async def send_custom_json( + self, recipient_id: Text, json_message: Dict[Text, Any], **kwargs: Any + ) -> None: + """Send custom json dict.""" + json_message.setdefault("to", recipient_id) + if not json_message.get("media_url"): + json_message.setdefault("body", "") + if not json_message.get("messaging_service_sid"): + json_message.setdefault("from_", self.twilio_number) + + await self._send_message(json_message) + + +class TwilioInput(InputChannel): + """Twilio input channel.""" + + @classmethod + def name(cls) -> Text: + return "twilio" + + @classmethod + def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel: + if not credentials: + cls.raise_missing_credentials_exception() + + return cls( + credentials.get("account_sid"), + credentials.get("auth_token"), + credentials.get("twilio_number"), + ) + + @classmethod + def _is_location_message(cls, request: Request) -> bool: + """Check if the users message is a location.""" + return request.form.get("Latitude") and request.form.get("Longitude") + + def __init__( + self, + account_sid: Optional[Text], + auth_token: Optional[Text], + twilio_number: Optional[Text], + debug_mode: bool = True, + ) -> None: + self.account_sid = account_sid + self.auth_token = auth_token + self.twilio_number = twilio_number + self.debug_mode = debug_mode + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[Any]] + ) -> Blueprint: + twilio_webhook = Blueprint("twilio_webhook", __name__) + + @twilio_webhook.route("/", methods=["GET"]) + async def health(_: Request) -> HTTPResponse: + return response.json({"status": "ok"}) + + @twilio_webhook.route("/webhook", methods=["POST"]) + async def message(request: Request) -> HTTPResponse: + sender = request.form.get("From", None) + text = request.form.get("Body", None) + out_channel = self.get_output_channel() + + if self._is_location_message(request): + # Text is always None with a location message/media from Twilio whatsapp + text = "/locationData{{'Latitude': {lat},'Longitude': {long}}}".format( + lat=request.form.get("Latitude"), long=request.form.get("Longitude") + ) + + if sender is not None and message is not None: + metadata = self.get_metadata(request) + try: + # @ signs get corrupted in SMSes by some carriers + text = text.replace("¡", "@") + await on_new_message( + UserMessage( + text, + out_channel, + sender, + input_channel=self.name(), + metadata=metadata, + ) + ) + except Exception as e: + logger.error(f"Exception when trying to handle message.{e}") + logger.debug(e, exc_info=True) + if self.debug_mode: + raise + pass + else: + logger.debug("Invalid message") + + return response.text("", status=204) + + return twilio_webhook + + def get_output_channel(self) -> OutputChannel: + return TwilioOutput(self.account_sid, self.auth_token, self.twilio_number) diff --git a/rasa/core/channels/twilio_voice.py b/rasa/core/channels/twilio_voice.py new file mode 100644 index 0000000..ca22ddd --- /dev/null +++ b/rasa/core/channels/twilio_voice.py @@ -0,0 +1,363 @@ +from sanic import Blueprint, response +from sanic.request import Request +from sanic.response import HTTPResponse +from twilio.twiml.voice_response import VoiceResponse, Gather +from typing import Text, Callable, Awaitable, List, Any, Dict, Optional + +import rasa.utils.io +import rasa.shared.utils.io +from rasa.shared.core.events import BotUttered +from rasa.shared.exceptions import InvalidConfigException +from rasa.core.channels.channel import ( + InputChannel, + CollectingOutputChannel, + UserMessage, +) + + +class TwilioVoiceInput(InputChannel): + """Input channel for Twilio Voice.""" + + SUPPORTED_VOICES = [ + "man", + "woman", + "alice", + "Polly.Mads", + "Polly.Naja", + "Polly.Lotte", + "Polly.Reuben", + "Polly.Nicole", + "Polly.Russell", + "Polly.Amy", + "Polly.Brian", + "Polly.Emma", + "Polly.Amy-Neural", + "Polly.Emma-Neural", + "Polly.Brian-Neural", + "Polly.Raveena", + "Polly.Ivy", + "Polly.Joanna", + "Polly.Joey", + "Polly.Justin", + "Polly.Kendra", + "Polly.Kimberly", + "Polly.Matthew", + "Polly.Salli", + "Polly.Ivy-Neural", + "Polly.Joanna-Neural", + "Polly.Kendra-Neural", + "Polly.Kimberly-Neural", + "Polly.Sally-Neural", + "Polly.Joey-Neural", + "Polly.Justin-Neural", + "Polly.Matthew-Neural", + "Polly.Geraint", + "Polly.Celine", + "Polly.Mathieu", + "Polly.Chantal", + "Polly.Hans", + "Polly.Marlene", + "Polly.Vicki", + "Polly.Dora", + "Polly.Karl", + "Polly.Carla", + "Polly.Giorgio", + "Polly.Mizuki", + "Polly.Takumi", + "Polly.Liv", + "Polly.Jacek", + "Polly.Jan", + "Polly.Ewa", + "Polly.Maja", + "Polly.Ricardo", + "Polly.Vitoria", + "Polly.Camila-Neural", + "Polly.Cristiano", + "Polly.Ines", + "Polly.Carmen", + "Polly.Maxim", + "Polly.Tatyana", + "Polly.Conchita", + "Polly.Enrique", + "Polly.Miguel", + "Polly.Penelope", + "Polly.Lupe-Neural", + "Polly.Astrid", + "Polly.Filiz", + "Polly.Gwyneth", + "Polly.Aditi", + ] + + SUPPORTED_SPEECH_MODELS = ["default", "numbers_and_commands", "phone_call"] + + @classmethod + def name(cls) -> Text: + """Name of channel.""" + return "twilio_voice" + + @classmethod + def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel: + """Load custom configurations.""" + credentials = credentials or {} + + return cls( + credentials.get("initial_prompt", "hello"), + credentials.get( + "reprompt_fallback_phrase", + "I'm sorry I didn't get that could you rephrase.", + ), + credentials.get("assistant_voice", "woman"), + credentials.get("speech_timeout", "5"), + credentials.get("speech_model", "default"), + credentials.get("enhanced", "false"), + ) + + def __init__( + self, + initial_prompt: Optional[Text], + reprompt_fallback_phrase: Optional[Text], + assistant_voice: Optional[Text], + speech_timeout: Text = "5", + speech_model: Text = "default", + enhanced: Text = "false", + ) -> None: + """Creates a connection to Twilio voice. + + Args: + initial_prompt: text to use to prompt a conversation when call is answered. + reprompt_fallback_phrase: phrase to use if no user response. + assistant_voice: name of the assistant voice to use. + speech_timeout: how long to pause when user finished speaking. + speech_model: type of transcription model to use from Twilio. + enhanced: toggle to use Twilio's premium speech transcription model. + """ + self.initial_prompt = initial_prompt + self.reprompt_fallback_phrase = reprompt_fallback_phrase + self.assistant_voice = assistant_voice + self.speech_timeout = speech_timeout + self.speech_model = speech_model + self.enhanced = enhanced + + self._validate_configuration() + + def _validate_configuration(self) -> None: + """Checks that the user configurations are valid.""" + if self.assistant_voice not in self.SUPPORTED_VOICES: + self._raise_invalid_voice_exception() + + try: + int(self.speech_timeout) + except ValueError: + if self.speech_timeout.lower() != "auto": + self._raise_invalid_speech_timeout_exception() + + if self.speech_model not in self.SUPPORTED_SPEECH_MODELS: + self._raise_invalid_speech_model_exception() + + if self.enhanced.lower() not in [ + "true", + "false", + ]: + self._raise_invalid_enhanced_option_exception() + + if ( + self.enhanced.lower() == "true" + and self.speech_model.lower() != "phone_call" + ): + self._raise_invalid_enhanced_speech_model_exception() + + if ( + self.speech_model.lower() != "numbers_and_commands" + and self.speech_timeout.lower() == "auto" + ): + self._raise_invalid_speech_model_timeout_exception() + + def _raise_invalid_speech_model_timeout_exception(self) -> None: + """Raises an error if incompatible speech_timeout and speech_model used.""" + raise InvalidConfigException( + "If speech_timeout is 'auto' the speech_model must be " + "'numbers_and_commands'. Please update your speech_model " + "to be 'numbers_and_commands' if you would like to continue " + "using the 'auto' speech_model." + ) + + def _raise_invalid_enhanced_option_exception(self) -> None: + """Raises an error if an invalid value is passed to the enhanced parameter.""" + raise InvalidConfigException( + f"The value {self.enhanced} is invalid for the enhanced parameter. " + f"You must provide either `true` or `false` for this value." + ) + + def _raise_invalid_speech_model_exception(self) -> None: + """Raises an error if an invalid speech_model is provided.""" + raise InvalidConfigException( + f"The value {self.speech_model} for speech_model is invalid. " + f"You must choose one of 'default', 'numbers_and_commands', " + f"or 'phone_call'. Refer to the documentation for details " + f"about the selections." + ) + + def _raise_invalid_speech_timeout_exception(self) -> None: + """Raises an error if an invalid speech_timeout is provided.""" + raise InvalidConfigException( + f"The vale {self.speech_timeout} is an invalid value for speech_timeout. " + f"Only integers and 'auto' are valid entries." + ) + + def _raise_invalid_voice_exception(self) -> None: + """Raises an error if an invalid voice is provided.""" + raise InvalidConfigException( + f"The value {self.assistant_voice} is an invalid for assistant_voice. " + f"Please refer to the documentation for a list of valid voices " + f"you can use for your voice assistant." + ) + + def _raise_invalid_enhanced_speech_model_exception(self) -> None: + """Raises error if enhanced is used with an incompatible speech_model.""" + raise InvalidConfigException( + f"If you set enhanced to 'true' then speech_model must be 'phone_call'. " + f"Current speech_model is: {self.speech_model}." + ) + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[None]] + ) -> Blueprint: + """Defines endpoints for Twilio voice channel.""" + twilio_voice_webhook = Blueprint("Twilio_voice_webhook", __name__) + + @twilio_voice_webhook.route("/", methods=["GET"]) + async def health(request: Request) -> HTTPResponse: + return response.json({"status": "ok"}) + + @twilio_voice_webhook.route("/webhook", methods=["POST"]) + async def receive(request: Request) -> HTTPResponse: + sender_id = request.form.get("From") + text = request.form.get("SpeechResult") + input_channel = self.name() + call_status = request.form.get("CallStatus") + + collector = TwilioVoiceCollectingOutputChannel() + + # Provide an initial greeting to answer the user's call. + if (text is None) and (call_status == "ringing"): + text = self.initial_prompt + + # determine the response. + if text is not None: + await on_new_message( + UserMessage(text, collector, sender_id, input_channel=input_channel) + ) + + twilio_response = self._build_twilio_voice_response(collector.messages) + # If the user doesn't respond resend the last message. + else: + # Get last user utterance from tracker. + tracker = await request.app.ctx.agent.tracker_store.retrieve(sender_id) + last_response = None + if tracker: + last_response = next( + ( + e + for e in reversed(tracker.events) + if isinstance(e, BotUttered) + ), + None, + ) + + # If no previous utterance found use the reprompt_fallback phrase. + if last_response is None: + last_response_text = self.reprompt_fallback_phrase + else: + last_response_text = last_response.text + + twilio_response = self._build_twilio_voice_response( + [{"text": last_response_text}] + ) + return response.text(str(twilio_response), content_type="text/xml") + + return twilio_voice_webhook + + def _build_twilio_voice_response( + self, messages: List[Dict[Text, Any]] + ) -> VoiceResponse: + """Builds the Twilio Voice Response object.""" + voice_response = VoiceResponse() + gather = Gather( + input="speech", + action=f"/webhooks/{self.name()}/webhook", + actionOnEmptyResult=True, + speechTimeout=self.speech_timeout, + speechModel=self.speech_model, + enhanced=self.enhanced, + ) + + # Add pauses between messages. + # Add a listener to the last message to listen for user response. + for i, message in enumerate(messages): + msg_text = message["text"] + if i + 1 == len(messages): + gather.say(msg_text, voice=self.assistant_voice) + voice_response.append(gather) + else: + voice_response.say(msg_text, voice=self.assistant_voice) + voice_response.pause(length=1) + + return voice_response + + +class TwilioVoiceCollectingOutputChannel(CollectingOutputChannel): + """Output channel that collects send messages in a list. + + (doesn't send them anywhere, just collects them). + """ + + @classmethod + def name(cls) -> Text: + """Name of the output channel.""" + return "twilio_voice" + + @staticmethod + def _emoji_warning(text: Text) -> None: + """Raises a warning if text contains an emoji.""" + emoji_regex = rasa.utils.io.get_emoji_regex() + if emoji_regex.findall(text): + rasa.shared.utils.io.raise_warning( + "Text contains an emoji in a voice response. " + "Review responses to provide a voice-friendly alternative." + ) + + async def send_text_message( + self, recipient_id: Text, text: Text, **kwargs: Any + ) -> None: + """Sends the text message after removing emojis.""" + self._emoji_warning(text) + for message_part in text.strip().split("\n\n"): + await self._persist_message(self._message(recipient_id, text=message_part)) + + async def send_text_with_buttons( + self, + recipient_id: Text, + text: Text, + buttons: List[Dict[Text, Any]], + **kwargs: Any, + ) -> None: + """Convert buttons into a voice representation.""" + self._emoji_warning(text) + await self._persist_message(self._message(recipient_id, text=text)) + + for b in buttons: + self._emoji_warning(b["title"]) + await self._persist_message(self._message(recipient_id, text=b["title"])) + + async def send_image_url( + self, recipient_id: Text, image: Text, **kwargs: Any + ) -> None: + """For voice channel do not send images.""" + rasa.shared.utils.io.raise_warning( + "An image was removed from the voice message and " + "only the text of message was sent. " + "It's recommended that you define voice-friendly " + "alternatives for all responses " + "with a visual elements such as images and emojis " + "that are used in your voice channel." + ) diff --git a/rasa/core/channels/webexteams.py b/rasa/core/channels/webexteams.py new file mode 100644 index 0000000..522d7e2 --- /dev/null +++ b/rasa/core/channels/webexteams.py @@ -0,0 +1,136 @@ +import logging +from sanic import Blueprint, response +from sanic.request import Request +from typing import Text, Optional, Dict, Any, Callable, Awaitable + +from sanic.response import HTTPResponse +from webexteamssdk import WebexTeamsAPI, Webhook + +from rasa.core.channels.channel import InputChannel +from rasa.core.channels.channel import UserMessage, OutputChannel + +logger = logging.getLogger(__name__) + + +class WebexTeamsBot(OutputChannel): + """A Cisco WebexTeams communication channel.""" + + @classmethod + def name(cls) -> Text: + return "webexteams" + + def __init__(self, access_token: Optional[Text], room: Optional[Text]) -> None: + self.room = room + self.api = WebexTeamsAPI(access_token) + + async def send_text_message( + self, recipient_id: Text, text: Text, **kwargs: Any + ) -> None: + recipient = self.room or recipient_id + for message_part in text.strip().split("\n\n"): + self.api.messages.create(roomId=recipient, text=message_part) + + async def send_image_url( + self, recipient_id: Text, image: Text, **kwargs: Any + ) -> None: + recipient = self.room or recipient_id + return self.api.messages.create(roomId=recipient, files=[image]) + + async def send_custom_json( + self, recipient_id: Text, json_message: Dict[Text, Any], **kwargs: Any + ) -> None: + json_message.setdefault("roomId", recipient_id) + return self.api.messages.create(**json_message) + + +class WebexTeamsInput(InputChannel): + """WebexTeams input channel. Based on the HTTPInputChannel.""" + + @classmethod + def name(cls) -> Text: + return "webexteams" + + @classmethod + def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel: + if not credentials: + cls.raise_missing_credentials_exception() + + return cls(credentials.get("access_token"), credentials.get("room")) + + def __init__(self, access_token: Text, room: Optional[Text] = None) -> None: + """Create a Cisco Webex Teams input channel. + + Needs a couple of settings to properly authenticate and validate + messages. Details here https://developer.webex.com/authentication.html + + Args: + access_token: Cisco WebexTeams bot access token. + room: the string identifier for a room to which the bot posts + """ + self.token = access_token + self.room = room + self.api = WebexTeamsAPI(access_token) + + async def process_message( + self, + on_new_message: Callable[[UserMessage], Awaitable[Any]], + text: Optional[Text], + sender_id: Optional[Text], + metadata: Optional[Dict], + ) -> Any: + + try: + out_channel = self.get_output_channel() + user_msg = UserMessage( + text, + out_channel, + sender_id, + input_channel=self.name(), + metadata=metadata, + ) + await on_new_message(user_msg) + except Exception as e: + logger.error(f"Exception when trying to handle message.{e}") + logger.error(str(e), exc_info=True) + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[Any]] + ) -> Blueprint: + webexteams_webhook = Blueprint("webexteams_webhook", __name__) + + @webexteams_webhook.route("/", methods=["GET"]) + async def health(_: Request) -> HTTPResponse: + return response.json({"status": "ok"}) + + @webexteams_webhook.route("/webhook", methods=["POST"]) + async def webhook(request: Request) -> HTTPResponse: + """Respond to inbound webhook HTTP POST from Webex Teams.""" + + logger.debug("Received webex webhook call") + # Get the POST data sent from Webex Teams + json_data = request.json + + # Create a Webhook object from the JSON data + webhook_obj = Webhook(json_data) + # Get the message details + message = self.api.messages.get(webhook_obj.data.id) + + # This is a VERY IMPORTANT loop prevention control step. + # If you respond to all messages... You will respond to the + # messages that the bot posts and thereby create a loop + me = self.api.people.me() + if message.personId == me.id: + # Message was sent by me (bot); do not respond. + return response.text("OK") + + else: + metadata = self.get_metadata(request) + await self.process_message( + on_new_message, message.text, message.roomId, metadata + ) + return response.text("") + + return webexteams_webhook + + def get_output_channel(self) -> OutputChannel: + return WebexTeamsBot(self.token, self.room) diff --git a/rasa/core/constants.py b/rasa/core/constants.py new file mode 100644 index 0000000..40d65c3 --- /dev/null +++ b/rasa/core/constants.py @@ -0,0 +1,83 @@ +DEFAULT_SERVER_PORT = 5005 + +DEFAULT_SERVER_INTERFACE = "0.0.0.0" + +DEFAULT_SERVER_FORMAT = "{}://localhost:{}" + +DEFAULT_SERVER_URL = DEFAULT_SERVER_FORMAT.format("http", DEFAULT_SERVER_PORT) + +DEFAULT_INTERACTIVE_SERVER_URL = "{}://localhost:{}" + +DEFAULT_NLU_FALLBACK_THRESHOLD = 0.3 + +DEFAULT_NLU_FALLBACK_AMBIGUITY_THRESHOLD = 0.1 + +DEFAULT_CORE_FALLBACK_THRESHOLD = 0.3 + +DEFAULT_MAX_HISTORY = None # Core policy history is unbounded by default. + +DEFAULT_RESPONSE_TIMEOUT = 60 * 60 # 1 hour + +DEFAULT_REQUEST_TIMEOUT = 60 * 5 # 5 minutes + +DEFAULT_STREAM_READING_TIMEOUT = 10 # in seconds + +DEFAULT_LOCK_LIFETIME = 60 # in seconds + +DEFAULT_KEEP_ALIVE_TIMEOUT = 120 # in seconds + +BEARER_TOKEN_PREFIX = "Bearer " + +# The lowest priority is intended to be used by machine learning policies. +DEFAULT_POLICY_PRIORITY = 1 + +# The priority of intent-prediction policies. +# This should be below all rule based policies but higher than ML +# based policies. This enables a loop inside ensemble where if none +# of the rule based policies predict an action and intent prediction +# policy predicts one, its prediction is chosen by the ensemble and +# then the ML based policies are again run to get the prediction for +# an actual action. To prevent an infinite loop, intent prediction +# policies only predict an action if the last event in +# the tracker is of type `UserUttered`. Hence, they make at most +# one action prediction in each conversation turn. This allows other +# policies to predict a winning action prediction. +UNLIKELY_INTENT_POLICY_PRIORITY = DEFAULT_POLICY_PRIORITY + 1 + +# The priority intended to be used by memoization policies. +# It is higher than default to prioritize training stories. +MEMOIZATION_POLICY_PRIORITY = UNLIKELY_INTENT_POLICY_PRIORITY + 1 +# The priority of the `RulePolicy` is higher than all other policies since +# rule execution takes precedence over training stories or predicted actions. +RULE_POLICY_PRIORITY = MEMOIZATION_POLICY_PRIORITY + 1 + +DIALOGUE = "dialogue" + +# RabbitMQ message property header added to events published using `rasa export` +RASA_EXPORT_PROCESS_ID_HEADER_NAME = "rasa-export-process-id" + +# Name of the environment variable defining the PostgreSQL schema to access. See +# https://www.postgresql.org/docs/9.1/ddl-schemas.html for more details. +POSTGRESQL_SCHEMA = "POSTGRESQL_SCHEMA" + +# Names of the environment variables defining PostgreSQL pool size and max overflow +POSTGRESQL_POOL_SIZE = "SQL_POOL_SIZE" +POSTGRESQL_MAX_OVERFLOW = "SQL_MAX_OVERFLOW" + +# File names for testing +CONFUSION_MATRIX_STORIES_FILE = "story_confusion_matrix.png" +REPORT_STORIES_FILE = "story_report.json" +FAILED_STORIES_FILE = "failed_test_stories.yml" +SUCCESSFUL_STORIES_FILE = "successful_test_stories.yml" +STORIES_WITH_WARNINGS_FILE = "stories_with_warnings.yml" + +POLICY_PRIORITY = "priority" +POLICY_FEATURIZER = "featurizer" +POLICY_MAX_HISTORY = "max_history" + +DEFAULT_PROTOCOL = "UDP" +DEFAULT_SYSLOG_HOST = "localhost" +DEFAULT_SYSLOG_PORT = 514 + +COMPRESS_ACTION_SERVER_REQUEST_ENV_NAME = "COMPRESS_ACTION_SERVER_REQUEST" +DEFAULT_COMPRESS_ACTION_SERVER_REQUEST = False diff --git a/rasa/core/evaluation/__init__.py b/rasa/core/evaluation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/core/evaluation/marker.py b/rasa/core/evaluation/marker.py new file mode 100644 index 0000000..7b69b4b --- /dev/null +++ b/rasa/core/evaluation/marker.py @@ -0,0 +1,267 @@ +from rasa.shared.core.domain import Domain +from typing import Optional, Text, List +from rasa.core.evaluation.marker_base import ( + OperatorMarker, + ConditionMarker, + MarkerRegistry, + Marker, +) +from rasa.shared.core.events import ActionExecuted, SlotSet, UserUttered, Event +import logging + +logger = logging.getLogger(__name__) + + +@MarkerRegistry.configurable_marker +class AndMarker(OperatorMarker): + """Checks that all sub-markers apply.""" + + @staticmethod + def positive_tag() -> Text: + """Returns the tag to be used in a config file.""" + return "and" + + @staticmethod + def negated_tag() -> Text: + """Returns the tag to be used in a config file for the negated version.""" + return "at_least_one_not" + + def _non_negated_version_applies_at(self, event: Event) -> bool: + return all(marker.history[-1] for marker in self.sub_markers) + + +@MarkerRegistry.configurable_marker +class OrMarker(OperatorMarker): + """Checks that at least one sub-marker applies.""" + + @staticmethod + def positive_tag() -> Text: + """Returns the tag to be used in a config file.""" + return "or" + + def _non_negated_version_applies_at(self, event: Event) -> bool: + return any(marker.history[-1] for marker in self.sub_markers) + + +@MarkerRegistry.configurable_marker +class NotMarker(OperatorMarker): + """Checks that at least one sub-marker applies.""" + + @staticmethod + def positive_tag() -> Text: + """Returns the tag to be used in a config file.""" + return "not" + + @staticmethod + def expected_number_of_sub_markers() -> Optional[int]: + """Returns the expected number of sub-markers (if there is any).""" + return 1 + + def _non_negated_version_applies_at(self, event: Event) -> bool: + return not self.sub_markers[0].history[-1] + + +@MarkerRegistry.configurable_marker +class SequenceMarker(OperatorMarker): + """Checks that all sub-markers apply consecutively in the specified order. + + The sequence marker application follows two rules: + (1) Given a sequence of sub-markers `m_0, m_1,...,m_n`, the sequence marker applies + at the `i`-th event if all sub-markers successively applied to some previous + events and the last sub-marker applies at the current `i`-th events. + (2) If the sequence marker applies at the `i`-th event, then for it's next + application the events up to the `i`-th event will be ignored. + + """ + + def __init__( + self, markers: List[Marker], negated: bool = False, name: Optional[Text] = None + ) -> None: + """Instantiate a new sequence marker. + + Args: + markers: the sub-markers listed in the expected order + negated: whether this marker should be negated (i.e. a negated marker + applies if and only if the non-negated marker does not apply) + name: a custom name that can be used to replace the default string + conversion of this marker + """ + super().__init__(markers=markers, negated=negated, name=name) + self._progress: int = 0 + + @staticmethod + def positive_tag() -> Text: + """Returns the tag to be used in a config file.""" + return "seq" + + def _to_str_with(self, tag: Text) -> Text: + sub_markers_str = " -> ".join(str(marker) for marker in self.sub_markers) + return f"{tag}({sub_markers_str})" + + def _non_negated_version_applies_at(self, event: Event) -> bool: + # Remember that all the sub-markers have been updated before this tracker. + # Hence, whether the sub-markers apply to the current `event` is stored in the + # last item of their history. Hence, to check if we made some progress in + # identifying a sequence, we can simply check: + if self.sub_markers[self._progress].history[-1]: + self._progress += 1 + # If we were able to apply every sub-marker once, we reset our progress: + if self._progress == len(self.sub_markers): + self._progress = 0 + return True + return False + + +@MarkerRegistry.configurable_marker +class OccurrenceMarker(OperatorMarker): + """Checks that all sub-markers applied at least once in history. + + It doesn't matter if the sub markers stop applying later in history. If they + applied at least once they will always evaluate to `True`. + """ + + @staticmethod + def positive_tag() -> Text: + """Returns the tag to be used in a config file.""" + return "at_least_once" + + @staticmethod + def negated_tag() -> Optional[Text]: + """Returns the tag to be used in a config file for the negated version.""" + return "never" + + @staticmethod + def expected_number_of_sub_markers() -> Optional[int]: + """Returns the expected number of sub-markers (if there is any).""" + return 1 + + def _non_negated_version_applies_at(self, event: Event) -> bool: + occurred_before = False + + if self.history: + occurred_before = self.history[-1] + if self.negated: + occurred_before = not occurred_before + + return occurred_before or self.sub_markers[0].history[-1] + + def relevant_events(self) -> List[int]: + """Only return index of first match (see parent class for full docstring).""" + try: + return [self.history.index(True)] + except ValueError: + return [] + + +@MarkerRegistry.configurable_marker +class ActionExecutedMarker(ConditionMarker): + """Checks whether an action is executed at the current step.""" + + @staticmethod + def positive_tag() -> Text: + """Returns the tag to be used in a config file.""" + return "action" + + @staticmethod + def negated_tag() -> Optional[Text]: + """Returns the tag to be used in a config file for the negated version.""" + return "not_action" + + def validate_against_domain(self, domain: Domain) -> bool: + """Checks that this marker (and its children) refer to entries in the domain. + + Args: + domain: The domain to check against + """ + valid = self.text in domain.action_names_or_texts + + if not valid: + logger.error( + f"Referenced action '{self.text}' does not exist in the domain" + ) + + return valid + + def _non_negated_version_applies_at(self, event: Event) -> bool: + return isinstance(event, ActionExecuted) and event.action_name == self.text + + +@MarkerRegistry.configurable_marker +class IntentDetectedMarker(ConditionMarker): + """Checks whether an intent is expressed at the current step. + + More precisely it applies at an event if this event is a `UserUttered` event + where either (1) the retrieval intent or (2) just the intent coincides with + the specified text. + """ + + @staticmethod + def positive_tag() -> Text: + """Returns the tag to be used in a config file.""" + return "intent" + + @staticmethod + def negated_tag() -> Optional[Text]: + """Returns the tag to be used in a config file for the negated version.""" + return "not_intent" + + def validate_against_domain(self, domain: Domain) -> bool: + """Checks that this marker (and its children) refer to entries in the domain. + + Args: + domain: The domain to check against + """ + valid = self.text in domain.intent_properties + + if not valid: + logger.error( + f"Referenced intent '{self.text}' does not exist in the domain" + ) + + return valid + + def _non_negated_version_applies_at(self, event: Event) -> bool: + return isinstance(event, UserUttered) and self.text in [ + event.intent_name, + event.full_retrieval_intent_name, + ] + + +@MarkerRegistry.configurable_marker +class SlotSetMarker(ConditionMarker): + """Checks whether a slot is set at the current step. + + The actual `SlotSet` event might have happened at an earlier step. + """ + + @staticmethod + def positive_tag() -> Text: + """Returns the tag to be used in a config file.""" + return "slot_was_set" + + @staticmethod + def negated_tag() -> Optional[Text]: + """Returns the tag to be used in a config file for the negated version.""" + return "slot_was_not_set" + + def validate_against_domain(self, domain: Domain) -> bool: + """Checks that this marker (and its children) refer to entries in the domain. + + Args: + domain: The domain to check against. + """ + valid = any(self.text == slot.name for slot in domain.slots) + + if not valid: + logger.error(f"Referenced slot '{self.text}' does not exist in the domain") + + return valid + + def _non_negated_version_applies_at(self, event: Event) -> bool: + if isinstance(event, SlotSet) and event.key == self.text: + # slot is set if and only if it's value is not `None` + return event.value is not None + if self.history: + was_set = self.history[-1] if not self.negated else not self.history[-1] + return was_set + return False diff --git a/rasa/core/evaluation/marker_base.py b/rasa/core/evaluation/marker_base.py new file mode 100644 index 0000000..f942ff3 --- /dev/null +++ b/rasa/core/evaluation/marker_base.py @@ -0,0 +1,921 @@ +from __future__ import annotations +import os +from abc import ABC, abstractmethod +from typing import ( + Dict, + Iterator, + Optional, + Set, + Text, + List, + Tuple, + Type, + TypeVar, + TYPE_CHECKING, + Union, + Any, + AsyncIterator, +) + +from pathlib import Path +from dataclasses import dataclass + +import rasa.shared.core.constants +import rasa.shared.nlu.constants +import rasa.shared.utils.validation +import rasa.shared.utils.io +import rasa.shared.utils.common +from rasa.shared.data import is_likely_yaml_file +from rasa.shared.exceptions import InvalidConfigException, RasaException +from rasa.shared.core.events import ActionExecuted, UserUttered, Event +from rasa import telemetry +from rasa.shared.core.domain import Domain +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.utils.io import WriteRow +from rasa.shared.constants import DOCS_URL_MARKERS + +import logging +import csv +import os.path + +if TYPE_CHECKING: + from rasa.core.evaluation.marker import OrMarker + +logger = logging.getLogger(__name__) + + +class MarkerRegistry: + """Keeps track of tags that can be used to configure markers.""" + + all_tags: Set[Text] = set() + condition_tag_to_marker_class: Dict[Text, Type[ConditionMarker]] = {} + operator_tag_to_marker_class: Dict[Text, Type[OperatorMarker]] = {} + marker_class_to_tag: Dict[Type[Marker], Text] = {} + negated_tag_to_tag: Dict[Text, Text] = {} + tag_to_negated_tag: Dict[Text, Text] = {} + + @classmethod + def register_builtin_markers(cls) -> None: + """Must import all modules containing markers.""" + import rasa.core.evaluation.marker # noqa + + @classmethod + def configurable_marker(cls, marker_class: Type[Marker]) -> Type[Marker]: + """Decorator used to register a marker that can be used in config files. + + Args: + marker_class: the marker class to be made available via config files + Returns: + the registered marker class + """ + if not issubclass(marker_class, Marker): + raise RuntimeError("Can only register marker classes as configurable.") + + tag = marker_class.positive_tag() + negated_tag = marker_class.negated_tag() + cls._register_tags(tags={tag, negated_tag}) + cls._register_tag_class(marker_class=marker_class, positive_tag=tag) + cls._register_negation(tag=tag, negated_tag=negated_tag) + return marker_class + + @classmethod + def _register_tags(cls, tags: Set[Optional[Text]]) -> None: + specified_tags = {tag for tag in tags if tag is not None} + for tag_ in specified_tags: + if tag_ in cls.all_tags: + raise RuntimeError( + "Expected the tags of all configurable markers to be " + "identifiable by their tag." + ) + cls.all_tags.add(tag_) + + @classmethod + def _register_negation(cls, tag: Text, negated_tag: Optional[Text]) -> None: + if negated_tag is not None: + cls.negated_tag_to_tag[negated_tag] = tag + cls.tag_to_negated_tag[tag] = negated_tag + + @classmethod + def get_non_negated_tag(cls, tag_or_negated_tag: Text) -> Tuple[Text, bool]: + """Returns the non-negated marker tag, given a (possible) negated marker tag. + + Args: + tag_or_negated_tag: the tag for a possibly negated marker + Returns: + the tag itself if it was already positive, otherwise the positive version; + and a boolean that represents whether the given tag was a negative one + """ + # either the given `marker_name` is already "positive" (e.g. "slot was set") + # or there is an entry mapping it to it's positive version: + positive_version = cls.negated_tag_to_tag.get( + tag_or_negated_tag, tag_or_negated_tag + ) + is_negation = tag_or_negated_tag != positive_version + return positive_version, is_negation + + @classmethod + def _register_tag_class( + cls, marker_class: Type[Marker], positive_tag: Text + ) -> None: + if issubclass(marker_class, ConditionMarker): + cls.condition_tag_to_marker_class[positive_tag] = marker_class + elif issubclass(marker_class, OperatorMarker): + cls.operator_tag_to_marker_class[positive_tag] = marker_class + else: + raise RuntimeError( + f"Can only register `{OperatorMarker.__name__} or " + f" {ConditionMarker.__name__} subclasses." + ) + cls.marker_class_to_tag[marker_class] = positive_tag + + +class InvalidMarkerConfig(RasaException): + """Exception that can be raised when the config for a marker is not valid.""" + + +@dataclass +class EventMetaData: + """Describes meta data per event in some session.""" + + idx: int + preceding_user_turns: int + + +# We evaluate markers separately against every session and extract, for every marker +# that we want to evaluate, the meta data of the respective relevant events where the +# marker applies. +SessionEvaluation = Dict[Text, List[EventMetaData]] + +T = TypeVar("T") + + +class Marker(ABC): + """A marker is a way of describing points in conversations you're interested in. + + Here, markers are stateful objects because they track the events of a conversation. + At each point in the conversation, one can observe whether a marker applies or + does not apply to the conversation so far. + """ + + # Identifier for an artificial marker that is created when loading markers + # from a dictionary of configs. For more details, see `from_config_dict`. + ANY_MARKER = "<any_marker>" + + def __init__( + self, + name: Optional[Text] = None, + negated: bool = False, + description: Optional[Text] = None, + ) -> None: + """Instantiates a marker. + + Args: + name: a custom name that can be used to replace the default string + conversion of this marker + negated: whether this marker should be negated (i.e. a negated marker + applies if and only if the non-negated marker does not apply) + description: an optional description of the marker. It is not used + internally but can be used to document the marker. + Raises: + `InvalidMarkerConfig` if the chosen *name* of the marker is the tag of + a predefined marker. + """ + if name == Marker.ANY_MARKER or name in MarkerRegistry.all_tags: + raise InvalidMarkerConfig( + f"You must not use the special marker name {Marker.ANY_MARKER}. " + f"This is to avoid confusion when you generate a marker from a " + f"dictionary of marker configurations, which will lead to all " + f"markers being combined under one common `ORMarker` named " + f"{Marker.ANY_MARKER}." + ) + self.name = name + self.history: List[bool] = [] + # Note: we allow negation here even though there might not be a negated tag + # for 2 reasons: testing and the fact that the `MarkerRegistry`+`from_config` + # won't allow to create a negated marker if there is no negated tag. + self.negated: bool = negated + self.description = description + + def __str__(self) -> Text: + return self.name or repr(self) + + def __repr__(self) -> Text: + return self._to_str_with(self.get_tag()) + + def get_tag(self) -> Text: + """Returns the tag describing this marker.""" + if self.negated: + tag = self.negated_tag() + if tag is None: + # We allow the instantiation of a negated marker even if there + # is no negated tag (ie. direct creation of the negated marker from + # a config is not allowed). To be able to print a tag nonetheless: + tag = f"not({self.positive_tag()})" + return tag + else: + return self.positive_tag() + + @staticmethod + @abstractmethod + def positive_tag() -> Text: + """Returns the tag to be used in a config file.""" + ... + + @staticmethod + def negated_tag() -> Optional[Text]: + """Returns the tag to be used in a config file for the negated version. + + If this maps to `None`, then this indicates that we do not allow a short-cut + for negating this marker. Hence, there is not a single tag to instantiate + a negated version of this marker. One must use a "not" in the configuration + file then. + """ + return None + + @abstractmethod + def _to_str_with(self, tag: Text) -> Text: + """Returns a string representation using the given tag.""" + ... + + def track(self, event: Event) -> None: + """Updates the marker according to the given event. + + Args: + event: the next event of the conversation + """ + result = self._non_negated_version_applies_at(event) + if self.negated: + result = not result + self.history.append(result) + + @abstractmethod + def _non_negated_version_applies_at(self, event: Event) -> bool: + """Checks whether the non-negated version applies at the next given event. + + This method must *not* update the marker. + + Args: + event: the next event of the conversation + """ + ... + + def reset(self) -> None: + """Clears the history of the marker.""" + self.history = [] + + @abstractmethod + def flatten(self) -> Iterator[Marker]: + """Returns an iterator over all conditions and operators used in this marker. + + Returns: + an iterator over all conditions and operators that are part of this marker + """ + ... + + @abstractmethod + def validate_against_domain(self, domain: Domain) -> bool: + """Checks that this marker (and its children) refer to entries in the domain. + + Args: + domain: The domain to check against + """ + ... + + @abstractmethod + def max_depth(self) -> int: + """Gets the maximum depth from this point in the marker tree.""" + ... + + def evaluate_events(self, events: List[Event]) -> List[SessionEvaluation]: + """Resets the marker, tracks all events, and collects some information. + + The collected information includes: + - the timestamp of each event where the marker applied and + - the number of user turns that preceded that timestamp + + If this marker is the special `ANY_MARKER` (identified by its name), then + results will be collected for all (immediate) sub-markers. + + Args: + events: a list of events describing a conversation + Returns: + a list that contains, for each session contained in the tracker, a + dictionary mapping that maps marker names to meta data of relevant + events + """ + # determine which marker to extract results from + if isinstance(self, OperatorMarker) and self.name == Marker.ANY_MARKER: + markers_to_be_evaluated = self.sub_markers + else: + markers_to_be_evaluated = [self] + + # split the events into sessions and evaluate them separately + sessions_and_start_indices = self._split_sessions(events=events) + + extracted_markers: List[Dict[Text, List[EventMetaData]]] = [] + for session, start_idx in sessions_and_start_indices: + # track all events and collect meta data per time step + meta_data = self._track_all_and_collect_meta_data( + events=session, event_idx_offset=start_idx + ) + # for each marker, keep only certain meta data + extracted: Dict[Text, List[EventMetaData]] = { + str(marker): [meta_data[idx] for idx in marker.relevant_events()] + for marker in markers_to_be_evaluated + } + extracted_markers.append(extracted) + return extracted_markers + + @staticmethod + def _split_sessions(events: List[Event]) -> List[Tuple[List[Event], int]]: + """Identifies single sessions in a the given sequence of events. + + Args: + events: a sequence of events, e.g. extracted from a tracker store + Returns: + a list of sub-sequences of the given events that describe single + conversations and the respective index that describes where the + subsequence starts in the original sequence + """ + session_start_indices = [ + idx + for idx, event in enumerate(events) + if isinstance(event, ActionExecuted) + and event.action_name + == rasa.shared.core.constants.ACTION_SESSION_START_NAME + ] + if len(session_start_indices) == 0: + return [(events, 0)] + sessions_and_start_indices: List[Tuple[List[Event], int]] = [] + for session_idx in range(len(session_start_indices) - 1): + start_idx = session_start_indices[session_idx] + end_idx = session_start_indices[session_idx + 1] + session = [events[idx] for idx in range(start_idx, end_idx)] + sessions_and_start_indices.append((session, start_idx)) + last_session = [ + events[idx] for idx in range(session_start_indices[-1], len(events)) + ] + sessions_and_start_indices.append((last_session, session_start_indices[-1])) + return sessions_and_start_indices + + def _track_all_and_collect_meta_data( + self, events: List[Event], event_idx_offset: int = 0 + ) -> List[EventMetaData]: + """Resets the marker, tracks all events, and collects metadata. + + Args: + events: all events of a *single* session that should be tracked and + evaluated + event_idx_offset: offset that will be used to modify the collected event + meta data, i.e. all event indices will be shifted by this offset + Returns: + metadata for each tracked event with all event indices shifted by the + given `event_idx_offset` + """ + self.reset() + session_meta_data: List[EventMetaData] = [] + num_preceding_user_turns = 0 + for idx, event in enumerate(events): + is_user_turn = isinstance(event, UserUttered) + session_meta_data.append( + EventMetaData( + idx=idx + event_idx_offset, + preceding_user_turns=num_preceding_user_turns, + ) + ) + self.track(event=event) + num_preceding_user_turns += int(is_user_turn) + return session_meta_data + + def relevant_events(self) -> List[int]: + """Returns the indices of those tracked events that are relevant for evaluation. + + Note: Overwrite this method if you create a new marker class that should *not* + contain meta data about each event where the marker applied in the final + evaluation (see `evaluate_events`). + + Returns: + indices of tracked events + """ + return [idx for (idx, applies) in enumerate(self.history) if applies] + + @classmethod + def from_path(cls, path: Union[Path, Text]) -> "OrMarker": + """Loads markers from one config file or all config files in a directory tree. + + Each config file should contain a dictionary mapping marker names to the + respective marker configuration. + To avoid confusion, the marker names must not coincide with the tag of + any pre-defined markers. Moreover, marker names must be unique. This means, + if you you load the markers from multiple files, then you have to make sure + that the names of the markers defined in these files are unique across all + loaded files. + + Note that all loaded markers will be combined into one marker via one + artificial OR-operator. When evaluating the resulting marker, then the + artificial OR-operator will be ignored and results will be reported for + every sub-marker. + + For more details how a single marker configuration looks like, have a look + at `Marker.from_config`. + + Args: + path: either the path to a single config file or the root of the directory + tree that contains marker config files + Returns: + all configured markers, combined into one marker object + """ + MarkerRegistry.register_builtin_markers() + from rasa.core.evaluation.marker import OrMarker + + # collect all the files from which we want to load configs (i.e. either just + # the given path if points to a yaml or all yaml-files in the directory tree) + yaml_files = cls._collect_yaml_files_from_path(path) + + # collect all the configs from those yaml files (assure it's dictionaries + # mapping marker names to something) -- keep track of which config came + # from which file to be able to raise meaningful errors later + loaded_configs: Dict[Text, Dict] = cls._collect_configs_from_yaml_files( + yaml_files + ) + + # create markers from all loaded configurations + loaded_markers: List[Marker] = [] + for yaml_file, config in loaded_configs.items(): + for marker_name, marker_config in config.items(): + try: + marker = Marker.from_config(marker_config, name=marker_name) + except InvalidMarkerConfig as e: + # we don't re-raise here because the stack trace would only be + # printed when we run rasa evaluate with --debug flag + raise InvalidMarkerConfig( + f"Could not load marker {marker_name} from {yaml_file}. " + f"Reason: {str(e)}. " + ) + loaded_markers.append(marker) + + # Reminder: We could also just create a dictionary of markers from this. + # However, if we want to allow re-using top-level markers (e.g. + # "custom_marker1 or custom_marker2" and/or optimize the marker evaluation such + # that e.g. the same condition is not instantiated (and evaluated) twice, then + # the current approach might be better (e.g. with a dictionary of markers one + # might expect the markers to be independent objects). + + # combine the markers + marker = OrMarker(markers=loaded_markers) + marker.name = Marker.ANY_MARKER # cannot be set via name parameter + return marker + + @staticmethod + def _collect_yaml_files_from_path(path: Union[Text, Path]) -> List[Text]: + path = os.path.abspath(path) + if os.path.isfile(path): + yaml_files = [ + yaml_file for yaml_file in [path] if is_likely_yaml_file(yaml_file) + ] + if not yaml_files: + raise InvalidMarkerConfig(f"Could not find a yaml file at '{path}'.") + elif os.path.isdir(path): + yaml_files = [ + os.path.join(root, file) + for root, _, files in os.walk(path, followlinks=True) + for file in files + if is_likely_yaml_file(file) + ] + if not yaml_files: + raise InvalidMarkerConfig( + f"Could not find any yaml in the directory tree rooted at '{path}'." + ) + else: + raise InvalidMarkerConfig( + f"The given path ({path}) is neither pointing to a directory " + f"nor a file. Please specify the location of a yaml file or a " + f"root directory (all yaml configs found in the directories " + f"under that root directory will be loaded). " + f"Refer to the docs for more information: {DOCS_URL_MARKERS} " + ) + return yaml_files + + @staticmethod + def _collect_configs_from_yaml_files(yaml_files: List[Text]) -> Dict[Text, Dict]: + marker_names: Set[Text] = set() + loaded_configs: Dict[Text, Dict] = {} + for yaml_file in yaml_files: + loaded_config = rasa.shared.utils.io.read_yaml_file(yaml_file) + if not isinstance(loaded_config, dict): + raise InvalidMarkerConfig( + f"Expected the loaded configurations to be a dictionary " + f"of marker configurations but found a " + f"{type(loaded_config)} in {yaml_file}. " + f"Refer to the docs for more information: {DOCS_URL_MARKERS} " + ) + if set(loaded_config.keys()).intersection(marker_names): + raise InvalidMarkerConfig( + f"The names of markers defined in {yaml_file} " + f"({sorted(loaded_config.keys())}) " + f"overlap with the names of markers loaded so far " + f"({sorted(marker_names)}). " + f"Please adapt your configurations such that your custom " + f"marker names are unique. " + f"Refer to the docs for more information: {DOCS_URL_MARKERS} " + ) + if set(loaded_config.keys()).intersection(MarkerRegistry.all_tags): + raise InvalidMarkerConfig( + f"The top level of your marker configuration should consist " + f"of names for your custom markers. " + f"If you use a condition or operator name at the top level, " + f"then that will not be recognised as an actual condition " + f"or operator and won't be evaluated against any sessions. " + f"Please remove any of the pre-defined marker tags " + f"(i.e. {MarkerRegistry.all_tags}) " + f"from {yaml_file}. " + f"Refer to the docs for more information: {DOCS_URL_MARKERS} " + ) + marker_names.update(loaded_config.keys()) + loaded_configs[yaml_file] = loaded_config + return loaded_configs + + @staticmethod + def from_config(config: Any, name: Optional[Text] = None) -> Marker: + """Creates a marker from the given config. + + A marker configuration is a dictionary mapping a marker tag (either a + `positive_tag` or a `negated_tag`) to a sub-configuration. + How that sub-configuration looks like, depends on whether the tag describes + an operator (see `OperatorMarker.from_tag_and_sub_config`) or a + condition (see `ConditionMarker.from_tag_and_sub_config`). + + Args: + config: a marker configuration + name: a custom name that will be used for the top-level marker (if and + only if there is only one top-level marker) + + Returns: + the configured marker + """ + # Triggers the import of all modules containing marker classes in order to + # register all configurable markers. + MarkerRegistry.register_builtin_markers() + + if not isinstance(config, dict) or len(config) not in [1, 2]: + raise InvalidMarkerConfig( + "To configure a marker, please define a dictionary that maps a " + "single operator tag or a single condition tag to the " + "corresponding parameter configuration or a list of marker " + "configurations, respectively. " + f"Refer to the docs for more information: {DOCS_URL_MARKERS} " + ) + + description = config.pop("description", None) + tag = next(iter(config)) + sub_marker_config = config[tag] + + tag, _ = MarkerRegistry.get_non_negated_tag(tag_or_negated_tag=tag) + if tag in MarkerRegistry.operator_tag_to_marker_class: + return OperatorMarker.from_tag_and_sub_config( + tag=tag, + sub_config=sub_marker_config, + name=name, + description=description, + ) + elif tag in MarkerRegistry.condition_tag_to_marker_class: + return ConditionMarker.from_tag_and_sub_config( + tag=tag, + sub_config=sub_marker_config, + name=name, + description=description, + ) + + raise InvalidMarkerConfig( + f"Expected a marker configuration with a key that specifies" + f" an operator or a condition but found {tag}. " + f"Available conditions and operators are: " + f"{sorted(MarkerRegistry.all_tags)}. " + f"Refer to the docs for more information: {DOCS_URL_MARKERS} " + ) + + async def evaluate_trackers( + self, + trackers: AsyncIterator[Optional[DialogueStateTracker]], + output_file: Path, + session_stats_file: Optional[Path] = None, + overall_stats_file: Optional[Path] = None, + ) -> None: + """Collect markers for each dialogue in each tracker loaded. + + Args: + trackers: An iterator over the trackers from which we want to extract + markers. + output_file: Path to write out the extracted markers. + session_stats_file: (Optional) Path to write out statistics about the + extracted markers for each session separately. + overall_stats_file: (Optional) Path to write out statistics about the + markers extracted from all session data. + + Raises: + `FileExistsError` if any of the specified files already exists + `NotADirectoryError` if any of the specified files is supposed to be + contained in a directory that does not exist + """ + # Check files and folders before doing the costly swipe over the trackers: + for path in [session_stats_file, overall_stats_file, output_file]: + if path is not None and path.is_file(): + raise FileExistsError(f"Expected that no file {path} already exists.") + if path is not None and not path.parent.is_dir(): + raise NotADirectoryError(f"Expected directory {path.parent} to exist.") + + # Apply marker to each session stored in each tracker and save the results. + processed_trackers: Dict[Text, List[SessionEvaluation]] = {} + async for tracker in trackers: + if tracker: + tracker_result = self.evaluate_events(tracker.events) + processed_trackers[tracker.sender_id] = tracker_result + + processed_trackers_count = len(processed_trackers) + telemetry.track_markers_extracted(processed_trackers_count) + Marker._save_results(output_file, processed_trackers) + + # Compute and write statistics if requested. + if session_stats_file or overall_stats_file: + from rasa.core.evaluation.marker_stats import MarkerStatistics + + stats = MarkerStatistics() + for sender_id, tracker_result in processed_trackers.items(): + for session_idx, session_result in enumerate(tracker_result): + stats.process( + sender_id=sender_id, + session_idx=session_idx, + meta_data_on_relevant_events_per_marker=session_result, + ) + + telemetry.track_markers_stats_computed(processed_trackers_count) + if overall_stats_file: + stats.overall_statistic_to_csv(path=overall_stats_file) + if session_stats_file: + stats.per_session_statistics_to_csv(path=session_stats_file) + + @staticmethod + def _save_results(path: Path, results: Dict[Text, List[SessionEvaluation]]) -> None: + """Save extracted marker results as CSV to specified path. + + Args: + path: Path to write out the extracted markers. + results: Extracted markers from a selection of trackers. + """ + with path.open(mode="w") as f: + table_writer = csv.writer(f) + table_writer.writerow( + [ + "sender_id", + "session_idx", + "marker", + "event_idx", + "num_preceding_user_turns", + ] + ) + for sender_id, results_per_session in results.items(): + for session_idx, session_result in enumerate(results_per_session): + Marker._write_relevant_events( + table_writer, sender_id, session_idx, session_result + ) + + @staticmethod + def _write_relevant_events( + writer: WriteRow, sender_id: Text, session_idx: int, session: SessionEvaluation + ) -> None: + for marker_name, meta_data_per_relevant_event in session.items(): + for event_meta_data in meta_data_per_relevant_event: + writer.writerow( + [ + sender_id, + str(session_idx), + marker_name, + str(event_meta_data.idx), + str(event_meta_data.preceding_user_turns), + ] + ) + + +class OperatorMarker(Marker, ABC): + """Combines several markers into one.""" + + def __init__( + self, + markers: List[Marker], + negated: bool = False, + name: Optional[Text] = None, + description: Optional[Text] = None, + ) -> None: + """Instantiates a marker. + + Args: + markers: the list of markers to combine + negated: whether this marker should be negated (i.e. a negated marker + applies if and only if the non-negated marker does not apply) + name: a custom name that can be used to replace the default string + conversion of this marker + description: an optional description of the marker. It is not used + internally but can be used to document the marker. + Raises: + `InvalidMarkerConfig` if the given number of sub-markers does not match + the expected number of sub-markers + """ + super().__init__(name=name, negated=negated, description=description) + self.sub_markers: List[Marker] = markers + expected_num = self.expected_number_of_sub_markers() + if expected_num is not None and len(markers) != expected_num: + raise InvalidMarkerConfig( + f"Expected {expected_num} sub-marker(s) to be specified for marker " + f"'{self}' ({self.get_tag()}) but found {len(markers)}. " + f"Please adapt your configuration so that there are exactly " + f"{expected_num} sub-markers. " + ) + elif len(markers) == 0: + raise InvalidMarkerConfig( + f"Expected some sub-markers to be specified for {self} but " + f"found none. Please adapt your configuration so that there is " + f"at least one sub-marker. " + ) + + @staticmethod + def expected_number_of_sub_markers() -> Optional[int]: + """Returns the expected number of sub-markers (if there is any).""" + return None + + def _to_str_with(self, tag: Text) -> Text: + marker_str = ", ".join(str(marker) for marker in self.sub_markers) + return f"{tag}({marker_str})" + + def track(self, event: Event) -> None: + """Updates the marker according to the given event. + + All sub-markers will be updated before the compound marker itself is updated. + + Args: + event: the next event of the conversation + """ + for marker in self.sub_markers: + marker.track(event) + super().track(event) + + def flatten(self) -> Iterator[Marker]: + """Returns an iterator over all included markers, plus this marker itself. + + Returns: + an iterator over all markers that are part of this marker + """ + for marker in self.sub_markers: + for sub_marker in marker.flatten(): + yield sub_marker + yield self + + def reset(self) -> None: + """Resets the history of this marker and all its sub-markers.""" + for marker in self.sub_markers: + marker.reset() + super().reset() + + def validate_against_domain(self, domain: Domain) -> bool: + """Checks that this marker (and its children) refer to entries in the domain. + + Args: + domain: The domain to check against + """ + return all( + marker.validate_against_domain(domain) for marker in self.sub_markers + ) + + def max_depth(self) -> int: + """Gets the maximum depth from this point in the marker tree.""" + return 1 + max(child.max_depth() for child in self.sub_markers) + + @staticmethod + def from_tag_and_sub_config( + tag: Text, + sub_config: Any, + name: Optional[Text] = None, + description: Optional[Text] = None, + ) -> OperatorMarker: + """Creates an operator marker from the given config. + + The configuration must consist of a list of marker configurations. + See `Marker.from_config` for more details. + + Args: + tag: the tag identifying an operator + sub_config: a list of marker configs + name: an optional custom name to be attached to the resulting marker + description: an optional description of the marker + Returns: + the configured operator marker + Raises: + `InvalidMarkerConfig` if the given config or the tag are not well-defined + """ + positive_tag, is_negation = MarkerRegistry.get_non_negated_tag(tag) + operator_class = MarkerRegistry.operator_tag_to_marker_class.get(positive_tag) + if operator_class is None: + raise InvalidConfigException(f"Unknown operator '{tag}'.") + + if not isinstance(sub_config, list): + raise InvalidMarkerConfig( + f"Expected a list of sub-marker configurations under {tag}." + ) + collected_sub_markers: List[Marker] = [] + for sub_marker_config in sub_config: + try: + sub_marker = Marker.from_config(sub_marker_config) + except InvalidMarkerConfig as e: + # we don't re-raise here because the stack trace would only be + # printed when we run rasa evaluate with --debug flag + raise InvalidMarkerConfig( + f"Could not create sub-marker for operator '{tag}' from " + f"{sub_marker_config}. Reason: {str(e)}" + ) + collected_sub_markers.append(sub_marker) + try: + marker = operator_class(markers=collected_sub_markers, negated=is_negation) + except InvalidMarkerConfig as e: + # we don't re-raise here because the stack trace would only be + # printed when we run rasa evaluate with --debug flag + raise InvalidMarkerConfig( + f"Could not create operator '{tag}' with sub-markers " + f"{collected_sub_markers}. Reason: {str(e)}" + ) + marker.name = name + marker.description = description + return marker + + +class ConditionMarker(Marker, ABC): + """A marker that does not contain any sub-markers.""" + + def __init__( + self, + text: Text, + negated: bool = False, + name: Optional[Text] = None, + description: Optional[Text] = None, + ) -> None: + """Instantiates an atomic marker. + + Args: + text: some text used to decide whether the marker applies + negated: whether this marker should be negated (i.e. a negated marker + applies if and only if the non-negated marker does not apply) + name: a custom name that can be used to replace the default string + conversion of this marker + description: an optional description of the marker. It is not used + internally but can be used to document the marker. + """ + super().__init__(name=name, negated=negated) + self.text = text + self.description = description + + def _to_str_with(self, tag: Text) -> Text: + return f"({tag}: {self.text})" + + def flatten(self) -> Iterator[ConditionMarker]: + """Returns an iterator that just returns this `AtomicMarker`. + + Returns: + an iterator over all markers that are part of this marker, i.e. this marker + """ + yield self + + def max_depth(self) -> int: + """Gets the maximum depth from this point in the marker tree.""" + return 1 + + @staticmethod + def from_tag_and_sub_config( + tag: Text, + sub_config: Any, + name: Optional[Text] = None, + description: Optional[Text] = None, + ) -> ConditionMarker: + """Creates an atomic marker from the given config. + + Args: + tag: the tag identifying a condition + sub_config: a single text parameter expected by all condition markers; + e.g. if the tag is for an `intent_detected` marker then the `config` + should contain an intent name + name: a custom name for this marker + Returns: + the configured `ConditionMarker` + Raises: + `InvalidMarkerConfig` if the given config or the tag are not well-defined + """ + positive_tag, is_negation = MarkerRegistry.get_non_negated_tag(tag) + marker_class = MarkerRegistry.condition_tag_to_marker_class.get(positive_tag) + if marker_class is None: + raise InvalidConfigException(f"Unknown condition '{tag}'.") + + if not isinstance(sub_config, str): + raise InvalidMarkerConfig( + f"Expected a text parameter to be specified for marker '{tag}'." + ) + marker = marker_class(sub_config, negated=is_negation) + marker.name = name + marker.description = description + return marker diff --git a/rasa/core/evaluation/marker_stats.py b/rasa/core/evaluation/marker_stats.py new file mode 100644 index 0000000..3fcef56 --- /dev/null +++ b/rasa/core/evaluation/marker_stats.py @@ -0,0 +1,294 @@ +from __future__ import annotations +from typing import Dict, Text, Union, List, Tuple + +from rasa.utils.io import WriteRow +from pathlib import Path +import csv + +import numpy as np + + +from rasa.core.evaluation.marker_base import EventMetaData + + +def compute_statistics( + values: List[Union[float, int]] +) -> Dict[Text, Union[int, float, np.floating]]: + """Computes some statistics over the given numbers.""" + return { + "count": len(values) if values else 0, + "mean": np.mean(values) if values else np.nan, + "median": np.median(values) if values else np.nan, + "min": min(values) if values else np.nan, + "max": max(values) if values else np.nan, + } + + +class MarkerStatistics: + """Computes some statistics on marker extraction results. + + (1) Number of sessions where markers apply: + + For each marker, we compute the total number (as well as the percentage) of + sessions in which a marker applies at least once. + Moreover, we output the total number of sessions that were parsed. + + (2) Number of user turns preceding relevant events - per sessions: + + For each marker, we consider all relevant events where that marker applies. + Everytime a marker applies, we check how many user turns precede that event. + We collect all these numbers and compute basic statistics (e.g. count and mean) + on them. + + This means, per session, we compute how often a marker applies and how many + user turns precede a relevant marker application on average, in that session. + + (3) Number of user turns preceding relevant events - over all sessions: + + Here, for each marker, we consider all relevant events where a marker applies + *in any of the sessions*. Then, we again calculate basic statistics over the + respective number of user turns that precede each of these events. + + This means, we compute how many events the marker applies in total and we + compute an estimate of the expected number of user turns preceding that + precede an (relevant) event where a marker applies. + """ + + NO_MARKER = "-" + STAT_NUM_SESSIONS = "total_number_of_sessions" + STAT_NUM_SESSIONS_WHERE_APPLIES = ( + "number_of_sessions_where_marker_applied_at_least_once" + ) + STAT_PERCENTAGE_SESSIONS_WHERE_APPLIES = ( + "percentage_of_sessions_where_marker_applied_at_least_once" + ) + + @staticmethod + def _add_num_user_turns_str_to(stat_name: Text) -> Text: + return f"{stat_name}(number of preceding user turns)" + + ALL_SESSIONS = np.nan + ALL_SENDERS = "all" + + def __init__(self) -> None: + """Creates a new marker statistics object.""" + # to ensure consistency of processed rows + self._marker_names: List[Text] = [] + + # (1) For collecting the per-session analysis: + # NOTE: we could stream / compute them later instead of collecting them... + self.session_results: Dict[Text, Dict[Text, List[Union[int, float]]]] = {} + self.session_identifier: List[Tuple[Text, int]] = [] + + # (2) For the overall statistics: + self.num_preceding_user_turns_collected: Dict[Text, List[int]] = {} + self.count_if_applied_at_least_once: Dict[Text, int] = {} + self.num_sessions = 0 + + def process( + self, + sender_id: Text, + session_idx: int, + meta_data_on_relevant_events_per_marker: Dict[Text, List[EventMetaData]], + ) -> None: + """Processes the meta data that was extracted from a single session. + + Internally, this method .. + 1. computes some statistics for the given meta data and saves it for later + 2. keeps track of the total number of sessions processed and the + collects all metadata to be able to compute meta data over *all* + + Args: + sender_id: an id that, together with the `session_idx` identifies + the session from which the markers where extracted + session_idx: an index that, together with the `sender_id` identifies + the session from which the markers where extracted + meta_data_on_relevant_events_per_marker: marker extraction results, + i.e. a dictionary mapping + marker names to the meta data describing relevant events + for those markers + """ + if len(self._marker_names) == 0: + # sort and initialise here once so our result tables are sorted + self._marker_names = sorted(meta_data_on_relevant_events_per_marker.keys()) + self.count_if_applied_at_least_once = { + marker_name: 0 for marker_name in self._marker_names + } + self.num_preceding_user_turns_collected = { + marker_name: [] for marker_name in self._marker_names + } + # NOTE: we could stream / compute them later instead of collecting them... + stat_names = sorted(compute_statistics([]).keys()) + self.session_results = { + marker_name: {stat_name: [] for stat_name in stat_names} + for marker_name in self._marker_names + } + else: + given_markers = meta_data_on_relevant_events_per_marker.keys() + if set(given_markers) != set(self._marker_names): + raise RuntimeError( + f"Expected all processed extraction results to contain information" + f"for the same set of markers. But found " + f"{sorted(given_markers)} which differs from " + f"the marker extracted so far (i.e. {sorted(self._marker_names)})." + ) + + # update session identifiers / count + self.num_sessions += 1 + self.session_identifier.append((sender_id, session_idx)) + + for marker_name, meta_data in meta_data_on_relevant_events_per_marker.items(): + + num_preceding_user_turns = [ + event_meta_data.preceding_user_turns for event_meta_data in meta_data + ] + + # update per session statistics + statistics = compute_statistics(num_preceding_user_turns) + for stat_name, stat_value in statistics.items(): + self.session_results[marker_name][stat_name].append(stat_value) + + # update overall statistics + self.num_preceding_user_turns_collected[marker_name].extend( + num_preceding_user_turns + ) + if len(num_preceding_user_turns): + self.count_if_applied_at_least_once[marker_name] += 1 + + def overall_statistic_to_csv(self, path: Path, overwrite: bool = False) -> None: + """Exports the overall statistics (over all processes sessions) to a csv file. + + Args: + path: path to where the csv file should be written. + overwrite: set to `True` to enable overwriting an existing file + """ + if path.is_file() and not overwrite: + raise FileExistsError(f"Expected that there was no file at {path}.") + with path.open(mode="w") as f: + table_writer = csv.writer(f) + table_writer.writerow(self._header()) + self._write_overview(table_writer) + self._write_overall_statistics(table_writer) + + def per_session_statistics_to_csv( + self, path: Path, overwrite: bool = False + ) -> None: + """Exports the resulting statistics to a csv file. + + Args: + path: path to where the csv file should be written. + overwrite: set to `True` to enable overwriting an existing file + """ + if path.is_file() and not overwrite: + raise FileExistsError(f"Expected that there was no file at {path}.") + with path.open(mode="w") as f: + table_writer = csv.writer(f) + table_writer.writerow(self._header()) + # NOTE: we could stream / compute them later instead of collecting them... + self._write_per_session_statistics(table_writer) + + @staticmethod + def _header() -> List[Text]: + return ["sender_id", "session_idx", "marker", "statistic", "value"] + + def _write_overview(self, table_writer: WriteRow) -> None: + special_sender_idx = self.ALL_SENDERS + special_session_idx = self.ALL_SESSIONS + self._write_row( + table_writer=table_writer, + sender_id=special_sender_idx, + session_idx=special_session_idx, + marker_name=self.NO_MARKER, + statistic_name=self.STAT_NUM_SESSIONS, + statistic_value=self.num_sessions, + ) + for marker_name, count in self.count_if_applied_at_least_once.items(): + self._write_row( + table_writer=table_writer, + sender_id=special_sender_idx, + session_idx=special_session_idx, + marker_name=marker_name, + statistic_name=self.STAT_NUM_SESSIONS_WHERE_APPLIES, + statistic_value=count, + ) + self._write_row( + table_writer=table_writer, + sender_id=special_sender_idx, + session_idx=special_session_idx, + marker_name=marker_name, + statistic_name=self.STAT_PERCENTAGE_SESSIONS_WHERE_APPLIES, + statistic_value=(count / self.num_sessions * 100) + if self.num_sessions + else 100.0, + ) + + def _write_overall_statistics(self, table_writer: WriteRow) -> None: + for marker_name, num_list in self.num_preceding_user_turns_collected.items(): + for statistic_name, value in compute_statistics(num_list).items(): + MarkerStatistics._write_row( + table_writer=table_writer, + sender_id=self.ALL_SENDERS, + session_idx=self.ALL_SESSIONS, + marker_name=marker_name, + statistic_name=self._add_num_user_turns_str_to(statistic_name), + statistic_value=value, + ) + + def _write_per_session_statistics(self, table_writer: WriteRow) -> None: + for marker_name, collected_statistics in self.session_results.items(): + for statistic_name, values in collected_statistics.items(): + self._write_per_session_statistic( + table_writer=table_writer, + marker_name=marker_name, + statistic_name=statistic_name, + session_identifiers=self.session_identifier, + values=values, + ) + + @staticmethod + def _write_per_session_statistic( + table_writer: WriteRow, + marker_name: Text, + statistic_name: Text, + session_identifiers: List[Tuple[Text, int]], + values: List[Union[float, int]], + ) -> None: + for record_idx, (sender_id, session_idx) in enumerate(session_identifiers): + MarkerStatistics._write_row( + table_writer=table_writer, + sender_id=sender_id, + session_idx=session_idx, + marker_name=marker_name, + statistic_name=MarkerStatistics._add_num_user_turns_str_to( + statistic_name + ), + statistic_value=values[record_idx], + ) + + @staticmethod + def _write_row( + table_writer: WriteRow, + sender_id: Text, + session_idx: Union[int, float], + marker_name: Text, + statistic_name: Text, + statistic_value: Union[int, float], + ) -> None: + if isinstance(statistic_value, int): + value_str = str(statistic_value) + elif np.isnan(statistic_value): + value_str = str(np.nan) + else: + value_str = np.round(statistic_value, 3) + table_writer.writerow( + [ + str(item) + for item in [ + sender_id, + session_idx, + marker_name, + statistic_name, + value_str, + ] + ] + ) diff --git a/rasa/core/evaluation/marker_tracker_loader.py b/rasa/core/evaluation/marker_tracker_loader.py new file mode 100644 index 0000000..5ef83d8 --- /dev/null +++ b/rasa/core/evaluation/marker_tracker_loader.py @@ -0,0 +1,103 @@ +import random +from rasa.shared.exceptions import RasaException +from rasa.shared.core.trackers import DialogueStateTracker +from typing import Any, Iterable, List, Text, Optional, AsyncIterator +from rasa.core.tracker_store import TrackerStore +import rasa.shared.utils.io + +STRATEGY_ALL = "all" +STRATEGY_FIRST_N = "first_n" +STRATEGY_SAMPLE_N = "sample_n" + + +def strategy_all(keys: List[Text], count: int) -> Iterable[Text]: + """Selects all keys from the set of keys.""" + return keys + + +def strategy_first_n(keys: List[Text], count: int) -> Iterable[Text]: + """Takes the first N keys from the set of keys.""" + return keys[:count] + + +def strategy_sample_n(keys: List[Text], count: int) -> Iterable[Text]: + """Samples N unique keys from the set of keys.""" + return random.sample(keys, k=count) + + +class MarkerTrackerLoader: + """Represents a wrapper over a `TrackerStore` with a configurable access pattern.""" + + _STRATEGY_MAP = { + "all": strategy_all, + "first_n": strategy_first_n, + "sample_n": strategy_sample_n, + } + + def __init__( + self, + tracker_store: TrackerStore, + strategy: str, + count: int = None, + seed: Any = None, + ) -> None: + """Creates a MarkerTrackerLoader. + + Args: + tracker_store: The underlying tracker store to access. + strategy: The strategy to use for selecting trackers, + can be 'all', 'sample_n', or 'first_n'. + count: Number of trackers to return, can only be None if strategy is 'all'. + seed: Optional seed to set up random number generator, + only useful if strategy is 'sample_n'. + """ + self.tracker_store = tracker_store + + if strategy not in MarkerTrackerLoader._STRATEGY_MAP: + raise RasaException( + f"Invalid strategy for loading markers - '{strategy}' was given, \ + options 'all', 'sample_n', or 'first_n' exist." + ) + + self.strategy = MarkerTrackerLoader._STRATEGY_MAP[strategy] + + if strategy != STRATEGY_ALL: + if not count: + raise RasaException( + f"Desired tracker count must be given for strategy '{strategy}'." + ) + + if count < 1: + # If count is ever < 1, user has an error, so issue exception + raise RasaException("Parameter 'count' must be greater than 0.") + + self.count = count + + if count and strategy == STRATEGY_ALL: + rasa.shared.utils.io.raise_warning( + "Parameter 'count' is ignored by strategy 'all'." + ) + self.count = None + + if seed: + if strategy == STRATEGY_SAMPLE_N: + random.seed(seed) + else: + rasa.shared.utils.io.raise_warning( + f"Parameter 'seed' is ignored by strategy '{strategy}'." + ) + + async def load(self) -> AsyncIterator[Optional[DialogueStateTracker]]: + """Loads trackers according to strategy.""" + stored_keys = list(await self.tracker_store.keys()) + if self.count is not None and self.count > len(stored_keys): + # Warn here as user may have overestimated size of data set + rasa.shared.utils.io.raise_warning( + "'count' exceeds number of trackers in the store -\ + all trackers will be processed." + ) + self.count = len(stored_keys) + + keys = self.strategy(stored_keys, self.count) + for sender in keys: + yield await self.tracker_store.retrieve_full_tracker(sender) diff --git a/rasa/core/exceptions.py b/rasa/core/exceptions.py new file mode 100644 index 0000000..bab74e4 --- /dev/null +++ b/rasa/core/exceptions.py @@ -0,0 +1,29 @@ +from typing import Text + +from rasa.shared.exceptions import RasaCoreException, RasaException + + +class AgentNotReady(RasaCoreException): + """Raised if someone tries to use an agent that is not ready. + + An agent might be created, e.g. without an processor attached. But + if someone tries to parse a message with that agent, this exception + will be thrown. + """ + + def __init__(self, message: Text) -> None: + """Initialize message attribute.""" + self.message = message + super(AgentNotReady, self).__init__() + + +class ChannelConfigError(RasaCoreException): + """Raised if a channel is not configured correctly.""" + + +class InvalidTrackerFeaturizerUsageError(RasaCoreException): + """Raised if a tracker featurizer is incorrectly used.""" + + +class KafkaProducerInitializationError(RasaException): + """Raised if the Kafka Producer cannot be properly initialized.""" diff --git a/rasa/core/exporter.py b/rasa/core/exporter.py new file mode 100644 index 0000000..2961b6b --- /dev/null +++ b/rasa/core/exporter.py @@ -0,0 +1,284 @@ +import logging +import uuid +import datetime +from typing import AsyncIterator, Text, Optional, List, Set, Dict, Any + +from tqdm import tqdm + +import rasa.shared.utils.cli +import rasa.shared.utils.io +from rasa.core.brokers.broker import EventBroker +from rasa.core.brokers.pika import PikaEventBroker +from rasa.core.constants import RASA_EXPORT_PROCESS_ID_HEADER_NAME +from rasa.core.tracker_store import TrackerStore +from rasa.shared.core.trackers import EventVerbosity +from rasa.exceptions import ( + NoEventsToMigrateError, + NoConversationsInTrackerStoreError, + PublishingError, +) + +logger = logging.getLogger(__name__) + + +class Exporter: + """Manages the publishing of events in a tracker store to an event broker. + + Attributes: + endpoints_path: Path to the endpoints file used to configure the event + broker and tracker store. If `None`, the default path ('endpoints.yml') + is used. + tracker_store: `TrackerStore` to export conversations from. + event_broker: `EventBroker` to export conversations to. + requested_conversation_ids: List of conversation IDs requested to be + processed. + minimum_timestamp: Minimum timestamp of events that are published. + If `None`, apply no such constraint. + maximum_timestamp: Maximum timestamp of events that are published. + If `None`, apply no such constraint. + """ + + def __init__( + self, + tracker_store: TrackerStore, + event_broker: EventBroker, + endpoints_path: Text, + requested_conversation_ids: Optional[Text] = None, + minimum_timestamp: Optional[float] = None, + maximum_timestamp: Optional[float] = None, + offset_timestamps_by_seconds: Optional[int] = None, + ) -> None: + self.endpoints_path = endpoints_path + self.tracker_store = tracker_store + + self.event_broker = event_broker + self.requested_conversation_ids = requested_conversation_ids + self.minimum_timestamp = minimum_timestamp + self.maximum_timestamp = maximum_timestamp + self.offset_timestamps_by_seconds = offset_timestamps_by_seconds + + async def publish_events(self) -> int: + """Publish events in a tracker store using an event broker. + + Exits if the publishing of events is interrupted due to an error. In that case, + the CLI command to continue the export where it was interrupted is printed. + + Returns: + The number of successfully published events. + """ + self._print_offset_info() + + published_events = 0 + current_timestamp = None + + headers = self._get_message_headers() + + async for event in self._fetch_events_within_time_range(): + # noinspection PyBroadException + try: + self._publish_with_message_headers(event, headers) + published_events += 1 + current_timestamp = event["timestamp"] + except Exception as e: + logger.exception(e) + raise PublishingError(current_timestamp) + + await self.event_broker.close() + + return published_events + + def _print_offset_info(self) -> None: + """Output information about the offset applied to event timestamps.""" + if self.offset_timestamps_by_seconds is None: + return + + delta = datetime.timedelta(seconds=abs(self.offset_timestamps_by_seconds)) + operator = "-" if self.offset_timestamps_by_seconds > 0 else "" + rasa.shared.utils.cli.print_info( + f"All event timestamps will be offset by {operator}{delta}! ⏰" + ) + + def _get_message_headers(self) -> Optional[Dict[Text, Text]]: + """Generate a message header for publishing events to a `PikaEventBroker`. + + Returns: + Message headers with a randomly generated uuid under the + `RASA_EXPORT_PROCESS_ID_HEADER_NAME` key if `self.event_broker` is a + `PikaEventBroker`, else `None`. + + """ + if isinstance(self.event_broker, PikaEventBroker): + return {RASA_EXPORT_PROCESS_ID_HEADER_NAME: uuid.uuid4().hex} + + return None + + def _publish_with_message_headers( + self, original_event: Dict[Text, Any], headers: Optional[Dict[Text, Text]] + ) -> None: + """Publish `event` to a message broker with `headers`. + + Args: + original_event: Serialized event to be published. + headers: Message headers to be published if `self.event_broker` is a + `PikaEventBroker`. + + """ + if self.offset_timestamps_by_seconds is not None: + event = dict(original_event) + event["timestamp"] += self.offset_timestamps_by_seconds + else: + event = original_event + + if isinstance(self.event_broker, PikaEventBroker): + self.event_broker.publish(event=event, headers=headers) + else: + self.event_broker.publish(event) + + async def _get_conversation_ids_in_tracker(self) -> Set[Text]: + """Fetch conversation IDs in `self.tracker_store`. + + Returns: + A set of conversation IDs in `self.tracker_store`. + + Raises: + `NoConversationsInTrackerStoreError` if + `conversation_ids_in_tracker_store` is empty. + + """ + conversation_ids_in_tracker_store = set(await self.tracker_store.keys()) + + if conversation_ids_in_tracker_store: + return conversation_ids_in_tracker_store + + raise NoConversationsInTrackerStoreError( + "Could not find any conversations in connected tracker store. " + "Please validate your `endpoints.yml` and make sure the defined " + "tracker store exists. Exiting." + ) + + def _validate_all_requested_ids_exist( + self, conversation_ids_in_tracker_store: Set[Text] + ) -> None: + """Warn user if `self.requested_conversation_ids` contains IDs not found in + `conversation_ids_in_tracker_store`. + + Args: + conversation_ids_in_tracker_store: Set of conversation IDs contained in + the tracker store. + + """ + missing_ids_in_tracker_store = ( + set(self.requested_conversation_ids) - conversation_ids_in_tracker_store + ) + if missing_ids_in_tracker_store: + rasa.shared.utils.cli.print_warning( + f"Could not find the following requested " + f"conversation IDs in connected tracker store: " + f"{', '.join(sorted(missing_ids_in_tracker_store))}" + ) + + async def _get_conversation_ids_to_process(self) -> Set[Text]: + """Get conversation IDs that are good for processing. + + Finds the intersection of events that are contained in the tracker store with + those events requested as a command-line argument. + + Returns: + Conversation IDs that are both requested and contained in the tracker + store. If no conversation IDs are requested, all conversation IDs in the + tracker store are returned. + + """ + conversation_ids_in_tracker_store = ( + await self._get_conversation_ids_in_tracker() + ) + + if not self.requested_conversation_ids: + return conversation_ids_in_tracker_store + + self._validate_all_requested_ids_exist(conversation_ids_in_tracker_store) + + conversation_ids_to_process = conversation_ids_in_tracker_store & set( + self.requested_conversation_ids + ) + + if not conversation_ids_to_process: + raise NoEventsToMigrateError( + "Could not find an overlap between the requested " + "conversation IDs and those found in the tracker store. Exiting." + ) + + return conversation_ids_to_process + + async def _fetch_events_within_time_range(self) -> AsyncIterator[Dict[Text, Any]]: + """Fetch all events for `conversation_ids` within the supplied time range. + + Returns: + Serialized events with added `sender_id` field. + + """ + conversation_ids_to_process = await self._get_conversation_ids_to_process() + + rasa.shared.utils.cli.print_info( + f"Fetching events for {len(conversation_ids_to_process)} " + f"conversation IDs:" + ) + for conversation_id in tqdm(conversation_ids_to_process, "conversation IDs"): + tracker = await self.tracker_store.retrieve_full_tracker(conversation_id) + if not tracker: + logger.info( + f"Could not retrieve tracker for conversation ID " + f"'{conversation_id}'. Skipping." + ) + continue + + _events = tracker.current_state(EventVerbosity.ALL)["events"] + + if not _events: + logger.info( + f"No events to migrate for conversation ID '{conversation_id}'." + ) + continue + + events = self._get_events_for_conversation_id(_events, conversation_id) + + # the order of events was changed after ATO-2192 + # more context: https://github.com/RasaHQ/rasa/pull/13019 + # we should sort the events by timestamp to keep the order + events.sort(key=lambda x: x["timestamp"]) + + # the conversation IDs are needed in the event publishing + for event in events: + if ( + self.minimum_timestamp is not None + and event["timestamp"] < self.minimum_timestamp + ): + continue + if ( + self.maximum_timestamp is not None + and event["timestamp"] >= self.maximum_timestamp + ): + continue + yield event + + @staticmethod + def _get_events_for_conversation_id( + events: List[Dict[Text, Any]], conversation_id: Text + ) -> List[Dict[Text, Any]]: + """Get serialised events with added `sender_id` key. + + Args: + events: Events to modify. + conversation_id: Conversation ID to add to events. + + Returns: + Events with added `sender_id` key. + + """ + events_with_conversation_id = [] + + for event in events: + event["sender_id"] = conversation_id + events_with_conversation_id.append(event) + + return events_with_conversation_id diff --git a/rasa/core/featurizers/__init__.py b/rasa/core/featurizers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/core/featurizers/precomputation.py b/rasa/core/featurizers/precomputation.py new file mode 100644 index 0000000..febbb8a --- /dev/null +++ b/rasa/core/featurizers/precomputation.py @@ -0,0 +1,410 @@ +from __future__ import annotations +from typing import Optional, Text, Dict, List, Union, Iterable, Any +from collections.abc import ValuesView, KeysView + +from rasa.engine.graph import GraphComponent +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.storage.resource import Resource +from rasa.engine.graph import ExecutionContext +from rasa.shared.core.domain import Domain, SubState +from rasa.shared.core.events import ActionExecuted, UserUttered, Event +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.training_data.structures import StoryGraph +from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, INTENT, TEXT +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.features import Features +import rasa.shared.utils.io + +# TODO: make precomputations (MessageContainerForCoreFeaturization) cacheable + + +class MessageContainerForCoreFeaturization: + """A key-value store for specific `Messages`. + + This container can be only be used to store messages that contain exactly + one of the following attributes: `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`. + A combination of the key attribute and the corresponding value will be used as + key for the respective message. + + Background/Motivation: + - Our policies only require these attributes to be tokenized and/or featurized + via NLU graph components, which is why we don't care about storing anything else. + - Our tokenizers and featurizers work independently for each attribute, + which is why we can separate them and ask for "exactly one" of the key + attributes. + - Our tokenizers add attributes (e.g. token sequences) and not just `Features`, + which is why we need messages and why we allow messages to contain more than + just the key attributes. + - Due to the way we use this datastructure, it won't contain all features that the + policies need (cf. `rasa.core.featurizers.SingleStateFeaturizer`) and sometimes + the messages will contain no features at all, which is the motivation for the + name of this class. + - Values for different attributes might coincide (e.g. 'greet' can appear as user + text as well as name of an intent), but attributes are not all tokenized and + featurized in the same way, which is why we use the combination of key attribute + and value to identify a message. + + Usage: + - At the start of core's featurization pipeline, we use this container to + de-duplicate the given story data during training (e.g. "Hello" might appear very + often but it will end up in the training data only once) and to de-duplicate + the data given in the tracker (e.g. if a text appears repeatedly in the + dialogue, it will only be featurized once later). + See: `rasa.core.featurizers.precomputation.CoreFeaturizationInputConverter`. + - At the end of core's featurization pipeline, we wrap all resulting + (training data) messages into this container again. + See: `rasa.core.featurizers.precomputation.CoreFeaturizationCollector`. + """ + + KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT] + + def __init__(self) -> None: + """Creates an empty container for precomputations.""" + self._table: Dict[Text, Dict[Text, Message]] = { + key: {} for key in self.KEY_ATTRIBUTES + } + self._num_collisions_ignored = 0 + + def fingerprint(self) -> Text: + """Fingerprint the container. + + Returns: + hex string as a fingerprint of the container. + """ + message_fingerprints = [ + message.fingerprint() for message in self.all_messages() + ] + return rasa.shared.utils.io.deep_container_fingerprint(message_fingerprints) + + def __repr__(self) -> Text: + return f"{self.__class__.__name__}({self._table})" + + def __len__(self) -> int: + return sum( + len(key_attribute_table) for key_attribute_table in self._table.values() + ) + + def messages(self, key_attribute: Text = None) -> ValuesView: + """Returns a view of all messages.""" + if key_attribute not in self._table: + raise ValueError( + f"Expected key attribute (i.e. one of {self.KEY_ATTRIBUTES}) " + f"but received {key_attribute}." + ) + return self._table[key_attribute].values() + + def all_messages(self) -> List[Message]: + """Returns a list containing all messages.""" + return [ + message + for key_attribute_table in self._table.values() + for message in key_attribute_table.values() + ] + + def keys(self, key_attribute: Text) -> KeysView: + """Returns a view of the value keys for the given key attribute.""" + if key_attribute not in self._table: + raise ValueError( + f"Expected key attribute (i.e. one of {self.KEY_ATTRIBUTES}) " + f"but received {key_attribute}." + ) + return self._table[key_attribute].keys() + + @property + def num_collisions_ignored(self) -> int: + """Returns the number of collisions that have been ignored.""" + return self._num_collisions_ignored + + def add(self, message_with_one_key_attribute: Message) -> None: + """Adds the given message if it is not already present. + + Args: + message_with_one_key_attribute: The message we want to add to the lookup + table. It must have exactly one key attribute. + + Raises: + `ValueError` if the given message does not contain exactly one key + attribute or if there is a collision with a message that has a different + hash value + """ + # extract the key pair + attributes = message_with_one_key_attribute.data.keys() + key_attributes = set(attributes).intersection(self.KEY_ATTRIBUTES) + if not key_attributes or len(key_attributes) != 1: + raise ValueError( + f"Expected exactly one attribute out of " + f"{self.KEY_ATTRIBUTES} but received {len(attributes)} attributes " + f"({attributes})." + ) + key_attribute = list(key_attributes)[0] + key_value = str(message_with_one_key_attribute.data[key_attribute]) + # extract the message + existing_message = self._table[key_attribute].get(key_value) + if existing_message is not None: + if hash(existing_message) != hash(message_with_one_key_attribute): + raise ValueError( + f"Expected added message to be consistent. " + f"({key_attribute}, {key_value}) already maps " + f"to {existing_message}, but we want to add " + f"{message_with_one_key_attribute} now." + ) + else: + self._num_collisions_ignored += 1 + else: + self._table[key_attribute][key_value] = message_with_one_key_attribute + + def add_all(self, messages_with_one_key_attribute: List[Message]) -> None: + """Adds the given messages. + + Args: + messages_with_one_key_attribute: The messages that we want to add. + Each one must have exactly one key attribute. + + Raises: + `ValueError` if we cannot create a key for the given message or if there is + a collisions with a message that has a different hash value + """ + for message in messages_with_one_key_attribute: + self.add(message) + + def collect_features( + self, sub_state: SubState, attributes: Optional[Iterable[Text]] = None + ) -> Dict[Text, List[Features]]: + """Collects features for all attributes in the given substate. + + There might be be multiple messages in the container that contain features + relevant for the given substate, e.g. this is the case if `TEXT` and + `INTENT` are present in the given substate. All of those messages will be + collected and their features combined. + + Args: + sub_state: substate for which we want to extract the relevent features + attributes: if not `None`, this specifies the list of the attributes of the + `Features` that we're interested in (i.e. all other `Features` contained + in the relevant messages will be ignored) + + Returns: + a dictionary that maps all the (requested) attributes to a list of `Features` + + Raises: + `ValueError`: if there exists some key pair (i.e. key attribute and + corresponding value) from the given substate cannot be found + `RuntimeError`: if features for the same attribute are found in two + different messages that are associated with the given substate + """ + # If we specify a list of attributes, then we want a dict with one entry + # for each attribute back - even if the corresponding list of features is empty. + features: Dict[Text, List[Features]] = ( + dict() + if attributes is None + else {attribute: [] for attribute in attributes} + ) + # collect all relevant key attributes + key_attributes = set(sub_state.keys()).intersection(self.KEY_ATTRIBUTES) + for key_attribute in key_attributes: + key_value = str(sub_state[key_attribute]) + message = self._table[key_attribute].get(key_value) + if not message: + raise ValueError( + f"Unknown key ({key_attribute},{key_value}). Cannot retrieve " + f"features for substate {sub_state}" + ) + features_from_message = Features.groupby_attribute( + message.features, attributes=attributes + ) + for feat_attribute, feat_value in features_from_message.items(): + existing_values = features.get(feat_attribute) + # Note: the following if-s are needed because if we specify a list of + # attributes then `features_from_message` will contain one entry per + # attribute even if the corresponding feature list is empty. + if feat_value and existing_values: + raise RuntimeError( + f"Feature for attribute {feat_attribute} has already been " + f"extracted from a different message stored under a key " + f"in {key_attributes} " + f"that is different from {key_attribute}. This means there's a " + f"redundancy in the message container." + ) + if feat_value: + features[feat_attribute] = feat_value + return features + + def lookup_message(self, user_text: Text) -> Message: + """Returns a message that contains the given user text. + + Args: + user_text: the text of a user utterance + Raises: + `ValueError` if there is no message associated with the given user text + """ + message = self._table[TEXT].get(user_text) + if message is None: + raise ValueError( + f"Expected a message with key ({TEXT}, {user_text}) in lookup table." + ) + return message + + def derive_messages_from_domain_and_add(self, domain: Domain) -> None: + """Adds all lookup table entries that can be derived from the domain. + + That is, all action names, action texts, and intents defined in the domain + will be turned into a (separate) messages and added to this lookup table. + + Args: + domain: the domain from which we extract the substates + """ + if ( + domain.action_texts + and domain.action_names_or_texts[-len(domain.action_texts) :] + != domain.action_texts + ): + raise NotImplementedError( + "We assumed that domain's `action_names_or_texts` start with a list of " + "all action names, followed by the action texts. " + "Please update the code to grab the action_name and action_texts from " + "the domain correctly." + ) + action_texts = domain.action_texts + action_names = domain.action_names_or_texts[ + slice(0, -len(domain.action_texts) if domain.action_texts else None) + ] + + for key_attribute, actions in [ + (ACTION_NAME, action_names), + (ACTION_TEXT, action_texts), + ]: + for action in actions: + self.add(Message({key_attribute: action})) + + for intent in domain.intent_properties.keys(): + self.add(Message({INTENT: intent})) + + def derive_messages_from_events_and_add(self, events: Iterable[Event]) -> None: + """Adds all relevant messages that can be derived from the given events. + + That is, each action name, action text, user text and intent that can be + found in the given events will be turned into a (separate) message and added + to this container. + + Args: + events: list of events to extract the substate from + """ + for event in events: + key_value_list = [] + if isinstance(event, UserUttered): + key_value_list = [(TEXT, event.text), (INTENT, event.intent_name)] + elif isinstance(event, ActionExecuted): + key_value_list = [ + (ACTION_TEXT, event.action_text), + (ACTION_NAME, event.action_name), + ] + for key, value in key_value_list: + if value is not None: + self.add(Message(data={key: value})) + + +class CoreFeaturizationInputConverter(GraphComponent): + """Provides data for the featurization pipeline. + + During training as well as during inference, the converter de-duplicates the given + data (i.e. story graph or list of messages) such that each text and intent from a + user message and each action name and action text appears exactly once. + """ + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> CoreFeaturizationInputConverter: + """Creates a new instance (see parent class for full docstring).""" + return cls() + + def convert_for_training( + self, domain: Domain, story_graph: StoryGraph + ) -> TrainingData: + """Creates de-duplicated training data. + + Each possible user text and intent and each action name and action text + that can be found in the given domain and story graph appears exactly once + in the resulting training data. Moreover, each item is contained in a separate + messsage. + + Args: + domain: the domain + story_graph: a story graph + Returns: + training data + """ + container = MessageContainerForCoreFeaturization() + + # collect all action and user (intent-only) substates known from domain + container.derive_messages_from_domain_and_add(domain=domain) + + # collect all substates we see in the given data + all_events = ( + event + for step in story_graph.story_steps + for event in step.events + if isinstance(event, UserUttered) + # because all action names and texts are known to the domain + ) + container.derive_messages_from_events_and_add(events=all_events) + + # Reminder: in case of complex recipes that train CountVectorizers, we'll have + # to make sure that there is at least one user substate with a TEXT to ensure + # `CountVectorizer` is trained... + + return TrainingData(training_examples=container.all_messages()) + + def convert_for_inference(self, tracker: DialogueStateTracker) -> List[Message]: + """Creates a list of messages containing single user and action attributes. + + Each possible user text and intent and each action name and action text + that can be found in the events of the given tracker will appear exactly once + in the resulting messages. Moreover, each item is contained in a separate + messsage. + + Args: + tracker: a dialogue state tracker containing events + Returns: + a list of messages + """ + # Note: `tracker.applied_events()` doesn't convert any events to a different + # type and hence just iterating over the events is quicker than "applying" + # events first and then iterating over results (again). + container = MessageContainerForCoreFeaturization() + container.derive_messages_from_events_and_add(tracker.events) + return container.all_messages() + + +class CoreFeaturizationCollector(GraphComponent): + """Collects featurized messages for use by a policy.""" + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> CoreFeaturizationCollector: + """Creates a new instance (see parent class for full docstring).""" + return cls() + + def collect( + self, messages: Union[TrainingData, List[Message]] + ) -> MessageContainerForCoreFeaturization: + """Collects messages.""" + if isinstance(messages, TrainingData): + messages = messages.training_examples + # Note that the input messages had been contained in a lookup table in + # `StoryToTrainingDataConverter. Hence, we don't need to worry about + # collisions here anymore. + container = MessageContainerForCoreFeaturization() + for message in messages: + container.add(message) + return container diff --git a/rasa/core/featurizers/single_state_featurizer.py b/rasa/core/featurizers/single_state_featurizer.py new file mode 100644 index 0000000..0a6c921 --- /dev/null +++ b/rasa/core/featurizers/single_state_featurizer.py @@ -0,0 +1,423 @@ +import logging +from typing import List, Optional, Dict, Text, Set, Any + +import numpy as np +import scipy.sparse + +from rasa.core.featurizers.precomputation import MessageContainerForCoreFeaturization +from rasa.nlu.extractors.extractor import EntityTagSpec +from rasa.nlu.utils import bilou_utils +from rasa.nlu.utils.bilou_utils import BILOU_PREFIXES +from rasa.shared.core.domain import SubState, State, Domain +from rasa.shared.core.constants import PREVIOUS_ACTION, ACTIVE_LOOP, USER, SLOTS +from rasa.shared.core.trackers import is_prev_action_listen_in_state +from rasa.shared.nlu.constants import ( + ENTITIES, + FEATURE_TYPE_SENTENCE, + ACTION_TEXT, + ACTION_NAME, + INTENT, + NO_ENTITY_TAG, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_TAGS, + TEXT, +) +from rasa.shared.nlu.training_data.features import Features +from rasa.utils.tensorflow import model_data_utils + +logger = logging.getLogger(__name__) + + +class SingleStateFeaturizer: + """Base class to transform the dialogue state into an ML format. + + Subclasses of SingleStateFeaturizer will decide how a bot will + transform the dialogue state into a dictionary mapping an attribute + to its features. Possible attributes are: `INTENT`, `TEXT`, `ACTION_NAME`, + `ACTION_TEXT`, `ENTITIES`, `SLOTS` and `ACTIVE_LOOP`. Each attribute will be + featurized into a list of `rasa.utils.features.Features`. + """ + + def __init__(self) -> None: + """Initialize the single state featurizer.""" + self._default_feature_states: Dict[Text, Any] = {} + self.action_texts: List[Text] = [] + self.entity_tag_specs: List[EntityTagSpec] = [] + + def _create_entity_tag_specs( + self, bilou_tagging: bool = False + ) -> List[EntityTagSpec]: + """Returns the tag to index mapping for entities. + + Returns: + Tag to index mapping. + """ + if ENTITIES not in self._default_feature_states: + return [] + + if bilou_tagging: + tag_id_index_mapping = { + f"{prefix}{tag}": idx_1 * len(BILOU_PREFIXES) + idx_2 + 1 + for tag, idx_1 in self._default_feature_states[ENTITIES].items() + for idx_2, prefix in enumerate(BILOU_PREFIXES) + } + else: + tag_id_index_mapping = { + tag: idx + 1 # +1 to keep 0 for the NO_ENTITY_TAG + for tag, idx in self._default_feature_states[ENTITIES].items() + } + + # NO_ENTITY_TAG corresponds to non-entity which should correspond to 0 index + # needed for correct prediction for padding + tag_id_index_mapping[NO_ENTITY_TAG] = 0 + + # TODO + # The entity states used to create the tag-idx-mapping contains the + # entities and the concatenated entity and roles/groups. We do not + # distinguish between entities and roles/groups right now. + # we return a list to anticipate that + return [ + EntityTagSpec( + tag_name=ENTITY_ATTRIBUTE_TYPE, + tags_to_ids=tag_id_index_mapping, + ids_to_tags={value: key for key, value in tag_id_index_mapping.items()}, + num_tags=len(tag_id_index_mapping), + ) + ] + + def prepare_for_training(self, domain: Domain, bilou_tagging: bool = False) -> None: + """Gets necessary information for featurization from domain. + + Args: + domain: An instance of :class:`rasa.shared.core.domain.Domain`. + bilou_tagging: indicates whether BILOU tagging should be used or not + """ + # store feature states for each attribute in order to create binary features + def convert_to_dict(feature_states: List[Text]) -> Dict[Text, int]: + return { + feature_state: idx for idx, feature_state in enumerate(feature_states) + } + + self._default_feature_states[INTENT] = convert_to_dict(domain.intents) + self._default_feature_states[ACTION_NAME] = convert_to_dict( + domain.action_names_or_texts + ) + self._default_feature_states[ENTITIES] = convert_to_dict(domain.entity_states) + self._default_feature_states[SLOTS] = convert_to_dict(domain.slot_states) + self._default_feature_states[ACTIVE_LOOP] = convert_to_dict(domain.form_names) + self.action_texts = domain.action_texts + self.entity_tag_specs = self._create_entity_tag_specs(bilou_tagging) + + def _state_features_for_attribute( + self, sub_state: SubState, attribute: Text + ) -> Dict[Text, int]: + # FIXME: the code below is not type-safe, but fixing it + # would require more refactoring, for instance using + # data classes in our states + if attribute in {INTENT, ACTION_NAME}: + return {sub_state[attribute]: 1} # type: ignore[dict-item] + elif attribute == ENTITIES: + return {entity: 1 for entity in sub_state.get(ENTITIES, [])} # type: ignore[misc] # noqa: E501 + elif attribute == ACTIVE_LOOP: + return {sub_state["name"]: 1} # type: ignore[dict-item] + elif attribute == SLOTS: + return { + f"{slot_name}_{i}": value # type: ignore[misc] + for slot_name, slot_as_feature in sub_state.items() + for i, value in enumerate(slot_as_feature) + } + else: + raise ValueError( + f"Given attribute '{attribute}' is not supported. " + f"It must be one of '{self._default_feature_states.keys()}'." + ) + + def _create_features( + self, sub_state: SubState, attribute: Text, sparse: bool = False + ) -> List[Features]: + state_features = self._state_features_for_attribute(sub_state, attribute) + + features = np.zeros(len(self._default_feature_states[attribute]), np.float32) + for state_feature, value in state_features.items(): + # check that the value is in default_feature_states to be able to assign + # its value + if state_feature in self._default_feature_states[attribute]: + features[self._default_feature_states[attribute][state_feature]] = value + features = np.expand_dims(features, 0) + + if sparse: + features = scipy.sparse.coo_matrix(features) + + return [ + Features( + features, FEATURE_TYPE_SENTENCE, attribute, self.__class__.__name__ + ) + ] + + @staticmethod + def _to_sparse_sentence_features( + sparse_sequence_features: List[Features], + ) -> List[Features]: + return [ + Features( + scipy.sparse.coo_matrix(feature.features.sum(0)), + FEATURE_TYPE_SENTENCE, + feature.attribute, + feature.origin, + ) + for feature in sparse_sequence_features + ] + + @staticmethod + def _get_name_attribute(attributes: Set[Text]) -> Optional[Text]: + # there is always either INTENT or ACTION_NAME + return next( + ( + attribute + for attribute in attributes + if attribute in {INTENT, ACTION_NAME} + ), + None, + ) + + def _extract_state_features( + self, + sub_state: SubState, + precomputations: Optional[MessageContainerForCoreFeaturization], + sparse: bool = False, + ) -> Dict[Text, List[Features]]: + + # Remove entities from possible attributes + attributes = set( + attribute for attribute in sub_state.keys() if attribute != ENTITIES + ) + + if precomputations is not None: + + # Collect features for all those attributes + attributes_to_features = precomputations.collect_features( + sub_state, attributes=attributes + ) + # if features for INTENT or ACTION_NAME exist, + # they are always sparse sequence features; + # transform them to sentence sparse features + if attributes_to_features.get(INTENT): + attributes_to_features[INTENT] = self._to_sparse_sentence_features( + attributes_to_features[INTENT] + ) + if attributes_to_features.get(ACTION_NAME): + attributes_to_features[ACTION_NAME] = self._to_sparse_sentence_features( + attributes_to_features[ACTION_NAME] + ) + + # Combine and sort the features: + # Per attribute, combine features of same type and level into one Feature, + # and (if there are any such features) store the results in a list where + # - all the sparse features are listed first and a + # - sequence feature is always listed before the sentence feature of the + # same type (sparse/not sparse). + output = { + attribute: Features.reduce( + features_list=features_list, expected_origins=None + ) + for attribute, features_list in attributes_to_features.items() + if len(features_list) > 0 # otherwise, following will fail + } + else: + output = {} + + # Check that the name attribute has features + name_attribute = self._get_name_attribute(attributes) + if name_attribute and name_attribute not in output: + # nlu pipeline didn't create features for user or action + # this might happen, for example, when we have action_name in the state + # but it did not get featurized because only character level + # CountVectorsFeaturizer was included in the config. + output[name_attribute] = self._create_features( + sub_state, name_attribute, sparse + ) + return output + + def encode_state( + self, + state: State, + precomputations: Optional[MessageContainerForCoreFeaturization], + ) -> Dict[Text, List[Features]]: + """Encode the given state. + + Args: + state: The state to encode + precomputations: Contains precomputed features and attributes. + + Returns: + A dictionary of state_type to list of features. + """ + state_features = {} + for state_type, sub_state in state.items(): + if state_type == PREVIOUS_ACTION: + state_features.update( + self._extract_state_features( + sub_state, precomputations=precomputations, sparse=True + ) + ) + # featurize user only if it is "real" user input, + # i.e. input from a turn after action_listen + if state_type == USER and is_prev_action_listen_in_state(state): + + state_features.update( + self._extract_state_features( + sub_state, precomputations=precomputations, sparse=True + ) + ) + if sub_state.get(ENTITIES): + state_features[ENTITIES] = self._create_features( + sub_state, ENTITIES, sparse=True + ) + + if state_type in {SLOTS, ACTIVE_LOOP}: + state_features[state_type] = self._create_features( + sub_state, state_type, sparse=True + ) + + return state_features + + def encode_entities( + self, + entity_data: Dict[Text, Any], + precomputations: Optional[MessageContainerForCoreFeaturization], + bilou_tagging: bool = False, + ) -> Dict[Text, List[Features]]: + """Encode the given entity data. + + Produce numeric entity tags for tokens. + + Args: + entity_data: The dict containing the text and entity labels and locations + precomputations: Contains precomputed features and attributes. + bilou_tagging: indicates whether BILOU tagging should be used or not + + Returns: + A dictionary of entity type to list of features. + """ + # TODO + # The entity states used to create the tag-idx-mapping contains the + # entities and the concatenated entity and roles/groups. We do not + # distinguish between entities and roles/groups right now. + if ( + not entity_data + or not self.entity_tag_specs + or self.entity_tag_specs[0].num_tags < 2 + ): + # we cannot build a classifier with fewer than 2 classes + return {} + if precomputations is None: + message = None + else: + message = precomputations.lookup_message(user_text=entity_data[TEXT]) + message.data[ENTITIES] = entity_data[ENTITIES] + + if not message: + return {} + + if bilou_tagging: + bilou_utils.apply_bilou_schema_to_message(message) + + return { + ENTITY_TAGS: [ + model_data_utils.get_tag_ids( + message, self.entity_tag_specs[0], bilou_tagging + ) + ] + } + + def _encode_action( + self, + action: Text, + precomputations: Optional[MessageContainerForCoreFeaturization], + ) -> Dict[Text, List[Features]]: + if action in self.action_texts: + action_as_sub_state = {ACTION_TEXT: action} + else: + action_as_sub_state = {ACTION_NAME: action} + + return self._extract_state_features( + action_as_sub_state, precomputations=precomputations + ) + + def encode_all_labels( + self, + domain: Domain, + precomputations: Optional[MessageContainerForCoreFeaturization], + ) -> List[Dict[Text, List[Features]]]: + """Encode all action from the domain. + + Args: + domain: The domain that contains the actions. + precomputations: Contains precomputed features and attributes. + + Returns: + A list of encoded actions. + """ + return [ + self._encode_action(action, precomputations) + for action in domain.action_names_or_texts + ] + + def to_dict(self) -> Dict[str, Any]: + return { + "action_texts": self.action_texts, + "entity_tag_specs": self.entity_tag_specs, + "feature_states": self._default_feature_states, + } + + @classmethod + def create_from_dict( + cls, data: Dict[str, Any] + ) -> Optional["SingleStateFeaturizer"]: + if not data: + return None + + featurizer = SingleStateFeaturizer() + featurizer.action_texts = data["action_texts"] + featurizer._default_feature_states = data["feature_states"] + featurizer.entity_tag_specs = data["entity_tag_specs"] + return featurizer + + +class IntentTokenizerSingleStateFeaturizer(SingleStateFeaturizer): + """A SingleStateFeaturizer for use with policies that predict intent labels.""" + + def _encode_intent( + self, + intent: Text, + precomputations: Optional[MessageContainerForCoreFeaturization], + ) -> Dict[Text, List[Features]]: + """Extracts a numeric representation of an intent. + + Args: + intent: Intent to be encoded. + precomputations: Contains precomputed features and attributes. + + Returns: + Encoded representation of intent. + """ + intent_as_sub_state = {INTENT: intent} + return self._extract_state_features(intent_as_sub_state, precomputations) + + def encode_all_labels( + self, + domain: Domain, + precomputations: Optional[MessageContainerForCoreFeaturization], + ) -> List[Dict[Text, List[Features]]]: + """Encodes all relevant labels from the domain using the given precomputations. + + Args: + domain: The domain that contains the labels. + precomputations: Contains precomputed features and attributes. + + Returns: + A list of encoded labels. + """ + return [ + self._encode_intent(intent, precomputations) for intent in domain.intents + ] diff --git a/rasa/core/featurizers/tracker_featurizers.py b/rasa/core/featurizers/tracker_featurizers.py new file mode 100644 index 0000000..9c6dbca --- /dev/null +++ b/rasa/core/featurizers/tracker_featurizers.py @@ -0,0 +1,1269 @@ +from __future__ import annotations + +import logging +from abc import abstractmethod +from collections import defaultdict +from pathlib import Path +from typing import ( + Tuple, + List, + Optional, + Dict, + Text, + Union, + Any, + Iterator, + Set, + DefaultDict, + cast, + Type, + Callable, + ClassVar, +) + +import numpy as np +from tqdm import tqdm + +import rasa.shared.core.trackers +import rasa.shared.utils.io +from rasa.core.exceptions import InvalidTrackerFeaturizerUsageError +from rasa.core.featurizers.precomputation import MessageContainerForCoreFeaturization +from rasa.core.featurizers.single_state_featurizer import SingleStateFeaturizer +from rasa.shared.core.constants import ( + USER, + ACTION_UNLIKELY_INTENT_NAME, + PREVIOUS_ACTION, +) +from rasa.shared.core.domain import State, Domain +from rasa.shared.core.events import Event, ActionExecuted, UserUttered +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.exceptions import RasaException +from rasa.shared.nlu.constants import TEXT, INTENT, ENTITIES, ACTION_NAME +from rasa.shared.nlu.training_data.features import Features +from rasa.utils.tensorflow.constants import LABEL_PAD_ID +from rasa.utils.tensorflow.model_data import ragged_array_to_ndarray + +FEATURIZER_FILE = "featurizer.json" + +logger = logging.getLogger(__name__) + + +class InvalidStory(RasaException): + """Exception that can be raised if story cannot be featurized.""" + + def __init__(self, message: Text) -> None: + """Creates an InvalidStory exception. + + Args: + message: a custom exception message. + """ + self.message = message + super(InvalidStory, self).__init__() + + def __str__(self) -> Text: + return self.message + + +class TrackerFeaturizer: + """Base class for actual tracker featurizers.""" + + # Class registry to store all subclasses + _registry: ClassVar[Dict[str, Type["TrackerFeaturizer"]]] = {} + _featurizer_type: str = "TrackerFeaturizer" + + def __init__( + self, state_featurizer: Optional[SingleStateFeaturizer] = None + ) -> None: + """Initializes the tracker featurizer. + + Args: + state_featurizer: The state featurizer used to encode tracker states. + """ + self.state_featurizer = state_featurizer + + @classmethod + def register(cls, featurizer_type: str) -> Callable: + """Decorator to register featurizer subclasses.""" + + def wrapper(subclass: Type["TrackerFeaturizer"]) -> Type["TrackerFeaturizer"]: + cls._registry[featurizer_type] = subclass + # Store the type identifier in the class for serialization + subclass._featurizer_type = featurizer_type + return subclass + + return wrapper + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "TrackerFeaturizer": + """Create featurizer instance from dictionary.""" + featurizer_type = data.pop("type") + + if featurizer_type not in cls._registry: + raise ValueError(f"Unknown featurizer type: {featurizer_type}") + + # Get the correct subclass and instantiate it + subclass = cls._registry[featurizer_type] + return subclass.create_from_dict(data) + + @classmethod + @abstractmethod + def create_from_dict(cls, data: Dict[str, Any]) -> "TrackerFeaturizer": + """Each subclass must implement its own creation from dict method.""" + pass + + @staticmethod + def _create_states( + tracker: DialogueStateTracker, + domain: Domain, + omit_unset_slots: bool = False, + ignore_rule_only_turns: bool = False, + rule_only_data: Optional[Dict[Text, Any]] = None, + ) -> List[State]: + """Creates states for the given tracker. + + Args: + tracker: The tracker to transform to states. + domain: The domain of the tracker. + omit_unset_slots: If `True` do not include the initial values of slots. + ignore_rule_only_turns: If `True` ignore dialogue turns that are present + only in rules. + rule_only_data: Slots and loops, + which only occur in rules but not in stories. + + Returns: + Trackers as states. + """ + return tracker.past_states( + domain, + omit_unset_slots=omit_unset_slots, + ignore_rule_only_turns=ignore_rule_only_turns, + rule_only_data=rule_only_data, + ) + + def _featurize_states( + self, + trackers_as_states: List[List[State]], + precomputations: Optional[MessageContainerForCoreFeaturization], + ) -> List[List[Dict[Text, List[Features]]]]: + """Featurizes state histories with `state_featurizer`. + + Args: + trackers_as_states: Lists of states produced by a `DialogueStateTracker` + instance. + precomputations: Contains precomputed features and attributes. + + Returns: + Featurized tracker states. + """ + if self.state_featurizer is None: + return [[{}]] + else: + return [ + [ + self.state_featurizer.encode_state(state, precomputations) + for state in tracker_states + ] + for tracker_states in trackers_as_states + ] + + @staticmethod + def _convert_labels_to_ids( + trackers_as_actions: List[List[Text]], domain: Domain + ) -> np.ndarray: + """Converts actions to label ids for each tracker. + + Args: + trackers_as_actions: A list of tracker labels. + + Returns: + Label IDs for each tracker + """ + # store labels in numpy arrays so that it corresponds to np arrays of input + # features + return ragged_array_to_ndarray( + [ + np.array( + [domain.index_for_action(action) for action in tracker_actions] + ) + for tracker_actions in trackers_as_actions + ] + ) + + def _create_entity_tags( + self, + trackers_as_entities: List[List[Dict[Text, Any]]], + precomputations: Optional[MessageContainerForCoreFeaturization], + bilou_tagging: bool = False, + ) -> List[List[Dict[Text, List[Features]]]]: + """Featurizes extracted entities with `state_featurizer`. + + Args: + trackers_as_entities: Extracted entities from trackers. + precomputations: Contains precomputed features and attributes. + bilou_tagging: When `True` use the BILOU tagging scheme. + + Returns: + Trackers as entity features. + """ + if self.state_featurizer is None: + return [[{}]] + else: + return [ + [ + self.state_featurizer.encode_entities( + entity_data, precomputations, bilou_tagging + ) + for entity_data in trackers_entities + ] + for trackers_entities in trackers_as_entities + ] + + @staticmethod + def _entity_data(event: UserUttered) -> Dict[Text, Any]: + """Extracts entities from event if not using intents. + + Args: + event: The event from which to extract entities. + + Returns: + Event text and entities if no intent is present. + """ + # train stories support both text and intent, + # but if intent is present, the text is ignored + if event.text and not event.intent_name: + return {TEXT: event.text, ENTITIES: event.entities} + + # input is not textual, so add empty dict + return {} + + @staticmethod + def _remove_user_text_if_intent(trackers_as_states: List[List[State]]) -> None: + """Deletes user text from state dictionaries if intent is present. + + Only featurizing either the intent or user text is currently supported. When + both are present in a state, the user text is removed so that only the intent + is featurized. + + `trackers_as_states` is modified in place. + + Args: + trackers_as_states: States produced by a `DialogueStateTracker` instance. + """ + for states in trackers_as_states: + for state in states: + # remove text features to only use intent + if state.get(USER, {}).get(INTENT) and state.get(USER, {}).get(TEXT): + del state[USER][TEXT] + + def training_states_and_labels( + self, + trackers: List[DialogueStateTracker], + domain: Domain, + omit_unset_slots: bool = False, + ignore_action_unlikely_intent: bool = False, + ) -> Tuple[List[List[State]], List[List[Text]]]: + """Transforms trackers to states and labels. + + Args: + trackers: The trackers to transform. + domain: The domain. + omit_unset_slots: If `True` do not include the initial values of slots. + ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent` + from training states. + + Returns: + Trackers as states and labels. + """ + ( + trackers_as_states, + trackers_as_labels, + _, + ) = self.training_states_labels_and_entities( + trackers, + domain, + omit_unset_slots=omit_unset_slots, + ignore_action_unlikely_intent=ignore_action_unlikely_intent, + ) + return trackers_as_states, trackers_as_labels + + @abstractmethod + def training_states_labels_and_entities( + self, + trackers: List[DialogueStateTracker], + domain: Domain, + omit_unset_slots: bool = False, + ignore_action_unlikely_intent: bool = False, + ) -> Tuple[List[List[State]], List[List[Text]], List[List[Dict[Text, Any]]]]: + """Transforms trackers to states, labels, and entity data. + + Args: + trackers: The trackers to transform. + domain: The domain. + omit_unset_slots: If `True` do not include the initial values of slots. + ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent` + from training states. + + Returns: + Trackers as states, labels, and entity data. + """ + raise NotImplementedError( + f"`{self.__class__.__name__}` should implement how to " + f"encode trackers as feature vectors" + ) + + def prepare_for_featurization( + self, domain: Domain, bilou_tagging: bool = False + ) -> None: + """Ensures that the featurizer is ready to be called during training. + + State featurizer needs to build its vocabulary from the domain + for it to be ready to be used during training. + + Args: + domain: Domain of the assistant. + bilou_tagging: Whether to consider bilou tagging. + """ + if self.state_featurizer is None: + raise InvalidTrackerFeaturizerUsageError( + f"Instance variable 'state_featurizer' is not set. " + f"During initialization set 'state_featurizer' to an instance of " + f"'{SingleStateFeaturizer.__class__.__name__}' class " + f"to get numerical features for trackers." + ) + self.state_featurizer.prepare_for_training(domain, bilou_tagging) + + def featurize_trackers( + self, + trackers: List[DialogueStateTracker], + domain: Domain, + precomputations: Optional[MessageContainerForCoreFeaturization], + bilou_tagging: bool = False, + ignore_action_unlikely_intent: bool = False, + ) -> Tuple[ + List[List[Dict[Text, List[Features]]]], + np.ndarray, + List[List[Dict[Text, List[Features]]]], + ]: + """Featurizes the training trackers. + + Args: + trackers: list of training trackers + domain: the domain + precomputations: Contains precomputed features and attributes. + bilou_tagging: indicates whether BILOU tagging should be used or not + ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent` + from training state features. + + Returns: + - a dictionary of state types (INTENT, TEXT, ACTION_NAME, ACTION_TEXT, + ENTITIES, SLOTS, ACTIVE_LOOP) to a list of features for all dialogue + turns in all training trackers + - the label ids (e.g. action ids) for every dialogue turn in all training + trackers + - A dictionary of entity type (ENTITY_TAGS) to a list of features + containing entity tag ids for text user inputs otherwise empty dict + for all dialogue turns in all training trackers + """ + self.prepare_for_featurization(domain, bilou_tagging) + ( + trackers_as_states, + trackers_as_labels, + trackers_as_entities, + ) = self.training_states_labels_and_entities( + trackers, + domain, + ignore_action_unlikely_intent=ignore_action_unlikely_intent, + ) + + tracker_state_features = self._featurize_states( + trackers_as_states, precomputations + ) + + if not tracker_state_features and not trackers_as_labels: + # If input and output were empty, it means there is + # no data on which the policy can be trained + # hence return them as it is. They'll be handled + # appropriately inside the policy. + return tracker_state_features, np.ndarray(trackers_as_labels), [] + + label_ids = self._convert_labels_to_ids(trackers_as_labels, domain) + + entity_tags = self._create_entity_tags( + trackers_as_entities, precomputations, bilou_tagging + ) + + return tracker_state_features, label_ids, entity_tags + + def _choose_last_user_input( + self, trackers_as_states: List[List[State]], use_text_for_last_user_input: bool + ) -> None: + for states in trackers_as_states: + last_state = states[-1] + # only update the state of the real user utterance + if not rasa.shared.core.trackers.is_prev_action_listen_in_state(last_state): + continue + + if use_text_for_last_user_input: + # remove intent features to only use text + if last_state.get(USER, {}).get(INTENT): + del last_state[USER][INTENT] + # don't add entities if text is used for featurization + if last_state.get(USER, {}).get(ENTITIES): + del last_state[USER][ENTITIES] + else: + # remove text features to only use intent + if last_state.get(USER, {}).get(TEXT): + del last_state[USER][TEXT] + + # make sure that all dialogue steps are either intent or text based + self._remove_user_text_if_intent(trackers_as_states) + + def prediction_states( + self, + trackers: List[DialogueStateTracker], + domain: Domain, + use_text_for_last_user_input: bool = False, + ignore_rule_only_turns: bool = False, + rule_only_data: Optional[Dict[Text, Any]] = None, + ignore_action_unlikely_intent: bool = False, + ) -> List[List[State]]: + """Transforms trackers to states for prediction. + + Args: + trackers: The trackers to transform. + domain: The domain. + use_text_for_last_user_input: Indicates whether to use text or intent label + for featurizing last user input. + ignore_rule_only_turns: If True ignore dialogue turns that are present + only in rules. + rule_only_data: Slots and loops, + which only occur in rules but not in stories. + ignore_action_unlikely_intent: Whether to remove states containing + `action_unlikely_intent` from prediction states. + + Returns: + Trackers as states for prediction. + """ + raise NotImplementedError( + "Featurizer must have the capacity to create feature vector" + ) + + def create_state_features( + self, + trackers: List[DialogueStateTracker], + domain: Domain, + precomputations: Optional[MessageContainerForCoreFeaturization], + use_text_for_last_user_input: bool = False, + ignore_rule_only_turns: bool = False, + rule_only_data: Optional[Dict[Text, Any]] = None, + ignore_action_unlikely_intent: bool = False, + ) -> List[List[Dict[Text, List[Features]]]]: + """Creates state features for prediction. + + Args: + trackers: A list of state trackers + domain: The domain + precomputations: Contains precomputed features and attributes. + use_text_for_last_user_input: Indicates whether to use text or intent label + for featurizing last user input. + ignore_rule_only_turns: If True ignore dialogue turns that are present + only in rules. + rule_only_data: Slots and loops, + which only occur in rules but not in stories. + ignore_action_unlikely_intent: Whether to remove any states containing + `action_unlikely_intent` from state features. + + Returns: + Dictionaries of state type (INTENT, TEXT, ACTION_NAME, ACTION_TEXT, + ENTITIES, SLOTS, ACTIVE_LOOP) to a list of features for all dialogue + turns in all trackers. + """ + trackers_as_states = self.prediction_states( + trackers, + domain, + use_text_for_last_user_input, + ignore_rule_only_turns, + rule_only_data, + ignore_action_unlikely_intent=ignore_action_unlikely_intent, + ) + return self._featurize_states(trackers_as_states, precomputations) + + def persist(self, path: Union[Text, Path]) -> None: + """Persists the tracker featurizer to the given path. + + Args: + path: The path to persist the tracker featurizer to. + """ + featurizer_file = Path(path) / FEATURIZER_FILE + rasa.shared.utils.io.create_directory_for_file(featurizer_file) + + # entity tags are persisted in TED policy, they are not needed for prediction + if self.state_featurizer is not None: + self.state_featurizer.entity_tag_specs = [] + + # noinspection PyTypeChecker + rasa.shared.utils.io.dump_obj_as_json_to_file(featurizer_file, self.to_dict()) + + @staticmethod + def load(path: Union[Text, Path]) -> Optional[TrackerFeaturizer]: + """Loads the featurizer from file. + + Args: + path: The path to load the tracker featurizer from. + + Returns: + The loaded tracker featurizer. + """ + featurizer_file = Path(path) / FEATURIZER_FILE + if featurizer_file.is_file(): + data = rasa.shared.utils.io.read_json_file(featurizer_file) + + if "type" not in data: + logger.error( + f"Couldn't load featurizer for policy. " + f"File '{featurizer_file}' does not contain all " + f"necessary information. 'type' is missing." + ) + return None + + return TrackerFeaturizer.from_dict(data) + + logger.error( + f"Couldn't load featurizer for policy. " + f"File '{featurizer_file}' doesn't exist." + ) + return None + + @staticmethod + def _remove_action_unlikely_intent_from_states(states: List[State]) -> List[State]: + return [ + state + for state in states + if not _is_prev_action_unlikely_intent_in_state(state) + ] + + @staticmethod + def _remove_action_unlikely_intent_from_events(events: List[Event]) -> List[Event]: + return [ + event + for event in events + if ( + not isinstance(event, ActionExecuted) + or event.action_name != ACTION_UNLIKELY_INTENT_NAME + ) + ] + + def to_dict(self) -> Dict[str, Any]: + return { + "type": self.__class__._featurizer_type, + "state_featurizer": ( + self.state_featurizer.to_dict() if self.state_featurizer else None + ), + } + + +@TrackerFeaturizer.register("FullDialogueTrackerFeaturizer") +class FullDialogueTrackerFeaturizer(TrackerFeaturizer): + """Creates full dialogue training data for time distributed architectures. + + Creates training data that uses each time output for prediction. + """ + + def training_states_labels_and_entities( + self, + trackers: List[DialogueStateTracker], + domain: Domain, + omit_unset_slots: bool = False, + ignore_action_unlikely_intent: bool = False, + ) -> Tuple[List[List[State]], List[List[Text]], List[List[Dict[Text, Any]]]]: + """Transforms trackers to states, action labels, and entity data. + + Args: + trackers: The trackers to transform. + domain: The domain. + omit_unset_slots: If `True` do not include the initial values of slots. + ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent` + from training states. + + Returns: + Trackers as states, action labels, and entity data. + """ + trackers_as_states = [] + trackers_as_actions = [] + trackers_as_entities = [] + + logger.debug( + "Creating states and action examples from " + "collected trackers (by {}({}))..." + "".format(type(self).__name__, type(self.state_featurizer).__name__) + ) + pbar = tqdm( + trackers, + desc="Processed trackers", + disable=rasa.shared.utils.io.is_logging_disabled(), + ) + for tracker in pbar: + states = self._create_states( + tracker, domain, omit_unset_slots=omit_unset_slots + ) + events = tracker.applied_events() + + if ignore_action_unlikely_intent: + states = self._remove_action_unlikely_intent_from_states(states) + events = self._remove_action_unlikely_intent_from_events(events) + + delete_first_state = False + actions = [] + entities = [] + entity_data = {} + for event in events: + if isinstance(event, UserUttered): + entity_data = self._entity_data(event) + + if not isinstance(event, ActionExecuted): + continue + + if not event.unpredictable: + # only actions which can be + # predicted at a stories start + action = event.action_name or event.action_text + if action is not None: + actions.append(action) + entities.append(entity_data) + else: + # unpredictable actions can be + # only the first in the story + if delete_first_state: + raise InvalidStory( + f"Found two unpredictable actions in one story " + f"'{tracker.sender_id}'. Check your story files." + ) + delete_first_state = True + + # reset entity_data for the the next turn + entity_data = {} + + if delete_first_state: + states = states[1:] + + trackers_as_states.append(states[:-1]) + trackers_as_actions.append(actions) + trackers_as_entities.append(entities) + + self._remove_user_text_if_intent(trackers_as_states) + + return trackers_as_states, trackers_as_actions, trackers_as_entities + + def prediction_states( + self, + trackers: List[DialogueStateTracker], + domain: Domain, + use_text_for_last_user_input: bool = False, + ignore_rule_only_turns: bool = False, + rule_only_data: Optional[Dict[Text, Any]] = None, + ignore_action_unlikely_intent: bool = False, + ) -> List[List[State]]: + """Transforms trackers to states for prediction. + + Args: + trackers: The trackers to transform. + domain: The domain. + use_text_for_last_user_input: Indicates whether to use text or intent label + for featurizing last user input. + ignore_rule_only_turns: If True ignore dialogue turns that are present + only in rules. + rule_only_data: Slots and loops, + which only occur in rules but not in stories. + ignore_action_unlikely_intent: Whether to remove any states containing + `action_unlikely_intent` from prediction states. + + Returns: + Trackers as states for prediction. + """ + trackers_as_states = [ + self._create_states( + tracker, + domain, + ignore_rule_only_turns=ignore_rule_only_turns, + rule_only_data=rule_only_data, + ) + for tracker in trackers + ] + + if ignore_action_unlikely_intent: + trackers_as_states = [ + self._remove_action_unlikely_intent_from_states(states) + for states in trackers_as_states + ] + + self._choose_last_user_input(trackers_as_states, use_text_for_last_user_input) + + return trackers_as_states + + def to_dict(self) -> Dict[str, Any]: + return super().to_dict() + + @classmethod + def create_from_dict(cls, data: Dict[str, Any]) -> "FullDialogueTrackerFeaturizer": + state_featurizer = SingleStateFeaturizer.create_from_dict( + data["state_featurizer"] + ) + return cls( + state_featurizer, + ) + + +@TrackerFeaturizer.register("MaxHistoryTrackerFeaturizer") +class MaxHistoryTrackerFeaturizer(TrackerFeaturizer): + """Truncates the tracker history into `max_history` long sequences. + + Creates training data from trackers where actions are the output prediction + labels. Tracker state sequences which represent policy input are truncated + to not excede `max_history` states. + """ + + LABEL_NAME = "action" + + def __init__( + self, + state_featurizer: Optional[SingleStateFeaturizer] = None, + max_history: Optional[int] = None, + remove_duplicates: bool = True, + ) -> None: + """Initializes the tracker featurizer. + + Args: + state_featurizer: The state featurizer used to encode the states. + max_history: The maximum length of an extracted state sequence. + remove_duplicates: Keep only unique training state sequence/label pairs. + """ + super().__init__(state_featurizer) + self.max_history = max_history + self.remove_duplicates = remove_duplicates + + @staticmethod + def slice_state_history( + states: List[State], slice_length: Optional[int] + ) -> List[State]: + """Slices states from the trackers history. + + Args: + states: The states + slice_length: The slice length + + Returns: + The sliced states. + """ + if not slice_length: + return states + + return states[-slice_length:] + + @staticmethod + def _hash_example(states: List[State], labels: Optional[List[Text]] = None) -> int: + """Hashes states (and optionally label). + + Produces a hash of the tracker state sequence (and optionally the labels). + If `labels` is `None`, labels don't get hashed. + + Args: + states: The tracker state sequence to hash. + labels: Label strings associated with this state sequence. + + Returns: + The hash of the states and (optionally) the label. + """ + frozen_states = tuple( + s if s is None else DialogueStateTracker.freeze_current_state(s) + for s in states + ) + if labels is not None: + frozen_labels = tuple(labels) + return hash((frozen_states, frozen_labels)) + else: + return hash(frozen_states) + + def training_states_labels_and_entities( + self, + trackers: List[DialogueStateTracker], + domain: Domain, + omit_unset_slots: bool = False, + ignore_action_unlikely_intent: bool = False, + ) -> Tuple[List[List[State]], List[List[Text]], List[List[Dict[Text, Any]]]]: + """Transforms trackers to states, action labels, and entity data. + + Args: + trackers: The trackers to transform. + domain: The domain. + omit_unset_slots: If `True` do not include the initial values of slots. + ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent` + from training states. + + Returns: + Trackers as states, labels, and entity data. + """ + example_states = [] + example_labels = [] + example_entities = [] + + # Store of example hashes for removing duplicate training examples. + hashed_examples = set() + + logger.debug( + f"Creating states and {self.LABEL_NAME} label examples from " + f"collected trackers " + f"(by {type(self).__name__}({type(self.state_featurizer).__name__}))..." + ) + pbar = tqdm( + trackers, + desc="Processed trackers", + disable=rasa.shared.utils.io.is_logging_disabled(), + ) + for tracker in pbar: + + for states, label, entities in self._extract_examples( + tracker, + domain, + omit_unset_slots=omit_unset_slots, + ignore_action_unlikely_intent=ignore_action_unlikely_intent, + ): + + if self.remove_duplicates: + hashed = self._hash_example(states, label) + if hashed in hashed_examples: + continue + hashed_examples.add(hashed) + + example_states.append(states) + example_labels.append(label) + example_entities.append(entities) + + pbar.set_postfix({f"# {self.LABEL_NAME}": f"{len(example_labels):d}"}) + + self._remove_user_text_if_intent(example_states) + + logger.debug(f"Created {len(example_states)} {self.LABEL_NAME} examples.") + + return example_states, example_labels, example_entities + + def _extract_examples( + self, + tracker: DialogueStateTracker, + domain: Domain, + omit_unset_slots: bool = False, + ignore_action_unlikely_intent: bool = False, + ) -> Iterator[Tuple[List[State], List[Text], List[Dict[Text, Any]]]]: + """Creates an iterator over training examples from a tracker. + + Args: + trackers: The tracker from which to extract training examples. + domain: The domain of the training data. + omit_unset_slots: If `True` do not include the initial values of slots. + ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent` + from training states. + + Returns: + An iterator over example states, labels, and entity data. + """ + tracker_states = self._create_states( + tracker, domain, omit_unset_slots=omit_unset_slots + ) + events = tracker.applied_events() + + if ignore_action_unlikely_intent: + tracker_states = self._remove_action_unlikely_intent_from_states( + tracker_states + ) + events = self._remove_action_unlikely_intent_from_events(events) + + label_index = 0 + entity_data = {} + for event in events: + if isinstance(event, UserUttered): + entity_data = self._entity_data(event) + + elif isinstance(event, ActionExecuted): + + label_index += 1 + + # use only actions which can be predicted at a stories start + if event.unpredictable: + continue + + sliced_states = self.slice_state_history( + tracker_states[:label_index], self.max_history + ) + label = cast(List[Text], [event.action_name or event.action_text]) + entities = [entity_data] + + yield sliced_states, label, entities + + # reset entity_data for the the next turn + entity_data = {} + + def prediction_states( + self, + trackers: List[DialogueStateTracker], + domain: Domain, + use_text_for_last_user_input: bool = False, + ignore_rule_only_turns: bool = False, + rule_only_data: Optional[Dict[Text, Any]] = None, + ignore_action_unlikely_intent: bool = False, + ) -> List[List[State]]: + """Transforms trackers to states for prediction. + + Args: + trackers: The trackers to transform. + domain: The domain. + use_text_for_last_user_input: Indicates whether to use text or intent label + for featurizing last user input. + ignore_rule_only_turns: If True ignore dialogue turns that are present + only in rules. + rule_only_data: Slots and loops, + which only occur in rules but not in stories. + ignore_action_unlikely_intent: Whether to remove any states containing + `action_unlikely_intent` from prediction states. + + Returns: + Trackers as states for prediction. + """ + trackers_as_states = [ + self._create_states( + tracker, + domain, + ignore_rule_only_turns=ignore_rule_only_turns, + rule_only_data=rule_only_data, + ) + for tracker in trackers + ] + + # Remove `action_unlikely_intent` from `trackers_as_states`. + # This must be done before state history slicing to ensure the + # max history of the sliced states matches training time. + if ignore_action_unlikely_intent: + trackers_as_states = [ + self._remove_action_unlikely_intent_from_states(states) + for states in trackers_as_states + ] + + trackers_as_states = [ + self.slice_state_history(states, self.max_history) + for states in trackers_as_states + ] + self._choose_last_user_input(trackers_as_states, use_text_for_last_user_input) + + return trackers_as_states + + def to_dict(self) -> Dict[str, Any]: + data = super().to_dict() + data.update( + { + "remove_duplicates": self.remove_duplicates, + "max_history": self.max_history, + } + ) + return data + + @classmethod + def create_from_dict(cls, data: Dict[str, Any]) -> "MaxHistoryTrackerFeaturizer": + state_featurizer = SingleStateFeaturizer.create_from_dict( + data["state_featurizer"] + ) + return cls(state_featurizer, data["max_history"], data["remove_duplicates"]) + + +@TrackerFeaturizer.register("IntentMaxHistoryTrackerFeaturizer") +class IntentMaxHistoryTrackerFeaturizer(MaxHistoryTrackerFeaturizer): + """Truncates the tracker history into `max_history` long sequences. + + Creates training data from trackers where intents are the output prediction + labels. Tracker state sequences which represent policy input are truncated + to not excede `max_history` states. + """ + + LABEL_NAME = "intent" + + @classmethod + def _convert_labels_to_ids( + cls, trackers_as_intents: List[List[Text]], domain: Domain + ) -> np.ndarray: + """Converts a list of labels to a matrix of label ids. + + The number of rows is equal to `len(trackers_as_intents)`. The number of + columns is equal to the maximum number of positive labels that any training + example is associated with. Rows are padded with `LABEL_PAD_ID` if not all rows + have the same number of labels. + + Args: + trackers_as_intents: Positive example label ids + associated with each training example. + domain: The domain of the training data. + + Returns: + A matrix of label ids. + """ + # store labels in numpy arrays so that it corresponds to np arrays + # of input features + label_ids = [ + [domain.intents.index(intent) for intent in tracker_intents] + for tracker_intents in trackers_as_intents + ] + + return np.array(cls._pad_label_ids(label_ids)) + + @staticmethod + def _pad_label_ids(label_ids: List[List[int]]) -> List[List[int]]: + """Pads label ids so that all are of the same length. + + Args: + label_ids: Label ids of varying lengths + + Returns: + Label ids padded to be of uniform length. + """ + # If `label_ids` is an empty list, no padding needs to be added. + if not label_ids: + return label_ids + + # Add `LABEL_PAD_ID` padding to labels array so that + # each example has equal number of labels + multiple_labels_count = [len(a) for a in label_ids] + max_labels_count = max(multiple_labels_count) + num_padding_needed = [max_labels_count - len(a) for a in label_ids] + + padded_label_ids = [] + for ids, num_pads in zip(label_ids, num_padding_needed): + padded_row = list(ids) + [LABEL_PAD_ID] * num_pads + padded_label_ids.append(padded_row) + return padded_label_ids + + def training_states_labels_and_entities( + self, + trackers: List[DialogueStateTracker], + domain: Domain, + omit_unset_slots: bool = False, + ignore_action_unlikely_intent: bool = False, + ) -> Tuple[List[List[State]], List[List[Text]], List[List[Dict[Text, Any]]]]: + """Transforms trackers to states, intent labels, and entity data. + + Args: + trackers: The trackers to transform. + domain: The domain. + omit_unset_slots: If `True` do not include the initial values of slots. + ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent` + from training states. + + Returns: + Trackers as states, labels, and entity data. + """ + example_states = [] + example_entities = [] + + # Store of example hashes (of both states and labels) for removing + # duplicate training examples. + hashed_examples = set() + # Mapping of example state hash to set of + # positive labels associated with the state. + state_hash_to_label_set: DefaultDict[int, Set[Text]] = defaultdict(set) + + logger.debug( + f"Creating states and {self.LABEL_NAME} label examples from " + f"collected trackers " + f"(by {type(self).__name__}({type(self.state_featurizer).__name__}))..." + ) + pbar = tqdm( + trackers, + desc="Processed trackers", + disable=rasa.shared.utils.io.is_logging_disabled(), + ) + for tracker in pbar: + + for states, label, entities in self._extract_examples( + tracker, + domain, + omit_unset_slots=omit_unset_slots, + ignore_action_unlikely_intent=ignore_action_unlikely_intent, + ): + + if self.remove_duplicates: + hashed = self._hash_example(states, label) + if hashed in hashed_examples: + continue + hashed_examples.add(hashed) + + # Store all positive labels associated with a training state. + state_hash = self._hash_example(states) + + # Only add unique example states unless `remove_duplicates` is `False`. + if ( + not self.remove_duplicates + or state_hash not in state_hash_to_label_set + ): + example_states.append(states) + example_entities.append(entities) + + state_hash_to_label_set[state_hash].add(label[0]) + + pbar.set_postfix({f"# {self.LABEL_NAME}": f"{len(example_states):d}"}) + + # Collect positive labels for each state example. + example_labels = [ + list(state_hash_to_label_set[self._hash_example(state)]) + for state in example_states + ] + + self._remove_user_text_if_intent(example_states) + + logger.debug(f"Created {len(example_states)} {self.LABEL_NAME} examples.") + + return example_states, example_labels, example_entities + + def _extract_examples( + self, + tracker: DialogueStateTracker, + domain: Domain, + omit_unset_slots: bool = False, + ignore_action_unlikely_intent: bool = False, + ) -> Iterator[Tuple[List[State], List[Text], List[Dict[Text, Any]]]]: + """Creates an iterator over training examples from a tracker. + + Args: + tracker: The tracker from which to extract training examples. + domain: The domain of the training data. + omit_unset_slots: If `True` do not include the initial values of slots. + ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent` + from training states. + + Returns: + An iterator over example states, labels, and entity data. + """ + tracker_states = self._create_states( + tracker, domain, omit_unset_slots=omit_unset_slots + ) + events = tracker.applied_events() + + if ignore_action_unlikely_intent: + tracker_states = self._remove_action_unlikely_intent_from_states( + tracker_states + ) + events = self._remove_action_unlikely_intent_from_events(events) + + label_index = 0 + for event in events: + + if isinstance(event, ActionExecuted): + label_index += 1 + + elif isinstance(event, UserUttered): + + sliced_states = self.slice_state_history( + tracker_states[:label_index], self.max_history + ) + label = cast(List[Text], [event.intent_name or event.text]) + entities: List[Dict[Text, Any]] = [{}] + + yield sliced_states, label, entities + + @staticmethod + def _cleanup_last_user_state_with_action_listen( + trackers_as_states: List[List[State]], + ) -> List[List[State]]: + """Removes the last tracker state if the previous action is `action_listen`. + + States with the previous action equal to `action_listen` correspond to states + with a new user intent. This information is what `UnexpecTEDIntentPolicy` is + trying to predict so it needs to be removed before obtaining a prediction. + + Args: + trackers_as_states: Trackers converted to states + + Returns: + Filtered states with last `action_listen` removed. + """ + for states in trackers_as_states: + if not states: + continue + last_state = states[-1] + if rasa.shared.core.trackers.is_prev_action_listen_in_state(last_state): + del states[-1] + + return trackers_as_states + + def prediction_states( + self, + trackers: List[DialogueStateTracker], + domain: Domain, + use_text_for_last_user_input: bool = False, + ignore_rule_only_turns: bool = False, + rule_only_data: Optional[Dict[Text, Any]] = None, + ignore_action_unlikely_intent: bool = False, + ) -> List[List[State]]: + """Transforms trackers to states for prediction. + + Args: + trackers: The trackers to transform. + domain: The domain. + use_text_for_last_user_input: Indicates whether to use text or intent label + for featurizing last user input. + ignore_rule_only_turns: If True ignore dialogue turns that are present + only in rules. + rule_only_data: Slots and loops, + which only occur in rules but not in stories. + ignore_action_unlikely_intent: Whether to remove any states containing + `action_unlikely_intent` from prediction states. + + Returns: + Trackers as states for prediction. + """ + trackers_as_states = [ + self._create_states( + tracker, + domain, + ignore_rule_only_turns=ignore_rule_only_turns, + rule_only_data=rule_only_data, + ) + for tracker in trackers + ] + + # Remove `action_unlikely_intent` from `trackers_as_states`. + # This must be done before state history slicing to ensure the + # max history of the sliced states matches training time. + if ignore_action_unlikely_intent: + trackers_as_states = [ + self._remove_action_unlikely_intent_from_states(states) + for states in trackers_as_states + ] + + self._choose_last_user_input(trackers_as_states, use_text_for_last_user_input) + + # `tracker_as_states` contain a state with intent = last intent + # and previous action = action_listen. This state needs to be + # removed as it was not present during training as well because + # predicting the last intent is what the policies using this + # featurizer do. This is specifically done before state history + # is sliced so that the number of past states is same as `max_history`. + self._cleanup_last_user_state_with_action_listen(trackers_as_states) + + trackers_as_states = [ + self.slice_state_history(states, self.max_history) + for states in trackers_as_states + ] + + return trackers_as_states + + def to_dict(self) -> Dict[str, Any]: + return super().to_dict() + + @classmethod + def create_from_dict( + cls, data: Dict[str, Any] + ) -> "IntentMaxHistoryTrackerFeaturizer": + state_featurizer = SingleStateFeaturizer.create_from_dict( + data["state_featurizer"] + ) + return cls(state_featurizer, data["max_history"], data["remove_duplicates"]) + + +def _is_prev_action_unlikely_intent_in_state(state: State) -> bool: + prev_action_name = state.get(PREVIOUS_ACTION, {}).get(ACTION_NAME) + return prev_action_name == ACTION_UNLIKELY_INTENT_NAME diff --git a/rasa/core/http_interpreter.py b/rasa/core/http_interpreter.py new file mode 100644 index 0000000..375dc7d --- /dev/null +++ b/rasa/core/http_interpreter.py @@ -0,0 +1,89 @@ +import aiohttp + +import copy +import logging +import structlog + +from typing import Text, Dict, Any, Optional + +from rasa.core import constants +from rasa.core.channels import UserMessage +from rasa.shared.nlu.constants import INTENT_NAME_KEY +from rasa.utils.endpoints import EndpointConfig + +logger = logging.getLogger(__name__) +structlogger = structlog.get_logger() + + +class RasaNLUHttpInterpreter: + """Allows for an HTTP endpoint to be used to parse messages.""" + + def __init__(self, endpoint_config: Optional[EndpointConfig] = None) -> None: + """Initializes a `RasaNLUHttpInterpreter`.""" + self.session = aiohttp.ClientSession() + if endpoint_config: + self.endpoint_config = endpoint_config + else: + self.endpoint_config = EndpointConfig(constants.DEFAULT_SERVER_URL) + + async def parse(self, message: UserMessage) -> Dict[Text, Any]: + """Parse a text message. + + Return a default value if the parsing of the text failed. + """ + default_return = { + "intent": {INTENT_NAME_KEY: "", "confidence": 0.0}, + "entities": [], + "text": "", + } + + result = await self._rasa_http_parse(message.text, message.sender_id) + return result if result is not None else default_return + + async def _rasa_http_parse( + self, text: Text, message_id: Optional[Text] = None + ) -> Optional[Dict[Text, Any]]: + """Send a text message to a running rasa NLU http server. + + Return `None` on failure. + """ + if not self.endpoint_config or self.endpoint_config.url is None: + structlogger.error( + "http.parse.text", + text=copy.deepcopy(text), + event_info="No rasa NLU server specified!", + ) + return None + + params = { + "token": self.endpoint_config.token, + "text": text, + "message_id": message_id, + } + + if self.endpoint_config.url.endswith("/"): + url = self.endpoint_config.url + "model/parse" + else: + url = self.endpoint_config.url + "/model/parse" + + # noinspection PyBroadException + try: + async with self.session.post(url, json=params) as resp: + if resp.status == 200: + return await resp.json() + else: + response_text = await resp.text() + structlogger.error( + "http.parse.text.failure", + text=copy.deepcopy(text), + response_text=copy.deepcopy(response_text), + ) + return None + except Exception: # skipcq: PYL-W0703 + # need to catch all possible exceptions when doing http requests + # (timeouts, value errors, parser errors, ...) + structlogger.exception( + "http.parse.text.exception", + text=copy.deepcopy(text), + ) + return None diff --git a/rasa/core/jobs.py b/rasa/core/jobs.py new file mode 100644 index 0000000..0bd843a --- /dev/null +++ b/rasa/core/jobs.py @@ -0,0 +1,63 @@ +import asyncio +import logging + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from pytz import UnknownTimeZoneError, utc +import rasa.shared.utils.io + +__scheduler = None + +logger = logging.getLogger(__name__) + + +async def scheduler() -> AsyncIOScheduler: + """Thread global scheduler to handle all recurring tasks. + + If no scheduler exists yet, this will instantiate one.""" + + global __scheduler + + if not __scheduler: + try: + __scheduler = AsyncIOScheduler(event_loop=asyncio.get_event_loop()) + __scheduler.start() + return __scheduler + except UnknownTimeZoneError: + rasa.shared.utils.io.raise_warning( + "apscheduler could not find a timezone and is " + "defaulting to utc. This is probably because " + "your system timezone is not set. " + 'Set it with e.g. echo "Europe/Berlin" > ' + "/etc/timezone" + ) + __scheduler = AsyncIOScheduler( + event_loop=asyncio.get_event_loop(), timezone=utc + ) + __scheduler.start() + return __scheduler + else: + # scheduler already created, make sure it is running on + # the correct loop + # noinspection PyProtectedMember + if not __scheduler._eventloop == asyncio.get_event_loop(): + raise RuntimeError( + "Detected inconsistent loop usage. " + "Trying to schedule a task on a new event " + "loop, but scheduler was created with a " + "different event loop. Make sure there " + "is only one event loop in use and that the " + "scheduler is running on that one." + ) + return __scheduler + + +def kill_scheduler() -> None: + """Terminate the scheduler if started. + + Another call to `scheduler` will create a new scheduler.""" + + global __scheduler + + if __scheduler: + __scheduler.shutdown() + __scheduler = None diff --git a/rasa/core/lock.py b/rasa/core/lock.py new file mode 100644 index 0000000..7195ecb --- /dev/null +++ b/rasa/core/lock.py @@ -0,0 +1,139 @@ +import json +import logging +from collections import deque + +import time +from typing import Text, Optional, Union, Deque, Dict, Any + +logger = logging.getLogger(__name__) + +NO_TICKET_ISSUED = -1 # index of latest issued ticket if no tickets exist + + +class Ticket: + def __init__(self, number: int, expires: float) -> None: + self.number = number + self.expires = expires + + def has_expired(self) -> bool: + return time.time() > self.expires + + def as_dict(self) -> Dict[Text, Any]: + return dict(number=self.number, expires=self.expires) + + def dumps(self) -> Text: + """Return json dump of `Ticket` as dictionary.""" + return json.dumps(self.as_dict()) + + @classmethod + def from_dict(cls, data: Dict[Text, Union[int, float]]) -> "Ticket": + """Creates `Ticket` from dictionary.""" + return cls(number=data["number"], expires=data["expires"]) + + def __repr__(self) -> Text: + return f"Ticket(number: {self.number}, expires: {self.expires})" + + +class TicketLock: + """Locking mechanism that issues tickets managing access to conversation IDs. + + Tickets are issued in the order in which they are requested. A detailed + explanation of the ticket lock algorithm can be found at + http://pages.cs.wisc.edu/~remzi/OSTEP/threads-locks.pdf#page=13 + """ + + def __init__( + self, conversation_id: Text, tickets: Optional[Deque[Ticket]] = None + ) -> None: + self.conversation_id = conversation_id + self.tickets = tickets or deque() + + @classmethod + def from_dict(cls, data: Dict[Text, Any]) -> "TicketLock": + """Create `TicketLock` from dictionary.""" + tickets = [Ticket.from_dict(json.loads(d)) for d in data.get("tickets", [])] + return cls(data.get("conversation_id"), deque(tickets)) + + def dumps(self) -> Text: + """Return json dump of `TicketLock`.""" + tickets = [ticket.dumps() for ticket in self.tickets] + return json.dumps(dict(conversation_id=self.conversation_id, tickets=tickets)) + + def is_locked(self, ticket_number: int) -> bool: + """Return whether `ticket_number` is locked. + + Returns: + True if `now_serving` is not equal to `ticket`. + """ + return self.now_serving != ticket_number + + def issue_ticket(self, lifetime: float) -> int: + """Issue a new ticket and return its number.""" + self.remove_expired_tickets() + number = self.last_issued + 1 + ticket = Ticket(number, time.time() + lifetime) + self.tickets.append(ticket) + + return number + + def remove_expired_tickets(self) -> None: + """Remove expired tickets.""" + # iterate over copy of self.tickets so we can remove items + for ticket in list(self.tickets): + if ticket.has_expired(): + self.tickets.remove(ticket) + + @property + def last_issued(self) -> int: + """Return number of the ticket that was last added. + + Returns: + Number of `Ticket` that was last added. `NO_TICKET_ISSUED` if no + tickets exist. + """ + ticket_number = self._ticket_number_for(-1) + + return ticket_number if ticket_number is not None else NO_TICKET_ISSUED + + @property + def now_serving(self) -> Optional[int]: + """Get number of the ticket to be served next. + + Returns: + Number of `Ticket` that is served next. 0 if no `Ticket` exists. + """ + return self._ticket_number_for(0) or 0 + + def _ticket_number_for(self, ticket_index: int) -> Optional[int]: + """Get ticket number for `ticket_index`. + + Returns: + Ticket number for `Ticket` with index `ticket_index`. None if there are no + tickets, or if `ticket_index` is out of bounds of `self.tickets`. + """ + self.remove_expired_tickets() + + try: + return self.tickets[ticket_index].number + except IndexError: + return None + + def _ticket_for_ticket_number(self, ticket_number: int) -> Optional[Ticket]: + """Return ticket for `ticket_number`.""" + self.remove_expired_tickets() + + return next((t for t in self.tickets if t.number == ticket_number), None) + + def is_someone_waiting(self) -> bool: + """Return whether someone is waiting for the lock to become available. + + Returns: + True if the `self.tickets` queue has length greater than 0. + """ + return len(self.tickets) > 0 + + def remove_ticket_for(self, ticket_number: int) -> None: + """Remove `Ticket` for `ticket_number.""" + ticket = self._ticket_for_ticket_number(ticket_number) + if ticket: + self.tickets.remove(ticket) diff --git a/rasa/core/lock_store.py b/rasa/core/lock_store.py new file mode 100644 index 0000000..e8baff1 --- /dev/null +++ b/rasa/core/lock_store.py @@ -0,0 +1,340 @@ +from __future__ import annotations +import asyncio +from contextlib import asynccontextmanager +import json +import logging +import os + +from typing import AsyncGenerator, Dict, Optional, Text, Union + +from rasa.shared.exceptions import RasaException, ConnectionException +import rasa.shared.utils.common +from rasa.core.constants import DEFAULT_LOCK_LIFETIME +from rasa.core.lock import TicketLock +from rasa.utils.endpoints import EndpointConfig + +logger = logging.getLogger(__name__) + + +def _get_lock_lifetime() -> int: + return int(os.environ.get("TICKET_LOCK_LIFETIME", 0)) or DEFAULT_LOCK_LIFETIME + + +LOCK_LIFETIME = _get_lock_lifetime() +DEFAULT_SOCKET_TIMEOUT_IN_SECONDS = 10 + +DEFAULT_REDIS_LOCK_STORE_KEY_PREFIX = "lock:" + + +# noinspection PyUnresolvedReferences +class LockError(RasaException): + """Exception that is raised when a lock cannot be acquired. + + Attributes: + message (str): explanation of which `conversation_id` raised the error + """ + + pass + + +class LockStore: + """Base class for ticket locks.""" + + @staticmethod + def create(obj: Union[LockStore, EndpointConfig, None]) -> LockStore: + """Factory to create a lock store.""" + if isinstance(obj, LockStore): + return obj + + try: + return _create_from_endpoint_config(obj) + except ConnectionError as error: + raise ConnectionException("Cannot connect to lock store.") from error + + @staticmethod + def create_lock(conversation_id: Text) -> TicketLock: + """Create a new `TicketLock` for `conversation_id`.""" + return TicketLock(conversation_id) + + def get_lock(self, conversation_id: Text) -> Optional[TicketLock]: + """Fetch lock for `conversation_id` from storage.""" + raise NotImplementedError + + def delete_lock(self, conversation_id: Text) -> None: + """Delete lock for `conversation_id` from storage.""" + raise NotImplementedError + + def save_lock(self, lock: TicketLock) -> None: + """Commit `lock` to storage.""" + raise NotImplementedError + + def issue_ticket( + self, conversation_id: Text, lock_lifetime: float = LOCK_LIFETIME + ) -> int: + """Issue new ticket with `lock_lifetime` for lock associated with + `conversation_id`. + + Creates a new lock if none is found. + """ + logger.debug(f"Issuing ticket for conversation '{conversation_id}'.") + try: + lock = self.get_or_create_lock(conversation_id) + ticket = lock.issue_ticket(lock_lifetime) + self.save_lock(lock) + + return ticket + except Exception as e: + raise LockError(f"Error while acquiring lock. Error:\n{e}") + + @asynccontextmanager + async def lock( + self, + conversation_id: Text, + lock_lifetime: float = LOCK_LIFETIME, + wait_time_in_seconds: float = 1, + ) -> AsyncGenerator[TicketLock, None]: + """Acquire lock with lifetime `lock_lifetime`for `conversation_id`. + + Try acquiring lock with a wait time of `wait_time_in_seconds` seconds + between attempts. Raise a `LockError` if lock has expired. + """ + ticket = self.issue_ticket(conversation_id, lock_lifetime) + try: + + yield await self._acquire_lock( + conversation_id, ticket, wait_time_in_seconds + ) + finally: + self.cleanup(conversation_id, ticket) + + async def _acquire_lock( + self, conversation_id: Text, ticket: int, wait_time_in_seconds: float + ) -> TicketLock: + logger.debug(f"Acquiring lock for conversation '{conversation_id}'.") + while True: + # fetch lock in every iteration because lock might no longer exist + lock = self.get_lock(conversation_id) + + # exit loop if lock does not exist anymore (expired) + if not lock: + break + + # acquire lock if it isn't locked + if not lock.is_locked(ticket): + logger.debug(f"Acquired lock for conversation '{conversation_id}'.") + return lock + + items_before_this = ticket - (lock.now_serving or 0) + + logger.debug( + f"Failed to acquire lock for conversation ID '{conversation_id}' " + f"because {items_before_this} other item(s) for this " + f"conversation ID have to be finished processing first. " + f"Retrying in {wait_time_in_seconds} seconds ..." + ) + + # sleep and update lock + await asyncio.sleep(wait_time_in_seconds) + self.update_lock(conversation_id) + + raise LockError( + f"Could not acquire lock for conversation_id '{conversation_id}'." + ) + + def update_lock(self, conversation_id: Text) -> None: + """Fetch lock for `conversation_id`, remove expired tickets and save lock.""" + lock = self.get_lock(conversation_id) + if lock: + lock.remove_expired_tickets() + self.save_lock(lock) + + def get_or_create_lock(self, conversation_id: Text) -> TicketLock: + """Fetch existing lock for `conversation_id`. + + Alternatively, create a new one if it doesn't exist. + """ + existing_lock = self.get_lock(conversation_id) + + if existing_lock: + return existing_lock + + return self.create_lock(conversation_id) + + def is_someone_waiting(self, conversation_id: Text) -> bool: + """Return whether someone is waiting for lock for this `conversation_id`.""" + lock = self.get_lock(conversation_id) + if lock: + return lock.is_someone_waiting() + + return False + + def finish_serving(self, conversation_id: Text, ticket_number: int) -> None: + """Finish serving ticket with `ticket_number` for `conversation_id`. + + Removes ticket from lock and saves lock. + """ + lock = self.get_lock(conversation_id) + if lock: + lock.remove_ticket_for(ticket_number) + self.save_lock(lock) + + def cleanup(self, conversation_id: Text, ticket_number: int) -> None: + """Remove lock for `conversation_id` if no one is waiting.""" + self.finish_serving(conversation_id, ticket_number) + if not self.is_someone_waiting(conversation_id): + self.delete_lock(conversation_id) + + @staticmethod + def _log_deletion(conversation_id: Text, deletion_successful: bool) -> None: + if deletion_successful: + logger.debug(f"Deleted lock for conversation '{conversation_id}'.") + else: + logger.debug(f"Could not delete lock for conversation '{conversation_id}'.") + + +class RedisLockStore(LockStore): + """Redis store for ticket locks.""" + + def __init__( + self, + host: Text = "localhost", + port: int = 6379, + db: int = 1, + username: Optional[Text] = None, + password: Optional[Text] = None, + use_ssl: bool = False, + ssl_certfile: Optional[Text] = None, + ssl_keyfile: Optional[Text] = None, + ssl_ca_certs: Optional[Text] = None, + key_prefix: Optional[Text] = None, + socket_timeout: float = DEFAULT_SOCKET_TIMEOUT_IN_SECONDS, + ) -> None: + """Create a lock store which uses Redis for persistence. + + Args: + host: The host of the redis server. + port: The port of the redis server. + db: The name of the database within Redis which should be used by Rasa + Open Source. + username: The username which should be used for authentication with the + Redis database. + password: The password which should be used for authentication with the + Redis database. + use_ssl: `True` if SSL should be used for the connection to Redis. + ssl_certfile: Path to the SSL certificate file. + ssl_keyfile: Path to the SSL private key file. + ssl_ca_certs: Path to the SSL CA certificate file. + key_prefix: prefix to prepend to all keys used by the lock store. Must be + alphanumeric. + socket_timeout: Timeout in seconds after which an exception will be raised + in case Redis doesn't respond within `socket_timeout` seconds. + """ + import redis + + self.red = redis.StrictRedis( + host=host, + port=int(port), + db=int(db), + username=username, + password=password, + ssl=use_ssl, + ssl_certfile=ssl_certfile, + ssl_keyfile=ssl_keyfile, + ssl_ca_certs=ssl_ca_certs, + socket_timeout=socket_timeout, + ) + + self.key_prefix = DEFAULT_REDIS_LOCK_STORE_KEY_PREFIX + if key_prefix: + logger.debug(f"Setting non-default redis key prefix: '{key_prefix}'.") + self._set_key_prefix(key_prefix) + + super().__init__() + + def _set_key_prefix(self, key_prefix: Text) -> None: + if isinstance(key_prefix, str) and key_prefix.isalnum(): + self.key_prefix = key_prefix + ":" + DEFAULT_REDIS_LOCK_STORE_KEY_PREFIX + else: + logger.warning( + f"Omitting provided non-alphanumeric redis key prefix: '{key_prefix}'. " + f"Using default '{self.key_prefix}' instead." + ) + + def get_lock(self, conversation_id: Text) -> Optional[TicketLock]: + """Retrieves lock (see parent docstring for more information).""" + serialised_lock = self.red.get(self.key_prefix + conversation_id) + if serialised_lock: + return TicketLock.from_dict(json.loads(serialised_lock)) + + return None + + def delete_lock(self, conversation_id: Text) -> None: + """Deletes lock for conversation ID.""" + deletion_successful = self.red.delete(self.key_prefix + conversation_id) + self._log_deletion(conversation_id, deletion_successful) + + def save_lock(self, lock: TicketLock) -> None: + self.red.set(self.key_prefix + lock.conversation_id, lock.dumps()) + + +class InMemoryLockStore(LockStore): + """In-memory store for ticket locks.""" + + def __init__(self) -> None: + """Initialise dictionary of locks.""" + self.conversation_locks: Dict[Text, TicketLock] = {} + super().__init__() + + def get_lock(self, conversation_id: Text) -> Optional[TicketLock]: + """Get lock for conversation if it exists.""" + return self.conversation_locks.get(conversation_id) + + def delete_lock(self, conversation_id: Text) -> None: + """Delete lock for conversation.""" + deleted_lock = self.conversation_locks.pop(conversation_id, None) + self._log_deletion( + conversation_id, deletion_successful=deleted_lock is not None + ) + + def save_lock(self, lock: TicketLock) -> None: + """Save lock in store.""" + self.conversation_locks[lock.conversation_id] = lock + + +def _create_from_endpoint_config( + endpoint_config: Optional[EndpointConfig] = None, +) -> LockStore: + """Given an endpoint configuration, create a proper `LockStore` object.""" + if ( + endpoint_config is None + or endpoint_config.type is None + or endpoint_config.type == "in_memory" + ): + # this is the default type if no lock store type is set + + lock_store: LockStore = InMemoryLockStore() + elif endpoint_config.type == "redis": + lock_store = RedisLockStore(host=endpoint_config.url, **endpoint_config.kwargs) + else: + lock_store = _load_from_module_name_in_endpoint_config(endpoint_config) + + logger.debug(f"Connected to lock store '{lock_store.__class__.__name__}'.") + + return lock_store + + +def _load_from_module_name_in_endpoint_config( + endpoint_config: EndpointConfig, +) -> LockStore: + """Retrieve a `LockStore` based on its class name.""" + try: + lock_store_class = rasa.shared.utils.common.class_from_module_path( + endpoint_config.type + ) + return lock_store_class(endpoint_config=endpoint_config) + except (AttributeError, ImportError) as e: + raise Exception( + f"Could not find a class based on the module path " + f"'{endpoint_config.type}'. Failed to create a `LockStore` " + f"instance. Error: {e}" + ) diff --git a/rasa/core/migrate.py b/rasa/core/migrate.py new file mode 100644 index 0000000..37433cd --- /dev/null +++ b/rasa/core/migrate.py @@ -0,0 +1,407 @@ +import copy +import shutil +from pathlib import Path +from typing import List, Dict, Text, Any, Tuple, Optional, Union + +from ruamel.yaml.scalarstring import DoubleQuotedScalarString + +import rasa.shared.utils.io +import rasa.shared.utils.cli +from rasa.shared.constants import REQUIRED_SLOTS_KEY, IGNORED_INTENTS +from rasa.shared.core.constants import ( + ACTIVE_LOOP, + REQUESTED_SLOT, + SlotMappingType, + MAPPING_TYPE, + SLOT_MAPPINGS, +) +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION +from rasa.shared.core.domain import KEY_ENTITIES, KEY_SLOTS, KEY_FORMS, Domain +from rasa.shared.exceptions import RasaException +from rasa.shared.utils.validation import KEY_TRAINING_DATA_FORMAT_VERSION + +ORIGINAL_DOMAIN = "original_domain" # not a default, fixed +DEFAULT_NEW_DOMAIN = "new_domain" +YML_SUFFIX = ".yml" + + +def _create_back_up(domain_file: Path, backup_location: Path) -> Dict[Text, Any]: + """Makes a backup and returns the content of the file.""" + original_content = rasa.shared.utils.io.read_yaml( + rasa.shared.utils.io.read_file(domain_file) + ) + rasa.shared.utils.io.write_yaml( + original_content, backup_location, should_preserve_key_order=True + ) + return original_content + + +def _get_updated_mapping_condition( + condition: Dict[Text, Text], mapping: Dict[Text, Any], slot_name: Text +) -> Dict[Text, Text]: + if mapping.get(MAPPING_TYPE) not in [ + str(SlotMappingType.FROM_ENTITY), + str(SlotMappingType.FROM_TRIGGER_INTENT), + ]: + return {**condition, REQUESTED_SLOT: slot_name} + return condition + + +def _get_updated_or_new_mappings( + existing_mappings: List[Dict[Text, Any]], + new_mappings: List[Dict[Text, Any]], + condition: Dict[Text, Text], + slot_name: Text, +) -> List[Dict[Text, Any]]: + updated_mappings = [] + + for existing_mapping in existing_mappings: + mapping_copy = copy.deepcopy(existing_mapping) + + conditions = existing_mapping.pop("conditions", []) + if existing_mapping in new_mappings: + new_mappings.remove(existing_mapping) + conditions.append( + _get_updated_mapping_condition(condition, existing_mapping, slot_name) + ) + existing_mapping.update({"conditions": conditions}) + updated_mappings.append(existing_mapping) + else: + updated_mappings.append(mapping_copy) + + for mapping in new_mappings: + mapping.update( + { + "conditions": [ + _get_updated_mapping_condition(condition, mapping, slot_name) + ] + } + ) + updated_mappings.append(mapping) + + return updated_mappings + + +def _migrate_form_slots( + domain: Dict[Text, Any] +) -> Tuple[Dict[Any, Dict[str, Any]], Optional[Any]]: + updated_slots = domain.get(KEY_SLOTS, {}) + forms = domain.get(KEY_FORMS, {}) + + new_forms = {} + + for form_name, form_data in forms.items(): + ignored_intents = form_data.pop(IGNORED_INTENTS, []) + if REQUIRED_SLOTS_KEY in form_data: + form_data = form_data.get(REQUIRED_SLOTS_KEY, {}) + + required_slots = [] + + for slot_name, mappings in form_data.items(): + condition = {ACTIVE_LOOP: form_name} + slot_properties = updated_slots.get(slot_name, {}) + existing_mappings = slot_properties.get("mappings", []) + updated_mappings = _get_updated_or_new_mappings( + existing_mappings, mappings, condition, slot_name + ) + slot_properties.update({"mappings": updated_mappings}) + updated_slots[slot_name] = slot_properties + + required_slots.append(slot_name) + + new_forms[form_name] = {REQUIRED_SLOTS_KEY: required_slots} + + if ignored_intents: + new_forms[form_name][IGNORED_INTENTS] = ignored_intents + + return new_forms, updated_slots + + +def _migrate_auto_fill( + slot_name: Text, properties: Dict[Text, Any], entities: List[Text] +) -> Dict[Text, Any]: + if slot_name in entities and properties.get("auto_fill", True) is True: + from_entity_mapping = { + "type": str(SlotMappingType.FROM_ENTITY), + "entity": slot_name, + } + mappings = properties.get(SLOT_MAPPINGS, []) + if from_entity_mapping not in mappings: + mappings.append(from_entity_mapping) + properties.update({SLOT_MAPPINGS: mappings}) + + if "auto_fill" in properties: + del properties["auto_fill"] + + return properties + + +def _migrate_custom_slots( + slot_name: Text, properties: Dict[Text, Any] +) -> Dict[Text, Any]: + if not properties.get("mappings"): + properties.update({"mappings": [{"type": "custom"}]}) + + rasa.shared.utils.io.raise_warning( + f"A custom mapping was added to slot '{slot_name}'. " + f"Please double-check this is correct.", + UserWarning, + ) + + return properties + + +def _migrate_auto_fill_and_custom_slots( + domain: Dict[Text, Any], slots: Dict[Text, Any] +) -> Dict[Text, Any]: + new_slots = {} + entities = domain.get(KEY_ENTITIES, []) + + for slot_name, properties in slots.items(): + updated_properties = _migrate_auto_fill(slot_name, properties, entities) + updated_properties = _migrate_custom_slots(slot_name, updated_properties) + + new_slots[slot_name] = updated_properties + return new_slots + + +def _assemble_new_domain( + domain_file: Path, new_forms: Dict[Text, Any], new_slots: Dict[Text, Any] +) -> Dict[Text, Any]: + original_content = rasa.shared.utils.io.read_yaml( + rasa.shared.utils.io.read_file(domain_file) + ) + new_domain: Dict[Text, Any] = {} + for key, value in original_content.items(): + if key == KEY_SLOTS: + new_domain.update({key: new_slots}) + elif key == KEY_FORMS: + new_domain.update({key: new_forms}) + elif key == "version": + new_domain.update( + {key: DoubleQuotedScalarString(LATEST_TRAINING_DATA_FORMAT_VERSION)} + ) + else: + new_domain.update({key: value}) + return new_domain + + +def _write_final_domain( + domain_file: Path, new_forms: Dict, new_slots: Dict, out_file: Path +) -> None: + if domain_file.is_dir(): + for file in domain_file.iterdir(): + if not Domain.is_domain_file(file): + continue + new_domain = _assemble_new_domain(file, new_forms, new_slots) + rasa.shared.utils.io.write_yaml(new_domain, out_file / file.name, True) + else: + new_domain = _assemble_new_domain(domain_file, new_forms, new_slots) + rasa.shared.utils.io.write_yaml(new_domain, out_file, True) + + +def _migrate_domain_files( + domain_path: Path, backup_location: Path, out_path: Path +) -> Dict[Text, Any]: + """Migrates files that only need a version update and collects the remaining info. + + Moreover, backups will be created from all domain files that can be found in the + given domain directory. + + Args: + domain_path: directory containing domain files + backup_location: where to backup all domain files + out_path: location where to store the migrated files + """ + slots: Dict[Text, Any] = {} + forms: Dict[Text, Any] = {} + entities: List[Any] = [] + + domain_files = [ + file for file in domain_path.iterdir() if Domain.is_domain_file(file) + ] + + if not domain_files: + raise RasaException( + f"The domain directory '{domain_path.as_posix()}' does not contain any " + f"domain files. Please make sure to include these for a successful " + f"migration." + ) + + for file in domain_files: + + backup = backup_location / file.name + original_content = _create_back_up(file, backup) + + if KEY_SLOTS not in original_content and KEY_FORMS not in original_content: + if isinstance(original_content, dict): + original_content.update( + { + "version": DoubleQuotedScalarString( + LATEST_TRAINING_DATA_FORMAT_VERSION + ) + } + ) + + # this is done so that the other domain files can be moved + # in the migrated directory + rasa.shared.utils.io.write_yaml( + original_content, out_path / file.name, True + ) + elif KEY_SLOTS in original_content and slots: + raise RasaException( + f"Domain files with multiple '{KEY_SLOTS}' " + f"sections were provided. Please group these sections " + f"in one file only to prevent content duplication across " + f"multiple files. " + ) + elif KEY_FORMS in original_content and forms: + raise RasaException( + f"Domain files with multiple '{KEY_FORMS}' " + f"sections were provided. Please group these sections " + f"in one file only to prevent content duplication across " + f"multiple files. " + ) + + slots.update(original_content.get(KEY_SLOTS, {})) + forms.update(original_content.get(KEY_FORMS, {})) + entities.extend(original_content.get(KEY_ENTITIES, [])) + + if not slots or not forms: + raise RasaException( + f"The files you have provided in '{domain_path}' are missing slots " + f"or forms. Please make sure to include these for a " + f"successful migration." + ) + + return {KEY_SLOTS: slots, KEY_FORMS: forms, KEY_ENTITIES: entities} + + +def migrate_domain_format( + domain_path: Union[Text, Path], out_path: Optional[Union[Text, Path]] +) -> None: + """Converts 2.0 domain to 3.0 format.""" + domain_path = Path(domain_path) + out_path = Path(out_path) if out_path else None + + domain_parent_dir = domain_path.parent + migrate_file_only = domain_path.is_file() + + # Ensure the backup location does not exist yet + # Note: We demand that file as well as folder with this name gets deleted before + # the command is run to avoid confusion afterwards. + suffix = f"{ORIGINAL_DOMAIN}{YML_SUFFIX}" if migrate_file_only else ORIGINAL_DOMAIN + backup_location = domain_parent_dir / suffix + if backup_location.exists(): + backup_location_str = "directory" if backup_location.is_dir() else "file" + raise RasaException( + f"The domain could not be migrated since the " + f"{backup_location_str} '{backup_location}' already exists." + f"Please make sure that there is no {backup_location_str} at " + f"'{backup_location}'." + ) + + # Choose a default output location if nothing was specified + if out_path is None: + suffix = ( + f"{DEFAULT_NEW_DOMAIN}{YML_SUFFIX}" + if migrate_file_only + else DEFAULT_NEW_DOMAIN + ) + out_path = domain_parent_dir / suffix + + # Ensure the output location is not already in-use + if not migrate_file_only: + if out_path.is_dir() and any(out_path.iterdir()): + raise RasaException( + f"The domain could not be migrated to " + f"'{out_path}' because that folder is not empty." + "Please remove the contents of the folder and try again." + ) + else: + if out_path.is_file(): + raise RasaException( + f"The domain could not be migrated to " + f"'{out_path}' because that file already exists." + "Please remove the file and try again." + ) + + # Sanity Check: Assert the files to be migrated aren't in 3.0 format already + # Note: we do not enforce that the version tag is 2.0 everywhere + validate that + # migrate-able domain files are among these files later + original_files = ( + { + file: rasa.shared.utils.io.read_yaml_file(file) + for file in domain_path.iterdir() + if Domain.is_domain_file(file) + } + if domain_path.is_dir() + else {domain_path: rasa.shared.utils.io.read_yaml_file(domain_path)} + ) + migrated_files = [] + + for file, file_dict in original_files.items(): + if not isinstance(file_dict, dict): + raise RasaException( + f"The file {file} could not be read " + f"as an eligible domain dictionary. " + f"Please make sure you have included " + f"only eligible domain files." + ) + + if ( + file_dict.get(KEY_TRAINING_DATA_FORMAT_VERSION) + == LATEST_TRAINING_DATA_FORMAT_VERSION + ): + migrated_files.append(file) + + if migrated_files: + raise RasaException( + f"Some of the given files ({[file for file in migrated_files]}) " + f"have already been migrated to Rasa 3.0 format. Please remove these " + f"migrated files (or replace them with files in 2.0 format) and try again." + ) + + # Validate given domain file(s) and migrate them + try: + created_out_dir = False + if not migrate_file_only: + if not out_path.is_dir(): + out_path.mkdir() + created_out_dir = True + backup_location.mkdir() + original_domain = _migrate_domain_files( + domain_path, backup_location, out_path + ) + else: + if not Domain.is_domain_file(domain_path): + raise RasaException( + f"The file '{domain_path.as_posix()}' could not be validated as a " + f"domain file. Only domain yaml files can be migrated. " + ) + original_domain = _create_back_up(domain_path, backup_location) + + new_forms, updated_slots = _migrate_form_slots(original_domain) + new_slots = _migrate_auto_fill_and_custom_slots(original_domain, updated_slots) + + _write_final_domain(domain_path, new_forms, new_slots, out_path) + + rasa.shared.utils.cli.print_success( + f"Your domain file '{str(domain_path)}' was successfully migrated! " + f"The migrated version is now '{str(out_path)}'. " + f"The original domain file is backed-up at '{str(backup_location)}'." + ) + + except Exception as e: + # Remove the backups if migration couldn't be completed + if backup_location.is_dir(): + shutil.rmtree(backup_location) + if out_path.is_dir(): + if created_out_dir: + shutil.rmtree(out_path) + else: # just remove contained files so we do not mess with access rights + for f in out_path.glob("*"): + f.unlink() + if backup_location.is_file(): + backup_location.unlink() + raise e diff --git a/rasa/core/nlg/__init__.py b/rasa/core/nlg/__init__.py new file mode 100644 index 0000000..0df389e --- /dev/null +++ b/rasa/core/nlg/__init__.py @@ -0,0 +1,3 @@ +from rasa.core.nlg.generator import NaturalLanguageGenerator # noqa: F401 +from rasa.core.nlg.response import TemplatedNaturalLanguageGenerator # noqa: F401 +from rasa.core.nlg.callback import CallbackNaturalLanguageGenerator # noqa: F401 diff --git a/rasa/core/nlg/callback.py b/rasa/core/nlg/callback.py new file mode 100644 index 0000000..d997f6a --- /dev/null +++ b/rasa/core/nlg/callback.py @@ -0,0 +1,147 @@ +import json +import logging +from typing import List, Text, Any, Dict, Optional + +from rasa.core.constants import DEFAULT_REQUEST_TIMEOUT +from rasa.core.nlg.generator import NaturalLanguageGenerator, ResponseVariationFilter +from rasa.shared.core.trackers import DialogueStateTracker, EventVerbosity +from rasa.shared.exceptions import RasaException +from rasa.utils.endpoints import EndpointConfig + +logger = logging.getLogger(__name__) + + +def nlg_response_format_spec() -> Dict[Text, Any]: + """Expected response schema for an NLG endpoint. + + Used for validation of the response returned from the NLG endpoint. + """ + return { + "type": "object", + "properties": { + "text": {"type": "string"}, + "id": {"type": ["string", "null"]}, + "buttons": {"type": ["array", "null"], "items": {"type": "object"}}, + "elements": {"type": ["array", "null"], "items": {"type": "object"}}, + "attachment": {"type": ["object", "null"]}, + "image": {"type": ["string", "null"]}, + "custom": {"type": "object"}, + }, + } + + +RESPONSE_ID_KEY = "response_ids" + + +def nlg_request_format( + utter_action: Text, + tracker: DialogueStateTracker, + output_channel: Text, + **kwargs: Any, +) -> Dict[Text, Any]: + """Create the json body for the NLG json body for the request.""" + tracker_state = tracker.current_state(EventVerbosity.ALL) + response_id = kwargs.pop("response_id", None) + + return { + "response": utter_action, + "id": response_id, + "arguments": kwargs, + "tracker": tracker_state, + "channel": {"name": output_channel}, + } + + +class CallbackNaturalLanguageGenerator(NaturalLanguageGenerator): + """Generate bot utterances by using a remote endpoint for the generation. + + The generator will call the endpoint for each message it wants to + generate. The endpoint needs to respond with a properly formatted + json. The generator will use this message to create a response for + the bot. + """ + + def __init__(self, endpoint_config: EndpointConfig) -> None: + + self.nlg_endpoint = endpoint_config + + async def generate( + self, + utter_action: Text, + tracker: DialogueStateTracker, + output_channel: Text, + **kwargs: Any, + ) -> Dict[Text, Any]: + """Retrieve a named response from the domain using an endpoint.""" + domain_responses = kwargs.pop("domain_responses", None) + response_id = self.fetch_response_id( + utter_action, tracker, output_channel, domain_responses + ) + kwargs["response_id"] = response_id + + body = nlg_request_format(utter_action, tracker, output_channel, **kwargs) + + logger.debug( + "Requesting NLG for {} from {}." + "The request body is {}." + "".format(utter_action, self.nlg_endpoint.url, json.dumps(body)) + ) + + response = await self.nlg_endpoint.request( + method="post", json=body, timeout=DEFAULT_REQUEST_TIMEOUT + ) + + logger.debug(f"Received NLG response: {json.dumps(response)}") + + if isinstance(response, dict) and self.validate_response(response): + return response + else: + raise RasaException("NLG web endpoint returned an invalid response.") + + @staticmethod + def validate_response(content: Optional[Dict[Text, Any]]) -> bool: + """Validate the NLG response. Raises exception on failure.""" + from jsonschema import validate + from jsonschema import ValidationError + + try: + if content is None or content == "": + # means the endpoint did not want to respond with anything + return True + else: + validate(content, nlg_response_format_spec()) + return True + except ValidationError as e: + raise RasaException( + f"{e.message}. Failed to validate NLG response from API, make sure " + f"your response from the NLG endpoint is valid. " + f"For more information about the format please consult the " + f"`nlg_response_format_spec` function from this same module: " + f"https://github.com/RasaHQ/rasa/blob/main/rasa/core/nlg/callback.py" + ) + + @staticmethod + def fetch_response_id( + utter_action: Text, + tracker: DialogueStateTracker, + output_channel: Text, + domain_responses: Optional[Dict[Text, List[Dict[Text, Any]]]], + ) -> Optional[Text]: + """Fetch the response id for the utter action. + + The response id is retrieved from the domain responses for the + utter action given the tracker state and channel. + """ + if domain_responses is None: + logger.debug("Failed to fetch response id. Responses not provided.") + return None + + response_filter = ResponseVariationFilter(domain_responses) + response_id = response_filter.get_response_variation_id( + utter_action, tracker, output_channel + ) + + if response_id is None: + logger.debug(f"Failed to fetch response id for action '{utter_action}'.") + + return response_id diff --git a/rasa/core/nlg/generator.py b/rasa/core/nlg/generator.py new file mode 100644 index 0000000..20b5154 --- /dev/null +++ b/rasa/core/nlg/generator.py @@ -0,0 +1,222 @@ +import logging +from typing import List, Optional, Union, Text, Any, Dict + +import rasa.shared.utils.common +import rasa.shared.utils.io +from rasa.shared.constants import CHANNEL, RESPONSE_CONDITION +from rasa.shared.core.domain import Domain +from rasa.utils.endpoints import EndpointConfig +from rasa.shared.core.trackers import DialogueStateTracker + +logger = logging.getLogger(__name__) + + +class NaturalLanguageGenerator: + """Generate bot utterances based on a dialogue state.""" + + async def generate( + self, + utter_action: Text, + tracker: "DialogueStateTracker", + output_channel: Text, + **kwargs: Any, + ) -> Optional[Dict[Text, Any]]: + """Generate a response for the requested utter action. + + There are a lot of different methods to implement this, e.g. the + generation can be based on responses or be fully ML based by feeding + the dialogue state into a machine learning NLG model. + """ + raise NotImplementedError + + @staticmethod + def create( + obj: Union["NaturalLanguageGenerator", EndpointConfig, None], + domain: Optional[Domain], + ) -> "NaturalLanguageGenerator": + """Factory to create a generator.""" + if isinstance(obj, NaturalLanguageGenerator): + return obj + else: + return _create_from_endpoint_config(obj, domain) + + +def _create_from_endpoint_config( + endpoint_config: Optional[EndpointConfig] = None, domain: Optional[Domain] = None +) -> "NaturalLanguageGenerator": + """Given an endpoint configuration, create a proper NLG object.""" + domain = domain or Domain.empty() + + if endpoint_config is None: + from rasa.core.nlg import TemplatedNaturalLanguageGenerator + + # this is the default type if no endpoint config is set + nlg: "NaturalLanguageGenerator" = TemplatedNaturalLanguageGenerator( + domain.responses + ) + elif endpoint_config.type is None or endpoint_config.type.lower() == "callback": + from rasa.core.nlg import CallbackNaturalLanguageGenerator + + # this is the default type if no nlg type is set + nlg = CallbackNaturalLanguageGenerator(endpoint_config=endpoint_config) + elif endpoint_config.type.lower() == "response": + from rasa.core.nlg import TemplatedNaturalLanguageGenerator + + nlg = TemplatedNaturalLanguageGenerator(domain.responses) + else: + nlg = _load_from_module_name_in_endpoint_config(endpoint_config, domain) + + logger.debug(f"Instantiated NLG to '{nlg.__class__.__name__}'.") + return nlg + + +def _load_from_module_name_in_endpoint_config( + endpoint_config: EndpointConfig, domain: Domain +) -> "NaturalLanguageGenerator": + """Initializes a custom natural language generator. + + Args: + domain: defines the universe in which the assistant operates + endpoint_config: the specific natural language generator + """ + try: + nlg_class = rasa.shared.utils.common.class_from_module_path( + endpoint_config.type + ) + return nlg_class(endpoint_config=endpoint_config, domain=domain) + except (AttributeError, ImportError) as e: + raise Exception( + f"Could not find a class based on the module path " + f"'{endpoint_config.type}'. Failed to create a " + f"`NaturalLanguageGenerator` instance. Error: {e}" + ) + + +class ResponseVariationFilter: + """Filters response variations based on the channel, action and condition.""" + + def __init__(self, responses: Dict[Text, List[Dict[Text, Any]]]) -> None: + self.responses = responses + + @staticmethod + def _matches_filled_slots( + filled_slots: Dict[Text, Any], response: Dict[Text, Any] + ) -> bool: + """Checks if the conditional response variation matches the filled slots.""" + constraints = response.get(RESPONSE_CONDITION, []) + for constraint in constraints: + name = constraint["name"] + value = constraint["value"] + filled_slots_value = filled_slots.get(name) + if isinstance(filled_slots_value, str) and isinstance(value, str): + if filled_slots_value.casefold() != value.casefold(): + return False + # slot values can be of different data types + # such as int, float, bool, etc. hence, this check + # executes when slot values are not strings + elif filled_slots_value != value: + return False + + return True + + def responses_for_utter_action( + self, + utter_action: Text, + output_channel: Text, + filled_slots: Dict[Text, Any], + ) -> List[Dict[Text, Any]]: + """Returns array of responses that fit the channel, action and condition.""" + # filter responses without a condition + default_responses = list( + filter( + lambda x: (x.get(RESPONSE_CONDITION) is None), + self.responses[utter_action], + ) + ) + # filter responses with a condition that matches the filled slots + conditional_responses = list( + filter( + lambda x: ( + x.get(RESPONSE_CONDITION) + and self._matches_filled_slots( + filled_slots=filled_slots, response=x + ) + ), + self.responses[utter_action], + ) + ) + + # filter conditional responses that match the channel + conditional_channel = list( + filter(lambda x: (x.get(CHANNEL) == output_channel), conditional_responses) + ) + # filter conditional responses that don't match the channel + conditional_no_channel = list( + filter(lambda x: (x.get(CHANNEL) is None), conditional_responses) + ) + # filter default responses that match the channel + default_channel = list( + filter(lambda x: (x.get(CHANNEL) == output_channel), default_responses) + ) + # filter default responses that don't match the channel + default_no_channel = list( + filter(lambda x: (x.get(CHANNEL) is None), default_responses) + ) + + if conditional_channel: + return conditional_channel + + if default_channel: + return default_channel + + if conditional_no_channel: + return conditional_no_channel + + return default_no_channel + + def get_response_variation_id( + self, + utter_action: Text, + tracker: DialogueStateTracker, + output_channel: Text, + ) -> Optional[Text]: + """Returns the first matched response variation ID. + + This ID corresponds to the response variation that fits + the channel, action and condition. + """ + filled_slots = tracker.current_slot_values() + if utter_action in self.responses: + eligible_variations = self.responses_for_utter_action( + utter_action, output_channel, filled_slots + ) + response_ids_are_valid = self._validate_response_ids(eligible_variations) + + if eligible_variations and response_ids_are_valid: + return eligible_variations[0].get("id") + + return None + + @staticmethod + def _validate_response_ids(response_variations: List[Dict[Text, Any]]) -> bool: + """Checks that the response IDs of a particular utter_action are unique. + + Args: + response_variations: The response variations to validate. + + Returns: + True if the response IDs are unique, False otherwise. + """ + response_ids = set() + for response_variation in response_variations: + response_variation_id = response_variation.get("id") + if response_variation_id and response_variation_id in response_ids: + rasa.shared.utils.io.raise_warning( + f"Duplicate response id '{response_variation_id}' " + f"defined in the domain." + ) + return False + + response_ids.add(response_variation_id) + + return True diff --git a/rasa/core/nlg/interpolator.py b/rasa/core/nlg/interpolator.py new file mode 100644 index 0000000..77d55b4 --- /dev/null +++ b/rasa/core/nlg/interpolator.py @@ -0,0 +1,81 @@ +import copy +import re +import logging +import structlog +from typing import Text, Dict, Union, Any, List + +logger = logging.getLogger(__name__) +structlogger = structlog.get_logger() + + +def interpolate_text(response: Text, values: Dict[Text, Text]) -> Text: + """Interpolate values into responses with placeholders. + + Transform response tags from "{tag_name}" to "{0[tag_name]}" as described here: + https://stackoverflow.com/questions/7934620/python-dots-in-the-name-of-variable-in-a-format-string#comment9695339_7934969 + Block characters, making sure not to allow: + (a) newline in slot name + (b) { or } in slot name + + Args: + response: The piece of text that should be interpolated. + values: A dictionary of keys and the values that those + keys should be replaced with. + + Returns: + The piece of text with any replacements made. + """ + try: + text = re.sub(r"{([^\n{}]+?)}", r"{0[\1]}", response) + text = text.format(values) + if "0[" in text: + # regex replaced tag but format did not replace + # likely cause would be that tag name was enclosed + # in double curly and format func simply escaped it. + # we don't want to return {0[SLOTNAME]} thus + # restoring original value with { being escaped. + return response.format({}) + + return text + except KeyError as e: + event_info = ( + "The specified slot name does not exist, " + "and no explicit value was provided during the response invocation. " + "Return the response without populating it." + ) + structlogger.exception( + "interpolator.interpolate.text", + response=copy.deepcopy(response), + placeholder_key=e.args[0], + event_info=event_info, + ) + return response + + +def interpolate( + response: Union[List[Any], Dict[Text, Any], Text], values: Dict[Text, Text] +) -> Union[List[Any], Dict[Text, Any], Text]: + """Recursively process response and interpolate any text keys. + + Args: + response: The response that should be interpolated. + values: A dictionary of keys and the values that those + keys should be replaced with. + + Returns: + The response with any replacements made. + """ + if isinstance(response, str): + return interpolate_text(response, values) + elif isinstance(response, dict): + for k, v in response.items(): + if isinstance(v, dict): + interpolate(v, values) + elif isinstance(v, list): + response[k] = [interpolate(i, values) for i in v] + elif isinstance(v, str): + response[k] = interpolate_text(v, values) + return response + elif isinstance(response, list): + return [interpolate(i, values) for i in response] + return response diff --git a/rasa/core/nlg/response.py b/rasa/core/nlg/response.py new file mode 100644 index 0000000..4553712 --- /dev/null +++ b/rasa/core/nlg/response.py @@ -0,0 +1,146 @@ +import copy +import logging + +from rasa.shared.core.trackers import DialogueStateTracker +from typing import Text, Any, Dict, Optional, List + +from rasa.core.nlg import interpolator +from rasa.core.nlg.generator import NaturalLanguageGenerator, ResponseVariationFilter +from rasa.shared.constants import RESPONSE_CONDITION + +logger = logging.getLogger(__name__) + + +class TemplatedNaturalLanguageGenerator(NaturalLanguageGenerator): + """Natural language generator that generates messages based on responses. + + The responses can use variables to customize the utterances based on the + state of the dialogue. + """ + + def __init__(self, responses: Dict[Text, List[Dict[Text, Any]]]) -> None: + """Creates a Template Natural Language Generator. + + Args: + responses: responses that will be used to generate messages. + """ + self.responses = responses + + # noinspection PyUnusedLocal + def _random_response_for( + self, utter_action: Text, output_channel: Text, filled_slots: Dict[Text, Any] + ) -> Optional[Dict[Text, Any]]: + """Select random response for the utter action from available ones. + + If channel-specific responses for the current output channel are given, + only choose from channel-specific ones. + """ + import numpy as np + + if utter_action in self.responses: + response_filter = ResponseVariationFilter(self.responses) + suitable_responses = response_filter.responses_for_utter_action( + utter_action, output_channel, filled_slots + ) + + if suitable_responses: + selected_response = np.random.choice(suitable_responses) + condition = selected_response.get(RESPONSE_CONDITION) + if condition: + formatted_response_conditions = self._format_response_conditions( + condition + ) + logger.debug( + "Selecting response variation with conditions:" + f"{formatted_response_conditions}" + ) + return selected_response + else: + return None + else: + return None + + async def generate( + self, + utter_action: Text, + tracker: DialogueStateTracker, + output_channel: Text, + **kwargs: Any, + ) -> Optional[Dict[Text, Any]]: + """Generate a response for the requested utter action.""" + filled_slots = tracker.current_slot_values() + return self.generate_from_slots( + utter_action, filled_slots, output_channel, **kwargs + ) + + def generate_from_slots( + self, + utter_action: Text, + filled_slots: Dict[Text, Any], + output_channel: Text, + **kwargs: Any, + ) -> Optional[Dict[Text, Any]]: + """Generate a response for the requested utter action.""" + # Fetching a random response for the passed utter action + r = copy.deepcopy( + self._random_response_for(utter_action, output_channel, filled_slots) + ) + # Filling the slots in the response with placeholders and returning the response + if r is not None: + return self._fill_response(r, filled_slots, **kwargs) + else: + return None + + def _fill_response( + self, + response: Dict[Text, Any], + filled_slots: Optional[Dict[Text, Any]] = None, + **kwargs: Any, + ) -> Dict[Text, Any]: + """Combine slot values and key word arguments to fill responses.""" + # Getting the slot values in the response variables + response_vars = self._response_variables(filled_slots, kwargs) + + keys_to_interpolate = [ + "text", + "image", + "custom", + "buttons", + "attachment", + "quick_replies", + ] + if response_vars: + for key in keys_to_interpolate: + if key in response: + response[key] = interpolator.interpolate( + response[key], response_vars + ) + return response + + @staticmethod + def _response_variables( + filled_slots: Dict[Text, Any], kwargs: Dict[Text, Any] + ) -> Dict[Text, Any]: + """Combine slot values and key word arguments to fill responses.""" + if filled_slots is None: + filled_slots = {} + + # Copying the filled slots in the response variables. + response_vars = filled_slots.copy() + response_vars.update(kwargs) + return response_vars + + @staticmethod + def _format_response_conditions(response_conditions: List[Dict[Text, Any]]) -> Text: + formatted_response_conditions = [""] + for index, condition in enumerate(response_conditions): + constraints = [] + constraints.append(f"type: {str(condition['type'])}") + constraints.append(f"name: {str(condition['name'])}") + constraints.append(f"value: {str(condition['value'])}") + + condition_message = " | ".join(constraints) + formatted_condition = f"[condition {str(index + 1)}] {condition_message}" + formatted_response_conditions.append(formatted_condition) + + return "\n".join(formatted_response_conditions) diff --git a/rasa/core/policies/__init__.py b/rasa/core/policies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/core/policies/ensemble.py b/rasa/core/policies/ensemble.py new file mode 100644 index 0000000..cdbb8cd --- /dev/null +++ b/rasa/core/policies/ensemble.py @@ -0,0 +1,329 @@ +from __future__ import annotations +from abc import abstractmethod, ABC +from typing import Optional, Text, List, Dict, Any +import logging + +from rasa.engine.graph import GraphComponent +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.storage.resource import Resource +from rasa.engine.runner.interface import ExecutionContext +from rasa.core.policies.policy import PolicyPrediction +from rasa.shared.exceptions import RasaException, InvalidConfigException +from rasa.shared.core.constants import ACTION_LISTEN_NAME +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + ActionExecutionRejected, + ActionExecuted, + DefinePrevUserUtteredFeaturization, +) +from rasa.shared.core.trackers import DialogueStateTracker + +logger = logging.getLogger(__name__) + + +def is_not_in_training_data( + policy_name: Optional[Text], max_confidence: Optional[float] = None +) -> bool: + """Checks whether the prediction is empty or by a policy which did not memoize data. + + Args: + policy_name: The name of the policy. + max_confidence: The max confidence of the policy's prediction. + + Returns: + `False` if and only if an action was predicted (i.e. `max_confidence` > 0) by + a `MemoizationPolicy` + """ + from rasa.core.policies.rule_policy import RulePolicy + from rasa.core.policies.memoization import ( + MemoizationPolicy, + AugmentedMemoizationPolicy, + ) + + if not policy_name: + return True + + memorizing_policies = [ + RulePolicy.__name__, + MemoizationPolicy.__name__, + AugmentedMemoizationPolicy.__name__, + ] + is_memorized = any( + policy_name.endswith(f"_{memoizing_policy}") + for memoizing_policy in memorizing_policies + ) + + # also check if confidence is 0, than it cannot be count as prediction + return not is_memorized or max_confidence == 0.0 + + +class InvalidPolicyEnsembleConfig(RasaException): + """Exception that can be raised when the policy ensemble is not valid.""" + + +class PolicyPredictionEnsemble(ABC): + """Interface for any policy prediction ensemble. + + Given a list of predictions from policies, which include some meta data about the + policies themselves, an "ensemble" decides what the final prediction should be, in + the following way: + 1. If the previously predicted action was rejected, then the ensemble sets the + probability for this action to 0.0 (in all given predictions). + 2. It combines the information from the single predictions, which include some + meta data about the policies (e.g. priority), into a final prediction. + 3. If the sequence of events given at the time of prediction ends with a user + utterance, then the ensemble adds a special event to the event-list included in + the final prediction that indicates whether the final prediction was made based + on the actual text of that user utterance. + + Observe that policies predict "mandatory" as well as "optional" + events. The ensemble decides which of the optional events should + be passed on. + """ + + def combine_predictions_from_kwargs( + self, tracker: DialogueStateTracker, domain: Domain, **kwargs: Any + ) -> PolicyPrediction: + """Derives a single prediction from predictions given as kwargs. + + Args: + tracker: dialogue state tracker holding the state of the conversation, + which may influence the combination of predictions as well + domain: the common domain + **kwargs: arbitrary keyword arguments. All policy predictions passed as + kwargs will be combined. + + Returns: + a single prediction + """ + predictions = [ + value for value in kwargs.values() if isinstance(value, PolicyPrediction) + ] + return self.combine_predictions( + predictions=predictions, tracker=tracker, domain=domain + ) + + @abstractmethod + def combine_predictions( + self, + predictions: List[PolicyPrediction], + tracker: DialogueStateTracker, + domain: Domain, + ) -> PolicyPrediction: + """Derives a single prediction from the given list of predictions. + + Args: + predictions: a list of policy predictions that include "confidence scores" + which are non-negative but *do not* necessarily up to 1 + tracker: dialogue state tracker holding the state of the conversation, + which may influence the combination of predictions as well + domain: the common domain + + Returns: + a single prediction + """ + ... + + +class DefaultPolicyPredictionEnsemble(PolicyPredictionEnsemble, GraphComponent): + """An ensemble that picks the "best" prediction and combines events from all. + + The following rules determine which prediction is the "best": + 1. "No user" predictions overrule all other predictions. + + 2. End-to-end predictions overrule all other predictions based on + user input - if and only if *no* "no user" prediction is present in the + given ensemble. + + 3. Given two predictions, if the maximum confidence of one prediction is + strictly larger than that of the other, then the prediction with the + strictly larger maximum confidence is considered to be "better". + The priorities of the policies that made these predictions does not matter. + + 4. Given two predictions of policies that are equally confident, the + prediction of the policy with the higher priority is considered to be + "better". + + Observe that this comparison is *not* symmetric if the priorities are allowed to + coincide (i.e. if we cannot distinguish two predictions using 1.-4., then + the first prediction is considered to be "better"). + + The list of events in the final prediction will contain all mandatory + events contained in the given predictions, the optional events given in the + "best" prediction, and `DefinePrevUserUtteredFeaturization` event (if the + prediction was made for a sequence of events ending with a user utterance). + """ + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> DefaultPolicyPredictionEnsemble: + """Creates a new instance (see parent class for full docstring).""" + return cls() + + def __str__(self) -> Text: + return f"{self.__class__.__name__}()" + + @staticmethod + def _pick_best_policy(predictions: List[PolicyPrediction]) -> PolicyPrediction: + """Picks the best policy prediction based on probabilities and policy priority. + + Args: + predictions: a list containing policy predictions + + Returns: + The index of the best prediction + """ + best_confidence = (-1.0, -1) + best_index = -1 + + # different type of predictions have different priorities + # No user predictions overrule all other predictions. + is_no_user_prediction = any( + prediction.is_no_user_prediction for prediction in predictions + ) + # End-to-end predictions overrule all other predictions based on user input. + is_end_to_end_prediction = any( + prediction.is_end_to_end_prediction for prediction in predictions + ) + + policy_events = [] + for idx, prediction in enumerate(predictions): + policy_events += prediction.events + + # No user predictions (e.g. happy path loop predictions) + # overrule all other predictions. + if prediction.is_no_user_prediction != is_no_user_prediction: + continue + + # End-to-end predictions overrule all other predictions based on user input. + if ( + not is_no_user_prediction + and prediction.is_end_to_end_prediction != is_end_to_end_prediction + ): + continue + + confidence = (prediction.max_confidence, prediction.policy_priority) + if confidence > best_confidence: + # pick the best policy + best_confidence = confidence + best_index = idx + + if best_index < 0: + raise InvalidConfigException( + "No best prediction found. Please check your model configuration." + ) + + best_prediction = predictions[best_index] + policy_events += best_prediction.optional_events + + return PolicyPrediction( + best_prediction.probabilities, + best_prediction.policy_name, + best_prediction.policy_priority, + policy_events, + is_end_to_end_prediction=best_prediction.is_end_to_end_prediction, + is_no_user_prediction=best_prediction.is_no_user_prediction, + diagnostic_data=best_prediction.diagnostic_data, + hide_rule_turn=best_prediction.hide_rule_turn, + action_metadata=best_prediction.action_metadata, + ) + + @staticmethod + def _best_policy_prediction( + predictions: List[PolicyPrediction], + tracker: DialogueStateTracker, + domain: Domain, + ) -> PolicyPrediction: + """Finds the best policy prediction. + + Args: + predictions: a list of policy predictions that include "confidence scores" + which are non-negative but *do not* necessarily up to 1 + tracker: dialogue state tracker holding the state of the conversation, + which may influence the combination of predictions as well + domain: the common domain + + Returns: + The winning policy prediction. + """ + last_action_event = next( + ( + event + for event in reversed(tracker.events) + if isinstance(event, (ActionExecutionRejected, ActionExecuted)) + ), + None, + ) + + rejected_action_name = None + if len(tracker.events) > 0 and isinstance( + last_action_event, ActionExecutionRejected + ): + rejected_action_name = last_action_event.action_name + + if rejected_action_name: + logger.debug( + f"Execution of '{rejected_action_name}' was rejected. " + f"Setting its confidence to 0.0 in all predictions." + ) + index_of_rejected_action = domain.index_for_action(rejected_action_name) + for prediction in predictions: + prediction.probabilities[index_of_rejected_action] = 0.0 + + return DefaultPolicyPredictionEnsemble._pick_best_policy(predictions) + + def combine_predictions( + self, + predictions: List[PolicyPrediction], + tracker: DialogueStateTracker, + domain: Domain, + ) -> PolicyPrediction: + """Derives a single prediction from the given list of predictions. + + Note that you might get unexpected results if the priorities are non-unique. + Moreover, the order of events in the result is determined by the order of the + predictions passed to this method. + + Args: + predictions: a list of policy predictions that include "probabilities" + which are non-negative but *do not* necessarily up to 1 + tracker: dialogue state tracker holding the state of the conversation + domain: the common domain + + Returns: + The "best" prediction. + """ + if not predictions: + raise InvalidConfigException( + "Expected at least one prediction. Please check your model " + "configuration." + ) + # Reminder: If just a single policy is given, we do *not* just return it because + # it is expected that the final prediction contains mandatory and optional + # events in the `events` attribute and no optional events. + + winning_prediction = self._best_policy_prediction( + predictions=predictions, domain=domain, tracker=tracker + ) + + if tracker.latest_action_name == ACTION_LISTEN_NAME: + if winning_prediction.is_end_to_end_prediction: + logger.debug("Made e2e prediction using user text.") + logger.debug("Added `DefinePrevUserUtteredFeaturization(True)` event.") + winning_prediction.events.append( + DefinePrevUserUtteredFeaturization(True) + ) + else: + logger.debug("Made prediction using user intent.") + logger.debug("Added `DefinePrevUserUtteredFeaturization(False)` event.") + winning_prediction.events.append( + DefinePrevUserUtteredFeaturization(False) + ) + + logger.debug(f"Predicted next action using {winning_prediction.policy_name}.") + return winning_prediction diff --git a/rasa/core/policies/memoization.py b/rasa/core/policies/memoization.py new file mode 100644 index 0000000..4244ed6 --- /dev/null +++ b/rasa/core/policies/memoization.py @@ -0,0 +1,535 @@ +from __future__ import annotations +import copy +import zlib + +import base64 +import json +import logging +import structlog + +from tqdm import tqdm +from typing import Optional, Any, Dict, List, Text +from pathlib import Path + +import rasa.utils.io +import rasa.shared.utils.io +from rasa.engine.graph import ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.core.domain import State, Domain +from rasa.shared.core.events import ActionExecuted +from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer +from rasa.core.featurizers.tracker_featurizers import MaxHistoryTrackerFeaturizer +from rasa.core.featurizers.tracker_featurizers import FEATURIZER_FILE +from rasa.shared.exceptions import FileIOException +from rasa.core.policies.policy import PolicyPrediction, Policy, SupportedData +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.generator import TrackerWithCachedStates +from rasa.shared.utils.io import is_logging_disabled +from rasa.core.constants import ( + MEMOIZATION_POLICY_PRIORITY, + DEFAULT_MAX_HISTORY, + POLICY_MAX_HISTORY, + POLICY_PRIORITY, +) +from rasa.shared.core.constants import ACTION_LISTEN_NAME + +logger = logging.getLogger(__name__) +structlogger = structlog.get_logger() + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.POLICY_WITHOUT_END_TO_END_SUPPORT, is_trainable=True +) +class MemoizationPolicy(Policy): + """A policy that follows exact examples of `max_history` turns in training stories. + + Since `slots` that are set some time in the past are + preserved in all future feature vectors until they are set + to None, this policy implicitly remembers and most importantly + recalls examples in the context of the current dialogue + longer than `max_history`. + + This policy is not supposed to be the only policy in an ensemble, + it is optimized for precision and not recall. + It should get a 100% precision because it emits probabilities of 1.1 + along it's predictions, which makes every mistake fatal as + no other policy can overrule it. + + If it is needed to recall turns from training dialogues where + some slots might not be set during prediction time, and there are + training stories for this, use AugmentedMemoizationPolicy. + """ + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns the default config (see parent class for full docstring).""" + # please make sure to update the docs when changing a default parameter + return { + "enable_feature_string_compression": True, + "use_nlu_confidence_as_score": False, + POLICY_PRIORITY: MEMOIZATION_POLICY_PRIORITY, + POLICY_MAX_HISTORY: DEFAULT_MAX_HISTORY, + } + + def _standard_featurizer(self) -> MaxHistoryTrackerFeaturizer: + # Memoization policy always uses MaxHistoryTrackerFeaturizer + # without state_featurizer + return MaxHistoryTrackerFeaturizer( + state_featurizer=None, max_history=self.config[POLICY_MAX_HISTORY] + ) + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + featurizer: Optional[TrackerFeaturizer] = None, + lookup: Optional[Dict] = None, + ) -> None: + """Initialize the policy.""" + super().__init__(config, model_storage, resource, execution_context, featurizer) + self.lookup = lookup or {} + + def _create_lookup_from_states( + self, + trackers_as_states: List[List[State]], + trackers_as_actions: List[List[Text]], + ) -> Dict[Text, Text]: + """Creates lookup dictionary from the tracker represented as states. + + Args: + trackers_as_states: representation of the trackers as a list of states + trackers_as_actions: representation of the trackers as a list of actions + + Returns: + lookup dictionary + """ + lookup: Dict[Text, Text] = {} + + if not trackers_as_states: + return lookup + + assert len(trackers_as_actions[0]) == 1, ( + f"The second dimension of trackers_as_action should be 1, " + f"instead of {len(trackers_as_actions[0])}" + ) + + ambiguous_feature_keys = set() + + pbar = tqdm( + zip(trackers_as_states, trackers_as_actions), + desc="Processed actions", + disable=is_logging_disabled(), + ) + for states, actions in pbar: + action = actions[0] + + feature_key = self._create_feature_key(states) + if not feature_key: + continue + + if feature_key not in ambiguous_feature_keys: + if feature_key in lookup.keys(): + if lookup[feature_key] != action: + # delete contradicting example created by + # partial history augmentation from memory + ambiguous_feature_keys.add(feature_key) + del lookup[feature_key] + else: + lookup[feature_key] = action + pbar.set_postfix({"# examples": "{:d}".format(len(lookup))}) + + return lookup + + def _create_feature_key(self, states: List[State]) -> Optional[Text]: + if not states: + return None + + # we sort keys to make sure that the same states + # represented as dictionaries have the same json strings + # quotes are removed for aesthetic reasons + feature_str = json.dumps(states, sort_keys=True).replace('"', "") + if self.config["enable_feature_string_compression"]: + compressed = zlib.compress( + bytes(feature_str, rasa.shared.utils.io.DEFAULT_ENCODING) + ) + return base64.b64encode(compressed).decode( + rasa.shared.utils.io.DEFAULT_ENCODING + ) + else: + return feature_str + + def train( + self, + training_trackers: List[TrackerWithCachedStates], + domain: Domain, + **kwargs: Any, + ) -> Resource: + # only considers original trackers (no augmented ones) + training_trackers = [ + t + for t in training_trackers + if not hasattr(t, "is_augmented") or not t.is_augmented + ] + training_trackers = SupportedData.trackers_for_supported_data( + self.supported_data(), training_trackers + ) + + ( + trackers_as_states, + trackers_as_actions, + ) = self.featurizer.training_states_and_labels(training_trackers, domain) + self.lookup = self._create_lookup_from_states( + trackers_as_states, trackers_as_actions + ) + logger.debug(f"Memorized {len(self.lookup)} unique examples.") + + self.persist() + return self._resource + + def _recall_states(self, states: List[State]) -> Optional[Text]: + return self.lookup.get(self._create_feature_key(states)) + + def recall( + self, + states: List[State], + tracker: DialogueStateTracker, + domain: Domain, + rule_only_data: Optional[Dict[Text, Any]], + ) -> Optional[Text]: + """Finds the action based on the given states. + + Args: + states: List of states. + tracker: The tracker. + domain: The Domain. + rule_only_data: Slots and loops which are specific to rules and hence + should be ignored by this policy. + + Returns: + The name of the action. + """ + return self._recall_states(states) + + def _prediction_result( + self, action_name: Text, tracker: DialogueStateTracker, domain: Domain + ) -> List[float]: + result = self._default_predictions(domain) + if action_name: + if ( + self.config["use_nlu_confidence_as_score"] + and tracker.latest_message is not None + ): + # the memoization will use the confidence of NLU on the latest + # user message to set the confidence of the action + score = tracker.latest_message.intent.get("confidence", 1.0) + else: + score = 1.0 + + result[domain.index_for_action(action_name)] = score + + return result + + def predict_action_probabilities( + self, + tracker: DialogueStateTracker, + domain: Domain, + rule_only_data: Optional[Dict[Text, Any]] = None, + **kwargs: Any, + ) -> PolicyPrediction: + """Predicts the next action the bot should take after seeing the tracker. + + Args: + tracker: the :class:`rasa.core.trackers.DialogueStateTracker` + domain: the :class:`rasa.shared.core.domain.Domain` + rule_only_data: Slots and loops which are specific to rules and hence + should be ignored by this policy. + + Returns: + The policy's prediction (e.g. the probabilities for the actions). + """ + result = self._default_predictions(domain) + + states = self._prediction_states(tracker, domain, rule_only_data=rule_only_data) + structlogger.debug( + "memoization.predict.actions", tracker_states=copy.deepcopy(states) + ) + predicted_action_name = self.recall( + states, tracker, domain, rule_only_data=rule_only_data + ) + if predicted_action_name is not None: + logger.debug(f"There is a memorised next action '{predicted_action_name}'") + result = self._prediction_result(predicted_action_name, tracker, domain) + else: + logger.debug("There is no memorised next action") + + return self._prediction(result) + + def _metadata(self) -> Dict[Text, Any]: + return {"lookup": self.lookup} + + @classmethod + def _metadata_filename(cls) -> Text: + return "memorized_turns.json" + + def persist(self) -> None: + """Persists the policy to storage.""" + with self._model_storage.write_to(self._resource) as path: + # not all policies have a featurizer + if self.featurizer is not None: + self.featurizer.persist(path) + + file = Path(path) / self._metadata_filename() + + rasa.shared.utils.io.create_directory_for_file(file) + rasa.shared.utils.io.dump_obj_as_json_to_file(file, self._metadata()) + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> MemoizationPolicy: + """Loads a trained policy (see parent class for full docstring).""" + featurizer = None + lookup = None + + try: + with model_storage.read_from(resource) as path: + metadata_file = Path(path) / cls._metadata_filename() + metadata = rasa.shared.utils.io.read_json_file(metadata_file) + lookup = metadata["lookup"] + + if (Path(path) / FEATURIZER_FILE).is_file(): + featurizer = TrackerFeaturizer.load(path) + + except (ValueError, FileNotFoundError, FileIOException): + logger.warning( + f"Couldn't load metadata for policy '{cls.__name__}' as the persisted " + f"metadata couldn't be loaded." + ) + + return cls( + config, + model_storage, + resource, + execution_context, + featurizer=featurizer, + lookup=lookup, + ) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.POLICY_WITHOUT_END_TO_END_SUPPORT, is_trainable=True +) +class AugmentedMemoizationPolicy(MemoizationPolicy): + """The policy that remembers examples from training stories for `max_history` turns. + + If it is needed to recall turns from training dialogues + where some slots might not be set during prediction time, + add relevant stories without such slots to training data. + E.g. reminder stories. + + Since `slots` that are set some time in the past are + preserved in all future feature vectors until they are set + to None, this policy has a capability to recall the turns + up to `max_history` from training stories during prediction + even if additional slots were filled in the past + for current dialogue. + """ + + @staticmethod + def _strip_leading_events_until_action_executed( + tracker: DialogueStateTracker, again: bool = False + ) -> Optional[DialogueStateTracker]: + """Truncates the tracker to begin at the next `ActionExecuted` event. + + Args: + tracker: The tracker to truncate. + again: When true, truncate tracker at the second action. + Otherwise truncate to the first action. + + Returns: + The truncated tracker if there were actions present. + If none are found, returns `None`. + """ + idx_of_first_action = None + idx_of_second_action = None + + applied_events = tracker.applied_events() + + # we need to find second executed action + for e_i, event in enumerate(applied_events): + if isinstance(event, ActionExecuted): + if idx_of_first_action is None: + idx_of_first_action = e_i + else: + idx_of_second_action = e_i + break + + # use first action, if we went first time and second action, if we went again + idx_to_use = idx_of_second_action if again else idx_of_first_action + if idx_to_use is None: + return None + + # make second ActionExecuted the first one + events = applied_events[idx_to_use:] + if not events: + return None + + truncated_tracker = tracker.init_copy() + for e in events: + truncated_tracker.update(e) + + return truncated_tracker + + def _recall_using_truncation( + self, + old_states: List[State], + tracker: DialogueStateTracker, + domain: Domain, + rule_only_data: Optional[Dict[Text, Any]], + ) -> Optional[Text]: + """Attempts to match memorized states to progressively shorter trackers. + + This method iteratively removes the oldest events up to the next action + executed and checks if the truncated event sequence matches some memorized + states, until a match has been found or until the even sequence has been + exhausted. + + Args: + old_states: List of states. + tracker: The tracker. + domain: The Domain. + rule_only_data: Slots and loops which are specific to rules and hence + should be ignored by this policy. + + Returns: + The name of the action. + """ + logger.debug("Launch DeLorean...") + + # Truncate the tracker based on `max_history` + truncated_tracker: Optional[ + DialogueStateTracker + ] = _trim_tracker_by_max_history(tracker, self.config[POLICY_MAX_HISTORY]) + truncated_tracker = self._strip_leading_events_until_action_executed( + truncated_tracker + ) + while truncated_tracker is not None: + states = self._prediction_states( + truncated_tracker, domain, rule_only_data=rule_only_data + ) + + if old_states != states: + # check if we like new futures + memorised = self._recall_states(states) + if memorised is not None: + structlogger.debug( + "memoization.states_recall", states=copy.deepcopy(states) + ) + return memorised + old_states = states + + # go back again + truncated_tracker = self._strip_leading_events_until_action_executed( + truncated_tracker, again=True + ) + + # No match found + structlogger.debug( + "memoization.states_recall", old_states=copy.deepcopy(old_states) + ) + return None + + def recall( + self, + states: List[State], + tracker: DialogueStateTracker, + domain: Domain, + rule_only_data: Optional[Dict[Text, Any]], + ) -> Optional[Text]: + """Finds the action based on the given states. + + Uses back to the future idea to change the past and check whether the new future + can be used to recall the action. + + Args: + states: List of states. + tracker: The tracker. + domain: The Domain. + rule_only_data: Slots and loops which are specific to rules and hence + should be ignored by this policy. + + Returns: + The name of the action. + """ + predicted_action_name = self._recall_states(states) + if predicted_action_name is None: + # let's try a different method to recall that tracker + return self._recall_using_truncation( + states, tracker, domain, rule_only_data=rule_only_data + ) + else: + return predicted_action_name + + +def _get_max_applied_events_for_max_history( + tracker: DialogueStateTracker, max_history: Optional[int] +) -> Optional[int]: + """Computes the number of events in the tracker that correspond to max_history. + + To ensure that the last user utterance is correctly included in the prediction + states, return the index of the most recent `action_listen` event occuring + before the tracker would be truncated according to the value of `max_history`. + + Args: + tracker: Some tracker holding the events + max_history: The number of actions to count + + Returns: + The number of events, as counted from the end of the event list, that should + be taken into accout according to the `max_history` setting. If all events + should be taken into account, the return value is `None`. + """ + if not max_history: + return None + num_events = 0 + num_actions = 0 + for event in reversed(tracker.applied_events()): + num_events += 1 + if isinstance(event, ActionExecuted): + num_actions += 1 + if num_actions > max_history and event.action_name == ACTION_LISTEN_NAME: + return num_events + return None + + +def _trim_tracker_by_max_history( + tracker: DialogueStateTracker, max_history: Optional[int] +) -> DialogueStateTracker: + """Removes events from the tracker until it has `max_history` actions. + + Args: + tracker: Some tracker. + max_history: Number of actions to keep. + + Returns: + A new tracker with up to `max_history` actions, or the same tracker if + `max_history` is `None`. + """ + max_applied_events = _get_max_applied_events_for_max_history(tracker, max_history) + if not max_applied_events: + return tracker + + applied_events = tracker.applied_events()[-max_applied_events:] + new_tracker = tracker.init_copy() + for event in applied_events: + new_tracker.update(event) + return new_tracker diff --git a/rasa/core/policies/policy.py b/rasa/core/policies/policy.py new file mode 100644 index 0000000..94ab302 --- /dev/null +++ b/rasa/core/policies/policy.py @@ -0,0 +1,661 @@ +from __future__ import annotations +import abc +import copy +import logging +from enum import Enum +from pathlib import Path +from rasa.shared.core.events import Event +from typing import ( + Any, + List, + Optional, + Text, + Dict, + Callable, + Tuple, + TypeVar, + TYPE_CHECKING, +) + +import numpy as np + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.core.featurizers.precomputation import MessageContainerForCoreFeaturization +from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer +from rasa.core.featurizers.tracker_featurizers import MaxHistoryTrackerFeaturizer +from rasa.core.featurizers.single_state_featurizer import SingleStateFeaturizer +from rasa.core.featurizers.tracker_featurizers import FEATURIZER_FILE +import rasa.utils.common +import rasa.shared.utils.io +from rasa.shared.exceptions import RasaException, FileIOException +from rasa.shared.nlu.constants import ENTITIES, INTENT, TEXT, ACTION_TEXT, ACTION_NAME +from rasa.shared.core.domain import Domain, State +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.generator import TrackerWithCachedStates +from rasa.core.constants import ( + DEFAULT_POLICY_PRIORITY, + POLICY_PRIORITY, + POLICY_MAX_HISTORY, +) +from rasa.shared.core.constants import USER, SLOTS, PREVIOUS_ACTION, ACTIVE_LOOP +import rasa.shared.utils.common + + +if TYPE_CHECKING: + from rasa.shared.nlu.training_data.features import Features + + +logger = logging.getLogger(__name__) + +TrackerListTypeVar = TypeVar( + "TrackerListTypeVar", List[DialogueStateTracker], List[TrackerWithCachedStates] +) + + +class SupportedData(Enum): + """Enumeration of a policy's supported training data type.""" + + # policy only supports ML-based training data ("stories") + ML_DATA = 1 + + # policy only supports rule-based data ("rules") + RULE_DATA = 2 + + # policy supports both ML-based and rule-based data ("stories" as well as "rules") + ML_AND_RULE_DATA = 3 + + @staticmethod + def trackers_for_supported_data( + supported_data: SupportedData, + trackers: TrackerListTypeVar, + ) -> TrackerListTypeVar: + """Return trackers for a given policy. + + Args: + supported_data: Supported data filter for the `trackers`. + trackers: Trackers to split. + + Returns: + Trackers from ML-based training data and/or rule-based data. + """ + if supported_data == SupportedData.RULE_DATA: + return [tracker for tracker in trackers if tracker.is_rule_tracker] + + if supported_data == SupportedData.ML_DATA: + return [tracker for tracker in trackers if not tracker.is_rule_tracker] + + # `supported_data` is `SupportedData.ML_AND_RULE_DATA` + return trackers + + +class Policy(GraphComponent): + """Common parent class for all dialogue policies.""" + + @staticmethod + def supported_data() -> SupportedData: + """The type of data supported by this policy. + + By default, this is only ML-based training data. If policies support rule data, + or both ML-based data and rule data, they need to override this method. + + Returns: + The data type supported by this policy (ML-based training data). + """ + return SupportedData.ML_DATA + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + featurizer: Optional[TrackerFeaturizer] = None, + ) -> None: + """Constructs a new Policy object.""" + self.config = config + if featurizer is None: + featurizer = self._create_featurizer() + self.__featurizer = featurizer + + self.priority = config.get(POLICY_PRIORITY, DEFAULT_POLICY_PRIORITY) + self.finetune_mode = execution_context.is_finetuning + + self._model_storage = model_storage + self._resource = resource + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> Policy: + """Creates a new untrained policy (see parent class for full docstring).""" + return cls(config, model_storage, resource, execution_context) + + def _create_featurizer(self) -> TrackerFeaturizer: + policy_config = copy.deepcopy(self.config) + + featurizer_configs = policy_config.get("featurizer") + + if not featurizer_configs: + return self._standard_featurizer() + + featurizer_func = _get_featurizer_from_config( + featurizer_configs, + self.__class__.__name__, + lookup_path="rasa.core.featurizers.tracker_featurizers", + ) + featurizer_config = featurizer_configs[0] + + state_featurizer_configs = featurizer_config.pop("state_featurizer", None) + if state_featurizer_configs: + state_featurizer_func = _get_featurizer_from_config( + state_featurizer_configs, + self.__class__.__name__, + lookup_path="rasa.core.featurizers.single_state_featurizer", + ) + state_featurizer_config = state_featurizer_configs[0] + + featurizer_config["state_featurizer"] = state_featurizer_func( + **state_featurizer_config + ) + + featurizer = featurizer_func(**featurizer_config) + if ( + isinstance(featurizer, MaxHistoryTrackerFeaturizer) + and POLICY_MAX_HISTORY in policy_config + and POLICY_MAX_HISTORY not in featurizer_config + ): + featurizer.max_history = policy_config[POLICY_MAX_HISTORY] + return featurizer + + def _standard_featurizer(self) -> MaxHistoryTrackerFeaturizer: + """Initializes the standard featurizer for this policy.""" + return MaxHistoryTrackerFeaturizer( + SingleStateFeaturizer(), self.config.get(POLICY_MAX_HISTORY) + ) + + @property + def featurizer(self) -> TrackerFeaturizer: + """Returns the policy's featurizer.""" + return self.__featurizer + + @staticmethod + def _get_valid_params(func: Callable, **kwargs: Any) -> Dict: + """Filters out kwargs that cannot be passed to func. + + Args: + func: a callable function + + Returns: + the dictionary of parameters + """ + valid_keys = rasa.shared.utils.common.arguments_of(func) + + params = {key: kwargs.get(key) for key in valid_keys if kwargs.get(key)} + ignored_params = { + key: kwargs.get(key) for key in kwargs.keys() if not params.get(key) + } + logger.debug(f"Parameters ignored by `model.fit(...)`: {ignored_params}") + return params + + def _featurize_for_training( + self, + training_trackers: List[DialogueStateTracker], + domain: Domain, + precomputations: Optional[MessageContainerForCoreFeaturization], + bilou_tagging: bool = False, + **kwargs: Any, + ) -> Tuple[ + List[List[Dict[Text, List[Features]]]], + np.ndarray, + List[List[Dict[Text, List[Features]]]], + ]: + """Transform training trackers into a vector representation. + + The trackers, consisting of multiple turns, will be transformed + into a float vector which can be used by a ML model. + + Args: + training_trackers: + the list of the :class:`rasa.core.trackers.DialogueStateTracker` + domain: the :class:`rasa.shared.core.domain.Domain` + precomputations: Contains precomputed features and attributes. + bilou_tagging: indicates whether BILOU tagging should be used or not + + Returns: + - a dictionary of attribute (INTENT, TEXT, ACTION_NAME, ACTION_TEXT, + ENTITIES, SLOTS, FORM) to a list of features for all dialogue turns in + all training trackers + - the label ids (e.g. action ids) for every dialogue turn in all training + trackers + - A dictionary of entity type (ENTITY_TAGS) to a list of features + containing entity tag ids for text user inputs otherwise empty dict + for all dialogue turns in all training trackers + """ + state_features, label_ids, entity_tags = self.featurizer.featurize_trackers( + training_trackers, + domain, + precomputations=precomputations, + bilou_tagging=bilou_tagging, + ignore_action_unlikely_intent=self.supported_data() + == SupportedData.ML_DATA, + ) + + max_training_samples = kwargs.get("max_training_samples") + if max_training_samples is not None: + logger.debug( + "Limit training data to {} training samples." + "".format(max_training_samples) + ) + state_features = state_features[:max_training_samples] + label_ids = label_ids[:max_training_samples] + entity_tags = entity_tags[:max_training_samples] + + return state_features, label_ids, entity_tags + + def _prediction_states( + self, + tracker: DialogueStateTracker, + domain: Domain, + use_text_for_last_user_input: bool = False, + rule_only_data: Optional[Dict[Text, Any]] = None, + ) -> List[State]: + """Transforms tracker to states for prediction. + + Args: + tracker: The tracker to be featurized. + domain: The Domain. + use_text_for_last_user_input: Indicates whether to use text or intent label + for featurizing last user input. + rule_only_data: Slots and loops which are specific to rules and hence + should be ignored by this policy. + + Returns: + A list of states. + """ + return self.featurizer.prediction_states( + [tracker], + domain, + use_text_for_last_user_input=use_text_for_last_user_input, + ignore_rule_only_turns=self.supported_data() == SupportedData.ML_DATA, + rule_only_data=rule_only_data, + ignore_action_unlikely_intent=self.supported_data() + == SupportedData.ML_DATA, + )[0] + + def _featurize_for_prediction( + self, + tracker: DialogueStateTracker, + domain: Domain, + precomputations: Optional[MessageContainerForCoreFeaturization], + rule_only_data: Optional[Dict[Text, Any]], + use_text_for_last_user_input: bool = False, + ) -> List[List[Dict[Text, List[Features]]]]: + """Transforms training tracker into a vector representation. + + The trackers, consisting of multiple turns, will be transformed + into a float vector which can be used by a ML model. + + Args: + tracker: The tracker to be featurized. + domain: The Domain. + precomputations: Contains precomputed features and attributes. + use_text_for_last_user_input: Indicates whether to use text or intent label + for featurizing last user input. + rule_only_data: Slots and loops which are specific to rules and hence + should be ignored by this policy. + + Returns: + A list (corresponds to the list of trackers) + of lists (corresponds to all dialogue turns) + of dictionaries of state type (INTENT, TEXT, ACTION_NAME, ACTION_TEXT, + ENTITIES, SLOTS, ACTIVE_LOOP) to a list of features for all dialogue + turns in all trackers. + """ + return self.featurizer.create_state_features( + [tracker], + domain, + precomputations=precomputations, + use_text_for_last_user_input=use_text_for_last_user_input, + ignore_rule_only_turns=self.supported_data() == SupportedData.ML_DATA, + rule_only_data=rule_only_data, + ignore_action_unlikely_intent=self.supported_data() + == SupportedData.ML_DATA, + ) + + @abc.abstractmethod + def train( + self, + training_trackers: List[TrackerWithCachedStates], + domain: Domain, + **kwargs: Any, + ) -> Resource: + """Trains a policy. + + Args: + training_trackers: The story and rules trackers from the training data. + domain: The model's domain. + **kwargs: Depending on the specified `needs` section and the resulting + graph structure the policy can use different input to train itself. + + Returns: + A policy must return its resource locator so that potential children nodes + can load the policy from the resource. + """ + raise NotImplementedError("Policy must have the capacity to train.") + + @abc.abstractmethod + def predict_action_probabilities( + self, + tracker: DialogueStateTracker, + domain: Domain, + rule_only_data: Optional[Dict[Text, Any]] = None, + **kwargs: Any, + ) -> PolicyPrediction: + """Predicts the next action the bot should take after seeing the tracker. + + Args: + tracker: The tracker containing the conversation history up to now. + domain: The model's domain. + rule_only_data: Slots and loops which are specific to rules and hence + should be ignored by this policy. + **kwargs: Depending on the specified `needs` section and the resulting + graph structure the policy can use different input to make predictions. + + Returns: + The prediction. + """ + raise NotImplementedError("Policy must have the capacity to predict.") + + def _prediction( + self, + probabilities: List[float], + events: Optional[List[Event]] = None, + optional_events: Optional[List[Event]] = None, + is_end_to_end_prediction: bool = False, + is_no_user_prediction: bool = False, + diagnostic_data: Optional[Dict[Text, Any]] = None, + action_metadata: Optional[Dict[Text, Any]] = None, + ) -> PolicyPrediction: + return PolicyPrediction( + probabilities, + self.__class__.__name__, + self.priority, + events, + optional_events, + is_end_to_end_prediction, + is_no_user_prediction, + diagnostic_data, + action_metadata=action_metadata, + ) + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> Policy: + """Loads a trained policy (see parent class for full docstring).""" + featurizer = None + + try: + with model_storage.read_from(resource) as path: + if (Path(path) / FEATURIZER_FILE).is_file(): + featurizer = TrackerFeaturizer.load(path) + + config.update(kwargs) + + except (ValueError, FileNotFoundError, FileIOException): + logger.debug( + f"Couldn't load metadata for policy '{cls.__name__}' as the persisted " + f"metadata couldn't be loaded." + ) + + return cls( + config, model_storage, resource, execution_context, featurizer=featurizer + ) + + def _default_predictions(self, domain: Domain) -> List[float]: + """Creates a list of zeros. + + Args: + domain: the :class:`rasa.shared.core.domain.Domain` + Returns: + the list of the length of the number of actions + """ + return [0.0] * domain.num_actions + + @staticmethod + def format_tracker_states(states: List[Dict]) -> Text: + """Format tracker states to human readable format on debug log. + + Args: + states: list of tracker states dicts + + Returns: + the string of the states with user intents and actions + """ + # empty string to insert line break before first state + formatted_states = [""] + if states: + for index, state in enumerate(states): + state_messages = [] + if state: + if USER in state: + if TEXT in state[USER]: + state_messages.append( + f"user text: {str(state[USER][TEXT])}" + ) + if INTENT in state[USER]: + state_messages.append( + f"user intent: {str(state[USER][INTENT])}" + ) + if ENTITIES in state[USER]: + state_messages.append( + f"user entities: {str(state[USER][ENTITIES])}" + ) + if PREVIOUS_ACTION in state: + if ACTION_NAME in state[PREVIOUS_ACTION]: + state_messages.append( + f"previous action name: " + f"{str(state[PREVIOUS_ACTION][ACTION_NAME])}" + ) + if ACTION_TEXT in state[PREVIOUS_ACTION]: + state_messages.append( + f"previous action text: " + f"{str(state[PREVIOUS_ACTION][ACTION_TEXT])}" + ) + if ACTIVE_LOOP in state: + state_messages.append(f"active loop: {str(state[ACTIVE_LOOP])}") + if SLOTS in state: + state_messages.append(f"slots: {str(state[SLOTS])}") + state_message_formatted = " | ".join(state_messages) + state_formatted = f"[state {str(index)}] {state_message_formatted}" + formatted_states.append(state_formatted) + + return "\n".join(formatted_states) + + def __repr__(self) -> Text: + """Returns text representation of object.""" + return f"{self.__class__.__name__}@{id(self)}" + + +class PolicyPrediction: + """Stores information about the prediction of a `Policy`.""" + + def __init__( + self, + probabilities: List[float], + policy_name: Optional[Text], + policy_priority: int = 1, + events: Optional[List[Event]] = None, + optional_events: Optional[List[Event]] = None, + is_end_to_end_prediction: bool = False, + is_no_user_prediction: bool = False, + diagnostic_data: Optional[Dict[Text, Any]] = None, + hide_rule_turn: bool = False, + action_metadata: Optional[Dict[Text, Any]] = None, + ) -> None: + """Creates a `PolicyPrediction`. + + Args: + probabilities: The probabilities for each action. + policy_name: Name of the policy which made the prediction. + policy_priority: The priority of the policy which made the prediction. + events: Events which the `Policy` needs to have applied to the tracker + after the prediction. These events are applied independent of whether + the policy wins against other policies or not. Be careful which events + you return as they can potentially influence the conversation flow. + optional_events: Events which the `Policy` needs to have applied to the + tracker after the prediction in case it wins. These events are only + applied in case the policy's prediction wins. Be careful which events + you return as they can potentially influence the conversation flow. + is_end_to_end_prediction: `True` if the prediction used the text of the + user message instead of the intent. + is_no_user_prediction: `True` if the prediction uses neither the text + of the user message nor the intent. This is for the example the case + for happy loop paths. + diagnostic_data: Intermediate results or other information that is not + necessary for Rasa to function, but intended for debugging and + fine-tuning purposes. + hide_rule_turn: `True` if the prediction was made by the rules which + do not appear in the stories + action_metadata: Specifies additional metadata that can be passed + by policies. + """ + self.probabilities = probabilities + self.policy_name = policy_name + self.policy_priority = policy_priority + self.events = events or [] + self.optional_events = optional_events or [] + self.is_end_to_end_prediction = is_end_to_end_prediction + self.is_no_user_prediction = is_no_user_prediction + self.diagnostic_data = diagnostic_data or {} + self.hide_rule_turn = hide_rule_turn + self.action_metadata = action_metadata + + @staticmethod + def for_action_name( + domain: Domain, + action_name: Text, + policy_name: Optional[Text] = None, + confidence: float = 1.0, + action_metadata: Optional[Dict[Text, Any]] = None, + ) -> "PolicyPrediction": + """Create a prediction for a given action. + + Args: + domain: The current model domain + action_name: The action which should be predicted. + policy_name: The policy which did the prediction. + confidence: The prediction confidence. + action_metadata: Additional metadata to be attached with the prediction. + + Returns: + The prediction. + """ + probabilities = confidence_scores_for(action_name, confidence, domain) + + return PolicyPrediction( + probabilities, policy_name, action_metadata=action_metadata + ) + + def __eq__(self, other: Any) -> bool: + """Checks if the two objects are equal. + + Args: + other: Any other object. + + Returns: + `True` if other has the same type and the values are the same. + """ + if not isinstance(other, PolicyPrediction): + return False + + return ( + self.probabilities == other.probabilities + and self.policy_name == other.policy_name + and self.policy_priority == other.policy_priority + and self.events == other.events + and self.optional_events == other.optional_events + and self.is_end_to_end_prediction == other.is_end_to_end_prediction + and self.is_no_user_prediction == other.is_no_user_prediction + and self.hide_rule_turn == other.hide_rule_turn + and self.action_metadata == other.action_metadata + # We do not compare `diagnostic_data`, because it has no effect on the + # action prediction. + ) + + @property + def max_confidence_index(self) -> int: + """Gets the index of the action prediction with the highest confidence. + + Returns: + The index of the action with the highest confidence. + """ + return self.probabilities.index(self.max_confidence) + + @property + def max_confidence(self) -> float: + """Gets the highest predicted confidence. + + Returns: + The highest predicted confidence. + """ + return max(self.probabilities, default=0.0) + + +def confidence_scores_for( + action_name: Text, value: float, domain: Domain +) -> List[float]: + """Returns confidence scores if a single action is predicted. + + Args: + action_name: the name of the action for which the score should be set + value: the confidence for `action_name` + domain: the :class:`rasa.shared.core.domain.Domain` + + Returns: + the list of the length of the number of actions + """ + results = [0.0] * domain.num_actions + idx = domain.index_for_action(action_name) + results[idx] = value + + return results + + +class InvalidPolicyConfig(RasaException): + """Exception that can be raised when policy config is not valid.""" + + +def _get_featurizer_from_config( + config: List[Dict[Text, Any]], policy_name: Text, lookup_path: Text +) -> Callable[..., TrackerFeaturizer]: + """Gets the featurizer initializer and its arguments from a policy config.""" + # Only 1 featurizer is allowed + if len(config) > 1: + featurizer_names = [ + featurizer_config.get("name") for featurizer_config in config + ] + raise InvalidPolicyConfig( + f"Every policy can only have 1 featurizer but '{policy_name}' " + f"uses {len(config)} featurizers ('{', '.join(featurizer_names)}')." + ) + + featurizer_config = config[0] + featurizer_name = featurizer_config.pop("name") + featurizer_func = rasa.shared.utils.common.class_from_module_path( + featurizer_name, lookup_path=lookup_path + ) + + return featurizer_func diff --git a/rasa/core/policies/rule_policy.py b/rasa/core/policies/rule_policy.py new file mode 100644 index 0000000..8ec921f --- /dev/null +++ b/rasa/core/policies/rule_policy.py @@ -0,0 +1,1267 @@ +from __future__ import annotations +import copy +import functools +import logging +import structlog +from typing import Any, List, DefaultDict, Dict, Text, Optional, Set, Tuple, cast + +from tqdm import tqdm +import numpy as np +import json +from collections import defaultdict + +from rasa.engine.graph import ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.constants import DOCS_URL_RULES +from rasa.shared.exceptions import RasaException +import rasa.shared.utils.io +from rasa.shared.core.events import LoopInterrupted, UserUttered, ActionExecuted +from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer +from rasa.core.policies.memoization import MemoizationPolicy +from rasa.core.policies.policy import SupportedData, PolicyPrediction +from rasa.shared.core.trackers import ( + DialogueStateTracker, + get_active_loop_name, + is_prev_action_listen_in_state, +) +from rasa.shared.core.generator import TrackerWithCachedStates +from rasa.core.constants import ( + DEFAULT_CORE_FALLBACK_THRESHOLD, + RULE_POLICY_PRIORITY, + POLICY_PRIORITY, + POLICY_MAX_HISTORY, +) +from rasa.shared.core.constants import ( + USER_INTENT_RESTART, + USER_INTENT_BACK, + USER_INTENT_SESSION_START, + ACTION_LISTEN_NAME, + ACTION_RESTART_NAME, + ACTION_SESSION_START_NAME, + ACTION_DEFAULT_FALLBACK_NAME, + ACTION_BACK_NAME, + RULE_SNIPPET_ACTION_NAME, + SHOULD_NOT_BE_SET, + PREVIOUS_ACTION, + LOOP_NAME, + SLOTS, + ACTIVE_LOOP, + RULE_ONLY_SLOTS, + RULE_ONLY_LOOPS, +) +from rasa.shared.core.domain import InvalidDomain, State, Domain +from rasa.shared.nlu.constants import ACTION_NAME, INTENT_NAME_KEY +import rasa.core.test +from rasa.core.training.training import create_action_fingerprints, ActionFingerprint + +logger = logging.getLogger(__name__) +structlogger = structlog.get_logger() + + +# These are Rasa Open Source default actions and overrule everything at any time. +DEFAULT_ACTION_MAPPINGS = { + USER_INTENT_RESTART: ACTION_RESTART_NAME, + USER_INTENT_BACK: ACTION_BACK_NAME, + USER_INTENT_SESSION_START: ACTION_SESSION_START_NAME, +} + +RULES = "rules" +RULES_FOR_LOOP_UNHAPPY_PATH = "rules_for_loop_unhappy_path" +RULES_NOT_IN_STORIES = "rules_not_in_stories" + +LOOP_WAS_INTERRUPTED = "loop_was_interrupted" +DO_NOT_PREDICT_LOOP_ACTION = "do_not_predict_loop_action" + +DEFAULT_RULES = "predicting default action with intent " +LOOP_RULES = "handling active loops and forms - " +LOOP_RULES_SEPARATOR = " - " + + +class InvalidRule(RasaException): + """Exception that can be raised when rules are not valid.""" + + def __init__(self, message: Text) -> None: + super().__init__() + self.message = message + + def __str__(self) -> Text: + return self.message + ( + f"\nYou can find more information about the usage of " + f"rules at {DOCS_URL_RULES}. " + ) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.POLICY_WITHOUT_END_TO_END_SUPPORT, is_trainable=True +) +class RulePolicy(MemoizationPolicy): + """Policy which handles all the rules.""" + + # rules use explicit json strings + ENABLE_FEATURE_STRING_COMPRESSION = False + + # number of user inputs that is allowed in case rules are restricted + ALLOWED_NUMBER_OF_USER_INPUTS = 1 + + @staticmethod + def supported_data() -> SupportedData: + """The type of data supported by this policy. + + Returns: + The data type supported by this policy (ML and rule data). + """ + return SupportedData.ML_AND_RULE_DATA + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns the default config (see parent class for full docstring).""" + return { + # Priority of the policy which is used if multiple policies predict + # actions with the same confidence. + POLICY_PRIORITY: RULE_POLICY_PRIORITY, + # Confidence of the prediction if no rule matched and de-facto + # threshold for a core fallback. + "core_fallback_threshold": DEFAULT_CORE_FALLBACK_THRESHOLD, + # Name of the action which should be predicted if no rule matched. + "core_fallback_action_name": ACTION_DEFAULT_FALLBACK_NAME, + # If `True` `core_fallback_action_name` is predicted in case no rule + # matched. + "enable_fallback_prediction": True, + # If `True` rules are restricted to contain a maximum of 1 + # user message. This is used to avoid that users build a state machine + # using the rules. + "restrict_rules": True, + # Whether to check for contradictions between rules and stories + "check_for_contradictions": True, + # the policy will use the confidence of NLU on the latest + # user message to set the confidence of the action + "use_nlu_confidence_as_score": False, + } + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + featurizer: Optional[TrackerFeaturizer] = None, + lookup: Optional[Dict] = None, + ) -> None: + """Initializes the policy.""" + # max history is set to `None` in order to capture any lengths of rule stories + config[POLICY_MAX_HISTORY] = None + + super().__init__( + config, model_storage, resource, execution_context, featurizer, lookup + ) + + self._fallback_action_name = config["core_fallback_action_name"] + self._enable_fallback_prediction = config["enable_fallback_prediction"] + self._check_for_contradictions = config["check_for_contradictions"] + + self._rules_sources: DefaultDict[Text, List[Tuple[Text, Text]]] = defaultdict( + list + ) + + @classmethod + def raise_if_incompatible_with_domain( + cls, config: Dict[Text, Any], domain: Domain + ) -> None: + """Checks whether the domains action names match the configured fallback. + + Args: + config: configuration of a `RulePolicy` + domain: a domain + Raises: + `InvalidDomain` if this policy is incompatible with the domain + """ + fallback_action_name = config.get("core_fallback_action_name", None) + if ( + fallback_action_name + and fallback_action_name not in domain.action_names_or_texts + ): + raise InvalidDomain( + f"The fallback action '{fallback_action_name}' which was " + f"configured for the {RulePolicy.__name__} must be " + f"present in the domain." + ) + + @staticmethod + def _is_rule_snippet_state(state: State) -> bool: + prev_action_name = state.get(PREVIOUS_ACTION, {}).get(ACTION_NAME) + return prev_action_name == RULE_SNIPPET_ACTION_NAME + + def _create_feature_key(self, states: List[State]) -> Optional[Text]: + new_states: List[State] = [] + for state in reversed(states): + if self._is_rule_snippet_state(state): + # remove all states before RULE_SNIPPET_ACTION_NAME + break + new_states.insert(0, state) + + if not new_states: + return None + + # we sort keys to make sure that the same states + # represented as dictionaries have the same json strings + return json.dumps(new_states, sort_keys=True) + + @staticmethod + def _states_for_unhappy_loop_predictions(states: List[State]) -> List[State]: + """Modifies the states to create feature keys for loop unhappy path conditions. + + Args: + states: a representation of a tracker + as a list of dictionaries containing features + + Returns: + modified states + """ + # leave only last 2 dialogue turns to + # - capture previous meaningful action before action_listen + # - ignore previous intent + if len(states) == 1 or not states[-2].get(PREVIOUS_ACTION): + return [states[-1]] + else: + return [{PREVIOUS_ACTION: states[-2][PREVIOUS_ACTION]}, states[-1]] + + @staticmethod + def _remove_rule_snippet_predictions(lookup: Dict[Text, Text]) -> Dict[Text, Text]: + # Delete rules if it would predict the RULE_SNIPPET_ACTION_NAME action + return { + feature_key: action + for feature_key, action in lookup.items() + if action != RULE_SNIPPET_ACTION_NAME + } + + def _create_loop_unhappy_lookup_from_states( + self, + trackers_as_states: List[List[State]], + trackers_as_actions: List[List[Text]], + ) -> Dict[Text, Text]: + """Creates lookup dictionary from the tracker represented as states. + + Args: + trackers_as_states: representation of the trackers as a list of states + trackers_as_actions: representation of the trackers as a list of actions + + Returns: + lookup dictionary + """ + lookup = {} + for states, actions in zip(trackers_as_states, trackers_as_actions): + action = actions[0] + active_loop = get_active_loop_name(states[-1]) + # even if there are two identical feature keys + # their loop will be the same + if not active_loop: + continue + + states = self._states_for_unhappy_loop_predictions(states) + feature_key = self._create_feature_key(states) + if not feature_key: + continue + + # Since rule snippets and stories inside the loop contain + # only unhappy paths, notify the loop that + # it was predicted after an answer to a different question and + # therefore it should not validate user input + if ( + # loop is predicted after action_listen in unhappy path, + # therefore no validation is needed + is_prev_action_listen_in_state(states[-1]) + and action == active_loop + ): + lookup[feature_key] = LOOP_WAS_INTERRUPTED + elif ( + # some action other than active_loop is predicted in unhappy path, + # therefore active_loop shouldn't be predicted by the rule + not is_prev_action_listen_in_state(states[-1]) + and action != active_loop + ): + lookup[feature_key] = DO_NOT_PREDICT_LOOP_ACTION + return lookup + + def _check_rule_restriction( + self, rule_trackers: List[TrackerWithCachedStates] + ) -> None: + rules_exceeding_max_user_turns = [] + for tracker in rule_trackers: + number_of_user_uttered = sum( + isinstance(event, UserUttered) for event in tracker.events + ) + if number_of_user_uttered > self.ALLOWED_NUMBER_OF_USER_INPUTS: + rules_exceeding_max_user_turns.append(tracker.sender_id) + + if rules_exceeding_max_user_turns: + raise InvalidRule( + f"Found rules '{', '.join(rules_exceeding_max_user_turns)}' " + f"that contain more than {self.ALLOWED_NUMBER_OF_USER_INPUTS} " + f"user message. Rules are not meant to hardcode a state machine. " + f"Please use stories for these cases." + ) + + @staticmethod + def _expected_but_missing_slots( + fingerprint: ActionFingerprint, state: State + ) -> Set[Text]: + expected_slots = set(fingerprint.slots) + current_slots = set(state.get(SLOTS, {}).keys()) + # report all slots that are expected but aren't set in current slots + return expected_slots.difference(current_slots) + + @staticmethod + def _check_active_loops_fingerprint( + fingerprint: ActionFingerprint, state: State + ) -> Set[Optional[Text]]: + expected_active_loops = set(fingerprint.active_loop) + # we don't use tracker.active_loop_name + # because we need to keep should_not_be_set + current_active_loop = state.get(ACTIVE_LOOP, {}).get(LOOP_NAME) + if current_active_loop in expected_active_loops: + # one of expected active loops is set + return set() + + return expected_active_loops + + @staticmethod + def _error_messages_from_fingerprints( + action_name: Text, + missing_fingerprint_slots: Set[Text], + fingerprint_active_loops: Set[Text], + rule_name: Text, + ) -> List[Text]: + error_messages = [] + if action_name and missing_fingerprint_slots: + error_messages.append( + f"- the action '{action_name}' in rule '{rule_name}' does not set some " + f"of the slots that it sets in other rules. Slots not set in rule " + f"'{rule_name}': '{', '.join(missing_fingerprint_slots)}'. Please " + f"update the rule with an appropriate slot or if it is the last action " + f"add 'wait_for_user_input: false' after this action." + ) + if action_name and fingerprint_active_loops: + # substitute `SHOULD_NOT_BE_SET` with `null` so that users + # know what to put in their rules + fingerprint_active_loops = set( + "null" if active_loop == SHOULD_NOT_BE_SET else active_loop + for active_loop in fingerprint_active_loops + ) + # add action_name to active loop so that users + # know what to put in their rules + fingerprint_active_loops.add(action_name) + + error_messages.append( + f"- the form '{action_name}' in rule '{rule_name}' does not set " + f"the 'active_loop', that it sets in other rules: " + f"'{', '.join(fingerprint_active_loops)}'. Please update the rule with " + f"the appropriate 'active loop' property or if it is the last action " + f"add 'wait_for_user_input: false' after this action." + ) + return error_messages + + def _check_for_incomplete_rules( + self, rule_trackers: List[TrackerWithCachedStates], domain: Domain + ) -> None: + logger.debug("Started checking if some rules are incomplete.") + # we need to use only fingerprints from rules + rule_fingerprints = create_action_fingerprints(rule_trackers, domain) + if not rule_fingerprints: + return + + error_messages: List[Text] = [] + for tracker in rule_trackers: + states = tracker.past_states(domain) + # the last action is always action listen + action_names = [ + state.get(PREVIOUS_ACTION, {}).get(ACTION_NAME) for state in states[1:] + ] + [ACTION_LISTEN_NAME] + + for state, action_name in zip(states, action_names): + previous_action_name = state.get(PREVIOUS_ACTION, {}).get(ACTION_NAME) + fingerprint = rule_fingerprints.get(previous_action_name) + if ( + not previous_action_name + or not fingerprint + or action_name == RULE_SNIPPET_ACTION_NAME + or previous_action_name == RULE_SNIPPET_ACTION_NAME + ): + # do not check fingerprints for rule snippet action + # and don't raise if fingerprints are not satisfied + # for a previous action if current action is rule snippet action + continue + + missing_expected_slots = self._expected_but_missing_slots( + fingerprint, state + ) + expected_active_loops = self._check_active_loops_fingerprint( + fingerprint, state + ) + error_messages.extend( + self._error_messages_from_fingerprints( + previous_action_name, + missing_expected_slots, + expected_active_loops, + tracker.sender_id, + ) + ) + + if error_messages: + error_text = "\n".join(error_messages) + raise InvalidRule( + f"\nIncomplete rules found🚨\n\n{error_text}\n" + f"Please note that if some slots or active loops should not be set " + f"during prediction you need to explicitly set them to 'null' in the " + f"rules." + ) + + logger.debug("Found no incompletions in rules.") + + @staticmethod + def _get_slots_loops_from_states( + trackers_as_states: List[List[State]], + ) -> Tuple[Set[Text], Set[Text]]: + slots = set() + loops = set() + for states in trackers_as_states: + for state in states: + slots.update(set(state.get(SLOTS, {}).keys())) + # FIXME: ideally we have better annotation for State, TypedDict + # could work but support in mypy is very limited. Dataclass are + # another option + active_loop = cast(Text, state.get(ACTIVE_LOOP, {}).get(LOOP_NAME)) + if active_loop: + loops.add(active_loop) + return slots, loops + + def _find_rule_only_slots_loops( + self, + rule_trackers_as_states: List[List[State]], + story_trackers_as_states: List[List[State]], + ) -> Tuple[List[Text], List[Text]]: + rule_slots, rule_loops = self._get_slots_loops_from_states( + rule_trackers_as_states + ) + story_slots, story_loops = self._get_slots_loops_from_states( + story_trackers_as_states + ) + + # set is not json serializable, so convert to list + return ( + list(rule_slots - story_slots - {SHOULD_NOT_BE_SET}), + list(rule_loops - story_loops - {SHOULD_NOT_BE_SET}), + ) + + def _predict_next_action( + self, tracker: TrackerWithCachedStates, domain: Domain + ) -> Tuple[Optional[Text], Optional[Text]]: + prediction, prediction_source = self._predict(tracker, domain) + probabilities = prediction.probabilities + # do not raise an error if RulePolicy didn't predict anything for stories; + # however for rules RulePolicy should always predict an action + predicted_action_name = None + if ( + probabilities != self._default_predictions(domain) + or tracker.is_rule_tracker + ): + predicted_action_name = domain.action_names_or_texts[ + np.argmax(probabilities) + ] + + return predicted_action_name, prediction_source + + def _predicted_action_name( + self, tracker: TrackerWithCachedStates, domain: Domain, gold_action_name: Text + ) -> Tuple[Optional[Text], Optional[Text]]: + predicted_action_name, prediction_source = self._predict_next_action( + tracker, domain + ) + # if there is an active_loop, + # RulePolicy will always predict active_loop first, + # but inside loop unhappy path there might be another action + if ( + tracker.active_loop_name + and predicted_action_name != gold_action_name + and predicted_action_name == tracker.active_loop_name + ): + rasa.core.test.emulate_loop_rejection(tracker) + predicted_action_name, prediction_source = self._predict_next_action( + tracker, domain + ) + + return predicted_action_name, prediction_source + + def _collect_sources( + self, + tracker: TrackerWithCachedStates, + predicted_action_name: Optional[Text], + gold_action_name: Optional[Text], + prediction_source: Text, + ) -> None: + # we need to remember which action should be predicted by the rule + # in order to correctly output the names of the contradicting rules + rule_name = tracker.sender_id + + if prediction_source is not None and ( + prediction_source.startswith(DEFAULT_RULES) + or prediction_source.startswith(LOOP_RULES) + ): + # the real gold action contradict the one in the rules in this case + gold_action_name = predicted_action_name + rule_name = prediction_source + + self._rules_sources[prediction_source].append((rule_name, gold_action_name)) + + @staticmethod + def _default_sources() -> Set[Text]: + return { + DEFAULT_RULES + default_intent + for default_intent in DEFAULT_ACTION_MAPPINGS.keys() + } + + @staticmethod + def _handling_loop_sources(domain: Domain) -> Set[Text]: + loop_sources = set() + for loop_name in domain.form_names: + loop_sources.add(LOOP_RULES + loop_name) + loop_sources.add( + LOOP_RULES + loop_name + LOOP_RULES_SEPARATOR + ACTION_LISTEN_NAME + ) + return loop_sources + + def _should_delete( + self, + prediction_source: Text, + tracker: TrackerWithCachedStates, + predicted_action_name: Text, + ) -> bool: + """Checks whether this contradiction is due to action, intent pair. + + Args: + prediction_source: the states that result in the prediction + tracker: the tracker that raises the contradiction + + Returns: + true if the contradiction is a result of an action, intent pair in the rule. + """ + if ( + # only apply to contradicting story, not rule + tracker.is_rule_tracker + # only apply for prediction after unpredictable action + or prediction_source.count(PREVIOUS_ACTION) > 1 + # only apply for prediction of action_listen + or predicted_action_name != ACTION_LISTEN_NAME + ): + return False + for source in self.lookup[RULES]: + # remove rule only if another action is predicted after action_listen + if ( + source.startswith(prediction_source[:-2]) + and not prediction_source == source + ): + return True + return False + + def _check_prediction( + self, + tracker: TrackerWithCachedStates, + predicted_action_name: Optional[Text], + gold_action_name: Text, + prediction_source: Optional[Text], + ) -> List[Text]: + # FIXME: `predicted_action_name` and `prediction_source` are + # either None together or defined together. This could be improved + # by better typing in this class, but requires some refactoring + if ( + not predicted_action_name + or not prediction_source + or predicted_action_name == gold_action_name + ): + return [] + + if self._should_delete(prediction_source, tracker, predicted_action_name): + self.lookup[RULES].pop(prediction_source) + return [] + + tracker_type = "rule" if tracker.is_rule_tracker else "story" + contradicting_rules = { + rule_name + for rule_name, action_name in self._rules_sources[prediction_source] + if action_name != gold_action_name + } + + if not contradicting_rules: + return [] + + error_message = ( + f"- the prediction of the action '{gold_action_name}' in {tracker_type} " + f"'{tracker.sender_id}' " + f"is contradicting with rule(s) '{', '.join(contradicting_rules)}'" + ) + # outputting predicted action 'action_default_fallback' is confusing + if predicted_action_name != self._fallback_action_name: + error_message += f" which predicted action '{predicted_action_name}'" + + return [error_message + "."] + + def _run_prediction_on_trackers( + self, + trackers: List[TrackerWithCachedStates], + domain: Domain, + collect_sources: bool, + ) -> Tuple[List[Text], Set[Optional[Text]]]: + if collect_sources: + self._rules_sources = defaultdict(list) + + error_messages = [] + rules_used_in_stories = set() + pbar = tqdm( + trackers, + desc="Processed trackers", + disable=rasa.shared.utils.io.is_logging_disabled(), + ) + for tracker in pbar: + running_tracker = tracker.init_copy() + running_tracker.sender_id = tracker.sender_id + # the first action is always unpredictable + next_action_is_unpredictable = True + for event in tracker.applied_events(): + if not isinstance(event, ActionExecuted): + running_tracker.update(event) + continue + + if event.action_name == RULE_SNIPPET_ACTION_NAME: + # notify that the action after RULE_SNIPPET_ACTION_NAME is + # unpredictable + next_action_is_unpredictable = True + running_tracker.update(event) + continue + + # do not run prediction on unpredictable actions + if next_action_is_unpredictable or event.unpredictable: + next_action_is_unpredictable = False # reset unpredictability + running_tracker.update(event) + continue + + gold_action_name = event.action_name or event.action_text + predicted_action_name, prediction_source = self._predicted_action_name( + running_tracker, domain, gold_action_name + ) + if collect_sources: + if prediction_source: + self._collect_sources( + running_tracker, + predicted_action_name, + gold_action_name, + prediction_source, + ) + else: + # to be able to remove only rules turns from the dialogue history + # for ML policies, + # we need to know which rules were used in ML trackers + if ( + not tracker.is_rule_tracker + and predicted_action_name == gold_action_name + ): + rules_used_in_stories.add(prediction_source) + + error_messages += self._check_prediction( + running_tracker, + predicted_action_name, + gold_action_name, + prediction_source, + ) + + running_tracker.update(event) + + return error_messages, rules_used_in_stories + + def _collect_rule_sources( + self, rule_trackers: List[TrackerWithCachedStates], domain: Domain + ) -> None: + self._run_prediction_on_trackers(rule_trackers, domain, collect_sources=True) + + def _find_contradicting_and_used_in_stories_rules( + self, trackers: List[TrackerWithCachedStates], domain: Domain + ) -> Tuple[List[Text], Set[Optional[Text]]]: + return self._run_prediction_on_trackers(trackers, domain, collect_sources=False) + + def _analyze_rules( + self, + rule_trackers: List[TrackerWithCachedStates], + all_trackers: List[TrackerWithCachedStates], + domain: Domain, + ) -> List[Text]: + """Analyzes learned rules by running prediction on training trackers. + + This method collects error messages for contradicting rules + and creates the lookup for rules that are not present in the stories. + + Args: + rule_trackers: The list of the rule trackers. + all_trackers: The list of all trackers. + domain: The domain. + + Returns: + Rules that are not present in the stories. + """ + logger.debug("Started checking rules and stories for contradictions.") + # during training we run `predict_action_probabilities` to check for + # contradicting rules. + # We silent prediction debug to avoid too many logs during these checks. + logger_level = logger.level + logger.setLevel(logging.WARNING) + + # we need to run prediction on rule trackers twice, because we need to collect + # the information about which rule snippets contributed to the learned rules + self._collect_rule_sources(rule_trackers, domain) + ( + error_messages, + rules_used_in_stories, + ) = self._find_contradicting_and_used_in_stories_rules(all_trackers, domain) + + logger.setLevel(logger_level) # reset logger level + if error_messages: + error_text = "\n".join(error_messages) + raise InvalidRule( + f"\nContradicting rules or stories found 🚨\n\n{error_text}\n" + f"Please update your stories and rules so that they don't contradict " + f"each other." + ) + + logger.debug("Found no contradicting rules.") + all_rules = ( + set(self._rules_sources.keys()) + | self._default_sources() + | self._handling_loop_sources(domain) + ) + # set is not json serializable, so convert to list + return list(all_rules - rules_used_in_stories) + + def _create_lookup_from_trackers( + self, + rule_trackers: List[TrackerWithCachedStates], + story_trackers: List[TrackerWithCachedStates], + domain: Domain, + ) -> None: + ( + rule_trackers_as_states, + rule_trackers_as_actions, + ) = self.featurizer.training_states_and_labels( + rule_trackers, domain, omit_unset_slots=True + ) + + rules_lookup = self._create_lookup_from_states( + rule_trackers_as_states, rule_trackers_as_actions + ) + self.lookup[RULES] = self._remove_rule_snippet_predictions(rules_lookup) + + ( + story_trackers_as_states, + story_trackers_as_actions, + ) = self.featurizer.training_states_and_labels(story_trackers, domain) + + if self._check_for_contradictions: + ( + self.lookup[RULE_ONLY_SLOTS], + self.lookup[RULE_ONLY_LOOPS], + ) = self._find_rule_only_slots_loops( + rule_trackers_as_states, story_trackers_as_states + ) + + # use all trackers to find negative rules in unhappy paths + trackers_as_states = rule_trackers_as_states + story_trackers_as_states + trackers_as_actions = rule_trackers_as_actions + story_trackers_as_actions + + # negative rules are not anti-rules, they are auxiliary to actual rules + self.lookup[ + RULES_FOR_LOOP_UNHAPPY_PATH + ] = self._create_loop_unhappy_lookup_from_states( + trackers_as_states, trackers_as_actions + ) + + def train( + self, + training_trackers: List[TrackerWithCachedStates], + domain: Domain, + **kwargs: Any, + ) -> Resource: + """Trains the policy on given training trackers. + + Args: + training_trackers: The list of the trackers. + domain: The domain. + + Returns: + The resource which can be used to load the trained policy. + """ + self.raise_if_incompatible_with_domain(self.config, domain) + + # only consider original trackers (no augmented ones) + training_trackers = [ + t for t in training_trackers if not getattr(t, "is_augmented", False) + ] + # trackers from rule-based training data + rule_trackers = [t for t in training_trackers if t.is_rule_tracker] + if self.config["restrict_rules"]: + self._check_rule_restriction(rule_trackers) + if self._check_for_contradictions: + self._check_for_incomplete_rules(rule_trackers, domain) + + # trackers from ML-based training data + story_trackers = [t for t in training_trackers if not t.is_rule_tracker] + + self._create_lookup_from_trackers(rule_trackers, story_trackers, domain) + + # make this configurable because checking might take a lot of time + if self._check_for_contradictions: + # using trackers here might not be the most efficient way, however + # it allows us to directly test `predict_action_probabilities` method + self.lookup[RULES_NOT_IN_STORIES] = self._analyze_rules( + rule_trackers, training_trackers, domain + ) + + logger.debug(f"Memorized '{len(self.lookup[RULES])}' unique rules.") + + self.persist() + + return self._resource + + @staticmethod + def _does_rule_match_state(rule_state: State, conversation_state: State) -> bool: + for state_type, rule_sub_state in rule_state.items(): + conversation_sub_state = conversation_state.get(state_type, {}) + for key, value_from_rules in rule_sub_state.items(): + if isinstance(value_from_rules, list): + # json dumps and loads tuples as lists, + # so we need to convert them back + value_from_rules = tuple(value_from_rules) + value_from_conversation = conversation_sub_state.get(key) + if ( + # value should be set, therefore + # check whether it is the same as in the state + value_from_rules + and value_from_rules != SHOULD_NOT_BE_SET + and value_from_conversation != value_from_rules + ) or ( + # value shouldn't be set, therefore + # it should be None or non existent in the state + value_from_rules == SHOULD_NOT_BE_SET + and value_from_conversation + # during training `SHOULD_NOT_BE_SET` is provided. Hence, we also + # have to check for the value of the slot state + and value_from_conversation != SHOULD_NOT_BE_SET + ): + return False + + return True + + @staticmethod + # This function is called a lot (e.g. for checking contradictions) so we cache + # its results. + @functools.lru_cache(maxsize=1000) + def _rule_key_to_state(rule_key: Text) -> List[State]: + return json.loads(rule_key) + + def _is_rule_applicable( + self, rule_key: Text, turn_index: int, conversation_state: State + ) -> bool: + """Checks if rule is satisfied with current state at turn. + + Args: + rule_key: the textual representation of learned rule + turn_index: index of a current dialogue turn + conversation_state: the state that corresponds to turn_index + + Returns: + a boolean that says whether the rule is applicable to current state + """ + # turn_index goes back in time + reversed_rule_states = list(reversed(self._rule_key_to_state(rule_key))) + + # the rule must be applicable because we got (without any applicability issues) + # further in the conversation history than the rule's length + if turn_index >= len(reversed_rule_states): + return True + + # a state has previous action if and only if it is not a conversation start + # state + current_previous_action = conversation_state.get(PREVIOUS_ACTION) + rule_previous_action = reversed_rule_states[turn_index].get(PREVIOUS_ACTION) + + # current conversation state and rule state are conversation starters. + # any slots with initial_value set will necessarily be in both states and don't + # need to be checked. + if not rule_previous_action and not current_previous_action: + return True + + # current rule state is a conversation starter (due to conversation_start: true) + # but current conversation state is not. + # or + # current conversation state is a starter + # but current rule state is not. + if not rule_previous_action or not current_previous_action: + return False + + # check: current rule state features are present in current conversation state + return self._does_rule_match_state( + reversed_rule_states[turn_index], conversation_state + ) + + def _get_possible_keys( + self, lookup: Dict[Text, Text], states: List[State] + ) -> Set[Text]: + possible_keys = set(lookup.keys()) + for i, state in enumerate(reversed(states)): + # find rule keys that correspond to current state + possible_keys = set( + filter( + lambda _key: self._is_rule_applicable(_key, i, state), possible_keys + ) + ) + return possible_keys + + @staticmethod + def _find_action_from_default_actions( + tracker: DialogueStateTracker, + ) -> Tuple[Optional[Text], Optional[Text]]: + if ( + not tracker.latest_action_name == ACTION_LISTEN_NAME + or not tracker.latest_message + ): + return None, None + + intent_name = tracker.latest_message.intent.get(INTENT_NAME_KEY) + if intent_name is None: + return None, None + + default_action_name = DEFAULT_ACTION_MAPPINGS.get(intent_name) + if default_action_name is None: + return None, None + + logger.debug(f"Predicted default action '{default_action_name}'.") + return ( + default_action_name, + # create prediction source that corresponds to one of + # default prediction sources in `_default_sources()` + DEFAULT_RULES + intent_name, + ) + + @staticmethod + def _find_action_from_loop_happy_path( + tracker: DialogueStateTracker, + ) -> Tuple[Optional[Text], Optional[Text]]: + + active_loop_name = tracker.active_loop_name + if active_loop_name is None: + return None, None + + active_loop_rejected = tracker.is_active_loop_rejected + should_predict_loop = ( + not active_loop_rejected + and tracker.latest_action + and tracker.latest_action.get(ACTION_NAME) != active_loop_name + ) + should_predict_listen = ( + not active_loop_rejected and tracker.latest_action_name == active_loop_name + ) + + if should_predict_loop: + logger.debug(f"Predicted loop '{active_loop_name}'.") + return active_loop_name, LOOP_RULES + active_loop_name + + # predict `action_listen` if loop action was run successfully + if should_predict_listen: + logger.debug( + f"Predicted '{ACTION_LISTEN_NAME}' after loop '{active_loop_name}'." + ) + return ( + ACTION_LISTEN_NAME, + ( + f"{LOOP_RULES}{active_loop_name}" + f"{LOOP_RULES_SEPARATOR}{ACTION_LISTEN_NAME}" + ), + ) + + return None, None + + def _find_action_from_rules( + self, + tracker: DialogueStateTracker, + domain: Domain, + use_text_for_last_user_input: bool, + ) -> Tuple[Optional[Text], Optional[Text], bool]: + """Predicts the next action based on the memoized rules. + + Args: + tracker: The current conversation tracker. + domain: The domain of the current model. + use_text_for_last_user_input: `True` if text of last user message + should be used for the prediction. `False` if intent should be used. + + Returns: + A tuple of the predicted action name or text (or `None` if no matching rule + was found), a description of the matching rule, and `True` if a loop action + was predicted after the loop has been in an unhappy path before. + """ + if ( + use_text_for_last_user_input + and not tracker.latest_action_name == ACTION_LISTEN_NAME + ): + # make text prediction only directly after user utterance + # because we've otherwise already decided whether to use + # the text or the intent + return None, None, False + + states = self._prediction_states( + tracker, + domain, + use_text_for_last_user_input, + rule_only_data=self._get_rule_only_data(), + ) + + current_states = self.format_tracker_states(states) + structlogger.debug( + "rule_policy.actions.find", current_states=copy.deepcopy(current_states) + ) + + # Tracks if we are returning after an unhappy loop path. If this becomes `True` + # the policy returns an event which notifies the loop action that it + # is returning after an unhappy path. For example, the `FormAction` uses this + # to skip the validation of slots for its first execution after an unhappy path. + returning_from_unhappy_path = False + + rule_keys = self._get_possible_keys(self.lookup[RULES], states) + predicted_action_name = None + best_rule_key = "" + if rule_keys: + # if there are several rules, + # it should mean that some rule is a subset of another rule + # therefore we pick a rule of maximum length + best_rule_key = max(rule_keys, key=len) + predicted_action_name = self.lookup[RULES].get(best_rule_key) + + active_loop_name = tracker.active_loop_name + if active_loop_name: + # find rules for unhappy path of the loop + loop_unhappy_keys = self._get_possible_keys( + self.lookup[RULES_FOR_LOOP_UNHAPPY_PATH], states + ) + # there could be several unhappy path conditions + unhappy_path_conditions = [ + self.lookup[RULES_FOR_LOOP_UNHAPPY_PATH].get(key) + for key in loop_unhappy_keys + ] + + # Check if a rule that predicted action_listen + # was applied inside the loop. + # Rules might not explicitly switch back to the loop. + # Hence, we have to take care of that. + predicted_listen_from_general_rule = ( + predicted_action_name == ACTION_LISTEN_NAME + and not get_active_loop_name(self._rule_key_to_state(best_rule_key)[-1]) + ) + if predicted_listen_from_general_rule: + if DO_NOT_PREDICT_LOOP_ACTION not in unhappy_path_conditions: + # negative rules don't contain a key that corresponds to + # the fact that active_loop shouldn't be predicted + logger.debug( + f"Predicted loop '{active_loop_name}' by overwriting " + f"'{ACTION_LISTEN_NAME}' predicted by general rule." + ) + return ( + active_loop_name, + best_rule_key, + returning_from_unhappy_path, + ) + + # do not predict anything + predicted_action_name = None + + if LOOP_WAS_INTERRUPTED in unhappy_path_conditions: + logger.debug( + "Returning from unhappy path. Loop will be notified that " + "it was interrupted." + ) + returning_from_unhappy_path = True + + if predicted_action_name is not None: + logger.debug( + f"There is a rule for the next action '{predicted_action_name}'." + ) + else: + logger.debug("There is no applicable rule.") + + # if we didn't predict anything from the rules, then the feature key created + # from states can be used as an indicator that this state will lead to fallback + return ( + predicted_action_name, + best_rule_key or self._create_feature_key(states), + returning_from_unhappy_path, + ) + + def predict_action_probabilities( + self, + tracker: DialogueStateTracker, + domain: Domain, + rule_only_data: Optional[Dict[Text, Any]] = None, + **kwargs: Any, + ) -> PolicyPrediction: + """Predicts the next action (see parent class for more information).""" + prediction, _ = self._predict(tracker, domain) + return prediction + + def _predict( + self, tracker: DialogueStateTracker, domain: Domain + ) -> Tuple[PolicyPrediction, Optional[Text]]: + ( + rules_action_name_from_text, + prediction_source_from_text, + returning_from_unhappy_path_from_text, + ) = self._find_action_from_rules( + tracker, domain, use_text_for_last_user_input=True + ) + + # Rasa Open Source default actions overrule anything. If users want to achieve + # the same, they need to write a rule or make sure that their loop rejects + # accordingly. + ( + default_action_name, + default_prediction_source, + ) = self._find_action_from_default_actions(tracker) + + # text has priority over intents including default, + # however loop happy path has priority over rules prediction + if default_action_name and not rules_action_name_from_text: + return ( + self._rule_prediction( + self._prediction_result(default_action_name, tracker, domain), + default_prediction_source, + ), + default_prediction_source, + ) + + # A loop has priority over any other rule except defaults. + # The rules or any other prediction will be applied only if a loop was rejected. + # If we are in a loop, and the loop didn't run previously or rejected, we can + # simply force predict the loop. + ( + loop_happy_path_action_name, + loop_happy_path_prediction_source, + ) = self._find_action_from_loop_happy_path(tracker) + if loop_happy_path_action_name: + # this prediction doesn't use user input + # and happy user input anyhow should be ignored during featurization + return ( + self._rule_prediction( + self._prediction_result( + loop_happy_path_action_name, tracker, domain + ), + loop_happy_path_prediction_source, + is_no_user_prediction=True, + ), + loop_happy_path_prediction_source, + ) + + # predict rules from text first + if rules_action_name_from_text: + return ( + self._rule_prediction( + self._prediction_result( + rules_action_name_from_text, tracker, domain + ), + prediction_source_from_text, + returning_from_unhappy_path=returning_from_unhappy_path_from_text, + is_end_to_end_prediction=True, + ), + prediction_source_from_text, + ) + + ( + rules_action_name_from_intent, + # we want to remember the source even if rules didn't predict any action + prediction_source_from_intent, + returning_from_unhappy_path_from_intent, + ) = self._find_action_from_rules( + tracker, domain, use_text_for_last_user_input=False + ) + if rules_action_name_from_intent: + probabilities = self._prediction_result( + rules_action_name_from_intent, tracker, domain + ) + else: + probabilities = self._default_predictions(domain) + + return ( + self._rule_prediction( + probabilities, + prediction_source_from_intent, + returning_from_unhappy_path=( + # returning_from_unhappy_path is a negative condition, + # so `or` should be applied + returning_from_unhappy_path_from_text + or returning_from_unhappy_path_from_intent + ), + is_end_to_end_prediction=False, + ), + prediction_source_from_intent, + ) + + def _rule_prediction( + self, + probabilities: List[float], + prediction_source: Text, + returning_from_unhappy_path: bool = False, + is_end_to_end_prediction: bool = False, + is_no_user_prediction: bool = False, + ) -> PolicyPrediction: + return PolicyPrediction( + probabilities, + self.__class__.__name__, + self.priority, + events=[LoopInterrupted(True)] if returning_from_unhappy_path else [], + is_end_to_end_prediction=is_end_to_end_prediction, + is_no_user_prediction=is_no_user_prediction, + hide_rule_turn=( + True + if prediction_source in self.lookup.get(RULES_NOT_IN_STORIES, []) + else False + ), + ) + + def _default_predictions(self, domain: Domain) -> List[float]: + result = super()._default_predictions(domain) + + if self._enable_fallback_prediction: + result[domain.index_for_action(self._fallback_action_name)] = self.config[ + "core_fallback_threshold" + ] + return result + + def persist(self) -> None: + """Persists trained `RulePolicy`.""" + super().persist() + with self._model_storage.write_to(self._resource) as directory: + rule_only_data = self._get_rule_only_data() + rasa.shared.utils.io.dump_obj_as_json_to_file( + directory / "rule_only_data.json", rule_only_data + ) + + def _metadata(self) -> Dict[Text, Any]: + return {"lookup": self.lookup} + + @classmethod + def _metadata_filename(cls) -> Text: + return "rule_policy.json" + + def _get_rule_only_data(self) -> Dict[Text, Any]: + """Gets the slots and loops that are used only in rule data. + + Returns: + Slots and loops that are used only in rule data. + """ + return { + key: self.lookup.get(key, []) for key in [RULE_ONLY_SLOTS, RULE_ONLY_LOOPS] + } diff --git a/rasa/core/policies/ted_policy.py b/rasa/core/policies/ted_policy.py new file mode 100644 index 0000000..af96af6 --- /dev/null +++ b/rasa/core/policies/ted_policy.py @@ -0,0 +1,2171 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from collections import defaultdict +import contextlib +from typing import Any, List, Optional, Text, Dict, Tuple, Union, Type + +import numpy as np +import tensorflow as tf + +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.exceptions import ModelNotFound +from rasa.nlu.constants import TOKENS_NAMES +from rasa.nlu.extractors.extractor import EntityTagSpec, EntityExtractorMixin +import rasa.core.actions.action +from rasa.core.featurizers.precomputation import MessageContainerForCoreFeaturization +from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer +from rasa.core.featurizers.tracker_featurizers import MaxHistoryTrackerFeaturizer +from rasa.shared.exceptions import RasaException +from rasa.shared.nlu.constants import ( + ACTION_TEXT, + ACTION_NAME, + INTENT, + TEXT, + ENTITIES, + FEATURE_TYPE_SENTENCE, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_TAGS, + EXTRACTOR, + SPLIT_ENTITIES_BY_COMMA, + SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE, +) +from rasa.core.policies.policy import PolicyPrediction, Policy, SupportedData +from rasa.core.constants import ( + DIALOGUE, + POLICY_MAX_HISTORY, + DEFAULT_MAX_HISTORY, + DEFAULT_POLICY_PRIORITY, + POLICY_PRIORITY, +) +from rasa.shared.constants import DIAGNOSTIC_DATA +from rasa.shared.core.constants import ACTIVE_LOOP, SLOTS, ACTION_LISTEN_NAME +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.generator import TrackerWithCachedStates +from rasa.shared.core.events import EntitiesAdded, Event +from rasa.shared.core.domain import Domain +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.features import ( + Features, + save_features, + load_features, +) +import rasa.shared.utils.io +import rasa.utils.io +from rasa.utils import train_utils +from rasa.utils.tensorflow.feature_array import ( + FeatureArray, + serialize_nested_feature_arrays, + deserialize_nested_feature_arrays, +) +from rasa.utils.tensorflow.models import RasaModel, TransformerRasaModel +from rasa.utils.tensorflow import rasa_layers +from rasa.utils.tensorflow.model_data import RasaModelData, FeatureSignature, Data +from rasa.utils.tensorflow.model_data_utils import convert_to_data_format +from rasa.utils.tensorflow.constants import ( + LABEL, + IDS, + TRANSFORMER_SIZE, + NUM_TRANSFORMER_LAYERS, + NUM_HEADS, + BATCH_SIZES, + BATCH_STRATEGY, + EPOCHS, + RANDOM_SEED, + LEARNING_RATE, + RANKING_LENGTH, + RENORMALIZE_CONFIDENCES, + LOSS_TYPE, + SIMILARITY_TYPE, + NUM_NEG, + EVAL_NUM_EXAMPLES, + EVAL_NUM_EPOCHS, + NEGATIVE_MARGIN_SCALE, + REGULARIZATION_CONSTANT, + SCALE_LOSS, + USE_MAX_NEG_SIM, + MAX_NEG_SIM, + MAX_POS_SIM, + EMBEDDING_DIMENSION, + DROP_RATE_DIALOGUE, + DROP_RATE_LABEL, + DROP_RATE, + DROP_RATE_ATTENTION, + CONNECTION_DENSITY, + KEY_RELATIVE_ATTENTION, + VALUE_RELATIVE_ATTENTION, + MAX_RELATIVE_POSITION, + CROSS_ENTROPY, + AUTO, + BALANCED, + TENSORBOARD_LOG_DIR, + TENSORBOARD_LOG_LEVEL, + CHECKPOINT_MODEL, + ENCODING_DIMENSION, + UNIDIRECTIONAL_ENCODER, + SEQUENCE, + SENTENCE, + SEQUENCE_LENGTH, + DENSE_DIMENSION, + CONCAT_DIMENSION, + SPARSE_INPUT_DROPOUT, + DENSE_INPUT_DROPOUT, + MASKED_LM, + MASK, + HIDDEN_LAYERS_SIZES, + FEATURIZERS, + ENTITY_RECOGNITION, + CONSTRAIN_SIMILARITIES, + MODEL_CONFIDENCE, + SOFTMAX, + BILOU_FLAG, + EPOCH_OVERRIDE, + USE_GPU, +) + +logger = logging.getLogger(__name__) + +E2E_CONFIDENCE_THRESHOLD = "e2e_confidence_threshold" +LABEL_KEY = LABEL +LABEL_SUB_KEY = IDS +LENGTH = "length" +INDICES = "indices" +SENTENCE_FEATURES_TO_ENCODE = [INTENT, TEXT, ACTION_NAME, ACTION_TEXT] +SEQUENCE_FEATURES_TO_ENCODE = [TEXT, ACTION_TEXT, f"{LABEL}_{ACTION_TEXT}"] +LABEL_FEATURES_TO_ENCODE = [ + f"{LABEL}_{ACTION_NAME}", + f"{LABEL}_{ACTION_TEXT}", + f"{LABEL}_{INTENT}", +] +STATE_LEVEL_FEATURES = [ENTITIES, SLOTS, ACTIVE_LOOP] +PREDICTION_FEATURES = STATE_LEVEL_FEATURES + SENTENCE_FEATURES_TO_ENCODE + [DIALOGUE] + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.POLICY_WITH_END_TO_END_SUPPORT, is_trainable=True +) +class TEDPolicy(Policy): + """Transformer Embedding Dialogue (TED) Policy. + + The model architecture is described in + detail in https://arxiv.org/abs/1910.00486. + In summary, the architecture comprises of the + following steps: + - concatenate user input (user intent and entities), previous system actions, + slots and active forms for each time step into an input vector to + pre-transformer embedding layer; + - feed it to transformer; + - apply a dense layer to the output of the transformer to get embeddings of a + dialogue for each time step; + - apply a dense layer to create embeddings for system actions for each time + step; + - calculate the similarity between the dialogue embedding and embedded system + actions. This step is based on the StarSpace + (https://arxiv.org/abs/1709.03856) idea. + """ + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns the default config (see parent class for full docstring).""" + # please make sure to update the docs when changing a default parameter + return { + # ## Architecture of the used neural network + # Hidden layer sizes for layers before the embedding layers for user message + # and labels. + # The number of hidden layers is equal to the length of the corresponding + # list. + HIDDEN_LAYERS_SIZES: { + TEXT: [], + ACTION_TEXT: [], + f"{LABEL}_{ACTION_TEXT}": [], + }, + # Dense dimension to use for sparse features. + DENSE_DIMENSION: { + TEXT: 128, + ACTION_TEXT: 128, + f"{LABEL}_{ACTION_TEXT}": 128, + INTENT: 20, + ACTION_NAME: 20, + f"{LABEL}_{ACTION_NAME}": 20, + ENTITIES: 20, + SLOTS: 20, + ACTIVE_LOOP: 20, + }, + # Default dimension to use for concatenating sequence and sentence features. + CONCAT_DIMENSION: { + TEXT: 128, + ACTION_TEXT: 128, + f"{LABEL}_{ACTION_TEXT}": 128, + }, + # Dimension size of embedding vectors before the dialogue transformer + # encoder. + ENCODING_DIMENSION: 50, + # Number of units in transformer encoders + TRANSFORMER_SIZE: { + TEXT: 128, + ACTION_TEXT: 128, + f"{LABEL}_{ACTION_TEXT}": 128, + DIALOGUE: 128, + }, + # Number of layers in transformer encoders + NUM_TRANSFORMER_LAYERS: { + TEXT: 1, + ACTION_TEXT: 1, + f"{LABEL}_{ACTION_TEXT}": 1, + DIALOGUE: 1, + }, + # Number of attention heads in transformer + NUM_HEADS: 4, + # If 'True' use key relative embeddings in attention + KEY_RELATIVE_ATTENTION: False, + # If 'True' use value relative embeddings in attention + VALUE_RELATIVE_ATTENTION: False, + # Max position for relative embeddings. Only in effect if key- or value + # relative + # attention are turned on + MAX_RELATIVE_POSITION: 5, + # Use a unidirectional or bidirectional encoder + # for `text`, `action_text`, and `label_action_text`. + UNIDIRECTIONAL_ENCODER: False, + # ## Training parameters + # Initial and final batch sizes: + # Batch size will be linearly increased for each epoch. + BATCH_SIZES: [64, 256], + # Strategy used whenc creating batches. + # Can be either 'sequence' or 'balanced'. + BATCH_STRATEGY: BALANCED, + # Number of epochs to train + EPOCHS: 1, + # Set random seed to any 'int' to get reproducible results + RANDOM_SEED: None, + # Initial learning rate for the optimizer + LEARNING_RATE: 0.001, + # ## Parameters for embeddings + # Dimension size of embedding vectors + EMBEDDING_DIMENSION: 20, + # The number of incorrect labels. The algorithm will minimize + # their similarity to the user input during training. + NUM_NEG: 20, + # Type of similarity measure to use, either 'auto' or 'cosine' or 'inner'. + SIMILARITY_TYPE: AUTO, + # The type of the loss function, either 'cross_entropy' or 'margin'. + LOSS_TYPE: CROSS_ENTROPY, + # Number of top actions for which confidences should be predicted. + # The number of Set to `0` if confidences for all actions should be + # predicted. The confidences for all other actions will be set to 0. + RANKING_LENGTH: 0, + # Determines wether the confidences of the chosen top actions should be + # renormalized so that they sum up to 1. By default, we do not renormalize + # and return the confidences for the top actions as is. + # Note that renormalization only makes sense if confidences are generated + # via `softmax`. + RENORMALIZE_CONFIDENCES: False, + # Indicates how similar the algorithm should try to make embedding vectors + # for correct labels. + # Should be 0.0 < ... < 1.0 for 'cosine' similarity type. + MAX_POS_SIM: 0.8, + # Maximum negative similarity for incorrect labels. + # Should be -1.0 < ... < 1.0 for 'cosine' similarity type. + MAX_NEG_SIM: -0.2, + # If 'True' the algorithm only minimizes maximum similarity over + # incorrect intent labels, used only if 'loss_type' is set to 'margin'. + USE_MAX_NEG_SIM: True, + # If 'True' scale loss inverse proportionally to the confidence + # of the correct prediction + SCALE_LOSS: True, + # ## Regularization parameters + # The scale of regularization + REGULARIZATION_CONSTANT: 0.001, + # The scale of how important is to minimize the maximum similarity + # between embeddings of different labels, + # used only if 'loss_type' is set to 'margin'. + NEGATIVE_MARGIN_SCALE: 0.8, + # Dropout rate for embedding layers of dialogue features. + DROP_RATE_DIALOGUE: 0.1, + # Dropout rate for embedding layers of utterance level features. + DROP_RATE: 0.0, + # Dropout rate for embedding layers of label, e.g. action, features. + DROP_RATE_LABEL: 0.0, + # Dropout rate for attention. + DROP_RATE_ATTENTION: 0.0, + # Fraction of trainable weights in internal layers. + CONNECTION_DENSITY: 0.2, + # If 'True' apply dropout to sparse input tensors + SPARSE_INPUT_DROPOUT: True, + # If 'True' apply dropout to dense input tensors + DENSE_INPUT_DROPOUT: True, + # If 'True' random tokens of the input message will be masked. Since there + # is no related loss term used inside TED, the masking effectively becomes + # just input dropout applied to the text of user utterances. + MASKED_LM: False, + # ## Evaluation parameters + # How often calculate validation accuracy. + # Small values may hurt performance. + EVAL_NUM_EPOCHS: 20, + # How many examples to use for hold out validation set + # Large values may hurt performance, e.g. model accuracy. + # Set to 0 for no validation. + EVAL_NUM_EXAMPLES: 0, + # If you want to use tensorboard to visualize training and validation + # metrics, set this option to a valid output directory. + TENSORBOARD_LOG_DIR: None, + # Define when training metrics for tensorboard should be logged. + # Either after every epoch or for every training step. + # Valid values: 'epoch' and 'batch' + TENSORBOARD_LOG_LEVEL: "epoch", + # Perform model checkpointing + CHECKPOINT_MODEL: False, + # Only pick e2e prediction if the policy is confident enough + E2E_CONFIDENCE_THRESHOLD: 0.5, + # Specify what features to use as sequence and sentence features. + # By default all features in the pipeline are used. + FEATURIZERS: [], + # If set to true, entities are predicted in user utterances. + ENTITY_RECOGNITION: True, + # if 'True' applies sigmoid on all similarity terms and adds + # it to the loss function to ensure that similarity values are + # approximately bounded. Used inside cross-entropy loss only. + CONSTRAIN_SIMILARITIES: False, + # Model confidence to be returned during inference. Currently, the only + # possible value is `softmax`. + MODEL_CONFIDENCE: SOFTMAX, + # 'BILOU_flag' determines whether to use BILOU tagging or not. + # If set to 'True' labelling is more rigorous, however more + # examples per entity are required. + # Rule of thumb: you should have more than 100 examples per entity. + BILOU_FLAG: True, + # Split entities by comma, this makes sense e.g. for a list of + # ingredients in a recipe, but it doesn't make sense for the parts of + # an address + SPLIT_ENTITIES_BY_COMMA: SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE, + # Max history of the policy, unbounded by default + POLICY_MAX_HISTORY: DEFAULT_MAX_HISTORY, + # Determines the importance of policies, higher values take precedence + POLICY_PRIORITY: DEFAULT_POLICY_PRIORITY, + USE_GPU: True, + } + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + model: Optional[RasaModel] = None, + featurizer: Optional[TrackerFeaturizer] = None, + fake_features: Optional[Dict[Text, List[Features]]] = None, + entity_tag_specs: Optional[List[EntityTagSpec]] = None, + ) -> None: + """Declares instance variables with default values.""" + super().__init__( + config, model_storage, resource, execution_context, featurizer=featurizer + ) + self.split_entities_config = rasa.utils.train_utils.init_split_entities( + config[SPLIT_ENTITIES_BY_COMMA], SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE + ) + self._load_params(config) + + self.model = model + + self._entity_tag_specs = entity_tag_specs + + self.fake_features = fake_features or defaultdict(list) + # TED is only e2e if only text is present in fake features, which represent + # all possible input features for current version of this trained ted + self.only_e2e = TEXT in self.fake_features and INTENT not in self.fake_features + + self._label_data: Optional[RasaModelData] = None + self.data_example: Optional[Dict[Text, Dict[Text, List[FeatureArray]]]] = None + + self.tmp_checkpoint_dir = None + if self.config[CHECKPOINT_MODEL]: + self.tmp_checkpoint_dir = Path(rasa.utils.io.create_temporary_directory()) + + @staticmethod + def model_class() -> Type[TED]: + """Gets the class of the model architecture to be used by the policy. + + Returns: + Required class. + """ + return TED + + @classmethod + def _metadata_filename(cls) -> Optional[Text]: + return "ted_policy" + + def _load_params(self, config: Dict[Text, Any]) -> None: + new_config = rasa.utils.train_utils.check_core_deprecated_options(config) + self.config = new_config + self._auto_update_configuration() + + def _auto_update_configuration(self) -> None: + """Takes care of deprecations and compatibility of parameters.""" + self.config = rasa.utils.train_utils.update_confidence_type(self.config) + rasa.utils.train_utils.validate_configuration_settings(self.config) + self.config = rasa.utils.train_utils.update_similarity_type(self.config) + self.config = rasa.utils.train_utils.update_evaluation_parameters(self.config) + + def _create_label_data( + self, + domain: Domain, + precomputations: Optional[MessageContainerForCoreFeaturization], + ) -> Tuple[RasaModelData, List[Dict[Text, List[Features]]]]: + # encode all label_ids with policies' featurizer + state_featurizer = self.featurizer.state_featurizer + encoded_all_labels = ( + state_featurizer.encode_all_labels(domain, precomputations) + if state_featurizer is not None + else [] + ) + + attribute_data, _ = convert_to_data_format( + encoded_all_labels, featurizers=self.config[FEATURIZERS] + ) + + label_data = self._assemble_label_data(attribute_data, domain) + + return label_data, encoded_all_labels + + def _assemble_label_data( + self, attribute_data: Data, domain: Domain + ) -> RasaModelData: + """Constructs data regarding labels to be fed to the model. + + The resultant model data can possibly contain one or both of the + keys - [`label_action_name`, `label_action_text`] but will definitely + contain the `label` key. + `label_action_*` will contain the sequence, sentence and mask features + for corresponding labels and `label` will contain the numerical label ids. + + Args: + attribute_data: Feature data for all labels. + domain: Domain of the assistant. + + Returns: + Features of labels ready to be fed to the model. + """ + label_data = RasaModelData() + label_data.add_data(attribute_data, key_prefix=f"{LABEL_KEY}_") + label_data.add_lengths( + f"{LABEL}_{ACTION_TEXT}", + SEQUENCE_LENGTH, + f"{LABEL}_{ACTION_TEXT}", + SEQUENCE, + ) + label_ids = np.arange(domain.num_actions) + label_data.add_features( + LABEL_KEY, + LABEL_SUB_KEY, + [ + FeatureArray( + np.expand_dims(label_ids, -1), + number_of_dimensions=2, + ) + ], + ) + return label_data + + @staticmethod + def _should_extract_entities( + entity_tags: List[List[Dict[Text, List[Features]]]] + ) -> bool: + for turns_tags in entity_tags: + for turn_tags in turns_tags: + # if turn_tags are empty or all entity tag indices are `0` + # it means that all the inputs only contain NO_ENTITY_TAG + if turn_tags and np.any(turn_tags[ENTITY_TAGS][0].features): + return True + return False + + def _create_data_for_entities( + self, entity_tags: Optional[List[List[Dict[Text, List[Features]]]]] + ) -> Optional[Data]: + if not self.config[ENTITY_RECOGNITION]: + return None + + # check that there are real entity tags + if entity_tags and self._should_extract_entities(entity_tags): + entity_tags_data, _ = convert_to_data_format(entity_tags) + return entity_tags_data + + # there are no "real" entity tags + logger.debug( + f"Entity recognition cannot be performed, " + f"set '{ENTITY_RECOGNITION}' config parameter to 'False'." + ) + self.config[ENTITY_RECOGNITION] = False + + return None + + def _create_model_data( + self, + tracker_state_features: List[List[Dict[Text, List[Features]]]], + label_ids: Optional[np.ndarray] = None, + entity_tags: Optional[List[List[Dict[Text, List[Features]]]]] = None, + encoded_all_labels: Optional[List[Dict[Text, List[Features]]]] = None, + ) -> RasaModelData: + """Combine all model related data into RasaModelData. + + Args: + tracker_state_features: a dictionary of attributes + (INTENT, TEXT, ACTION_NAME, ACTION_TEXT, ENTITIES, SLOTS, ACTIVE_LOOP) + to a list of features for all dialogue turns in all training trackers + label_ids: the label ids (e.g. action ids) for every dialogue turn in all + training trackers + entity_tags: a dictionary of entity type (ENTITY_TAGS) to a list of features + containing entity tag ids for text user inputs otherwise empty dict + for all dialogue turns in all training trackers + encoded_all_labels: a list of dictionaries containing attribute features + for label ids + + Returns: + RasaModelData + """ + model_data = RasaModelData(label_key=LABEL_KEY, label_sub_key=LABEL_SUB_KEY) + + if label_ids is not None and encoded_all_labels is not None: + label_ids = np.array( + [np.expand_dims(seq_label_ids, -1) for seq_label_ids in label_ids] + ) + model_data.add_features( + LABEL_KEY, + LABEL_SUB_KEY, + [FeatureArray(label_ids, number_of_dimensions=3)], + ) + + attribute_data, self.fake_features = convert_to_data_format( + tracker_state_features, featurizers=self.config[FEATURIZERS] + ) + + entity_tags_data = self._create_data_for_entities(entity_tags) + if entity_tags_data is not None: + model_data.add_data(entity_tags_data) + else: + # method is called during prediction + attribute_data, _ = convert_to_data_format( + tracker_state_features, + self.fake_features, + featurizers=self.config[FEATURIZERS], + ) + + model_data.add_data(attribute_data) + model_data.add_lengths(TEXT, SEQUENCE_LENGTH, TEXT, SEQUENCE) + model_data.add_lengths(ACTION_TEXT, SEQUENCE_LENGTH, ACTION_TEXT, SEQUENCE) + + # add the dialogue lengths + attribute_present = next(iter(list(attribute_data.keys()))) + dialogue_lengths = np.array( + [ + np.size(np.squeeze(f, -1)) + for f in model_data.data[attribute_present][MASK][0] + ] + ) + model_data.data[DIALOGUE][LENGTH] = [ + FeatureArray(dialogue_lengths, number_of_dimensions=1) + ] + + # make sure all keys are in the same order during training and prediction + model_data.sort() + + return model_data + + @staticmethod + def _get_trackers_for_training( + trackers: List[TrackerWithCachedStates], + ) -> List[TrackerWithCachedStates]: + """Filters out the list of trackers which should not be used for training. + + Args: + trackers: All trackers available for training. + + Returns: + Trackers which should be used for training. + """ + # By default, we train on all available trackers. + return trackers + + def _prepare_for_training( + self, + trackers: List[TrackerWithCachedStates], + domain: Domain, + precomputations: MessageContainerForCoreFeaturization, + **kwargs: Any, + ) -> Tuple[RasaModelData, np.ndarray]: + """Prepares data to be fed into the model. + + Args: + trackers: List of training trackers to be featurized. + domain: Domain of the assistant. + precomputations: Contains precomputed features and attributes. + **kwargs: Any other arguments. + + Returns: + Featurized data to be fed to the model and corresponding label ids. + """ + training_trackers = self._get_trackers_for_training(trackers) + # dealing with training data + tracker_state_features, label_ids, entity_tags = self._featurize_for_training( + training_trackers, + domain, + precomputations=precomputations, + bilou_tagging=self.config[BILOU_FLAG], + **kwargs, + ) + + if not tracker_state_features: + return RasaModelData(), label_ids + + self._label_data, encoded_all_labels = self._create_label_data( + domain, precomputations=precomputations + ) + + # extract actual training data to feed to model + model_data = self._create_model_data( + tracker_state_features, label_ids, entity_tags, encoded_all_labels + ) + + if self.config[ENTITY_RECOGNITION]: + self._entity_tag_specs = ( + self.featurizer.state_featurizer.entity_tag_specs + if self.featurizer.state_featurizer is not None + else [] + ) + + # keep one example for persisting and loading + self.data_example = model_data.first_data_example() + + return model_data, label_ids + + def run_training( + self, model_data: RasaModelData, label_ids: Optional[np.ndarray] = None + ) -> None: + """Feeds the featurized training data to the model. + + Args: + model_data: Featurized training data. + label_ids: Label ids corresponding to the data points in `model_data`. + These may or may not be used by the function depending + on how the policy is trained. + """ + if not self.finetune_mode: + # This means the model wasn't loaded from a + # previously trained model and hence needs + # to be instantiated. + self.model = self.model_class()( + model_data.get_signature(), + self.config, + isinstance(self.featurizer, MaxHistoryTrackerFeaturizer), + self._label_data, + self._entity_tag_specs, + ) + self.model.compile( + optimizer=tf.keras.optimizers.Adam(self.config[LEARNING_RATE]) + ) + ( + data_generator, + validation_data_generator, + ) = rasa.utils.train_utils.create_data_generators( + model_data, + self.config[BATCH_SIZES], + self.config[EPOCHS], + self.config[BATCH_STRATEGY], + self.config[EVAL_NUM_EXAMPLES], + self.config[RANDOM_SEED], + ) + callbacks = rasa.utils.train_utils.create_common_callbacks( + self.config[EPOCHS], + self.config[TENSORBOARD_LOG_DIR], + self.config[TENSORBOARD_LOG_LEVEL], + self.tmp_checkpoint_dir, + ) + + if self.model is None: + raise ModelNotFound("No model was detected prior to training.") + + self.model.fit( + data_generator, + epochs=self.config[EPOCHS], + validation_data=validation_data_generator, + validation_freq=self.config[EVAL_NUM_EPOCHS], + callbacks=callbacks, + verbose=False, + shuffle=False, # we use custom shuffle inside data generator + ) + + def train( + self, + training_trackers: List[TrackerWithCachedStates], + domain: Domain, + precomputations: Optional[MessageContainerForCoreFeaturization] = None, + **kwargs: Any, + ) -> Resource: + """Trains the policy (see parent class for full docstring).""" + if not training_trackers: + rasa.shared.utils.io.raise_warning( + f"Skipping training of `{self.__class__.__name__}` " + f"as no data was provided. You can exclude this " + f"policy in the configuration " + f"file to avoid this warning.", + category=UserWarning, + ) + return self._resource + + training_trackers = SupportedData.trackers_for_supported_data( + self.supported_data(), training_trackers + ) + + model_data, label_ids = self._prepare_for_training( + training_trackers, domain, precomputations + ) + + if model_data.is_empty(): + rasa.shared.utils.io.raise_warning( + f"Skipping training of `{self.__class__.__name__}` " + f"as no data was provided. You can exclude this " + f"policy in the configuration " + f"file to avoid this warning.", + category=UserWarning, + ) + return self._resource + + with ( + contextlib.nullcontext() if self.config["use_gpu"] else tf.device("/cpu:0") + ): + self.run_training(model_data, label_ids) + + self.persist() + + return self._resource + + def _featurize_tracker( + self, + tracker: DialogueStateTracker, + domain: Domain, + precomputations: Optional[MessageContainerForCoreFeaturization], + rule_only_data: Optional[Dict[Text, Any]], + ) -> List[List[Dict[Text, List[Features]]]]: + # construct two examples in the batch to be fed to the model - + # one by featurizing last user text + # and second - an optional one (see conditions below), + # the first example in the constructed batch either does not contain user input + # or uses intent or text based on whether TED is e2e only. + tracker_state_features = self._featurize_for_prediction( + tracker, + domain, + precomputations=precomputations, + use_text_for_last_user_input=self.only_e2e, + rule_only_data=rule_only_data, + ) + # the second - text, but only after user utterance and if not only e2e + if ( + tracker.latest_action_name == ACTION_LISTEN_NAME + and TEXT in self.fake_features + and not self.only_e2e + ): + tracker_state_features += self._featurize_for_prediction( + tracker, + domain, + precomputations=precomputations, + use_text_for_last_user_input=True, + rule_only_data=rule_only_data, + ) + return tracker_state_features + + def _pick_confidence( + self, confidences: np.ndarray, similarities: np.ndarray, domain: Domain + ) -> Tuple[np.ndarray, bool]: + # the confidences and similarities have shape (batch-size x number of actions) + # batch-size can only be 1 or 2; + # in the case batch-size==2, the first example contain user intent as features, + # the second - user text as features + if confidences.shape[0] > 2: + raise ValueError( + "We cannot pick prediction from batches of size more than 2." + ) + # we use heuristic to pick correct prediction + if confidences.shape[0] == 2: + # we use similarities to pick appropriate input, + # since it seems to be more accurate measure, + # policy is trained to maximize the similarity not the confidence + non_e2e_action_name = domain.action_names_or_texts[ + np.argmax(confidences[0]) + ] + logger.debug(f"User intent lead to '{non_e2e_action_name}'.") + e2e_action_name = domain.action_names_or_texts[np.argmax(confidences[1])] + logger.debug(f"User text lead to '{e2e_action_name}'.") + if ( + np.max(confidences[1]) > self.config[E2E_CONFIDENCE_THRESHOLD] + # TODO maybe compare confidences is better + and np.max(similarities[1]) > np.max(similarities[0]) + ): + logger.debug(f"TED predicted '{e2e_action_name}' based on user text.") + return confidences[1], True + + logger.debug(f"TED predicted '{non_e2e_action_name}' based on user intent.") + return confidences[0], False + + # by default the first example in a batch is the one to use for prediction + predicted_action_name = domain.action_names_or_texts[np.argmax(confidences[0])] + basis_for_prediction = "text" if self.only_e2e else "intent" + logger.debug( + f"TED predicted '{predicted_action_name}' " + f"based on user {basis_for_prediction}." + ) + return confidences[0], self.only_e2e + + def predict_action_probabilities( + self, + tracker: DialogueStateTracker, + domain: Domain, + rule_only_data: Optional[Dict[Text, Any]] = None, + precomputations: Optional[MessageContainerForCoreFeaturization] = None, + **kwargs: Any, + ) -> PolicyPrediction: + """Predicts the next action (see parent class for full docstring).""" + if self.model is None: + return self._prediction(self._default_predictions(domain)) + + # create model data from tracker + tracker_state_features = self._featurize_tracker( + tracker, domain, precomputations, rule_only_data=rule_only_data + ) + model_data = self._create_model_data(tracker_state_features) + outputs = self.model.run_inference(model_data) + + if isinstance(outputs["similarities"], np.ndarray): + # take the last prediction in the sequence + similarities = outputs["similarities"][:, -1, :] + else: + raise TypeError( + "model output for `similarities` " "should be a numpy array" + ) + if isinstance(outputs["scores"], np.ndarray): + confidences = outputs["scores"][:, -1, :] + else: + raise TypeError("model output for `scores` should be a numpy array") + # take correct prediction from batch + confidence, is_e2e_prediction = self._pick_confidence( + confidences, similarities, domain + ) + + # rank and mask the confidence (if we need to) + ranking_length = self.config[RANKING_LENGTH] + if 0 < ranking_length < len(confidence): + renormalize = ( + self.config[RENORMALIZE_CONFIDENCES] + and self.config[MODEL_CONFIDENCE] == SOFTMAX + ) + _, confidence = train_utils.rank_and_mask( + confidence, ranking_length=ranking_length, renormalize=renormalize + ) + + optional_events = self._create_optional_event_for_entities( + outputs, is_e2e_prediction, precomputations, tracker + ) + + return self._prediction( + confidence.tolist(), + is_end_to_end_prediction=is_e2e_prediction, + optional_events=optional_events, + diagnostic_data=outputs.get(DIAGNOSTIC_DATA), + ) + + def _create_optional_event_for_entities( + self, + prediction_output: Dict[Text, tf.Tensor], + is_e2e_prediction: bool, + precomputations: Optional[MessageContainerForCoreFeaturization], + tracker: DialogueStateTracker, + ) -> Optional[List[Event]]: + if tracker.latest_action_name != ACTION_LISTEN_NAME or not is_e2e_prediction: + # entities belong only to the last user message + # and only if user text was used for prediction, + # a user message always comes after action listen + return None + + if not self.config[ENTITY_RECOGNITION]: + # entity recognition is not turned on, no entities can be predicted + return None + + # The batch dimension of entity prediction is not the same as batch size, + # rather it is the number of last (if max history featurizer else all) + # text inputs in the batch + # therefore, in order to pick entities from the latest user message + # we need to pick entities from the last batch dimension of entity prediction + predicted_tags, confidence_values = rasa.utils.train_utils.entity_label_to_tags( + prediction_output, + self._entity_tag_specs, + self.config[BILOU_FLAG], + prediction_index=-1, + ) + + if ENTITY_ATTRIBUTE_TYPE not in predicted_tags: + # no entities detected + return None + + # entities belong to the last message of the tracker + # convert the predicted tags to actual entities + text = tracker.latest_message.text if tracker.latest_message is not None else "" + if precomputations is not None: + parsed_message = precomputations.lookup_message(user_text=text) + else: + parsed_message = Message(data={TEXT: text}) + tokens = parsed_message.get(TOKENS_NAMES[TEXT]) + entities = EntityExtractorMixin.convert_predictions_into_entities( + text, + tokens, + predicted_tags, + self.split_entities_config, + confidences=confidence_values, + ) + + # add the extractor name + for entity in entities: + entity[EXTRACTOR] = "TEDPolicy" + + return [EntitiesAdded(entities)] + + def persist(self) -> None: + """Persists the policy to a storage.""" + if self.model is None: + logger.debug( + "Method `persist(...)` was called without a trained model present. " + "Nothing to persist then!" + ) + return + + with self._model_storage.write_to(self._resource) as model_path: + model_filename = self._metadata_filename() + tf_model_file = model_path / f"{model_filename}.tf_model" + + rasa.shared.utils.io.create_directory_for_file(tf_model_file) + + self.featurizer.persist(model_path) + + if self.config[CHECKPOINT_MODEL] and self.tmp_checkpoint_dir: + self.model.load_weights(self.tmp_checkpoint_dir / "checkpoint.tf_model") + # Save an empty file to flag that this model has been + # produced using checkpointing + checkpoint_marker = model_path / f"{model_filename}.from_checkpoint.pkl" + checkpoint_marker.touch() + + self.model.save(str(tf_model_file)) + + self.persist_model_utilities(model_path) + + def persist_model_utilities(self, model_path: Path) -> None: + """Persists model's utility attributes like model weights, etc. + + Args: + model_path: Path where model is to be persisted + """ + model_filename = self._metadata_filename() + rasa.shared.utils.io.dump_obj_as_json_to_file( + model_path / f"{model_filename}.priority.json", self.priority + ) + rasa.shared.utils.io.dump_obj_as_json_to_file( + model_path / f"{model_filename}.meta.json", self.config + ) + # save data example + serialize_nested_feature_arrays( + self.data_example, + str(model_path / f"{model_filename}.data_example.st"), + str(model_path / f"{model_filename}.data_example_metadata.json"), + ) + # save label data + serialize_nested_feature_arrays( + dict(self._label_data.data) if self._label_data is not None else {}, + str(model_path / f"{model_filename}.label_data.st"), + str(model_path / f"{model_filename}.label_data_metadata.json"), + ) + # save fake features + metadata = save_features( + self.fake_features, str(model_path / f"{model_filename}.fake_features.st") + ) + rasa.shared.utils.io.dump_obj_as_json_to_file( + model_path / f"{model_filename}.fake_features_metadata.json", metadata + ) + + entity_tag_specs = ( + [tag_spec._asdict() for tag_spec in self._entity_tag_specs] + if self._entity_tag_specs + else [] + ) + rasa.shared.utils.io.dump_obj_as_json_to_file( + model_path / f"{model_filename}.entity_tag_specs.json", entity_tag_specs + ) + + @classmethod + def _load_model_utilities(cls, model_path: Path) -> Dict[Text, Any]: + """Loads model's utility attributes. + + Args: + model_path: Path where model is to be persisted. + """ + tf_model_file = model_path / f"{cls._metadata_filename()}.tf_model" + + # load data example + loaded_data = deserialize_nested_feature_arrays( + str(model_path / f"{cls._metadata_filename()}.data_example.st"), + str(model_path / f"{cls._metadata_filename()}.data_example_metadata.json"), + ) + # load label data + loaded_label_data = deserialize_nested_feature_arrays( + str(model_path / f"{cls._metadata_filename()}.label_data.st"), + str(model_path / f"{cls._metadata_filename()}.label_data_metadata.json"), + ) + label_data = RasaModelData(data=loaded_label_data) + + # load fake features + metadata = rasa.shared.utils.io.read_json_file( + model_path / f"{cls._metadata_filename()}.fake_features_metadata.json" + ) + fake_features = load_features( + str(model_path / f"{cls._metadata_filename()}.fake_features.st"), metadata + ) + + priority = rasa.shared.utils.io.read_json_file( + model_path / f"{cls._metadata_filename()}.priority.json" + ) + entity_tag_specs = rasa.shared.utils.io.read_json_file( + model_path / f"{cls._metadata_filename()}.entity_tag_specs.json" + ) + entity_tag_specs = [ + EntityTagSpec( + tag_name=tag_spec["tag_name"], + ids_to_tags={ + int(key): value for key, value in tag_spec["ids_to_tags"].items() + }, + tags_to_ids={ + key: int(value) for key, value in tag_spec["tags_to_ids"].items() + }, + num_tags=tag_spec["num_tags"], + ) + for tag_spec in entity_tag_specs + ] + model_config = rasa.shared.utils.io.read_json_file( + model_path / f"{cls._metadata_filename()}.meta.json" + ) + + return { + "tf_model_file": tf_model_file, + "loaded_data": loaded_data, + "fake_features": fake_features, + "label_data": label_data, + "priority": priority, + "entity_tag_specs": entity_tag_specs, + "model_config": model_config, + } + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> TEDPolicy: + """Loads a policy from the storage (see parent class for full docstring).""" + try: + with model_storage.read_from(resource) as model_path: + return cls._load( + model_path, config, model_storage, resource, execution_context + ) + except ValueError: + logger.debug( + f"Failed to load {cls.__class__.__name__} from model storage. Resource " + f"'{resource.name}' doesn't exist." + ) + return cls(config, model_storage, resource, execution_context) + + @classmethod + def _load( + cls, + model_path: Path, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> TEDPolicy: + featurizer = TrackerFeaturizer.load(model_path) + + if not (model_path / f"{cls._metadata_filename()}.data_example.st").is_file(): + return cls( + config, + model_storage, + resource, + execution_context, + featurizer=featurizer, + ) + + model_utilities = cls._load_model_utilities(model_path) + + config = cls._update_loaded_params(config) + if execution_context.is_finetuning and EPOCH_OVERRIDE in config: + config[EPOCHS] = config.get(EPOCH_OVERRIDE) + + ( + model_data_example, + predict_data_example, + ) = cls._construct_model_initialization_data(model_utilities["loaded_data"]) + + model = None + + with (contextlib.nullcontext() if config["use_gpu"] else tf.device("/cpu:0")): + model = cls._load_tf_model( + model_utilities, + model_data_example, + predict_data_example, + featurizer, + execution_context.is_finetuning, + ) + + return cls._load_policy_with_model( + config, + model_storage, + resource, + execution_context, + featurizer=featurizer, + model_utilities=model_utilities, + model=model, + ) + + @classmethod + def _load_policy_with_model( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + featurizer: TrackerFeaturizer, + model: TED, + model_utilities: Dict[Text, Any], + ) -> TEDPolicy: + return cls( + config, + model_storage, + resource, + execution_context, + model=model, + featurizer=featurizer, + fake_features=model_utilities["fake_features"], + entity_tag_specs=model_utilities["entity_tag_specs"], + ) + + @classmethod + def _load_tf_model( + cls, + model_utilities: Dict[Text, Any], + model_data_example: RasaModelData, + predict_data_example: RasaModelData, + featurizer: TrackerFeaturizer, + should_finetune: bool, + ) -> TED: + model = cls.model_class().load( + str(model_utilities["tf_model_file"]), + model_data_example, + predict_data_example, + data_signature=model_data_example.get_signature(), + config=model_utilities["model_config"], + max_history_featurizer_is_used=isinstance( + featurizer, MaxHistoryTrackerFeaturizer + ), + label_data=model_utilities["label_data"], + entity_tag_specs=model_utilities["entity_tag_specs"], + finetune_mode=should_finetune, + ) + return model + + @classmethod + def _construct_model_initialization_data( + cls, loaded_data: Dict[Text, Dict[Text, List[FeatureArray]]] + ) -> Tuple[RasaModelData, RasaModelData]: + model_data_example = RasaModelData( + label_key=LABEL_KEY, label_sub_key=LABEL_SUB_KEY, data=loaded_data + ) + predict_data_example = RasaModelData( + label_key=LABEL_KEY, + label_sub_key=LABEL_SUB_KEY, + data={ + feature_name: features + for feature_name, features in model_data_example.items() + # we need to remove label features for prediction if they are present + if feature_name in PREDICTION_FEATURES + }, + ) + return model_data_example, predict_data_example + + @classmethod + def _update_loaded_params(cls, meta: Dict[Text, Any]) -> Dict[Text, Any]: + meta = rasa.utils.train_utils.update_confidence_type(meta) + meta = rasa.utils.train_utils.update_similarity_type(meta) + + return meta + + +class TED(TransformerRasaModel): + """TED model architecture from https://arxiv.org/abs/1910.00486.""" + + def __init__( + self, + data_signature: Dict[Text, Dict[Text, List[FeatureSignature]]], + config: Dict[Text, Any], + max_history_featurizer_is_used: bool, + label_data: RasaModelData, + entity_tag_specs: Optional[List[EntityTagSpec]], + ) -> None: + """Initializes the TED model. + + Args: + data_signature: the data signature of the input data + config: the model configuration + max_history_featurizer_is_used: if 'True' + only the last dialogue turn will be used + label_data: the label data + entity_tag_specs: the entity tag specifications + """ + super().__init__("TED", config, data_signature, label_data) + + self.max_history_featurizer_is_used = max_history_featurizer_is_used + + self.predict_data_signature = { + feature_name: features + for feature_name, features in data_signature.items() + if feature_name in PREDICTION_FEATURES + } + + self._entity_tag_specs = entity_tag_specs + + # metrics + self.action_loss = tf.keras.metrics.Mean(name="loss") + self.action_acc = tf.keras.metrics.Mean(name="acc") + self.entity_loss = tf.keras.metrics.Mean(name="e_loss") + self.entity_f1 = tf.keras.metrics.Mean(name="e_f1") + self.metrics_to_log += ["loss", "acc"] + if self.config[ENTITY_RECOGNITION]: + self.metrics_to_log += ["e_loss", "e_f1"] + + # needed for efficient prediction + self.all_labels_embed: Optional[tf.Tensor] = None + + self._prepare_layers() + + def _check_data(self) -> None: + if not any(key in [INTENT, TEXT] for key in self.data_signature.keys()): + raise RasaException( + f"No user features specified. " + f"Cannot train '{self.__class__.__name__}' model." + ) + + if not any( + key in [ACTION_NAME, ACTION_TEXT] for key in self.data_signature.keys() + ): + raise ValueError( + f"No action features specified. " + f"Cannot train '{self.__class__.__name__}' model." + ) + if LABEL not in self.data_signature: + raise ValueError( + f"No label features specified. " + f"Cannot train '{self.__class__.__name__}' model." + ) + + # ---CREATING LAYERS HELPERS--- + + def _prepare_layers(self) -> None: + for name in self.data_signature.keys(): + self._prepare_input_layers( + name, self.data_signature[name], is_label_attribute=False + ) + self._prepare_encoding_layers(name) + + for name in self.label_signature.keys(): + self._prepare_input_layers( + name, self.label_signature[name], is_label_attribute=True + ) + self._prepare_encoding_layers(name) + + self._tf_layers[ + f"transformer.{DIALOGUE}" + ] = rasa_layers.prepare_transformer_layer( + attribute_name=DIALOGUE, + config=self.config, + num_layers=self.config[NUM_TRANSFORMER_LAYERS][DIALOGUE], + units=self.config[TRANSFORMER_SIZE][DIALOGUE], + drop_rate=self.config[DROP_RATE_DIALOGUE], + # use bidirectional transformer, because + # we will invert dialogue sequence so that the last turn is located + # at the first position and would always have + # exactly the same positional encoding + unidirectional=not self.max_history_featurizer_is_used, + ) + + self._prepare_label_classification_layers(DIALOGUE) + + if self.config[ENTITY_RECOGNITION]: + self._prepare_entity_recognition_layers() + + def _prepare_input_layers( + self, + attribute_name: Text, + attribute_signature: Dict[Text, List[FeatureSignature]], + is_label_attribute: bool = False, + ) -> None: + """Prepares feature processing layers for sentence/sequence-level features. + + Distinguishes between label features and other features, not applying input + dropout to the label ones. + """ + # Disable input dropout in the config to be used if this is a label attribute. + if is_label_attribute: + config_to_use = self.config.copy() + config_to_use.update( + {SPARSE_INPUT_DROPOUT: False, DENSE_INPUT_DROPOUT: False} + ) + else: + config_to_use = self.config + # Attributes with sequence-level features also have sentence-level features, + # all these need to be combined and further processed. + if attribute_name in SEQUENCE_FEATURES_TO_ENCODE: + self._tf_layers[ + f"sequence_layer.{attribute_name}" + ] = rasa_layers.RasaSequenceLayer( + attribute_name, attribute_signature, config_to_use + ) + # Attributes without sequence-level features require some actual feature + # processing only if they have sentence-level features. Attributes with no + # sequence- and sentence-level features (dialogue, entity_tags, label) are + # skipped here. + elif SENTENCE in attribute_signature: + self._tf_layers[ + f"sparse_dense_concat_layer.{attribute_name}" + ] = rasa_layers.ConcatenateSparseDenseFeatures( + attribute=attribute_name, + feature_type=SENTENCE, + feature_type_signature=attribute_signature[SENTENCE], + config=config_to_use, + ) + + def _prepare_encoding_layers(self, name: Text) -> None: + """Create Ffnn encoding layer used just before combining all dialogue features. + + Args: + name: attribute name + """ + # create encoding layers only for the features which should be encoded; + if name not in SENTENCE_FEATURES_TO_ENCODE + LABEL_FEATURES_TO_ENCODE: + return + # check that there are SENTENCE features for the attribute name in data + if ( + name in SENTENCE_FEATURES_TO_ENCODE + and FEATURE_TYPE_SENTENCE not in self.data_signature[name] + ): + return + # same for label_data + if ( + name in LABEL_FEATURES_TO_ENCODE + and FEATURE_TYPE_SENTENCE not in self.label_signature[name] + ): + return + + self._prepare_ffnn_layer( + f"{name}", + [self.config[ENCODING_DIMENSION]], + self.config[DROP_RATE_DIALOGUE], + prefix="encoding_layer", + ) + + # ---GRAPH BUILDING HELPERS--- + + @staticmethod + def _compute_dialogue_indices( + tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]] + ) -> None: + dialogue_lengths = tf.cast(tf_batch_data[DIALOGUE][LENGTH][0], dtype=tf.int32) + # wrap in a list, because that's the structure of tf_batch_data + tf_batch_data[DIALOGUE][INDICES] = [ + ( + tf.map_fn( + tf.range, + dialogue_lengths, + fn_output_signature=tf.RaggedTensorSpec( + shape=[None], dtype=tf.int32 + ), + ) + ).values + ] + + def _create_all_labels_embed(self) -> Tuple[tf.Tensor, tf.Tensor]: + all_label_ids = self.tf_label_data[LABEL_KEY][LABEL_SUB_KEY][0] + # labels cannot have all features "fake" + all_labels_encoded = {} + for key in self.tf_label_data.keys(): + if key != LABEL_KEY: + attribute_features, _, _ = self._encode_real_features_per_attribute( + self.tf_label_data, key + ) + all_labels_encoded[key] = attribute_features + + x = self._collect_label_attribute_encodings(all_labels_encoded) + + # additional sequence axis is artifact of our RasaModelData creation + # TODO check whether this should be solved in data creation + x = tf.squeeze(x, axis=1) + + all_labels_embed = self._tf_layers[f"embed.{LABEL}"](x) + + return all_label_ids, all_labels_embed + + @staticmethod + def _collect_label_attribute_encodings( + all_labels_encoded: Dict[Text, tf.Tensor] + ) -> tf.Tensor: + # Initialize with at least one attribute first + # so that the subsequent TF ops are simplified. + all_attributes_present = list(all_labels_encoded.keys()) + x = all_labels_encoded.pop(all_attributes_present[0]) + + # Add remaining attributes + for attribute in all_labels_encoded: + x += all_labels_encoded.get(attribute) + return x + + def _embed_dialogue( + self, + dialogue_in: tf.Tensor, + tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], + ) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor, Optional[tf.Tensor]]: + """Creates dialogue level embedding and mask. + + Args: + dialogue_in: The encoded dialogue. + tf_batch_data: Batch in model data format. + + Returns: + The dialogue embedding, the mask, and (for diagnostic purposes) + also the attention weights. + """ + dialogue_lengths = tf.cast(tf_batch_data[DIALOGUE][LENGTH][0], tf.int32) + mask = rasa_layers.compute_mask(dialogue_lengths) + + if self.max_history_featurizer_is_used: + # invert dialogue sequence so that the last turn would always have + # exactly the same positional encoding + dialogue_in = tf.reverse_sequence(dialogue_in, dialogue_lengths, seq_axis=1) + + dialogue_transformed, attention_weights = self._tf_layers[ + f"transformer.{DIALOGUE}" + ](dialogue_in, 1 - mask, self._training) + dialogue_transformed = tf.nn.gelu(dialogue_transformed) + + if self.max_history_featurizer_is_used: + # pick last vector if max history featurizer is used, since we inverted + # dialogue sequence, the last vector is actually the first one + dialogue_transformed = dialogue_transformed[:, :1, :] + mask = tf.expand_dims(self._last_token(mask, dialogue_lengths), 1) + elif not self._training: + # during prediction we don't care about previous dialogue turns, + # so to save computation time, use only the last one + dialogue_transformed = tf.expand_dims( + self._last_token(dialogue_transformed, dialogue_lengths), 1 + ) + mask = tf.expand_dims(self._last_token(mask, dialogue_lengths), 1) + + dialogue_embed = self._tf_layers[f"embed.{DIALOGUE}"](dialogue_transformed) + + return dialogue_embed, mask, dialogue_transformed, attention_weights + + def _encode_features_per_attribute( + self, tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], attribute: Text + ) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor]: + # The input is a representation of 4d tensor of + # shape (batch-size x dialogue-len x sequence-len x units) in 3d of shape + # (sum of dialogue history length for all tensors in the batch x + # max sequence length x number of features). + + # However, some dialogue turns contain non existent state features, + # e.g. `intent` and `text` features are mutually exclusive, + # as well as `action_name` and `action_text` are mutually exclusive, + # or some dialogue turns don't contain any `slots`. + # In order to create 4d full tensors, we created "fake" zero features for + # these non existent state features. And filtered them during batch generation. + # Therefore the first dimensions for different attributes are different. + # It could happen that some batches don't contain "real" features at all, + # e.g. large number of stories don't contain any `slots`. + # Therefore actual input tensors will be empty. + # Since we need actual numbers to create dialogue turn features, we create + # zero tensors in `_encode_fake_features_per_attribute` for these attributes. + return tf.cond( + tf.shape(tf_batch_data[attribute][SENTENCE][0])[0] > 0, + lambda: self._encode_real_features_per_attribute(tf_batch_data, attribute), + lambda: self._encode_fake_features_per_attribute(tf_batch_data, attribute), + ) + + def _encode_fake_features_per_attribute( + self, tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], attribute: Text + ) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor]: + """Returns dummy outputs for fake features of a given attribute. + + Needs to match the outputs of `_encode_real_features_per_attribute` in shape + but these outputs will be filled with zeros. + + Args: + tf_batch_data: Maps each attribute to its features and masks. + attribute: The attribute whose fake features will be "processed", e.g. + `ACTION_NAME`, `INTENT`. + + Returns: + attribute_features: A tensor of shape `(batch_size, dialogue_length, units)` + filled with zeros. + text_output: Only for `TEXT` attribute (otherwise an empty tensor): A tensor + of shape `(combined batch_size & dialogue_length, max seq length, + units)` filled with zeros. + text_sequence_lengths: Only for `TEXT` attribute, otherwise an empty tensor: + Of hape `(combined batch_size & dialogue_length, 1)`, filled with zeros. + """ + # we need to create real zero tensors with appropriate batch and dialogue dim + # because they are passed to dialogue transformer + attribute_mask = tf_batch_data[attribute][MASK][0] + + # determine all dimensions so that fake features of the correct shape can be + # created + batch_dim = tf.shape(attribute_mask)[0] + dialogue_dim = tf.shape(attribute_mask)[1] + if attribute in set(SENTENCE_FEATURES_TO_ENCODE + LABEL_FEATURES_TO_ENCODE): + units = self.config[ENCODING_DIMENSION] + else: + # state-level attributes don't use an encoding layer, hence their size is + # just the output size of the corresponding sparse+dense feature combining + # layer + units = self._tf_layers[ + f"sparse_dense_concat_layer.{attribute}" + ].output_units + + attribute_features = tf.zeros( + (batch_dim, dialogue_dim, units), dtype=tf.float32 + ) + + # Only for user text, the transformer output and sequence lengths also have to + # be created (here using fake features) to enable entity recognition training + # and prediction. + if attribute == TEXT: + # we just need to get the correct last dimension size from the prepared + # transformer + text_units = self._tf_layers[f"sequence_layer.{attribute}"].output_units + text_output = tf.zeros((0, 0, text_units), dtype=tf.float32) + text_sequence_lengths = tf.zeros((0,), dtype=tf.int32) + else: + # simulate None with empty tensor of zeros + text_output = tf.zeros((0,)) + text_sequence_lengths = tf.zeros((0,)) + + return attribute_features, text_output, text_sequence_lengths + + @staticmethod + def _create_last_dialogue_turns_mask( + tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], attribute: Text + ) -> tf.Tensor: + # Since max_history_featurizer_is_used is True, + # we need to find the locations of last dialogue turns in + # (combined batch dimension and dialogue length,) dimension, + # so that we can use `_sequence_lengths` as a boolean mask to pick + # which ones are "real" textual input in these last dialogue turns. + + # In order to do that we can use given `dialogue_lengths`. + # For example: + # If we have `dialogue_lengths = [2, 1, 3]`, than + # `dialogue_indices = [0, 1, 0, 0, 1, 2]` here we can spot that `0` + # always indicates the first dialogue turn, + # which means that previous dialogue turn is the last dialogue turn. + # Combining this with the fact that the last element in + # `dialogue_indices` is always the last dialogue turn, we can add + # a `0` to the end, getting + # `_dialogue_indices = [0, 1, 0, 0, 1, 2, 0]`. + # Then removing the first element + # `_last_dialogue_turn_inverse_indicator = [1, 0, 0, 1, 2, 0]` + # we see that `0` points to the last dialogue turn. + # We convert all positive numbers to `True` and take + # the inverse mask to get + # `last_dialogue_mask = [0, 1, 1, 0, 0, 1], + # which precisely corresponds to the fact that first dialogue is of + # length 2, the second 1 and the third 3. + last_dialogue_turn_mask = tf.math.logical_not( + tf.cast( + tf.concat( + [ + tf_batch_data[DIALOGUE][INDICES][0], + tf.zeros((1,), dtype=tf.int32), + ], + axis=0, + )[1:], + dtype=tf.bool, + ) + ) + # get only the indices of real inputs + return tf.boolean_mask( + last_dialogue_turn_mask, + tf.reshape(tf_batch_data[attribute][SEQUENCE_LENGTH][0], (-1,)), + ) + + def _encode_real_features_per_attribute( + self, tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], attribute: Text + ) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor]: + """Encodes features for a given attribute. + + Args: + tf_batch_data: Maps each attribute to its features and masks. + attribute: the attribute we will encode features for + (e.g., ACTION_NAME, INTENT) + + Returns: + attribute_features: A tensor of shape `(batch_size, dialogue_length, units)` + with all features for `attribute` processed and combined. If sequence- + level features are present, the sequence dimension is eliminated using + a transformer. + text_output: Only for `TEXT` attribute (otherwise an empty tensor): A tensor + of shape `(combined batch_size & dialogue_length, max seq length, + units)` containing token-level embeddings further used for entity + extraction from user text. Similar to `attribute_features` but returned + for all tokens, not just for the last one. + text_sequence_lengths: Only for `TEXT` attribute, otherwise an empty tensor: + Shape `(combined batch_size & dialogue_length, 1)`, containing the + sequence length for user text examples in `text_output`. The sequence + length is effectively the number of tokens + 1 (to account also for + sentence-level features). Needed for entity extraction from user text. + """ + # simulate None with empty tensor of zeros + text_output = tf.zeros((0,)) + text_sequence_lengths = tf.zeros((0,)) + + if attribute in SEQUENCE_FEATURES_TO_ENCODE: + # get lengths of real token sequences as a 3D tensor + sequence_feature_lengths = self._get_sequence_feature_lengths( + tf_batch_data, attribute + ) + + # sequence_feature_lengths contain `0` for "fake" features, while + # tf_batch_data[attribute] contains only "real" features. Hence, we need to + # get rid of the lengths that are 0. This step produces a 1D tensor. + sequence_feature_lengths = tf.boolean_mask( + sequence_feature_lengths, sequence_feature_lengths + ) + + attribute_features, _, _, _, _, _ = self._tf_layers[ + f"sequence_layer.{attribute}" + ]( + ( + tf_batch_data[attribute][SEQUENCE], + tf_batch_data[attribute][SENTENCE], + sequence_feature_lengths, + ), + training=self._training, + ) + + combined_sentence_sequence_feature_lengths = sequence_feature_lengths + 1 + + # Only for user text, the transformer output and sequence lengths also have + # to be returned to enable entity recognition training and prediction. + if attribute == TEXT: + text_output = attribute_features + text_sequence_lengths = combined_sentence_sequence_feature_lengths + + if self.max_history_featurizer_is_used: + # get the location of all last dialogue inputs + last_dialogue_turns_mask = self._create_last_dialogue_turns_mask( + tf_batch_data, attribute + ) + # pick outputs that correspond to the last dialogue turns + text_output = tf.boolean_mask(text_output, last_dialogue_turns_mask) + text_sequence_lengths = tf.boolean_mask( + text_sequence_lengths, last_dialogue_turns_mask + ) + + # resulting attribute features will have shape + # combined batch dimension and dialogue length x 1 x units + attribute_features = tf.expand_dims( + self._last_token( + attribute_features, combined_sentence_sequence_feature_lengths + ), + axis=1, + ) + + # for attributes without sequence-level features, all we need is to combine the + # sparse and dense sentence-level features into one + else: + # resulting attribute features will have shape + # combined batch dimension and dialogue length x 1 x units + attribute_features = self._tf_layers[ + f"sparse_dense_concat_layer.{attribute}" + ]((tf_batch_data[attribute][SENTENCE],), training=self._training) + + if attribute in SENTENCE_FEATURES_TO_ENCODE + LABEL_FEATURES_TO_ENCODE: + attribute_features = self._tf_layers[f"encoding_layer.{attribute}"]( + attribute_features, self._training + ) + + # attribute features have shape + # (combined batch dimension and dialogue length x 1 x units) + # convert them back to their original shape of + # batch size x dialogue length x units + attribute_features = self._convert_to_original_shape( + attribute_features, tf_batch_data, attribute + ) + + return attribute_features, text_output, text_sequence_lengths + + @staticmethod + def _convert_to_original_shape( + attribute_features: tf.Tensor, + tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], + attribute: Text, + ) -> tf.Tensor: + """Transform attribute features back to original shape. + + Given shape: (combined batch and dialogue dimension x 1 x units) + Original shape: (batch x dialogue length x units) + + Args: + attribute_features: the "real" features to convert + tf_batch_data: dictionary mapping every attribute to its features and masks + attribute: the attribute we will encode features for + (e.g., ACTION_NAME, INTENT) + + Returns: + The converted attribute features + """ + # in order to convert the attribute features with shape + # (combined batch-size and dialogue length x 1 x units) + # to a shape of (batch-size x dialogue length x units) + # we use tf.scatter_nd. Therefore, we need the target shape and the indices + # mapping the values of attribute features to the position in the resulting + # tensor. + + # attribute_mask has shape batch x dialogue_len x 1 + attribute_mask = tf_batch_data[attribute][MASK][0] + + if attribute in SENTENCE_FEATURES_TO_ENCODE + STATE_LEVEL_FEATURES: + dialogue_lengths = tf.cast( + tf_batch_data[DIALOGUE][LENGTH][0], dtype=tf.int32 + ) + dialogue_indices = tf_batch_data[DIALOGUE][INDICES][0] + else: + # for labels, dialogue length is a fake dim and equal to 1 + dialogue_lengths = tf.ones((tf.shape(attribute_mask)[0],), dtype=tf.int32) + dialogue_indices = tf.zeros((tf.shape(attribute_mask)[0],), dtype=tf.int32) + + batch_dim = tf.shape(attribute_mask)[0] + dialogue_dim = tf.shape(attribute_mask)[1] + units = attribute_features.shape[-1] + + # attribute_mask has shape (batch x dialogue_len x 1), remove last dimension + attribute_mask = tf.cast(tf.squeeze(attribute_mask, axis=-1), dtype=tf.int32) + # sum of attribute mask contains number of dialogue turns with "real" features + non_fake_dialogue_lengths = tf.reduce_sum(attribute_mask, axis=-1) + # create the batch indices + batch_indices = tf.repeat(tf.range(batch_dim), non_fake_dialogue_lengths) + + # attribute_mask has shape (batch x dialogue_len x 1), while + # dialogue_indices has shape (combined_dialogue_len,) + # in order to find positions of real input we need to flatten + # attribute mask to (combined_dialogue_len,) + dialogue_indices_mask = tf.boolean_mask( + attribute_mask, tf.sequence_mask(dialogue_lengths, dtype=tf.int32) + ) + # pick only those indices that contain "real" input + dialogue_indices = tf.boolean_mask(dialogue_indices, dialogue_indices_mask) + + indices = tf.stack([batch_indices, dialogue_indices], axis=1) + + shape = tf.convert_to_tensor([batch_dim, dialogue_dim, units]) + attribute_features = tf.squeeze(attribute_features, axis=1) + + return tf.scatter_nd(indices, attribute_features, shape) + + def _process_batch_data( + self, tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]] + ) -> Tuple[tf.Tensor, Optional[tf.Tensor], Optional[tf.Tensor]]: + """Encodes batch data. + + Combines intent and text and action name and action text if both are present. + + Args: + tf_batch_data: dictionary mapping every attribute to its features and masks + + Returns: + Tensor: encoding of all features in the batch, combined; + """ + # encode each attribute present in tf_batch_data + text_output = None + text_sequence_lengths = None + batch_encoded = {} + for attribute in tf_batch_data.keys(): + if attribute in SENTENCE_FEATURES_TO_ENCODE + STATE_LEVEL_FEATURES: + ( + attribute_features, + _text_output, + _text_sequence_lengths, + ) = self._encode_features_per_attribute(tf_batch_data, attribute) + + batch_encoded[attribute] = attribute_features + if attribute == TEXT: + text_output = _text_output + text_sequence_lengths = _text_sequence_lengths + + # if both action text and action name are present, combine them; otherwise, + # return the one which is present + + if ( + batch_encoded.get(ACTION_TEXT) is not None + and batch_encoded.get(ACTION_NAME) is not None + ): + batch_action = batch_encoded.pop(ACTION_TEXT) + batch_encoded.pop( + ACTION_NAME + ) + elif batch_encoded.get(ACTION_TEXT) is not None: + batch_action = batch_encoded.pop(ACTION_TEXT) + else: + batch_action = batch_encoded.pop(ACTION_NAME) + # same for user input + if ( + batch_encoded.get(INTENT) is not None + and batch_encoded.get(TEXT) is not None + ): + batch_user = batch_encoded.pop(INTENT) + batch_encoded.pop(TEXT) + elif batch_encoded.get(TEXT) is not None: + batch_user = batch_encoded.pop(TEXT) + else: + batch_user = batch_encoded.pop(INTENT) + + batch_features = [batch_user, batch_action] + # once we have user input and previous action, + # add all other attributes (SLOTS, ACTIVE_LOOP, etc.) to batch_features; + for key in batch_encoded.keys(): + batch_features.append(batch_encoded.get(key)) + + batch_features = tf.concat(batch_features, axis=-1) + + return batch_features, text_output, text_sequence_lengths + + def _reshape_for_entities( + self, + tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], + dialogue_transformer_output: tf.Tensor, + text_output: tf.Tensor, + text_sequence_lengths: tf.Tensor, + ) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor]: + # The first dim of the output of the text sequence transformer is the same + # as number of "real" features for `text` at the last dialogue turns + # (let's call it `N`), + # which corresponds to the first dim of the tag ids tensor. + # To calculate the loss for entities we need the output of the text + # sequence transformer (shape: N x sequence length x units), + # the output of the dialogue transformer + # (shape: batch size x dialogue length x units) and the tag ids for the + # entities (shape: N x sequence length - 1 x units) + # In order to process the tensors, they need to have the same shape. + # Convert the output of the dialogue transformer to shape + # (N x 1 x units). + + # Note: The CRF layer cannot handle 4D tensors. E.g. we cannot use the shape + # batch size x dialogue length x sequence length x units + + # convert the output of the dialogue transformer + # to shape (real entity dim x 1 x units) + attribute_mask = tf_batch_data[TEXT][MASK][0] + dialogue_lengths = tf.cast(tf_batch_data[DIALOGUE][LENGTH][0], tf.int32) + + if self.max_history_featurizer_is_used: + # pick outputs that correspond to the last dialogue turns + attribute_mask = tf.expand_dims( + self._last_token(attribute_mask, dialogue_lengths), axis=1 + ) + dialogue_transformer_output = tf.boolean_mask( + dialogue_transformer_output, tf.squeeze(attribute_mask, axis=-1) + ) + + # boolean mask removed axis=1, add it back + dialogue_transformer_output = tf.expand_dims( + dialogue_transformer_output, axis=1 + ) + + # broadcast the dialogue transformer output sequence-length-times to get the + # same shape as the text sequence transformer output + dialogue_transformer_output = tf.tile( + dialogue_transformer_output, (1, tf.shape(text_output)[1], 1) + ) + + # concat the output of the dialogue transformer to the output of the text + # sequence transformer (adding context) + # resulting shape (N x sequence length x 2 units) + # N = number of "real" features for `text` at the last dialogue turns + text_transformed = tf.concat( + [text_output, dialogue_transformer_output], axis=-1 + ) + text_mask = rasa_layers.compute_mask(text_sequence_lengths) + + # add zeros to match the shape of text_transformed, because + # max sequence length might differ, since it is calculated dynamically + # based on a subset of sequence lengths + sequence_diff = tf.shape(text_transformed)[1] - tf.shape(text_mask)[1] + text_mask = tf.pad(text_mask, [[0, 0], [0, sequence_diff], [0, 0]]) + + # remove additional dims and sentence features + text_sequence_lengths = tf.reshape(text_sequence_lengths, (-1,)) - 1 + + return text_transformed, text_mask, text_sequence_lengths + + # ---TRAINING--- + + def _batch_loss_entities( + self, + tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], + dialogue_transformer_output: tf.Tensor, + text_output: tf.Tensor, + text_sequence_lengths: tf.Tensor, + ) -> tf.Tensor: + # It could happen that some batches don't contain "real" features for `text`, + # e.g. large number of stories are intent only. + # Therefore actual `text_output` will be empty. + # We cannot create a loss with empty tensors. + # Since we need actual numbers to create a full loss, we output + # zero in this case. + return tf.cond( + tf.shape(text_output)[0] > 0, + lambda: self._real_batch_loss_entities( + tf_batch_data, + dialogue_transformer_output, + text_output, + text_sequence_lengths, + ), + lambda: tf.constant(0.0), + ) + + def _real_batch_loss_entities( + self, + tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], + dialogue_transformer_output: tf.Tensor, + text_output: tf.Tensor, + text_sequence_lengths: tf.Tensor, + ) -> tf.Tensor: + + text_transformed, text_mask, text_sequence_lengths = self._reshape_for_entities( + tf_batch_data, + dialogue_transformer_output, + text_output, + text_sequence_lengths, + ) + + tag_ids = tf_batch_data[ENTITY_TAGS][IDS][0] + # add a zero (no entity) for the sentence features to match the shape of inputs + sequence_diff = tf.shape(text_transformed)[1] - tf.shape(tag_ids)[1] + tag_ids = tf.pad(tag_ids, [[0, 0], [0, sequence_diff], [0, 0]]) + + loss, f1, _ = self._calculate_entity_loss( + text_transformed, + tag_ids, + text_mask, + text_sequence_lengths, + ENTITY_ATTRIBUTE_TYPE, + ) + + self.entity_loss.update_state(loss) + self.entity_f1.update_state(f1) + + return loss + + @staticmethod + def _get_labels_embed( + label_ids: tf.Tensor, all_labels_embed: tf.Tensor + ) -> tf.Tensor: + # instead of processing labels again, gather embeddings from + # all_labels_embed using label ids + + indices = tf.cast(label_ids[:, :, 0], tf.int32) + labels_embed = tf.gather(all_labels_embed, indices) + + return labels_embed + + def batch_loss( + self, batch_in: Union[Tuple[tf.Tensor, ...], Tuple[np.ndarray, ...]] + ) -> tf.Tensor: + """Calculates the loss for the given batch. + + Args: + batch_in: The batch. + + Returns: + The loss of the given batch. + """ + tf_batch_data = self.batch_to_model_data_format(batch_in, self.data_signature) + self._compute_dialogue_indices(tf_batch_data) + + all_label_ids, all_labels_embed = self._create_all_labels_embed() + + label_ids = tf_batch_data[LABEL_KEY][LABEL_SUB_KEY][0] + labels_embed = self._get_labels_embed(label_ids, all_labels_embed) + + dialogue_in, text_output, text_sequence_lengths = self._process_batch_data( + tf_batch_data + ) + ( + dialogue_embed, + dialogue_mask, + dialogue_transformer_output, + _, + ) = self._embed_dialogue(dialogue_in, tf_batch_data) + dialogue_mask = tf.squeeze(dialogue_mask, axis=-1) + + losses = [] + + loss, acc = self._tf_layers[f"loss.{LABEL}"]( + dialogue_embed, + labels_embed, + label_ids, + all_labels_embed, + all_label_ids, + dialogue_mask, + ) + losses.append(loss) + + if ( + self.config[ENTITY_RECOGNITION] + and text_output is not None + and text_sequence_lengths is not None + ): + losses.append( + self._batch_loss_entities( + tf_batch_data, + dialogue_transformer_output, + text_output, + text_sequence_lengths, + ) + ) + + self.action_loss.update_state(loss) + self.action_acc.update_state(acc) + + return tf.math.add_n(losses) + + # ---PREDICTION--- + def prepare_for_predict(self) -> None: + """Prepares the model for prediction.""" + _, self.all_labels_embed = self._create_all_labels_embed() + + def batch_predict( + self, batch_in: Union[Tuple[tf.Tensor, ...], Tuple[np.ndarray, ...]] + ) -> Dict[Text, Union[tf.Tensor, Dict[Text, tf.Tensor]]]: + """Predicts the output of the given batch. + + Args: + batch_in: The batch. + + Returns: + The output to predict. + """ + if self.all_labels_embed is None: + raise ValueError( + "The model was not prepared for prediction. " + "Call `prepare_for_predict` first." + ) + + tf_batch_data = self.batch_to_model_data_format( + batch_in, self.predict_data_signature + ) + self._compute_dialogue_indices(tf_batch_data) + + dialogue_in, text_output, text_sequence_lengths = self._process_batch_data( + tf_batch_data + ) + ( + dialogue_embed, + dialogue_mask, + dialogue_transformer_output, + attention_weights, + ) = self._embed_dialogue(dialogue_in, tf_batch_data) + dialogue_mask = tf.squeeze(dialogue_mask, axis=-1) + + sim_all, scores = self._tf_layers[ + f"loss.{LABEL}" + ].get_similarities_and_confidences_from_embeddings( + dialogue_embed[:, :, tf.newaxis, :], + self.all_labels_embed[tf.newaxis, tf.newaxis, :, :], + dialogue_mask, + ) + + predictions = { + "scores": scores, + "similarities": sim_all, + DIAGNOSTIC_DATA: {"attention_weights": attention_weights}, + } + + if ( + self.config[ENTITY_RECOGNITION] + and text_output is not None + and text_sequence_lengths is not None + ): + pred_ids, confidences = self._batch_predict_entities( + tf_batch_data, + dialogue_transformer_output, + text_output, + text_sequence_lengths, + ) + name = ENTITY_ATTRIBUTE_TYPE + predictions[f"e_{name}_ids"] = pred_ids + predictions[f"e_{name}_scores"] = confidences + + return predictions + + def _batch_predict_entities( + self, + tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], + dialogue_transformer_output: tf.Tensor, + text_output: tf.Tensor, + text_sequence_lengths: tf.Tensor, + ) -> Tuple[tf.Tensor, tf.Tensor]: + # It could happen that current prediction turn don't contain + # "real" features for `text`, + # Therefore actual `text_output` will be empty. + # We cannot predict entities with empty tensors. + # Since we need to output some tensors of the same shape, we output + # zero tensors. + return tf.cond( + tf.shape(text_output)[0] > 0, + lambda: self._real_batch_predict_entities( + tf_batch_data, + dialogue_transformer_output, + text_output, + text_sequence_lengths, + ), + lambda: ( + # the output is of shape (batch_size, max_seq_len) + tf.zeros(tf.shape(text_output)[:2], dtype=tf.int32), + tf.zeros(tf.shape(text_output)[:2], dtype=tf.float32), + ), + ) + + def _real_batch_predict_entities( + self, + tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], + dialogue_transformer_output: tf.Tensor, + text_output: tf.Tensor, + text_sequence_lengths: tf.Tensor, + ) -> Tuple[tf.Tensor, tf.Tensor]: + + text_transformed, _, text_sequence_lengths = self._reshape_for_entities( + tf_batch_data, + dialogue_transformer_output, + text_output, + text_sequence_lengths, + ) + + name = ENTITY_ATTRIBUTE_TYPE + + _logits = self._tf_layers[f"embed.{name}.logits"](text_transformed) + + return self._tf_layers[f"crf.{name}"](_logits, text_sequence_lengths) diff --git a/rasa/core/policies/unexpected_intent_policy.py b/rasa/core/policies/unexpected_intent_policy.py new file mode 100644 index 0000000..ca78866 --- /dev/null +++ b/rasa/core/policies/unexpected_intent_policy.py @@ -0,0 +1,1022 @@ +import dataclasses +import logging +from pathlib import Path +from typing import Any, List, Optional, Text, Dict, Type, Union + +import numpy as np +import tensorflow as tf + +import rasa.utils.common +from rasa.engine.graph import ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.classifiers import LABEL_RANKING_LENGTH +from rasa.shared.nlu.training_data.features import Features +from rasa.shared.core.domain import Domain +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.constants import SLOTS, ACTIVE_LOOP, ACTION_UNLIKELY_INTENT_NAME +from rasa.shared.core.events import UserUttered, ActionExecuted +import rasa.shared.utils.io +from rasa.shared.nlu.constants import ( + INTENT, + TEXT, + ENTITIES, + ACTION_NAME, + SPLIT_ENTITIES_BY_COMMA, + SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE, +) +from rasa.nlu.extractors.extractor import EntityTagSpec +from rasa.core.featurizers.precomputation import MessageContainerForCoreFeaturization +from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer +from rasa.core.featurizers.tracker_featurizers import IntentMaxHistoryTrackerFeaturizer +from rasa.core.featurizers.single_state_featurizer import ( + IntentTokenizerSingleStateFeaturizer, +) +from rasa.shared.core.generator import TrackerWithCachedStates +from rasa.core.constants import ( + DIALOGUE, + POLICY_MAX_HISTORY, + POLICY_PRIORITY, + UNLIKELY_INTENT_POLICY_PRIORITY, +) +from rasa.core.policies.policy import PolicyPrediction +from rasa.core.policies.ted_policy import ( + LABEL_KEY, + LABEL_SUB_KEY, + TEDPolicy, + TED, + SEQUENCE_LENGTH, + SEQUENCE, + PREDICTION_FEATURES, +) +from rasa.utils import train_utils +from rasa.utils.tensorflow.models import RasaModel +from rasa.utils.tensorflow.constants import ( + LABEL, + DENSE_DIMENSION, + ENCODING_DIMENSION, + UNIDIRECTIONAL_ENCODER, + TRANSFORMER_SIZE, + NUM_TRANSFORMER_LAYERS, + NUM_HEADS, + BATCH_SIZES, + BATCH_STRATEGY, + EPOCHS, + RANDOM_SEED, + RANKING_LENGTH, + LOSS_TYPE, + SIMILARITY_TYPE, + NUM_NEG, + EVAL_NUM_EXAMPLES, + EVAL_NUM_EPOCHS, + REGULARIZATION_CONSTANT, + SCALE_LOSS, + EMBEDDING_DIMENSION, + DROP_RATE_DIALOGUE, + DROP_RATE_LABEL, + DROP_RATE, + DROP_RATE_ATTENTION, + CONNECTION_DENSITY, + KEY_RELATIVE_ATTENTION, + VALUE_RELATIVE_ATTENTION, + MAX_RELATIVE_POSITION, + INNER, + BALANCED, + TENSORBOARD_LOG_DIR, + TENSORBOARD_LOG_LEVEL, + CHECKPOINT_MODEL, + FEATURIZERS, + ENTITY_RECOGNITION, + IGNORE_INTENTS_LIST, + BILOU_FLAG, + LEARNING_RATE, + CROSS_ENTROPY, + SPARSE_INPUT_DROPOUT, + DENSE_INPUT_DROPOUT, + MASKED_LM, + HIDDEN_LAYERS_SIZES, + CONCAT_DIMENSION, + TOLERANCE, + LABEL_PAD_ID, + POSITIVE_SCORES_KEY, + NEGATIVE_SCORES_KEY, + USE_GPU, +) +from rasa.utils.tensorflow import layers +from rasa.utils.tensorflow.model_data import RasaModelData, FeatureArray, Data +from rasa.core.exceptions import RasaCoreException +from rasa.shared.utils import common + + +@dataclasses.dataclass +class RankingCandidateMetadata: + """Dataclass to represent metada for a candidate intent.""" + + name: Text + score: float + threshold: Optional[float] + severity: Optional[float] + + +@dataclasses.dataclass +class UnexpecTEDIntentPolicyMetadata: + """Dataclass to represent policy metadata.""" + + query_intent: RankingCandidateMetadata + ranking: List[RankingCandidateMetadata] + + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.POLICY_WITH_END_TO_END_SUPPORT, is_trainable=True +) +class UnexpecTEDIntentPolicy(TEDPolicy): + """`UnexpecTEDIntentPolicy` has the same model architecture as `TEDPolicy`. + + The difference is at a task level. + Instead of predicting the next probable action, this policy + predicts whether the last predicted intent is a likely intent + according to the training stories and conversation context. + """ + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns the default config (see parent class for full docstring).""" + return { + # ## Architecture of the used neural network + # Hidden layer sizes for layers before the embedding layers for user message + # and labels. + # The number of hidden layers is equal to the length + # of the corresponding list. + HIDDEN_LAYERS_SIZES: {TEXT: []}, + # Dense dimension to use for sparse features. + DENSE_DIMENSION: { + TEXT: 128, + INTENT: 20, + ACTION_NAME: 20, + ENTITIES: 20, + SLOTS: 20, + ACTIVE_LOOP: 20, + f"{LABEL}_{INTENT}": 20, + }, + # Default dimension to use for concatenating sequence and sentence features. + CONCAT_DIMENSION: {TEXT: 128}, + # Dimension size of embedding vectors before + # the dialogue transformer encoder. + ENCODING_DIMENSION: 50, + # Number of units in transformer encoders + TRANSFORMER_SIZE: {TEXT: 128, DIALOGUE: 128}, + # Number of layers in transformer encoders + NUM_TRANSFORMER_LAYERS: {TEXT: 1, DIALOGUE: 1}, + # Number of attention heads in transformer + NUM_HEADS: 4, + # If 'True' use key relative embeddings in attention + KEY_RELATIVE_ATTENTION: False, + # If 'True' use value relative embeddings in attention + VALUE_RELATIVE_ATTENTION: False, + # Max position for relative embeddings. Only in effect + # if key- or value relative attention are turned on + MAX_RELATIVE_POSITION: 5, + # Use a unidirectional or bidirectional encoder + # for `text`, `action_text`, and `label_action_text`. + UNIDIRECTIONAL_ENCODER: False, + # ## Training parameters + # Initial and final batch sizes: + # Batch size will be linearly increased for each epoch. + BATCH_SIZES: [64, 256], + # Strategy used when creating batches. + # Can be either 'sequence' or 'balanced'. + BATCH_STRATEGY: BALANCED, + # Number of epochs to train + EPOCHS: 1, + # Set random seed to any 'int' to get reproducible results + RANDOM_SEED: None, + # Initial learning rate for the optimizer + LEARNING_RATE: 0.001, + # ## Parameters for embeddings + # Dimension size of embedding vectors + EMBEDDING_DIMENSION: 20, + # The number of incorrect labels. The algorithm will minimize + # their similarity to the user input during training. + NUM_NEG: 20, + # Number of intents to store in ranking key of predicted action metadata. + # Set this to `0` to include all intents. + RANKING_LENGTH: LABEL_RANKING_LENGTH, + # If 'True' scale loss inverse proportionally to the confidence + # of the correct prediction + SCALE_LOSS: True, + # ## Regularization parameters + # The scale of regularization + REGULARIZATION_CONSTANT: 0.001, + # Dropout rate for embedding layers of dialogue features. + DROP_RATE_DIALOGUE: 0.1, + # Dropout rate for embedding layers of utterance level features. + DROP_RATE: 0.0, + # Dropout rate for embedding layers of label, e.g. action, features. + DROP_RATE_LABEL: 0.0, + # Dropout rate for attention. + DROP_RATE_ATTENTION: 0.0, + # Fraction of trainable weights in internal layers. + CONNECTION_DENSITY: 0.2, + # If 'True' apply dropout to sparse input tensors + SPARSE_INPUT_DROPOUT: True, + # If 'True' apply dropout to dense input tensors + DENSE_INPUT_DROPOUT: True, + # If 'True' random tokens of the input message will be masked. + # Since there is no related loss term used inside TED, the masking + # effectively becomes just input dropout applied to the text of user + # utterances. + MASKED_LM: False, + # ## Evaluation parameters + # How often calculate validation accuracy. + # Small values may hurt performance, e.g. model accuracy. + EVAL_NUM_EPOCHS: 20, + # How many examples to use for hold out validation set + # Large values may hurt performance, e.g. model accuracy. + EVAL_NUM_EXAMPLES: 0, + # If you want to use tensorboard to visualize training and validation + # metrics, set this option to a valid output directory. + TENSORBOARD_LOG_DIR: None, + # Define when training metrics for tensorboard should be logged. + # Either after every epoch or for every training step. + # Valid values: 'epoch' and 'batch' + TENSORBOARD_LOG_LEVEL: "epoch", + # Perform model checkpointing + CHECKPOINT_MODEL: False, + # Specify what features to use as sequence and sentence features. + # By default all features in the pipeline are used. + FEATURIZERS: [], + # List of intents to ignore for `action_unlikely_intent` prediction. + IGNORE_INTENTS_LIST: [], + # Tolerance for prediction of `action_unlikely_intent`. + # For each intent, the tolerance is the percentage of + # negative training instances (trackers for which + # the corresponding intent is not the correct label) that + # would be ignored by `UnexpecTEDIntentPolicy`. This is converted + # into a similarity threshold by identifying the similarity + # score for the (1 - tolerance) percentile of negative + # examples. Any tracker with a similarity score below this + # threshold will trigger an `action_unlikely_intent`. + # Higher values of `tolerance` means the policy is more + # "tolerant" to surprising paths in conversations and + # hence will result in lesser number of `action_unlikely_intent` + # triggers. Acceptable values are between 0.0 and 1.0 (inclusive). + TOLERANCE: 0.0, + # Split entities by comma, this makes sense e.g. for a list of + # ingredients in a recipe, but it doesn't make sense for the parts of + # an address + SPLIT_ENTITIES_BY_COMMA: SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE, + # Type of similarity measure to use, either 'auto' or 'cosine' or 'inner'. + SIMILARITY_TYPE: INNER, + # If set to true, entities are predicted in user utterances. + ENTITY_RECOGNITION: False, + # 'BILOU_flag' determines whether to use BILOU tagging or not. + # If set to 'True' labelling is more rigorous, however more + # examples per entity are required. + # Rule of thumb: you should have more than 100 examples per entity. + BILOU_FLAG: False, + # The type of the loss function, either 'cross_entropy' or 'margin'. + LOSS_TYPE: CROSS_ENTROPY, + # Determines the importance of policies, higher values take precedence + POLICY_PRIORITY: UNLIKELY_INTENT_POLICY_PRIORITY, + USE_GPU: True, + } + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + model: Optional[RasaModel] = None, + featurizer: Optional[TrackerFeaturizer] = None, + fake_features: Optional[Dict[Text, List[Features]]] = None, + entity_tag_specs: Optional[List[EntityTagSpec]] = None, + label_quantiles: Optional[Dict[int, List[float]]] = None, + ): + """Declares instance variables with default values.""" + # Set all invalid / non configurable parameters + config[ENTITY_RECOGNITION] = False + config[BILOU_FLAG] = False + config[SIMILARITY_TYPE] = INNER + config[LOSS_TYPE] = CROSS_ENTROPY + self.config = config + + super().__init__( + self.config, + model_storage, + resource, + execution_context, + model, + featurizer, + fake_features, + entity_tag_specs, + ) + + self.label_quantiles = label_quantiles or {} + self.label_thresholds = ( + self._pick_thresholds(self.label_quantiles, self.config[TOLERANCE]) + if self.label_quantiles + else {} + ) + self.ignore_intent_list = self.config[IGNORE_INTENTS_LIST] + + common.mark_as_experimental_feature("UnexpecTED Intent Policy") + + def _standard_featurizer(self) -> IntentMaxHistoryTrackerFeaturizer: + return IntentMaxHistoryTrackerFeaturizer( + IntentTokenizerSingleStateFeaturizer(), + max_history=self.config.get(POLICY_MAX_HISTORY), + ) + + @staticmethod + def model_class() -> Type["IntentTED"]: + """Gets the class of the model architecture to be used by the policy. + + Returns: + Required class. + """ + return IntentTED + + def _auto_update_configuration(self) -> None: + self.config = train_utils.update_evaluation_parameters(self.config) + + @classmethod + def _metadata_filename(cls) -> Optional[Text]: + return "unexpected_intent_policy" + + def _assemble_label_data( + self, attribute_data: Data, domain: Domain + ) -> RasaModelData: + """Constructs data regarding labels to be fed to the model. + + The resultant model data should contain the keys `label_intent`, `label`. + `label_intent` will contain the sequence, sentence and mask features + for all intent labels and `label` will contain the numerical label ids. + + Args: + attribute_data: Feature data for all intent labels. + domain: Domain of the assistant. + + Returns: + Features of labels ready to be fed to the model. + """ + label_data = RasaModelData() + label_data.add_data(attribute_data, key_prefix=f"{LABEL_KEY}_") + label_data.add_lengths( + f"{LABEL}_{INTENT}", SEQUENCE_LENGTH, f"{LABEL}_{INTENT}", SEQUENCE + ) + label_ids = np.arange(len(domain.intents)) + label_data.add_features( + LABEL_KEY, + LABEL_SUB_KEY, + [ + FeatureArray( + np.expand_dims(label_ids, -1), + number_of_dimensions=2, + ) + ], + ) + return label_data + + @staticmethod + def _prepare_data_for_prediction(model_data: RasaModelData) -> RasaModelData: + """Transforms training model data to data usable for making model predictions. + + Transformation involves filtering out all features which + are not useful at prediction time. This is important + because the prediction signature will not contain these + attributes and hence prediction will break. + + Args: + model_data: Data used during model training. + + Returns: + Transformed data usable for making predictions. + """ + filtered_data: Dict[Text, Dict[Text, Any]] = { + key: features + for key, features in model_data.data.items() + if key in PREDICTION_FEATURES + } + return RasaModelData(data=filtered_data) + + def compute_label_quantiles_post_training( + self, model_data: RasaModelData, label_ids: np.ndarray + ) -> None: + """Computes quantile scores for prediction of `action_unlikely_intent`. + + Multiple quantiles are computed for each label + so that an appropriate threshold can be picked at + inference time according to the `tolerance` value specified. + + Args: + model_data: Data used for training the model. + label_ids: Numerical IDs of labels for each data point used during training. + """ + # `model_data` contains data attributes like `label` which were + # used during training. These attributes are not present in + # the `predict_data_signature`. Prediction through the model + # will break if `model_data` is passed as it is through the model. + # Hence, we first filter out the attributes inside `model_data` + # to keep only those which should be present during prediction. + model_prediction_data = self._prepare_data_for_prediction(model_data) + prediction_scores = ( + self.model.run_bulk_inference(model_prediction_data) + if self.model is not None + else {} + ) + label_id_scores = self._collect_label_id_grouped_scores( + prediction_scores, label_ids + ) + # For each label id, compute multiple quantile scores. + # These quantile scores can be looked up during inference + # to select a specific threshold according to the `tolerance` + # value specified in the configuration. + self.label_quantiles = self._compute_label_quantiles(label_id_scores) + + @staticmethod + def _get_trackers_for_training( + trackers: List[TrackerWithCachedStates], + ) -> List[TrackerWithCachedStates]: + """Filters out the list of trackers which should not be used for training. + + `UnexpecTEDIntentPolicy` cannot be trained on trackers with: + 1. `UserUttered` events with no intent. + 2. `ActionExecuted` events with no action_name. + + Trackers with such events are filtered out. + + Args: + trackers: All trackers available for training. + + Returns: + Trackers which should be used for training. + """ + trackers_for_training = [] + for tracker in trackers: + tracker_compatible = True + for event in tracker.applied_events(): + if (isinstance(event, UserUttered) and event.intent_name is None) or ( + isinstance(event, ActionExecuted) and event.action_name is None + ): + tracker_compatible = False + break + if tracker_compatible: + trackers_for_training.append(tracker) + return trackers_for_training + + def run_training( + self, model_data: RasaModelData, label_ids: Optional[np.ndarray] = None + ) -> None: + """Feeds the featurized training data to the model. + + Args: + model_data: Featurized training data. + label_ids: Label ids corresponding to the data points in `model_data`. + + Raises: + `RasaCoreException` if `label_ids` is None as it's needed for + running post training procedures. + """ + if label_ids is None: + raise RasaCoreException( + f"Incorrect usage of `run_training` " + f"method of `{self.__class__.__name__}`." + f"`label_ids` cannot be left to `None`." + ) + super().run_training(model_data, label_ids) + self.compute_label_quantiles_post_training(model_data, label_ids) + + def _collect_action_metadata( + self, domain: Domain, similarities: np.ndarray, query_intent: Text + ) -> UnexpecTEDIntentPolicyMetadata: + """Collects metadata to be attached to the predicted action. + + Metadata schema looks like this: + + { + "query_intent": <metadata of intent that was queried>, + "ranking": <sorted list of metadata corresponding to all intents + (truncated by `ranking_length` parameter) + It also includes the `query_intent`. + Sorting is based on predicted similarities.> + } + + Each metadata dictionary looks like this: + + { + "name": <name of intent>, + "score": <predicted similarity score>, + "threshold": <threshold used for intent>, + "severity": <numerical difference between threshold and score> + } + + Args: + domain: Domain of the assistant. + similarities: Predicted similarities for each intent. + query_intent: Name of intent queried in this round of inference. + + Returns: + Metadata to be attached. + """ + query_intent_index = domain.intents.index(query_intent) + + def _compile_metadata_for_label( + label_name: Text, similarity_score: float, threshold: Optional[float] + ) -> RankingCandidateMetadata: + severity = float(threshold - similarity_score) if threshold else None + return RankingCandidateMetadata( + label_name, + float(similarity_score), + float(threshold) if threshold else None, + severity, + ) + + query_intent_metadata = _compile_metadata_for_label( + query_intent, + similarities[0][domain.intents.index(query_intent)], + self.label_thresholds.get(query_intent_index), + ) + + # Ranking in descending order of predicted similarities + sorted_similarities = sorted( + [(index, similarity) for index, similarity in enumerate(similarities[0])], + key=lambda x: -x[1], + ) + + if self.config[RANKING_LENGTH] > 0: + sorted_similarities = sorted_similarities[: self.config[RANKING_LENGTH]] + + ranking_metadata = [ + _compile_metadata_for_label( + domain.intents[intent_index], + similarity, + self.label_thresholds.get(intent_index), + ) + for intent_index, similarity in sorted_similarities + ] + + return UnexpecTEDIntentPolicyMetadata(query_intent_metadata, ranking_metadata) + + def predict_action_probabilities( + self, + tracker: DialogueStateTracker, + domain: Domain, + rule_only_data: Optional[Dict[Text, Any]] = None, + precomputations: Optional[MessageContainerForCoreFeaturization] = None, + **kwargs: Any, + ) -> PolicyPrediction: + """Predicts the next action the bot should take after seeing the tracker. + + Args: + tracker: Tracker containing past conversation events. + domain: Domain of the assistant. + rule_only_data: Slots and loops which are specific to rules and hence + should be ignored by this policy. + precomputations: Contains precomputed features and attributes. + + Returns: + The policy's prediction (e.g. the probabilities for the actions). + """ + if self.model is None: + return self._prediction(self._default_predictions(domain)) + + # Prediction through the policy is skipped if: + # 1. If the tracker does not contain any event of type `UserUttered` + # till now or the intent of such event is not in domain. + # 2. There is at least one event of type `ActionExecuted` + # after the last `UserUttered` event. + if self._should_skip_prediction(tracker, domain): + logger.debug( + f"Skipping predictions for {self.__class__.__name__} " + f"as either there is no event of type `UserUttered`, " + f"event's intent is new and not in domain or " + f"there is an event of type `ActionExecuted` after " + f"the last `UserUttered`." + ) + return self._prediction(self._default_predictions(domain)) + + # create model data from tracker + tracker_state_features = self._featurize_for_prediction( + tracker, domain, precomputations, rule_only_data=rule_only_data + ) + + model_data = self._create_model_data(tracker_state_features) + output = self.model.run_inference(model_data) + + # take the last prediction in the sequence + if isinstance(output["similarities"], np.ndarray): + sequence_similarities = output["similarities"][:, -1, :] + else: + raise TypeError( + "model output for `similarities` " "should be a numpy array" + ) + + # Check for unlikely intent + last_user_uttered_event = tracker.get_last_event_for(UserUttered) + query_intent = ( + last_user_uttered_event.intent_name + if last_user_uttered_event is not None + else "" + ) + is_unlikely_intent = self._check_unlikely_intent( + domain, sequence_similarities, query_intent + ) + + confidences = list(np.zeros(domain.num_actions)) + + if is_unlikely_intent: + confidences[domain.index_for_action(ACTION_UNLIKELY_INTENT_NAME)] = 1.0 + + return self._prediction( + confidences, + action_metadata=dataclasses.asdict( + self._collect_action_metadata( + domain, sequence_similarities, query_intent + ) + ), + ) + + @staticmethod + def _should_skip_prediction(tracker: DialogueStateTracker, domain: Domain) -> bool: + """Checks if the policy should skip making a prediction. + + A prediction can be skipped if: + 1. There is no event of type `UserUttered` in the tracker. + 2. If the `UserUttered` event's intent is new and not in domain + (a new intent can be created from rasa interactive and not placed in + domain yet) + 3. There is an event of type `ActionExecuted` after the last + `UserUttered` event. This is to prevent the dialogue manager + from getting stuck in a prediction loop. + For example, if the last `ActionExecuted` event + contained `action_unlikely_intent` predicted by + `UnexpecTEDIntentPolicy` and + if `UnexpecTEDIntentPolicy` runs inference + on the same tracker, it will predict `action_unlikely_intent` + again which would make the dialogue manager get stuck in a + prediction loop. + + Returns: + Whether prediction should be skipped. + """ + applied_events = tracker.applied_events() + + for event in reversed(applied_events): + if isinstance(event, ActionExecuted): + return True + elif isinstance(event, UserUttered): + if event.intent_name not in domain.intents: + return True + return False + # No event of type `ActionExecuted` and `UserUttered` means + # that there is nothing for `UnexpecTEDIntentPolicy` to predict on. + return True + + def _should_check_for_intent(self, intent: Text, domain: Domain) -> bool: + """Checks if the intent should raise `action_unlikely_intent`. + + Args: + intent: Intent to be queried. + domain: Domain of the assistant. + + Returns: + Whether intent should raise `action_unlikely_intent` or not. + """ + if domain.intents.index(intent) not in self.label_thresholds: + # This means the intent was never present in a story + logger.debug( + f"Query intent index {domain.intents.index(intent)} not " + f"found in label thresholds - {self.label_thresholds}. " + f"Check for `{ACTION_UNLIKELY_INTENT_NAME}` prediction will be skipped." + ) + return False + if intent in self.config[IGNORE_INTENTS_LIST]: + logger.debug( + f"Query intent `{intent}` found in " + f"`{IGNORE_INTENTS_LIST}={self.config[IGNORE_INTENTS_LIST]}`. " + f"Check for `{ACTION_UNLIKELY_INTENT_NAME}` prediction will be skipped." + ) + return False + + return True + + def _check_unlikely_intent( + self, domain: Domain, similarities: np.ndarray, query_intent: Text + ) -> bool: + """Checks if the query intent is probable according to model's predictions. + + If the similarity prediction for the intent + is lower than the threshold calculated for that + intent during training, the corresponding user + intent is unlikely. + + Args: + domain: Domain of the assistant. + similarities: Predicted similarities for all intents. + query_intent: Intent to be queried. + + Returns: + Whether query intent is likely or not. + """ + logger.debug(f"Querying for intent `{query_intent}`.") + + if not self._should_check_for_intent(query_intent, domain): + return False + + predicted_intent_scores = { + index: similarities[0][index] for index, intent in enumerate(domain.intents) + } + sorted_intent_scores = sorted( + [ + (domain.intents[label_index], score) + for label_index, score in predicted_intent_scores.items() + ], + key=lambda x: x[1], + ) + query_intent_id = domain.intents.index(query_intent) + query_intent_similarity = similarities[0][query_intent_id] + highest_likely_intent_id = domain.intents.index(sorted_intent_scores[-1][0]) + + logger.debug( + f"Score for intent `{query_intent}` is " + f"`{query_intent_similarity}`, while " + f"threshold is `{self.label_thresholds[query_intent_id]}`." + ) + logger.debug( + f"Top 5 intents (in ascending order) that " + f"are likely here are: `{sorted_intent_scores[-5:]}`." + ) + + # If score for query intent is below threshold and + # the query intent is not the top likely intent + if ( + query_intent_similarity < self.label_thresholds[query_intent_id] + and query_intent_id != highest_likely_intent_id + ): + logger.debug( + f"Intent `{query_intent}-{query_intent_id}` unlikely to occur here." + ) + return True + + return False + + @staticmethod + def _collect_label_id_grouped_scores( + output_scores: Dict[Text, np.ndarray], label_ids: np.ndarray + ) -> Dict[int, Dict[Text, List[float]]]: + """Collects similarities predicted for each label id. + + For each `label_id`, we collect similarity scores across + all trackers and categorize them into two buckets: + 1. Similarity scores when `label_id` is the correct label. + 2. Similarity scores when `label_id` is the wrong label. + + Args: + output_scores: Model's predictions for each data point. + label_ids: Numerical IDs of labels for each data point. + + Returns: + Both buckets of similarity scores grouped by each unique label id. + """ + unique_label_ids = np.unique(label_ids).tolist() + if LABEL_PAD_ID in unique_label_ids: + unique_label_ids.remove(LABEL_PAD_ID) + + label_id_scores: Dict[int, Dict[Text, List[float]]] = { + label_id: {POSITIVE_SCORES_KEY: [], NEGATIVE_SCORES_KEY: []} + for label_id in unique_label_ids + } + + for index, all_pos_labels in enumerate(label_ids): + + for candidate_label_id in unique_label_ids: + if candidate_label_id in all_pos_labels: + label_id_scores[candidate_label_id][POSITIVE_SCORES_KEY].append( + output_scores["similarities"][index, 0, candidate_label_id] + ) + else: + label_id_scores[candidate_label_id][NEGATIVE_SCORES_KEY].append( + output_scores["similarities"][index, 0, candidate_label_id] + ) + + return label_id_scores + + @staticmethod + def _compute_label_quantiles( + label_id_scores: Dict[int, Dict[Text, List[float]]] + ) -> Dict[int, List[float]]: + """Computes multiple quantiles for each label id. + + The quantiles are computed over the negative scores + collected for each label id. However, no quantile score + can be greater than the minimum positive score collected + for the corresponding label id. + + Args: + label_id_scores: Scores collected for each label id + over positive and negative trackers. + + Returns: + Computed quantiles for each label id. + """ + label_quantiles = {} + + quantile_indices = [ + 1 - tolerance_value / 100.0 for tolerance_value in range(0, 100, 5) + ] + for label_id, prediction_scores in label_id_scores.items(): + positive_scores, negative_scores = ( + prediction_scores[POSITIVE_SCORES_KEY], + prediction_scores[NEGATIVE_SCORES_KEY], + ) + minimum_positive_score = min(positive_scores) + if negative_scores: + quantile_values = np.quantile( # type: ignore[call-overload] + negative_scores, quantile_indices, interpolation="lower" + ) + label_quantiles[label_id] = [ + min(minimum_positive_score, value) for value in quantile_values + ] + else: + label_quantiles[label_id] = [minimum_positive_score] * len( + quantile_indices + ) + + return label_quantiles + + @staticmethod + def _pick_thresholds( + label_quantiles: Dict[int, List[float]], tolerance: float + ) -> Dict[int, float]: + """Computes a threshold for each label id. + + Uses tolerance which is the percentage of negative + trackers for which predicted score should be equal + to or above the threshold. + + Args: + label_quantiles: Quantiles computed for each label id + tolerance: Specified tolerance value from the configuration. + + Returns: + Computed thresholds + """ + label_thresholds = {} + for label_id in label_quantiles: + num_thresholds = len(label_quantiles[label_id]) + label_thresholds[label_id] = label_quantiles[label_id][ + min(int(tolerance * num_thresholds), num_thresholds - 1) + ] + return label_thresholds + + def persist_model_utilities(self, model_path: Path) -> None: + """Persists model's utility attributes like model weights, etc. + + Args: + model_path: Path where model is to be persisted + """ + super().persist_model_utilities(model_path) + + from safetensors.numpy import save_file + + save_file( + {str(k): np.array(v) for k, v in self.label_quantiles.items()}, + model_path / f"{self._metadata_filename()}.label_quantiles.st", + ) + + @classmethod + def _load_model_utilities(cls, model_path: Path) -> Dict[Text, Any]: + """Loads model's utility attributes. + + Args: + model_path: Path where model is to be persisted. + """ + model_utilties = super()._load_model_utilities(model_path) + + from safetensors.numpy import load_file + + loaded_label_quantiles = load_file( + model_path / f"{cls._metadata_filename()}.label_quantiles.st" + ) + label_quantiles = {int(k): list(v) for k, v in loaded_label_quantiles.items()} + + model_utilties.update({"label_quantiles": label_quantiles}) + return model_utilties + + @classmethod + def _update_loaded_params(cls, meta: Dict[Text, Any]) -> Dict[Text, Any]: + meta = rasa.utils.common.override_defaults(cls.get_default_config(), meta) + return meta + + @classmethod + def _load_policy_with_model( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + featurizer: TrackerFeaturizer, + model: "IntentTED", + model_utilities: Dict[Text, Any], + ) -> "UnexpecTEDIntentPolicy": + return cls( + config, + model_storage, + resource, + execution_context, + model=model, + featurizer=featurizer, + fake_features=model_utilities["fake_features"], + entity_tag_specs=model_utilities["entity_tag_specs"], + label_quantiles=model_utilities["label_quantiles"], + ) + + +class IntentTED(TED): + """Follows TED's model architecture from https://arxiv.org/abs/1910.00486. + + However, it has been re-purposed to predict multiple + labels (intents) instead of a single label (action). + """ + + def _prepare_dot_product_loss( + self, name: Text, scale_loss: bool, prefix: Text = "loss" + ) -> None: + self._tf_layers[f"{prefix}.{name}"] = self.dot_product_loss_layer( + self.config[NUM_NEG], + scale_loss, + similarity_type=self.config[SIMILARITY_TYPE], + ) + + @property + def dot_product_loss_layer(self) -> tf.keras.layers.Layer: + """Returns the dot-product loss layer to use. + + Multiple intents can be valid simultaneously, so `IntentTED` uses the + `MultiLabelDotProductLoss`. + + Returns: + The loss layer that is used by `_prepare_dot_product_loss`. + """ + return layers.MultiLabelDotProductLoss + + @staticmethod + def _get_labels_embed( + label_ids: tf.Tensor, all_labels_embed: tf.Tensor + ) -> tf.Tensor: + # instead of processing labels again, gather embeddings from + # all_labels_embed using label ids + + indices = tf.cast(label_ids[:, :, 0], tf.int32) + + # Find padding indices. They should have a value equal to `LABEL_PAD_ID` + padding_indices = tf.where(tf.equal(indices, LABEL_PAD_ID)) + + # Create a tensor of values with sign opposite to `LABEL_PAD_ID` which + # will serve as updates to original `indices` + updates_to_indices = ( + tf.ones((tf.shape(padding_indices)[0]), dtype=tf.int32) * -1 * LABEL_PAD_ID + ) + + # Add the updates tensor to indices with padding. + # So, effectively all indices with `LABEL_PAD_ID=-1` + # become 0 because updates contain 1s. + # This is fine because we don't change the original non-padding label + # indices but only make the padding indices 'compatible' + # for the `tf.gather` op below. + indices_to_gather = tf.cast( + tf.tensor_scatter_nd_add(indices, padding_indices, updates_to_indices), + tf.int32, + ) + + labels_embed = tf.gather(all_labels_embed, indices_to_gather) + + return labels_embed + + def run_bulk_inference( + self, model_data: RasaModelData + ) -> Dict[Text, Union[np.ndarray, Dict[Text, Any]]]: + """Computes model's predictions for input data. + + Args: + model_data: Data to be passed as input + + Returns: + Predictions for the input data. + """ + self._training = False + + batch_size = ( + self.config[BATCH_SIZES] + if isinstance(self.config[BATCH_SIZES], int) + else self.config[BATCH_SIZES][0] + ) + + return self.run_inference( + model_data, batch_size=batch_size, output_keys_expected=["similarities"] + ) diff --git a/rasa/core/processor.py b/rasa/core/processor.py new file mode 100644 index 0000000..fc628c7 --- /dev/null +++ b/rasa/core/processor.py @@ -0,0 +1,1124 @@ +import copy +import logging +import structlog +import os +from pathlib import Path +import tarfile +import time +from types import LambdaType +from typing import Any, Dict, List, Optional, Text, Tuple, Union + +from rasa.core.http_interpreter import RasaNLUHttpInterpreter +from rasa.engine import loader +from rasa.engine.constants import PLACEHOLDER_MESSAGE, PLACEHOLDER_TRACKER +from rasa.engine.runner.dask import DaskGraphRunner +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.engine.storage.storage import ModelMetadata +from rasa.model import get_latest_model +from rasa.plugin import plugin_manager +from rasa.shared.data import TrainingType +import rasa.shared.utils.io +import rasa.core.actions.action +from rasa.core import jobs +from rasa.core.actions.action import Action +from rasa.core.channels.channel import ( + CollectingOutputChannel, + OutputChannel, + UserMessage, +) +import rasa.core.utils +from rasa.core.policies.policy import PolicyPrediction +from rasa.engine.runner.interface import GraphRunner +from rasa.exceptions import ActionLimitReached, ModelNotFound +from rasa.shared.core.constants import ( + USER_INTENT_RESTART, + ACTION_LISTEN_NAME, + ACTION_SESSION_START_NAME, + FOLLOWUP_ACTION, + SESSION_START_METADATA_SLOT, + ACTION_EXTRACT_SLOTS, +) +from rasa.shared.core.events import ( + ActionExecutionRejected, + BotUttered, + Event, + ReminderCancelled, + ReminderScheduled, + SlotSet, + UserUttered, + ActionExecuted, +) +from rasa.shared.constants import ( + ASSISTANT_ID_KEY, + DOCS_URL_DOMAINS, + DEFAULT_SENDER_ID, + DOCS_URL_POLICIES, + UTTER_PREFIX, +) +from rasa.core.nlg import NaturalLanguageGenerator +from rasa.core.lock_store import LockStore +from rasa.utils.common import TempDirectoryPath, get_temp_dir_name +import rasa.core.tracker_store +import rasa.core.actions.action +import rasa.shared.core.trackers +from rasa.shared.core.trackers import DialogueStateTracker, EventVerbosity +from rasa.shared.nlu.constants import ( + ENTITIES, + INTENT, + INTENT_NAME_KEY, + INTENT_RESPONSE_KEY, + PREDICTED_CONFIDENCE_KEY, + FULL_RETRIEVAL_INTENT_NAME_KEY, + RESPONSE_SELECTOR, + RESPONSE, + TEXT, +) +from rasa.utils.endpoints import EndpointConfig + +logger = logging.getLogger(__name__) +structlogger = structlog.get_logger() + +MAX_NUMBER_OF_PREDICTIONS = int(os.environ.get("MAX_NUMBER_OF_PREDICTIONS", "10")) + + +class MessageProcessor: + """The message processor is interface for communicating with a bot model.""" + + def __init__( + self, + model_path: Union[Text, Path], + tracker_store: rasa.core.tracker_store.TrackerStore, + lock_store: LockStore, + generator: NaturalLanguageGenerator, + action_endpoint: Optional[EndpointConfig] = None, + max_number_of_predictions: int = MAX_NUMBER_OF_PREDICTIONS, + on_circuit_break: Optional[LambdaType] = None, + http_interpreter: Optional[RasaNLUHttpInterpreter] = None, + ) -> None: + """Initializes a `MessageProcessor`.""" + self.nlg = generator + self.tracker_store = tracker_store + self.lock_store = lock_store + self.max_number_of_predictions = max_number_of_predictions + self.on_circuit_break = on_circuit_break + self.action_endpoint = action_endpoint + self.model_filename, self.model_metadata, self.graph_runner = self._load_model( + model_path + ) + + if self.model_metadata.assistant_id is None: + rasa.shared.utils.io.raise_warning( + f"The model metadata does not contain a value for the " + f"'{ASSISTANT_ID_KEY}' attribute. Check that 'config.yml' " + f"file contains a value for the '{ASSISTANT_ID_KEY}' key " + f"and re-train the model. Failure to do so will result in " + f"streaming events without a unique assistant identifier.", + UserWarning, + ) + + self.model_path = Path(model_path) + self.domain = self.model_metadata.domain + self.http_interpreter = http_interpreter + + @staticmethod + def _load_model( + model_path: Union[Text, Path] + ) -> Tuple[Text, ModelMetadata, GraphRunner]: + """Unpacks a model from a given path using the graph model loader.""" + try: + if os.path.isfile(model_path): + model_tar = model_path + else: + model_file_path = get_latest_model(model_path) + if not model_file_path: + raise ModelNotFound(f"No model found at path '{model_path}'.") + model_tar = model_file_path + except TypeError: + raise ModelNotFound(f"Model {model_path} can not be loaded.") + + logger.info(f"Loading model {model_tar}...") + with TempDirectoryPath(get_temp_dir_name()) as temporary_directory: + try: + metadata, runner = loader.load_predict_graph_runner( + Path(temporary_directory), + Path(model_tar), + LocalModelStorage, + DaskGraphRunner, + ) + return os.path.basename(model_tar), metadata, runner + except tarfile.ReadError: + raise ModelNotFound(f"Model {model_path} can not be loaded.") + + async def handle_message( + self, message: UserMessage + ) -> Optional[List[Dict[Text, Any]]]: + """Handle a single message with this processor.""" + # preprocess message if necessary + tracker = await self.log_message(message, should_save_tracker=False) + + if self.model_metadata.training_type == TrainingType.NLU: + await self.save_tracker(tracker) + rasa.shared.utils.io.raise_warning( + "No core model. Skipping action prediction and execution.", + docs=DOCS_URL_POLICIES, + ) + return None + + tracker = await self.run_action_extract_slots(message.output_channel, tracker) + + await self._run_prediction_loop(message.output_channel, tracker) + + await self.run_anonymization_pipeline(tracker) + + await self.save_tracker(tracker) + + if isinstance(message.output_channel, CollectingOutputChannel): + return message.output_channel.messages + + return None + + async def run_action_extract_slots( + self, output_channel: OutputChannel, tracker: DialogueStateTracker + ) -> DialogueStateTracker: + """Run action to extract slots and update the tracker accordingly. + + Args: + output_channel: Output channel associated with the incoming user message. + tracker: A tracker representing a conversation state. + + Returns: + the given (updated) tracker + """ + action_extract_slots = rasa.core.actions.action.action_for_name_or_text( + ACTION_EXTRACT_SLOTS, self.domain, self.action_endpoint + ) + extraction_events = await action_extract_slots.run( + output_channel, self.nlg, tracker, self.domain + ) + + await self._send_bot_messages(extraction_events, tracker, output_channel) + + tracker.update_with_events(extraction_events, self.domain) + + structlogger.debug( + "processor.extract.slots", + action_extract_slot=ACTION_EXTRACT_SLOTS, + len_extraction_events=len(extraction_events), + rasa_events=copy.deepcopy(extraction_events), + ) + + return tracker + + async def run_anonymization_pipeline(self, tracker: DialogueStateTracker) -> None: + """Run the anonymization pipeline on the new tracker events. + + Args: + tracker: A tracker representing a conversation state. + """ + anonymization_pipeline = plugin_manager().hook.get_anonymization_pipeline() + if anonymization_pipeline is None: + return None + + old_tracker = await self.tracker_store.retrieve(tracker.sender_id) + new_events = rasa.shared.core.trackers.TrackerEventDiffEngine.event_difference( + old_tracker, tracker + ) + + for event in new_events: + body = {"sender_id": tracker.sender_id} + body.update(event.as_dict()) + anonymization_pipeline.run(body) + + async def predict_next_for_sender_id( + self, sender_id: Text + ) -> Optional[Dict[Text, Any]]: + """Predict the next action for the given sender_id. + + Args: + sender_id: Conversation ID. + + Returns: + The prediction for the next action. `None` if no domain or policies loaded. + """ + tracker = await self.fetch_tracker_and_update_session(sender_id) + result = self.predict_next_with_tracker(tracker) + + # save tracker state to continue conversation from this state + await self.save_tracker(tracker) + + return result + + def predict_next_with_tracker( + self, + tracker: DialogueStateTracker, + verbosity: EventVerbosity = EventVerbosity.AFTER_RESTART, + ) -> Optional[Dict[Text, Any]]: + """Predict the next action for a given conversation state. + + Args: + tracker: A tracker representing a conversation state. + verbosity: Verbosity for the returned conversation state. + + Returns: + The prediction for the next action. `None` if no domain or policies loaded. + """ + if self.model_metadata.training_type == TrainingType.NLU: + rasa.shared.utils.io.raise_warning( + "No core model. Skipping action prediction and execution.", + docs=DOCS_URL_POLICIES, + ) + return None + + prediction = self._predict_next_with_tracker(tracker) + + scores = [ + {"action": a, "score": p} + for a, p in zip(self.domain.action_names_or_texts, prediction.probabilities) + ] + return { + "scores": scores, + "policy": prediction.policy_name, + "confidence": prediction.max_confidence, + "tracker": tracker.current_state(verbosity), + } + + async def _update_tracker_session( + self, + tracker: DialogueStateTracker, + output_channel: OutputChannel, + metadata: Optional[Dict] = None, + ) -> None: + """Check the current session in `tracker` and update it if expired. + + An 'action_session_start' is run if the latest tracker session has expired, + or if the tracker does not yet contain any events (only those after the last + restart are considered). + + Args: + metadata: Data sent from client associated with the incoming user message. + tracker: Tracker to inspect. + output_channel: Output channel for potential utterances in a custom + `ActionSessionStart`. + """ + if not tracker.applied_events() or self._has_session_expired(tracker): + logger.debug( + f"Starting a new session for conversation ID '{tracker.sender_id}'." + ) + + action_session_start = self._get_action(ACTION_SESSION_START_NAME) + + if metadata: + tracker.update( + SlotSet(SESSION_START_METADATA_SLOT, metadata), self.domain + ) + + await self._run_action( + action=action_session_start, + tracker=tracker, + output_channel=output_channel, + nlg=self.nlg, + prediction=PolicyPrediction.for_action_name( + self.domain, ACTION_SESSION_START_NAME + ), + ) + + async def fetch_tracker_and_update_session( + self, + sender_id: Text, + output_channel: Optional[OutputChannel] = None, + metadata: Optional[Dict] = None, + ) -> DialogueStateTracker: + """Fetches tracker for `sender_id` and updates its conversation session. + + If a new tracker is created, `action_session_start` is run. + + Args: + metadata: Data sent from client associated with the incoming user message. + output_channel: Output channel associated with the incoming user message. + sender_id: Conversation ID for which to fetch the tracker. + + Returns: + Tracker for `sender_id`. + """ + tracker = await self.get_tracker(sender_id) + + await self._update_tracker_session(tracker, output_channel, metadata) + + return tracker + + async def fetch_tracker_with_initial_session( + self, + sender_id: Text, + output_channel: Optional[OutputChannel] = None, + metadata: Optional[Dict] = None, + ) -> DialogueStateTracker: + """Fetches tracker for `sender_id` and runs a session start if it's a new + tracker. + + Args: + metadata: Data sent from client associated with the incoming user message. + output_channel: Output channel associated with the incoming user message. + sender_id: Conversation ID for which to fetch the tracker. + + Returns: + Tracker for `sender_id`. + """ + tracker = await self.get_tracker(sender_id) + + # run session start only if the tracker is empty + if not tracker.events: + await self._update_tracker_session(tracker, output_channel, metadata) + + return tracker + + async def get_tracker(self, conversation_id: Text) -> DialogueStateTracker: + """Get the tracker for a conversation. + + In contrast to `fetch_tracker_and_update_session` this does not add any + `action_session_start` or `session_start` events at the beginning of a + conversation. + + Args: + conversation_id: The ID of the conversation for which the history should be + retrieved. + + Returns: + Tracker for the conversation. Creates an empty tracker in case it's a new + conversation. + """ + conversation_id = conversation_id or DEFAULT_SENDER_ID + + tracker = await self.tracker_store.get_or_create_tracker( + conversation_id, append_action_listen=False + ) + tracker.model_id = self.model_metadata.model_id + if tracker.assistant_id is None: + tracker.assistant_id = self.model_metadata.assistant_id + return tracker + + async def fetch_full_tracker_with_initial_session( + self, + conversation_id: Text, + output_channel: Optional[OutputChannel] = None, + metadata: Optional[Dict] = None, + ) -> DialogueStateTracker: + """Get the full tracker for a conversation, including events after a restart. + + Args: + conversation_id: The ID of the conversation for which the history should be + retrieved. + output_channel: Output channel associated with the incoming user message. + metadata: Data sent from client associated with the incoming user message. + + Returns: + Tracker for the conversation. Creates an empty tracker with a new session + initialized in case it's a new conversation. + """ + conversation_id = conversation_id or DEFAULT_SENDER_ID + + tracker = await self.tracker_store.get_or_create_full_tracker( + conversation_id, False + ) + tracker.model_id = self.model_metadata.model_id + + if tracker.assistant_id is None: + tracker.assistant_id = self.model_metadata.assistant_id + + if not tracker.events: + await self._update_tracker_session(tracker, output_channel, metadata) + + return tracker + + async def get_trackers_for_all_conversation_sessions( + self, conversation_id: Text + ) -> List[DialogueStateTracker]: + """Fetches all trackers for a conversation. + + Individual trackers are returned for each conversation session found + for `conversation_id`. + + Args: + conversation_id: The ID of the conversation for which the trackers should + be retrieved. + + Returns: + Trackers for the conversation. + """ + conversation_id = conversation_id or DEFAULT_SENDER_ID + + tracker = await self.tracker_store.retrieve_full_tracker(conversation_id) + + return rasa.shared.core.trackers.get_trackers_for_conversation_sessions(tracker) + + async def log_message( + self, message: UserMessage, should_save_tracker: bool = True + ) -> DialogueStateTracker: + """Log `message` on tracker belonging to the message's conversation_id. + + Optionally save the tracker if `should_save_tracker` is `True`. Tracker saving + can be skipped if the tracker returned by this method is used for further + processing and saved at a later stage. + """ + tracker = await self.fetch_tracker_and_update_session( + message.sender_id, message.output_channel, message.metadata + ) + + await self._handle_message_with_tracker(message, tracker) + + if should_save_tracker: + await self.save_tracker(tracker) + + return tracker + + async def execute_action( + self, + sender_id: Text, + action_name: Text, + output_channel: OutputChannel, + nlg: NaturalLanguageGenerator, + prediction: PolicyPrediction, + ) -> Optional[DialogueStateTracker]: + """Execute an action for a conversation. + + Note that this might lead to unexpected bot behavior. Rather use an intent + to execute certain behavior within a conversation (e.g. by using + `trigger_external_user_uttered`). + + Args: + sender_id: The ID of the conversation. + action_name: The name of the action which should be executed. + output_channel: The output channel which should be used for bot responses. + nlg: The response generator. + prediction: The prediction for the action. + + Returns: + The new conversation state. Note that the new state is also persisted. + """ + # we have a Tracker instance for each user + # which maintains conversation state + tracker = await self.fetch_tracker_and_update_session(sender_id, output_channel) + + action = self._get_action(action_name) + await self._run_action(action, tracker, output_channel, nlg, prediction) + + # save tracker state to continue conversation from this state + await self.save_tracker(tracker) + + return tracker + + def predict_next_with_tracker_if_should( + self, tracker: DialogueStateTracker + ) -> Tuple[rasa.core.actions.action.Action, PolicyPrediction]: + """Predicts the next action the bot should take after seeing x. + + This should be overwritten by more advanced policies to use + ML to predict the action. + + Returns: + The index of the next action and prediction of the policy. + + Raises: + ActionLimitReached if the limit of actions to predict has been reached. + """ + should_predict_another_action = self.should_predict_another_action( + tracker.latest_action_name + ) + + if self.is_action_limit_reached(tracker, should_predict_another_action): + raise ActionLimitReached( + "The limit of actions to predict has been reached." + ) + + prediction = self._predict_next_with_tracker(tracker) + + action = rasa.core.actions.action.action_for_index( + prediction.max_confidence_index, self.domain, self.action_endpoint + ) + + logger.debug( + f"Predicted next action '{action.name()}' with confidence " + f"{prediction.max_confidence:.2f}." + ) + + return action, prediction + + @staticmethod + def _is_reminder(e: Event, name: Text) -> bool: + return isinstance(e, ReminderScheduled) and e.name == name + + @staticmethod + def _is_reminder_still_valid( + tracker: DialogueStateTracker, reminder_event: ReminderScheduled + ) -> bool: + """Check if the conversation has been restarted after reminder.""" + for e in reversed(tracker.applied_events()): + if MessageProcessor._is_reminder(e, reminder_event.name): + return True + return False # not found in applied events --> has been restarted + + @staticmethod + def _has_message_after_reminder( + tracker: DialogueStateTracker, reminder_event: ReminderScheduled + ) -> bool: + """Check if the user sent a message after the reminder.""" + for e in reversed(tracker.events): + if MessageProcessor._is_reminder(e, reminder_event.name): + return False + + if isinstance(e, UserUttered) and e.text: + return True + + return True # tracker has probably been restarted + + async def handle_reminder( + self, + reminder_event: ReminderScheduled, + sender_id: Text, + output_channel: OutputChannel, + ) -> None: + """Handle a reminder that is triggered asynchronously.""" + async with self.lock_store.lock(sender_id): + tracker = await self.fetch_tracker_and_update_session( + sender_id, output_channel + ) + + if ( + reminder_event.kill_on_user_message + and self._has_message_after_reminder(tracker, reminder_event) + or not self._is_reminder_still_valid(tracker, reminder_event) + ): + logger.debug( + f"Canceled reminder because it is outdated ({reminder_event})." + ) + else: + intent = reminder_event.intent + entities: Union[List[Dict], Dict] = reminder_event.entities or {} + await self.trigger_external_user_uttered( + intent, entities, tracker, output_channel + ) + + async def trigger_external_user_uttered( + self, + intent_name: Text, + entities: Optional[Union[List[Dict[Text, Any]], Dict[Text, Text]]], + tracker: DialogueStateTracker, + output_channel: OutputChannel, + ) -> None: + """Triggers an external message. + + Triggers an external message (like a user message, but invisible; + used, e.g., by a reminder or the trigger_intent endpoint). + + Args: + intent_name: Name of the intent to be triggered. + entities: Entities to be passed on. + tracker: The tracker to which the event should be added. + output_channel: The output channel. + """ + if isinstance(entities, list): + entity_list = entities + elif isinstance(entities, dict): + # Allow for a short-hand notation {"ent1": "val1", "ent2": "val2", ...}. + # Useful if properties like 'start', 'end', or 'extractor' are not given, + # e.g. for external events. + entity_list = [ + {"entity": ent, "value": val} for ent, val in entities.items() + ] + elif not entities: + entity_list = [] + else: + rasa.shared.utils.io.raise_warning( + f"Invalid entity specification: {entities}. Assuming no entities." + ) + entity_list = [] + + # Set the new event's input channel to the latest input channel, so + # that we don't lose this property. + input_channel = tracker.get_latest_input_channel() + + tracker.update( + UserUttered.create_external(intent_name, entity_list, input_channel), + self.domain, + ) + + tracker = await self.run_action_extract_slots(output_channel, tracker) + + await self._run_prediction_loop(output_channel, tracker) + # save tracker state to continue conversation from this state + await self.save_tracker(tracker) + + @staticmethod + def _log_slots(tracker: DialogueStateTracker) -> None: + # Log currently set slots + slot_values = "\n".join( + [f"\t{s.name}: {s.value}" for s in tracker.slots.values()] + ) + if slot_values.strip(): + structlogger.debug( + "processor.slots.log", slot_values=copy.deepcopy(slot_values) + ) + + def _check_for_unseen_features(self, parse_data: Dict[Text, Any]) -> None: + """Warns the user if the NLU parse data contains unrecognized features. + + Checks intents and entities picked up by the NLU parsing + against the domain and warns the user of those that don't match. + Also considers a list of default intents that are valid but don't + need to be listed in the domain. + + Args: + parse_data: Message parse data to check against the domain. + """ + if not self.domain or self.domain.is_empty(): + return + + intent = parse_data["intent"][INTENT_NAME_KEY] + if intent and intent not in self.domain.intents: + rasa.shared.utils.io.raise_warning( + f"Parsed an intent '{intent}' " + f"which is not defined in the domain. " + f"Please make sure all intents are listed in the domain.", + docs=DOCS_URL_DOMAINS, + ) + + entities = parse_data["entities"] or [] + for element in entities: + entity = element["entity"] + if entity and entity not in self.domain.entities: + rasa.shared.utils.io.raise_warning( + f"Parsed an entity '{entity}' " + f"which is not defined in the domain. " + f"Please make sure all entities are listed in the domain.", + docs=DOCS_URL_DOMAINS, + ) + + def _get_action( + self, action_name: Text + ) -> Optional[rasa.core.actions.action.Action]: + return rasa.core.actions.action.action_for_name_or_text( + action_name, self.domain, self.action_endpoint + ) + + async def parse_message( + self, + message: UserMessage, + tracker: Optional[DialogueStateTracker] = None, + only_output_properties: bool = True, + ) -> Dict[Text, Any]: + """Interprets the passed message. + + Args: + message: Message to handle. + tracker: Tracker to use. + only_output_properties: If `True`, restrict the output to + Message.only_output_properties. + + Returns: + Parsed data extracted from the message. + """ + if self.http_interpreter: + parse_data = await self.http_interpreter.parse(message) + else: + if tracker is None: + tracker = DialogueStateTracker.from_events(message.sender_id, []) + parse_data = self._parse_message_with_graph( + message, tracker, only_output_properties + ) + + self._update_full_retrieval_intent(parse_data) + structlogger.debug( + "processor.message.parse", + parse_data_text=copy.deepcopy(parse_data["text"]), + parse_data_intent=parse_data["intent"], + parse_data_entities=copy.deepcopy(parse_data["entities"]), + ) + + self._check_for_unseen_features(parse_data) + + return parse_data + + def _update_full_retrieval_intent(self, parse_data: Dict[Text, Any]) -> None: + """Update the parse data with the full retrieval intent. + + Args: + parse_data: Message parse data to update. + """ + intent_name = parse_data.get(INTENT, {}).get(INTENT_NAME_KEY) + response_selector = parse_data.get(RESPONSE_SELECTOR, {}) + all_retrieval_intents = response_selector.get("all_retrieval_intents", []) + if intent_name and intent_name in all_retrieval_intents: + retrieval_intent = ( + response_selector.get(intent_name, {}) + .get(RESPONSE, {}) + .get(INTENT_RESPONSE_KEY) + ) + parse_data[INTENT][FULL_RETRIEVAL_INTENT_NAME_KEY] = retrieval_intent + + def _parse_message_with_graph( + self, + message: UserMessage, + tracker: DialogueStateTracker, + only_output_properties: bool = True, + ) -> Dict[Text, Any]: + """Interprets the passed message. + + Arguments: + message: Message to handle + tracker: Tracker to use + only_output_properties: If `True`, restrict the output to + Message.only_output_properties. + + Returns: + Parsed data extracted from the message. + """ + results = self.graph_runner.run( + inputs={PLACEHOLDER_MESSAGE: [message], PLACEHOLDER_TRACKER: tracker}, + targets=[self.model_metadata.nlu_target], + ) + parsed_messages = results[self.model_metadata.nlu_target] + parsed_message = parsed_messages[0] + parse_data = { + TEXT: "", + INTENT: {INTENT_NAME_KEY: None, PREDICTED_CONFIDENCE_KEY: 0.0}, + ENTITIES: [], + } + parse_data.update( + parsed_message.as_dict(only_output_properties=only_output_properties) + ) + return parse_data + + async def _handle_message_with_tracker( + self, message: UserMessage, tracker: DialogueStateTracker + ) -> None: + + if message.parse_data: + parse_data = message.parse_data + else: + parse_data = await self.parse_message(message, tracker) + + # don't ever directly mutate the tracker + # - instead pass its events to log + tracker.update( + UserUttered( + message.text, + parse_data["intent"], + parse_data["entities"], + parse_data, + input_channel=message.input_channel, + message_id=message.message_id, + metadata=message.metadata, + ), + self.domain, + ) + + if parse_data["entities"]: + self._log_slots(tracker) + + logger.debug( + f"Logged UserUtterance - tracker now has {len(tracker.events)} events." + ) + + @staticmethod + def _should_handle_message(tracker: DialogueStateTracker) -> bool: + return not tracker.is_paused() or ( + tracker.latest_message is not None + and tracker.latest_message.intent.get(INTENT_NAME_KEY) + == USER_INTENT_RESTART + ) + + def is_action_limit_reached( + self, tracker: DialogueStateTracker, should_predict_another_action: bool + ) -> bool: + """Check whether the maximum number of predictions has been met. + + Args: + tracker: instance of DialogueStateTracker. + should_predict_another_action: Whether the last executed action allows + for more actions to be predicted or not. + + Returns: + `True` if the limit of actions to predict has been reached. + """ + reversed_events = list(tracker.events)[::-1] + num_predicted_actions = 0 + + for e in reversed_events: + if isinstance(e, ActionExecuted): + if e.action_name in (ACTION_LISTEN_NAME, ACTION_SESSION_START_NAME): + break + num_predicted_actions += 1 + + return ( + num_predicted_actions >= self.max_number_of_predictions + and should_predict_another_action + ) + + async def _run_prediction_loop( + self, output_channel: OutputChannel, tracker: DialogueStateTracker + ) -> None: + # keep taking actions decided by the policy until it chooses to 'listen' + should_predict_another_action = True + + # action loop. predicts actions until we hit action listen + while should_predict_another_action and self._should_handle_message(tracker): + # this actually just calls the policy's method by the same name + try: + action, prediction = self.predict_next_with_tracker_if_should(tracker) + except ActionLimitReached: + logger.warning( + "Circuit breaker tripped. Stopped predicting " + f"more actions for sender '{tracker.sender_id}'." + ) + if self.on_circuit_break: + # call a registered callback + self.on_circuit_break(tracker, output_channel, self.nlg) + break + + if prediction.is_end_to_end_prediction: + logger.debug( + f"An end-to-end prediction was made which has triggered the 2nd " + f"execution of the default action '{ACTION_EXTRACT_SLOTS}'." + ) + tracker = await self.run_action_extract_slots(output_channel, tracker) + + should_predict_another_action = await self._run_action( + action, tracker, output_channel, self.nlg, prediction + ) + + @staticmethod + def should_predict_another_action(action_name: Text) -> bool: + """Determine whether the processor should predict another action. + + Args: + action_name: Name of the latest executed action. + + Returns: + `False` if `action_name` is `ACTION_LISTEN_NAME` or + `ACTION_SESSION_START_NAME`, otherwise `True`. + """ + return action_name not in (ACTION_LISTEN_NAME, ACTION_SESSION_START_NAME) + + async def execute_side_effects( + self, + events: List[Event], + tracker: DialogueStateTracker, + output_channel: OutputChannel, + ) -> None: + """Send bot messages, schedule and cancel reminders that are logged + in the events array. + """ + await self._send_bot_messages(events, tracker, output_channel) + await self._schedule_reminders(events, tracker, output_channel) + await self._cancel_reminders(events, tracker) + + @staticmethod + async def _send_bot_messages( + events: List[Event], + tracker: DialogueStateTracker, + output_channel: OutputChannel, + ) -> None: + """Send all the bot messages that are logged in the events array.""" + for e in events: + if not isinstance(e, BotUttered): + continue + + await output_channel.send_response(tracker.sender_id, e.message()) + + async def _schedule_reminders( + self, + events: List[Event], + tracker: DialogueStateTracker, + output_channel: OutputChannel, + ) -> None: + """Uses the scheduler to time a job to trigger the passed reminder. + + Reminders with the same `id` property will overwrite one another + (i.e. only one of them will eventually run). + """ + for e in events: + if not isinstance(e, ReminderScheduled): + continue + + (await jobs.scheduler()).add_job( + self.handle_reminder, + "date", + run_date=e.trigger_date_time, + args=[e, tracker.sender_id, output_channel], + id=e.name, + replace_existing=True, + name=e.scheduled_job_name(tracker.sender_id), + ) + + @staticmethod + async def _cancel_reminders( + events: List[Event], tracker: DialogueStateTracker + ) -> None: + """Cancel reminders that match the `ReminderCancelled` event.""" + # All Reminders specified by ReminderCancelled events will be cancelled + for event in events: + if isinstance(event, ReminderCancelled): + scheduler = await jobs.scheduler() + for scheduled_job in scheduler.get_jobs(): + if event.cancels_job_with_name( + scheduled_job.name, tracker.sender_id + ): + scheduler.remove_job(scheduled_job.id) + + async def _run_action( + self, + action: rasa.core.actions.action.Action, + tracker: DialogueStateTracker, + output_channel: OutputChannel, + nlg: NaturalLanguageGenerator, + prediction: PolicyPrediction, + ) -> bool: + # events and return values are used to update + # the tracker state after an action has been taken + try: + # Use temporary tracker as we might need to discard the policy events in + # case of a rejection. + temporary_tracker = tracker.copy() + temporary_tracker.update_with_events(prediction.events, self.domain) + events = await action.run( + output_channel, nlg, temporary_tracker, self.domain + ) + except rasa.core.actions.action.ActionExecutionRejection: + events = [ + ActionExecutionRejected( + action.name(), prediction.policy_name, prediction.max_confidence + ) + ] + tracker.update(events[0]) + return self.should_predict_another_action(action.name()) + except Exception: + logger.exception( + f"Encountered an exception while running action '{action.name()}'." + "Bot will continue, but the actions events are lost. " + "Please check the logs of your action server for " + "more information." + ) + events = [] + + self._log_action_on_tracker(tracker, action, events, prediction) + + if any(isinstance(e, UserUttered) for e in events): + logger.debug( + f"A `UserUttered` event was returned by executing " + f"action '{action.name()}'. This will run the default action " + f"'{ACTION_EXTRACT_SLOTS}'." + ) + tracker = await self.run_action_extract_slots(output_channel, tracker) + + if action.name() != ACTION_LISTEN_NAME and not action.name().startswith( + UTTER_PREFIX + ): + self._log_slots(tracker) + + await self.execute_side_effects(events, tracker, output_channel) + + return self.should_predict_another_action(action.name()) + + def _log_action_on_tracker( + self, + tracker: DialogueStateTracker, + action: Action, + events: Optional[List[Event]], + prediction: PolicyPrediction, + ) -> None: + # Ensures that the code still works even if a lazy programmer missed + # to type `return []` at the end of an action or the run method + # returns `None` for some other reason. + if events is None: + events = [] + + action_was_rejected_manually = any( + isinstance(event, ActionExecutionRejected) for event in events + ) + if not action_was_rejected_manually: + structlogger.debug( + "processor.actions.policy_prediction", + prediction_events=copy.deepcopy(prediction.events), + ) + tracker.update_with_events(prediction.events, self.domain) + + # log the action and its produced events + tracker.update(action.event_for_successful_execution(prediction)) + + structlogger.debug( + "processor.actions.log", + action_name=action.name(), + rasa_events=copy.deepcopy(events), + ) + tracker.update_with_events(events, self.domain) + + def _has_session_expired(self, tracker: DialogueStateTracker) -> bool: + """Determine whether the latest session in `tracker` has expired. + + Args: + tracker: Tracker to inspect. + + Returns: + `True` if the session in `tracker` has expired, `False` otherwise. + """ + if not self.domain.session_config.are_sessions_enabled(): + # tracker has never expired if sessions are disabled + return False + + user_uttered_event: Optional[UserUttered] = tracker.get_last_event_for( + UserUttered + ) + + if not user_uttered_event: + # there is no user event so far so the session should not be considered + # expired + return False + + time_delta_in_seconds = time.time() - user_uttered_event.timestamp + has_expired = ( + time_delta_in_seconds / 60 + > self.domain.session_config.session_expiration_time + ) + if has_expired: + logger.debug( + f"The latest session for conversation ID '{tracker.sender_id}' has " + f"expired." + ) + + return has_expired + + async def save_tracker(self, tracker: DialogueStateTracker) -> None: + """Save the given tracker to the tracker store. + + Args: + tracker: Tracker to be saved. + """ + await self.tracker_store.save(tracker) + + def _predict_next_with_tracker( + self, tracker: DialogueStateTracker + ) -> PolicyPrediction: + """Collect predictions from ensemble and return action and predictions.""" + followup_action = tracker.followup_action + if followup_action: + tracker.clear_followup_action() + if followup_action in self.domain.action_names_or_texts: + prediction = PolicyPrediction.for_action_name( + self.domain, followup_action, FOLLOWUP_ACTION + ) + return prediction + + logger.error( + f"Trying to run unknown follow-up action '{followup_action}'. " + "Instead of running that, Rasa Open Source will ignore the action " + "and predict the next action." + ) + + target = self.model_metadata.core_target + if not target: + raise ValueError("Cannot predict next action if there is no core target.") + + results = self.graph_runner.run( + inputs={PLACEHOLDER_TRACKER: tracker}, targets=[target] + ) + policy_prediction = results[target] + return policy_prediction diff --git a/rasa/core/run.py b/rasa/core/run.py new file mode 100644 index 0000000..3a81336 --- /dev/null +++ b/rasa/core/run.py @@ -0,0 +1,314 @@ +import asyncio +import logging +import uuid +import platform +import os +from functools import partial +from typing import ( + Any, + Callable, + List, + Optional, + Text, + Tuple, + Union, + Dict, +) + +import rasa.core.utils +from rasa.plugin import plugin_manager +from rasa.shared.exceptions import RasaException +import rasa.shared.utils.common +import rasa.utils +import rasa.utils.common +import rasa.utils.io +from rasa import server, telemetry +from rasa.constants import ENV_SANIC_BACKLOG +from rasa.core import agent, channels, constants +from rasa.core.agent import Agent +from rasa.core.channels import console +from rasa.core.channels.channel import InputChannel +from rasa.core.utils import AvailableEndpoints +import rasa.shared.utils.io +from sanic import Sanic +from asyncio import AbstractEventLoop + + +logger = logging.getLogger() # get the root logger + + +def create_http_input_channels( + channel: Optional[Text], credentials_file: Optional[Text] +) -> List["InputChannel"]: + """Instantiate the chosen input channel.""" + if credentials_file: + all_credentials = rasa.shared.utils.io.read_config_file(credentials_file) + else: + all_credentials = {} + + if channel: + if len(all_credentials) > 1: + logger.info( + "Connecting to channel '{}' which was specified by the " + "'--connector' argument. Any other channels will be ignored. " + "To connect to all given channels, omit the '--connector' " + "argument.".format(channel) + ) + return [_create_single_channel(channel, all_credentials.get(channel))] + else: + return [_create_single_channel(c, k) for c, k in all_credentials.items()] + + +def _create_single_channel(channel: Text, credentials: Dict[Text, Any]) -> Any: + from rasa.core.channels import BUILTIN_CHANNELS + + if channel in BUILTIN_CHANNELS: + return BUILTIN_CHANNELS[channel].from_credentials(credentials) + else: + # try to load channel based on class name + try: + input_channel_class = rasa.shared.utils.common.class_from_module_path( + channel + ) + return input_channel_class.from_credentials(credentials) + except (AttributeError, ImportError): + raise RasaException( + f"Failed to find input channel class for '{channel}'. Unknown " + f"input channel. Check your credentials configuration to " + f"make sure the mentioned channel is not misspelled. " + f"If you are creating your own channel, make sure it " + f"is a proper name of a class in a module." + ) + + +def _create_app_without_api(cors: Optional[Union[Text, List[Text]]] = None) -> Sanic: + app = Sanic("rasa_core_no_api", configure_logging=False) + server.add_root_route(app) + server.configure_cors(app, cors) + return app + + +def _is_apple_silicon_system() -> bool: + # check if the system is MacOS + if platform.system().lower() != "darwin": + return False + # check for arm architecture, indicating apple silicon + return platform.machine().startswith("arm") or os.uname().machine.startswith("arm") + + +def configure_app( + input_channels: Optional[List["InputChannel"]] = None, + cors: Optional[Union[Text, List[Text], None]] = None, + auth_token: Optional[Text] = None, + enable_api: bool = True, + response_timeout: int = constants.DEFAULT_RESPONSE_TIMEOUT, + jwt_secret: Optional[Text] = None, + jwt_private_key: Optional[Text] = None, + jwt_method: Optional[Text] = None, + route: Optional[Text] = "/webhooks/", + port: int = constants.DEFAULT_SERVER_PORT, + endpoints: Optional[AvailableEndpoints] = None, + log_file: Optional[Text] = None, + conversation_id: Optional[Text] = uuid.uuid4().hex, + use_syslog: bool = False, + syslog_address: Optional[Text] = None, + syslog_port: Optional[int] = None, + syslog_protocol: Optional[Text] = None, + request_timeout: Optional[int] = None, + server_listeners: Optional[List[Tuple[Callable, Text]]] = None, + use_uvloop: Optional[bool] = True, + keep_alive_timeout: int = constants.DEFAULT_KEEP_ALIVE_TIMEOUT, +) -> Sanic: + """Run the agent.""" + rasa.core.utils.configure_file_logging( + logger, log_file, use_syslog, syslog_address, syslog_port, syslog_protocol + ) + + if enable_api: + app = server.create_app( + cors_origins=cors, + auth_token=auth_token, + response_timeout=response_timeout, + jwt_secret=jwt_secret, + jwt_private_key=jwt_private_key, + jwt_method=jwt_method, + endpoints=endpoints, + ) + else: + app = _create_app_without_api(cors) + + app.config.KEEP_ALIVE_TIMEOUT = keep_alive_timeout + if _is_apple_silicon_system() or not use_uvloop: + app.config.USE_UVLOOP = False + # some library still sets the loop to uvloop, even if disabled for sanic + # using uvloop leads to breakingio errors, see + # https://rasahq.atlassian.net/browse/ENG-667 + asyncio.set_event_loop_policy(None) + + if input_channels: + channels.channel.register(input_channels, app, route=route) + else: + input_channels = [] + + if logger.isEnabledFor(logging.DEBUG): + rasa.core.utils.list_routes(app) + + async def configure_async_logging() -> None: + if logger.isEnabledFor(logging.DEBUG): + rasa.utils.io.enable_async_loop_debugging(asyncio.get_event_loop()) + + app.add_task(configure_async_logging) + + if "cmdline" in {c.name() for c in input_channels}: + + async def run_cmdline_io(running_app: Sanic) -> None: + """Small wrapper to shut down the server once cmd io is done.""" + await asyncio.sleep(1) # allow server to start + + await console.record_messages( + server_url=constants.DEFAULT_SERVER_FORMAT.format("http", port), + sender_id=conversation_id, + request_timeout=request_timeout, + ) + + logger.info("Killing Sanic server now.") + running_app.stop() # kill the sanic server + plugin_manager().hook.after_server_stop() + + app.add_task(run_cmdline_io) + + if server_listeners: + for (listener, event) in server_listeners: + app.register_listener(listener, event) + + return app + + +def serve_application( + model_path: Optional[Text] = None, + channel: Optional[Text] = None, + interface: Optional[Text] = constants.DEFAULT_SERVER_INTERFACE, + port: int = constants.DEFAULT_SERVER_PORT, + credentials: Optional[Text] = None, + cors: Optional[Union[Text, List[Text]]] = None, + auth_token: Optional[Text] = None, + enable_api: bool = True, + response_timeout: int = constants.DEFAULT_RESPONSE_TIMEOUT, + jwt_secret: Optional[Text] = None, + jwt_private_key: Optional[Text] = None, + jwt_method: Optional[Text] = None, + endpoints: Optional[AvailableEndpoints] = None, + remote_storage: Optional[Text] = None, + log_file: Optional[Text] = None, + ssl_certificate: Optional[Text] = None, + ssl_keyfile: Optional[Text] = None, + ssl_ca_file: Optional[Text] = None, + ssl_password: Optional[Text] = None, + conversation_id: Optional[Text] = uuid.uuid4().hex, + use_syslog: Optional[bool] = False, + syslog_address: Optional[Text] = None, + syslog_port: Optional[int] = None, + syslog_protocol: Optional[Text] = None, + request_timeout: Optional[int] = None, + server_listeners: Optional[List[Tuple[Callable, Text]]] = None, +) -> None: + """Run the API entrypoint.""" + if not channel and not credentials: + channel = "cmdline" + + input_channels = create_http_input_channels(channel, credentials) + + app = configure_app( + input_channels, + cors, + auth_token, + enable_api, + response_timeout, + jwt_secret, + jwt_private_key, + jwt_method, + port=port, + endpoints=endpoints, + log_file=log_file, + conversation_id=conversation_id, + use_syslog=use_syslog, + syslog_address=syslog_address, + syslog_port=syslog_port, + syslog_protocol=syslog_protocol, + request_timeout=request_timeout, + server_listeners=server_listeners, + ) + + ssl_context = server.create_ssl_context( + ssl_certificate, ssl_keyfile, ssl_ca_file, ssl_password + ) + protocol = "https" if ssl_context else "http" + + logger.info(f"Starting Rasa server on {protocol}://{interface}:{port}") + + app.register_listener( + partial(load_agent_on_start, model_path, endpoints, remote_storage), + "before_server_start", + ) + + app.register_listener(close_resources, "after_server_stop") + + number_of_workers = rasa.core.utils.number_of_sanic_workers( + endpoints.lock_store if endpoints else None + ) + + telemetry.track_server_start( + input_channels, endpoints, model_path, number_of_workers, enable_api + ) + + rasa.utils.common.update_sanic_log_level( + log_file, use_syslog, syslog_address, syslog_port, syslog_protocol + ) + + app.run( + host=interface, + port=port, + ssl=ssl_context, + backlog=int(os.environ.get(ENV_SANIC_BACKLOG, "100")), + workers=number_of_workers, + ) + + +# noinspection PyUnusedLocal +async def load_agent_on_start( + model_path: Text, + endpoints: AvailableEndpoints, + remote_storage: Optional[Text], + app: Sanic, + loop: AbstractEventLoop, +) -> Agent: + """Load an agent. + + Used to be scheduled on server start + (hence the `app` and `loop` arguments). + """ + app.ctx.agent = await agent.load_agent( + model_path=model_path, + remote_storage=remote_storage, + endpoints=endpoints, + loop=loop, + ) + logger.info("Rasa server is up and running.") + return app.ctx.agent + + +async def close_resources(app: Sanic, _: AbstractEventLoop) -> None: + """Gracefully closes resources when shutting down server. + + Args: + app: The Sanic application. + _: The current Sanic worker event loop. + """ + current_agent = getattr(app.ctx, "agent", None) + if not current_agent: + logger.debug("No agent found when shutting down server.") + return + + event_broker = current_agent.tracker_store.event_broker + if event_broker: + await event_broker.close() diff --git a/rasa/core/test.py b/rasa/core/test.py new file mode 100644 index 0000000..81215a9 --- /dev/null +++ b/rasa/core/test.py @@ -0,0 +1,1326 @@ +import logging +import os +from pathlib import Path +import tempfile +import warnings as pywarnings +from collections import defaultdict, namedtuple +from typing import Any, Dict, List, Optional, Text, Tuple, TYPE_CHECKING, cast + +from rasa import telemetry +from rasa.core.constants import ( + CONFUSION_MATRIX_STORIES_FILE, + REPORT_STORIES_FILE, + FAILED_STORIES_FILE, + SUCCESSFUL_STORIES_FILE, + STORIES_WITH_WARNINGS_FILE, +) +from rasa.core.channels import UserMessage +from rasa.core.policies.policy import PolicyPrediction +from rasa.nlu.test import EntityEvaluationResult, evaluate_entities +from rasa.nlu.tokenizers.tokenizer import Token +from rasa.shared.core.constants import ( + POLICIES_THAT_EXTRACT_ENTITIES, + ACTION_UNLIKELY_INTENT_NAME, +) +from rasa.shared.exceptions import RasaException +import rasa.shared.utils.io +from rasa.shared.core.training_data.story_writer.yaml_story_writer import ( + YAMLStoryWriter, +) +from rasa.shared.core.training_data.structures import StoryStep +from rasa.shared.core.domain import Domain +from rasa.nlu.constants import ( + RESPONSE_SELECTOR_DEFAULT_INTENT, + RESPONSE_SELECTOR_RETRIEVAL_INTENTS, + TOKENS_NAMES, + RESPONSE_SELECTOR_PROPERTY_NAME, +) +from rasa.shared.nlu.constants import ( + INTENT, + ENTITIES, + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + EXTRACTOR, + ENTITY_ATTRIBUTE_TYPE, + INTENT_RESPONSE_KEY, + INTENT_NAME_KEY, + RESPONSE, + RESPONSE_SELECTOR, + FULL_RETRIEVAL_INTENT_NAME_KEY, + TEXT, + ENTITY_ATTRIBUTE_TEXT, +) +from rasa.constants import RESULTS_FILE, PERCENTAGE_KEY +from rasa.shared.core.events import ActionExecuted, EntitiesAdded, UserUttered, SlotSet +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.nlu.training_data.formats.readerwriter import TrainingDataWriter +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.utils.io import DEFAULT_ENCODING +from rasa.utils.tensorflow.constants import QUERY_INTENT_KEY, SEVERITY_KEY +from rasa.exceptions import ActionLimitReached + +from rasa.core.actions.action import ActionRetrieveResponse + +if TYPE_CHECKING: + from rasa.core.agent import Agent + from rasa.core.processor import MessageProcessor + from rasa.shared.core.generator import TrainingDataGenerator + from rasa.shared.core.events import Event, EntityPrediction + +logger = logging.getLogger(__name__) + +StoryEvaluation = namedtuple( + "StoryEvaluation", + [ + "evaluation_store", + "failed_stories", + "successful_stories", + "stories_with_warnings", + "action_list", + "in_training_data_fraction", + ], +) + +PredictionList = List[Optional[Text]] + + +class WrongPredictionException(RasaException, ValueError): + """Raised if a wrong prediction is encountered.""" + + +class WarningPredictedAction(ActionExecuted): + """The model predicted the correct action with warning.""" + + type_name = "warning_predicted" + + def __init__( + self, + action_name_prediction: Text, + action_name: Optional[Text] = None, + policy: Optional[Text] = None, + confidence: Optional[float] = None, + timestamp: Optional[float] = None, + metadata: Optional[Dict] = None, + ): + """Creates event `action_unlikely_intent` predicted as warning. + + See the docstring of the parent class for more information. + """ + self.action_name_prediction = action_name_prediction + super().__init__(action_name, policy, confidence, timestamp, metadata) + + def inline_comment(self, **kwargs: Any) -> Text: + """A comment attached to this event. Used during dumping.""" + return f"predicted: {self.action_name_prediction}" + + +class WronglyPredictedAction(ActionExecuted): + """The model predicted the wrong action. + + Mostly used to mark wrong predictions and be able to + dump them as stories. + """ + + type_name = "wrong_action" + + def __init__( + self, + action_name_target: Text, + action_text_target: Text, + action_name_prediction: Text, + policy: Optional[Text] = None, + confidence: Optional[float] = None, + timestamp: Optional[float] = None, + metadata: Optional[Dict] = None, + predicted_action_unlikely_intent: bool = False, + ) -> None: + """Creates event for a successful event execution. + + See the docstring of the parent class `ActionExecuted` for more information. + """ + self.action_name_prediction = action_name_prediction + self.predicted_action_unlikely_intent = predicted_action_unlikely_intent + super().__init__( + action_name_target, + policy, + confidence, + timestamp, + metadata, + action_text=action_text_target, + ) + + def inline_comment(self, **kwargs: Any) -> Text: + """A comment attached to this event. Used during dumping.""" + comment = f"predicted: {self.action_name_prediction}" + if self.predicted_action_unlikely_intent: + return f"{comment} after {ACTION_UNLIKELY_INTENT_NAME}" + return comment + + def as_story_string(self) -> Text: + """Returns the story equivalent representation.""" + return f"{self.action_name} <!-- {self.inline_comment()} -->" + + def __repr__(self) -> Text: + """Returns event as string for debugging.""" + return ( + f"WronglyPredictedAction(action_target: {self.action_name}, " + f"action_prediction: {self.action_name_prediction}, " + f"policy: {self.policy}, confidence: {self.confidence}, " + f"metadata: {self.metadata})" + ) + + +class EvaluationStore: + """Class storing action, intent and entity predictions and targets.""" + + def __init__( + self, + action_predictions: Optional[PredictionList] = None, + action_targets: Optional[PredictionList] = None, + intent_predictions: Optional[PredictionList] = None, + intent_targets: Optional[PredictionList] = None, + entity_predictions: Optional[List["EntityPrediction"]] = None, + entity_targets: Optional[List["EntityPrediction"]] = None, + ) -> None: + """Initialize store attributes.""" + self.action_predictions = action_predictions or [] + self.action_targets = action_targets or [] + self.intent_predictions = intent_predictions or [] + self.intent_targets = intent_targets or [] + self.entity_predictions: List["EntityPrediction"] = entity_predictions or [] + self.entity_targets: List["EntityPrediction"] = entity_targets or [] + + def add_to_store( + self, + action_predictions: Optional[PredictionList] = None, + action_targets: Optional[PredictionList] = None, + intent_predictions: Optional[PredictionList] = None, + intent_targets: Optional[PredictionList] = None, + entity_predictions: Optional[List["EntityPrediction"]] = None, + entity_targets: Optional[List["EntityPrediction"]] = None, + ) -> None: + """Add items or lists of items to the store.""" + self.action_predictions.extend(action_predictions or []) + self.action_targets.extend(action_targets or []) + self.intent_targets.extend(intent_targets or []) + self.intent_predictions.extend(intent_predictions or []) + self.entity_predictions.extend(entity_predictions or []) + self.entity_targets.extend(entity_targets or []) + + def merge_store(self, other: "EvaluationStore") -> None: + """Add the contents of other to self.""" + self.add_to_store( + action_predictions=other.action_predictions, + action_targets=other.action_targets, + intent_predictions=other.intent_predictions, + intent_targets=other.intent_targets, + entity_predictions=other.entity_predictions, + entity_targets=other.entity_targets, + ) + + def _check_entity_prediction_target_mismatch(self) -> bool: + """Checks that same entities were expected and actually extracted. + + Possible duplicates or differences in order should not matter. + """ + deduplicated_targets = set( + tuple(entity.items()) for entity in self.entity_targets + ) + deduplicated_predictions = set( + tuple(entity.items()) for entity in self.entity_predictions + ) + return deduplicated_targets != deduplicated_predictions + + def check_prediction_target_mismatch(self) -> bool: + """Checks if intent, entity or action predictions don't match expected ones.""" + return ( + self.intent_predictions != self.intent_targets + or self._check_entity_prediction_target_mismatch() + or self.action_predictions != self.action_targets + ) + + @staticmethod + def _compare_entities( + entity_predictions: List["EntityPrediction"], + entity_targets: List["EntityPrediction"], + i_pred: int, + i_target: int, + ) -> int: + """ + Compare the current predicted and target entities and decide which one + comes first. If the predicted entity comes first it returns -1, + while it returns 1 if the target entity comes first. + If target and predicted are aligned it returns 0 + """ + pred = None + target = None + if i_pred < len(entity_predictions): + pred = entity_predictions[i_pred] + if i_target < len(entity_targets): + target = entity_targets[i_target] + if target and pred: + # Check which entity has the lower "start" value + if pred.get(ENTITY_ATTRIBUTE_START) < target.get(ENTITY_ATTRIBUTE_START): + return -1 + elif target.get(ENTITY_ATTRIBUTE_START) < pred.get(ENTITY_ATTRIBUTE_START): + return 1 + else: + # Since both have the same "start" values, + # check which one has the lower "end" value + if pred.get(ENTITY_ATTRIBUTE_END) < target.get(ENTITY_ATTRIBUTE_END): + return -1 + elif target.get(ENTITY_ATTRIBUTE_END) < pred.get(ENTITY_ATTRIBUTE_END): + return 1 + else: + # The entities have the same "start" and "end" values + return 0 + return 1 if target else -1 + + @staticmethod + def _generate_entity_training_data(entity: Dict[Text, Any]) -> Text: + return TrainingDataWriter.generate_entity(entity.get("text"), entity) + + def serialise(self) -> Tuple[PredictionList, PredictionList]: + """Turn targets and predictions to lists of equal size for sklearn.""" + texts = sorted( + set( + [str(e.get("text", "")) for e in self.entity_targets] + + [str(e.get("text", "")) for e in self.entity_predictions] + ) + ) + + aligned_entity_targets: List[Optional[Text]] = [] + aligned_entity_predictions: List[Optional[Text]] = [] + + for text in texts: + # sort the entities of this sentence to compare them directly + entity_targets = sorted( + filter( + lambda x: x.get(ENTITY_ATTRIBUTE_TEXT) == text, self.entity_targets + ), + key=lambda x: x[ENTITY_ATTRIBUTE_START], # type: ignore[literal-required] # noqa: E501 + ) + entity_predictions = sorted( + filter( + lambda x: x.get(ENTITY_ATTRIBUTE_TEXT) == text, + self.entity_predictions, + ), + key=lambda x: x[ENTITY_ATTRIBUTE_START], # type: ignore[literal-required] # noqa: E501 + ) + + i_pred, i_target = 0, 0 + + while i_pred < len(entity_predictions) or i_target < len(entity_targets): + cmp = self._compare_entities( + entity_predictions, entity_targets, i_pred, i_target + ) + if cmp == -1: # predicted comes first + aligned_entity_predictions.append( + self._generate_entity_training_data(entity_predictions[i_pred]) + ) + aligned_entity_targets.append("None") + i_pred += 1 + elif cmp == 1: # target entity comes first + aligned_entity_targets.append( + self._generate_entity_training_data(entity_targets[i_target]) + ) + aligned_entity_predictions.append("None") + i_target += 1 + else: # target and predicted entity are aligned + aligned_entity_predictions.append( + self._generate_entity_training_data(entity_predictions[i_pred]) + ) + aligned_entity_targets.append( + self._generate_entity_training_data(entity_targets[i_target]) + ) + i_pred += 1 + i_target += 1 + + targets = self.action_targets + self.intent_targets + aligned_entity_targets + + predictions = ( + self.action_predictions + + self.intent_predictions + + aligned_entity_predictions + ) + return targets, predictions + + +class EndToEndUserUtterance(UserUttered): + """End-to-end user utterance. + + Mostly used to print the full end-to-end user message in the + `failed_test_stories.yml` output file. + """ + + def as_story_string(self, e2e: bool = True) -> Text: + """Returns the story equivalent representation.""" + return super().as_story_string(e2e=True) + + +class WronglyClassifiedUserUtterance(UserUttered): + """The NLU model predicted the wrong user utterance. + + Mostly used to mark wrong predictions and be able to + dump them as stories.""" + + type_name = "wrong_utterance" + + def __init__(self, event: UserUttered, eval_store: EvaluationStore) -> None: + """Set `predicted_intent` and `predicted_entities` attributes.""" + try: + self.predicted_intent = eval_store.intent_predictions[0] + except LookupError: + self.predicted_intent = None + + self.target_entities = eval_store.entity_targets + self.predicted_entities = eval_store.entity_predictions + + intent = {"name": eval_store.intent_targets[0]} + + super().__init__( + event.text, + intent, + eval_store.entity_targets, + event.parse_data, + event.timestamp, + event.input_channel, + ) + + def inline_comment(self, force_comment_generation: bool = False) -> Optional[Text]: + """A comment attached to this event. Used during dumping.""" + from rasa.shared.core.events import format_message + + if force_comment_generation or self.predicted_intent != self.intent["name"]: + predicted_message = format_message( + self.text, self.predicted_intent, self.predicted_entities + ) + + return f"predicted: {self.predicted_intent}: {predicted_message}" + else: + return None + + @staticmethod + def inline_comment_for_entity( + predicted: Dict[Text, Any], entity: Dict[Text, Any] + ) -> Optional[Text]: + """Returns the predicted entity which is then printed as a comment.""" + if predicted["entity"] != entity["entity"]: + return "predicted: " + predicted["entity"] + ": " + predicted["value"] + else: + return None + + def as_story_string(self, e2e: bool = True) -> Text: + """Returns text representation of event.""" + from rasa.shared.core.events import format_message + + correct_message = format_message( + self.text, self.intent.get("name"), self.entities + ) + return ( + f"{self.intent.get('name')}: {correct_message} " + f"<!-- {self.inline_comment()} -->" + ) + + +def _create_data_generator( + resource_name: Text, + agent: "Agent", + max_stories: Optional[int] = None, + use_conversation_test_files: bool = False, +) -> "TrainingDataGenerator": + from rasa.shared.core.generator import TrainingDataGenerator + + tmp_domain_path = Path(tempfile.mkdtemp()) / "domain.yaml" + domain = agent.domain if agent.domain is not None else Domain.empty() + domain.persist(tmp_domain_path) + test_data_importer = TrainingDataImporter.load_from_dict( + training_data_paths=[resource_name], domain_path=str(tmp_domain_path) + ) + if use_conversation_test_files: + story_graph = test_data_importer.get_conversation_tests() + else: + story_graph = test_data_importer.get_stories() + + return TrainingDataGenerator( + story_graph, + agent.domain, + use_story_concatenation=False, + augmentation_factor=0, + tracker_limit=max_stories, + ) + + +def _clean_entity_results( + text: Text, entity_results: List[Dict[Text, Any]] +) -> List["EntityPrediction"]: + """Extract only the token variables from an entity dict.""" + cleaned_entities = [] + + for r in tuple(entity_results): + cleaned_entity: EntityPrediction = {ENTITY_ATTRIBUTE_TEXT: text} # type: ignore[misc] # noqa E501 + for k in ( + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_VALUE, + ): + if k in set(r): + if k == ENTITY_ATTRIBUTE_VALUE and EXTRACTOR in set(r): + # convert values to strings for evaluation as + # target values are all of type string + r[k] = str(r[k]) + cleaned_entity[k] = r[k] # type: ignore[literal-required] + cleaned_entities.append(cleaned_entity) + + return cleaned_entities + + +def _get_full_retrieval_intent(parsed: Dict[Text, Any]) -> Text: + """Return full retrieval intent, if it's present, or normal intent otherwise. + + Args: + parsed: Predicted parsed data. + + Returns: + The extracted intent. + """ + base_intent = parsed.get(INTENT, {}).get(INTENT_NAME_KEY) + response_selector = parsed.get(RESPONSE_SELECTOR, {}) + + # return normal intent if it's not a retrieval intent + if base_intent not in response_selector.get( + RESPONSE_SELECTOR_RETRIEVAL_INTENTS, {} + ): + return base_intent + + # extract full retrieval intent + # if the response selector parameter was not specified in config, + # the response selector contains a "default" key + if RESPONSE_SELECTOR_DEFAULT_INTENT in response_selector: + full_retrieval_intent = ( + response_selector.get(RESPONSE_SELECTOR_DEFAULT_INTENT, {}) + .get(RESPONSE, {}) + .get(INTENT_RESPONSE_KEY) + ) + return full_retrieval_intent if full_retrieval_intent else base_intent + + # if specified, the response selector contains the base intent as key + full_retrieval_intent = ( + response_selector.get(base_intent, {}) + .get(RESPONSE, {}) + .get(INTENT_RESPONSE_KEY) + ) + return full_retrieval_intent if full_retrieval_intent else base_intent + + +def _collect_user_uttered_predictions( + event: UserUttered, + predicted: Dict[Text, Any], + partial_tracker: DialogueStateTracker, + fail_on_prediction_errors: bool, +) -> EvaluationStore: + user_uttered_eval_store = EvaluationStore() + + # intent from the test story, may either be base intent or full retrieval intent + base_intent = event.intent.get(INTENT_NAME_KEY) + full_retrieval_intent = event.intent.get(FULL_RETRIEVAL_INTENT_NAME_KEY) + intent_gold = full_retrieval_intent if full_retrieval_intent else base_intent + + # predicted intent: note that this is only the base intent at this point + predicted_base_intent = predicted.get(INTENT, {}).get(INTENT_NAME_KEY) + # if the test story only provides the base intent AND the prediction was correct, + # we are not interested in full retrieval intents and skip this section. + # In any other case we are interested in the full retrieval intent (e.g. for report) + if intent_gold != predicted_base_intent: + predicted_base_intent = _get_full_retrieval_intent(predicted) + + user_uttered_eval_store.add_to_store( + intent_targets=[intent_gold], intent_predictions=[predicted_base_intent] + ) + + entity_gold = event.entities + predicted_entities = predicted.get(ENTITIES) + + if entity_gold or predicted_entities: + user_uttered_eval_store.add_to_store( + entity_targets=_clean_entity_results(event.text, entity_gold), + entity_predictions=_clean_entity_results(event.text, predicted_entities), + ) + + if user_uttered_eval_store.check_prediction_target_mismatch(): + partial_tracker.update( + WronglyClassifiedUserUtterance(event, user_uttered_eval_store) + ) + if fail_on_prediction_errors: + story_dump = YAMLStoryWriter().dumps(partial_tracker.as_story().story_steps) + raise WrongPredictionException( + f"NLU model predicted a wrong intent or entities. Failed Story:" + f" \n\n{story_dump}" + ) + else: + response_selector_info = ( + { + RESPONSE_SELECTOR_PROPERTY_NAME: predicted[ + RESPONSE_SELECTOR_PROPERTY_NAME + ] + } + if RESPONSE_SELECTOR_PROPERTY_NAME in predicted + else None + ) + end_to_end_user_utterance = EndToEndUserUtterance( + text=event.text, + intent=event.intent, + entities=event.entities, + parse_data=response_selector_info, + ) + partial_tracker.update(end_to_end_user_utterance) + + return user_uttered_eval_store + + +def emulate_loop_rejection(partial_tracker: DialogueStateTracker) -> None: + """Add `ActionExecutionRejected` event to the tracker. + + During evaluation, we don't run action server, therefore in order to correctly + test unhappy paths of the loops, we need to emulate loop rejection. + + Args: + partial_tracker: a :class:`rasa.core.trackers.DialogueStateTracker` + """ + from rasa.shared.core.events import ActionExecutionRejected + + rejected_action_name = partial_tracker.active_loop_name + partial_tracker.update(ActionExecutionRejected(rejected_action_name)) + + +async def _get_e2e_entity_evaluation_result( + processor: "MessageProcessor", + tracker: DialogueStateTracker, + prediction: PolicyPrediction, +) -> Optional[EntityEvaluationResult]: + previous_event: Optional["Event"] = tracker.events[-1] + + if isinstance(previous_event, SlotSet): + # UserUttered events with entities can be followed by SlotSet events + # if slots are defined in the domain + previous_event = tracker.get_last_event_for((UserUttered, ActionExecuted)) + + if isinstance(previous_event, UserUttered): + entities_predicted_by_policies = [ + entity + for prediction_event in prediction.events + if isinstance(prediction_event, EntitiesAdded) + for entity in prediction_event.entities + ] + entity_targets = previous_event.entities + if entity_targets or entities_predicted_by_policies: + text = previous_event.text + if text: + parsed_message = await processor.parse_message(UserMessage(text=text)) + if parsed_message: + tokens = [ + Token(text[start:end], start, end) + for start, end in parsed_message.get(TOKENS_NAMES[TEXT], []) + ] + return EntityEvaluationResult( + entity_targets, entities_predicted_by_policies, tokens, text + ) + return None + + +def _get_predicted_action_name( + predicted_action: rasa.core.actions.action.Action, + partial_tracker: DialogueStateTracker, + expected_action_name: Text, +) -> Text: + """Get the name of predicted action. + + If the action is instance of `ActionRetrieveResponse`, we need to return full + action name with its retrieval intent (e.g. utter_faq/is-this-legit). + The only case when we should not do it is when an expected action given in + a test story is a retrieval action but it's not specified in the test story. + To illustrate this, we're basically avoiding this unnecessary mismatch: + utter_faq (expected) != utter_faq/is-this-legit (predicted). + In this case or if the action isn't instance of `ActionRetrieveResponse`, + the function returns only the action name (e.g. utter_faq). + """ + if ( + isinstance(predicted_action, ActionRetrieveResponse) + and expected_action_name != predicted_action.name() + ): + full_retrieval_name = predicted_action.get_full_retrieval_name(partial_tracker) + predicted_action_name = ( + full_retrieval_name if full_retrieval_name else predicted_action.name() + ) + else: + predicted_action_name = predicted_action.name() + return predicted_action_name + + +async def _run_action_prediction( + processor: "MessageProcessor", + partial_tracker: DialogueStateTracker, + expected_action: Text, +) -> Tuple[Text, PolicyPrediction, Optional[EntityEvaluationResult]]: + action, prediction = processor.predict_next_with_tracker_if_should(partial_tracker) + predicted_action = _get_predicted_action_name( + action, partial_tracker, expected_action + ) + + policy_entity_result = await _get_e2e_entity_evaluation_result( + processor, partial_tracker, prediction + ) + if ( + prediction.policy_name + and predicted_action != expected_action + and _form_might_have_been_rejected( + processor.domain, partial_tracker, predicted_action + ) + ): + # Wrong action was predicted, + # but it might be Ok if form action is rejected. + emulate_loop_rejection(partial_tracker) + # try again + action, prediction = processor.predict_next_with_tracker_if_should( + partial_tracker + ) + # Even if the prediction is also wrong, we don't have to undo the emulation + # of the action rejection as we know that the user explicitly specified + # that something else than the form was supposed to run. + predicted_action = _get_predicted_action_name( + action, partial_tracker, expected_action + ) + + return predicted_action, prediction, policy_entity_result + + +async def _collect_action_executed_predictions( + processor: "MessageProcessor", + partial_tracker: DialogueStateTracker, + event: ActionExecuted, + fail_on_prediction_errors: bool, +) -> Tuple[EvaluationStore, PolicyPrediction, Optional[EntityEvaluationResult]]: + + action_executed_eval_store = EvaluationStore() + + expected_action_name = event.action_name + expected_action_text = event.action_text + expected_action = expected_action_name or expected_action_text + + policy_entity_result = None + prev_action_unlikely_intent = False + + try: + ( + predicted_action, + prediction, + policy_entity_result, + ) = await _run_action_prediction(processor, partial_tracker, expected_action) + except ActionLimitReached: + prediction = PolicyPrediction([], policy_name=None) + predicted_action = "circuit breaker tripped" + + predicted_action_unlikely_intent = predicted_action == ACTION_UNLIKELY_INTENT_NAME + if predicted_action_unlikely_intent and predicted_action != expected_action: + partial_tracker.update( + WronglyPredictedAction( + predicted_action, + expected_action_text, + predicted_action, + prediction.policy_name, + prediction.max_confidence, + event.timestamp, + metadata=prediction.action_metadata, + ) + ) + prev_action_unlikely_intent = True + + try: + ( + predicted_action, + prediction, + policy_entity_result, + ) = await _run_action_prediction( + processor, partial_tracker, expected_action + ) + except ActionLimitReached: + prediction = PolicyPrediction([], policy_name=None) + predicted_action = "circuit breaker tripped" + + action_executed_eval_store.add_to_store( + action_predictions=[predicted_action], action_targets=[expected_action] + ) + + if action_executed_eval_store.check_prediction_target_mismatch(): + partial_tracker.update( + WronglyPredictedAction( + expected_action_name, + expected_action_text, + predicted_action, + prediction.policy_name, + prediction.max_confidence, + event.timestamp, + metadata=prediction.action_metadata, + predicted_action_unlikely_intent=prev_action_unlikely_intent, + ) + ) + if ( + fail_on_prediction_errors + and predicted_action != ACTION_UNLIKELY_INTENT_NAME + and predicted_action != expected_action + ): + story_dump = YAMLStoryWriter().dumps(partial_tracker.as_story().story_steps) + error_msg = ( + f"Model predicted a wrong action. Failed Story: " f"\n\n{story_dump}" + ) + raise WrongPredictionException(error_msg) + elif prev_action_unlikely_intent: + partial_tracker.update( + WarningPredictedAction( + ACTION_UNLIKELY_INTENT_NAME, + predicted_action, + prediction.policy_name, + prediction.max_confidence, + event.timestamp, + prediction.action_metadata, + ) + ) + else: + partial_tracker.update( + ActionExecuted( + predicted_action, + prediction.policy_name, + prediction.max_confidence, + event.timestamp, + metadata=prediction.action_metadata, + ) + ) + + return action_executed_eval_store, prediction, policy_entity_result + + +def _form_might_have_been_rejected( + domain: Domain, tracker: DialogueStateTracker, predicted_action_name: Text +) -> bool: + return ( + tracker.active_loop_name == predicted_action_name + and predicted_action_name in domain.form_names + ) + + +async def _predict_tracker_actions( + tracker: DialogueStateTracker, + agent: "Agent", + fail_on_prediction_errors: bool = False, + use_e2e: bool = False, +) -> Tuple[ + EvaluationStore, + DialogueStateTracker, + List[Dict[Text, Any]], + List[EntityEvaluationResult], +]: + + processor = agent.processor + if agent.processor is not None: + processor = agent.processor + else: + raise RasaException( + "The agent's processor has not been instantiated. " + "The processor needs to be defined before running " + "prediction." + ) + + tracker_eval_store = EvaluationStore() + + events = list(tracker.events) + + slots = agent.domain.slots if agent.domain is not None else [] + + partial_tracker = DialogueStateTracker.from_events( + tracker.sender_id, + events[:1], + slots, + sender_source=tracker.sender_source, + ) + tracker_actions = [] + policy_entity_results = [] + + for event in events[1:]: + if isinstance(event, ActionExecuted): + ( + action_executed_result, + prediction, + entity_result, + ) = await _collect_action_executed_predictions( + processor, partial_tracker, event, fail_on_prediction_errors + ) + if entity_result: + policy_entity_results.append(entity_result) + + if action_executed_result.action_targets: + tracker_eval_store.merge_store(action_executed_result) + tracker_actions.append( + { + "action": action_executed_result.action_targets[0], + "predicted": action_executed_result.action_predictions[0], + "policy": prediction.policy_name, + "confidence": prediction.max_confidence, + } + ) + elif use_e2e and isinstance(event, UserUttered): + # This means that user utterance didn't have a user message, only intent, + # so we can skip the NLU part and take the parse data directly. + # Indirectly that means that the test story was in YAML format. + if not event.text: + # FIXME: better type annotation for `parse_data` would require + # a larger refactoring (e.g. switch to dataclass) + predicted = cast(Dict[Text, Any], event.parse_data) + # Indirectly that means that the test story was either: + # in YAML format containing a user message, or in Markdown format. + # Leaving that as it is because Markdown is in legacy mode. + else: + predicted = await processor.parse_message(UserMessage(event.text)) + + user_uttered_result = _collect_user_uttered_predictions( + event, predicted, partial_tracker, fail_on_prediction_errors + ) + tracker_eval_store.merge_store(user_uttered_result) + else: + partial_tracker.update(event) + return tracker_eval_store, partial_tracker, tracker_actions, policy_entity_results + + +def _in_training_data_fraction(action_list: List[Dict[Text, Any]]) -> float: + """Given a list of actions, returns the fraction predicted by non ML policies.""" + import rasa.core.policies.ensemble + + in_training_data = [ + a["action"] + for a in action_list + if a["policy"] + and not rasa.core.policies.ensemble.is_not_in_training_data(a["policy"]) + ] + + return len(in_training_data) / len(action_list) if action_list else 0 + + +def _sort_trackers_with_severity_of_warning( + trackers_to_sort: List[DialogueStateTracker], +) -> List[DialogueStateTracker]: + """Sort the given trackers according to 'severity' of `action_unlikely_intent`. + + Severity is calculated by `IntentTEDPolicy` and is attached as + metadata to `ActionExecuted` event. + + Args: + trackers_to_sort: Trackers to be sorted + + Returns: + Sorted trackers in descending order of severity. + """ + tracker_severity_scores = [] + for tracker in trackers_to_sort: + max_severity = 0 + for event in tracker.applied_events(): + if ( + isinstance(event, WronglyPredictedAction) + and event.action_name_prediction == ACTION_UNLIKELY_INTENT_NAME + ): + max_severity = max( + max_severity, + event.metadata.get(QUERY_INTENT_KEY, {}).get(SEVERITY_KEY, 0), + ) + tracker_severity_scores.append(max_severity) + + sorted_trackers_with_severity = sorted( + zip(tracker_severity_scores, trackers_to_sort), + # tuple unpacking is not supported in + # python 3.x that's why it might look a bit weird + key=lambda severity_tracker_tuple: -severity_tracker_tuple[0], + ) + + return [tracker for (_, tracker) in sorted_trackers_with_severity] + + +async def _collect_story_predictions( + completed_trackers: List["DialogueStateTracker"], + agent: "Agent", + fail_on_prediction_errors: bool = False, + use_e2e: bool = False, +) -> Tuple[StoryEvaluation, int, List[EntityEvaluationResult]]: + """Test the stories from a file, running them through the stored model.""" + from sklearn.metrics import accuracy_score + from tqdm import tqdm + + story_eval_store = EvaluationStore() + failed_stories = [] + successful_stories = [] + stories_with_warnings = [] + correct_dialogues = [] + number_of_stories = len(completed_trackers) + + logger.info(f"Evaluating {number_of_stories} stories\nProgress:") + + action_list = [] + entity_results = [] + + for tracker in tqdm(completed_trackers): + ( + tracker_results, + predicted_tracker, + tracker_actions, + tracker_entity_results, + ) = await _predict_tracker_actions( + tracker, agent, fail_on_prediction_errors, use_e2e + ) + + entity_results.extend(tracker_entity_results) + + story_eval_store.merge_store(tracker_results) + + action_list.extend(tracker_actions) + + if tracker_results.check_prediction_target_mismatch(): + # there is at least one wrong prediction + failed_stories.append(predicted_tracker) + correct_dialogues.append(0) + else: + successful_stories.append(predicted_tracker) + correct_dialogues.append(1) + + if any( + isinstance(event, WronglyPredictedAction) + and event.action_name_prediction == ACTION_UNLIKELY_INTENT_NAME + for event in predicted_tracker.events + ): + stories_with_warnings.append(predicted_tracker) + + logger.info("Finished collecting predictions.") + + in_training_data_fraction = _in_training_data_fraction(action_list) + + if len(correct_dialogues): + accuracy = accuracy_score([1] * len(correct_dialogues), correct_dialogues) + else: + accuracy = 0 + + _log_evaluation_table([1] * len(completed_trackers), "CONVERSATION", accuracy) + + return ( + StoryEvaluation( + evaluation_store=story_eval_store, + failed_stories=failed_stories, + successful_stories=successful_stories, + stories_with_warnings=_sort_trackers_with_severity_of_warning( + stories_with_warnings + ), + action_list=action_list, + in_training_data_fraction=in_training_data_fraction, + ), + number_of_stories, + entity_results, + ) + + +def _filter_step_events(step: StoryStep) -> StoryStep: + events = [] + for event in step.events: + if ( + isinstance(event, WronglyPredictedAction) + and event.action_name + == event.action_name_prediction + == ACTION_UNLIKELY_INTENT_NAME + ): + continue + events.append(event) + updated_step = step.create_copy(use_new_id=False) + updated_step.events = events + return updated_step + + +def _log_stories( + trackers: List[DialogueStateTracker], file_path: Text, message_if_no_trackers: Text +) -> None: + """Write given stories to the given file.""" + with open(file_path, "w", encoding=DEFAULT_ENCODING) as f: + if not trackers: + f.write(f"# {message_if_no_trackers}") + else: + stories = [tracker.as_story(include_source=True) for tracker in trackers] + steps = [ + _filter_step_events(step) + for story in stories + for step in story.story_steps + ] + f.write(YAMLStoryWriter().dumps(steps)) + + +async def test( + stories: Text, + agent: "Agent", + max_stories: Optional[int] = None, + out_directory: Optional[Text] = None, + fail_on_prediction_errors: bool = False, + e2e: bool = False, + disable_plotting: bool = False, + successes: bool = False, + errors: bool = True, + warnings: bool = True, +) -> Dict[Text, Any]: + """Run the evaluation of the stories, optionally plot the results. + + Args: + stories: the stories to evaluate on + agent: the agent + max_stories: maximum number of stories to consider + out_directory: path to directory to results to + fail_on_prediction_errors: boolean indicating whether to fail on prediction + errors or not + e2e: boolean indicating whether to use end to end evaluation or not + disable_plotting: boolean indicating whether to disable plotting or not + successes: boolean indicating whether to write down successful predictions or + not + errors: boolean indicating whether to write down incorrect predictions or not + warnings: boolean indicating whether to write down prediction warnings or not + + Returns: + Evaluation summary. + """ + from rasa.model_testing import get_evaluation_metrics + + generator = _create_data_generator(stories, agent, max_stories, e2e) + completed_trackers = generator.generate_story_trackers() + + story_evaluation, _, entity_results = await _collect_story_predictions( + completed_trackers, agent, fail_on_prediction_errors, use_e2e=e2e + ) + + evaluation_store = story_evaluation.evaluation_store + + with pywarnings.catch_warnings(): + from sklearn.exceptions import UndefinedMetricWarning + + pywarnings.simplefilter("ignore", UndefinedMetricWarning) + + targets, predictions = evaluation_store.serialise() + + report, precision, f1, action_accuracy = get_evaluation_metrics( + targets, predictions, output_dict=True + ) + if out_directory: + # Add conversation level accuracy to story report. + num_failed = len(story_evaluation.failed_stories) + num_correct = len(story_evaluation.successful_stories) + num_warnings = len(story_evaluation.stories_with_warnings) + num_convs = num_failed + num_correct + if num_convs and isinstance(report, Dict): + conv_accuracy = num_correct / num_convs + report["conversation_accuracy"] = { + "accuracy": conv_accuracy, + "correct": num_correct, + "with_warnings": num_warnings, + "total": num_convs, + } + report_filename = os.path.join(out_directory, REPORT_STORIES_FILE) + rasa.shared.utils.io.dump_obj_as_json_to_file(report_filename, report) + logger.info(f"Stories report saved to {report_filename}.") + + evaluate_entities( + entity_results, + POLICIES_THAT_EXTRACT_ENTITIES, + out_directory, + successes, + errors, + disable_plotting, + ) + + telemetry.track_core_model_test(len(generator.story_graph.story_steps), e2e, agent) + + _log_evaluation_table( + evaluation_store.action_targets, + "ACTION", + action_accuracy, + precision=precision, + f1=f1, + in_training_data_fraction=story_evaluation.in_training_data_fraction, + ) + + if not disable_plotting and out_directory: + _plot_story_evaluation( + evaluation_store.action_targets, + evaluation_store.action_predictions, + out_directory, + ) + + if errors and out_directory: + _log_stories( + story_evaluation.failed_stories, + os.path.join(out_directory, FAILED_STORIES_FILE), + "None of the test stories failed - all good!", + ) + if successes and out_directory: + _log_stories( + story_evaluation.successful_stories, + os.path.join(out_directory, SUCCESSFUL_STORIES_FILE), + "None of the test stories succeeded :(", + ) + if warnings and out_directory: + _log_stories( + story_evaluation.stories_with_warnings, + os.path.join(out_directory, STORIES_WITH_WARNINGS_FILE), + "No warnings for test stories", + ) + + return { + "report": report, + "precision": precision, + "f1": f1, + "accuracy": action_accuracy, + "actions": story_evaluation.action_list, + "in_training_data_fraction": story_evaluation.in_training_data_fraction, + "is_end_to_end_evaluation": e2e, + } + + +def _log_evaluation_table( + golds: List[Any], + name: Text, + accuracy: float, + report: Optional[Dict[Text, Any]] = None, + precision: Optional[float] = None, + f1: Optional[float] = None, + in_training_data_fraction: Optional[float] = None, + include_report: bool = True, +) -> None: # pragma: no cover + """Log the sklearn evaluation metrics.""" + logger.info(f"Evaluation Results on {name} level:") + logger.info(f"\tCorrect: {int(len(golds) * accuracy)} / {len(golds)}") + if f1 is not None: + logger.info(f"\tF1-Score: {f1:.3f}") + if precision is not None: + logger.info(f"\tPrecision: {precision:.3f}") + logger.info(f"\tAccuracy: {accuracy:.3f}") + if in_training_data_fraction is not None: + logger.info(f"\tIn-data fraction: {in_training_data_fraction:.3g}") + + if include_report and report is not None: + logger.info(f"\tClassification report: \n{report}") + + +def _plot_story_evaluation( + targets: PredictionList, + predictions: PredictionList, + output_directory: Optional[Text], +) -> None: + """Plot a confusion matrix of story evaluation.""" + from sklearn.metrics import confusion_matrix + from sklearn.utils.multiclass import unique_labels + from rasa.utils.plotting import plot_confusion_matrix + + confusion_matrix_filename = CONFUSION_MATRIX_STORIES_FILE + if output_directory: + confusion_matrix_filename = os.path.join( + output_directory, confusion_matrix_filename + ) + + cnf_matrix = confusion_matrix(targets, predictions) + + plot_confusion_matrix( + cnf_matrix, + classes=unique_labels(targets, predictions), + title="Action Confusion matrix", + output_file=confusion_matrix_filename, + ) + + +async def compare_models_in_dir( + model_dir: Text, + stories_file: Text, + output: Text, + use_conversation_test_files: bool = False, +) -> None: + """Evaluates multiple trained models in a directory on a test set. + + Args: + model_dir: path to directory that contains the models to evaluate + stories_file: path to the story file + output: output directory to store results to + use_conversation_test_files: `True` if conversation test files should be used + for testing instead of regular Core story files. + """ + number_correct = defaultdict(list) + + for run in rasa.shared.utils.io.list_subdirectories(model_dir): + number_correct_in_run = defaultdict(list) + + for model in sorted(rasa.shared.utils.io.list_files(run)): + if not model.endswith("tar.gz"): + continue + + # The model files are named like <config-name>PERCENTAGE_KEY<number>.tar.gz + # Remove the percentage key and number from the name to get the config name + config_name = os.path.basename(model).split(PERCENTAGE_KEY)[0] + number_of_correct_stories = await _evaluate_core_model( + model, + stories_file, + use_conversation_test_files=use_conversation_test_files, + ) + number_correct_in_run[config_name].append(number_of_correct_stories) + + for k, v in number_correct_in_run.items(): + number_correct[k].append(v) + + rasa.shared.utils.io.dump_obj_as_json_to_file( + os.path.join(output, RESULTS_FILE), number_correct + ) + + +async def compare_models( + models: List[Text], + stories_file: Text, + output: Text, + use_conversation_test_files: bool = False, +) -> None: + """Evaluates multiple trained models on a test set. + + Args: + models: Paths to model files. + stories_file: path to the story file + output: output directory to store results to + use_conversation_test_files: `True` if conversation test files should be used + for testing instead of regular Core story files. + """ + number_correct = defaultdict(list) + + for model in models: + number_of_correct_stories = await _evaluate_core_model( + model, stories_file, use_conversation_test_files=use_conversation_test_files + ) + number_correct[os.path.basename(model)].append(number_of_correct_stories) + + rasa.shared.utils.io.dump_obj_as_json_to_file( + os.path.join(output, RESULTS_FILE), number_correct + ) + + +async def _evaluate_core_model( + model: Text, stories_file: Text, use_conversation_test_files: bool = False +) -> int: + from rasa.core.agent import Agent + + logger.info(f"Evaluating model '{model}'") + + agent = Agent.load(model) + generator = _create_data_generator( + stories_file, agent, use_conversation_test_files=use_conversation_test_files + ) + completed_trackers = generator.generate_story_trackers() + + # Entities are ignored here as we only compare number of correct stories. + story_eval_store, number_of_stories, _ = await _collect_story_predictions( + completed_trackers, agent + ) + failed_stories = story_eval_store.failed_stories + return number_of_stories - len(failed_stories) diff --git a/rasa/core/tracker_store.py b/rasa/core/tracker_store.py new file mode 100644 index 0000000..a91f9f5 --- /dev/null +++ b/rasa/core/tracker_store.py @@ -0,0 +1,1665 @@ +from __future__ import annotations +import contextlib +import itertools +import json +import logging +import os +from inspect import isawaitable, iscoroutinefunction + +from time import sleep +from typing import ( + Any, + Callable, + Dict, + Iterable, + Iterator, + List, + Optional, + Text, + Union, + TYPE_CHECKING, + Generator, + TypeVar, + Generic, +) + +from boto3.dynamodb.conditions import Key +from pymongo.collection import Collection + +import rasa.core.utils as core_utils +import rasa.shared.utils.cli +import rasa.shared.utils.common +import rasa.shared.utils.io +from rasa.plugin import plugin_manager +from rasa.shared.core.constants import ACTION_LISTEN_NAME +from rasa.core.brokers.broker import EventBroker +from rasa.core.constants import ( + POSTGRESQL_SCHEMA, + POSTGRESQL_MAX_OVERFLOW, + POSTGRESQL_POOL_SIZE, +) +from rasa.shared.core.conversation import Dialogue +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import SessionStarted, Event +from rasa.shared.core.trackers import ( + ActionExecuted, + DialogueStateTracker, + EventVerbosity, + TrackerEventDiffEngine, +) +from rasa.shared.exceptions import ConnectionException, RasaException +from rasa.shared.nlu.constants import INTENT_NAME_KEY +from rasa.utils.endpoints import EndpointConfig +import sqlalchemy as sa +from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta + +if TYPE_CHECKING: + import boto3.resources.factory.dynamodb.Table + from sqlalchemy.engine.url import URL + from sqlalchemy.engine.base import Engine + from sqlalchemy.orm import Session, Query + from sqlalchemy import Sequence + +logger = logging.getLogger(__name__) + +# default values of PostgreSQL pool size and max overflow +POSTGRESQL_DEFAULT_MAX_OVERFLOW = 100 +POSTGRESQL_DEFAULT_POOL_SIZE = 50 + +# default value for key prefix in RedisTrackerStore +DEFAULT_REDIS_TRACKER_STORE_KEY_PREFIX = "tracker:" + + +def check_if_tracker_store_async(tracker_store: TrackerStore) -> bool: + """Evaluates if a tracker store object is async based on implementation of methods. + + :param tracker_store: tracker store object we're evaluating + :return: if the tracker store correctly implements all async methods + """ + return all( + iscoroutinefunction(getattr(tracker_store, method)) + for method in _get_async_tracker_store_methods() + ) + + +def _get_async_tracker_store_methods() -> List[str]: + return [ + attribute + for attribute in dir(TrackerStore) + if iscoroutinefunction(getattr(TrackerStore, attribute)) + ] + + +class TrackerDeserialisationException(RasaException): + """Raised when an error is encountered while deserialising a tracker.""" + + +SerializationType = TypeVar("SerializationType") + + +class SerializedTrackerRepresentation(Generic[SerializationType]): + """Mixin class for specifying different serialization methods per tracker store.""" + + @staticmethod + def serialise_tracker(tracker: DialogueStateTracker) -> SerializationType: + """Requires implementation to return representation of tracker.""" + raise NotImplementedError() + + +class SerializedTrackerAsText(SerializedTrackerRepresentation[Text]): + """Mixin class that returns the serialized tracker as string.""" + + @staticmethod + def serialise_tracker(tracker: DialogueStateTracker) -> Text: + """Serializes the tracker, returns representation of the tracker.""" + dialogue = tracker.as_dialogue() + + return json.dumps(dialogue.as_dict()) + + +class SerializedTrackerAsDict(SerializedTrackerRepresentation[Dict]): + """Mixin class that returns the serialized tracker as dictionary.""" + + @staticmethod + def serialise_tracker(tracker: DialogueStateTracker) -> Dict: + """Serializes the tracker, returns representation of the tracker.""" + d = tracker.as_dialogue().as_dict() + d.update({"sender_id": tracker.sender_id}) + return d + + +class TrackerStore: + """Represents common behavior and interface for all `TrackerStore`s.""" + + def __init__( + self, + domain: Optional[Domain], + event_broker: Optional[EventBroker] = None, + **kwargs: Dict[Text, Any], + ) -> None: + """Create a TrackerStore. + + Args: + domain: The `Domain` to initialize the `DialogueStateTracker`. + event_broker: An event broker to publish any new events to another + destination. + kwargs: Additional kwargs. + """ + self._domain = domain or Domain.empty() + self.event_broker = event_broker + self.max_event_history: Optional[int] = None + + @staticmethod + def create( + obj: Union[TrackerStore, EndpointConfig, None], + domain: Optional[Domain] = None, + event_broker: Optional[EventBroker] = None, + ) -> TrackerStore: + """Factory to create a tracker store.""" + if isinstance(obj, TrackerStore): + return obj + + from botocore.exceptions import BotoCoreError + import pymongo.errors + import sqlalchemy.exc + + try: + _tracker_store = plugin_manager().hook.create_tracker_store( + endpoint_config=obj, + domain=domain, + event_broker=event_broker, + ) + + tracker_store = ( + _tracker_store + if _tracker_store + else create_tracker_store(obj, domain, event_broker) + ) + + return tracker_store + except ( + BotoCoreError, + pymongo.errors.ConnectionFailure, + sqlalchemy.exc.OperationalError, + ConnectionError, + pymongo.errors.OperationFailure, + ) as error: + raise ConnectionException( + "Cannot connect to tracker store." + str(error) + ) from error + + async def get_or_create_tracker( + self, + sender_id: Text, + max_event_history: Optional[int] = None, + append_action_listen: bool = True, + ) -> "DialogueStateTracker": + """Returns tracker or creates one if the retrieval returns None. + + Args: + sender_id: Conversation ID associated with the requested tracker. + max_event_history: Value to update the tracker store's max event history to. + append_action_listen: Whether or not to append an initial `action_listen`. + """ + self.max_event_history = max_event_history + + tracker = await self.retrieve(sender_id) + + if tracker is None: + tracker = await self.create_tracker( + sender_id, append_action_listen=append_action_listen + ) + + return tracker + + def init_tracker(self, sender_id: Text) -> "DialogueStateTracker": + """Returns a Dialogue State Tracker.""" + return DialogueStateTracker( + sender_id, + self.domain.slots, + max_event_history=self.max_event_history, + ) + + async def create_tracker( + self, sender_id: Text, append_action_listen: bool = True + ) -> DialogueStateTracker: + """Creates a new tracker for `sender_id`. + + The tracker begins with a `SessionStarted` event and is initially listening. + + Args: + sender_id: Conversation ID associated with the tracker. + append_action_listen: Whether or not to append an initial `action_listen`. + + Returns: + The newly created tracker for `sender_id`. + """ + tracker = self.init_tracker(sender_id) + + if append_action_listen: + tracker.update(ActionExecuted(ACTION_LISTEN_NAME)) + + await self.save(tracker) + + return tracker + + async def save(self, tracker: DialogueStateTracker) -> None: + """Save method that will be overridden by specific tracker.""" + raise NotImplementedError() + + async def exists(self, conversation_id: Text) -> bool: + """Checks if tracker exists for the specified ID. + + This method may be overridden by the specific tracker store for + faster implementations. + + Args: + conversation_id: Conversation ID to check if the tracker exists. + + Returns: + `True` if the tracker exists, `False` otherwise. + """ + return await self.retrieve(conversation_id) is not None + + async def retrieve(self, sender_id: Text) -> Optional[DialogueStateTracker]: + """Retrieves tracker for the latest conversation session. + + This method will be overridden by the specific tracker store. + + Args: + sender_id: Conversation ID to fetch the tracker for. + + Returns: + Tracker containing events from the latest conversation sessions. + """ + raise NotImplementedError() + + async def retrieve_full_tracker( + self, conversation_id: Text + ) -> Optional[DialogueStateTracker]: + """Retrieve method for fetching all tracker events across conversation sessions\ + that may be overridden by specific tracker. + + The default implementation uses `self.retrieve()`. + + Args: + conversation_id: The conversation ID to retrieve the tracker for. + + Returns: + The fetch tracker containing all events across session starts. + """ + return await self.retrieve(conversation_id) + + async def get_or_create_full_tracker( + self, + sender_id: Text, + append_action_listen: bool = True, + ) -> "DialogueStateTracker": + """Returns tracker or creates one if the retrieval returns None. + + Args: + sender_id: Conversation ID associated with the requested tracker. + append_action_listen: Whether to append an initial `action_listen`. + + Returns: + The tracker for the conversation ID. + """ + tracker = await self.retrieve_full_tracker(sender_id) + + if tracker is None: + tracker = await self.create_tracker( + sender_id, append_action_listen=append_action_listen + ) + + return tracker + + async def stream_events(self, tracker: DialogueStateTracker) -> None: + """Streams events to a message broker.""" + if self.event_broker is None: + logger.debug("No event broker configured. Skipping streaming events.") + return None + + old_tracker = await self.retrieve(tracker.sender_id) + new_events = TrackerEventDiffEngine.event_difference(old_tracker, tracker) + + await self._stream_new_events(self.event_broker, new_events, tracker.sender_id) + + async def _stream_new_events( + self, + event_broker: EventBroker, + new_events: List[Event], + sender_id: Text, + ) -> None: + """Publishes new tracker events to a message broker.""" + for event in new_events: + body = {"sender_id": sender_id} + body.update(event.as_dict()) + event_broker.publish(body) + + async def keys(self) -> Iterable[Text]: + """Returns the set of values for the tracker store's primary key.""" + raise NotImplementedError() + + def deserialise_tracker( + self, sender_id: Text, serialised_tracker: Union[Text, bytes] + ) -> Optional[DialogueStateTracker]: + """Deserializes the tracker and returns it.""" + tracker = self.init_tracker(sender_id) + + try: + dialogue = Dialogue.from_parameters(json.loads(serialised_tracker)) + except UnicodeDecodeError as e: + raise TrackerDeserialisationException( + "Tracker cannot be deserialised. " + "Trackers must be serialised as json. " + "Support for deserialising pickled trackers has been removed." + ) from e + + tracker.recreate_from_dialogue(dialogue) + + return tracker + + @property + def domain(self) -> Domain: + """Returns the domain of the tracker store.""" + return self._domain + + @domain.setter + def domain(self, domain: Optional[Domain]) -> None: + self._domain = domain or Domain.empty() + + +class InMemoryTrackerStore(TrackerStore, SerializedTrackerAsText): + """Stores conversation history in memory.""" + + def __init__( + self, + domain: Domain, + event_broker: Optional[EventBroker] = None, + **kwargs: Dict[Text, Any], + ) -> None: + """Initializes the tracker store.""" + self.store: Dict[Text, Text] = {} + super().__init__(domain, event_broker, **kwargs) + + async def save(self, tracker: DialogueStateTracker) -> None: + """Updates and saves the current conversation state.""" + await self.stream_events(tracker) + serialised = InMemoryTrackerStore.serialise_tracker(tracker) + self.store[tracker.sender_id] = serialised + + async def retrieve(self, sender_id: Text) -> Optional[DialogueStateTracker]: + """Returns tracker matching sender_id.""" + return await self._retrieve(sender_id, fetch_all_sessions=False) + + async def keys(self) -> Iterable[Text]: + """Returns sender_ids of the Tracker Store in memory.""" + return self.store.keys() + + async def retrieve_full_tracker( + self, sender_id: Text + ) -> Optional[DialogueStateTracker]: + """Returns tracker matching sender_id. + + Args: + sender_id: Conversation ID to fetch the tracker for. + """ + return await self._retrieve(sender_id, fetch_all_sessions=True) + + async def _retrieve( + self, sender_id: Text, fetch_all_sessions: bool + ) -> Optional[DialogueStateTracker]: + """Returns tracker matching sender_id. + + Args: + sender_id: Conversation ID to fetch the tracker for. + fetch_all_sessions: Whether to fetch all sessions or only the last one. + """ + if sender_id not in self.store: + logger.debug(f"Could not find tracker for conversation ID '{sender_id}'.") + return None + + logger.debug(f"Recreating tracker for id '{sender_id}'") + + tracker = self.deserialise_tracker(sender_id, self.store[sender_id]) + + if not tracker: + logger.debug(f"Could not find tracker for conversation ID '{sender_id}'.") + return None + + if fetch_all_sessions: + return tracker + + # only return the last session + multiple_tracker_sessions = ( + rasa.shared.core.trackers.get_trackers_for_conversation_sessions(tracker) + ) + + if 0 <= len(multiple_tracker_sessions) <= 1: + return tracker + + return multiple_tracker_sessions[-1] + + +class RedisTrackerStore(TrackerStore, SerializedTrackerAsText): + """Stores conversation history in Redis.""" + + def __init__( + self, + domain: Domain, + host: Text = "localhost", + port: int = 6379, + db: int = 0, + username: Optional[Text] = None, + password: Optional[Text] = None, + event_broker: Optional[EventBroker] = None, + record_exp: Optional[float] = None, + key_prefix: Optional[Text] = None, + use_ssl: bool = False, + ssl_keyfile: Optional[Text] = None, + ssl_certfile: Optional[Text] = None, + ssl_ca_certs: Optional[Text] = None, + **kwargs: Dict[Text, Any], + ) -> None: + """Initializes the tracker store.""" + import redis + + self.red = redis.StrictRedis( + host=host, + port=port, + db=db, + username=username, + password=password, + ssl=use_ssl, + ssl_keyfile=ssl_keyfile, + ssl_certfile=ssl_certfile, + ssl_ca_certs=ssl_ca_certs, + decode_responses=True, + ) + self.record_exp = record_exp + + self.key_prefix = DEFAULT_REDIS_TRACKER_STORE_KEY_PREFIX + if key_prefix: + logger.debug(f"Setting non-default redis key prefix: '{key_prefix}'.") + self._set_key_prefix(key_prefix) + + super().__init__(domain, event_broker, **kwargs) + + def _set_key_prefix(self, key_prefix: Text) -> None: + if isinstance(key_prefix, str) and key_prefix.isalnum(): + self.key_prefix = key_prefix + ":" + DEFAULT_REDIS_TRACKER_STORE_KEY_PREFIX + else: + logger.warning( + f"Omitting provided non-alphanumeric redis key prefix: '{key_prefix}'. " + f"Using default '{self.key_prefix}' instead." + ) + + def _get_key_prefix(self) -> Text: + return self.key_prefix + + async def save( + self, tracker: DialogueStateTracker, timeout: Optional[float] = None + ) -> None: + """Saves the current conversation state.""" + await self.stream_events(tracker) + + if not timeout and self.record_exp: + timeout = self.record_exp + + stored = self.red.get(self.key_prefix + tracker.sender_id) + + if stored is not None: + prior_tracker = self.deserialise_tracker(tracker.sender_id, stored) + + tracker = self._merge_trackers(prior_tracker, tracker) + + serialised_tracker = self.serialise_tracker(tracker) + self.red.set( + self.key_prefix + tracker.sender_id, serialised_tracker, ex=timeout + ) + + async def retrieve(self, sender_id: Text) -> Optional[DialogueStateTracker]: + """Retrieves tracker for the latest conversation session. + + The Redis key is formed by appending a prefix to sender_id. + + Args: + sender_id: Conversation ID to fetch the tracker for. + + Returns: + Tracker containing events from the latest conversation sessions. + """ + return await self._retrieve(sender_id, fetch_all_sessions=False) + + async def retrieve_full_tracker( + self, sender_id: Text + ) -> Optional[DialogueStateTracker]: + """Retrieves tracker for all conversation sessions. + + The Redis key is formed by appending a prefix to sender_id. + + Args: + sender_id: Conversation ID to fetch the tracker for. + + Returns: + Tracker containing events from all conversation sessions. + """ + return await self._retrieve(sender_id, fetch_all_sessions=True) + + async def _retrieve( + self, sender_id: Text, fetch_all_sessions: bool + ) -> Optional[DialogueStateTracker]: + """Returns tracker matching sender_id. + + Args: + sender_id: Conversation ID to fetch the tracker for. + fetch_all_sessions: Whether to fetch all sessions or only the last one. + """ + stored = self.red.get(self.key_prefix + sender_id) + if stored is None: + logger.debug(f"Could not find tracker for conversation ID '{sender_id}'.") + return None + + tracker = self.deserialise_tracker(sender_id, stored) + if fetch_all_sessions: + return tracker + + # only return the last session + multiple_tracker_sessions = ( + rasa.shared.core.trackers.get_trackers_for_conversation_sessions(tracker) + ) + + if 0 <= len(multiple_tracker_sessions) <= 1: + return tracker + + return multiple_tracker_sessions[-1] + + async def keys(self) -> Iterable[Text]: + """Returns keys of the Redis Tracker Store.""" + return self.red.keys(self.key_prefix + "*") + + @staticmethod + def _merge_trackers( + prior_tracker: DialogueStateTracker, tracker: DialogueStateTracker + ) -> DialogueStateTracker: + """Merges two trackers. + + Args: + prior_tracker: Tracker containing events from the previous conversation + sessions. + tracker: Tracker containing events from the current conversation session. + """ + if not prior_tracker.events: + return tracker + + last_event_timestamp = prior_tracker.events[-1].timestamp + past_tracker = tracker.travel_back_in_time(target_time=last_event_timestamp) + + if past_tracker.events == prior_tracker.events: + return tracker + + merged = tracker.init_copy() + merged.update_with_events( + list(prior_tracker.events), override_timestamp=False, domain=None + ) + + for new_event in tracker.events: + # Event subclasses implement `__eq__` method that make it difficult + # to compare events. We use `as_dict` to compare events. + if all( + [ + new_event.as_dict() != existing_event.as_dict() + for existing_event in merged.events + ] + ): + merged.update(new_event) + + return merged + + +class DynamoTrackerStore(TrackerStore, SerializedTrackerAsDict): + """Stores conversation history in DynamoDB.""" + + def __init__( + self, + domain: Domain, + table_name: Text = "states", + region: Text = "us-east-1", + event_broker: Optional[EndpointConfig] = None, + **kwargs: Dict[Text, Any], + ) -> None: + """Initialize `DynamoTrackerStore`. + + Args: + domain: Domain associated with this tracker store. + table_name: The name of the DynamoDB table, does not need to be present a + priori. + region: The name of the region associated with the client. + A client is associated with a single region. + event_broker: An event broker used to publish events. + kwargs: Additional kwargs. + """ + import boto3 + + self.client = boto3.client("dynamodb", region_name=region) + self.region = region + self.table_name = table_name + self.db = self.get_or_create_table(table_name) + super().__init__(domain, event_broker, **kwargs) + + def get_or_create_table( + self, table_name: Text + ) -> "boto3.resources.factory.dynamodb.Table": + """Returns table or creates one if the table name is not in the table list.""" + import boto3 + + dynamo = boto3.resource("dynamodb", region_name=self.region) + try: + self.client.describe_table(TableName=table_name) + except self.client.exceptions.ResourceNotFoundException: + table = dynamo.create_table( + TableName=self.table_name, + KeySchema=[{"AttributeName": "sender_id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "sender_id", "AttributeType": "S"} + ], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + + # Wait until the table exists. + table.meta.client.get_waiter("table_exists").wait(TableName=table_name) + else: + table = dynamo.Table(table_name) + + return table + + async def save(self, tracker: DialogueStateTracker) -> None: + """Saves the current conversation state.""" + await self.stream_events(tracker) + serialized = self.serialise_tracker(tracker) + + self.db.put_item(Item=serialized) + + @staticmethod + def serialise_tracker( + tracker: "DialogueStateTracker", + ) -> Dict: + """Serializes the tracker, returns object with decimal types. + + DynamoDB cannot store `float`s, so we'll convert them to `Decimal`s. + """ + return core_utils.replace_floats_with_decimals( + SerializedTrackerAsDict.serialise_tracker(tracker) + ) + + async def retrieve(self, sender_id: Text) -> Optional[DialogueStateTracker]: + """Retrieve dialogues for a sender_id in reverse-chronological order. + + Based on the session_date sort key. + """ + return await self._retrieve(sender_id, fetch_all_sessions=False) + + async def retrieve_full_tracker( + self, sender_id: Text + ) -> Optional[DialogueStateTracker]: + """Retrieves tracker for all conversation sessions. + + Args: + sender_id: Conversation ID to fetch the tracker for. + """ + return await self._retrieve(sender_id, fetch_all_sessions=True) + + async def _retrieve( + self, sender_id: Text, fetch_all_sessions: bool + ) -> Optional[DialogueStateTracker]: + """Returns tracker matching sender_id. + + Args: + sender_id: Conversation ID to fetch the tracker for. + fetch_all_sessions: Whether to fetch all sessions or only the last one. + """ + dialogues = self.db.query( + KeyConditionExpression=Key("sender_id").eq(sender_id), + ScanIndexForward=False, + )["Items"] + + if not dialogues: + return None + + if fetch_all_sessions: + events_with_floats = [] + for dialogue in dialogues: + if dialogue.get("events"): + events = core_utils.replace_decimals_with_floats(dialogue["events"]) + events_with_floats += events + else: + events = dialogues[0].get("events", []) + # `float`s are stored as `Decimal` objects - we need to convert them back + events_with_floats = core_utils.replace_decimals_with_floats(events) + + if self.domain is None: + slots = [] + else: + slots = self.domain.slots + + return DialogueStateTracker.from_dict(sender_id, events_with_floats, slots) + + async def keys(self) -> Iterable[Text]: + """Returns sender_ids of the `DynamoTrackerStore`.""" + response = self.db.scan(ProjectionExpression="sender_id") + sender_ids = [i["sender_id"] for i in response["Items"]] + + while response.get("LastEvaluatedKey"): + response = self.db.scan( + ProjectionExpression="sender_id", + ExclusiveStartKey=response["LastEvaluatedKey"], + ) + sender_ids.extend([i["sender_id"] for i in response["Items"]]) + + return sender_ids + + +class MongoTrackerStore(TrackerStore, SerializedTrackerAsText): + """Stores conversation history in Mongo. + + Property methods: + conversations: returns the current conversation + """ + + def __init__( + self, + domain: Domain, + host: Optional[Text] = "mongodb://localhost:27017", + db: Optional[Text] = "rasa", + username: Optional[Text] = None, + password: Optional[Text] = None, + auth_source: Optional[Text] = "admin", + collection: Text = "conversations", + event_broker: Optional[EventBroker] = None, + **kwargs: Dict[Text, Any], + ) -> None: + from pymongo.database import Database + from pymongo import MongoClient + + self.client: MongoClient = MongoClient( + host, + username=username, + password=password, + authSource=auth_source, + # delay connect until process forking is done + connect=False, + ) + + self.db = Database(self.client, db) + self.collection = collection + super().__init__(domain, event_broker, **kwargs) + + self._ensure_indices() + + @property + def conversations(self) -> Collection: + """Returns the current conversation.""" + return self.db[self.collection] + + def _ensure_indices(self) -> None: + """Create an index on the sender_id.""" + self.conversations.create_index("sender_id") + + @staticmethod + def _current_tracker_state_without_events(tracker: DialogueStateTracker) -> Dict: + # get current tracker state and remove `events` key from state + # since events are pushed separately in the `update_one()` operation + state = tracker.current_state(EventVerbosity.ALL) + state.pop("events", None) + + return state + + async def save(self, tracker: DialogueStateTracker) -> None: + """Saves the current conversation state.""" + await self.stream_events(tracker) + + additional_events = self._additional_events(tracker) + + self.conversations.update_one( + {"sender_id": tracker.sender_id}, + { + "$set": self._current_tracker_state_without_events(tracker), + "$push": { + "events": {"$each": [e.as_dict() for e in additional_events]} + }, + }, + upsert=True, + ) + + def _additional_events(self, tracker: DialogueStateTracker) -> Iterator: + """Return events from the tracker which aren't currently stored. + + Args: + tracker: Tracker to inspect. + + Returns: + List of serialised events that aren't currently stored. + + """ + stored = self.conversations.find_one({"sender_id": tracker.sender_id}) or {} + all_events = self._events_from_serialized_tracker(stored) + + number_events_since_last_session = len( + self._events_since_last_session_start(all_events) + ) + + return itertools.islice( + tracker.events, number_events_since_last_session, len(tracker.events) + ) + + @staticmethod + def _events_from_serialized_tracker(serialised: Dict) -> List[Dict]: + return serialised.get("events", []) + + @staticmethod + def _events_since_last_session_start(events: List[Dict]) -> List[Dict]: + """Retrieve events since and including the latest `SessionStart` event. + + Args: + events: All events for a conversation ID. + + Returns: + List of serialised events since and including the latest `SessionStarted` + event. Returns all events if no such event is found. + + """ + events_after_session_start = [] + for event in reversed(events): + events_after_session_start.append(event) + if event["event"] == SessionStarted.type_name: + break + + return list(reversed(events_after_session_start)) + + async def _retrieve( + self, sender_id: Text, fetch_events_from_all_sessions: bool + ) -> Optional[List[Dict[Text, Any]]]: + stored = self.conversations.find_one({"sender_id": sender_id}) + + # look for conversations which have used an `int` sender_id in the past + # and update them. + if not stored and sender_id.isdigit(): + from pymongo import ReturnDocument + + stored = self.conversations.find_one_and_update( + {"sender_id": int(sender_id)}, + {"$set": {"sender_id": str(sender_id)}}, + return_document=ReturnDocument.AFTER, + ) + + if not stored: + return None + + events = self._events_from_serialized_tracker(stored) + + if not fetch_events_from_all_sessions: + events = self._events_since_last_session_start(events) + + return events + + async def retrieve(self, sender_id: Text) -> Optional[DialogueStateTracker]: + """Retrieves tracker for the latest conversation session.""" + events = await self._retrieve(sender_id, fetch_events_from_all_sessions=False) + + if not events: + return None + + return DialogueStateTracker.from_dict(sender_id, events, self.domain.slots) + + async def retrieve_full_tracker( + self, conversation_id: Text + ) -> Optional[DialogueStateTracker]: + """Fetching all tracker events across conversation sessions.""" + events = await self._retrieve( + conversation_id, fetch_events_from_all_sessions=True + ) + + if not events: + return None + + return DialogueStateTracker.from_dict( + conversation_id, events, self.domain.slots + ) + + async def keys(self) -> Iterable[Text]: + """Returns sender_ids of the Mongo Tracker Store.""" + return [c["sender_id"] for c in self.conversations.find()] + + +def _create_sequence(table_name: Text) -> "Sequence": + """Creates a sequence object for a specific table name. + + If using Oracle you will need to create a sequence in your database, + as described here: https://rasa.com/docs/rasa/tracker-stores#sqltrackerstore + Args: + table_name: The name of the table, which gets a Sequence assigned + + Returns: A `Sequence` object + """ + from sqlalchemy.ext.declarative import declarative_base + + sequence_name = f"{table_name}_seq" + Base = declarative_base() + return sa.Sequence(sequence_name, metadata=Base.metadata, optional=True) + + +def is_postgresql_url(url: Union[Text, "URL"]) -> bool: + """Determine whether `url` configures a PostgreSQL connection. + + Args: + url: SQL connection URL. + + Returns: + `True` if `url` is a PostgreSQL connection URL. + """ + if isinstance(url, str): + return "postgresql" in url + + return url.drivername == "postgresql" + + +def create_engine_kwargs(url: Union[Text, "URL"]) -> Dict[Text, Any]: + """Get `sqlalchemy.create_engine()` kwargs. + + Args: + url: SQL connection URL. + + Returns: + kwargs to be passed into `sqlalchemy.create_engine()`. + """ + if not is_postgresql_url(url): + return {} + + kwargs: Dict[Text, Any] = {} + + schema_name = os.environ.get(POSTGRESQL_SCHEMA) + + if schema_name: + logger.debug(f"Using PostgreSQL schema '{schema_name}'.") + kwargs["connect_args"] = {"options": f"-csearch_path={schema_name}"} + + # pool_size and max_overflow can be set to control the number of + # connections that are kept in the connection pool. Not available + # for SQLite, and only tested for PostgreSQL. See + # https://docs.sqlalchemy.org/en/13/core/pooling.html#sqlalchemy.pool.QueuePool + kwargs["pool_size"] = int( + os.environ.get(POSTGRESQL_POOL_SIZE, POSTGRESQL_DEFAULT_POOL_SIZE) + ) + kwargs["max_overflow"] = int( + os.environ.get(POSTGRESQL_MAX_OVERFLOW, POSTGRESQL_DEFAULT_MAX_OVERFLOW) + ) + + return kwargs + + +def ensure_schema_exists(session: "Session") -> None: + """Ensure that the requested PostgreSQL schema exists in the database. + + Args: + session: Session used to inspect the database. + + Raises: + `ValueError` if the requested schema does not exist. + """ + schema_name = os.environ.get(POSTGRESQL_SCHEMA) + + if not schema_name: + return + + engine = session.get_bind() + + if is_postgresql_url(engine.url): + query = sa.exists( + sa.select([(sa.text("schema_name"))]) + .select_from(sa.text("information_schema.schemata")) + .where(sa.text(f"schema_name = '{schema_name}'")) + ) + if not session.query(query).scalar(): + raise ValueError(schema_name) + + +def validate_port(port: Any) -> Optional[int]: + """Ensure that port can be converted to integer. + + Raises: + RasaException if port cannot be cast to integer. + """ + if port is not None and not isinstance(port, int): + try: + port = int(port) + except ValueError as e: + raise RasaException(f"The port '{port}' cannot be cast to integer.") from e + + return port + + +class SQLTrackerStore(TrackerStore, SerializedTrackerAsText): + """Store which can save and retrieve trackers from an SQL database.""" + + Base: DeclarativeMeta = declarative_base() + + class SQLEvent(Base): + """Represents an event in the SQL Tracker Store.""" + + __tablename__ = "events" + + # `create_sequence` is needed to create a sequence for databases that + # don't autoincrement Integer primary keys (e.g. Oracle) + id = sa.Column(sa.Integer, _create_sequence(__tablename__), primary_key=True) + sender_id = sa.Column(sa.String(255), nullable=False, index=True) + type_name = sa.Column(sa.String(255), nullable=False) + timestamp = sa.Column(sa.Float) + intent_name = sa.Column(sa.String(255)) + action_name = sa.Column(sa.String(255)) + data = sa.Column(sa.Text) + + def __init__( + self, + domain: Optional[Domain] = None, + dialect: Text = "sqlite", + host: Optional[Text] = None, + port: Optional[int] = None, + db: Text = "rasa.db", + username: Text = None, + password: Text = None, + event_broker: Optional[EventBroker] = None, + login_db: Optional[Text] = None, + query: Optional[Dict] = None, + **kwargs: Dict[Text, Any], + ) -> None: + import sqlalchemy.exc + + port = validate_port(port) + + engine_url = self.get_db_url( + dialect, host, port, db, username, password, login_db, query + ) + + self.engine = sa.create_engine(engine_url, **create_engine_kwargs(engine_url)) + + logger.debug( + f"Attempting to connect to database via '{repr(self.engine.url)}'." + ) + + # Database might take a while to come up + while True: + try: + # if `login_db` has been provided, use current channel with + # that database to create working database `db` + if login_db: + self._create_database_and_update_engine(db, engine_url) + + try: + self.Base.metadata.create_all(self.engine) + except ( + sqlalchemy.exc.OperationalError, + sqlalchemy.exc.ProgrammingError, + ) as e: + # Several Rasa services started in parallel may attempt to + # create tables at the same time. That is okay so long as + # the first services finishes the table creation. + logger.error(f"Could not create tables: {e}") + + self.sessionmaker = sa.orm.session.sessionmaker(bind=self.engine) + break + except ( + sqlalchemy.exc.OperationalError, + sqlalchemy.exc.IntegrityError, + ) as error: + + logger.warning(error) + sleep(5) + + logger.debug(f"Connection to SQL database '{db}' successful.") + + super().__init__(domain, event_broker, **kwargs) + + @staticmethod + def get_db_url( + dialect: Text = "sqlite", + host: Optional[Text] = None, + port: Optional[int] = None, + db: Text = "rasa.db", + username: Text = None, + password: Text = None, + login_db: Optional[Text] = None, + query: Optional[Dict] = None, + ) -> Union[Text, "URL"]: + """Build an SQLAlchemy `URL` object representing the parameters needed + to connect to an SQL database. + + Args: + dialect: SQL database type. + host: Database network host. + port: Database network port. + db: Database name. + username: User name to use when connecting to the database. + password: Password for database user. + login_db: Alternative database name to which initially connect, and create + the database specified by `db` (PostgreSQL only). + query: Dictionary of options to be passed to the dialect and/or the + DBAPI upon connect. + + Returns: + URL ready to be used with an SQLAlchemy `Engine` object. + """ + from urllib import parse + + # Users might specify a url in the host + if host and "://" in host: + # assumes this is a complete database host name including + # e.g. `postgres://...` + return host + elif host: + # add fake scheme to properly parse components + parsed = parse.urlsplit(f"scheme://{host}") + + # users might include the port in the url + port = parsed.port or port + host = parsed.hostname or host + + return sa.engine.url.URL( + dialect, + username, + password, + host, + port, + database=login_db if login_db else db, + query=query, + ) + + def _create_database_and_update_engine(self, db: Text, engine_url: "URL") -> None: + """Creates database `db` and updates engine accordingly.""" + from sqlalchemy import create_engine + + if not self.engine.dialect.name == "postgresql": + rasa.shared.utils.io.raise_warning( + "The parameter 'login_db' can only be used with a postgres database." + ) + return + + self._create_database(self.engine, db) + self.engine.dispose() + engine_url = sa.engine.url.URL( + drivername=engine_url.drivername, + username=engine_url.username, + password=engine_url.password, + host=engine_url.host, + port=engine_url.port, + database=db, + query=engine_url.query, + ) + self.engine = create_engine(engine_url) + + @staticmethod + def _create_database(engine: "Engine", database_name: Text) -> None: + """Create database `db` on `engine` if it does not exist.""" + import sqlalchemy.exc + + conn = engine.connect() + + matching_rows = ( + conn.execution_options(isolation_level="AUTOCOMMIT") + .execute( + sa.text( + "SELECT 1 FROM pg_catalog.pg_database " + "WHERE datname = :database_name" + ), + database_name=database_name, + ) + .rowcount + ) + + if not matching_rows: + try: + conn.execute(f"CREATE DATABASE {database_name}") + except ( + sqlalchemy.exc.ProgrammingError, + sqlalchemy.exc.IntegrityError, + ) as e: + logger.error(f"Could not create database '{database_name}': {e}") + + conn.close() + + @contextlib.contextmanager + def session_scope(self) -> Generator["Session", None, None]: + """Provide a transactional scope around a series of operations.""" + session = self.sessionmaker() + try: + ensure_schema_exists(session) + yield session + except ValueError as e: + rasa.shared.utils.cli.print_error_and_exit( + f"Requested PostgreSQL schema '{e}' was not found in the database. To " + f"continue, please create the schema by running 'CREATE DATABASE {e};' " + f"or unset the '{POSTGRESQL_SCHEMA}' environment variable in order to " + f"use the default schema. Exiting application." + ) + finally: + session.close() + + async def keys(self) -> Iterable[Text]: + """Returns sender_ids of the SQLTrackerStore.""" + with self.session_scope() as session: + sender_ids = session.query(self.SQLEvent.sender_id).distinct().all() + return [sender_id for (sender_id,) in sender_ids] + + async def retrieve(self, sender_id: Text) -> Optional[DialogueStateTracker]: + """Retrieves tracker for the latest conversation session.""" + return await self._retrieve(sender_id, fetch_events_from_all_sessions=False) + + async def retrieve_full_tracker( + self, conversation_id: Text + ) -> Optional[DialogueStateTracker]: + """Fetching all tracker events across conversation sessions.""" + return await self._retrieve( + conversation_id, fetch_events_from_all_sessions=True + ) + + async def _retrieve( + self, sender_id: Text, fetch_events_from_all_sessions: bool + ) -> Optional[DialogueStateTracker]: + with self.session_scope() as session: + + serialised_events = self._event_query( + session, + sender_id, + fetch_events_from_all_sessions=fetch_events_from_all_sessions, + ).all() + + events = [json.loads(event.data) for event in serialised_events] + + if self.domain and len(events) > 0: + logger.debug(f"Recreating tracker from sender id '{sender_id}'") + return DialogueStateTracker.from_dict( + sender_id, events, self.domain.slots + ) + else: + logger.debug( + f"Can't retrieve tracker matching " + f"sender id '{sender_id}' from SQL storage. " + f"Returning `None` instead." + ) + return None + + def _event_query( + self, session: "Session", sender_id: Text, fetch_events_from_all_sessions: bool + ) -> "Query": + """Provide the query to retrieve the conversation events for a specific sender. + The events are ordered by ID to ensure correct sequence of events. + As `timestamp` is not guaranteed to be unique and low-precision (float), it + cannot be used to order the events. + + Args: + session: Current database session. + sender_id: Sender id whose conversation events should be retrieved. + fetch_events_from_all_sessions: Whether to fetch events from all + conversation sessions. If `False`, only fetch events from the + latest conversation session. + + Returns: + Query to get the conversation events. + """ + # Subquery to find the timestamp of the latest `SessionStarted` event + session_start_sub_query = ( + session.query(sa.func.max(self.SQLEvent.timestamp).label("session_start")) + .filter( + self.SQLEvent.sender_id == sender_id, + self.SQLEvent.type_name == SessionStarted.type_name, + ) + .subquery() + ) + + event_query = session.query(self.SQLEvent).filter( + self.SQLEvent.sender_id == sender_id + ) + if not fetch_events_from_all_sessions: + event_query = event_query.filter( + # Find events after the latest `SessionStarted` event or return all + # events + sa.or_( + self.SQLEvent.timestamp >= session_start_sub_query.c.session_start, + session_start_sub_query.c.session_start.is_(None), + ) + ) + + return event_query.order_by(self.SQLEvent.id) + + async def save(self, tracker: DialogueStateTracker) -> None: + """Update database with events from the current conversation.""" + await self.stream_events(tracker) + + with self.session_scope() as session: + # only store recent events + events = self._additional_events(session, tracker) + + for event in events: + data = event.as_dict() + intent = ( + data.get("parse_data", {}).get("intent", {}).get(INTENT_NAME_KEY) + ) + action = data.get("name") + timestamp = data.get("timestamp") + + # noinspection PyArgumentList + session.add( + self.SQLEvent( + sender_id=tracker.sender_id, + type_name=event.type_name, + timestamp=timestamp, + intent_name=intent, + action_name=action, + data=json.dumps(data), + ) + ) + session.commit() + + logger.debug(f"Tracker with sender_id '{tracker.sender_id}' stored to database") + + def _additional_events( + self, session: "Session", tracker: DialogueStateTracker + ) -> Iterator: + """Return events from the tracker which aren't currently stored.""" + number_of_events_since_last_session = self._event_query( + session, tracker.sender_id, fetch_events_from_all_sessions=False + ).count() + + return itertools.islice( + tracker.events, number_of_events_since_last_session, len(tracker.events) + ) + + +class FailSafeTrackerStore(TrackerStore): + """Tracker store wrapper. + + Allows a fallback to a different tracker store in case of errors. + """ + + def __init__( + self, + tracker_store: TrackerStore, + on_tracker_store_error: Optional[Callable[[Exception], None]] = None, + fallback_tracker_store: Optional[TrackerStore] = None, + ) -> None: + """Create a `FailSafeTrackerStore`. + + Args: + tracker_store: Primary tracker store. + on_tracker_store_error: Callback which is called when there is an error + in the primary tracker store. + fallback_tracker_store: Fallback tracker store. + """ + self._fallback_tracker_store: Optional[TrackerStore] = fallback_tracker_store + self._tracker_store = tracker_store + self._on_tracker_store_error = on_tracker_store_error + + super().__init__(tracker_store.domain, tracker_store.event_broker) + + @property + def domain(self) -> Domain: + """Returns the domain of the primary tracker store.""" + return self._tracker_store.domain + + @domain.setter + def domain(self, domain: Domain) -> None: + self._tracker_store.domain = domain + + if self._fallback_tracker_store: + self._fallback_tracker_store.domain = domain + + @property + def fallback_tracker_store(self) -> TrackerStore: + """Returns the fallback tracker store.""" + if not self._fallback_tracker_store: + self._fallback_tracker_store = InMemoryTrackerStore( + self._tracker_store.domain, self._tracker_store.event_broker + ) + + return self._fallback_tracker_store + + def on_tracker_store_error(self, error: Exception) -> None: + """Calls the callback when there is an error in the primary tracker store.""" + if self._on_tracker_store_error: + self._on_tracker_store_error(error) + else: + logger.error( + f"Error happened when trying to save conversation tracker to " + f"'{self._tracker_store.__class__.__name__}'. Falling back to use " + f"the '{InMemoryTrackerStore.__name__}'. Please " + f"investigate the following error: {error}." + ) + + async def retrieve(self, sender_id: Text) -> Optional[DialogueStateTracker]: + """Calls `retrieve` method of primary tracker store.""" + try: + return await self._tracker_store.retrieve(sender_id) + except Exception as e: + self.on_tracker_store_retrieve_error(e) + return None + + async def keys(self) -> Iterable[Text]: + """Calls `keys` method of primary tracker store.""" + try: + return await self._tracker_store.keys() + except Exception as e: + self.on_tracker_store_error(e) + return [] + + async def save(self, tracker: DialogueStateTracker) -> None: + """Calls `save` method of primary tracker store.""" + try: + await self._tracker_store.save(tracker) + except Exception as e: + self.on_tracker_store_error(e) + await self.fallback_tracker_store.save(tracker) + + async def retrieve_full_tracker( + self, sender_id: Text + ) -> Optional[DialogueStateTracker]: + """Calls `retrieve_full_tracker` method of primary tracker store. + + Args: + sender_id: The sender id of the tracker to retrieve. + """ + try: + return await self._tracker_store.retrieve_full_tracker(sender_id) + except Exception as e: + self.on_tracker_store_retrieve_error(e) + return None + + def on_tracker_store_retrieve_error(self, error: Exception) -> None: + """Calls `_on_tracker_store_error` callable attribute if set. + + Otherwise, logs the error. + + Args: + error: The error that occurred. + """ + if self._on_tracker_store_error: + self._on_tracker_store_error(error) + else: + logger.error( + f"Error happened when trying to retrieve conversation tracker from " + f"'{self._tracker_store.__class__.__name__}'. Falling back to use " + f"the '{InMemoryTrackerStore.__name__}'. Please " + f"investigate the following error: {error}." + ) + + +def _create_from_endpoint_config( + endpoint_config: Optional[EndpointConfig] = None, + domain: Optional[Domain] = None, + event_broker: Optional[EventBroker] = None, +) -> TrackerStore: + """Given an endpoint configuration, create a proper tracker store object.""" + domain = domain or Domain.empty() + + if endpoint_config is None or endpoint_config.type is None: + # default tracker store if no type is set + tracker_store: TrackerStore = InMemoryTrackerStore(domain, event_broker) + elif endpoint_config.type.lower() == "redis": + tracker_store = RedisTrackerStore( + domain=domain, + host=endpoint_config.url, + event_broker=event_broker, + **endpoint_config.kwargs, + ) + elif endpoint_config.type.lower() == "mongod": + tracker_store = MongoTrackerStore( + domain=domain, + host=endpoint_config.url, + event_broker=event_broker, + **endpoint_config.kwargs, + ) + elif endpoint_config.type.lower() == "sql": + tracker_store = SQLTrackerStore( + domain=domain, + host=endpoint_config.url, + event_broker=event_broker, + **endpoint_config.kwargs, + ) + elif endpoint_config.type.lower() == "dynamo": + tracker_store = DynamoTrackerStore( + domain=domain, event_broker=event_broker, **endpoint_config.kwargs + ) + else: + tracker_store = _load_from_module_name_in_endpoint_config( + domain, endpoint_config, event_broker + ) + + logger.debug(f"Connected to {tracker_store.__class__.__name__}.") + + return tracker_store + + +def _load_from_module_name_in_endpoint_config( + domain: Domain, store: EndpointConfig, event_broker: Optional[EventBroker] = None +) -> TrackerStore: + """Initializes a custom tracker. + + Defaults to the InMemoryTrackerStore if the module path can not be found. + + Args: + domain: defines the universe in which the assistant operates + store: the specific tracker store + event_broker: an event broker to publish events + + Returns: + a tracker store from a specified type in a stores endpoint configuration + """ + try: + tracker_store_class = rasa.shared.utils.common.class_from_module_path( + store.type + ) + + return tracker_store_class( + host=store.url, domain=domain, event_broker=event_broker, **store.kwargs + ) + except (AttributeError, ImportError): + rasa.shared.utils.io.raise_warning( + f"Tracker store with type '{store.type}' not found. " + f"Using `InMemoryTrackerStore` instead." + ) + return InMemoryTrackerStore(domain) + + +def create_tracker_store( + endpoint_config: Optional[EndpointConfig], + domain: Optional[Domain] = None, + event_broker: Optional[EventBroker] = None, +) -> TrackerStore: + """Creates a tracker store based on the current configuration.""" + tracker_store = _create_from_endpoint_config(endpoint_config, domain, event_broker) + + if not check_if_tracker_store_async(tracker_store): + rasa.shared.utils.io.raise_deprecation_warning( + f"Tracker store implementation " + f"{tracker_store.__class__.__name__} " + f"is not asynchronous. Non-asynchronous tracker stores " + f"are currently deprecated and will be removed in 4.0. " + f"Please make the following methods async: " + f"{_get_async_tracker_store_methods()}" + ) + tracker_store = AwaitableTrackerStore(tracker_store) + + return tracker_store + + +class AwaitableTrackerStore(TrackerStore): + """Wraps a tracker store so it can be implemented with async overrides.""" + + def __init__( + self, + tracker_store: TrackerStore, + ) -> None: + """Create a `AwaitableTrackerStore`. + + Args: + tracker_store: the wrapped tracker store. + """ + self._tracker_store = tracker_store + + super().__init__(tracker_store.domain, tracker_store.event_broker) + + @property + def domain(self) -> Domain: + """Returns the domain of the primary tracker store.""" + return self._tracker_store.domain + + @domain.setter + def domain(self, domain: Optional[Domain]) -> None: + """Setter method to modify the wrapped tracker store's domain field.""" + self._tracker_store.domain = domain or Domain.empty() + + @staticmethod + def create( + obj: Union[TrackerStore, EndpointConfig, None], + domain: Optional[Domain] = None, + event_broker: Optional[EventBroker] = None, + ) -> TrackerStore: + """Wrapper to call `create` method of primary tracker store.""" + if isinstance(obj, TrackerStore): + return AwaitableTrackerStore(obj) + elif isinstance(obj, EndpointConfig): + return AwaitableTrackerStore(_create_from_endpoint_config(obj)) + else: + raise ValueError( + f"{type(obj).__name__} supplied " + f"but expected object of type {TrackerStore.__name__} or " + f"of type {EndpointConfig.__name__}." + ) + + async def retrieve(self, sender_id: Text) -> Optional[DialogueStateTracker]: + """Wrapper to call `retrieve` method of primary tracker store.""" + result = self._tracker_store.retrieve(sender_id) + return ( + await result + if isawaitable(result) + else result # type: ignore[return-value] + ) + + async def keys(self) -> Iterable[Text]: + """Wrapper to call `keys` method of primary tracker store.""" + result = self._tracker_store.keys() + return await result if isawaitable(result) else result + + async def save(self, tracker: DialogueStateTracker) -> None: + """Wrapper to call `save` method of primary tracker store.""" + result = self._tracker_store.save(tracker) + return await result if isawaitable(result) else result + + async def retrieve_full_tracker( + self, conversation_id: Text + ) -> Optional[DialogueStateTracker]: + """Wrapper to call `retrieve_full_tracker` method of primary tracker store.""" + result = self._tracker_store.retrieve_full_tracker(conversation_id) + return ( + await result + if isawaitable(result) + else result # type: ignore[return-value] + ) diff --git a/rasa/core/train.py b/rasa/core/train.py new file mode 100644 index 0000000..69af617 --- /dev/null +++ b/rasa/core/train.py @@ -0,0 +1,107 @@ +import argparse +import logging +import os +from pathlib import Path +from typing import Dict, Optional, Text, List + +import rasa.shared.utils.io +import rasa.utils.io +from rasa.constants import NUMBER_OF_TRAINING_STORIES_FILE, PERCENTAGE_KEY +from rasa.shared.importers.importer import TrainingDataImporter + +logger = logging.getLogger(__name__) + + +def train_comparison_models( + story_file: Text, + domain: Text, + output_path: Text = "", + exclusion_percentages: Optional[List] = None, + policy_configs: Optional[List] = None, + runs: int = 1, + additional_arguments: Optional[Dict] = None, +) -> None: + """Trains multiple models for comparison of policies.""" + import rasa.model_training + + exclusion_percentages = exclusion_percentages or [] + policy_configs = policy_configs or [] + + for r in range(runs): + logging.info("Starting run {}/{}".format(r + 1, runs)) + + for current_run, percentage in enumerate(exclusion_percentages, 1): + for policy_config in policy_configs: + config_name = os.path.splitext(os.path.basename(policy_config))[0] + logging.info( + "Starting to train {} round {}/{}" + " with {}% exclusion" + "".format( + config_name, current_run, len(exclusion_percentages), percentage + ) + ) + + rasa.model_training.train_core( + domain, + policy_config, + stories=story_file, + output=str(Path(output_path, f"run_{r +1}")), + fixed_model_name=config_name + PERCENTAGE_KEY + str(percentage), + additional_arguments={ + **additional_arguments, + "exclusion_percentage": percentage, + }, + ) + + +def get_no_of_stories(story_file: Text, domain: Text) -> int: + """Gets number of stories in a file.""" + importer = TrainingDataImporter.load_from_dict( + domain_path=domain, training_data_paths=[story_file] + ) + story_graph = importer.get_stories() + return len(story_graph.story_steps) + + +def do_compare_training( + args: argparse.Namespace, + story_file: Text, + additional_arguments: Optional[Dict] = None, +) -> None: + """Train multiple models for comparison of policies and dumps the result.""" + train_comparison_models( + story_file=story_file, + domain=args.domain, + output_path=args.out, + exclusion_percentages=args.percentages, + policy_configs=args.config, + runs=args.runs, + additional_arguments=additional_arguments, + ) + no_stories = get_no_of_stories(args.stories, args.domain) + + # store the list of the number of stories present at each exclusion + # percentage + story_range = [ + no_stories - round((x / 100.0) * no_stories) for x in args.percentages + ] + + training_stories_per_model_file = os.path.join( + args.out, NUMBER_OF_TRAINING_STORIES_FILE + ) + rasa.shared.utils.io.dump_obj_as_json_to_file( + training_stories_per_model_file, story_range + ) + + +def do_interactive_learning( + args: argparse.Namespace, file_importer: TrainingDataImporter +) -> None: + from rasa.core.training import interactive + + interactive.run_interactive_learning( + file_importer=file_importer, + skip_visualization=args.skip_visualization, + conversation_id=args.conversation_id, + server_args=args.__dict__, + ) diff --git a/rasa/core/training/__init__.py b/rasa/core/training/__init__.py new file mode 100644 index 0000000..414f945 --- /dev/null +++ b/rasa/core/training/__init__.py @@ -0,0 +1,90 @@ +from typing import Text, List, Optional, Union, TYPE_CHECKING + +if TYPE_CHECKING: + from rasa.shared.core.domain import Domain + from rasa.shared.core.generator import TrackerWithCachedStates + from rasa.shared.core.training_data.structures import StoryGraph + from rasa.shared.importers.importer import TrainingDataImporter + + +def extract_story_graph( + resource_name: Text, domain: "Domain", exclusion_percentage: Optional[int] = None +) -> "StoryGraph": + """Loads training stories / rules from file or directory. + + Args: + resource_name: Path to file or directory. + domain: The model domain. + exclusion_percentage: Percentage of stories which should be dropped. `None` + if all training data should be used. + + Returns: + The loaded training data as graph. + """ + from rasa.shared.core.training_data.structures import StoryGraph + import rasa.shared.core.training_data.loading as core_loading + + story_steps = core_loading.load_data_from_resource( + resource_name, domain, exclusion_percentage=exclusion_percentage + ) + return StoryGraph(story_steps) + + +def load_data( + resource_name: Union[Text, "TrainingDataImporter"], + domain: "Domain", + remove_duplicates: bool = True, + unique_last_num_states: Optional[int] = None, + augmentation_factor: int = 50, + tracker_limit: Optional[int] = None, + use_story_concatenation: bool = True, + debug_plots: bool = False, + exclusion_percentage: Optional[int] = None, +) -> List["TrackerWithCachedStates"]: + """ + Load training data from a resource. + + Args: + resource_name: resource to load the data from. either a path or an importer + domain: domain used for loading + remove_duplicates: should duplicated training examples be removed? + unique_last_num_states: number of states in a conversation that make the + a tracker unique (this is used to identify duplicates) + augmentation_factor: + by how much should the story training data be augmented + tracker_limit: + maximum number of trackers to generate during augmentation + use_story_concatenation: + should stories be concatenated when doing data augmentation + debug_plots: + generate debug plots during loading + exclusion_percentage: + how much data to exclude + + Returns: + list of loaded trackers + """ + from rasa.shared.core.generator import TrainingDataGenerator + from rasa.shared.importers.importer import TrainingDataImporter + + if resource_name: + if isinstance(resource_name, TrainingDataImporter): + graph = resource_name.get_stories(exclusion_percentage=exclusion_percentage) + else: + graph = extract_story_graph( + resource_name, domain, exclusion_percentage=exclusion_percentage + ) + + g = TrainingDataGenerator( + graph, + domain, + remove_duplicates, + unique_last_num_states, + augmentation_factor, + tracker_limit, + use_story_concatenation, + debug_plots, + ) + return g.generate() + else: + return [] diff --git a/rasa/core/training/converters/__init__.py b/rasa/core/training/converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/core/training/converters/responses_prefix_converter.py b/rasa/core/training/converters/responses_prefix_converter.py new file mode 100644 index 0000000..8be36e9 --- /dev/null +++ b/rasa/core/training/converters/responses_prefix_converter.py @@ -0,0 +1,121 @@ +from pathlib import Path +from typing import Text + +from rasa.shared.core.domain import Domain, InvalidDomain +from rasa.shared.core.events import ActionExecuted +from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, +) +from rasa.shared.core.training_data.story_writer.yaml_story_writer import ( + YAMLStoryWriter, +) +from rasa.shared.constants import UTTER_PREFIX +from rasa.utils.converter import TrainingDataConverter + + +OBSOLETE_RESPOND_PREFIX = "respond_" + + +def normalize_utter_action(action_name: Text) -> Text: + """Ensure that response names start with `utter_`. + + Args: + action_name: The name of the response. + + Returns: + The name of the response, starting with `utter_`. + """ + return ( + f"{UTTER_PREFIX}{action_name[len(OBSOLETE_RESPOND_PREFIX):]}" + if action_name.startswith(OBSOLETE_RESPOND_PREFIX) + else action_name + ) + + +class StoryResponsePrefixConverter(TrainingDataConverter): + """ + Converter responsible for ensuring that retrieval intent actions in stories + start with `utter_` instead of `respond_`. + """ + + @classmethod + def filter(cls, source_path: Path) -> bool: + """Only accept YAML story files. + + Args: + source_path: Path to a training data file. + + Returns: + `True` if the given file is a YAML stories file, `False` otherwise. + """ + return YAMLStoryReader.is_stories_file(source_path) + + @classmethod + async def convert_and_write(cls, source_path: Path, output_path: Path) -> None: + """Migrate retrieval intent responses to the new 2.0 format in stories. + + Before 2.0, retrieval intent responses needed to start + with `respond_`. Now, they need to start with `utter_`. + + Args: + source_path: the source YAML stories file. + output_path: Path to the output directory. + """ + reader = YAMLStoryReader() + story_steps = reader.read_from_file(source_path) + + for story_step in story_steps: + for event in story_step.events: + if isinstance(event, ActionExecuted): + event.action_name = normalize_utter_action(event.action_name) + + output_file = cls.generate_path_for_converted_training_data_file( + source_path, output_path + ) + YAMLStoryWriter().dump(output_file, story_steps) + + +class DomainResponsePrefixConverter(TrainingDataConverter): + """ + Converter responsible for ensuring that retrieval intent actions in domain + start with `utter_` instead of `respond_`. + """ + + @classmethod + def filter(cls, source_path: Path) -> bool: + """Only accept domain files. + + Args: + source_path: Path to a domain file. + + Returns: + `True` if the given file can is a valid domain file, `False` otherwise. + """ + try: + Domain.from_path(source_path) + except InvalidDomain: + return False + return True + + @classmethod + async def convert_and_write(cls, source_path: Path, output_path: Path) -> None: + """Migrate retrieval intent responses to the new 2.0 format in domains. + + Before 2.0, retrieval intent responses needed to start + with `respond_`. Now, they need to start with `utter_`. + + Args: + source_path: The source domain file. + output_path: Path to the output directory. + """ + domain = Domain.from_path(source_path) + domain_dict = domain.as_dict() + domain_dict["actions"] = [ + normalize_utter_action(action) for action in domain_dict["actions"] + ] + + new_domain = Domain.from_dict(domain_dict) + output_file = cls.generate_path_for_converted_training_data_file( + source_path, output_path + ) + new_domain.persist(output_file) diff --git a/rasa/core/training/interactive.py b/rasa/core/training/interactive.py new file mode 100644 index 0000000..2978b94 --- /dev/null +++ b/rasa/core/training/interactive.py @@ -0,0 +1,1746 @@ +import asyncio +import logging +import os +import textwrap +import uuid +from functools import partial +from multiprocessing import Process +from typing import ( + Any, + Callable, + Deque, + Dict, + List, + Optional, + Text, + Tuple, + Union, + Set, + cast, +) + +from sanic import Sanic, response +from sanic.exceptions import NotFound +from sanic.request import Request +from sanic.response import HTTPResponse +from terminaltables import AsciiTable, SingleTable +import terminaltables.width_and_alignment +import numpy as np +from aiohttp import ClientError +from colorclass import Color +import questionary +from questionary import Choice, Form, Question + +from rasa import telemetry +import rasa.shared.utils.cli +import rasa.shared.utils.io +import rasa.cli.utils +import rasa.shared.data +from rasa.shared.nlu.constants import TEXT, INTENT_NAME_KEY +from rasa.shared.nlu.training_data.loading import RASA, RASA_YAML +from rasa.shared.core.constants import ( + USER_INTENT_RESTART, + ACTION_LISTEN_NAME, + LOOP_NAME, + ACTIVE_LOOP, + LOOP_REJECTED, + REQUESTED_SLOT, + LOOP_INTERRUPTED, + ACTION_UNLIKELY_INTENT_NAME, +) +from rasa.core import run, utils +import rasa.core.train +from rasa.core.constants import DEFAULT_SERVER_FORMAT, DEFAULT_SERVER_PORT +from rasa.shared.core.domain import ( + Domain, + KEY_INTENTS, + KEY_ENTITIES, + KEY_RESPONSES, + KEY_ACTIONS, + KEY_RESPONSES_TEXT, +) +import rasa.shared.core.events +from rasa.shared.core.events import ( + ActionExecuted, + ActionReverted, + BotUttered, + Event, + Restarted, + UserUttered, + UserUtteranceReverted, +) +from rasa.shared.constants import ( + INTENT_MESSAGE_PREFIX, + DEFAULT_SENDER_ID, + UTTER_PREFIX, + DOCS_URL_POLICIES, +) +from rasa.shared.core.trackers import EventVerbosity, DialogueStateTracker +from rasa.shared.core.training_data import visualization +from rasa.shared.core.training_data.visualization import ( + VISUALIZATION_TEMPLATE_PATH, + visualize_neighborhood, +) +from rasa.core.utils import AvailableEndpoints +from rasa.shared.importers.rasa import TrainingDataImporter +from rasa.utils.common import update_sanic_log_level +from rasa.utils.endpoints import EndpointConfig +from rasa.shared.exceptions import InvalidConfigException + +# noinspection PyProtectedMember +from rasa.shared.nlu.training_data import loading +from rasa.shared.nlu.training_data.message import Message + +# WARNING: This command line UI is using an external library +# communicating with the shell - these functions are hard to test +# automatically. If you change anything in here, please make sure to +# run the interactive learning and check if your part of the "ui" +# still works. +import rasa.utils.io as io_utils + +from rasa.shared.core.generator import TrackerWithCachedStates + +logger = logging.getLogger(__name__) + +PATHS = { + "stories": "data/stories.yml", + "nlu": "data/nlu.yml", + "backup": "data/nlu_interactive.yml", + "domain": "domain.yml", +} + +SAVE_IN_E2E = False + +# choose other intent, making sure this doesn't clash with an existing intent +OTHER_INTENT = uuid.uuid4().hex +OTHER_ACTION = uuid.uuid4().hex +NEW_ACTION = uuid.uuid4().hex + +NEW_RESPONSES: Dict[Text, List[Dict[Text, Any]]] = {} + +MAX_NUMBER_OF_TRAINING_STORIES_FOR_VISUALIZATION = 200 + +DEFAULT_STORY_GRAPH_FILE = "story_graph.dot" + + +class RestartConversation(Exception): + """Exception used to break out the flow and restart the conversation.""" + + pass + + +class ForkTracker(Exception): + """Exception used to break out the flow and fork at a previous step. + + The tracker will be reset to the selected point in the past and the + conversation will continue from there.""" + + pass + + +class UndoLastStep(Exception): + """Exception used to break out the flow and undo the last step. + + The last step is either the most recent user message or the most + recent action run by the bot.""" + + pass + + +class Abort(Exception): + """Exception used to abort the interactive learning and exit.""" + + pass + + +async def send_message( + endpoint: EndpointConfig, + conversation_id: Text, + message: Text, + parse_data: Optional[Dict[Text, Any]] = None, +) -> Optional[Any]: + """Send a user message to a conversation.""" + payload = { + "sender": UserUttered.type_name, + "text": message, + "parse_data": parse_data, + } + + return await endpoint.request( + json=payload, + method="post", + subpath=f"/conversations/{conversation_id}/messages", + ) + + +async def request_prediction( + endpoint: EndpointConfig, conversation_id: Text +) -> Optional[Any]: + """Request the next action prediction from core.""" + return await endpoint.request( + method="post", subpath=f"/conversations/{conversation_id}/predict" + ) + + +async def retrieve_domain(endpoint: EndpointConfig) -> Optional[Any]: + """Retrieve the domain from core.""" + return await endpoint.request( + method="get", subpath="/domain", headers={"Accept": "application/json"} + ) + + +async def retrieve_status(endpoint: EndpointConfig) -> Optional[Any]: + """Retrieve the status from core.""" + return await endpoint.request(method="get", subpath="/status") + + +async def retrieve_tracker( + endpoint: EndpointConfig, + conversation_id: Text, + verbosity: EventVerbosity = EventVerbosity.ALL, +) -> Dict[Text, Any]: + """Retrieve a tracker from core.""" + path = f"/conversations/{conversation_id}/tracker?include_events={verbosity.name}" + result = await endpoint.request( + method="get", subpath=path, headers={"Accept": "application/json"} + ) + + # If the request wasn't successful the previous call had already raised. Hence, + # we can be sure we have the tracker in the right format. + return cast(Dict[Text, Any], result) + + +async def send_action( + endpoint: EndpointConfig, + conversation_id: Text, + action_name: Text, + policy: Optional[Text] = None, + confidence: Optional[float] = None, + is_new_action: bool = False, +) -> Optional[Any]: + """Log an action to a conversation.""" + payload = ActionExecuted(action_name, policy, confidence).as_dict() + + subpath = f"/conversations/{conversation_id}/execute" + + try: + return await endpoint.request(json=payload, method="post", subpath=subpath) + except ClientError: + if is_new_action: + if action_name in NEW_RESPONSES: + warning_questions = questionary.confirm( + f"WARNING: You have created a new action: '{action_name}', " + f"with matching response: " + f"'{NEW_RESPONSES[action_name][0][KEY_RESPONSES_TEXT]}'. " + f"This action will not return its message in this session, " + f"but the new response will be saved to your domain file " + f"when you exit and save this session. " + f"You do not need to do anything further." + ) + await _ask_questions(warning_questions, conversation_id, endpoint) + else: + warning_questions = questionary.confirm( + f"WARNING: You have created a new action: '{action_name}', " + f"which was not successfully executed. " + f"If this action does not return any events, " + f"you do not need to do anything. " + f"If this is a custom action which returns events, " + f"you are recommended to implement this action " + f"in your action server and try again." + ) + await _ask_questions(warning_questions, conversation_id, endpoint) + + payload = ActionExecuted(action_name).as_dict() + return await send_event(endpoint, conversation_id, payload) + else: + logger.error("failed to execute action!") + raise + + +async def send_event( + endpoint: EndpointConfig, + conversation_id: Text, + evt: Union[List[Dict[Text, Any]], Dict[Text, Any]], +) -> Optional[Any]: + """Log an event to a conversation.""" + subpath = f"/conversations/{conversation_id}/tracker/events" + + return await endpoint.request(json=evt, method="post", subpath=subpath) + + +def format_bot_output(message: BotUttered) -> Text: + """Format a bot response to be displayed in the history table.""" + # First, add text to output + output = message.text or "" + + # Then, append all additional items + data = message.data or {} + if not data: + return output + + if "image" in data and data["image"] is not None: + output += "\nImage: " + data["image"] + + if "attachment" in data and data["attachment"] is not None: + output += "\nAttachment: " + data["attachment"] + + if "buttons" in data and data["buttons"] is not None: + output += "\nButtons:" + choices = rasa.cli.utils.button_choices_from_message_data( + data, allow_free_text_input=True + ) + for choice in choices: + output += "\n" + choice + + if "elements" in data and data["elements"] is not None: + output += "\nElements:" + for idx, element in enumerate(data["elements"]): + element_str = rasa.cli.utils.element_to_string(element, idx) + output += "\n" + element_str + + if "quick_replies" in data and data["quick_replies"] is not None: + output += "\nQuick replies:" + for idx, element in enumerate(data["quick_replies"]): + element_str = rasa.cli.utils.element_to_string(element, idx) + output += "\n" + element_str + return output + + +def latest_user_message(events: List[Dict[Text, Any]]) -> Optional[Dict[Text, Any]]: + """Return most recent user message.""" + for i, e in enumerate(reversed(events)): + if e.get("event") == UserUttered.type_name: + return e + return None + + +async def _ask_questions( + questions: Union[Form, Question], + conversation_id: Text, + endpoint: EndpointConfig, + is_abort: Callable[[Dict[Text, Any]], bool] = lambda x: False, +) -> Any: + """Ask the user a question, if Ctrl-C is pressed provide user with menu.""" + should_retry = True + answers: Any = {} + + while should_retry: + answers = await questions.ask_async() + if answers is None or is_abort(answers): + should_retry = await _ask_if_quit(conversation_id, endpoint) + else: + should_retry = False + return answers + + +def _selection_choices_from_intent_prediction( + predictions: List[Dict[Text, Any]] +) -> List[Dict[Text, Any]]: + """Given a list of ML predictions create a UI choice list.""" + sorted_intents = sorted( + predictions, key=lambda k: (-k["confidence"], k[INTENT_NAME_KEY]) + ) + + choices = [] + for p in sorted_intents: + name_with_confidence = ( + f'{p.get("confidence"):03.2f} {p.get(INTENT_NAME_KEY):40}' + ) + choice = { + INTENT_NAME_KEY: name_with_confidence, + "value": p.get(INTENT_NAME_KEY), + } + choices.append(choice) + + return choices + + +async def _request_free_text_intent( + conversation_id: Text, endpoint: EndpointConfig +) -> Text: + question = questionary.text( + message="Please type the intent name:", + validate=io_utils.not_empty_validator("Please enter an intent name"), + ) + return await _ask_questions(question, conversation_id, endpoint) + + +async def _request_free_text_action( + conversation_id: Text, endpoint: EndpointConfig +) -> Text: + question = questionary.text( + message="Please type the action name:", + validate=io_utils.not_empty_validator("Please enter an action name"), + ) + return await _ask_questions(question, conversation_id, endpoint) + + +async def _request_free_text_utterance( + conversation_id: Text, endpoint: EndpointConfig, action: Text +) -> Text: + question = questionary.text( + message=(f"Please type the message for your new bot response '{action}':"), + validate=io_utils.not_empty_validator("Please enter a response"), + ) + return await _ask_questions(question, conversation_id, endpoint) + + +async def _request_selection_from_intents( + intents: List[Dict[Text, Text]], conversation_id: Text, endpoint: EndpointConfig +) -> Text: + question = questionary.select("What intent is it?", choices=intents) + return await _ask_questions(question, conversation_id, endpoint) + + +async def _request_fork_point_from_list( + forks: List[Dict[Text, Text]], conversation_id: Text, endpoint: EndpointConfig +) -> Text: + question = questionary.select( + "Before which user message do you want to fork?", choices=forks + ) + return await _ask_questions(question, conversation_id, endpoint) + + +async def _request_fork_from_user( + conversation_id: Text, endpoint: EndpointConfig +) -> Optional[List[Dict[Text, Any]]]: + """Take in a conversation and ask at which point to fork the conversation. + + Returns the list of events that should be kept. Forking means, the + conversation will be reset and continued from this previous point.""" + + tracker = await retrieve_tracker( + endpoint, conversation_id, EventVerbosity.AFTER_RESTART + ) + + choices = [] + for i, e in enumerate(tracker.get("events", [])): + if e.get("event") == UserUttered.type_name: + choices.append({"name": e.get("text"), "value": i}) + + fork_idx = await _request_fork_point_from_list( + list(reversed(choices)), conversation_id, endpoint + ) + + if fork_idx is not None: + return tracker.get("events", [])[: int(fork_idx)] + else: + return None + + +async def _request_intent_from_user( + latest_message: Dict[Text, Any], + intents: List[Text], + conversation_id: Text, + endpoint: EndpointConfig, +) -> Dict[Text, Any]: + """Take in latest message and ask which intent it should have been. + + Returns the intent dict that has been selected by the user.""" + + predictions = latest_message.get("parse_data", {}).get("intent_ranking", []) + + predicted_intents = {p[INTENT_NAME_KEY] for p in predictions} + + for i in intents: + if i not in predicted_intents: + predictions.append({INTENT_NAME_KEY: i, "confidence": 0.0}) + + # convert intents to ui list and add <other> as a free text alternative + choices = [ + {INTENT_NAME_KEY: "<create_new_intent>", "value": OTHER_INTENT} + ] + _selection_choices_from_intent_prediction(predictions) + + intent_name = await _request_selection_from_intents( + choices, conversation_id, endpoint + ) + + if intent_name == OTHER_INTENT: + intent_name = await _request_free_text_intent(conversation_id, endpoint) + selected_intent = {INTENT_NAME_KEY: intent_name, "confidence": 1.0} + else: + # returns the selected intent with the original probability value + selected_intent = next( + (x for x in predictions if x[INTENT_NAME_KEY] == intent_name), + {INTENT_NAME_KEY: None}, + ) + + return selected_intent + + +async def _print_history(conversation_id: Text, endpoint: EndpointConfig) -> None: + """Print information about the conversation for the user.""" + tracker_dump = await retrieve_tracker( + endpoint, conversation_id, EventVerbosity.AFTER_RESTART + ) + events = tracker_dump.get("events", []) + + table = _chat_history_table(events) + slot_strings = _slot_history(tracker_dump) + + print("------") + print("Chat History\n") + loop = asyncio.get_running_loop() + loop.run_in_executor(None, print, table) + + if slot_strings: + print("\n") + slots_info = f"Current slots: \n\t{', '.join(slot_strings)}\n" + loop.run_in_executor(None, print, slots_info) + + loop.run_in_executor(None, print, "------") + + +def _chat_history_table(events: List[Dict[Text, Any]]) -> Text: + """Create a table containing bot and user messages. + + Also includes additional information, like any events and + prediction probabilities.""" + + def wrap(txt: Text, max_width: int) -> Text: + true_wrapping_width = calc_true_wrapping_width(txt, max_width) + return "\n".join( + textwrap.wrap(txt, true_wrapping_width, replace_whitespace=False) + ) + + def colored(txt: Text, color: Text) -> Text: + return "{" + color + "}" + txt + "{/" + color + "}" + + def format_user_msg(user_event: UserUttered, max_width: int) -> Text: + intent = user_event.intent or {} + intent_name = intent.get(INTENT_NAME_KEY, "") + _confidence = intent.get("confidence", 1.0) + _md = _as_md_message(user_event.parse_data) + + _lines = [ + colored(wrap(_md, max_width), "hired"), + f"intent: {intent_name} {_confidence:03.2f}", + ] + return "\n".join(_lines) + + def bot_width(_table: AsciiTable) -> int: + return _table.column_max_width(1) + + def user_width(_table: AsciiTable) -> int: + return _table.column_max_width(3) + + def add_bot_cell(data: List[List[Union[Text, Color]]], cell: Text) -> None: + data.append([len(data), Color(cell), "", ""]) + + def add_user_cell(data: List[List[Union[Text, Color]]], cell: Text) -> None: + data.append([len(data), "", "", Color(cell)]) + + # prints the historical interactions between the bot and the user, + # to help with correctly identifying the action + table_data = [ + [ + "# ", + Color(colored("Bot ", "autoblue")), + " ", + Color(colored("You ", "hired")), + ] + ] + + table = SingleTable(table_data, "Chat History") + + bot_column = [] + + tracker = DialogueStateTracker.from_dict("any", events) + applied_events = tracker.applied_events() + + for idx, event in enumerate(applied_events): + if isinstance(event, ActionExecuted): + if ( + event.action_name == ACTION_UNLIKELY_INTENT_NAME + and event.confidence == 0 + ): + continue + bot_column.append(colored(str(event), "autocyan")) + if event.confidence is not None: + bot_column[-1] += colored(f" {event.confidence:03.2f}", "autowhite") + + elif isinstance(event, UserUttered): + if bot_column: + text = "\n".join(bot_column) + add_bot_cell(table_data, text) + bot_column = [] + + msg = format_user_msg(event, user_width(table)) + add_user_cell(table_data, msg) + + elif isinstance(event, BotUttered): + wrapped = wrap(format_bot_output(event), bot_width(table)) + bot_column.append(colored(wrapped, "autoblue")) + + else: + if event.as_story_string(): + bot_column.append(wrap(event.as_story_string(), bot_width(table))) + + if bot_column: + text = "\n".join(bot_column) + add_bot_cell(table_data, text) + + table.inner_heading_row_border = False + table.inner_row_border = True + table.inner_column_border = False + table.outer_border = False + table.justify_columns = {0: "left", 1: "left", 2: "center", 3: "right"} + + return table.table + + +def _slot_history(tracker_dump: Dict[Text, Any]) -> List[Text]: + """Create an array of slot representations to be displayed.""" + slot_strings = [] + for k, s in tracker_dump.get("slots", {}).items(): + colored_value = rasa.shared.utils.io.wrap_with_color( + str(s), color=rasa.shared.utils.io.bcolors.WARNING + ) + slot_strings.append(f"{k}: {colored_value}") + return slot_strings + + +async def _retry_on_error( + func: Callable, export_path: Text, *args: Any, **kwargs: Any +) -> None: + while True: + try: + return func(export_path, *args, **kwargs) + except OSError as e: + answer = await questionary.confirm( + f"Failed to export '{export_path}': {e}. Please make sure 'rasa' " + f"has read and write access to this file. Would you like to retry?" + ).ask_async() + if not answer: + raise e + + +async def _write_data_to_file(conversation_id: Text, endpoint: EndpointConfig) -> None: + """Write stories and nlu data to file.""" + story_path, nlu_path, domain_path = await _request_export_info() + + tracker = await retrieve_tracker(endpoint, conversation_id) + events = tracker.get("events", []) + + serialised_domain = await retrieve_domain(endpoint) + domain = Domain.from_dict(serialised_domain) + + await _retry_on_error(_write_stories_to_file, story_path, events, domain) + await _retry_on_error(_write_nlu_to_file, nlu_path, events) + await _retry_on_error(_write_domain_to_file, domain_path, events, domain) + + logger.info("Successfully wrote stories and NLU data") + + +async def _ask_if_quit(conversation_id: Text, endpoint: EndpointConfig) -> bool: + """Display the exit menu. + + Return `True` if the previous question should be retried. + """ + answer = await questionary.select( + message="Do you want to stop?", + choices=[ + Choice("Continue", "continue"), + Choice("Undo Last", "undo"), + Choice("Fork", "fork"), + Choice("Start Fresh", "restart"), + Choice("Export & Quit", "quit"), + ], + ).ask_async() + + if not answer or answer == "quit": + # this is also the default answer if the user presses Ctrl-C + await _write_data_to_file(conversation_id, endpoint) + raise Abort() + elif answer == "undo": + raise UndoLastStep() + elif answer == "fork": + raise ForkTracker() + elif answer == "restart": + raise RestartConversation() + else: # `continue` or no answer + # in this case we will just return, and the original + # question will get asked again + return True + + +async def _request_action_from_user( + predictions: List[Dict[Text, Any]], conversation_id: Text, endpoint: EndpointConfig +) -> Tuple[Text, bool]: + """Ask the user to correct an action prediction.""" + + await _print_history(conversation_id, endpoint) + + choices = [ + {"name": f'{a["score"]:03.2f} {a["action"]:40}', "value": a["action"]} + for a in predictions + ] + + tracker = await retrieve_tracker(endpoint, conversation_id) + events = tracker.get("events", []) + + session_actions_all = [a["name"] for a in _collect_actions(events)] + session_actions_unique = list(set(session_actions_all)) + old_actions = [action["value"] for action in choices] + new_actions = [ + {"name": action, "value": OTHER_ACTION + action} + for action in session_actions_unique + if action not in old_actions + ] + choices = ( + [{"name": "<create new action>", "value": NEW_ACTION}] + new_actions + choices + ) + question = questionary.select("What is the next action of the bot?", choices) + + action_name = await _ask_questions(question, conversation_id, endpoint) + is_new_action = action_name == NEW_ACTION + + if is_new_action: + # create new action + action_name = await _request_free_text_action(conversation_id, endpoint) + if action_name.startswith(UTTER_PREFIX): + utter_message = await _request_free_text_utterance( + conversation_id, endpoint, action_name + ) + NEW_RESPONSES[action_name] = [{KEY_RESPONSES_TEXT: utter_message}] + + elif action_name[:32] == OTHER_ACTION: + # action was newly created in the session, but not this turn + is_new_action = True + action_name = action_name[32:] + + print(f"Thanks! The bot will now run {action_name}.\n") + return action_name, is_new_action + + +async def _request_export_info() -> Tuple[Text, Text, Text]: + import rasa.shared.data + + """Request file path and export stories & nlu data to that path""" + + # export training data and quit + questions = questionary.form( + export_stories=questionary.text( + message="Export stories to (if file exists, this " + "will append the stories)", + default=PATHS["stories"], + validate=io_utils.file_type_validator( + rasa.shared.data.YAML_FILE_EXTENSIONS, + "Please provide a valid export path for the stories, " + "e.g. 'stories.yml'.", + ), + ), + export_nlu=questionary.text( + message="Export NLU data to (if file exists, this will " + "merge learned data with previous training examples)", + default=PATHS["nlu"], + validate=io_utils.file_type_validator( + list(rasa.shared.data.TRAINING_DATA_EXTENSIONS), + "Please provide a valid export path for the NLU data, " + "e.g. 'nlu.yml'.", + ), + ), + export_domain=questionary.text( + message="Export domain file to (if file exists, this " + "will be overwritten)", + default=PATHS["domain"], + validate=io_utils.file_type_validator( + rasa.shared.data.YAML_FILE_EXTENSIONS, + "Please provide a valid export path for the domain file, " + "e.g. 'domain.yml'.", + ), + ), + ) + + answers = await questions.ask_async() + if not answers: + raise Abort() + + return answers["export_stories"], answers["export_nlu"], answers["export_domain"] + + +def _split_conversation_at_restarts( + events: List[Dict[Text, Any]] +) -> List[List[Dict[Text, Any]]]: + """Split a conversation at restart events. + + Returns an array of event lists, without the restart events.""" + deserialized_events = [Event.from_parameters(event) for event in events] + split_events = rasa.shared.core.events.split_events( + deserialized_events, Restarted, include_splitting_event=False + ) + + return [[event.as_dict() for event in events] for events in split_events] + + +def _collect_messages(events: List[Dict[Text, Any]]) -> List[Message]: + """Collect the message text and parsed data from the UserMessage events + into a list""" + + import rasa.shared.nlu.training_data.util as rasa_nlu_training_data_utils + + messages = [] + + for event in events: + if event.get("event") == UserUttered.type_name: + data = event.get("parse_data", {}) + rasa_nlu_training_data_utils.remove_untrainable_entities_from(data) + msg = Message.build( + data["text"], data["intent"][INTENT_NAME_KEY], data["entities"] + ) + messages.append(msg) + elif event.get("event") == UserUtteranceReverted.type_name and messages: + messages.pop() # user corrected the nlu, remove incorrect example + + return messages + + +def _collect_actions(events: List[Dict[Text, Any]]) -> List[Dict[Text, Any]]: + """Collect all the `ActionExecuted` events into a list.""" + + return [evt for evt in events if evt.get("event") == ActionExecuted.type_name] + + +def _write_stories_to_file( + export_story_path: Text, events: List[Dict[Text, Any]], domain: Domain +) -> None: + """Write the conversation of the conversation_id to the file paths.""" + from rasa.shared.core.training_data.story_writer.yaml_story_writer import ( + YAMLStoryWriter, + ) + + sub_conversations = _split_conversation_at_restarts(events) + io_utils.create_path(export_story_path) + + if rasa.shared.data.is_likely_yaml_file(export_story_path): + writer = YAMLStoryWriter() + + should_append_stories = False + if os.path.exists(export_story_path): + append_write = "a" # append if already exists + should_append_stories = True + else: + append_write = "w" # make a new file if not + + with open( + export_story_path, append_write, encoding=rasa.shared.utils.io.DEFAULT_ENCODING + ) as f: + interactive_story_counter = 1 + for conversation in sub_conversations: + parsed_events = rasa.shared.core.events.deserialise_events(conversation) + tracker = DialogueStateTracker.from_events( + f"interactive_story_{interactive_story_counter}", + evts=parsed_events, + slots=domain.slots, + ) + + if any( + isinstance(event, UserUttered) for event in tracker.applied_events() + ): + interactive_story_counter += 1 + f.write( + "\n" + + tracker.export_stories( + writer=writer, + should_append_stories=should_append_stories, + e2e=SAVE_IN_E2E, + ) + ) + + +def _filter_messages(msgs: List[Message]) -> List[Message]: + """Filter messages removing those that start with INTENT_MESSAGE_PREFIX""" + + filtered_messages = [] + for msg in msgs: + if not msg.get(TEXT).startswith(INTENT_MESSAGE_PREFIX): + filtered_messages.append(msg) + return filtered_messages + + +def _write_nlu_to_file(export_nlu_path: Text, events: List[Dict[Text, Any]]) -> None: + """Write the nlu data of the conversation_id to the file paths.""" + from rasa.shared.nlu.training_data.training_data import TrainingData + + msgs = _collect_messages(events) + msgs = _filter_messages(msgs) + + # noinspection PyBroadException + try: + previous_examples = loading.load_data(export_nlu_path) + except Exception as e: + logger.debug( + f"An exception occurred while trying to load the NLU data. {str(e)}" + ) + # No previous file exists, use empty training data as replacement. + previous_examples = TrainingData() + + nlu_data = previous_examples.merge(TrainingData(msgs)) + + # need to guess the format of the file before opening it to avoid a read + # in a write + nlu_format = _get_nlu_target_format(export_nlu_path) + if nlu_format == RASA_YAML: + stringified_training_data = nlu_data.nlu_as_yaml() + else: + stringified_training_data = nlu_data.nlu_as_json() + + rasa.shared.utils.io.write_text_file(stringified_training_data, export_nlu_path) + + +def _get_nlu_target_format(export_path: Text) -> Text: + guessed_format = loading.guess_format(export_path) + + if guessed_format not in {RASA, RASA_YAML}: + if rasa.shared.data.is_likely_json_file(export_path): + guessed_format = RASA + elif rasa.shared.data.is_likely_yaml_file(export_path): + guessed_format = RASA_YAML + + return guessed_format + + +def _entities_from_messages(messages: List[Message]) -> List[Text]: + """Return all entities that occur in at least one of the messages.""" + return list({e["entity"] for m in messages for e in m.data.get("entities", [])}) + + +def _intents_from_messages(messages: List[Message]) -> Set[Text]: + """Return all intents that occur in at least one of the messages.""" + + # set of distinct intents + distinct_intents = {m.data["intent"] for m in messages if "intent" in m.data} + + return distinct_intents + + +def _write_domain_to_file( + domain_path: Text, events: List[Dict[Text, Any]], old_domain: Domain +) -> None: + """Write an updated domain file to the file path.""" + + io_utils.create_path(domain_path) + + messages = _collect_messages(events) + actions = _collect_actions(events) + responses = NEW_RESPONSES + + # TODO for now there is no way to distinguish between action and form + collected_actions = list( + { + e["name"] + for e in actions + if e["name"] not in rasa.shared.core.constants.DEFAULT_ACTION_NAMES + and e["name"] not in old_domain.form_names + } + ) + + new_domain = Domain.from_dict( + { + KEY_INTENTS: list(_intents_from_messages(messages)), + KEY_ENTITIES: _entities_from_messages(messages), + KEY_RESPONSES: responses, + KEY_ACTIONS: collected_actions, + } + ) + + old_domain.merge(new_domain).persist(domain_path) + + +async def _predict_till_next_listen( + endpoint: EndpointConfig, + conversation_id: Text, + conversation_ids: List[Text], + plot_file: Optional[Text], +) -> None: + """Predict and validate actions until we need to wait for a user message.""" + + listen = False + while not listen: + result = await request_prediction(endpoint, conversation_id) + if result is None: + result = {} + + predictions = result.get("scores", []) + if not predictions: + raise InvalidConfigException( + "Cannot continue as no action was predicted by the dialogue manager. " + "This can happen if you trained the assistant with no policy included " + "in the configuration. If so, please re-train the assistant with at " + f"least one policy ({DOCS_URL_POLICIES}) included in the configuration." + ) + + probabilities = [prediction["score"] for prediction in predictions] + pred_out = int(np.argmax(probabilities)) + action_name = predictions[pred_out].get("action") + policy = result.get("policy") + confidence = result.get("confidence") + + await _print_history(conversation_id, endpoint) + await _plot_trackers( + conversation_ids, + plot_file, + endpoint, + unconfirmed=[ActionExecuted(action_name)], + ) + + listen = await _validate_action( + action_name, policy, confidence, predictions, endpoint, conversation_id + ) + + await _plot_trackers(conversation_ids, plot_file, endpoint) + + tracker_dump = await retrieve_tracker( + endpoint, conversation_id, EventVerbosity.AFTER_RESTART + ) + events = tracker_dump.get("events", []) + + if len(events) >= 2: + last_event = events[-2] # last event before action_listen + + # if bot message includes buttons the user will get a list choice to reply + # the list choice is displayed in place of action listen + if last_event.get("event") == BotUttered.type_name and last_event["data"].get( + "buttons", None + ): + user_selection = await _get_button_choice(last_event) + if user_selection != rasa.cli.utils.FREE_TEXT_INPUT_PROMPT: + await send_message(endpoint, conversation_id, user_selection) + + +async def _get_button_choice(last_event: Dict[Text, Any]) -> Text: + data = last_event["data"] + message = last_event.get("text", "") + + choices = rasa.cli.utils.button_choices_from_message_data( + data, allow_free_text_input=True + ) + question = questionary.select(message, choices) + return await rasa.cli.utils.payload_from_button_question(question) + + +async def _correct_wrong_nlu( + corrected_nlu: Dict[Text, Any], + events: List[Dict[Text, Any]], + endpoint: EndpointConfig, + conversation_id: Text, +) -> None: + """A wrong NLU prediction got corrected, update core's tracker.""" + revert_latest_user_utterance = UserUtteranceReverted().as_dict() + # `UserUtteranceReverted` also removes the `ACTION_LISTEN` event before, hence we + # have to replay it. + listen_for_next_message = ActionExecuted(ACTION_LISTEN_NAME).as_dict() + corrected_message = latest_user_message(events) + + if corrected_message is None: + raise Exception("Failed to correct NLU data. User message not found.") + + corrected_message["parse_data"] = corrected_nlu + await send_event( + endpoint, + conversation_id, + [revert_latest_user_utterance, listen_for_next_message, corrected_message], + ) + + +async def _correct_wrong_action( + corrected_action: Text, + endpoint: EndpointConfig, + conversation_id: Text, + is_new_action: bool = False, +) -> None: + """A wrong action prediction got corrected, update core's tracker.""" + await send_action( + endpoint, conversation_id, corrected_action, is_new_action=is_new_action + ) + + +def _form_is_rejected(action_name: Text, tracker: Dict[Text, Any]) -> bool: + """Check if the form got rejected with the most recent action name.""" + return ( + tracker.get(ACTIVE_LOOP, {}).get(LOOP_NAME) + and action_name != tracker[ACTIVE_LOOP][LOOP_NAME] + and action_name != ACTION_LISTEN_NAME + ) + + +def _form_is_restored(action_name: Text, tracker: Dict[Text, Any]) -> bool: + """Check whether the form is called again after it was rejected.""" + return ( + tracker.get(ACTIVE_LOOP, {}).get(LOOP_REJECTED) + and tracker.get("latest_action_name") == ACTION_LISTEN_NAME + and action_name == tracker.get(ACTIVE_LOOP, {}).get(LOOP_NAME) + ) + + +async def _confirm_form_validation( + action_name: Text, + tracker: Dict[Text, Any], + endpoint: EndpointConfig, + conversation_id: Text, +) -> None: + """Ask a user whether an input for a form should be validated. + + Previous to this call, the active form was chosen after it was rejected. + """ + requested_slot = tracker.get("slots", {}).get(REQUESTED_SLOT) + + validation_questions = questionary.confirm( + f"Should '{action_name}' validate user input to fill " + f"the slot '{requested_slot}'?" + ) + validate_input = await _ask_questions( + validation_questions, conversation_id, endpoint + ) + + if not validate_input: + # notify form action to skip validation + await send_event( + endpoint, + conversation_id, + { + "event": rasa.shared.core.events.LoopInterrupted.type_name, + LOOP_INTERRUPTED: True, + }, + ) + + elif tracker.get(ACTIVE_LOOP, {}).get(LOOP_INTERRUPTED): + # handle contradiction with learned behaviour + warning_question = questionary.confirm( + "ERROR: FormPolicy predicted no form validation " + "based on previous training stories. " + "Make sure to remove contradictory stories " + "from training data. " + "Otherwise predicting no form validation " + "will not work as expected." + ) + + await _ask_questions(warning_question, conversation_id, endpoint) + # notify form action to validate an input + await send_event( + endpoint, + conversation_id, + { + "event": rasa.shared.core.events.LoopInterrupted.type_name, + LOOP_INTERRUPTED: False, + }, + ) + + +async def _validate_action( + action_name: Text, + policy: Text, + confidence: float, + predictions: List[Dict[Text, Any]], + endpoint: EndpointConfig, + conversation_id: Text, +) -> bool: + """Query the user to validate if an action prediction is correct. + + Returns `True` if the prediction is correct, `False` otherwise. + """ + if action_name == ACTION_UNLIKELY_INTENT_NAME: + question = questionary.confirm( + f"The bot wants to run '{action_name}' " + f"to indicate that the last user message was unexpected " + f"at this point in the conversation. " + f"Check out UnexpecTEDIntentPolicy " + f"({DOCS_URL_POLICIES}#unexpected-intent-policy) " + f"to learn more. Is that correct?" + ) + else: + question = questionary.confirm( + f"The bot wants to run '{action_name}', correct?" + ) + + is_correct = await _ask_questions(question, conversation_id, endpoint) + + if not is_correct and action_name != ACTION_UNLIKELY_INTENT_NAME: + action_name, is_new_action = await _request_action_from_user( + predictions, conversation_id, endpoint + ) + else: + is_new_action = False + + tracker = await retrieve_tracker( + endpoint, conversation_id, EventVerbosity.AFTER_RESTART + ) + + if _form_is_rejected(action_name, tracker): + # notify the tracker that form was rejected + await send_event( + endpoint, + conversation_id, + { + "event": "action_execution_rejected", + LOOP_NAME: tracker[ACTIVE_LOOP][LOOP_NAME], + }, + ) + + elif _form_is_restored(action_name, tracker): + await _confirm_form_validation(action_name, tracker, endpoint, conversation_id) + + if not is_correct: + await _correct_wrong_action( + action_name, endpoint, conversation_id, is_new_action=is_new_action + ) + else: + await send_action(endpoint, conversation_id, action_name, policy, confidence) + + return action_name == ACTION_LISTEN_NAME + + +def _as_md_message(parse_data: Dict[Text, Any]) -> Text: + """Display the parse data of a message in markdown format.""" + from rasa.shared.nlu.training_data.formats.readerwriter import TrainingDataWriter + + if parse_data.get("text", "").startswith(INTENT_MESSAGE_PREFIX): + return parse_data["text"] + + if not parse_data.get("entities"): + parse_data["entities"] = [] + + return TrainingDataWriter.generate_message(parse_data) + + +def _validate_user_regex(latest_message: Dict[Text, Any], intents: List[Text]) -> bool: + """Validate if a users message input is correct. + + This assumes the user entered an intent directly, e.g. using + `/greet`. Return `True` if the intent is a known one. + """ + parse_data = latest_message.get("parse_data", {}) + intent = parse_data.get("intent", {}).get(INTENT_NAME_KEY) + + if intent in intents: + return True + else: + return False + + +async def _validate_user_text( + latest_message: Dict[Text, Any], endpoint: EndpointConfig, conversation_id: Text +) -> bool: + """Validate a user message input as free text. + + This assumes the user message is a text message (so NOT `/greet`). + """ + parse_data = latest_message.get("parse_data", {}) + text = _as_md_message(parse_data) + intent = parse_data.get("intent", {}).get(INTENT_NAME_KEY) + entities = parse_data.get("entities", []) + if entities: + message = ( + f"Is the intent '{intent}' correct for '{text}' and are " + f"all entities labeled correctly?" + ) + else: + message = ( + f"Your NLU model classified '{text}' with intent '{intent}'" + f" and there are no entities, is this correct?" + ) + + if intent is None: + print(f"The NLU classification for '{text}' returned '{intent}'") + return False + else: + question = questionary.confirm(message) + + return await _ask_questions(question, conversation_id, endpoint) + + +async def _validate_nlu( + intents: List[Text], endpoint: EndpointConfig, conversation_id: Text +) -> None: + """Validate if a user message, either text or intent is correct. + + If the prediction of the latest user message is incorrect, + the tracker will be corrected with the correct intent / entities. + """ + tracker = await retrieve_tracker( + endpoint, conversation_id, EventVerbosity.AFTER_RESTART + ) + + latest_message = latest_user_message(tracker.get("events", [])) or {} + + if latest_message.get("text", "").startswith(INTENT_MESSAGE_PREFIX): + valid = _validate_user_regex(latest_message, intents) + else: + valid = await _validate_user_text(latest_message, endpoint, conversation_id) + + if not valid: + corrected_intent = await _request_intent_from_user( + latest_message, intents, conversation_id, endpoint + ) + # corrected intents have confidence 1.0 + corrected_intent["confidence"] = 1.0 + + events = tracker.get("events", []) + + entities = await _correct_entities(latest_message, endpoint, conversation_id) + corrected_nlu = { + "intent": corrected_intent, + "entities": entities, + "text": latest_message.get("text"), + } + + await _correct_wrong_nlu(corrected_nlu, events, endpoint, conversation_id) + + +async def _correct_entities( + latest_message: Dict[Text, Any], endpoint: EndpointConfig, conversation_id: Text +) -> List[Dict[Text, Any]]: + """Validate the entities of a user message. + + Returns the corrected entities. + """ + from rasa.shared.nlu.training_data import entities_parser + + parse_original = latest_message.get("parse_data", {}) + entity_str = _as_md_message(parse_original) + question = questionary.text( + "Please mark the entities using [value](type) notation", default=entity_str + ) + + annotation = await _ask_questions(question, conversation_id, endpoint) + parse_annotated = entities_parser.parse_training_example(annotation) + + corrected_entities = _merge_annotated_and_original_entities( + parse_annotated, parse_original + ) + + return corrected_entities + + +def _merge_annotated_and_original_entities( + parse_annotated: Message, parse_original: Dict[Text, Any] +) -> List[Dict[Text, Any]]: + # overwrite entities which have already been + # annotated in the original annotation to preserve + # additional entity parser information + entities = parse_annotated.get("entities", [])[:] + for i, entity in enumerate(entities): + for original_entity in parse_original.get("entities", []): + if _is_same_entity_annotation(entity, original_entity): + entities[i] = original_entity + break + return entities + + +def _is_same_entity_annotation(entity: Dict[Text, Any], other: Dict[Text, Any]) -> bool: + return ( + entity["value"] == other["value"] + and entity["entity"] == other["entity"] + and entity.get("group") == other.get("group") + and entity.get("role") == other.get("group") + ) + + +async def _enter_user_message(conversation_id: Text, endpoint: EndpointConfig) -> None: + """Request a new message from the user.""" + question = questionary.text("Your input ->") + + message = await _ask_questions(question, conversation_id, endpoint, lambda a: not a) + + if message == (INTENT_MESSAGE_PREFIX + USER_INTENT_RESTART): + raise RestartConversation() + + await send_message(endpoint, conversation_id, message) + + +async def is_listening_for_message( + conversation_id: Text, endpoint: EndpointConfig +) -> bool: + """Check if the conversation is in need for a user message.""" + tracker = await retrieve_tracker(endpoint, conversation_id, EventVerbosity.APPLIED) + + for i, e in enumerate(reversed(tracker.get("events", []))): + if e.get("event") == UserUttered.type_name: + return False + elif e.get("event") == ActionExecuted.type_name: + return e.get("name") == ACTION_LISTEN_NAME + return False + + +async def _undo_latest(conversation_id: Text, endpoint: EndpointConfig) -> None: + """Undo either the latest bot action or user message, whatever is last.""" + tracker = await retrieve_tracker(endpoint, conversation_id, EventVerbosity.ALL) + + # Get latest `UserUtterance` or `ActionExecuted` event. + last_event_type = None + for i, e in enumerate(reversed(tracker.get("events", []))): + last_event_type = e.get("event") + if last_event_type in {ActionExecuted.type_name, UserUttered.type_name}: + break + elif last_event_type == Restarted.type_name: + break + + if last_event_type == ActionExecuted.type_name: + undo_action = ActionReverted().as_dict() + await send_event(endpoint, conversation_id, undo_action) + elif last_event_type == UserUttered.type_name: + undo_user_message = UserUtteranceReverted().as_dict() + listen_for_next_message = ActionExecuted(ACTION_LISTEN_NAME).as_dict() + + await send_event( + endpoint, conversation_id, [undo_user_message, listen_for_next_message] + ) + + +async def _fetch_events( + conversation_ids: List[Union[Text, List[Event]]], endpoint: EndpointConfig +) -> List[List[Event]]: + """Retrieve all event trackers from the endpoint for all conversation ids.""" + event_sequences = [] + for conversation_id in conversation_ids: + if isinstance(conversation_id, str): + tracker = await retrieve_tracker(endpoint, conversation_id) + events = tracker.get("events", []) + + for conversation in _split_conversation_at_restarts(events): + parsed_events = rasa.shared.core.events.deserialise_events(conversation) + event_sequences.append(parsed_events) + else: + event_sequences.append(conversation_id) + return event_sequences + + +async def _plot_trackers( + conversation_ids: List[Union[Text, List[Event]]], + output_file: Optional[Text], + endpoint: EndpointConfig, + unconfirmed: Optional[List[Event]] = None, +) -> None: + """Create a plot of the trackers of the passed conversation ids. + + This assumes that the last conversation id is the conversation we are currently + working on. If there are events that are not part of this active tracker + yet, they can be passed as part of `unconfirmed`. They will be appended + to the currently active conversation. + """ + if not output_file or not conversation_ids: + # if there is no output file provided, we are going to skip plotting + # same happens if there are no conversation ids + return + + event_sequences = await _fetch_events(conversation_ids, endpoint) + + if unconfirmed: + event_sequences[-1].extend(unconfirmed) + + graph = visualize_neighborhood( + event_sequences[-1], event_sequences, output_file=None, max_history=2 + ) + + from networkx.drawing.nx_pydot import write_dot + + with open(output_file, "w", encoding="utf-8") as f: + write_dot(graph, f) + + +def _print_help(skip_visualization: bool) -> None: + """Print some initial help message for the user.""" + if not skip_visualization: + visualization_url = DEFAULT_SERVER_FORMAT.format( + "http", DEFAULT_SERVER_PORT + 1 + ) + visualization_help = ( + f"Visualisation at {visualization_url}/visualization.html ." + ) + else: + visualization_help = "" + + rasa.shared.utils.cli.print_success( + f"Bot loaded. {visualization_help}\n" + f"Type a message and press enter " + f"(press 'Ctrl-c' to exit)." + ) + + +def intent_names_from_domain(domain: Any) -> List[Text]: + """Get a list of the possible intents names from the domain specification. + + This is its own function as intents are non-trivial to unpack and this + warrants testing. + """ + domain_intents = domain.get("intents", []) if domain is not None else [] + + # intents with properties such as `use_entities` or `ignore_entities` + # are a dictionary which needs unpacking. Other intents are strings + # and can be used as-is. + return [next(iter(i)) if isinstance(i, dict) else i for i in domain_intents] + + +async def record_messages( + endpoint: EndpointConfig, + file_importer: TrainingDataImporter, + conversation_id: Text = DEFAULT_SENDER_ID, + max_message_limit: Optional[int] = None, + skip_visualization: bool = False, +) -> None: + """Read messages from the command line and print bot responses.""" + try: + try: + domain = await retrieve_domain(endpoint) + except ClientError: + logger.exception( + f"Failed to connect to Rasa Core server at '{endpoint.url}'. " + f"Is the server running?" + ) + return + + intents = intent_names_from_domain(domain) + + num_messages = 0 + + if not skip_visualization: + events_including_current_user_id = _get_tracker_events_to_plot( + domain, file_importer, conversation_id + ) + + plot_file = DEFAULT_STORY_GRAPH_FILE + await _plot_trackers(events_including_current_user_id, plot_file, endpoint) + else: + # `None` means that future `_plot_trackers` calls will also skip the + # visualization. + plot_file = None + events_including_current_user_id = [] + + _print_help(skip_visualization) + + while not utils.is_limit_reached(num_messages, max_message_limit): + try: + if await is_listening_for_message(conversation_id, endpoint): + await _enter_user_message(conversation_id, endpoint) + await _validate_nlu(intents, endpoint, conversation_id) + + await _predict_till_next_listen( + endpoint, + conversation_id, + events_including_current_user_id, + plot_file, + ) + + num_messages += 1 + except RestartConversation: + await send_event(endpoint, conversation_id, Restarted().as_dict()) + + await send_event( + endpoint, + conversation_id, + ActionExecuted(ACTION_LISTEN_NAME).as_dict(), + ) + + logger.info("Restarted conversation, starting a new one.") + except UndoLastStep: + await _undo_latest(conversation_id, endpoint) + await _print_history(conversation_id, endpoint) + except ForkTracker: + await _print_history(conversation_id, endpoint) + + events_fork = await _request_fork_from_user(conversation_id, endpoint) + + await send_event(endpoint, conversation_id, Restarted().as_dict()) + + if events_fork: + for evt in events_fork: + await send_event(endpoint, conversation_id, evt) + logger.info("Restarted conversation at fork.") + + await _print_history(conversation_id, endpoint) + await _plot_trackers( + events_including_current_user_id, plot_file, endpoint + ) + + except Abort: + return + except Exception: + logger.exception("An exception occurred while recording messages.") + raise + + +def _get_tracker_events_to_plot( + domain: Dict[Text, Any], file_importer: TrainingDataImporter, conversation_id: Text +) -> List[Union[Text, Deque[Event]]]: + training_trackers = _get_training_trackers(file_importer, domain) + number_of_trackers = len(training_trackers) + if number_of_trackers > MAX_NUMBER_OF_TRAINING_STORIES_FOR_VISUALIZATION: + rasa.shared.utils.cli.print_warning( + f"You have {number_of_trackers} different story paths in " + f"your training data. Visualizing them is very resource " + f"consuming. Hence, the visualization will only show the stories " + f"which you created during interactive learning, but not your " + f"training stories." + ) + training_trackers = [] + + training_data_events: List[Union[Text, Deque[Event]]] = [ + t.events for t in training_trackers + ] + return training_data_events + [conversation_id] + + +def _get_training_trackers( + file_importer: TrainingDataImporter, domain: Dict[str, Any] +) -> List[TrackerWithCachedStates]: + from rasa.core import training + + return training.load_data( + file_importer, + Domain.from_dict(domain), + augmentation_factor=0, + use_story_concatenation=False, + ) + + +def _serve_application( + app: Sanic, + file_importer: TrainingDataImporter, + skip_visualization: bool, + conversation_id: Text, + port: int, +) -> Sanic: + """Start a core server and attach the interactive learning IO.""" + endpoint = EndpointConfig(url=DEFAULT_SERVER_FORMAT.format("http", port)) + + async def run_interactive_io(running_app: Sanic) -> None: + """Small wrapper to shut down the server once cmd io is done.""" + + await record_messages( + endpoint=endpoint, + file_importer=file_importer, + skip_visualization=skip_visualization, + conversation_id=conversation_id, + ) + + logger.info("Killing Sanic server now.") + + running_app.stop() # kill the sanic server + + app.add_task(run_interactive_io) + + update_sanic_log_level() + + app.run(host="0.0.0.0", port=port) + + return app + + +def start_visualization(image_path: Text, port: int) -> None: + """Add routes to serve the conversation visualization files.""" + app = Sanic("rasa_interactive") + + # noinspection PyUnusedLocal + @app.exception(NotFound) + async def ignore_404s(request: Request, exception: Exception) -> HTTPResponse: + return response.text("Not found", status=404) + + # noinspection PyUnusedLocal + @app.route(VISUALIZATION_TEMPLATE_PATH, methods=["GET"]) + async def visualisation_html(request: Request) -> HTTPResponse: + return await response.file(visualization.visualization_html_path()) + + # noinspection PyUnusedLocal + @app.route("/visualization.dot", methods=["GET"]) + async def visualisation_png(request: Request) -> HTTPResponse: + try: + headers = {"Cache-Control": "no-cache"} + return await response.file(os.path.abspath(image_path), headers=headers) + except FileNotFoundError: + return response.text("", 404) + + update_sanic_log_level() + + app.run(host="0.0.0.0", port=port, access_log=False) + + +def run_interactive_learning( + file_importer: TrainingDataImporter, + skip_visualization: bool = False, + conversation_id: Text = uuid.uuid4().hex, + server_args: Dict[Text, Any] = None, +) -> None: + """Start the interactive learning with the model of the agent.""" + global SAVE_IN_E2E + server_args = server_args or {} + + if server_args.get("nlu_data"): + PATHS["nlu"] = server_args["nlu_data"] + + if server_args.get("stories"): + PATHS["stories"] = server_args["stories"] + + if server_args.get("domain"): + PATHS["domain"] = server_args["domain"] + + port = server_args.get("port", DEFAULT_SERVER_PORT) + + SAVE_IN_E2E = server_args["e2e"] + + if not skip_visualization: + visualisation_port = port + 1 + p = Process( + target=start_visualization, + args=(DEFAULT_STORY_GRAPH_FILE, visualisation_port), + daemon=True, + ) + p.start() + else: + p = None + + app = run.configure_app(port=port, conversation_id="default", enable_api=True) + endpoints = AvailableEndpoints.read_endpoints(server_args.get("endpoints")) + + # before_server_start handlers make sure the agent is loaded before the + # interactive learning IO starts + app.register_listener( + partial(run.load_agent_on_start, server_args.get("model"), endpoints, None), + "before_server_start", + ) + + telemetry.track_interactive_learning_start(skip_visualization, SAVE_IN_E2E) + + _serve_application(app, file_importer, skip_visualization, conversation_id, port) + + if not skip_visualization and p is not None: + p.terminate() + p.join() + + +def calc_true_wrapping_width(text: Text, monospace_wrapping_width: int) -> int: + """Calculates a wrapping width that also works for CJK characters. + + Chinese, Japanese and Korean characters are often broader than ascii + characters: + abcdefgh (8 chars) + 我要去北京 (5 chars, roughly same visible width) + + We need to account for that otherwise the wrapping doesn't work + appropriately for long strings and the table overflows and creates + errors. + + params: + text: text sequence that should be wrapped into multiple lines + monospace_wrapping_width: the maximum width per line in number of + standard ascii characters + returns: + The maximum line width for the given string that takes into account + the strings visible width, so that it won't lead to table overflow. + """ + true_wrapping_width = 0 + + # testing potential width from longest to shortest + for potential_width in range(monospace_wrapping_width, -1, -1): + lines = textwrap.wrap(text, potential_width) + # test whether all lines' visible width fits the available width + if all( + [ + terminaltables.width_and_alignment.visible_width(line) + <= monospace_wrapping_width + for line in lines + ] + ): + true_wrapping_width = potential_width + break + + return true_wrapping_width diff --git a/rasa/core/training/story_conflict.py b/rasa/core/training/story_conflict.py new file mode 100644 index 0000000..a6e7ec3 --- /dev/null +++ b/rasa/core/training/story_conflict.py @@ -0,0 +1,384 @@ +from collections import defaultdict +import logging +import json +from typing import DefaultDict, Dict, Generator, List, NamedTuple, Optional, Text, Tuple + +from rasa.core.featurizers.tracker_featurizers import MaxHistoryTrackerFeaturizer +from rasa.shared.core.constants import ( + ACTION_LISTEN_NAME, + PREVIOUS_ACTION, + ACTION_UNLIKELY_INTENT_NAME, + USER, +) +from rasa.shared.core.domain import Domain, State +from rasa.shared.core.events import ActionExecuted, Event +from rasa.shared.core.generator import TrackerWithCachedStates + +from rasa.nlu.tokenizers.tokenizer import Tokenizer +from rasa.shared.nlu.constants import TEXT +from rasa.shared.nlu.training_data.message import Message + +logger = logging.getLogger(__name__) + + +class StoryConflict: + """Represents a conflict between two or more stories. + + Here, a conflict means that different actions are supposed to follow from + the same dialogue state, which most policies cannot learn. + """ + + def __init__(self, sliced_states: List[State]) -> None: + """ + Creates a `StoryConflict` from a given state. + + Args: + sliced_states: The (sliced) dialogue state at which the conflict occurs. + """ + + self._sliced_states = sliced_states + # A list of actions that all follow from the same state. + self._conflicting_actions: DefaultDict[Text, List[Text]] = defaultdict( + list + ) # {"action": ["story_1", ...], ...} + + def __hash__(self) -> int: + return hash(str(list(self._sliced_states))) + + def add_conflicting_action(self, action: Text, story_name: Text) -> None: + """Adds another action that follows from the same state. + + Args: + action: Name of the action. + story_name: Name of the story where this action is chosen. + """ + self._conflicting_actions[action] += [story_name] + + @property + def conflicting_actions(self) -> List[Text]: + """List of conflicting actions. + + Returns: + List of conflicting actions. + + """ + return list(self._conflicting_actions.keys()) + + @property + def conflict_has_prior_events(self) -> bool: + """Checks if prior events exist. + + Returns: + `True` if anything has happened before this conflict, otherwise `False`. + """ + return _get_previous_event(self._sliced_states[-1])[0] is not None + + def __str__(self) -> Text: + # Describe where the conflict occurs in the stories + last_event_type, last_event_name = _get_previous_event(self._sliced_states[-1]) + if last_event_type: + conflict_message = ( + f"Story structure conflict after {last_event_type} " + f"'{last_event_name}':\n" + ) + else: + conflict_message = "Story structure conflict at the beginning of stories:\n" + + # List which stories are in conflict with one another + for action, stories in self._conflicting_actions.items(): + conflict_message += ( + f" {self._summarize_conflicting_actions(action, stories)}" + ) + + return conflict_message + + @staticmethod + def _summarize_conflicting_actions(action: Text, stories: List[Text]) -> Text: + """Gives a summarized textual description of where one action occurs. + + Args: + action: The name of the action. + stories: The stories in which the action occurs. + + Returns: + A textural summary. + """ + if len(stories) > 3: + # Four or more stories are present + conflict_description = ( + f"'{stories[0]}', '{stories[1]}', and {len(stories) - 2} other trackers" + ) + elif len(stories) == 3: + conflict_description = f"'{stories[0]}', '{stories[1]}', and '{stories[2]}'" + elif len(stories) == 2: + conflict_description = f"'{stories[0]}' and '{stories[1]}'" + elif len(stories) == 1: + conflict_description = f"'{stories[0]}'" + else: + raise ValueError( + "An internal error occurred while trying to summarise a conflict " + "without stories. Please file a bug report at " + "https://github.com/RasaHQ/rasa." + ) + + return f"{action} predicted in {conflict_description}\n" + + +class TrackerEventStateTuple(NamedTuple): + """Holds a tracker, an event, and sliced states associated with those.""" + + tracker: TrackerWithCachedStates + event: Event + sliced_states: List[State] + + @property + def sliced_states_hash(self) -> int: + """Returns the hash of the sliced states.""" + return hash(json.dumps(self.sliced_states, sort_keys=True)) + + +def find_story_conflicts( + trackers: List[TrackerWithCachedStates], + domain: Domain, + max_history: Optional[int] = None, +) -> List[StoryConflict]: + """Generates `StoryConflict` objects, describing conflicts in the given trackers. + + Args: + trackers: Trackers in which to search for conflicts. + domain: The domain. + max_history: The maximum history length to be taken into account. + + Returns: + StoryConflict objects. + """ + if max_history: + logger.info( + f"Considering the preceding {max_history} turns for conflict analysis." + ) + else: + logger.info("Considering all preceding turns for conflict analysis.") + + # We do this in two steps, to reduce memory consumption: + + # Create a 'state -> list of actions' dict, where the state is + # represented by its hash + conflicting_state_action_mapping = _find_conflicting_states( + trackers, domain, max_history + ) + + # Iterate once more over all states and note the (unhashed) state, + # for which a conflict occurs + conflicts = _build_conflicts_from_states( + trackers, domain, max_history, conflicting_state_action_mapping + ) + + return conflicts + + +def _find_conflicting_states( + trackers: List[TrackerWithCachedStates], + domain: Domain, + max_history: Optional[int], + tokenizer: Optional[Tokenizer] = None, +) -> Dict[int, List[int]]: + """Identifies all states from which different actions follow. + + Args: + trackers: Trackers that contain the states. + domain: The domain object. + max_history: Number of turns to take into account for the state descriptions. + tokenizer: A tokenizer to tokenize the user messages. + + Returns: + A dictionary mapping state-hashes to a list of actions that follow from each + state. + """ + # Create a 'state -> list of actions' dict, where the state is + # represented by its hash + state_action_mapping: DefaultDict[int, List[int]] = defaultdict(list) + + for element in _sliced_states_iterator(trackers, domain, max_history, tokenizer): + hashed_state = element.sliced_states_hash + current_hash = hash(element.event) + + if current_hash not in state_action_mapping[ + hashed_state + ] or _unlearnable_action(element.event): + state_action_mapping[hashed_state] += [current_hash] + + # Keep only conflicting `state_action_mapping`s + # or those mappings that contain `action_unlikely_intent` + action_unlikely_intent_hash = hash( + ActionExecuted(action_name=ACTION_UNLIKELY_INTENT_NAME) + ) + return { + state_hash: actions + for (state_hash, actions) in state_action_mapping.items() + if len(actions) > 1 or action_unlikely_intent_hash in actions + } + + +def _unlearnable_action(event: Event) -> bool: + """Identifies if the action cannot be learned by policies that use story data. + + Args: + event: An event to be checked. + + Returns: + `True` if the event can be learned, `False` otherwise. + """ + return ( + isinstance(event, ActionExecuted) + and event.action_name == ACTION_UNLIKELY_INTENT_NAME + ) + + +def _build_conflicts_from_states( + trackers: List[TrackerWithCachedStates], + domain: Domain, + max_history: Optional[int], + conflicting_state_action_mapping: Dict[int, List[int]], + tokenizer: Optional[Tokenizer] = None, +) -> List["StoryConflict"]: + """Builds a list of `StoryConflict` objects for each given conflict. + + Args: + trackers: Trackers that contain the states. + domain: The domain object. + max_history: Number of turns to take into account for the state descriptions. + conflicting_state_action_mapping: A dictionary mapping state-hashes to a list + of actions that follow from each state. + tokenizer: A tokenizer to tokenize the user messages. + + Returns: + A list of `StoryConflict` objects that describe inconsistencies in the story + structure. These objects also contain the history that leads up to the conflict. + """ + # Iterate once more over all states and note the (unhashed) state, + # for which a conflict occurs + conflicts = {} + for element in _sliced_states_iterator(trackers, domain, max_history, tokenizer): + hashed_state = element.sliced_states_hash + + if hashed_state in conflicting_state_action_mapping: + if hashed_state not in conflicts: + conflicts[hashed_state] = StoryConflict(element.sliced_states) + + conflicts[hashed_state].add_conflicting_action( + action=str(element.event), story_name=element.tracker.sender_id + ) + + # Return list of conflicts that arise from unpredictable actions + # (actions that start the conversation) + return [ + conflict + for (hashed_state, conflict) in conflicts.items() + if conflict.conflict_has_prior_events + ] + + +def _sliced_states_iterator( + trackers: List[TrackerWithCachedStates], + domain: Domain, + max_history: Optional[int], + tokenizer: Optional[Tokenizer], +) -> Generator[TrackerEventStateTuple, None, None]: + """Creates an iterator over sliced states. + + Iterate over all given trackers and all sliced states within each tracker, + where the slicing is based on `max_history`. + + Args: + trackers: List of trackers. + domain: Domain (used for tracker.past_states). + max_history: Assumed `max_history` value for slicing. + tokenizer: A tokenizer to tokenize the user messages. + + Yields: + A (tracker, event, sliced_states) triplet. + """ + for tracker in trackers: + states = tracker.past_states(domain) + + idx = 0 + for event in tracker.events: + if isinstance(event, ActionExecuted): + sliced_states = MaxHistoryTrackerFeaturizer.slice_state_history( + states[: idx + 1], max_history + ) + if tokenizer: + _apply_tokenizer_to_states(tokenizer, sliced_states) + # TODO: deal with oov (different tokens can lead to identical features + # if some of those tokens are out of vocabulary for all featurizers) + yield TrackerEventStateTuple(tracker, event, sliced_states) + idx += 1 + + +def _apply_tokenizer_to_states(tokenizer: Tokenizer, states: List[State]) -> None: + """Split each user text into tokens and concatenate them again. + + Args: + tokenizer: A tokenizer to tokenize the user messages. + states: The states to be tokenized. + """ + for state in states: + if USER in state and TEXT in state[USER]: + state[USER][TEXT] = " ".join( + token.text + for token in tokenizer.tokenize( + Message({TEXT: state[USER][TEXT]}), TEXT + ) + ) + + +def _get_previous_event( + state: Optional[State], +) -> Tuple[Optional[Text], Optional[Text]]: + """Returns previous event type and name. + + Returns the type and name of the event (action or intent) previous to the + given state (excluding action_listen). + + Args: + state: Element of sliced states. + + Returns: + Tuple of (type, name) strings of the prior event. + """ + + previous_event_type = None + previous_event_name = None + + # A typical state might be + # `{'user': {'intent': 'greet'}, 'prev_action': {'action_name': 'action_listen'}}`. + if not state: + previous_event_type = None + previous_event_name = None + elif ( + PREVIOUS_ACTION in state.keys() + and "action_name" in state[PREVIOUS_ACTION] + and state[PREVIOUS_ACTION]["action_name"] != ACTION_LISTEN_NAME + ): + previous_event_type = "action" + previous_event_name = state[PREVIOUS_ACTION]["action_name"] + elif PREVIOUS_ACTION in state.keys() and "action_text" in state[PREVIOUS_ACTION]: + previous_event_type = "bot utterance" + previous_event_name = state[PREVIOUS_ACTION]["action_text"] + elif USER in state.keys(): + if "intent" in state[USER]: + previous_event_type = "intent" + previous_event_name = state[USER]["intent"] + elif "text" in state[USER]: + previous_event_type = "user utterance" + previous_event_name = state[USER]["text"] + + if not isinstance(previous_event_name, (str, type(None))): + # While the Substate type doesn't restrict the value of `action_text` / + # `intent`, etc. to be a string, it always should be + raise TypeError( + f"The value '{previous_event_name}' in the substate should be a string or " + f"None, not {type(previous_event_name)}. Did you modify Rasa source code?" + ) + + return previous_event_type, previous_event_name diff --git a/rasa/core/training/training.py b/rasa/core/training/training.py new file mode 100644 index 0000000..836a628 --- /dev/null +++ b/rasa/core/training/training.py @@ -0,0 +1,93 @@ +import dataclasses + +from typing import DefaultDict, Dict, List, Optional, Set, Text, TYPE_CHECKING +from collections import defaultdict + +from rasa.shared.core.events import ActionExecuted, UserUttered +from rasa.shared.core.events import SlotSet, ActiveLoop + +if TYPE_CHECKING: + from rasa.shared.core.domain import Domain + from rasa.shared.core.trackers import DialogueStateTracker + from rasa.shared.core.events import Event + + +@dataclasses.dataclass +class ActionFingerprint: + """Dataclass to represent an action fingerprint.""" + + slots: List[Text] + active_loop: List[Optional[Text]] + + +def _find_events_after_actions( + trackers: List["DialogueStateTracker"], +) -> DefaultDict[Text, Set["Event"]]: + """Creates a mapping of action names / texts and events that follow these actions. + + Args: + trackers: the list of trackers + + Returns: + A mapping of action names / texts and events that follow these actions. + """ + events_after_actions = defaultdict(set) + + for tracker in trackers: + action_name = None + for event in tracker.events: + if isinstance(event, ActionExecuted): + action_name = event.action_name or event.action_text + continue + if isinstance(event, UserUttered): + # UserUttered can contain entities that might set some slots, reset + # action_name so that these slots are not attributed to action_listen + action_name = None + continue + + if action_name: + events_after_actions[action_name].add(event) + + return events_after_actions + + +def create_action_fingerprints( + trackers: List["DialogueStateTracker"], domain: "Domain" +) -> Dict[Text, ActionFingerprint]: + """Fingerprint each action using the events it created during train. + + This allows us to emit warnings when the model is used + if an action does things it hasn't done during training, + or if rules are incomplete. + + Args: + trackers: the list of trackers + domain: the domain + + Returns: + a nested dictionary of action names and slots and active loops + that this action sets + """ + events_after_actions = _find_events_after_actions(trackers) + if not events_after_actions: + return {} + + # take into account only featurized slots + featurized_slots = {slot.name for slot in domain.slots if slot.has_features()} + action_fingerprints: Dict[Text, ActionFingerprint] = {} + for action_name, events_after_action in events_after_actions.items(): + slots = list( + set( + event.key for event in events_after_action if isinstance(event, SlotSet) + ).intersection(featurized_slots) + ) + active_loops = list( + set( + event.name + for event in events_after_action + if isinstance(event, ActiveLoop) + ) + ) + action_fingerprints[action_name] = ActionFingerprint(slots, active_loops) + + return action_fingerprints diff --git a/rasa/core/utils.py b/rasa/core/utils.py new file mode 100644 index 0000000..0f3150e --- /dev/null +++ b/rasa/core/utils.py @@ -0,0 +1,342 @@ +import json +import logging +import os +from decimal import Decimal +from pathlib import Path +from typing import Any, Dict, Optional, Set, Text, Tuple, Union + +import numpy as np + +import rasa.shared.utils.io +from rasa.constants import DEFAULT_SANIC_WORKERS, ENV_SANIC_WORKERS +from rasa.shared.constants import DEFAULT_ENDPOINTS_PATH, TCP_PROTOCOL + +from rasa.core.lock_store import LockStore, RedisLockStore, InMemoryLockStore +from rasa.utils.endpoints import EndpointConfig, read_endpoint_config +from sanic import Sanic +from socket import SOCK_DGRAM, SOCK_STREAM +import rasa.cli.utils as cli_utils + + +logger = logging.getLogger(__name__) + + +def configure_file_logging( + logger_obj: logging.Logger, + log_file: Optional[Text], + use_syslog: Optional[bool], + syslog_address: Optional[Text] = None, + syslog_port: Optional[int] = None, + syslog_protocol: Optional[Text] = None, +) -> None: + """Configure logging to a file. + + Args: + logger_obj: Logger object to configure. + log_file: Path of log file to write to. + use_syslog: Add syslog as a logger. + syslog_address: Adress of the syslog server. + syslog_port: Port of the syslog server. + syslog_protocol: Protocol with the syslog server + """ + if use_syslog: + formatter = logging.Formatter( + "%(asctime)s [%(levelname)-5.5s] [%(process)d]" " %(message)s" + ) + socktype = SOCK_STREAM if syslog_protocol == TCP_PROTOCOL else SOCK_DGRAM + syslog_handler = logging.handlers.SysLogHandler( + address=(syslog_address, syslog_port), socktype=socktype + ) + syslog_handler.setLevel(logger_obj.level) + syslog_handler.setFormatter(formatter) + logger_obj.addHandler(syslog_handler) + if log_file: + formatter = logging.Formatter("%(asctime)s [%(levelname)-5.5s] %(message)s") + file_handler = logging.FileHandler( + log_file, encoding=rasa.shared.utils.io.DEFAULT_ENCODING + ) + file_handler.setLevel(logger_obj.level) + file_handler.setFormatter(formatter) + logger_obj.addHandler(file_handler) + + +def one_hot(hot_idx: int, length: int, dtype: Optional[Text] = None) -> np.ndarray: + """Create a one-hot array. + + Args: + hot_idx: Index of the hot element. + length: Length of the array. + dtype: ``numpy.dtype`` of the array. + + Returns: + One-hot array. + """ + if hot_idx >= length: + raise ValueError( + "Can't create one hot. Index '{}' is out " + "of range (length '{}')".format(hot_idx, length) + ) + r = np.zeros(length, dtype) + r[hot_idx] = 1 + return r + + +def dump_obj_as_yaml_to_file( + filename: Union[Text, Path], obj: Any, should_preserve_key_order: bool = False +) -> None: + """Writes `obj` to the filename in YAML repr. + + Args: + filename: Target filename. + obj: Object to dump. + should_preserve_key_order: Whether to preserve key order in `obj`. + """ + rasa.shared.utils.io.write_yaml( + obj, filename, should_preserve_key_order=should_preserve_key_order + ) + + +def list_routes(app: Sanic) -> Dict[Text, Text]: + """List all the routes of a sanic application. Mainly used for debugging.""" + from urllib.parse import unquote + + output = {} + + def find_route(suffix: Text, path: Text) -> Optional[Text]: + for name, (uri, _) in app.router.routes_names.items(): + if name.split(".")[-1] == suffix and uri == path: + return name + return None + + for route in app.router.routes: + endpoint = route.parts + if endpoint[:-1] in app.router.routes_all and endpoint[-1] == "/": + continue + + options = {} + for arg in route._params: + options[arg] = f"[{arg}]" + + handlers = [(list(route.methods)[0], route.name.replace("rasa_server.", ""))] + + for method, name in handlers: + full_endpoint = "/" + "/".join(endpoint) + line = unquote(f"{full_endpoint:50s} {method:30s} {name}") + output[name] = line + + url_table = "\n".join(output[url] for url in sorted(output)) + logger.debug(f"Available web server routes: \n{url_table}") + + return output + + +def extract_args( + kwargs: Dict[Text, Any], keys_to_extract: Set[Text] +) -> Tuple[Dict[Text, Any], Dict[Text, Any]]: + """Go through the kwargs and filter out the specified keys. + + Return both, the filtered kwargs as well as the remaining kwargs. + """ + remaining = {} + extracted = {} + for k, v in kwargs.items(): + if k in keys_to_extract: + extracted[k] = v + else: + remaining[k] = v + + return extracted, remaining + + +def is_limit_reached(num_messages: int, limit: Optional[int]) -> bool: + """Determine whether the number of messages has reached a limit. + + Args: + num_messages: The number of messages to check. + limit: Limit on the number of messages. + + Returns: + `True` if the limit has been reached, otherwise `False`. + """ + return limit is not None and num_messages >= limit + + +def file_as_bytes(path: Text) -> bytes: + """Read in a file as a byte array.""" + with open(path, "rb") as f: + return f.read() + + +class AvailableEndpoints: + """Collection of configured endpoints.""" + + @classmethod + def read_endpoints(cls, endpoint_file: Text) -> "AvailableEndpoints": + """Read the different endpoints from a yaml file.""" + nlg = read_endpoint_config(endpoint_file, endpoint_type="nlg") + nlu = read_endpoint_config(endpoint_file, endpoint_type="nlu") + action = read_endpoint_config(endpoint_file, endpoint_type="action_endpoint") + model = read_endpoint_config(endpoint_file, endpoint_type="models") + tracker_store = read_endpoint_config( + endpoint_file, endpoint_type="tracker_store" + ) + lock_store = read_endpoint_config(endpoint_file, endpoint_type="lock_store") + event_broker = read_endpoint_config(endpoint_file, endpoint_type="event_broker") + + return cls( + nlg, + nlu, + action, + model, + tracker_store, + lock_store, + event_broker, + ) + + def __init__( + self, + nlg: Optional[EndpointConfig] = None, + nlu: Optional[EndpointConfig] = None, + action: Optional[EndpointConfig] = None, + model: Optional[EndpointConfig] = None, + tracker_store: Optional[EndpointConfig] = None, + lock_store: Optional[EndpointConfig] = None, + event_broker: Optional[EndpointConfig] = None, + ) -> None: + """Create an `AvailableEndpoints` object.""" + self.model = model + self.action = action + self.nlu = nlu + self.nlg = nlg + self.tracker_store = tracker_store + self.lock_store = lock_store + self.event_broker = event_broker + + +def read_endpoints_from_path( + endpoints_path: Optional[Union[Path, Text]] = None +) -> AvailableEndpoints: + """Get `AvailableEndpoints` object from specified path. + + Args: + endpoints_path: Path of the endpoints file to be read. If `None` the + default path for that file is used (`endpoints.yml`). + + Returns: + `AvailableEndpoints` object read from endpoints file. + + """ + endpoints_config_path = cli_utils.get_validated_path( + endpoints_path, "endpoints", DEFAULT_ENDPOINTS_PATH, True + ) + return AvailableEndpoints.read_endpoints(endpoints_config_path) + + +def replace_floats_with_decimals(obj: Any, round_digits: int = 9) -> Any: + """Convert all instances in `obj` of `float` to `Decimal`. + + Args: + obj: Input object. + round_digits: Rounding precision of `Decimal` values. + + Returns: + Input `obj` with all `float` types replaced by `Decimal`s rounded to + `round_digits` decimal places. + """ + + def _float_to_rounded_decimal(s: Text) -> Decimal: + return Decimal(s).quantize(Decimal(10) ** -round_digits) + + return json.loads(json.dumps(obj), parse_float=_float_to_rounded_decimal) + + +class DecimalEncoder(json.JSONEncoder): + """`json.JSONEncoder` that dumps `Decimal`s as `float`s.""" + + def default(self, obj: Any) -> Any: + """Get serializable object for `o`. + + Args: + obj: Object to serialize. + + Returns: + `obj` converted to `float` if `o` is a `Decimals`, else the base class + `default()` method. + """ + if isinstance(obj, Decimal): + return float(obj) + return super().default(obj) + + +def replace_decimals_with_floats(obj: Any) -> Any: + """Convert all instances in `obj` of `Decimal` to `float`. + + Args: + obj: A `List` or `Dict` object. + + Returns: + Input `obj` with all `Decimal` types replaced by `float`s. + """ + return json.loads(json.dumps(obj, cls=DecimalEncoder)) + + +def _lock_store_is_multi_worker_compatible( + lock_store: Union[EndpointConfig, LockStore, None] +) -> bool: + if isinstance(lock_store, InMemoryLockStore): + return False + + if isinstance(lock_store, RedisLockStore): + return True + + # `lock_store` is `None` or `EndpointConfig` + return ( + lock_store is not None + and isinstance(lock_store, EndpointConfig) + and lock_store.type != "in_memory" + ) + + +def number_of_sanic_workers(lock_store: Union[EndpointConfig, LockStore, None]) -> int: + """Get the number of Sanic workers to use in `app.run()`. + + If the environment variable constants.ENV_SANIC_WORKERS is set and is not equal to + 1, that value will only be permitted if the used lock store is not the + `InMemoryLockStore`. + """ + + def _log_and_get_default_number_of_workers() -> int: + logger.debug( + f"Using the default number of Sanic workers ({DEFAULT_SANIC_WORKERS})." + ) + return DEFAULT_SANIC_WORKERS + + try: + env_value = int(os.environ.get(ENV_SANIC_WORKERS, DEFAULT_SANIC_WORKERS)) + except ValueError: + logger.error( + f"Cannot convert environment variable `{ENV_SANIC_WORKERS}` " + f"to int ('{os.environ[ENV_SANIC_WORKERS]}')." + ) + return _log_and_get_default_number_of_workers() + + if env_value == DEFAULT_SANIC_WORKERS: + return _log_and_get_default_number_of_workers() + + if env_value < 1: + logger.debug( + f"Cannot set number of Sanic workers to the desired value " + f"({env_value}). The number of workers must be at least 1." + ) + return _log_and_get_default_number_of_workers() + + if _lock_store_is_multi_worker_compatible(lock_store): + logger.debug(f"Using {env_value} Sanic workers.") + return env_value + + logger.debug( + f"Unable to assign desired number of Sanic workers ({env_value}) as " + f"no `RedisLockStore` or custom `LockStore` endpoint " + f"configuration has been found." + ) + return _log_and_get_default_number_of_workers() diff --git a/rasa/core/visualize.py b/rasa/core/visualize.py new file mode 100644 index 0000000..ce8fa33 --- /dev/null +++ b/rasa/core/visualize.py @@ -0,0 +1,70 @@ +import logging +import os +from typing import Text + +from rasa import telemetry +from rasa.shared.core.training_data import loading +from rasa.shared.utils.cli import print_error +from rasa.shared.core.domain import InvalidDomain, Domain + +logger = logging.getLogger(__name__) + + +def visualize( + domain_path: Text, + stories_path: Text, + nlu_data_path: Text, + output_path: Text, + max_history: int, +) -> None: + """Visualizes stories as graph. + + Args: + domain_path: Path to the domain file. + stories_path: Path to the stories files. + nlu_data_path: Path to the NLU training data which can be used to interpolate + intents with actual examples in the graph. + output_path: Path where the created graph should be persisted. + max_history: Max history to use for the story visualization. + """ + import rasa.shared.core.training_data.visualization + + try: + domain = Domain.load(domain_path) + except InvalidDomain as e: + print_error( + f"Could not load domain due to: '{e}'. To specify a valid domain path use " + f"the '--domain' argument." + ) + return + + # this is optional, only needed if the `/greet` type of + # messages in the stories should be replaced with actual + # messages (e.g. `hello`) + if nlu_data_path is not None: + import rasa.shared.nlu.training_data.loading + + nlu_training_data = rasa.shared.nlu.training_data.loading.load_data( + nlu_data_path + ) + else: + nlu_training_data = None + + logger.info("Starting to visualize stories...") + telemetry.track_visualization() + + story_steps = loading.load_data_from_resource(stories_path, domain) + rasa.shared.core.training_data.visualization.visualize_stories( + story_steps, + domain, + output_path, + max_history, + nlu_training_data=nlu_training_data, + ) + + full_output_path = "file://{}".format(os.path.abspath(output_path)) + logger.info(f"Finished graph creation. Saved into {full_output_path}") + + import webbrowser + + webbrowser.open(full_output_path) diff --git a/rasa/engine/__init__.py b/rasa/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/engine/caching.py b/rasa/engine/caching.py new file mode 100644 index 0000000..b1e250f --- /dev/null +++ b/rasa/engine/caching.py @@ -0,0 +1,470 @@ +from __future__ import annotations + +import abc +import logging +import os +import shutil +from datetime import datetime +from pathlib import Path +from typing import Text, Any, Optional, Tuple, List + +from packaging import version +from sqlalchemy.engine import URL + +from sqlalchemy.exc import OperationalError +from typing_extensions import Protocol, runtime_checkable + +import rasa +import rasa.model +import rasa.utils.common +import rasa.shared.utils.common +from rasa.constants import MINIMUM_COMPATIBLE_VERSION +import sqlalchemy as sa +import sqlalchemy.orm +from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta + +from rasa.engine.storage.storage import ModelStorage + +logger = logging.getLogger(__name__) + +DEFAULT_CACHE_LOCATION = Path(".rasa", "cache") +DEFAULT_CACHE_NAME = "cache.db" +DEFAULT_CACHE_SIZE_MB = 1000 + +CACHE_LOCATION_ENV = "RASA_CACHE_DIRECTORY" +CACHE_DB_NAME_ENV = "RASA_CACHE_NAME" +CACHE_SIZE_ENV = "RASA_MAX_CACHE_SIZE" + + +class TrainingCache(abc.ABC): + """Stores training results in a persistent cache. + + Used to minimize re-retraining when the data / config didn't change in between + training runs. + """ + + @abc.abstractmethod + def cache_output( + self, + fingerprint_key: Text, + output: Any, + output_fingerprint: Text, + model_storage: ModelStorage, + ) -> None: + """Adds the output to the cache. + + If the output is of type `Cacheable` the output is persisted to disk in addition + to its fingerprint. + + Args: + fingerprint_key: The fingerprint key serves as key for the cache. Graph + components can use their fingerprint key to lookup fingerprints of + previous training runs. + output: The output. The output is only cached to disk if it's of type + `Cacheable`. + output_fingerprint: The fingerprint of their output. This can be used + to lookup potentially persisted outputs on disk. + model_storage: Required for caching `Resource` instances. E.g. `Resource`s + use that to copy data from the model storage to the cache. + """ + + ... + + @abc.abstractmethod + def get_cached_output_fingerprint(self, fingerprint_key: Text) -> Optional[Text]: + """Retrieves fingerprint of output based on fingerprint key. + + Args: + fingerprint_key: The fingerprint serves as key for the lookup of output + fingerprints. + + Returns: + The fingerprint of a matching output or `None` in case no cache entry was + found for the given fingerprint key. + """ + ... + + @abc.abstractmethod + def get_cached_result( + self, output_fingerprint_key: Text, node_name: Text, model_storage: ModelStorage + ) -> Optional[Cacheable]: + """Returns a potentially cached output result. + + Args: + output_fingerprint_key: The fingerprint key of the output serves as lookup + key for a potentially cached version of this output. + node_name: The name of the graph node which wants to use this cached result. + model_storage: The current model storage (e.g. used when restoring + `Resource` objects so that they can fill the model storage with data). + + Returns: + `None` if no matching result was found or restored `Cacheable`. + """ + ... + + +@runtime_checkable +class Cacheable(Protocol): + """Protocol for cacheable graph component outputs. + + We only cache graph component outputs which are `Cacheable`. We only store the + output fingerprint for everything else. + """ + + def to_cache(self, directory: Path, model_storage: ModelStorage) -> None: + """Persists `Cacheable` to disk. + + Args: + directory: The directory where the `Cacheable` can persist itself to. + model_storage: The current model storage (e.g. used when caching `Resource` + objects. + """ + ... + + @classmethod + def from_cache( + cls, + node_name: Text, + directory: Path, + model_storage: ModelStorage, + output_fingerprint: Text, + ) -> Cacheable: + """Loads `Cacheable` from cache. + + Args: + node_name: The name of the graph node which wants to use this cached result. + directory: Directory containing the persisted `Cacheable`. + model_storage: The current model storage (e.g. used when restoring + `Resource` objects so that they can fill the model storage with data). + output_fingerprint: The fingerprint of the cached result (e.g. used when + restoring `Resource` objects as the fingerprint can not be easily + calculated from the object itself). + + Returns: + Instantiated `Cacheable`. + """ + ... + + +class LocalTrainingCache(TrainingCache): + """Caches training results on local disk (see parent class for full docstring).""" + + Base: DeclarativeMeta = declarative_base() + + class CacheEntry(Base): + """Stores metadata about a single cache entry.""" + + __tablename__ = "cache_entry" + + fingerprint_key = sa.Column(sa.String(), primary_key=True) + output_fingerprint_key = sa.Column(sa.String(), nullable=False, index=True) + last_used = sa.Column(sa.DateTime(timezone=True), nullable=False) + rasa_version = sa.Column(sa.String(255), nullable=False) + result_location = sa.Column(sa.String()) + result_type = sa.Column(sa.String()) + + def __init__(self) -> None: + """Creates cache. + + The `Cache` setting can be configured via environment variables. + """ + self._cache_location = LocalTrainingCache._get_cache_location() + + self._max_cache_size = float( + os.environ.get(CACHE_SIZE_ENV, DEFAULT_CACHE_SIZE_MB) + ) + + self._cache_database_name = os.environ.get( + CACHE_DB_NAME_ENV, DEFAULT_CACHE_NAME + ) + + if not self._cache_location.exists() and not self._is_disabled(): + logger.debug( + f"Creating caching directory '{self._cache_location}' because " + f"it doesn't exist yet." + ) + self._cache_location.mkdir(parents=True) + + self._sessionmaker = self._create_database() + + self._drop_cache_entries_from_incompatible_versions() + + @staticmethod + def _get_cache_location() -> Path: + return Path(os.environ.get(CACHE_LOCATION_ENV, DEFAULT_CACHE_LOCATION)) + + def _create_database(self) -> sqlalchemy.orm.sessionmaker: + if self._is_disabled(): + # Use in-memory database as mock to avoid having to check `_is_disabled` + # everywhere + database = "" + else: + database = str(self._cache_location / self._cache_database_name) + + # Use `future=True` as we are using the 2.x query style + engine = sa.create_engine( + URL.create(drivername="sqlite", database=database), future=True + ) + self.Base.metadata.create_all(engine) + + return sa.orm.sessionmaker(engine) + + def _drop_cache_entries_from_incompatible_versions(self) -> None: + incompatible_entries = self._find_incompatible_cache_entries() + + for entry in incompatible_entries: + self._delete_cached_result(entry) + + self._delete_incompatible_entries_from_cache(incompatible_entries) + + logger.debug( + f"Deleted {len(incompatible_entries)} from disk as their version " + f"is older than the minimum compatible version " + f"('{MINIMUM_COMPATIBLE_VERSION}')." + ) + + def _find_incompatible_cache_entries(self) -> List[LocalTrainingCache.CacheEntry]: + with self._sessionmaker() as session: + query_for_cache_entries = sa.select(self.CacheEntry) + all_entries: List[LocalTrainingCache.CacheEntry] = ( + session.execute(query_for_cache_entries).scalars().all() + ) + + return [ + entry + for entry in all_entries + if version.parse(MINIMUM_COMPATIBLE_VERSION) + > version.parse(entry.rasa_version) + ] + + def _delete_incompatible_entries_from_cache( + self, incompatible_entries: List[LocalTrainingCache.CacheEntry] + ) -> None: + incompatible_fingerprints = [ + entry.fingerprint_key for entry in incompatible_entries + ] + with self._sessionmaker.begin() as session: + delete_query = sa.delete(self.CacheEntry).where( + self.CacheEntry.fingerprint_key.in_(incompatible_fingerprints) + ) + session.execute(delete_query) + + @staticmethod + def _delete_cached_result(entry: LocalTrainingCache.CacheEntry) -> None: + if entry.result_location and Path(entry.result_location).is_dir(): + shutil.rmtree(entry.result_location) + + def cache_output( + self, + fingerprint_key: Text, + output: Any, + output_fingerprint: Text, + model_storage: ModelStorage, + ) -> None: + """Adds the output to the cache (see parent class for full docstring).""" + if self._is_disabled(): + return + + cache_dir, output_type = None, None + if isinstance(output, Cacheable): + cache_dir, output_type = self._cache_output_to_disk(output, model_storage) + + try: + self._add_cache_entry( + cache_dir, fingerprint_key, output_fingerprint, output_type + ) + except OperationalError: + if cache_dir: + shutil.rmtree(cache_dir) + + raise + + def _add_cache_entry( + self, + cache_dir: Optional[Text], + fingerprint_key: Text, + output_fingerprint: Text, + output_type: Text, + ) -> None: + with self._sessionmaker.begin() as session: + cache_entry = self.CacheEntry( + fingerprint_key=fingerprint_key, + output_fingerprint_key=output_fingerprint, + last_used=datetime.utcnow(), + rasa_version=rasa.__version__, + result_location=cache_dir, + result_type=output_type, + ) + session.merge(cache_entry) + + def _is_disabled(self) -> bool: + return self._max_cache_size == 0.0 + + def _cache_output_to_disk( + self, output: Cacheable, model_storage: ModelStorage + ) -> Tuple[Optional[Text], Optional[Text]]: + tempdir_name = rasa.utils.common.get_temp_dir_name() + + # Use `TempDirectoryPath` instead of `tempfile.TemporaryDirectory` as this + # leads to errors on Windows when the context manager tries to delete an + # already deleted temporary directory (e.g. https://bugs.python.org/issue29982) + with rasa.utils.common.TempDirectoryPath(tempdir_name) as temp_dir: + tmp_path = Path(temp_dir) + try: + + output.to_cache(tmp_path, model_storage) + + logger.debug( + f"Caching output of type '{type(output).__name__}' succeeded." + ) + except Exception as e: + logger.error( + f"Caching output of type '{type(output).__name__}' failed with the " + f"following error:\n{e}" + ) + return None, None + + output_size = rasa.utils.common.directory_size_in_mb(tmp_path) + if output_size > self._max_cache_size: + logger.debug( + f"Caching result of type '{type(output).__name__}' was skipped " + f"because it exceeds the maximum cache size of " + f"{self._max_cache_size} MiB." + ) + return None, None + + while ( + rasa.utils.common.directory_size_in_mb( + self._cache_location, + filenames_to_exclude=[self._cache_database_name], + ) + + output_size + > self._max_cache_size + ): + self._drop_least_recently_used_item() + + output_type = rasa.shared.utils.common.module_path_from_instance(output) + cache_path = shutil.move(temp_dir, self._cache_location) + + return cache_path, output_type + + def _drop_least_recently_used_item(self) -> None: + with self._sessionmaker.begin() as session: + query_for_least_recently_used_entry = sa.select(self.CacheEntry).order_by( + self.CacheEntry.last_used.asc() + ) + oldest_cache_item = ( + session.execute(query_for_least_recently_used_entry).scalars().first() + ) + + if not oldest_cache_item: + self._purge_cache_dir_content() + return + + self._delete_cached_result(oldest_cache_item) + delete_query = sa.delete(self.CacheEntry).where( + self.CacheEntry.fingerprint_key == oldest_cache_item.fingerprint_key + ) + session.execute(delete_query) + + logger.debug( + f"Deleted item with fingerprint " + f"'{oldest_cache_item.fingerprint_key}' to free space." + ) + + def _purge_cache_dir_content(self) -> None: + for item in self._cache_location.glob("*"): + if item.name == self._cache_database_name: + continue + + if item.is_dir(): + shutil.rmtree(item) + else: + item.unlink() + + def get_cached_output_fingerprint(self, fingerprint_key: Text) -> Optional[Text]: + """Returns cached output fingerprint (see parent class for full docstring).""" + with self._sessionmaker.begin() as session: + query = sa.select(self.CacheEntry).filter_by( + fingerprint_key=fingerprint_key + ) + match = session.execute(query).scalars().first() + + if match: + # This result was used during a fingerprint run. + match.last_used = datetime.utcnow() + return match.output_fingerprint_key + + return None + + def get_cached_result( + self, output_fingerprint_key: Text, node_name: Text, model_storage: ModelStorage + ) -> Optional[Cacheable]: + """Returns a potentially cached output (see parent class for full docstring).""" + result_location, result_type = self._get_cached_result(output_fingerprint_key) + + if not result_location: + logger.debug(f"No cached output found for '{output_fingerprint_key}'") + return None + + path_to_cached = Path(result_location) + if not path_to_cached.is_dir(): + logger.debug( + f"Cached output for '{output_fingerprint_key}' can't be found on disk." + ) + return None + + return self._load_from_cache( + result_location, + result_type, + node_name, + model_storage, + output_fingerprint_key, + ) + + def _get_cached_result( + self, output_fingerprint_key: Text + ) -> Tuple[Optional[Path], Optional[Text]]: + with self._sessionmaker.begin() as session: + query = sa.select( + self.CacheEntry.result_location, self.CacheEntry.result_type + ).where( + self.CacheEntry.output_fingerprint_key == output_fingerprint_key, + self.CacheEntry.result_location != sa.null(), + ) + + match = session.execute(query).first() + + if match: + return Path(match.result_location), match.result_type + + return None, None + + @staticmethod + def _load_from_cache( + path_to_cached: Path, + result_type: Text, + node_name: Text, + model_storage: ModelStorage, + output_fingerprint_key: Text, + ) -> Optional[Cacheable]: + try: + module = rasa.shared.utils.common.class_from_module_path(result_type) + + if not isinstance(module, Cacheable): + logger.warning( + "Failed to restore a non cacheable module from cache. " + "Please implement the 'Cacheable' interface for module " + f"'{result_type}'." + ) + return None + + return module.from_cache( + node_name, path_to_cached, model_storage, output_fingerprint_key + ) + except Exception as e: + logger.warning( + f"Failed to restore cached output of type '{result_type}' from " + f"cache. Error:\n{e}" + ) + return None diff --git a/rasa/engine/constants.py b/rasa/engine/constants.py new file mode 100644 index 0000000..244aa20 --- /dev/null +++ b/rasa/engine/constants.py @@ -0,0 +1,14 @@ +from typing import List + +from rasa.core.channels import UserMessage +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.importers.importer import TrainingDataImporter + +PLACEHOLDER_IMPORTER = "__importer__" +PLACEHOLDER_MESSAGE = "__message__" +PLACEHOLDER_TRACKER = "__tracker__" +RESERVED_PLACEHOLDERS = { + PLACEHOLDER_IMPORTER: TrainingDataImporter, + PLACEHOLDER_MESSAGE: List[UserMessage], + PLACEHOLDER_TRACKER: DialogueStateTracker, +} diff --git a/rasa/engine/exceptions.py b/rasa/engine/exceptions.py new file mode 100644 index 0000000..6014494 --- /dev/null +++ b/rasa/engine/exceptions.py @@ -0,0 +1,14 @@ +class GraphRunError(Exception): + """Exception class for errors originating when running a graph.""" + + +class GraphComponentException(Exception): + """Exception class for errors originating within a `GraphComponent`.""" + + +class GraphSchemaException(Exception): + """Represents errors when dealing with `GraphSchema`s.""" + + +class GraphSchemaValidationException(Exception): + """Indicates that the given graph schema is invalid.""" diff --git a/rasa/engine/graph.py b/rasa/engine/graph.py new file mode 100644 index 0000000..ad0ab22 --- /dev/null +++ b/rasa/engine/graph.py @@ -0,0 +1,592 @@ +from __future__ import annotations + +import dataclasses +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +import logging +from typing import Any, Callable, Dict, List, Optional, Text, Type, Tuple, Union + +from rasa.engine.exceptions import ( + GraphComponentException, + GraphRunError, + GraphSchemaException, +) +import rasa.shared.utils.common +import rasa.utils.common +from rasa.engine.storage.resource import Resource + +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.exceptions import InvalidConfigException, RasaException +from rasa.shared.data import TrainingType + +logger = logging.getLogger(__name__) + + +@dataclass +class SchemaNode: + """Represents one node in the schema. + + Args: + needs: describes which parameters in `fn` (or `constructor_name` + if `eager==False`) are filled by which parent nodes. + uses: The class which models the behavior of this specific graph node. + constructor_name: The name of the constructor which should be used to + instantiate the component. If `eager==False` then the `constructor` can + also specify parameters which are filled by parent nodes. This is e.g. + useful if a parent node returns a `Resource` and this node wants to + directly load itself from this resource. + fn: The name of the function which should be called on the instantiated + component when the graph is executed. The parameters from `needs` are + filled from the parent nodes. + config: The user's configuration for this graph node. This configuration + does not need to be specify all possible parameters; the default values + for missing parameters will be filled in later. + eager: If `eager` then the component is instantiated before the graph is run. + Otherwise it's instantiated as the graph runs (lazily). Usually we always + instantiated lazily during training and eagerly during inference (to + avoid that the first prediction takes longer). + is_target: If `True` then this node can't be pruned during fingerprinting + (it might be replaced with a cached value though). This is e.g. used for + all components which train as their result always needs to be added to + the model archive so that the data is available during inference. + is_input: Nodes with `is_input` are _always_ run (also during the fingerprint + run). This makes sure that we e.g. detect changes in file contents. + resource: If given, then the graph node is loaded from an existing resource + instead of instantiated from scratch. This is e.g. used to load a trained + component for predictions. + """ + + needs: Dict[Text, Text] + uses: Type[GraphComponent] + constructor_name: Text + fn: Text + config: Dict[Text, Any] + eager: bool = False + is_target: bool = False + is_input: bool = False + resource: Optional[Resource] = None + + +@dataclass +class GraphSchema: + """Represents a graph for training a model or making predictions.""" + + nodes: Dict[Text, SchemaNode] + + def as_dict(self) -> Dict[Text, Any]: + """Returns graph schema in a serializable format. + + Returns: + The graph schema in a format which can be dumped as JSON or other formats. + """ + serializable_graph_schema: Dict[Text, Dict[Text, Any]] = {"nodes": {}} + for node_name, node in self.nodes.items(): + serializable = dataclasses.asdict(node) + + # Classes are not JSON serializable (surprise) + serializable["uses"] = f"{node.uses.__module__}.{node.uses.__name__}" + + serializable_graph_schema["nodes"][node_name] = serializable + + return serializable_graph_schema + + @classmethod + def from_dict(cls, serialized_graph_schema: Dict[Text, Any]) -> GraphSchema: + """Loads a graph schema which has been serialized using `schema.as_dict()`. + + Args: + serialized_graph_schema: A serialized graph schema. + + Returns: + A properly loaded schema. + + Raises: + GraphSchemaException: In case the component class for a node couldn't be + found. + """ + nodes = {} + for node_name, serialized_node in serialized_graph_schema["nodes"].items(): + try: + serialized_node[ + "uses" + ] = rasa.shared.utils.common.class_from_module_path( + serialized_node["uses"] + ) + + resource = serialized_node["resource"] + if resource: + serialized_node["resource"] = Resource(**resource) + + except ImportError as e: + raise GraphSchemaException( + "Error deserializing graph schema. Can't " + "find class for graph component type " + f"'{serialized_node['uses']}'." + ) from e + + nodes[node_name] = SchemaNode(**serialized_node) + + return GraphSchema(nodes) + + @property + def target_names(self) -> List[Text]: + """Returns the names of all target nodes.""" + return [node_name for node_name, node in self.nodes.items() if node.is_target] + + def minimal_graph_schema(self, targets: Optional[List[Text]] = None) -> GraphSchema: + """Returns a new schema where all nodes are a descendant of a target.""" + dependencies = self._all_dependencies_schema( + targets if targets else self.target_names + ) + + return GraphSchema( + { + node_name: node + for node_name, node in self.nodes.items() + if node_name in dependencies + } + ) + + def _all_dependencies_schema(self, targets: List[Text]) -> List[Text]: + required = [] + for target in targets: + required.append(target) + try: + target_dependencies = self.nodes[target].needs.values() + except KeyError: # This can happen if the target is an input placeholder. + continue + for dependency in target_dependencies: + required += self._all_dependencies_schema([dependency]) + + return required + + +class GraphComponent(ABC): + """Interface for any component which will run in a graph.""" + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [] + + @classmethod + @abstractmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + """Creates a new `GraphComponent`. + + Args: + config: This config overrides the `default_config`. + model_storage: Storage which graph components can use to persist and load + themselves. + resource: Resource locator for this component which can be used to persist + and load itself from the `model_storage`. + execution_context: Information about the current graph run. + + Returns: An instantiated `GraphComponent`. + """ + ... + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> GraphComponent: + """Creates a component using a persisted version of itself. + + If not overridden this method merely calls `create`. + + Args: + config: The config for this graph component. This is the default config of + the component merged with config specified by the user. + model_storage: Storage which graph components can use to persist and load + themselves. + resource: Resource locator for this component which can be used to persist + and load itself from the `model_storage`. + execution_context: Information about the current graph run. + kwargs: Output values from previous nodes might be passed in as `kwargs`. + + Returns: + An instantiated, loaded `GraphComponent`. + """ + return cls.create(config, model_storage, resource, execution_context) + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns the component's default config. + + Default config and user config are merged by the `GraphNode` before the + config is passed to the `create` and `load` method of the component. + + Returns: + The default config of the component. + """ + return {} + + @staticmethod + def supported_languages() -> Optional[List[Text]]: + """Determines which languages this component can work with. + + Returns: A list of supported languages, or `None` to signify all are supported. + """ + return None + + @staticmethod + def not_supported_languages() -> Optional[List[Text]]: + """Determines which languages this component cannot work with. + + Returns: A list of not supported languages, or + `None` to signify all are supported. + """ + return None + + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return [] + + @classmethod + def fingerprint_addon(cls, config: Dict[str, Any]) -> Optional[str]: + """Adds additional data to the fingerprint calculation. + + This is useful if a component uses external data that is not provided + by the graph. + """ + return None + + +class GraphNodeHook(ABC): + """Holds functionality to be run before and after a `GraphNode`.""" + + @abstractmethod + def on_before_node( + self, + node_name: Text, + execution_context: ExecutionContext, + config: Dict[Text, Any], + received_inputs: Dict[Text, Any], + ) -> Dict: + """Runs before the `GraphNode` executes. + + Args: + node_name: The name of the node being run. + execution_context: The execution context of the current graph run. + config: The node's config. + received_inputs: Mapping from parameter name to input value. + + Returns: + Data that is then passed to `on_after_node` + + """ + ... + + @abstractmethod + def on_after_node( + self, + node_name: Text, + execution_context: ExecutionContext, + config: Dict[Text, Any], + output: Any, + input_hook_data: Dict, + ) -> None: + """Runs after the `GraphNode` as executed. + + Args: + node_name: The name of the node that has run. + execution_context: The execution context of the current graph run. + config: The node's config. + output: The output of the node. + input_hook_data: Data returned from `on_before_node`. + """ + ... + + +@dataclass +class ExecutionContext: + """Holds information about a single graph run.""" + + graph_schema: GraphSchema = field(repr=False) + model_id: Optional[Text] = None + should_add_diagnostic_data: bool = False + is_finetuning: bool = False + # This is set by the `GraphNode` before it is passed to the `GraphComponent`. + node_name: Optional[Text] = None + + +class GraphNode: + """Instantiates and runs a `GraphComponent` within a graph. + + A `GraphNode` is a wrapper for a `GraphComponent` that allows it to be executed + in the context of a graph. It is responsible for instantiating the component at the + correct time, collecting the inputs from the parent nodes, running the run function + of the component and passing the output onwards. + """ + + def __init__( + self, + node_name: Text, + component_class: Type[GraphComponent], + constructor_name: Text, + component_config: Dict[Text, Any], + fn_name: Text, + inputs: Dict[Text, Text], + eager: bool, + model_storage: ModelStorage, + resource: Optional[Resource], + execution_context: ExecutionContext, + hooks: Optional[List[GraphNodeHook]] = None, + ) -> None: + """Initializes `GraphNode`. + + Args: + node_name: The name of the node in the schema. + component_class: The class to be instantiated and run. + constructor_name: The method used to instantiate the component. + component_config: Config to be passed to the component. + fn_name: The function on the instantiated `GraphComponent` to be run when + the node executes. + inputs: A map from input name to parent node name that provides it. + eager: Determines if the node is instantiated right away, or just before + being run. + model_storage: Storage which graph components can use to persist and load + themselves. + resource: If given the `GraphComponent` will be loaded from the + `model_storage` using the given resource. + execution_context: Information about the current graph run. + hooks: These are called before and after execution. + """ + self._node_name: Text = node_name + self._component_class: Type[GraphComponent] = component_class + self._constructor_name: Text = constructor_name + self._constructor_fn: Callable = getattr( + self._component_class, self._constructor_name + ) + self._component_config: Dict[Text, Any] = rasa.utils.common.override_defaults( + self._component_class.get_default_config(), component_config + ) + self._fn_name: Text = fn_name + self._fn: Callable = getattr(self._component_class, self._fn_name) + self._inputs: Dict[Text, Text] = inputs + self._eager: bool = eager + + self._model_storage = model_storage + self._existing_resource = resource + + self._execution_context: ExecutionContext = dataclasses.replace( + execution_context, node_name=self._node_name + ) + + self._hooks: List[GraphNodeHook] = hooks if hooks else [] + + self._component: Optional[GraphComponent] = None + if self._eager: + self._load_component() + + def _load_component(self, **kwargs: Any) -> None: + logger.debug( + f"Node '{self._node_name}' loading " + f"'{self._component_class.__name__}.{self._constructor_name}' " + f"and kwargs: '{kwargs}'." + ) + + constructor = getattr(self._component_class, self._constructor_name) + try: + self._component: GraphComponent = constructor( # type: ignore[no-redef] + config=self._component_config, + model_storage=self._model_storage, + resource=self._get_resource(kwargs), + execution_context=self._execution_context, + **kwargs, + ) + except InvalidConfigException: + # Pass through somewhat expected exception to allow more fine granular + # handling of exceptions. + raise + except Exception as e: + if not isinstance(e, RasaException): + raise GraphComponentException( + f"Error initializing graph component for node {self._node_name}." + ) from e + else: + logger.error( + f"Error initializing graph component for node {self._node_name}." + ) + raise + + def _get_resource(self, kwargs: Dict[Text, Any]) -> Resource: + if "resource" in kwargs: + # A parent node provides resource during training. The component wrapped + # by this `GraphNode` will load itself from this resource. + return kwargs.pop("resource") + + if self._existing_resource: + # The component should be loaded from a trained resource during inference. + # E.g. a classifier might train and persist itself during training and will + # then load itself from this resource during inference. + return self._existing_resource + + # The component gets a chance to persist itself + return Resource(self._node_name) + + def __call__( + self, *inputs_from_previous_nodes: Union[Tuple[Text, Any], Text] + ) -> Tuple[Text, Any]: + """Calls the `GraphComponent` run method when the node executes in the graph. + + Args: + *inputs_from_previous_nodes: The output of all parent nodes. Each is a + dictionary with a single item mapping the node's name to its output. + If the node couldn't be resolved and has no output, the node name is + provided instead of a tuple. + + Returns: + The node name and its output. + """ + # filter out arguments that dask couldn't lookup + received_inputs: Dict[Text, Any] = {} + for i in inputs_from_previous_nodes: + if isinstance(i, tuple): + node_name, node_output = i + received_inputs[node_name] = node_output + else: + logger.warning( + f"Node '{i}' was not resolved, there is no putput. " + f"Another component should have provided this as an " + f"output." + ) + + kwargs = {} + for input_name, input_provider_node_name in self._inputs.items(): + if input_provider_node_name not in received_inputs: + raise GraphRunError( + f"Missing input to run node '{self._node_name}'. " + f"Expected input '{input_provider_node_name}' to " + f"provide parameter '{input_name}'." + ) + kwargs[input_name] = received_inputs[input_provider_node_name] + + input_hook_outputs = self._run_before_hooks(kwargs) + + if not self._eager: + constructor_kwargs = rasa.shared.utils.common.minimal_kwargs( + kwargs, self._constructor_fn + ) + self._load_component(**constructor_kwargs) + run_kwargs = { + k: v for k, v in kwargs.items() if k not in constructor_kwargs + } + else: + run_kwargs = kwargs + + logger.debug( + f"Node '{self._node_name}' running " + f"'{self._component_class.__name__}.{self._fn_name}'." + ) + + try: + output = self._fn(self._component, **run_kwargs) + except InvalidConfigException: + # Pass through somewhat expected exception to allow more fine granular + # handling of exceptions. + raise + except Exception as e: + if not isinstance(e, RasaException): + raise GraphComponentException( + f"Error running graph component for node {self._node_name}." + ) from e + else: + logger.error( + f"Error running graph component for node {self._node_name}." + ) + raise + + self._run_after_hooks(input_hook_outputs, output) + + return self._node_name, output + + def _run_after_hooks(self, input_hook_outputs: List[Dict], output: Any) -> None: + for hook, hook_data in zip(self._hooks, input_hook_outputs): + try: + logger.debug( + f"Hook '{hook.__class__.__name__}.on_after_node' " + f"running for node '{self._node_name}'." + ) + hook.on_after_node( + node_name=self._node_name, + execution_context=self._execution_context, + config=self._component_config, + output=output, + input_hook_data=hook_data, + ) + except Exception as e: + raise GraphComponentException( + f"Error running after hook for node '{self._node_name}'." + ) from e + + def _run_before_hooks(self, received_inputs: Dict[Text, Any]) -> List[Dict]: + input_hook_outputs = [] + for hook in self._hooks: + try: + logger.debug( + f"Hook '{hook.__class__.__name__}.on_before_node' " + f"running for node '{self._node_name}'." + ) + hook_output = hook.on_before_node( + node_name=self._node_name, + execution_context=self._execution_context, + config=self._component_config, + received_inputs=received_inputs, + ) + input_hook_outputs.append(hook_output) + except Exception as e: + raise GraphComponentException( + f"Error running before hook for node '{self._node_name}'." + ) from e + return input_hook_outputs + + @classmethod + def from_schema_node( + cls, + node_name: Text, + schema_node: SchemaNode, + model_storage: ModelStorage, + execution_context: ExecutionContext, + hooks: Optional[List[GraphNodeHook]] = None, + ) -> GraphNode: + """Creates a `GraphNode` from a `SchemaNode`.""" + return cls( + node_name=node_name, + component_class=schema_node.uses, + constructor_name=schema_node.constructor_name, + component_config=schema_node.config, + fn_name=schema_node.fn, + inputs=schema_node.needs, + eager=schema_node.eager, + model_storage=model_storage, + execution_context=execution_context, + resource=schema_node.resource, + hooks=hooks, + ) + + +@dataclass() +class GraphModelConfiguration: + """The model configuration to run as a graph during training and prediction.""" + + train_schema: GraphSchema + predict_schema: GraphSchema + training_type: TrainingType + assistant_id: Optional[Text] + language: Optional[Text] + core_target: Optional[Text] + nlu_target: Optional[Text] + spaces: Optional[Dict[Text, Text]] = None diff --git a/rasa/engine/loader.py b/rasa/engine/loader.py new file mode 100644 index 0000000..dfcd80c --- /dev/null +++ b/rasa/engine/loader.py @@ -0,0 +1,36 @@ +from pathlib import Path +from typing import Tuple, Type + +from rasa.engine.graph import ExecutionContext +from rasa.engine.runner.interface import GraphRunner +from rasa.engine.storage.storage import ModelMetadata, ModelStorage + + +def load_predict_graph_runner( + storage_path: Path, + model_archive_path: Path, + model_storage_class: Type[ModelStorage], + graph_runner_class: Type[GraphRunner], +) -> Tuple[ModelMetadata, GraphRunner]: + """Loads a model from an archive and creates the prediction graph runner. + + Args: + storage_path: Directory which contains the persisted graph components. + model_archive_path: The path to the model archive. + model_storage_class: The class to instantiate the model storage from. + graph_runner_class: The class to instantiate the runner from. + + Returns: + A tuple containing the model metadata and the prediction graph runner. + """ + model_storage, model_metadata = model_storage_class.from_model_archive( + storage_path=storage_path, model_archive_path=model_archive_path + ) + runner = graph_runner_class.create( + graph_schema=model_metadata.predict_schema, + model_storage=model_storage, + execution_context=ExecutionContext( + graph_schema=model_metadata.predict_schema, model_id=model_metadata.model_id + ), + ) + return model_metadata, runner diff --git a/rasa/engine/recipes/__init__.py b/rasa/engine/recipes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/engine/recipes/config_files/default_config.yml b/rasa/engine/recipes/config_files/default_config.yml new file mode 100644 index 0000000..a6acaba --- /dev/null +++ b/rasa/engine/recipes/config_files/default_config.yml @@ -0,0 +1,44 @@ +# The config recipe. +# https://rasa.com/docs/rasa/model-configuration/ +recipe: default.v1 + +# The assistant project unique identifier +# This default value must be replaced with a unique assistant name within your deployment +assistant_id: placeholder_default + +# Configuration for the Rasa NLU components. +# https://rasa.com/docs/rasa/components +language: en + +pipeline: + - name: WhitespaceTokenizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: "char_wb" + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 100 + constrain_similarities: true + - name: EntitySynonymMapper + - name: ResponseSelector + epochs: 100 + constrain_similarities: true + - name: FallbackClassifier + threshold: 0.3 + ambiguity_threshold: 0.1 + +# Configuration for the Rasa Core policies. +# https://rasa.com/docs/rasa/policies +policies: + - name: MemoizationPolicy + - name: RulePolicy + - name: UnexpecTEDIntentPolicy + max_history: 5 + epochs: 100 + - name: TEDPolicy + max_history: 5 + epochs: 100 + constrain_similarities: true diff --git a/rasa/engine/recipes/default_components.py b/rasa/engine/recipes/default_components.py new file mode 100644 index 0000000..5ddbea3 --- /dev/null +++ b/rasa/engine/recipes/default_components.py @@ -0,0 +1,79 @@ +from rasa.nlu.classifiers.diet_classifier import DIETClassifier +from rasa.nlu.classifiers.fallback_classifier import FallbackClassifier +from rasa.nlu.classifiers.keyword_intent_classifier import KeywordIntentClassifier +from rasa.nlu.classifiers.logistic_regression_classifier import ( + LogisticRegressionClassifier, +) +from rasa.nlu.classifiers.mitie_intent_classifier import MitieIntentClassifier +from rasa.nlu.classifiers.sklearn_intent_classifier import SklearnIntentClassifier +from rasa.nlu.extractors.crf_entity_extractor import CRFEntityExtractor +from rasa.nlu.extractors.duckling_entity_extractor import DucklingEntityExtractor +from rasa.nlu.extractors.entity_synonyms import EntitySynonymMapper +from rasa.nlu.extractors.mitie_entity_extractor import MitieEntityExtractor +from rasa.nlu.extractors.spacy_entity_extractor import SpacyEntityExtractor +from rasa.nlu.extractors.regex_entity_extractor import RegexEntityExtractor +from rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer import ( + LexicalSyntacticFeaturizer, +) +from rasa.nlu.featurizers.dense_featurizer.convert_featurizer import ConveRTFeaturizer +from rasa.nlu.featurizers.dense_featurizer.mitie_featurizer import MitieFeaturizer +from rasa.nlu.featurizers.dense_featurizer.spacy_featurizer import SpacyFeaturizer +from rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer import ( + CountVectorsFeaturizer, +) +from rasa.nlu.featurizers.dense_featurizer.lm_featurizer import LanguageModelFeaturizer +from rasa.nlu.featurizers.sparse_featurizer.regex_featurizer import RegexFeaturizer +from rasa.nlu.selectors.response_selector import ResponseSelector +from rasa.nlu.tokenizers.jieba_tokenizer import JiebaTokenizer +from rasa.nlu.tokenizers.mitie_tokenizer import MitieTokenizer +from rasa.nlu.tokenizers.spacy_tokenizer import SpacyTokenizer +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +from rasa.nlu.utils.mitie_utils import MitieNLP +from rasa.nlu.utils.spacy_utils import SpacyNLP + + +from rasa.core.policies.ted_policy import TEDPolicy +from rasa.core.policies.memoization import MemoizationPolicy, AugmentedMemoizationPolicy +from rasa.core.policies.rule_policy import RulePolicy +from rasa.core.policies.unexpected_intent_policy import UnexpecTEDIntentPolicy + +DEFAULT_COMPONENTS = [ + # Message Classifiers + DIETClassifier, + FallbackClassifier, + KeywordIntentClassifier, + MitieIntentClassifier, + SklearnIntentClassifier, + LogisticRegressionClassifier, + # Response Selectors + ResponseSelector, + # Message Entity Extractors + CRFEntityExtractor, + DucklingEntityExtractor, + EntitySynonymMapper, + MitieEntityExtractor, + SpacyEntityExtractor, + RegexEntityExtractor, + # Message Feauturizers + LexicalSyntacticFeaturizer, + ConveRTFeaturizer, + MitieFeaturizer, + SpacyFeaturizer, + CountVectorsFeaturizer, + LanguageModelFeaturizer, + RegexFeaturizer, + # Tokenizers + JiebaTokenizer, + MitieTokenizer, + SpacyTokenizer, + WhitespaceTokenizer, + # Language Model Providers + MitieNLP, + SpacyNLP, + # Dialogue Management Policies + TEDPolicy, + UnexpecTEDIntentPolicy, + RulePolicy, + MemoizationPolicy, + AugmentedMemoizationPolicy, +] diff --git a/rasa/engine/recipes/default_recipe.py b/rasa/engine/recipes/default_recipe.py new file mode 100644 index 0000000..c0355c1 --- /dev/null +++ b/rasa/engine/recipes/default_recipe.py @@ -0,0 +1,1187 @@ +from __future__ import annotations + +import copy +import enum +import logging +import math +from enum import Enum +from typing import Dict, Text, Any, Tuple, Type, Optional, List, Callable, Set, Union + +import dataclasses + +from rasa.core.featurizers.precomputation import ( + CoreFeaturizationInputConverter, + CoreFeaturizationCollector, +) +from rasa.plugin import plugin_manager +from rasa.shared.exceptions import FileNotFoundException +from rasa.core.policies.ensemble import DefaultPolicyPredictionEnsemble + +from rasa.engine.graph import ( + GraphSchema, + GraphComponent, + SchemaNode, + GraphModelConfiguration, +) +from rasa.engine.constants import ( + PLACEHOLDER_IMPORTER, + PLACEHOLDER_MESSAGE, + PLACEHOLDER_TRACKER, +) +from rasa.engine.recipes.recipe import Recipe +from rasa.engine.storage.resource import Resource +from rasa.graph_components.converters.nlu_message_converter import NLUMessageConverter +from rasa.graph_components.providers.domain_provider import DomainProvider +from rasa.graph_components.providers.forms_provider import FormsProvider +from rasa.graph_components.providers.responses_provider import ResponsesProvider +from rasa.graph_components.providers.domain_for_core_training_provider import ( + DomainForCoreTrainingProvider, +) +from rasa.graph_components.providers.nlu_training_data_provider import ( + NLUTrainingDataProvider, +) +from rasa.graph_components.providers.rule_only_provider import RuleOnlyDataProvider +from rasa.graph_components.providers.story_graph_provider import StoryGraphProvider +from rasa.graph_components.providers.training_tracker_provider import ( + TrainingTrackerProvider, +) +import rasa.shared.constants +from rasa.shared.exceptions import RasaException, InvalidConfigException +from rasa.shared.constants import ASSISTANT_ID_KEY +from rasa.shared.data import TrainingType + +from rasa.utils.tensorflow.constants import EPOCHS +from rasa.shared.utils.common import ( + class_from_module_path, + transform_collection_to_sentence, +) + +logger = logging.getLogger(__name__) + + +DEFAULT_PREDICT_KWARGS = dict(constructor_name="load", eager=True, is_target=False) + +COMMENTS_FOR_KEYS = { + "pipeline": ( + f"# # No configuration for the NLU pipeline was provided. The following " + f"default pipeline was used to train your model.\n" + f"# # If you'd like to customize it, uncomment and adjust the pipeline.\n" + f"# # See {rasa.shared.constants.DOCS_URL_PIPELINE} for more information.\n" + ), + "policies": ( + f"# # No configuration for policies was provided. The following default " + f"policies were used to train your model.\n" + f"# # If you'd like to customize them, uncomment and adjust the policies.\n" + f"# # See {rasa.shared.constants.DOCS_URL_POLICIES} for more information.\n" + ), +} + + +class DefaultV1RecipeRegisterException(RasaException): + """If you register a class which is not of type `GraphComponent`.""" + + pass + + +class DefaultV1Recipe(Recipe): + """Recipe which converts the normal model config to train and predict graph.""" + + @enum.unique + class ComponentType(Enum): + """Enum to categorize and place custom components correctly in the graph.""" + + MESSAGE_TOKENIZER = 0 + MESSAGE_FEATURIZER = 1 + INTENT_CLASSIFIER = 2 + ENTITY_EXTRACTOR = 3 + POLICY_WITHOUT_END_TO_END_SUPPORT = 4 + POLICY_WITH_END_TO_END_SUPPORT = 5 + MODEL_LOADER = 6 + + name = "default.v1" + _registered_components: Dict[Text, RegisteredComponent] = {} + + def __init__(self) -> None: + """Creates recipe.""" + self._use_core = True + self._use_nlu = True + self._use_end_to_end = True + self._is_finetuning = False + + @dataclasses.dataclass() + class RegisteredComponent: + """Describes a graph component which was registered with the decorator.""" + + clazz: Type[GraphComponent] + types: Set[DefaultV1Recipe.ComponentType] + is_trainable: bool + model_from: Optional[Text] + + @classmethod + def register( + cls, + component_types: Union[ComponentType, List[ComponentType]], + is_trainable: bool, + model_from: Optional[Text] = None, + ) -> Callable[[Type[GraphComponent]], Type[GraphComponent]]: + """This decorator can be used to register classes with the recipe. + + Args: + component_types: Describes the types of a component which are then used + to place the component in the graph. + is_trainable: `True` if the component requires training. + model_from: Can be used if this component requires a pre-loaded model + such as `SpacyNLP` or `MitieNLP`. + + Returns: + The registered class. + """ + + def decorator(registered_class: Type[GraphComponent]) -> Type[GraphComponent]: + if not issubclass(registered_class, GraphComponent): + raise DefaultV1RecipeRegisterException( + f"Failed to register class '{registered_class.__name__}' with " + f"the recipe '{cls.name}'. The class has to be of type " + f"'{GraphComponent.__name__}'." + ) + + if isinstance(component_types, cls.ComponentType): + unique_types = {component_types} + else: + unique_types = set(component_types) + + cls._registered_components[ + registered_class.__name__ + ] = cls.RegisteredComponent( + registered_class, unique_types, is_trainable, model_from + ) + return registered_class + + return decorator + + @classmethod + def _from_registry(cls, name: Text) -> RegisteredComponent: + # Importing all the default Rasa components will automatically register them + from rasa.engine.recipes.default_components import DEFAULT_COMPONENTS # noqa + + if name in cls._registered_components: + return cls._registered_components[name] + + if "." in name: + clazz = class_from_module_path(name) + if clazz.__name__ in cls._registered_components: + return cls._registered_components[clazz.__name__] + + raise InvalidConfigException( + f"Can't load class for name '{name}'. Please make sure to provide " + f"a valid name or module path and to register it using the " + f"'@DefaultV1Recipe.register' decorator." + ) + + def graph_config_for_recipe( + self, + config: Dict, + cli_parameters: Dict[Text, Any], + training_type: TrainingType = TrainingType.BOTH, + is_finetuning: bool = False, + ) -> GraphModelConfiguration: + """Converts the default config to graphs (see interface for full docstring).""" + self._use_core = ( + bool(config.get("policies")) and not training_type == TrainingType.NLU + ) + self._use_nlu = ( + bool(config.get("pipeline")) and not training_type == TrainingType.CORE + ) + + if not self._use_nlu and training_type == TrainingType.NLU: + raise InvalidConfigException( + "Can't train an NLU model without a specified pipeline. Please make " + "sure to specify a valid pipeline in your configuration." + ) + + if not self._use_core and training_type == TrainingType.CORE: + raise InvalidConfigException( + "Can't train an Core model without policies. Please make " + "sure to specify a valid policy in your configuration." + ) + + self._use_end_to_end = ( + self._use_nlu + and self._use_core + and training_type == TrainingType.END_TO_END + ) + + self._is_finetuning = is_finetuning + + train_nodes, preprocessors = self._create_train_nodes(config, cli_parameters) + predict_nodes = self._create_predict_nodes(config, preprocessors, train_nodes) + + core_target = "select_prediction" if self._use_core else None + + from rasa.nlu.classifiers.regex_message_handler import RegexMessageHandler + + return GraphModelConfiguration( + train_schema=GraphSchema(train_nodes), + predict_schema=GraphSchema(predict_nodes), + training_type=training_type, + assistant_id=config.get(ASSISTANT_ID_KEY), + language=config.get("language"), + spaces=config.get("spaces"), + core_target=core_target, + nlu_target=f"run_{RegexMessageHandler.__name__}", + ) + + def _create_train_nodes( + self, config: Dict[Text, Any], cli_parameters: Dict[Text, Any] + ) -> Tuple[Dict[Text, SchemaNode], List[Text]]: + from rasa.graph_components.validators.default_recipe_validator import ( + DefaultV1RecipeValidator, + ) + from rasa.graph_components.validators.finetuning_validator import ( + FinetuningValidator, + ) + + train_config = copy.deepcopy(config) + + train_nodes = { + "schema_validator": SchemaNode( + needs={"importer": PLACEHOLDER_IMPORTER}, + uses=DefaultV1RecipeValidator, + constructor_name="create", + fn="validate", + config={}, + is_input=True, + ), + "finetuning_validator": SchemaNode( + needs={"importer": "schema_validator"}, + uses=FinetuningValidator, + constructor_name="load" if self._is_finetuning else "create", + fn="validate", + is_input=True, + config={"validate_core": self._use_core, "validate_nlu": self._use_nlu}, + ), + } + + preprocessors = [] + + if self._use_nlu: + preprocessors = self._add_nlu_train_nodes( + train_config, train_nodes, cli_parameters + ) + + if self._use_core: + self._add_core_train_nodes( + train_config, train_nodes, preprocessors, cli_parameters + ) + + return train_nodes, preprocessors + + def _add_nlu_train_nodes( + self, + train_config: Dict[Text, Any], + train_nodes: Dict[Text, SchemaNode], + cli_parameters: Dict[Text, Any], + ) -> List[Text]: + plugin_manager().hook.modify_default_recipe_graph_train_nodes( + train_config=train_config, + train_nodes=train_nodes, + cli_parameters=cli_parameters, + ) + + persist_nlu_data = bool(cli_parameters.get("persist_nlu_training_data")) + train_nodes["nlu_training_data_provider"] = SchemaNode( + needs={"importer": "finetuning_validator"}, + uses=NLUTrainingDataProvider, + constructor_name="create", + fn="provide", + config={ + "language": train_config.get("language"), + "persist": persist_nlu_data, + }, + is_target=persist_nlu_data, + is_input=True, + ) + + last_run_node = "nlu_training_data_provider" + preprocessors: List[Text] = [] + + for idx, config in enumerate(train_config["pipeline"]): + component_name = config.pop("name") + component = self._from_registry(component_name) + component_name = f"{component_name}{idx}" + + if ( + self.ComponentType.POLICY_WITHOUT_END_TO_END_SUPPORT in component.types + or self.ComponentType.POLICY_WITH_END_TO_END_SUPPORT in component.types + ): + raise InvalidConfigException( + f"Found policy '{component_name}' in NLU pipeline. Policies should " + f"be defined in the 'policies' section of your configuration." + ) + if self.ComponentType.MODEL_LOADER in component.types: + node_name = f"provide_{component_name}" + train_nodes[node_name] = SchemaNode( + needs={}, + uses=component.clazz, + constructor_name="create", + fn="provide", + config=config, + ) + + from_resource = None + if component.is_trainable: + from_resource = self._add_nlu_train_node( + train_nodes, + component.clazz, + component_name, + last_run_node, + config, + cli_parameters, + ) + + if component.types.intersection( + { + self.ComponentType.MESSAGE_TOKENIZER, + self.ComponentType.MESSAGE_FEATURIZER, + } + ): + last_run_node = self._add_nlu_process_node( + train_nodes, + component.clazz, + component_name, + last_run_node, + config, + from_resource=from_resource, + ) + + # Remember for End-to-End-Featurization + preprocessors.append(last_run_node) + + return preprocessors + + def _get_needs_from_args( + self, component: Type[GraphComponent], fn_name: str + ) -> Dict[str, str]: + """Get the needed arguments from the method on the component. + + Filters out arguments that are already provided by other graph + components. Does not check if the created providers are actually + part of the graph. If they aren't an error will be raised later on + when the graph is validated. + + Args: + component: The component class. + fn_name: The name of the method to inspect. + + Returns: + The name of the arguments which need to be provided. + """ + from inspect import signature + + if not hasattr(component, fn_name): + return {} + + def resolver_name_from_parameter(parameter: str) -> str: + # we got a couple special cases to handle wher the parameter name + # doesn't match the provider name + if "training_trackers" == parameter: + return "training_tracker_provider" + elif "tracker" == parameter: + return PLACEHOLDER_TRACKER + elif "training_data" == parameter: + return "nlu_training_data_provider" + return f"{parameter}_provider" + + sig = signature(getattr(component, fn_name)) + parameters = { + name + for name, param in sig.parameters.items() + if param.kind == param.POSITIONAL_OR_KEYWORD + } + + # filter out parameters which are already resolved in other ways + unprovided_parameters = parameters - { + "message", + "messages", + "self", + "model", + "precomputations", + } + + return { + parameter: resolver_name_from_parameter(parameter) + for parameter in unprovided_parameters + } + + def _add_nlu_train_node( + self, + train_nodes: Dict[Text, SchemaNode], + component: Type[GraphComponent], + component_name: Text, + last_run_node: Text, + config: Dict[Text, Any], + cli_parameters: Dict[Text, Any], + ) -> Text: + config_from_cli = self._extra_config_from_cli(cli_parameters, component, config) + needs = self._get_needs_from_args(component, "train") + needs.update(self._get_model_provider_needs(train_nodes, component)) + needs["training_data"] = last_run_node + + train_node_name = f"train_{component_name}" + train_nodes[train_node_name] = SchemaNode( + needs=needs, + uses=component, + constructor_name="load" if self._is_finetuning else "create", + fn="train", + config={**config, **config_from_cli}, + is_target=True, + ) + return train_node_name + + def _extra_config_from_cli( + self, + cli_parameters: Dict[Text, Any], + component: Type[GraphComponent], + component_config: Dict[Text, Any], + ) -> Dict[Text, Any]: + from rasa.nlu.classifiers.mitie_intent_classifier import MitieIntentClassifier + from rasa.nlu.extractors.mitie_entity_extractor import MitieEntityExtractor + from rasa.nlu.classifiers.sklearn_intent_classifier import ( + SklearnIntentClassifier, + ) + + cli_args_mapping: Dict[Type[GraphComponent], List[Text]] = { + MitieIntentClassifier: ["num_threads"], + MitieEntityExtractor: ["num_threads"], + SklearnIntentClassifier: ["num_threads"], + } + + config_from_cli = { + param: cli_parameters[param] + for param in cli_args_mapping.get(component, []) + if param in cli_parameters and cli_parameters[param] is not None + } + + if ( + self._is_finetuning + and "finetuning_epoch_fraction" in cli_parameters + and EPOCHS in component.get_default_config() + ): + old_number_epochs = component_config.get( + EPOCHS, component.get_default_config()[EPOCHS] + ) + epoch_fraction = cli_parameters["finetuning_epoch_fraction"] + epoch_fraction = epoch_fraction if epoch_fraction is not None else 1.0 + config_from_cli["finetuning_epoch_fraction"] = epoch_fraction + config_from_cli[EPOCHS] = math.ceil( + old_number_epochs * float(epoch_fraction) + ) + + return config_from_cli + + def _add_nlu_process_node( + self, + train_nodes: Dict[Text, SchemaNode], + component_class: Type[GraphComponent], + component_name: Text, + last_run_node: Text, + component_config: Dict[Text, Any], + from_resource: Optional[Text] = None, + ) -> Text: + needs = self._get_needs_from_args(component_class, "process_training_data") + needs.update(self._get_model_provider_needs(train_nodes, component_class)) + + if from_resource: + needs["resource"] = from_resource + + needs["training_data"] = last_run_node + + node_name = f"run_{component_name}" + train_nodes[node_name] = SchemaNode( + needs=needs, + uses=component_class, + constructor_name="load", + fn="process_training_data", + config=component_config, + ) + return node_name + + def _get_model_provider_needs( + self, nodes: Dict[Text, SchemaNode], component_class: Type[GraphComponent] + ) -> Dict[Text, Text]: + model_provider_needs = {} + component = self._from_registry(component_class.__name__) + + if not component.model_from: + return {} + + node_name_of_provider = next( + ( + node_name + for node_name, node in nodes.items() + if node.uses.__name__ == component.model_from + ), + None, + ) + if node_name_of_provider: + model_provider_needs["model"] = node_name_of_provider + + return model_provider_needs + + def _add_core_train_nodes( + self, + train_config: Dict[Text, Any], + train_nodes: Dict[Text, SchemaNode], + preprocessors: List[Text], + cli_parameters: Dict[Text, Any], + ) -> None: + plugin_manager().hook.modify_default_recipe_graph_train_nodes( + train_config=train_config, + train_nodes=train_nodes, + cli_parameters=cli_parameters, + ) + train_nodes["domain_provider"] = SchemaNode( + needs={"importer": "finetuning_validator"}, + uses=DomainProvider, + constructor_name="create", + fn="provide_train", + config={}, + is_target=True, + is_input=True, + ) + train_nodes["domain_for_core_training_provider"] = SchemaNode( + needs={"domain": "domain_provider"}, + uses=DomainForCoreTrainingProvider, + constructor_name="create", + fn="provide", + config={}, + is_input=True, + ) + train_nodes["forms_provider"] = SchemaNode( + needs={"domain": "domain_provider"}, + uses=FormsProvider, + constructor_name="create", + fn="provide", + config={}, + is_input=True, + ) + train_nodes["responses_provider"] = SchemaNode( + needs={"domain": "domain_provider"}, + uses=ResponsesProvider, + constructor_name="create", + fn="provide", + config={}, + is_input=True, + ) + train_nodes["story_graph_provider"] = SchemaNode( + needs={"importer": "finetuning_validator"}, + uses=StoryGraphProvider, + constructor_name="create", + fn="provide", + config={"exclusion_percentage": cli_parameters.get("exclusion_percentage")}, + is_input=True, + ) + train_nodes["training_tracker_provider"] = SchemaNode( + needs={ + "story_graph": "story_graph_provider", + "domain": "domain_for_core_training_provider", + }, + uses=TrainingTrackerProvider, + constructor_name="create", + fn="provide", + config={ + param: cli_parameters[param] + for param in ["debug_plots", "augmentation_factor"] + if param in cli_parameters + }, + ) + + policy_with_end_to_end_support_used = False + for idx, config in enumerate(train_config["policies"]): + component_name = config.pop("name") + component = self._from_registry(component_name) + + extra_config_from_cli = self._extra_config_from_cli( + cli_parameters, component.clazz, config + ) + + requires_end_to_end_data = self._use_end_to_end and ( + self.ComponentType.POLICY_WITH_END_TO_END_SUPPORT in component.types + ) + policy_with_end_to_end_support_used = ( + policy_with_end_to_end_support_used or requires_end_to_end_data + ) + + needs = self._get_needs_from_args(component.clazz, "train") + if requires_end_to_end_data: + needs["precomputations"] = "end_to_end_features_provider" + # during core training we use a stripped down version of the domain + needs["domain"] = "domain_for_core_training_provider" + train_nodes[f"train_{component_name}{idx}"] = SchemaNode( + needs=needs, + uses=component.clazz, + constructor_name="load" if self._is_finetuning else "create", + fn="train", + is_target=True, + config={**config, **extra_config_from_cli}, + ) + + if self._use_end_to_end and policy_with_end_to_end_support_used: + self._add_end_to_end_features_for_training(preprocessors, train_nodes) + + def _add_end_to_end_features_for_training( + self, preprocessors: List[Text], train_nodes: Dict[Text, SchemaNode] + ) -> None: + train_nodes["story_to_nlu_training_data_converter"] = SchemaNode( + needs={ + "story_graph": "story_graph_provider", + "domain": "domain_for_core_training_provider", + }, + uses=CoreFeaturizationInputConverter, + constructor_name="create", + fn="convert_for_training", + config={}, + is_input=True, + ) + + last_node_name = "story_to_nlu_training_data_converter" + for preprocessor in preprocessors: + node = copy.deepcopy(train_nodes[preprocessor]) + node.needs["training_data"] = last_node_name + + node_name = f"e2e_{preprocessor}" + train_nodes[node_name] = node + last_node_name = node_name + + node_with_e2e_features = "end_to_end_features_provider" + train_nodes[node_with_e2e_features] = SchemaNode( + needs={"messages": last_node_name}, + uses=CoreFeaturizationCollector, + constructor_name="create", + fn="collect", + config={}, + ) + + def _create_predict_nodes( + self, + config: Dict[Text, SchemaNode], + preprocessors: List[Text], + train_nodes: Dict[Text, SchemaNode], + ) -> Dict[Text, SchemaNode]: + + predict_config = copy.deepcopy(config) + predict_nodes = {} + + from rasa.nlu.classifiers.regex_message_handler import RegexMessageHandler + + predict_nodes["nlu_message_converter"] = SchemaNode( + **DEFAULT_PREDICT_KWARGS, + needs={"messages": PLACEHOLDER_MESSAGE}, + uses=NLUMessageConverter, + fn="convert_user_message", + config={}, + ) + + last_run_nlu_node = "nlu_message_converter" + + if self._use_nlu: + last_run_nlu_node = self._add_nlu_predict_nodes( + last_run_nlu_node, predict_config, predict_nodes, train_nodes + ) + + domain_needs = {} + if self._use_core: + domain_needs["domain"] = "domain_provider" + + regex_handler_node_name = f"run_{RegexMessageHandler.__name__}" + predict_nodes[regex_handler_node_name] = SchemaNode( + **DEFAULT_PREDICT_KWARGS, + needs={"messages": last_run_nlu_node, **domain_needs}, + uses=RegexMessageHandler, + fn="process", + config={}, + ) + + if self._use_core: + self._add_core_predict_nodes( + predict_config, predict_nodes, train_nodes, preprocessors + ) + + return predict_nodes + + def _add_nlu_predict_nodes( + self, + last_run_node: Text, + predict_config: Dict[Text, Any], + predict_nodes: Dict[Text, SchemaNode], + train_nodes: Dict[Text, SchemaNode], + ) -> Text: + plugin_manager().hook.modify_default_recipe_graph_predict_nodes( + predict_nodes=predict_nodes + ) + for idx, config in enumerate(predict_config["pipeline"]): + component_name = config.pop("name") + component = self._from_registry(component_name) + component_name = f"{component_name}{idx}" + if self.ComponentType.MODEL_LOADER in component.types: + predict_nodes[f"provide_{component_name}"] = SchemaNode( + **DEFAULT_PREDICT_KWARGS, + needs={}, + uses=component.clazz, + fn="provide", + config=config, + ) + + if component.types.intersection( + { + self.ComponentType.MESSAGE_TOKENIZER, + self.ComponentType.MESSAGE_FEATURIZER, + } + ): + last_run_node = self._add_nlu_predict_node_from_train( + predict_nodes, + component_name, + train_nodes, + last_run_node, + config, + from_resource=component.is_trainable, + ) + elif component.types.intersection( + { + self.ComponentType.INTENT_CLASSIFIER, + self.ComponentType.ENTITY_EXTRACTOR, + } + ): + if component.is_trainable: + last_run_node = self._add_nlu_predict_node_from_train( + predict_nodes, + component_name, + train_nodes, + last_run_node, + config, + from_resource=component.is_trainable, + ) + else: + new_node = SchemaNode( + needs={"messages": last_run_node}, + uses=component.clazz, + constructor_name="create", + fn="process", + config=config, + ) + + last_run_node = self._add_nlu_predict_node( + predict_nodes, new_node, component_name, last_run_node + ) + + return last_run_node + + def _add_nlu_predict_node_from_train( + self, + predict_nodes: Dict[Text, SchemaNode], + node_name: Text, + train_nodes: Dict[Text, SchemaNode], + last_run_node: Text, + item_config: Dict[Text, Any], + from_resource: bool = False, + ) -> Text: + train_node_name = f"run_{node_name}" + resource = None + if from_resource: + train_node_name = f"train_{node_name}" + resource = Resource(train_node_name) + + return self._add_nlu_predict_node( + predict_nodes, + dataclasses.replace( + train_nodes[train_node_name], resource=resource, config=item_config + ), + node_name, + last_run_node, + ) + + def _add_nlu_predict_node( + self, + predict_nodes: Dict[Text, SchemaNode], + node: SchemaNode, + component_name: Text, + last_run_node: Text, + ) -> Text: + node_name = f"run_{component_name}" + + needs = self._get_needs_from_args(node.uses, "process") + needs.update(self._get_model_provider_needs(predict_nodes, node.uses)) + needs["messages"] = last_run_node + predict_nodes[node_name] = dataclasses.replace( + node, + needs=needs, + fn="process", + **DEFAULT_PREDICT_KWARGS, + ) + + return node_name + + def _add_core_predict_nodes( + self, + predict_config: Dict[Text, Any], + predict_nodes: Dict[Text, SchemaNode], + train_nodes: Dict[Text, SchemaNode], + preprocessors: List[Text], + ) -> None: + plugin_manager().hook.modify_default_recipe_graph_predict_nodes( + predict_nodes=predict_nodes + ) + predict_nodes["domain_provider"] = SchemaNode( + **DEFAULT_PREDICT_KWARGS, + needs={}, + uses=DomainProvider, + fn="provide_inference", + config={}, + resource=Resource("domain_provider"), + ) + + node_with_e2e_features = None + + if "end_to_end_features_provider" in train_nodes: + node_with_e2e_features = self._add_end_to_end_features_for_inference( + predict_nodes, preprocessors + ) + + rule_policy_resource = None + policies: List[Text] = [] + + for idx, config in enumerate(predict_config["policies"]): + component_name = config.pop("name") + component = self._from_registry(component_name) + + train_node_name = f"train_{component_name}{idx}" + node_name = f"run_{component_name}{idx}" + + from rasa.core.policies.rule_policy import RulePolicy + + if issubclass(component.clazz, RulePolicy) and not rule_policy_resource: + rule_policy_resource = train_node_name + + needs = self._get_needs_from_args( + train_nodes[train_node_name].uses, "predict_action_probabilities" + ) + if ( + self.ComponentType.POLICY_WITH_END_TO_END_SUPPORT in component.types + and node_with_e2e_features + ): + needs["precomputations"] = node_with_e2e_features + + predict_nodes[node_name] = dataclasses.replace( + train_nodes[train_node_name], + **DEFAULT_PREDICT_KWARGS, + needs=needs, + fn="predict_action_probabilities", + resource=Resource(train_node_name), + ) + policies.append(node_name) + + predict_nodes["rule_only_data_provider"] = SchemaNode( + **DEFAULT_PREDICT_KWARGS, + needs={}, + uses=RuleOnlyDataProvider, + fn="provide", + config={}, + resource=Resource(rule_policy_resource) if rule_policy_resource else None, + ) + + predict_nodes["select_prediction"] = SchemaNode( + **DEFAULT_PREDICT_KWARGS, + needs={ + **{f"policy{idx}": name for idx, name in enumerate(policies)}, + "domain": "domain_provider", + "tracker": PLACEHOLDER_TRACKER, + }, + uses=DefaultPolicyPredictionEnsemble, + fn="combine_predictions_from_kwargs", + config={}, + ) + + def _add_end_to_end_features_for_inference( + self, predict_nodes: Dict[Text, SchemaNode], preprocessors: List[Text] + ) -> Text: + predict_nodes["tracker_to_message_converter"] = SchemaNode( + **DEFAULT_PREDICT_KWARGS, + needs={"tracker": PLACEHOLDER_TRACKER}, + uses=CoreFeaturizationInputConverter, + fn="convert_for_inference", + config={}, + ) + + last_node_name = "tracker_to_message_converter" + for preprocessor in preprocessors: + node = dataclasses.replace( + predict_nodes[preprocessor], needs={"messages": last_node_name} + ) + + node_name = f"e2e_{preprocessor}" + predict_nodes[node_name] = node + last_node_name = node_name + + node_with_e2e_features = "end_to_end_features_provider" + predict_nodes[node_with_e2e_features] = SchemaNode( + **DEFAULT_PREDICT_KWARGS, + needs={"messages": last_node_name}, + uses=CoreFeaturizationCollector, + fn="collect", + config={}, + ) + return node_with_e2e_features + + @staticmethod + def auto_configure( + config_file_path: Optional[Text], + config: Dict, + training_type: Optional[TrainingType] = TrainingType.BOTH, + ) -> Tuple[Dict[Text, Any], Set[str], Set[str]]: + """Determine configuration from auto-filled configuration file. + + Keys that are provided and have a value in the file are kept. Keys that are not + provided are configured automatically. + + Note that this needs to be called explicitly; ie. we cannot + auto-configure automatically from importers because importers are not + allowed to access code outside of `rasa.shared`. + + Args: + config_file_path: The path to the configuration file. + config: Configuration in dictionary format. + training_type: Optional training type to auto-configure. By default + both core and NLU will be auto-configured. + """ + missing_keys = DefaultV1Recipe._get_missing_config_keys(config, training_type) + keys_to_configure = DefaultV1Recipe._get_unspecified_autoconfigurable_keys( + config, training_type + ) + + if keys_to_configure: + config = DefaultV1Recipe.complete_config(config, keys_to_configure) + DefaultV1Recipe._dump_config( + config, config_file_path, missing_keys, keys_to_configure, training_type + ) + + return config, missing_keys, keys_to_configure + + @staticmethod + def _get_unspecified_autoconfigurable_keys( + config: Dict[Text, Any], + training_type: Optional[TrainingType] = TrainingType.BOTH, + ) -> Set[Text]: + if training_type == TrainingType.NLU: + all_keys = rasa.shared.constants.CONFIG_AUTOCONFIGURABLE_KEYS_NLU + elif training_type == TrainingType.CORE: + all_keys = rasa.shared.constants.CONFIG_AUTOCONFIGURABLE_KEYS_CORE + else: + all_keys = rasa.shared.constants.CONFIG_AUTOCONFIGURABLE_KEYS + + return {k for k in all_keys if config.get(k) is None} + + @staticmethod + def _get_missing_config_keys( + config: Dict[Text, Any], + training_type: Optional[TrainingType] = TrainingType.BOTH, + ) -> Set[Text]: + if training_type == TrainingType.NLU: + all_keys = rasa.shared.constants.CONFIG_KEYS_NLU + elif training_type == TrainingType.CORE: + all_keys = rasa.shared.constants.CONFIG_KEYS_CORE + else: + all_keys = rasa.shared.constants.CONFIG_KEYS + + return {k for k in all_keys if k not in config.keys()} + + @staticmethod + def complete_config( + config: Dict[Text, Any], keys_to_configure: Set[Text] + ) -> Dict[Text, Any]: + """Complete a config by adding automatic configuration for the specified keys. + + Args: + config: The provided configuration. + keys_to_configure: Keys to be configured automatically (e.g. `policies`). + + Returns: + The resulting configuration including both the provided and + the automatically configured keys. + """ + import pkg_resources + + if keys_to_configure: + logger.debug( + f"The provided configuration does not contain the key(s) " + f"{transform_collection_to_sentence(keys_to_configure)}. " + f"Values will be provided from the default configuration." + ) + + filename = "config_files/default_config.yml" + + default_config_file = pkg_resources.resource_filename(__name__, filename) + default_config = rasa.shared.utils.io.read_config_file(default_config_file) + + config = copy.deepcopy(config) + for key in keys_to_configure: + config[key] = default_config[key] + + return config + + @staticmethod + def _dump_config( + config: Dict[Text, Any], + config_file_path: Text, + missing_keys: Set[Text], + auto_configured_keys: Set[Text], + training_type: Optional[TrainingType] = TrainingType.BOTH, + ) -> None: + """Dump the automatically configured keys into the config file. + + The configuration provided in the file is kept as it is (preserving the order of + keys and comments). + For keys that were automatically configured, an explanatory + comment is added and the automatically chosen configuration is + added commented-out. + If there are already blocks with comments from a previous auto + configuration run, they are replaced with the new auto + configuration. + + Args: + config: The configuration including the automatically configured keys. + config_file_path: The file into which the configuration should be dumped. + missing_keys: Keys that need to be added to the config file. + auto_configured_keys: Keys for which a commented out auto + configuration section needs to be added to the config file. + training_type: NLU, CORE or BOTH depending on which is trained. + """ + config_as_expected = DefaultV1Recipe._is_config_file_as_expected( + config_file_path, missing_keys, auto_configured_keys, training_type + ) + if not config_as_expected: + rasa.shared.utils.cli.print_error( + f"The configuration file at '{config_file_path}' has been removed or " + f"modified while the automatic configuration was running. The current " + f"configuration will therefore not be dumped to the file. If you want " + f"your model to use the configuration provided in " + f"'{config_file_path}' you need to re-run training." + ) + return + + DefaultV1Recipe._add_missing_config_keys_to_file(config_file_path, missing_keys) + + autoconfig_lines = DefaultV1Recipe._get_commented_out_autoconfig_lines( + config, auto_configured_keys + ) + + current_config_content = rasa.shared.utils.io.read_file(config_file_path) + current_config_lines = current_config_content.splitlines(keepends=True) + + updated_lines = DefaultV1Recipe._get_lines_including_autoconfig( + current_config_lines, autoconfig_lines + ) + + rasa.shared.utils.io.write_text_file("".join(updated_lines), config_file_path) + + auto_configured_keys_text = transform_collection_to_sentence( + auto_configured_keys + ) + rasa.shared.utils.cli.print_info( + f"The configuration for {auto_configured_keys_text} " + f"was chosen automatically. " + f"It was written into the config file at '{config_file_path}'." + ) + + @staticmethod + def _is_config_file_as_expected( + config_file_path: Text, + missing_keys: Set[Text], + auto_configured_keys: Set[Text], + training_type: Optional[TrainingType] = TrainingType.BOTH, + ) -> bool: + try: + content = rasa.shared.utils.io.read_config_file(config_file_path) + except FileNotFoundException: + content = {} + + return ( + bool(content) + and missing_keys + == DefaultV1Recipe._get_missing_config_keys(content, training_type) + and auto_configured_keys + == DefaultV1Recipe._get_unspecified_autoconfigurable_keys( + content, training_type + ) + ) + + @staticmethod + def _add_missing_config_keys_to_file( + config_file_path: Text, missing_keys: Set[Text] + ) -> None: + if not missing_keys: + return + with open( + config_file_path, "a", encoding=rasa.shared.utils.io.DEFAULT_ENCODING + ) as f: + for key in missing_keys: + f.write(f"{key}:\n") + + @staticmethod + def _get_lines_including_autoconfig( + lines: List[Text], autoconfig_lines: Dict[Text, List[Text]] + ) -> List[Text]: + auto_configured_keys = autoconfig_lines.keys() + + lines_with_autoconfig = [] + remove_comments_until_next_uncommented_line = False + for line in lines: + insert_section = None + + # remove old auto configuration + if remove_comments_until_next_uncommented_line: + if line.startswith("#"): + continue + remove_comments_until_next_uncommented_line = False + + # add an explanatory comment to autoconfigured sections + for key in auto_configured_keys: + if line.startswith(f"{key}:"): # start of next auto-section + line = line + COMMENTS_FOR_KEYS[key] + insert_section = key + remove_comments_until_next_uncommented_line = True + + lines_with_autoconfig.append(line) + + if not insert_section: + continue + + # add the autoconfiguration (commented out) + lines_with_autoconfig += autoconfig_lines[insert_section] + + return lines_with_autoconfig + + @staticmethod + def _get_commented_out_autoconfig_lines( + config: Dict[Text, Any], auto_configured_keys: Set[Text] + ) -> Dict[Text, List[Text]]: + import ruamel.yaml + import ruamel.yaml.compat + + yaml_parser = ruamel.yaml.YAML() + yaml_parser.indent(mapping=2, sequence=4, offset=2) + + autoconfig_lines = {} + + for key in auto_configured_keys: + stream = ruamel.yaml.compat.StringIO() + yaml_parser.dump(config.get(key), stream) + dump = stream.getvalue() + + lines = dump.split("\n") + if not lines[-1]: + lines = lines[:-1] # yaml dump adds an empty line at the end + lines = [f"# {line}\n" for line in lines] + + autoconfig_lines[key] = lines + + return autoconfig_lines diff --git a/rasa/engine/recipes/graph_recipe.py b/rasa/engine/recipes/graph_recipe.py new file mode 100644 index 0000000..6f94f97 --- /dev/null +++ b/rasa/engine/recipes/graph_recipe.py @@ -0,0 +1,79 @@ +import logging + +from rasa.engine.recipes.recipe import Recipe +from rasa.engine.graph import GraphModelConfiguration +from rasa.shared.constants import DOCS_URL_GRAPH_RECIPE, ASSISTANT_ID_KEY +from rasa.shared.data import TrainingType +from rasa.shared.exceptions import InvalidConfigException +from rasa.shared.utils.common import mark_as_experimental_feature +from rasa.shared.utils.io import raise_warning +from rasa.engine.graph import GraphSchema + +from typing import Dict, Text, Any, Tuple + + +logger = logging.getLogger(__name__) + + +class GraphV1Recipe(Recipe): + """Recipe which converts the graph model config to train and predict graph.""" + + name = "graph.v1" + + def get_targets( + self, config: Dict, training_type: TrainingType + ) -> Tuple[Text, Any]: + """Return NLU and core targets from config dictionary. + + Note that default recipe has `nlu_target` and `core_target` as + fixed values of `run_RegexMessageHandler` and `select_prediction` + respectively. For graph recipe, target values are customizable. These + can be used in validation (default recipe does this validation check) + and during execution (all recipes use targets during execution). + """ + if training_type == TrainingType.NLU: + core_required = False + core_target = None + else: + core_required = True + core_target = config.get("core_target") + # NLU target is required because core (prediction) depends on NLU. + nlu_target = config.get("nlu_target") + if nlu_target is None or (core_required and core_target is None): + raise InvalidConfigException( + "Can't find target names for NLU and/or core. Please make " + "sure to provide 'nlu_target' (required for all training types) " + "and 'core_target' (required if training is not just NLU) values in " + "your config.yml file." + ) + return nlu_target, core_target + + def graph_config_for_recipe( + self, + config: Dict, + cli_parameters: Dict[Text, Any], + training_type: TrainingType = TrainingType.BOTH, + is_finetuning: bool = False, + ) -> GraphModelConfiguration: + """Converts the default config to graphs (see interface for full docstring).""" + mark_as_experimental_feature("graph recipe") + if cli_parameters or is_finetuning: + raise_warning( + "Unlike the Default Recipe, Graph Recipe does not utilize CLI " + "parameters or finetuning and these configurations will be ignored. " + "Add configuration to the recipe itself if you want them to be used.", + docs=DOCS_URL_GRAPH_RECIPE, + ) + + nlu_target, core_target = self.get_targets(config, training_type) + + return GraphModelConfiguration( + train_schema=GraphSchema.from_dict(config.get("train_schema")), + predict_schema=GraphSchema.from_dict(config.get("predict_schema")), + training_type=training_type, + assistant_id=config.get(ASSISTANT_ID_KEY), + language=config.get("language"), + spaces=config.get("spaces"), + core_target=core_target, + nlu_target=nlu_target, + ) diff --git a/rasa/engine/recipes/recipe.py b/rasa/engine/recipes/recipe.py new file mode 100644 index 0000000..0d67f21 --- /dev/null +++ b/rasa/engine/recipes/recipe.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import abc +from typing import Text, Dict, Any, Optional, Tuple, Set + +import rasa.shared.utils.io +from rasa.engine.graph import GraphModelConfiguration +from rasa.shared.exceptions import RasaException +from rasa.shared.data import TrainingType + + +class InvalidRecipeException(RasaException): + """Exception in case the specified recipe is invalid.""" + + +class Recipe(abc.ABC): + """Base class for `Recipe`s which convert configs to graph schemas.""" + + @staticmethod + def recipe_for_name(name: Optional[Text]) -> Recipe: + """Returns `Recipe` based on an optional recipe identifier. + + Args: + name: The identifier which is used to select a certain `Recipe`. If `None` + the default recipe will be used. + + Returns: + A recipe which can be used to convert a given config to train and predict + graph schemas. + """ + from rasa.engine.recipes.default_recipe import DefaultV1Recipe + from rasa.engine.recipes.graph_recipe import GraphV1Recipe + + if name is None: + rasa.shared.utils.io.raise_deprecation_warning( + "From Rasa Open Source 4.0.0 onwards it will be required to specify " + "a recipe in your model configuration. Defaulting to recipe " + f"'{DefaultV1Recipe.name}'." + ) + return DefaultV1Recipe() + recipes = { + DefaultV1Recipe.name: DefaultV1Recipe, + GraphV1Recipe.name: GraphV1Recipe, + } + + recipe_constructor = recipes.get(name) + if recipe_constructor: + return recipe_constructor() + + raise InvalidRecipeException( + f"No recipe with name '{name}' was found. " + f"Available recipes are: " + f"'{DefaultV1Recipe.name}'." + ) + + @staticmethod + def auto_configure( + config_file_path: Optional[Text], + config: Dict, + training_type: Optional[TrainingType] = TrainingType.BOTH, + ) -> Tuple[Dict[Text, Any], Set[str], Set[str]]: + """Adds missing options with defaults and dumps the configuration. + + Override in child classes if this functionality is needed, each recipe + will have different auto configuration values. + """ + return config, set(), set() + + @abc.abstractmethod + def graph_config_for_recipe( + self, + config: Dict, + cli_parameters: Dict[Text, Any], + training_type: TrainingType = TrainingType.BOTH, + is_finetuning: bool = False, + ) -> GraphModelConfiguration: + """Converts a config to a graph compatible model configuration. + + Args: + config: The config which the `Recipe` is supposed to convert. + cli_parameters: Potential CLI params which should be interpolated into the + components configs. + training_type: The current training type. Can be used to omit / add certain + parts of the graphs. + is_finetuning: If `True` then the components should load themselves from + trained version of themselves instead of using `create` to start from + scratch. + + Returns: + The model configuration which enables to run the model as a graph for + training and prediction. + """ + ... diff --git a/rasa/engine/runner/__init__.py b/rasa/engine/runner/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/engine/runner/dask.py b/rasa/engine/runner/dask.py new file mode 100644 index 0000000..2b17d5e --- /dev/null +++ b/rasa/engine/runner/dask.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional, Text + +import dask + +from rasa.engine.exceptions import GraphRunError +from rasa.engine.graph import ExecutionContext, GraphNode, GraphNodeHook, GraphSchema +from rasa.engine.runner.interface import GraphRunner +from rasa.engine.storage.storage import ModelStorage + +logger = logging.getLogger(__name__) + + +class DaskGraphRunner(GraphRunner): + """Dask implementation of a `GraphRunner`.""" + + def __init__( + self, + graph_schema: GraphSchema, + model_storage: ModelStorage, + execution_context: ExecutionContext, + hooks: Optional[List[GraphNodeHook]] = None, + ) -> None: + """Initializes a `DaskGraphRunner`. + + Args: + graph_schema: The graph schema that will be run. + model_storage: Storage which graph components can use to persist and load + themselves. + execution_context: Information about the current graph run to be passed to + each node. + hooks: These are called before and after the execution of each node. + """ + self._graph_schema = graph_schema + self._instantiated_nodes: Dict[Text, GraphNode] = self._instantiate_nodes( + graph_schema, model_storage, execution_context, hooks + ) + self._execution_context: ExecutionContext = execution_context + + @classmethod + def create( + cls, + graph_schema: GraphSchema, + model_storage: ModelStorage, + execution_context: ExecutionContext, + hooks: Optional[List[GraphNodeHook]] = None, + ) -> DaskGraphRunner: + """Creates the runner (see parent class for full docstring).""" + return cls(graph_schema, model_storage, execution_context, hooks) + + @staticmethod + def _instantiate_nodes( + graph_schema: GraphSchema, + model_storage: ModelStorage, + execution_context: ExecutionContext, + hooks: Optional[List[GraphNodeHook]] = None, + ) -> Dict[Text, GraphNode]: + return { + node_name: GraphNode.from_schema_node( + node_name, schema_node, model_storage, execution_context, hooks + ) + for node_name, schema_node in graph_schema.nodes.items() + } + + def _build_dask_graph(self, schema: GraphSchema) -> Dict[Text, Any]: + """Builds a dask graph from the instantiated graph. + + For more information about dask graphs + see: https://docs.dask.org/en/latest/spec.html + """ + run_graph = { + node_name: ( + self._instantiated_nodes[node_name], + *schema_node.needs.values(), + ) + for node_name, schema_node in schema.nodes.items() + } + return run_graph + + def run( + self, + inputs: Optional[Dict[Text, Any]] = None, + targets: Optional[List[Text]] = None, + ) -> Dict[Text, Any]: + """Runs the graph (see parent class for full docstring).""" + run_targets = targets if targets else self._graph_schema.target_names + minimal_schema = self._graph_schema.minimal_graph_schema(run_targets) + run_graph = self._build_dask_graph(minimal_schema) + + if inputs: + self._add_inputs_to_graph(inputs, run_graph) + + logger.debug( + f"Running graph with inputs: {inputs}, targets: {targets} " + f"and {self._execution_context}." + ) + + try: + dask_result = dask.get(run_graph, run_targets) + return dict(dask_result) + except RuntimeError as e: + raise GraphRunError("Error running runner.") from e + + @staticmethod + def _add_inputs_to_graph(inputs: Optional[Dict[Text, Any]], graph: Any) -> None: + if inputs is None: + return + + for input_name, input_value in inputs.items(): + if isinstance(input_value, str) and input_value in graph.keys(): + raise GraphRunError( + f"Input value '{input_value}' clashes with a node name. Make sure " + f"that none of the input names passed to the `run` method are the " + f"same as node names in the graph schema." + ) + graph[input_name] = (input_name, input_value) diff --git a/rasa/engine/runner/interface.py b/rasa/engine/runner/interface.py new file mode 100644 index 0000000..bac20ed --- /dev/null +++ b/rasa/engine/runner/interface.py @@ -0,0 +1,49 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Text + +from rasa.engine.graph import ExecutionContext, GraphNodeHook, GraphSchema +from rasa.engine.storage.storage import ModelStorage + + +class GraphRunner(ABC): + """A `GraphRunner` is responsible for running a `GraphSchema`.""" + + @classmethod + @abstractmethod + def create( + cls, + graph_schema: GraphSchema, + model_storage: ModelStorage, + execution_context: ExecutionContext, + hooks: Optional[List[GraphNodeHook]] = None, + ) -> GraphRunner: + """Creates a new instance of a `GraphRunner`. + + Args: + graph_schema: The graph schema that will be instantiated and run. + model_storage: Storage which graph components can use to persist and load + themselves. + execution_context: Context that will be passed to every `GraphComponent`. + hooks: These are called before and after the execution of each node. + + Returns: Instantiated `GraphRunner` + """ + ... + + @abstractmethod + def run( + self, + inputs: Optional[Dict[Text, Any]] = None, + targets: Optional[List[Text]] = None, + ) -> Dict[Text, Any]: + """Runs the instantiated graph with the given inputs and targets. + + Args: + inputs: Input nodes to be added to the graph. These can be referenced by + name in the "needs" key of a node in the schema. + targets: Nodes whose output is needed and must always run. + + Returns: A mapping of target node name to output value. + """ + ... diff --git a/rasa/engine/storage/__init__.py b/rasa/engine/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/engine/storage/local_model_storage.py b/rasa/engine/storage/local_model_storage.py new file mode 100644 index 0000000..c486819 --- /dev/null +++ b/rasa/engine/storage/local_model_storage.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import logging +import shutil +import sys +import tempfile +import uuid +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path +from tarsafe import TarSafe +from typing import Generator, Optional, Text, Tuple, Union + +import rasa.utils.common +import rasa.shared.utils.io +from rasa.engine.storage.storage import ModelMetadata, ModelStorage +from rasa.engine.graph import GraphModelConfiguration +from rasa.engine.storage.resource import Resource +from rasa.exceptions import UnsupportedModelVersionError +from rasa.shared.core.domain import Domain +import rasa.model + +logger = logging.getLogger(__name__) + +# Paths within model archive +MODEL_ARCHIVE_COMPONENTS_DIR = "components" +MODEL_ARCHIVE_METADATA_FILE = "metadata.json" + + +@contextmanager +def windows_safe_temporary_directory( + suffix: Optional[Text] = None, + prefix: Optional[Text] = None, + dir: Optional[Text] = None, +) -> Generator[Text, None, None]: + """Like `tempfile.TemporaryDirectory`, but works with Windows and long file names. + + On Windows by default there is a restriction on long path names. + Using the prefix below allows to bypass this restriction in environments + where it's not possible to override this behavior, mostly for internal + policy reasons. + + Reference: https://stackoverflow.com/a/49102229 + """ + if sys.platform == "win32": + directory = tempfile.mkdtemp(suffix, prefix, dir) + directory = rasa.utils.common.decode_bytes(directory) + + try: + yield directory + finally: + shutil.rmtree(f"\\\\?\\{directory}") + else: + with tempfile.TemporaryDirectory() as temporary_directory: + temporary_directory = rasa.utils.common.decode_bytes(temporary_directory) + yield temporary_directory + + +class LocalModelStorage(ModelStorage): + """Stores and provides output of `GraphComponents` on local disk.""" + + def __init__(self, storage_path: Path) -> None: + """Creates storage (see parent class for full docstring).""" + self._storage_path = storage_path + + @classmethod + def create(cls, storage_path: Path) -> ModelStorage: + """Creates a new instance (see parent class for full docstring).""" + return cls(storage_path) + + @classmethod + def from_model_archive( + cls, storage_path: Path, model_archive_path: Union[Text, Path] + ) -> Tuple[LocalModelStorage, ModelMetadata]: + """Initializes storage from archive (see parent class for full docstring).""" + if next(storage_path.glob("*"), None): + raise ValueError( + f"The model storage with path '{storage_path}' is " + f"not empty. You can only unpack model archives into an " + f"empty model storage." + ) + + with windows_safe_temporary_directory() as temporary_directory: + temporary_directory_path = Path(temporary_directory) + + cls._extract_archive_to_directory( + model_archive_path, temporary_directory_path + ) + logger.debug(f"Extracted model to '{temporary_directory_path}'.") + + cls._initialize_model_storage_from_model_archive( + temporary_directory_path, storage_path + ) + + metadata = cls._load_metadata(temporary_directory_path) + + return (cls(storage_path), metadata) + + @classmethod + def metadata_from_archive( + cls, model_archive_path: Union[Text, Path] + ) -> ModelMetadata: + """Retrieves metadata from archive (see parent class for full docstring).""" + with windows_safe_temporary_directory() as temporary_directory: + temporary_directory_path = Path(temporary_directory) + + cls._extract_archive_to_directory( + model_archive_path, temporary_directory_path + ) + metadata = cls._load_metadata(temporary_directory_path) + + return metadata + + @staticmethod + def _extract_archive_to_directory( + model_archive_path: Union[Text, Path], temporary_directory: Path + ) -> None: + with TarSafe.open(model_archive_path, mode="r:gz") as tar: + if sys.platform == "win32": + # on Windows by default there is a restriction on long + # path names; using the prefix below allows to bypass + # this restriction in environments where it's not possible + # to override this behavior, mostly for internal policy reasons + # reference: https://stackoverflow.com/a/49102229 + tar.extractall(f"\\\\?\\{temporary_directory}") + else: + tar.extractall(temporary_directory) + LocalModelStorage._assert_not_rasa2_archive(temporary_directory) + + @staticmethod + def _assert_not_rasa2_archive(temporary_directory: Union[Text, Path]) -> None: + fingerprint_file = Path(temporary_directory) / "fingerprint.json" + if fingerprint_file.is_file(): + serialized_fingerprint = rasa.shared.utils.io.read_json_file( + fingerprint_file + ) + raise UnsupportedModelVersionError( + model_version=serialized_fingerprint["version"] + ) + + @staticmethod + def _initialize_model_storage_from_model_archive( + temporary_directory: Path, storage_path: Path + ) -> None: + for path in (temporary_directory / MODEL_ARCHIVE_COMPONENTS_DIR).glob("*"): + shutil.move(str(path), str(storage_path)) + + @staticmethod + def _load_metadata(directory: Path) -> ModelMetadata: + serialized_metadata = rasa.shared.utils.io.read_json_file( + directory / MODEL_ARCHIVE_METADATA_FILE + ) + + return ModelMetadata.from_dict(serialized_metadata) + + @contextmanager + def write_to(self, resource: Resource) -> Generator[Path, None, None]: + """Persists data for a resource (see parent class for full docstring).""" + logger.debug(f"Resource '{resource.name}' was requested for writing.") + directory = self._directory_for_resource(resource) + + if not directory.exists(): + directory.mkdir() + + yield directory + + logger.debug(f"Resource '{resource.name}' was persisted.") + + def _directory_for_resource(self, resource: Resource) -> Path: + return self._storage_path / resource.name + + @contextmanager + def read_from(self, resource: Resource) -> Generator[Path, None, None]: + """Provides the data of a `Resource` (see parent class for full docstring).""" + logger.debug(f"Resource '{resource.name}' was requested for reading.") + directory = self._directory_for_resource(resource) + + if not directory.exists(): + raise ValueError( + f"Resource '{resource.name}' does not exist. Please make " + f"sure that the graph component providing the resource " + f"is a parent node of the current graph node " + f"(in case this happens during training) or that the " + f"resource was actually persisted during training " + f"(in case this happens during inference)." + ) + + yield directory + + def create_model_package( + self, + model_archive_path: Union[Text, Path], + model_configuration: GraphModelConfiguration, + domain: Domain, + ) -> ModelMetadata: + """Creates model package (see parent class for full docstring).""" + logger.debug(f"Start to created model package for path '{model_archive_path}'.") + + with windows_safe_temporary_directory() as temp_dir: + + temporary_directory = Path(temp_dir) + + shutil.copytree( + self._storage_path, temporary_directory / MODEL_ARCHIVE_COMPONENTS_DIR + ) + + model_metadata = self._create_model_metadata(domain, model_configuration) + self._persist_metadata(model_metadata, temporary_directory) + + if isinstance(model_archive_path, str): + model_archive_path = Path(model_archive_path) + + if not model_archive_path.parent.exists(): + model_archive_path.parent.mkdir(parents=True) + + with TarSafe.open(model_archive_path, "w:gz") as tar: + tar.add(temporary_directory, arcname="") + + logger.debug(f"Model package created in path '{model_archive_path}'.") + + return model_metadata + + @staticmethod + def _persist_metadata(metadata: ModelMetadata, temporary_directory: Path) -> None: + + rasa.shared.utils.io.dump_obj_as_json_to_file( + temporary_directory / MODEL_ARCHIVE_METADATA_FILE, metadata.as_dict() + ) + + @staticmethod + def _create_model_metadata( + domain: Domain, model_configuration: GraphModelConfiguration + ) -> ModelMetadata: + return ModelMetadata( + trained_at=datetime.utcnow(), + rasa_open_source_version=rasa.__version__, + model_id=uuid.uuid4().hex, + assistant_id=model_configuration.assistant_id, + domain=domain, + train_schema=model_configuration.train_schema, + predict_schema=model_configuration.predict_schema, + training_type=model_configuration.training_type, + project_fingerprint=rasa.model.project_fingerprint(), + spaces=model_configuration.spaces, + language=model_configuration.language, + core_target=model_configuration.core_target, + nlu_target=model_configuration.nlu_target, + ) diff --git a/rasa/engine/storage/resource.py b/rasa/engine/storage/resource.py new file mode 100644 index 0000000..b987b14 --- /dev/null +++ b/rasa/engine/storage/resource.py @@ -0,0 +1,110 @@ +from __future__ import annotations +import logging +import typing +from dataclasses import dataclass, field +from pathlib import Path +from typing import Text +import uuid + +import rasa.utils.common +import rasa.utils.io + +if typing.TYPE_CHECKING: + from rasa.engine.storage.storage import ModelStorage + +logger = logging.getLogger(__name__) + + +@dataclass +class Resource: + """Represents a persisted graph component in the graph. + + Attributes: + name: The unique identifier for the `Resource`. Used to locate the associated + data from a `ModelStorage`. Normally matches the name of the node which + created it. + output_fingerprint: An unique identifier for a specific instantiation of a + `Resource`. Used to distinguish a specific persistence for the same + `Resource` when saving to the cache. + + """ + + name: Text + output_fingerprint: Text = field( + default_factory=lambda: uuid.uuid4().hex, + # We do not use this for comparison as it is not consistent after serialization. + compare=False, + ) + + @classmethod + def from_cache( + cls, + node_name: Text, + directory: Path, + model_storage: ModelStorage, + output_fingerprint: Text, + ) -> Resource: + """Loads a `Resource` from the cache. + + This automatically loads the persisted resource into the given `ModelStorage`. + + Args: + node_name: The node name of the `Resource`. + directory: The directory with the cached `Resource`. + model_storage: The `ModelStorage` which the cached `Resource` will be added + to so that the `Resource` is accessible for other graph nodes. + output_fingerprint: The fingerprint of the cached `Resource`. + + Returns: + The ready-to-use and accessible `Resource`. + """ + logger.debug(f"Loading resource '{node_name}' from cache.") + + resource = Resource(node_name, output_fingerprint=output_fingerprint) + if not any(directory.glob("*")): + logger.debug(f"Cached resource for '{node_name}' was empty.") + return resource + + try: + with model_storage.write_to(resource) as resource_directory: + rasa.utils.common.copy_directory(directory, resource_directory) + except ValueError: + # This might happen during finetuning as in this case the model storage + # is already filled + if not rasa.utils.io.are_directories_equal(directory, resource_directory): + # We skip caching in case we see the cached output and output + # from the model which we want to finetune are not the same + raise + + logger.debug(f"Successfully initialized resource '{node_name}' from cache.") + + return resource + + def to_cache(self, directory: Path, model_storage: ModelStorage) -> None: + """Persists the `Resource` to the cache. + + Args: + directory: The directory which receives the persisted `Resource`. + model_storage: The model storage which currently contains the persisted + `Resource`. + """ + try: + with model_storage.read_from(self) as resource_directory: + rasa.utils.common.copy_directory(resource_directory, directory) + except ValueError: + logger.debug( + f"Skipped caching resource '{self.name}' as no persisted " + f"data was found." + ) + + def fingerprint(self) -> Text: + """Provides fingerprint for `Resource`. + + A unique fingerprint is created on initialization of a `Resource` however we + also allow a value to be provided for when we retrieve a `Resource` from the + cache (see `Resource.from_cache`). + + Returns: + Fingerprint for `Resource`. + """ + return self.output_fingerprint diff --git a/rasa/engine/storage/storage.py b/rasa/engine/storage/storage.py new file mode 100644 index 0000000..a113c0e --- /dev/null +++ b/rasa/engine/storage/storage.py @@ -0,0 +1,203 @@ +from __future__ import annotations +import abc +import logging +import typing +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import List, Tuple, Union, Text, Generator, Dict, Any, Optional +from packaging import version + +from rasa.constants import MINIMUM_COMPATIBLE_VERSION +from rasa.exceptions import UnsupportedModelVersionError +from rasa.engine.storage.resource import Resource +from rasa.shared.core.domain import Domain +from rasa.shared.data import TrainingType + +if typing.TYPE_CHECKING: + from rasa.engine.graph import GraphSchema, GraphModelConfiguration + +logger = logging.getLogger(__name__) + + +class ModelStorage(abc.ABC): + """Serves as storage backend for `GraphComponents` which need persistence.""" + + @classmethod + @abc.abstractmethod + def create(cls, storage_path: Path) -> ModelStorage: + """Creates the storage. + + Args: + storage_path: Directory which will contain the persisted graph components. + """ + ... + + @classmethod + @abc.abstractmethod + def from_model_archive( + cls, storage_path: Path, model_archive_path: Union[Text, Path] + ) -> Tuple[ModelStorage, ModelMetadata]: + """Unpacks a model archive and initializes a `ModelStorage`. + + Args: + storage_path: Directory which will contain the persisted graph components. + model_archive_path: The path to the model archive. + + Returns: + Initialized model storage, and metadata about the model. + + Raises: + `UnsupportedModelError` if the loaded meta data indicates that the model + has been created with an outdated Rasa version. + """ + ... + + @classmethod + @abc.abstractmethod + def metadata_from_archive( + cls, model_archive_path: Union[Text, Path] + ) -> ModelMetadata: + """Retrieves metadata from archive. + + Args: + model_archive_path: The path to the model archive. + + Returns: + Metadata about the model. + + Raises: + `UnsupportedModelError` if the loaded meta data indicates that the model + has been created with an outdated Rasa version. + """ + ... + + @contextmanager + @abc.abstractmethod + def write_to(self, resource: Resource) -> Generator[Path, None, None]: + """Persists data for a given resource. + + This `Resource` can then be accessed in dependent graph nodes via + `model_storage.read_from`. + + Args: + resource: The resource which should be persisted. + + Returns: + A directory which can be used to persist data for the given `Resource`. + """ + ... + + @contextmanager + @abc.abstractmethod + def read_from(self, resource: Resource) -> Generator[Path, None, None]: + """Provides the data of a persisted `Resource`. + + Args: + resource: The `Resource` whose persisted should be accessed. + + Returns: + A directory containing the data of the persisted `Resource`. + + Raises: + ValueError: In case no persisted data for the given `Resource` exists. + """ + ... + + @abc.abstractmethod + def create_model_package( + self, + model_archive_path: Union[Text, Path], + model_configuration: GraphModelConfiguration, + domain: Domain, + ) -> ModelMetadata: + """Creates a model archive containing all data to load and run the model. + + Args: + model_archive_path: The path to the archive which should be created. + model_configuration: The model configuration (schemas, language, etc.) + domain: The `Domain` which was used to train the model. + + Returns: + The model metadata. + """ + ... + + +@dataclass() +class ModelMetadata: + """Describes a trained model.""" + + trained_at: datetime + rasa_open_source_version: Text + model_id: Text + assistant_id: Optional[Text] + domain: Domain + train_schema: GraphSchema + predict_schema: GraphSchema + project_fingerprint: Text + core_target: Optional[Text] + nlu_target: Text + language: Optional[Text] + spaces: Optional[List[Dict[Text, Any]]] = None + training_type: TrainingType = TrainingType.BOTH + + def __post_init__(self) -> None: + """Raises an exception when the metadata indicates an unsupported version. + + Raises: + `UnsupportedModelException` if the `rasa_open_source_version` is lower + than the minimum compatible version + """ + minimum_version = version.parse(MINIMUM_COMPATIBLE_VERSION) + model_version = version.parse(self.rasa_open_source_version) + if model_version < minimum_version: + raise UnsupportedModelVersionError(model_version=model_version) + + def as_dict(self) -> Dict[Text, Any]: + """Returns serializable version of the `ModelMetadata`.""" + return { + "domain": self.domain.as_dict(), + "trained_at": self.trained_at.isoformat(), + "model_id": self.model_id, + "assistant_id": self.assistant_id, + "rasa_open_source_version": self.rasa_open_source_version, + "train_schema": self.train_schema.as_dict(), + "predict_schema": self.predict_schema.as_dict(), + "training_type": self.training_type.value, + "project_fingerprint": self.project_fingerprint, + "core_target": self.core_target, + "nlu_target": self.nlu_target, + "language": self.language, + "spaces": self.spaces, + } + + @classmethod + def from_dict(cls, serialized: Dict[Text, Any]) -> ModelMetadata: + """Loads `ModelMetadata` which has been serialized using `metadata.as_dict()`. + + Args: + serialized: Serialized `ModelMetadata` (e.g. read from disk). + + Returns: + Instantiated `ModelMetadata`. + """ + from rasa.engine.graph import GraphSchema + + return ModelMetadata( + trained_at=datetime.fromisoformat(serialized["trained_at"]), + rasa_open_source_version=serialized["rasa_open_source_version"], + model_id=serialized["model_id"], + assistant_id=serialized.get("assistant_id"), + domain=Domain.from_dict(serialized["domain"]), + train_schema=GraphSchema.from_dict(serialized["train_schema"]), + predict_schema=GraphSchema.from_dict(serialized["predict_schema"]), + training_type=TrainingType(serialized["training_type"]), + project_fingerprint=serialized["project_fingerprint"], + core_target=serialized["core_target"], + nlu_target=serialized["nlu_target"], + language=serialized["language"], + # optional, since introduced later + spaces=serialized.get("spaces"), + ) diff --git a/rasa/engine/training/__init__.py b/rasa/engine/training/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/engine/training/components.py b/rasa/engine/training/components.py new file mode 100644 index 0000000..f73255f --- /dev/null +++ b/rasa/engine/training/components.py @@ -0,0 +1,176 @@ +from __future__ import annotations +from typing import Any, Dict, Optional, Text, Type +import dataclasses +import uuid + +from rasa.engine.caching import Cacheable, TrainingCache +from rasa.engine.graph import ExecutionContext, GraphComponent, SchemaNode +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.training import fingerprinting + + +class PrecomputedValueProvider(GraphComponent): + """Holds the precomputed values of a `GraphNode` from a previous training. + + Pre-computed values can either be + - values loaded from cache + - values which were provided during the fingerprint run by input nodes + """ + + def __init__(self, output: Cacheable): + """Initializes a `PrecomputedValueProvider`. + + Args: + output: The precomputed output to return. + """ + self._output = output + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> PrecomputedValueProvider: + """Creates instance (see parent class for full docstring).""" + return cls(output=config["output"]) + + def get_value(self) -> Cacheable: + """Returns the precomputed output.""" + return self._output + + @classmethod + def replace_schema_node(cls, node: SchemaNode, output: Any) -> None: + """Updates a `SchemaNode` to use a `PrecomputedValueProvider`. + + This is for when we want to use the precomputed output value of a node from a + previous training in a subsequent training. We replace the class in the `uses` + of the node to a be a `PrecomputedValueProvider` configured to return the + precomputed value. + + Args: + node: The node to update. + output: precomputed cached output that the `PrecomputedValueProvider` will + return. + """ + node.uses = cls + node.config = {"output": output} + node.fn = cls.get_value.__name__ + node.constructor_name = cls.create.__name__ + + +@dataclasses.dataclass +class FingerprintStatus: + """Holds the output of a `FingerprintComponent` and is used to prune the graph. + + Attributes: + output_fingerprint: A fingerprint of the node's output value. + is_hit: `True` if node's fingerprint key exists in the cache, `False` otherwise. + """ + + output_fingerprint: Optional[Text] + is_hit: bool + + def fingerprint(self) -> Text: + """Returns the internal fingerprint. + + If there is no fingerprint returns a random string that will never match. + """ + return self.output_fingerprint or uuid.uuid4().hex + + +class FingerprintComponent(GraphComponent): + """Replaces non-input nodes during a fingerprint run.""" + + def __init__( + self, + cache: TrainingCache, + config_of_replaced_component: Dict[Text, Any], + class_of_replaced_component: Type, + ) -> None: + """Initializes a `FingerprintComponent`. + + Args: + cache: Training cache used to determine if the run is a hit or not. + config_of_replaced_component: Needed to generate the fingerprint key. + class_of_replaced_component: Needed to generate the fingerprint key. + """ + self._cache = cache + self._config_of_replaced_component = config_of_replaced_component + self._class_of_replaced_component = class_of_replaced_component + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> FingerprintComponent: + """Creates a `FingerprintComponent` (see parent class for full docstring).""" + return cls( + cache=config["cache"], + config_of_replaced_component=config["config_of_replaced_component"], + class_of_replaced_component=config["graph_component_class"], + ) + + def run(self, **kwargs: Any) -> FingerprintStatus: + """Calculates the fingerprint key to determine if cached output can be used. + + If the fingerprint key matches an entry in the cache it means that there has + been a previous node execution which matches the same component class, component + config and input values. This means that we can potentially prune this node + from the schema, or replace it with a cached value before the next graph run. + + Args: + **kwargs: Inputs from all parent nodes. + + Returns: + A `FingerprintStatus` determining if the run was a hit, and if it was a hit + also the output fingerprint from the cache. + """ + fingerprint_key = fingerprinting.calculate_fingerprint_key( + graph_component_class=self._class_of_replaced_component, + config={ + **self._class_of_replaced_component.get_default_config(), + **self._config_of_replaced_component, + }, + inputs=kwargs, + ) + + output_fingerprint = self._cache.get_cached_output_fingerprint(fingerprint_key) + + return FingerprintStatus( + is_hit=output_fingerprint is not None, output_fingerprint=output_fingerprint + ) + + @classmethod + def replace_schema_node(cls, node: SchemaNode, cache: TrainingCache) -> None: + """Updates a `SchemaNode` to use a `FingerprintComponent`. + + This is for when we want to do a fingerprint run. During the fingerprint run we + replace all non-input nodes with `FingerprintComponent`s so we can determine + whether they are able to be pruned or cached before the next graph run without + running the actual components. + + + Args: + node: The node to update. + cache: The cache is needed to determine of there is cache hit for the + fingerprint key. + """ + graph_component_class = node.uses + node.uses = cls + # We update the node to be "eager" so that `FingerprintComponent.run` sees + # ALL the inputs to the node. If it was not eager, we would miss any args used + # by the constructor. + node.eager = True + node.constructor_name = cls.create.__name__ + node.fn = cls.run.__name__ + node.config = { + "config_of_replaced_component": node.config, + "cache": cache, + "graph_component_class": graph_component_class, + } diff --git a/rasa/engine/training/fingerprinting.py b/rasa/engine/training/fingerprinting.py new file mode 100644 index 0000000..17838a7 --- /dev/null +++ b/rasa/engine/training/fingerprinting.py @@ -0,0 +1,64 @@ +import inspect +import logging +from typing import Any, Dict, Text, Type +from typing_extensions import Protocol, runtime_checkable +import pkg_resources +import rasa.utils.common +import rasa.shared.utils.io +from rasa.engine.graph import GraphComponent + +logger = logging.getLogger(__name__) + +import_name_to_package_map = {"sklearn": "scikit_learn"} + + +@runtime_checkable +class Fingerprintable(Protocol): + """Interface that enforces training data can be fingerprinted.""" + + def fingerprint(self) -> Text: + """Returns a unique stable fingerprint of the data.""" + ... + + +def calculate_fingerprint_key( + graph_component_class: Type[GraphComponent], + config: Dict[Text, Any], + inputs: Dict[Text, Fingerprintable], +) -> Text: + """Calculates a fingerprint key that uniquely represents a single node's execution. + + Args: + graph_component_class: The graph component class. + config: The component config. + inputs: The inputs as a mapping of parent node name to input value. + + Returns: + The fingerprint key. + """ + dependency_versions = { + package: pkg_resources.get_distribution( + import_name_to_package_map.get(package, package) + ).version + for package in graph_component_class.required_packages() + } + fingerprint_data = { + "node_name": rasa.utils.common.module_path_from_class(graph_component_class), + "component_implementation": inspect.getsource(graph_component_class), + "config": config, + "inputs": inputs, + "dependency_versions": dependency_versions, + } + + fingerprint_addon = graph_component_class.fingerprint_addon(config) + if fingerprint_addon is not None: + fingerprint_data["addon"] = fingerprint_addon + + fingerprint_key = rasa.shared.utils.io.deep_container_fingerprint(fingerprint_data) + + logger.debug( + f"Calculated fingerprint_key '{fingerprint_key}' for class " + f"'{graph_component_class.__name__}'." + ) + + return fingerprint_key diff --git a/rasa/engine/training/graph_trainer.py b/rasa/engine/training/graph_trainer.py new file mode 100644 index 0000000..cf20692 --- /dev/null +++ b/rasa/engine/training/graph_trainer.py @@ -0,0 +1,253 @@ +import copy +import logging +from pathlib import Path +from typing import Any, Dict, Text, Type, Union + +from rasa.engine.caching import TrainingCache +from rasa.engine.graph import ExecutionContext, GraphSchema, GraphModelConfiguration +from rasa.engine.constants import PLACEHOLDER_IMPORTER +from rasa.engine.runner.interface import GraphRunner +from rasa.engine.storage.storage import ModelStorage, ModelMetadata +from rasa.engine.training.components import ( + PrecomputedValueProvider, + FingerprintComponent, + FingerprintStatus, +) +from rasa.engine.training.hooks import TrainingHook, LoggingHook +from rasa.shared.importers.importer import TrainingDataImporter + +logger = logging.getLogger(__name__) + + +class GraphTrainer: + """Trains a model using a graph schema.""" + + def __init__( + self, + model_storage: ModelStorage, + cache: TrainingCache, + graph_runner_class: Type[GraphRunner], + ) -> None: + """Initializes a `GraphTrainer`. + + Args: + model_storage: Storage which graph components can use to persist and load. + Also used for packaging the trained model. + cache: Cache used to store fingerprints and outputs. + graph_runner_class: The class to instantiate the runner from. + """ + self._model_storage = model_storage + self._cache = cache + self._graph_runner_class = graph_runner_class + + def train( + self, + model_configuration: GraphModelConfiguration, + importer: TrainingDataImporter, + output_filename: Path, + force_retraining: bool = False, + is_finetuning: bool = False, + ) -> ModelMetadata: + """Trains and packages a model and returns the prediction graph runner. + + Args: + model_configuration: The model configuration (schemas, language, etc.) + importer: The importer which provides the training data for the training. + output_filename: The location to save the packaged model. + force_retraining: If `True` then the cache is skipped and all components + are retrained. + + Returns: + The metadata describing the trained model. + """ + logger.debug("Starting training.") + + # Retrieve the domain for the model metadata right at the start. + # This avoids that something during the graph runs mutates it. + domain = copy.deepcopy(importer.get_domain()) + + if force_retraining: + logger.debug( + "Skip fingerprint run as a full training of the model was enforced." + ) + pruned_training_schema = model_configuration.train_schema + else: + fingerprint_run_outputs = self.fingerprint( + model_configuration.train_schema, + importer=importer, + is_finetuning=is_finetuning, + ) + pruned_training_schema = self._prune_schema( + model_configuration.train_schema, fingerprint_run_outputs + ) + + hooks = [ + LoggingHook(pruned_schema=pruned_training_schema), + TrainingHook( + cache=self._cache, + model_storage=self._model_storage, + pruned_schema=pruned_training_schema, + ), + ] + + graph_runner = self._graph_runner_class.create( + graph_schema=pruned_training_schema, + model_storage=self._model_storage, + execution_context=ExecutionContext( + graph_schema=model_configuration.train_schema, + is_finetuning=is_finetuning, + ), + hooks=hooks, + ) + + logger.debug("Running the pruned train graph with real node execution.") + + graph_runner.run(inputs={PLACEHOLDER_IMPORTER: importer}) + + return self._model_storage.create_model_package( + output_filename, model_configuration, domain + ) + + def fingerprint( + self, + train_schema: GraphSchema, + importer: TrainingDataImporter, + is_finetuning: bool = False, + ) -> Dict[Text, Union[FingerprintStatus, Any]]: + """Runs the graph using fingerprints to determine which nodes need to re-run. + + Nodes which have a matching fingerprint key in the cache can either be removed + entirely from the graph, or replaced with a cached value if their output is + needed by descendent nodes. + + Args: + train_schema: The train graph schema that will be run in fingerprint mode. + importer: The importer which provides the training data for the training. + is_finetuning: `True` if we want to finetune the model. + + Returns: + Mapping of node names to fingerprint results. + """ + fingerprint_schema = self._create_fingerprint_schema(train_schema) + + fingerprint_graph_runner = self._graph_runner_class.create( + graph_schema=fingerprint_schema, + model_storage=self._model_storage, + execution_context=ExecutionContext( + graph_schema=train_schema, is_finetuning=is_finetuning + ), + ) + + logger.debug("Running the train graph in fingerprint mode.") + return fingerprint_graph_runner.run(inputs={PLACEHOLDER_IMPORTER: importer}) + + def _create_fingerprint_schema(self, train_schema: GraphSchema) -> GraphSchema: + fingerprint_schema = copy.deepcopy(train_schema) + for node_name, schema_node in fingerprint_schema.nodes.items(): + # We make every node a target so that `graph_runner.run(...)` returns + # the output for each node. We need the output of each node + # to decide which nodes we can prune. + schema_node.is_target = True + + # We do not replace the input nodes as we need an up-to-date fingerprint of + # any input data to the graph. This means we can prune according to what + # has actually changed. + if not schema_node.is_input: + FingerprintComponent.replace_schema_node(schema_node, self._cache) + return fingerprint_schema + + def _prune_schema( + self, + schema: GraphSchema, + fingerprint_run_outputs: Dict[Text, Union[FingerprintStatus, Any]], + ) -> GraphSchema: + """Uses the fingerprint statuses to prune the graph schema. + + Walks the graph starting at each target node. If a node has a cache hit we + replace it with a `PrecomputedValueProvider` and remove its input dependencies. + At the end, any node that is not an ancestor of a target node will be pruned + when we call `minimal_graph_schema()`. + + Args: + schema: The graph to prune. + fingerprint_run_outputs: Node outputs from the fingerprint run as a mapping + from node name to output. + + Returns: + The pruned schema. + """ + pruned_schema = copy.deepcopy(schema) + target_node_names = pruned_schema.target_names + + for target_node_name in target_node_names: + self._walk_and_prune( + pruned_schema, target_node_name, fingerprint_run_outputs + ) + + return pruned_schema.minimal_graph_schema() + + def _walk_and_prune( + self, + schema: GraphSchema, + current_node_name: Text, + fingerprint_run_outputs: Dict[Text, Union[FingerprintStatus, Any]], + ) -> None: + """Recursively walks backwards though a graph checking the status of each node. + + If node has a fingerprint key hit then we check if there is a cached output. + If there is a cached output we will replace the node with a + `PrecomputedValueProvider` and remove all its dependencies (`.needs`). If + there is not a fingerprint key hit, or there is no cached output, the node is + left untouched and will be executed again next run unless it is no longer the + ancestor of a target node. + + Args: + schema: The graph we are currently walking. + current_node_name: The current node on the walk. + fingerprint_run_outputs: The fingerprint statuses of every node as a mapping + from node name to status. + """ + fingerprint_run_output = fingerprint_run_outputs[current_node_name] + node = schema.nodes[current_node_name] + + # If we have replaced this node with a `PrecomputedValueProvider` we have + # already visited this node. A `PrecomputedValueProvider` is updated to have + # no parent nodes, so + # we can end the walk here. + if node.uses == PrecomputedValueProvider: + return + + # If the output was a `FingerprintStatus` we must check the cache and status. + if isinstance(fingerprint_run_output, FingerprintStatus): + # If there is a fingerprint key hit we can potentially use a cached output. + if fingerprint_run_output.is_hit: + output_result = self._cache.get_cached_result( + output_fingerprint_key=fingerprint_run_output.output_fingerprint, + node_name=current_node_name, + model_storage=self._model_storage, + ) + if output_result: + logger.debug( + f"Updating '{current_node_name}' to use a " + f"'{PrecomputedValueProvider.__name__}'." + ) + PrecomputedValueProvider.replace_schema_node(node, output_result) + # We remove all parent dependencies as the cached output value will + # be used. + node.needs = {} + else: + # If there is no cached output the node must be re-run if it ends + # up as an ancestor of a target node. + fingerprint_run_output.is_hit = False + + # Else the node was an input node and the output is the actual node's output. + else: + # As fingerprint_run_output is just the node's output there is no need to + # execute the node again. We can just return it from a + # `PrecomputedValueProvider`. + PrecomputedValueProvider.replace_schema_node(node, fingerprint_run_output) + node.needs = {} + + # Continue walking for every parent node. + for parent_node_name in node.needs.values(): + self._walk_and_prune(schema, parent_node_name, fingerprint_run_outputs) diff --git a/rasa/engine/training/hooks.py b/rasa/engine/training/hooks.py new file mode 100644 index 0000000..b399a0f --- /dev/null +++ b/rasa/engine/training/hooks.py @@ -0,0 +1,150 @@ +import logging +from typing import Any, Dict, Text, Type + +from rasa.engine.caching import TrainingCache +from rasa.engine.graph import ExecutionContext, GraphNodeHook, GraphSchema, SchemaNode +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.training.components import PrecomputedValueProvider +import rasa.shared.utils.io +from rasa.engine.training import fingerprinting + +logger = logging.getLogger(__name__) + + +class TrainingHook(GraphNodeHook): + """Caches fingerprints and outputs of nodes during model training.""" + + def __init__( + self, + cache: TrainingCache, + model_storage: ModelStorage, + pruned_schema: GraphSchema, + ) -> None: + """Initializes a `TrainingHook`. + + Args: + cache: Cache used to store fingerprints and outputs. + model_storage: Used to cache `Resource`s. + pruned_schema: The pruned training schema. + """ + self._cache = cache + self._model_storage = model_storage + self._pruned_schema = pruned_schema + + def on_before_node( + self, + node_name: Text, + execution_context: ExecutionContext, + config: Dict[Text, Any], + received_inputs: Dict[Text, Any], + ) -> Dict: + """Calculates the run fingerprint for use in `on_after_node`.""" + graph_component_class = self._get_graph_component_class( + execution_context, node_name + ) + + fingerprint_key = fingerprinting.calculate_fingerprint_key( + graph_component_class=graph_component_class, + config=config, + inputs=received_inputs, + ) + + return {"fingerprint_key": fingerprint_key} + + def on_after_node( + self, + node_name: Text, + execution_context: ExecutionContext, + config: Dict[Text, Any], + output: Any, + input_hook_data: Dict, + ) -> None: + """Stores the fingerprints and caches the output of the node.""" + # We should not re-cache the output of a PrecomputedValueProvider. + graph_component_class = self._pruned_schema.nodes[node_name].uses + + if graph_component_class == PrecomputedValueProvider: + return None + + output_fingerprint = rasa.shared.utils.io.deep_container_fingerprint(output) + fingerprint_key = input_hook_data["fingerprint_key"] + + logger.debug( + f"Caching '{output.__class__.__name__}' with fingerprint_key: " + f"'{fingerprint_key}' and output_fingerprint '{output_fingerprint}'." + ) + + self._cache.cache_output( + fingerprint_key=fingerprint_key, + output=output, + output_fingerprint=output_fingerprint, + model_storage=self._model_storage, + ) + + @staticmethod + def _get_graph_component_class( + execution_context: ExecutionContext, node_name: Text + ) -> Type: + graph_component_class = execution_context.graph_schema.nodes[node_name].uses + return graph_component_class + + +class LoggingHook(GraphNodeHook): + """Logs the training of components.""" + + def __init__(self, pruned_schema: GraphSchema) -> None: + """Creates hook. + + Args: + pruned_schema: The pruned schema provides us with the information whether + a component is cached or not. + """ + self._pruned_schema = pruned_schema + + def on_before_node( + self, + node_name: Text, + execution_context: ExecutionContext, + config: Dict[Text, Any], + received_inputs: Dict[Text, Any], + ) -> Dict: + """Logs the training start of a graph node.""" + node = self._pruned_schema.nodes[node_name] + + if not self._is_cached_node(node) and self._does_node_train(node): + logger.info(f"Starting to train component '{node.uses.__name__}'.") + + return {} + + @staticmethod + def _does_node_train(node: SchemaNode) -> bool: + # Nodes which train are always targets so that they store their output in the + # model storage. `is_input` filters out nodes which don't really train but e.g. + # persist some training data. + return node.is_target and not node.is_input + + @staticmethod + def _is_cached_node(node: SchemaNode) -> bool: + return node.uses == PrecomputedValueProvider + + def on_after_node( + self, + node_name: Text, + execution_context: ExecutionContext, + config: Dict[Text, Any], + output: Any, + input_hook_data: Dict, + ) -> None: + """Logs when a component finished its training.""" + node = self._pruned_schema.nodes[node_name] + + if not self._does_node_train(node): + return + + if self._is_cached_node(node): + actual_component = execution_context.graph_schema.nodes[node_name] + logger.info( + f"Restored component '{actual_component.uses.__name__}' from cache." + ) + else: + logger.info(f"Finished training component '{node.uses.__name__}'.") diff --git a/rasa/engine/validation.py b/rasa/engine/validation.py new file mode 100644 index 0000000..c07b887 --- /dev/null +++ b/rasa/engine/validation.py @@ -0,0 +1,585 @@ +import inspect +import logging +import typing +from typing import ( + Optional, + Callable, + Text, + Tuple, + Dict, + Type, + Any, + Set, + Union, + TypeVar, + List, +) + +import dataclasses + +from rasa.core.policies.policy import PolicyPrediction +from rasa.engine.training.fingerprinting import Fingerprintable +import rasa.utils.common +import typing_utils + +from rasa.engine.exceptions import GraphSchemaValidationException +from rasa.engine.graph import ( + GraphSchema, + GraphComponent, + SchemaNode, + ExecutionContext, + GraphModelConfiguration, +) +from rasa.engine.constants import RESERVED_PLACEHOLDERS +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.constants import DOCS_URL_GRAPH_COMPONENTS +from rasa.shared.exceptions import RasaException +from rasa.shared.nlu.training_data.message import Message + +TypeAnnotation = Union[TypeVar, Text, Type] + + +@dataclasses.dataclass +class ParameterInfo: + """Stores metadata about a function parameter.""" + + type_annotation: TypeAnnotation + # `True` if we have a parameter like `**kwargs` + is_variable_length_keyword_arg: bool + has_default: bool + + +KEYWORDS_EXPECTED_TYPES: Dict[Text, TypeAnnotation] = { + "resource": Resource, + "execution_context": ExecutionContext, + "model_storage": ModelStorage, + "config": Dict[Text, Any], +} + + +def validate(model_configuration: GraphModelConfiguration) -> None: + """Validates a graph schema. + + This tries to validate that the graph structure is correct (e.g. all nodes pass the + correct things into each other) as well as validates the individual graph + components. + + Args: + model_configuration: The model configuration (schemas, language, etc.) + + Raises: + GraphSchemaValidationException: If the validation failed. + """ + _validate(model_configuration.train_schema, True, model_configuration.language) + _validate(model_configuration.predict_schema, False, model_configuration.language) + + _validate_prediction_targets( + model_configuration.predict_schema, + core_target=model_configuration.core_target, + nlu_target=model_configuration.nlu_target, + ) + + +def _validate( + schema: GraphSchema, is_train_graph: bool, language: Optional[Text] +) -> None: + _validate_cycle(schema) + + for node_name, node in schema.nodes.items(): + _validate_interface_usage(node) + _validate_supported_languages(language, node) + _validate_required_packages(node) + + run_fn_params, run_fn_return_type = _get_parameter_information( + node.uses, node.fn + ) + _validate_run_fn(node, run_fn_params, run_fn_return_type, is_train_graph) + + create_fn_params, _ = _get_parameter_information( + node.uses, node.constructor_name + ) + _validate_constructor(node, create_fn_params) + + _validate_needs(node, schema, create_fn_params, run_fn_params) + + _validate_required_components(schema) + + +def _validate_prediction_targets( + schema: GraphSchema, core_target: Optional[Text], nlu_target: Text +) -> None: + if not nlu_target: + raise GraphSchemaValidationException( + "Graph schema specifies no target for the 'nlu_target'. It is required " + "for a prediction graph to specify this. Please choose a valid node " + "name for this." + ) + + _validate_target(nlu_target, "NLU", List[Message], schema) + + if core_target: + _validate_target(core_target, "Core", PolicyPrediction, schema) + + +def _validate_target( + target_name: Text, target_type: Text, expected_type: Type, schema: GraphSchema +) -> None: + if target_name not in schema.nodes: + raise GraphSchemaValidationException( + f"Graph schema specifies invalid {target_type} target '{target_name}'. " + f"Please make sure specify a valid node name as target." + ) + + if any(target_name in node.needs.values() for node in schema.nodes.values()): + raise GraphSchemaValidationException( + f"One graph node uses the {target_type} target '{target_name}' as input. " + f"This is not allowed as NLU prediction and Core prediction are run " + f"separately." + ) + + target_node = schema.nodes[target_name] + _, target_return_type = _get_parameter_information(target_node.uses, target_node.fn) + + if not typing_utils.issubtype(target_return_type, expected_type): + raise GraphSchemaValidationException( + f"Your {target_type} model's output component " + f"'{target_node.uses.__name__}' returns an invalid return " + f"type '{target_return_type}'. This is not allowed. The {target_type} " + f"model's last component is expected to return the type '{expected_type}'. " + f"See {DOCS_URL_GRAPH_COMPONENTS} for more information." + ) + + +def _validate_cycle(schema: GraphSchema) -> None: + for target_name in schema.target_names: + parents = schema.nodes[target_name].needs.values() + for parent_name in parents: + _walk_and_check_for_cycles([], parent_name, schema) + + +def _walk_and_check_for_cycles( + visited_so_far: List[Text], node_name: Text, schema: GraphSchema +) -> None: + if node_name in visited_so_far: + raise GraphSchemaValidationException( + f"Node '{node_name}' has itself as dependency. Cycles are not allowed " + f"in the graph. Please make sure that '{node_name}' does not have itself " + f"specified in 'needs' and none of '{node_name}'s dependencies have " + f"'{node_name}' specified in 'needs'." + ) + + if node_name not in schema.nodes: + raise GraphSchemaValidationException( + f"Node '{node_name}' is not part of the graph. Node was expected to be " + f"present in the graph as it is used by another component." + ) + + parents = schema.nodes[node_name].needs.values() + for parent_name in parents: + if not _is_placeholder_input(parent_name): + _walk_and_check_for_cycles( + [*visited_so_far, node_name], parent_name, schema + ) + + +def _is_placeholder_input(name: Text) -> bool: + return name in RESERVED_PLACEHOLDERS + + +def _validate_interface_usage(node: SchemaNode) -> None: + if not issubclass(node.uses, GraphComponent): + raise GraphSchemaValidationException( + f"Your model uses a component with class '{node.uses.__name__}'. " + f"This class does not implement the '{GraphComponent.__name__}' interface " + f"and can hence not be run within Rasa Open Source. Please use a different " + f"component or implement the '{GraphComponent}' interface in class " + f"'{node.uses.__name__}'. " + f"See {DOCS_URL_GRAPH_COMPONENTS} for more information." + ) + + +def _validate_supported_languages(language: Optional[Text], node: SchemaNode) -> None: + supported_languages = node.uses.supported_languages() + not_supported_languages = node.uses.not_supported_languages() + + if supported_languages and not_supported_languages: + raise RasaException( + "Only one of `supported_languages` and " + "`not_supported_languages` can return a value different from `None`." + ) + + if ( + language + and supported_languages is not None + and language not in supported_languages + ): + raise GraphSchemaValidationException( + f"The component '{node.uses.__name__}' does not support the currently " + f"specified language '{language}'." + ) + + if ( + language + and not_supported_languages is not None + and language in not_supported_languages + ): + raise GraphSchemaValidationException( + f"The component '{node.uses.__name__}' does not support the currently " + f"specified language '{language}'." + ) + + +def _validate_required_packages(node: SchemaNode) -> None: + missing_packages = rasa.utils.common.find_unavailable_packages( + node.uses.required_packages() + ) + if missing_packages: + raise GraphSchemaValidationException( + f"Component '{node.uses.__name__}' requires the following packages which " + f"are currently not installed: {', '.join(missing_packages)}." + ) + + +def _get_parameter_information( + uses: Type[GraphComponent], method_name: Text +) -> Tuple[Dict[Text, ParameterInfo], TypeAnnotation]: + fn = _get_fn(uses, method_name) + + type_hints = _get_type_hints(uses, fn) + return_type = type_hints.pop("return", inspect.Parameter.empty) + type_hints.pop("cls", None) + + params = inspect.signature(fn).parameters + + type_info = {} + for param_name, type_annotation in type_hints.items(): + inspect_info = params[param_name] + if inspect_info.kind == inspect.Parameter.VAR_POSITIONAL: + # We always pass things using keywords so we can ignore the any variable + # length positional arguments + continue + + type_info[param_name] = ParameterInfo( + type_annotation=type_annotation, + is_variable_length_keyword_arg=inspect_info.kind + == inspect.Parameter.VAR_KEYWORD, + has_default=inspect_info.default != inspect.Parameter.empty, + ) + + return type_info, return_type + + +def _get_type_hints( + uses: Type[GraphComponent], fn: Callable +) -> Dict[Text, TypeAnnotation]: + try: + return typing.get_type_hints(fn) + except NameError as e: + logging.debug( + f"Failed to retrieve type annotations for component " + f"'{uses.__name__}' due to error:\n{e}" + ) + raise GraphSchemaValidationException( + f"Your model uses a component '{uses.__name__}' which has " + f"type annotations in its method '{fn.__name__}' which failed to be " + f"retrieved. Please make sure remove any forward " + f"reference by removing the quotes around the type " + f"(e.g. 'def foo() -> \"int\"' becomes 'def foo() -> int'. and make sure " + f"all type annotations can be resolved during runtime. Note that you might " + f"need to do a 'from __future__ import annotations' to avoid forward " + f"references." + f"See {DOCS_URL_GRAPH_COMPONENTS} for more information." + ) + + +def _get_fn(uses: Type[GraphComponent], method_name: Text) -> Callable: + fn = getattr(uses, method_name, None) + if fn is None: + raise GraphSchemaValidationException( + f"Your model uses a graph component '{uses.__name__}' which does not " + f"have the required " + f"method '{method_name}'. Please make sure you're either using " + f"the right component or that your component is registered with the " + f"correct component type." + f"See {DOCS_URL_GRAPH_COMPONENTS} for more information." + ) + return fn + + +def _validate_run_fn( + node: SchemaNode, + run_fn_params: Dict[Text, ParameterInfo], + run_fn_return_type: TypeAnnotation, + is_train_graph: bool, +) -> None: + _validate_types_of_reserved_keywords(run_fn_params, node, node.fn) + _validate_run_fn_return_type(node, run_fn_return_type, is_train_graph) + + for param_name in _required_args(run_fn_params): + if param_name not in node.needs: + raise GraphSchemaValidationException( + f"Your model uses a component '{node.uses.__name__}' which " + f"needs the param '{param_name}' to be provided to its method " + f"'{node.fn}'. Please make sure that you registered " + f"your component correctly and and that your model configuration is " + f"valid." + f"See {DOCS_URL_GRAPH_COMPONENTS} for more information." + ) + + +def _required_args(fn_params: Dict[Text, ParameterInfo]) -> Set[Text]: + keywords = set(KEYWORDS_EXPECTED_TYPES) + return { + param_name + for param_name, param in fn_params.items() + if not param.has_default + and not param.is_variable_length_keyword_arg + and param_name not in keywords + } + + +def _validate_run_fn_return_type( + node: SchemaNode, return_type: Type, is_training: bool +) -> None: + if return_type == inspect.Parameter.empty: + raise GraphSchemaValidationException( + f"Your model uses a component '{node.uses.__name__}' whose " + f"method '{node.fn}' does not have a type annotation for " + f"its return value. Type annotations are required for all " + f"components to validate your model's structure." + f"See {DOCS_URL_GRAPH_COMPONENTS} for more information." + ) + + # TODO: Handle forward references here + if typing_utils.issubtype(return_type, list): + return_type = typing_utils.get_args(return_type)[0] + + if is_training and not isinstance(return_type, Fingerprintable): + raise GraphSchemaValidationException( + f"Your model uses a component '{node.uses.__name__}' whose method " + f"'{node.fn}' does not return a fingerprintable " + f"output. This is required for proper caching between model trainings. " + f"Please make sure you're using a return type which implements the " + f"'{Fingerprintable.__name__}' protocol." + ) + + +def _validate_types_of_reserved_keywords( + params: Dict[Text, ParameterInfo], node: SchemaNode, fn_name: Text +) -> None: + for param_name, param in params.items(): + if param_name in KEYWORDS_EXPECTED_TYPES: + if not typing_utils.issubtype( + param.type_annotation, KEYWORDS_EXPECTED_TYPES[param_name] + ): + raise GraphSchemaValidationException( + f"Your model uses a component '{node.uses.__name__}' which has an " + f"incompatible type '{param.type_annotation}' for " + f"the '{param_name}' parameter in its '{fn_name}' method. " + f"The expected type is '{KEYWORDS_EXPECTED_TYPES[param_name]}'." + f"See {DOCS_URL_GRAPH_COMPONENTS} for more information." + ) + + +def _validate_constructor( + node: SchemaNode, create_fn_params: Dict[Text, ParameterInfo] +) -> None: + _validate_types_of_reserved_keywords(create_fn_params, node, node.constructor_name) + + required_args = _required_args(create_fn_params) + + if required_args and node.eager: + raise GraphSchemaValidationException( + f"Your model uses a component '{node.uses.__name__}' which has a " + f"method '{node.constructor_name}' which has required parameters " + f"('{', '.join(required_args)}'). " + f"Extra parameters can only be supplied to the constructor method which is " + f"used during training." + f"See {DOCS_URL_GRAPH_COMPONENTS} for more information." + ) + + for param_name in _required_args(create_fn_params): + if not node.eager and param_name not in node.needs: + raise GraphSchemaValidationException( + f"Your model uses a component '{node.uses.__name__}' which " + f"needs the param '{param_name}' to be provided to its method " + f"'{node.constructor_name}'. Please make sure that you registered " + f"your component correctly and and that your model configuration is " + f"valid." + f"See {DOCS_URL_GRAPH_COMPONENTS} for more information." + ) + + +def _validate_needs( + node: SchemaNode, + graph: GraphSchema, + create_fn_params: Dict[Text, ParameterInfo], + run_fn_params: Dict[Text, ParameterInfo], +) -> None: + available_args, has_kwargs = _get_available_args( + node, create_fn_params, run_fn_params + ) + + for param_name, parent_name in node.needs.items(): + if not has_kwargs and param_name not in available_args: + raise GraphSchemaValidationException( + f"Your model uses a component '{node.uses.__name__}' which is " + f"supposed to retrieve a value for the " + f"param '{param_name}' although " + f"its method '{node.fn}' does not accept a parameter with this " + f"name. Please make sure that you registered " + f"your component correctly and and that your model configuration is " + f"valid." + f"See {DOCS_URL_GRAPH_COMPONENTS} for more information." + ) + + if not _is_placeholder_input(parent_name) and parent_name not in graph.nodes: + raise GraphSchemaValidationException( + f"Missing graph component '{parent_name}'." + f"Your model uses a component '{node.uses.__name__}' which expects " + f"input from the missing component. The component is missing from " + f"your model configuration. Please make sure that you registered " + f"your component correctly and and that your model configuration is " + f"valid." + f"See {DOCS_URL_GRAPH_COMPONENTS} for more information." + ) + + required_type = available_args.get(param_name) + + if not has_kwargs and required_type is not None: + parent = None + if _is_placeholder_input(parent_name): + parent_return_type: TypeAnnotation = RESERVED_PLACEHOLDERS[parent_name] + else: + parent = graph.nodes[parent_name] + _, parent_return_type = _get_parameter_information( + parent.uses, parent.fn + ) + + _validate_parent_return_type( + node, parent, parent_return_type, required_type.type_annotation + ) + + +def _get_available_args( + node: SchemaNode, + create_fn_params: Dict[Text, ParameterInfo], + run_fn_params: Dict[Text, ParameterInfo], +) -> Tuple[Dict[Text, ParameterInfo], bool]: + has_kwargs = any( + param.is_variable_length_keyword_arg for param in run_fn_params.values() + ) + available_args = run_fn_params.copy() + if node.eager is False: + has_kwargs = has_kwargs or any( + param.is_variable_length_keyword_arg for param in create_fn_params.values() + ) + available_args.update(create_fn_params) + return available_args, has_kwargs + + +def _validate_parent_return_type( + node: SchemaNode, + parent_node: Optional[SchemaNode], + parent_return_type: TypeAnnotation, + required_type: TypeAnnotation, +) -> None: + + if not typing_utils.issubtype(parent_return_type, required_type): + parent_node_text = "" + if parent_node: + parent_node_text = f" by the component '{parent_node.uses.__name__}'" + + raise GraphSchemaValidationException( + f"Your component '{node.uses.__name__}' expects an input of type " + f"'{required_type}' but it receives an input of type '{parent_return_type}'" + f"{parent_node_text}. " + f"Please make sure that you registered " + f"your component correctly and and that your model configuration is " + f"valid." + f"See {DOCS_URL_GRAPH_COMPONENTS} for more information." + ) + + +def _validate_required_components(schema: GraphSchema) -> None: + unmet_requirements: Dict[Type, Set[Text]] = dict() + for target_name in schema.target_names: + unmet_requirements_for_target, _ = _recursively_check_required_components( + node_name=target_name, schema=schema + ) + for component_type, node_names in unmet_requirements_for_target.items(): + unmet_requirements.setdefault(component_type, set()).update(node_names) + if unmet_requirements: + errors = "\n".join( + [ + f"The following components require a {component_type.__name__}: " + f"{', '.join(sorted(required_by))}. " + for component_type, required_by in unmet_requirements.items() + ] + ) + num_nodes = len( + set( + node_name + for required_by in unmet_requirements.values() + for node_name in required_by + ) + ) + raise GraphSchemaValidationException( + f"{num_nodes} components are missing required components which have to " + f"run before themselves:\n" + f"{errors}" + f"Please add the required components to your model configuration." + ) + + +def _recursively_check_required_components( + node_name: Text, schema: GraphSchema +) -> Tuple[Dict[Type, Set[Text]], Set[Type]]: + """Collects unmet requirements and types used in the subtree rooted at `node_name`. + + Args: + schema: the graph schema + node_name: the name of the root of the subtree + Returns: + unmet requirements, i.e. a mapping from component types to names of nodes that + are contained in the subtree rooted at `schema_node` that require that component + type but can't find it in their respective subtrees and + a set containing all component types of nodes that are ancestors of the + `schema_node` (or of the`schema_node` itself) + """ + schema_node = schema.nodes[node_name] + + unmet_requirements: Dict[Type, Set[Text]] = dict() + component_types = set() + + # collect all component types used by ancestors and their unmet requirements + for parent_node_name in schema_node.needs.values(): + if _is_placeholder_input(parent_node_name): + continue + ( + unmet_requirements_of_ancestors, + ancestor_types, + ) = _recursively_check_required_components( + node_name=parent_node_name, schema=schema + ) + for _type, nodes in unmet_requirements_of_ancestors.items(): + unmet_requirements.setdefault(_type, set()).update(nodes) + component_types.update(ancestor_types) + + # check which requirements of the `schema_node` are not fulfilled by + # comparing its requirements with the types found so far among the ancestor nodes + unmet_requirements_of_current_node = set( + required + for required in schema_node.uses.required_components() + if not any( + issubclass(used_subtype, required) for used_subtype in component_types + ) + ) + + # add the unmet requirements and the type of the `schema_node` + for component_type in unmet_requirements_of_current_node: + unmet_requirements.setdefault(component_type, set()).add(node_name) + component_types.add(schema_node.uses) + + return unmet_requirements, component_types diff --git a/rasa/exceptions.py b/rasa/exceptions.py new file mode 100644 index 0000000..bc1c48d --- /dev/null +++ b/rasa/exceptions.py @@ -0,0 +1,69 @@ +from typing import Text +from packaging import version +from dataclasses import dataclass + +from rasa.shared.exceptions import RasaException +from rasa.constants import MINIMUM_COMPATIBLE_VERSION + + +@dataclass +class UnsupportedModelVersionError(RasaException): + """Raised when a model is too old to be loaded. + + Args: + model_version: the used model version that is not supported and triggered + this exception + """ + + model_version: Text + + def __str__(self) -> Text: + minimum_version = version.parse(MINIMUM_COMPATIBLE_VERSION) + return ( + f"The model version is trained using Rasa Open Source {self.model_version} " + f"and is not compatible with your current installation " + f"which supports models build with Rasa Open Source {minimum_version} " + f"or higher. " + f"This means that you either need to retrain your model " + f"or revert back to the Rasa version that trained the model " + f"to ensure that the versions match up again." + ) + + +class ModelNotFound(RasaException): + """Raised when a model is not found in the path provided by the user.""" + + +class NoEventsToMigrateError(RasaException): + """Raised when no events to be migrated are found.""" + + +class NoConversationsInTrackerStoreError(RasaException): + """Raised when a tracker store does not contain any conversations.""" + + +class NoEventsInTimeRangeError(RasaException): + """Raised when a tracker store does not contain events within a given time range.""" + + +class MissingDependencyException(RasaException): + """Raised if a python package dependency is needed, but not installed.""" + + +@dataclass +class PublishingError(RasaException): + """Raised when publishing of an event fails. + + Attributes: + timestamp -- Unix timestamp of the event during which publishing fails. + """ + + timestamp: float + + def __str__(self) -> Text: + """Returns string representation of exception.""" + return str(self.timestamp) + + +class ActionLimitReached(RasaException): + """Raised when predicted action limit is reached.""" diff --git a/rasa/graph_components/__init__.py b/rasa/graph_components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/graph_components/converters/__init__.py b/rasa/graph_components/converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/graph_components/converters/nlu_message_converter.py b/rasa/graph_components/converters/nlu_message_converter.py new file mode 100644 index 0000000..b5f1bd3 --- /dev/null +++ b/rasa/graph_components/converters/nlu_message_converter.py @@ -0,0 +1,48 @@ +from __future__ import annotations +from typing import Dict, Text, Any, List + +from rasa.core.channels.channel import UserMessage + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.nlu.constants import TEXT, TEXT_TOKENS +from rasa.shared.nlu.training_data.message import Message + + +class NLUMessageConverter(GraphComponent): + """Converts the user message into a NLU Message object.""" + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> NLUMessageConverter: + """Creates component (see parent class for full docstring).""" + return cls() + + def convert_user_message(self, messages: List[UserMessage]) -> List[Message]: + """Converts user message into Message object. + + Args: + messages: The user messages which should be converted to be processed by + the NLU components. + + Returns: + List containing only one instance of Message. + Else empty list if user message is None. + """ + return [ + Message( + data={ + TEXT: message.text, + "message_id": message.message_id, + "metadata": message.metadata, + }, + output_properties={TEXT_TOKENS}, + ) + for message in messages + ] diff --git a/rasa/graph_components/providers/__init__.py b/rasa/graph_components/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/graph_components/providers/domain_for_core_training_provider.py b/rasa/graph_components/providers/domain_for_core_training_provider.py new file mode 100644 index 0000000..e3dc178 --- /dev/null +++ b/rasa/graph_components/providers/domain_for_core_training_provider.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import copy +from typing import Dict, Text, Any + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.constants import REQUIRED_SLOTS_KEY +from rasa.shared.core.domain import KEY_RESPONSES, Domain, SESSION_CONFIG_KEY, KEY_FORMS + + +class DomainForCoreTrainingProvider(GraphComponent): + """Provides domain without information that is irrelevant for core training. + + The information that we retain includes: + - intents and their "used" and "ignored" entities because intents influence the + next action prediction directly and the latter flags determine whether the + listed entities influence the next action prediction + - entities, their roles and groups, and their `influence_conversation` flag because + all of those items are used by policies + - slots names along with their types, since this type information determines the + pre-featurization of slot values + - response keys (i.e. `utter_*) because those keys may appear in stories + - form names because those appear in stories + - how slots are filled (i.e. 'mappings' key under 'slots') because a domain instance + needs to be created by core during training time to parse the training data + properly + + This information that we drop (or replace with default values) includes: + - the 'session_config' which determines details of a session e.g. whether data is + transferred from one session to the next (this is replaced with defaults as it + cannot just be removed) + - the actual text of a 'response' because those are only used by response selectors + - the actual configuration of 'forms' because those are not actually executed + by core components + + References: + - `rasa.core.featurizer.tracker_featurizer.py` (used by all policies) + - `rasa.core.featurizer.single_state_featurizer.py` (used by ML policies) + - `rasa.shared.core.domain.get_active_state` (used by above references) + - `rasa.shared.core.slots.as_features` (used by above references) + - `rasa.shared.core.training_data.structures.StoryStep.explicit_events` + (i.e. slots needed for core training) + """ + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> DomainForCoreTrainingProvider: + """Creates component (see parent class for full docstring).""" + return cls() + + def provide(self, domain: Domain) -> Domain: + """Recreates the given domain but drops information that is irrelevant for core. + + Args: + domain: A domain. + + Returns: + A similar domain without information that is irrelevant for core training. + """ + return self.create_pruned_version(domain) + + @staticmethod + def create_pruned_version(domain: Domain) -> Domain: + """Recreates the given domain but drops information that is irrelevant for core. + + Args: + domain: A domain. + + Returns: + A similar domain without information that is irrelevant for core training. + """ + serialized_domain = copy.deepcopy(domain.as_dict()) + + serialized_domain.pop("config", None) # `store_entities_as_slots` + serialized_domain.pop(SESSION_CONFIG_KEY, None) + for response_name in serialized_domain.get(KEY_RESPONSES, []): + serialized_domain[KEY_RESPONSES][response_name] = [] + for form_name in serialized_domain.get(KEY_FORMS, []): + serialized_domain[KEY_FORMS][form_name] = {REQUIRED_SLOTS_KEY: []} + return Domain.from_dict(serialized_domain) diff --git a/rasa/graph_components/providers/domain_provider.py b/rasa/graph_components/providers/domain_provider.py new file mode 100644 index 0000000..e959c7e --- /dev/null +++ b/rasa/graph_components/providers/domain_provider.py @@ -0,0 +1,71 @@ +from __future__ import annotations +from typing import Dict, Text, Any, Optional + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.core.domain import Domain +from rasa.shared.exceptions import InvalidConfigException +from rasa.shared.importers.importer import TrainingDataImporter + + +class DomainProvider(GraphComponent): + """Provides domain during training and inference time.""" + + def __init__( + self, + model_storage: ModelStorage, + resource: Resource, + domain: Optional[Domain] = None, + ) -> None: + """Creates domain provider.""" + self._model_storage = model_storage + self._resource = resource + self._domain = domain + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> DomainProvider: + """Creates component (see parent class for full docstring).""" + return cls(model_storage, resource) + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> DomainProvider: + """Creates provider using a persisted version of itself.""" + with model_storage.read_from(resource) as resource_directory: + domain = Domain.from_path(resource_directory) + return cls(model_storage, resource, domain) + + def _persist(self, domain: Domain) -> None: + """Persists domain to model storage.""" + with self._model_storage.write_to(self._resource) as resource_directory: + domain.persist(resource_directory / "domain.yml") + + def provide_train(self, importer: TrainingDataImporter) -> Domain: + """Provides domain from training data during training.""" + domain = importer.get_domain() + self._persist(domain) + return domain + + def provide_inference(self) -> Domain: + """Provides the domain during inference.""" + if self._domain is None: + # This can't really happen but if it happens then we fail early + raise InvalidConfigException( + "No domain was found. This is required for " + "making model predictions. Please make sure to " + "provide a valid domain during training." + ) + return self._domain diff --git a/rasa/graph_components/providers/forms_provider.py b/rasa/graph_components/providers/forms_provider.py new file mode 100644 index 0000000..65e3261 --- /dev/null +++ b/rasa/graph_components/providers/forms_provider.py @@ -0,0 +1,44 @@ +from __future__ import annotations +import dataclasses + +from typing import Dict, Text, Any + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.core.domain import Domain +import rasa.shared.utils.io + + +@dataclasses.dataclass +class Forms: + """Holds the forms of the domain.""" + + data: Dict[Text, Any] + + def fingerprint(self) -> Text: + """Returns a fingerprint of the responses.""" + return rasa.shared.utils.io.get_dictionary_fingerprint(self.data) + + def get(self, key: Text, default: Any) -> Any: + """Returns the value for the given key.""" + return self.data.get(key, default) + + +class FormsProvider(GraphComponent): + """Provides forms during training and inference time.""" + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> FormsProvider: + """Creates component (see parent class for full docstring).""" + return cls() + + def provide(self, domain: Domain) -> Forms: + """Returns the forms from the given domain.""" + return Forms(data=domain.forms) diff --git a/rasa/graph_components/providers/nlu_training_data_provider.py b/rasa/graph_components/providers/nlu_training_data_provider.py new file mode 100644 index 0000000..55aa889 --- /dev/null +++ b/rasa/graph_components/providers/nlu_training_data_provider.py @@ -0,0 +1,56 @@ +from __future__ import annotations +from typing import Dict, Text, Any +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.nlu.training_data.training_data import ( + TrainingData, + DEFAULT_TRAINING_DATA_OUTPUT_PATH, +) + + +class NLUTrainingDataProvider(GraphComponent): + """Provides NLU training data during training.""" + + def __init__( + self, config: Dict[Text, Any], model_storage: ModelStorage, resource: Resource + ) -> None: + """Creates a new NLU training data provider.""" + self._config = config + self._model_storage = model_storage + self._resource = resource + + @classmethod + def get_default_config(cls) -> Dict[Text, Any]: + """Returns the default config for NLU training data provider.""" + return {"persist": False, "language": None} + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> NLUTrainingDataProvider: + """Creates a new NLU training data provider.""" + return cls(config, model_storage, resource) + + def _persist(self, training_data: TrainingData) -> None: + """Persists NLU training data to model storage.""" + with self._model_storage.write_to(self._resource) as resource_directory: + training_data.persist( + dir_name=str(resource_directory), + filename=DEFAULT_TRAINING_DATA_OUTPUT_PATH, + ) + + def provide(self, importer: TrainingDataImporter) -> TrainingData: + """Provides nlu training data during training.""" + if "language" in self._config: + training_data = importer.get_nlu_data(language=self._config["language"]) + else: + training_data = importer.get_nlu_data() + if self._config["persist"]: + self._persist(training_data) + return training_data diff --git a/rasa/graph_components/providers/responses_provider.py b/rasa/graph_components/providers/responses_provider.py new file mode 100644 index 0000000..873db1b --- /dev/null +++ b/rasa/graph_components/providers/responses_provider.py @@ -0,0 +1,44 @@ +from __future__ import annotations +import dataclasses + +from typing import Dict, List, Text, Any + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.core.domain import Domain +import rasa.shared.utils.io + + +@dataclasses.dataclass +class Responses: + """Holds the responses of the domain.""" + + data: Dict[Text, List[Dict[Text, Any]]] + + def fingerprint(self) -> Text: + """Returns a fingerprint of the responses.""" + return rasa.shared.utils.io.get_dictionary_fingerprint(self.data) + + def get(self, key: Text, default: Any) -> Any: + """Returns the value for the given key.""" + return self.data.get(key, default) + + +class ResponsesProvider(GraphComponent): + """Provides responses during training and inference time.""" + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> ResponsesProvider: + """Creates component (see parent class for full docstring).""" + return cls() + + def provide(self, domain: Domain) -> Responses: + """Returns the responses from the given domain.""" + return Responses(data=domain.responses) diff --git a/rasa/graph_components/providers/rule_only_provider.py b/rasa/graph_components/providers/rule_only_provider.py new file mode 100644 index 0000000..4c45c2f --- /dev/null +++ b/rasa/graph_components/providers/rule_only_provider.py @@ -0,0 +1,49 @@ +from __future__ import annotations +import dataclasses +import logging +from typing import Dict, Text, Any + +import rasa.shared.utils.io +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass +class RuleOnlyDataProvider(GraphComponent): + """Provides slots and loops that are only used in rules to other policies. + + Policies can use this to exclude features which are only used by rules from the + featurization. + """ + + rule_only_data: Dict[Text, Any] + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> RuleOnlyDataProvider: + """Creates component (see parent class for docstring).""" + rule_only_data = {} + try: + with model_storage.read_from(resource) as directory: + rule_only_data = rasa.shared.utils.io.read_json_file( + directory / "rule_only_data.json" + ) + except ValueError: + logger.debug( + "Failed to load rule-only data from a trained 'RulePolicy'. " + "Providing empty rule-only data instead." + ) + + return cls(rule_only_data) + + def provide(self) -> Dict[Text, Any]: + """Provides data to other graph component.""" + return self.rule_only_data diff --git a/rasa/graph_components/providers/story_graph_provider.py b/rasa/graph_components/providers/story_graph_provider.py new file mode 100644 index 0000000..bb46351 --- /dev/null +++ b/rasa/graph_components/providers/story_graph_provider.py @@ -0,0 +1,43 @@ +from __future__ import annotations +from typing import Dict, Text, Any + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.core.training_data.structures import StoryGraph +from rasa.shared.importers.importer import TrainingDataImporter + + +class StoryGraphProvider(GraphComponent): + """Provides the training data from stories.""" + + def __init__(self, config: Dict[Text, Any]) -> None: + """Creates provider from config.""" + self._config = config + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns default configuration (see parent class for full docstring).""" + return {"exclusion_percentage": None} + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> StoryGraphProvider: + """Creates component (see parent class for full docstring).""" + return cls(config) + + def provide(self, importer: TrainingDataImporter) -> StoryGraph: + """Provides the story graph from the training data. + + Args: + importer: instance of TrainingDataImporter. + + Returns: + The story graph containing stories and rules used for training. + """ + return importer.get_stories(**self._config) diff --git a/rasa/graph_components/providers/training_tracker_provider.py b/rasa/graph_components/providers/training_tracker_provider.py new file mode 100644 index 0000000..c30b5dc --- /dev/null +++ b/rasa/graph_components/providers/training_tracker_provider.py @@ -0,0 +1,55 @@ +from __future__ import annotations +from typing import Dict, Text, Any, List + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.core.domain import Domain +from rasa.shared.core.generator import TrackerWithCachedStates, TrainingDataGenerator +from rasa.shared.core.training_data.structures import StoryGraph + + +class TrainingTrackerProvider(GraphComponent): + """Provides training trackers to policies based on training stories.""" + + def __init__(self, config: Dict[Text, Any]) -> None: + """Creates provider from config.""" + self._config = config + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns default configuration (see parent class for full docstring).""" + return { + "remove_duplicates": True, + "unique_last_num_states": None, + "augmentation_factor": 50, + "tracker_limit": None, + "use_story_concatenation": True, + "debug_plots": False, + } + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> TrainingTrackerProvider: + """Creates component (see parent class for full docstring).""" + return cls(config) + + def provide( + self, story_graph: StoryGraph, domain: Domain + ) -> List[TrackerWithCachedStates]: + """Generates the training trackers from the training data. + + Args: + story_graph: The story graph containing the test stories and rules. + domain: The domain of the model. + + Returns: + The trackers which can be used to train dialogue policies. + """ + generator = TrainingDataGenerator(story_graph, domain, **self._config) + return generator.generate() diff --git a/rasa/graph_components/validators/__init__.py b/rasa/graph_components/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/graph_components/validators/default_recipe_validator.py b/rasa/graph_components/validators/default_recipe_validator.py new file mode 100644 index 0000000..1d2aab1 --- /dev/null +++ b/rasa/graph_components/validators/default_recipe_validator.py @@ -0,0 +1,509 @@ +from __future__ import annotations +from collections import defaultdict +from typing import Iterable, List, Dict, Text, Any, Set, Type, cast + +from rasa.core.featurizers.precomputation import CoreFeaturizationInputConverter +from rasa.engine.graph import ExecutionContext, GraphComponent, GraphSchema, SchemaNode +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.storage.resource import Resource +from rasa.nlu.featurizers.featurizer import Featurizer +from rasa.nlu.extractors.mitie_entity_extractor import MitieEntityExtractor +from rasa.nlu.extractors.regex_entity_extractor import RegexEntityExtractor +from rasa.nlu.extractors.crf_entity_extractor import ( + CRFEntityExtractor, + CRFEntityExtractorOptions, +) +from rasa.nlu.extractors.entity_synonyms import EntitySynonymMapper +from rasa.nlu.featurizers.sparse_featurizer.regex_featurizer import RegexFeaturizer +from rasa.nlu.classifiers.diet_classifier import DIETClassifier +from rasa.nlu.selectors.response_selector import ResponseSelector +from rasa.nlu.tokenizers.tokenizer import Tokenizer +from rasa.core.policies.rule_policy import RulePolicy +from rasa.core.policies.policy import Policy, SupportedData +from rasa.core.policies.memoization import MemoizationPolicy +from rasa.core.policies.ted_policy import TEDPolicy +from rasa.core.constants import POLICY_PRIORITY +from rasa.shared.core.training_data.structures import RuleStep, StoryGraph +from rasa.shared.constants import ( + DEFAULT_CONFIG_PATH, + DOCS_URL_COMPONENTS, + DOCS_URL_DEFAULT_ACTIONS, + DOCS_URL_POLICIES, + DOCS_URL_RULES, +) +from rasa.shared.core.domain import Domain, InvalidDomain +from rasa.shared.core.constants import ( + ACTION_BACK_NAME, + ACTION_RESTART_NAME, + USER_INTENT_BACK, + USER_INTENT_RESTART, +) +from rasa.shared.exceptions import InvalidConfigException +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.nlu.training_data.training_data import TrainingData +import rasa.shared.utils.io + + +# TODO: Can we replace this with the registered types from the regitry? +TRAINABLE_EXTRACTORS = [MitieEntityExtractor, CRFEntityExtractor, DIETClassifier] +# TODO: replace these once the Recipe is merged (used in tests) +POLICY_CLASSSES = {TEDPolicy, MemoizationPolicy, RulePolicy} + + +def _types_to_str(types: Iterable[Type]) -> Text: + """Returns a text containing the names of all given types. + + Args: + types: some types + Returns: + text containing all type names + """ + return ", ".join([type.__name__ for type in types]) + + +class DefaultV1RecipeValidator(GraphComponent): + """Validates a "DefaultV1" configuration against the training data and domain.""" + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> DefaultV1RecipeValidator: + """Creates a new `ConfigValidator` (see parent class for full docstring).""" + return cls(execution_context.graph_schema) + + def __init__(self, graph_schema: GraphSchema) -> None: + """Instantiates a new `ConfigValidator`. + + Args: + graph_schema: a graph schema + """ + self._graph_schema = graph_schema + self._component_types = set(node.uses for node in graph_schema.nodes.values()) + self._policy_schema_nodes: List[SchemaNode] = [ + node + for node in self._graph_schema.nodes.values() + if issubclass(node.uses, Policy) + ] + + def validate(self, importer: TrainingDataImporter) -> TrainingDataImporter: + """Validates the current graph schema against the training data and domain. + + Args: + importer: the training data importer which can also load the domain + Raises: + `InvalidConfigException` or `InvalidDomain` in case there is some mismatch + """ + nlu_data = importer.get_nlu_data() + self._validate_nlu(nlu_data) + + story_graph = importer.get_stories() + domain = importer.get_domain() + self._validate_core(story_graph, domain) + return importer + + def _validate_nlu(self, training_data: TrainingData) -> None: + """Validates whether the configuration matches the training data. + + Args: + training_data: The training data for the NLU components. + """ + training_data.validate() + + self._raise_if_more_than_one_tokenizer() + self._raise_if_featurizers_are_not_compatible() + self._warn_of_competing_extractors() + self._warn_of_competition_with_regex_extractor(training_data=training_data) + self._warn_if_some_training_data_is_unused(training_data=training_data) + + def _warn_if_some_training_data_is_unused( + self, training_data: TrainingData + ) -> None: + """Validates that all training data will be consumed by some component. + + For example, if you specify response examples in your training data, but there + is no `ResponseSelector` component in your configuration, then this method + issues a warning. + + Args: + training_data: The training data for the NLU components. + """ + if ( + training_data.response_examples + and ResponseSelector not in self._component_types + ): + rasa.shared.utils.io.raise_warning( + f"You have defined training data with examples for training a response " + f"selector, but your NLU configuration does not include a response " + f"selector component. " + f"To train a model on your response selector data, add a " + f"'{ResponseSelector.__name__}' to your configuration.", + docs=DOCS_URL_COMPONENTS, + ) + + if training_data.entity_examples and self._component_types.isdisjoint( + TRAINABLE_EXTRACTORS + ): + rasa.shared.utils.io.raise_warning( + f"You have defined training data consisting of entity examples, but " + f"your NLU configuration does not include an entity extractor " + f"trained on your training data. " + f"To extract non-pretrained entities, add one of " + f"{_types_to_str(TRAINABLE_EXTRACTORS)} to your configuration.", + docs=DOCS_URL_COMPONENTS, + ) + + if training_data.entity_examples and self._component_types.isdisjoint( + {DIETClassifier, CRFEntityExtractor} + ): + if training_data.entity_roles_groups_used(): + rasa.shared.utils.io.raise_warning( + f"You have defined training data with entities that " + f"have roles/groups, but your NLU configuration does not " + f"include a '{DIETClassifier.__name__}' " + f"or a '{CRFEntityExtractor.__name__}'. " + f"To train entities that have roles/groups, " + f"add either '{DIETClassifier.__name__}' " + f"or '{CRFEntityExtractor.__name__}' to your " + f"configuration.", + docs=DOCS_URL_COMPONENTS, + ) + + if training_data.regex_features and self._component_types.isdisjoint( + [RegexFeaturizer, RegexEntityExtractor] + ): + rasa.shared.utils.io.raise_warning( + f"You have defined training data with regexes, but " + f"your NLU configuration does not include a 'RegexFeaturizer' " + f" or a " + f"'RegexEntityExtractor'. To use regexes, include either a " + f"'{RegexFeaturizer.__name__}' or a " + f"'{RegexEntityExtractor.__name__}' " + f"in your configuration.", + docs=DOCS_URL_COMPONENTS, + ) + + if training_data.lookup_tables and self._component_types.isdisjoint( + [RegexFeaturizer, RegexEntityExtractor] + ): + rasa.shared.utils.io.raise_warning( + f"You have defined training data consisting of lookup tables, but " + f"your NLU configuration does not include a featurizer " + f"or an entity extractor using the lookup table." + f"To use the lookup tables, include either a " + f"'{RegexFeaturizer.__name__}' " + f"or a '{RegexEntityExtractor.__name__}' " + f"in your configuration.", + docs=DOCS_URL_COMPONENTS, + ) + + if training_data.lookup_tables: + + if self._component_types.isdisjoint([CRFEntityExtractor, DIETClassifier]): + rasa.shared.utils.io.raise_warning( + f"You have defined training data consisting of lookup tables, but " + f"your NLU configuration does not include any components " + f"that uses the features created from the lookup table. " + f"To make use of the features that are created with the " + f"help of the lookup tables, " + f"add a '{DIETClassifier.__name__}' or a " + f"'{CRFEntityExtractor.__name__}' " + f"with the 'pattern' feature " + f"to your configuration.", + docs=DOCS_URL_COMPONENTS, + ) + + elif CRFEntityExtractor in self._component_types: + + crf_schema_nodes = [ + schema_node + for schema_node in self._graph_schema.nodes.values() + if schema_node.uses == CRFEntityExtractor + ] + has_pattern_feature = any( + CRFEntityExtractorOptions.PATTERN in feature_list + for crf in crf_schema_nodes + for feature_list in crf.config.get("features", []) + ) + + if not has_pattern_feature: + rasa.shared.utils.io.raise_warning( + f"You have defined training data consisting of " + f"lookup tables, but your NLU configuration's " + f"'{CRFEntityExtractor.__name__}' " + f"does not include the " + f"'pattern' feature. To featurize lookup tables, " + f"add the 'pattern' feature to the " + f"'{CRFEntityExtractor.__name__}' " + "in your configuration.", + docs=DOCS_URL_COMPONENTS, + ) + + if ( + training_data.entity_synonyms + and EntitySynonymMapper not in self._component_types + ): + rasa.shared.utils.io.raise_warning( + f"You have defined synonyms in your training data, but " + f"your NLU configuration does not include an " + f"'{EntitySynonymMapper.__name__}'. " + f"To map synonyms, add an " + f"'{EntitySynonymMapper.__name__}' to your " + f"configuration.", + docs=DOCS_URL_COMPONENTS, + ) + + def _raise_if_more_than_one_tokenizer(self) -> None: + """Validates that only one tokenizer is present in the configuration. + + Note that the existence of a tokenizer and its position in the graph schema + will be validated via the validation of required components during + schema validation. + + Raises: + `InvalidConfigException` in case there is more than one tokenizer + """ + types_of_tokenizer_schema_nodes = [ + schema_node.uses + for schema_node in self._graph_schema.nodes.values() + if issubclass(schema_node.uses, Tokenizer) and schema_node.fn != "train" + ] + + is_end_to_end = any( + issubclass(schema_node.uses, CoreFeaturizationInputConverter) + for schema_node in self._graph_schema.nodes.values() + ) + + allowed_number_of_tokenizers = 2 if is_end_to_end else 1 + if len(types_of_tokenizer_schema_nodes) > allowed_number_of_tokenizers: + raise InvalidConfigException( + f"The configuration configuration contains more than one tokenizer, " + f"which is not possible at this time. You can only use one tokenizer. " + f"The configuration contains the following tokenizers: " + f"{_types_to_str(types_of_tokenizer_schema_nodes)}. " + ) + + def _warn_of_competing_extractors(self) -> None: + """Warns the user when using competing extractors. + + Competing extractors are e.g. `CRFEntityExtractor` and `DIETClassifier`. + Both of these look for the same entities based on the same training data + leading to ambiguity in the results. + """ + extractors_in_configuration: Set[ + Type[GraphComponent] + ] = self._component_types.intersection(TRAINABLE_EXTRACTORS) + if len(extractors_in_configuration) > 1: + rasa.shared.utils.io.raise_warning( + f"You have defined multiple entity extractors that do the same job " + f"in your configuration: " + f"{_types_to_str(extractors_in_configuration)}. " + f"This can lead to the same entity getting " + f"extracted multiple times. Please read the documentation section " + f"on entity extractors to make sure you understand the implications.", + docs=f"{DOCS_URL_COMPONENTS}#entity-extractors", + ) + + def _warn_of_competition_with_regex_extractor( + self, training_data: TrainingData + ) -> None: + """Warns when regex entity extractor is competing with a general one. + + This might be the case when the following conditions are all met: + * You are using a general entity extractor and the `RegexEntityExtractor` + * AND you have regex patterns for entity type A + * AND you have annotated text examples for entity type A + + Args: + training_data: The training data for the NLU components. + """ + present_general_extractors = self._component_types.intersection( + TRAINABLE_EXTRACTORS + ) + has_general_extractors = len(present_general_extractors) > 0 + has_regex_extractor = RegexEntityExtractor in self._component_types + + regex_entity_types = {rf["name"] for rf in training_data.regex_features} + overlap_between_types = training_data.entities.intersection(regex_entity_types) + has_overlap = len(overlap_between_types) > 0 + + if has_general_extractors and has_regex_extractor and has_overlap: + rasa.shared.utils.io.raise_warning( + f"You have an overlap between the " + f"'{RegexEntityExtractor.__name__}' and the " + f"statistical entity extractors " + f"{_types_to_str(present_general_extractors)} " + f"in your configuration. Specifically both types of extractors will " + f"attempt to extract entities of the types " + f"{', '.join(overlap_between_types)}. " + f"This can lead to multiple " + f"extraction of entities. Please read " + f"'{RegexEntityExtractor.__name__}''s " + f"documentation section to make sure you understand the " + f"implications.", + docs=f"{DOCS_URL_COMPONENTS}#regexentityextractor", + ) + + def _raise_if_featurizers_are_not_compatible(self) -> None: + """Raises or warns if there are problems regarding the featurizers. + + Raises: + `InvalidConfigException` in case the featurizers are not compatible + """ + featurizers: List[SchemaNode] = [ + node + for node_name, node in self._graph_schema.nodes.items() + if issubclass(node.uses, Featurizer) + # Featurizers are split in `train` and `process_training_data` - + # we only need to look at the nodes which _add_ features. + and node.fn == "process_training_data" + # Tokenizers are re-used in the Core part of the graph when using End-to-End + and not node_name.startswith("e2e") + ] + + Featurizer.raise_if_featurizer_configs_are_not_compatible( + [schema_node.config for schema_node in featurizers] + ) + + def _validate_core(self, story_graph: StoryGraph, domain: Domain) -> None: + """Validates whether the configuration matches the training data. + + Args: + story_graph: a story graph (core training data) + domain: the domain + """ + if not self._policy_schema_nodes and story_graph.story_steps: + rasa.shared.utils.io.raise_warning( + "Found data for training policies but no policy was configured.", + docs=DOCS_URL_POLICIES, + ) + if not self._policy_schema_nodes: + return + self._warn_if_no_rule_policy_is_contained() + self._raise_if_domain_contains_form_names_but_no_rule_policy_given(domain) + self._raise_if_a_rule_policy_is_incompatible_with_domain(domain) + self._validate_policy_priorities() + self._warn_if_rule_based_data_is_unused_or_missing(story_graph=story_graph) + + def _warn_if_no_rule_policy_is_contained(self) -> None: + """Warns if there is no rule policy among the given policies.""" + if not any(node.uses == RulePolicy for node in self._policy_schema_nodes): + rasa.shared.utils.io.raise_warning( + f"'{RulePolicy.__name__}' is not included in the model's " + f"policy configuration. Default intents such as " + f"'{USER_INTENT_RESTART}' and '{USER_INTENT_BACK}' will " + f"not trigger actions '{ACTION_RESTART_NAME}' and " + f"'{ACTION_BACK_NAME}'.", + docs=DOCS_URL_DEFAULT_ACTIONS, + ) + + def _raise_if_domain_contains_form_names_but_no_rule_policy_given( + self, domain: Domain + ) -> None: + """Validates that there exists a rule policy if forms are defined. + + Raises: + `InvalidConfigException` if domain and rule policies do not match + """ + contains_rule_policy = any( + schema_node + for schema_node in self._graph_schema.nodes.values() + if issubclass(schema_node.uses, RulePolicy) + ) + + if domain.form_names and not contains_rule_policy: + raise InvalidDomain( + "You have defined a form action, but have not added the " + f"'{RulePolicy.__name__}' to your policy ensemble. " + f"Either remove all forms from your domain or add the " + f"'{RulePolicy.__name__}' to your policy configuration." + ) + + def _raise_if_a_rule_policy_is_incompatible_with_domain( + self, domain: Domain + ) -> None: + """Validates the rule policies against the domain. + + Raises: + `InvalidDomain` if domain and rule policies do not match + """ + for schema_node in self._graph_schema.nodes.values(): + if schema_node.uses == RulePolicy: + RulePolicy.raise_if_incompatible_with_domain( + config=schema_node.config, domain=domain + ) + + def _validate_policy_priorities(self) -> None: + """Checks if every policy has a valid priority value. + + A policy must have a priority value. The priority values of + the policies used in the configuration should be unique. + + Raises: + `InvalidConfigException` if any of the policies doesn't have a priority + """ + priority_dict = defaultdict(list) + for schema_node in self._policy_schema_nodes: + default_config = schema_node.uses.get_default_config() + if POLICY_PRIORITY not in default_config: + raise InvalidConfigException( + f"Found a policy {schema_node.uses.__name__} which has no " + f"priority. Every policy must have a priority value which you " + f"can set in the `get_default_config` method of your policy." + ) + default_priority = default_config[POLICY_PRIORITY] + priority = schema_node.config.get(POLICY_PRIORITY, default_priority) + priority_dict[priority].append(schema_node.uses) + + for k, v in priority_dict.items(): + if len(v) > 1: + rasa.shared.utils.io.raise_warning( + f"Found policies {_types_to_str(v)} with same priority {k} " + f"in PolicyEnsemble. When personalizing " + f"priorities, be sure to give all policies " + f"different priorities.", + docs=DOCS_URL_POLICIES, + ) + + def _warn_if_rule_based_data_is_unused_or_missing( + self, story_graph: StoryGraph + ) -> None: + """Warns if rule-data is unused or missing. + + Args: + story_graph: a story graph (core training data) + """ + consuming_rule_data = any( + cast(Policy, policy_node.uses).supported_data() + in [SupportedData.RULE_DATA, SupportedData.ML_AND_RULE_DATA] + for policy_node in self._policy_schema_nodes + ) + + # Reminder: We generate rule trackers via: + # rasa/shared/core/generator/... + # .../TrainingDataGenerator/_generate_rule_trackers/ + contains_rule_tracker = any( + isinstance(step, RuleStep) for step in story_graph.ordered_steps() + ) + + if consuming_rule_data and not contains_rule_tracker: + rasa.shared.utils.io.raise_warning( + f"Found a rule-based policy in your configuration but " + f"no rule-based training data. Please add rule-based " + f"stories to your training data or " + f"remove the rule-based policy " + f"(`{RulePolicy.__name__}`) from your " + f"your configuration.", + docs=DOCS_URL_RULES, + ) + elif not consuming_rule_data and contains_rule_tracker: + rasa.shared.utils.io.raise_warning( + f"Found rule-based training data but no policy supporting rule-based " + f"data. Please add `{RulePolicy.__name__}` " + f"or another rule-supporting " + f"policy to the `policies` section in `{DEFAULT_CONFIG_PATH}`.", + docs=DOCS_URL_RULES, + ) diff --git a/rasa/graph_components/validators/finetuning_validator.py b/rasa/graph_components/validators/finetuning_validator.py new file mode 100644 index 0000000..006e19a --- /dev/null +++ b/rasa/graph_components/validators/finetuning_validator.py @@ -0,0 +1,302 @@ +from __future__ import annotations +from typing import Dict, Text, Any, Optional +import copy +import logging + +from packaging import version +from rasa.constants import MINIMUM_COMPATIBLE_VERSION +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.storage.resource import Resource +from rasa.shared.exceptions import InvalidConfigException +from rasa.shared.core.domain import Domain +from rasa.shared.importers.importer import TrainingDataImporter +import rasa.shared.utils.io +from rasa.utils.tensorflow.constants import EPOCHS +from rasa.graph_components.providers.domain_for_core_training_provider import ( + DomainForCoreTrainingProvider, +) + +FINGERPRINT_CONFIG = "fingerprint-config" +FINGERPRINT_CORE = "fingerprint-core" +FINGERPRINT_NLU = "fingerprint-nlu" +FINGERPRINT_VERSION = "rasa-version" + +logger = logging.getLogger(__name__) + + +class FinetuningValidator(GraphComponent): + """Component that checks whether fine-tuning is possible. + + This is a component at the beginning of the graph which receives all training data + and raises an exception in case `is_finetuning` is `True` and finetuning is not + possible (e.g. because new labels were added). + In case we are doing a regular training (and not finetuning) this persists the + necessary information extracted from the training data to be able to validate when + initialized via load whether we can finetune. + + Finetuning is possible if, compared to the initial training phase, it holds that + 1. the configuration (except for "epoch" keys) does not change + 2. the domain (except for e.g. "responses") does not change - or we're not + finetuning the core part + 3. the intents, entities, entity groups, entity roles, and action names that + appeared in the original NLU training data, appear in the NLU training data + used for finetuning, and no new such items (i.e. intents, entities, entity + groups, entity roles, or action names) have been added, compared to the original + training data - or we're not finetuning the nlu part. + Note that even though conditions 2. and 3. differ based on which part we finetune, + condition 1. always covers both parts, i.e. NLU and Core. + """ + + FILENAME = "fingerprints-for-validation.json" + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Default config for ProjectProvider.""" + return {"validate_core": True, "validate_nlu": True} + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + fingerprints: Optional[Dict[Text, Text]] = None, + ) -> None: + """Instantiates a `FineTuningValidator`. + + Args: + model_storage: Storage which graph components can use to persist and load + themselves. + resource: Resource locator for this component which can be used to persist + and load itself from the `model_storage`. + execution_context: Information about the current graph run. + fingerprints: a dictionary of fingerprints generated by a + `FineTuningValidator` + """ + self._is_finetuning = execution_context.is_finetuning + self._execution_context = execution_context + self._model_storage = model_storage + self._resource = resource + self._fingerprints: Dict[Text, Text] = fingerprints or {} + + self._core = config["validate_core"] + self._nlu = config["validate_nlu"] + + def validate(self, importer: TrainingDataImporter) -> TrainingDataImporter: + """Validates whether we can finetune Core and NLU when finetuning is enabled. + + Args: + importer: a training data importer + + Raises: + `InvalidConfigException` if there is a conflict + + Returns: + Training Data Importer. + """ + self._validate(importer) + return importer + + def _validate(self, importer: TrainingDataImporter) -> None: + """Validate whether the finetuning setting conflicts with other settings. + + Note that this validation always takes into account the configuration of + nlu *and* core part, while the validation of aspects of the domain and + the NLU training data only happen if we request to validate finetuning + with respect to NLU/Core models, respectively. + + For more details, see docstring of this class. + + Args: + importer: a training data importer + + Raises: + `InvalidConfigException` if there is a conflict + """ + if self._is_finetuning and not self._fingerprints: + raise InvalidConfigException( + f"Finetuning is enabled but the {self.__class__.__name__} " + f"does not remember seeing a training run. Ensure that you have " + f"trained your model at least once (with finetuning disabled) " + f"and ensure that the {self.__class__.__name__} is part of the " + f"training graph. " + ) + + rasa_version = rasa.__version__ + if self._is_finetuning: + old_rasa_version = self._fingerprints[FINGERPRINT_VERSION] + if version.parse(old_rasa_version) < version.parse( + MINIMUM_COMPATIBLE_VERSION + ): + raise InvalidConfigException( + f"The minimum compatible Rasa Version is " + f"{MINIMUM_COMPATIBLE_VERSION} but the model we attempt to " + f"finetune has been generated with an older version " + f"({old_rasa_version}." + ) + self._fingerprints[FINGERPRINT_VERSION] = rasa_version + + fingerprint_config = self._get_fingerprint_of_schema_without_irrelevant_keys() + self._compare_or_memorize( + fingerprint_key=FINGERPRINT_CONFIG, + new_fingerprint=fingerprint_config, + error_message=( + "Cannot finetune because more than just the 'epoch' keys have been " + "changed in the configuration. " + "Please revert your configuration and only change " + "the 'epoch' settings where needed." + ), + ) + + if self._core: + # NOTE: If there's a consistency check between domain and core training data + # that ensures domain and core training data are consistent, then we can + # drop this check. + fingerprint_core = self._get_fingerprint_of_domain_pruned_for_core( + domain=importer.get_domain() + ) + self._compare_or_memorize( + fingerprint_key=FINGERPRINT_CORE, + new_fingerprint=fingerprint_core, + error_message=( + "Cannot finetune because keys that affect the training of core " + "components have changed." + "Please revert all settings in your domain file that affect the " + "training of core components." + ), + ) + + if self._nlu: + fingerprint_nlu = importer.get_nlu_data().label_fingerprint() + self._compare_or_memorize( + fingerprint_key=FINGERPRINT_NLU, + new_fingerprint=fingerprint_nlu, + error_message=( + "Cannot finetune because NLU training data contains new labels " + "or does not contain any examples for some known labels. " + "Please make sure that the NLU data that you use " + "for finetuning contains at least one example for every label " + "(i.e. intent, action name, ...) that was included in the NLU " + "data used for training the model which we attempt to finetune " + "now. Moreover, you must not add labels that were not included " + "during training before. " + ), + ) + + self.persist() + + def _compare_or_memorize( + self, fingerprint_key: Text, new_fingerprint: Text, error_message: Text + ) -> None: + """Compares given fingerprint if we are finetuning, otherwise just saves it. + + Args: + fingerprint_key: name of the fingerprint + new_fingerprint: a new fingerprint value + error_message: message of `InvalidConfigException` that will be raised if + a fingerprint is stored under `fingerprint_key` and differs from the + `new_fingerprint` - and we're in finetuning mode (according to the + execution context of this component) + + Raises: + `InvalidConfigException` if and old fingerprint exists and differs from + the new one + """ + if self._is_finetuning: + old_fingerprint = self._fingerprints[fingerprint_key] + if old_fingerprint != new_fingerprint: + raise InvalidConfigException(error_message) + else: + self._fingerprints[fingerprint_key] = new_fingerprint + + @staticmethod + def _get_fingerprint_of_domain_pruned_for_core(domain: Domain) -> Text: + """Returns a fingerprint of a pruned version of the domain relevant for core. + + Args: + domain: a domain + Returns: + fingerprint + """ + pruned_domain = DomainForCoreTrainingProvider.create_pruned_version(domain) + return pruned_domain.fingerprint() + + def _get_fingerprint_of_schema_without_irrelevant_keys(self) -> Text: + """Returns a fingerprint of the given schema with certain items removed. + + These items include specifications that do not influence actual training + results such as "eager" mode. The only configuration (in your config) that is + allowed to change is the number of `epochs`. + + Returns: + fingerprint + """ + graph_schema = self._execution_context.graph_schema + schema_as_dict = graph_schema.as_dict() + for node_name, node_dict in schema_as_dict["nodes"].items(): + config_copy = copy.deepcopy(node_dict["config"]) + config_copy.pop(EPOCHS, None) + config_copy.pop("finetuning_epoch_fraction", None) + # ignore default values since they're filled in anyway later and can + # end up in configs (or not) in mysterious ways + defaults = graph_schema.nodes[node_name].uses.get_default_config() + for key, default_value in defaults.items(): + if key in config_copy and config_copy[key] == default_value: + config_copy.pop(key) + node_dict["config"] = config_copy + node_dict.pop("eager") + node_dict.pop("constructor_name") + return rasa.shared.utils.io.deep_container_fingerprint(schema_as_dict) + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> FinetuningValidator: + """Creates a new `FineTuningValidator` (see parent class for full docstring).""" + return cls( + config=config, + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + ) + + def persist(self) -> None: + """Persists this `FineTuningValidator`.""" + with self._model_storage.write_to(self._resource) as path: + rasa.shared.utils.io.dump_obj_as_json_to_file( + filename=path / self.FILENAME, obj=self._fingerprints + ) + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> GraphComponent: + """Loads a `FineTuningValidator` (see parent class for full docstring).""" + try: + with model_storage.read_from(resource) as path: + fingerprints = rasa.shared.utils.io.read_json_file( + filename=path / cls.FILENAME + ) + return cls( + config=config, + model_storage=model_storage, + execution_context=execution_context, + resource=resource, + fingerprints=fingerprints, + ) + except ValueError as e: + raise InvalidConfigException( + f"Loading {cls.__name__} failed. Ensure that the {cls.__name__} " + f"is part of your training graph and re-train your models before " + f"attempting to use the {cls.__name__}." + ) from e diff --git a/rasa/jupyter.py b/rasa/jupyter.py new file mode 100644 index 0000000..668d58b --- /dev/null +++ b/rasa/jupyter.py @@ -0,0 +1,63 @@ +import asyncio +import pprint as pretty_print +import typing +from typing import Any, Dict, Optional, Text + +from rasa.shared.exceptions import RasaException +from rasa.shared.utils.cli import print_success +import rasa.core.agent +import rasa.utils.common + +if typing.TYPE_CHECKING: + from rasa.core.agent import Agent + + +def pprint(obj: Any) -> None: + """Prints JSONs with indent.""" + pretty_print.pprint(obj, indent=2) + + +def chat( + model_path: Optional[Text] = None, + endpoints: Optional[Text] = None, + agent: Optional["Agent"] = None, +) -> None: + """Chat to the bot within a Jupyter notebook. + + Args: + model_path: Path to a combined Rasa model. + endpoints: Path to a yaml with the action server is custom actions are defined. + agent: Rasa Core agent (used if no Rasa model given). + """ + if model_path: + agent = asyncio.run( + rasa.core.agent.load_agent(model_path=model_path, endpoints=endpoints) + ) + + if agent is None: + raise RasaException( + "Either the provided model path could not load the agent " + "or no core agent was provided." + ) + + print("Your bot is ready to talk! Type your messages here or send '/stop'.") + while True: + message = input() + if message == "/stop": + break + + responses = asyncio.run(agent.handle_text(message)) + for response in responses: + _display_bot_response(response) + + +def _display_bot_response(response: Dict) -> None: + from IPython.display import Image, display + + for response_type, value in response.items(): + if response_type == "text": + print_success(value) + + if response_type == "image": + image = Image(url=value) + display(image) diff --git a/rasa/model.py b/rasa/model.py new file mode 100644 index 0000000..0d17aa8 --- /dev/null +++ b/rasa/model.py @@ -0,0 +1,118 @@ +import glob +import hashlib +import logging +import os +from pathlib import Path +from subprocess import check_output, DEVNULL, CalledProcessError +from typing import Text, Optional, Union + +from rasa.shared.constants import DEFAULT_MODELS_PATH + +from rasa.exceptions import ModelNotFound + + +logger = logging.getLogger(__name__) + +# TODO: rename this whole module. + + +def get_local_model(model_path: Text = DEFAULT_MODELS_PATH) -> Text: + """Returns verified path to local model archive. + + Args: + model_path: Path to the zipped model. If it's a directory, the latest + trained model is returned. + + Returns: + Path to the zipped model. If it's a directory, the latest + trained model is returned. + + Raises: + ModelNotFound Exception: When no model could be found at the provided path. + + """ + if not model_path: + raise ModelNotFound("No path specified.") + elif not os.path.exists(model_path): + raise ModelNotFound(f"No file or directory at '{model_path}'.") + + if os.path.isdir(model_path): + file_model_path = get_latest_model(model_path) + if not file_model_path: + raise ModelNotFound( + f"Could not find any Rasa model files in '{model_path}'." + ) + model_path = file_model_path + elif not model_path.endswith(".tar.gz"): + raise ModelNotFound(f"Path '{model_path}' does not point to a Rasa model file.") + + return model_path + + +def get_latest_model(model_path: Text = DEFAULT_MODELS_PATH) -> Optional[Text]: + """Get the latest model from a path. + + Args: + model_path: Path to a directory containing zipped models. + + Returns: + Path to latest model in the given directory. + + """ + if not model_path: + return None + + if not os.path.exists(model_path) or os.path.isfile(model_path): + model_path = os.path.dirname(model_path) + + list_of_files = glob.glob(os.path.join(model_path, "*.tar.gz")) + + if len(list_of_files) == 0: + return None + + return max(list_of_files, key=os.path.getmtime) + + +def get_model_for_finetuning( + previous_model_file_or_dir: Union[Path, Text] +) -> Optional[Path]: + """Gets validated path for model to finetune. + + Args: + previous_model_file: Path to model file which should be used for finetuning or + a directory in case the latest trained model should be used. + + Returns: + Path to model archive. `None` if there is no model. + """ + model_file: Optional[Union[Path, Text]] = previous_model_file_or_dir + if Path(previous_model_file_or_dir).is_dir(): + logger.debug( + f"Trying to load latest model from '{previous_model_file_or_dir}' for " + f"finetuning." + ) + model_file = get_latest_model(previous_model_file_or_dir) + + if model_file and Path(model_file).is_file(): + return Path(model_file) + + logger.debug( + "No valid model for finetuning found as directory either " + "contains no model or model file cannot be found." + ) + return None + + +def project_fingerprint() -> Optional[Text]: + """Create a hash for the project in the current working directory. + + Returns: + project hash + """ + try: + remote = check_output( # skipcq:BAN-B607,BAN-B603 + ["git", "remote", "get-url", "origin"], stderr=DEVNULL + ) + return hashlib.sha256(remote).hexdigest() + except (CalledProcessError, OSError): + return None diff --git a/rasa/model_testing.py b/rasa/model_testing.py new file mode 100644 index 0000000..b07b3f7 --- /dev/null +++ b/rasa/model_testing.py @@ -0,0 +1,459 @@ +import copy +import logging +import os +from typing import ( + Text, + Dict, + Optional, + List, + Any, + Iterable, + Tuple, + Union, +) +from pathlib import Path + +from rasa.core.agent import Agent +from rasa.engine.storage.local_model_storage import LocalModelStorage +import rasa.shared.utils.cli +import rasa.shared.utils.common +import rasa.shared.utils.io +import rasa.utils.common +from rasa.constants import RESULTS_FILE, NUMBER_OF_TRAINING_STORIES_FILE +from rasa.exceptions import ModelNotFound +from rasa.shared.constants import DEFAULT_RESULTS_PATH +import rasa.shared.nlu.training_data.loading +from rasa.shared.data import TrainingType +from rasa.shared.nlu.training_data.training_data import TrainingData +import rasa.model + + +logger = logging.getLogger(__name__) + + +class ClassificationReportException(Exception): + """Raised when clf_report doesn't correctly set accuracy and/or micro avg. + + sklearn.metrics.classification_report should provide either accuracy or micro avg. + """ + + +async def test_core_models_in_directory( + model_directory: Text, + stories: Text, + output: Text, + use_conversation_test_files: bool = False, +) -> None: + """Evaluates a directory with multiple Core models using test data. + + Args: + model_directory: Directory containing multiple model files. + stories: Path to a conversation test file. + output: Output directory to store results to. + use_conversation_test_files: `True` if conversation test files should be used + for testing instead of regular Core story files. + """ + from rasa.core.test import compare_models_in_dir + + model_directory = _get_sanitized_model_directory(model_directory) + + await compare_models_in_dir( + model_directory, + stories, + output, + use_conversation_test_files=use_conversation_test_files, + ) + + story_n_path = os.path.join(model_directory, NUMBER_OF_TRAINING_STORIES_FILE) + number_of_stories = rasa.shared.utils.io.read_json_file(story_n_path) + plot_core_results(output, number_of_stories) + + +def plot_core_results(output_directory: Text, number_of_examples: List[int]) -> None: + """Plot core model comparison graph. + + Args: + output_directory: path to the output directory + number_of_examples: number of examples per run + """ + import rasa.utils.plotting as plotting_utils + + graph_path = os.path.join(output_directory, "core_model_comparison_graph.pdf") + + plotting_utils.plot_curve( + output_directory, + number_of_examples, + x_label_text="Number of stories present during training", + y_label_text="Number of correct test stories", + graph_path=graph_path, + ) + + +def _get_sanitized_model_directory(model_directory: Text) -> Text: + """Adjusts the `--model` argument of `rasa test core` when called with + `--evaluate-model-directory`. + + By default rasa uses the latest model for the `--model` parameter. However, for + `--evaluate-model-directory` we need a directory. This function checks if the + passed parameter is a model or an individual model file. + + Args: + model_directory: The model_directory argument that was given to + `test_core_models_in_directory`. + + Returns: The adjusted model_directory that should be used in + `test_core_models_in_directory`. + """ + + p = Path(model_directory) + if p.is_file(): + if model_directory != rasa.model.get_latest_model(): + rasa.shared.utils.cli.print_warning( + "You passed a file as '--model'. Will use the directory containing " + "this file instead." + ) + model_directory = str(p.parent) + + return model_directory + + +async def test_core_models( + models: List[Text], + stories: Text, + output: Text, + use_conversation_test_files: bool = False, +) -> None: + """Compares multiple Core models based on test data. + + Args: + models: A list of models files. + stories: Path to test data. + output: Path to output directory for test results. + use_conversation_test_files: `True` if conversation test files should be used + for testing instead of regular Core story files. + """ + from rasa.core.test import compare_models + + await compare_models( + models, stories, output, use_conversation_test_files=use_conversation_test_files + ) + + +async def test_core( + model: Optional[Text] = None, + stories: Optional[Text] = None, + output: Text = DEFAULT_RESULTS_PATH, + additional_arguments: Optional[Dict] = None, + use_conversation_test_files: bool = False, +) -> None: + """Tests a trained Core model against a set of test stories.""" + try: + model = rasa.model.get_local_model(model) + except ModelNotFound: + rasa.shared.utils.cli.print_error( + "Unable to test: could not find a model. Use 'rasa train' to train a " + "Rasa model and provide it via the '--model' argument." + ) + return + + metadata = LocalModelStorage.metadata_from_archive(model) + if metadata.training_type == TrainingType.NLU: + rasa.shared.utils.cli.print_error( + "Unable to test: no core model found. Use 'rasa train' to train a " + "Rasa model and provide it via the '--model' argument." + ) + elif metadata.training_type == TrainingType.CORE and use_conversation_test_files: + rasa.shared.utils.cli.print_warning( + "No NLU model found. Using default 'RegexMessageHandler' for end-to-end " + "evaluation. If you added actual user messages to your test stories " + "this will likely lead to the tests failing. In that case, you need " + "to train a NLU model first, e.g. using `rasa train`." + ) + + if additional_arguments is None: + additional_arguments = {} + + if output: + rasa.shared.utils.io.create_directory(output) + + _agent = Agent.load(model_path=model) + + if not _agent.is_ready(): + rasa.shared.utils.cli.print_error( + "Unable to test: processor not loaded. Use 'rasa train' to train a " + "Rasa model and provide it via the '--model' argument." + ) + return + + from rasa.core.test import test as core_test + + kwargs = rasa.shared.utils.common.minimal_kwargs( + additional_arguments, core_test, ["stories", "agent", "e2e"] + ) + + await core_test( + stories, _agent, e2e=use_conversation_test_files, out_directory=output, **kwargs + ) + + +async def test_nlu( + model: Optional[Text], + nlu_data: Optional[Text], + output_directory: Text = DEFAULT_RESULTS_PATH, + additional_arguments: Optional[Dict] = None, + domain_path: Optional[Text] = None, +) -> None: + """Tests the NLU Model.""" + from rasa.nlu.test import run_evaluation + + rasa.shared.utils.io.create_directory(output_directory) + + try: + model = rasa.model.get_local_model(model) + except ModelNotFound: + rasa.shared.utils.cli.print_error( + "Could not find any model. Use 'rasa train nlu' to train a " + "Rasa model and provide it via the '--model' argument." + ) + return + + metadata = LocalModelStorage.metadata_from_archive(model) + + if os.path.exists(model) and metadata.training_type != TrainingType.CORE: + kwargs = rasa.shared.utils.common.minimal_kwargs( + additional_arguments, run_evaluation, ["data_path", "model"] + ) + _agent = Agent.load(model_path=model) + await run_evaluation( + nlu_data, + _agent.processor, + output_directory=output_directory, + domain_path=domain_path, + **kwargs, + ) + else: + rasa.shared.utils.cli.print_error( + "Could not find any model. Use 'rasa train nlu' to train a " + "Rasa model and provide it via the '--model' argument." + ) + + +async def compare_nlu_models( + configs: List[Text], + test_data: TrainingData, + output: Text, + runs: int, + exclusion_percentages: List[int], +) -> None: + """Trains multiple models, compares them and saves the results.""" + + from rasa.nlu.test import drop_intents_below_freq + from rasa.nlu.utils import write_json_to_file + from rasa.utils.io import create_path + from rasa.nlu.test import compare_nlu + + test_data = drop_intents_below_freq(test_data, cutoff=5) + + create_path(output) + + bases = [os.path.basename(nlu_config) for nlu_config in configs] + model_names = [os.path.splitext(base)[0] for base in bases] + + f1_score_results: Dict[Text, List[List[float]]] = { + model_name: [[] for _ in range(runs)] for model_name in model_names + } + + training_examples_per_run = await compare_nlu( + configs, + test_data, + exclusion_percentages, + f1_score_results, + model_names, + output, + runs, + ) + + f1_path = os.path.join(output, RESULTS_FILE) + write_json_to_file(f1_path, f1_score_results) + + plot_nlu_results(output, training_examples_per_run) + + +def plot_nlu_results(output_directory: Text, number_of_examples: List[int]) -> None: + """Plot NLU model comparison graph. + + Args: + output_directory: path to the output directory + number_of_examples: number of examples per run + """ + import rasa.utils.plotting as plotting_utils + + graph_path = os.path.join(output_directory, "nlu_model_comparison_graph.pdf") + + plotting_utils.plot_curve( + output_directory, + number_of_examples, + x_label_text="Number of intent examples present during training", + y_label_text="Label-weighted average F1 score on test set", + graph_path=graph_path, + ) + + +async def perform_nlu_cross_validation( + config: Dict[Text, Any], + data: TrainingData, + output: Text, + additional_arguments: Optional[Dict[Text, Any]], +) -> None: + """Runs cross-validation on test data. + + Args: + config: The model configuration. + data: The data which is used for the cross-validation. + output: Output directory for the cross-validation results. + additional_arguments: Additional arguments which are passed to the + cross-validation, like number of `disable_plotting`. + """ + from rasa.nlu.test import ( + drop_intents_below_freq, + cross_validate, + log_results, + log_entity_results, + ) + + additional_arguments = additional_arguments or {} + folds = int(additional_arguments.get("folds", 3)) + + data = drop_intents_below_freq(data, cutoff=folds) + kwargs = rasa.shared.utils.common.minimal_kwargs( + additional_arguments, cross_validate + ) + + results, entity_results, response_selection_results = await cross_validate( + data, folds, config, output, **kwargs + ) + logger.info(f"CV evaluation (n={folds})") + + if any(results): + logger.info("Intent evaluation results") + log_results(results.train, "train") + log_results(results.test, "test") + if any(entity_results): + logger.info("Entity evaluation results") + log_entity_results(entity_results.train, "train") + log_entity_results(entity_results.test, "test") + if any(response_selection_results): + logger.info("Response Selection evaluation results") + log_results(response_selection_results.train, "train") + log_results(response_selection_results.test, "test") + + +def get_evaluation_metrics( + targets: Iterable[Any], + predictions: Iterable[Any], + output_dict: bool = False, + exclude_label: Optional[Text] = None, +) -> Tuple[Union[Text, Dict[Text, Dict[Text, float]]], float, float, float]: + """Compute the f1, precision, accuracy and summary report from sklearn. + + Args: + targets: target labels + predictions: predicted labels + output_dict: if True sklearn returns a summary report as dict, if False the + report is in string format + exclude_label: labels to exclude from evaluation + + Returns: + Report from sklearn, precision, f1, and accuracy values. + """ + from sklearn import metrics + + targets = clean_labels(targets) + predictions = clean_labels(predictions) + + labels = get_unique_labels(targets, exclude_label) + if not labels: + logger.warning("No labels to evaluate. Skip evaluation.") + return {}, 0.0, 0.0, 0.0 + + report = metrics.classification_report( + targets, predictions, labels=labels, output_dict=output_dict + ) + precision = metrics.precision_score( + targets, predictions, labels=labels, average="weighted" + ) + f1 = metrics.f1_score(targets, predictions, labels=labels, average="weighted") + accuracy = metrics.accuracy_score(targets, predictions) + + if output_dict: + report = make_classification_report_complete(report, accuracy) + + return report, precision, f1, accuracy + + +def make_classification_report_complete(report: dict, accuracy: float) -> dict: + """Completes the sklearn classification report with accuracy xor micro avg. + + Args: + report: Report generated by metrics.classification_report with output_dict=True + accuracy: Model accuracy + + Raises: + Exception: When sklearn.metrics.classification_report + behaves different to our expectation. + + Returns: + report: Report generated by metrics.classification_report + enhanced with accuracy xor micro avg. + """ + report = copy.deepcopy(report) + if "accuracy" in report and "micro avg" not in report: + # micro avg corresponds to accuracy in this case + # and is the same for all metrics + acc = report["accuracy"] + support = report["macro avg"]["support"] + report["micro avg"] = { + "precision": acc, + "recall": acc, + "f1-score": acc, + "support": support, + } + elif "accuracy" not in report and "micro avg" in report: + # Due to provided labels, micro avg can have recall != precision + # The accuracy therefore has to be inferred separately + report["accuracy"] = accuracy + else: + raise ClassificationReportException( + "This cannot happen according to classification_report's docs" + ) + return report + + +def clean_labels(labels: Iterable[Text]) -> List[Text]: + """Remove `None` labels. sklearn metrics do not support them. + + Args: + labels: list of labels + + Returns: + Cleaned labels. + """ + return [label if label is not None else "" for label in labels] + + +def get_unique_labels( + targets: Iterable[Text], exclude_label: Optional[Text] +) -> List[Text]: + """Get unique labels. Exclude 'exclude_label' if specified. + + Args: + targets: labels + exclude_label: label to exclude + + Returns: + Unique labels. + """ + labels = set(targets) + if exclude_label and exclude_label in labels: + labels.remove(exclude_label) + return list(labels) diff --git a/rasa/model_training.py b/rasa/model_training.py new file mode 100644 index 0000000..84cc45f --- /dev/null +++ b/rasa/model_training.py @@ -0,0 +1,462 @@ +import time +from pathlib import Path +from typing import Text, NamedTuple, Optional, List, Union, Dict, Any + +import randomname + +import rasa.engine.validation +from rasa.engine.caching import LocalTrainingCache +from rasa.engine.recipes.recipe import Recipe +from rasa.engine.runner.dask import DaskGraphRunner +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.training.components import FingerprintStatus +from rasa.engine.training.graph_trainer import GraphTrainer +from rasa.shared.core.events import SlotSet +from rasa.shared.core.training_data.structures import StoryGraph +from rasa.shared.data import TrainingType +from rasa.shared.importers.importer import TrainingDataImporter +from rasa import telemetry +from rasa.shared.core.domain import Domain +import rasa.utils.common +import rasa.shared.utils.common +import rasa.shared.utils.cli +import rasa.shared.exceptions +import rasa.shared.utils.io +import rasa.shared.constants +import rasa.model + +CODE_NEEDS_TO_BE_RETRAINED = 0b0001 +CODE_FORCED_TRAINING = 0b1000 + + +class TrainingResult(NamedTuple): + """Holds information about the results of training.""" + + model: Optional[Text] = None + code: int = 0 + dry_run_results: Optional[Dict[Text, Union[FingerprintStatus, Any]]] = None + + +def _dry_run_result( + fingerprint_results: Dict[Text, Union[FingerprintStatus, Any]], + force_full_training: bool, +) -> TrainingResult: + """Returns a dry run result. + + Args: + fingerprint_results: A result of fingerprint run.. + force_full_training: Whether the user used the `--force` flag to enforce a + full retraining of the model. + + Returns: + Result containing the return code and the fingerprint results. + """ + if force_full_training: + rasa.shared.utils.cli.print_warning("The training was forced.") + return TrainingResult( + code=CODE_FORCED_TRAINING, dry_run_results=fingerprint_results + ) + + training_required = any( + isinstance(result, FingerprintStatus) and not result.is_hit + for result in fingerprint_results.values() + ) + + if training_required: + rasa.shared.utils.cli.print_warning("The model needs to be retrained.") + return TrainingResult( + code=CODE_NEEDS_TO_BE_RETRAINED, dry_run_results=fingerprint_results + ) + + rasa.shared.utils.cli.print_success( + "No training of components required " + "(the responses might still need updating!)." + ) + return TrainingResult(dry_run_results=fingerprint_results) + + +def get_unresolved_slots(domain: Domain, stories: StoryGraph) -> List[Text]: + """Returns a list of unresolved slots. + + Args: + domain: The domain. + stories: The story graph. + + Returns: + A list of unresolved slots. + """ + return list( + set( + evnt.key + for step in stories.story_steps + for evnt in step.events + if isinstance(evnt, SlotSet) + ) + - set(slot.name for slot in domain.slots) + ) + + +def _check_unresolved_slots(domain: Domain, stories: StoryGraph) -> None: + """Checks if there are any unresolved slots. + + Args: + domain: The domain. + stories: The story graph. + + Raises: + `Sys exit` if there are any unresolved slots. + + Returns: + `None` if there are no unresolved slots. + """ + unresolved_slots = get_unresolved_slots(domain, stories) + if unresolved_slots: + rasa.shared.utils.cli.print_error_and_exit( + f"Unresolved slots found in stories/rules🚨 \n" + f'Tried to set slots "{unresolved_slots}" that are not present in' + f"your domain.\n Check whether they need to be added to the domain or " + f"whether there is a spelling error." + ) + return None + + +def train( + domain: Text, + config: Text, + training_files: Optional[Union[Text, List[Text]]], + output: Text = rasa.shared.constants.DEFAULT_MODELS_PATH, + dry_run: bool = False, + force_training: bool = False, + fixed_model_name: Optional[Text] = None, + persist_nlu_training_data: bool = False, + core_additional_arguments: Optional[Dict] = None, + nlu_additional_arguments: Optional[Dict] = None, + model_to_finetune: Optional[Text] = None, + finetuning_epoch_fraction: float = 1.0, +) -> TrainingResult: + """Trains a Rasa model (Core and NLU). + + Args: + domain: Path to the domain file. + config: Path to the config file. + training_files: List of paths to training data files. + output: Output directory for the trained model. + dry_run: If `True` then no training will be done, and the information about + whether the training needs to be done will be printed. + force_training: If `True` retrain model even if data has not changed. + fixed_model_name: Name of model to be stored. + persist_nlu_training_data: `True` if the NLU training data should be persisted + with the model. + core_additional_arguments: Additional training parameters for core training. + nlu_additional_arguments: Additional training parameters forwarded to training + method of each NLU component. + model_to_finetune: Optional path to a model which should be finetuned or + a directory in case the latest trained model should be used. + finetuning_epoch_fraction: The fraction currently specified training epochs + in the model configuration which should be used for finetuning. + + Returns: + An instance of `TrainingResult`. + """ + file_importer = TrainingDataImporter.load_from_config( + config, domain, training_files, core_additional_arguments + ) + + stories = file_importer.get_stories() + nlu_data = file_importer.get_nlu_data() + + training_type = TrainingType.BOTH + + if nlu_data.has_e2e_examples(): + rasa.shared.utils.common.mark_as_experimental_feature("end-to-end training") + training_type = TrainingType.END_TO_END + + if stories.is_empty() and nlu_data.contains_no_pure_nlu_data(): + rasa.shared.utils.cli.print_error( + "No training data given. Please provide stories and NLU data in " + "order to train a Rasa model using the '--data' argument." + ) + return TrainingResult(code=1) + + domain_object = file_importer.get_domain() + if domain_object.is_empty(): + rasa.shared.utils.cli.print_warning( + "Core training was skipped because no valid domain file was found. " + "Only an NLU-model was created. Please specify a valid domain using " + "the '--domain' argument or check if the provided domain file exists." + ) + training_type = TrainingType.NLU + + elif stories.is_empty(): + rasa.shared.utils.cli.print_warning( + "No stories present. Just a Rasa NLU model will be trained." + ) + training_type = TrainingType.NLU + + # We will train nlu if there are any nlu example, including from e2e stories. + elif nlu_data.contains_no_pure_nlu_data() and not nlu_data.has_e2e_examples(): + rasa.shared.utils.cli.print_warning( + "No NLU data present. Just a Rasa Core model will be trained." + ) + training_type = TrainingType.CORE + + _check_unresolved_slots(domain_object, stories) + + with telemetry.track_model_training(file_importer, model_type="rasa"): + return _train_graph( + file_importer, + training_type=training_type, + output_path=output, + fixed_model_name=fixed_model_name, + model_to_finetune=model_to_finetune, + force_full_training=force_training, + persist_nlu_training_data=persist_nlu_training_data, + finetuning_epoch_fraction=finetuning_epoch_fraction, + dry_run=dry_run, + **(core_additional_arguments or {}), + **(nlu_additional_arguments or {}), + ) + + +def _train_graph( + file_importer: TrainingDataImporter, + training_type: TrainingType, + output_path: Text, + fixed_model_name: Text, + model_to_finetune: Optional[Union[Text, Path]] = None, + force_full_training: bool = False, + dry_run: bool = False, + **kwargs: Any, +) -> TrainingResult: + if model_to_finetune: + model_to_finetune = rasa.model.get_model_for_finetuning(model_to_finetune) + if not model_to_finetune: + rasa.shared.utils.cli.print_error_and_exit( + f"No model for finetuning found. Please make sure to either " + f"specify a path to a previous model or to have a finetunable " + f"model within the directory '{output_path}'." + ) + + rasa.shared.utils.common.mark_as_experimental_feature( + "Incremental Training feature" + ) + + is_finetuning = model_to_finetune is not None + + config = file_importer.get_config() + recipe = Recipe.recipe_for_name(config.get("recipe")) + config, _missing_keys, _configured_keys = recipe.auto_configure( + file_importer.get_config_file_for_auto_config(), + config, + training_type, + ) + model_configuration = recipe.graph_config_for_recipe( + config, + kwargs, + training_type=training_type, + is_finetuning=is_finetuning, + ) + rasa.engine.validation.validate(model_configuration) + + tempdir_name = rasa.utils.common.get_temp_dir_name() + + # Use `TempDirectoryPath` instead of `tempfile.TemporaryDirectory` as this + # leads to errors on Windows when the context manager tries to delete an + # already deleted temporary directory (e.g. https://bugs.python.org/issue29982) + with rasa.utils.common.TempDirectoryPath(tempdir_name) as temp_model_dir: + model_storage = _create_model_storage( + is_finetuning, model_to_finetune, Path(temp_model_dir) + ) + cache = LocalTrainingCache() + trainer = GraphTrainer(model_storage, cache, DaskGraphRunner) + + if dry_run: + fingerprint_status = trainer.fingerprint( + model_configuration.train_schema, file_importer + ) + return _dry_run_result(fingerprint_status, force_full_training) + + model_name = _determine_model_name(fixed_model_name, training_type) + full_model_path = Path(output_path, model_name) + + with telemetry.track_model_training( + file_importer, model_type=training_type.model_type + ): + trainer.train( + model_configuration, + file_importer, + full_model_path, + force_retraining=force_full_training, + is_finetuning=is_finetuning, + ) + rasa.shared.utils.cli.print_success( + f"Your Rasa model is trained and saved at '{full_model_path}'." + ) + + return TrainingResult(str(full_model_path), 0) + + +def _create_model_storage( + is_finetuning: bool, model_to_finetune: Optional[Path], temp_model_dir: Path +) -> ModelStorage: + if is_finetuning: + model_storage, _ = LocalModelStorage.from_model_archive( + temp_model_dir, model_to_finetune + ) + else: + model_storage = LocalModelStorage(temp_model_dir) + + return model_storage + + +def _determine_model_name( + fixed_model_name: Optional[Text], training_type: TrainingType +) -> Text: + if fixed_model_name: + model_file = Path(fixed_model_name) + if not model_file.name.endswith(".tar.gz"): + return model_file.with_suffix(".tar.gz").name + + return fixed_model_name + + prefix = "" + if training_type in [TrainingType.CORE, TrainingType.NLU]: + prefix = f"{training_type.model_type}-" + + time_format = "%Y%m%d-%H%M%S" + return f"{prefix}{time.strftime(time_format)}-{randomname.get_name()}.tar.gz" + + +def train_core( + domain: Union[Domain, Text], + config: Text, + stories: Text, + output: Text, + fixed_model_name: Optional[Text] = None, + additional_arguments: Optional[Dict] = None, + model_to_finetune: Optional[Text] = None, + finetuning_epoch_fraction: float = 1.0, +) -> Optional[Text]: + """Trains a Core model. + + Args: + domain: Path to the domain file. + config: Path to the config file for Core. + stories: Path to the Core training data. + output: Output path. + fixed_model_name: Name of model to be stored. + additional_arguments: Additional training parameters. + model_to_finetune: Optional path to a model which should be finetuned or + a directory in case the latest trained model should be used. + finetuning_epoch_fraction: The fraction currently specified training epochs + in the model configuration which should be used for finetuning. + + Returns: + Path to the model archive. + + """ + file_importer = TrainingDataImporter.load_core_importer_from_config( + config, domain, [stories], additional_arguments + ) + stories_data = file_importer.get_stories() + nlu_data = file_importer.get_nlu_data() + domain = file_importer.get_domain() + + if nlu_data.has_e2e_examples(): + rasa.shared.utils.cli.print_error( + "Stories file contains e2e stories. Please train using `rasa train` so that" + " the NLU model is also trained." + ) + return None + + if domain.is_empty(): + rasa.shared.utils.cli.print_error( + "Core training was skipped because no valid domain file was found. " + "Please specify a valid domain using '--domain' argument or check " + "if the provided domain file exists." + ) + return None + + if not stories_data: + rasa.shared.utils.cli.print_error( + "No stories given. Please provide stories in order to " + "train a Rasa Core model using the '--stories' argument." + ) + return None + + _check_unresolved_slots(domain, stories_data) + + return _train_graph( + file_importer, + training_type=TrainingType.CORE, + output_path=output, + model_to_finetune=model_to_finetune, + fixed_model_name=fixed_model_name, + finetuning_epoch_fraction=finetuning_epoch_fraction, + **(additional_arguments or {}), + ).model + + +def train_nlu( + config: Text, + nlu_data: Optional[Text], + output: Text, + fixed_model_name: Optional[Text] = None, + persist_nlu_training_data: bool = False, + additional_arguments: Optional[Dict] = None, + domain: Optional[Union[Domain, Text]] = None, + model_to_finetune: Optional[Text] = None, + finetuning_epoch_fraction: float = 1.0, +) -> Optional[Text]: + """Trains an NLU model. + + Args: + config: Path to the config file for NLU. + nlu_data: Path to the NLU training data. + output: Output path. + fixed_model_name: Name of the model to be stored. + persist_nlu_training_data: `True` if the NLU training data should be persisted + with the model. + additional_arguments: Additional training parameters which will be passed to + the `train` method of each component. + domain: Path to the optional domain file/Domain object. + model_to_finetune: Optional path to a model which should be finetuned or + a directory in case the latest trained model should be used. + finetuning_epoch_fraction: The fraction currently specified training epochs + in the model configuration which should be used for finetuning. + + Returns: + Path to the model archive. + """ + if not nlu_data: + rasa.shared.utils.cli.print_error( + "No NLU data given. Please provide NLU data in order to train " + "a Rasa NLU model using the '--nlu' argument." + ) + return None + + # training NLU only hence the training files still have to be selected + file_importer = TrainingDataImporter.load_nlu_importer_from_config( + config, domain, training_data_paths=[nlu_data], args=additional_arguments + ) + + training_data = file_importer.get_nlu_data() + if training_data.contains_no_pure_nlu_data(): + rasa.shared.utils.cli.print_error( + f"Path '{nlu_data}' doesn't contain valid NLU data in it. " + f"Please verify the data format. " + f"The NLU model training will be skipped now." + ) + return None + + return _train_graph( + file_importer, + training_type=TrainingType.NLU, + output_path=output, + model_to_finetune=model_to_finetune, + fixed_model_name=fixed_model_name, + finetuning_epoch_fraction=finetuning_epoch_fraction, + persist_nlu_training_data=persist_nlu_training_data, + **(additional_arguments or {}), + ).model diff --git a/rasa/nlu/__init__.py b/rasa/nlu/__init__.py new file mode 100644 index 0000000..4defa9b --- /dev/null +++ b/rasa/nlu/__init__.py @@ -0,0 +1,7 @@ +import logging + +import rasa + +logging.getLogger(__name__).addHandler(logging.NullHandler()) + +__version__ = rasa.__version__ diff --git a/rasa/nlu/classifiers/__init__.py b/rasa/nlu/classifiers/__init__.py new file mode 100644 index 0000000..ae7b52d --- /dev/null +++ b/rasa/nlu/classifiers/__init__.py @@ -0,0 +1,3 @@ +# How many labels are at max put into the output +# ranking, everything else will be cut off +LABEL_RANKING_LENGTH = 10 diff --git a/rasa/nlu/classifiers/classifier.py b/rasa/nlu/classifiers/classifier.py new file mode 100644 index 0000000..edb5602 --- /dev/null +++ b/rasa/nlu/classifiers/classifier.py @@ -0,0 +1,5 @@ +class IntentClassifier: + """An intent classifier.""" + + # TODO: move "add intent / rankings to message" functions here + pass diff --git a/rasa/nlu/classifiers/diet_classifier.py b/rasa/nlu/classifiers/diet_classifier.py new file mode 100644 index 0000000..b53eb5d --- /dev/null +++ b/rasa/nlu/classifiers/diet_classifier.py @@ -0,0 +1,1887 @@ +from __future__ import annotations + +import copy +import logging +from collections import defaultdict +from pathlib import Path +from typing import Any, Dict, List, Optional, Text, Tuple, Union, TypeVar, Type + +import numpy as np +import scipy.sparse +import tensorflow as tf + +from rasa.exceptions import ModelNotFound +from rasa.nlu.featurizers.featurizer import Featurizer +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.extractors.extractor import EntityExtractorMixin +from rasa.nlu.classifiers.classifier import IntentClassifier +import rasa.shared.utils.io +import rasa.nlu.utils.bilou_utils as bilou_utils +from rasa.shared.constants import DIAGNOSTIC_DATA +from rasa.nlu.extractors.extractor import EntityTagSpec +from rasa.nlu.classifiers import LABEL_RANKING_LENGTH +from rasa.utils import train_utils +from rasa.utils.tensorflow import rasa_layers +from rasa.utils.tensorflow.feature_array import ( + FeatureArray, + serialize_nested_feature_arrays, + deserialize_nested_feature_arrays, +) +from rasa.utils.tensorflow.models import RasaModel, TransformerRasaModel +from rasa.utils.tensorflow.model_data import ( + RasaModelData, + FeatureSignature, +) +from rasa.nlu.constants import TOKENS_NAMES, DEFAULT_TRANSFORMER_SIZE +from rasa.shared.nlu.constants import ( + SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE, + TEXT, + INTENT, + INTENT_RESPONSE_KEY, + ENTITIES, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_GROUP, + ENTITY_ATTRIBUTE_ROLE, + NO_ENTITY_TAG, + SPLIT_ENTITIES_BY_COMMA, +) +from rasa.shared.exceptions import InvalidConfigException +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.utils.tensorflow.constants import ( + DROP_SMALL_LAST_BATCH, + LABEL, + IDS, + HIDDEN_LAYERS_SIZES, + RENORMALIZE_CONFIDENCES, + SHARE_HIDDEN_LAYERS, + TRANSFORMER_SIZE, + NUM_TRANSFORMER_LAYERS, + NUM_HEADS, + BATCH_SIZES, + BATCH_STRATEGY, + EPOCHS, + RANDOM_SEED, + LEARNING_RATE, + RANKING_LENGTH, + LOSS_TYPE, + SIMILARITY_TYPE, + NUM_NEG, + SPARSE_INPUT_DROPOUT, + DENSE_INPUT_DROPOUT, + MASKED_LM, + ENTITY_RECOGNITION, + TENSORBOARD_LOG_DIR, + INTENT_CLASSIFICATION, + EVAL_NUM_EXAMPLES, + EVAL_NUM_EPOCHS, + UNIDIRECTIONAL_ENCODER, + DROP_RATE, + DROP_RATE_ATTENTION, + CONNECTION_DENSITY, + NEGATIVE_MARGIN_SCALE, + REGULARIZATION_CONSTANT, + SCALE_LOSS, + USE_MAX_NEG_SIM, + MAX_NEG_SIM, + MAX_POS_SIM, + EMBEDDING_DIMENSION, + BILOU_FLAG, + KEY_RELATIVE_ATTENTION, + VALUE_RELATIVE_ATTENTION, + MAX_RELATIVE_POSITION, + AUTO, + BALANCED, + CROSS_ENTROPY, + TENSORBOARD_LOG_LEVEL, + CONCAT_DIMENSION, + FEATURIZERS, + CHECKPOINT_MODEL, + SEQUENCE, + SENTENCE, + SEQUENCE_LENGTH, + DENSE_DIMENSION, + MASK, + CONSTRAIN_SIMILARITIES, + MODEL_CONFIDENCE, + SOFTMAX, + RUN_EAGERLY, +) + +logger = logging.getLogger(__name__) + +SPARSE = "sparse" +DENSE = "dense" +LABEL_KEY = LABEL +LABEL_SUB_KEY = IDS + +POSSIBLE_TAGS = [ENTITY_ATTRIBUTE_TYPE, ENTITY_ATTRIBUTE_ROLE, ENTITY_ATTRIBUTE_GROUP] + +DIETClassifierT = TypeVar("DIETClassifierT", bound="DIETClassifier") + + +@DefaultV1Recipe.register( + [ + DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER, + DefaultV1Recipe.ComponentType.ENTITY_EXTRACTOR, + ], + is_trainable=True, +) +class DIETClassifier(GraphComponent, IntentClassifier, EntityExtractorMixin): + """A multi-task model for intent classification and entity extraction. + + DIET is Dual Intent and Entity Transformer. + The architecture is based on a transformer which is shared for both tasks. + A sequence of entity labels is predicted through a Conditional Random Field (CRF) + tagging layer on top of the transformer output sequence corresponding to the + input sequence of tokens. The transformer output for the ``__CLS__`` token and + intent labels are embedded into a single semantic vector space. We use the + dot-product loss to maximize the similarity with the target label and minimize + similarities with negative samples. + """ + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [Featurizer] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """The component's default config (see parent class for full docstring).""" + # please make sure to update the docs when changing a default parameter + return { + # ## Architecture of the used neural network + # Hidden layer sizes for layers before the embedding layers for user message + # and labels. + # The number of hidden layers is equal to the length of the corresponding + # list. + HIDDEN_LAYERS_SIZES: {TEXT: [], LABEL: []}, + # Whether to share the hidden layer weights between user message and labels. + SHARE_HIDDEN_LAYERS: False, + # Number of units in transformer + TRANSFORMER_SIZE: DEFAULT_TRANSFORMER_SIZE, + # Number of transformer layers + NUM_TRANSFORMER_LAYERS: 2, + # Number of attention heads in transformer + NUM_HEADS: 4, + # If 'True' use key relative embeddings in attention + KEY_RELATIVE_ATTENTION: False, + # If 'True' use value relative embeddings in attention + VALUE_RELATIVE_ATTENTION: False, + # Max position for relative embeddings. Only in effect if key- or value + # relative attention are turned on + MAX_RELATIVE_POSITION: 5, + # Use a unidirectional or bidirectional encoder. + UNIDIRECTIONAL_ENCODER: False, + # ## Training parameters + # Initial and final batch sizes: + # Batch size will be linearly increased for each epoch. + BATCH_SIZES: [64, 256], + # Strategy used when creating batches. + # Can be either 'sequence' or 'balanced'. + BATCH_STRATEGY: BALANCED, + # Number of epochs to train + EPOCHS: 300, + # Set random seed to any 'int' to get reproducible results + RANDOM_SEED: None, + # Initial learning rate for the optimizer + LEARNING_RATE: 0.001, + # ## Parameters for embeddings + # Dimension size of embedding vectors + EMBEDDING_DIMENSION: 20, + # Dense dimension to use for sparse features. + DENSE_DIMENSION: {TEXT: 128, LABEL: 20}, + # Default dimension to use for concatenating sequence and sentence features. + CONCAT_DIMENSION: {TEXT: 128, LABEL: 20}, + # The number of incorrect labels. The algorithm will minimize + # their similarity to the user input during training. + NUM_NEG: 20, + # Type of similarity measure to use, either 'auto' or 'cosine' or 'inner'. + SIMILARITY_TYPE: AUTO, + # The type of the loss function, either 'cross_entropy' or 'margin'. + LOSS_TYPE: CROSS_ENTROPY, + # Number of top intents for which confidences should be reported. + # Set to 0 if confidences for all intents should be reported. + RANKING_LENGTH: LABEL_RANKING_LENGTH, + # Indicates how similar the algorithm should try to make embedding vectors + # for correct labels. + # Should be 0.0 < ... < 1.0 for 'cosine' similarity type. + MAX_POS_SIM: 0.8, + # Maximum negative similarity for incorrect labels. + # Should be -1.0 < ... < 1.0 for 'cosine' similarity type. + MAX_NEG_SIM: -0.4, + # If 'True' the algorithm only minimizes maximum similarity over + # incorrect intent labels, used only if 'loss_type' is set to 'margin'. + USE_MAX_NEG_SIM: True, + # If 'True' scale loss inverse proportionally to the confidence + # of the correct prediction + SCALE_LOSS: False, + # ## Regularization parameters + # The scale of regularization + REGULARIZATION_CONSTANT: 0.002, + # The scale of how important is to minimize the maximum similarity + # between embeddings of different labels, + # used only if 'loss_type' is set to 'margin'. + NEGATIVE_MARGIN_SCALE: 0.8, + # Dropout rate for encoder + DROP_RATE: 0.2, + # Dropout rate for attention + DROP_RATE_ATTENTION: 0, + # Fraction of trainable weights in internal layers. + CONNECTION_DENSITY: 0.2, + # If 'True' apply dropout to sparse input tensors + SPARSE_INPUT_DROPOUT: True, + # If 'True' apply dropout to dense input tensors + DENSE_INPUT_DROPOUT: True, + # ## Evaluation parameters + # How often calculate validation accuracy. + # Small values may hurt performance. + EVAL_NUM_EPOCHS: 20, + # How many examples to use for hold out validation set + # Large values may hurt performance, e.g. model accuracy. + # Set to 0 for no validation. + EVAL_NUM_EXAMPLES: 0, + # ## Model config + # If 'True' intent classification is trained and intent predicted. + INTENT_CLASSIFICATION: True, + # If 'True' named entity recognition is trained and entities predicted. + ENTITY_RECOGNITION: True, + # If 'True' random tokens of the input message will be masked and the model + # should predict those tokens. + MASKED_LM: False, + # 'BILOU_flag' determines whether to use BILOU tagging or not. + # If set to 'True' labelling is more rigorous, however more + # examples per entity are required. + # Rule of thumb: you should have more than 100 examples per entity. + BILOU_FLAG: True, + # If you want to use tensorboard to visualize training and validation + # metrics, set this option to a valid output directory. + TENSORBOARD_LOG_DIR: None, + # Define when training metrics for tensorboard should be logged. + # Either after every epoch or for every training step. + # Valid values: 'epoch' and 'batch' + TENSORBOARD_LOG_LEVEL: "epoch", + # Perform model checkpointing + CHECKPOINT_MODEL: False, + # Specify what features to use as sequence and sentence features + # By default all features in the pipeline are used. + FEATURIZERS: [], + # Split entities by comma, this makes sense e.g. for a list of ingredients + # in a recipie, but it doesn't make sense for the parts of an address + SPLIT_ENTITIES_BY_COMMA: True, + # If 'True' applies sigmoid on all similarity terms and adds + # it to the loss function to ensure that similarity values are + # approximately bounded. Used inside cross-entropy loss only. + CONSTRAIN_SIMILARITIES: False, + # Model confidence to be returned during inference. Currently, the only + # possible value is `softmax`. + MODEL_CONFIDENCE: SOFTMAX, + # Determines whether the confidences of the chosen top intents should be + # renormalized so that they sum up to 1. By default, we do not renormalize + # and return the confidences for the top intents as is. + # Note that renormalization only makes sense if confidences are generated + # via `softmax`. + RENORMALIZE_CONFIDENCES: False, + # Determines whether to construct the model graph or not. + # This is advantageous when the model is only trained or inferred for + # a few steps, as the compilation of the graph tends to take more time than + # running it. It is recommended to not adjust the optimization parameter. + RUN_EAGERLY: False, + # Determines whether the last batch should be dropped if it contains fewer + # than half a batch size of examples + DROP_SMALL_LAST_BATCH: False, + } + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + index_label_id_mapping: Optional[Dict[int, Text]] = None, + entity_tag_specs: Optional[List[EntityTagSpec]] = None, + model: Optional[RasaModel] = None, + sparse_feature_sizes: Optional[Dict[Text, Dict[Text, List[int]]]] = None, + ) -> None: + """Declare instance variables with default values.""" + if EPOCHS not in config: + rasa.shared.utils.io.raise_warning( + f"Please configure the number of '{EPOCHS}' in your configuration file." + f" We will change the default value of '{EPOCHS}' in the future to 1. " + ) + + self.component_config = config + self._model_storage = model_storage + self._resource = resource + self._execution_context = execution_context + + self._check_config_parameters() + + # transform numbers to labels + self.index_label_id_mapping = index_label_id_mapping or {} + + self._entity_tag_specs = entity_tag_specs + + self.model = model + + self.tmp_checkpoint_dir = None + if self.component_config[CHECKPOINT_MODEL]: + self.tmp_checkpoint_dir = Path(rasa.utils.io.create_temporary_directory()) + + self._label_data: Optional[RasaModelData] = None + self._data_example: Optional[Dict[Text, Dict[Text, List[FeatureArray]]]] = None + + self.split_entities_config = rasa.utils.train_utils.init_split_entities( + self.component_config[SPLIT_ENTITIES_BY_COMMA], + SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE, + ) + + self.finetune_mode = self._execution_context.is_finetuning + self._sparse_feature_sizes = sparse_feature_sizes + + # init helpers + def _check_masked_lm(self) -> None: + if ( + self.component_config[MASKED_LM] + and self.component_config[NUM_TRANSFORMER_LAYERS] == 0 + ): + raise ValueError( + f"If number of transformer layers is 0, " + f"'{MASKED_LM}' option should be 'False'." + ) + + def _check_share_hidden_layers_sizes(self) -> None: + if self.component_config.get(SHARE_HIDDEN_LAYERS): + first_hidden_layer_sizes = next( + iter(self.component_config[HIDDEN_LAYERS_SIZES].values()) + ) + # check that all hidden layer sizes are the same + identical_hidden_layer_sizes = all( + current_hidden_layer_sizes == first_hidden_layer_sizes + for current_hidden_layer_sizes in self.component_config[ + HIDDEN_LAYERS_SIZES + ].values() + ) + if not identical_hidden_layer_sizes: + raise ValueError( + f"If hidden layer weights are shared, " + f"{HIDDEN_LAYERS_SIZES} must coincide." + ) + + def _check_config_parameters(self) -> None: + self.component_config = train_utils.check_deprecated_options( + self.component_config + ) + + self._check_masked_lm() + self._check_share_hidden_layers_sizes() + + self.component_config = train_utils.update_confidence_type( + self.component_config + ) + + train_utils.validate_configuration_settings(self.component_config) + + self.component_config = train_utils.update_similarity_type( + self.component_config + ) + self.component_config = train_utils.update_evaluation_parameters( + self.component_config + ) + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> DIETClassifier: + """Creates a new untrained component (see parent class for full docstring).""" + return cls(config, model_storage, resource, execution_context) + + @property + def label_key(self) -> Optional[Text]: + """Return key if intent classification is activated.""" + return LABEL_KEY if self.component_config[INTENT_CLASSIFICATION] else None + + @property + def label_sub_key(self) -> Optional[Text]: + """Return sub key if intent classification is activated.""" + return LABEL_SUB_KEY if self.component_config[INTENT_CLASSIFICATION] else None + + @staticmethod + def model_class() -> Type[RasaModel]: + return DIET + + # training data helpers: + @staticmethod + def _label_id_index_mapping( + training_data: TrainingData, attribute: Text + ) -> Dict[Text, int]: + """Create label_id dictionary.""" + distinct_label_ids = { + example.get(attribute) for example in training_data.intent_examples + } - {None} + return { + label_id: idx for idx, label_id in enumerate(sorted(distinct_label_ids)) + } + + @staticmethod + def _invert_mapping(mapping: Dict) -> Dict: + return {value: key for key, value in mapping.items()} + + def _create_entity_tag_specs( + self, training_data: TrainingData + ) -> List[EntityTagSpec]: + """Create entity tag specifications with their respective tag id mappings.""" + _tag_specs = [] + + for tag_name in POSSIBLE_TAGS: + if self.component_config[BILOU_FLAG]: + tag_id_index_mapping = bilou_utils.build_tag_id_dict( + training_data, tag_name + ) + else: + tag_id_index_mapping = self._tag_id_index_mapping_for( + tag_name, training_data + ) + + if tag_id_index_mapping: + _tag_specs.append( + EntityTagSpec( + tag_name=tag_name, + tags_to_ids=tag_id_index_mapping, + ids_to_tags=self._invert_mapping(tag_id_index_mapping), + num_tags=len(tag_id_index_mapping), + ) + ) + + return _tag_specs + + @staticmethod + def _tag_id_index_mapping_for( + tag_name: Text, training_data: TrainingData + ) -> Optional[Dict[Text, int]]: + """Create mapping from tag name to id.""" + if tag_name == ENTITY_ATTRIBUTE_ROLE: + distinct_tags = training_data.entity_roles + elif tag_name == ENTITY_ATTRIBUTE_GROUP: + distinct_tags = training_data.entity_groups + else: + distinct_tags = training_data.entities + + distinct_tags = distinct_tags - {NO_ENTITY_TAG} - {None} + + if not distinct_tags: + return None + + tag_id_dict = { + tag_id: idx for idx, tag_id in enumerate(sorted(distinct_tags), 1) + } + # NO_ENTITY_TAG corresponds to non-entity which should correspond to 0 index + # needed for correct prediction for padding + tag_id_dict[NO_ENTITY_TAG] = 0 + + return tag_id_dict + + @staticmethod + def _find_example_for_label( + label: Text, examples: List[Message], attribute: Text + ) -> Optional[Message]: + for ex in examples: + if ex.get(attribute) == label: + return ex + return None + + def _check_labels_features_exist( + self, labels_example: List[Message], attribute: Text + ) -> bool: + """Checks if all labels have features set.""" + return all( + label_example.features_present( + attribute, self.component_config[FEATURIZERS] + ) + for label_example in labels_example + ) + + def _extract_features( + self, message: Message, attribute: Text + ) -> Dict[Text, Union[scipy.sparse.spmatrix, np.ndarray]]: + + ( + sparse_sequence_features, + sparse_sentence_features, + ) = message.get_sparse_features(attribute, self.component_config[FEATURIZERS]) + dense_sequence_features, dense_sentence_features = message.get_dense_features( + attribute, self.component_config[FEATURIZERS] + ) + + if dense_sequence_features is not None and sparse_sequence_features is not None: + if ( + dense_sequence_features.features.shape[0] + != sparse_sequence_features.features.shape[0] + ): + raise ValueError( + f"Sequence dimensions for sparse and dense sequence features " + f"don't coincide in '{message.get(TEXT)}'" + f"for attribute '{attribute}'." + ) + if dense_sentence_features is not None and sparse_sentence_features is not None: + if ( + dense_sentence_features.features.shape[0] + != sparse_sentence_features.features.shape[0] + ): + raise ValueError( + f"Sequence dimensions for sparse and dense sentence features " + f"don't coincide in '{message.get(TEXT)}'" + f"for attribute '{attribute}'." + ) + + # If we don't use the transformer and we don't want to do entity recognition, + # to speed up training take only the sentence features as feature vector. + # We would not make use of the sequence anyway in this setup. Carrying over + # those features to the actual training process takes quite some time. + if ( + self.component_config[NUM_TRANSFORMER_LAYERS] == 0 + and not self.component_config[ENTITY_RECOGNITION] + and attribute not in [INTENT, INTENT_RESPONSE_KEY] + ): + sparse_sequence_features = None + dense_sequence_features = None + + out = {} + + if sparse_sentence_features is not None: + out[f"{SPARSE}_{SENTENCE}"] = sparse_sentence_features.features + if sparse_sequence_features is not None: + out[f"{SPARSE}_{SEQUENCE}"] = sparse_sequence_features.features + if dense_sentence_features is not None: + out[f"{DENSE}_{SENTENCE}"] = dense_sentence_features.features + if dense_sequence_features is not None: + out[f"{DENSE}_{SEQUENCE}"] = dense_sequence_features.features + + return out + + def _check_input_dimension_consistency(self, model_data: RasaModelData) -> None: + """Checks if features have same dimensionality if hidden layers are shared.""" + if self.component_config.get(SHARE_HIDDEN_LAYERS): + num_text_sentence_features = model_data.number_of_units(TEXT, SENTENCE) + num_label_sentence_features = model_data.number_of_units(LABEL, SENTENCE) + num_text_sequence_features = model_data.number_of_units(TEXT, SEQUENCE) + num_label_sequence_features = model_data.number_of_units(LABEL, SEQUENCE) + + if (0 < num_text_sentence_features != num_label_sentence_features > 0) or ( + 0 < num_text_sequence_features != num_label_sequence_features > 0 + ): + raise ValueError( + "If embeddings are shared text features and label features " + "must coincide. Check the output dimensions of previous components." + ) + + def _extract_labels_precomputed_features( + self, label_examples: List[Message], attribute: Text = INTENT + ) -> Tuple[List[FeatureArray], List[FeatureArray]]: + """Collects precomputed encodings.""" + features = defaultdict(list) + + for e in label_examples: + label_features = self._extract_features(e, attribute) + for feature_key, feature_value in label_features.items(): + features[feature_key].append(feature_value) + sequence_features = [] + sentence_features = [] + for feature_name, feature_value in features.items(): + if SEQUENCE in feature_name: + sequence_features.append( + FeatureArray(np.array(feature_value), number_of_dimensions=3) + ) + else: + sentence_features.append( + FeatureArray(np.array(feature_value), number_of_dimensions=3) + ) + return sequence_features, sentence_features + + @staticmethod + def _compute_default_label_features( + labels_example: List[Message], + ) -> List[FeatureArray]: + """Computes one-hot representation for the labels.""" + logger.debug("No label features found. Computing default label features.") + + eye_matrix = np.eye(len(labels_example), dtype=np.float32) + # add sequence dimension to one-hot labels + return [ + FeatureArray( + np.array([np.expand_dims(a, 0) for a in eye_matrix]), + number_of_dimensions=3, + ) + ] + + def _create_label_data( + self, + training_data: TrainingData, + label_id_dict: Dict[Text, int], + attribute: Text, + ) -> RasaModelData: + """Create matrix with label_ids encoded in rows as bag of words. + + Find a training example for each label and get the encoded features + from the corresponding Message object. + If the features are already computed, fetch them from the message object + else compute a one hot encoding for the label as the feature vector. + """ + # Collect one example for each label + labels_idx_examples = [] + for label_name, idx in label_id_dict.items(): + label_example = self._find_example_for_label( + label_name, training_data.intent_examples, attribute + ) + labels_idx_examples.append((idx, label_example)) + + # Sort the list of tuples based on label_idx + labels_idx_examples = sorted(labels_idx_examples, key=lambda x: x[0]) + labels_example = [example for (_, example) in labels_idx_examples] + # Collect features, precomputed if they exist, else compute on the fly + if self._check_labels_features_exist(labels_example, attribute): + ( + sequence_features, + sentence_features, + ) = self._extract_labels_precomputed_features(labels_example, attribute) + else: + sequence_features = None + sentence_features = self._compute_default_label_features(labels_example) + + label_data = RasaModelData() + label_data.add_features(LABEL, SEQUENCE, sequence_features) + label_data.add_features(LABEL, SENTENCE, sentence_features) + if label_data.does_feature_not_exist( + LABEL, SENTENCE + ) and label_data.does_feature_not_exist(LABEL, SEQUENCE): + raise ValueError( + "No label features are present. Please check your configuration file." + ) + + label_ids = np.array([idx for (idx, _) in labels_idx_examples]) + # explicitly add last dimension to label_ids + # to track correctly dynamic sequences + label_data.add_features( + LABEL_KEY, + LABEL_SUB_KEY, + [ + FeatureArray( + np.expand_dims(label_ids, -1), + number_of_dimensions=2, + ) + ], + ) + + label_data.add_lengths(LABEL, SEQUENCE_LENGTH, LABEL, SEQUENCE) + + return label_data + + def _use_default_label_features(self, label_ids: np.ndarray) -> List[FeatureArray]: + if self._label_data is None: + return [] + + feature_arrays = self._label_data.get(LABEL, SENTENCE) + all_label_features = feature_arrays[0] + return [ + FeatureArray( + np.array([all_label_features[label_id] for label_id in label_ids]), + number_of_dimensions=all_label_features.number_of_dimensions, + ) + ] + + def _create_model_data( + self, + training_data: List[Message], + label_id_dict: Optional[Dict[Text, int]] = None, + label_attribute: Optional[Text] = None, + training: bool = True, + ) -> RasaModelData: + """Prepare data for training and create a RasaModelData object.""" + from rasa.utils.tensorflow import model_data_utils + + attributes_to_consider = [TEXT] + if training and self.component_config[INTENT_CLASSIFICATION]: + # we don't have any intent labels during prediction, just add them during + # training + attributes_to_consider.append(label_attribute) + if ( + training + and self.component_config[ENTITY_RECOGNITION] + and self._entity_tag_specs + ): + # Add entities as labels only during training and only if there was + # training data added for entities with DIET configured to predict entities. + attributes_to_consider.append(ENTITIES) + + if training and label_attribute is not None: + # only use those training examples that have the label_attribute set + # during training + training_data = [ + example for example in training_data if label_attribute in example.data + ] + + training_data = [ + message + for message in training_data + if message.features_present( + attribute=TEXT, featurizers=self.component_config.get(FEATURIZERS) + ) + ] + + if not training_data: + # no training data are present to train + return RasaModelData() + + ( + features_for_examples, + sparse_feature_sizes, + ) = model_data_utils.featurize_training_examples( + training_data, + attributes_to_consider, + entity_tag_specs=self._entity_tag_specs, + featurizers=self.component_config[FEATURIZERS], + bilou_tagging=self.component_config[BILOU_FLAG], + ) + attribute_data, _ = model_data_utils.convert_to_data_format( + features_for_examples, consider_dialogue_dimension=False + ) + + model_data = RasaModelData( + label_key=self.label_key, label_sub_key=self.label_sub_key + ) + model_data.add_data(attribute_data) + model_data.add_lengths(TEXT, SEQUENCE_LENGTH, TEXT, SEQUENCE) + # Current implementation doesn't yet account for updating sparse + # feature sizes of label attributes. That's why we remove them. + sparse_feature_sizes = self._remove_label_sparse_feature_sizes( + sparse_feature_sizes=sparse_feature_sizes, label_attribute=label_attribute + ) + model_data.add_sparse_feature_sizes(sparse_feature_sizes) + + self._add_label_features( + model_data, training_data, label_attribute, label_id_dict, training + ) + + # make sure all keys are in the same order during training and prediction + # as we rely on the order of key and sub-key when constructing the actual + # tensors from the model data + model_data.sort() + + return model_data + + @staticmethod + def _remove_label_sparse_feature_sizes( + sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + label_attribute: Optional[Text] = None, + ) -> Dict[Text, Dict[Text, List[int]]]: + + if label_attribute in sparse_feature_sizes: + del sparse_feature_sizes[label_attribute] + return sparse_feature_sizes + + def _add_label_features( + self, + model_data: RasaModelData, + training_data: List[Message], + label_attribute: Text, + label_id_dict: Dict[Text, int], + training: bool = True, + ) -> None: + label_ids = [] + if training and self.component_config[INTENT_CLASSIFICATION]: + for example in training_data: + if example.get(label_attribute): + label_ids.append(label_id_dict[example.get(label_attribute)]) + # explicitly add last dimension to label_ids + # to track correctly dynamic sequences + model_data.add_features( + LABEL_KEY, + LABEL_SUB_KEY, + [ + FeatureArray( + np.expand_dims(label_ids, -1), + number_of_dimensions=2, + ) + ], + ) + + if ( + label_attribute + and model_data.does_feature_not_exist(label_attribute, SENTENCE) + and model_data.does_feature_not_exist(label_attribute, SEQUENCE) + ): + # no label features are present, get default features from _label_data + model_data.add_features( + LABEL, SENTENCE, self._use_default_label_features(np.array(label_ids)) + ) + + # as label_attribute can have different values, e.g. INTENT or RESPONSE, + # copy over the features to the LABEL key to make + # it easier to access the label features inside the model itself + model_data.update_key(label_attribute, SENTENCE, LABEL, SENTENCE) + model_data.update_key(label_attribute, SEQUENCE, LABEL, SEQUENCE) + model_data.update_key(label_attribute, MASK, LABEL, MASK) + + model_data.add_lengths(LABEL, SEQUENCE_LENGTH, LABEL, SEQUENCE) + + # train helpers + def preprocess_train_data(self, training_data: TrainingData) -> RasaModelData: + """Prepares data for training. + + Performs sanity checks on training data, extracts encodings for labels. + """ + if ( + self.component_config[BILOU_FLAG] + and self.component_config[ENTITY_RECOGNITION] + ): + bilou_utils.apply_bilou_schema(training_data) + + label_id_index_mapping = self._label_id_index_mapping( + training_data, attribute=INTENT + ) + + if not label_id_index_mapping: + # no labels are present to train + return RasaModelData() + + self.index_label_id_mapping = self._invert_mapping(label_id_index_mapping) + + self._label_data = self._create_label_data( + training_data, label_id_index_mapping, attribute=INTENT + ) + + self._entity_tag_specs = self._create_entity_tag_specs(training_data) + + label_attribute = ( + INTENT if self.component_config[INTENT_CLASSIFICATION] else None + ) + model_data = self._create_model_data( + training_data.nlu_examples, + label_id_index_mapping, + label_attribute=label_attribute, + ) + + self._check_input_dimension_consistency(model_data) + + return model_data + + @staticmethod + def _check_enough_labels(model_data: RasaModelData) -> bool: + return len(np.unique(model_data.get(LABEL_KEY, LABEL_SUB_KEY))) >= 2 + + def train(self, training_data: TrainingData) -> Resource: + """Train the embedding intent classifier on a data set.""" + model_data = self.preprocess_train_data(training_data) + if model_data.is_empty(): + logger.debug( + f"Cannot train '{self.__class__.__name__}'. No data was provided. " + f"Skipping training of the classifier." + ) + return self._resource + + if not self.model and self.finetune_mode: + raise rasa.shared.exceptions.InvalidParameterException( + f"{self.__class__.__name__} was instantiated " + f"with `model=None` and `finetune_mode=True`. " + f"This is not a valid combination as the component " + f"needs an already instantiated and trained model " + f"to continue training in finetune mode." + ) + + if self.component_config.get(INTENT_CLASSIFICATION): + if not self._check_enough_labels(model_data): + logger.error( + f"Cannot train '{self.__class__.__name__}'. " + f"Need at least 2 different intent classes. " + f"Skipping training of classifier." + ) + return self._resource + if self.component_config.get(ENTITY_RECOGNITION): + self.check_correct_entity_annotations(training_data) + + # keep one example for persisting and loading + self._data_example = model_data.first_data_example() + + if not self.finetune_mode: + # No pre-trained model to load from. Create a new instance of the model. + self.model = self._instantiate_model_class(model_data) + self.model.compile( + optimizer=tf.keras.optimizers.Adam( + self.component_config[LEARNING_RATE] + ), + run_eagerly=self.component_config[RUN_EAGERLY], + ) + else: + if self.model is None: + raise ModelNotFound("Model could not be found. ") + + self.model.adjust_for_incremental_training( + data_example=self._data_example, + new_sparse_feature_sizes=model_data.get_sparse_feature_sizes(), + old_sparse_feature_sizes=self._sparse_feature_sizes, + ) + self._sparse_feature_sizes = model_data.get_sparse_feature_sizes() + + data_generator, validation_data_generator = train_utils.create_data_generators( + model_data, + self.component_config[BATCH_SIZES], + self.component_config[EPOCHS], + self.component_config[BATCH_STRATEGY], + self.component_config[EVAL_NUM_EXAMPLES], + self.component_config[RANDOM_SEED], + drop_small_last_batch=self.component_config[DROP_SMALL_LAST_BATCH], + ) + callbacks = train_utils.create_common_callbacks( + self.component_config[EPOCHS], + self.component_config[TENSORBOARD_LOG_DIR], + self.component_config[TENSORBOARD_LOG_LEVEL], + self.tmp_checkpoint_dir, + ) + + self.model.fit( + data_generator, + epochs=self.component_config[EPOCHS], + validation_data=validation_data_generator, + validation_freq=self.component_config[EVAL_NUM_EPOCHS], + callbacks=callbacks, + verbose=False, + shuffle=False, # we use custom shuffle inside data generator + ) + + self.persist() + + return self._resource + + # process helpers + def _predict( + self, message: Message + ) -> Optional[Dict[Text, Union[tf.Tensor, Dict[Text, tf.Tensor]]]]: + if self.model is None: + logger.debug( + f"There is no trained model for '{self.__class__.__name__}': The " + f"component is either not trained or didn't receive enough training " + f"data." + ) + return None + + # create session data from message and convert it into a batch of 1 + model_data = self._create_model_data([message], training=False) + if model_data.is_empty(): + return None + return self.model.run_inference(model_data) + + def _predict_label( + self, predict_out: Optional[Dict[Text, tf.Tensor]] + ) -> Tuple[Dict[Text, Any], List[Dict[Text, Any]]]: + """Predicts the intent of the provided message.""" + label: Dict[Text, Any] = {"name": None, "confidence": 0.0} + label_ranking: List[Dict[Text, Any]] = [] + + if predict_out is None: + return label, label_ranking + + message_sim = predict_out["i_scores"] + message_sim = message_sim.flatten() # sim is a matrix + + # if X contains all zeros do not predict some label + if message_sim.size == 0: + return label, label_ranking + + # rank the confidences + ranking_length = self.component_config[RANKING_LENGTH] + renormalize = ( + self.component_config[RENORMALIZE_CONFIDENCES] + and self.component_config[MODEL_CONFIDENCE] == SOFTMAX + ) + ranked_label_indices, message_sim = train_utils.rank_and_mask( + message_sim, ranking_length=ranking_length, renormalize=renormalize + ) + + # construct the label and ranking + casted_message_sim: List[float] = message_sim.tolist() # np.float to float + top_label_idx = ranked_label_indices[0] + label = { + "name": self.index_label_id_mapping[top_label_idx], + "confidence": casted_message_sim[top_label_idx], + } + + ranking = [(idx, casted_message_sim[idx]) for idx in ranked_label_indices] + label_ranking = [ + {"name": self.index_label_id_mapping[label_idx], "confidence": score} + for label_idx, score in ranking + ] + + return label, label_ranking + + def _predict_entities( + self, predict_out: Optional[Dict[Text, tf.Tensor]], message: Message + ) -> List[Dict]: + if predict_out is None: + return [] + + predicted_tags, confidence_values = train_utils.entity_label_to_tags( + predict_out, self._entity_tag_specs, self.component_config[BILOU_FLAG] + ) + + entities = self.convert_predictions_into_entities( + message.get(TEXT), + message.get(TOKENS_NAMES[TEXT], []), + predicted_tags, + self.split_entities_config, + confidence_values, + ) + + entities = self.add_extractor_name(entities) + entities = message.get(ENTITIES, []) + entities + + return entities + + def process(self, messages: List[Message]) -> List[Message]: + """Augments the message with intents, entities, and diagnostic data.""" + for message in messages: + out = self._predict(message) + + if self.component_config[INTENT_CLASSIFICATION]: + label, label_ranking = self._predict_label(out) + + message.set(INTENT, label, add_to_output=True) + message.set("intent_ranking", label_ranking, add_to_output=True) + + if self.component_config[ENTITY_RECOGNITION]: + entities = self._predict_entities(out, message) + + message.set(ENTITIES, entities, add_to_output=True) + + if out and self._execution_context.should_add_diagnostic_data: + message.add_diagnostic_data( + self._execution_context.node_name, out.get(DIAGNOSTIC_DATA) + ) + + return messages + + def persist(self) -> None: + """Persist this model into the passed directory.""" + if self.model is None: + return None + + with self._model_storage.write_to(self._resource) as model_path: + file_name = self.__class__.__name__ + tf_model_file = model_path / f"{file_name}.tf_model" + + rasa.shared.utils.io.create_directory_for_file(tf_model_file) + + if self.component_config[CHECKPOINT_MODEL] and self.tmp_checkpoint_dir: + self.model.load_weights(self.tmp_checkpoint_dir / "checkpoint.tf_model") + # Save an empty file to flag that this model has been + # produced using checkpointing + checkpoint_marker = model_path / f"{file_name}.from_checkpoint.pkl" + checkpoint_marker.touch() + + self.model.save(str(tf_model_file)) + + # save data example + serialize_nested_feature_arrays( + self._data_example, + model_path / f"{file_name}.data_example.st", + model_path / f"{file_name}.data_example_metadata.json", + ) + # save label data + serialize_nested_feature_arrays( + dict(self._label_data.data) if self._label_data is not None else {}, + model_path / f"{file_name}.label_data.st", + model_path / f"{file_name}.label_data_metadata.json", + ) + + rasa.shared.utils.io.dump_obj_as_json_to_file( + model_path / f"{file_name}.sparse_feature_sizes.json", + self._sparse_feature_sizes, + ) + rasa.shared.utils.io.dump_obj_as_json_to_file( + model_path / f"{file_name}.index_label_id_mapping.json", + self.index_label_id_mapping, + ) + + entity_tag_specs = ( + [tag_spec._asdict() for tag_spec in self._entity_tag_specs] + if self._entity_tag_specs + else [] + ) + rasa.shared.utils.io.dump_obj_as_json_to_file( + model_path / f"{file_name}.entity_tag_specs.json", entity_tag_specs + ) + + @classmethod + def load( + cls: Type[DIETClassifierT], + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> DIETClassifierT: + """Loads a policy from the storage (see parent class for full docstring).""" + try: + with model_storage.read_from(resource) as model_path: + return cls._load( + model_path, config, model_storage, resource, execution_context + ) + except ValueError: + logger.debug( + f"Failed to load {cls.__class__.__name__} from model storage. Resource " + f"'{resource.name}' doesn't exist." + ) + return cls(config, model_storage, resource, execution_context) + + @classmethod + def _load( + cls: Type[DIETClassifierT], + model_path: Path, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> DIETClassifierT: + """Loads the trained model from the provided directory.""" + ( + index_label_id_mapping, + entity_tag_specs, + label_data, + data_example, + sparse_feature_sizes, + ) = cls._load_from_files(model_path) + + config = train_utils.update_confidence_type(config) + config = train_utils.update_similarity_type(config) + + model = cls._load_model( + entity_tag_specs, + label_data, + config, + data_example, + model_path, + finetune_mode=execution_context.is_finetuning, + ) + + return cls( + config=config, + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + index_label_id_mapping=index_label_id_mapping, + entity_tag_specs=entity_tag_specs, + model=model, + sparse_feature_sizes=sparse_feature_sizes, + ) + + @classmethod + def _load_from_files( + cls, model_path: Path + ) -> Tuple[ + Dict[int, Text], + List[EntityTagSpec], + RasaModelData, + Dict[Text, Dict[Text, List[FeatureArray]]], + Dict[Text, Dict[Text, List[int]]], + ]: + file_name = cls.__name__ + + # load data example + data_example = deserialize_nested_feature_arrays( + str(model_path / f"{file_name}.data_example.st"), + str(model_path / f"{file_name}.data_example_metadata.json"), + ) + # load label data + loaded_label_data = deserialize_nested_feature_arrays( + str(model_path / f"{file_name}.label_data.st"), + str(model_path / f"{file_name}.label_data_metadata.json"), + ) + label_data = RasaModelData(data=loaded_label_data) + + sparse_feature_sizes = rasa.shared.utils.io.read_json_file( + model_path / f"{file_name}.sparse_feature_sizes.json" + ) + index_label_id_mapping = rasa.shared.utils.io.read_json_file( + model_path / f"{file_name}.index_label_id_mapping.json" + ) + entity_tag_specs = rasa.shared.utils.io.read_json_file( + model_path / f"{file_name}.entity_tag_specs.json" + ) + entity_tag_specs = [ + EntityTagSpec( + tag_name=tag_spec["tag_name"], + ids_to_tags={ + int(key): value for key, value in tag_spec["ids_to_tags"].items() + }, + tags_to_ids={ + key: int(value) for key, value in tag_spec["tags_to_ids"].items() + }, + num_tags=tag_spec["num_tags"], + ) + for tag_spec in entity_tag_specs + ] + + index_label_id_mapping = { + int(key): value for key, value in index_label_id_mapping.items() + } + + return ( + index_label_id_mapping, + entity_tag_specs, + label_data, + data_example, + sparse_feature_sizes, + ) + + @classmethod + def _load_model( + cls, + entity_tag_specs: List[EntityTagSpec], + label_data: RasaModelData, + config: Dict[Text, Any], + data_example: Dict[Text, Dict[Text, List[FeatureArray]]], + model_path: Path, + finetune_mode: bool = False, + ) -> "RasaModel": + file_name = cls.__name__ + tf_model_file = model_path / f"{file_name}.tf_model" + + label_key = LABEL_KEY if config[INTENT_CLASSIFICATION] else None + label_sub_key = LABEL_SUB_KEY if config[INTENT_CLASSIFICATION] else None + + model_data_example = RasaModelData( + label_key=label_key, label_sub_key=label_sub_key, data=data_example + ) + + model = cls._load_model_class( + tf_model_file, + model_data_example, + label_data, + entity_tag_specs, + config, + finetune_mode=finetune_mode, + ) + + return model + + @classmethod + def _load_model_class( + cls, + tf_model_file: Text, + model_data_example: RasaModelData, + label_data: RasaModelData, + entity_tag_specs: List[EntityTagSpec], + config: Dict[Text, Any], + finetune_mode: bool, + ) -> "RasaModel": + + predict_data_example = RasaModelData( + label_key=model_data_example.label_key, + data={ + feature_name: features + for feature_name, features in model_data_example.items() + if TEXT in feature_name + }, + ) + + return cls.model_class().load( + tf_model_file, + model_data_example, + predict_data_example, + data_signature=model_data_example.get_signature(), + label_data=label_data, + entity_tag_specs=entity_tag_specs, + config=copy.deepcopy(config), + finetune_mode=finetune_mode, + ) + + def _instantiate_model_class(self, model_data: RasaModelData) -> "RasaModel": + return self.model_class()( + data_signature=model_data.get_signature(), + label_data=self._label_data, + entity_tag_specs=self._entity_tag_specs, + config=self.component_config, + ) + + +class DIET(TransformerRasaModel): + def __init__( + self, + data_signature: Dict[Text, Dict[Text, List[FeatureSignature]]], + label_data: RasaModelData, + entity_tag_specs: Optional[List[EntityTagSpec]], + config: Dict[Text, Any], + ) -> None: + # create entity tag spec before calling super otherwise building the model + # will fail + super().__init__("DIET", config, data_signature, label_data) + self._entity_tag_specs = self._ordered_tag_specs(entity_tag_specs) + + self.predict_data_signature = { + feature_name: features + for feature_name, features in data_signature.items() + if TEXT in feature_name + } + + # tf training + self._create_metrics() + self._update_metrics_to_log() + + # needed for efficient prediction + self.all_labels_embed: Optional[tf.Tensor] = None + + self._prepare_layers() + + @staticmethod + def _ordered_tag_specs( + entity_tag_specs: Optional[List[EntityTagSpec]], + ) -> List[EntityTagSpec]: + """Ensure that order of entity tag specs matches CRF layer order.""" + if entity_tag_specs is None: + return [] + + crf_order = [ + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_ROLE, + ENTITY_ATTRIBUTE_GROUP, + ] + + ordered_tag_spec = [] + + for tag_name in crf_order: + for tag_spec in entity_tag_specs: + if tag_name == tag_spec.tag_name: + ordered_tag_spec.append(tag_spec) + + return ordered_tag_spec + + def _check_data(self) -> None: + if TEXT not in self.data_signature: + raise InvalidConfigException( + f"No text features specified. " + f"Cannot train '{self.__class__.__name__}' model." + ) + if self.config[INTENT_CLASSIFICATION]: + if LABEL not in self.data_signature: + raise InvalidConfigException( + f"No label features specified. " + f"Cannot train '{self.__class__.__name__}' model." + ) + + if self.config[SHARE_HIDDEN_LAYERS]: + different_sentence_signatures = False + different_sequence_signatures = False + if ( + SENTENCE in self.data_signature[TEXT] + and SENTENCE in self.data_signature[LABEL] + ): + different_sentence_signatures = ( + self.data_signature[TEXT][SENTENCE] + != self.data_signature[LABEL][SENTENCE] + ) + if ( + SEQUENCE in self.data_signature[TEXT] + and SEQUENCE in self.data_signature[LABEL] + ): + different_sequence_signatures = ( + self.data_signature[TEXT][SEQUENCE] + != self.data_signature[LABEL][SEQUENCE] + ) + + if different_sentence_signatures or different_sequence_signatures: + raise ValueError( + "If hidden layer weights are shared, data signatures " + "for text_features and label_features must coincide." + ) + + if self.config[ENTITY_RECOGNITION] and ( + ENTITIES not in self.data_signature + or ENTITY_ATTRIBUTE_TYPE not in self.data_signature[ENTITIES] + ): + logger.debug( + f"You specified '{self.__class__.__name__}' to train entities, but " + f"no entities are present in the training data. Skipping training of " + f"entities." + ) + self.config[ENTITY_RECOGNITION] = False + + def _create_metrics(self) -> None: + # self.metrics will have the same order as they are created + # so create loss metrics first to output losses first + self.mask_loss = tf.keras.metrics.Mean(name="m_loss") + self.intent_loss = tf.keras.metrics.Mean(name="i_loss") + self.entity_loss = tf.keras.metrics.Mean(name="e_loss") + self.entity_group_loss = tf.keras.metrics.Mean(name="g_loss") + self.entity_role_loss = tf.keras.metrics.Mean(name="r_loss") + # create accuracy metrics second to output accuracies second + self.mask_acc = tf.keras.metrics.Mean(name="m_acc") + self.intent_acc = tf.keras.metrics.Mean(name="i_acc") + self.entity_f1 = tf.keras.metrics.Mean(name="e_f1") + self.entity_group_f1 = tf.keras.metrics.Mean(name="g_f1") + self.entity_role_f1 = tf.keras.metrics.Mean(name="r_f1") + + def _update_metrics_to_log(self) -> None: + debug_log_level = logging.getLogger("rasa").level == logging.DEBUG + + if self.config[MASKED_LM]: + self.metrics_to_log.append("m_acc") + if debug_log_level: + self.metrics_to_log.append("m_loss") + if self.config[INTENT_CLASSIFICATION]: + self.metrics_to_log.append("i_acc") + if debug_log_level: + self.metrics_to_log.append("i_loss") + if self.config[ENTITY_RECOGNITION]: + for tag_spec in self._entity_tag_specs: + if tag_spec.num_tags != 0: + name = tag_spec.tag_name + self.metrics_to_log.append(f"{name[0]}_f1") + if debug_log_level: + self.metrics_to_log.append(f"{name[0]}_loss") + + self._log_metric_info() + + def _log_metric_info(self) -> None: + metric_name = { + "t": "total", + "i": "intent", + "e": "entity", + "m": "mask", + "r": "role", + "g": "group", + } + logger.debug("Following metrics will be logged during training: ") + for metric in self.metrics_to_log: + parts = metric.split("_") + name = f"{metric_name[parts[0]]} {parts[1]}" + logger.debug(f" {metric} ({name})") + + def _prepare_layers(self) -> None: + # For user text, prepare layers that combine different feature types, embed + # everything using a transformer and optionally also do masked language + # modeling. + self.text_name = TEXT + self._tf_layers[ + f"sequence_layer.{self.text_name}" + ] = rasa_layers.RasaSequenceLayer( + self.text_name, self.data_signature[self.text_name], self.config + ) + if self.config[MASKED_LM]: + self._prepare_mask_lm_loss(self.text_name) + + # Intent labels are treated similarly to user text but without the transformer, + # without masked language modelling, and with no dropout applied to the + # individual features, only to the overall label embedding after all label + # features have been combined. + if self.config[INTENT_CLASSIFICATION]: + self.label_name = TEXT if self.config[SHARE_HIDDEN_LAYERS] else LABEL + + # disable input dropout applied to sparse and dense label features + label_config = self.config.copy() + label_config.update( + {SPARSE_INPUT_DROPOUT: False, DENSE_INPUT_DROPOUT: False} + ) + + self._tf_layers[ + f"feature_combining_layer.{self.label_name}" + ] = rasa_layers.RasaFeatureCombiningLayer( + self.label_name, self.label_signature[self.label_name], label_config + ) + + self._prepare_ffnn_layer( + self.label_name, + self.config[HIDDEN_LAYERS_SIZES][self.label_name], + self.config[DROP_RATE], + ) + + self._prepare_label_classification_layers(predictor_attribute=TEXT) + + if self.config[ENTITY_RECOGNITION]: + self._prepare_entity_recognition_layers() + + def _prepare_mask_lm_loss(self, name: Text) -> None: + # for embedding predicted tokens at masked positions + self._prepare_embed_layers(f"{name}_lm_mask") + + # for embedding the true tokens that got masked + self._prepare_embed_layers(f"{name}_golden_token") + + # mask loss is additional loss + # set scaling to False, so that it doesn't overpower other losses + self._prepare_dot_product_loss(f"{name}_mask", scale_loss=False) + + def _create_bow( + self, + sequence_features: List[Union[tf.Tensor, tf.SparseTensor]], + sentence_features: List[Union[tf.Tensor, tf.SparseTensor]], + sequence_feature_lengths: tf.Tensor, + name: Text, + ) -> tf.Tensor: + + x, _ = self._tf_layers[f"feature_combining_layer.{name}"]( + (sequence_features, sentence_features, sequence_feature_lengths), + training=self._training, + ) + + # convert to bag-of-words by summing along the sequence dimension + x = tf.reduce_sum(x, axis=1) + + return self._tf_layers[f"ffnn.{name}"](x, self._training) + + def _create_all_labels(self) -> Tuple[tf.Tensor, tf.Tensor]: + all_label_ids = self.tf_label_data[LABEL_KEY][LABEL_SUB_KEY][0] + + sequence_feature_lengths = self._get_sequence_feature_lengths( + self.tf_label_data, LABEL + ) + + x = self._create_bow( + self.tf_label_data[LABEL][SEQUENCE], + self.tf_label_data[LABEL][SENTENCE], + sequence_feature_lengths, + self.label_name, + ) + all_labels_embed = self._tf_layers[f"embed.{LABEL}"](x) + + return all_label_ids, all_labels_embed + + def _mask_loss( + self, + outputs: tf.Tensor, + inputs: tf.Tensor, + seq_ids: tf.Tensor, + mlm_mask_boolean: tf.Tensor, + name: Text, + ) -> tf.Tensor: + # make sure there is at least one element in the mask + mlm_mask_boolean = tf.cond( + tf.reduce_any(mlm_mask_boolean), + lambda: mlm_mask_boolean, + lambda: tf.scatter_nd([[0, 0, 0]], [True], tf.shape(mlm_mask_boolean)), + ) + + mlm_mask_boolean = tf.squeeze(mlm_mask_boolean, -1) + + # Pick elements that were masked, throwing away the batch & sequence dimension + # and effectively switching from shape (batch_size, sequence_length, units) to + # (num_masked_elements, units). + outputs = tf.boolean_mask(outputs, mlm_mask_boolean) + inputs = tf.boolean_mask(inputs, mlm_mask_boolean) + ids = tf.boolean_mask(seq_ids, mlm_mask_boolean) + + tokens_predicted_embed = self._tf_layers[f"embed.{name}_lm_mask"](outputs) + tokens_true_embed = self._tf_layers[f"embed.{name}_golden_token"](inputs) + + # To limit the otherwise computationally expensive loss calculation, we + # constrain the label space in MLM (i.e. token space) to only those tokens that + # were masked in this batch. Hence the reduced list of token embeddings + # (tokens_true_embed) and the reduced list of labels (ids) are passed as + # all_labels_embed and all_labels, respectively. In the future, we could be less + # restrictive and construct a slightly bigger label space which could include + # tokens not masked in the current batch too. + return self._tf_layers[f"loss.{name}_mask"]( + inputs_embed=tokens_predicted_embed, + labels_embed=tokens_true_embed, + labels=ids, + all_labels_embed=tokens_true_embed, + all_labels=ids, + ) + + def _calculate_label_loss( + self, text_features: tf.Tensor, label_features: tf.Tensor, label_ids: tf.Tensor + ) -> tf.Tensor: + all_label_ids, all_labels_embed = self._create_all_labels() + + text_embed = self._tf_layers[f"embed.{TEXT}"](text_features) + label_embed = self._tf_layers[f"embed.{LABEL}"](label_features) + + return self._tf_layers[f"loss.{LABEL}"]( + text_embed, label_embed, label_ids, all_labels_embed, all_label_ids + ) + + def batch_loss( + self, batch_in: Union[Tuple[tf.Tensor, ...], Tuple[np.ndarray, ...]] + ) -> tf.Tensor: + """Calculates the loss for the given batch. + + Args: + batch_in: The batch. + + Returns: + The loss of the given batch. + """ + tf_batch_data = self.batch_to_model_data_format(batch_in, self.data_signature) + + sequence_feature_lengths = self._get_sequence_feature_lengths( + tf_batch_data, TEXT + ) + + ( + text_transformed, + text_in, + mask_combined_sequence_sentence, + text_seq_ids, + mlm_mask_boolean_text, + _, + ) = self._tf_layers[f"sequence_layer.{self.text_name}"]( + ( + tf_batch_data[TEXT][SEQUENCE], + tf_batch_data[TEXT][SENTENCE], + sequence_feature_lengths, + ), + training=self._training, + ) + + losses = [] + + # Lengths of sequences in case of sentence-level features are always 1, but they + # can effectively be 0 if sentence-level features aren't present. + sentence_feature_lengths = self._get_sentence_feature_lengths( + tf_batch_data, TEXT + ) + + combined_sequence_sentence_feature_lengths = ( + sequence_feature_lengths + sentence_feature_lengths + ) + + if self.config[MASKED_LM] and self._training: + loss, acc = self._mask_loss( + text_transformed, text_in, text_seq_ids, mlm_mask_boolean_text, TEXT + ) + self.mask_loss.update_state(loss) + self.mask_acc.update_state(acc) + losses.append(loss) + + if self.config[INTENT_CLASSIFICATION]: + loss = self._batch_loss_intent( + combined_sequence_sentence_feature_lengths, + text_transformed, + tf_batch_data, + ) + losses.append(loss) + + if self.config[ENTITY_RECOGNITION]: + losses += self._batch_loss_entities( + mask_combined_sequence_sentence, + sequence_feature_lengths, + text_transformed, + tf_batch_data, + ) + + return tf.math.add_n(losses) + + def _batch_loss_intent( + self, + combined_sequence_sentence_feature_lengths_text: tf.Tensor, + text_transformed: tf.Tensor, + tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], + ) -> tf.Tensor: + # get sentence features vector for intent classification + sentence_vector = self._last_token( + text_transformed, combined_sequence_sentence_feature_lengths_text + ) + + sequence_feature_lengths_label = self._get_sequence_feature_lengths( + tf_batch_data, LABEL + ) + + label_ids = tf_batch_data[LABEL_KEY][LABEL_SUB_KEY][0] + label = self._create_bow( + tf_batch_data[LABEL][SEQUENCE], + tf_batch_data[LABEL][SENTENCE], + sequence_feature_lengths_label, + self.label_name, + ) + loss, acc = self._calculate_label_loss(sentence_vector, label, label_ids) + + self._update_label_metrics(loss, acc) + + return loss + + def _update_label_metrics(self, loss: tf.Tensor, acc: tf.Tensor) -> None: + + self.intent_loss.update_state(loss) + self.intent_acc.update_state(acc) + + def _batch_loss_entities( + self, + mask_combined_sequence_sentence: tf.Tensor, + sequence_feature_lengths: tf.Tensor, + text_transformed: tf.Tensor, + tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], + ) -> List[tf.Tensor]: + losses = [] + + entity_tags = None + + for tag_spec in self._entity_tag_specs: + if tag_spec.num_tags == 0: + continue + + tag_ids = tf_batch_data[ENTITIES][tag_spec.tag_name][0] + # add a zero (no entity) for the sentence features to match the shape of + # inputs + tag_ids = tf.pad(tag_ids, [[0, 0], [0, 1], [0, 0]]) + + loss, f1, _logits = self._calculate_entity_loss( + text_transformed, + tag_ids, + mask_combined_sequence_sentence, + sequence_feature_lengths, + tag_spec.tag_name, + entity_tags, + ) + + if tag_spec.tag_name == ENTITY_ATTRIBUTE_TYPE: + # use the entity tags as additional input for the role + # and group CRF + entity_tags = tf.one_hot( + tf.cast(tag_ids[:, :, 0], tf.int32), depth=tag_spec.num_tags + ) + + self._update_entity_metrics(loss, f1, tag_spec.tag_name) + + losses.append(loss) + + return losses + + def _update_entity_metrics( + self, loss: tf.Tensor, f1: tf.Tensor, tag_name: Text + ) -> None: + if tag_name == ENTITY_ATTRIBUTE_TYPE: + self.entity_loss.update_state(loss) + self.entity_f1.update_state(f1) + elif tag_name == ENTITY_ATTRIBUTE_GROUP: + self.entity_group_loss.update_state(loss) + self.entity_group_f1.update_state(f1) + elif tag_name == ENTITY_ATTRIBUTE_ROLE: + self.entity_role_loss.update_state(loss) + self.entity_role_f1.update_state(f1) + + def prepare_for_predict(self) -> None: + """Prepares the model for prediction.""" + if self.config[INTENT_CLASSIFICATION]: + _, self.all_labels_embed = self._create_all_labels() + + def batch_predict( + self, batch_in: Union[Tuple[tf.Tensor, ...], Tuple[np.ndarray, ...]] + ) -> Dict[Text, tf.Tensor]: + """Predicts the output of the given batch. + + Args: + batch_in: The batch. + + Returns: + The output to predict. + """ + tf_batch_data = self.batch_to_model_data_format( + batch_in, self.predict_data_signature + ) + + sequence_feature_lengths = self._get_sequence_feature_lengths( + tf_batch_data, TEXT + ) + sentence_feature_lengths = self._get_sentence_feature_lengths( + tf_batch_data, TEXT + ) + + text_transformed, _, _, _, _, attention_weights = self._tf_layers[ + f"sequence_layer.{self.text_name}" + ]( + ( + tf_batch_data[TEXT][SEQUENCE], + tf_batch_data[TEXT][SENTENCE], + sequence_feature_lengths, + ), + training=self._training, + ) + predictions = { + DIAGNOSTIC_DATA: { + "attention_weights": attention_weights, + "text_transformed": text_transformed, + } + } + + if self.config[INTENT_CLASSIFICATION]: + predictions.update( + self._batch_predict_intents( + sequence_feature_lengths + sentence_feature_lengths, + text_transformed, + ) + ) + + if self.config[ENTITY_RECOGNITION]: + predictions.update( + self._batch_predict_entities(sequence_feature_lengths, text_transformed) + ) + + return predictions + + def _batch_predict_entities( + self, sequence_feature_lengths: tf.Tensor, text_transformed: tf.Tensor + ) -> Dict[Text, tf.Tensor]: + predictions: Dict[Text, tf.Tensor] = {} + + entity_tags = None + + for tag_spec in self._entity_tag_specs: + # skip crf layer if it was not trained + if tag_spec.num_tags == 0: + continue + + name = tag_spec.tag_name + _input = text_transformed + + if entity_tags is not None: + _tags = self._tf_layers[f"embed.{name}.tags"](entity_tags) + _input = tf.concat([_input, _tags], axis=-1) + + _logits = self._tf_layers[f"embed.{name}.logits"](_input) + pred_ids, confidences = self._tf_layers[f"crf.{name}"]( + _logits, sequence_feature_lengths + ) + + predictions[f"e_{name}_ids"] = pred_ids + predictions[f"e_{name}_scores"] = confidences + + if name == ENTITY_ATTRIBUTE_TYPE: + # use the entity tags as additional input for the role + # and group CRF + entity_tags = tf.one_hot( + tf.cast(pred_ids, tf.int32), depth=tag_spec.num_tags + ) + + return predictions + + def _batch_predict_intents( + self, + combined_sequence_sentence_feature_lengths: tf.Tensor, + text_transformed: tf.Tensor, + ) -> Dict[Text, tf.Tensor]: + + if self.all_labels_embed is None: + raise ValueError( + "The model was not prepared for prediction. " + "Call `prepare_for_predict` first." + ) + + # get sentence feature vector for intent classification + sentence_vector = self._last_token( + text_transformed, combined_sequence_sentence_feature_lengths + ) + sentence_vector_embed = self._tf_layers[f"embed.{TEXT}"](sentence_vector) + + _, scores = self._tf_layers[ + f"loss.{LABEL}" + ].get_similarities_and_confidences_from_embeddings( + sentence_vector_embed[:, tf.newaxis, :], + self.all_labels_embed[tf.newaxis, :, :], + ) + + return {"i_scores": scores} diff --git a/rasa/nlu/classifiers/fallback_classifier.py b/rasa/nlu/classifiers/fallback_classifier.py new file mode 100644 index 0000000..05d1dbc --- /dev/null +++ b/rasa/nlu/classifiers/fallback_classifier.py @@ -0,0 +1,192 @@ +from __future__ import annotations +import copy +import logging +from typing import Any, List, Text, Dict, Type, Union, Tuple, Optional + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.constants import DEFAULT_NLU_FALLBACK_INTENT_NAME +from rasa.core.constants import ( + DEFAULT_NLU_FALLBACK_THRESHOLD, + DEFAULT_NLU_FALLBACK_AMBIGUITY_THRESHOLD, +) +from rasa.nlu.classifiers.classifier import IntentClassifier +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.constants import ( + INTENT, + INTENT_NAME_KEY, + INTENT_RANKING_KEY, + PREDICTED_CONFIDENCE_KEY, +) + +THRESHOLD_KEY = "threshold" +AMBIGUITY_THRESHOLD_KEY = "ambiguity_threshold" + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER, is_trainable=False +) +class FallbackClassifier(GraphComponent, IntentClassifier): + """Handles incoming messages with low NLU confidence.""" + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [IntentClassifier] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """The component's default config (see parent class for full docstring).""" + # please make sure to update the docs when changing a default parameter + return { + # If all intent confidence scores are beyond this threshold, set the current + # intent to `FALLBACK_INTENT_NAME` + THRESHOLD_KEY: DEFAULT_NLU_FALLBACK_THRESHOLD, + # If the confidence scores for the top two intent predictions are closer + # than `AMBIGUITY_THRESHOLD_KEY`, + # then `FALLBACK_INTENT_NAME` is predicted. + AMBIGUITY_THRESHOLD_KEY: DEFAULT_NLU_FALLBACK_AMBIGUITY_THRESHOLD, + } + + def __init__(self, config: Dict[Text, Any]) -> None: + """Constructs a new fallback classifier.""" + self.component_config = config + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> FallbackClassifier: + """Creates a new component (see parent class for full docstring).""" + return cls(config) + + def process(self, messages: List[Message]) -> List[Message]: + """Process a list of incoming messages. + + This is the component's chance to process incoming + messages. The component can rely on + any context attribute to be present, that gets created + by a call to :meth:`rasa.nlu.components.Component.create` + of ANY component and + on any context attributes created by a call to + :meth:`rasa.nlu.components.Component.process` + of components previous to this one. + + Args: + messages: List containing :class: + `rasa.shared.nlu.training_data.message.Message` to process. + """ + for message in messages: + if not self._should_fallback(message): + continue + + # we assume that the fallback confidence + # is the same as the fallback threshold + confidence = self.component_config[THRESHOLD_KEY] + message.data[INTENT] = _fallback_intent(confidence) + message.data.setdefault(INTENT_RANKING_KEY, []) + message.data[INTENT_RANKING_KEY].insert(0, _fallback_intent(confidence)) + + return messages + + def _should_fallback(self, message: Message) -> bool: + """Check if the fallback intent should be predicted. + + Args: + message: The current message and its intent predictions. + + Returns: + `True` if the fallback intent should be predicted. + """ + intent_name = message.data[INTENT].get(INTENT_NAME_KEY) + below_threshold, nlu_confidence = self._nlu_confidence_below_threshold(message) + + if below_threshold: + logger.debug( + f"NLU confidence {nlu_confidence} for intent '{intent_name}' is lower " + f"than NLU threshold {self.component_config[THRESHOLD_KEY]:.2f}." + ) + return True + + ambiguous_prediction, confidence_delta = self._nlu_prediction_ambiguous(message) + if ambiguous_prediction: + logger.debug( + f"The difference in NLU confidences " + f"for the top two intents ({confidence_delta}) is lower than " + f"the ambiguity threshold " + f"{self.component_config[AMBIGUITY_THRESHOLD_KEY]:.2f}. Predicting " + f"intent '{DEFAULT_NLU_FALLBACK_INTENT_NAME}' instead of " + f"'{intent_name}'." + ) + return True + + return False + + def _nlu_confidence_below_threshold(self, message: Message) -> Tuple[bool, float]: + nlu_confidence = message.data[INTENT].get(PREDICTED_CONFIDENCE_KEY) + return nlu_confidence < self.component_config[THRESHOLD_KEY], nlu_confidence + + def _nlu_prediction_ambiguous( + self, message: Message + ) -> Tuple[bool, Optional[float]]: + intents = message.data.get(INTENT_RANKING_KEY, []) + if len(intents) >= 2: + first_confidence = intents[0].get(PREDICTED_CONFIDENCE_KEY, 1.0) + second_confidence = intents[1].get(PREDICTED_CONFIDENCE_KEY, 1.0) + difference = first_confidence - second_confidence + return ( + difference < self.component_config[AMBIGUITY_THRESHOLD_KEY], + difference, + ) + return False, None + + +def _fallback_intent(confidence: float) -> Dict[Text, Union[Text, float]]: + return { + INTENT_NAME_KEY: DEFAULT_NLU_FALLBACK_INTENT_NAME, + PREDICTED_CONFIDENCE_KEY: confidence, + } + + +def is_fallback_classifier_prediction(prediction: Dict[Text, Any]) -> bool: + """Checks if the intent was predicted by the `FallbackClassifier`. + + Args: + prediction: The prediction of the NLU model. + + Returns: + `True` if the top classified intent was the fallback intent. + """ + return ( + prediction.get(INTENT, {}).get(INTENT_NAME_KEY) + == DEFAULT_NLU_FALLBACK_INTENT_NAME + ) + + +def undo_fallback_prediction(prediction: Dict[Text, Any]) -> Dict[Text, Any]: + """Undo the prediction of the fallback intent. + + Args: + prediction: The prediction of the NLU model. + + Returns: + The prediction as if the `FallbackClassifier` wasn't present in the pipeline. + If the fallback intent is the only intent, return the prediction as it was + provided. + """ + intent_ranking = prediction.get(INTENT_RANKING_KEY, []) + if len(intent_ranking) < 2: + return prediction + + prediction = copy.deepcopy(prediction) + prediction[INTENT] = intent_ranking[1] + prediction[INTENT_RANKING_KEY] = prediction[INTENT_RANKING_KEY][1:] + + return prediction diff --git a/rasa/nlu/classifiers/keyword_intent_classifier.py b/rasa/nlu/classifiers/keyword_intent_classifier.py new file mode 100644 index 0000000..33ea3b2 --- /dev/null +++ b/rasa/nlu/classifiers/keyword_intent_classifier.py @@ -0,0 +1,188 @@ +from __future__ import annotations +import logging +import re +from typing import Any, Dict, Optional, Text, List + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.constants import DOCS_URL_COMPONENTS +from rasa.nlu.classifiers.classifier import IntentClassifier +from rasa.shared.nlu.constants import ( + INTENT, + TEXT, + INTENT_NAME_KEY, + PREDICTED_CONFIDENCE_KEY, +) +import rasa.shared.utils.io +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER, is_trainable=True +) +class KeywordIntentClassifier(GraphComponent, IntentClassifier): + """Intent classifier using simple keyword matching. + + The classifier takes a list of keywords and associated intents as an input. + An input sentence is checked for the keywords and the intent is returned. + """ + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """The component's default config (see parent class for full docstring).""" + return {"case_sensitive": True} + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + intent_keyword_map: Optional[Dict] = None, + ) -> None: + """Creates classifier.""" + self.component_config = config + self._model_storage = model_storage + self._resource = resource + self._execution_context = execution_context + + self.case_sensitive = self.component_config.get("case_sensitive") + self.intent_keyword_map = intent_keyword_map or {} + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> KeywordIntentClassifier: + """Creates a new untrained component (see parent class for full docstring).""" + return cls(config, model_storage, resource, execution_context) + + def train(self, training_data: TrainingData) -> Resource: + """Trains the intent classifier on a data set.""" + duplicate_examples = set() + for ex in training_data.intent_examples: + if ( + ex.get(TEXT) in self.intent_keyword_map.keys() + and ex.get(INTENT) != self.intent_keyword_map[ex.get(TEXT)] + ): + duplicate_examples.add(ex.get(TEXT)) + rasa.shared.utils.io.raise_warning( + f"Keyword '{ex.get(TEXT)}' is a keyword to trigger intent " + f"'{self.intent_keyword_map[ex.get(TEXT)]}' and also " + f"intent '{ex.get(INTENT)}', it will be removed " + f"from the list of keywords for both of them. " + f"Remove (one of) the duplicates from the training data.", + docs=DOCS_URL_COMPONENTS + "#keyword-intent-classifier", + ) + else: + self.intent_keyword_map[ex.get(TEXT)] = ex.get(INTENT) + for keyword in duplicate_examples: + self.intent_keyword_map.pop(keyword) + logger.debug( + f"Removed '{keyword}' from the list of keywords because it was " + "a keyword for more than one intent." + ) + + self._validate_keyword_map() + self.persist() + return self._resource + + def _validate_keyword_map(self) -> None: + re_flag = 0 if self.case_sensitive else re.IGNORECASE + + ambiguous_mappings = [] + for keyword1, intent1 in self.intent_keyword_map.items(): + for keyword2, intent2 in self.intent_keyword_map.items(): + if ( + re.search(r"\b" + keyword1 + r"\b", keyword2, flags=re_flag) + and intent1 != intent2 + ): + ambiguous_mappings.append((intent1, keyword1)) + rasa.shared.utils.io.raise_warning( + f"Keyword '{keyword1}' is a keyword of intent '{intent1}', " + f"but also a substring of '{keyword2}', which is a " + f"keyword of intent '{intent2}." + f" '{keyword1}' will be removed from the list of keywords.\n" + f"Remove (one of) the conflicting keywords from the" + f" training data.", + docs=DOCS_URL_COMPONENTS + "#keyword-intent-classifier", + ) + for intent, keyword in ambiguous_mappings: + self.intent_keyword_map.pop(keyword) + logger.debug( + f"Removed keyword '{keyword}' from intent " + f"'{intent}' because it matched a " + f"keyword of another intent." + ) + + def process(self, messages: List[Message]) -> List[Message]: + """Sets the message intent and add it to the output if it exists.""" + for message in messages: + intent_name = self._map_keyword_to_intent(message.get(TEXT)) + + confidence = 0.0 if intent_name is None else 1.0 + intent = { + INTENT_NAME_KEY: intent_name, + PREDICTED_CONFIDENCE_KEY: confidence, + } + + if message.get(INTENT) is None or intent_name is not None: + message.set(INTENT, intent, add_to_output=True) + + return messages + + def _map_keyword_to_intent(self, text: Text) -> Optional[Text]: + re_flag = 0 if self.case_sensitive else re.IGNORECASE + + for keyword, intent in self.intent_keyword_map.items(): + if re.search(r"\b" + keyword + r"\b", text, flags=re_flag): + logger.debug( + f"KeywordClassifier matched keyword '{keyword}' to" + f" intent '{intent}'." + ) + return intent + + logger.debug("KeywordClassifier did not find any keywords in the message.") + return None + + def persist(self) -> None: + """Persist this model into the passed directory.""" + with self._model_storage.write_to(self._resource) as model_dir: + file_name = f"{self.__class__.__name__}.json" + keyword_file = model_dir / file_name + rasa.shared.utils.io.dump_obj_as_json_to_file( + keyword_file, self.intent_keyword_map + ) + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> KeywordIntentClassifier: + """Loads trained component (see parent class for full docstring).""" + try: + with model_storage.read_from(resource) as model_dir: + keyword_file = model_dir / f"{cls.__name__}.json" + intent_keyword_map = rasa.shared.utils.io.read_json_file(keyword_file) + except ValueError: + logger.warning( + f"Failed to load {cls.__class__.__name__} from model storage. Resource " + f"'{resource.name}' doesn't exist." + ) + intent_keyword_map = None + + return cls( + config, model_storage, resource, execution_context, intent_keyword_map + ) diff --git a/rasa/nlu/classifiers/logistic_regression_classifier.py b/rasa/nlu/classifiers/logistic_regression_classifier.py new file mode 100644 index 0000000..46303a1 --- /dev/null +++ b/rasa/nlu/classifiers/logistic_regression_classifier.py @@ -0,0 +1,212 @@ +import logging +from typing import Any, Text, Dict, List, Type, Tuple + +from scipy.sparse import hstack, vstack, csr_matrix +from sklearn.linear_model import LogisticRegression + +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.classifiers import LABEL_RANKING_LENGTH +from rasa.nlu.classifiers.classifier import IntentClassifier +from rasa.nlu.featurizers.featurizer import Featurizer +from rasa.shared.nlu.constants import TEXT, INTENT +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.utils.tensorflow.constants import RANKING_LENGTH + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER, is_trainable=True +) +class LogisticRegressionClassifier(IntentClassifier, GraphComponent): + """Intent classifier using the Logistic Regression.""" + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [Featurizer] + + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return ["sklearn"] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """The component's default config (see parent class for full docstring).""" + return { + "max_iter": 100, + "solver": "lbfgs", + "tol": 1e-4, + "random_state": 42, + RANKING_LENGTH: LABEL_RANKING_LENGTH, + } + + def __init__( + self, + config: Dict[Text, Any], + name: Text, + model_storage: ModelStorage, + resource: Resource, + ) -> None: + """Construct a new classifier.""" + self.name = name + self.config = {**self.get_default_config(), **config} + self.clf = LogisticRegression( + solver=self.config["solver"], + max_iter=self.config["max_iter"], + class_weight="balanced", + tol=self.config["tol"], + random_state=self.config["random_state"], + # Added these parameters to ensure sklearn changes won't affect us. + # Should a sklearn update the defaults, we won't be affected. + dual=False, + fit_intercept=True, + intercept_scaling=1, + multi_class="auto", + verbose=0, + warm_start=False, + n_jobs=None, + l1_ratio=None, + ) + + # We need to use these later when saving the trained component. + self._model_storage = model_storage + self._resource = resource + + def _create_X(self, messages: List[Message]) -> csr_matrix: + """This method creates a sparse X array that can be used for predicting.""" + X = [] + for e in messages: + # First element is sequence features, second is sentence features + sparse_feats = e.get_sparse_features(attribute=TEXT)[1] + # First element is sequence features, second is sentence features + dense_feats = e.get_dense_features(attribute=TEXT)[1] + together = hstack( + [ + csr_matrix(sparse_feats.features if sparse_feats else []), + csr_matrix(dense_feats.features if dense_feats else []), + ] + ) + X.append(together) + return vstack(X) + + def _create_training_matrix( + self, training_data: TrainingData + ) -> Tuple[csr_matrix, List[str]]: + """This method creates a scikit-learn compatible (X, y) training pairs.""" + y = [] + + examples = [ + e + for e in training_data.intent_examples + if (e.get("intent") and e.get("text")) + ] + + for e in examples: + y.append(e.get(INTENT)) + + return self._create_X(examples), y + + def train(self, training_data: TrainingData) -> Resource: + """Train the intent classifier on a data set.""" + X, y = self._create_training_matrix(training_data) + if X.shape[0] == 0: + logger.debug( + f"Cannot train '{self.__class__.__name__}'. No data was provided. " + f"Skipping training of the classifier." + ) + return self._resource + + self.clf.fit(X, y) + self.persist() + + return self._resource + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> "LogisticRegressionClassifier": + """Creates a new untrained component (see parent class for full docstring).""" + return cls(config, execution_context.node_name, model_storage, resource) + + def process(self, messages: List[Message]) -> List[Message]: + """Return the most likely intent and its probability for a message.""" + X = self._create_X(messages) + probas = self.clf.predict_proba(X) + for idx, message in enumerate(messages): + intents = self.clf.classes_ + intent_ranking = [ + {"name": k, "confidence": v} for k, v in zip(intents, probas[idx]) + ] + sorted_ranking = sorted(intent_ranking, key=lambda e: -e["confidence"]) + intent = sorted_ranking[0] + if self.config[RANKING_LENGTH] > 0: + sorted_ranking = sorted_ranking[: self.config[RANKING_LENGTH]] + message.set("intent", intent, add_to_output=True) + message.set("intent_ranking", sorted_ranking, add_to_output=True) + return messages + + def persist(self) -> None: + """Persist this model into the passed directory.""" + import skops.io as sio + + with self._model_storage.write_to(self._resource) as model_dir: + path = model_dir / f"{self._resource.name}.skops" + sio.dump(self.clf, path) + logger.debug(f"Saved intent classifier to '{path}'.") + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> "LogisticRegressionClassifier": + """Loads trained component (see parent class for full docstring).""" + import skops.io as sio + + try: + with model_storage.read_from(resource) as model_dir: + classifier_file = model_dir / f"{resource.name}.skops" + unknown_types = sio.get_untrusted_types(file=classifier_file) + + if unknown_types: + logger.debug( + f"Untrusted types ({unknown_types}) found when " + f"loading {classifier_file}!", + ) + raise ValueError() + + classifier = sio.load(classifier_file, trusted=unknown_types) + component = cls( + config, execution_context.node_name, model_storage, resource + ) + component.clf = classifier + return component + except ValueError: + logger.debug( + f"Failed to load {cls.__class__.__name__} from model storage. Resource " + f"'{resource.name}' doesn't exist." + ) + return cls.create(config, model_storage, resource, execution_context) + + def process_training_data(self, training_data: TrainingData) -> TrainingData: + """Process the training data.""" + self.process(training_data.training_examples) + return training_data + + @classmethod + def validate_config(cls, config: Dict[Text, Any]) -> None: + """Validates that the component is configured properly.""" + pass diff --git a/rasa/nlu/classifiers/mitie_intent_classifier.py b/rasa/nlu/classifiers/mitie_intent_classifier.py new file mode 100644 index 0000000..f196f8e --- /dev/null +++ b/rasa/nlu/classifiers/mitie_intent_classifier.py @@ -0,0 +1,156 @@ +from __future__ import annotations +import logging +from rasa.nlu.featurizers.featurizer import Featurizer +import typing +from typing import Any, Dict, List, Optional, Text, Type + +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.classifiers.classifier import IntentClassifier +from rasa.nlu.utils.mitie_utils import MitieModel, MitieNLP +from rasa.nlu.constants import TOKENS_NAMES +from rasa.shared.nlu.constants import TEXT, INTENT +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message + +if typing.TYPE_CHECKING: + import mitie + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER, + is_trainable=True, + model_from="MitieNLP", +) +class MitieIntentClassifier(GraphComponent, IntentClassifier): + """Intent classifier which uses the `mitie` library.""" + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [MitieNLP, Featurizer] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns default config (see parent class for full docstring).""" + return {"num_threads": 1} + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + clf: Optional["mitie.text_categorizer"] = None, + ) -> None: + """Constructs a new intent classifier using the MITIE framework.""" + self._config = config + self._model_storage = model_storage + self._resource = resource + self._clf = clf + + @staticmethod + def required_packages() -> List[Text]: + """Lists required dependencies (see parent class for full docstring).""" + return ["mitie"] + + def train(self, training_data: TrainingData, model: MitieModel) -> Resource: + """Trains classifier. + + Args: + training_data: The NLU training data. + model: The loaded mitie model provided by `MitieNLP`. + + Returns: + The resource locator for the trained classifier. + """ + import mitie + + trainer = mitie.text_categorizer_trainer(str(model.model_path)) + trainer.num_threads = self._config["num_threads"] + + for example in training_data.intent_examples: + tokens = self._tokens_of_message(example) + trainer.add_labeled_text(tokens, example.get(INTENT)) + + if training_data.intent_examples: + # we can not call train if there are no examples! + clf = trainer.train() + self._persist(clf) + + return self._resource + + def process(self, messages: List[Message], model: MitieModel) -> List[Message]: + """Make intent predictions using `mitie`. + + Args: + messages: The message which the intents should be predicted for. + model: The loaded mitie model provided by `MitieNLP`. + """ + for message in messages: + if self._clf: + token_strs = self._tokens_of_message(message) + intent, confidence = self._clf(token_strs, model.word_feature_extractor) + else: + # either the model didn't get trained or it wasn't + # provided with any data + intent = None + confidence = 0.0 + + message.set( + "intent", {"name": intent, "confidence": confidence}, add_to_output=True + ) + + return messages + + @staticmethod + def _tokens_of_message(message: Message) -> List[Text]: + return [token.text for token in message.get(TOKENS_NAMES[TEXT], [])] + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> MitieIntentClassifier: + """Creates component for training see parent class for full docstring).""" + return cls(config, model_storage, resource) + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> MitieIntentClassifier: + """Loads component for inference see parent class for full docstring).""" + import mitie + + text_categorizer = None + + try: + with model_storage.read_from(resource) as directory: + text_categorizer = mitie.text_categorizer(str(directory / "model.dat")) + except ( + ValueError, + Exception, + ): # the latter is thrown by the `mitie.text_categorizer` + logger.warning( + f"Failed to load {cls.__class__.__name__} from model storage. Resource " + f"'{resource.name}' doesn't exist." + ) + + return cls(config, model_storage, resource, text_categorizer) + + def _persist(self, text_categorizer: "mitie.text_categorizer") -> None: + """Persists trained model (see parent class for full docstring).""" + with self._model_storage.write_to(self._resource) as directory: + classifier_file = directory / "model.dat" + text_categorizer.save_to_disk(str(classifier_file), pure_model=True) diff --git a/rasa/nlu/classifiers/regex_message_handler.py b/rasa/nlu/classifiers/regex_message_handler.py new file mode 100644 index 0000000..8b506eb --- /dev/null +++ b/rasa/nlu/classifiers/regex_message_handler.py @@ -0,0 +1,56 @@ +from __future__ import annotations +import logging +from typing import Any, Dict, Optional, Text, List + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.extractors.extractor import EntityExtractorMixin +from rasa.shared.core.domain import Domain +from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, +) +from rasa.shared.nlu.training_data.message import Message + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER, is_trainable=False +) +class RegexMessageHandler(GraphComponent, EntityExtractorMixin): + """Handles hardcoded NLU predictions from messages starting with a `/`.""" + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> RegexMessageHandler: + """Creates a new untrained component (see parent class for full docstring).""" + return cls() + + # TODO: Handle empty domain (NLU only training) + def process( + self, messages: List[Message], domain: Optional[Domain] = None + ) -> List[Message]: + """Adds hardcoded intents and entities for messages starting with '/'. + + Args: + messages: The messages which should be handled. + domain: If given the domain is used to check whether the intent, entities + valid. + + Returns: + The messages with potentially intent and entity prediction replaced + in case the message started with a `/`. + """ + return [ + YAMLStoryReader.unpack_regex_message( + message, domain, entity_extractor_name=self.name + ) + for message in messages + ] diff --git a/rasa/nlu/classifiers/sklearn_intent_classifier.py b/rasa/nlu/classifiers/sklearn_intent_classifier.py new file mode 100644 index 0000000..3aa656f --- /dev/null +++ b/rasa/nlu/classifiers/sklearn_intent_classifier.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import logging +import typing +import warnings +from typing import Any, Dict, List, Optional, Text, Tuple, Type + +import numpy as np + +import rasa.shared.utils.io +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.classifiers import LABEL_RANKING_LENGTH +from rasa.nlu.classifiers.classifier import IntentClassifier +from rasa.nlu.featurizers.dense_featurizer.dense_featurizer import DenseFeaturizer +from rasa.shared.constants import DOCS_URL_TRAINING_DATA_NLU +from rasa.shared.exceptions import RasaException +from rasa.shared.nlu.constants import TEXT +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.utils.tensorflow.constants import FEATURIZERS + +logger = logging.getLogger(__name__) + +if typing.TYPE_CHECKING: + import sklearn + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER, is_trainable=True +) +class SklearnIntentClassifier(GraphComponent, IntentClassifier): + """Intent classifier using the sklearn framework.""" + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [DenseFeaturizer] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """The component's default config (see parent class for full docstring).""" + return { + # C parameter of the svm - cross validation will select the best value + "C": [1, 2, 5, 10, 20, 100], + # gamma parameter of the svm + "gamma": [0.1], + # the kernels to use for the svm training - cross validation will + # decide which one of them performs best + "kernels": ["linear"], + # We try to find a good number of cross folds to use during + # intent training, this specifies the max number of folds + "max_cross_validation_folds": 5, + # Scoring function used for evaluating the hyper parameters + # This can be a name or a function (cfr GridSearchCV doc for more info) + "scoring_function": "f1_weighted", + "num_threads": 1, + } + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + clf: Optional["sklearn.model_selection.GridSearchCV"] = None, + le: Optional["sklearn.preprocessing.LabelEncoder"] = None, + ) -> None: + """Construct a new intent classifier using the sklearn framework.""" + from sklearn.preprocessing import LabelEncoder + + self.component_config = config + self._model_storage = model_storage + self._resource = resource + + if le is not None: + self.le = le + else: + self.le = LabelEncoder() + self.clf = clf + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> SklearnIntentClassifier: + """Creates a new untrained component (see parent class for full docstring).""" + return cls(config, model_storage, resource) + + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return ["sklearn"] + + def transform_labels_str2num(self, labels: List[Text]) -> np.ndarray: + """Transforms a list of strings into numeric label representation. + + :param labels: List of labels to convert to numeric representation + """ + return self.le.fit_transform(labels) + + def transform_labels_num2str(self, y: np.ndarray) -> np.ndarray: + """Transforms a list of strings into numeric label representation. + + :param y: List of labels to convert to numeric representation""" + + return self.le.inverse_transform(y) + + def train(self, training_data: TrainingData) -> Resource: + """Train the intent classifier on a data set.""" + num_threads = self.component_config["num_threads"] + + labels = [e.get("intent") for e in training_data.intent_examples] + + if len(set(labels)) < 2: + rasa.shared.utils.io.raise_warning( + "Can not train an intent classifier as there are not " + "enough intents. Need at least 2 different intents. " + "Skipping training of intent classifier.", + docs=DOCS_URL_TRAINING_DATA_NLU, + ) + return self._resource + + y = self.transform_labels_str2num(labels) + training_examples = [ + message + for message in training_data.intent_examples + if message.features_present( + attribute=TEXT, featurizers=self.component_config.get(FEATURIZERS) + ) + ] + X = np.stack( + [self._get_sentence_features(example) for example in training_examples] + ) + # reduce dimensionality + X = np.reshape(X, (len(X), -1)) + + self.clf = self._create_classifier(num_threads, y) + + with warnings.catch_warnings(): + # sklearn raises lots of + # "UndefinedMetricWarning: F - score is ill - defined" + # if there are few intent examples, this is needed to prevent it + warnings.simplefilter("ignore") + self.clf.fit(X, y) + + self.persist() + return self._resource + + @staticmethod + def _get_sentence_features(message: Message) -> np.ndarray: + _, sentence_features = message.get_dense_features(TEXT) + if sentence_features is not None: + return sentence_features.features[0] + + raise ValueError( + "No sentence features present. Not able to train sklearn policy." + ) + + def _num_cv_splits(self, y: np.ndarray) -> int: + folds = self.component_config["max_cross_validation_folds"] + return max(2, min(folds, np.min(np.bincount(y)) // 5)) + + def _create_classifier( + self, num_threads: int, y: np.ndarray + ) -> "sklearn.model_selection.GridSearchCV": + from sklearn.model_selection import GridSearchCV + from sklearn.svm import SVC + + C = self.component_config["C"] + kernels = self.component_config["kernels"] + gamma = self.component_config["gamma"] + # dirty str fix because sklearn is expecting + # str not instance of basestr... + tuned_parameters = [ + {"C": C, "gamma": gamma, "kernel": [str(k) for k in kernels]} + ] + + # aim for 5 examples in each fold + + cv_splits = self._num_cv_splits(y) + + return GridSearchCV( + SVC(C=1, probability=True, class_weight="balanced"), + param_grid=tuned_parameters, + n_jobs=num_threads, + cv=cv_splits, + scoring=self.component_config["scoring_function"], + verbose=1, + ) + + def process(self, messages: List[Message]) -> List[Message]: + """Return the most likely intent and its probability for a message.""" + for message in messages: + if self.clf is None or not message.features_present( + attribute=TEXT, featurizers=self.component_config.get(FEATURIZERS) + ): + # component is either not trained or didn't + # receive enough training data or the input doesn't + # have required features. + intent = None + intent_ranking = [] + else: + X = self._get_sentence_features(message).reshape(1, -1) + + intent_ids, probabilities = self.predict(X) + intents = self.transform_labels_num2str(np.ravel(intent_ids)) + # `predict` returns a matrix as it is supposed + # to work for multiple examples as well, hence we need to flatten + probabilities = probabilities.flatten() + + if intents.size > 0 and probabilities.size > 0: + ranking = list(zip(list(intents), list(probabilities)))[ + :LABEL_RANKING_LENGTH + ] + + intent = {"name": intents[0], "confidence": probabilities[0]} + + intent_ranking = [ + {"name": intent_name, "confidence": score} + for intent_name, score in ranking + ] + else: + intent = {"name": None, "confidence": 0.0} + intent_ranking = [] + + message.set("intent", intent, add_to_output=True) + message.set("intent_ranking", intent_ranking, add_to_output=True) + + return messages + + def predict_prob(self, X: np.ndarray) -> np.ndarray: + """Given a bow vector of an input text, predict the intent label. + + Return probabilities for all labels. + + :param X: bow of input text + :return: vector of probabilities containing one entry for each label. + """ + if self.clf is None: + raise RasaException( + "Sklearn intent classifier has not been initialised and trained." + ) + + return self.clf.predict_proba(X) + + def predict(self, X: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """Given a bow vector of an input text, predict most probable label. + + Return only the most likely label. + + :param X: bow of input text + :return: tuple of first, the most probable label and second, + its probability. + """ + pred_result = self.predict_prob(X) + # sort the probabilities retrieving the indices of + # the elements in sorted order + + sorted_indices = np.fliplr(np.argsort(pred_result, axis=1)) + return sorted_indices, pred_result[:, sorted_indices] + + def persist(self) -> None: + """Persist this model into the passed directory.""" + import skops.io as sio + + with self._model_storage.write_to(self._resource) as model_dir: + file_name = self.__class__.__name__ + classifier_file_name = model_dir / f"{file_name}_classifier.skops" + encoder_file_name = model_dir / f"{file_name}_encoder.json" + + if self.clf and self.le: + # convert self.le.classes_ (numpy array of strings) to a list in order + # to use json dump + rasa.shared.utils.io.dump_obj_as_json_to_file( + encoder_file_name, list(self.le.classes_) + ) + sio.dump(self.clf.best_estimator_, classifier_file_name) + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> SklearnIntentClassifier: + """Loads trained component (see parent class for full docstring).""" + from sklearn.preprocessing import LabelEncoder + import skops.io as sio + + try: + with model_storage.read_from(resource) as model_dir: + file_name = cls.__name__ + classifier_file = model_dir / f"{file_name}_classifier.skops" + + if classifier_file.exists(): + unknown_types = sio.get_untrusted_types(file=classifier_file) + + if unknown_types: + logger.error( + f"Untrusted types ({unknown_types}) found when " + f"loading {classifier_file}!" + ) + raise ValueError() + else: + classifier = sio.load(classifier_file, trusted=unknown_types) + + encoder_file = model_dir / f"{file_name}_encoder.json" + classes = rasa.shared.utils.io.read_json_file(encoder_file) + + encoder = LabelEncoder() + intent_classifier = cls( + config, model_storage, resource, classifier, encoder + ) + # convert list of strings (class labels) back to numpy array of + # strings + intent_classifier.transform_labels_str2num(classes) + return intent_classifier + except ValueError: + logger.debug( + f"Failed to load '{cls.__name__}' from model storage. Resource " + f"'{resource.name}' doesn't exist." + ) + return cls(config, model_storage, resource) diff --git a/rasa/nlu/constants.py b/rasa/nlu/constants.py new file mode 100644 index 0000000..c62eeb0 --- /dev/null +++ b/rasa/nlu/constants.py @@ -0,0 +1,77 @@ +import rasa.shared.nlu.constants +from rasa.shared.nlu.constants import ENTITY_ATTRIBUTE_CONFIDENCE + +BILOU_ENTITIES = "bilou_entities" +BILOU_ENTITIES_ROLE = "bilou_entities_role" +BILOU_ENTITIES_GROUP = "bilou_entities_group" + +ENTITY_ATTRIBUTE_CONFIDENCE_TYPE = ( + f"{ENTITY_ATTRIBUTE_CONFIDENCE}_{rasa.shared.nlu.constants.ENTITY_ATTRIBUTE_TYPE}" +) +ENTITY_ATTRIBUTE_CONFIDENCE_GROUP = ( + f"{ENTITY_ATTRIBUTE_CONFIDENCE}_{rasa.shared.nlu.constants.ENTITY_ATTRIBUTE_GROUP}" +) +ENTITY_ATTRIBUTE_CONFIDENCE_ROLE = ( + f"{ENTITY_ATTRIBUTE_CONFIDENCE}_{rasa.shared.nlu.constants.ENTITY_ATTRIBUTE_ROLE}" +) + +EXTRACTOR = "extractor" + +PRETRAINED_EXTRACTORS = {"DucklingEntityExtractor", "SpacyEntityExtractor"} + +NUMBER_OF_SUB_TOKENS = "number_of_sub_tokens" + +MESSAGE_ATTRIBUTES = [ + rasa.shared.nlu.constants.TEXT, + rasa.shared.nlu.constants.INTENT, + rasa.shared.nlu.constants.RESPONSE, + rasa.shared.nlu.constants.ACTION_NAME, + rasa.shared.nlu.constants.ACTION_TEXT, + rasa.shared.nlu.constants.INTENT_RESPONSE_KEY, +] +# the dense featurizable attributes are essentially text attributes +DENSE_FEATURIZABLE_ATTRIBUTES = [ + rasa.shared.nlu.constants.TEXT, + rasa.shared.nlu.constants.RESPONSE, + rasa.shared.nlu.constants.ACTION_TEXT, +] + +LANGUAGE_MODEL_DOCS = { + rasa.shared.nlu.constants.TEXT: "text_language_model_doc", + rasa.shared.nlu.constants.RESPONSE: "response_language_model_doc", + rasa.shared.nlu.constants.ACTION_TEXT: "action_text_model_doc", +} +SPACY_DOCS = { + rasa.shared.nlu.constants.TEXT: "text_spacy_doc", + rasa.shared.nlu.constants.RESPONSE: "response_spacy_doc", + rasa.shared.nlu.constants.ACTION_TEXT: "action_text_spacy_doc", +} + +TOKENS_NAMES = { + rasa.shared.nlu.constants.TEXT: "text_tokens", + rasa.shared.nlu.constants.INTENT: "intent_tokens", + rasa.shared.nlu.constants.RESPONSE: "response_tokens", + rasa.shared.nlu.constants.ACTION_NAME: "action_name_tokens", + rasa.shared.nlu.constants.ACTION_TEXT: "action_text_tokens", + rasa.shared.nlu.constants.INTENT_RESPONSE_KEY: "intent_response_key_tokens", +} + +SEQUENCE_FEATURES = "sequence_features" +SENTENCE_FEATURES = "sentence_features" + +RESPONSE_SELECTOR_PROPERTY_NAME = "response_selector" +RESPONSE_SELECTOR_RETRIEVAL_INTENTS = "all_retrieval_intents" +RESPONSE_SELECTOR_DEFAULT_INTENT = "default" +RESPONSE_SELECTOR_PREDICTION_KEY = "response" +RESPONSE_SELECTOR_RANKING_KEY = "ranking" +RESPONSE_SELECTOR_RESPONSES_KEY = "responses" +RESPONSE_SELECTOR_RESPONSE_TEMPLATES_KEY = "response_templates" +RESPONSE_SELECTOR_UTTER_ACTION_KEY = "utter_action" +RESPONSE_SELECTOR_TEMPLATE_NAME_KEY = "template_name" +RESPONSE_IDENTIFIER_DELIMITER = "/" + +DEFAULT_TRANSFORMER_SIZE = 256 + +FEATURIZER_CLASS_ALIAS = "alias" + +NO_LENGTH_RESTRICTION = -1 diff --git a/rasa/nlu/convert.py b/rasa/nlu/convert.py new file mode 100644 index 0000000..6b8cbf7 --- /dev/null +++ b/rasa/nlu/convert.py @@ -0,0 +1,40 @@ +import os +from typing import Text, Union + +from rasa.shared.nlu.training_data.formats.rasa_yaml import RasaYAMLWriter +from rasa.shared.utils.cli import print_error +import rasa.shared.nlu.training_data.loading +from rasa.nlu.utils import write_to_file + + +def convert_training_data( + data_file: Union[list, Text], out_file: Text, output_format: Text, language: Text +) -> None: + """Convert training data. + + Args: + data_file (Union[list, Text]): Path to the file or directory + containing Rasa data. + out_file (Text): File or existing path where to save + training data in Rasa format. + output_format (Text): Output format the training data + should be converted into. + language (Text): Language of the data. + """ + if isinstance(data_file, list): + data_file = data_file[0] + + if not os.path.exists(str(data_file)): + print_error( + "Data file '{}' does not exist. Provide a valid NLU data file using " + "the '--data' argument.".format(data_file) + ) + return + + td = rasa.shared.nlu.training_data.loading.load_data(data_file, language) + if output_format == "json": + output = td.nlu_as_json(indent=2) + else: + output = RasaYAMLWriter().dumps(td) + + write_to_file(out_file, output) diff --git a/rasa/nlu/emulators/__init__.py b/rasa/nlu/emulators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/nlu/emulators/dialogflow.py b/rasa/nlu/emulators/dialogflow.py new file mode 100644 index 0000000..6e7665b --- /dev/null +++ b/rasa/nlu/emulators/dialogflow.py @@ -0,0 +1,55 @@ +import uuid +from collections import defaultdict +from typing import Any, Dict, Text + +from rasa.shared.nlu.constants import ( + INTENT_NAME_KEY, + ENTITIES, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_VALUE, + INTENT, + TEXT, + PREDICTED_CONFIDENCE_KEY, +) +from rasa.nlu.emulators.emulator import Emulator + + +class DialogflowEmulator(Emulator): + """Emulates the response format of the DialogFlow. + + # noqa: W505 + https://cloud.google.com/dialogflow/es/docs/reference/rest/v2/projects.agent.environments.users.sessions/detectIntent + https://cloud.google.com/dialogflow/es/docs/reference/rest/v2/DetectIntentResponse + """ + + def normalise_response_json(self, data: Dict[Text, Any]) -> Dict[Text, Any]: + """Transform response JSON to DialogFlow format. + + Args: + data: input JSON data as a dictionary. + + Returns: + The transformed input data. + """ + entities = defaultdict(list) + for entity in data[ENTITIES]: + entities[entity[ENTITY_ATTRIBUTE_TYPE]].append( + entity[ENTITY_ATTRIBUTE_VALUE] + ) + + return { + "responseId": str(uuid.uuid1()), + "queryResult": { + "queryText": data[TEXT], + "action": data[INTENT][INTENT_NAME_KEY], + "parameters": entities, + "fulfillmentText": "", + "fulfillmentMessages": [], + "outputContexts": [], + "intent": { + "name": data[INTENT][INTENT_NAME_KEY], + "displayName": data[INTENT][INTENT_NAME_KEY], + }, + "intentDetectionConfidence": data[INTENT][PREDICTED_CONFIDENCE_KEY], + }, + } diff --git a/rasa/nlu/emulators/emulator.py b/rasa/nlu/emulators/emulator.py new file mode 100644 index 0000000..e6cbfd8 --- /dev/null +++ b/rasa/nlu/emulators/emulator.py @@ -0,0 +1,47 @@ +from typing import Any, Dict, Text + + +class Emulator: + """Emulator specifies how requests and responses are getting transformed.""" + + @classmethod + def name(cls) -> Text: + """Name that identifies the emulator.""" + return cls.__name__ + + def normalise_request_json(self, data: Dict[Text, Any]) -> Dict[Text, Any]: + """Transform request JSON to target format. + + Args: + data: input JSON data as a dictionary. + + Returns: + The transformed input data. + """ + _data = { + "text": data["text"][0] if type(data["text"]) == list else data["text"] + } + + if data.get("model"): + if type(data["model"]) == list: + _data["model"] = data["model"][0] + else: + _data["model"] = data["model"] + + _data["time"] = data["time"] if "time" in data else None + return _data + + def normalise_response_json(self, data: Dict[Text, Any]) -> Dict[Text, Any]: + """Transform response JSON to target format. + + Args: + data: input JSON data as a dictionary. + + Returns: + The transformed input data. + """ + raise NotImplementedError + + def __str__(self) -> Text: + """Return the string representation of the emulator.""" + return "Emulator('{}')".format(self.name()) diff --git a/rasa/nlu/emulators/luis.py b/rasa/nlu/emulators/luis.py new file mode 100644 index 0000000..fc22209 --- /dev/null +++ b/rasa/nlu/emulators/luis.py @@ -0,0 +1,86 @@ +from typing import Any, Dict, Text + +from rasa.nlu.emulators.emulator import Emulator +from rasa.shared.nlu.constants import ( + ENTITIES, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_ROLE, + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + EXTRACTOR, + INTENT_RANKING_KEY, + TEXT, + INTENT, + INTENT_NAME_KEY, + PREDICTED_CONFIDENCE_KEY, +) + + +class LUISEmulator(Emulator): + """Emulates the response format of the LUIS Endpoint API v3.0 /predict endpoint. + + https://westcentralus.dev.cognitive.microsoft.com/docs/services/luis-endpoint-api-v3-0/ + https://docs.microsoft.com/en-us/azure/cognitive-services/LUIS/luis-concept-data-extraction?tabs=V3 + """ + + def _intents(self, data: Dict[Text, Any]) -> Dict[Text, Any]: + if data.get(INTENT_RANKING_KEY): + return { + intent[INTENT_NAME_KEY]: {"score": intent[PREDICTED_CONFIDENCE_KEY]} + for intent in data[INTENT_RANKING_KEY] + } + + top = data.get(INTENT) + if not top: + return {} + + return {top[INTENT_NAME_KEY]: {"score": top[PREDICTED_CONFIDENCE_KEY]}} + + def _entities(self, data: Dict[Text, Any]) -> Dict[Text, Any]: + if ENTITIES not in data: + return {} + + entities: Dict[Text, Any] = {"$instance": {}} + for e in data[ENTITIES]: + # LUIS API v3 uses entity roles instead of entity names + # (it's possible because its roles are unique): + # https://docs.microsoft.com/en-us/azure/cognitive-services/LUIS/luis-migration-api-v3#entity-role-name-instead-of-entity-name + key = e.get(ENTITY_ATTRIBUTE_ROLE, e[ENTITY_ATTRIBUTE_TYPE]) + entities[key] = [e[ENTITY_ATTRIBUTE_VALUE]] + + entities["$instance"][key] = [ + { + "role": e.get(ENTITY_ATTRIBUTE_ROLE), + "type": e[ENTITY_ATTRIBUTE_TYPE], + "text": e[ENTITY_ATTRIBUTE_VALUE], + "startIndex": e.get(ENTITY_ATTRIBUTE_START), + "length": (e[ENTITY_ATTRIBUTE_END] - e[ENTITY_ATTRIBUTE_START]) + if ENTITY_ATTRIBUTE_START in e and ENTITY_ATTRIBUTE_END in e + else None, + "score": e.get(PREDICTED_CONFIDENCE_KEY), + "modelType": e.get(EXTRACTOR), + } + ] + return entities + + def normalise_response_json(self, data: Dict[Text, Any]) -> Dict[Text, Any]: + """Transform response JSON to LUIS format. + + Args: + data: input JSON data as a dictionary. + + Returns: + The transformed input data. + """ + top = data.get(INTENT) + + return { + "query": data[TEXT], + "prediction": { + "normalizedQuery": data[TEXT], + "topIntent": top[INTENT_NAME_KEY] if top else None, + "intents": self._intents(data), + "entities": self._entities(data), + }, + } diff --git a/rasa/nlu/emulators/no_emulator.py b/rasa/nlu/emulators/no_emulator.py new file mode 100644 index 0000000..4889f2a --- /dev/null +++ b/rasa/nlu/emulators/no_emulator.py @@ -0,0 +1,10 @@ +from typing import Any, Dict, Text +from rasa.nlu.emulators.emulator import Emulator + + +class NoEmulator(Emulator): + """Default emulator that is used when no emulator is specified.""" + + def normalise_response_json(self, data: Dict[Text, Any]) -> Dict[Text, Any]: + """Transform data to target format.""" + return data diff --git a/rasa/nlu/emulators/wit.py b/rasa/nlu/emulators/wit.py new file mode 100644 index 0000000..1f5c9c0 --- /dev/null +++ b/rasa/nlu/emulators/wit.py @@ -0,0 +1,56 @@ +from collections import defaultdict +from typing import Any, Dict, Text + +from rasa.nlu.emulators.emulator import Emulator +from rasa.shared.nlu.constants import ( + ENTITIES, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_ROLE, + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_END, + ENTITY_ATTRIBUTE_START, + TEXT, + INTENT, +) + + +class WitEmulator(Emulator): + """Emulates the response format of this wit.ai endpoint. + + More information about the endpoint: + https://wit.ai/docs/http/20200513/#get__message_link + """ + + def normalise_response_json(self, data: Dict[Text, Any]) -> Dict[Text, Any]: + """Transform response JSON to wit.ai format. + + Args: + data: input JSON data as a dictionary. + + Returns: + The transformed input data. + """ + entities = defaultdict(list) + for entity in data[ENTITIES]: + entity_name = entity[ENTITY_ATTRIBUTE_TYPE] + role = entity.get(ENTITY_ATTRIBUTE_ROLE, entity_name) + entity_name_including_role = f"{entity[ENTITY_ATTRIBUTE_TYPE]}:{role}" + normalized_entity: Dict[Text, Any] = { + "confidence": entity.get("confidence_entity") or 1, + "name": entity_name, + "value": entity[ENTITY_ATTRIBUTE_VALUE], + # Entity value before value was transformed (e.g. by synonym mapper) + "body": data["text"][ + entity.get(ENTITY_ATTRIBUTE_START, 0) : entity.get( + ENTITY_ATTRIBUTE_END, 0 + ) + ], + "start": entity[ENTITY_ATTRIBUTE_START], + "end": entity[ENTITY_ATTRIBUTE_END], + "role": role, + "entities": [], + } + + entities[entity_name_including_role].append(normalized_entity) + + return {"text": data[TEXT], "intents": [data[INTENT]], "entities": entities} diff --git a/rasa/nlu/extractors/__init__.py b/rasa/nlu/extractors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/nlu/extractors/crf_entity_extractor.py b/rasa/nlu/extractors/crf_entity_extractor.py new file mode 100644 index 0000000..357d604 --- /dev/null +++ b/rasa/nlu/extractors/crf_entity_extractor.py @@ -0,0 +1,717 @@ +from __future__ import annotations + +import logging +import typing +from collections import OrderedDict +from enum import Enum +from typing import Any, Dict, List, Optional, Text, Tuple, Callable, Type + +import numpy as np + +import rasa.nlu.utils.bilou_utils as bilou_utils +import rasa.shared.utils.io +import rasa.utils.train_utils +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.constants import TOKENS_NAMES +from rasa.nlu.extractors.extractor import EntityExtractorMixin +from rasa.nlu.test import determine_token_labels +from rasa.nlu.tokenizers.spacy_tokenizer import POS_TAG_KEY +from rasa.nlu.tokenizers.tokenizer import Token, Tokenizer +from rasa.shared.constants import DOCS_URL_COMPONENTS +from rasa.shared.nlu.constants import ( + TEXT, + ENTITIES, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_GROUP, + ENTITY_ATTRIBUTE_ROLE, + NO_ENTITY_TAG, + SPLIT_ENTITIES_BY_COMMA, + SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE, +) +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.utils.tensorflow.constants import BILOU_FLAG, FEATURIZERS + +logger = logging.getLogger(__name__) + +if typing.TYPE_CHECKING: + from sklearn_crfsuite import CRF + + +CONFIG_FEATURES = "features" + + +class CRFToken: + def __init__( + self, + text: Text, + pos_tag: Text, + pattern: Dict[Text, Any], + dense_features: np.ndarray, + entity_tag: Text, + entity_role_tag: Text, + entity_group_tag: Text, + ): + self.text = text + self.pos_tag = pos_tag + self.pattern = pattern + self.dense_features = dense_features + self.entity_tag = entity_tag + self.entity_role_tag = entity_role_tag + self.entity_group_tag = entity_group_tag + + def to_dict(self) -> Dict[str, Any]: + return { + "text": self.text, + "pos_tag": self.pos_tag, + "pattern": self.pattern, + "dense_features": [str(x) for x in list(self.dense_features)], + "entity_tag": self.entity_tag, + "entity_role_tag": self.entity_role_tag, + "entity_group_tag": self.entity_group_tag, + } + + @classmethod + def create_from_dict(cls, data: Dict[str, Any]) -> "CRFToken": + return cls( + data["text"], + data["pos_tag"], + data["pattern"], + np.array([float(x) for x in data["dense_features"]]), + data["entity_tag"], + data["entity_role_tag"], + data["entity_group_tag"], + ) + + +class CRFEntityExtractorOptions(str, Enum): + """Features that can be used for the 'CRFEntityExtractor'.""" + + PATTERN = "pattern" + LOW = "low" + TITLE = "title" + PREFIX5 = "prefix5" + PREFIX2 = "prefix2" + SUFFIX5 = "suffix5" + SUFFIX3 = "suffix3" + SUFFIX2 = "suffix2" + SUFFIX1 = "suffix1" + BIAS = "bias" + POS = "pos" + POS2 = "pos2" + UPPER = "upper" + DIGIT = "digit" + TEXT_DENSE_FEATURES = "text_dense_features" + ENTITY = "entity" + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.ENTITY_EXTRACTOR, is_trainable=True +) +class CRFEntityExtractor(GraphComponent, EntityExtractorMixin): + """Implements conditional random fields (CRF) to do named entity recognition.""" + + CONFIG_FEATURES = "features" + + function_dict: Dict[Text, Callable[[CRFToken], Any]] = { + CRFEntityExtractorOptions.LOW: lambda crf_token: crf_token.text.lower(), + CRFEntityExtractorOptions.TITLE: lambda crf_token: crf_token.text.istitle(), + CRFEntityExtractorOptions.PREFIX5: lambda crf_token: crf_token.text[:5], + CRFEntityExtractorOptions.PREFIX2: lambda crf_token: crf_token.text[:2], + CRFEntityExtractorOptions.SUFFIX5: lambda crf_token: crf_token.text[-5:], + CRFEntityExtractorOptions.SUFFIX3: lambda crf_token: crf_token.text[-3:], + CRFEntityExtractorOptions.SUFFIX2: lambda crf_token: crf_token.text[-2:], + CRFEntityExtractorOptions.SUFFIX1: lambda crf_token: crf_token.text[-1:], + CRFEntityExtractorOptions.BIAS: lambda _: "bias", + CRFEntityExtractorOptions.POS: lambda crf_token: crf_token.pos_tag, + CRFEntityExtractorOptions.POS2: lambda crf_token: crf_token.pos_tag[:2] + if crf_token.pos_tag is not None + else None, + CRFEntityExtractorOptions.UPPER: lambda crf_token: crf_token.text.isupper(), + CRFEntityExtractorOptions.DIGIT: lambda crf_token: crf_token.text.isdigit(), + CRFEntityExtractorOptions.PATTERN: lambda crf_token: crf_token.pattern, + CRFEntityExtractorOptions.TEXT_DENSE_FEATURES: ( + lambda crf_token: CRFEntityExtractor._convert_dense_features_for_crfsuite( # noqa: E501 + crf_token + ) + ), + CRFEntityExtractorOptions.ENTITY: lambda crf_token: crf_token.entity_tag, + } + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [Tokenizer] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """The component's default config (see parent class for full docstring).""" + return { + # BILOU_flag determines whether to use BILOU tagging or not. + # More rigorous however requires more examples per entity + # rule of thumb: use only if more than 100 egs. per entity + BILOU_FLAG: True, + # Split entities by comma, this makes sense e.g. for a list of ingredients + # in a recipie, but it doesn't make sense for the parts of an address + SPLIT_ENTITIES_BY_COMMA: True, + # crf_features is [before, token, after] array with before, token, + # after holding keys about which features to use for each token, + # for example, 'title' in array before will have the feature + # "is the preceding token in title case?" + # POS features require SpacyTokenizer + # pattern feature require RegexFeaturizer + CONFIG_FEATURES: [ + [ + CRFEntityExtractorOptions.LOW, + CRFEntityExtractorOptions.TITLE, + CRFEntityExtractorOptions.UPPER, + ], + [ + CRFEntityExtractorOptions.LOW, + CRFEntityExtractorOptions.BIAS, + CRFEntityExtractorOptions.PREFIX5, + CRFEntityExtractorOptions.PREFIX2, + CRFEntityExtractorOptions.SUFFIX5, + CRFEntityExtractorOptions.SUFFIX3, + CRFEntityExtractorOptions.SUFFIX2, + CRFEntityExtractorOptions.UPPER, + CRFEntityExtractorOptions.TITLE, + CRFEntityExtractorOptions.DIGIT, + CRFEntityExtractorOptions.PATTERN, + ], + [ + CRFEntityExtractorOptions.LOW, + CRFEntityExtractorOptions.TITLE, + CRFEntityExtractorOptions.UPPER, + ], + ], + # The maximum number of iterations for optimization algorithms. + "max_iterations": 50, + # weight of the L1 regularization + "L1_c": 0.1, + # weight of the L2 regularization + "L2_c": 0.1, + # Name of dense featurizers to use. + # If list is empty all available dense features are used. + "featurizers": [], + } + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + entity_taggers: Optional[Dict[Text, "CRF"]] = None, + ) -> None: + """Creates an instance of entity extractor.""" + self.component_config = config + self._model_storage = model_storage + self._resource = resource + + self.entity_taggers = entity_taggers + + self.crf_order = [ + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_ROLE, + ENTITY_ATTRIBUTE_GROUP, + ] + + self._validate_configuration() + + self.split_entities_config = rasa.utils.train_utils.init_split_entities( + config[SPLIT_ENTITIES_BY_COMMA], SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE + ) + + def _validate_configuration(self) -> None: + if len(self.component_config.get(CONFIG_FEATURES, [])) % 2 != 1: + raise ValueError( + "Need an odd number of crf feature lists to have a center word." + ) + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> CRFEntityExtractor: + """Creates a new untrained component (see parent class for full docstring).""" + return cls(config, model_storage, resource) + + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return ["sklearn_crfsuite", "sklearn"] + + def train(self, training_data: TrainingData) -> Resource: + """Trains the extractor on a data set.""" + # checks whether there is at least one + # example with an entity annotation + if not training_data.entity_examples: + logger.debug( + "No training examples with entities present. Skip training" + "of 'CRFEntityExtractor'." + ) + return self._resource + + self.check_correct_entity_annotations(training_data) + + if self.component_config[BILOU_FLAG]: + bilou_utils.apply_bilou_schema(training_data) + + # only keep the CRFs for tags we actually have training data for + self._update_crf_order(training_data) + + # filter out pre-trained entity examples + entity_examples = self.filter_trainable_entities(training_data.nlu_examples) + entity_examples = [ + message + for message in entity_examples + if message.features_present( + attribute=TEXT, featurizers=self.component_config.get(FEATURIZERS) + ) + ] + dataset = [self._convert_to_crf_tokens(example) for example in entity_examples] + + self.entity_taggers = self.train_model( + dataset, self.component_config, self.crf_order + ) + + self.persist(dataset) + + return self._resource + + def _update_crf_order(self, training_data: TrainingData) -> None: + """Train only CRFs we actually have training data for.""" + _crf_order = [] + + for tag_name in self.crf_order: + if tag_name == ENTITY_ATTRIBUTE_TYPE and training_data.entities: + _crf_order.append(ENTITY_ATTRIBUTE_TYPE) + elif tag_name == ENTITY_ATTRIBUTE_ROLE and training_data.entity_roles: + _crf_order.append(ENTITY_ATTRIBUTE_ROLE) + elif tag_name == ENTITY_ATTRIBUTE_GROUP and training_data.entity_groups: + _crf_order.append(ENTITY_ATTRIBUTE_GROUP) + + self.crf_order = _crf_order + + def process(self, messages: List[Message]) -> List[Message]: + """Augments messages with entities.""" + for message in messages: + entities = self.extract_entities(message) + entities = self.add_extractor_name(entities) + message.set( + ENTITIES, message.get(ENTITIES, []) + entities, add_to_output=True + ) + + return messages + + def extract_entities(self, message: Message) -> List[Dict[Text, Any]]: + """Extract entities from the given message using the trained model(s).""" + if self.entity_taggers is None or not message.features_present( + attribute=TEXT, featurizers=self.component_config.get(FEATURIZERS) + ): + return [] + + tokens = message.get(TOKENS_NAMES[TEXT]) + crf_tokens = self._convert_to_crf_tokens(message) + + predictions: Dict[Text, List[Dict[Text, float]]] = {} + for tag_name, entity_tagger in self.entity_taggers.items(): + # use predicted entity tags as features for second level CRFs + include_tag_features = tag_name != ENTITY_ATTRIBUTE_TYPE + if include_tag_features: + self._add_tag_to_crf_token(crf_tokens, predictions) + + features = self._crf_tokens_to_features( + crf_tokens, self.component_config, include_tag_features + ) + predictions[tag_name] = entity_tagger.predict_marginals_single(features) + + # convert predictions into a list of tags and a list of confidences + tags, confidences = self._tag_confidences(tokens, predictions) + + return self.convert_predictions_into_entities( + message.get(TEXT), tokens, tags, self.split_entities_config, confidences + ) + + def _add_tag_to_crf_token( + self, + crf_tokens: List[CRFToken], + predictions: Dict[Text, List[Dict[Text, float]]], + ) -> None: + """Add predicted entity tags to CRF tokens.""" + if ENTITY_ATTRIBUTE_TYPE in predictions: + _tags, _ = self._most_likely_tag(predictions[ENTITY_ATTRIBUTE_TYPE]) + for tag, token in zip(_tags, crf_tokens): + token.entity_tag = tag + + def _most_likely_tag( + self, predictions: List[Dict[Text, float]] + ) -> Tuple[List[Text], List[float]]: + """Get the entity tags with the highest confidence. + + Args: + predictions: list of mappings from entity tag to confidence value + + Returns: + List of entity tags and list of confidence values. + """ + _tags = [] + _confidences = [] + + for token_predictions in predictions: + tag = max(token_predictions, key=lambda key: token_predictions[key]) + _tags.append(tag) + + if self.component_config[BILOU_FLAG]: + # if we are using BILOU flags, we will sum up the prob + # of the B, I, L and U tags for an entity + _confidences.append( + sum( + _confidence + for _tag, _confidence in token_predictions.items() + if bilou_utils.tag_without_prefix(tag) + == bilou_utils.tag_without_prefix(_tag) + ) + ) + else: + _confidences.append(token_predictions[tag]) + + return _tags, _confidences + + def _tag_confidences( + self, tokens: List[Token], predictions: Dict[Text, List[Dict[Text, float]]] + ) -> Tuple[Dict[Text, List[Text]], Dict[Text, List[float]]]: + """Get most likely tag predictions with confidence values for tokens.""" + tags = {} + confidences = {} + + for tag_name, predicted_tags in predictions.items(): + if len(tokens) != len(predicted_tags): + raise Exception( + "Inconsistency in amount of tokens between crfsuite and message" + ) + + _tags, _confidences = self._most_likely_tag(predicted_tags) + + if self.component_config[BILOU_FLAG]: + _tags, _confidences = bilou_utils.ensure_consistent_bilou_tagging( + _tags, _confidences + ) + + confidences[tag_name] = _confidences + tags[tag_name] = _tags + + return tags, confidences + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> CRFEntityExtractor: + """Loads trained component (see parent class for full docstring).""" + try: + with model_storage.read_from(resource) as model_dir: + dataset = rasa.shared.utils.io.read_json_file( + model_dir / "crf_dataset.json" + ) + crf_order = rasa.shared.utils.io.read_json_file( + model_dir / "crf_order.json" + ) + + dataset = [ + [CRFToken.create_from_dict(token_data) for token_data in sub_list] + for sub_list in dataset + ] + + entity_taggers = cls.train_model(dataset, config, crf_order) + + entity_extractor = cls(config, model_storage, resource, entity_taggers) + entity_extractor.crf_order = crf_order + return entity_extractor + except ValueError: + logger.warning( + f"Failed to load {cls.__name__} from model storage. Resource " + f"'{resource.name}' doesn't exist." + ) + return cls(config, model_storage, resource) + + def persist(self, dataset: List[List[CRFToken]]) -> None: + """Persist this model into the passed directory.""" + with self._model_storage.write_to(self._resource) as model_dir: + data_to_store = [ + [token.to_dict() for token in sub_list] for sub_list in dataset + ] + + rasa.shared.utils.io.dump_obj_as_json_to_file( + model_dir / "crf_dataset.json", data_to_store + ) + rasa.shared.utils.io.dump_obj_as_json_to_file( + model_dir / "crf_order.json", self.crf_order + ) + + @classmethod + def _crf_tokens_to_features( + cls, + crf_tokens: List[CRFToken], + config: Dict[str, Any], + include_tag_features: bool = False, + ) -> List[Dict[Text, Any]]: + """Convert the list of tokens into discrete features.""" + configured_features = config[CONFIG_FEATURES] + sentence_features = [] + + for token_idx in range(len(crf_tokens)): + # the features for the current token include features of the token + # before and after the current features (if defined in the config) + # token before (-1), current token (0), token after (+1) + window_size = len(configured_features) + half_window_size = window_size // 2 + window_range = range(-half_window_size, half_window_size + 1) + + token_features = cls._create_features_for_token( + crf_tokens, + token_idx, + half_window_size, + window_range, + include_tag_features, + config, + ) + + sentence_features.append(token_features) + + return sentence_features + + @classmethod + def _create_features_for_token( + cls, + crf_tokens: List[CRFToken], + token_idx: int, + half_window_size: int, + window_range: range, + include_tag_features: bool, + config: Dict[str, Any], + ) -> Dict[Text, Any]: + """Convert a token into discrete features including words before and after.""" + configured_features = config[CONFIG_FEATURES] + prefixes = [str(i) for i in window_range] + + token_features = {} + + # iterate over the tokens in the window range (-1, 0, +1) to collect the + # features for the token at token_idx + for pointer_position in window_range: + current_token_idx = token_idx + pointer_position + + if current_token_idx >= len(crf_tokens): + # token is at the end of the sentence + token_features["EOS"] = True + elif current_token_idx < 0: + # token is at the beginning of the sentence + token_features["BOS"] = True + else: + token = crf_tokens[current_token_idx] + + # get the features to extract for the token we are currently looking at + current_feature_idx = pointer_position + half_window_size + features = configured_features[current_feature_idx] + + prefix = prefixes[current_feature_idx] + + # we add the 'entity' feature to include the entity type as features + # for the role and group CRFs + # (do not modify features, otherwise we will end up adding 'entity' + # over and over again, making training very slow) + additional_features = [] + if include_tag_features: + additional_features.append(CRFEntityExtractorOptions.ENTITY) + + for feature in features + additional_features: + if feature == CRFEntityExtractorOptions.PATTERN: + # add all regexes extracted from the 'RegexFeaturizer' as a + # feature: 'pattern_name' is the name of the pattern the user + # set in the training data, 'matched' is either 'True' or + # 'False' depending on whether the token actually matches the + # pattern or not + regex_patterns = cls.function_dict[feature](token) + for pattern_name, matched in regex_patterns.items(): + token_features[ + f"{prefix}:{feature}:{pattern_name}" + ] = matched + else: + value = cls.function_dict[feature](token) + token_features[f"{prefix}:{feature}"] = value + + return token_features + + @staticmethod + def _crf_tokens_to_tags(crf_tokens: List[CRFToken], tag_name: Text) -> List[Text]: + """Return the list of tags for the given tag name.""" + if tag_name == ENTITY_ATTRIBUTE_ROLE: + return [crf_token.entity_role_tag for crf_token in crf_tokens] + if tag_name == ENTITY_ATTRIBUTE_GROUP: + return [crf_token.entity_group_tag for crf_token in crf_tokens] + + return [crf_token.entity_tag for crf_token in crf_tokens] + + @staticmethod + def _pattern_of_token(message: Message, idx: int) -> Dict[Text, bool]: + """Get the patterns of the token at the given index extracted by the + 'RegexFeaturizer'. + + The 'RegexFeaturizer' adds all patterns listed in the training data to the + token. The pattern name is mapped to either 'True' (pattern applies to token) or + 'False' (pattern does not apply to token). + + Args: + message: The message. + idx: The token index. + + Returns: + The pattern dict. + """ + if message.get(TOKENS_NAMES[TEXT]) is not None: + return message.get(TOKENS_NAMES[TEXT])[idx].get( + CRFEntityExtractorOptions.PATTERN, {} + ) + return {} + + def _get_dense_features(self, message: Message) -> Optional[np.ndarray]: + """Convert dense features to python-crfsuite feature format.""" + features, _ = message.get_dense_features( + TEXT, self.component_config["featurizers"] + ) + + if features is None: + return None + + tokens = message.get(TOKENS_NAMES[TEXT]) + if len(tokens) != len(features.features): + rasa.shared.utils.io.raise_warning( + f"Number of dense features ({len(features.features)}) for attribute " + f"'{TEXT}' does not match number of tokens ({len(tokens)}).", + docs=DOCS_URL_COMPONENTS + "#crfentityextractor", + ) + return None + + return features.features + + @staticmethod + def _convert_dense_features_for_crfsuite( + crf_token: CRFToken, + ) -> Dict[Text, Dict[Text, float]]: + """Converts dense features of CRFTokens to dicts for the crfsuite.""" + feature_dict = { + str(index): token_features + for index, token_features in enumerate(crf_token.dense_features) + } + converted = {"text_dense_features": feature_dict} + return converted + + def _convert_to_crf_tokens(self, message: Message) -> List[CRFToken]: + """Take a message and convert it to crfsuite format.""" + crf_format = [] + tokens = message.get(TOKENS_NAMES[TEXT]) + + text_dense_features = self._get_dense_features(message) + tags = self._get_tags(message) + + for i, token in enumerate(tokens): + pattern = self._pattern_of_token(message, i) + entity = self.get_tag_for(tags, ENTITY_ATTRIBUTE_TYPE, i) + group = self.get_tag_for(tags, ENTITY_ATTRIBUTE_GROUP, i) + role = self.get_tag_for(tags, ENTITY_ATTRIBUTE_ROLE, i) + pos_tag = token.get(POS_TAG_KEY) + dense_features = ( + text_dense_features[i] if text_dense_features is not None else [] + ) + + crf_format.append( + CRFToken( + text=token.text, + pos_tag=pos_tag, + entity_tag=entity, + entity_group_tag=group, + entity_role_tag=role, + pattern=pattern, + dense_features=dense_features, + ) + ) + + return crf_format + + def _get_tags(self, message: Message) -> Dict[Text, List[Text]]: + """Get assigned entity tags of message.""" + tokens = message.get(TOKENS_NAMES[TEXT]) + tags = {} + + for tag_name in self.crf_order: + if self.component_config[BILOU_FLAG]: + bilou_key = bilou_utils.get_bilou_key_for_tag(tag_name) + if message.get(bilou_key): + _tags = message.get(bilou_key) + else: + _tags = [NO_ENTITY_TAG for _ in tokens] + else: + _tags = [ + determine_token_labels( + token, message.get(ENTITIES), attribute_key=tag_name + ) + for token in tokens + ] + tags[tag_name] = _tags + + return tags + + @classmethod + def train_model( + cls, + df_train: List[List[CRFToken]], + config: Dict[str, Any], + crf_order: List[str], + ) -> OrderedDict[str, CRF]: + """Train the crf tagger based on the training data.""" + import sklearn_crfsuite + + entity_taggers = OrderedDict() + + for tag_name in crf_order: + logger.debug(f"Training CRF for '{tag_name}'.") + + # add entity tag features for second level CRFs + include_tag_features = tag_name != ENTITY_ATTRIBUTE_TYPE + X_train = ( + cls._crf_tokens_to_features(sentence, config, include_tag_features) + for sentence in df_train + ) + y_train = ( + cls._crf_tokens_to_tags(sentence, tag_name) for sentence in df_train + ) + + entity_tagger = sklearn_crfsuite.CRF( + algorithm="lbfgs", + # coefficient for L1 penalty + c1=config["L1_c"], + # coefficient for L2 penalty + c2=config["L2_c"], + # stop earlier + max_iterations=config["max_iterations"], + # include transitions that are possible, but not observed + all_possible_transitions=True, + ) + entity_tagger.fit(X_train, y_train) + + entity_taggers[tag_name] = entity_tagger + + logger.debug("Training finished.") + + return entity_taggers diff --git a/rasa/nlu/extractors/duckling_entity_extractor.py b/rasa/nlu/extractors/duckling_entity_extractor.py new file mode 100644 index 0000000..aa5b8f0 --- /dev/null +++ b/rasa/nlu/extractors/duckling_entity_extractor.py @@ -0,0 +1,206 @@ +from __future__ import annotations +import time +import json +import logging +import os +import requests +from typing import Any, List, Optional, Text, Dict + +import rasa.utils.endpoints as endpoints_utils +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.constants import DOCS_URL_COMPONENTS +from rasa.shared.nlu.constants import ENTITIES, TEXT +from rasa.nlu.extractors.extractor import EntityExtractorMixin +from rasa.shared.nlu.training_data.message import Message +import rasa.shared.utils.io + + +logger = logging.getLogger(__name__) + + +def extract_value(match: Dict[Text, Any]) -> Dict[Text, Any]: + if match["value"].get("type") == "interval": + value = { + "to": match["value"].get("to", {}).get("value"), + "from": match["value"].get("from", {}).get("value"), + } + else: + value = match["value"].get("value") + + return value + + +def convert_duckling_format_to_rasa( + matches: List[Dict[Text, Any]] +) -> List[Dict[Text, Any]]: + extracted = [] + + for match in matches: + value = extract_value(match) + entity = { + "start": match["start"], + "end": match["end"], + "text": match.get("body", match.get("text", None)), + "value": value, + "confidence": 1.0, + "additional_info": match["value"], + "entity": match["dim"], + } + + extracted.append(entity) + + return extracted + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.ENTITY_EXTRACTOR, is_trainable=False +) +class DucklingEntityExtractor(GraphComponent, EntityExtractorMixin): + """Searches for structured entities, e.g. dates, using a duckling server.""" + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """The component's default config (see parent class for full docstring).""" + return { + # by default all dimensions recognized by duckling are returned + # dimensions can be configured to contain an array of strings + # with the names of the dimensions to filter for + "dimensions": None, + # http url of the running duckling server + "url": None, + # locale - if not set, we will use the language of the model + "locale": None, + # timezone like Europe/Berlin + # if not set the default timezone of Duckling is going to be used + "timezone": None, + # Timeout for receiving response from HTTP URL of the running + # duckling server. If not set the default timeout of duckling HTTP URL + # is set to 3 seconds. + "timeout": 3, + } + + def __init__(self, config: Dict[Text, Any]) -> None: + """Creates the extractor. + + Args: + config: The extractor's config. + """ + self.component_config = config + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> DucklingEntityExtractor: + """Creates component (see parent class for full docstring).""" + return cls(config) + + def _url(self) -> Optional[Text]: + """Return url of the duckling service. Environment var will override.""" + if os.environ.get("RASA_DUCKLING_HTTP_URL"): + return os.environ["RASA_DUCKLING_HTTP_URL"] + + return self.component_config.get("url") + + def _payload(self, text: Text, reference_time: int) -> Dict[Text, Any]: + dimensions = self.component_config["dimensions"] + return { + "text": text, + "locale": self.component_config["locale"], + "tz": self.component_config.get("timezone"), + "dims": json.dumps(dimensions), + "reftime": reference_time, + } + + def _duckling_parse(self, text: Text, reference_time: int) -> List[Dict[Text, Any]]: + """Sends the request to the duckling server and parses the result. + + Args: + text: Text for duckling server to parse. + reference_time: Reference time in milliseconds. + + Returns: + JSON response from duckling server with parse data. + """ + parse_url = endpoints_utils.concat_url(self._url(), "/parse") + try: + payload = self._payload(text, reference_time) + headers = { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" + } + response = requests.post( + parse_url, + data=payload, + headers=headers, + timeout=self.component_config.get("timeout"), + ) + if response.status_code == 200: + return response.json() + else: + logger.error( + f"Failed to get a proper response from remote " + f"duckling at '{parse_url}. " + f"Status Code: {response.status_code}. " + f"Response: {response.text}" + ) + return [] + except ( + requests.exceptions.ConnectionError, + requests.exceptions.ReadTimeout, + ) as e: + logger.error( + "Failed to connect to duckling http server. Make sure " + "the duckling server is running/healthy/not stale and the proper host " + "and port are set in the configuration. More " + "information on how to run the server can be found on " + "github: " + "https://github.com/facebook/duckling#quickstart " + "Error: {}".format(e) + ) + return [] + + @staticmethod + def _reference_time_from_message(message: Message) -> int: + if message.time is not None: + try: + return message.time * 1000 + except ValueError as e: + logging.warning( + "Could not parse timestamp {}. Instead " + "current UTC time will be passed to " + "duckling. Error: {}".format(message.time, e) + ) + # fallbacks to current time, multiplied by 1000 because duckling + # requires the reftime in milliseconds + return int(time.time()) * 1000 + + def process(self, messages: List[Message]) -> List[Message]: + """Augments the message with potentially extracted entities.""" + if self._url() is None: + rasa.shared.utils.io.raise_warning( + "Duckling HTTP component in pipeline, but no " + "`url` configuration in the config " + "file nor is `RASA_DUCKLING_HTTP_URL` " + "set as an environment variable. No entities will be extracted!", + docs=DOCS_URL_COMPONENTS + "#DucklingEntityExtractor", + ) + return messages + + for message in messages: + reference_time = self._reference_time_from_message(message) + matches = self._duckling_parse(message.get(TEXT), reference_time) + all_extracted = convert_duckling_format_to_rasa(matches) + dimensions = self.component_config["dimensions"] + extracted = self.filter_irrelevant_entities(all_extracted, dimensions) + extracted = self.add_extractor_name(extracted) + message.set( + ENTITIES, message.get(ENTITIES, []) + extracted, add_to_output=True + ) + + return messages diff --git a/rasa/nlu/extractors/entity_synonyms.py b/rasa/nlu/extractors/entity_synonyms.py new file mode 100644 index 0000000..7c3765e --- /dev/null +++ b/rasa/nlu/extractors/entity_synonyms.py @@ -0,0 +1,178 @@ +from __future__ import annotations +import os +from typing import Any, Dict, List, Optional, Text +import logging + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.shared.constants import DOCS_URL_TRAINING_DATA +from rasa.shared.nlu.constants import ENTITIES, TEXT +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.utils import write_json_to_file +from rasa.nlu.extractors.extractor import EntityExtractorMixin +import rasa.utils.io +import rasa.shared.utils.io +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.ENTITY_EXTRACTOR, is_trainable=True +) +class EntitySynonymMapper(GraphComponent, EntityExtractorMixin): + """Maps entities to their synonyms if they appear in the training data.""" + + SYNONYM_FILENAME = "synonyms.json" + + def __init__( + self, + config: Optional[Dict[Text, Any]], + model_storage: ModelStorage, + resource: Resource, + synonyms: Optional[Dict[Text, Any]] = None, + ) -> None: + """Creates the mapper. + + Args: + config: The mapper's config. + model_storage: Storage which the component can use to persist and load + itself. + resource: Resource locator for this component which can be used to persist + and load itself from the `model_storage`. + synonyms: A dictionary of previously known synonyms. + """ + self._config = config + self._model_storage = model_storage + self._resource = resource + + self.synonyms = synonyms if synonyms else {} + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + synonyms: Optional[Dict[Text, Any]] = None, + ) -> EntitySynonymMapper: + """Creates component (see parent class for full docstring).""" + return cls(config, model_storage, resource, synonyms) + + def train(self, training_data: TrainingData) -> Resource: + """Trains the synonym lookup table.""" + for key, value in list(training_data.entity_synonyms.items()): + self._add_entities_if_synonyms(key, value) + + for example in training_data.entity_examples: + for entity in example.get(ENTITIES, []): + entity_val = example.get(TEXT)[entity["start"] : entity["end"]] + self._add_entities_if_synonyms(entity_val, str(entity.get("value"))) + + self._persist() + return self._resource + + def process(self, messages: List[Message]) -> List[Message]: + """Modifies entities attached to message to resolve synonyms. + + Args: + messages: List containing the latest user message + + Returns: + List containing the latest user message with entities resolved to + synonyms if there is a match. + """ + for message in messages: + updated_entities = message.get(ENTITIES, [])[:] + self.replace_synonyms(updated_entities) + message.set(ENTITIES, updated_entities, add_to_output=True) + + return messages + + def _persist(self) -> None: + if self.synonyms: + with self._model_storage.write_to(self._resource) as storage: + entity_synonyms_file = storage / EntitySynonymMapper.SYNONYM_FILENAME + + write_json_to_file( + entity_synonyms_file, self.synonyms, separators=(",", ": ") + ) + + # Adapt to get path from model storage and resource + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> EntitySynonymMapper: + """Loads trained component (see parent class for full docstring).""" + synonyms = None + try: + with model_storage.read_from(resource) as storage: + entity_synonyms_file = storage / EntitySynonymMapper.SYNONYM_FILENAME + + if os.path.isfile(entity_synonyms_file): + synonyms = rasa.shared.utils.io.read_json_file(entity_synonyms_file) + else: + synonyms = None + rasa.shared.utils.io.raise_warning( + f"Failed to load synonyms file from '{entity_synonyms_file}'.", + docs=DOCS_URL_TRAINING_DATA + "#synonyms", + ) + except ValueError: + logger.debug( + f"Failed to load {cls.__class__.__name__} from model storage. Resource " + f"'{resource.name}' doesn't exist." + ) + + return cls(config, model_storage, resource, synonyms) + + def replace_synonyms(self, entities: List[Dict[Text, Any]]) -> None: + """Replace any entities which match a synonym with the synonymous entity.""" + for entity in entities: + # need to wrap in `str` to handle e.g. entity values of type int + entity_value = str(entity["value"]) + if entity_value.lower() in self.synonyms: + entity["value"] = self.synonyms[entity_value.lower()] + self.add_processor_name(entity) + + def _add_entities_if_synonyms(self, entity: Text, synonym: Optional[Text]) -> None: + """Adds entities to the synonym lookup table. + + Lowercase is used as keys to make the lookup case-insensitive. + """ + if synonym is not None: + entity = str(entity) + synonym = str(synonym) + if entity != synonym: + entity_lowercase = entity.lower() + if ( + entity_lowercase in self.synonyms + and self.synonyms[entity_lowercase] != synonym + ): + rasa.shared.utils.io.raise_warning( + f"Found conflicting synonym definitions " + f"for {repr(entity_lowercase)}. Overwriting target " + f"{repr(self.synonyms[entity_lowercase])} with " + f"{repr(synonym)}. " + f"Check your training data and remove " + f"conflicting synonym definitions to " + f"prevent this from happening.", + docs=DOCS_URL_TRAINING_DATA + "#synonyms", + ) + + self.synonyms[entity_lowercase] = synonym + + # add a self-reference to handle entities extracted in alternate cases + # i.e. for a synonym Austria, + # entities extracted as AUSTRIA, austria, ausTRIA, etc + # should also have the value of `Austria` + synonym_lowercase = synonym.lower() + if synonym_lowercase not in self.synonyms: + self.synonyms[synonym_lowercase] = synonym diff --git a/rasa/nlu/extractors/extractor.py b/rasa/nlu/extractors/extractor.py new file mode 100644 index 0000000..8f38eb3 --- /dev/null +++ b/rasa/nlu/extractors/extractor.py @@ -0,0 +1,471 @@ +import abc +from typing import Any, Dict, List, NamedTuple, Text, Tuple, Optional + +import rasa.shared.utils.io +from rasa.shared.constants import DOCS_URL_TRAINING_DATA_NLU +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.tokenizers.tokenizer import Token +from rasa.nlu.constants import ( + TOKENS_NAMES, + ENTITY_ATTRIBUTE_CONFIDENCE_TYPE, + ENTITY_ATTRIBUTE_CONFIDENCE_ROLE, + ENTITY_ATTRIBUTE_CONFIDENCE_GROUP, +) +from rasa.shared.nlu.constants import ( + TEXT, + INTENT, + ENTITIES, + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + EXTRACTOR, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_GROUP, + ENTITY_ATTRIBUTE_ROLE, + NO_ENTITY_TAG, + SPLIT_ENTITIES_BY_COMMA, + SINGLE_ENTITY_ALLOWED_INTERLEAVING_CHARSET, +) +import rasa.utils.train_utils + + +class EntityTagSpec(NamedTuple): + """Specification of an entity tag present in the training data.""" + + tag_name: Text + ids_to_tags: Dict[int, Text] + tags_to_ids: Dict[Text, int] + num_tags: int + + +class EntityExtractorMixin(abc.ABC): + """Provides functionality for components that do entity extraction. + + Inheriting from this class will add utility functions for entity extraction. + Entity extraction is the process of identifying and extracting entities like a + person's name, or a location from a message. + """ + + @property + def name(self) -> Text: + """Returns the name of the class.""" + return self.__class__.__name__ + + def add_extractor_name( + self, entities: List[Dict[Text, Any]] + ) -> List[Dict[Text, Any]]: + """Adds this extractor's name to a list of entities. + + Args: + entities: the extracted entities. + + Returns: + the modified entities. + """ + for entity in entities: + entity[EXTRACTOR] = self.name + return entities + + def add_processor_name(self, entity: Dict[Text, Any]) -> Dict[Text, Any]: + """Adds this extractor's name to the list of processors for this entity. + + Args: + entity: the extracted entity and its metadata. + + Returns: + the modified entity. + """ + if "processors" in entity: + entity["processors"].append(self.name) + else: + entity["processors"] = [self.name] + + return entity + + @staticmethod + def filter_irrelevant_entities(extracted: list, requested_dimensions: set) -> list: + """Only return dimensions the user configured.""" + if requested_dimensions: + return [ + entity + for entity in extracted + if entity[ENTITY_ATTRIBUTE_TYPE] in requested_dimensions + ] + return extracted + + @staticmethod + def find_entity( + entity: Dict[Text, Any], text: Text, tokens: List[Token] + ) -> Tuple[int, int]: + offsets = [token.start for token in tokens] + ends = [token.end for token in tokens] + + if entity[ENTITY_ATTRIBUTE_START] not in offsets: + message = ( + "Invalid entity {} in example '{}': " + "entities must span whole tokens. " + "Wrong entity start.".format(entity, text) + ) + raise ValueError(message) + + if entity[ENTITY_ATTRIBUTE_END] not in ends: + message = ( + "Invalid entity {} in example '{}': " + "entities must span whole tokens. " + "Wrong entity end.".format(entity, text) + ) + raise ValueError(message) + + start = offsets.index(entity[ENTITY_ATTRIBUTE_START]) + end = ends.index(entity[ENTITY_ATTRIBUTE_END]) + 1 + return start, end + + def filter_trainable_entities( + self, entity_examples: List[Message] + ) -> List[Message]: + """Filters out untrainable entity annotations. + + Creates a copy of entity_examples in which entities that have + `extractor` set to something other than + self.name (e.g. 'CRFEntityExtractor') are removed. + """ + + filtered = [] + for message in entity_examples: + entities = [] + for ent in message.get(ENTITIES, []): + extractor = ent.get(EXTRACTOR) + if not extractor or extractor == self.name: + entities.append(ent) + data = message.data.copy() + data[ENTITIES] = entities + filtered.append( + Message( + text=message.get(TEXT), + data=data, + output_properties=message.output_properties, + time=message.time, + features=message.features, + ) + ) + + return filtered + + @staticmethod + def convert_predictions_into_entities( + text: Text, + tokens: List[Token], + tags: Dict[Text, List[Text]], + split_entities_config: Dict[Text, bool] = None, + confidences: Optional[Dict[Text, List[float]]] = None, + ) -> List[Dict[Text, Any]]: + """Convert predictions into entities. + + Args: + text: The text message. + tokens: Message tokens without CLS token. + tags: Predicted tags. + split_entities_config: config for handling splitting a list of entities + confidences: Confidences of predicted tags. + + Returns: + Entities. + """ + import rasa.nlu.utils.bilou_utils as bilou_utils + + entities = [] + + last_entity_tag = NO_ENTITY_TAG + last_role_tag = NO_ENTITY_TAG + last_group_tag = NO_ENTITY_TAG + last_token_end = -1 + + for idx, token in enumerate(tokens): + current_entity_tag = EntityExtractorMixin.get_tag_for( + tags, ENTITY_ATTRIBUTE_TYPE, idx + ) + + if current_entity_tag == NO_ENTITY_TAG: + last_entity_tag = NO_ENTITY_TAG + last_token_end = token.end + continue + + current_group_tag = EntityExtractorMixin.get_tag_for( + tags, ENTITY_ATTRIBUTE_GROUP, idx + ) + current_group_tag = bilou_utils.tag_without_prefix(current_group_tag) + current_role_tag = EntityExtractorMixin.get_tag_for( + tags, ENTITY_ATTRIBUTE_ROLE, idx + ) + current_role_tag = bilou_utils.tag_without_prefix(current_role_tag) + + group_or_role_changed = ( + last_group_tag != current_group_tag or last_role_tag != current_role_tag + ) + + if bilou_utils.bilou_prefix_from_tag(current_entity_tag): + # checks for new bilou tag + # new bilou tag begins are not with I- , L- tags + new_bilou_tag_starts = last_entity_tag != current_entity_tag and ( + bilou_utils.LAST + != bilou_utils.bilou_prefix_from_tag(current_entity_tag) + and bilou_utils.INSIDE + != bilou_utils.bilou_prefix_from_tag(current_entity_tag) + ) + + # to handle bilou tags such as only I-, L- tags without B-tag + # and handle multiple U-tags consecutively + new_unigram_bilou_tag_starts = ( + last_entity_tag == NO_ENTITY_TAG + or bilou_utils.UNIT + == bilou_utils.bilou_prefix_from_tag(current_entity_tag) + ) + + new_tag_found = ( + new_bilou_tag_starts + or new_unigram_bilou_tag_starts + or group_or_role_changed + ) + last_entity_tag = current_entity_tag + current_entity_tag = bilou_utils.tag_without_prefix(current_entity_tag) + else: + new_tag_found = ( + last_entity_tag != current_entity_tag or group_or_role_changed + ) + last_entity_tag = current_entity_tag + + if new_tag_found: + # new entity found + entity = EntityExtractorMixin._create_new_entity( + list(tags.keys()), + current_entity_tag, + current_group_tag, + current_role_tag, + token, + idx, + confidences, + ) + entities.append(entity) + elif EntityExtractorMixin._check_is_single_entity( + text, token, last_token_end, split_entities_config, current_entity_tag + ): + # current token has the same entity tag as the token before and + # the two tokens are separated by at most 3 symbols, where each + # of the symbols has to be either punctuation (e.g. "." or ",") + # and a whitespace. + entities[-1][ENTITY_ATTRIBUTE_END] = token.end + if confidences is not None: + EntityExtractorMixin._update_confidence_values( + entities, confidences, idx + ) + + else: + # the token has the same entity tag as the token before but the two + # tokens are separated by at least 2 symbols (e.g. multiple spaces, + # a comma and a space, etc.) and also shouldn't be represented as a + # single entity + entity = EntityExtractorMixin._create_new_entity( + list(tags.keys()), + current_entity_tag, + current_group_tag, + current_role_tag, + token, + idx, + confidences, + ) + entities.append(entity) + + last_group_tag = current_group_tag + last_role_tag = current_role_tag + last_token_end = token.end + + for entity in entities: + entity[ENTITY_ATTRIBUTE_VALUE] = text[ + entity[ENTITY_ATTRIBUTE_START] : entity[ENTITY_ATTRIBUTE_END] + ] + + return entities + + @staticmethod + def _update_confidence_values( + entities: List[Dict[Text, Any]], confidences: Dict[Text, List[float]], idx: int + ) -> None: + # use the lower confidence value + entities[-1][ENTITY_ATTRIBUTE_CONFIDENCE_TYPE] = min( + entities[-1][ENTITY_ATTRIBUTE_CONFIDENCE_TYPE], + confidences[ENTITY_ATTRIBUTE_TYPE][idx], + ) + if ENTITY_ATTRIBUTE_ROLE in entities[-1]: + entities[-1][ENTITY_ATTRIBUTE_CONFIDENCE_ROLE] = min( + entities[-1][ENTITY_ATTRIBUTE_CONFIDENCE_ROLE], + confidences[ENTITY_ATTRIBUTE_ROLE][idx], + ) + if ENTITY_ATTRIBUTE_GROUP in entities[-1]: + entities[-1][ENTITY_ATTRIBUTE_CONFIDENCE_GROUP] = min( + entities[-1][ENTITY_ATTRIBUTE_CONFIDENCE_GROUP], + confidences[ENTITY_ATTRIBUTE_GROUP][idx], + ) + + @staticmethod + def _check_is_single_entity( + text: Text, + token: Token, + last_token_end: int, + split_entities_config: Dict[Text, bool], + current_entity_tag: Text, + ) -> bool: + # current token has the same entity tag as the token before and + # the two tokens are only separated by at most one symbol (e.g. space, + # dash, etc.) + if token.start - last_token_end <= 1: + return True + + # Tokens need to be no further than 3 positions apart + # The magic number 3 is chosen such that the following two cases can be + # extracted + # - Schönhauser Allee 175, 10119 Berlin + # (address compounds separated by 2 tokens (", ")) + # - 22 Powderhall Rd., EH7 4GB + # (abbreviated "Rd." results in a separation of 3 tokens ("., ")) + # More than 3 might already introduce cases that shouldn't be considered by + # this logic + tokens_within_range = token.start - last_token_end <= 3 + + # The interleaving tokens *must* be a full stop, a comma, or a whitespace + interleaving_text = text[last_token_end : token.start] + tokens_separated_by_allowed_chars = all( + filter( + lambda char: True + if char in SINGLE_ENTITY_ALLOWED_INTERLEAVING_CHARSET + else False, + interleaving_text, + ) + ) + + # The current entity type must match with the config (default value is True) + default_value = split_entities_config[SPLIT_ENTITIES_BY_COMMA] + split_current_entity_type = split_entities_config.get( + current_entity_tag, default_value + ) + + return ( + tokens_within_range + and tokens_separated_by_allowed_chars + and not split_current_entity_type + ) + + @staticmethod + def get_tag_for(tags: Dict[Text, List[Text]], tag_name: Text, idx: int) -> Text: + """Get the value of the given tag name from the list of tags. + + Args: + tags: Mapping of tag name to list of tags; + tag_name: The tag name of interest. + idx: The index position of the tag. + + Returns: + The tag value. + """ + if tag_name in tags: + return tags[tag_name][idx] + return NO_ENTITY_TAG + + @staticmethod + def _create_new_entity( + tag_names: List[Text], + entity_tag: Text, + group_tag: Text, + role_tag: Text, + token: Token, + idx: int, + confidences: Optional[Dict[Text, List[float]]] = None, + ) -> Dict[Text, Any]: + """Create a new entity. + + Args: + tag_names: The tag names to include in the entity. + entity_tag: The entity type value. + group_tag: The entity group value. + role_tag: The entity role value. + token: The token. + confidence: The confidence value. + + Returns: + Created entity. + """ + entity = { + ENTITY_ATTRIBUTE_TYPE: entity_tag, + ENTITY_ATTRIBUTE_START: token.start, + ENTITY_ATTRIBUTE_END: token.end, + } + + if confidences is not None: + entity[ENTITY_ATTRIBUTE_CONFIDENCE_TYPE] = confidences[ + ENTITY_ATTRIBUTE_TYPE + ][idx] + + if ENTITY_ATTRIBUTE_ROLE in tag_names and role_tag != NO_ENTITY_TAG: + entity[ENTITY_ATTRIBUTE_ROLE] = role_tag + if confidences is not None: + entity[ENTITY_ATTRIBUTE_CONFIDENCE_ROLE] = confidences[ + ENTITY_ATTRIBUTE_ROLE + ][idx] + if ENTITY_ATTRIBUTE_GROUP in tag_names and group_tag != NO_ENTITY_TAG: + entity[ENTITY_ATTRIBUTE_GROUP] = group_tag + if confidences is not None: + entity[ENTITY_ATTRIBUTE_CONFIDENCE_GROUP] = confidences[ + ENTITY_ATTRIBUTE_GROUP + ][idx] + + return entity + + @staticmethod + def check_correct_entity_annotations(training_data: TrainingData) -> None: + """Check if entities are correctly annotated in the training data. + + If the start and end values of an entity do not match any start and end values + of the respected token, we define an entity as misaligned and log a warning. + + Args: + training_data: The training data. + """ + for example in training_data.entity_examples: + entity_boundaries = [ + (entity[ENTITY_ATTRIBUTE_START], entity[ENTITY_ATTRIBUTE_END]) + for entity in example.get(ENTITIES) + ] + token_start_positions = [t.start for t in example.get(TOKENS_NAMES[TEXT])] + token_end_positions = [t.end for t in example.get(TOKENS_NAMES[TEXT])] + + for entity_start, entity_end in entity_boundaries: + if ( + entity_start not in token_start_positions + or entity_end not in token_end_positions + ): + entities_repr = [ + ( + entity[ENTITY_ATTRIBUTE_START], + entity[ENTITY_ATTRIBUTE_END], + entity[ENTITY_ATTRIBUTE_VALUE], + ) + for entity in example.get(ENTITIES) + ] + tokens_repr = [ + (t.start, t.end, t.text) + for t in example.get(TOKENS_NAMES[TEXT]) + ] + rasa.shared.utils.io.raise_warning( + f"Misaligned entity annotation in message " + f"'{example.get(TEXT)}' with intent '{example.get(INTENT)}'. " + f"Make sure the start and end values of entities " + f"({entities_repr}) in the training " + f"data match the token boundaries ({tokens_repr}). " + "Common causes: \n 1) entities include trailing whitespaces " + "or punctuation" + "\n 2) the tokenizer gives an unexpected result, due to " + "languages such as Chinese that don't use whitespace for word " + "separation", + docs=DOCS_URL_TRAINING_DATA_NLU, + ) + break diff --git a/rasa/nlu/extractors/mitie_entity_extractor.py b/rasa/nlu/extractors/mitie_entity_extractor.py new file mode 100644 index 0000000..2a2705f --- /dev/null +++ b/rasa/nlu/extractors/mitie_entity_extractor.py @@ -0,0 +1,293 @@ +from __future__ import annotations +import logging +from rasa.nlu.tokenizers.tokenizer import Tokenizer +import typing +from typing import Any, Dict, List, Optional, Text, Type + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.constants import TOKENS_NAMES +from rasa.shared.nlu.constants import ( + ENTITY_ATTRIBUTE_CONFIDENCE, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_VALUE, + TEXT, + ENTITIES, +) +from rasa.nlu.utils.mitie_utils import MitieModel, MitieNLP +from rasa.nlu.extractors.extractor import EntityExtractorMixin +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +import rasa.shared.utils.io +from rasa.shared.exceptions import InvalidConfigException + +logger = logging.getLogger(__name__) + +if typing.TYPE_CHECKING: + import mitie + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.ENTITY_EXTRACTOR, + is_trainable=True, + model_from="MitieNLP", +) +class MitieEntityExtractor(GraphComponent, EntityExtractorMixin): + """A Mitie Entity Extractor (which is a thin wrapper around `Dlib-ml`).""" + + MITIE_RESOURCE_FILE = "mitie_ner.dat" + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [MitieNLP, Tokenizer] + + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return ["mitie"] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """The component's default config (see parent class for full docstring).""" + return {"num_threads": 1} + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + ner: Optional["mitie.named_entity_extractor"] = None, + ) -> None: + """Creates a new instance. + + Args: + config: The configuration. + model_storage: Storage which graph components can use to persist and load + themselves. + resource: Resource locator for this component which can be used to persist + and load itself from the `model_storage`. + ner: Mitie named entity extractor + """ + self._config = config + self._model_storage = model_storage + self._resource = resource + self.validate_config(self._config) + self._ner = ner + + def validate_config(cls, config: Dict[Text, Any]) -> None: + """Checks whether the given configuration is valid. + + Args: + config: a configuration for a Mitie entity extractor component + """ + num_threads = config.get("num_threads") + if num_threads is None or num_threads <= 0: + raise InvalidConfigException( + f"Expected `num_threads` to be some value >= 1 (default: 1)." + f"but received {num_threads}" + ) + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + """Creates a new `MitieEntityExtractor`. + + Args: + config: This config overrides the `default_config`. + model_storage: Storage which graph components can use to persist and load + themselves. + resource: Resource locator for this component which can be used to persist + and load itself from the `model_storage`. + execution_context: Information about the current graph run. Unused. + + Returns: An instantiated `MitieEntityExtractor`. + """ + return cls(config, model_storage, resource) + + def train(self, training_data: TrainingData, model: MitieModel) -> Resource: + """Trains a MITIE named entity recognizer. + + Args: + training_data: the training data + model: a MitieModel + Returns: + resource for loading the trained model + """ + import mitie + + trainer = mitie.ner_trainer(str(model.model_path)) + trainer.num_threads = self._config["num_threads"] + + # check whether there are any (not pre-trained) entities in the training data + found_one_entity = False + + # filter out pre-trained entity examples + filtered_entity_examples = self.filter_trainable_entities( + training_data.nlu_examples + ) + + for example in filtered_entity_examples: + sample = self._prepare_mitie_sample(example) + + found_one_entity = sample.num_entities > 0 or found_one_entity + trainer.add(sample) + + # Mitie will fail to train if there is not a single entity tagged + if found_one_entity: + self._ner = trainer.train() + else: + rasa.shared.utils.io.raise_warning( + f"{self.__class__.__name__} could not be trained because no trainable " + f"entities where found in the given training data. Please add some " + f"NLU training examples that include entities where the `extractor` " + f"is either `None` or '{self.__class__.__name__}'." + ) + + self.persist() + return self._resource + + @staticmethod + def _prepare_mitie_sample(training_example: Message) -> Any: + """Prepare a message so that it can be passed to a MITIE trainer.""" + import mitie + + text = training_example.get(TEXT) + tokens = training_example.get(TOKENS_NAMES[TEXT]) + sample = mitie.ner_training_instance([t.text for t in tokens]) + for ent in training_example.get(ENTITIES, []): + try: + # if the token is not aligned an exception will be raised + start, end = MitieEntityExtractor.find_entity(ent, text, tokens) + except ValueError as e: + rasa.shared.utils.io.raise_warning( + f"Failed to use example '{text}' to train MITIE " + f"entity extractor. Example will be skipped." + f"Error: {e}" + ) + continue + try: + # mitie will raise an exception on malicious + # input - e.g. on overlapping entities + sample.add_entity(list(range(start, end)), ent["entity"]) + except Exception as e: + rasa.shared.utils.io.raise_warning( + f"Failed to add entity example " + f"'{str(e)}' of sentence '{str(text)}'. " + f"Example will be ignored. Reason: " + f"{e}" + ) + continue + return sample + + def process(self, messages: List[Message], model: MitieModel) -> List[Message]: + """Extracts entities from messages and appends them to the attribute. + + If no patterns where found during training, then the given messages will not + be modified. In particular, if no `ENTITIES` attribute exists yet, then + it will *not* be created. + + If no pattern can be found in the given message, then no entities will be + added to any existing list of entities. However, if no `ENTITIES` attribute + exists yet, then an `ENTITIES` attribute will be created. + + Returns: + the given list of messages that have been modified + """ + if not self._ner: + return messages + + for message in messages: + entities = self._extract_entities(message, mitie_model=model) + extracted = self.add_extractor_name(entities) + message.set( + ENTITIES, message.get(ENTITIES, []) + extracted, add_to_output=True + ) + return messages + + def _extract_entities( + self, message: Message, mitie_model: MitieModel + ) -> List[Dict[Text, Any]]: + """Extract entities of the given type from the given user message. + + Args: + message: a user message + mitie_model: MitieModel containing a `mitie.total_word_feature_extractor` + + Returns: + a list of dictionaries describing the entities + """ + text = message.get(TEXT) + tokens = message.get(TOKENS_NAMES[TEXT]) + + entities = [] + token_texts = [token.text for token in tokens] + if self._ner is None: + mitie_entities = [] + else: + mitie_entities = self._ner.extract_entities( + token_texts, mitie_model.word_feature_extractor + ) + for e in mitie_entities: + if len(e[0]): + start = tokens[e[0][0]].start + end = tokens[e[0][-1]].end + + entities.append( + { + ENTITY_ATTRIBUTE_TYPE: e[1], + ENTITY_ATTRIBUTE_VALUE: text[start:end], + ENTITY_ATTRIBUTE_START: start, + ENTITY_ATTRIBUTE_END: end, + ENTITY_ATTRIBUTE_CONFIDENCE: None, + } + ) + return entities + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> MitieEntityExtractor: + """Loads trained component (see parent class for full docstring).""" + import mitie + + try: + with model_storage.read_from(resource) as model_path: + ner_file = model_path / cls.MITIE_RESOURCE_FILE + if not ner_file.exists(): + raise FileNotFoundError( + f"Expected a MITIE extractor file at {ner_file}." + ) + ner = mitie.named_entity_extractor(str(ner_file)) + return cls(config, model_storage, resource, ner=ner) + + except (FileNotFoundError, ValueError) as e: + logger.debug( + f"Failed to load {cls.__name__} from model storage. " + f"This can happen if the model could not be trained because regexes " + f"could not be extracted from the given training data - and hence " + f"could not be persisted. Error: {e}." + ) + return cls(config, model_storage, resource) + + def persist(self) -> None: + """Persist this model.""" + if not self._ner: + return + with self._model_storage.write_to(self._resource) as model_path: + ner_file = model_path / self.MITIE_RESOURCE_FILE + self._ner.save_to_disk(str(ner_file), pure_model=True) diff --git a/rasa/nlu/extractors/regex_entity_extractor.py b/rasa/nlu/extractors/regex_entity_extractor.py new file mode 100644 index 0000000..5f84f93 --- /dev/null +++ b/rasa/nlu/extractors/regex_entity_extractor.py @@ -0,0 +1,220 @@ +from __future__ import annotations +import logging +import re +from typing import Any, Dict, List, Optional, Text + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.storage.resource import Resource +import rasa.shared.utils.io +import rasa.nlu.utils.pattern_utils as pattern_utils +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.constants import ( + ENTITIES, + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + TEXT, + ENTITY_ATTRIBUTE_TYPE, +) +from rasa.nlu.extractors.extractor import EntityExtractorMixin + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.ENTITY_EXTRACTOR, is_trainable=True +) +class RegexEntityExtractor(GraphComponent, EntityExtractorMixin): + """Extracts entities via lookup tables and regexes defined in the training data.""" + + REGEX_FILE_NAME = "regex.json" + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """The component's default config (see parent class for full docstring).""" + return { + # text will be processed with case insensitive as default + "case_sensitive": False, + # use lookup tables to extract entities + "use_lookup_tables": True, + # use regexes to extract entities + "use_regexes": True, + # use match word boundaries for lookup table + "use_word_boundaries": True, + } + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> RegexEntityExtractor: + """Creates a new `GraphComponent`. + + Args: + config: This config overrides the `default_config`. + model_storage: Storage which graph components can use to persist and load + themselves. + resource: Resource locator for this component which can be used to persist + and load itself from the `model_storage`. + execution_context: Information about the current graph run. Unused. + + Returns: An instantiated `GraphComponent`. + """ + return cls(config, model_storage, resource) + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + patterns: Optional[List[Dict[Text, Text]]] = None, + ) -> None: + """Creates a new instance. + + Args: + config: The configuration. + model_storage: Storage which graph components can use to persist and load + themselves. + resource: Resource locator for this component which can be used to persist + and load itself from the `model_storage`. + patterns: a list of patterns + """ + # graph component + self._config = {**self.get_default_config(), **config} + self._model_storage = model_storage + self._resource = resource + # extractor + self.case_sensitive = self._config["case_sensitive"] + self.patterns = patterns or [] + + def train(self, training_data: TrainingData) -> Resource: + """Extract patterns from the training data. + + Args: + training_data: the training data + """ + self.patterns = pattern_utils.extract_patterns( + training_data, + use_lookup_tables=self._config["use_lookup_tables"], + use_regexes=self._config["use_regexes"], + use_only_entities=True, + use_word_boundaries=self._config["use_word_boundaries"], + ) + + if not self.patterns: + rasa.shared.utils.io.raise_warning( + "No lookup tables or regexes defined in the training data that have " + "a name equal to any entity in the training data. In order for this " + "component to work you need to define valid lookup tables or regexes " + "in the training data." + ) + self.persist() + return self._resource + + def process(self, messages: List[Message]) -> List[Message]: + """Extracts entities from messages and appends them to the attribute. + + If no patterns where found during training, then the given messages will not + be modified. In particular, if no `ENTITIES` attribute exists yet, then + it will *not* be created. + + If no pattern can be found in the given message, then no entities will be + added to any existing list of entities. However, if no `ENTITIES` attribute + exists yet, then an `ENTITIES` attribute will be created. + + Returns: + the given list of messages that have been modified + """ + if not self.patterns: + rasa.shared.utils.io.raise_warning( + f"The {self.__class__.__name__} has not been " + f"trained properly yet. " + f"Continuing without extracting entities via this extractor." + ) + return messages + + for message in messages: + extracted_entities = self._extract_entities(message) + extracted_entities = self.add_extractor_name(extracted_entities) + message.set( + ENTITIES, + message.get(ENTITIES, []) + extracted_entities, + add_to_output=True, + ) + return messages + + def _extract_entities(self, message: Message) -> List[Dict[Text, Any]]: + """Extract entities of the given type from the given user message. + + Args: + message: a message + Returns: + a list of dictionaries describing the entities + """ + entities = [] + + flags = 0 # default flag + if not self.case_sensitive: + flags = re.IGNORECASE + + for pattern in self.patterns: + matches = re.finditer(pattern["pattern"], message.get(TEXT), flags=flags) + + for match in matches: + start_index = match.start() + end_index = match.end() + entities.append( + { + ENTITY_ATTRIBUTE_TYPE: pattern["name"], + ENTITY_ATTRIBUTE_START: start_index, + ENTITY_ATTRIBUTE_END: end_index, + ENTITY_ATTRIBUTE_VALUE: message.get(TEXT)[ + start_index:end_index + ], + } + ) + + return entities + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> RegexEntityExtractor: + """Loads trained component (see parent class for full docstring).""" + try: + with model_storage.read_from(resource) as model_path: + regex_file = model_path / cls.REGEX_FILE_NAME + patterns = rasa.shared.utils.io.read_json_file(regex_file) + return cls( + config, + model_storage=model_storage, + resource=resource, + patterns=patterns, + ) + except (ValueError, FileNotFoundError): + rasa.shared.utils.io.raise_warning( + f"Failed to load {cls.__name__} from model storage. " + f"This can happen if the model could not be trained because regexes " + f"could not be extracted from the given training data - and hence " + f"could not be persisted." + ) + return cls(config, model_storage=model_storage, resource=resource) + + def persist(self) -> None: + """Persist this model.""" + if not self.patterns: + return + with self._model_storage.write_to(self._resource) as model_path: + regex_file = model_path / self.REGEX_FILE_NAME + rasa.shared.utils.io.dump_obj_as_json_to_file(regex_file, self.patterns) diff --git a/rasa/nlu/extractors/spacy_entity_extractor.py b/rasa/nlu/extractors/spacy_entity_extractor.py new file mode 100644 index 0000000..7ae4c2b --- /dev/null +++ b/rasa/nlu/extractors/spacy_entity_extractor.py @@ -0,0 +1,95 @@ +import typing +from typing import Any, Dict, List, Text, Type + +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.nlu.constants import ENTITIES, TEXT +from rasa.nlu.utils.spacy_utils import SpacyModel, SpacyNLP +from rasa.nlu.extractors.extractor import EntityExtractorMixin +from rasa.shared.nlu.training_data.message import Message + +if typing.TYPE_CHECKING: + from spacy.tokens.doc import Doc + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.ENTITY_EXTRACTOR, + is_trainable=False, + model_from="SpacyNLP", +) +class SpacyEntityExtractor(GraphComponent, EntityExtractorMixin): + """Entity extractor which uses SpaCy.""" + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [SpacyNLP] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """The component's default config (see parent class for full docstring).""" + return { + # by default all dimensions recognized by spacy are returned + # dimensions can be configured to contain an array of strings + # with the names of the dimensions to filter for + "dimensions": None + } + + def __init__(self, config: Dict[Text, Any]) -> None: + """Initialize SpacyEntityExtractor.""" + self._config = config + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + """Creates a new component (see parent class for full docstring).""" + return cls(config) + + @staticmethod + def required_packages() -> List[Text]: + """Lists required dependencies (see parent class for full docstring).""" + return ["spacy"] + + def process(self, messages: List[Message], model: SpacyModel) -> List[Message]: + """Extract entities using SpaCy. + + Args: + messages: List of messages to process. + model: Container holding a loaded spacy nlp model. + + Returns: The processed messages. + """ + for message in messages: + # can't use the existing doc here (spacy_doc on the message) + # because tokens are lower cased which is bad for NER + spacy_nlp = model.model + doc = spacy_nlp(message.get(TEXT)) + all_extracted = self.add_extractor_name(self._extract_entities(doc)) + dimensions = self._config["dimensions"] + extracted = self.filter_irrelevant_entities(all_extracted, dimensions) + message.set( + ENTITIES, message.get(ENTITIES, []) + extracted, add_to_output=True + ) + + return messages + + @staticmethod + def _extract_entities(doc: "Doc") -> List[Dict[Text, Any]]: + entities = [ + { + "entity": ent.label_, + "value": ent.text, + "start": ent.start_char, + "confidence": None, + "end": ent.end_char, + } + for ent in doc.ents + ] + return entities diff --git a/rasa/nlu/featurizers/__init__.py b/rasa/nlu/featurizers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/nlu/featurizers/dense_featurizer/__init__.py b/rasa/nlu/featurizers/dense_featurizer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/nlu/featurizers/dense_featurizer/convert_featurizer.py b/rasa/nlu/featurizers/dense_featurizer/convert_featurizer.py new file mode 100644 index 0000000..526619e --- /dev/null +++ b/rasa/nlu/featurizers/dense_featurizer/convert_featurizer.py @@ -0,0 +1,449 @@ +from __future__ import annotations +import logging +import os +from typing import Any, Dict, List, Optional, Text, Tuple, Type + +import tensorflow as tf +from tensorflow.python.eager.wrap_function import WrappedFunction +from tqdm import tqdm +import numpy as np + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.storage.resource import Resource +import rasa.shared.utils.io +import rasa.core.utils +from rasa.nlu.tokenizers.tokenizer import Token, Tokenizer +from rasa.nlu.featurizers.dense_featurizer.dense_featurizer import DenseFeaturizer +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.constants import ( + DENSE_FEATURIZABLE_ATTRIBUTES, + TOKENS_NAMES, + NUMBER_OF_SUB_TOKENS, +) +from rasa.shared.nlu.constants import TEXT, ACTION_TEXT +from rasa.exceptions import RasaException +import rasa.nlu.utils +import rasa.utils.train_utils as train_utils + +logger = logging.getLogger(__name__) + +# URL to the old remote location of the model which +# users might use. The model is no longer hosted here. +ORIGINAL_TF_HUB_MODULE_URL = ( + "https://github.com/PolyAI-LDN/polyai-models/releases/download/v1.0/model.tar.gz" +) + +# Warning: This URL is only intended for running pytests on ConveRT +# related components. This URL should not be allowed to be used by the user. +RESTRICTED_ACCESS_URL = ( + "https://storage.googleapis.com/continuous-" + "integration-model-storage/convert_tf2.tar.gz" +) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_FEATURIZER, is_trainable=False +) +class ConveRTFeaturizer(DenseFeaturizer, GraphComponent): + """Featurizer using ConveRT model. + + Loads the ConveRT(https://github.com/PolyAI-LDN/polyai-models#convert) + model from TFHub and computes sentence and sequence level feature representations + for dense featurizable attributes of each message object. + """ + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [Tokenizer] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """The component's default config (see parent class for full docstring).""" + return { + **DenseFeaturizer.get_default_config(), + # Remote URL/Local path to model files + "model_url": None, + } + + @staticmethod + def required_packages() -> List[Text]: + """Packages needed to be installed.""" + return ["tensorflow_text", "tensorflow_hub"] + + @staticmethod + def supported_languages() -> Optional[List[Text]]: + """Determines which languages this component can work with. + + Returns: A list of supported languages, or `None` to signify all are supported. + """ + return ["en"] + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> ConveRTFeaturizer: + """Creates a new component (see parent class for full docstring).""" + return cls(name=execution_context.node_name, config=config) + + def __init__(self, name: Text, config: Dict[Text, Any]) -> None: + """Initializes a `ConveRTFeaturizer`. + + Args: + name: An identifier for this featurizer. + config: The configuration. + """ + super().__init__(name=name, config=config) + + model_url = self._config["model_url"] + self.model_url = ( + model_url + if rasa.nlu.utils.is_url(model_url) + else os.path.abspath(model_url) + ) + + self.module = train_utils.load_tf_hub_model(self.model_url) + + self.tokenize_signature: WrappedFunction = self._get_signature( + "tokenize", self.module + ) + self.sequence_encoding_signature: WrappedFunction = self._get_signature( + "encode_sequence", self.module + ) + self.sentence_encoding_signature: WrappedFunction = self._get_signature( + "default", self.module + ) + + @classmethod + def validate_config(cls, config: Dict[Text, Any]) -> None: + """Validates that the component is configured properly.""" + cls._validate_model_url(config) + + @staticmethod + def _validate_model_files_exist(model_directory: Text) -> None: + """Check if essential model files exist inside the model_directory. + + Args: + model_directory: Directory to investigate + """ + files_to_check = [ + os.path.join(model_directory, "saved_model.pb"), + os.path.join(model_directory, "variables/variables.index"), + os.path.join(model_directory, "variables/variables.data-00001-of-00002"), + os.path.join(model_directory, "variables/variables.data-00000-of-00002"), + ] + + for file_path in files_to_check: + if not os.path.exists(file_path): + raise RasaException( + f"File {file_path} does not exist. " + f"Re-check the files inside the directory {model_directory}. " + f"It should contain the following model " + f"files - [{', '.join(files_to_check)}]" + ) + + @classmethod + def _validate_model_url(cls, config: Dict[Text, Any]) -> None: + """Validates the specified `model_url` parameter. + + The `model_url` parameter cannot be left empty. It can either + be set to a remote URL where the model is hosted or it can be + a path to a local directory. + + Args: + config: a configuration for this graph component + """ + model_url = config.get("model_url", None) + + if not model_url: + raise RasaException( + f"Parameter 'model_url' was not specified in the configuration " + f"of '{ConveRTFeaturizer.__name__}'. " + f"It is mandatory to pass a value for this parameter. " + f"You can either use a community hosted URL of the model " + f"or if you have a local copy of the model, pass the " + f"path to the directory containing the model files." + ) + + if model_url == ORIGINAL_TF_HUB_MODULE_URL: + # Can't use the originally hosted URL + raise RasaException( + f"Parameter 'model_url' of " + f"'{ConveRTFeaturizer.__name__}' was " + f"set to '{model_url}' which does not contain the model any longer. " + f"You can either use a community hosted URL or if you have a " + f"local copy of the model, pass the path to the directory " + f"containing the model files." + ) + + if model_url == RESTRICTED_ACCESS_URL: + # Can't use the URL that is reserved for tests only + raise RasaException( + f"Parameter 'model_url' of " + f"'{ConveRTFeaturizer.__name__}' was " + f"set to '{model_url}' which is strictly reserved for pytests of " + f"Rasa Open Source only. Due to licensing issues you are " + f"not allowed to use the model from this URL. " + f"You can either use a community hosted URL or if you have a " + f"local copy of the model, pass the path to the directory " + f"containing the model files." + ) + + if os.path.isfile(model_url): + # Definitely invalid since the specified path should be a directory + raise RasaException( + f"Parameter 'model_url' of " + f"'{ConveRTFeaturizer.__name__}' was " + f"set to the path of a file which is invalid. You " + f"can either use a community hosted URL or if you have a " + f"local copy of the model, pass the path to the directory " + f"containing the model files." + ) + + if not rasa.nlu.utils.is_url(model_url) and not os.path.isdir(model_url): + raise RasaException( + f"{model_url} is neither a valid remote URL nor a local directory. " + f"You can either use a community hosted URL or if you have a " + f"local copy of the model, pass the path to " + f"the directory containing the model files." + ) + + if os.path.isdir(model_url): + # Looks like a local directory. Inspect the directory + # to see if model files exist. + cls._validate_model_files_exist(model_url) + + @staticmethod + def _get_signature(signature: Text, module: Any) -> WrappedFunction: + """Retrieve a signature from a (hopefully loaded) TF model.""" + if not module: + raise Exception( + f"{ConveRTFeaturizer.__name__} needs " + f"a proper loaded tensorflow module when used. " + f"Make sure to pass a module when training and using the component." + ) + + return module.signatures[signature] + + def _compute_features( + self, batch_examples: List[Message], attribute: Text = TEXT + ) -> Tuple[np.ndarray, np.ndarray]: + sentence_encodings = self._compute_sentence_encodings(batch_examples, attribute) + + ( + sequence_encodings, + number_of_tokens_in_sentence, + ) = self._compute_sequence_encodings(batch_examples, attribute) + + return self._get_features( + sentence_encodings, sequence_encodings, number_of_tokens_in_sentence + ) + + def _compute_sentence_encodings( + self, batch_examples: List[Message], attribute: Text = TEXT + ) -> np.ndarray: + # Get text for attribute of each example + batch_attribute_text = [ex.get(attribute) for ex in batch_examples] + sentence_encodings = self._sentence_encoding_of_text(batch_attribute_text) + + # convert them to a sequence of 1 + return np.reshape(sentence_encodings, (len(batch_examples), 1, -1)) + + def _compute_sequence_encodings( + self, batch_examples: List[Message], attribute: Text = TEXT + ) -> Tuple[np.ndarray, List[int]]: + list_of_tokens = [ + self.tokenize(example, attribute) for example in batch_examples + ] + + number_of_tokens_in_sentence = [ + len(sent_tokens) for sent_tokens in list_of_tokens + ] + + # join the tokens to get a clean text to ensure the sequence length of + # the returned embeddings from ConveRT matches the length of the tokens + # (including sub-tokens) + tokenized_texts = self._tokens_to_text(list_of_tokens) + token_features = self._sequence_encoding_of_text(tokenized_texts) + + # ConveRT might split up tokens into sub-tokens + # take the mean of the sub-token vectors and use that as the token vector + token_features = train_utils.align_token_features( + list_of_tokens, token_features + ) + + return token_features, number_of_tokens_in_sentence + + @staticmethod + def _get_features( + sentence_encodings: np.ndarray, + sequence_encodings: np.ndarray, + number_of_tokens_in_sentence: List[int], + ) -> Tuple[np.ndarray, np.ndarray]: + """Get the sequence and sentence features.""" + sentence_embeddings = [] + sequence_embeddings = [] + + for index in range(len(number_of_tokens_in_sentence)): + sequence_length = number_of_tokens_in_sentence[index] + sequence_encoding = sequence_encodings[index][:sequence_length] + sentence_encoding = sentence_encodings[index] + + sequence_embeddings.append(sequence_encoding) + sentence_embeddings.append(sentence_encoding) + + return np.array(sequence_embeddings), np.array(sentence_embeddings) + + @staticmethod + def _tokens_to_text(list_of_tokens: List[List[Token]]) -> List[Text]: + """Convert list of tokens to text. + + Add a whitespace between two tokens if the end value of the first tokens + is not the same as the end value of the second token. + """ + texts = [] + for tokens in list_of_tokens: + text = "" + offset = 0 + for token in tokens: + if offset != token.start: + text += " " + text += token.text + + offset = token.end + texts.append(text) + + return texts + + def _sentence_encoding_of_text(self, batch: List[Text]) -> np.ndarray: + + return self.sentence_encoding_signature(tf.convert_to_tensor(batch))[ + "default" + ].numpy() + + def _sequence_encoding_of_text(self, batch: List[Text]) -> np.ndarray: + + return self.sequence_encoding_signature(tf.convert_to_tensor(batch))[ + "sequence_encoding" + ].numpy() + + def process_training_data(self, training_data: TrainingData) -> TrainingData: + """Featurize all message attributes in the training data with the ConveRT model. + + Args: + training_data: Training data to be featurized + + Returns: + featurized training data + """ + batch_size = 64 + + for attribute in DENSE_FEATURIZABLE_ATTRIBUTES: + + non_empty_examples = list( + filter(lambda x: x.get(attribute), training_data.training_examples) + ) + + progress_bar = tqdm( + range(0, len(non_empty_examples), batch_size), + desc=attribute.capitalize() + " batches", + ) + for batch_start_index in progress_bar: + batch_end_index = min( + batch_start_index + batch_size, len(non_empty_examples) + ) + + # Collect batch examples + batch_examples = non_empty_examples[batch_start_index:batch_end_index] + + ( + batch_sequence_features, + batch_sentence_features, + ) = self._compute_features(batch_examples, attribute) + + self._set_features( + batch_examples, + batch_sequence_features, + batch_sentence_features, + attribute, + ) + return training_data + + def process(self, messages: List[Message]) -> List[Message]: + """Featurize an incoming message with the ConveRT model. + + Args: + messages: Message to be featurized + """ + for message in messages: + for attribute in {TEXT, ACTION_TEXT}: + if message.get(attribute): + sequence_features, sentence_features = self._compute_features( + [message], attribute=attribute + ) + + self._set_features( + [message], sequence_features, sentence_features, attribute + ) + return messages + + def _set_features( + self, + examples: List[Message], + sequence_features: np.ndarray, + sentence_features: np.ndarray, + attribute: Text, + ) -> None: + for index, example in enumerate(examples): + self.add_features_to_message( + sequence=sequence_features[index], + sentence=sentence_features[index], + message=example, + attribute=attribute, + ) + + def _tokenize(self, sentence: Text) -> Any: + + return self.tokenize_signature(tf.convert_to_tensor([sentence]))[ + "default" + ].numpy() + + def tokenize(self, message: Message, attribute: Text) -> List[Token]: + """Tokenize the text using the ConveRT model. + + ConveRT adds a special char in front of (some) words and splits words into + sub-words. To ensure the entity start and end values matches the token values, + reuse the tokens that are already assigned to the message. If individual tokens + are split up into multiple tokens, add this information to the + respected tokens. + """ + tokens_in = message.get(TOKENS_NAMES[attribute]) + + tokens_out = [] + + for token in tokens_in: + # use ConveRT model to tokenize the text + split_token_strings = self._tokenize(token.text)[0] + + # clean tokens (remove special chars and empty tokens) + split_token_strings = self._clean_tokens(split_token_strings) + + token.set(NUMBER_OF_SUB_TOKENS, len(split_token_strings)) + + tokens_out.append(token) + + message.set(TOKENS_NAMES[attribute], tokens_out) + return tokens_out + + @staticmethod + def _clean_tokens(tokens: List[bytes]) -> List[Text]: + """Encode tokens and remove special char added by ConveRT.""" + decoded_tokens = [string.decode("utf-8").replace("﹏", "") for string in tokens] + return [string for string in decoded_tokens if string] diff --git a/rasa/nlu/featurizers/dense_featurizer/dense_featurizer.py b/rasa/nlu/featurizers/dense_featurizer/dense_featurizer.py new file mode 100644 index 0000000..d3bc699 --- /dev/null +++ b/rasa/nlu/featurizers/dense_featurizer/dense_featurizer.py @@ -0,0 +1,57 @@ +from abc import ABC +from typing import Text +import numpy as np + +from rasa.nlu.featurizers.featurizer import Featurizer +from rasa.utils.tensorflow.constants import MEAN_POOLING, MAX_POOLING +from rasa.shared.exceptions import InvalidConfigException + + +class DenseFeaturizer(Featurizer[np.ndarray], ABC): + """Base class for all dense featurizers.""" + + @staticmethod + def aggregate_sequence_features( + dense_sequence_features: np.ndarray, + pooling_operation: Text, + only_non_zero_vectors: bool = True, + ) -> np.ndarray: + """Aggregates the non-zero vectors of a dense sequence feature matrix. + + Args: + dense_sequence_features: a 2-dimensional matrix where the first dimension + is the sequence dimension over which we want to aggregate of shape + [seq_len, feat_dim] + pooling_operation: either max pooling or average pooling + only_non_zero_vectors: determines whether the aggregation is done over + non-zero vectors only + Returns: + a matrix of shape [1, feat_dim] + """ + shape = dense_sequence_features.shape + if len(shape) != 2 or min(shape) == 0: + raise ValueError( + f"Expected a non-empty 2-dimensional matrix (where the first " + f"dimension is the sequence dimension which we want to aggregate), " + f"but found a matrix of shape {dense_sequence_features.shape}." + ) + + if only_non_zero_vectors: + # take only non zeros feature vectors into account + is_non_zero_vector = [f.any() for f in dense_sequence_features] + dense_sequence_features = dense_sequence_features[is_non_zero_vector] + + # if features are all zero, then we must continue with zeros + if not len(dense_sequence_features): + dense_sequence_features = np.zeros([1, shape[-1]]) + + if pooling_operation == MEAN_POOLING: + return np.mean(dense_sequence_features, axis=0, keepdims=True) + elif pooling_operation == MAX_POOLING: + return np.max(dense_sequence_features, axis=0, keepdims=True) + else: + raise InvalidConfigException( + f"Invalid pooling operation specified. Available operations are " + f"'{MEAN_POOLING}' or '{MAX_POOLING}', but provided value is " + f"'{pooling_operation}'." + ) diff --git a/rasa/nlu/featurizers/dense_featurizer/lm_featurizer.py b/rasa/nlu/featurizers/dense_featurizer/lm_featurizer.py new file mode 100644 index 0000000..f6c1536 --- /dev/null +++ b/rasa/nlu/featurizers/dense_featurizer/lm_featurizer.py @@ -0,0 +1,771 @@ +from __future__ import annotations +import numpy as np +import logging + +from typing import Any, Text, List, Dict, Tuple, Type +import tensorflow as tf + +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.featurizers.dense_featurizer.dense_featurizer import DenseFeaturizer +from rasa.nlu.tokenizers.tokenizer import Token, Tokenizer +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.constants import ( + DENSE_FEATURIZABLE_ATTRIBUTES, + SEQUENCE_FEATURES, + SENTENCE_FEATURES, + NO_LENGTH_RESTRICTION, + NUMBER_OF_SUB_TOKENS, + TOKENS_NAMES, +) +from rasa.shared.nlu.constants import TEXT, ACTION_TEXT +from rasa.utils import train_utils +from rasa.utils.tensorflow.model_data import ragged_array_to_ndarray + +logger = logging.getLogger(__name__) + +MAX_SEQUENCE_LENGTHS = { + "bert": 512, + "gpt": 512, + "gpt2": 512, + "xlnet": NO_LENGTH_RESTRICTION, + "distilbert": 512, + "roberta": 512, + "camembert": 512, +} + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_FEATURIZER, is_trainable=False +) +class LanguageModelFeaturizer(DenseFeaturizer, GraphComponent): + """A featurizer that uses transformer-based language models. + + This component loads a pre-trained language model + from the Transformers library (https://github.com/huggingface/transformers) + including BERT, GPT, GPT-2, xlnet, distilbert, and roberta. + It also tokenizes and featurizes the featurizable dense attributes of + each message. + """ + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [Tokenizer] + + def __init__( + self, config: Dict[Text, Any], execution_context: ExecutionContext + ) -> None: + """Initializes the featurizer with the model in the config.""" + super(LanguageModelFeaturizer, self).__init__( + execution_context.node_name, config + ) + self._load_model_metadata() + self._load_model_instance() + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns LanguageModelFeaturizer's default config.""" + return { + **DenseFeaturizer.get_default_config(), + # name of the language model to load. + "model_name": "bert", + # Pre-Trained weights to be loaded(string) + "model_weights": None, + # an optional path to a specific directory to download + # and cache the pre-trained model weights. + "cache_dir": None, + } + + @classmethod + def validate_config(cls, config: Dict[Text, Any]) -> None: + """Validates the configuration.""" + pass + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> LanguageModelFeaturizer: + """Creates a LanguageModelFeaturizer. + + Loads the model specified in the config. + """ + return cls(config, execution_context) + + @staticmethod + def required_packages() -> List[Text]: + """Returns the extra python dependencies required.""" + return ["transformers"] + + def _load_model_metadata(self) -> None: + """Loads the metadata for the specified model and set them as properties. + + This includes the model name, model weights, cache directory and the + maximum sequence length the model can handle. + """ + from rasa.nlu.utils.hugging_face.registry import ( + model_class_dict, + model_weights_defaults, + ) + + self.model_name = self._config["model_name"] + + if self.model_name not in model_class_dict: + raise KeyError( + f"'{self.model_name}' not a valid model name. Choose from " + f"{str(list(model_class_dict.keys()))} or create" + f"a new class inheriting from this class to support your model." + ) + + self.model_weights = self._config["model_weights"] + self.cache_dir = self._config["cache_dir"] + + if not self.model_weights: + logger.info( + f"Model weights not specified. Will choose default model " + f"weights: {model_weights_defaults[self.model_name]}" + ) + self.model_weights = model_weights_defaults[self.model_name] + + self.max_model_sequence_length = MAX_SEQUENCE_LENGTHS[self.model_name] + + def _load_model_instance(self) -> None: + """Tries to load the model instance. + + Model loading should be skipped in unit tests. + See unit tests for examples. + """ + from rasa.nlu.utils.hugging_face.registry import ( + model_class_dict, + model_tokenizer_dict, + ) + + logger.debug(f"Loading Tokenizer and Model for {self.model_name}") + + self.tokenizer = model_tokenizer_dict[self.model_name].from_pretrained( + self.model_weights, cache_dir=self.cache_dir + ) + self.model = model_class_dict[self.model_name].from_pretrained( + self.model_weights, cache_dir=self.cache_dir + ) + + # Use a universal pad token since all transformer architectures do not have a + # consistent token. Instead of pad_token_id we use unk_token_id because + # pad_token_id is not set for all architectures. We can't add a new token as + # well since vocabulary resizing is not yet supported for TF classes. + # Also, this does not hurt the model predictions since we use an attention mask + # while feeding input. + self.pad_token_id = self.tokenizer.unk_token_id + + def _lm_tokenize(self, text: Text) -> Tuple[List[int], List[Text]]: + """Passes the text through the tokenizer of the language model. + + Args: + text: Text to be tokenized. + + Returns: List of token ids and token strings. + """ + split_token_ids = self.tokenizer.encode(text, add_special_tokens=False) + + split_token_strings = self.tokenizer.convert_ids_to_tokens(split_token_ids) + + return split_token_ids, split_token_strings + + def _add_lm_specific_special_tokens( + self, token_ids: List[List[int]] + ) -> List[List[int]]: + """Adds the language and model-specific tokens used during training. + + Args: + token_ids: List of token ids for each example in the batch. + + Returns: Augmented list of token ids for each example in the batch. + """ + from rasa.nlu.utils.hugging_face.registry import ( + model_special_tokens_pre_processors, + ) + + augmented_tokens = [ + model_special_tokens_pre_processors[self.model_name](example_token_ids) + for example_token_ids in token_ids + ] + return augmented_tokens + + def _lm_specific_token_cleanup( + self, split_token_ids: List[int], token_strings: List[Text] + ) -> Tuple[List[int], List[Text]]: + """Cleans up special chars added by tokenizers of language models. + + Many language models add a special char in front/back of (some) words. We clean + up those chars as they are not + needed once the features are already computed. + + Args: + split_token_ids: List of token ids received as output from the language + model specific tokenizer. + token_strings: List of token strings received as output from the language + model specific tokenizer. + + Returns: Cleaned up token ids and token strings. + """ + from rasa.nlu.utils.hugging_face.registry import model_tokens_cleaners + + return model_tokens_cleaners[self.model_name](split_token_ids, token_strings) + + def _post_process_sequence_embeddings( + self, sequence_embeddings: np.ndarray + ) -> Tuple[np.ndarray, np.ndarray]: + """Computes sentence and sequence level representations for relevant tokens. + + Args: + sequence_embeddings: Sequence level dense features received as output from + language model. + + Returns: Sentence and sequence level representations. + """ + from rasa.nlu.utils.hugging_face.registry import ( + model_embeddings_post_processors, + ) + + sentence_embeddings = [] + post_processed_sequence_embeddings = [] + + for example_embedding in sequence_embeddings: + ( + example_sentence_embedding, + example_post_processed_embedding, + ) = model_embeddings_post_processors[self.model_name](example_embedding) + + sentence_embeddings.append(example_sentence_embedding) + post_processed_sequence_embeddings.append(example_post_processed_embedding) + + return ( + np.array(sentence_embeddings), + ragged_array_to_ndarray(post_processed_sequence_embeddings), + ) + + def _tokenize_example( + self, message: Message, attribute: Text + ) -> Tuple[List[Token], List[int]]: + """Tokenizes a single message example. + + Many language models add a special char in front of (some) words and split + words into sub-words. To ensure the entity start and end values matches the + token values, use the tokens produced by the Tokenizer component. If + individual tokens are split up into multiple tokens, we add this information + to the respected token. + + Args: + message: Single message object to be processed. + attribute: Property of message to be processed, one of ``TEXT`` or + ``RESPONSE``. + + Returns: List of token strings and token ids for the corresponding + attribute of the message. + """ + tokens_in = message.get(TOKENS_NAMES[attribute]) + tokens_out = [] + + token_ids_out = [] + + for token in tokens_in: + # use lm specific tokenizer to further tokenize the text + split_token_ids, split_token_strings = self._lm_tokenize(token.text) + + if not split_token_ids: + # fix the situation that `token.text` only contains whitespace or other + # special characters, which cause `split_token_ids` and + # `split_token_strings` be empty, finally cause + # `self._lm_specific_token_cleanup()` to raise an exception + continue + + (split_token_ids, split_token_strings) = self._lm_specific_token_cleanup( + split_token_ids, split_token_strings + ) + + token_ids_out += split_token_ids + + token.set(NUMBER_OF_SUB_TOKENS, len(split_token_strings)) + + tokens_out.append(token) + + return tokens_out, token_ids_out + + def _get_token_ids_for_batch( + self, batch_examples: List[Message], attribute: Text + ) -> Tuple[List[List[Token]], List[List[int]]]: + """Computes token ids and token strings for each example in batch. + + A token id is the id of that token in the vocabulary of the language model. + + Args: + batch_examples: Batch of message objects for which tokens need to be + computed. + attribute: Property of message to be processed, one of ``TEXT`` or + ``RESPONSE``. + + Returns: List of token strings and token ids for each example in the batch. + """ + batch_token_ids = [] + batch_tokens = [] + for example in batch_examples: + + example_tokens, example_token_ids = self._tokenize_example( + example, attribute + ) + batch_tokens.append(example_tokens) + batch_token_ids.append(example_token_ids) + + return batch_tokens, batch_token_ids + + @staticmethod + def _compute_attention_mask( + actual_sequence_lengths: List[int], max_input_sequence_length: int + ) -> np.ndarray: + """Computes a mask for padding tokens. + + This mask will be used by the language model so that it does not attend to + padding tokens. + + Args: + actual_sequence_lengths: List of length of each example without any + padding. + max_input_sequence_length: Maximum length of a sequence that will be + present in the input batch. This is + after taking into consideration the maximum input sequence the model + can handle. Hence it can never be + greater than self.max_model_sequence_length in case the model + applies length restriction. + + Returns: Computed attention mask, 0 for padding and 1 for non-padding + tokens. + """ + attention_mask = [] + + for actual_sequence_length in actual_sequence_lengths: + # add 1s for present tokens, fill up the remaining space up to max + # sequence length with 0s (non-existing tokens) + padded_sequence = [1] * min( + actual_sequence_length, max_input_sequence_length + ) + [0] * ( + max_input_sequence_length + - min(actual_sequence_length, max_input_sequence_length) + ) + attention_mask.append(padded_sequence) + + return np.array(attention_mask).astype(np.float32) + + def _extract_sequence_lengths( + self, batch_token_ids: List[List[int]] + ) -> Tuple[List[int], int]: + """Extracts the sequence length for each example and maximum sequence length. + + Args: + batch_token_ids: List of token ids for each example in the batch. + + Returns: + Tuple consisting of: the actual sequence lengths for each example, + and the maximum input sequence length (taking into account the + maximum sequence length that the model can handle. + """ + # Compute max length across examples + max_input_sequence_length = 0 + actual_sequence_lengths = [] + + for example_token_ids in batch_token_ids: + sequence_length = len(example_token_ids) + actual_sequence_lengths.append(sequence_length) + max_input_sequence_length = max( + max_input_sequence_length, len(example_token_ids) + ) + + # Take into account the maximum sequence length the model can handle + max_input_sequence_length = ( + max_input_sequence_length + if self.max_model_sequence_length == NO_LENGTH_RESTRICTION + else min(max_input_sequence_length, self.max_model_sequence_length) + ) + + return actual_sequence_lengths, max_input_sequence_length + + def _add_padding_to_batch( + self, batch_token_ids: List[List[int]], max_sequence_length_model: int + ) -> List[List[int]]: + """Adds padding so that all examples in the batch are of the same length. + + Args: + batch_token_ids: Batch of examples where each example is a non-padded list + of token ids. + max_sequence_length_model: Maximum length of any input sequence in the batch + to be fed to the model. + + Returns: + Padded batch with all examples of the same length. + """ + padded_token_ids = [] + + # Add padding according to max_sequence_length + # Some models don't contain pad token, we use unknown token as padding token. + # This doesn't affect the computation since we compute an attention mask + # anyways. + for example_token_ids in batch_token_ids: + + # Truncate any longer sequences so that they can be fed to the model + if len(example_token_ids) > max_sequence_length_model: + example_token_ids = example_token_ids[:max_sequence_length_model] + + padded_token_ids.append( + example_token_ids + + [self.pad_token_id] + * (max_sequence_length_model - len(example_token_ids)) + ) + return padded_token_ids + + @staticmethod + def _extract_nonpadded_embeddings( + embeddings: np.ndarray, actual_sequence_lengths: List[int] + ) -> np.ndarray: + """Extracts embeddings for actual tokens. + + Use pre-computed non-padded lengths of each example to extract embeddings + for non-padding tokens. + + Args: + embeddings: sequence level representations for each example of the batch. + actual_sequence_lengths: non-padded lengths of each example of the batch. + + Returns: + Sequence level embeddings for only non-padding tokens of the batch. + """ + nonpadded_sequence_embeddings = [] + for index, embedding in enumerate(embeddings): + unmasked_embedding = embedding[: actual_sequence_lengths[index]] + nonpadded_sequence_embeddings.append(unmasked_embedding) + + return ragged_array_to_ndarray(nonpadded_sequence_embeddings) + + def _compute_batch_sequence_features( + self, batch_attention_mask: np.ndarray, padded_token_ids: List[List[int]] + ) -> np.ndarray: + """Feeds the padded batch to the language model. + + Args: + batch_attention_mask: Mask of 0s and 1s which indicate whether the token + is a padding token or not. + padded_token_ids: Batch of token ids for each example. The batch is padded + and hence can be fed at once. + + Returns: + Sequence level representations from the language model. + """ + model_outputs = self.model( + tf.convert_to_tensor(padded_token_ids), + attention_mask=tf.convert_to_tensor(batch_attention_mask), + ) + + # sequence hidden states is always the first output from all models + sequence_hidden_states = model_outputs[0] + + sequence_hidden_states = sequence_hidden_states.numpy() + return sequence_hidden_states + + def _validate_sequence_lengths( + self, + actual_sequence_lengths: List[int], + batch_examples: List[Message], + attribute: Text, + inference_mode: bool = False, + ) -> None: + """Validates sequence length. + + Checks if sequence lengths of inputs are less than + the max sequence length the model can handle. + + This method should throw an error during training, and log a debug + message during inference if any of the input examples have a length + greater than maximum sequence length allowed. + + Args: + actual_sequence_lengths: original sequence length of all inputs + batch_examples: all message instances in the batch + attribute: attribute of message object to be processed + inference_mode: whether this is during training or inference + """ + if self.max_model_sequence_length == NO_LENGTH_RESTRICTION: + # There is no restriction on sequence length from the model + return + + for sequence_length, example in zip(actual_sequence_lengths, batch_examples): + if sequence_length > self.max_model_sequence_length: + if not inference_mode: + raise RuntimeError( + f"The sequence length of '{example.get(attribute)[:20]}...' " + f"is too long({sequence_length} tokens) for the " + f"model chosen {self.model_name} which has a maximum " + f"sequence length of {self.max_model_sequence_length} tokens. " + f"Either shorten the message or use a model which has no " + f"restriction on input sequence length like XLNet." + ) + logger.debug( + f"The sequence length of '{example.get(attribute)[:20]}...' " + f"is too long({sequence_length} tokens) for the " + f"model chosen {self.model_name} which has a maximum " + f"sequence length of {self.max_model_sequence_length} tokens. " + f"Downstream model predictions may be affected because of this." + ) + + def _add_extra_padding( + self, sequence_embeddings: np.ndarray, actual_sequence_lengths: List[int] + ) -> np.ndarray: + """Adds extra zero padding to match the original sequence length. + + This is only done if the input was truncated during the batch + preparation of input for the model. + Args: + sequence_embeddings: Embeddings returned from the model + actual_sequence_lengths: original sequence length of all inputs + + Returns: + Modified sequence embeddings with padding if necessary + """ + if self.max_model_sequence_length == NO_LENGTH_RESTRICTION: + # No extra padding needed because there wouldn't have been any + # truncation in the first place + return sequence_embeddings + + reshaped_sequence_embeddings = [] + for index, embedding in enumerate(sequence_embeddings): + embedding_size = embedding.shape[-1] + if actual_sequence_lengths[index] > self.max_model_sequence_length: + embedding = np.concatenate( + [ + embedding, + np.zeros( + ( + actual_sequence_lengths[index] + - self.max_model_sequence_length, + embedding_size, + ), + dtype=np.float32, + ), + ] + ) + reshaped_sequence_embeddings.append(embedding) + return ragged_array_to_ndarray(reshaped_sequence_embeddings) + + def _get_model_features_for_batch( + self, + batch_token_ids: List[List[int]], + batch_tokens: List[List[Token]], + batch_examples: List[Message], + attribute: Text, + inference_mode: bool = False, + ) -> Tuple[np.ndarray, np.ndarray]: + """Computes dense features of each example in the batch. + + We first add the special tokens corresponding to each language model. Next, we + add appropriate padding and compute a mask for that padding so that it doesn't + affect the feature computation. The padded batch is next fed to the language + model and token level embeddings are computed. Using the pre-computed mask, + embeddings for non-padding tokens are extracted and subsequently sentence + level embeddings are computed. + + Args: + batch_token_ids: List of token ids of each example in the batch. + batch_tokens: List of token objects for each example in the batch. + batch_examples: List of examples in the batch. + attribute: attribute of the Message object to be processed. + inference_mode: Whether the call is during training or during inference. + + Returns: + Sentence and token level dense representations. + """ + # Let's first add tokenizer specific special tokens to all examples + batch_token_ids_augmented = self._add_lm_specific_special_tokens( + batch_token_ids + ) + + # Compute sequence lengths for all examples + ( + actual_sequence_lengths, + max_input_sequence_length, + ) = self._extract_sequence_lengths(batch_token_ids_augmented) + + # Validate that all sequences can be processed based on their sequence + # lengths and the maximum sequence length the model can handle + self._validate_sequence_lengths( + actual_sequence_lengths, batch_examples, attribute, inference_mode + ) + + # Add padding so that whole batch can be fed to the model + padded_token_ids = self._add_padding_to_batch( + batch_token_ids_augmented, max_input_sequence_length + ) + + # Compute attention mask based on actual_sequence_length + batch_attention_mask = self._compute_attention_mask( + actual_sequence_lengths, max_input_sequence_length + ) + + # Get token level features from the model + sequence_hidden_states = self._compute_batch_sequence_features( + batch_attention_mask, padded_token_ids + ) + + # Extract features for only non-padding tokens + sequence_nonpadded_embeddings = self._extract_nonpadded_embeddings( + sequence_hidden_states, actual_sequence_lengths + ) + + # Extract sentence level and post-processed features + ( + sentence_embeddings, + sequence_embeddings, + ) = self._post_process_sequence_embeddings(sequence_nonpadded_embeddings) + + # Pad zeros for examples which were truncated in inference mode. + # This is intentionally done after sentence embeddings have been + # extracted so that they are not affected + sequence_embeddings = self._add_extra_padding( + sequence_embeddings, actual_sequence_lengths + ) + + # shape of matrix for all sequence embeddings + batch_dim = len(sequence_embeddings) + seq_dim = max(e.shape[0] for e in sequence_embeddings) + feature_dim = sequence_embeddings[0].shape[1] + shape = (batch_dim, seq_dim, feature_dim) + + # align features with tokens so that we have just one vector per token + # (don't include sub-tokens) + sequence_embeddings = train_utils.align_token_features( + batch_tokens, sequence_embeddings, shape + ) + + # sequence_embeddings is a padded numpy array + # remove the padding, keep just the non-zero vectors + sequence_final_embeddings = [] + for embeddings, tokens in zip(sequence_embeddings, batch_tokens): + sequence_final_embeddings.append(embeddings[: len(tokens)]) + + return sentence_embeddings, ragged_array_to_ndarray(sequence_final_embeddings) + + def _get_docs_for_batch( + self, + batch_examples: List[Message], + attribute: Text, + inference_mode: bool = False, + ) -> List[Dict[Text, Any]]: + """Computes language model docs for all examples in the batch. + + Args: + batch_examples: Batch of message objects for which language model docs + need to be computed. + attribute: Property of message to be processed, one of ``TEXT`` or + ``RESPONSE``. + inference_mode: Whether the call is during inference or during training. + + + Returns: + List of language model docs for each message in batch. + """ + batch_tokens, batch_token_ids = self._get_token_ids_for_batch( + batch_examples, attribute + ) + + ( + batch_sentence_features, + batch_sequence_features, + ) = self._get_model_features_for_batch( + batch_token_ids, batch_tokens, batch_examples, attribute, inference_mode + ) + + # A doc consists of + # {'sequence_features': ..., 'sentence_features': ...} + batch_docs = [] + for index in range(len(batch_examples)): + doc = { + SEQUENCE_FEATURES: batch_sequence_features[index], + SENTENCE_FEATURES: np.reshape(batch_sentence_features[index], (1, -1)), + } + batch_docs.append(doc) + + return batch_docs + + def process_training_data(self, training_data: TrainingData) -> TrainingData: + """Computes tokens and dense features for each message in training data. + + Args: + training_data: NLU training data to be tokenized and featurized + config: NLU pipeline config consisting of all components. + """ + batch_size = 64 + + for attribute in DENSE_FEATURIZABLE_ATTRIBUTES: + + non_empty_examples = list( + filter(lambda x: x.get(attribute), training_data.training_examples) + ) + + batch_start_index = 0 + + while batch_start_index < len(non_empty_examples): + + batch_end_index = min( + batch_start_index + batch_size, len(non_empty_examples) + ) + # Collect batch examples + batch_messages = non_empty_examples[batch_start_index:batch_end_index] + + # Construct a doc with relevant features + # extracted(tokens, dense_features) + batch_docs = self._get_docs_for_batch(batch_messages, attribute) + + for index, ex in enumerate(batch_messages): + self._set_lm_features(batch_docs[index], ex, attribute) + batch_start_index += batch_size + + return training_data + + def process(self, messages: List[Message]) -> List[Message]: + """Processes messages by computing tokens and dense features.""" + for message in messages: + self._process_message(message) + return messages + + def _process_message(self, message: Message) -> Message: + """Processes a message by computing tokens and dense features.""" + # processing featurizers operates only on TEXT and ACTION_TEXT attributes, + # because all other attributes are labels which are featurized during + # training and their features are stored by the model itself. + for attribute in {TEXT, ACTION_TEXT}: + if message.get(attribute): + self._set_lm_features( + self._get_docs_for_batch( + [message], attribute=attribute, inference_mode=True + )[0], + message, + attribute, + ) + return message + + def _set_lm_features( + self, doc: Dict[Text, Any], message: Message, attribute: Text = TEXT + ) -> None: + """Adds the precomputed word vectors to the messages features.""" + sequence_features = doc[SEQUENCE_FEATURES] + sentence_features = doc[SENTENCE_FEATURES] + + self.add_features_to_message( + sequence=sequence_features, + sentence=sentence_features, + attribute=attribute, + message=message, + ) diff --git a/rasa/nlu/featurizers/dense_featurizer/mitie_featurizer.py b/rasa/nlu/featurizers/dense_featurizer/mitie_featurizer.py new file mode 100644 index 0000000..cbba1ea --- /dev/null +++ b/rasa/nlu/featurizers/dense_featurizer/mitie_featurizer.py @@ -0,0 +1,170 @@ +from __future__ import annotations +import numpy as np +import logging +import typing +from typing import Any, List, Text, Dict, Tuple, Type + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.featurizers.dense_featurizer.dense_featurizer import DenseFeaturizer +from rasa.nlu.tokenizers.tokenizer import Token, Tokenizer +from rasa.nlu.constants import ( + DENSE_FEATURIZABLE_ATTRIBUTES, + FEATURIZER_CLASS_ALIAS, + TOKENS_NAMES, +) +from rasa.nlu.utils.mitie_utils import MitieModel, MitieNLP +from rasa.utils.tensorflow.constants import MEAN_POOLING, POOLING +from rasa.shared.nlu.training_data.features import Features +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.constants import FEATURE_TYPE_SENTENCE, FEATURE_TYPE_SEQUENCE +from rasa.shared.nlu.training_data.training_data import TrainingData + +if typing.TYPE_CHECKING: + import mitie + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_FEATURIZER, + is_trainable=False, + model_from="MitieNLP", +) +class MitieFeaturizer(DenseFeaturizer, GraphComponent): + """A class that featurizes using Mitie.""" + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [MitieNLP, Tokenizer] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns the component's default config.""" + return { + **DenseFeaturizer.get_default_config(), + # Specify what pooling operation should be used to calculate the vector of + # the complete utterance. Available options: 'mean' and 'max' + POOLING: MEAN_POOLING, + } + + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return ["mitie", "numpy"] + + def __init__( + self, config: Dict[Text, Any], execution_context: ExecutionContext + ) -> None: + """Instantiates a new `MitieFeaturizer` instance.""" + super().__init__(execution_context.node_name, config) + self.pooling_operation = self._config[POOLING] + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> MitieFeaturizer: + """Creates a new untrained component (see parent class for full docstring).""" + return cls(config, execution_context) + + @classmethod + def validate_config(cls, config: Dict[Text, Any]) -> None: + """Validates that the component is configured properly.""" + pass + + def ndim(self, feature_extractor: "mitie.total_word_feature_extractor") -> int: + """Returns the number of dimensions.""" + return feature_extractor.num_dimensions + + def process(self, messages: List[Message], model: MitieModel) -> List[Message]: + """Featurizes all given messages in-place. + + Returns: + The given list of messages which have been modified in-place. + """ + for message in messages: + self._process_message(message, model) + return messages + + def process_training_data( + self, training_data: TrainingData, model: MitieModel + ) -> TrainingData: + """Processes the training examples in the given training data in-place. + + Args: + training_data: Training data. + model: A Mitie model. + + Returns: + Same training data after processing. + """ + self.process(training_data.training_examples, model) + return training_data + + def _process_message(self, message: Message, model: MitieModel) -> None: + """Processes a message.""" + for attribute in DENSE_FEATURIZABLE_ATTRIBUTES: + self._process_training_example( + message, attribute, model.word_feature_extractor + ) + + def _process_training_example( + self, + example: Message, + attribute: Text, + mitie_feature_extractor: "mitie.total_word_feature_extractor", + ) -> None: + tokens = example.get(TOKENS_NAMES[attribute]) + + if tokens: + sequence_features, sentence_features = self.features_for_tokens( + tokens, mitie_feature_extractor + ) + + self._set_features(example, sequence_features, sentence_features, attribute) + + def _set_features( + self, + message: Message, + sequence_features: np.ndarray, + sentence_features: np.ndarray, + attribute: Text, + ) -> None: + final_sequence_features = Features( + sequence_features, + FEATURE_TYPE_SEQUENCE, + attribute, + self._config[FEATURIZER_CLASS_ALIAS], + ) + message.add_features(final_sequence_features) + + final_sentence_features = Features( + sentence_features, + FEATURE_TYPE_SENTENCE, + attribute, + self._config[FEATURIZER_CLASS_ALIAS], + ) + message.add_features(final_sentence_features) + + def features_for_tokens( + self, + tokens: List[Token], + feature_extractor: "mitie.total_word_feature_extractor", + ) -> Tuple[np.ndarray, np.ndarray]: + """Calculates features.""" + sequence_features = np.array( + [feature_extractor.get_feature_vector(token.text) for token in tokens] + ) + + sentence_fetaures = self.aggregate_sequence_features( + sequence_features, self.pooling_operation + ) + + return sequence_features, sentence_fetaures diff --git a/rasa/nlu/featurizers/dense_featurizer/spacy_featurizer.py b/rasa/nlu/featurizers/dense_featurizer/spacy_featurizer.py new file mode 100644 index 0000000..dfcf830 --- /dev/null +++ b/rasa/nlu/featurizers/dense_featurizer/spacy_featurizer.py @@ -0,0 +1,132 @@ +import numpy as np +import typing +import logging +from typing import Any, Text, Dict, List, Type + +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.featurizers.dense_featurizer.dense_featurizer import DenseFeaturizer +from rasa.nlu.tokenizers.spacy_tokenizer import SpacyTokenizer +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.features import Features +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.constants import ( + SPACY_DOCS, + DENSE_FEATURIZABLE_ATTRIBUTES, + FEATURIZER_CLASS_ALIAS, +) +from rasa.shared.nlu.constants import TEXT, FEATURE_TYPE_SENTENCE, FEATURE_TYPE_SEQUENCE +from rasa.utils.tensorflow.constants import POOLING, MEAN_POOLING + +if typing.TYPE_CHECKING: + from spacy.tokens import Doc + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_FEATURIZER, is_trainable=False +) +class SpacyFeaturizer(DenseFeaturizer, GraphComponent): + """Featurize messages using SpaCy.""" + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [SpacyTokenizer] + + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return ["spacy"] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """The component's default config (see parent class for full docstring).""" + return { + **DenseFeaturizer.get_default_config(), + # Specify what pooling operation should be used to calculate the vector of + # the complete utterance. Available options: 'mean' and 'max' + POOLING: MEAN_POOLING, + } + + def __init__(self, config: Dict[Text, Any], name: Text) -> None: + """Initializes SpacyFeaturizer.""" + super().__init__(name, config) + self.pooling_operation = self._config[POOLING] + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + """Creates a new component (see parent class for full docstring).""" + return cls(config, execution_context.node_name) + + def _features_for_doc(self, doc: "Doc") -> np.ndarray: + """Feature vector for a single document / sentence / tokens.""" + return np.array([t.vector for t in doc if t.text and t.text.strip()]) + + def _get_doc(self, message: Message, attribute: Text) -> Any: + return message.get(SPACY_DOCS[attribute]) + + def process(self, messages: List[Message]) -> List[Message]: + """Processes incoming messages and computes and sets features.""" + for message in messages: + for attribute in DENSE_FEATURIZABLE_ATTRIBUTES: + self._set_spacy_features(message, attribute) + return messages + + def process_training_data(self, training_data: TrainingData) -> TrainingData: + """Processes the training examples in the given training data in-place. + + Args: + training_data: Training data. + + Returns: + Same training data after processing. + """ + self.process(training_data.training_examples) + return training_data + + def _set_spacy_features(self, message: Message, attribute: Text = TEXT) -> None: + """Adds the spacy word vectors to the messages features.""" + doc = self._get_doc(message, attribute) + + if doc is None: + return + + # in case an empty spaCy model was used, no vectors are present + if doc.vocab.vectors_length == 0: + logger.debug("No features present. You are using an empty spaCy model.") + return + + sequence_features = self._features_for_doc(doc) + sentence_features = self.aggregate_sequence_features( + sequence_features, self.pooling_operation + ) + + final_sequence_features = Features( + sequence_features, + FEATURE_TYPE_SEQUENCE, + attribute, + self._config[FEATURIZER_CLASS_ALIAS], + ) + message.add_features(final_sequence_features) + final_sentence_features = Features( + sentence_features, + FEATURE_TYPE_SENTENCE, + attribute, + self._config[FEATURIZER_CLASS_ALIAS], + ) + message.add_features(final_sentence_features) + + @classmethod + def validate_config(cls, config: Dict[Text, Any]) -> None: + """Validates that the component is configured properly.""" + pass diff --git a/rasa/nlu/featurizers/featurizer.py b/rasa/nlu/featurizers/featurizer.py new file mode 100644 index 0000000..41559b3 --- /dev/null +++ b/rasa/nlu/featurizers/featurizer.py @@ -0,0 +1,89 @@ +from __future__ import annotations +from abc import abstractmethod, ABC +from collections import Counter +from typing import Generic, Iterable, Text, Optional, Dict, Any, TypeVar + +from rasa.nlu.constants import FEATURIZER_CLASS_ALIAS +from rasa.shared.nlu.training_data.features import Features +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.exceptions import InvalidConfigException +from rasa.shared.nlu.constants import FEATURE_TYPE_SENTENCE, FEATURE_TYPE_SEQUENCE + +FeatureType = TypeVar("FeatureType") + + +class Featurizer(Generic[FeatureType], ABC): + """Base class for all featurizers.""" + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns the component's default config.""" + return {FEATURIZER_CLASS_ALIAS: None} + + def __init__(self, name: Text, config: Dict[Text, Any]) -> None: + """Instantiates a new featurizer. + + Args: + config: configuration + name: a name that can be used as identifier, in case the configuration does + not specify an `alias` (or this `alias` is None) + """ + super().__init__() + self.validate_config(config) + self._config = config + self._identifier = self._config[FEATURIZER_CLASS_ALIAS] or name + + @classmethod + @abstractmethod + def validate_config(cls, config: Dict[Text, Any]) -> None: + """Validates that the component is configured properly.""" + ... + + def add_features_to_message( + self, + sequence: FeatureType, + sentence: Optional[FeatureType], + attribute: Text, + message: Message, + ) -> None: + """Adds sequence and sentence features for the attribute to the given message. + + Args: + sequence: sequence feature matrix + sentence: sentence feature matrix + attribute: the attribute which both features describe + message: the message to which we want to add those features + """ + for type, features in [ + (FEATURE_TYPE_SEQUENCE, sequence), + (FEATURE_TYPE_SENTENCE, sentence), + ]: + if features is not None: + wrapped_feature = Features(features, type, attribute, self._identifier) + message.add_features(wrapped_feature) + + @staticmethod + def raise_if_featurizer_configs_are_not_compatible( + featurizer_configs: Iterable[Dict[Text, Any]] + ) -> None: + """Validates that the given configurations of featurizers can be used together. + + Raises: + `InvalidConfigException` if the given featurizers should not be used in + the same graph. + """ + # NOTE: this assumes the names given via the execution context are unique + alias_counter = Counter( + config[FEATURIZER_CLASS_ALIAS] + for config in featurizer_configs + if FEATURIZER_CLASS_ALIAS in config + ) + if not alias_counter: # no alias found + return + if alias_counter.most_common(1)[0][1] > 1: + raise InvalidConfigException( + f"Expected the featurizers to have unique names but found " + f" (name, count): {alias_counter.most_common()}. " + f"Please update your config such that each featurizer has a unique " + f"alias." + ) diff --git a/rasa/nlu/featurizers/sparse_featurizer/__init__.py b/rasa/nlu/featurizers/sparse_featurizer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/nlu/featurizers/sparse_featurizer/count_vectors_featurizer.py b/rasa/nlu/featurizers/sparse_featurizer/count_vectors_featurizer.py new file mode 100644 index 0000000..98cecba --- /dev/null +++ b/rasa/nlu/featurizers/sparse_featurizer/count_vectors_featurizer.py @@ -0,0 +1,865 @@ +from __future__ import annotations + +import logging +import re +from typing import Any, Dict, List, Optional, Text, Tuple, Set, Type, Union + +import numpy as np +import scipy.sparse +from sklearn.feature_extraction.text import CountVectorizer + +import rasa.shared.utils.io +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.constants import ( + TOKENS_NAMES, + MESSAGE_ATTRIBUTES, + DENSE_FEATURIZABLE_ATTRIBUTES, +) +from rasa.nlu.featurizers.sparse_featurizer.sparse_featurizer import SparseFeaturizer +from rasa.nlu.tokenizers.tokenizer import Tokenizer +from rasa.nlu.utils.spacy_utils import SpacyModel +from rasa.shared.constants import DOCS_URL_COMPONENTS +from rasa.shared.exceptions import RasaException, FileIOException +from rasa.shared.nlu.constants import TEXT, INTENT, INTENT_RESPONSE_KEY, ACTION_NAME +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData + +BUFFER_SLOTS_PREFIX = "buf_" + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_FEATURIZER, is_trainable=True +) +class CountVectorsFeaturizer(SparseFeaturizer, GraphComponent): + """Creates a sequence of token counts features based on sklearn's `CountVectorizer`. + + All tokens which consist only of digits (e.g. 123 and 99 + but not ab12d) will be represented by a single feature. + + Set `analyzer` to 'char_wb' + to use the idea of Subword Semantic Hashing + from https://arxiv.org/abs/1810.07150. + """ + + OOV_words: List[Text] + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [Tokenizer] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns the component's default config.""" + return { + **SparseFeaturizer.get_default_config(), + # whether to use a shared vocab + "use_shared_vocab": False, + # the parameters are taken from + # sklearn's CountVectorizer + # whether to use word or character n-grams + # 'char_wb' creates character n-grams inside word boundaries + # n-grams at the edges of words are padded with space. + "analyzer": "word", # use 'char' or 'char_wb' for character + # remove accents during the preprocessing step + "strip_accents": None, # {'ascii', 'unicode', None} + # list of stop words + "stop_words": None, # string {'english'}, list, or None (default) + # min document frequency of a word to add to vocabulary + # float - the parameter represents a proportion of documents + # integer - absolute counts + "min_df": 1, # float in range [0.0, 1.0] or int + # max document frequency of a word to add to vocabulary + # float - the parameter represents a proportion of documents + # integer - absolute counts + "max_df": 1.0, # float in range [0.0, 1.0] or int + # set range of ngrams to be extracted + "min_ngram": 1, # int + "max_ngram": 1, # int + # limit vocabulary size + "max_features": None, # int or None + # if convert all characters to lowercase + "lowercase": True, # bool + # handling Out-Of-Vocabulary (OOV) words + # will be converted to lowercase if lowercase is True + "OOV_token": None, # string or None + "OOV_words": [], # string or list of strings + # indicates whether the featurizer should use the lemma of a word for + # counting (if available) or not + "use_lemma": True, + } + + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return ["sklearn"] + + def _load_count_vect_params(self) -> None: + + # Use shared vocabulary between text and all other attributes of Message + self.use_shared_vocab = self._config["use_shared_vocab"] + + # set analyzer + self.analyzer = self._config["analyzer"] + + # remove accents during the preprocessing step + self.strip_accents = self._config["strip_accents"] + + # list of stop words + self.stop_words = self._config["stop_words"] + + # min number of word occurancies in the document to add to vocabulary + self.min_df = self._config["min_df"] + + # max number (fraction if float) of word occurancies + # in the document to add to vocabulary + self.max_df = self._config["max_df"] + + # set ngram range + self.min_ngram = self._config["min_ngram"] + self.max_ngram = self._config["max_ngram"] + + # limit vocabulary size + self.max_features = self._config["max_features"] + + # if convert all characters to lowercase + self.lowercase = self._config["lowercase"] + + # use the lemma of the words or not + self.use_lemma = self._config["use_lemma"] + + def _load_vocabulary_params(self) -> Tuple[Text, List[Text]]: + OOV_token = self._config["OOV_token"] + + OOV_words = self._config["OOV_words"] + if OOV_words and not OOV_token: + logger.error( + "The list OOV_words={} was given, but " + "OOV_token was not. OOV words are ignored." + "".format(OOV_words) + ) + self.OOV_words = [] + + if self.lowercase and OOV_token: + # convert to lowercase + OOV_token = OOV_token.lower() + if OOV_words: + OOV_words = [w.lower() for w in OOV_words] + + return OOV_token, OOV_words + + def _get_attribute_vocabulary(self, attribute: Text) -> Optional[Dict[Text, int]]: + """Gets trained vocabulary from attribute's count vectorizer.""" + try: + return self.vectorizers[attribute].vocabulary_ + except (AttributeError, TypeError, KeyError): + return None + + def _check_analyzer(self) -> None: + if self.analyzer != "word": + if self.OOV_token is not None: + logger.warning( + "Analyzer is set to character, " + "provided OOV word token will be ignored." + ) + if self.stop_words is not None: + logger.warning( + "Analyzer is set to character, " + "provided stop words will be ignored." + ) + if self.max_ngram == 1: + logger.warning( + "Analyzer is set to character, " + "but max n-gram is set to 1. " + "It means that the vocabulary will " + "contain single letters only." + ) + + @staticmethod + def _attributes_for(analyzer: Text) -> List[Text]: + """Create a list of attributes that should be featurized.""" + # intents should be featurized only by word level count vectorizer + return ( + MESSAGE_ATTRIBUTES if analyzer == "word" else DENSE_FEATURIZABLE_ATTRIBUTES + ) + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + vectorizers: Optional[Dict[Text, "CountVectorizer"]] = None, + oov_token: Optional[Text] = None, + oov_words: Optional[List[Text]] = None, + ) -> None: + """Constructs a new count vectorizer using the sklearn framework.""" + super().__init__(execution_context.node_name, config) + + self._model_storage = model_storage + self._resource = resource + + # parameters for sklearn's CountVectorizer + self._load_count_vect_params() + + # handling Out-Of-Vocabulary (OOV) words + if oov_token and oov_words: + self.OOV_token = oov_token + self.OOV_words = oov_words + else: + self.OOV_token, self.OOV_words = self._load_vocabulary_params() + + # warn that some of config parameters might be ignored + self._check_analyzer() + + # set which attributes to featurize + self._attributes = self._attributes_for(self.analyzer) + + # declare class instance for CountVectorizer + self.vectorizers = vectorizers or {} + + self.finetune_mode = execution_context.is_finetuning + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> CountVectorsFeaturizer: + """Creates a new untrained component (see parent class for full docstring).""" + return cls(config, model_storage, resource, execution_context) + + def _get_message_tokens_by_attribute( + self, message: "Message", attribute: Text + ) -> List[Text]: + """Get text tokens of an attribute of a message.""" + if message.get(TOKENS_NAMES[attribute]): + return [ + t.lemma if self.use_lemma else t.text + for t in message.get(TOKENS_NAMES[attribute]) + ] + else: + return [] + + def _process_tokens(self, tokens: List[Text], attribute: Text = TEXT) -> List[Text]: + """Apply processing and cleaning steps to text.""" + if attribute in [INTENT, ACTION_NAME, INTENT_RESPONSE_KEY]: + # Don't do any processing for intent attribute. Treat them as whole labels + return tokens + + # replace all digits with NUMBER token + tokens = [re.sub(r"\b[0-9]+\b", "__NUMBER__", text) for text in tokens] + + # convert to lowercase if necessary + if self.lowercase: + tokens = [text.lower() for text in tokens] + + return tokens + + def _replace_with_oov_token( + self, tokens: List[Text], attribute: Text + ) -> List[Text]: + """Replace OOV words with OOV token.""" + if self.OOV_token and self.analyzer == "word": + attribute_vocab = self._get_attribute_vocabulary(attribute) + if attribute_vocab is not None and self.OOV_token in attribute_vocab: + # CountVectorizer is trained, process for prediction + attribute_vocabulary_tokens = set(attribute_vocab.keys()) + tokens = [ + t if t in attribute_vocabulary_tokens else self.OOV_token + for t in tokens + ] + elif self.OOV_words: + # CountVectorizer is not trained, process for train + tokens = [self.OOV_token if t in self.OOV_words else t for t in tokens] + + return tokens + + def _get_processed_message_tokens_by_attribute( + self, message: Message, attribute: Text = TEXT + ) -> List[Text]: + """Get processed text of attribute of a message.""" + if message.get(attribute) is None: + # return empty list since sklearn countvectorizer does not like None + # object while training and predicting + return [] + + tokens = self._get_message_tokens_by_attribute(message, attribute) + tokens = self._process_tokens(tokens, attribute) + tokens = self._replace_with_oov_token(tokens, attribute) + + return tokens + + # noinspection PyPep8Naming + def _check_OOV_present(self, all_tokens: List[List[Text]], attribute: Text) -> None: + """Check if an OOV word is present.""" + if not self.OOV_token or self.OOV_words or not all_tokens: + return + + for tokens in all_tokens: + for text in tokens: + if self.OOV_token in text or ( + self.lowercase and self.OOV_token in text.lower() + ): + return + + if any(text for tokens in all_tokens for text in tokens): + training_data_type = "NLU" if attribute == TEXT else "ResponseSelector" + + # if there is some text in tokens, warn if there is no oov token + rasa.shared.utils.io.raise_warning( + f"The out of vocabulary token '{self.OOV_token}' was configured, but " + f"could not be found in any one of the {training_data_type} " + f"training examples. All unseen words will be " + f"ignored during prediction.", + docs=DOCS_URL_COMPONENTS + "#countvectorsfeaturizer", + ) + + def _get_all_attributes_processed_tokens( + self, training_data: TrainingData + ) -> Dict[Text, List[List[Text]]]: + """Get processed text for all attributes of examples in training data.""" + processed_attribute_tokens = {} + for attribute in self._attributes: + all_tokens = [ + self._get_processed_message_tokens_by_attribute(example, attribute) + for example in training_data.training_examples + ] + if attribute in DENSE_FEATURIZABLE_ATTRIBUTES: + # check for oov tokens only in text based attributes + self._check_OOV_present(all_tokens, attribute) + processed_attribute_tokens[attribute] = all_tokens + + return processed_attribute_tokens + + @staticmethod + def _convert_attribute_tokens_to_texts( + attribute_tokens: Dict[Text, List[List[Text]]] + ) -> Dict[Text, List[Text]]: + attribute_texts = {} + + for attribute in attribute_tokens.keys(): + list_of_tokens = attribute_tokens[attribute] + attribute_texts[attribute] = [" ".join(tokens) for tokens in list_of_tokens] + + return attribute_texts + + def _update_vectorizer_vocabulary( + self, attribute: Text, new_vocabulary: Set[Text] + ) -> None: + """Updates the existing vocabulary of the vectorizer with new unseen words. + + Args: + attribute: Message attribute for which vocabulary should be updated. + new_vocabulary: Set of words to expand the vocabulary with if they are + unseen. + """ + existing_vocabulary: Dict[Text, int] = self.vectorizers[attribute].vocabulary + self._merge_new_vocabulary_tokens(existing_vocabulary, new_vocabulary) + self._set_vocabulary(attribute, existing_vocabulary) + + def _merge_new_vocabulary_tokens( + self, existing_vocabulary: Dict[Text, int], vocabulary: Set[Text] + ) -> None: + """Merges new vocabulary tokens with the existing vocabulary. + + New vocabulary items should always be added to the end of the existing + vocabulary and the order of the existing vocabulary should not be disturbed. + + Args: + existing_vocabulary: existing vocabulary + vocabulary: set of new tokens + + Raises: + RasaException: if `use_shared_vocab` is set to True and there are new + vocabulary items added during incremental training. + """ + for token in vocabulary: + if token not in existing_vocabulary: + if self.use_shared_vocab: + raise RasaException( + "Using a shared vocabulary in `CountVectorsFeaturizer` is not " + "supported during incremental training since it requires " + "dynamically adjusting layers that correspond to label " + f"attributes such as {INTENT_RESPONSE_KEY}, {INTENT}, etc. " + "This is currently not possible. In order to avoid this " + "exception we suggest to set `use_shared_vocab=False` or train" + " from scratch." + ) + existing_vocabulary[token] = len(existing_vocabulary) + + def _set_vocabulary( + self, attribute: Text, original_vocabulary: Dict[Text, int] + ) -> None: + """Sets the vocabulary of the vectorizer of attribute. + + Args: + attribute: Message attribute for which vocabulary should be set + original_vocabulary: Vocabulary for the attribute to be set. + """ + self.vectorizers[attribute].vocabulary_ = original_vocabulary + self.vectorizers[attribute]._validate_vocabulary() + + @staticmethod + def _construct_vocabulary_from_texts( + vectorizer: CountVectorizer, texts: List[Text] + ) -> Set: + """Applies vectorizer's preprocessor on texts to get the vocabulary from texts. + + Args: + vectorizer: Sklearn's count vectorizer which has been pre-configured. + texts: Examples from which the vocabulary should be constructed + + Returns: + Unique vocabulary words extracted. + """ + analyzer = vectorizer.build_analyzer() + vocabulary_words = set() + for example in texts: + example_vocabulary: List[Text] = analyzer(example) + vocabulary_words.update(example_vocabulary) + return vocabulary_words + + @staticmethod + def _attribute_texts_is_non_empty(attribute_texts: List[Text]) -> bool: + return any(attribute_texts) + + def _train_with_shared_vocab(self, attribute_texts: Dict[Text, List[Text]]) -> None: + """Constructs the vectorizers and train them with a shared vocab.""" + combined_cleaned_texts = [] + for attribute in self._attributes: + combined_cleaned_texts += attribute_texts[attribute] + + # To train a shared vocabulary, we use TEXT as the + # attribute for which a combined vocabulary is built. + if not self.finetune_mode: + self.vectorizers = self._create_shared_vocab_vectorizers( + { + "strip_accents": self.strip_accents, + "lowercase": self.lowercase, + "stop_words": self.stop_words, + "min_ngram": self.min_ngram, + "max_ngram": self.max_ngram, + "max_df": self.max_df, + "min_df": self.min_df, + "max_features": self.max_features, + "analyzer": self.analyzer, + } + ) + self._fit_vectorizer_from_scratch(TEXT, combined_cleaned_texts) + else: + self._fit_loaded_vectorizer(TEXT, combined_cleaned_texts) + self._log_vocabulary_stats(TEXT) + + def _train_with_independent_vocab( + self, attribute_texts: Dict[Text, List[Text]] + ) -> None: + """Constructs the vectorizers and train them with an independent vocab.""" + if not self.finetune_mode: + self.vectorizers = self._create_independent_vocab_vectorizers( + { + "strip_accents": self.strip_accents, + "lowercase": self.lowercase, + "stop_words": self.stop_words, + "min_ngram": self.min_ngram, + "max_ngram": self.max_ngram, + "max_df": self.max_df, + "min_df": self.min_df, + "max_features": self.max_features, + "analyzer": self.analyzer, + } + ) + for attribute in self._attributes: + if self._attribute_texts_is_non_empty(attribute_texts[attribute]): + if not self.finetune_mode: + self._fit_vectorizer_from_scratch( + attribute, attribute_texts[attribute] + ) + else: + self._fit_loaded_vectorizer(attribute, attribute_texts[attribute]) + + self._log_vocabulary_stats(attribute) + else: + logger.debug( + f"No text provided for {attribute} attribute in any messages of " + f"training data. Skipping training a CountVectorizer for it." + ) + + def _log_vocabulary_stats(self, attribute: Text) -> None: + """Logs number of vocabulary items that were created for a specified attribute. + + Args: + attribute: Message attribute for which vocabulary stats are logged. + """ + if attribute in DENSE_FEATURIZABLE_ATTRIBUTES: + vocabulary_size = len(self.vectorizers[attribute].vocabulary_) + logger.info( + f"{vocabulary_size} vocabulary items " + f"were created for {attribute} attribute." + ) + + def _fit_loaded_vectorizer( + self, attribute: Text, attribute_texts: List[Text] + ) -> None: + """Fits training texts to a previously trained count vectorizer. + + We do not use the `.fit()` method because the new unseen + words should occupy the buffer slots of the vocabulary. + + Args: + attribute: Message attribute for which the vectorizer is to be trained. + attribute_texts: Training texts for the attribute + """ + # Get vocabulary words by the preprocessor + new_vocabulary = self._construct_vocabulary_from_texts( + self.vectorizers[attribute], attribute_texts + ) + # update the vocabulary of vectorizer with new vocabulary + self._update_vectorizer_vocabulary(attribute, new_vocabulary) + + def _fit_vectorizer_from_scratch( + self, attribute: Text, attribute_texts: List[Text] + ) -> None: + """Fits training texts to an untrained count vectorizer. + + Args: + attribute: Message attribute for which the vectorizer is to be trained. + attribute_texts: Training texts for the attribute + """ + try: + self.vectorizers[attribute].fit(attribute_texts) + except ValueError: + logger.warning( + f"Unable to train CountVectorizer for message " + f"attribute {attribute} since the call to sklearn's " + f"`.fit()` method failed. Leaving an untrained " + f"CountVectorizer for it." + ) + + def _create_features( + self, attribute: Text, all_tokens: List[List[Text]] + ) -> Tuple[ + List[Optional[scipy.sparse.spmatrix]], List[Optional[scipy.sparse.spmatrix]] + ]: + if not self.vectorizers.get(attribute): + return [None], [None] + + sequence_features: List[Optional[scipy.sparse.spmatrix]] = [] + sentence_features: List[Optional[scipy.sparse.spmatrix]] = [] + + for i, tokens in enumerate(all_tokens): + if not tokens: + # nothing to featurize + sequence_features.append(None) + sentence_features.append(None) + continue + + # vectorizer.transform returns a sparse matrix of size + # [n_samples, n_features] + # set input to list of tokens if sequence should be returned + # otherwise join all tokens to a single string and pass that as a list + if not tokens: + # attribute is not set (e.g. response not present) + sequence_features.append(None) + sentence_features.append(None) + continue + + seq_vec = self.vectorizers[attribute].transform(tokens) + seq_vec.sort_indices() + + sequence_features.append(seq_vec.tocoo()) + + if attribute in DENSE_FEATURIZABLE_ATTRIBUTES: + tokens_text = [" ".join(tokens)] + sentence_vec = self.vectorizers[attribute].transform(tokens_text) + sentence_vec.sort_indices() + + sentence_features.append(sentence_vec.tocoo()) + else: + sentence_features.append(None) + + return sequence_features, sentence_features + + def _get_featurized_attribute( + self, attribute: Text, all_tokens: List[List[Text]] + ) -> Tuple[ + List[Optional[scipy.sparse.spmatrix]], List[Optional[scipy.sparse.spmatrix]] + ]: + """Returns features of a particular attribute for complete data.""" + if self._get_attribute_vocabulary(attribute) is not None: + # count vectorizer was trained + return self._create_features(attribute, all_tokens) + else: + return [], [] + + def train( + self, training_data: TrainingData, model: Optional[SpacyModel] = None + ) -> Resource: + """Trains the featurizer. + + Take parameters from config and + construct a new count vectorizer using the sklearn framework. + """ + if model is not None: + # create spacy lemma_ for OOV_words + self.OOV_words = [ + t.lemma_ if self.use_lemma else t.text + for w in self.OOV_words + for t in model.model(w) + ] + + # process sentences and collect data for all attributes + processed_attribute_tokens = self._get_all_attributes_processed_tokens( + training_data + ) + + # train for all attributes + attribute_texts = self._convert_attribute_tokens_to_texts( + processed_attribute_tokens + ) + if self.use_shared_vocab: + self._train_with_shared_vocab(attribute_texts) + else: + self._train_with_independent_vocab(attribute_texts) + + self.persist() + + return self._resource + + def process_training_data(self, training_data: TrainingData) -> TrainingData: + """Processes the training examples in the given training data in-place. + + Args: + training_data: the training data + + Returns: + same training data after processing + """ + self.process(training_data.training_examples) + return training_data + + def process(self, messages: List[Message]) -> List[Message]: + """Processes incoming message and compute and set features.""" + if self.vectorizers is None: + logger.error( + "There is no trained CountVectorizer: " + "component is either not trained or " + "didn't receive enough training data" + ) + return messages + + for message in messages: + for attribute in self._attributes: + + message_tokens = self._get_processed_message_tokens_by_attribute( + message, attribute + ) + + # features shape (1, seq, dim) + sequence_features, sentence_features = self._create_features( + attribute, [message_tokens] + ) + self.add_features_to_message( + sequence_features[0], sentence_features[0], attribute, message + ) + + return messages + + def _collect_vectorizer_vocabularies(self) -> Dict[Text, Optional[Dict[Text, int]]]: + """Gets vocabulary for all attributes.""" + attribute_vocabularies = {} + for attribute in self._attributes: + attribute_vocabularies[attribute] = self._get_attribute_vocabulary( + attribute + ) + return attribute_vocabularies + + @staticmethod + def _is_any_model_trained( + attribute_vocabularies: Dict[Text, Optional[Dict[Text, int]]] + ) -> bool: + """Check if any model got trained.""" + return any(value is not None for value in attribute_vocabularies.values()) + + @staticmethod + def convert_vocab( + vocab: Dict[str, Union[int, Optional[Dict[str, int]]]], to_int: bool + ) -> Dict[str, Union[None, int, np.int64, Dict[str, Union[int, np.int64]]]]: + """Converts numpy integers in the vocabulary to Python integers.""" + + def convert_value(value: int) -> Union[int, np.int64]: + """Helper function to convert a single value based on to_int flag.""" + return int(value) if to_int else np.int64(value) + + result_dict: Dict[ + str, Union[None, int, np.int64, Dict[str, Union[int, np.int64]]] + ] = {} + for key, sub_dict in vocab.items(): + if isinstance(sub_dict, int): + result_dict[key] = convert_value(sub_dict) + elif not sub_dict: + result_dict[key] = None + else: + result_dict[key] = { + sub_key: convert_value(value) for sub_key, value in sub_dict.items() + } + + return result_dict + + def persist(self) -> None: + """Persist this model into the passed directory. + + Returns the metadata necessary to load the model again. + """ + if not self.vectorizers: + return + + with self._model_storage.write_to(self._resource) as model_dir: + # vectorizer instance was not None, some models could have been trained + attribute_vocabularies = self._collect_vectorizer_vocabularies() + if self._is_any_model_trained(attribute_vocabularies): + # Definitely need to persist some vocabularies + featurizer_file = model_dir / "vocabularies.json" + + # Only persist vocabulary from one attribute if `use_shared_vocab`. + # Can be loaded and distributed to all attributes. + loaded_vocab = ( + attribute_vocabularies[TEXT] + if self.use_shared_vocab + else attribute_vocabularies + ) + vocab = self.convert_vocab(loaded_vocab, to_int=True) + + rasa.shared.utils.io.dump_obj_as_json_to_file(featurizer_file, vocab) + + # Dump OOV words separately as they might have been modified during + # training + rasa.shared.utils.io.dump_obj_as_json_to_file( + model_dir / "oov_words.json", self.OOV_words + ) + + @classmethod + def _create_shared_vocab_vectorizers( + cls, parameters: Dict[Text, Any], vocabulary: Optional[Any] = None + ) -> Dict[Text, CountVectorizer]: + """Create vectorizers for all attributes with shared vocabulary.""" + shared_vectorizer = CountVectorizer( + token_pattern=r"(?u)\b\w+\b" if parameters["analyzer"] == "word" else None, + strip_accents=parameters["strip_accents"], + lowercase=parameters["lowercase"], + stop_words=parameters["stop_words"], + ngram_range=(parameters["min_ngram"], parameters["max_ngram"]), + max_df=parameters["max_df"], + min_df=parameters["min_df"], + max_features=parameters["max_features"], + analyzer=parameters["analyzer"], + vocabulary=vocabulary, + ) + + attribute_vectorizers = {} + + for attribute in cls._attributes_for(parameters["analyzer"]): + attribute_vectorizers[attribute] = shared_vectorizer + + return attribute_vectorizers + + @classmethod + def _create_independent_vocab_vectorizers( + cls, parameters: Dict[Text, Any], vocabulary: Optional[Any] = None + ) -> Dict[Text, CountVectorizer]: + """Create vectorizers for all attributes with independent vocabulary.""" + attribute_vectorizers = {} + + for attribute in cls._attributes_for(parameters["analyzer"]): + attribute_vocabulary = vocabulary[attribute] if vocabulary else None + + attribute_vectorizer = CountVectorizer( + token_pattern=r"(?u)\b\w+\b" + if parameters["analyzer"] == "word" + else None, + strip_accents=parameters["strip_accents"], + lowercase=parameters["lowercase"], + stop_words=parameters["stop_words"], + ngram_range=(parameters["min_ngram"], parameters["max_ngram"]), + max_df=parameters["max_df"], + min_df=parameters["min_df"] + if attribute == rasa.shared.nlu.constants.TEXT + else 1, + max_features=parameters["max_features"], + analyzer=parameters["analyzer"], + vocabulary=attribute_vocabulary, + ) + attribute_vectorizers[attribute] = attribute_vectorizer + + return attribute_vectorizers + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> CountVectorsFeaturizer: + """Loads trained component (see parent class for full docstring).""" + try: + with model_storage.read_from(resource) as model_dir: + featurizer_file = model_dir / "vocabularies.json" + vocabulary = rasa.shared.utils.io.read_json_file(featurizer_file) + vocabulary = cls.convert_vocab(vocabulary, to_int=False) + + share_vocabulary = config["use_shared_vocab"] + + if share_vocabulary: + vectorizers = cls._create_shared_vocab_vectorizers( + config, vocabulary=vocabulary + ) + else: + vectorizers = cls._create_independent_vocab_vectorizers( + config, vocabulary=vocabulary + ) + + oov_words = rasa.shared.utils.io.read_json_file( + model_dir / "oov_words.json" + ) + + ftr = cls( + config, + model_storage, + resource, + execution_context, + vectorizers=vectorizers, + oov_token=config["OOV_token"], + oov_words=oov_words, + ) + + # make sure the vocabulary has been loaded correctly + for attribute in vectorizers: + ftr.vectorizers[attribute]._validate_vocabulary() + + return ftr + + except (ValueError, FileNotFoundError, FileIOException): + logger.debug( + f"Failed to load `{cls.__class__.__name__}` from model storage. " + f"Resource '{resource.name}' doesn't exist." + ) + return cls( + config=config, + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + ) + + @classmethod + def validate_config(cls, config: Dict[Text, Any]) -> None: + """Validates that the component is configured properly.""" + pass diff --git a/rasa/nlu/featurizers/sparse_featurizer/lexical_syntactic_featurizer.py b/rasa/nlu/featurizers/sparse_featurizer/lexical_syntactic_featurizer.py new file mode 100644 index 0000000..2c4ee39 --- /dev/null +++ b/rasa/nlu/featurizers/sparse_featurizer/lexical_syntactic_featurizer.py @@ -0,0 +1,572 @@ +from __future__ import annotations + +import logging +from collections import OrderedDict +from typing import ( + Any, + Dict, + Text, + List, + Tuple, + Callable, + Set, + Optional, + Type, + Union, +) + +import numpy as np +import scipy.sparse + +import rasa.shared.utils.io +import rasa.utils.io +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.constants import TOKENS_NAMES +from rasa.nlu.featurizers.sparse_featurizer.sparse_featurizer import SparseFeaturizer +from rasa.nlu.tokenizers.spacy_tokenizer import POS_TAG_KEY, SpacyTokenizer +from rasa.nlu.tokenizers.tokenizer import Token, Tokenizer +from rasa.shared.constants import DOCS_URL_COMPONENTS +from rasa.shared.exceptions import InvalidConfigException +from rasa.shared.nlu.constants import TEXT +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData + +logger = logging.getLogger(__name__) + +END_OF_SENTENCE = "EOS" +BEGIN_OF_SENTENCE = "BOS" + +FEATURES = "features" + +SEPERATOR = "###" + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_FEATURIZER, is_trainable=True +) +class LexicalSyntacticFeaturizer(SparseFeaturizer, GraphComponent): + """Extracts and encodes lexical syntactic features. + + Given a sequence of tokens, this featurizer produces a sequence of features + where the `t`-th feature encodes lexical and syntactic information about the `t`-th + token and it's surrounding tokens. + + In detail: The lexical syntactic features can be specified via a list of + configurations `[c_0, c_1, ..., c_n]` where each `c_i` is a list of names of + lexical and syntactic features (e.g. `low`, `suffix2`, `digit`). + For a given tokenized text, the featurizer will consider a window of size `n` + around each token and evaluate the given list of configurations as follows: + - It will extract the features listed in `c_m` where `m = (n-1)/2` if n is even and + `n/2` from token `t` + - It will extract the features listed in `c_{m-1}`,`c_{m-2}` ... , from the last, + second to last, ... token before token `t`, respectively. + - It will extract the features listed `c_{m+1}`, `c_{m+1}`, ... for the first, + second, ... token `t`, respectively. + It will then combine all these features into one feature for position `t`. + + Example: + If we specify `[['low'], ['upper'], ['prefix2']]`, then for each position `t` + the `t`-th feature will encode whether the token at position `t` is upper case, + where the token at position `t-1` is lower case and the first two characters + of the token at position `t+1`. + """ + + FILENAME_FEATURE_TO_IDX_DICT = "feature_to_idx_dict.json" + + # NOTE: "suffix5" of the token "is" will be "is". Hence, when combining multiple + # prefixes, short words will be represented/encoded repeatedly. + _FUNCTION_DICT: Dict[Text, Callable[[Token], Union[Text, bool, None]]] = { + "low": lambda token: token.text.islower(), + "title": lambda token: token.text.istitle(), + "prefix5": lambda token: token.text[:5], + "prefix2": lambda token: token.text[:2], + "suffix5": lambda token: token.text[-5:], + "suffix3": lambda token: token.text[-3:], + "suffix2": lambda token: token.text[-2:], + "suffix1": lambda token: token.text[-1:], + "pos": lambda token: token.data.get(POS_TAG_KEY, None), + "pos2": lambda token: token.data.get(POS_TAG_KEY, [])[:2] + if POS_TAG_KEY in token.data + else None, + "upper": lambda token: token.text.isupper(), + "digit": lambda token: token.text.isdigit(), + } + + SUPPORTED_FEATURES = sorted( + set(_FUNCTION_DICT.keys()).union([END_OF_SENTENCE, BEGIN_OF_SENTENCE]) + ) + + @classmethod + def _extract_raw_features_from_token( + cls, feature_name: Text, token: Token, token_position: int, num_tokens: int + ) -> Text: + """Extracts a raw feature from the token at the given position. + + Args: + feature_name: the name of a supported feature + token: the token from which we want to extract the feature + token_position: the position of the token inside the tokenized text + num_tokens: the total number of tokens in the tokenized text + Returns: + the raw feature value as text + """ + if feature_name not in cls.SUPPORTED_FEATURES: + raise InvalidConfigException( + f"Configured feature '{feature_name}' not valid. Please check " + f"'{DOCS_URL_COMPONENTS}' for valid configuration parameters." + ) + if feature_name == END_OF_SENTENCE: + return str(token_position == num_tokens - 1) + if feature_name == BEGIN_OF_SENTENCE: + return str(token_position == 0) + return str(cls._FUNCTION_DICT[feature_name](token)) + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [Tokenizer] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns the component's default config.""" + return { + **SparseFeaturizer.get_default_config(), + FEATURES: [ + ["low", "title", "upper"], + ["BOS", "EOS", "low", "upper", "title", "digit"], + ["low", "title", "upper"], + ], + } + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + feature_to_idx_dict: Optional[Dict[Tuple[int, Text], Dict[Text, int]]] = None, + ) -> None: + """Instantiates a new `LexicalSyntacticFeaturizer` instance.""" + super().__init__(execution_context.node_name, config) + # graph component + self._model_storage = model_storage + self._resource = resource + self._execution_context = execution_context + # featurizer specific + self._feature_config = self._config[FEATURES] + self._set_feature_to_idx_dict( + feature_to_idx_dict or {}, check_consistency_with_config=True + ) + + @classmethod + def validate_config(cls, config: Dict[Text, Any]) -> None: + """Validates that the component is configured properly.""" + if FEATURES not in config: + return # will be replaced with default + feature_config = config[FEATURES] + message = ( + f"Expected configuration of `features` to be a list of lists that " + f"that contain names of lexical and syntactic features " + f"(i.e. {cls.SUPPORTED_FEATURES}). " + f"Received {feature_config} instead. " + ) + try: + configured_feature_names = set( + feature_name + for pos_config in feature_config + for feature_name in pos_config + ) + except TypeError as e: + raise InvalidConfigException(message) from e + if configured_feature_names.difference(cls.SUPPORTED_FEATURES): + raise InvalidConfigException(message) + + def _set_feature_to_idx_dict( + self, + feature_to_idx_dict: Dict[Tuple[int, Text], Dict[Text, int]], + check_consistency_with_config: bool = False, + ) -> None: + """Sets the "feature" to index mapping. + + Here, "feature" denotes the combination of window position, feature name, + and feature_value. + + Args: + feature_to_idx_dict: mapping from tuples of window position and feature name + to a mapping from feature values to indices + check_consistency_with_config: whether the consistency with the current + `self.config` should be checked + """ + self._feature_to_idx_dict = feature_to_idx_dict + self._number_of_features = sum( + [ + len(feature_values.values()) + for feature_values in self._feature_to_idx_dict.values() + ] + ) + if check_consistency_with_config: + known_features = set(self._feature_to_idx_dict.keys()) + not_in_config = known_features.difference( + ( + (window_idx, feature_name) + for window_idx, feature_names in enumerate(self._feature_config) + for feature_name in feature_names + ) + ) + if not_in_config: + rasa.shared.utils.io.raise_warning( + f"A feature to index mapping has been loaded that does not match " + f"the configured features. The given mapping configures " + f" (position in window, feature_name): {not_in_config}. " + f" These are not specified in the given config " + f" {self._feature_config}. " + f"Continuing with constant values for these features. " + ) + + def train(self, training_data: TrainingData) -> Resource: + """Trains the featurizer. + + Args: + training_data: the training data + + Returns: + the resource from which this trained component can be loaded + """ + self.warn_if_pos_features_cannot_be_computed(training_data) + feature_to_idx_dict = self._create_feature_to_idx_dict(training_data) + self._set_feature_to_idx_dict(feature_to_idx_dict=feature_to_idx_dict) + if not self._feature_to_idx_dict: + rasa.shared.utils.io.raise_warning( + "No lexical syntactic features could be extracted from the training " + "data. In order for this component to work you need to define " + "`features` that can be found in the given training data." + ) + self.persist() + return self._resource + + def warn_if_pos_features_cannot_be_computed( + self, training_data: TrainingData + ) -> None: + """Warn if part-of-speech features are needed but not given.""" + training_example = next( + ( + message + for message in training_data.training_examples + if message.get(TOKENS_NAMES[TEXT], []) + ), + Message(), + ) + tokens_example = training_example.get(TOKENS_NAMES[TEXT], []) + + configured_feature_names = set( + feature_name + for pos_config in self._feature_config + for feature_name in pos_config + ) + if {"pos", "pos2"}.intersection( + configured_feature_names + ) and not tokens_example[0].data.get(POS_TAG_KEY, []): + rasa.shared.utils.io.raise_warning( + f"Expected training data to include tokens with part-of-speech tags" + f"because the given configuration includes part-of-speech features " + f"`pos` and/or `pos2`. " + f"Please add a {SpacyTokenizer.__name__} to your " + f"configuration if you want to use the part-of-speech-features in the" + f"{self.__class__.__name__}. " + f"Continuing without the part-of-speech-features." + ) + + def _create_feature_to_idx_dict( + self, training_data: TrainingData + ) -> Dict[Tuple[int, Text], Dict[Text, int]]: + """Create a nested dictionary of all feature values. + + Returns: + a nested mapping that maps from tuples of positions (in the window) and + supported feature names to "raw feature to index" mappings, i.e. + mappings that map the respective raw feature values to unique indices + (where `unique` means unique with respect to all indices in the + *nested* mapping) + """ + # collect all raw feature values + feature_vocabulary: Dict[Tuple[int, Text], Set[Text]] = dict() + for example in training_data.training_examples: + tokens = example.get(TOKENS_NAMES[TEXT], []) + sentence_features = self._map_tokens_to_raw_features(tokens) + for token_features in sentence_features: + for position_and_feature_name, feature_value in token_features.items(): + feature_vocabulary.setdefault(position_and_feature_name, set()).add( + feature_value + ) + # assign a unique index to each feature value + return self._build_feature_to_index_map(feature_vocabulary) + + def _map_tokens_to_raw_features( + self, tokens: List[Token] + ) -> List[Dict[Tuple[int, Text], Text]]: + """Extracts the raw feature values. + + Args: + tokens: a tokenized text + Returns: + a list of feature dictionaries for each token in the given list + where each feature dictionary maps a tuple containing + - a position (in the window) and + - a supported feature name + to the corresponding raw feature value + """ + sentence_features = [] + + # in case of an even number we will look at one more word before, + # e.g. window size 4 will result in a window range of + # [-2, -1, 0, 1] (0 = current word in sentence) + window_size = len(self._feature_config) + half_window_size = window_size // 2 + window_range = range(-half_window_size, half_window_size + window_size % 2) + assert len(window_range) == window_size + + for anchor in range(len(tokens)): + + token_features: Dict[Tuple[int, Text], Text] = {} + + for window_position, relative_position in enumerate(window_range): + absolute_position = anchor + relative_position + + # skip, if current_idx is pointing to a non-existing token + if absolute_position < 0 or absolute_position >= len(tokens): + continue + + token = tokens[absolute_position] + for feature_name in self._feature_config[window_position]: + token_features[ + (window_position, feature_name) + ] = self._extract_raw_features_from_token( + token=token, + feature_name=feature_name, + token_position=absolute_position, + num_tokens=len(tokens), + ) + + sentence_features.append(token_features) + + return sentence_features + + @staticmethod + def _build_feature_to_index_map( + feature_vocabulary: Dict[Tuple[int, Text], Set[Text]] + ) -> Dict[Tuple[int, Text], Dict[Text, int]]: + """Creates a nested dictionary for mapping raw features to indices. + + Args: + feature_vocabulary: a mapping from tuples of positions (in the window) and + supported feature names to the set of possible feature values + Returns: + a nested mapping that maps from tuples of positions (in the window) and + supported feature names to "raw feature to index" mappings, i.e. + mappings that map the respective raw feature values to unique indices + (where `unique` means unique with respect to all indices in the + *nested* mapping) + """ + # Note that this will only sort the top level keys - and we keep + # doing it to ensure consistently with what was done before) + ordered_feature_vocabulary: Dict[Tuple[int, Text], Set[Text]] = OrderedDict( + sorted(feature_vocabulary.items()) + ) + + # create the nested mapping + feature_to_idx_dict: Dict[Tuple[int, Text], Dict[Text, int]] = {} + offset = 0 + for ( + position_and_feature_name, + feature_values, + ) in ordered_feature_vocabulary.items(): + sorted_feature_values = sorted(feature_values) + feature_to_idx_dict[position_and_feature_name] = { + feature_value: feature_idx + for feature_idx, feature_value in enumerate( + sorted_feature_values, start=offset + ) + } + offset += len(feature_values) + + return feature_to_idx_dict + + def process(self, messages: List[Message]) -> List[Message]: + """Featurizes all given messages in-place. + + Args: + messages: messages to be featurized. + + Returns: + The same list with the same messages after featurization. + """ + for message in messages: + self._process_message(message) + return messages + + def process_training_data(self, training_data: TrainingData) -> TrainingData: + """Processes the training examples in the given training data in-place. + + Args: + training_data: the training data + + Returns: + same training data after processing + """ + self.process(training_data.training_examples) + return training_data + + def _process_message(self, message: Message) -> None: + """Featurizes the given message in-place. + + Args: + message: a message to be featurized + """ + if not self._feature_to_idx_dict: + rasa.shared.utils.io.raise_warning( + f"The {self.__class__.__name__} {self._identifier} has not been " + f"trained properly yet. " + f"Continuing without adding features from this featurizer." + ) + return + tokens = message.get(TOKENS_NAMES[TEXT]) + if tokens: + sentence_features = self._map_tokens_to_raw_features(tokens) + sparse_matrix = self._map_raw_features_to_indices(sentence_features) + self.add_features_to_message( + # FIXME: create sentence feature and make `sentence` non optional + sequence=sparse_matrix, + sentence=None, + attribute=TEXT, + message=message, + ) + + def _map_raw_features_to_indices( + self, sentence_features: List[Dict[Tuple[int, Text], Any]] + ) -> scipy.sparse.coo_matrix: + """Converts the raw features to one-hot encodings. + + Requires the "feature" to index dictionary, i.e. the featurizer must have + been trained. + + Args: + sentence_features: a list of feature dictionaries where the `t`-th feature + dictionary maps a tuple containing + - a position (in the window) and + - a supported feature name + to the raw feature value extracted from the window around the `t`-th token. + + Returns: + a sparse matrix where the `i`-th row is a multi-hot vector that encodes the + raw features extracted from the window around the `i`-th token + """ + rows = [] + cols = [] + shape = (len(sentence_features), self._number_of_features) + for token_idx, token_features in enumerate(sentence_features): + for position_and_feature_name, feature_value in token_features.items(): + mapping = self._feature_to_idx_dict.get(position_and_feature_name) + if not mapping: + continue + feature_idx = mapping.get(feature_value, -1) + if feature_idx > -1: + rows.append(token_idx) + cols.append(feature_idx) + data = np.ones(len(rows)) + return scipy.sparse.coo_matrix( + (data, (np.array(rows), np.array(cols))), shape=shape + ) + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> LexicalSyntacticFeaturizer: + """Creates a new untrained component (see parent class for full docstring).""" + return cls(config, model_storage, resource, execution_context) + + @staticmethod + def _restructure_feature_to_idx_dict( + loaded_data: Dict[str, Dict[str, int]], + ) -> Dict[Tuple[int, str], Dict[str, int]]: + """Reconstructs the feature to idx dict. + + When storing the feature_to_idx_dict to disk, we need to convert the tuple (key) + into a string to be able to store it via json. When loading the data + we need to reconstruct the tuple from the stored string. + + Args: + loaded_data: The loaded feature to idx dict from file. + + Returns: + The reconstructed feature_to_idx_dict + """ + feature_to_idx_dict = {} + for tuple_string, feature_value in loaded_data.items(): + # Example of tuple_string: "1###low" + index, feature_name = tuple_string.split(SEPERATOR) + + feature_key = (int(index), feature_name) + feature_to_idx_dict[feature_key] = feature_value + + return feature_to_idx_dict + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> LexicalSyntacticFeaturizer: + """Loads trained component (see parent class for full docstring).""" + try: + with model_storage.read_from(resource) as model_path: + loaded_data = rasa.shared.utils.io.read_json_file( + model_path / cls.FILENAME_FEATURE_TO_IDX_DICT, + ) + + # convert the key back into tuple + feature_to_idx_dict = cls._restructure_feature_to_idx_dict(loaded_data) + + return cls( + config=config, + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + feature_to_idx_dict=feature_to_idx_dict, + ) + except ValueError: + logger.debug( + f"Failed to load `{cls.__class__.__name__}` from model storage. " + f"Resource '{resource.name}' doesn't exist." + ) + return cls( + config=config, + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + ) + + def persist(self) -> None: + """Persist this model (see parent class for full docstring).""" + if not self._feature_to_idx_dict: + return None + + # as we cannot dump tuples, convert the tuple into a string + restructured_feature_dict = { + f"{k[0]}{SEPERATOR}{k[1]}": v for k, v in self._feature_to_idx_dict.items() + } + + with self._model_storage.write_to(self._resource) as model_path: + rasa.shared.utils.io.dump_obj_as_json_to_file( + model_path / self.FILENAME_FEATURE_TO_IDX_DICT, + restructured_feature_dict, + ) diff --git a/rasa/nlu/featurizers/sparse_featurizer/regex_featurizer.py b/rasa/nlu/featurizers/sparse_featurizer/regex_featurizer.py new file mode 100644 index 0000000..baed7f2 --- /dev/null +++ b/rasa/nlu/featurizers/sparse_featurizer/regex_featurizer.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import logging +import re +from typing import Any, Dict, List, Optional, Text, Tuple, Type + +import numpy as np +import scipy.sparse + +from rasa.nlu.tokenizers.tokenizer import Tokenizer +import rasa.shared.utils.io +import rasa.utils.io +import rasa.nlu.utils.pattern_utils as pattern_utils +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.constants import TOKENS_NAMES +from rasa.nlu.featurizers.sparse_featurizer.sparse_featurizer import SparseFeaturizer +from rasa.shared.nlu.constants import TEXT, RESPONSE, ACTION_TEXT +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_FEATURIZER, is_trainable=True +) +class RegexFeaturizer(SparseFeaturizer, GraphComponent): + """Adds message features based on regex expressions.""" + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [Tokenizer] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns the component's default config.""" + return { + **SparseFeaturizer.get_default_config(), + # text will be processed with case sensitive as default + "case_sensitive": True, + # use lookup tables to generate features + "use_lookup_tables": True, + # use regexes to generate features + "use_regexes": True, + # use match word boundaries for lookup table + "use_word_boundaries": True, + } + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + known_patterns: Optional[List[Dict[Text, Text]]] = None, + ) -> None: + """Constructs new features for regexes and lookup table using regex expressions. + + Args: + config: Configuration for the component. + model_storage: Storage which graph components can use to persist and load + themselves. + resource: Resource locator for this component which can be used to persist + and load itself from the `model_storage`. + execution_context: Information about the current graph run. + known_patterns: Regex Patterns the component should pre-load itself with. + """ + super().__init__(execution_context.node_name, config) + + self._model_storage = model_storage + self._resource = resource + + self.known_patterns = known_patterns if known_patterns else [] + self.case_sensitive = config["case_sensitive"] + self.finetune_mode = execution_context.is_finetuning + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> RegexFeaturizer: + """Creates a new untrained component (see parent class for full docstring).""" + return cls(config, model_storage, resource, execution_context) + + def _merge_new_patterns(self, new_patterns: List[Dict[Text, Text]]) -> None: + """Updates already known patterns with new patterns extracted from data. + + New patterns should always be added to the end of the existing + patterns and the order of the existing patterns should not be disturbed. + + Args: + new_patterns: Patterns extracted from training data and to be merged with + known patterns. + """ + pattern_name_index_map = { + pattern["name"]: index for index, pattern in enumerate(self.known_patterns) + } + for extra_pattern in new_patterns: + new_pattern_name = extra_pattern["name"] + + # Some patterns may have just new examples added + # to them. These do not count as additional pattern. + if new_pattern_name in pattern_name_index_map: + self.known_patterns[pattern_name_index_map[new_pattern_name]][ + "pattern" + ] = extra_pattern["pattern"] + else: + self.known_patterns.append(extra_pattern) + + def train(self, training_data: TrainingData) -> Resource: + """Trains the component with all patterns extracted from training data.""" + patterns_from_data = pattern_utils.extract_patterns( + training_data, + use_lookup_tables=self._config["use_lookup_tables"], + use_regexes=self._config["use_regexes"], + use_word_boundaries=self._config["use_word_boundaries"], + ) + if self.finetune_mode: + # Merge patterns extracted from data with known patterns + self._merge_new_patterns(patterns_from_data) + else: + self.known_patterns = patterns_from_data + + self._persist() + return self._resource + + def process_training_data(self, training_data: TrainingData) -> TrainingData: + """Processes the training examples (see parent class for full docstring).""" + for example in training_data.training_examples: + for attribute in [TEXT, RESPONSE, ACTION_TEXT]: + self._text_features_with_regex(example, attribute) + + return training_data + + def process(self, messages: List[Message]) -> List[Message]: + """Featurizes all given messages in-place. + + Returns: + the given list of messages which have been modified in-place + """ + for message in messages: + self._text_features_with_regex(message, TEXT) + + return messages + + def _text_features_with_regex(self, message: Message, attribute: Text) -> None: + """Helper method to extract features and set them appropriately in the message. + + Args: + message: Message to be featurized. + attribute: Attribute of message to be featurized. + """ + if self.known_patterns: + sequence_features, sentence_features = self._features_for_patterns( + message, attribute + ) + + self.add_features_to_message( + sequence_features, sentence_features, attribute, message + ) + + def _features_for_patterns( + self, message: Message, attribute: Text + ) -> Tuple[Optional[scipy.sparse.coo_matrix], Optional[scipy.sparse.coo_matrix]]: + """Checks which known patterns match the message. + + Given a sentence, returns a vector of {1,0} values indicating which + regexes did match. Furthermore, if the + message is tokenized, the function will mark all tokens with a dict + relating the name of the regex to whether it was matched. + + Args: + message: Message to be featurized. + attribute: Attribute of message to be featurized. + + Returns: + Token and sentence level features of message attribute. + """ + # Attribute not set (e.g. response not present) + if not message.get(attribute): + return None, None + + tokens = message.get(TOKENS_NAMES[attribute], []) + + if not tokens: + # nothing to featurize + return None, None + + flags = 0 # default flag + if not self.case_sensitive: + flags = re.IGNORECASE + + sequence_length = len(tokens) + + num_patterns = len(self.known_patterns) + + sequence_features = np.zeros([sequence_length, num_patterns]) + sentence_features = np.zeros([1, num_patterns]) + + for pattern_index, pattern in enumerate(self.known_patterns): + matches = list( + re.finditer(pattern["pattern"], message.get(attribute), flags=flags) + ) + + for token_index, t in enumerate(tokens): + patterns = t.get("pattern", default={}) + patterns[pattern["name"]] = False + + for match in matches: + if t.start < match.end() and t.end > match.start(): + patterns[pattern["name"]] = True + sequence_features[token_index][pattern_index] = 1.0 + if attribute in [RESPONSE, TEXT, ACTION_TEXT]: + # sentence vector should contain all patterns + sentence_features[0][pattern_index] = 1.0 + + t.set("pattern", patterns) + + return ( + scipy.sparse.coo_matrix(sequence_features), + scipy.sparse.coo_matrix(sentence_features), + ) + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> RegexFeaturizer: + """Loads trained component (see parent class for full docstring).""" + known_patterns = None + + try: + with model_storage.read_from(resource) as model_dir: + patterns_file_name = model_dir / "patterns.json" + known_patterns = rasa.shared.utils.io.read_json_file(patterns_file_name) + except (ValueError, FileNotFoundError): + logger.warning( + f"Failed to load `{cls.__class__.__name__}` from model storage. " + f"Resource '{resource.name}' doesn't exist." + ) + + return cls( + config, + model_storage, + resource, + execution_context, + known_patterns=known_patterns, + ) + + def _persist(self) -> None: + with self._model_storage.write_to(self._resource) as model_dir: + regex_file = model_dir / "patterns.json" + rasa.shared.utils.io.dump_obj_as_json_to_file( + regex_file, self.known_patterns + ) + + @classmethod + def validate_config(cls, config: Dict[Text, Any]) -> None: + """Validates that the component is configured properly.""" + pass diff --git a/rasa/nlu/featurizers/sparse_featurizer/sparse_featurizer.py b/rasa/nlu/featurizers/sparse_featurizer/sparse_featurizer.py new file mode 100644 index 0000000..9bd33d4 --- /dev/null +++ b/rasa/nlu/featurizers/sparse_featurizer/sparse_featurizer.py @@ -0,0 +1,9 @@ +from abc import ABC +import scipy.sparse +from rasa.nlu.featurizers.featurizer import Featurizer + + +class SparseFeaturizer(Featurizer[scipy.sparse.spmatrix], ABC): + """Base class for all sparse featurizers.""" + + pass diff --git a/rasa/nlu/model.py b/rasa/nlu/model.py new file mode 100644 index 0000000..5283273 --- /dev/null +++ b/rasa/nlu/model.py @@ -0,0 +1,24 @@ +import logging +from typing import Text + +from rasa.shared.exceptions import RasaException + + +logger = logging.getLogger(__name__) + + +# TODO: remove/move +class InvalidModelError(RasaException): + """Raised when a model failed to load. + + Attributes: + message -- explanation of why the model is invalid + """ + + def __init__(self, message: Text) -> None: + """Initialize message attribute.""" + self.message = message + super(InvalidModelError, self).__init__(message) + + def __str__(self) -> Text: + return self.message diff --git a/rasa/nlu/persistor.py b/rasa/nlu/persistor.py new file mode 100644 index 0000000..8a32d11 --- /dev/null +++ b/rasa/nlu/persistor.py @@ -0,0 +1,240 @@ +import abc +import logging +import os +import shutil +from typing import Optional, Text, Tuple, TYPE_CHECKING + +import rasa.shared.utils.common +import rasa.utils.common + +if TYPE_CHECKING: + from azure.storage.blob import ContainerClient + +logger = logging.getLogger(__name__) + + +def get_persistor(name: Text) -> Optional["Persistor"]: + """Returns an instance of the requested persistor. + + Currently, `aws`, `gcs`, `azure` and providing module paths are supported remote + storages. + """ + if name == "aws": + return AWSPersistor( + os.environ.get("BUCKET_NAME"), os.environ.get("AWS_ENDPOINT_URL") + ) + if name == "gcs": + return GCSPersistor(os.environ.get("BUCKET_NAME")) + + if name == "azure": + return AzurePersistor( + os.environ.get("AZURE_CONTAINER"), + os.environ.get("AZURE_ACCOUNT_NAME"), + os.environ.get("AZURE_ACCOUNT_KEY"), + ) + if name: + try: + persistor = rasa.shared.utils.common.class_from_module_path(name) + return persistor() + except ImportError: + raise ImportError( + f"Unknown model persistor {name}. Please make sure to " + "either use an included model persistor (`aws`, `gcs` " + "or `azure`) or specify the module path to an external " + "model persistor." + ) + return None + + +class Persistor(abc.ABC): + """Store models in cloud and fetch them when needed.""" + + def persist(self, model_directory: Text, model_name: Text) -> None: + """Uploads a model persisted in the `target_dir` to cloud storage.""" + if not os.path.isdir(model_directory): + raise ValueError(f"Target directory '{model_directory}' not found.") + + file_key, tar_path = self._compress(model_directory, model_name) + self._persist_tar(file_key, tar_path) + + def retrieve(self, model_name: Text, target_path: Text) -> None: + """Downloads a model that has been persisted to cloud storage.""" + tar_name = model_name + + if not model_name.endswith("tar.gz"): + # ensure backward compatibility + tar_name = self._tar_name(model_name) + + self._retrieve_tar(tar_name) + self._copy(os.path.basename(tar_name), target_path) + + @abc.abstractmethod + def _retrieve_tar(self, filename: Text) -> None: + """Downloads a model previously persisted to cloud storage.""" + raise NotImplementedError + + @abc.abstractmethod + def _persist_tar(self, filekey: Text, tarname: Text) -> None: + """Uploads a model persisted in the `target_dir` to cloud storage.""" + raise NotImplementedError + + def _compress(self, model_directory: Text, model_name: Text) -> Tuple[Text, Text]: + """Creates a compressed archive and returns key and tar.""" + import tempfile + + dirpath = tempfile.mkdtemp() + base_name = self._tar_name(model_name, include_extension=False) + tar_name = shutil.make_archive( + os.path.join(dirpath, base_name), + "gztar", + root_dir=model_directory, + base_dir=".", + ) + file_key = os.path.basename(tar_name) + return file_key, tar_name + + @staticmethod + def _tar_name(model_name: Text, include_extension: bool = True) -> Text: + + ext = ".tar.gz" if include_extension else "" + return f"{model_name}{ext}" + + @staticmethod + def _copy(compressed_path: Text, target_path: Text) -> None: + shutil.copy2(compressed_path, target_path) + + +class AWSPersistor(Persistor): + """Store models on S3. + + Fetches them when needed, instead of storing them on the local disk. + """ + + def __init__( + self, + bucket_name: Text, + endpoint_url: Optional[Text] = None, + region_name: Optional[Text] = None, + ) -> None: + import boto3 + + super().__init__() + self.s3 = boto3.resource( + "s3", endpoint_url=endpoint_url, region_name=region_name + ) + self._ensure_bucket_exists(bucket_name, region_name) + self.bucket_name = bucket_name + self.bucket = self.s3.Bucket(bucket_name) + + def _ensure_bucket_exists( + self, bucket_name: Text, region_name: Optional[Text] = None + ) -> None: + import boto3 + import botocore + + if not region_name: + region_name = boto3.DEFAULT_SESSION.region_name + + bucket_config = {"LocationConstraint": region_name} + # noinspection PyUnresolvedReferences + try: + self.s3.create_bucket( + Bucket=bucket_name, CreateBucketConfiguration=bucket_config + ) + except botocore.exceptions.ClientError: + pass # bucket already exists + + def _persist_tar(self, file_key: Text, tar_path: Text) -> None: + """Uploads a model persisted in the `target_dir` to s3.""" + with open(tar_path, "rb") as f: + self.s3.Object(self.bucket_name, file_key).put(Body=f) + + def _retrieve_tar(self, model_path: Text) -> None: + """Downloads a model that has previously been persisted to s3.""" + tar_name = os.path.basename(model_path) + with open(tar_name, "wb") as f: + self.bucket.download_fileobj(model_path, f) + + +class GCSPersistor(Persistor): + """Store models on Google Cloud Storage. + + Fetches them when needed, instead of storing them on the local disk. + """ + + def __init__(self, bucket_name: Text) -> None: + """Initialise class with client and bucket.""" + # there are no type hints in this repo for now + # https://github.com/googleapis/python-storage/issues/393 + from google.cloud import storage # type: ignore[attr-defined] + + super().__init__() + + self.storage_client = storage.Client() + self._ensure_bucket_exists(bucket_name) + + self.bucket_name = bucket_name + self.bucket = self.storage_client.bucket(bucket_name) + + def _ensure_bucket_exists(self, bucket_name: Text) -> None: + from google.cloud import exceptions + + try: + self.storage_client.create_bucket(bucket_name) + except exceptions.Conflict: + # bucket exists + pass + + def _persist_tar(self, file_key: Text, tar_path: Text) -> None: + """Uploads a model persisted in the `target_dir` to GCS.""" + blob = self.bucket.blob(file_key) + blob.upload_from_filename(tar_path) + + def _retrieve_tar(self, target_filename: Text) -> None: + """Downloads a model that has previously been persisted to GCS.""" + blob = self.bucket.blob(target_filename) + blob.download_to_filename(target_filename) + + +class AzurePersistor(Persistor): + """Store models on Azure.""" + + def __init__( + self, azure_container: Text, azure_account_name: Text, azure_account_key: Text + ) -> None: + from azure.storage.blob import BlobServiceClient + + super().__init__() + + self.blob_service = BlobServiceClient( + account_url=f"https://{azure_account_name}.blob.core.windows.net/", + credential=azure_account_key, + ) + + self._ensure_container_exists(azure_container) + self.container_name = azure_container + + def _ensure_container_exists(self, container_name: Text) -> None: + from azure.core.exceptions import ResourceExistsError + + try: + self.blob_service.create_container(container_name) + except ResourceExistsError: + # no need to create the container, it already exists + pass + + def _container_client(self) -> "ContainerClient": + return self.blob_service.get_container_client(self.container_name) + + def _persist_tar(self, file_key: Text, tar_path: Text) -> None: + """Uploads a model persisted in the `target_dir` to Azure.""" + with open(tar_path, "rb") as data: + self._container_client().upload_blob(name=file_key, data=data) + + def _retrieve_tar(self, target_filename: Text) -> None: + """Downloads a model that has previously been persisted to Azure.""" + blob_client = self._container_client().get_blob_client(target_filename) + + with open(target_filename, "wb") as blob: + download_stream = blob_client.download_blob() + blob.write(download_stream.readall()) diff --git a/rasa/nlu/run.py b/rasa/nlu/run.py new file mode 100644 index 0000000..4550dec --- /dev/null +++ b/rasa/nlu/run.py @@ -0,0 +1,27 @@ +import asyncio +import logging +from typing import Text + +from rasa.core.agent import Agent +from rasa.shared.utils.cli import print_info, print_success +from rasa.shared.utils.io import json_to_string + +logger = logging.getLogger(__name__) + + +def run_cmdline(model_path: Text) -> None: + """Loops over CLI input, passing each message to a loaded NLU model.""" + agent = Agent.load(model_path) + + print_success("NLU model loaded. Type a message and press enter to parse it.") + while True: + print_success("Next message:") + try: + message = input().strip() + except (EOFError, KeyboardInterrupt): + print_info("Wrapping up command line chat...") + break + + result = asyncio.run(agent.parse_message(message)) + + print(json_to_string(result)) diff --git a/rasa/nlu/selectors/__init__.py b/rasa/nlu/selectors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/nlu/selectors/response_selector.py b/rasa/nlu/selectors/response_selector.py new file mode 100644 index 0000000..f3ea9f3 --- /dev/null +++ b/rasa/nlu/selectors/response_selector.py @@ -0,0 +1,990 @@ +from __future__ import annotations +import copy +import logging +from rasa.nlu.featurizers.featurizer import Featurizer + +import numpy as np +import tensorflow as tf + +from typing import Any, Dict, Optional, Text, Tuple, Union, List, Type + +from rasa.engine.graph import ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.constants import DIAGNOSTIC_DATA +from rasa.shared.nlu.training_data import util +import rasa.shared.utils.io +from rasa.shared.exceptions import InvalidConfigException +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.classifiers.diet_classifier import ( + DIET, + LABEL_KEY, + LABEL_SUB_KEY, + SENTENCE, + SEQUENCE, + DIETClassifier, +) +from rasa.nlu.extractors.extractor import EntityTagSpec +from rasa.utils.tensorflow import rasa_layers +from rasa.utils.tensorflow.constants import ( + LABEL, + HIDDEN_LAYERS_SIZES, + SHARE_HIDDEN_LAYERS, + TRANSFORMER_SIZE, + NUM_TRANSFORMER_LAYERS, + NUM_HEADS, + BATCH_SIZES, + BATCH_STRATEGY, + EPOCHS, + RANDOM_SEED, + LEARNING_RATE, + RANKING_LENGTH, + RENORMALIZE_CONFIDENCES, + LOSS_TYPE, + SIMILARITY_TYPE, + NUM_NEG, + SPARSE_INPUT_DROPOUT, + DENSE_INPUT_DROPOUT, + MASKED_LM, + ENTITY_RECOGNITION, + INTENT_CLASSIFICATION, + EVAL_NUM_EXAMPLES, + EVAL_NUM_EPOCHS, + UNIDIRECTIONAL_ENCODER, + DROP_RATE, + DROP_RATE_ATTENTION, + CONNECTION_DENSITY, + NEGATIVE_MARGIN_SCALE, + REGULARIZATION_CONSTANT, + SCALE_LOSS, + USE_MAX_NEG_SIM, + MAX_NEG_SIM, + MAX_POS_SIM, + EMBEDDING_DIMENSION, + BILOU_FLAG, + KEY_RELATIVE_ATTENTION, + VALUE_RELATIVE_ATTENTION, + MAX_RELATIVE_POSITION, + RETRIEVAL_INTENT, + USE_TEXT_AS_LABEL, + CROSS_ENTROPY, + AUTO, + BALANCED, + TENSORBOARD_LOG_DIR, + TENSORBOARD_LOG_LEVEL, + CONCAT_DIMENSION, + FEATURIZERS, + CHECKPOINT_MODEL, + DENSE_DIMENSION, + CONSTRAIN_SIMILARITIES, + MODEL_CONFIDENCE, + SOFTMAX, +) +from rasa.nlu.constants import ( + RESPONSE_SELECTOR_PROPERTY_NAME, + RESPONSE_SELECTOR_RETRIEVAL_INTENTS, + RESPONSE_SELECTOR_RESPONSES_KEY, + RESPONSE_SELECTOR_PREDICTION_KEY, + RESPONSE_SELECTOR_RANKING_KEY, + RESPONSE_SELECTOR_UTTER_ACTION_KEY, + RESPONSE_SELECTOR_DEFAULT_INTENT, + DEFAULT_TRANSFORMER_SIZE, +) +from rasa.shared.nlu.constants import ( + TEXT, + INTENT, + RESPONSE, + INTENT_RESPONSE_KEY, + INTENT_NAME_KEY, + PREDICTED_CONFIDENCE_KEY, +) + +from rasa.utils.tensorflow.model_data import RasaModelData +from rasa.utils.tensorflow.models import RasaModel + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER, is_trainable=True +) +class ResponseSelector(DIETClassifier): + """Response selector using supervised embeddings. + + The response selector embeds user inputs + and candidate response into the same space. + Supervised embeddings are trained by maximizing similarity between them. + It also provides rankings of the response that did not "win". + + The supervised response selector needs to be preceded by + a featurizer in the pipeline. + This featurizer creates the features used for the embeddings. + It is recommended to use ``CountVectorsFeaturizer`` that + can be optionally preceded by ``SpacyNLP`` and ``SpacyTokenizer``. + + Based on the starspace idea from: https://arxiv.org/abs/1709.03856. + However, in this implementation the `mu` parameter is treated differently + and additional hidden layers are added together with dropout. + """ + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [Featurizer] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """The component's default config (see parent class for full docstring).""" + return { + **DIETClassifier.get_default_config(), + # ## Architecture of the used neural network + # Hidden layer sizes for layers before the embedding layers for user message + # and labels. + # The number of hidden layers is equal to the length of the corresponding + # list. + HIDDEN_LAYERS_SIZES: {TEXT: [256, 128], LABEL: [256, 128]}, + # Whether to share the hidden layer weights between input words + # and responses + SHARE_HIDDEN_LAYERS: False, + # Number of units in transformer + TRANSFORMER_SIZE: None, + # Number of transformer layers + NUM_TRANSFORMER_LAYERS: 0, + # Number of attention heads in transformer + NUM_HEADS: 4, + # If 'True' use key relative embeddings in attention + KEY_RELATIVE_ATTENTION: False, + # If 'True' use key relative embeddings in attention + VALUE_RELATIVE_ATTENTION: False, + # Max position for relative embeddings. Only in effect if key- + # or value relative attention are turned on + MAX_RELATIVE_POSITION: 5, + # Use a unidirectional or bidirectional encoder. + UNIDIRECTIONAL_ENCODER: False, + # ## Training parameters + # Initial and final batch sizes: + # Batch size will be linearly increased for each epoch. + BATCH_SIZES: [64, 256], + # Strategy used when creating batches. + # Can be either 'sequence' or 'balanced'. + BATCH_STRATEGY: BALANCED, + # Number of epochs to train + EPOCHS: 300, + # Set random seed to any 'int' to get reproducible results + RANDOM_SEED: None, + # Initial learning rate for the optimizer + LEARNING_RATE: 0.001, + # ## Parameters for embeddings + # Dimension size of embedding vectors + EMBEDDING_DIMENSION: 20, + # Default dense dimension to use if no dense features are present. + DENSE_DIMENSION: {TEXT: 512, LABEL: 512}, + # Default dimension to use for concatenating sequence and sentence features. + CONCAT_DIMENSION: {TEXT: 512, LABEL: 512}, + # The number of incorrect labels. The algorithm will minimize + # their similarity to the user input during training. + NUM_NEG: 20, + # Type of similarity measure to use, either 'auto' or 'cosine' or 'inner'. + SIMILARITY_TYPE: AUTO, + # The type of the loss function, either 'cross_entropy' or 'margin'. + LOSS_TYPE: CROSS_ENTROPY, + # Number of top actions for which confidences should be predicted. + # Set to 0 if confidences for all intents should be reported. + RANKING_LENGTH: 10, + # Determines whether the confidences of the chosen top actions should be + # renormalized so that they sum up to 1. By default, we do not renormalize + # and return the confidences for the top actions as is. + # Note that renormalization only makes sense if confidences are generated + # via `softmax`. + RENORMALIZE_CONFIDENCES: False, + # Indicates how similar the algorithm should try to make embedding vectors + # for correct labels. + # Should be 0.0 < ... < 1.0 for 'cosine' similarity type. + MAX_POS_SIM: 0.8, + # Maximum negative similarity for incorrect labels. + # Should be -1.0 < ... < 1.0 for 'cosine' similarity type. + MAX_NEG_SIM: -0.4, + # If 'True' the algorithm only minimizes maximum similarity over + # incorrect intent labels, used only if 'loss_type' is set to 'margin'. + USE_MAX_NEG_SIM: True, + # Scale loss inverse proportionally to confidence of correct prediction + SCALE_LOSS: True, + # ## Regularization parameters + # The scale of regularization + REGULARIZATION_CONSTANT: 0.002, + # Fraction of trainable weights in internal layers. + CONNECTION_DENSITY: 1.0, + # The scale of how important is to minimize the maximum similarity + # between embeddings of different labels. + NEGATIVE_MARGIN_SCALE: 0.8, + # Dropout rate for encoder + DROP_RATE: 0.2, + # Dropout rate for attention + DROP_RATE_ATTENTION: 0, + # If 'True' apply dropout to sparse input tensors + SPARSE_INPUT_DROPOUT: False, + # If 'True' apply dropout to dense input tensors + DENSE_INPUT_DROPOUT: False, + # ## Evaluation parameters + # How often calculate validation accuracy. + # Small values may hurt performance, e.g. model accuracy. + EVAL_NUM_EPOCHS: 20, + # How many examples to use for hold out validation set + # Large values may hurt performance, e.g. model accuracy. + EVAL_NUM_EXAMPLES: 0, + # ## Selector config + # If 'True' random tokens of the input message will be masked and the model + # should predict those tokens. + MASKED_LM: False, + # Name of the intent for which this response selector is to be trained + RETRIEVAL_INTENT: None, + # Boolean flag to check if actual text of the response + # should be used as ground truth label for training the model. + USE_TEXT_AS_LABEL: False, + # If you want to use tensorboard to visualize training + # and validation metrics, + # set this option to a valid output directory. + TENSORBOARD_LOG_DIR: None, + # Define when training metrics for tensorboard should be logged. + # Either after every epoch or for every training step. + # Valid values: 'epoch' and 'batch' + TENSORBOARD_LOG_LEVEL: "epoch", + # Specify what features to use as sequence and sentence features + # By default all features in the pipeline are used. + FEATURIZERS: [], + # Perform model checkpointing + CHECKPOINT_MODEL: False, + # if 'True' applies sigmoid on all similarity terms and adds it + # to the loss function to ensure that similarity values are + # approximately bounded. Used inside cross-entropy loss only. + CONSTRAIN_SIMILARITIES: False, + # Model confidence to be returned during inference. Currently, the only + # possible value is `softmax`. + MODEL_CONFIDENCE: SOFTMAX, + } + + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + index_label_id_mapping: Optional[Dict[int, Text]] = None, + entity_tag_specs: Optional[List[EntityTagSpec]] = None, + model: Optional[RasaModel] = None, + all_retrieval_intents: Optional[List[Text]] = None, + responses: Optional[Dict[Text, List[Dict[Text, Any]]]] = None, + sparse_feature_sizes: Optional[Dict[Text, Dict[Text, List[int]]]] = None, + ) -> None: + """Declare instance variables with default values. + + Args: + config: Configuration for the component. + model_storage: Storage which graph components can use to persist and load + themselves. + resource: Resource locator for this component which can be used to persist + and load itself from the `model_storage`. + execution_context: Information about the current graph run. + index_label_id_mapping: Mapping between label and index used for encoding. + entity_tag_specs: Format specification all entity tags. + model: Model architecture. + all_retrieval_intents: All retrieval intents defined in the data. + responses: All responses defined in the data. + finetune_mode: If `True` loads the model with pre-trained weights, + otherwise initializes it with random weights. + sparse_feature_sizes: Sizes of the sparse features the model was trained on. + """ + component_config = config + + # the following properties cannot be adapted for the ResponseSelector + component_config[INTENT_CLASSIFICATION] = True + component_config[ENTITY_RECOGNITION] = False + component_config[BILOU_FLAG] = None + + # Initialize defaults + self.responses = responses or {} + self.all_retrieval_intents = all_retrieval_intents or [] + self.retrieval_intent = None + self.use_text_as_label = False + + super().__init__( + component_config, + model_storage, + resource, + execution_context, + index_label_id_mapping, + entity_tag_specs, + model, + sparse_feature_sizes=sparse_feature_sizes, + ) + + @property + def label_key(self) -> Text: + """Returns label key.""" + return LABEL_KEY + + @property + def label_sub_key(self) -> Text: + """Returns label sub_key.""" + return LABEL_SUB_KEY + + @staticmethod + def model_class( # type: ignore[override] + use_text_as_label: bool, + ) -> Type[RasaModel]: + """Returns model class.""" + if use_text_as_label: + return DIET2DIET + else: + return DIET2BOW + + def _load_selector_params(self) -> None: + self.retrieval_intent = self.component_config[RETRIEVAL_INTENT] + self.use_text_as_label = self.component_config[USE_TEXT_AS_LABEL] + + def _warn_about_transformer_and_hidden_layers_enabled( + self, selector_name: Text + ) -> None: + """Warns user if they enabled the transformer but didn't disable hidden layers. + + ResponseSelector defaults specify considerable hidden layer sizes, but + this is for cases where no transformer is used. If a transformer exists, + then, from our experience, the best results are achieved with no hidden layers + used between the feature-combining layers and the transformer. + """ + default_config = self.get_default_config() + hidden_layers_is_at_default_value = ( + self.component_config[HIDDEN_LAYERS_SIZES] + == default_config[HIDDEN_LAYERS_SIZES] + ) + config_for_disabling_hidden_layers: Dict[Text, List[Any]] = { + k: [] for k, _ in default_config[HIDDEN_LAYERS_SIZES].items() + } + # warn if the hidden layers aren't disabled + if ( + self.component_config[HIDDEN_LAYERS_SIZES] + != config_for_disabling_hidden_layers + ): + # make the warning text more contextual by explaining what the user did + # to the hidden layers' config (i.e. what it is they should change) + if hidden_layers_is_at_default_value: + what_user_did = "left the hidden layer sizes at their default value:" + else: + what_user_did = "set the hidden layer sizes to be non-empty by setting" + + rasa.shared.utils.io.raise_warning( + f"You have enabled a transformer inside {selector_name} by" + f" setting a positive value for `{NUM_TRANSFORMER_LAYERS}`, but you " + f"{what_user_did} `{HIDDEN_LAYERS_SIZES}=" + f"{self.component_config[HIDDEN_LAYERS_SIZES]}`. We recommend to " + f"disable the hidden layers when using a transformer, by specifying " + f"`{HIDDEN_LAYERS_SIZES}={config_for_disabling_hidden_layers}`.", + category=UserWarning, + ) + + def _warn_and_correct_transformer_size(self, selector_name: Text) -> None: + """Corrects transformer size so that training doesn't break; informs the user. + + If a transformer is used, the default `transformer_size` breaks things. + We need to set a reasonable default value so that the model works fine. + """ + if ( + self.component_config[TRANSFORMER_SIZE] is None + or self.component_config[TRANSFORMER_SIZE] < 1 + ): + rasa.shared.utils.io.raise_warning( + f"`{TRANSFORMER_SIZE}` is set to " + f"`{self.component_config[TRANSFORMER_SIZE]}` for " + f"{selector_name}, but a positive size is required when using " + f"`{NUM_TRANSFORMER_LAYERS} > 0`. {selector_name} will proceed, using " + f"`{TRANSFORMER_SIZE}={DEFAULT_TRANSFORMER_SIZE}`. " + f"Alternatively, specify a different value in the component's config.", + category=UserWarning, + ) + self.component_config[TRANSFORMER_SIZE] = DEFAULT_TRANSFORMER_SIZE + + def _check_config_params_when_transformer_enabled(self) -> None: + """Checks & corrects config parameters when the transformer is enabled. + + This is needed because the defaults for individual config parameters are + interdependent and some defaults should change when the transformer is enabled. + """ + if self.component_config[NUM_TRANSFORMER_LAYERS] > 0: + selector_name = "ResponseSelector" + ( + f"({self.retrieval_intent})" if self.retrieval_intent else "" + ) + self._warn_about_transformer_and_hidden_layers_enabled(selector_name) + self._warn_and_correct_transformer_size(selector_name) + + def _check_config_parameters(self) -> None: + """Checks that component configuration makes sense; corrects it where needed.""" + super()._check_config_parameters() + self._load_selector_params() + # Once general DIET-related parameters have been checked, check also the ones + # specific to ResponseSelector. + self._check_config_params_when_transformer_enabled() + + def _set_message_property( + self, message: Message, prediction_dict: Dict[Text, Any], selector_key: Text + ) -> None: + message_selector_properties = message.get(RESPONSE_SELECTOR_PROPERTY_NAME, {}) + message_selector_properties[ + RESPONSE_SELECTOR_RETRIEVAL_INTENTS + ] = self.all_retrieval_intents + message_selector_properties[selector_key] = prediction_dict + message.set( + RESPONSE_SELECTOR_PROPERTY_NAME, + message_selector_properties, + add_to_output=True, + ) + + def preprocess_train_data(self, training_data: TrainingData) -> RasaModelData: + """Prepares data for training. + + Performs sanity checks on training data, extracts encodings for labels. + + Args: + training_data: training data to preprocessed. + """ + # Collect all retrieval intents present in the data before filtering + self.all_retrieval_intents = list(training_data.retrieval_intents) + + if self.retrieval_intent: + training_data = training_data.filter_training_examples( + lambda ex: self.retrieval_intent == ex.get(INTENT) + ) + else: + # retrieval intent was left to its default value + logger.info( + "Retrieval intent parameter was left to its default value. This " + "response selector will be trained on training examples combining " + "all retrieval intents." + ) + + label_attribute = RESPONSE if self.use_text_as_label else INTENT_RESPONSE_KEY + + label_id_index_mapping = self._label_id_index_mapping( + training_data, attribute=label_attribute + ) + + self.responses = training_data.responses + + if not label_id_index_mapping: + # no labels are present to train + return RasaModelData() + + self.index_label_id_mapping = self._invert_mapping(label_id_index_mapping) + + self._label_data = self._create_label_data( + training_data, label_id_index_mapping, attribute=label_attribute + ) + + model_data = self._create_model_data( + training_data.intent_examples, + label_id_index_mapping, + label_attribute=label_attribute, + ) + + self._check_input_dimension_consistency(model_data) + + return model_data + + def _resolve_intent_response_key( + self, label: Dict[Text, Optional[Text]] + ) -> Optional[Text]: + """Given a label, return the response key based on the label id. + + Args: + label: predicted label by the selector + + Returns: + The match for the label that was found in the known responses. + It is always guaranteed to have a match, otherwise that case should have + been caught earlier and a warning should have been raised. + """ + for key, responses in self.responses.items(): + + # First check if the predicted label was the key itself + search_key = util.template_key_to_intent_response_key(key) + if search_key == label.get("name"): + return search_key + + # Otherwise loop over the responses to check if the text has a direct match + for response in responses: + if response.get(TEXT, "") == label.get("name"): + return search_key + return None + + def process(self, messages: List[Message]) -> List[Message]: + """Selects most like response for message. + + Args: + messages: List containing latest user message. + + Returns: + List containing the message augmented with the most likely response, + the associated intent_response_key and its similarity to the input. + """ + for message in messages: + out = self._predict(message) + top_label, label_ranking = self._predict_label(out) + + # Get the exact intent_response_key and the associated + # responses for the top predicted label + label_intent_response_key = ( + self._resolve_intent_response_key(top_label) + or top_label[INTENT_NAME_KEY] + ) + label_responses = self.responses.get( + util.intent_response_key_to_template_key(label_intent_response_key) + ) + + if label_intent_response_key and not label_responses: + # responses seem to be unavailable, + # likely an issue with the training data + # we'll use a fallback instead + rasa.shared.utils.io.raise_warning( + f"Unable to fetch responses for {label_intent_response_key} " + f"This means that there is likely an issue with the training data." + f"Please make sure you have added responses for this intent." + ) + label_responses = [{TEXT: label_intent_response_key}] + + for label in label_ranking: + label[INTENT_RESPONSE_KEY] = ( + self._resolve_intent_response_key(label) or label[INTENT_NAME_KEY] + ) + # Remove the "name" key since it is either the same as + # "intent_response_key" or it is the response text which + # is not needed in the ranking. + label.pop(INTENT_NAME_KEY) + + selector_key = ( + self.retrieval_intent + if self.retrieval_intent + else RESPONSE_SELECTOR_DEFAULT_INTENT + ) + + logger.debug( + f"Adding following selector key to message property: {selector_key}" + ) + + utter_action_key = util.intent_response_key_to_template_key( + label_intent_response_key + ) + prediction_dict = { + RESPONSE_SELECTOR_PREDICTION_KEY: { + RESPONSE_SELECTOR_RESPONSES_KEY: label_responses, + PREDICTED_CONFIDENCE_KEY: top_label[PREDICTED_CONFIDENCE_KEY], + INTENT_RESPONSE_KEY: label_intent_response_key, + RESPONSE_SELECTOR_UTTER_ACTION_KEY: utter_action_key, + }, + RESPONSE_SELECTOR_RANKING_KEY: label_ranking, + } + + self._set_message_property(message, prediction_dict, selector_key) + + if ( + self._execution_context.should_add_diagnostic_data + and out + and DIAGNOSTIC_DATA in out + ): + message.add_diagnostic_data( + self._execution_context.node_name, out.get(DIAGNOSTIC_DATA) + ) + + return messages + + def persist(self) -> None: + """Persist this model into the passed directory.""" + if self.model is None: + return None + + with self._model_storage.write_to(self._resource) as model_path: + file_name = self.__class__.__name__ + + rasa.shared.utils.io.dump_obj_as_json_to_file( + model_path / f"{file_name}.responses.json", self.responses + ) + + rasa.shared.utils.io.dump_obj_as_json_to_file( + model_path / f"{file_name}.retrieval_intents.json", + self.all_retrieval_intents, + ) + + super().persist() + + @classmethod + def _load_model_class( + cls, + tf_model_file: Text, + model_data_example: RasaModelData, + label_data: RasaModelData, + entity_tag_specs: List[EntityTagSpec], + config: Dict[Text, Any], + finetune_mode: bool = False, + ) -> "RasaModel": + + predict_data_example = RasaModelData( + label_key=model_data_example.label_key, + data={ + feature_name: features + for feature_name, features in model_data_example.items() + if TEXT in feature_name + }, + ) + return cls.model_class(config[USE_TEXT_AS_LABEL]).load( + tf_model_file, + model_data_example, + predict_data_example, + data_signature=model_data_example.get_signature(), + label_data=label_data, + entity_tag_specs=entity_tag_specs, + config=copy.deepcopy(config), + finetune_mode=finetune_mode, + ) + + def _instantiate_model_class(self, model_data: RasaModelData) -> "RasaModel": + return self.model_class(self.use_text_as_label)( + data_signature=model_data.get_signature(), + label_data=self._label_data, + entity_tag_specs=self._entity_tag_specs, + config=self.component_config, + ) + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> ResponseSelector: + """Loads the trained model from the provided directory.""" + model = super().load( + config, model_storage, resource, execution_context, **kwargs + ) + + try: + with model_storage.read_from(resource) as model_path: + file_name = cls.__name__ + responses = rasa.shared.utils.io.read_json_file( + model_path / f"{file_name}.responses.json" + ) + all_retrieval_intents = rasa.shared.utils.io.read_json_file( + model_path / f"{file_name}.retrieval_intents.json" + ) + model.responses = responses + model.all_retrieval_intents = all_retrieval_intents + return model + except ValueError: + logger.debug( + f"Failed to load {cls.__name__} from model storage. Resource " + f"'{resource.name}' doesn't exist." + ) + return cls(config, model_storage, resource, execution_context) + + +class DIET2BOW(DIET): + """DIET2BOW transformer implementation.""" + + def _create_metrics(self) -> None: + # self.metrics preserve order + # output losses first + self.mask_loss = tf.keras.metrics.Mean(name="m_loss") + self.response_loss = tf.keras.metrics.Mean(name="r_loss") + # output accuracies second + self.mask_acc = tf.keras.metrics.Mean(name="m_acc") + self.response_acc = tf.keras.metrics.Mean(name="r_acc") + + def _update_metrics_to_log(self) -> None: + debug_log_level = logging.getLogger("rasa").level == logging.DEBUG + + if self.config[MASKED_LM]: + self.metrics_to_log.append("m_acc") + if debug_log_level: + self.metrics_to_log.append("m_loss") + + self.metrics_to_log.append("r_acc") + if debug_log_level: + self.metrics_to_log.append("r_loss") + + self._log_metric_info() + + def _log_metric_info(self) -> None: + metric_name = {"t": "total", "m": "mask", "r": "response"} + logger.debug("Following metrics will be logged during training: ") + for metric in self.metrics_to_log: + parts = metric.split("_") + name = f"{metric_name[parts[0]]} {parts[1]}" + logger.debug(f" {metric} ({name})") + + def _update_label_metrics(self, loss: tf.Tensor, acc: tf.Tensor) -> None: + + self.response_loss.update_state(loss) + self.response_acc.update_state(acc) + + +class DIET2DIET(DIET): + """Diet 2 Diet transformer implementation.""" + + def _check_data(self) -> None: + if TEXT not in self.data_signature: + raise InvalidConfigException( + f"No text features specified. " + f"Cannot train '{self.__class__.__name__}' model." + ) + if LABEL not in self.data_signature: + raise InvalidConfigException( + f"No label features specified. " + f"Cannot train '{self.__class__.__name__}' model." + ) + if ( + self.config[SHARE_HIDDEN_LAYERS] + and self.data_signature[TEXT][SENTENCE] + != self.data_signature[LABEL][SENTENCE] + ): + raise ValueError( + "If hidden layer weights are shared, data signatures " + "for text_features and label_features must coincide." + ) + + def _create_metrics(self) -> None: + # self.metrics preserve order + # output losses first + self.mask_loss = tf.keras.metrics.Mean(name="m_loss") + self.response_loss = tf.keras.metrics.Mean(name="r_loss") + # output accuracies second + self.mask_acc = tf.keras.metrics.Mean(name="m_acc") + self.response_acc = tf.keras.metrics.Mean(name="r_acc") + + def _update_metrics_to_log(self) -> None: + debug_log_level = logging.getLogger("rasa").level == logging.DEBUG + + if self.config[MASKED_LM]: + self.metrics_to_log.append("m_acc") + if debug_log_level: + self.metrics_to_log.append("m_loss") + + self.metrics_to_log.append("r_acc") + if debug_log_level: + self.metrics_to_log.append("r_loss") + + self._log_metric_info() + + def _log_metric_info(self) -> None: + metric_name = {"t": "total", "m": "mask", "r": "response"} + logger.debug("Following metrics will be logged during training: ") + for metric in self.metrics_to_log: + parts = metric.split("_") + name = f"{metric_name[parts[0]]} {parts[1]}" + logger.debug(f" {metric} ({name})") + + def _prepare_layers(self) -> None: + self.text_name = TEXT + self.label_name = TEXT if self.config[SHARE_HIDDEN_LAYERS] else LABEL + + # For user text and response text, prepare layers that combine different feature + # types, embed everything using a transformer and optionally also do masked + # language modeling. Omit input dropout for label features. + label_config = self.config.copy() + label_config.update({SPARSE_INPUT_DROPOUT: False, DENSE_INPUT_DROPOUT: False}) + for attribute, config in [ + (self.text_name, self.config), + (self.label_name, label_config), + ]: + self._tf_layers[ + f"sequence_layer.{attribute}" + ] = rasa_layers.RasaSequenceLayer( + attribute, self.data_signature[attribute], config + ) + + if self.config[MASKED_LM]: + self._prepare_mask_lm_loss(self.text_name) + + self._prepare_label_classification_layers(predictor_attribute=self.text_name) + + def _create_all_labels(self) -> Tuple[tf.Tensor, tf.Tensor]: + all_label_ids = self.tf_label_data[LABEL_KEY][LABEL_SUB_KEY][0] + + sequence_feature_lengths = self._get_sequence_feature_lengths( + self.tf_label_data, LABEL + ) + + # Combine all feature types into one and embed using a transformer. + label_transformed, _, _, _, _, _ = self._tf_layers[ + f"sequence_layer.{self.label_name}" + ]( + ( + self.tf_label_data[LABEL][SEQUENCE], + self.tf_label_data[LABEL][SENTENCE], + sequence_feature_lengths, + ), + training=self._training, + ) + + # Last token is taken from the last position with real features, determined + # - by the number of real tokens, i.e. by the sequence length of sequence-level + # features, and + # - by the presence or absence of sentence-level features (reflected in the + # effective sequence length of these features being 1 or 0. + # We need to combine the two lengths to correctly get the last position. + sentence_feature_lengths = self._get_sentence_feature_lengths( + self.tf_label_data, LABEL + ) + sentence_label = self._last_token( + label_transformed, sequence_feature_lengths + sentence_feature_lengths + ) + + all_labels_embed = self._tf_layers[f"embed.{LABEL}"](sentence_label) + + return all_label_ids, all_labels_embed + + def batch_loss( + self, batch_in: Union[Tuple[tf.Tensor, ...], Tuple[np.ndarray, ...]] + ) -> tf.Tensor: + """Calculates the loss for the given batch. + + Args: + batch_in: The batch. + + Returns: + The loss of the given batch. + """ + tf_batch_data = self.batch_to_model_data_format(batch_in, self.data_signature) + + # Process all features for text. + sequence_feature_lengths_text = self._get_sequence_feature_lengths( + tf_batch_data, TEXT + ) + ( + text_transformed, + text_in, + _, + text_seq_ids, + mlm_mask_booleanean_text, + _, + ) = self._tf_layers[f"sequence_layer.{self.text_name}"]( + ( + tf_batch_data[TEXT][SEQUENCE], + tf_batch_data[TEXT][SENTENCE], + sequence_feature_lengths_text, + ), + training=self._training, + ) + + # Process all features for labels. + sequence_feature_lengths_label = self._get_sequence_feature_lengths( + tf_batch_data, LABEL + ) + label_transformed, _, _, _, _, _ = self._tf_layers[ + f"sequence_layer.{self.label_name}" + ]( + ( + tf_batch_data[LABEL][SEQUENCE], + tf_batch_data[LABEL][SENTENCE], + sequence_feature_lengths_label, + ), + training=self._training, + ) + + losses = [] + + if self.config[MASKED_LM]: + loss, acc = self._mask_loss( + text_transformed, + text_in, + text_seq_ids, + mlm_mask_booleanean_text, + self.text_name, + ) + + self.mask_loss.update_state(loss) + self.mask_acc.update_state(acc) + losses.append(loss) + + # Get sentence feature vector for label classification. The vector is extracted + # from the last position with real features. To determine this position, we + # combine the sequence lengths of sequence- and sentence-level features. + sentence_feature_lengths_text = self._get_sentence_feature_lengths( + tf_batch_data, TEXT + ) + sentence_vector_text = self._last_token( + text_transformed, + sequence_feature_lengths_text + sentence_feature_lengths_text, + ) + + # Extract sentence vector for the label attribute in the same way. + sentence_feature_lengths_label = self._get_sentence_feature_lengths( + tf_batch_data, LABEL + ) + sentence_vector_label = self._last_token( + label_transformed, + sequence_feature_lengths_label + sentence_feature_lengths_label, + ) + label_ids = tf_batch_data[LABEL_KEY][LABEL_SUB_KEY][0] + + loss, acc = self._calculate_label_loss( + sentence_vector_text, sentence_vector_label, label_ids + ) + self.response_loss.update_state(loss) + self.response_acc.update_state(acc) + losses.append(loss) + + return tf.math.add_n(losses) + + def batch_predict( + self, batch_in: Union[Tuple[tf.Tensor, ...], Tuple[np.ndarray, ...]] + ) -> Dict[Text, Union[tf.Tensor, Dict[Text, tf.Tensor]]]: + """Predicts the output of the given batch. + + Args: + batch_in: The batch. + + Returns: + The output to predict. + """ + tf_batch_data = self.batch_to_model_data_format( + batch_in, self.predict_data_signature + ) + + sequence_feature_lengths = self._get_sequence_feature_lengths( + tf_batch_data, TEXT + ) + text_transformed, _, _, _, _, attention_weights = self._tf_layers[ + f"sequence_layer.{self.text_name}" + ]( + ( + tf_batch_data[TEXT][SEQUENCE], + tf_batch_data[TEXT][SENTENCE], + sequence_feature_lengths, + ), + training=self._training, + ) + + predictions = { + DIAGNOSTIC_DATA: { + "attention_weights": attention_weights, + "text_transformed": text_transformed, + } + } + + if self.all_labels_embed is None: + _, self.all_labels_embed = self._create_all_labels() + + # get sentence feature vector for intent classification + sentence_vector = self._last_token(text_transformed, sequence_feature_lengths) + sentence_vector_embed = self._tf_layers[f"embed.{TEXT}"](sentence_vector) + + _, scores = self._tf_layers[ + f"loss.{LABEL}" + ].get_similarities_and_confidences_from_embeddings( + sentence_vector_embed[:, tf.newaxis, :], + self.all_labels_embed[tf.newaxis, :, :], + ) + predictions["i_scores"] = scores + + return predictions diff --git a/rasa/nlu/test.py b/rasa/nlu/test.py new file mode 100644 index 0000000..ae04060 --- /dev/null +++ b/rasa/nlu/test.py @@ -0,0 +1,1962 @@ +import copy +import itertools +import os +import logging +import structlog +from pathlib import Path + +import numpy as np +from collections import defaultdict, namedtuple +from tqdm import tqdm +from typing import ( + Iterable, + Iterator, + Tuple, + List, + Set, + Optional, + Text, + Union, + Dict, + Any, + NamedTuple, + TYPE_CHECKING, +) + +from rasa import telemetry +from rasa.core.agent import Agent +from rasa.core.channels import UserMessage +from rasa.core.processor import MessageProcessor +from rasa.plugin import plugin_manager +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.utils.common import TempDirectoryPath, get_temp_dir_name +import rasa.shared.utils.io +import rasa.utils.plotting as plot_utils +import rasa.utils.io as io_utils + +from rasa.constants import TEST_DATA_FILE, TRAIN_DATA_FILE, NLG_DATA_FILE +import rasa.nlu.classifiers.fallback_classifier +from rasa.nlu.constants import ( + RESPONSE_SELECTOR_DEFAULT_INTENT, + RESPONSE_SELECTOR_PROPERTY_NAME, + RESPONSE_SELECTOR_PREDICTION_KEY, + TOKENS_NAMES, + ENTITY_ATTRIBUTE_CONFIDENCE_TYPE, + ENTITY_ATTRIBUTE_CONFIDENCE_ROLE, + ENTITY_ATTRIBUTE_CONFIDENCE_GROUP, + RESPONSE_SELECTOR_RETRIEVAL_INTENTS, +) +from rasa.shared.nlu.constants import ( + TEXT, + INTENT, + INTENT_RESPONSE_KEY, + ENTITIES, + EXTRACTOR, + PRETRAINED_EXTRACTORS, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_GROUP, + ENTITY_ATTRIBUTE_ROLE, + NO_ENTITY_TAG, + INTENT_NAME_KEY, + PREDICTED_CONFIDENCE_KEY, +) +from rasa.nlu.classifiers import fallback_classifier +from rasa.nlu.tokenizers.tokenizer import Token +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.nlu.training_data.formats.rasa_yaml import RasaYAMLWriter + +if TYPE_CHECKING: + from typing_extensions import TypedDict + + EntityPrediction = TypedDict( + "EntityPrediction", + { + "text": Text, + "entities": List[Dict[Text, Any]], + "predicted_entities": List[Dict[Text, Any]], + }, + ) +logger = logging.getLogger(__name__) +structlogger = structlog.get_logger() + +# Exclude 'EntitySynonymMapper' and 'ResponseSelector' as their super class +# performs entity extraction but those two classifiers don't +ENTITY_PROCESSORS = {"EntitySynonymMapper", "ResponseSelector"} + +EXTRACTORS_WITH_CONFIDENCES = {"CRFEntityExtractor", "DIETClassifier"} + + +class CVEvaluationResult(NamedTuple): + """Stores NLU cross-validation results.""" + + train: Dict + test: Dict + evaluation: Dict + + +NO_ENTITY = "no_entity" + +IntentEvaluationResult = namedtuple( + "IntentEvaluationResult", "intent_target intent_prediction message confidence" +) + +ResponseSelectionEvaluationResult = namedtuple( + "ResponseSelectionEvaluationResult", + "intent_response_key_target intent_response_key_prediction message confidence", +) + +EntityEvaluationResult = namedtuple( + "EntityEvaluationResult", "entity_targets entity_predictions tokens message" +) + +IntentMetrics = Dict[Text, List[float]] +EntityMetrics = Dict[Text, Dict[Text, List[float]]] +ResponseSelectionMetrics = Dict[Text, List[float]] + + +def log_evaluation_table( + report: Text, precision: float, f1: float, accuracy: float +) -> None: # pragma: no cover + """Log the sklearn evaluation metrics.""" + logger.info(f"F1-Score: {f1}") + logger.info(f"Precision: {precision}") + logger.info(f"Accuracy: {accuracy}") + logger.info(f"Classification report: \n{report}") + + +def remove_empty_intent_examples( + intent_results: List[IntentEvaluationResult], +) -> List[IntentEvaluationResult]: + """Remove those examples without an intent. + + Args: + intent_results: intent evaluation results + + Returns: intent evaluation results + """ + filtered = [] + for r in intent_results: + # substitute None values with empty string + # to enable sklearn evaluation + if r.intent_prediction is None: + r = r._replace(intent_prediction="") + + if r.intent_target != "" and r.intent_target is not None: + filtered.append(r) + + return filtered + + +def remove_empty_response_examples( + response_results: List[ResponseSelectionEvaluationResult], +) -> List[ResponseSelectionEvaluationResult]: + """Remove those examples without a response. + + Args: + response_results: response selection evaluation results + + Returns: + Response selection evaluation results + """ + filtered = [] + for r in response_results: + # substitute None values with empty string + # to enable sklearn evaluation + if r.intent_response_key_prediction is None: + r = r._replace(intent_response_key_prediction="") + + if r.confidence is None: + # This might happen if response selector training data is present but + # no response selector is part of the model + r = r._replace(confidence=0.0) + + if r.intent_response_key_target: + filtered.append(r) + + return filtered + + +def drop_intents_below_freq( + training_data: TrainingData, cutoff: int = 5 +) -> TrainingData: + """Remove intent groups with less than cutoff instances. + + Args: + training_data: training data + cutoff: threshold + + Returns: updated training data + """ + logger.debug( + "Raw data intent examples: {}".format(len(training_data.intent_examples)) + ) + + examples_per_intent = training_data.number_of_examples_per_intent + return training_data.filter_training_examples( + lambda ex: examples_per_intent.get(ex.get(INTENT), 0) >= cutoff + ) + + +def write_intent_successes( + intent_results: List[IntentEvaluationResult], successes_filename: Text +) -> None: + """Write successful intent predictions to a file. + + Args: + intent_results: intent evaluation result + successes_filename: filename of file to save successful predictions to + """ + successes = [ + { + "text": r.message, + "intent": r.intent_target, + "intent_prediction": { + INTENT_NAME_KEY: r.intent_prediction, + "confidence": r.confidence, + }, + } + for r in intent_results + if r.intent_target == r.intent_prediction + ] + + if successes: + rasa.shared.utils.io.dump_obj_as_json_to_file(successes_filename, successes) + logger.info(f"Successful intent predictions saved to {successes_filename}.") + logger.debug(f"\n\nSuccessfully predicted the following intents: \n{successes}") + else: + logger.info("No successful intent predictions found.") + + +def _write_errors(errors: List[Dict], errors_filename: Text, error_type: Text) -> None: + """Write incorrect intent predictions to a file. + + Args: + errors: Serializable prediction errors. + errors_filename: filename of file to save incorrect predictions to + error_type: NLU entity which was evaluated (e.g. `intent` or `entity`). + """ + if errors: + rasa.shared.utils.io.dump_obj_as_json_to_file(errors_filename, errors) + logger.info(f"Incorrect {error_type} predictions saved to {errors_filename}.") + logger.debug( + f"\n\nThese {error_type} examples could not be classified " + f"correctly: \n{errors}" + ) + else: + logger.info(f"Every {error_type} was predicted correctly by the model.") + + +def _get_intent_errors(intent_results: List[IntentEvaluationResult]) -> List[Dict]: + return [ + { + "text": r.message, + "intent": r.intent_target, + "intent_prediction": { + INTENT_NAME_KEY: r.intent_prediction, + "confidence": r.confidence, + }, + } + for r in intent_results + if r.intent_target != r.intent_prediction + ] + + +def write_response_successes( + response_results: List[ResponseSelectionEvaluationResult], successes_filename: Text +) -> None: + """Write successful response selection predictions to a file. + + Args: + response_results: response selection evaluation result + successes_filename: filename of file to save successful predictions to + """ + successes = [ + { + "text": r.message, + "intent_response_key_target": r.intent_response_key_target, + "intent_response_key_prediction": { + "name": r.intent_response_key_prediction, + "confidence": r.confidence, + }, + } + for r in response_results + if r.intent_response_key_prediction == r.intent_response_key_target + ] + + if successes: + rasa.shared.utils.io.dump_obj_as_json_to_file(successes_filename, successes) + logger.info(f"Successful response predictions saved to {successes_filename}.") + structlogger.debug("test.write.response", successes=copy.deepcopy(successes)) + else: + logger.info("No successful response predictions found.") + + +def _response_errors( + response_results: List[ResponseSelectionEvaluationResult], +) -> List[Dict]: + """Write incorrect response selection predictions to a file. + + Args: + response_results: response selection evaluation result + + Returns: + Serializable prediction errors. + """ + return [ + { + "text": r.message, + "intent_response_key_target": r.intent_response_key_target, + "intent_response_key_prediction": { + "name": r.intent_response_key_prediction, + "confidence": r.confidence, + }, + } + for r in response_results + if r.intent_response_key_prediction != r.intent_response_key_target + ] + + +def plot_attribute_confidences( + results: Union[ + List[IntentEvaluationResult], List[ResponseSelectionEvaluationResult] + ], + hist_filename: Optional[Text], + target_key: Text, + prediction_key: Text, + title: Text, +) -> None: + """Create histogram of confidence distribution. + + Args: + results: evaluation results + hist_filename: filename to save plot to + target_key: key of target in results + prediction_key: key of predictions in results + title: title of plot + """ + pos_hist = [ + r.confidence + for r in results + if getattr(r, target_key) == getattr(r, prediction_key) + ] + + neg_hist = [ + r.confidence + for r in results + if getattr(r, target_key) != getattr(r, prediction_key) + ] + + plot_utils.plot_paired_histogram([pos_hist, neg_hist], title, hist_filename) + + +def plot_entity_confidences( + merged_targets: List[Text], + merged_predictions: List[Text], + merged_confidences: List[float], + hist_filename: Text, + title: Text, +) -> None: + """Creates histogram of confidence distribution. + + Args: + merged_targets: Entity labels. + merged_predictions: Predicted entities. + merged_confidences: Confidence scores of predictions. + hist_filename: filename to save plot to + title: title of plot + """ + pos_hist = [ + confidence + for target, prediction, confidence in zip( + merged_targets, merged_predictions, merged_confidences + ) + if target != NO_ENTITY and target == prediction + ] + + neg_hist = [ + confidence + for target, prediction, confidence in zip( + merged_targets, merged_predictions, merged_confidences + ) + if prediction not in (NO_ENTITY, target) + ] + + plot_utils.plot_paired_histogram([pos_hist, neg_hist], title, hist_filename) + + +def evaluate_response_selections( + response_selection_results: List[ResponseSelectionEvaluationResult], + output_directory: Optional[Text], + successes: bool, + errors: bool, + disable_plotting: bool, + report_as_dict: Optional[bool] = None, +) -> Dict: # pragma: no cover + """Creates summary statistics for response selection. + + Only considers those examples with a set response. + Others are filtered out. Returns a dictionary of containing the + evaluation result. + + Args: + response_selection_results: response selection evaluation results + output_directory: directory to store files to + successes: if True success are written down to disk + errors: if True errors are written down to disk + disable_plotting: if True no plots are created + report_as_dict: `True` if the evaluation report should be returned as `dict`. + If `False` the report is returned in a human-readable text format. If `None` + `report_as_dict` is considered as `True` in case an `output_directory` is + given. + + Returns: dictionary with evaluation results + """ + # remove empty response targets + num_examples = len(response_selection_results) + response_selection_results = remove_empty_response_examples( + response_selection_results + ) + + logger.info( + f"Response Selection Evaluation: Only considering those " + f"{len(response_selection_results)} examples that have a defined response out " + f"of {num_examples} examples." + ) + + ( + target_intent_response_keys, + predicted_intent_response_keys, + ) = _targets_predictions_from( + response_selection_results, + "intent_response_key_target", + "intent_response_key_prediction", + ) + + report, precision, f1, accuracy, confusion_matrix, labels = _calculate_report( + output_directory, + target_intent_response_keys, + predicted_intent_response_keys, + report_as_dict, + ) + if output_directory: + _dump_report(output_directory, "response_selection_report.json", report) + + if successes: + successes_filename = "response_selection_successes.json" + if output_directory: + successes_filename = os.path.join(output_directory, successes_filename) + # save classified samples to file for debugging + write_response_successes(response_selection_results, successes_filename) + + response_errors = _response_errors(response_selection_results) + + if errors and output_directory: + errors_filename = "response_selection_errors.json" + errors_filename = os.path.join(output_directory, errors_filename) + _write_errors(response_errors, errors_filename, error_type="response") + + if not disable_plotting: + confusion_matrix_filename = "response_selection_confusion_matrix.png" + if output_directory: + confusion_matrix_filename = os.path.join( + output_directory, confusion_matrix_filename + ) + + plot_utils.plot_confusion_matrix( + confusion_matrix, + classes=labels, + title="Response Selection Confusion Matrix", + output_file=confusion_matrix_filename, + ) + + histogram_filename = "response_selection_histogram.png" + if output_directory: + histogram_filename = os.path.join(output_directory, histogram_filename) + plot_attribute_confidences( + response_selection_results, + histogram_filename, + "intent_response_key_target", + "intent_response_key_prediction", + title="Response Selection Prediction Confidence Distribution", + ) + + predictions = [ + { + "text": res.message, + "intent_response_key_target": res.intent_response_key_target, + "intent_response_key_prediction": res.intent_response_key_prediction, + "confidence": res.confidence, + } + for res in response_selection_results + ] + + return { + "predictions": predictions, + "report": report, + "precision": precision, + "f1_score": f1, + "accuracy": accuracy, + "errors": response_errors, + } + + +def _add_confused_labels_to_report( + report: Dict[Text, Dict[Text, Any]], + confusion_matrix: np.ndarray, + labels: List[Text], + exclude_labels: Optional[List[Text]] = None, +) -> Dict[Text, Dict[Text, Union[Dict, Any]]]: + """Adds a field "confused_with" to the evaluation report. + + The value is a dict of {"false_positive_label": false_positive_count} pairs. + If there are no false positives in the confusion matrix, + the dict will be empty. Typically we include the two most + commonly false positive labels, three in the rare case that + the diagonal element in the confusion matrix is not one of the + three highest values in the row. + + Args: + report: the evaluation report + confusion_matrix: confusion matrix + labels: list of labels + + Returns: updated evaluation report + """ + if exclude_labels is None: + exclude_labels = [] + + # sort confusion matrix by false positives + indices = np.argsort(confusion_matrix, axis=1) + n_candidates = min(3, len(labels)) + + for label in labels: + if label in exclude_labels: + continue + # it is possible to predict intent 'None' + if report.get(label): + report[label]["confused_with"] = {} + + for i, label in enumerate(labels): + if label in exclude_labels: + continue + for j in range(n_candidates): + label_idx = indices[i, -(1 + j)] + false_pos_label = labels[label_idx] + false_positives = int(confusion_matrix[i, label_idx]) + if ( + false_pos_label != label + and false_pos_label not in exclude_labels + and false_positives > 0 + ): + report[label]["confused_with"][false_pos_label] = false_positives + + return report + + +def evaluate_intents( + intent_results: List[IntentEvaluationResult], + output_directory: Optional[Text], + successes: bool, + errors: bool, + disable_plotting: bool, + report_as_dict: Optional[bool] = None, +) -> Dict: # pragma: no cover + """Creates summary statistics for intents. + + Only considers those examples with a set intent. Others are filtered out. + Returns a dictionary of containing the evaluation result. + + Args: + intent_results: intent evaluation results + output_directory: directory to store files to + successes: if True correct predictions are written to disk + errors: if True incorrect predictions are written to disk + disable_plotting: if True no plots are created + report_as_dict: `True` if the evaluation report should be returned as `dict`. + If `False` the report is returned in a human-readable text format. If `None` + `report_as_dict` is considered as `True` in case an `output_directory` is + given. + + Returns: dictionary with evaluation results + """ + # remove empty intent targets + num_examples = len(intent_results) + intent_results = remove_empty_intent_examples(intent_results) + + logger.info( + f"Intent Evaluation: Only considering those {len(intent_results)} examples " + f"that have a defined intent out of {num_examples} examples." + ) + + target_intents, predicted_intents = _targets_predictions_from( + intent_results, "intent_target", "intent_prediction" + ) + + report, precision, f1, accuracy, confusion_matrix, labels = _calculate_report( + output_directory, target_intents, predicted_intents, report_as_dict + ) + if output_directory: + _dump_report(output_directory, "intent_report.json", report) + + if successes and output_directory: + successes_filename = os.path.join(output_directory, "intent_successes.json") + # save classified samples to file for debugging + write_intent_successes(intent_results, successes_filename) + + intent_errors = _get_intent_errors(intent_results) + if errors and output_directory: + errors_filename = os.path.join(output_directory, "intent_errors.json") + _write_errors(intent_errors, errors_filename, "intent") + + if not disable_plotting: + confusion_matrix_filename = "intent_confusion_matrix.png" + if output_directory: + confusion_matrix_filename = os.path.join( + output_directory, confusion_matrix_filename + ) + plot_utils.plot_confusion_matrix( + confusion_matrix, + classes=labels, + title="Intent Confusion matrix", + output_file=confusion_matrix_filename, + ) + + histogram_filename = "intent_histogram.png" + if output_directory: + histogram_filename = os.path.join(output_directory, histogram_filename) + plot_attribute_confidences( + intent_results, + histogram_filename, + "intent_target", + "intent_prediction", + title="Intent Prediction Confidence Distribution", + ) + + predictions = [ + { + "text": res.message, + "intent": res.intent_target, + "predicted": res.intent_prediction, + "confidence": res.confidence, + } + for res in intent_results + ] + + return { + "predictions": predictions, + "report": report, + "precision": precision, + "f1_score": f1, + "accuracy": accuracy, + "errors": intent_errors, + } + + +def _calculate_report( + output_directory: Optional[Text], + targets: Iterable[Any], + predictions: Iterable[Any], + report_as_dict: Optional[bool] = None, + exclude_label: Optional[Text] = None, +) -> Tuple[Union[Text, Dict], float, float, float, np.ndarray, List[Text]]: + from rasa.model_testing import get_evaluation_metrics + import sklearn.metrics + import sklearn.utils.multiclass + + confusion_matrix = sklearn.metrics.confusion_matrix(targets, predictions) + labels = sklearn.utils.multiclass.unique_labels(targets, predictions) + + if report_as_dict is None: + report_as_dict = bool(output_directory) + + report, precision, f1, accuracy = get_evaluation_metrics( + targets, predictions, output_dict=report_as_dict, exclude_label=exclude_label + ) + + if report_as_dict: + report = _add_confused_labels_to_report( # type: ignore[assignment] + report, + confusion_matrix, + labels, + exclude_labels=[exclude_label] if exclude_label else [], + ) + elif not output_directory: + log_evaluation_table(report, precision, f1, accuracy) + + return report, precision, f1, accuracy, confusion_matrix, labels + + +def _dump_report(output_directory: Text, filename: Text, report: Dict) -> None: + report_filename = os.path.join(output_directory, filename) + rasa.shared.utils.io.dump_obj_as_json_to_file(report_filename, report) + logger.info(f"Classification report saved to {report_filename}.") + + +def merge_labels( + aligned_predictions: List[Dict], extractor: Optional[Text] = None +) -> List[Text]: + """Concatenates all labels of the aligned predictions. + + Takes the aligned prediction labels which are grouped for each message + and concatenates them. + + Args: + aligned_predictions: aligned predictions + extractor: entity extractor name + + Returns: + Concatenated predictions + """ + if extractor: + label_lists = [ap["extractor_labels"][extractor] for ap in aligned_predictions] + else: + label_lists = [ap["target_labels"] for ap in aligned_predictions] + + return list(itertools.chain(*label_lists)) + + +def merge_confidences( + aligned_predictions: List[Dict], extractor: Optional[Text] = None +) -> List[float]: + """Concatenates all confidences of the aligned predictions. + + Takes the aligned prediction confidences which are grouped for each message + and concatenates them. + + Args: + aligned_predictions: aligned predictions + extractor: entity extractor name + + Returns: + Concatenated confidences + """ + label_lists = [ap["confidences"][extractor] for ap in aligned_predictions] + return list(itertools.chain(*label_lists)) + + +def substitute_labels(labels: List[Text], old: Text, new: Text) -> List[Text]: + """Replaces label names in a list of labels. + + Args: + labels: list of labels + old: old label name that should be replaced + new: new label name + + Returns: updated labels + """ + return [new if label == old else label for label in labels] + + +def collect_incorrect_entity_predictions( + entity_results: List[EntityEvaluationResult], + merged_predictions: List[Text], + merged_targets: List[Text], +) -> List["EntityPrediction"]: + """Get incorrect entity predictions. + + Args: + entity_results: entity evaluation results + merged_predictions: list of predicted entity labels + merged_targets: list of true entity labels + + Returns: list of incorrect predictions + """ + errors = [] + offset = 0 + for entity_result in entity_results: + for i in range(offset, offset + len(entity_result.tokens)): + if merged_targets[i] != merged_predictions[i]: + prediction: EntityPrediction = { + "text": entity_result.message, + "entities": entity_result.entity_targets, + "predicted_entities": entity_result.entity_predictions, + } + errors.append(prediction) + break + offset += len(entity_result.tokens) + return errors + + +def write_successful_entity_predictions( + entity_results: List[EntityEvaluationResult], + merged_targets: List[Text], + merged_predictions: List[Text], + successes_filename: Text, +) -> None: + """Write correct entity predictions to a file. + + Args: + entity_results: response selection evaluation result + merged_predictions: list of predicted entity labels + merged_targets: list of true entity labels + successes_filename: filename of file to save correct predictions to + """ + successes = collect_successful_entity_predictions( + entity_results, merged_predictions, merged_targets + ) + + if successes: + rasa.shared.utils.io.dump_obj_as_json_to_file(successes_filename, successes) + logger.info(f"Successful entity predictions saved to {successes_filename}.") + structlogger.debug("test.write.entities", successes=copy.deepcopy(successes)) + else: + logger.info("No successful entity prediction found.") + + +def collect_successful_entity_predictions( + entity_results: List[EntityEvaluationResult], + merged_predictions: List[Text], + merged_targets: List[Text], +) -> List["EntityPrediction"]: + """Get correct entity predictions. + + Args: + entity_results: entity evaluation results + merged_predictions: list of predicted entity labels + merged_targets: list of true entity labels + + Returns: list of correct predictions + """ + successes = [] + offset = 0 + for entity_result in entity_results: + for i in range(offset, offset + len(entity_result.tokens)): + if ( + merged_targets[i] == merged_predictions[i] + and merged_targets[i] != NO_ENTITY + ): + prediction: EntityPrediction = { + "text": entity_result.message, + "entities": entity_result.entity_targets, + "predicted_entities": entity_result.entity_predictions, + } + successes.append(prediction) + break + offset += len(entity_result.tokens) + return successes + + +def evaluate_entities( + entity_results: List[EntityEvaluationResult], + extractors: Set[Text], + output_directory: Optional[Text], + successes: bool, + errors: bool, + disable_plotting: bool, + report_as_dict: Optional[bool] = None, +) -> Dict: # pragma: no cover + """Creates summary statistics for each entity extractor. + + Logs precision, recall, and F1 per entity type for each extractor. + + Args: + entity_results: entity evaluation results + extractors: entity extractors to consider + output_directory: directory to store files to + successes: if True correct predictions are written to disk + errors: if True incorrect predictions are written to disk + disable_plotting: if True no plots are created + report_as_dict: `True` if the evaluation report should be returned as `dict`. + If `False` the report is returned in a human-readable text format. If `None` + `report_as_dict` is considered as `True` in case an `output_directory` is + given. + + Returns: dictionary with evaluation results + """ + aligned_predictions = align_all_entity_predictions(entity_results, extractors) + merged_targets = merge_labels(aligned_predictions) + merged_targets = substitute_labels(merged_targets, NO_ENTITY_TAG, NO_ENTITY) + + result = {} + + for extractor in extractors: + merged_predictions = merge_labels(aligned_predictions, extractor) + merged_predictions = substitute_labels( + merged_predictions, NO_ENTITY_TAG, NO_ENTITY + ) + + cleaned_targets = plugin_manager().hook.clean_entity_targets_for_evaluation( + merged_targets=merged_targets, extractor=extractor + ) + if len(cleaned_targets) > 0: + cleaned_targets = cleaned_targets[0] + else: + cleaned_targets = merged_targets + + logger.info(f"Evaluation for entity extractor: {extractor} ") + + report, precision, f1, accuracy, confusion_matrix, labels = _calculate_report( + output_directory, + cleaned_targets, + merged_predictions, + report_as_dict, + exclude_label=NO_ENTITY, + ) + if output_directory: + + _dump_report(output_directory, f"{extractor}_report.json", report) + + if successes: + successes_filename = f"{extractor}_successes.json" + if output_directory: + successes_filename = os.path.join(output_directory, successes_filename) + # save classified samples to file for debugging + write_successful_entity_predictions( + entity_results, cleaned_targets, merged_predictions, successes_filename + ) + + entity_errors = collect_incorrect_entity_predictions( + entity_results, merged_predictions, cleaned_targets + ) + if errors and output_directory: + errors_filename = os.path.join(output_directory, f"{extractor}_errors.json") + + _write_errors(entity_errors, errors_filename, "entity") + + if not disable_plotting: + confusion_matrix_filename = f"{extractor}_confusion_matrix.png" + if output_directory: + confusion_matrix_filename = os.path.join( + output_directory, confusion_matrix_filename + ) + plot_utils.plot_confusion_matrix( + confusion_matrix, + classes=labels, + title="Entity Confusion matrix", + output_file=confusion_matrix_filename, + ) + + if extractor in EXTRACTORS_WITH_CONFIDENCES: + merged_confidences = merge_confidences(aligned_predictions, extractor) + histogram_filename = f"{extractor}_histogram.png" + if output_directory: + histogram_filename = os.path.join( + output_directory, histogram_filename + ) + plot_entity_confidences( + cleaned_targets, + merged_predictions, + merged_confidences, + title="Entity Prediction Confidence Distribution", + hist_filename=histogram_filename, + ) + + result[extractor] = { + "report": report, + "precision": precision, + "f1_score": f1, + "accuracy": accuracy, + "errors": entity_errors, + } + + return result + + +def is_token_within_entity(token: Token, entity: Dict) -> bool: + """Checks if a token is within the boundaries of an entity.""" + return determine_intersection(token, entity) == len(token.text) + + +def does_token_cross_borders(token: Token, entity: Dict) -> bool: + """Checks if a token crosses the boundaries of an entity.""" + num_intersect = determine_intersection(token, entity) + return 0 < num_intersect < len(token.text) + + +def determine_intersection(token: Token, entity: Dict) -> int: + """Calculates how many characters a given token and entity share.""" + pos_token = set(range(token.start, token.end)) + pos_entity = set(range(entity["start"], entity["end"])) + return len(pos_token.intersection(pos_entity)) + + +def do_entities_overlap(entities: List[Dict]) -> bool: + """Checks if entities overlap. + + I.e. cross each others start and end boundaries. + + Args: + entities: list of entities + + Returns: true if entities overlap, false otherwise. + """ + sorted_entities = sorted(entities, key=lambda e: e["start"]) + for i in range(len(sorted_entities) - 1): + curr_ent = sorted_entities[i] + next_ent = sorted_entities[i + 1] + if ( + next_ent["start"] < curr_ent["end"] + and next_ent["entity"] != curr_ent["entity"] + ): + structlogger.warning( + "test.overlaping.entities", + current_entity=copy.deepcopy(curr_ent), + next_entity=copy.deepcopy(next_ent), + ) + return True + + return False + + +def find_intersecting_entities(token: Token, entities: List[Dict]) -> List[Dict]: + """Finds the entities that intersect with a token. + + Args: + token: a single token + entities: entities found by a single extractor + + Returns: list of entities + """ + candidates = [] + for e in entities: + if is_token_within_entity(token, e): + candidates.append(e) + elif does_token_cross_borders(token, e): + candidates.append(e) + structlogger.debug( + "test.intersecting.entities", + token_text=copy.deepcopy(token.text), + token_start=token.start, + token_end=token.end, + entity=copy.deepcopy(e), + ) + return candidates + + +def pick_best_entity_fit( + token: Token, candidates: List[Dict[Text, Any]] +) -> Optional[Dict[Text, Any]]: + """Determines the best fitting entity given intersecting entities. + + Args: + token: a single token + candidates: entities found by a single extractor + attribute_key: the attribute key of interest + + Returns: + the value of the attribute key of the best fitting entity + """ + if len(candidates) == 0: + return None + elif len(candidates) == 1: + return candidates[0] + else: + best_fit = np.argmax([determine_intersection(token, c) for c in candidates]) + return candidates[int(best_fit)] + + +def determine_token_labels( + token: Token, + entities: List[Dict], + extractors: Optional[Set[Text]] = None, + attribute_key: Text = ENTITY_ATTRIBUTE_TYPE, +) -> Text: + """Determines the token label for the provided attribute key given entities that do + not overlap. + + Args: + token: a single token + entities: entities found by a single extractor + extractors: list of extractors + attribute_key: the attribute key for which the entity type should be returned + Returns: + entity type + """ + entity = determine_entity_for_token(token, entities, extractors) + + if entity is None: + return NO_ENTITY_TAG + + label = entity.get(attribute_key) + + if not label: + return NO_ENTITY_TAG + + return label + + +def determine_entity_for_token( + token: Token, + entities: List[Dict[Text, Any]], + extractors: Optional[Set[Text]] = None, +) -> Optional[Dict[Text, Any]]: + """Determines the best fitting entity for the given token, given entities that do + not overlap. + + Args: + token: a single token + entities: entities found by a single extractor + extractors: list of extractors + + Returns: + entity type + """ + if entities is None or len(entities) == 0: + return None + if do_any_extractors_not_support_overlap(extractors) and do_entities_overlap( + entities + ): + raise ValueError("The possible entities should not overlap.") + + candidates = find_intersecting_entities(token, entities) + return pick_best_entity_fit(token, candidates) + + +def do_any_extractors_not_support_overlap(extractors: Optional[Set[Text]]) -> bool: + """Checks if any extractor does not support overlapping entities. + + Args: + Names of the entitiy extractors + + Returns: + `True` if and only if CRFEntityExtractor or DIETClassifier is in `extractors` + """ + if extractors is None: + return False + + from rasa.nlu.extractors.crf_entity_extractor import CRFEntityExtractor + from rasa.nlu.classifiers.diet_classifier import DIETClassifier + + return not extractors.isdisjoint( + {CRFEntityExtractor.__name__, DIETClassifier.__name__} + ) + + +def align_entity_predictions( + result: EntityEvaluationResult, extractors: Set[Text] +) -> Dict: + """Aligns entity predictions to the message tokens. + + Determines for every token the true label based on the + prediction targets and the label assigned by each + single extractor. + + Args: + result: entity evaluation result + extractors: the entity extractors that should be considered + + Returns: dictionary containing the true token labels and token labels + from the extractors + """ + true_token_labels = [] + entities_by_extractors: Dict[Text, List] = { + extractor: [] for extractor in extractors + } + for p in result.entity_predictions: + entities_by_extractors[p[EXTRACTOR]].append(p) + extractor_labels: Dict[Text, List] = {extractor: [] for extractor in extractors} + extractor_confidences: Dict[Text, List] = { + extractor: [] for extractor in extractors + } + for t in result.tokens: + true_token_labels.append(_concat_entity_labels(t, result.entity_targets)) + for extractor, entities in entities_by_extractors.items(): + extracted_labels = _concat_entity_labels(t, entities, {extractor}) + extracted_confidences = _get_entity_confidences(t, entities, {extractor}) + extractor_labels[extractor].append(extracted_labels) + extractor_confidences[extractor].append(extracted_confidences) + + return { + "target_labels": true_token_labels, + "extractor_labels": extractor_labels, + "confidences": extractor_confidences, + } + + +def _concat_entity_labels( + token: Token, entities: List[Dict], extractors: Optional[Set[Text]] = None +) -> Text: + """Concatenate labels for entity type, role, and group for evaluation. + + In order to calculate metrics also for entity type, role, and group we need to + concatenate their labels. For example, 'location.destination'. This allows + us to report metrics for every combination of entity type, role, and group. + + Args: + token: the token we are looking at + entities: the available entities + extractors: the extractor of interest + + Returns: + the entity label of the provided token + """ + entity_label = determine_token_labels( + token, entities, extractors, ENTITY_ATTRIBUTE_TYPE + ) + group_label = determine_token_labels( + token, entities, extractors, ENTITY_ATTRIBUTE_GROUP + ) + role_label = determine_token_labels( + token, entities, extractors, ENTITY_ATTRIBUTE_ROLE + ) + + if entity_label == role_label == group_label == NO_ENTITY_TAG: + return NO_ENTITY_TAG + + labels = [entity_label, group_label, role_label] + labels = [label for label in labels if label != NO_ENTITY_TAG] + + return ".".join(labels) + + +def _get_entity_confidences( + token: Token, entities: List[Dict], extractors: Optional[Set[Text]] = None +) -> float: + """Get the confidence value of the best fitting entity. + + If multiple confidence values are present, e.g. for type, role, group, we + pick the lowest confidence value. + + Args: + token: the token we are looking at + entities: the available entities + extractors: the extractor of interest + + Returns: + the confidence value + """ + entity = determine_entity_for_token(token, entities, extractors) + + if entity is None: + return 0.0 + + if entity.get("extractor") not in EXTRACTORS_WITH_CONFIDENCES: + return 0.0 + + conf_type = entity.get(ENTITY_ATTRIBUTE_CONFIDENCE_TYPE) or 1.0 + conf_role = entity.get(ENTITY_ATTRIBUTE_CONFIDENCE_ROLE) or 1.0 + conf_group = entity.get(ENTITY_ATTRIBUTE_CONFIDENCE_GROUP) or 1.0 + + return min(conf_type, conf_role, conf_group) + + +def align_all_entity_predictions( + entity_results: List[EntityEvaluationResult], extractors: Set[Text] +) -> List[Dict]: + """Aligns entity predictions to the message tokens for the whole dataset + using align_entity_predictions. + + Args: + entity_results: list of entity prediction results + extractors: the entity extractors that should be considered + + Returns: list of dictionaries containing the true token labels and token + labels from the extractors + """ + aligned_predictions = [] + for result in entity_results: + aligned_predictions.append(align_entity_predictions(result, extractors)) + + return aligned_predictions + + +async def get_eval_data( + processor: MessageProcessor, test_data: TrainingData +) -> Tuple[ + List[IntentEvaluationResult], + List[ResponseSelectionEvaluationResult], + List[EntityEvaluationResult], +]: + """Runs the model for the test set and extracts targets and predictions. + + Returns intent results (intent targets and predictions, the original + messages and the confidences of the predictions), response results ( + response targets and predictions) as well as entity results + (entity_targets, entity_predictions, and tokens). + + Args: + processor: the processor + test_data: test data + + Returns: intent, response, and entity evaluation results + """ + logger.info("Running model for predictions:") + + intent_results, entity_results, response_selection_results = [], [], [] + + response_labels = { + e.get(INTENT_RESPONSE_KEY) + for e in test_data.intent_examples + if e.get(INTENT_RESPONSE_KEY) is not None + } + intent_labels = {e.get(INTENT) for e in test_data.intent_examples} + should_eval_intents = len(intent_labels) >= 2 + should_eval_response_selection = len(response_labels) >= 2 + should_eval_entities = len(test_data.entity_examples) > 0 + + for example in tqdm(test_data.nlu_examples): + tracker = plugin_manager().hook.mock_tracker_for_evaluation( + example=example, model_metadata=processor.model_metadata + ) + # if the user overwrites the default implementation take the last tracker + if isinstance(tracker, list): + if len(tracker) > 0: + tracker = tracker[-1] + else: + tracker = None + result = await processor.parse_message( + UserMessage(text=example.get(TEXT)), + tracker=tracker, + only_output_properties=False, + ) + _remove_entities_of_extractors(result, PRETRAINED_EXTRACTORS) + if should_eval_intents: + if fallback_classifier.is_fallback_classifier_prediction(result): + # Revert fallback prediction to not shadow + # the wrongly predicted intent + # during the test phase. + result = fallback_classifier.undo_fallback_prediction(result) + intent_prediction = result.get(INTENT, {}) + intent_results.append( + IntentEvaluationResult( + example.get(INTENT, ""), + intent_prediction.get(INTENT_NAME_KEY), + result.get(TEXT), + intent_prediction.get("confidence"), + ) + ) + + if should_eval_response_selection: + # including all examples here. Empty response examples are filtered at the + # time of metric calculation + intent_target = example.get(INTENT, "") + selector_properties = result.get(RESPONSE_SELECTOR_PROPERTY_NAME, {}) + response_selector_retrieval_intents = selector_properties.get( + RESPONSE_SELECTOR_RETRIEVAL_INTENTS, set() + ) + if ( + intent_target in response_selector_retrieval_intents + and intent_target in selector_properties + ): + response_prediction_key = intent_target + else: + response_prediction_key = RESPONSE_SELECTOR_DEFAULT_INTENT + + response_prediction = selector_properties.get( + response_prediction_key, {} + ).get(RESPONSE_SELECTOR_PREDICTION_KEY, {}) + + intent_response_key_target = example.get(INTENT_RESPONSE_KEY, "") + + response_selection_results.append( + ResponseSelectionEvaluationResult( + intent_response_key_target, + response_prediction.get(INTENT_RESPONSE_KEY), + result.get(TEXT), + response_prediction.get(PREDICTED_CONFIDENCE_KEY), + ) + ) + + if should_eval_entities: + entity_results.append( + EntityEvaluationResult( + example.get(ENTITIES, []), + result.get(ENTITIES, []), + result.get(TOKENS_NAMES[TEXT], []), + result.get(TEXT), + ) + ) + + return intent_results, response_selection_results, entity_results + + +def _get_active_entity_extractors( + entity_results: List[EntityEvaluationResult], +) -> Set[Text]: + """Finds the names of entity extractors from the EntityEvaluationResults.""" + extractors: Set[Text] = set() + for result in entity_results: + for prediction in result.entity_predictions: + if EXTRACTOR in prediction: + extractors.add(prediction[EXTRACTOR]) + return extractors + + +def _remove_entities_of_extractors( + nlu_parse_result: Dict[Text, Any], extractor_names: Set[Text] +) -> None: + """Removes the entities annotated by the given extractor names.""" + entities = nlu_parse_result.get(ENTITIES) + if not entities: + return + filtered_entities = [e for e in entities if e.get(EXTRACTOR) not in extractor_names] + nlu_parse_result[ENTITIES] = filtered_entities + + +async def run_evaluation( + data_path: Text, + processor: MessageProcessor, + output_directory: Optional[Text] = None, + successes: bool = False, + errors: bool = False, + disable_plotting: bool = False, + report_as_dict: Optional[bool] = None, + domain_path: Optional[Text] = None, +) -> Dict: # pragma: no cover + """Evaluate intent classification, response selection and entity extraction. + + Args: + data_path: path to the test data + processor: the processor used to process and predict + output_directory: path to folder where all output will be stored + successes: if true successful predictions are written to a file + errors: if true incorrect predictions are written to a file + disable_plotting: if true confusion matrix and histogram will not be rendered + report_as_dict: `True` if the evaluation report should be returned as `dict`. + If `False` the report is returned in a human-readable text format. If `None` + `report_as_dict` is considered as `True` in case an `output_directory` is + given. + domain_path: Path to the domain file(s). + + Returns: dictionary containing evaluation results + """ + import rasa.shared.nlu.training_data.loading + from rasa.shared.constants import DEFAULT_DOMAIN_PATH + + test_data_importer = TrainingDataImporter.load_from_dict( + training_data_paths=[data_path], + domain_path=domain_path if domain_path else DEFAULT_DOMAIN_PATH, + ) + test_data = test_data_importer.get_nlu_data() + + result: Dict[Text, Optional[Dict]] = { + "intent_evaluation": None, + "entity_evaluation": None, + "response_selection_evaluation": None, + } + + if output_directory: + rasa.shared.utils.io.create_directory(output_directory) + + (intent_results, response_selection_results, entity_results) = await get_eval_data( + processor, test_data + ) + + if intent_results: + logger.info("Intent evaluation results:") + result["intent_evaluation"] = evaluate_intents( + intent_results, + output_directory, + successes, + errors, + disable_plotting, + report_as_dict=report_as_dict, + ) + + if response_selection_results: + logger.info("Response selection evaluation results:") + result["response_selection_evaluation"] = evaluate_response_selections( + response_selection_results, + output_directory, + successes, + errors, + disable_plotting, + report_as_dict=report_as_dict, + ) + + if any(entity_results): + logger.info("Entity evaluation results:") + extractors = _get_active_entity_extractors(entity_results) + result["entity_evaluation"] = evaluate_entities( + entity_results, + extractors, + output_directory, + successes, + errors, + disable_plotting, + report_as_dict=report_as_dict, + ) + + telemetry.track_nlu_model_test(test_data) + + return result + + +def generate_folds( + n: int, training_data: TrainingData +) -> Iterator[Tuple[TrainingData, TrainingData]]: + """Generates n cross validation folds for given training data.""" + from sklearn.model_selection import StratifiedKFold + + skf = StratifiedKFold(n_splits=n, shuffle=True) + x = training_data.intent_examples + + # Get labels as they appear in the training data because we want a + # stratified split on all intents(including retrieval intents if they exist) + y = [example.get_full_intent() for example in x] + for i_fold, (train_index, test_index) in enumerate(skf.split(x, y)): + logger.debug(f"Fold: {i_fold}") + train = [x[i] for i in train_index] + test = [x[i] for i in test_index] + yield ( + TrainingData( + training_examples=train, + entity_synonyms=training_data.entity_synonyms, + regex_features=training_data.regex_features, + lookup_tables=training_data.lookup_tables, + responses=training_data.responses, + ), + TrainingData( + training_examples=test, + entity_synonyms=training_data.entity_synonyms, + regex_features=training_data.regex_features, + lookup_tables=training_data.lookup_tables, + responses=training_data.responses, + ), + ) + + +async def combine_result( + intent_metrics: IntentMetrics, + entity_metrics: EntityMetrics, + response_selection_metrics: ResponseSelectionMetrics, + processor: MessageProcessor, + data: TrainingData, + intent_results: Optional[List[IntentEvaluationResult]] = None, + entity_results: Optional[List[EntityEvaluationResult]] = None, + response_selection_results: Optional[ + List[ResponseSelectionEvaluationResult] + ] = None, +) -> Tuple[IntentMetrics, EntityMetrics, ResponseSelectionMetrics]: + """Collects intent, response selection and entity metrics for cross validation + folds. + + If `intent_results`, `response_selection_results` or `entity_results` is provided + as a list, prediction results are also collected. + + Args: + intent_metrics: intent metrics + entity_metrics: entity metrics + response_selection_metrics: response selection metrics + processor: the processor + data: training data + intent_results: intent evaluation results + entity_results: entity evaluation results + response_selection_results: reponse selection evaluation results + + Returns: intent, entity, and response selection metrics + """ + ( + intent_current_metrics, + entity_current_metrics, + response_selection_current_metrics, + current_intent_results, + current_entity_results, + current_response_selection_results, + ) = await compute_metrics(processor, data) + + if intent_results is not None: + intent_results += current_intent_results + + if entity_results is not None: + entity_results += current_entity_results + + if response_selection_results is not None: + response_selection_results += current_response_selection_results + + for k, v in intent_current_metrics.items(): + intent_metrics[k] = v + intent_metrics[k] + + for k, v in response_selection_current_metrics.items(): + response_selection_metrics[k] = v + response_selection_metrics[k] + + for extractor, extractor_metric in entity_current_metrics.items(): + entity_metrics[extractor] = { + k: v + entity_metrics[extractor][k] for k, v in extractor_metric.items() + } + + return intent_metrics, entity_metrics, response_selection_metrics + + +def _contains_entity_labels(entity_results: List[EntityEvaluationResult]) -> bool: + + for result in entity_results: + if result.entity_targets or result.entity_predictions: + return True + return False + + +async def cross_validate( + data: TrainingData, + n_folds: int, + nlu_config: Union[Text, Dict], + output: Optional[Text] = None, + successes: bool = False, + errors: bool = False, + disable_plotting: bool = False, + report_as_dict: Optional[bool] = None, +) -> Tuple[CVEvaluationResult, CVEvaluationResult, CVEvaluationResult]: + """Stratified cross validation on data. + + Args: + data: Training Data + n_folds: integer, number of cv folds + nlu_config: nlu config file + output: path to folder where reports are stored + successes: if true successful predictions are written to a file + errors: if true incorrect predictions are written to a file + disable_plotting: if true no confusion matrix and historgram plates are created + report_as_dict: `True` if the evaluation report should be returned as `dict`. + If `False` the report is returned in a human-readable text format. If `None` + `report_as_dict` is considered as `True` in case an `output_directory` is + given. + + Returns: + dictionary with key, list structure, where each entry in list + corresponds to the relevant result for one fold + """ + import rasa.model_training + + with TempDirectoryPath(get_temp_dir_name()) as temp_dir: + tmp_path = Path(temp_dir) + + if isinstance(nlu_config, Dict): + config_path = tmp_path / "config.yml" + rasa.shared.utils.io.write_yaml(nlu_config, config_path) + nlu_config = str(config_path) + + if output: + rasa.shared.utils.io.create_directory(output) + + intent_train_metrics: IntentMetrics = defaultdict(list) + intent_test_metrics: IntentMetrics = defaultdict(list) + entity_train_metrics: EntityMetrics = defaultdict(lambda: defaultdict(list)) + entity_test_metrics: EntityMetrics = defaultdict(lambda: defaultdict(list)) + response_selection_train_metrics: ResponseSelectionMetrics = defaultdict(list) + response_selection_test_metrics: ResponseSelectionMetrics = defaultdict(list) + + intent_test_results: List[IntentEvaluationResult] = [] + entity_test_results: List[EntityEvaluationResult] = [] + response_selection_test_results: List[ResponseSelectionEvaluationResult] = [] + + for train, test in generate_folds(n_folds, data): + training_data_file = tmp_path / "training_data.yml" + RasaYAMLWriter().dump(training_data_file, train) + + model_file = rasa.model_training.train_nlu( + nlu_config, str(training_data_file), str(tmp_path) + ) + + processor = Agent.load(model_file).processor + + # calculate train accuracy + await combine_result( + intent_train_metrics, + entity_train_metrics, + response_selection_train_metrics, + processor, + train, + ) + # calculate test accuracy + await combine_result( + intent_test_metrics, + entity_test_metrics, + response_selection_test_metrics, + processor, + test, + intent_test_results, + entity_test_results, + response_selection_test_results, + ) + + intent_evaluation = {} + if intent_test_results: + logger.info("Accumulated test folds intent evaluation results:") + intent_evaluation = evaluate_intents( + intent_test_results, + output, + successes, + errors, + disable_plotting, + report_as_dict=report_as_dict, + ) + + entity_evaluation = {} + if entity_test_results: + logger.info("Accumulated test folds entity evaluation results:") + extractors = _get_active_entity_extractors(entity_test_results) + entity_evaluation = evaluate_entities( + entity_test_results, + extractors, + output, + successes, + errors, + disable_plotting, + report_as_dict=report_as_dict, + ) + + responses_evaluation = {} + if response_selection_test_results: + logger.info("Accumulated test folds response selection evaluation results:") + responses_evaluation = evaluate_response_selections( + response_selection_test_results, + output, + successes, + errors, + disable_plotting, + report_as_dict=report_as_dict, + ) + + return ( + CVEvaluationResult( + dict(intent_train_metrics), dict(intent_test_metrics), intent_evaluation + ), + CVEvaluationResult( + dict(entity_train_metrics), dict(entity_test_metrics), entity_evaluation + ), + CVEvaluationResult( + dict(response_selection_train_metrics), + dict(response_selection_test_metrics), + responses_evaluation, + ), + ) + + +def _targets_predictions_from( + results: Union[ + List[IntentEvaluationResult], List[ResponseSelectionEvaluationResult] + ], + target_key: Text, + prediction_key: Text, +) -> Iterator[Iterable[Optional[Text]]]: + return zip(*[(getattr(r, target_key), getattr(r, prediction_key)) for r in results]) + + +async def compute_metrics( + processor: MessageProcessor, training_data: TrainingData +) -> Tuple[ + IntentMetrics, + EntityMetrics, + ResponseSelectionMetrics, + List[IntentEvaluationResult], + List[EntityEvaluationResult], + List[ResponseSelectionEvaluationResult], +]: + """Computes metrics for intent classification, response selection and entity + extraction. + + Args: + processor: the processor + training_data: training data + + Returns: intent, response selection and entity metrics, and prediction results. + """ + intent_results, response_selection_results, entity_results = await get_eval_data( + processor, training_data + ) + + intent_results = remove_empty_intent_examples(intent_results) + + response_selection_results = remove_empty_response_examples( + response_selection_results + ) + + intent_metrics: IntentMetrics = {} + if intent_results: + intent_metrics = _compute_metrics( + intent_results, "intent_target", "intent_prediction" + ) + + entity_metrics = {} + if entity_results: + entity_metrics = _compute_entity_metrics(entity_results) + + response_selection_metrics: ResponseSelectionMetrics = {} + if response_selection_results: + response_selection_metrics = _compute_metrics( + response_selection_results, + "intent_response_key_target", + "intent_response_key_prediction", + ) + + return ( + intent_metrics, + entity_metrics, + response_selection_metrics, + intent_results, + entity_results, + response_selection_results, + ) + + +async def compare_nlu( + configs: List[Text], + data: TrainingData, + exclusion_percentages: List[int], + f_score_results: Dict[Text, List[List[float]]], + model_names: List[Text], + output: Text, + runs: int, +) -> List[int]: + """Trains and compares multiple NLU models. + For each run and exclusion percentage a model per config file is trained. + Thereby, the model is trained only on the current percentage of training data. + Afterwards, the model is tested on the complete test data of that run. + All results are stored in the provided output directory. + + Args: + configs: config files needed for training + data: training data + exclusion_percentages: percentages of training data to exclude during comparison + f_score_results: dictionary of model name to f-score results per run + model_names: names of the models to train + output: the output directory + runs: number of comparison runs + + Returns: training examples per run + """ + import rasa.model_training + + training_examples_per_run = [] + + for run in range(runs): + + logger.info("Beginning comparison run {}/{}".format(run + 1, runs)) + + run_path = os.path.join(output, "run_{}".format(run + 1)) + io_utils.create_path(run_path) + + test_path = os.path.join(run_path, TEST_DATA_FILE) + io_utils.create_path(test_path) + + train, test = data.train_test_split() + rasa.shared.utils.io.write_text_file(test.nlu_as_yaml(), test_path) + + for percentage in exclusion_percentages: + percent_string = f"{percentage}%_exclusion" + + _, train_included = train.train_test_split(percentage / 100) + # only count for the first run and ignore the others + if run == 0: + training_examples_per_run.append(len(train_included.nlu_examples)) + + model_output_path = os.path.join(run_path, percent_string) + train_split_path = os.path.join(model_output_path, "train") + train_nlu_split_path = os.path.join(train_split_path, TRAIN_DATA_FILE) + train_nlg_split_path = os.path.join(train_split_path, NLG_DATA_FILE) + io_utils.create_path(train_nlu_split_path) + rasa.shared.utils.io.write_text_file( + train_included.nlu_as_yaml(), train_nlu_split_path + ) + rasa.shared.utils.io.write_text_file( + train_included.nlg_as_yaml(), train_nlg_split_path + ) + + for nlu_config, model_name in zip(configs, model_names): + logger.info( + "Evaluating configuration '{}' with {} training data.".format( + model_name, percent_string + ) + ) + + try: + model_path = rasa.model_training.train_nlu( + nlu_config, + train_split_path, + model_output_path, + fixed_model_name=model_name, + ) + except Exception as e: # skipcq: PYL-W0703 + # general exception catching needed to continue evaluating other + # model configurations + logger.warning(f"Training model '{model_name}' failed. Error: {e}") + f_score_results[model_name][run].append(0.0) + continue + + output_path = os.path.join(model_output_path, f"{model_name}_report") + processor = Agent.load(model_path=model_path).processor + result = await run_evaluation( + test_path, processor, output_directory=output_path, errors=True + ) + + f1 = result["intent_evaluation"]["f1_score"] + f_score_results[model_name][run].append(f1) + + return training_examples_per_run + + +def _compute_metrics( + results: Union[ + List[IntentEvaluationResult], List[ResponseSelectionEvaluationResult] + ], + target_key: Text, + prediction_key: Text, +) -> Union[IntentMetrics, ResponseSelectionMetrics]: + """Computes evaluation metrics for a given corpus and returns the results. + + Args: + results: evaluation results + target_key: target key name + prediction_key: prediction key name + + Returns: metrics + """ + from rasa.model_testing import get_evaluation_metrics + + # compute fold metrics + targets, predictions = _targets_predictions_from( + results, target_key, prediction_key + ) + _, precision, f1, accuracy = get_evaluation_metrics(targets, predictions) + + return {"Accuracy": [accuracy], "F1-score": [f1], "Precision": [precision]} + + +def _compute_entity_metrics( + entity_results: List[EntityEvaluationResult], +) -> EntityMetrics: + """Computes entity evaluation metrics and returns the results. + + Args: + entity_results: entity evaluation results + Returns: entity metrics + """ + from rasa.model_testing import get_evaluation_metrics + + entity_metric_results: EntityMetrics = defaultdict(lambda: defaultdict(list)) + extractors = _get_active_entity_extractors(entity_results) + + if not extractors: + return entity_metric_results + + aligned_predictions = align_all_entity_predictions(entity_results, extractors) + + merged_targets = merge_labels(aligned_predictions) + merged_targets = substitute_labels(merged_targets, NO_ENTITY_TAG, NO_ENTITY) + + for extractor in extractors: + merged_predictions = merge_labels(aligned_predictions, extractor) + merged_predictions = substitute_labels( + merged_predictions, NO_ENTITY_TAG, NO_ENTITY + ) + _, precision, f1, accuracy = get_evaluation_metrics( + merged_targets, merged_predictions, exclude_label=NO_ENTITY + ) + entity_metric_results[extractor]["Accuracy"].append(accuracy) + entity_metric_results[extractor]["F1-score"].append(f1) + entity_metric_results[extractor]["Precision"].append(precision) + + return entity_metric_results + + +def log_results(results: IntentMetrics, dataset_name: Text) -> None: + """Logs results of cross validation. + + Args: + results: dictionary of results returned from cross validation + dataset_name: string of which dataset the results are from, e.g. test/train + """ + for k, v in results.items(): + logger.info(f"{dataset_name} {k}: {np.mean(v):.3f} ({np.std(v):.3f})") + + +def log_entity_results(results: EntityMetrics, dataset_name: Text) -> None: + """Logs entity results of cross validation. + + Args: + results: dictionary of dictionaries of results returned from cross validation + dataset_name: string of which dataset the results are from, e.g. test/train + """ + for extractor, result in results.items(): + logger.info(f"Entity extractor: {extractor}") + log_results(result, dataset_name) diff --git a/rasa/nlu/tokenizers/__init__.py b/rasa/nlu/tokenizers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/nlu/tokenizers/jieba_tokenizer.py b/rasa/nlu/tokenizers/jieba_tokenizer.py new file mode 100644 index 0000000..e0631c1 --- /dev/null +++ b/rasa/nlu/tokenizers/jieba_tokenizer.py @@ -0,0 +1,148 @@ +from __future__ import annotations +import glob +import logging +import os +import shutil +from typing import Any, Dict, List, Optional, Text + +from rasa.engine.graph import ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage + +from rasa.nlu.tokenizers.tokenizer import Token, Tokenizer +from rasa.shared.nlu.training_data.message import Message + +from rasa.shared.nlu.training_data.training_data import TrainingData + +logger = logging.getLogger(__name__) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_TOKENIZER, is_trainable=True +) +class JiebaTokenizer(Tokenizer): + """This tokenizer is a wrapper for Jieba (https://github.com/fxsjy/jieba).""" + + @staticmethod + def supported_languages() -> Optional[List[Text]]: + """Supported languages (see parent class for full docstring).""" + return ["zh"] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns default config (see parent class for full docstring).""" + return { + # default don't load custom dictionary + "dictionary_path": None, + # Flag to check whether to split intents + "intent_tokenization_flag": False, + # Symbol on which intent should be split + "intent_split_symbol": "_", + # Regular expression to detect tokens + "token_pattern": None, + # Symbol on which prefix should be split + "prefix_separator_symbol": None, + } + + def __init__( + self, config: Dict[Text, Any], model_storage: ModelStorage, resource: Resource + ) -> None: + """Initialize the tokenizer.""" + super().__init__(config) + self._model_storage = model_storage + self._resource = resource + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> JiebaTokenizer: + """Creates a new component (see parent class for full docstring).""" + # Path to the dictionaries on the local filesystem. + dictionary_path = config["dictionary_path"] + + if dictionary_path is not None: + cls._load_custom_dictionary(dictionary_path) + return cls(config, model_storage, resource) + + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return ["jieba"] + + @staticmethod + def _load_custom_dictionary(path: Text) -> None: + """Load all the custom dictionaries stored in the path. + + More information about the dictionaries file format can + be found in the documentation of jieba. + https://github.com/fxsjy/jieba#load-dictionary + """ + import jieba + + jieba_userdicts = glob.glob(f"{path}/*") + for jieba_userdict in jieba_userdicts: + logger.info(f"Loading Jieba User Dictionary at {jieba_userdict}") + jieba.load_userdict(jieba_userdict) + + def train(self, training_data: TrainingData) -> Resource: + """Copies the dictionary to the model storage.""" + self.persist() + return self._resource + + def tokenize(self, message: Message, attribute: Text) -> List[Token]: + """Tokenizes the text of the provided attribute of the incoming message.""" + import jieba + + text = message.get(attribute) + + tokenized = jieba.tokenize(text) + tokens = [Token(word, start) for (word, start, end) in tokenized] + + return self._apply_token_pattern(tokens) + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> JiebaTokenizer: + """Loads a custom dictionary from model storage.""" + dictionary_path = config["dictionary_path"] + + # If a custom dictionary path is in the config we know that it should have + # been saved to the model storage. + if dictionary_path is not None: + try: + with model_storage.read_from(resource) as resource_directory: + cls._load_custom_dictionary(str(resource_directory)) + except ValueError: + logger.debug( + f"Failed to load {cls.__name__} from model storage. " + f"Resource '{resource.name}' doesn't exist." + ) + return cls(config, model_storage, resource) + + @staticmethod + def _copy_files_dir_to_dir(input_dir: Text, output_dir: Text) -> None: + # make sure target path exists + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + target_file_list = glob.glob(f"{input_dir}/*") + for target_file in target_file_list: + shutil.copy2(target_file, output_dir) + + def persist(self) -> None: + """Persist the custom dictionaries.""" + dictionary_path = self._config["dictionary_path"] + if dictionary_path is not None: + with self._model_storage.write_to(self._resource) as resource_directory: + self._copy_files_dir_to_dir(dictionary_path, str(resource_directory)) diff --git a/rasa/nlu/tokenizers/mitie_tokenizer.py b/rasa/nlu/tokenizers/mitie_tokenizer.py new file mode 100644 index 0000000..fb69807 --- /dev/null +++ b/rasa/nlu/tokenizers/mitie_tokenizer.py @@ -0,0 +1,75 @@ +from __future__ import annotations +from typing import List, Text, Dict, Any + +from rasa.engine.graph import ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.tokenizers.tokenizer import Token, Tokenizer +from rasa.shared.nlu.training_data.message import Message + +from rasa.shared.utils.io import DEFAULT_ENCODING + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_TOKENIZER, is_trainable=False +) +class MitieTokenizer(Tokenizer): + """Tokenizes messages using the `mitie` library..""" + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns default config (see parent class for full docstring).""" + return { + # Flag to check whether to split intents + "intent_tokenization_flag": False, + # Symbol on which intent should be split + "intent_split_symbol": "_", + # Regular expression to detect tokens + "token_pattern": None, + # Symbol on which prefix should be split + "prefix_separator_symbol": None, + } + + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return ["mitie"] + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> MitieTokenizer: + """Creates a new component (see parent class for full docstring).""" + return cls(config) + + def tokenize(self, message: Message, attribute: Text) -> List[Token]: + """Tokenizes the text of the provided attribute of the incoming message.""" + import mitie + + text = message.get(attribute) + + encoded_sentence = text.encode(DEFAULT_ENCODING) + tokenized = mitie.tokenize_with_offsets(encoded_sentence) + tokens = [ + self._token_from_offset(token, offset, encoded_sentence) + for token, offset in tokenized + ] + + return self._apply_token_pattern(tokens) + + def _token_from_offset( + self, text: bytes, offset: int, encoded_sentence: bytes + ) -> Token: + return Token( + text.decode(DEFAULT_ENCODING), + self._byte_to_char_offset(encoded_sentence, offset), + ) + + @staticmethod + def _byte_to_char_offset(text: bytes, byte_offset: int) -> int: + return len(text[:byte_offset].decode(DEFAULT_ENCODING)) diff --git a/rasa/nlu/tokenizers/spacy_tokenizer.py b/rasa/nlu/tokenizers/spacy_tokenizer.py new file mode 100644 index 0000000..c6bc233 --- /dev/null +++ b/rasa/nlu/tokenizers/spacy_tokenizer.py @@ -0,0 +1,72 @@ +import typing +from typing import Dict, Text, List, Any, Optional, Type + +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.nlu.utils.spacy_utils import SpacyNLP +from rasa.nlu.tokenizers.tokenizer import Token, Tokenizer +from rasa.nlu.constants import SPACY_DOCS +from rasa.shared.nlu.training_data.message import Message + +if typing.TYPE_CHECKING: + from spacy.tokens.doc import Doc + +POS_TAG_KEY = "pos" + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_TOKENIZER, is_trainable=False +) +class SpacyTokenizer(Tokenizer): + """Tokenizer that uses SpaCy.""" + + @classmethod + def required_components(cls) -> List[Type]: + """Components that should be included in the pipeline before this component.""" + return [SpacyNLP] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """The component's default config (see parent class for full docstring).""" + return { + # Flag to check whether to split intents + "intent_tokenization_flag": False, + # Symbol on which intent should be split + "intent_split_symbol": "_", + # Regular expression to detect tokens + "token_pattern": None, + # Symbol on which prefix should be split + "prefix_separator_symbol": None, + } + + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return ["spacy"] + + def _get_doc(self, message: Message, attribute: Text) -> Optional["Doc"]: + return message.get(SPACY_DOCS[attribute]) + + def tokenize(self, message: Message, attribute: Text) -> List[Token]: + """Tokenizes the text of the provided attribute of the incoming message.""" + doc = self._get_doc(message, attribute) + if not doc: + return [] + + tokens = [ + Token( + t.text, t.idx, lemma=t.lemma_, data={POS_TAG_KEY: self._tag_of_token(t)} + ) + for t in doc + if t.text and t.text.strip() + ] + + return self._apply_token_pattern(tokens) + + @staticmethod + def _tag_of_token(token: Any) -> Text: + import spacy + + if spacy.about.__version__ > "2" and token._.has("tag"): + return token._.get("tag") + else: + return token.tag_ diff --git a/rasa/nlu/tokenizers/tokenizer.py b/rasa/nlu/tokenizers/tokenizer.py new file mode 100644 index 0000000..3e3f109 --- /dev/null +++ b/rasa/nlu/tokenizers/tokenizer.py @@ -0,0 +1,239 @@ +import abc +import logging +import re + +from typing import Text, List, Dict, Any, Optional + +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.constants import TOKENS_NAMES, MESSAGE_ATTRIBUTES +from rasa.shared.nlu.constants import ( + INTENT, + INTENT_RESPONSE_KEY, + RESPONSE_IDENTIFIER_DELIMITER, + ACTION_NAME, +) +import rasa.shared.utils.io + +logger = logging.getLogger(__name__) + + +class Token: + """Used by `Tokenizers` which split a single message into multiple `Token`s.""" + + def __init__( + self, + text: Text, + start: int, + end: Optional[int] = None, + data: Optional[Dict[Text, Any]] = None, + lemma: Optional[Text] = None, + ) -> None: + """Create a `Token`. + + Args: + text: The token text. + start: The start index of the token within the entire message. + end: The end index of the token within the entire message. + data: Additional token data. + lemma: An optional lemmatized version of the token text. + """ + self.text = text + self.start = start + self.end = end if end else start + len(text) + + self.data = data if data else {} + self.lemma = lemma or text + + def set(self, prop: Text, info: Any) -> None: + """Set property value.""" + self.data[prop] = info + + def get(self, prop: Text, default: Optional[Any] = None) -> Any: + """Returns token value.""" + return self.data.get(prop, default) + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Token): + return NotImplemented + return (self.start, self.end, self.text, self.lemma) == ( + other.start, + other.end, + other.text, + other.lemma, + ) + + def __lt__(self, other: Any) -> bool: + if not isinstance(other, Token): + return NotImplemented + return (self.start, self.end, self.text, self.lemma) < ( + other.start, + other.end, + other.text, + other.lemma, + ) + + def __repr__(self) -> Text: + return f"<Token object value='{self.text}' start={self.start} end={self.end} \ + at {hex(id(self))}>" + + def fingerprint(self) -> Text: + """Returns a stable hash for this Token.""" + return rasa.shared.utils.io.deep_container_fingerprint( + [self.text, self.start, self.end, self.lemma, self.data] + ) + + +class Tokenizer(GraphComponent, abc.ABC): + """Base class for tokenizers.""" + + def __init__(self, config: Dict[Text, Any]) -> None: + """Construct a new tokenizer.""" + self._config = config + # flag to check whether to split intents + self.intent_tokenization_flag = config["intent_tokenization_flag"] + # split symbol for intents + self.intent_split_symbol = config["intent_split_symbol"] + # token pattern to further split tokens + token_pattern = config.get("token_pattern") + self.token_pattern_regex = None + if token_pattern: + self.token_pattern_regex = re.compile(token_pattern) + # split intent to prefix and suffix greedily, None means don't split + self.prefix_separator_symbol = config.get("prefix_separator_symbol") + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + """Creates a new component (see parent class for full docstring).""" + return cls(config) + + @abc.abstractmethod + def tokenize(self, message: Message, attribute: Text) -> List[Token]: + """Tokenizes the text of the provided attribute of the incoming message.""" + ... + + def process_training_data(self, training_data: TrainingData) -> TrainingData: + """Tokenize all training data.""" + for example in training_data.training_examples: + for attribute in MESSAGE_ATTRIBUTES: + if ( + example.get(attribute) is not None + and not example.get(attribute) == "" + ): + if attribute in [INTENT, ACTION_NAME, INTENT_RESPONSE_KEY]: + tokens = self._split_name(example, attribute) + else: + tokens = self.tokenize(example, attribute) + example.set(TOKENS_NAMES[attribute], tokens) + return training_data + + def process(self, messages: List[Message]) -> List[Message]: + """Tokenize the incoming messages.""" + for message in messages: + for attribute in MESSAGE_ATTRIBUTES: + if isinstance(message.get(attribute), str): + if attribute in [ + INTENT, + ACTION_NAME, + RESPONSE_IDENTIFIER_DELIMITER, + ]: + tokens = self._split_name(message, attribute) + else: + tokens = self.tokenize(message, attribute) + + message.set(TOKENS_NAMES[attribute], tokens) + return messages + + def _tokenize_on_split_symbol(self, text: Text) -> List[Text]: + words = ( + text.split(self.intent_split_symbol) + if self.intent_tokenization_flag + else [text] + ) + + return words + + def _split_name(self, message: Message, attribute: Text = INTENT) -> List[Token]: + orig_text = message.get(attribute) + + if ( + self.prefix_separator_symbol is not None + and self.prefix_separator_symbol in orig_text + ): + prefix, text = orig_text.split(self.prefix_separator_symbol, maxsplit=1) + else: + prefix, text = None, orig_text + + # for INTENT_RESPONSE_KEY attribute, + # first split by RESPONSE_IDENTIFIER_DELIMITER + if attribute == INTENT_RESPONSE_KEY: + intent, response_key = text.split(RESPONSE_IDENTIFIER_DELIMITER) + words = self._tokenize_on_split_symbol( + intent + ) + self._tokenize_on_split_symbol(response_key) + + else: + words = self._tokenize_on_split_symbol(text) + + if prefix is not None: + words = self._tokenize_on_split_symbol(prefix) + words + + return self._convert_words_to_tokens(words, orig_text) + + def _apply_token_pattern(self, tokens: List[Token]) -> List[Token]: + """Apply the token pattern to the given tokens. + + Args: + tokens: list of tokens to split + + Returns: + List of tokens. + """ + if not self.token_pattern_regex: + return tokens + + final_tokens = [] + for token in tokens: + new_tokens = self.token_pattern_regex.findall(token.text) + new_tokens = [t for t in new_tokens if t] + + if not new_tokens: + final_tokens.append(token) + + running_offset = 0 + for new_token in new_tokens: + word_offset = token.text.index(new_token, running_offset) + word_len = len(new_token) + running_offset = word_offset + word_len + final_tokens.append( + Token( + new_token, + token.start + word_offset, + data=token.data, + lemma=token.lemma, + ) + ) + + return final_tokens + + @staticmethod + def _convert_words_to_tokens(words: List[Text], text: Text) -> List[Token]: + running_offset = 0 + tokens = [] + + for word in words: + word_offset = text.index(word, running_offset) + word_len = len(word) + running_offset = word_offset + word_len + tokens.append(Token(word, word_offset)) + + return tokens diff --git a/rasa/nlu/tokenizers/whitespace_tokenizer.py b/rasa/nlu/tokenizers/whitespace_tokenizer.py new file mode 100644 index 0000000..ea444c0 --- /dev/null +++ b/rasa/nlu/tokenizers/whitespace_tokenizer.py @@ -0,0 +1,106 @@ +from __future__ import annotations +from typing import Any, Dict, List, Optional, Text + +import regex + +import rasa.shared.utils.io +import rasa.utils.io + +from rasa.engine.graph import ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.tokenizers.tokenizer import Token, Tokenizer +from rasa.shared.constants import DOCS_URL_COMPONENTS +from rasa.shared.nlu.training_data.message import Message + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_TOKENIZER, is_trainable=False +) +class WhitespaceTokenizer(Tokenizer): + """Creates features for entity extraction.""" + + @staticmethod + def not_supported_languages() -> Optional[List[Text]]: + """The languages that are not supported.""" + return ["zh", "ja", "th"] + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns the component's default config.""" + return { + # Flag to check whether to split intents + "intent_tokenization_flag": False, + # Symbol on which intent should be split + "intent_split_symbol": "_", + # Regular expression to detect tokens + "token_pattern": None, + # Symbol on which prefix should be split + "prefix_separator_symbol": None, + } + + def __init__(self, config: Dict[Text, Any]) -> None: + """Initialize the tokenizer.""" + super().__init__(config) + self.emoji_pattern = rasa.utils.io.get_emoji_regex() + + if "case_sensitive" in self._config: + rasa.shared.utils.io.raise_warning( + "The option 'case_sensitive' was moved from the tokenizers to the " + "featurizers.", + docs=DOCS_URL_COMPONENTS, + ) + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> WhitespaceTokenizer: + """Creates a new component (see parent class for full docstring).""" + # Path to the dictionaries on the local filesystem. + return cls(config) + + def remove_emoji(self, text: Text) -> Text: + """Remove emoji if the full text, aka token, matches the emoji regex.""" + match = self.emoji_pattern.fullmatch(text) + + if match is not None: + return "" + + return text + + def tokenize(self, message: Message, attribute: Text) -> List[Token]: + text = message.get(attribute) + + # we need to use regex instead of re, because of + # https://stackoverflow.com/questions/12746458/python-unicode-regular-expression-matching-failing-with-some-unicode-characters + + # remove 'not a word character' if + words = regex.sub( + # there is a space or an end of a string after it + r"[^\w#@&]+(?=\s|$)|" + # there is a space or beginning of a string before it + # not followed by a number + r"(\s|^)[^\w#@&]+(?=[^0-9\s])|" + # not in between numbers and not . or @ or & or - or # + # e.g. 10'000.00 or blabla@gmail.com + # and not url characters + r"(?<=[^0-9\s])[^\w._~:/?#\[\]()@!$&*+,;=-]+(?=[^0-9\s])", + " ", + text, + ).split() + + words = [self.remove_emoji(w) for w in words] + words = [w for w in words if w] + + # if we removed everything like smiles `:)`, use the whole text as 1 token + if not words: + words = [text] + + tokens = self._convert_words_to_tokens(words, text) + + return self._apply_token_pattern(tokens) diff --git a/rasa/nlu/utils/__init__.py b/rasa/nlu/utils/__init__.py new file mode 100644 index 0000000..a0a00fe --- /dev/null +++ b/rasa/nlu/utils/__init__.py @@ -0,0 +1,36 @@ +from typing import Any, Text + +import rasa.shared.utils.io + + +def write_json_to_file(filename: Text, obj: Any, **kwargs: Any) -> None: + """Write an object as a json string to a file.""" + write_to_file(filename, rasa.shared.utils.io.json_to_string(obj, **kwargs)) + + +def write_to_file(filename: Text, text: Any) -> None: + """Write a text to a file.""" + + rasa.shared.utils.io.write_text_file(str(text), filename) + + +def is_url(resource_name: Text) -> bool: + """Check whether the url specified is a well formed one. + + Args: + resource_name: Remote URL to validate + + Returns: + `True` if valid, otherwise `False`. + """ + from urllib import parse + + try: + result = parse.urlparse(resource_name) + except Exception: + return False + + if result.scheme == "file": + return bool(result.path) + + return bool(result.scheme in ["http", "https", "ftp", "ftps"] and result.netloc) diff --git a/rasa/nlu/utils/bilou_utils.py b/rasa/nlu/utils/bilou_utils.py new file mode 100644 index 0000000..9f739a8 --- /dev/null +++ b/rasa/nlu/utils/bilou_utils.py @@ -0,0 +1,464 @@ +import logging +import operator +from collections import defaultdict, Counter +from typing import List, Tuple, Text, Optional, Dict, Any, TYPE_CHECKING + +from rasa.nlu.constants import ( + TOKENS_NAMES, + BILOU_ENTITIES, + BILOU_ENTITIES_GROUP, + BILOU_ENTITIES_ROLE, +) +from rasa.shared.nlu.constants import ( + TEXT, + ENTITIES, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_GROUP, + ENTITY_ATTRIBUTE_ROLE, + NO_ENTITY_TAG, +) + +if TYPE_CHECKING: + from rasa.nlu.tokenizers.tokenizer import Token + from rasa.shared.nlu.training_data.training_data import TrainingData + from rasa.shared.nlu.training_data.message import Message + +logger = logging.getLogger(__name__) + +BEGINNING = "B-" +INSIDE = "I-" +LAST = "L-" +UNIT = "U-" +BILOU_PREFIXES = [BEGINNING, INSIDE, LAST, UNIT] + + +def bilou_prefix_from_tag(tag: Text) -> Optional[Text]: + """Returns the BILOU prefix from the given tag. + + Args: + tag: the tag + + Returns: the BILOU prefix of the tag + """ + if tag[:2] in BILOU_PREFIXES: + return tag[:2] + return None + + +def tag_without_prefix(tag: Text) -> Text: + """Remove the BILOU prefix from the given tag. + + Args: + tag: the tag + + Returns: the tag without the BILOU prefix + """ + if tag[:2] in BILOU_PREFIXES: + return tag[2:] + return tag + + +def bilou_tags_to_ids( + message: "Message", + tag_id_dict: Dict[Text, int], + tag_name: Text = ENTITY_ATTRIBUTE_TYPE, +) -> List[int]: + """Maps the entity tags of the message to the ids of the provided dict. + + Args: + message: the message + tag_id_dict: mapping of tags to ids + tag_name: tag name of interest + + Returns: a list of tag ids + """ + bilou_key = get_bilou_key_for_tag(tag_name) + + if message.get(bilou_key): + _tags = [ + tag_id_dict[_tag] if _tag in tag_id_dict else tag_id_dict[NO_ENTITY_TAG] + for _tag in message.get(bilou_key) + ] + else: + _tags = [tag_id_dict[NO_ENTITY_TAG] for _ in message.get(TOKENS_NAMES[TEXT])] + + return _tags + + +def get_bilou_key_for_tag(tag_name: Text) -> Text: + """Get the message key for the BILOU tagging format of the provided tag name. + + Args: + tag_name: the tag name + + Returns: + the message key to store the BILOU tags + """ + if tag_name == ENTITY_ATTRIBUTE_ROLE: + return BILOU_ENTITIES_ROLE + + if tag_name == ENTITY_ATTRIBUTE_GROUP: + return BILOU_ENTITIES_GROUP + + return BILOU_ENTITIES + + +def build_tag_id_dict( + training_data: "TrainingData", tag_name: Text = ENTITY_ATTRIBUTE_TYPE +) -> Optional[Dict[Text, int]]: + """Create a mapping of unique tags to ids. + + Args: + training_data: the training data + tag_name: tag name of interest + + Returns: a mapping of tags to ids + """ + bilou_key = get_bilou_key_for_tag(tag_name) + + distinct_tags = set( + [ + tag_without_prefix(e) + for example in training_data.nlu_examples + if example.get(bilou_key) + for e in example.get(bilou_key) + ] + ) - {NO_ENTITY_TAG} + + if not distinct_tags: + return None + + tag_id_dict = { + f"{prefix}{tag}": idx_1 * len(BILOU_PREFIXES) + idx_2 + 1 + for idx_1, tag in enumerate(sorted(distinct_tags)) + for idx_2, prefix in enumerate(BILOU_PREFIXES) + } + # NO_ENTITY_TAG corresponds to non-entity which should correspond to 0 index + # needed for correct prediction for padding + tag_id_dict[NO_ENTITY_TAG] = 0 + + return tag_id_dict + + +def apply_bilou_schema(training_data: "TrainingData") -> None: + """Get a list of BILOU entity tags and set them on the given messages. + + Args: + training_data: the training data + """ + for message in training_data.nlu_examples: + apply_bilou_schema_to_message(message) + + +def apply_bilou_schema_to_message(message: "Message") -> None: + """Get a list of BILOU entity tags and set them on the given message. + + Args: + message: the message + """ + entities = message.get(ENTITIES) + + if not entities: + return + + tokens = message.get(TOKENS_NAMES[TEXT]) + + for attribute, message_key in [ + (ENTITY_ATTRIBUTE_TYPE, BILOU_ENTITIES), + (ENTITY_ATTRIBUTE_ROLE, BILOU_ENTITIES_ROLE), + (ENTITY_ATTRIBUTE_GROUP, BILOU_ENTITIES_GROUP), + ]: + entities = map_message_entities(message, attribute) + output = bilou_tags_from_offsets(tokens, entities) + message.set(message_key, output) + + +def map_message_entities( + message: "Message", attribute_key: Text = ENTITY_ATTRIBUTE_TYPE +) -> List[Tuple[int, int, Text]]: + """Maps the entities of the given message to their start, end, and tag values. + + Args: + message: the message + attribute_key: key of tag value to use + + Returns: a list of start, end, and tag value tuples + """ + + def convert_entity(entity: Dict[Text, Any]) -> Tuple[int, int, Text]: + return ( + entity[ENTITY_ATTRIBUTE_START], + entity[ENTITY_ATTRIBUTE_END], + entity.get(attribute_key) or NO_ENTITY_TAG, + ) + + entities = [convert_entity(entity) for entity in message.get(ENTITIES, [])] + + # entities is a list of tuples (start, end, tag value). + # filter out all entities with tag value == NO_ENTITY_TAG. + tag_value_idx = 2 + return [entity for entity in entities if entity[tag_value_idx] != NO_ENTITY_TAG] + + +def bilou_tags_from_offsets( + tokens: List["Token"], entities: List[Tuple[int, int, Text]] +) -> List[Text]: + """Creates BILOU tags for the given tokens and entities. + + Args: + message: The message object. + tokens: The list of tokens. + entities: The list of start, end, and tag tuples. + missing: The tag for missing entities. + + Returns: + BILOU tags. + """ + start_pos_to_token_idx = {token.start: i for i, token in enumerate(tokens)} + end_pos_to_token_idx = {token.end: i for i, token in enumerate(tokens)} + + bilou = [NO_ENTITY_TAG for _ in tokens] + + _add_bilou_tags_to_entities( + bilou, entities, end_pos_to_token_idx, start_pos_to_token_idx + ) + + return bilou + + +def _add_bilou_tags_to_entities( + bilou: List[Text], + entities: List[Tuple[int, int, Text]], + end_pos_to_token_idx: Dict[int, int], + start_pos_to_token_idx: Dict[int, int], +) -> None: + for start_pos, end_pos, label in entities: + start_token_idx = start_pos_to_token_idx.get(start_pos) + end_token_idx = end_pos_to_token_idx.get(end_pos) + + # Only interested if the tokenization is correct + if start_token_idx is not None and end_token_idx is not None: + if start_token_idx == end_token_idx: + bilou[start_token_idx] = f"{UNIT}{label}" + else: + bilou[start_token_idx] = f"{BEGINNING}{label}" + for i in range(start_token_idx + 1, end_token_idx): + bilou[i] = f"{INSIDE}{label}" + bilou[end_token_idx] = f"{LAST}{label}" + + +def ensure_consistent_bilou_tagging( + predicted_tags: List[Text], predicted_confidences: List[float] +) -> Tuple[List[Text], List[float]]: + """ + Ensure predicted tags follow the BILOU tagging schema. + + We assume that starting B- tags are correct. Followed tags that belong to start + tag but have a different entity type are updated considering also the confidence + values of those tags. + For example, B-a I-b L-a is updated to B-a I-a L-a and B-a I-a O is changed to + B-a L-a. + + Args: + predicted_tags: predicted tags + predicted_confidences: predicted confidences + + Return: + List of tags. + List of confidences. + """ + + for idx, predicted_tag in enumerate(predicted_tags): + prefix = bilou_prefix_from_tag(predicted_tag) + tag = tag_without_prefix(predicted_tag) + + if prefix == BEGINNING: + last_idx = _find_bilou_end(idx, predicted_tags) + + relevant_confidences = predicted_confidences[idx : last_idx + 1] + relevant_tags = [ + tag_without_prefix(tag) for tag in predicted_tags[idx : last_idx + 1] + ] + + # if not all tags are the same, for example, B-person I-person L-location + # we need to check what tag we should use depending on the confidence + # values and update the tags and confidences accordingly + if not all(relevant_tags[0] == tag for tag in relevant_tags): + # decide which tag this entity should use + tag, tag_score = _tag_to_use(relevant_tags, relevant_confidences) + + logger.debug( + f"Using tag '{tag}' for entity with mixed tag labels " + f"(original tags: {predicted_tags[idx : last_idx + 1]}, " + f"(original confidences: " + f"{predicted_confidences[idx : last_idx + 1]})." + ) + + # all tags that change get the score of that tag assigned + predicted_confidences = _update_confidences( + predicted_confidences, predicted_tags, tag, tag_score, idx, last_idx + ) + + # ensure correct BILOU annotations + if last_idx == idx: + predicted_tags[idx] = f"{UNIT}{tag}" + elif last_idx - idx == 1: + predicted_tags[idx] = f"{BEGINNING}{tag}" + predicted_tags[last_idx] = f"{LAST}{tag}" + else: + predicted_tags[idx] = f"{BEGINNING}{tag}" + predicted_tags[last_idx] = f"{LAST}{tag}" + for i in range(idx + 1, last_idx): + predicted_tags[i] = f"{INSIDE}{tag}" + + return predicted_tags, predicted_confidences + + +def _tag_to_use( + relevant_tags: List[Text], relevant_confidences: List[float] +) -> Tuple[Text, float]: + """Decide what tag to use according to the following metric: + + Calculate the average confidence per tag. + Calculate the percentage of tokens assigned to a tag within the entity per tag. + The harmonic mean of those two metrics is the score for the tag. + The tag with the highest score is taken as the tag for the entity. + + Args: + relevant_tags: The tags of the entity. + relevant_confidences: The confidence values. + + Returns: + The tag to use. The score of that tag. + """ + # Calculate the average confidence per tag. + avg_confidence_per_tag = _avg_confidence_per_tag( + relevant_tags, relevant_confidences + ) + # Calculate the percentage of tokens assigned to a tag per tag. + tag_counts = Counter(relevant_tags) + token_percentage_per_tag: Dict[Text, float] = {} + for tag, count in tag_counts.items(): + token_percentage_per_tag[tag] = round(count / len(relevant_tags), 2) + + # Calculate the harmonic mean between the two metrics per tag. + score_per_tag = {} + for tag, token_percentage in token_percentage_per_tag.items(): + avg_confidence = avg_confidence_per_tag[tag] + score_per_tag[tag] = ( + 2 + * (avg_confidence * token_percentage) + / (avg_confidence + token_percentage) + ) + + # Take the tag with the highest score as the tag for the entity + tag, score = max(score_per_tag.items(), key=operator.itemgetter(1)) + + return tag, score + + +def _update_confidences( + predicted_confidences: List[float], + predicted_tags: List[Text], + tag: Text, + score: float, + idx: int, + last_idx: int, +) -> List[float]: + """Update the confidence values. + + Set the confidence value of a tag to score value if the predicated + tag changed. + + Args: + predicted_confidences: The list of predicted confidences. + predicted_tags: The list of predicted tags. + tag: The tag of the entity. + score: The score value of that tag. + idx: The start index of the entity. + last_idx: The end index of the entity. + + Returns: + The updated list of confidences. + """ + for i in range(idx, last_idx + 1): + predicted_confidences[i] = ( + round(score, 2) + if tag_without_prefix(predicted_tags[i]) != tag + else predicted_confidences[i] + ) + return predicted_confidences + + +def _avg_confidence_per_tag( + relevant_tags: List[Text], relevant_confidences: List[float] +) -> Dict[Text, float]: + confidences_per_tag = defaultdict(list) + + for tag, confidence in zip(relevant_tags, relevant_confidences): + confidences_per_tag[tag].append(confidence) + + avg_confidence_per_tag = {} + for tag, confidences in confidences_per_tag.items(): + avg_confidence_per_tag[tag] = round(sum(confidences) / len(confidences), 2) + + return avg_confidence_per_tag + + +def _find_bilou_end(start_idx: int, predicted_tags: List[Text]) -> int: + """Find the last index of the entity. + + The start index is pointing to a B- tag. The entity is closed as soon as we find + a L- tag or a O tag. + + Args: + start_idx: The start index of the entity + predicted_tags: The list of predicted tags + + Returns: + The end index of the entity + """ + current_idx = start_idx + 1 + finished = False + start_tag = tag_without_prefix(predicted_tags[start_idx]) + + while not finished: + if current_idx >= len(predicted_tags): + logger.debug( + "Inconsistent BILOU tagging found, B- tag not closed by L- tag, " + "i.e [B-a, I-a, O] instead of [B-a, L-a, O].\n" + "Assuming last tag is L- instead of I-." + ) + current_idx -= 1 + break + + current_label = predicted_tags[current_idx] + prefix = bilou_prefix_from_tag(current_label) + tag = tag_without_prefix(current_label) + + if tag != start_tag: + # words are not tagged the same entity class + logger.debug( + "Inconsistent BILOU tagging found, B- tag, L- tag pair encloses " + "multiple entity classes.i.e. [B-a, I-b, L-a] instead of " + "[B-a, I-a, L-a].\nAssuming B- class is correct." + ) + + if prefix == LAST: + finished = True + elif prefix == INSIDE: + # middle part of the entity + current_idx += 1 + else: + # entity not closed by an L- tag + finished = True + current_idx -= 1 + logger.debug( + "Inconsistent BILOU tagging found, B- tag not closed by L- tag, " + "i.e [B-a, I-a, O] instead of [B-a, L-a, O].\n" + "Assuming last tag is L- instead of I-." + ) + + return current_idx diff --git a/rasa/nlu/utils/hugging_face/__init__.py b/rasa/nlu/utils/hugging_face/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/nlu/utils/hugging_face/registry.py b/rasa/nlu/utils/hugging_face/registry.py new file mode 100644 index 0000000..30ab8af --- /dev/null +++ b/rasa/nlu/utils/hugging_face/registry.py @@ -0,0 +1,108 @@ +import logging +from typing import Dict, Text, Type + +# Explicitly set logging level for this module before any import +# because otherwise it logs tensorflow/pytorch versions +logging.getLogger("transformers.file_utils").setLevel(logging.WARNING) + +from transformers import ( # noqa: E402 + TFPreTrainedModel, + TFBertModel, + TFOpenAIGPTModel, + TFGPT2Model, + TFXLNetModel, + # TFXLMModel, + TFDistilBertModel, + TFRobertaModel, + TFCamembertModel, + PreTrainedTokenizer, + BertTokenizer, + OpenAIGPTTokenizer, + GPT2Tokenizer, + XLNetTokenizer, + # XLMTokenizer, + DistilBertTokenizer, + RobertaTokenizer, + CamembertTokenizer, +) +from rasa.nlu.utils.hugging_face.transformers_pre_post_processors import ( # noqa: E402, E501 + bert_tokens_pre_processor, + gpt_tokens_pre_processor, + xlnet_tokens_pre_processor, + roberta_tokens_pre_processor, + bert_embeddings_post_processor, + gpt_embeddings_post_processor, + xlnet_embeddings_post_processor, + roberta_embeddings_post_processor, + bert_tokens_cleaner, + openaigpt_tokens_cleaner, + gpt2_tokens_cleaner, + xlnet_tokens_cleaner, + camembert_tokens_pre_processor, +) + + +model_class_dict: Dict[Text, Type[TFPreTrainedModel]] = { + "bert": TFBertModel, + "gpt": TFOpenAIGPTModel, + "gpt2": TFGPT2Model, + "xlnet": TFXLNetModel, + # "xlm": TFXLMModel, # Currently doesn't work because of a bug in transformers + # library https://github.com/huggingface/transformers/issues/2729 + "distilbert": TFDistilBertModel, + "roberta": TFRobertaModel, + "camembert": TFCamembertModel, +} +model_tokenizer_dict: Dict[Text, Type[PreTrainedTokenizer]] = { + "bert": BertTokenizer, + "gpt": OpenAIGPTTokenizer, + "gpt2": GPT2Tokenizer, + "xlnet": XLNetTokenizer, + # "xlm": XLMTokenizer, + "distilbert": DistilBertTokenizer, + "roberta": RobertaTokenizer, + "camembert": CamembertTokenizer, +} +model_weights_defaults = { + "bert": "rasa/LaBSE", + "gpt": "openai-gpt", + "gpt2": "gpt2", + "xlnet": "xlnet-base-cased", + # "xlm": "xlm-mlm-enfr-1024", + "distilbert": "distilbert-base-uncased", + "roberta": "roberta-base", + "camembert": "camembert-base", +} + +model_special_tokens_pre_processors = { + "bert": bert_tokens_pre_processor, + "gpt": gpt_tokens_pre_processor, + "gpt2": gpt_tokens_pre_processor, + "xlnet": xlnet_tokens_pre_processor, + # "xlm": xlm_tokens_pre_processor, + "distilbert": bert_tokens_pre_processor, + "roberta": roberta_tokens_pre_processor, + "camembert": camembert_tokens_pre_processor, +} + +model_tokens_cleaners = { + "bert": bert_tokens_cleaner, + "gpt": openaigpt_tokens_cleaner, + "gpt2": gpt2_tokens_cleaner, + "xlnet": xlnet_tokens_cleaner, + # "xlm": xlm_tokens_pre_processor, + "distilbert": bert_tokens_cleaner, # uses the same as BERT + "roberta": gpt2_tokens_cleaner, # Uses the same as GPT2 + "camembert": xlnet_tokens_cleaner, # Removing underscores _ +} + +model_embeddings_post_processors = { + "bert": bert_embeddings_post_processor, + "gpt": gpt_embeddings_post_processor, + "gpt2": gpt_embeddings_post_processor, + "xlnet": xlnet_embeddings_post_processor, + # "xlm": xlm_embeddings_post_processor, + "distilbert": bert_embeddings_post_processor, + "roberta": roberta_embeddings_post_processor, + "camembert": roberta_embeddings_post_processor, +} diff --git a/rasa/nlu/utils/hugging_face/transformers_pre_post_processors.py b/rasa/nlu/utils/hugging_face/transformers_pre_post_processors.py new file mode 100644 index 0000000..185ba43 --- /dev/null +++ b/rasa/nlu/utils/hugging_face/transformers_pre_post_processors.py @@ -0,0 +1,313 @@ +from typing import List, Tuple, Text +import numpy as np + + +def cleanup_tokens( + token_ids_string: List[Tuple[int, Text]], delimiter: Text +) -> Tuple[List[int], List[Text]]: + """Utility method to apply delimiter based cleanup on list of tokens. + + Args: + token_ids_string: List of tuples with each tuple containing + (token id, token string). + delimiter: character/string to be cleaned from token strings. + + Returns: + Token ids and Token strings unpacked. + """ + token_ids_string = [ + (id, string.replace(delimiter, "")) for id, string in token_ids_string + ] + + # remove empty strings + token_ids_string = [(id, string) for id, string in token_ids_string if string] + + # return as individual token ids and token strings + token_ids, token_strings = zip(*token_ids_string) + # FIXME: zip official typing is not really properly set up + return token_ids, token_strings # type: ignore[return-value] + + +def bert_tokens_pre_processor(token_ids: List[int]) -> List[int]: + """Add BERT style special tokens(CLS and SEP). + + Args: + token_ids: List of token ids without any special tokens. + + Returns: + List of token ids augmented with special tokens. + """ + BERT_CLS_ID = 101 + BERT_SEP_ID = 102 + + processed_tokens = token_ids + + processed_tokens.insert(0, BERT_CLS_ID) + processed_tokens.append(BERT_SEP_ID) + + return processed_tokens + + +def gpt_tokens_pre_processor(token_ids: List[int]) -> List[int]: + """Add GPT style special tokens(None). + + Args: + token_ids: List of token ids without any special tokens. + + Returns: + List of token ids augmented with special tokens. + """ + + return token_ids + + +def xlnet_tokens_pre_processor(token_ids: List[int]) -> List[int]: + """Add XLNET style special tokens. + + Args: + token_ids: List of token ids without any special tokens. + + Returns: + List of token ids augmented with special tokens. + """ + XLNET_CLS_ID = 3 + XLNET_SEP_ID = 4 + + token_ids.append(XLNET_SEP_ID) + token_ids.append(XLNET_CLS_ID) + + return token_ids + + +def roberta_tokens_pre_processor(token_ids: List[int]) -> List[int]: + """Add RoBERTa style special tokens. + + Args: + token_ids: List of token ids without any special tokens. + + Returns: + List of token ids augmented with special tokens. + """ + ROBERTA_BEG_ID = 0 + ROBERTA_END_ID = 2 + + token_ids.insert(0, ROBERTA_BEG_ID) + token_ids.append(ROBERTA_END_ID) + + return token_ids + + +def xlm_tokens_pre_processor(token_ids: List[int]) -> List[int]: + """Add XLM style special tokens. + + Args: + token_ids: List of token ids without any special tokens. + + Returns: + List of token ids augmented with special tokens. + """ + XLM_SEP_ID = 1 + + token_ids.insert(0, XLM_SEP_ID) + token_ids.append(XLM_SEP_ID) + + return token_ids + + +def camembert_tokens_pre_processor(token_ids: List[int]) -> List[int]: + """Add camembert style special tokens. + + Args: + token_ids: List of token ids without any special tokens. + + Returns: + List of token ids augmented with special tokens. + """ + CAMEMBERT_BEG_ID = 5 + CAMEMBERT_END_ID = 6 + + token_ids.insert(0, CAMEMBERT_BEG_ID) + token_ids.append(CAMEMBERT_END_ID) + + return token_ids + + +def bert_embeddings_post_processor( + sequence_embeddings: np.ndarray, +) -> Tuple[np.ndarray, np.ndarray]: + """Post-process embeddings from BERT. + + by removing CLS and SEP embeddings and returning CLS token embedding as + sentence representation. + + Args: + sequence_embeddings: Sequence of token level embeddings received as output from + BERT. + + Returns: + sentence level embedding and post-processed sequence level embedding. + """ + sentence_embedding = sequence_embeddings[0] + post_processed_embedding = sequence_embeddings[1:-1] + + return sentence_embedding, post_processed_embedding + + +def gpt_embeddings_post_processor( + sequence_embeddings: np.ndarray, +) -> Tuple[np.ndarray, np.ndarray]: + """Post-process embeddings from GPT models. + + by taking a mean over sequence embeddings and returning that as sentence + representation. + + Args: + sequence_embeddings: Sequence of token level embeddings received as output from + GPT. + + Returns: + sentence level embedding and post-processed sequence level embedding. + """ + sentence_embedding = np.mean(sequence_embeddings, axis=0) + post_processed_embedding = sequence_embeddings + + return sentence_embedding, post_processed_embedding + + +def xlnet_embeddings_post_processor( + sequence_embeddings: np.ndarray, +) -> Tuple[np.ndarray, np.ndarray]: + """Post-process embeddings from XLNet models. + + by taking a mean over sequence embeddings and returning that as sentence + representation. Remove last two time steps corresponding + to special tokens from the sequence embeddings. + + Args: + sequence_embeddings: Sequence of token level embeddings received as output from + XLNet. + + Returns: + sentence level embedding and post-processed sequence level embedding. + """ + post_processed_embedding = sequence_embeddings[:-2] + sentence_embedding = np.mean(post_processed_embedding, axis=0) + + return sentence_embedding, post_processed_embedding + + +def roberta_embeddings_post_processor( + sequence_embeddings: np.ndarray, +) -> Tuple[np.ndarray, np.ndarray]: + """Post process embeddings from Roberta models. + + by taking a mean over sequence embeddings and returning that as sentence + representation. Remove first and last time steps + corresponding to special tokens from the sequence embeddings. + + Args: + sequence_embeddings: Sequence of token level embeddings received as output from + Roberta + + Returns: + sentence level embedding and post-processed sequence level embedding + """ + + post_processed_embedding = sequence_embeddings[1:-1] + sentence_embedding = np.mean(post_processed_embedding, axis=0) + + return sentence_embedding, post_processed_embedding + + +def xlm_embeddings_post_processor( + sequence_embeddings: np.ndarray, +) -> Tuple[np.ndarray, np.ndarray]: + """Post process embeddings from XLM models + + by taking a mean over sequence embeddings and returning that as sentence + representation. Remove first and last time steps + corresponding to special tokens from the sequence embeddings. + + Args: + sequence_embeddings: Sequence of token level embeddings received as output from + XLM + + Returns: + sentence level embedding and post-processed sequence level embedding + """ + post_processed_embedding = sequence_embeddings[1:-1] + sentence_embedding = np.mean(post_processed_embedding, axis=0) + + return sentence_embedding, post_processed_embedding + + +def bert_tokens_cleaner( + token_ids: List[int], token_strings: List[Text] +) -> Tuple[List[int], List[Text]]: + """Token cleanup method for BERT. + + Clean up tokens with the extra delimiters(##) BERT adds while breaking a token into + sub-tokens. + + Args: + token_ids: List of token ids received as output from BERT Tokenizer. + token_strings: List of token strings received as output from BERT Tokenizer. + + Returns: + Cleaned token ids and token strings. + """ + return cleanup_tokens(list(zip(token_ids, token_strings)), "##") + + +def openaigpt_tokens_cleaner( + token_ids: List[int], token_strings: List[Text] +) -> Tuple[List[int], List[Text]]: + """Token cleanup method for GPT. + + Clean up tokens with the extra delimiters(</w>) OpenAIGPT adds while breaking a + token into sub-tokens. + + Args: + token_ids: List of token ids received as output from GPT Tokenizer. + token_strings: List of token strings received as output from GPT Tokenizer. + + Returns: + Cleaned token ids and token strings. + """ + return cleanup_tokens(list(zip(token_ids, token_strings)), "</w>") + + +def gpt2_tokens_cleaner( + token_ids: List[int], token_strings: List[Text] +) -> Tuple[List[int], List[Text]]: + """Token cleanup method for GPT2. + + Clean up tokens with the extra delimiters(Ġ) GPT2 adds while breaking a token into + sub-tokens. + + Args: + token_ids: List of token ids received as output from GPT Tokenizer. + token_strings: List of token strings received as output from GPT Tokenizer. + + Returns: + Cleaned token ids and token strings. + """ + return cleanup_tokens(list(zip(token_ids, token_strings)), "Ġ") + + +def xlnet_tokens_cleaner( + token_ids: List[int], token_strings: List[Text] +) -> Tuple[List[int], List[Text]]: + """Token cleanup method for XLNet. + + Clean up tokens with the extra delimiters(▁) XLNet adds while breaking a token into + sub-tokens. + + Args: + token_ids: List of token ids received as output from GPT Tokenizer. + token_strings: List of token strings received as output from GPT Tokenizer. + + Returns: + Cleaned token ids and token strings. + """ + return cleanup_tokens(list(zip(token_ids, token_strings)), "▁") diff --git a/rasa/nlu/utils/mitie_utils.py b/rasa/nlu/utils/mitie_utils.py new file mode 100644 index 0000000..1912956 --- /dev/null +++ b/rasa/nlu/utils/mitie_utils.py @@ -0,0 +1,113 @@ +from __future__ import annotations +import typing +from pathlib import Path +from typing import Any, Dict, List, Optional, Text + +from rasa.engine.graph import GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.exceptions import InvalidConfigException + +if typing.TYPE_CHECKING: + import mitie + + +class MitieModel: + """Wraps `MitieNLP` output to make it fingerprintable.""" + + def __init__( + self, + model_path: Path, + word_feature_extractor: Optional["mitie.total_word_feature_extractor"] = None, + ) -> None: + """Initializing MitieModel.""" + import mitie + + self.word_feature_extractor = ( + word_feature_extractor or mitie.total_word_feature_extractor + ) + self.model_path = model_path + + def fingerprint(self) -> Text: + """Fingerprints the model path. + + Use a static fingerprint as we assume this only changes if the file path + changes and want to avoid investigating the model in greater detail for now. + + Returns: + Fingerprint for model. + """ + return str(self.model_path) + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MODEL_LOADER, is_trainable=False +) +class MitieNLP(GraphComponent): + """Component which provides the common configuration and loaded model to others. + + This is used to avoid loading the Mitie model multiple times. Instead the Mitie + model is only loaded once and then shared by depending components. + """ + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Returns default config (see parent class for full docstring).""" + return { + # name of the language model to load - this contains + # the MITIE feature extractor + "model": Path("data", "total_word_feature_extractor.dat") + } + + def __init__( + self, + path_to_model_file: Path, + extractor: Optional["mitie.total_word_feature_extractor"] = None, + ) -> None: + """Constructs a new language model from the MITIE framework.""" + self._path_to_model_file = path_to_model_file + self._extractor = extractor + + @staticmethod + def required_packages() -> List[Text]: + """Lists required dependencies (see parent class for full docstring).""" + return ["mitie"] + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> MitieNLP: + """Creates component (see parent class for full docstring).""" + import mitie + + model_file = config.get("model") + if not model_file: + raise InvalidConfigException( + "The MITIE component 'MitieNLP' needs " + "the configuration value for 'model'." + "Please take a look at the " + "documentation in the pipeline section " + "to get more info about this " + "parameter." + ) + if not Path(model_file).is_file(): + raise InvalidConfigException( + "The model file configured in the MITIE " + "component cannot be found. " + "Please ensure the directory path and/or " + "filename, '{}', are correct.".format(model_file) + ) + extractor = mitie.total_word_feature_extractor(str(model_file)) + + return cls(Path(model_file), extractor) + + def provide(self) -> MitieModel: + """Provides loaded `MitieModel` and path during training and inference.""" + return MitieModel( + word_feature_extractor=self._extractor, model_path=self._path_to_model_file + ) diff --git a/rasa/nlu/utils/pattern_utils.py b/rasa/nlu/utils/pattern_utils.py new file mode 100644 index 0000000..6dd1ac3 --- /dev/null +++ b/rasa/nlu/utils/pattern_utils.py @@ -0,0 +1,168 @@ +import re +from typing import Dict, List, Text, Union + +import rasa.shared.utils.io +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.exceptions import InvalidConfigException + + +def _convert_lookup_tables_to_regex( + training_data: TrainingData, + use_only_entities: bool = False, + use_word_boundaries: bool = True, +) -> List[Dict[Text, Text]]: + r"""Convert the lookup tables from the training data to regex patterns. + + Args: + training_data: The training data. + use_only_entities: If True only regex features with a name equal to a entity + are considered. + use_word_boundaries: If True add `\b` around the regex expression + for each lookup table expressions. + + Returns: + A list of regex patterns. + """ + patterns = [] + for table in training_data.lookup_tables: + if use_only_entities and table["name"] not in training_data.entities: + continue + regex_pattern = _generate_lookup_regex(table, use_word_boundaries) + lookup_regex = {"name": table["name"], "pattern": regex_pattern} + patterns.append(lookup_regex) + return patterns + + +def _generate_lookup_regex( + lookup_table: Dict[Text, Union[Text, List[Text]]], use_word_boundaries: bool = True +) -> Text: + r"""Creates a regex pattern from the given lookup table. + + The lookup table is either a file or a list of entries. + + Args: + lookup_table: The lookup table. + use_word_boundaries: If True add `\b` around the regex expression + for each lookup table expressions. + + Returns: + The regex pattern. + """ + lookup_elements = lookup_table["elements"] + + # if it's a list, it should be the elements directly + if isinstance(lookup_elements, list): + elements_to_regex = lookup_elements + # otherwise it's a file path. + else: + elements_to_regex = read_lookup_table_file(lookup_elements) + + # sanitize the regex, escape special characters + elements_sanitized = [re.escape(e) for e in elements_to_regex] + + if use_word_boundaries: + # regex matching elements with word boundaries on either side + return "(\\b" + "\\b|\\b".join(elements_sanitized) + "\\b)" + else: + return "(" + "|".join(elements_sanitized) + ")" + + +def read_lookup_table_file(lookup_table_file: Text) -> List[Text]: + """Read the lookup table file. + + Args: + lookup_table_file: the file path to the lookup table + + Returns: + Elements listed in the lookup table file. + """ + try: + f = open(lookup_table_file, "r", encoding=rasa.shared.utils.io.DEFAULT_ENCODING) + except OSError: + raise ValueError( + f"Could not load lookup table {lookup_table_file}. " + f"Please make sure you've provided the correct path." + ) + + elements_to_regex = [] + with f: + for line in f: + new_element = line.strip() + if new_element: + elements_to_regex.append(new_element) + return elements_to_regex + + +def _collect_regex_features( + training_data: TrainingData, use_only_entities: bool = False +) -> List[Dict[Text, Text]]: + """Get regex features from training data. + + Args: + training_data: The training data + use_only_entities: If True only regex features with a name equal to a entity + are considered. + + Returns: + Regex features. + """ + if not use_only_entities: + return training_data.regex_features + + return [ + regex + for regex in training_data.regex_features + if regex["name"] in training_data.entities + ] + + +def extract_patterns( + training_data: TrainingData, + use_lookup_tables: bool = True, + use_regexes: bool = True, + use_only_entities: bool = False, + use_word_boundaries: bool = True, +) -> List[Dict[Text, Text]]: + r"""Extract a list of patterns from the training data. + + The patterns are constructed using the regex features and lookup tables defined + in the training data. + + Args: + training_data: The training data. + use_only_entities: If True only lookup tables and regex features with a name + equal to a entity are considered. + use_regexes: Boolean indicating whether to use regex features or not. + use_lookup_tables: Boolean indicating whether to use lookup tables or not. + use_word_boundaries: Boolean indicating whether to use `\b` around the lookup + table regex expressions + + Returns: + The list of regex patterns. + """ + if not training_data.lookup_tables and not training_data.regex_features: + return [] + + patterns = [] + + if use_regexes: + patterns.extend(_collect_regex_features(training_data, use_only_entities)) + if use_lookup_tables: + patterns.extend( + _convert_lookup_tables_to_regex( + training_data, use_only_entities, use_word_boundaries + ) + ) + + # validate regexes, raise Error when invalid + for pattern in patterns: + try: + re.compile(pattern["pattern"]) + except re.error: + raise InvalidConfigException( + f"Model training failed. '{pattern['pattern']}' " + "is not a valid regex. Please update your nlu " + f"training data configuration at {pattern}." + ) + + return patterns diff --git a/rasa/nlu/utils/spacy_utils.py b/rasa/nlu/utils/spacy_utils.py new file mode 100644 index 0000000..152328b --- /dev/null +++ b/rasa/nlu/utils/spacy_utils.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +import dataclasses +import typing +import logging +from typing import Any, Dict, List, Optional, Text, Tuple + +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.constants import DENSE_FEATURIZABLE_ATTRIBUTES, SPACY_DOCS +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.nlu.model import InvalidModelError +from rasa.shared.constants import DOCS_URL_COMPONENTS + +logger = logging.getLogger(__name__) + +if typing.TYPE_CHECKING: + from spacy.language import Language + from spacy.tokens import Doc + + +@dataclasses.dataclass +class SpacyModel: + """Wraps `SpacyNLP` output to make it fingerprintable.""" + + model: Language + model_name: Text + + def fingerprint(self) -> Text: + """Fingerprints the model name. + + Use a static fingerprint as we assume this only changes if the model name + changes and want to avoid investigating the model in greater detail for now. + + Returns: + Fingerprint for model. + """ + return str(self.model_name) + + +@DefaultV1Recipe.register( + [ + DefaultV1Recipe.ComponentType.MODEL_LOADER, + DefaultV1Recipe.ComponentType.MESSAGE_FEATURIZER, + ], + is_trainable=False, + model_from="SpacyNLP", +) +class SpacyNLP(GraphComponent): + """Component which provides the common loaded SpaCy model to others. + + This is used to avoid loading the SpaCy model multiple times. Instead the Spacy + model is only loaded once and then shared by depending components. + """ + + def __init__(self, model: SpacyModel, config: Dict[Text, Any]) -> None: + """Initializes a `SpacyNLP`.""" + self._model = model + self._config = config + + @staticmethod + def get_default_config() -> Dict[Text, Any]: + """Default config.""" + return { + # when retrieving word vectors, this will decide if the casing + # of the word is relevant. E.g. `hello` and `Hello` will + # retrieve the same vector, if set to `False`. For some + # applications and models it makes sense to differentiate + # between these two words, therefore setting this to `True`. + "case_sensitive": False + } + + @staticmethod + def load_model(spacy_model_name: Text) -> SpacyModel: + """Try loading the model, catching the OSError if missing.""" + import spacy + + if not spacy_model_name: + raise InvalidModelError( + f"Missing model configuration for `SpacyNLP` in `config.yml`.\n" + f"You must pass a model to the `SpacyNLP` component explicitly.\n" + f"For example:\n" + f"- name: SpacyNLP\n" + f" model: en_core_web_md\n" + f"More information can be found on {DOCS_URL_COMPONENTS}#spacynlp" + ) + + try: + language = spacy.load(spacy_model_name, disable=["parser"]) + spacy_runtime_version = spacy.about.__version__ + spacy_model_info = spacy.info(spacy_model_name) + spacy_model_version_req = ( + spacy_model_info.get("spacy_version") + if isinstance(spacy_model_info, dict) + else "" + ) + if not spacy.util.is_compatible_version( + spacy_runtime_version, spacy_model_version_req + ): + raise InvalidModelError( + f"The specified model - {spacy_model_name} requires a spaCy " + f"runtime version {spacy_model_version_req} and is not compatible " + f"with the current spaCy runtime version {spacy_runtime_version}" + ) + return SpacyModel(model=language, model_name=spacy_model_name) + except OSError: + raise InvalidModelError( + f"Please confirm that {spacy_model_name} is an available spaCy model. " + f"You need to download one upfront. For example:\n" + f"python -m spacy download en_core_web_md\n" + f"More information can be found on {DOCS_URL_COMPONENTS}#spacynlp" + ) + + @staticmethod + def required_packages() -> List[Text]: + """Lists required dependencies (see parent class for full docstring).""" + return ["spacy"] + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> SpacyNLP: + """Creates component (see parent class for full docstring).""" + spacy_model_name = config.get("model") + + logger.info(f"Trying to load SpaCy model with name '{spacy_model_name}'.") + + model = cls.load_model(spacy_model_name) + + cls.ensure_proper_language_model(model.model) + return cls(model, {**cls.get_default_config(), **config}) + + @staticmethod + def ensure_proper_language_model(nlp: Optional[Language]) -> None: + """Checks if the SpaCy language model is properly loaded. + + Raises an exception if the model is invalid. + """ + if nlp is None: + raise Exception( + "Failed to load SpaCy language model. " + "Loading the model returned 'None'." + ) + if nlp.path is None: + # Spacy sets the path to `None` if + # it did not load the model from disk. + # In this case `nlp` is an unusable stub. + raise Exception( + f"Failed to load SpaCy language model for " + f"lang '{nlp.lang}'. Make sure you have downloaded the " + f"correct model (https://spacy.io/docs/usage/)." + "" + ) + + def provide(self) -> SpacyModel: + """Provides the loaded SpaCy model.""" + return self._model + + def _doc_for_text(self, model: Language, text: Text) -> Doc: + """Makes a SpaCy doc object from a string of text.""" + return model(self._preprocess_text(text)) + + def _preprocess_text(self, text: Optional[Text]) -> Text: + """Processes the text before it is handled by SpaCy.""" + if text is None: + # converted to empty string so that it can still be passed to spacy. + # Another option could be to neglect tokenization of the attribute of + # this example, but since we are processing in batch mode, it would + # get complex to collect all processed and neglected examples. + text = "" + if self._config.get("case_sensitive"): + return text + else: + return text.lower() + + def _get_text(self, example: Dict[Text, Any], attribute: Text) -> Text: + return self._preprocess_text(example.get(attribute)) + + @staticmethod + def _merge_content_lists( + indexed_training_samples: List[Tuple[int, Text]], + doc_lists: List[Tuple[int, Doc]], + ) -> List[Tuple[int, Doc]]: + """Merge lists with processed Docs back into their original order.""" + dct = dict(indexed_training_samples) + dct.update(doc_lists) + return sorted(dct.items()) + + @staticmethod + def _filter_training_samples_by_content( + indexed_training_samples: List[Tuple[int, Text]] + ) -> Tuple[List[Tuple[int, Text]], List[Tuple[int, Text]]]: + """Separates empty training samples from content bearing ones.""" + docs_to_pipe = list( + filter( + lambda training_sample: training_sample[1] != "", + indexed_training_samples, + ) + ) + empty_docs = list( + filter( + lambda training_sample: training_sample[1] == "", + indexed_training_samples, + ) + ) + return docs_to_pipe, empty_docs + + @staticmethod + def _process_content_bearing_samples( + model: Language, samples_to_pipe: List[Tuple[int, Text]] + ) -> List[Tuple[int, Doc]]: + """Sends content bearing training samples to SpaCy's pipe.""" + docs = [ + (to_pipe_sample[0], doc) + for to_pipe_sample, doc in zip( + samples_to_pipe, + [ + doc + for doc in model.pipe( + [txt for _, txt in samples_to_pipe], batch_size=50 + ) + ], + ) + ] + return docs + + @staticmethod + def _process_non_content_bearing_samples( + model: Language, empty_samples: List[Tuple[int, Text]] + ) -> List[Tuple[int, Doc]]: + """Creates empty Doc-objects from zero-lengthed training samples strings.""" + from spacy.tokens import Doc + + n_docs = [ + (empty_sample[0], doc) + for empty_sample, doc in zip( + empty_samples, [Doc(model.vocab) for doc in empty_samples] + ) + ] + return n_docs + + def _docs_for_training_data( + self, model: Language, training_data: TrainingData + ) -> Dict[Text, List[Any]]: + attribute_docs = {} + for attribute in DENSE_FEATURIZABLE_ATTRIBUTES: + + texts = [ + self._get_text(e, attribute) for e in training_data.training_examples + ] + # Index and freeze indices of the training samples for preserving the order + # after processing the data. + indexed_training_samples = [(idx, text) for idx, text in enumerate(texts)] + + samples_to_pipe, empty_samples = self._filter_training_samples_by_content( + indexed_training_samples + ) + + content_bearing_docs = self._process_content_bearing_samples( + model, samples_to_pipe + ) + + non_content_bearing_docs = self._process_non_content_bearing_samples( + model, empty_samples + ) + + attribute_document_list = self._merge_content_lists( + indexed_training_samples, + content_bearing_docs + non_content_bearing_docs, + ) + + # Since we only need the training samples strings, + # we create a list to get them out of the tuple. + attribute_docs[attribute] = [doc for _, doc in attribute_document_list] + return attribute_docs + + def process_training_data( + self, training_data: TrainingData, model: SpacyModel + ) -> TrainingData: + """Adds SpaCy tokens and features to training data messages.""" + attribute_docs = self._docs_for_training_data(model.model, training_data) + + for attribute in DENSE_FEATURIZABLE_ATTRIBUTES: + + for idx, example in enumerate(training_data.training_examples): + example_attribute_doc = attribute_docs[attribute][idx] + if len(example_attribute_doc): + # If length is 0, that means the initial text feature + # was None and was replaced by '' + # in preprocess method + example.set(SPACY_DOCS[attribute], example_attribute_doc) + + return training_data + + def process(self, messages: List[Message], model: SpacyModel) -> List[Message]: + """Adds SpaCy tokens and features to messages.""" + for message in messages: + for attribute in DENSE_FEATURIZABLE_ATTRIBUTES: + if message.get(attribute): + message.set( + SPACY_DOCS[attribute], + self._doc_for_text(model.model, message.get(attribute)), + ) + + return messages diff --git a/rasa/plugin.py b/rasa/plugin.py new file mode 100644 index 0000000..b7d5fce --- /dev/null +++ b/rasa/plugin.py @@ -0,0 +1,159 @@ +import argparse +import functools +import sys +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Text, Tuple, Union + +import pluggy + +from rasa.cli import SubParsersAction + +if TYPE_CHECKING: + from rasa.core.brokers.broker import EventBroker + from rasa.core.tracker_store import TrackerStore + from rasa.engine.graph import SchemaNode + from rasa.engine.storage.storage import ModelMetadata + from rasa.shared.core.domain import Domain + from rasa.shared.core.trackers import DialogueStateTracker + from rasa.shared.nlu.training_data.message import Message + from rasa.utils.endpoints import EndpointConfig + + +hookspec = pluggy.HookspecMarker("rasa") + + +@functools.lru_cache(maxsize=2) +def plugin_manager() -> pluggy.PluginManager: + """Initialises a plugin manager which registers hook implementations.""" + _plugin_manager = pluggy.PluginManager("rasa") + _plugin_manager.add_hookspecs(sys.modules["rasa.plugin"]) + _discover_plugins(_plugin_manager) + + return _plugin_manager + + +def _discover_plugins(manager: pluggy.PluginManager) -> None: + try: + # rasa_plus is an enterprise-ready version of rasa open source + # which extends existing functionality via plugins + import rasa_plus + + rasa_plus.init_hooks(manager) + except ModuleNotFoundError: + pass + + +@hookspec # type: ignore[misc] +def refine_cli( + subparsers: SubParsersAction, + parent_parsers: List[argparse.ArgumentParser], +) -> None: + """Customizable hook for adding CLI commands.""" + + +@hookspec(firstresult=True) # type: ignore[misc] +def handle_space_args(args: argparse.Namespace) -> Dict[Text, Any]: + """Extracts space from the command line arguments.""" + return {} + + +@hookspec # type: ignore[misc] +def modify_default_recipe_graph_train_nodes( + train_config: Dict[Text, Any], + train_nodes: Dict[Text, "SchemaNode"], + cli_parameters: Dict[Text, Any], +) -> None: + """Hook specification to modify the default recipe graph for training. + + Modifications are made in-place. + """ + + +@hookspec # type: ignore[misc] +def modify_default_recipe_graph_predict_nodes( + predict_nodes: Dict[Text, "SchemaNode"] +) -> None: + """Hook specification to modify the default recipe graph for prediction. + + Modifications are made in-place. + """ + + +@hookspec # type: ignore[misc] +def get_version_info() -> Tuple[Text, Text]: # type: ignore[empty-body] + """Hook specification for getting plugin version info.""" + + +@hookspec # type: ignore[misc] +def configure_commandline(cmdline_arguments: argparse.Namespace) -> Optional[Text]: + """Hook specification for configuring plugin CLI.""" + + +@hookspec # type: ignore[misc] +def init_telemetry(endpoints_file: Optional[Text]) -> None: + """Hook specification for initialising plugin telemetry.""" + + +@hookspec # type: ignore[misc] +def mock_tracker_for_evaluation( + example: "Message", model_metadata: Optional["ModelMetadata"] +) -> Optional["DialogueStateTracker"]: + """Generate a mocked tracker for NLU evaluation.""" + + +@hookspec # type: ignore[misc] +def clean_entity_targets_for_evaluation( + merged_targets: List[str], extractor: str +) -> List[str]: + """Remove entity targets for space-based entity extractors.""" + return [] + + +@hookspec(firstresult=True) # type: ignore[misc] +def prefix_stripping_for_custom_actions(json_body: Dict[Text, Any]) -> Dict[Text, Any]: + """Remove namespacing introduced by spaces before custom actions call.""" + return {} + + +@hookspec # type: ignore[misc] +def prefixing_custom_actions_response( + json_body: Dict[Text, Any], response: Dict[Text, Any] +) -> None: + """Add namespacing to the response from custom actions.""" + + +@hookspec # type: ignore[misc] +def init_managers(endpoints_file: Optional[Text]) -> None: + """Hook specification for initialising managers.""" + + +@hookspec(firstresult=True) # type: ignore[misc] +def create_tracker_store( # type: ignore[empty-body] + endpoint_config: Union["TrackerStore", "EndpointConfig"], + domain: "Domain", + event_broker: Optional["EventBroker"], +) -> "TrackerStore": + """Hook specification for wrapping with AuthRetryTrackerStore.""" + + +@hookspec(firstresult=True) # type: ignore[misc] +def init_anonymization_pipeline(endpoints_file: Optional[Text]) -> None: + """Hook specification for initialising the anonymization pipeline.""" + + +@hookspec(firstresult=True) # type: ignore[misc] +def get_anonymization_pipeline() -> Optional[Any]: + """Hook specification for getting the anonymization pipeline.""" + + +@hookspec(firstresult=True) # type: ignore[misc] +def get_license_hash() -> Optional[Text]: + """Hook specification for getting the license hash.""" + + +@hookspec # type: ignore[misc] +def after_server_stop() -> None: + """Hook specification for stopping the server. + + Use this hook to de-initialize any resources that require explicit cleanup like, + thread shutdown, closing connections, etc. + """ diff --git a/rasa/server.py b/rasa/server.py new file mode 100644 index 0000000..d8bc94e --- /dev/null +++ b/rasa/server.py @@ -0,0 +1,1526 @@ +import asyncio +import concurrent.futures +import logging +import multiprocessing +import os +import traceback +from collections import defaultdict +from functools import reduce, wraps +from inspect import isawaitable +from pathlib import Path +from http import HTTPStatus +from typing import ( + Any, + Callable, + DefaultDict, + List, + Optional, + Text, + Union, + Dict, + TYPE_CHECKING, + NoReturn, + Coroutine, +) + +import aiohttp +import jsonschema +from sanic import Sanic, response +from sanic.request import Request +from sanic.response import HTTPResponse +from sanic_cors import CORS +from sanic_jwt import Initialize, exceptions + +import rasa +import rasa.core.utils +from rasa.nlu.emulators.emulator import Emulator +import rasa.utils.common +import rasa.shared.utils.common +import rasa.shared.utils.io +import rasa.shared.utils.validation +import rasa.shared.nlu.training_data.schemas.data_schema +import rasa.utils.endpoints +import rasa.utils.io +from rasa.shared.core.training_data.story_writer.yaml_story_writer import ( + YAMLStoryWriter, +) +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.nlu.training_data.formats import RasaYAMLReader +from rasa.core.constants import DEFAULT_RESPONSE_TIMEOUT +from rasa.constants import MINIMUM_COMPATIBLE_VERSION +from rasa.shared.constants import ( + DOCS_URL_TRAINING_DATA, + DOCS_BASE_URL, + DEFAULT_SENDER_ID, + DEFAULT_MODELS_PATH, + TEST_STORIES_FILE_PREFIX, +) +from rasa.shared.core.domain import InvalidDomain, Domain +from rasa.core.agent import Agent +from rasa.core.channels.channel import ( + CollectingOutputChannel, + OutputChannel, + UserMessage, +) +import rasa.shared.core.events +from rasa.shared.core.events import Event +from rasa.core.test import test +from rasa.utils.common import TempDirectoryPath, get_temp_dir_name +from rasa.shared.core.trackers import ( + DialogueStateTracker, + EventVerbosity, +) +from rasa.core.utils import AvailableEndpoints +from rasa.nlu.emulators.no_emulator import NoEmulator +import rasa.nlu.test +from rasa.nlu.test import CVEvaluationResult +from rasa.shared.utils.schemas.events import EVENTS_SCHEMA +from rasa.utils.endpoints import EndpointConfig + +if TYPE_CHECKING: + from ssl import SSLContext + from rasa.core.processor import MessageProcessor + from mypy_extensions import Arg, VarArg, KwArg + + SanicResponse = Union[ + response.HTTPResponse, Coroutine[Any, Any, response.HTTPResponse] + ] + SanicView = Callable[ + [Arg(Request, "request"), VarArg(), KwArg()], + Coroutine[Any, Any, SanicResponse], + ] + + +logger = logging.getLogger(__name__) + +JSON_CONTENT_TYPE = "application/json" +YAML_CONTENT_TYPE = "application/x-yaml" + +OUTPUT_CHANNEL_QUERY_KEY = "output_channel" +USE_LATEST_INPUT_CHANNEL_AS_OUTPUT_CHANNEL = "latest" +EXECUTE_SIDE_EFFECTS_QUERY_KEY = "execute_side_effects" + + +class ErrorResponse(Exception): + """Common exception to handle failing API requests.""" + + def __init__( + self, + status: Union[int, HTTPStatus], + reason: Text, + message: Text, + details: Any = None, + help_url: Optional[Text] = None, + ) -> None: + """Creates error. + + Args: + status: The HTTP status code to return. + reason: Short summary of the error. + message: Detailed explanation of the error. + details: Additional details which describe the error. Must be serializable. + help_url: URL where users can get further help (e.g. docs). + """ + self.error_info = { + "version": rasa.__version__, + "status": "failure", + "message": message, + "reason": reason, + "details": details or {}, + "help": help_url, + "code": status, + } + self.status = status + logger.error(message) + super(ErrorResponse, self).__init__() + + +def _docs(sub_url: Text) -> Text: + """Create a url to a subpart of the docs.""" + return DOCS_BASE_URL + sub_url + + +def ensure_loaded_agent( + app: Sanic, require_core_is_ready: bool = False +) -> Callable[[Callable], Callable[..., Any]]: + """Wraps a request handler ensuring there is a loaded and usable agent. + + Require the agent to have a loaded Core model if `require_core_is_ready` is + `True`. + """ + + def decorator(f: Callable) -> Callable: + @wraps(f) + def decorated(*args: Any, **kwargs: Any) -> Any: + # noinspection PyUnresolvedReferences + if not app.ctx.agent or not app.ctx.agent.is_ready(): + raise ErrorResponse( + HTTPStatus.CONFLICT, + "Conflict", + "No agent loaded. To continue processing, a " + "model of a trained agent needs to be loaded.", + help_url=_docs("/user-guide/configuring-http-api/"), + ) + + return f(*args, **kwargs) + + return decorated + + return decorator + + +def ensure_conversation_exists() -> Callable[["SanicView"], "SanicView"]: + """Wraps a request handler ensuring the conversation exists.""" + + def decorator(f: "SanicView") -> "SanicView": + @wraps(f) + async def decorated( + request: Request, *args: Any, **kwargs: Any + ) -> "SanicResponse": + conversation_id = kwargs["conversation_id"] + if await request.app.ctx.agent.tracker_store.exists(conversation_id): + return await f(request, *args, **kwargs) + else: + raise ErrorResponse( + HTTPStatus.NOT_FOUND, "Not found", "Conversation ID not found." + ) + + return decorated + + return decorator + + +def requires_auth( + app: Sanic, token: Optional[Text] = None +) -> Callable[["SanicView"], "SanicView"]: + """Wraps a request handler with token authentication.""" + + def decorator(f: "SanicView") -> "SanicView": + def conversation_id_from_args(args: Any, kwargs: Any) -> Optional[Text]: + argnames = rasa.shared.utils.common.arguments_of(f) + + try: + sender_id_arg_idx = argnames.index("conversation_id") + if "conversation_id" in kwargs: # try to fetch from kwargs first + return kwargs["conversation_id"] + if sender_id_arg_idx < len(args): + return args[sender_id_arg_idx] + return None + except ValueError: + return None + + async def sufficient_scope( + request: Request, *args: Any, **kwargs: Any + ) -> Optional[bool]: + # This is a coroutine since `sanic-jwt==1.6` + jwt_data = await rasa.utils.common.call_potential_coroutine( + request.app.ctx.auth.extract_payload(request) + ) + + user = jwt_data.get("user", {}) + + username = user.get("username", None) + role = user.get("role", None) + + if role == "admin": + return True + elif role == "user": + conversation_id = conversation_id_from_args(args, kwargs) + return conversation_id is not None and username == conversation_id + else: + return False + + @wraps(f) + async def decorated( + request: Request, *args: Any, **kwargs: Any + ) -> response.HTTPResponse: + + provided = request.args.get("token", None) + + # noinspection PyProtectedMember + if token is not None and provided == token: + result = f(request, *args, **kwargs) + return await result if isawaitable(result) else result + elif app.config.get( + "USE_JWT" + ) and await rasa.utils.common.call_potential_coroutine( + # This is a coroutine since `sanic-jwt==1.6` + request.app.ctx.auth.is_authenticated(request) + ): + if await sufficient_scope(request, *args, **kwargs): + result = f(request, *args, **kwargs) + return await result if isawaitable(result) else result + raise ErrorResponse( + HTTPStatus.FORBIDDEN, + "NotAuthorized", + "User has insufficient permissions.", + help_url=_docs( + "/user-guide/configuring-http-api/#security-considerations" + ), + ) + elif token is None and app.config.get("USE_JWT") is None: + # authentication is disabled + result = f(request, *args, **kwargs) + return await result if isawaitable(result) else result + raise ErrorResponse( + HTTPStatus.UNAUTHORIZED, + "NotAuthenticated", + "User is not authenticated.", + help_url=_docs( + "/user-guide/configuring-http-api/#security-considerations" + ), + ) + + return decorated + + return decorator + + +def event_verbosity_parameter( + request: Request, default_verbosity: EventVerbosity +) -> EventVerbosity: + """Create `EventVerbosity` object using request params if present.""" + event_verbosity_str = request.args.get( + "include_events", default_verbosity.name + ).upper() + try: + return EventVerbosity[event_verbosity_str] + except KeyError: + enum_values = ", ".join([e.name for e in EventVerbosity]) + raise ErrorResponse( + HTTPStatus.BAD_REQUEST, + "BadRequest", + "Invalid parameter value for 'include_events'. " + "Should be one of {}".format(enum_values), + {"parameter": "include_events", "in": "query"}, + ) + + +async def get_test_stories( + processor: "MessageProcessor", + conversation_id: Text, + until_time: Optional[float], + fetch_all_sessions: bool = False, +) -> Text: + """Retrieves test stories from `processor` for all conversation sessions for + `conversation_id`. + + Args: + processor: An instance of `MessageProcessor`. + conversation_id: Conversation ID to fetch stories for. + until_time: Timestamp up to which to include events. + fetch_all_sessions: Whether to fetch stories for all conversation sessions. + If `False`, only the last conversation session is retrieved. + + Returns: + The stories for `conversation_id` in test format. + """ + if fetch_all_sessions: + trackers = await processor.get_trackers_for_all_conversation_sessions( + conversation_id + ) + else: + trackers = [await processor.get_tracker(conversation_id)] + + if until_time is not None: + trackers = [tracker.travel_back_in_time(until_time) for tracker in trackers] + # keep only non-empty trackers + trackers = [tracker for tracker in trackers if len(tracker.events)] + + logger.debug( + f"Fetched trackers for {len(trackers)} conversation sessions " + f"for conversation ID {conversation_id}." + ) + + story_steps = [] + + more_than_one_story = len(trackers) > 1 + + for i, tracker in enumerate(trackers, 1): + tracker.sender_id = conversation_id + + if more_than_one_story: + tracker.sender_id += f", story {i}" + + story_steps += tracker.as_story().story_steps + + return YAMLStoryWriter().dumps(story_steps, is_test_story=True) + + +async def update_conversation_with_events( + conversation_id: Text, + processor: "MessageProcessor", + domain: Domain, + events: List[Event], +) -> DialogueStateTracker: + """Fetches or creates a tracker for `conversation_id` and appends `events` to it. + + Args: + conversation_id: The ID of the conversation to update the tracker for. + processor: An instance of `MessageProcessor`. + domain: The domain associated with the current `Agent`. + events: The events to append to the tracker. + + Returns: + The tracker for `conversation_id` with the updated events. + """ + if rasa.shared.core.events.do_events_begin_with_session_start(events): + tracker = await processor.get_tracker(conversation_id) + else: + tracker = await processor.fetch_tracker_with_initial_session(conversation_id) + + for event in events: + tracker.update(event, domain) + + return tracker + + +def validate_request_body(request: Request, error_message: Text) -> None: + """Check if `request` has a body.""" + if not request.body: + raise ErrorResponse(HTTPStatus.BAD_REQUEST, "BadRequest", error_message) + + +def validate_events_in_request_body(request: Request) -> None: + """Validates events format in request body.""" + if not isinstance(request.json, list): + events = [request.json] + else: + events = request.json + + try: + jsonschema.validate(events, EVENTS_SCHEMA) + except jsonschema.ValidationError as error: + raise ErrorResponse( + HTTPStatus.BAD_REQUEST, + "BadRequest", + f"Failed to validate the events format. " + f"For more information about the format visit the docs. Error: {error}", + help_url=_docs("/pages/http-api"), + ) from error + + +async def authenticate(_: Request) -> NoReturn: + """Callback for authentication failed.""" + raise exceptions.AuthenticationFailed( + "Direct JWT authentication not supported. You should already have " + "a valid JWT from an authentication provider, Rasa will just make " + "sure that the token is valid, but not issue new tokens." + ) + + +def create_ssl_context( + ssl_certificate: Optional[Text], + ssl_keyfile: Optional[Text], + ssl_ca_file: Optional[Text] = None, + ssl_password: Optional[Text] = None, +) -> Optional["SSLContext"]: + """Create an SSL context if a proper certificate is passed. + + Args: + ssl_certificate: path to the SSL client certificate + ssl_keyfile: path to the SSL key file + ssl_ca_file: path to the SSL CA file for verification (optional) + ssl_password: SSL private key password (optional) + + Returns: + SSL context if a valid certificate chain can be loaded, `None` otherwise. + + """ + if ssl_certificate: + import ssl + + ssl_context = ssl.create_default_context( + purpose=ssl.Purpose.CLIENT_AUTH, cafile=ssl_ca_file + ) + ssl_context.load_cert_chain( + ssl_certificate, keyfile=ssl_keyfile, password=ssl_password + ) + return ssl_context + else: + return None + + +def _create_emulator(mode: Optional[Text]) -> Emulator: + """Create emulator for specified mode. + + If no emulator is specified, we will use the Rasa NLU format. + """ + if mode is None: + return NoEmulator() + elif mode.lower() == "wit": + from rasa.nlu.emulators.wit import WitEmulator + + return WitEmulator() + elif mode.lower() == "luis": + from rasa.nlu.emulators.luis import LUISEmulator + + return LUISEmulator() + elif mode.lower() == "dialogflow": + from rasa.nlu.emulators.dialogflow import DialogflowEmulator + + return DialogflowEmulator() + else: + raise ErrorResponse( + HTTPStatus.BAD_REQUEST, + "BadRequest", + "Invalid parameter value for 'emulation_mode'. " + "Should be one of 'WIT', 'LUIS', 'DIALOGFLOW'.", + {"parameter": "emulation_mode", "in": "query"}, + ) + + +async def _load_agent( + model_path: Optional[Text] = None, + model_server: Optional[EndpointConfig] = None, + remote_storage: Optional[Text] = None, + endpoints: Optional[AvailableEndpoints] = None, +) -> Agent: + try: + loaded_agent = await rasa.core.agent.load_agent( + model_path=model_path, + model_server=model_server, + remote_storage=remote_storage, + endpoints=endpoints, + ) + except Exception as e: + logger.debug(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "LoadingError", + f"An unexpected error occurred. Error: {e}", + ) + + if not loaded_agent.is_ready(): + raise ErrorResponse( + HTTPStatus.BAD_REQUEST, + "BadRequest", + f"Agent with name '{model_path}' could not be loaded.", + {"parameter": "model", "in": "query"}, + ) + + return loaded_agent + + +def configure_cors( + app: Sanic, cors_origins: Union[Text, List[Text], None] = "" +) -> None: + """Configure CORS origins for the given app.""" + # Workaround so that socketio works with requests from other origins. + # https://github.com/miguelgrinberg/python-socketio/issues/205#issuecomment-493769183 + app.config.CORS_AUTOMATIC_OPTIONS = True + app.config.CORS_SUPPORTS_CREDENTIALS = True + app.config.CORS_EXPOSE_HEADERS = "filename" + + CORS( + app, resources={r"/*": {"origins": cors_origins or ""}}, automatic_options=True + ) + + +def add_root_route(app: Sanic) -> None: + """Add '/' route to return hello.""" + + @app.get("/") + async def hello(request: Request) -> HTTPResponse: + """Check if the server is running and responds with the version.""" + return response.text("Hello from Rasa: " + rasa.__version__) + + +def async_if_callback_url(f: Callable[..., Coroutine]) -> Callable: + """Decorator to enable async request handling. + + If the incoming HTTP request specified a `callback_url` query parameter, the request + will return immediately with a 204 while the actual request response will + be sent to the `callback_url`. If an error happens, the error payload will also + be sent to the `callback_url`. + + Args: + f: The request handler function which should be decorated. + + Returns: + The decorated function. + """ + + @wraps(f) + async def decorated_function( + request: Request, *args: Any, **kwargs: Any + ) -> HTTPResponse: + callback_url = request.args.get("callback_url") + # Only process request asynchronously if the user specified a `callback_url` + # query parameter. + if not callback_url: + return await f(request, *args, **kwargs) + + async def wrapped() -> None: + try: + result: HTTPResponse = await f(request, *args, **kwargs) + payload: Dict[Text, Any] = dict( + data=result.body, headers={"Content-Type": result.content_type} + ) + logger.debug( + "Asynchronous processing of request was successful. " + "Sending result to callback URL." + ) + + except Exception as e: + if not isinstance(e, ErrorResponse): + logger.error(e) + e = ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "UnexpectedError", + f"An unexpected error occurred. Error: {e}", + ) + # If an error happens, we send the error payload to the `callback_url` + payload = dict(json=e.error_info) + logger.error( + "Error happened when processing request asynchronously. " + "Sending error to callback URL." + ) + async with aiohttp.ClientSession() as session: + await session.post(callback_url, raise_for_status=True, **payload) + + # Run the request in the background on the event loop + request.app.add_task(wrapped()) + + # The incoming request will return immediately with a 204 + return response.empty() + + return decorated_function + + +def run_in_thread(f: Callable[..., Coroutine]) -> Callable: + """Decorator which runs request on a separate thread. + + Some requests (e.g. training or cross-validation) are computional intense requests. + This means that they will block the event loop and hence the processing of other + requests. This decorator can be used to process these requests on a separate thread + to avoid blocking the processing of incoming requests. + + Args: + f: The request handler function which should be decorated. + + Returns: + The decorated function. + """ + + @wraps(f) + async def decorated_function( + request: Request, *args: Any, **kwargs: Any + ) -> HTTPResponse: + # Use a sync wrapper for our `async` function as `run_in_executor` only supports + # sync functions + def run() -> HTTPResponse: + return asyncio.run(f(request, *args, **kwargs)) + + with concurrent.futures.ThreadPoolExecutor() as pool: + return await request.app.loop.run_in_executor(pool, run) + + return decorated_function + + +def inject_temp_dir(f: Callable[..., Coroutine]) -> Callable: + """Decorator to inject a temporary directory before a request and clean up after. + + Args: + f: The request handler function which should be decorated. + + Returns: + The decorated function. + """ + + @wraps(f) + async def decorated_function(*args: Any, **kwargs: Any) -> HTTPResponse: + with TempDirectoryPath(get_temp_dir_name()) as directory: + # Decorated request handles need to have a parameter `temporary_directory` + return await f(*args, temporary_directory=Path(directory), **kwargs) + + return decorated_function + + +def create_app( + agent: Optional["Agent"] = None, + cors_origins: Union[Text, List[Text], None] = "*", + auth_token: Optional[Text] = None, + response_timeout: int = DEFAULT_RESPONSE_TIMEOUT, + jwt_secret: Optional[Text] = None, + jwt_private_key: Optional[Text] = None, + jwt_method: Text = "HS256", + endpoints: Optional[AvailableEndpoints] = None, +) -> Sanic: + """Class representing a Rasa HTTP server.""" + app = Sanic("rasa_server") + app.config.RESPONSE_TIMEOUT = response_timeout + configure_cors(app, cors_origins) + + # Set up the Sanic-JWT extension + if jwt_secret and jwt_method: + # `sanic-jwt` depends on having an available event loop when making the call to + # `Initialize`. If there is none, the server startup will fail with + # `There is no current event loop in thread 'MainThread'`. + try: + _ = asyncio.get_running_loop() + except RuntimeError: + new_loop = asyncio.new_event_loop() + asyncio.set_event_loop(new_loop) + + # since we only want to check signatures, we don't actually care + # about the JWT method and set the passed secret as either symmetric + # or asymmetric key. jwt lib will choose the right one based on method + app.config["USE_JWT"] = True + Initialize( + app, + secret=jwt_secret, + private_key=jwt_private_key, + authenticate=authenticate, + algorithm=jwt_method, + user_id="username", + ) + + app.ctx.agent = agent + # Initialize shared object of type unsigned int for tracking + # the number of active training processes + app.ctx.active_training_processes = multiprocessing.Value("I", 0) + + @app.exception(ErrorResponse) + async def handle_error_response( + request: Request, exception: ErrorResponse + ) -> HTTPResponse: + return response.json(exception.error_info, status=exception.status) + + add_root_route(app) + + @app.get("/version") + async def version(request: Request) -> HTTPResponse: + """Respond with the version number of the installed Rasa.""" + return response.json( + { + "version": rasa.__version__, + "minimum_compatible_version": MINIMUM_COMPATIBLE_VERSION, + } + ) + + @app.get("/status") + @requires_auth(app, auth_token) + @ensure_loaded_agent(app) + async def status(request: Request) -> HTTPResponse: + """Respond with the model name and the fingerprint of that model.""" + return response.json( + { + "model_file": app.ctx.agent.processor.model_filename, + "model_id": app.ctx.agent.model_id, + "num_active_training_jobs": app.ctx.active_training_processes.value, + } + ) + + @app.get("/conversations/<conversation_id:path>/tracker") + @requires_auth(app, auth_token) + @ensure_loaded_agent(app) + async def retrieve_tracker(request: Request, conversation_id: Text) -> HTTPResponse: + """Get a dump of a conversation's tracker including its events.""" + verbosity = event_verbosity_parameter(request, EventVerbosity.AFTER_RESTART) + until_time = rasa.utils.endpoints.float_arg(request, "until") + + tracker = await app.ctx.agent.processor.fetch_full_tracker_with_initial_session( + conversation_id, + output_channel=CollectingOutputChannel(), + ) + + try: + if until_time is not None: + tracker = tracker.travel_back_in_time(until_time) + + state = tracker.current_state(verbosity) + return response.json(state) + except Exception as e: + logger.debug(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "ConversationError", + f"An unexpected error occurred. Error: {e}", + ) + + @app.post("/conversations/<conversation_id:path>/tracker/events") + @requires_auth(app, auth_token) + @ensure_loaded_agent(app) + async def append_events(request: Request, conversation_id: Text) -> HTTPResponse: + """Append a list of events to the state of a conversation.""" + validate_events_in_request_body(request) + + verbosity = event_verbosity_parameter(request, EventVerbosity.AFTER_RESTART) + + try: + async with app.ctx.agent.lock_store.lock(conversation_id): + processor = app.ctx.agent.processor + events = _get_events_from_request_body(request) + + tracker = await update_conversation_with_events( + conversation_id, processor, app.ctx.agent.domain, events + ) + + output_channel = _get_output_channel(request, tracker) + + if rasa.utils.endpoints.bool_arg( + request, EXECUTE_SIDE_EFFECTS_QUERY_KEY, False + ): + await processor.execute_side_effects( + events, tracker, output_channel + ) + await app.ctx.agent.tracker_store.save(tracker) + return response.json(tracker.current_state(verbosity)) + except Exception as e: + logger.debug(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "ConversationError", + f"An unexpected error occurred. Error: {e}", + ) + + def _get_events_from_request_body(request: Request) -> List[Event]: + events = request.json + + if not isinstance(events, list): + events = [events] + + events = [Event.from_parameters(event) for event in events] + events = [event for event in events if event] + + if not events: + rasa.shared.utils.io.raise_warning( + f"Append event called, but could not extract a valid event. " + f"Request JSON: {request.json}" + ) + raise ErrorResponse( + HTTPStatus.BAD_REQUEST, + "BadRequest", + "Couldn't extract a proper event from the request body.", + {"parameter": "", "in": "body"}, + ) + + return events + + @app.put("/conversations/<conversation_id:path>/tracker/events") + @requires_auth(app, auth_token) + @ensure_loaded_agent(app) + async def replace_events(request: Request, conversation_id: Text) -> HTTPResponse: + """Use a list of events to set a conversations tracker to a state.""" + validate_events_in_request_body(request) + + verbosity = event_verbosity_parameter(request, EventVerbosity.AFTER_RESTART) + + try: + async with app.ctx.agent.lock_store.lock(conversation_id): + tracker = DialogueStateTracker.from_dict( + conversation_id, request.json, app.ctx.agent.domain.slots + ) + + # will override an existing tracker with the same id! + await app.ctx.agent.tracker_store.save(tracker) + + return response.json(tracker.current_state(verbosity)) + except Exception as e: + logger.debug(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "ConversationError", + f"An unexpected error occurred. Error: {e}", + ) + + @app.get("/conversations/<conversation_id:path>/story") + @requires_auth(app, auth_token) + @ensure_loaded_agent(app) + @ensure_conversation_exists() + async def retrieve_story(request: Request, conversation_id: Text) -> HTTPResponse: + """Get an end-to-end story corresponding to this conversation.""" + until_time = rasa.utils.endpoints.float_arg(request, "until") + fetch_all_sessions = rasa.utils.endpoints.bool_arg( + request, "all_sessions", default=False + ) + + try: + stories = await get_test_stories( + app.ctx.agent.processor, + conversation_id, + until_time, + fetch_all_sessions=fetch_all_sessions, + ) + return response.text(stories) + except Exception as e: + logger.debug(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "ConversationError", + f"An unexpected error occurred. Error: {e}", + ) + + @app.post("/conversations/<conversation_id:path>/execute") + @requires_auth(app, auth_token) + @ensure_loaded_agent(app) + @ensure_conversation_exists() + async def execute_action(request: Request, conversation_id: Text) -> HTTPResponse: + rasa.shared.utils.io.raise_warning( + 'The "POST /conversations/<conversation_id>/execute"' + " endpoint is deprecated. Inserting actions to the tracker externally" + " should be avoided. Actions should be predicted by the policies only.", + category=FutureWarning, + ) + request_params = request.json + + action_to_execute = request_params.get("name", None) + + if not action_to_execute: + raise ErrorResponse( + HTTPStatus.BAD_REQUEST, + "BadRequest", + "Name of the action not provided in request body.", + {"parameter": "name", "in": "body"}, + ) + + policy = request_params.get("policy", None) + confidence = request_params.get("confidence", None) + verbosity = event_verbosity_parameter(request, EventVerbosity.AFTER_RESTART) + + try: + async with app.ctx.agent.lock_store.lock(conversation_id): + tracker = await ( + app.ctx.agent.processor.fetch_tracker_and_update_session( + conversation_id + ) + ) + + output_channel = _get_output_channel(request, tracker) + await app.ctx.agent.execute_action( + conversation_id, + action_to_execute, + output_channel, + policy, + confidence, + ) + + except Exception as e: + logger.debug(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "ConversationError", + f"An unexpected error occurred. Error: {e}", + ) + + state = tracker.current_state(verbosity) + + response_body: Dict[Text, Any] = {"tracker": state} + + if isinstance(output_channel, CollectingOutputChannel): + response_body["messages"] = output_channel.messages + + return response.json(response_body) + + @app.post("/conversations/<conversation_id:path>/trigger_intent") + @requires_auth(app, auth_token) + @ensure_loaded_agent(app) + async def trigger_intent(request: Request, conversation_id: Text) -> HTTPResponse: + request_params = request.json + + intent_to_trigger = request_params.get("name") + entities = request_params.get("entities", []) + + if not intent_to_trigger: + raise ErrorResponse( + HTTPStatus.BAD_REQUEST, + "BadRequest", + "Name of the intent not provided in request body.", + {"parameter": "name", "in": "body"}, + ) + + verbosity = event_verbosity_parameter(request, EventVerbosity.AFTER_RESTART) + + try: + async with app.ctx.agent.lock_store.lock(conversation_id): + tracker = await ( + app.ctx.agent.processor.fetch_tracker_and_update_session( + conversation_id + ) + ) + output_channel = _get_output_channel(request, tracker) + if intent_to_trigger not in app.ctx.agent.domain.intents: + raise ErrorResponse( + HTTPStatus.NOT_FOUND, + "NotFound", + f"The intent {trigger_intent} does not exist in the domain.", + ) + await app.ctx.agent.trigger_intent( + intent_name=intent_to_trigger, + entities=entities, + output_channel=output_channel, + tracker=tracker, + ) + except ErrorResponse: + raise + except Exception as e: + logger.debug(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "ConversationError", + f"An unexpected error occurred. Error: {e}", + ) + + state = tracker.current_state(verbosity) + + response_body: Dict[Text, Any] = {"tracker": state} + + if isinstance(output_channel, CollectingOutputChannel): + response_body["messages"] = output_channel.messages + + return response.json(response_body) + + @app.post("/conversations/<conversation_id:path>/predict") + @requires_auth(app, auth_token) + @ensure_loaded_agent(app) + @ensure_conversation_exists() + async def predict(request: Request, conversation_id: Text) -> HTTPResponse: + try: + # Fetches the appropriate bot response in a json format + responses = await app.ctx.agent.predict_next_for_sender_id(conversation_id) + responses["scores"] = sorted( + responses["scores"], key=lambda k: (-k["score"], k["action"]) + ) + return response.json(responses) + except Exception as e: + logger.debug(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "ConversationError", + f"An unexpected error occurred. Error: {e}", + ) + + @app.post("/conversations/<conversation_id:path>/messages") + @requires_auth(app, auth_token) + @ensure_loaded_agent(app) + async def add_message(request: Request, conversation_id: Text) -> HTTPResponse: + validate_request_body( + request, + "No message defined in request body. Add a message to the request body in " + "order to add it to the tracker.", + ) + + request_params = request.json + + message = request_params.get("text") + sender = request_params.get("sender") + parse_data = request_params.get("parse_data") + + verbosity = event_verbosity_parameter(request, EventVerbosity.AFTER_RESTART) + + # TODO: implement for agent / bot + if sender != "user": + raise ErrorResponse( + HTTPStatus.BAD_REQUEST, + "BadRequest", + "Currently, only user messages can be passed to this endpoint. " + "Messages of sender '{}' cannot be handled.".format(sender), + {"parameter": "sender", "in": "body"}, + ) + + user_message = UserMessage(message, None, conversation_id, parse_data) + + try: + async with app.ctx.agent.lock_store.lock(conversation_id): + # cf. processor.handle_message (ignoring prediction loop run) + tracker = await app.ctx.agent.processor.log_message( + user_message, should_save_tracker=False + ) + tracker = await app.ctx.agent.processor.run_action_extract_slots( + user_message.output_channel, tracker + ) + await app.ctx.agent.processor.save_tracker(tracker) + + return response.json(tracker.current_state(verbosity)) + except Exception as e: + logger.debug(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "ConversationError", + f"An unexpected error occurred. Error: {e}", + ) + + @app.post("/model/train") + @requires_auth(app, auth_token) + @async_if_callback_url + @run_in_thread + @inject_temp_dir + async def train(request: Request, temporary_directory: Path) -> HTTPResponse: + validate_request_body( + request, + "You must provide training data in the request body in order to " + "train your model.", + ) + + training_payload = _training_payload_from_yaml(request, temporary_directory) + + try: + with app.ctx.active_training_processes.get_lock(): + app.ctx.active_training_processes.value += 1 + + from rasa.model_training import train + + # pass `None` to run in default executor + training_result = train(**training_payload) + + if training_result.model: + filename = os.path.basename(training_result.model) + + return await response.file( + training_result.model, + filename=filename, + headers={"filename": filename}, + ) + else: + raise ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "TrainingError", + "Ran training, but it finished without a trained model.", + ) + except ErrorResponse as e: + raise e + except InvalidDomain as e: + raise ErrorResponse( + HTTPStatus.BAD_REQUEST, + "InvalidDomainError", + f"Provided domain file is invalid. Error: {e}", + ) + except Exception as e: + logger.error(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "TrainingError", + f"An unexpected error occurred during training. Error: {e}", + ) + finally: + with app.ctx.active_training_processes.get_lock(): + app.ctx.active_training_processes.value -= 1 + + @app.post("/model/test/stories") + @requires_auth(app, auth_token) + @ensure_loaded_agent(app, require_core_is_ready=True) + @inject_temp_dir + async def evaluate_stories( + request: Request, temporary_directory: Path + ) -> HTTPResponse: + """Evaluate stories against the currently loaded model.""" + validate_request_body( + request, + "You must provide some stories in the request body in order to " + "evaluate your model.", + ) + + test_data = _test_data_file_from_payload(request, temporary_directory) + + e2e = rasa.utils.endpoints.bool_arg(request, "e2e", default=False) + + try: + evaluation = await test( + test_data, app.ctx.agent, e2e=e2e, disable_plotting=True + ) + return response.json(evaluation) + except Exception as e: + logger.error(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "TestingError", + f"An unexpected error occurred during evaluation. Error: {e}", + ) + + @app.post("/model/test/intents") + @requires_auth(app, auth_token) + @async_if_callback_url + @run_in_thread + @inject_temp_dir + async def evaluate_intents( + request: Request, temporary_directory: Path + ) -> HTTPResponse: + """Evaluate intents against a Rasa model.""" + validate_request_body( + request, + "You must provide some nlu data in the request body in order to " + "evaluate your model.", + ) + + cross_validation_folds = request.args.get("cross_validation_folds") + is_yaml_payload = request.headers.get("Content-type") == YAML_CONTENT_TYPE + test_coroutine = None + + if is_yaml_payload: + payload = _training_payload_from_yaml(request, temporary_directory) + config_file = payload.get("config") + test_data = payload.get("training_files") + + if cross_validation_folds: + test_coroutine = _cross_validate( + test_data, config_file, int(cross_validation_folds) + ) + else: + payload = _nlu_training_payload_from_json(request, temporary_directory) + test_data = payload.get("training_files") + + if cross_validation_folds: + raise ErrorResponse( + HTTPStatus.BAD_REQUEST, + "TestingError", + "Cross-validation is only supported for YAML data.", + ) + + if not cross_validation_folds: + test_coroutine = _evaluate_model_using_test_set( + request.args.get("model"), test_data + ) + + try: + if test_coroutine is not None: + evaluation = await test_coroutine + return response.json(evaluation) + except Exception as e: + logger.error(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "TestingError", + f"An unexpected error occurred during evaluation. Error: {e}", + ) + + async def _evaluate_model_using_test_set( + model_path: Text, test_data_file: Text + ) -> Dict: + logger.info("Starting model evaluation using test set.") + + eval_agent = app.ctx.agent + + if model_path: + model_server = app.ctx.agent.model_server + if model_server is not None: + model_server = model_server.copy() + model_server.url = model_path + # Set wait time between pulls to `0` so that the agent does not schedule + # a job to pull the model from the server + model_server.kwargs["wait_time_between_pulls"] = 0 + eval_agent = await _load_agent( + model_path=model_path, + model_server=model_server, + remote_storage=app.ctx.agent.remote_storage, + ) + + data_path = os.path.abspath(test_data_file) + + if not eval_agent.is_ready(): + raise ErrorResponse( + HTTPStatus.CONFLICT, "Conflict", "Loaded model file not found." + ) + + return await rasa.nlu.test.run_evaluation( + data_path, eval_agent.processor, disable_plotting=True, report_as_dict=True + ) + + async def _cross_validate(data_file: Text, config_file: Text, folds: int) -> Dict: + logger.info(f"Starting cross-validation with {folds} folds.") + importer = TrainingDataImporter.load_from_dict( + config=None, config_path=config_file, training_data_paths=[data_file] + ) + config = importer.get_config() + nlu_data = importer.get_nlu_data() + + evaluations = await rasa.nlu.test.cross_validate( + data=nlu_data, + n_folds=folds, + nlu_config=config, + disable_plotting=True, + errors=True, + report_as_dict=True, + ) + evaluation_results = _get_evaluation_results(*evaluations) + + return evaluation_results + + def _get_evaluation_results( + intent_report: CVEvaluationResult, + entity_report: CVEvaluationResult, + response_selector_report: CVEvaluationResult, + ) -> Dict[Text, Any]: + eval_name_mapping = { + "intent_evaluation": intent_report, + "entity_evaluation": entity_report, + "response_selection_evaluation": response_selector_report, + } + + result: DefaultDict[Text, Any] = defaultdict(dict) + for evaluation_name, evaluation in eval_name_mapping.items(): + report = evaluation.evaluation.get("report", {}) + averages = report.get("weighted avg", {}) + result[evaluation_name]["report"] = report + result[evaluation_name]["precision"] = averages.get("precision") + result[evaluation_name]["f1_score"] = averages.get("1-score") + result[evaluation_name]["errors"] = evaluation.evaluation.get("errors", []) + + return result + + @app.post("/model/predict") + @requires_auth(app, auth_token) + @ensure_loaded_agent(app, require_core_is_ready=True) + async def tracker_predict(request: Request) -> HTTPResponse: + """Given a list of events, predicts the next action.""" + validate_events_in_request_body(request) + + verbosity = event_verbosity_parameter(request, EventVerbosity.AFTER_RESTART) + request_params = request.json + try: + tracker = DialogueStateTracker.from_dict( + DEFAULT_SENDER_ID, request_params, app.ctx.agent.domain.slots + ) + except Exception as e: + logger.debug(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.BAD_REQUEST, + "BadRequest", + f"Supplied events are not valid. {e}", + {"parameter": "", "in": "body"}, + ) + + try: + result = app.ctx.agent.predict_next_with_tracker(tracker, verbosity) + + return response.json(result) + except Exception as e: + logger.debug(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "PredictionError", + f"An unexpected error occurred. Error: {e}", + ) + + @app.post("/model/parse") + @requires_auth(app, auth_token) + @ensure_loaded_agent(app) + async def parse(request: Request) -> HTTPResponse: + validate_request_body( + request, + "No text message defined in request_body. Add text message to request body " + "in order to obtain the intent and extracted entities.", + ) + emulation_mode = request.args.get("emulation_mode") + emulator = _create_emulator(emulation_mode) + + try: + data = emulator.normalise_request_json(request.json) + try: + parsed_data = await app.ctx.agent.parse_message(data.get("text")) + except Exception as e: + logger.debug(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.BAD_REQUEST, + "ParsingError", + f"An unexpected error occurred. Error: {e}", + ) + response_data = emulator.normalise_response_json(parsed_data) + + return response.json(response_data) + + except Exception as e: + logger.debug(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, + "ParsingError", + f"An unexpected error occurred. Error: {e}", + ) + + @app.put("/model") + @requires_auth(app, auth_token) + async def load_model(request: Request) -> HTTPResponse: + validate_request_body(request, "No path to model file defined in request_body.") + + model_path = request.json.get("model_file", None) + model_server = request.json.get("model_server", None) + remote_storage = request.json.get("remote_storage", None) + + if model_server: + try: + model_server = EndpointConfig.from_dict(model_server) + except TypeError as e: + logger.debug(traceback.format_exc()) + raise ErrorResponse( + HTTPStatus.BAD_REQUEST, + "BadRequest", + f"Supplied 'model_server' is not valid. Error: {e}", + {"parameter": "model_server", "in": "body"}, + ) + + new_agent = await _load_agent( + model_path=model_path, + model_server=model_server, + remote_storage=remote_storage, + endpoints=endpoints, + ) + new_agent.lock_store = app.ctx.agent.lock_store + app.ctx.agent = new_agent + + logger.debug(f"Successfully loaded model '{model_path}'.") + return response.json(None, status=HTTPStatus.NO_CONTENT) + + @app.delete("/model") + @requires_auth(app, auth_token) + async def unload_model(request: Request) -> HTTPResponse: + model_file = app.ctx.agent.model_name + + app.ctx.agent = Agent(lock_store=app.ctx.agent.lock_store) + + logger.debug(f"Successfully unloaded model '{model_file}'.") + return response.json(None, status=HTTPStatus.NO_CONTENT) + + @app.get("/domain") + @requires_auth(app, auth_token) + @ensure_loaded_agent(app) + async def get_domain(request: Request) -> HTTPResponse: + """Get current domain in yaml or json format.""" + # FIXME: this is a false positive mypy error after upgrading to 0.931 + accepts = request.headers.get("Accept", default=JSON_CONTENT_TYPE) + if accepts.endswith("json"): + domain = app.ctx.agent.domain.as_dict() + return response.json(domain) + elif accepts.endswith("yml") or accepts.endswith("yaml"): + domain_yaml = app.ctx.agent.domain.as_yaml() + return response.text( + domain_yaml, status=HTTPStatus.OK, content_type=YAML_CONTENT_TYPE + ) + else: + raise ErrorResponse( + HTTPStatus.NOT_ACCEPTABLE, + "NotAcceptable", + f"Invalid Accept header. Domain can be " + f"provided as " + f'json ("Accept: {JSON_CONTENT_TYPE}") or' + f'yml ("Accept: {YAML_CONTENT_TYPE}"). ' + f"Make sure you've set the appropriate Accept " + f"header.", + ) + + return app + + +def _get_output_channel( + request: Request, tracker: Optional[DialogueStateTracker] +) -> OutputChannel: + """Returns the `OutputChannel` which should be used for the bot's responses. + + Args: + request: HTTP request whose query parameters can specify which `OutputChannel` + should be used. + tracker: Tracker for the conversation. Used to get the latest input channel. + + Returns: + `OutputChannel` which should be used to return the bot's responses to. + """ + requested_output_channel = request.args.get(OUTPUT_CHANNEL_QUERY_KEY) + + if ( + requested_output_channel == USE_LATEST_INPUT_CHANNEL_AS_OUTPUT_CHANNEL + and tracker + ): + requested_output_channel = tracker.get_latest_input_channel() + + # Interactive training does not set `input_channels`, hence we have to be cautious + registered_input_channels = getattr(request.app.ctx, "input_channels", None) or [] + matching_channels = [ + channel + for channel in registered_input_channels + if channel.name() == requested_output_channel + ] + + # Check if matching channels can provide a valid output channel, + # otherwise use `CollectingOutputChannel` + return reduce( + lambda output_channel_created_so_far, input_channel: ( + input_channel.get_output_channel() or output_channel_created_so_far + ), + matching_channels, + CollectingOutputChannel(), + ) + + +def _test_data_file_from_payload(request: Request, temporary_directory: Path) -> Text: + return str( + _training_payload_from_yaml( + request, + temporary_directory, + # test stories have to prefixed with `test_` + file_name=f"{TEST_STORIES_FILE_PREFIX}data.yml", + )["training_files"] + ) + + +def _training_payload_from_yaml( + request: Request, temp_dir: Path, file_name: Text = "data.yml" +) -> Dict[Text, Any]: + logger.debug("Extracting YAML training data from request body.") + + decoded = request.body.decode(rasa.shared.utils.io.DEFAULT_ENCODING) + _validate_yaml_training_payload(decoded) + + training_data = temp_dir / file_name + rasa.shared.utils.io.write_text_file(decoded, training_data) + + model_output_directory = str(temp_dir) + if rasa.utils.endpoints.bool_arg(request, "save_to_default_model_directory", True): + model_output_directory = DEFAULT_MODELS_PATH + + return dict( + domain=str(training_data), + config=str(training_data), + training_files=str(temp_dir), + output=model_output_directory, + force_training=rasa.utils.endpoints.bool_arg(request, "force_training", False), + core_additional_arguments=_extract_core_additional_arguments(request), + nlu_additional_arguments=_extract_nlu_additional_arguments(request), + ) + + +def _nlu_training_payload_from_json( + request: Request, temp_dir: Path, file_name: Text = "data.json" +) -> Dict[Text, Any]: + logger.debug("Extracting JSON training data from request body.") + + rasa.shared.utils.validation.validate_training_data( + request.json, + rasa.shared.nlu.training_data.schemas.data_schema.rasa_nlu_data_schema(), + ) + training_data = temp_dir / file_name + rasa.shared.utils.io.dump_obj_as_json_to_file(training_data, request.json) + + model_output_directory = str(temp_dir) + if rasa.utils.endpoints.bool_arg(request, "save_to_default_model_directory", True): + model_output_directory = DEFAULT_MODELS_PATH + + return dict( + domain=str(training_data), + config=str(training_data), + training_files=str(temp_dir), + output=model_output_directory, + force_training=rasa.utils.endpoints.bool_arg(request, "force_training", False), + core_additional_arguments=_extract_core_additional_arguments(request), + nlu_additional_arguments=_extract_nlu_additional_arguments(request), + ) + + +def _validate_yaml_training_payload(yaml_text: Text) -> None: + try: + RasaYAMLReader().validate(yaml_text) + except Exception as e: + raise ErrorResponse( + HTTPStatus.BAD_REQUEST, + "BadRequest", + f"The request body does not contain valid YAML. Error: {e}", + help_url=DOCS_URL_TRAINING_DATA, + ) + + +def _extract_core_additional_arguments(request: Request) -> Dict[Text, Any]: + return { + "augmentation_factor": rasa.utils.endpoints.int_arg(request, "augmentation", 50) + } + + +def _extract_nlu_additional_arguments(request: Request) -> Dict[Text, Any]: + return {"num_threads": rasa.utils.endpoints.int_arg(request, "num_threads", 1)} diff --git a/rasa/shared/__init__.py b/rasa/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/shared/constants.py b/rasa/shared/constants.py new file mode 100644 index 0000000..797e289 --- /dev/null +++ b/rasa/shared/constants.py @@ -0,0 +1,108 @@ +from typing import List, Text + + +DOCS_BASE_URL = "https://rasa.com/docs/rasa" +LEGACY_DOCS_BASE_URL = "https://legacy-docs-v1.rasa.com" +DOCS_URL_TRAINING_DATA = DOCS_BASE_URL + "/training-data-format" +DOCS_URL_TRAINING_DATA_NLU = DOCS_URL_TRAINING_DATA + "#nlu-training-data" +DOCS_URL_DOMAINS = DOCS_BASE_URL + "/domain" +DOCS_URL_SLOTS = DOCS_URL_DOMAINS + "#slots" +DOCS_URL_INTENTS = DOCS_URL_DOMAINS + "#intents" +DOCS_URL_ENTITIES = DOCS_URL_DOMAINS + "#entities" +DOCS_URL_RESPONSES = DOCS_BASE_URL + "/responses" +DOCS_URL_STORIES = DOCS_BASE_URL + "/stories" +DOCS_URL_RULES = DOCS_BASE_URL + "/rules" +DOCS_URL_FORMS = DOCS_BASE_URL + "/forms" +DOCS_URL_PIPELINE = DOCS_BASE_URL + "/tuning-your-model" +DOCS_URL_POLICIES = DOCS_BASE_URL + "/policies" +DOCS_URL_TEST_STORIES = DOCS_BASE_URL + "/testing-your-assistant" +DOCS_URL_MARKERS = DOCS_BASE_URL + "/markers" +DOCS_URL_ACTIONS = DOCS_BASE_URL + "/actions" +DOCS_URL_DEFAULT_ACTIONS = DOCS_BASE_URL + "/default-actions" +DOCS_URL_CONNECTORS = DOCS_BASE_URL + "/connectors/" +DOCS_URL_CONNECTORS_SLACK = DOCS_URL_CONNECTORS + "/slack" +DOCS_URL_EVENT_BROKERS = DOCS_BASE_URL + "/event-brokers" +DOCS_URL_PIKA_EVENT_BROKER = DOCS_URL_EVENT_BROKERS + "#pika-event-broker" +DOCS_URL_TRACKER_STORES = DOCS_BASE_URL + "/tracker-stores" +DOCS_URL_COMPONENTS = DOCS_BASE_URL + "/components" +DOCS_URL_GRAPH_COMPONENTS = DOCS_BASE_URL + "/custom-graph-components" +DOCS_URL_GRAPH_RECIPE = DOCS_BASE_URL + "/graph-recipe" +DOCS_URL_MIGRATION_GUIDE = DOCS_BASE_URL + "/migration-guide" +DOCS_URL_MIGRATION_GUIDE_MD_DEPRECATION = ( + f"{DOCS_URL_MIGRATION_GUIDE}#rasa-21-to-rasa-22" +) +DOCS_URL_TELEMETRY = DOCS_BASE_URL + "/telemetry/telemetry" +DOCS_BASE_URL_RASA_X = "https://rasa.com/docs/rasa-enterprise" +DOCS_BASE_URL_ACTION_SERVER = "https://rasa.com/docs/action-server" + +INTENT_MESSAGE_PREFIX = "/" + +PACKAGE_NAME = "rasa" +NEXT_MAJOR_VERSION_FOR_DEPRECATIONS = "4.0.0" + +MODEL_CONFIG_SCHEMA_FILE = "shared/utils/schemas/model_config.yml" +CONFIG_SCHEMA_FILE = "shared/utils/schemas/config.yml" +RESPONSES_SCHEMA_FILE = "shared/nlu/training_data/schemas/responses.yml" +SCHEMA_EXTENSIONS_FILE = "shared/utils/pykwalify_extensions.py" +LATEST_TRAINING_DATA_FORMAT_VERSION = "3.1" + +DOMAIN_SCHEMA_FILE = "shared/utils/schemas/domain.yml" + +DEFAULT_SESSION_EXPIRATION_TIME_IN_MINUTES = 60 +DEFAULT_CARRY_OVER_SLOTS_TO_NEW_SESSION = True + +DEFAULT_NLU_FALLBACK_INTENT_NAME = "nlu_fallback" + +DEFAULT_E2E_TESTS_PATH = "." +TEST_STORIES_FILE_PREFIX = "test_" + +DEFAULT_LOG_LEVEL = "INFO" +ENV_LOG_LEVEL = "LOG_LEVEL" +TCP_PROTOCOL = "TCP" + +DEFAULT_SENDER_ID = "default" +UTTER_PREFIX = "utter_" + +ASSISTANT_ID_KEY = "assistant_id" +ASSISTANT_ID_DEFAULT_VALUE = "placeholder_default" + +CONFIG_MANDATORY_COMMON_KEYS = [ASSISTANT_ID_KEY] +CONFIG_AUTOCONFIGURABLE_KEYS_CORE = ["policies"] +CONFIG_AUTOCONFIGURABLE_KEYS_NLU = ["pipeline"] +CONFIG_AUTOCONFIGURABLE_KEYS = ( + CONFIG_AUTOCONFIGURABLE_KEYS_CORE + CONFIG_AUTOCONFIGURABLE_KEYS_NLU +) +CONFIG_KEYS_CORE = ["policies"] + CONFIG_MANDATORY_COMMON_KEYS +CONFIG_KEYS_NLU = ["language", "pipeline"] + CONFIG_MANDATORY_COMMON_KEYS +CONFIG_KEYS = CONFIG_KEYS_CORE + CONFIG_KEYS_NLU +CONFIG_MANDATORY_KEYS_CORE: List[Text] = [] + CONFIG_MANDATORY_COMMON_KEYS +CONFIG_MANDATORY_KEYS_NLU = ["language"] + CONFIG_MANDATORY_COMMON_KEYS +CONFIG_MANDATORY_KEYS = CONFIG_MANDATORY_KEYS_CORE + CONFIG_MANDATORY_KEYS_NLU + +# Keys related to Forms (in the Domain) +REQUIRED_SLOTS_KEY = "required_slots" +IGNORED_INTENTS = "ignored_intents" + +# Constants for default Rasa Open Source project layout +DEFAULT_ENDPOINTS_PATH = "endpoints.yml" +DEFAULT_CREDENTIALS_PATH = "credentials.yml" +DEFAULT_CONFIG_PATH = "config.yml" +DEFAULT_DOMAIN_PATH = "domain.yml" +DEFAULT_ACTIONS_PATH = "actions" +DEFAULT_MODELS_PATH = "models" +DEFAULT_CONVERTED_DATA_PATH = "converted_data" +DEFAULT_DATA_PATH = "data" +DEFAULT_RESULTS_PATH = "results" +DEFAULT_NLU_RESULTS_PATH = "nlu_comparison_results" +DEFAULT_CORE_SUBDIRECTORY_NAME = "core" +DEFAULT_NLU_SUBDIRECTORY_NAME = "nlu" +DEFAULT_CONVERSATION_TEST_PATH = "tests" +DEFAULT_MARKERS_PATH = "markers" +DEFAULT_MARKERS_CONFIG_PATH = "markers/config" +DEFAULT_MARKERS_OUTPUT_PATH = "markers/output" +DEFAULT_MARKERS_STATS_PATH = "markers/stats" + +DIAGNOSTIC_DATA = "diagnostic_data" + +RESPONSE_CONDITION = "condition" +CHANNEL = "channel" diff --git a/rasa/shared/core/__init__.py b/rasa/shared/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/shared/core/constants.py b/rasa/shared/core/constants.py new file mode 100644 index 0000000..e5eba35 --- /dev/null +++ b/rasa/shared/core/constants.py @@ -0,0 +1,142 @@ +from __future__ import annotations +from enum import Enum + +import rasa.shared.constants as constants + + +DEFAULT_CATEGORICAL_SLOT_VALUE = "__other__" + +USER_INTENT_RESTART = "restart" +USER_INTENT_BACK = "back" +USER_INTENT_OUT_OF_SCOPE = "out_of_scope" +USER_INTENT_SESSION_START = "session_start" +SESSION_START_METADATA_SLOT = "session_started_metadata" + +DEFAULT_INTENTS = [ + USER_INTENT_RESTART, + USER_INTENT_BACK, + USER_INTENT_OUT_OF_SCOPE, + USER_INTENT_SESSION_START, + constants.DEFAULT_NLU_FALLBACK_INTENT_NAME, +] + +LOOP_NAME = "name" + +ACTION_LISTEN_NAME = "action_listen" +ACTION_RESTART_NAME = "action_restart" +ACTION_SESSION_START_NAME = "action_session_start" +ACTION_DEFAULT_FALLBACK_NAME = "action_default_fallback" +ACTION_DEACTIVATE_LOOP_NAME = "action_deactivate_loop" +ACTION_REVERT_FALLBACK_EVENTS_NAME = "action_revert_fallback_events" +ACTION_DEFAULT_ASK_AFFIRMATION_NAME = "action_default_ask_affirmation" +ACTION_DEFAULT_ASK_REPHRASE_NAME = "action_default_ask_rephrase" +ACTION_BACK_NAME = "action_back" +ACTION_TWO_STAGE_FALLBACK_NAME = "action_two_stage_fallback" +ACTION_UNLIKELY_INTENT_NAME = "action_unlikely_intent" +RULE_SNIPPET_ACTION_NAME = "..." +ACTION_EXTRACT_SLOTS = "action_extract_slots" +ACTION_VALIDATE_SLOT_MAPPINGS = "action_validate_slot_mappings" + +DEFAULT_ACTION_NAMES = [ + ACTION_LISTEN_NAME, + ACTION_RESTART_NAME, + ACTION_SESSION_START_NAME, + ACTION_DEFAULT_FALLBACK_NAME, + ACTION_DEACTIVATE_LOOP_NAME, + ACTION_REVERT_FALLBACK_EVENTS_NAME, + ACTION_DEFAULT_ASK_AFFIRMATION_NAME, + ACTION_DEFAULT_ASK_REPHRASE_NAME, + ACTION_TWO_STAGE_FALLBACK_NAME, + ACTION_UNLIKELY_INTENT_NAME, + ACTION_BACK_NAME, + RULE_SNIPPET_ACTION_NAME, + ACTION_EXTRACT_SLOTS, +] + +ACTION_SHOULD_SEND_DOMAIN = "send_domain" + +# rules allow setting a value of slots or active_loops to None; +# generator substitutes `None`s with this constant to notify rule policy that +# a value should not be set during prediction to activate a rule +SHOULD_NOT_BE_SET = "should_not_be_set" + +PREVIOUS_ACTION = "prev_action" +ACTIVE_LOOP = "active_loop" +LOOP_INTERRUPTED = "is_interrupted" +LOOP_REJECTED = "rejected" +TRIGGER_MESSAGE = "trigger_message" +FOLLOWUP_ACTION = "followup_action" + +# start of special user message section +EXTERNAL_MESSAGE_PREFIX = "EXTERNAL: " +# Key to access data in the event metadata +# It specifies if an event was caused by an external entity (e.g. a sensor). +IS_EXTERNAL = "is_external" + +ACTION_NAME_SENDER_ID_CONNECTOR_STR = "__sender_id:" + +REQUESTED_SLOT = "requested_slot" + +# slots for knowledge base +SLOT_LISTED_ITEMS = "knowledge_base_listed_objects" +SLOT_LAST_OBJECT = "knowledge_base_last_object" +SLOT_LAST_OBJECT_TYPE = "knowledge_base_last_object_type" +DEFAULT_KNOWLEDGE_BASE_ACTION = "action_query_knowledge_base" + +DEFAULT_SLOT_NAMES = { + REQUESTED_SLOT, + SESSION_START_METADATA_SLOT, + SLOT_LISTED_ITEMS, + SLOT_LAST_OBJECT, + SLOT_LAST_OBJECT_TYPE, +} + + +SLOT_MAPPINGS = "mappings" +MAPPING_CONDITIONS = "conditions" +MAPPING_TYPE = "type" + + +class SlotMappingType(Enum): + """Slot mapping types.""" + + FROM_ENTITY = "from_entity" + FROM_INTENT = "from_intent" + FROM_TRIGGER_INTENT = "from_trigger_intent" + FROM_TEXT = "from_text" + CUSTOM = "custom" + + def __str__(self) -> str: + """Returns the string representation that should be used in config files.""" + return self.value + + def is_predefined_type(self) -> bool: + """Returns True iff the mapping type is predefined. + + That is, to evaluate the mapping no custom action execution is needed. + """ + return self != SlotMappingType.CUSTOM + + +# the keys for `State` (USER, PREVIOUS_ACTION, SLOTS, ACTIVE_LOOP) +# represent the origin of a `SubState` +USER = "user" +SLOTS = "slots" + +USE_TEXT_FOR_FEATURIZATION = "use_text_for_featurization" +ENTITY_LABEL_SEPARATOR = "#" + +RULE_ONLY_SLOTS = "rule_only_slots" +RULE_ONLY_LOOPS = "rule_only_loops" + +# if you add more policy/classifier names, make sure to add a test as well to ensure +# that the name and the class stay in sync +POLICY_NAME_TWO_STAGE_FALLBACK = "TwoStageFallbackPolicy" +POLICY_NAME_MAPPING = "MappingPolicy" +POLICY_NAME_FALLBACK = "FallbackPolicy" +POLICY_NAME_FORM = "FormPolicy" +POLICY_NAME_RULE = "RulePolicy" + +CLASSIFIER_NAME_FALLBACK = "FallbackClassifier" + +POLICIES_THAT_EXTRACT_ENTITIES = {"TEDPolicy"} diff --git a/rasa/shared/core/conversation.py b/rasa/shared/core/conversation.py new file mode 100644 index 0000000..6b73b04 --- /dev/null +++ b/rasa/shared/core/conversation.py @@ -0,0 +1,44 @@ +from typing import Dict, List, Text, Any, TYPE_CHECKING + +import rasa.shared.core.events + + +if TYPE_CHECKING: + from rasa.shared.core.events import Event + + +class Dialogue: + """A dialogue comprises a list of Turn objects""" + + def __init__(self, name: Text, events: List["Event"]) -> None: + """This function initialises the dialogue with the dialogue name and the event + list.""" + self.name = name + self.events = events + + def __str__(self) -> Text: + """This function returns the dialogue and turns.""" + return "Dialogue with name '{}' and turns:\n{}".format( + self.name, "\n\n".join([f"\t{t}" for t in self.events]) + ) + + def as_dict(self) -> Dict: + """This function returns the dialogue as a dictionary to assist in + serialization.""" + return {"events": [event.as_dict() for event in self.events], "name": self.name} + + @classmethod + def from_parameters(cls, parameters: Dict[Text, Any]) -> "Dialogue": + """Create `Dialogue` from parameters. + + Args: + parameters: Serialised dialogue, should contain keys 'name' and 'events'. + + Returns: + Deserialised `Dialogue`. + + """ + return cls( + parameters.get("name"), + rasa.shared.core.events.deserialise_events(parameters.get("events", [])), + ) diff --git a/rasa/shared/core/domain.py b/rasa/shared/core/domain.py new file mode 100644 index 0000000..f9e3ae0 --- /dev/null +++ b/rasa/shared/core/domain.py @@ -0,0 +1,1973 @@ +import copy +import collections +import json +import logging +import os +from pathlib import Path +from typing import ( + Any, + Dict, + List, + NoReturn, + Optional, + Set, + Text, + Tuple, + Union, + TYPE_CHECKING, + Iterable, + MutableMapping, + NamedTuple, + Callable, + cast, +) +from dataclasses import dataclass + +from ruamel.yaml.scalarstring import DoubleQuotedScalarString + +from rasa.shared.constants import ( + DEFAULT_SESSION_EXPIRATION_TIME_IN_MINUTES, + DEFAULT_CARRY_OVER_SLOTS_TO_NEW_SESSION, + DOMAIN_SCHEMA_FILE, + DOCS_URL_DOMAINS, + DOCS_URL_FORMS, + LATEST_TRAINING_DATA_FORMAT_VERSION, + DOCS_URL_RESPONSES, + REQUIRED_SLOTS_KEY, + IGNORED_INTENTS, + RESPONSE_CONDITION, +) +import rasa.shared.core.constants +from rasa.shared.core.constants import ( + ACTION_SHOULD_SEND_DOMAIN, + SlotMappingType, + MAPPING_TYPE, + MAPPING_CONDITIONS, + ACTIVE_LOOP, +) +from rasa.shared.exceptions import ( + RasaException, + YamlException, + YamlSyntaxException, +) +import rasa.shared.utils.validation +import rasa.shared.utils.io +import rasa.shared.utils.common +import rasa.shared.core.slot_mappings +from rasa.shared.core.events import SlotSet, UserUttered +from rasa.shared.core.slots import Slot, CategoricalSlot, TextSlot, AnySlot, ListSlot +from rasa.shared.utils.validation import KEY_TRAINING_DATA_FORMAT_VERSION +from rasa.shared.nlu.constants import ( + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_ROLE, + ENTITY_ATTRIBUTE_GROUP, + RESPONSE_IDENTIFIER_DELIMITER, + INTENT_NAME_KEY, + ENTITIES, +) + + +if TYPE_CHECKING: + from rasa.shared.core.trackers import DialogueStateTracker + +CARRY_OVER_SLOTS_KEY = "carry_over_slots_to_new_session" +SESSION_EXPIRATION_TIME_KEY = "session_expiration_time" +SESSION_CONFIG_KEY = "session_config" +USED_ENTITIES_KEY = "used_entities" +USE_ENTITIES_KEY = "use_entities" +IGNORE_ENTITIES_KEY = "ignore_entities" +IS_RETRIEVAL_INTENT_KEY = "is_retrieval_intent" +ENTITY_ROLES_KEY = "roles" +ENTITY_GROUPS_KEY = "groups" +ENTITY_FEATURIZATION_KEY = "influence_conversation" + +KEY_SLOTS = "slots" +KEY_INTENTS = "intents" +KEY_ENTITIES = "entities" +KEY_RESPONSES = "responses" +KEY_ACTIONS = "actions" +KEY_FORMS = "forms" +KEY_E2E_ACTIONS = "e2e_actions" +KEY_RESPONSES_TEXT = "text" + +ALL_DOMAIN_KEYS = [ + KEY_SLOTS, + KEY_FORMS, + KEY_ACTIONS, + KEY_ENTITIES, + KEY_INTENTS, + KEY_RESPONSES, + KEY_E2E_ACTIONS, + SESSION_CONFIG_KEY, +] + +PREV_PREFIX = "prev_" + +# State is a dictionary with keys (USER, PREVIOUS_ACTION, SLOTS, ACTIVE_LOOP) +# representing the origin of a SubState; +# the values are SubStates, that contain the information needed for featurization +SubStateValue = Union[Text, Tuple[Union[float, Text], ...]] +SubState = MutableMapping[Text, SubStateValue] +State = Dict[Text, SubState] + +logger = logging.getLogger(__name__) + + +class InvalidDomain(RasaException): + """Exception that can be raised when domain is not valid.""" + + +class ActionNotFoundException(ValueError, RasaException): + """Raised when an action name could not be found.""" + + +class SessionConfig(NamedTuple): + """The Session Configuration.""" + + session_expiration_time: float # in minutes + carry_over_slots: bool + + @staticmethod + def default() -> "SessionConfig": + """Returns the SessionConfig with the default values.""" + return SessionConfig( + DEFAULT_SESSION_EXPIRATION_TIME_IN_MINUTES, + DEFAULT_CARRY_OVER_SLOTS_TO_NEW_SESSION, + ) + + def are_sessions_enabled(self) -> bool: + """Returns a boolean value depending on the value of session_expiration_time.""" + return self.session_expiration_time > 0 + + def as_dict(self) -> Dict: + """Return serialized `SessionConfig`.""" + return { + "session_expiration_time": self.session_expiration_time, + "carry_over_slots_to_new_session": self.carry_over_slots, + } + + +@dataclass +class EntityProperties: + """Class for keeping track of the properties of entities in the domain.""" + + entities: List[Text] + roles: Dict[Text, List[Text]] + groups: Dict[Text, List[Text]] + default_ignored_entities: List[Text] + + +class Domain: + """The domain specifies the universe in which the bot's policy acts. + + A Domain subclass provides the actions the bot can take, the intents + and entities it can recognise. + """ + + @classmethod + def empty(cls) -> "Domain": + """Returns empty Domain.""" + return Domain.from_dict({}) + + @classmethod + def load(cls, paths: Union[List[Union[Path, Text]], Text, Path]) -> "Domain": + """Returns loaded Domain after merging all domain files.""" + if not paths: + raise InvalidDomain( + "No domain file was specified. Please specify a path " + "to a valid domain file." + ) + elif not isinstance(paths, list) and not isinstance(paths, set): + paths = [paths] + + domain = Domain.empty() + for path in paths: + other = cls.from_path(path) + domain = domain.merge(other) + + return domain + + @classmethod + def from_path(cls, path: Union[Text, Path]) -> "Domain": + """Loads the `Domain` from a path.""" + path = os.path.abspath(path) + + if os.path.isfile(path): + domain = cls.from_file(path) + elif os.path.isdir(path): + domain = cls.from_directory(path) + else: + raise InvalidDomain( + "Failed to load domain specification from '{}'. " + "File not found!".format(os.path.abspath(path)) + ) + + return domain + + @classmethod + def from_file(cls, path: Text) -> "Domain": + """Loads the `Domain` from a YAML file.""" + return cls.from_yaml(rasa.shared.utils.io.read_file(path), path) + + @classmethod + def from_yaml(cls, yaml: Text, original_filename: Text = "") -> "Domain": + """Loads the `Domain` from YAML text after validating it.""" + try: + rasa.shared.utils.validation.validate_yaml_schema(yaml, DOMAIN_SCHEMA_FILE) + + data = rasa.shared.utils.io.read_yaml(yaml) + if not rasa.shared.utils.validation.validate_training_data_format_version( + data, original_filename + ): + return Domain.empty() + return cls.from_dict(data) + except YamlException as e: + e.filename = original_filename + raise e + + @classmethod + def from_dict(cls, data: Dict) -> "Domain": + """Deserializes and creates domain. + + Args: + data: The serialized domain. + + Returns: + The instantiated `Domain` object. + """ + duplicates = data.pop("duplicates", None) + if duplicates: + warn_about_duplicates_found_during_domain_merging(duplicates) + + responses = data.get(KEY_RESPONSES, {}) + + domain_slots = data.get(KEY_SLOTS, {}) + if domain_slots: + rasa.shared.core.slot_mappings.validate_slot_mappings(domain_slots) + slots = cls.collect_slots(domain_slots) + domain_actions = data.get(KEY_ACTIONS, []) + actions = cls._collect_action_names(domain_actions) + + additional_arguments = { + **data.get("config", {}), + "actions_which_explicitly_need_domain": cls._collect_actions_which_explicitly_need_domain( # noqa: E501 + domain_actions + ), + } + session_config = cls._get_session_config(data.get(SESSION_CONFIG_KEY, {})) + intents = data.get(KEY_INTENTS, {}) + + forms = data.get(KEY_FORMS, {}) + _validate_forms(forms) + + return cls( + intents=intents, + entities=data.get(KEY_ENTITIES, {}), + slots=slots, + responses=responses, + action_names=actions, + forms=data.get(KEY_FORMS, {}), + data=Domain._cleaned_data(data), + action_texts=data.get(KEY_E2E_ACTIONS, []), + session_config=session_config, + **additional_arguments, + ) + + @staticmethod + def _get_session_config(session_config: Dict) -> SessionConfig: + session_expiration_time_min = session_config.get(SESSION_EXPIRATION_TIME_KEY) + + if session_expiration_time_min is None: + session_expiration_time_min = DEFAULT_SESSION_EXPIRATION_TIME_IN_MINUTES + + carry_over_slots = session_config.get( + CARRY_OVER_SLOTS_KEY, DEFAULT_CARRY_OVER_SLOTS_TO_NEW_SESSION + ) + + return SessionConfig(session_expiration_time_min, carry_over_slots) + + @classmethod + def from_directory(cls, path: Text) -> "Domain": + """Loads and merges multiple domain files recursively from a directory tree.""" + combined: Dict[Text, Any] = {} + for root, _, files in os.walk(path, followlinks=True): + for file in files: + full_path = os.path.join(root, file) + if Domain.is_domain_file(full_path): + _ = Domain.from_file(full_path) # does the validation here only + other_dict = rasa.shared.utils.io.read_yaml( + rasa.shared.utils.io.read_file(full_path) + ) + combined = Domain.merge_domain_dicts(other_dict, combined) + + domain = Domain.from_dict(combined) + return domain + + def merge( + self, + domain: Optional["Domain"], + override: bool = False, + ) -> "Domain": + """Merges this domain dict with another one, combining their attributes. + + This method merges domain dicts, and ensures all attributes (like ``intents``, + ``entities``, and ``actions``) are known to the Domain when the + object is created. + + List attributes like ``intents`` and ``actions`` are deduped + and merged. Single attributes are taken from `domain1` unless + override is `True`, in which case they are taken from `domain2`. + """ + if not domain or domain.is_empty(): + return self + + if self.is_empty(): + return domain + + merged_dict = self.__class__.merge_domain_dicts( + domain.as_dict(), self.as_dict(), override + ) + + return Domain.from_dict(merged_dict) + + @staticmethod + def merge_domain_dicts( + domain_dict: Dict, + combined: Dict, + override: bool = False, + ) -> Dict: + """Combines two domain dictionaries.""" + if not domain_dict: + return combined + + if not combined: + return domain_dict + + if override: + config = domain_dict.get("config", {}) + for key, val in config.items(): + combined["config"][key] = val + + if ( + override + or combined.get(SESSION_CONFIG_KEY) == SessionConfig.default().as_dict() + or combined.get(SESSION_CONFIG_KEY) is None + ) and domain_dict.get(SESSION_CONFIG_KEY) not in [ + None, + SessionConfig.default().as_dict(), + ]: + combined[SESSION_CONFIG_KEY] = domain_dict[SESSION_CONFIG_KEY] + + # remove existing forms from new actions + for form in combined.get(KEY_FORMS, []): + if form in domain_dict.get(KEY_ACTIONS, []): + domain_dict[KEY_ACTIONS].remove(form) + + duplicates: Dict[Text, List[Text]] = {} + + merge_func_mappings: Dict[Text, Callable[..., Any]] = { + KEY_INTENTS: rasa.shared.utils.common.merge_lists_of_dicts, + KEY_ENTITIES: rasa.shared.utils.common.merge_lists_of_dicts, + KEY_ACTIONS: rasa.shared.utils.common.merge_lists_of_dicts, + KEY_E2E_ACTIONS: rasa.shared.utils.common.merge_lists, + KEY_FORMS: rasa.shared.utils.common.merge_dicts, + KEY_RESPONSES: rasa.shared.utils.common.merge_dicts, + KEY_SLOTS: rasa.shared.utils.common.merge_dicts, + } + + for key, merge_func in merge_func_mappings.items(): + duplicates[key] = rasa.shared.utils.common.extract_duplicates( + combined.get(key, []), domain_dict.get(key, []) + ) + + default: Union[List[Any], Dict[Text, Any]] = ( + {} if merge_func == rasa.shared.utils.common.merge_dicts else [] + ) + + combined[key] = merge_func( + combined.get(key, default), domain_dict.get(key, default), override + ) + + if duplicates: + duplicates = rasa.shared.utils.common.clean_duplicates(duplicates) + combined.update({"duplicates": duplicates}) + + return combined + + def _preprocess_domain_dict( + self, + data: Dict, + store_entities_as_slots: bool, + session_config: SessionConfig, + ) -> Dict: + data = self._add_default_keys_to_domain_dict( + data, + store_entities_as_slots, + session_config, + ) + data = self._sanitize_intents_in_domain_dict(data) + + return data + + @staticmethod + def _add_default_keys_to_domain_dict( + data: Dict, + store_entities_as_slots: bool, + session_config: SessionConfig, + ) -> Dict: + # add the config, session_config and training data version defaults + # if not included in the original domain dict + if "config" not in data and not store_entities_as_slots: + data.update( + {"config": {"store_entities_as_slots": store_entities_as_slots}} + ) + + if SESSION_CONFIG_KEY not in data: + data.update( + { + SESSION_CONFIG_KEY: { + SESSION_EXPIRATION_TIME_KEY: ( + session_config.session_expiration_time + ), + CARRY_OVER_SLOTS_KEY: session_config.carry_over_slots, + } + } + ) + + if KEY_TRAINING_DATA_FORMAT_VERSION not in data: + data.update( + { + KEY_TRAINING_DATA_FORMAT_VERSION: DoubleQuotedScalarString( + LATEST_TRAINING_DATA_FORMAT_VERSION + ) + } + ) + + return data + + @staticmethod + def _reset_intent_flags(intent: Dict[Text, Any]) -> None: + for intent_property in intent.values(): + if ( + USE_ENTITIES_KEY in intent_property.keys() + and not intent_property[USE_ENTITIES_KEY] + ): + intent_property[USE_ENTITIES_KEY] = [] + if ( + IGNORE_ENTITIES_KEY in intent_property.keys() + and not intent_property[IGNORE_ENTITIES_KEY] + ): + intent_property[IGNORE_ENTITIES_KEY] = [] + + @staticmethod + def _sanitize_intents_in_domain_dict(data: Dict[Text, Any]) -> Dict[Text, Any]: + if not data.get(KEY_INTENTS): + return data + + for intent in data.get(KEY_INTENTS, []): + if isinstance(intent, dict): + Domain._reset_intent_flags(intent) + + data[KEY_INTENTS] = Domain._sort_intent_names_alphabetical_order( + intents=data.get(KEY_INTENTS) + ) + + return data + + @staticmethod + def collect_slots(slot_dict: Dict[Text, Any]) -> List[Slot]: + """Collects a list of slots from a dictionary.""" + slots = [] + # make a copy to not alter the input dictionary + slot_dict = copy.deepcopy(slot_dict) + # Don't sort the slots, see https://github.com/RasaHQ/rasa-x/issues/3900 + for slot_name in slot_dict: + slot_type = slot_dict[slot_name].pop("type", None) + slot_class = Slot.resolve_by_type(slot_type) + + slot = slot_class(slot_name, **slot_dict[slot_name]) + slots.append(slot) + return slots + + @staticmethod + def _transform_intent_properties_for_internal_use( + intent: Dict[Text, Any], entity_properties: EntityProperties + ) -> Dict[Text, Any]: + """Transforms the intent's parameters in a format suitable for internal use. + + When an intent is retrieved from the `domain.yml` file, it contains two + parameters, the `use_entities` and the `ignore_entities` parameter. + With the values of these two parameters the Domain class is updated, a new + parameter is added to the intent called `used_entities` and the two + previous parameters are deleted. This happens because internally only the + parameter `used_entities` is needed to list all the entities that should be + used for this intent. + + Args: + intent: The intent as retrieved from the `domain.yml` file thus having two + parameters, the `use_entities` and the `ignore_entities` parameter. + entity_properties: Entity properties as provided by the domain file. + + Returns: + The intent with the new format thus having only one parameter called + `used_entities` since this is the expected format of the intent + when used internally. + """ + name, properties = list(intent.items())[0] + + if properties: + properties.setdefault(USE_ENTITIES_KEY, True) + else: + raise InvalidDomain( + f"In the `domain.yml` file, the intent '{name}' cannot have value of" + f" `{type(properties)}`. If you have placed a ':' character after the" + f" intent's name without adding any additional parameters to this" + f" intent then you would need to remove the ':' character. Please see" + f" {rasa.shared.constants.DOCS_URL_DOMAINS} for more information on how" + f" to correctly add `intents` in the `domain` and" + f" {rasa.shared.constants.DOCS_URL_INTENTS} for examples on" + f" when to use the ':' character after an intent's name." + ) + + properties.setdefault( + IGNORE_ENTITIES_KEY, entity_properties.default_ignored_entities + ) + if not properties[USE_ENTITIES_KEY]: # this covers False, None and [] + properties[USE_ENTITIES_KEY] = [] + + # `use_entities` is either a list of explicitly included entities + # or `True` if all should be included + # if the listed entities have a role or group label, concatenate the entity + # label with the corresponding role or group label to make sure roles and + # groups can also influence the dialogue predictions + if properties[USE_ENTITIES_KEY] is True: + included_entities = set(entity_properties.entities) - set( + entity_properties.default_ignored_entities + ) + included_entities.update( + Domain.concatenate_entity_labels(entity_properties.roles) + ) + included_entities.update( + Domain.concatenate_entity_labels(entity_properties.groups) + ) + else: + included_entities = set(properties[USE_ENTITIES_KEY]) + for entity in list(included_entities): + included_entities.update( + Domain.concatenate_entity_labels(entity_properties.roles, entity) + ) + included_entities.update( + Domain.concatenate_entity_labels(entity_properties.groups, entity) + ) + excluded_entities = set(properties[IGNORE_ENTITIES_KEY]) + for entity in list(excluded_entities): + excluded_entities.update( + Domain.concatenate_entity_labels(entity_properties.roles, entity) + ) + excluded_entities.update( + Domain.concatenate_entity_labels(entity_properties.groups, entity) + ) + used_entities = list(included_entities - excluded_entities) + used_entities.sort() + + # Only print warning for ambiguous configurations if entities were included + # explicitly. + explicitly_included = isinstance(properties[USE_ENTITIES_KEY], list) + ambiguous_entities = included_entities.intersection(excluded_entities) + if explicitly_included and ambiguous_entities: + rasa.shared.utils.io.raise_warning( + f"Entities: '{ambiguous_entities}' are explicitly included and" + f" excluded for intent '{name}'." + f"Excluding takes precedence in this case. " + f"Please resolve that ambiguity.", + docs=f"{DOCS_URL_DOMAINS}", + ) + + properties[USED_ENTITIES_KEY] = used_entities + del properties[USE_ENTITIES_KEY] + del properties[IGNORE_ENTITIES_KEY] + + return intent + + @rasa.shared.utils.common.lazy_property + def retrieval_intents(self) -> List[Text]: + """List retrieval intents present in the domain.""" + return [ + intent + for intent in self.intent_properties + if self.intent_properties[intent].get(IS_RETRIEVAL_INTENT_KEY) + ] + + @classmethod + def collect_entity_properties( + cls, domain_entities: List[Union[Text, Dict[Text, Any]]] + ) -> EntityProperties: + """Get entity properties for a domain from what is provided by a domain file. + + Args: + domain_entities: The entities as provided by a domain file. + + Returns: + An instance of EntityProperties. + """ + entity_properties = EntityProperties([], {}, {}, []) + for entity in domain_entities: + if isinstance(entity, str): + entity_properties.entities.append(entity) + elif isinstance(entity, dict): + for _entity, sub_labels in entity.items(): + entity_properties.entities.append(_entity) + if sub_labels: + if ENTITY_ROLES_KEY in sub_labels: + entity_properties.roles[_entity] = sub_labels[ + ENTITY_ROLES_KEY + ] + if ENTITY_GROUPS_KEY in sub_labels: + entity_properties.groups[_entity] = sub_labels[ + ENTITY_GROUPS_KEY + ] + if ( + ENTITY_FEATURIZATION_KEY in sub_labels + and sub_labels[ENTITY_FEATURIZATION_KEY] is False + ): + entity_properties.default_ignored_entities.append(_entity) + else: + raise InvalidDomain( + f"In the `domain.yml` file, the entity '{_entity}' cannot" + f" have value of `{type(sub_labels)}`. If you have placed a" + f" ':' character after the entity `{_entity}` without" + f" adding any additional parameters to this entity then you" + f" would need to remove the ':' character. Please see" + f" {rasa.shared.constants.DOCS_URL_DOMAINS} for more" + f" information on how to correctly add `entities` in the" + f" `domain` and {rasa.shared.constants.DOCS_URL_ENTITIES}" + f" for examples on when to use the ':' character after an" + f" entity's name." + ) + else: + raise InvalidDomain( + f"Invalid domain. Entity is invalid, type of entity '{entity}' " + f"not supported: '{type(entity).__name__}'" + ) + + return entity_properties + + @classmethod + def collect_intent_properties( + cls, + intents: List[Union[Text, Dict[Text, Any]]], + entity_properties: EntityProperties, + ) -> Dict[Text, Dict[Text, Union[bool, List]]]: + """Get intent properties for a domain from what is provided by a domain file. + + Args: + intents: The intents as provided by a domain file. + entity_properties: Entity properties as provided by the domain file. + + Returns: + The intent properties to be stored in the domain. + """ + # make a copy to not alter the input argument + intents = copy.deepcopy(intents) + intent_properties: Dict[Text, Any] = {} + duplicates = set() + + for intent in intents: + intent_name, properties = cls._intent_properties(intent, entity_properties) + + if intent_name in intent_properties.keys(): + duplicates.add(intent_name) + + intent_properties.update(properties) + + if duplicates: + raise InvalidDomain( + f"Intents are not unique! Found multiple intents " + f"with name(s) {sorted(duplicates)}. " + f"Either rename or remove the duplicate ones." + ) + + cls._add_default_intents(intent_properties, entity_properties) + + return intent_properties + + @classmethod + def _intent_properties( + cls, intent: Union[Text, Dict[Text, Any]], entity_properties: EntityProperties + ) -> Tuple[Text, Dict[Text, Any]]: + if not isinstance(intent, dict): + intent_name = intent + intent = { + intent_name: { + USE_ENTITIES_KEY: True, + IGNORE_ENTITIES_KEY: entity_properties.default_ignored_entities, + } + } + else: + intent_name = list(intent.keys())[0] + + return ( + intent_name, + cls._transform_intent_properties_for_internal_use( + intent, entity_properties + ), + ) + + @classmethod + def _add_default_intents( + cls, + intent_properties: Dict[Text, Dict[Text, Union[bool, List]]], + entity_properties: EntityProperties, + ) -> None: + for intent_name in rasa.shared.core.constants.DEFAULT_INTENTS: + if intent_name not in intent_properties: + _, properties = cls._intent_properties(intent_name, entity_properties) + intent_properties.update(properties) + + def __init__( + self, + intents: Union[Set[Text], List[Text], List[Dict[Text, Any]]], + entities: List[Union[Text, Dict[Text, Any]]], + slots: List[Slot], + responses: Dict[Text, List[Dict[Text, Any]]], + action_names: List[Text], + forms: Union[Dict[Text, Any], List[Text]], + data: Dict, + action_texts: Optional[List[Text]] = None, + store_entities_as_slots: bool = True, + session_config: SessionConfig = SessionConfig.default(), + **kwargs: Any, + ) -> None: + """Create a `Domain`. + + Args: + intents: Intent labels. + entities: The names of entities which might be present in user messages. + slots: Slots to store information during the conversation. + responses: Bot responses. If an action with the same name is executed, it + will send the matching response to the user. + action_names: Names of custom actions. + forms: Form names and their slot mappings. + data: original domain dict representation. + action_texts: End-to-End bot utterances from end-to-end stories. + store_entities_as_slots: If `True` Rasa will automatically create `SlotSet` + events for entities if there are slots with the same name as the entity. + session_config: Configuration for conversation sessions. Conversations are + restarted at the end of a session. + """ + self.entity_properties = self.collect_entity_properties(entities) + self.intent_properties = self.collect_intent_properties( + intents, self.entity_properties + ) + self.overridden_default_intents = self._collect_overridden_default_intents( + intents + ) + + self.form_names, self.forms, overridden_form_actions = self._initialize_forms( + forms + ) + action_names += overridden_form_actions + + self.responses = responses + + self.action_texts = action_texts if action_texts is not None else [] + + data_copy = copy.deepcopy(data) + self._data = self._preprocess_domain_dict( + data_copy, + store_entities_as_slots, + session_config, + ) + + self.session_config = session_config + + self._custom_actions = action_names + self._actions_which_explicitly_need_domain = ( + kwargs.get("actions_which_explicitly_need_domain") or [] + ) + + # only includes custom actions and utterance actions + self.user_actions = self._combine_with_responses(action_names, responses) + + # includes all action names (custom, utterance, default actions and forms) + # and action texts from end-to-end bot utterances + self.action_names_or_texts = ( + self._combine_user_with_default_actions(self.user_actions) + + [ + form_name + for form_name in self.form_names + if form_name not in self._custom_actions + ] + + self.action_texts + ) + + self._user_slots = copy.copy(slots) + self.slots = slots + self._add_default_slots() + self.store_entities_as_slots = store_entities_as_slots + self._check_domain_sanity() + + def __deepcopy__(self, memo: Optional[Dict[int, Any]]) -> "Domain": + """Enables making a deep copy of the `Domain` using `copy.deepcopy`. + + See https://docs.python.org/3/library/copy.html#copy.deepcopy + for more implementation. + + Args: + memo: Optional dictionary of objects already copied during the current + copying pass. + + Returns: + A deep copy of the current domain. + """ + domain_dict = self.as_dict() + return self.__class__.from_dict(copy.deepcopy(domain_dict, memo)) + + def count_conditional_response_variations(self) -> int: + """Returns count of conditional response variations.""" + count = 0 + for response_variations in self.responses.values(): + for variation in response_variations: + if RESPONSE_CONDITION in variation: + count += 1 + + return count + + @staticmethod + def _collect_overridden_default_intents( + intents: Union[Set[Text], List[Text], List[Dict[Text, Any]]] + ) -> List[Text]: + """Collects the default intents overridden by the user. + + Args: + intents: User-provided intents. + + Returns: + User-defined intents that are default intents. + """ + intent_names: Set[Text] = { + list(intent.keys())[0] if isinstance(intent, dict) else intent + for intent in intents + } + return sorted( + intent_names.intersection(set(rasa.shared.core.constants.DEFAULT_INTENTS)) + ) + + @staticmethod + def _initialize_forms( + forms: Dict[Text, Any] + ) -> Tuple[List[Text], Dict[Text, Any], List[Text]]: + """Retrieves the initial values for the Domain's form fields. + + Args: + forms: Parsed content of the `forms` section in the domain. + + Returns: + The form names, a mapping of form names and required slots, and custom + actions. + Returning custom actions for each forms means that Rasa Open Source should + not use the default `FormAction` for the forms, but rather a custom action + for it. This can e.g. be used to run the deprecated Rasa Open Source 1 + `FormAction` which is implemented in the Rasa SDK. + """ + for form_name, form_data in forms.items(): + if form_data is not None and REQUIRED_SLOTS_KEY not in form_data: + forms[form_name] = {REQUIRED_SLOTS_KEY: form_data} + return list(forms.keys()), forms, [] + + def __hash__(self) -> int: + """Returns a unique hash for the domain.""" + return int(self.fingerprint(), 16) + + def fingerprint(self) -> Text: + """Returns a unique hash for the domain which is stable across python runs. + + Returns: + fingerprint of the domain + """ + self_as_dict = self.as_dict() + transformed_intents: List[Text] = [] + for intent in self_as_dict.get(KEY_INTENTS, []): + if isinstance(intent, dict): + transformed_intents.append(*intent.keys()) + elif isinstance(intent, str): + transformed_intents.append(intent) + + self_as_dict[KEY_INTENTS] = sorted(transformed_intents) + self_as_dict[KEY_ACTIONS] = self.action_names_or_texts + return rasa.shared.utils.io.get_dictionary_fingerprint(self_as_dict) + + @staticmethod + def _sort_intent_names_alphabetical_order( + intents: List[Union[Text, Dict]] + ) -> List[Union[Text, Dict]]: + def sort(elem: Union[Text, Dict]) -> Union[Text, Dict]: + if isinstance(elem, dict): + return list(elem.keys())[0] + elif isinstance(elem, str): + return elem + + sorted_intents = sorted(intents, key=sort) + return sorted_intents + + @rasa.shared.utils.common.lazy_property + def user_actions_and_forms(self) -> List[Text]: + """Returns combination of user actions and forms.""" + return self.user_actions + self.form_names + + @rasa.shared.utils.common.lazy_property + def num_actions(self) -> int: + """Returns the number of available actions.""" + # noinspection PyTypeChecker + return len(self.action_names_or_texts) + + @rasa.shared.utils.common.lazy_property + def num_states(self) -> int: + """Number of used input states for the action prediction.""" + return len(self.input_states) + + @rasa.shared.utils.common.lazy_property + def retrieval_intent_responses(self) -> Dict[Text, List[Dict[Text, Any]]]: + """Return only the responses which are defined for retrieval intents.""" + return dict( + filter( + lambda intent_response: self.is_retrieval_intent_response( + intent_response + ), + self.responses.items(), + ) + ) + + @staticmethod + def is_retrieval_intent_response( + response: Tuple[Text, List[Dict[Text, Any]]] + ) -> bool: + """Check if the response is for a retrieval intent. + + These responses have a `/` symbol in their name. Use that to filter them from + the rest. + """ + return RESPONSE_IDENTIFIER_DELIMITER in response[0] + + def _add_default_slots(self) -> None: + """Sets up the default slots and slot values for the domain.""" + self._add_requested_slot() + self._add_knowledge_base_slots() + self._add_categorical_slot_default_value() + self._add_session_metadata_slot() + + def _add_categorical_slot_default_value(self) -> None: + """Add a default value to all categorical slots. + + All unseen values found for the slot will be mapped to this default value + for featurization. + """ + for slot in [s for s in self.slots if isinstance(s, CategoricalSlot)]: + slot.add_default_value() + + def _add_requested_slot(self) -> None: + """Add a slot called `requested_slot` to the list of slots. + + The value of this slot will hold the name of the slot which the user + needs to fill in next (either explicitly or implicitly) as part of a form. + """ + if self.form_names and rasa.shared.core.constants.REQUESTED_SLOT not in [ + slot.name for slot in self.slots + ]: + self.slots.append( + TextSlot( + rasa.shared.core.constants.REQUESTED_SLOT, + mappings=[], + influence_conversation=False, + ) + ) + + def _add_knowledge_base_slots(self) -> None: + """Add slots for the knowledge base action to slots. + + Slots are only added if the default knowledge base action name is present. + + As soon as the knowledge base action is not experimental anymore, we should + consider creating a new section in the domain file dedicated to knowledge + base slots. + """ + if ( + rasa.shared.core.constants.DEFAULT_KNOWLEDGE_BASE_ACTION + in self.action_names_or_texts + ): + logger.warning( + "You are using an experimental feature: Action '{}'!".format( + rasa.shared.core.constants.DEFAULT_KNOWLEDGE_BASE_ACTION + ) + ) + slot_names = [slot.name for slot in self.slots] + knowledge_base_slots = [ + rasa.shared.core.constants.SLOT_LISTED_ITEMS, + rasa.shared.core.constants.SLOT_LAST_OBJECT, + rasa.shared.core.constants.SLOT_LAST_OBJECT_TYPE, + ] + for slot in knowledge_base_slots: + if slot not in slot_names: + self.slots.append( + TextSlot(slot, mappings=[], influence_conversation=False) + ) + + def _add_session_metadata_slot(self) -> None: + self.slots.append( + AnySlot(rasa.shared.core.constants.SESSION_START_METADATA_SLOT, mappings=[]) + ) + + def index_for_action(self, action_name: Text) -> int: + """Looks up which action index corresponds to this action name.""" + try: + return self.action_names_or_texts.index(action_name) + except ValueError: + self.raise_action_not_found_exception(action_name) + + def raise_action_not_found_exception(self, action_name_or_text: Text) -> NoReturn: + """Raises exception if action name or text not part of the domain or stories. + + Args: + action_name_or_text: Name of an action or its text in case it's an + end-to-end bot utterance. + + Raises: + ActionNotFoundException: If `action_name_or_text` are not part of this + domain. + """ + action_names = "\n".join([f"\t - {a}" for a in self.action_names_or_texts]) + raise ActionNotFoundException( + f"Cannot access action '{action_name_or_text}', " + f"as that name is not a registered " + f"action for this domain. " + f"Available actions are: \n{action_names}" + ) + + # noinspection PyTypeChecker + @rasa.shared.utils.common.lazy_property + def slot_states(self) -> List[Text]: + """Returns all available slot state strings.""" + return [ + f"{slot.name}_{feature_index}" + for slot in self.slots + for feature_index in range(0, slot.feature_dimensionality()) + ] + + # noinspection PyTypeChecker + @rasa.shared.utils.common.lazy_property + def entity_states(self) -> List[Text]: + """Returns all available entity state strings.""" + entity_states = copy.deepcopy(self.entities) + entity_states.extend( + Domain.concatenate_entity_labels(self.entity_properties.roles) + ) + entity_states.extend( + Domain.concatenate_entity_labels(self.entity_properties.groups) + ) + + return entity_states + + @staticmethod + def concatenate_entity_labels( + entity_labels: Dict[Text, List[Text]], entity: Optional[Text] = None + ) -> List[Text]: + """Concatenates the given entity labels with their corresponding sub-labels. + + If a specific entity label is given, only this entity label will be + concatenated with its corresponding sub-labels. + + Args: + entity_labels: A map of an entity label to its sub-label list. + entity: If present, only this entity will be considered. + + Returns: + A list of labels. + """ + if entity is not None and entity not in entity_labels: + return [] + + if entity: + return [ + f"{entity}" + f"{rasa.shared.core.constants.ENTITY_LABEL_SEPARATOR}" + f"{sub_label}" + for sub_label in entity_labels[entity] + ] + + return [ + f"{entity_label}" + f"{rasa.shared.core.constants.ENTITY_LABEL_SEPARATOR}" + f"{entity_sub_label}" + for entity_label, entity_sub_labels in entity_labels.items() + for entity_sub_label in entity_sub_labels + ] + + @rasa.shared.utils.common.lazy_property + def input_state_map(self) -> Dict[Text, int]: + """Provide a mapping from state names to indices.""" + return {f: i for i, f in enumerate(self.input_states)} + + @rasa.shared.utils.common.lazy_property + def input_states(self) -> List[Text]: + """Returns all available states.""" + return ( + self.intents + + self.entity_states + + self.slot_states + + self.action_names_or_texts + + self.form_names + ) + + def _get_featurized_entities(self, latest_message: UserUttered) -> Set[Text]: + """Gets the names of all entities that are present and wanted in the message. + + Wherever an entity has a role or group specified as well, an additional role- + or group-specific entity name is added. + """ + intent_name = latest_message.intent.get(INTENT_NAME_KEY) + intent_config = self.intent_config(intent_name) + entities = latest_message.entities + + # If Entity Roles and Groups is used, we also need to make sure the roles and + # groups get featurized. We concatenate the entity label with the role/group + # label using a special separator to make sure that the resulting label is + # unique (as you can have the same role/group label for different entities). + entity_names_basic = set( + entity["entity"] for entity in entities if "entity" in entity.keys() + ) + entity_names_roles = set( + f"{entity['entity']}" + f"{rasa.shared.core.constants.ENTITY_LABEL_SEPARATOR}{entity['role']}" + for entity in entities + if "entity" in entity.keys() and "role" in entity.keys() + ) + entity_names_groups = set( + f"{entity['entity']}" + f"{rasa.shared.core.constants.ENTITY_LABEL_SEPARATOR}{entity['group']}" + for entity in entities + if "entity" in entity.keys() and "group" in entity.keys() + ) + entity_names = entity_names_basic.union(entity_names_roles, entity_names_groups) + + # the USED_ENTITIES_KEY of an intent also contains the entity labels and the + # concatenated entity labels with their corresponding roles and groups labels + wanted_entities = set(intent_config.get(USED_ENTITIES_KEY, entity_names)) + + return entity_names.intersection(wanted_entities) + + def _get_user_sub_state(self, tracker: "DialogueStateTracker") -> SubState: + """Turns latest UserUttered event into a substate. + + The substate will contain intent, text, and entities (if any are present). + + Args: + tracker: dialog state tracker containing the dialog so far + Returns: + a dictionary containing intent, text and set entities + """ + # proceed with values only if the user of a bot have done something + # at the previous step i.e., when the state is not empty. + latest_message = tracker.latest_message + if not latest_message or latest_message.is_empty(): + return {} + + sub_state = cast(SubState, latest_message.as_sub_state()) + + # Filter entities based on intent config. We need to convert the set into a + # tuple because sub_state will be later transformed into a frozenset (so it can + # be hashed for deduplication). + entities = tuple( + self._get_featurized_entities(latest_message).intersection( + set(sub_state.get(ENTITIES, ())) + ) + ) + # Sort entities so that any derived state representation is consistent across + # runs and invariant to the order in which the entities for an utterance are + # listed in data files. + entities = tuple(sorted(entities)) + + if entities: + sub_state[ENTITIES] = entities + else: + sub_state.pop(ENTITIES, None) + + return sub_state + + @staticmethod + def _get_slots_sub_state( + tracker: "DialogueStateTracker", omit_unset_slots: bool = False + ) -> SubState: + """Sets all set slots with the featurization of the stored value. + + Args: + tracker: dialog state tracker containing the dialog so far + omit_unset_slots: If `True` do not include the initial values of slots. + + Returns: + a mapping of slot names to their featurization + """ + slots: SubState = {} + for slot_name, slot in tracker.slots.items(): + # If the slot doesn't influence conversations, slot.as_feature() will return + # a result that evaluates to False, meaning that the slot shouldn't be + # included in featurised sub-states. + # Note that this condition checks if the slot itself is None. An unset slot + # will be a Slot object and its `value` attribute will be None. + if slot is not None and slot.as_feature(): + if omit_unset_slots and not slot.has_been_set: + continue + if slot.value == rasa.shared.core.constants.SHOULD_NOT_BE_SET: + slots[slot_name] = rasa.shared.core.constants.SHOULD_NOT_BE_SET + elif any(slot.as_feature()): + # Only include slot in featurised sub-state if the slot is not + # unset, i.e. is set to some actual value and has been successfully + # featurized, and hence has at least one non-zero feature. + slots[slot_name] = tuple(slot.as_feature()) + return slots + + @staticmethod + def _get_prev_action_sub_state( + tracker: "DialogueStateTracker", + ) -> Optional[Dict[Text, Text]]: + """Turn the previous taken action into a state name. + + Args: + tracker: dialog state tracker containing the dialog so far + Returns: + a dictionary with the information on latest action + """ + return tracker.latest_action + + @staticmethod + def _get_active_loop_sub_state( + tracker: "DialogueStateTracker", + ) -> Dict[Text, Optional[Text]]: + """Turn tracker's active loop into a state name. + + Args: + tracker: dialog state tracker containing the dialog so far + Returns: + a dictionary mapping "name" to active loop name if present + """ + # we don't use tracker.active_loop_name + # because we need to keep should_not_be_set + if tracker.active_loop: + return {rasa.shared.core.constants.LOOP_NAME: tracker.active_loop.name} + else: + return {} + + @staticmethod + def _clean_state(state: State) -> State: + return { + state_type: sub_state + for state_type, sub_state in state.items() + if sub_state + } + + def get_active_state( + self, tracker: "DialogueStateTracker", omit_unset_slots: bool = False + ) -> State: + """Given a dialogue tracker, makes a representation of current dialogue state. + + Args: + tracker: dialog state tracker containing the dialog so far + omit_unset_slots: If `True` do not include the initial values of slots. + + Returns: + A representation of the dialogue's current state. + """ + state = { + rasa.shared.core.constants.USER: self._get_user_sub_state(tracker), + rasa.shared.core.constants.SLOTS: self._get_slots_sub_state( + tracker, omit_unset_slots=omit_unset_slots + ), + rasa.shared.core.constants.PREVIOUS_ACTION: self._get_prev_action_sub_state( + tracker + ), + rasa.shared.core.constants.ACTIVE_LOOP: self._get_active_loop_sub_state( + tracker + ), + } + return self._clean_state(state) + + @staticmethod + def _remove_rule_only_features( + state: State, rule_only_data: Optional[Dict[Text, Any]] + ) -> None: + if not rule_only_data: + return + + rule_only_slots = rule_only_data.get( + rasa.shared.core.constants.RULE_ONLY_SLOTS, [] + ) + rule_only_loops = rule_only_data.get( + rasa.shared.core.constants.RULE_ONLY_LOOPS, [] + ) + + # remove slots which only occur in rules but not in stories + if rule_only_slots: + for slot in rule_only_slots: + state.get(rasa.shared.core.constants.SLOTS, {}).pop(slot, None) + # remove active loop which only occur in rules but not in stories + if ( + rule_only_loops + and state.get(rasa.shared.core.constants.ACTIVE_LOOP, {}).get( + rasa.shared.core.constants.LOOP_NAME + ) + in rule_only_loops + ): + del state[rasa.shared.core.constants.ACTIVE_LOOP] + + @staticmethod + def _substitute_rule_only_user_input(state: State, last_ml_state: State) -> None: + if not rasa.shared.core.trackers.is_prev_action_listen_in_state(state): + if not last_ml_state.get(rasa.shared.core.constants.USER) and state.get( + rasa.shared.core.constants.USER + ): + del state[rasa.shared.core.constants.USER] + elif last_ml_state.get(rasa.shared.core.constants.USER): + state[rasa.shared.core.constants.USER] = last_ml_state[ + rasa.shared.core.constants.USER + ] + + def states_for_tracker_history( + self, + tracker: "DialogueStateTracker", + omit_unset_slots: bool = False, + ignore_rule_only_turns: bool = False, + rule_only_data: Optional[Dict[Text, Any]] = None, + ) -> List[State]: + """List of states for each state of the trackers history. + + Args: + tracker: Dialogue state tracker containing the dialogue so far. + omit_unset_slots: If `True` do not include the initial values of slots. + ignore_rule_only_turns: If True ignore dialogue turns that are present + only in rules. + rule_only_data: Slots and loops, + which only occur in rules but not in stories. + + Return: + A list of states. + """ + states: List[State] = [] + last_ml_action_sub_state = None + turn_was_hidden = False + for tr, hide_rule_turn in tracker.generate_all_prior_trackers(): + if ignore_rule_only_turns: + # remember previous ml action based on the last non hidden turn + # we need this to override previous action in the ml state + if not turn_was_hidden: + last_ml_action_sub_state = self._get_prev_action_sub_state(tr) + + # followup action or happy path loop prediction + # don't change the fact whether dialogue turn should be hidden + if ( + not tr.followup_action + and not tr.latest_action_name == tr.active_loop_name + ): + turn_was_hidden = hide_rule_turn + + if turn_was_hidden: + continue + + state = self.get_active_state(tr, omit_unset_slots=omit_unset_slots) + + if ignore_rule_only_turns: + # clean state from only rule features + self._remove_rule_only_features(state, rule_only_data) + # make sure user input is the same as for previous state + # for non action_listen turns + if states: + self._substitute_rule_only_user_input(state, states[-1]) + # substitute previous rule action with last_ml_action_sub_state + if last_ml_action_sub_state: + # FIXME: better type annotation for `State` would require + # a larger refactoring (e.g. switch to dataclass) + state[rasa.shared.core.constants.PREVIOUS_ACTION] = cast( + SubState, + last_ml_action_sub_state, + ) + + states.append(self._clean_state(state)) + + return states + + def slots_for_entities(self, entities: List[Dict[Text, Any]]) -> List[SlotSet]: + """Creates slot events for entities if from_entity mapping matches. + + Args: + entities: The list of entities. + + Returns: + A list of `SlotSet` events. + """ + if self.store_entities_as_slots: + slot_events = [] + + for slot in self.slots: + matching_entities = [] + + for mapping in slot.mappings: + mapping_conditions = mapping.get(MAPPING_CONDITIONS) + if mapping[MAPPING_TYPE] != str(SlotMappingType.FROM_ENTITY) or ( + mapping_conditions + and mapping_conditions[0].get(ACTIVE_LOOP) is not None + ): + continue + + for entity in entities: + if ( + entity.get(ENTITY_ATTRIBUTE_TYPE) + == mapping.get(ENTITY_ATTRIBUTE_TYPE) + and entity.get(ENTITY_ATTRIBUTE_ROLE) + == mapping.get(ENTITY_ATTRIBUTE_ROLE) + and entity.get(ENTITY_ATTRIBUTE_GROUP) + == mapping.get(ENTITY_ATTRIBUTE_GROUP) + ): + matching_entities.append(entity.get("value")) + + if matching_entities: + if isinstance(slot, ListSlot): + slot_events.append(SlotSet(slot.name, matching_entities)) + else: + slot_events.append(SlotSet(slot.name, matching_entities[-1])) + + return slot_events + else: + return [] + + def persist_specification(self, model_path: Text) -> None: + """Persist the domain specification to storage.""" + domain_spec_path = os.path.join(model_path, "domain.json") + rasa.shared.utils.io.create_directory_for_file(domain_spec_path) + + metadata = {"states": self.input_states} + rasa.shared.utils.io.dump_obj_as_json_to_file(domain_spec_path, metadata) + + @classmethod + def load_specification(cls, path: Text) -> Dict[Text, Any]: + """Load a domains specification from a dumped model directory.""" + metadata_path = os.path.join(path, "domain.json") + + return json.loads(rasa.shared.utils.io.read_file(metadata_path)) + + def compare_with_specification(self, path: Text) -> bool: + """Compare the domain spec of the current and the loaded domain. + + Throws exception if the loaded domain specification is different + to the current domain are different. + """ + loaded_domain_spec = self.load_specification(path) + states = loaded_domain_spec["states"] + + if set(states) != set(self.input_states): + missing = ",".join(set(states) - set(self.input_states)) + additional = ",".join(set(self.input_states) - set(states)) + raise InvalidDomain( + f"Domain specification has changed. " + f"You MUST retrain the policy. " + f"Detected mismatch in domain specification. " + f"The following states have been \n" + f"\t - removed: {missing} \n" + f"\t - added: {additional} " + ) + else: + return True + + def as_dict(self) -> Dict[Text, Any]: + """Return serialized `Domain`.""" + return self._data + + @staticmethod + def get_responses_with_multilines( + responses: Dict[Text, List[Dict[Text, Any]]] + ) -> Dict[Text, List[Dict[Text, Any]]]: + """Returns `responses` with preserved multilines in the `text` key. + + Args: + responses: Original `responses`. + + Returns: + `responses` with preserved multilines in the `text` key. + """ + from ruamel.yaml.scalarstring import LiteralScalarString + + final_responses = responses.copy() + for utter_action, examples in final_responses.items(): + for i, example in enumerate(examples): + response_text = example.get(KEY_RESPONSES_TEXT, "") + if not response_text or "\n" not in response_text: + continue + # Has new lines, use `LiteralScalarString` + final_responses[utter_action][i][ + KEY_RESPONSES_TEXT + ] = LiteralScalarString(response_text) + + return final_responses + + @staticmethod + def _cleaned_data(data: Dict[Text, Any]) -> Dict[Text, Any]: + """Remove empty and redundant keys from merged domain dict. + + Returns: + A cleaned dictionary version of the domain. + """ + return { + key: val + for key, val in data.items() + if val != {} and val != [] and val is not None + } + + def persist(self, filename: Union[Text, Path]) -> None: + """Write domain to a file.""" + as_yaml = self.as_yaml() + rasa.shared.utils.io.write_text_file(as_yaml, filename) + + def as_yaml(self) -> Text: + """Dump the `Domain` object as a YAML string. + + This function preserves the orders of the keys in the domain. + + Returns: + A string in YAML format representing the domain. + """ + # setting the `version` key first so that it appears at the top of YAML files + # thanks to the `should_preserve_key_order` argument + # of `dump_obj_as_yaml_to_string` + domain_data: Dict[Text, Any] = { + KEY_TRAINING_DATA_FORMAT_VERSION: DoubleQuotedScalarString( + LATEST_TRAINING_DATA_FORMAT_VERSION + ) + } + + domain_data.update(self.as_dict()) + + if domain_data.get(KEY_RESPONSES, {}): + domain_data[KEY_RESPONSES] = self.get_responses_with_multilines( + domain_data[KEY_RESPONSES] + ) + + return rasa.shared.utils.io.dump_obj_as_yaml_to_string( + domain_data, should_preserve_key_order=True + ) + + def intent_config(self, intent_name: Text) -> Dict[Text, Any]: + """Return the configuration for an intent.""" + return self.intent_properties.get(intent_name, {}) + + @rasa.shared.utils.common.lazy_property + def intents(self) -> List[Text]: + """Returns sorted list of intents.""" + return sorted(self.intent_properties.keys()) + + @rasa.shared.utils.common.lazy_property + def entities(self) -> List[Text]: + """Returns sorted list of entities.""" + return sorted(self.entity_properties.entities) + + @property + def _slots_for_domain_warnings(self) -> List[Text]: + """Fetch names of slots that are used in domain warnings. + + Excludes slots which aren't featurized. + """ + return [slot.name for slot in self._user_slots if slot.influence_conversation] + + @property + def _actions_for_domain_warnings(self) -> List[Text]: + """Fetch names of actions that are used in domain warnings. + + Includes user and form actions, but excludes those that are default actions. + """ + return [ + action + for action in self.user_actions_and_forms + if action not in rasa.shared.core.constants.DEFAULT_ACTION_NAMES + ] + + @staticmethod + def _get_symmetric_difference( + domain_elements: Union[List[Text], Set[Text]], + training_data_elements: Optional[Union[List[Text], Set[Text]]], + ) -> Dict[Text, Set[Text]]: + """Gets the symmetric difference between two sets. + + One set represents domain elements and the other one is a set of training + data elements. + + Returns a dictionary containing a list of items found in the `domain_elements` + but not in `training_data_elements` at key `in_domain`, and a list of items + found in `training_data_elements` but not in `domain_elements` at key + `in_training_data_set`. + """ + if training_data_elements is None: + training_data_elements = set() + + in_domain_diff = set(domain_elements) - set(training_data_elements) + in_training_data_diff = set(training_data_elements) - set(domain_elements) + + return {"in_domain": in_domain_diff, "in_training_data": in_training_data_diff} + + @staticmethod + def _combine_with_responses( + actions: List[Text], responses: Dict[Text, Any] + ) -> List[Text]: + """Combines actions with utter actions listed in responses section.""" + unique_utter_actions = [ + action for action in sorted(list(responses.keys())) if action not in actions + ] + return actions + unique_utter_actions + + @staticmethod + def _combine_user_with_default_actions(user_actions: List[Text]) -> List[Text]: + # remove all user actions that overwrite default actions + # this logic is a bit reversed, you'd think that we should remove + # the action name from the default action names if the user overwrites + # the action, but there are some locations in the code where we + # implicitly assume that e.g. "action_listen" is always at location + # 0 in this array. to keep it that way, we remove the duplicate + # action names from the users list instead of the defaults + unique_user_actions = [ + action + for action in user_actions + if action not in rasa.shared.core.constants.DEFAULT_ACTION_NAMES + ] + return rasa.shared.core.constants.DEFAULT_ACTION_NAMES + unique_user_actions + + def domain_warnings( + self, + intents: Optional[Union[List[Text], Set[Text]]] = None, + entities: Optional[Union[List[Text], Set[Text]]] = None, + actions: Optional[Union[List[Text], Set[Text]]] = None, + slots: Optional[Union[List[Text], Set[Text]]] = None, + ) -> Dict[Text, Dict[Text, Set[Text]]]: + """Generate domain warnings from intents, entities, actions and slots. + + Returns a dictionary with entries for `intent_warnings`, + `entity_warnings`, `action_warnings` and `slot_warnings`. Excludes domain slots + from domain warnings in case they are not featurized. + """ + intent_warnings = self._get_symmetric_difference(self.intents, intents) + entity_warnings = self._get_symmetric_difference(self.entities, entities) + action_warnings = self._get_symmetric_difference( + self._actions_for_domain_warnings, actions + ) + slot_warnings = self._get_symmetric_difference( + self._slots_for_domain_warnings, slots + ) + + return { + "intent_warnings": intent_warnings, + "entity_warnings": entity_warnings, + "action_warnings": action_warnings, + "slot_warnings": slot_warnings, + } + + def _check_domain_sanity(self) -> None: + """Make sure the domain is properly configured. + + If the domain contains any duplicate slots, intents, actions + or entities, an InvalidDomain error is raised. This error + is also raised when intent-action mappings are incorrectly + named or a response is missing. + """ + + def get_duplicates(my_items: Iterable[Any]) -> List[Any]: + """Returns a list of duplicate items in my_items.""" + return [ + item + for item, count in collections.Counter(my_items).items() + if count > 1 + ] + + def check_mappings( + intent_properties: Dict[Text, Dict[Text, Union[bool, List]]] + ) -> List[Tuple[Text, Text]]: + """Checks whether intent-action mappings use valid action names or texts.""" + incorrect = [] + for intent, properties in intent_properties.items(): + if "triggers" in properties: + triggered_action = properties.get("triggers") + if triggered_action not in self.action_names_or_texts: + incorrect.append((intent, str(triggered_action))) + return incorrect + + def get_exception_message( + duplicates: Optional[List[Tuple[List[Text], Text]]] = None, + mappings: List[Tuple[Text, Text]] = None, + ) -> Text: + """Return a message given a list of error locations.""" + message = "" + if duplicates: + message += get_duplicate_exception_message(duplicates) + if mappings: + if message: + message += "\n" + message += get_mapping_exception_message(mappings) + return message + + def get_mapping_exception_message(mappings: List[Tuple[Text, Text]]) -> Text: + """Return a message given a list of duplicates.""" + message = "" + for name, action_name in mappings: + if message: + message += "\n" + message += ( + "Intent '{}' is set to trigger action '{}', which is " + "not defined in the domain.".format(name, action_name) + ) + return message + + def get_duplicate_exception_message( + duplicates: List[Tuple[List[Text], Text]] + ) -> Text: + """Return a message given a list of duplicates.""" + message = "" + for d, name in duplicates: + if d: + if message: + message += "\n" + message += ( + f"Duplicate {name} in domain. " + f"These {name} occur more than once in " + f"the domain: '{', '.join(d)}'." + ) + return message + + duplicate_actions = get_duplicates(self.action_names_or_texts) + duplicate_slots = get_duplicates([s.name for s in self.slots]) + duplicate_entities = get_duplicates(self.entities) + incorrect_mappings = check_mappings(self.intent_properties) + + if ( + duplicate_actions + or duplicate_slots + or duplicate_entities + or incorrect_mappings + ): + raise InvalidDomain( + get_exception_message( + [ + (duplicate_actions, KEY_ACTIONS), + (duplicate_slots, KEY_SLOTS), + (duplicate_entities, KEY_ENTITIES), + ], + incorrect_mappings, + ) + ) + + @property + def utterances_for_response(self) -> Set[Text]: + """Returns utterance set which should have a response. + + Will filter out utterances which are subintent (retrieval intent) types. + eg. if actions have ['utter_chitchat', 'utter_chitchat/greet'], this + will only return ['utter_chitchat/greet'] as only that will need a + response. + """ + utterances = set() + subintent_parents = set() + for action in self.action_names_or_texts: + if not action.startswith(rasa.shared.constants.UTTER_PREFIX): + continue + action_parent_split = action.split(RESPONSE_IDENTIFIER_DELIMITER) + if len(action_parent_split) == 2: + action_parent = action_parent_split[0] + subintent_parents.add(action_parent) + utterances.add(action) + return utterances - subintent_parents + + def check_missing_responses(self) -> None: + """Warn user of utterance names which have no specified response.""" + missing_responses = self.utterances_for_response - set(self.responses) + + for response in missing_responses: + rasa.shared.utils.io.raise_warning( + f"Action '{response}' is listed as a " + f"response action in the domain file, but there is " + f"no matching response defined. Please check your domain.", + docs=DOCS_URL_RESPONSES, + ) + + def is_empty(self) -> bool: + """Check whether the domain is empty.""" + return self.as_dict() == Domain.empty().as_dict() + + @staticmethod + def is_domain_file(filename: Union[Text, Path]) -> bool: + """Checks whether the given file path is a Rasa domain file. + + Args: + filename: Path of the file which should be checked. + + Returns: + `True` if it's a domain file, otherwise `False`. + + Raises: + YamlException: if the file seems to be a YAML file (extension) but + can not be read / parsed. + """ + from rasa.shared.data import is_likely_yaml_file + + if not is_likely_yaml_file(filename): + return False + + try: + content = rasa.shared.utils.io.read_yaml_file(filename) + except (RasaException, YamlSyntaxException): + rasa.shared.utils.io.raise_warning( + message=f"The file {filename} could not be loaded as domain file. " + + "You can use https://yamlchecker.com/ to validate " + + "the YAML syntax of your file.", + category=UserWarning, + ) + return False + + return any(key in content for key in ALL_DOMAIN_KEYS) + + def required_slots_for_form(self, form_name: Text) -> List[Text]: + """Retrieve the list of required slot names for a form defined in the domain. + + Args: + form_name: The name of the form. + + Returns: + The list of slot names or an empty list if no form was found. + """ + form = self.forms.get(form_name) + if form: + return form[REQUIRED_SLOTS_KEY] + + return [] + + def count_slot_mapping_statistics(self) -> Tuple[int, int, int]: + """Counts the total number of slot mappings and custom slot mappings. + + Returns: + A triple of integers where the first entry is the total number of mappings, + the second entry is the total number of custom mappings, and the third entry + is the total number of mappings which have conditions attached. + """ + total_mappings = 0 + custom_mappings = 0 + conditional_mappings = 0 + + for slot in self.slots: + total_mappings += len(slot.mappings) + for mapping in slot.mappings: + if mapping[MAPPING_TYPE] == str(SlotMappingType.CUSTOM): + custom_mappings += 1 + + if MAPPING_CONDITIONS in mapping: + conditional_mappings += 1 + + return (total_mappings, custom_mappings, conditional_mappings) + + def does_custom_action_explicitly_need_domain(self, action_name: Text) -> bool: + """Assert if action has explicitly stated that it needs domain. + + Args: + action_name: Name of the action to be checked + + Returns: + True if action has explicitly stated that it needs domain. + Otherwise, it returns false. + """ + return action_name in self._actions_which_explicitly_need_domain + + def __repr__(self) -> Text: + """Returns text representation of object.""" + return ( + f"{self.__class__.__name__}: {len(self.action_names_or_texts)} actions, " + f"{len(self.intent_properties)} intents, {len(self.responses)} responses, " + f"{len(self.slots)} slots, " + f"{len(self.entities)} entities, {len(self.form_names)} forms" + ) + + @staticmethod + def _collect_action_names( + actions: List[Union[Text, Dict[Text, Any]]] + ) -> List[Text]: + action_names: List[Text] = [] + + for action in actions: + if isinstance(action, dict): + action_names.extend(list(action.keys())) + elif isinstance(action, str): + action_names += [action] + + return action_names + + @staticmethod + def _collect_actions_which_explicitly_need_domain( + actions: List[Union[Text, Dict[Text, Any]]] + ) -> List[Text]: + action_names: List[Text] = [] + + for action in actions: + if isinstance(action, dict): + for action_name, action_config in action.items(): + should_send_domain = action_config.get( + ACTION_SHOULD_SEND_DOMAIN, False + ) + if should_send_domain: + action_names += [action_name] + + elif action.startswith("validate_"): + action_names += [action] + + return action_names + + +def warn_about_duplicates_found_during_domain_merging( + duplicates: Dict[Text, List[Text]] +) -> None: + """Emits warning about found duplicates while loading multiple domain paths.""" + message = "" + for key in [ + KEY_INTENTS, + KEY_FORMS, + KEY_ACTIONS, + KEY_E2E_ACTIONS, + KEY_RESPONSES, + KEY_SLOTS, + KEY_ENTITIES, + ]: + duplicates_per_key = duplicates.get(key) + if duplicates_per_key: + if message: + message += " \n" + + duplicates_per_key_str = ", ".join(duplicates_per_key) + message += ( + f"The following duplicated {key} have been found " + f"across multiple domain files: {duplicates_per_key_str}" + ) + + rasa.shared.utils.io.raise_warning(message, docs=DOCS_URL_DOMAINS) + return None + + +def _validate_forms(forms: Union[Dict, List]) -> None: + if not isinstance(forms, dict): + raise InvalidDomain("Forms have to be specified as dictionary.") + + for form_name, form_data in forms.items(): + if form_data is None: + continue + + if not isinstance(form_data, Dict): + raise InvalidDomain( + f"The contents of form '{form_name}' were specified " + f"as '{type(form_data)}'. They need to be specified " + f"as dictionary. Please see {DOCS_URL_FORMS} " + f"for more information." + ) + + if IGNORED_INTENTS in form_data and REQUIRED_SLOTS_KEY not in form_data: + raise InvalidDomain( + f"If you use the `{IGNORED_INTENTS}` parameter in your form, then " + f"the keyword `{REQUIRED_SLOTS_KEY}` is required. " + f"Please see {DOCS_URL_FORMS} for more information." + ) diff --git a/rasa/shared/core/events.py b/rasa/shared/core/events.py new file mode 100644 index 0000000..3b987de --- /dev/null +++ b/rasa/shared/core/events.py @@ -0,0 +1,2020 @@ +import abc +import copy +import json +import logging +import structlog +import re +from abc import ABC + +import jsonpickle +import time +import uuid +from dateutil import parser +from datetime import datetime +from typing import ( + List, + Dict, + Text, + Any, + Type, + Optional, + TYPE_CHECKING, + Iterable, + cast, + Tuple, + TypeVar, +) + +import rasa.shared.utils.common +import rasa.shared.utils.io +from typing import Union + +from rasa.shared.constants import DOCS_URL_TRAINING_DATA +from rasa.shared.core.constants import ( + LOOP_NAME, + EXTERNAL_MESSAGE_PREFIX, + ACTION_NAME_SENDER_ID_CONNECTOR_STR, + IS_EXTERNAL, + USE_TEXT_FOR_FEATURIZATION, + LOOP_INTERRUPTED, + ENTITY_LABEL_SEPARATOR, + ACTION_SESSION_START_NAME, + ACTION_LISTEN_NAME, +) +from rasa.shared.exceptions import UnsupportedFeatureException +from rasa.shared.nlu.constants import ( + ENTITY_ATTRIBUTE_TYPE, + INTENT, + TEXT, + ENTITIES, + ENTITY_ATTRIBUTE_VALUE, + ACTION_TEXT, + ACTION_NAME, + INTENT_NAME_KEY, + ENTITY_ATTRIBUTE_ROLE, + ENTITY_ATTRIBUTE_GROUP, + PREDICTED_CONFIDENCE_KEY, + INTENT_RANKING_KEY, + ENTITY_ATTRIBUTE_TEXT, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_CONFIDENCE, + ENTITY_ATTRIBUTE_END, + FULL_RETRIEVAL_INTENT_NAME_KEY, +) + + +if TYPE_CHECKING: + from typing_extensions import TypedDict + + from rasa.shared.core.trackers import DialogueStateTracker + + EntityPrediction = TypedDict( + "EntityPrediction", + { + ENTITY_ATTRIBUTE_TEXT: Text, # type: ignore[misc] + ENTITY_ATTRIBUTE_START: Optional[float], + ENTITY_ATTRIBUTE_END: Optional[float], + ENTITY_ATTRIBUTE_VALUE: Text, + ENTITY_ATTRIBUTE_CONFIDENCE: float, + ENTITY_ATTRIBUTE_TYPE: Text, + ENTITY_ATTRIBUTE_GROUP: Optional[Text], + ENTITY_ATTRIBUTE_ROLE: Optional[Text], + "additional_info": Any, + }, + total=False, + ) + + IntentPrediction = TypedDict( + "IntentPrediction", {INTENT_NAME_KEY: Text, PREDICTED_CONFIDENCE_KEY: float} # type: ignore[misc] # noqa: E501 + ) + NLUPredictionData = TypedDict( + "NLUPredictionData", + { + TEXT: Text, # type: ignore[misc] + INTENT: IntentPrediction, + INTENT_RANKING_KEY: List[IntentPrediction], + ENTITIES: List[EntityPrediction], + "message_id": Optional[Text], + "metadata": Dict, + }, + total=False, + ) +logger = logging.getLogger(__name__) +structlogger = structlog.get_logger() + + +def deserialise_events(serialized_events: List[Dict[Text, Any]]) -> List["Event"]: + """Convert a list of dictionaries to a list of corresponding events. + + Example format: + [{"event": "slot", "value": 5, "name": "my_slot"}] + """ + deserialised = [] + + for e in serialized_events: + if "event" in e: + event = Event.from_parameters(e) + if event: + deserialised.append(event) + else: + structlogger.warning( + "event.deserialization.failed", rasa_event=copy.deepcopy(event) + ) + + return deserialised + + +def deserialise_entities(entities: Union[Text, List[Any]]) -> List[Dict[Text, Any]]: + if isinstance(entities, str): + entities = json.loads(entities) + + return [e for e in entities if isinstance(e, dict)] + + +def format_message( + text: Text, intent: Optional[Text], entities: Union[Text, List[Any]] +) -> Text: + """Uses NLU parser information to generate a message with inline entity annotations. + + Arguments: + text: text of the message + intent: intent of the message + entities: entities of the message + + Return: + Message with entities annotated inline, e.g. + `I am from [Berlin]{`"`entity`"`: `"`city`"`}`. + """ + from rasa.shared.nlu.training_data.formats.readerwriter import TrainingDataWriter + from rasa.shared.nlu.training_data import entities_parser + + message_from_md = entities_parser.parse_training_example(text, intent) + deserialised_entities = deserialise_entities(entities) + return TrainingDataWriter.generate_message( + {"text": message_from_md.get(TEXT), "entities": deserialised_entities} + ) + + +def split_events( + events: Iterable["Event"], + event_type_to_split_on: Type["Event"], + additional_splitting_conditions: Optional[Dict[Text, Any]] = None, + include_splitting_event: bool = True, +) -> List[List["Event"]]: + """Splits events according to an event type and condition. + + Examples: + Splitting events according to the event type `ActionExecuted` and the + `action_name` 'action_session_start' would look as follows: + + >> _events = split_events( + events, + ActionExecuted, + {"action_name": "action_session_start"}, + True + ) + + Args: + events: Events to split. + event_type_to_split_on: The event type to split on. + additional_splitting_conditions: Additional event attributes to split on. + include_splitting_event: Whether the events of the type on which the split + is based should be included in the returned events. + + Returns: + The split events. + """ + sub_events = [] + current: List["Event"] = [] + + def event_fulfills_splitting_condition(evt: "Event") -> bool: + # event does not have the correct type + if not isinstance(evt, event_type_to_split_on): + return False + + # the type is correct and there are no further conditions + if not additional_splitting_conditions: + return True + + # there are further conditions - check those + return all( + getattr(evt, k, None) == v + for k, v in additional_splitting_conditions.items() + ) + + for event in events: + if event_fulfills_splitting_condition(event): + if current: + sub_events.append(current) + + current = [] + if include_splitting_event: + current.append(event) + else: + current.append(event) + + if current: + sub_events.append(current) + + return sub_events + + +def do_events_begin_with_session_start(events: List["Event"]) -> bool: + """Determines whether `events` begins with a session start sequence. + + A session start sequence is a sequence of two events: an executed + `action_session_start` as well as a logged `session_started`. + + Args: + events: The events to inspect. + + Returns: + Whether `events` begins with a session start sequence. + """ + if len(events) < 2: + return False + + first = events[0] + second = events[1] + + # We are not interested in specific metadata or timestamps. Action name and event + # type are sufficient for this check + return ( + isinstance(first, ActionExecuted) + and first.action_name == ACTION_SESSION_START_NAME + and isinstance(second, SessionStarted) + ) + + +def remove_parse_data(event: Dict[Text, Any]) -> Dict[Text, Any]: + """Reduce event details to the minimum necessary to be structlogged. + + Deletes the parse_data key from the event if it exists. + + Args: + event: The event to be reduced. + + Returns: + A reduced copy of the event. + """ + reduced_event = copy.deepcopy(event) + if "parse_data" in reduced_event: + del reduced_event["parse_data"] + return reduced_event + + +E = TypeVar("E", bound="Event") + + +class Event(ABC): + """Describes events in conversation and how the affect the conversation state. + + Immutable representation of everything which happened during a conversation of the + user with the assistant. Tells the `rasa.shared.core.trackers.DialogueStateTracker` + how to update its state as the events occur. + """ + + type_name = "event" + + def __init__( + self, + timestamp: Optional[float] = None, + metadata: Optional[Dict[Text, Any]] = None, + ) -> None: + self.timestamp = timestamp or time.time() + self.metadata = metadata or {} + + def __ne__(self, other: Any) -> bool: + # Not strictly necessary, but to avoid having both x==y and x!=y + # True at the same time + return not (self == other) + + @abc.abstractmethod + def as_story_string(self) -> Optional[Text]: + """Returns the event as story string. + + Returns: + textual representation of the event or None. + """ + # Every class should implement this + raise NotImplementedError + + @staticmethod + def from_story_string( + event_name: Text, + parameters: Dict[Text, Any], + default: Optional[Type["Event"]] = None, + ) -> Optional[List["Event"]]: + event_class = Event.resolve_by_type(event_name, default) + + if not event_class: + return None + + return event_class._from_story_string(parameters) + + @staticmethod + def from_parameters( + parameters: Dict[Text, Any], default: Optional[Type["Event"]] = None + ) -> Optional["Event"]: + + event_name = parameters.get("event") + if event_name is None: + return None + + event_class: Optional[Type[Event]] = Event.resolve_by_type(event_name, default) + if not event_class: + return None + + return event_class._from_parameters(parameters) + + @classmethod + def _from_story_string( + cls: Type[E], parameters: Dict[Text, Any] + ) -> Optional[List[E]]: + """Called to convert a parsed story line into an event.""" + return [cls(parameters.get("timestamp"), parameters.get("metadata"))] + + def as_dict(self) -> Dict[Text, Any]: + d = {"event": self.type_name, "timestamp": self.timestamp} + + if self.metadata: + d["metadata"] = self.metadata + + return d + + def fingerprint(self) -> Text: + """Returns a unique hash for the event which is stable across python runs. + + Returns: + fingerprint of the event + """ + data = self.as_dict() + del data["timestamp"] + return rasa.shared.utils.io.get_dictionary_fingerprint(data) + + @classmethod + def _from_parameters(cls, parameters: Dict[Text, Any]) -> Optional["Event"]: + """Called to convert a dictionary of parameters to a single event. + + By default uses the same implementation as the story line + conversation ``_from_story_string``. But the subclass might + decide to handle parameters differently if the parsed parameters + don't origin from a story file. + """ + result = cls._from_story_string(parameters) + if len(result) > 1: + logger.warning( + f"Event from parameters called with parameters " + f"for multiple events. This is not supported, " + f"only the first event will be returned. " + f"Parameters: {parameters}" + ) + return result[0] if result else None + + @staticmethod + def resolve_by_type( + type_name: Text, default: Optional[Type["Event"]] = None + ) -> Optional[Type["Event"]]: + """Returns a slots class by its type name.""" + for cls in rasa.shared.utils.common.all_subclasses(Event): + if cls.type_name == type_name: + return cls + if type_name == "topic": + return None # backwards compatibility to support old TopicSet evts + elif default is not None: + return default + else: + raise ValueError(f"Unknown event name '{type_name}'.") + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state. + + Args: + tracker: The current conversation state. + """ + pass + + @abc.abstractmethod + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + # Every class should implement this + raise NotImplementedError() + + def __str__(self) -> Text: + """Returns text representation of event.""" + return f"{self.__class__.__name__}()" + + +class AlwaysEqualEventMixin(Event, ABC): + """Class to deduplicate common behavior for events without additional attributes.""" + + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + if not isinstance(other, self.__class__): + return NotImplemented + + return True + + +class SkipEventInMDStoryMixin(Event, ABC): + """Skips the visualization of an event in Markdown stories.""" + + def as_story_string(self) -> None: + """Returns the event as story string. + + Returns: + None, as this event should not appear inside the story. + """ + return + + +class UserUttered(Event): + """The user has said something to the bot. + + As a side effect a new `Turn` will be created in the `Tracker`. + """ + + type_name = "user" + + def __init__( + self, + text: Optional[Text] = None, + intent: Optional[Dict] = None, + entities: Optional[List[Dict]] = None, + parse_data: Optional["NLUPredictionData"] = None, + timestamp: Optional[float] = None, + input_channel: Optional[Text] = None, + message_id: Optional[Text] = None, + metadata: Optional[Dict] = None, + use_text_for_featurization: Optional[bool] = None, + ) -> None: + """Creates event for incoming user message. + + Args: + text: Text of user message. + intent: Intent prediction of user message. + entities: Extracted entities. + parse_data: Detailed NLU parsing result for message. + timestamp: When the event was created. + metadata: Additional event metadata. + input_channel: Which channel the user used to send message. + message_id: Unique ID for message. + use_text_for_featurization: `True` if the message's text was used to predict + next action. `False` if the message's intent was used. + + """ + self.text = text + self.intent = intent if intent else {} + self.entities = entities if entities else [] + self.input_channel = input_channel + self.message_id = message_id + + super().__init__(timestamp, metadata) + + # The featurization is set by the policies during prediction time using a + # `DefinePrevUserUtteredFeaturization` event. + self.use_text_for_featurization = use_text_for_featurization + # define how this user utterance should be featurized + if self.text and not self.intent_name: + # happens during training + self.use_text_for_featurization = True + elif self.intent_name and not self.text: + # happens during training + self.use_text_for_featurization = False + + self.parse_data: "NLUPredictionData" = { + INTENT: self.intent, # type: ignore[misc] + # Copy entities so that changes to `self.entities` don't affect + # `self.parse_data` and hence don't get persisted + ENTITIES: self.entities.copy(), + TEXT: self.text, + "message_id": self.message_id, + "metadata": self.metadata, + } + if parse_data: + self.parse_data.update(**parse_data) + + @staticmethod + def _from_parse_data( + text: Text, + parse_data: "NLUPredictionData", + timestamp: Optional[float] = None, + input_channel: Optional[Text] = None, + message_id: Optional[Text] = None, + metadata: Optional[Dict] = None, + ) -> "UserUttered": + return UserUttered( + text, + parse_data.get(INTENT), + parse_data.get(ENTITIES, []), + parse_data, + timestamp, + input_channel, + message_id, + metadata, + ) + + def __hash__(self) -> int: + """Returns unique hash of object.""" + return hash(json.dumps(self.as_sub_state())) + + @property + def intent_name(self) -> Optional[Text]: + """Returns intent name or `None` if no intent.""" + return self.intent.get(INTENT_NAME_KEY) + + @property + def full_retrieval_intent_name(self) -> Optional[Text]: + """Returns full retrieval intent name or `None` if no retrieval intent.""" + return self.intent.get(FULL_RETRIEVAL_INTENT_NAME_KEY) + + # Note that this means two UserUttered events with the same text, intent + # and entities but _different_ timestamps will be considered equal. + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + if not isinstance(other, UserUttered): + return NotImplemented + + return ( + self.text, + self.intent_name, + [ + jsonpickle.encode(sorted(ent)) for ent in self.entities + ], # TODO: test? Or fix in regex_message_handler? + ) == ( + other.text, + other.intent_name, + [jsonpickle.encode(sorted(ent)) for ent in other.entities], + ) + + def __str__(self) -> Text: + """Returns text representation of event.""" + entities = "" + if self.entities: + entities_list = [ + f"{entity[ENTITY_ATTRIBUTE_VALUE]} " + f"(Type: {entity[ENTITY_ATTRIBUTE_TYPE]}, " + f"Role: {entity.get(ENTITY_ATTRIBUTE_ROLE)}, " + f"Group: {entity.get(ENTITY_ATTRIBUTE_GROUP)})" + for entity in self.entities + ] + entities = f", entities: {', '.join(entities_list)}" + + return ( + f"UserUttered(text: {self.text}, intent: {self.intent_name}" + f"{entities}" + f", use_text_for_featurization: {self.use_text_for_featurization})" + ) + + @staticmethod + def empty() -> "UserUttered": + return UserUttered(None) + + def is_empty(self) -> bool: + return not self.text and not self.intent_name and not self.entities + + def as_dict(self) -> Dict[Text, Any]: + _dict = super().as_dict() + _dict.update( + { + "text": self.text, + "parse_data": self.parse_data, + "input_channel": getattr(self, "input_channel", None), + "message_id": getattr(self, "message_id", None), + "metadata": self.metadata, + } + ) + return _dict + + def as_sub_state(self) -> Dict[Text, Union[None, Text, List[Optional[Text]]]]: + """Turns a UserUttered event into features. + + The substate contains information about entities, intent and text of the + `UserUttered` event. + + Returns: + a dictionary with intent name, text and entities + """ + entities = [entity.get(ENTITY_ATTRIBUTE_TYPE) for entity in self.entities] + entities.extend( + ( + f"{entity.get(ENTITY_ATTRIBUTE_TYPE)}{ENTITY_LABEL_SEPARATOR}" + f"{entity.get(ENTITY_ATTRIBUTE_ROLE)}" + ) + for entity in self.entities + if ENTITY_ATTRIBUTE_ROLE in entity + ) + entities.extend( + ( + f"{entity.get(ENTITY_ATTRIBUTE_TYPE)}{ENTITY_LABEL_SEPARATOR}" + f"{entity.get(ENTITY_ATTRIBUTE_GROUP)}" + ) + for entity in self.entities + if ENTITY_ATTRIBUTE_GROUP in entity + ) + + out: Dict[Text, Union[None, Text, List[Optional[Text]]]] = {} + # During training we expect either intent_name or text to be set. + # During prediction both will be set. + if self.text and ( + self.use_text_for_featurization or self.use_text_for_featurization is None + ): + out[TEXT] = self.text + if self.intent_name and not self.use_text_for_featurization: + out[INTENT] = self.intent_name + # don't add entities for e2e utterances + if entities and not self.use_text_for_featurization: + out[ENTITIES] = entities + + return out + + @classmethod + def _from_story_string( + cls, parameters: Dict[Text, Any] + ) -> Optional[List["UserUttered"]]: + try: + return [ + cls._from_parse_data( + parameters.get("text"), + parameters.get("parse_data"), + parameters.get("timestamp"), + parameters.get("input_channel"), + parameters.get("message_id"), + parameters.get("metadata"), + ) + ] + except KeyError as e: + raise ValueError(f"Failed to parse bot uttered event. {e}") + + def _entity_string(self) -> Text: + if self.entities: + return json.dumps( + { + entity[ENTITY_ATTRIBUTE_TYPE]: entity[ENTITY_ATTRIBUTE_VALUE] + for entity in self.entities + }, + ensure_ascii=False, + ) + return "" + + def as_story_string(self, e2e: bool = False) -> Text: + """Return event as string for Markdown training format. + + Args: + e2e: `True` if the the event should be printed in the format for + end-to-end conversation tests. + + Returns: + Event as string. + """ + if self.use_text_for_featurization and not e2e: + raise UnsupportedFeatureException( + f"Printing end-to-end user utterances is not supported in the " + f"Markdown training format. Please use the YAML training data format " + f"instead. Please see {DOCS_URL_TRAINING_DATA} for more information." + ) + + if e2e: + text_with_entities = format_message( + self.text or "", self.intent_name, self.entities + ) + + intent_prefix = f"{self.intent_name}: " if self.intent_name else "" + return f"{intent_prefix}{text_with_entities}" + + return f"{self.intent_name or ''}{self._entity_string()}" + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to tracker. See docstring of `Event`.""" + tracker.latest_message = self + tracker.clear_followup_action() + + @staticmethod + def create_external( + intent_name: Text, + entity_list: Optional[List[Dict[Text, Any]]] = None, + input_channel: Optional[Text] = None, + ) -> "UserUttered": + return UserUttered( + text=f"{EXTERNAL_MESSAGE_PREFIX}{intent_name}", + intent={INTENT_NAME_KEY: intent_name}, + metadata={IS_EXTERNAL: True}, + entities=entity_list or [], + input_channel=input_channel, + ) + + +class DefinePrevUserUtteredFeaturization(SkipEventInMDStoryMixin): + """Stores information whether action was predicted based on text or intent.""" + + type_name = "user_featurization" + + def __init__( + self, + use_text_for_featurization: bool, + timestamp: Optional[float] = None, + metadata: Optional[Dict[Text, Any]] = None, + ) -> None: + """Creates event. + + Args: + use_text_for_featurization: `True` if message text was used to predict + action. `False` if intent was used. + timestamp: When the event was created. + metadata: Additional event metadata. + """ + super().__init__(timestamp, metadata) + self.use_text_for_featurization = use_text_for_featurization + + def __str__(self) -> Text: + """Returns text representation of event.""" + return f"DefinePrevUserUtteredFeaturization({self.use_text_for_featurization})" + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash(self.use_text_for_featurization) + + @classmethod + def _from_parameters( + cls, parameters: Dict[Text, Any] + ) -> "DefinePrevUserUtteredFeaturization": + return DefinePrevUserUtteredFeaturization( + parameters.get(USE_TEXT_FOR_FEATURIZATION), + parameters.get("timestamp"), + parameters.get("metadata"), + ) + + def as_dict(self) -> Dict[Text, Any]: + """Returns serialized event.""" + d = super().as_dict() + d.update({USE_TEXT_FOR_FEATURIZATION: self.use_text_for_featurization}) + return d + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state. + + Args: + tracker: The current conversation state. + """ + if tracker.latest_action_name != ACTION_LISTEN_NAME: + # featurization belong only to the last user message + # a user message is always followed by action listen + return + + if not tracker.latest_message: + return + + # update previous user message's featurization based on this event + tracker.latest_message.use_text_for_featurization = ( + self.use_text_for_featurization + ) + + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + if not isinstance(other, DefinePrevUserUtteredFeaturization): + return NotImplemented + + return self.use_text_for_featurization == other.use_text_for_featurization + + +class EntitiesAdded(SkipEventInMDStoryMixin): + """Event that is used to add extracted entities to the tracker state.""" + + type_name = "entities" + + def __init__( + self, + entities: List[Dict[Text, Any]], + timestamp: Optional[float] = None, + metadata: Optional[Dict[Text, Any]] = None, + ) -> None: + """Initializes event. + + Args: + entities: Entities extracted from previous user message. This can either + be done by NLU components or end-to-end policy predictions. + timestamp: the timestamp + metadata: some optional metadata + """ + super().__init__(timestamp, metadata) + self.entities = entities + + def __str__(self) -> Text: + """Returns the string representation of the event.""" + entity_str = [e[ENTITY_ATTRIBUTE_TYPE] for e in self.entities] + return f"{self.__class__.__name__}({entity_str})" + + def __hash__(self) -> int: + """Returns the hash value of the event.""" + return hash(json.dumps(self.entities)) + + def __eq__(self, other: Any) -> bool: + """Compares this event with another event.""" + if not isinstance(other, EntitiesAdded): + return NotImplemented + + return self.entities == other.entities + + @classmethod + def _from_parameters(cls, parameters: Dict[Text, Any]) -> "EntitiesAdded": + return EntitiesAdded( + parameters.get(ENTITIES), + parameters.get("timestamp"), + parameters.get("metadata"), + ) + + def as_dict(self) -> Dict[Text, Any]: + """Converts the event into a dict. + + Returns: + A dict that represents this event. + """ + d = super().as_dict() + d.update({ENTITIES: self.entities}) + return d + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state. + + Args: + tracker: The current conversation state. + """ + if tracker.latest_action_name != ACTION_LISTEN_NAME: + # entities belong only to the last user message + # a user message always comes after action listen + return + + if not tracker.latest_message: + return + + for entity in self.entities: + if entity not in tracker.latest_message.entities: + tracker.latest_message.entities.append(entity) + + +class BotUttered(SkipEventInMDStoryMixin): + """The bot has said something to the user. + + This class is not used in the story training as it is contained in the + + ``ActionExecuted`` class. An entry is made in the ``Tracker``. + """ + + type_name = "bot" + + def __init__( + self, + text: Optional[Text] = None, + data: Optional[Dict] = None, + metadata: Optional[Dict[Text, Any]] = None, + timestamp: Optional[float] = None, + ) -> None: + """Creates event for a bot response. + + Args: + text: Plain text which bot responded with. + data: Additional data for more complex utterances (e.g. buttons). + timestamp: When the event was created. + metadata: Additional event metadata. + """ + self.text = text + self.data = data or {} + super().__init__(timestamp, metadata) + + def __members(self) -> Tuple[Optional[Text], Text, Text]: + data_no_nones = {k: v for k, v in self.data.items() if v is not None} + meta_no_nones = {k: v for k, v in self.metadata.items() if v is not None} + return ( + self.text, + jsonpickle.encode(data_no_nones), + jsonpickle.encode(meta_no_nones), + ) + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash(self.__members()) + + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + if not isinstance(other, BotUttered): + return NotImplemented + + return self.__members() == other.__members() + + def __str__(self) -> Text: + """Returns text representation of event.""" + return "BotUttered(text: {}, data: {}, metadata: {})".format( + self.text, json.dumps(self.data), json.dumps(self.metadata) + ) + + def __repr__(self) -> Text: + """Returns text representation of event for debugging.""" + return "BotUttered('{}', {}, {}, {})".format( + self.text, json.dumps(self.data), json.dumps(self.metadata), self.timestamp + ) + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state.""" + tracker.latest_bot_utterance = self + + def message(self) -> Dict[Text, Any]: + """Return the complete message as a dictionary.""" + m = self.data.copy() + m["text"] = self.text + m["timestamp"] = self.timestamp + m.update(self.metadata) + + if m.get("image") == m.get("attachment"): + # we need this as there is an oddity we introduced a while ago where + # we automatically set the attachment to the image. to not break + # any persisted events we kept that, but we need to make sure that + # the message contains the image only once + m["attachment"] = None + + return m + + @staticmethod + def empty() -> "BotUttered": + """Creates an empty bot utterance.""" + return BotUttered() + + def as_dict(self) -> Dict[Text, Any]: + """Returns serialized event.""" + d = super().as_dict() + d.update({"text": self.text, "data": self.data, "metadata": self.metadata}) + return d + + @classmethod + def _from_parameters(cls, parameters: Dict[Text, Any]) -> "BotUttered": + try: + return BotUttered( + parameters.get("text"), + parameters.get("data"), + parameters.get("metadata"), + parameters.get("timestamp"), + ) + except KeyError as e: + raise ValueError(f"Failed to parse bot uttered event. {e}") + + +class SlotSet(Event): + """The user has specified their preference for the value of a `slot`. + + Every slot has a name and a value. This event can be used to set a + value for a slot on a conversation. + + As a side effect the `Tracker`'s slots will be updated so + that `tracker.slots[key]=value`. + """ + + type_name = "slot" + + def __init__( + self, + key: Text, + value: Optional[Any] = None, + timestamp: Optional[float] = None, + metadata: Optional[Dict[Text, Any]] = None, + ) -> None: + """Creates event to set slot. + + Args: + key: Name of the slot which is set. + value: Value to which slot is set. + timestamp: When the event was created. + metadata: Additional event metadata. + """ + self.key = key + self.value = value + super().__init__(timestamp, metadata) + + def __repr__(self) -> Text: + """Returns text representation of event.""" + return f"SlotSet(key: {self.key}, value: {self.value})" + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash((self.key, jsonpickle.encode(self.value))) + + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + if not isinstance(other, SlotSet): + return NotImplemented + + return (self.key, self.value) == (other.key, other.value) + + def as_story_string(self) -> Text: + """Returns text representation of event.""" + props = json.dumps({self.key: self.value}, ensure_ascii=False) + return f"{self.type_name}{props}" + + @classmethod + def _from_story_string( + cls, parameters: Dict[Text, Any] + ) -> Optional[List["SlotSet"]]: + + slots = [] + for slot_key, slot_val in parameters.items(): + slots.append(SlotSet(slot_key, slot_val)) + + if slots: + return slots + else: + return None + + def as_dict(self) -> Dict[Text, Any]: + """Returns serialized event.""" + d = super().as_dict() + d.update({"name": self.key, "value": self.value}) + return d + + @classmethod + def _from_parameters(cls, parameters: Dict[Text, Any]) -> "SlotSet": + try: + return SlotSet( + parameters.get("name"), + parameters.get("value"), + parameters.get("timestamp"), + parameters.get("metadata"), + ) + except KeyError as e: + raise ValueError(f"Failed to parse set slot event. {e}") + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state.""" + tracker._set_slot(self.key, self.value) + + +class Restarted(AlwaysEqualEventMixin): + """Conversation should start over & history wiped. + + Instead of deleting all events, this event can be used to reset the + trackers state (e.g. ignoring any past user messages & resetting all + the slots). + """ + + type_name = "restart" + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash(32143124312) + + def as_story_string(self) -> Text: + """Returns text representation of event.""" + return self.type_name + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Resets the tracker and triggers a followup `ActionSessionStart`.""" + tracker._reset() + tracker.trigger_followup_action(ACTION_SESSION_START_NAME) + + +class UserUtteranceReverted(AlwaysEqualEventMixin): + """Bot reverts everything until before the most recent user message. + + The bot will revert all events after the latest `UserUttered`, this + also means that the last event on the tracker is usually `action_listen` + and the bot is waiting for a new user message. + """ + + type_name = "rewind" + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash(32143124315) + + def as_story_string(self) -> Text: + """Returns text representation of event.""" + return self.type_name + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state.""" + tracker._reset() + tracker.replay_events() + + +class AllSlotsReset(AlwaysEqualEventMixin): + """All Slots are reset to their initial values. + + If you want to keep the dialogue history and only want to reset the + slots, you can use this event to set all the slots to their initial + values. + """ + + type_name = "reset_slots" + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash(32143124316) + + def as_story_string(self) -> Text: + """Returns text representation of event.""" + return self.type_name + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state.""" + tracker._reset_slots() + + +class ReminderScheduled(Event): + """Schedules the asynchronous triggering of a user intent at a given time. + + The triggered intent can include entities if needed. + """ + + type_name = "reminder" + + def __init__( + self, + intent: Text, + trigger_date_time: datetime, + entities: Optional[List[Dict]] = None, + name: Optional[Text] = None, + kill_on_user_message: bool = True, + timestamp: Optional[float] = None, + metadata: Optional[Dict[Text, Any]] = None, + ) -> None: + """Creates the reminder. + + Args: + intent: Name of the intent to be triggered. + trigger_date_time: Date at which the execution of the action + should be triggered (either utc or with tz). + name: ID of the reminder. If there are multiple reminders with + the same id only the last will be run. + entities: Entities that should be supplied together with the + triggered intent. + kill_on_user_message: ``True`` means a user message before the + trigger date will abort the reminder. + timestamp: Creation date of the event. + metadata: Optional event metadata. + """ + self.intent = intent + self.entities = entities + self.trigger_date_time = trigger_date_time + self.kill_on_user_message = kill_on_user_message + self.name = name if name is not None else str(uuid.uuid1()) + super().__init__(timestamp, metadata) + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash( + ( + self.intent, + self.entities, + self.trigger_date_time.isoformat(), + self.kill_on_user_message, + self.name, + ) + ) + + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + if not isinstance(other, ReminderScheduled): + return NotImplemented + + return self.name == other.name + + def __str__(self) -> Text: + """Returns text representation of event.""" + return ( + f"ReminderScheduled(intent: {self.intent}, " + f"trigger_date: {self.trigger_date_time}, " + f"entities: {self.entities}, name: {self.name})" + ) + + def scheduled_job_name(self, sender_id: Text) -> Text: + return ( + f"[{hash(self.name)},{hash(self.intent)},{hash(str(self.entities))}]" + f"{ACTION_NAME_SENDER_ID_CONNECTOR_STR}" + f"{sender_id}" + ) + + def _properties(self) -> Dict[Text, Any]: + return { + "intent": self.intent, + "date_time": self.trigger_date_time.isoformat(), + "entities": self.entities, + "name": self.name, + "kill_on_user_msg": self.kill_on_user_message, + } + + def as_story_string(self) -> Text: + """Returns text representation of event.""" + props = json.dumps(self._properties()) + return f"{self.type_name}{props}" + + def as_dict(self) -> Dict[Text, Any]: + """Returns serialized event.""" + d = super().as_dict() + d.update(self._properties()) + return d + + @classmethod + def _from_story_string( + cls, parameters: Dict[Text, Any] + ) -> Optional[List["ReminderScheduled"]]: + + trigger_date_time = parser.parse(parameters.get("date_time")) + + return [ + ReminderScheduled( + parameters.get("intent"), + trigger_date_time, + parameters.get("entities"), + name=parameters.get("name"), + kill_on_user_message=parameters.get("kill_on_user_msg", True), + timestamp=parameters.get("timestamp"), + metadata=parameters.get("metadata"), + ) + ] + + +class ReminderCancelled(Event): + """Cancel certain jobs.""" + + type_name = "cancel_reminder" + + def __init__( + self, + name: Optional[Text] = None, + intent: Optional[Text] = None, + entities: Optional[List[Dict]] = None, + timestamp: Optional[float] = None, + metadata: Optional[Dict[Text, Any]] = None, + ) -> None: + """Creates a ReminderCancelled event. + + If all arguments are `None`, this will cancel all reminders. + are to be cancelled. If no arguments are supplied, this will cancel all + reminders. + + Args: + name: Name of the reminder to be cancelled. + intent: Intent name that is to be used to identify the reminders to be + cancelled. + entities: Entities that are to be used to identify the reminders to be + cancelled. + timestamp: Optional timestamp. + metadata: Optional event metadata. + """ + self.name = name + self.intent = intent + self.entities = entities + super().__init__(timestamp, metadata) + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash((self.name, self.intent, str(self.entities))) + + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + if not isinstance(other, ReminderCancelled): + return NotImplemented + + return hash(self) == hash(other) + + def __str__(self) -> Text: + """Returns text representation of event.""" + return ( + f"ReminderCancelled(name: {self.name}, intent: {self.intent}, " + f"entities: {self.entities})" + ) + + def cancels_job_with_name(self, job_name: Text, sender_id: Text) -> bool: + """Determines if this event should cancel the job with the given name. + + Args: + job_name: Name of the job to be tested. + sender_id: The `sender_id` of the tracker. + + Returns: + `True`, if this `ReminderCancelled` event should cancel the job with the + given name, and `False` otherwise. + """ + match = re.match( + rf"^\[([\d\-]*),([\d\-]*),([\d\-]*)\]" + rf"({re.escape(ACTION_NAME_SENDER_ID_CONNECTOR_STR)}" + rf"{re.escape(sender_id)})", + job_name, + ) + if not match: + return False + name_hash, intent_hash, entities_hash = match.group(1, 2, 3) + + # Cancel everything unless names/intents/entities are given to + # narrow it down. + return ( + ((not self.name) or self._matches_name_hash(name_hash)) + and ((not self.intent) or self._matches_intent_hash(intent_hash)) + and ((not self.entities) or self._matches_entities_hash(entities_hash)) + ) + + def _matches_name_hash(self, name_hash: Text) -> bool: + return str(hash(self.name)) == name_hash + + def _matches_intent_hash(self, intent_hash: Text) -> bool: + return str(hash(self.intent)) == intent_hash + + def _matches_entities_hash(self, entities_hash: Text) -> bool: + return str(hash(str(self.entities))) == entities_hash + + def as_story_string(self) -> Text: + """Returns text representation of event.""" + props = json.dumps( + {"name": self.name, "intent": self.intent, "entities": self.entities} + ) + return f"{self.type_name}{props}" + + @classmethod + def _from_story_string( + cls, parameters: Dict[Text, Any] + ) -> Optional[List["ReminderCancelled"]]: + return [ + ReminderCancelled( + parameters.get("name"), + parameters.get("intent"), + parameters.get("entities"), + timestamp=parameters.get("timestamp"), + metadata=parameters.get("metadata"), + ) + ] + + +class ActionReverted(AlwaysEqualEventMixin): + """Bot undoes its last action. + + The bot reverts everything until before the most recent action. + This includes the action itself, as well as any events that + action created, like set slot events - the bot will now + predict a new action using the state before the most recent + action. + """ + + type_name = "undo" + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash(32143124318) + + def as_story_string(self) -> Text: + """Returns text representation of event.""" + return self.type_name + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state.""" + tracker._reset() + tracker.replay_events() + + +class StoryExported(Event): + """Story should get dumped to a file.""" + + type_name = "export" + + def __init__( + self, + path: Optional[Text] = None, + timestamp: Optional[float] = None, + metadata: Optional[Dict[Text, Any]] = None, + ) -> None: + """Creates event about story exporting. + + Args: + path: Path to which story was exported to. + timestamp: When the event was created. + metadata: Additional event metadata. + """ + self.path = path + super().__init__(timestamp, metadata) + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash(32143124319) + + @classmethod + def _from_story_string( + cls, parameters: Dict[Text, Any] + ) -> Optional[List["StoryExported"]]: + return [ + StoryExported( + parameters.get("path"), + parameters.get("timestamp"), + parameters.get("metadata"), + ) + ] + + def as_story_string(self) -> Text: + """Returns text representation of event.""" + return self.type_name + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state.""" + if self.path: + tracker.export_stories_to_file(self.path) + + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + if not isinstance(other, StoryExported): + return NotImplemented + + return self.path == other.path + + +class FollowupAction(Event): + """Enqueue a followup action.""" + + type_name = "followup" + + def __init__( + self, + name: Text, + timestamp: Optional[float] = None, + metadata: Optional[Dict[Text, Any]] = None, + ) -> None: + """Creates an event which forces the model to run a certain action next. + + Args: + name: Name of the action to run. + timestamp: When the event was created. + metadata: Additional event metadata. + """ + self.action_name = name + super().__init__(timestamp, metadata) + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash(self.action_name) + + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + if not isinstance(other, FollowupAction): + return NotImplemented + + return self.action_name == other.action_name + + def __str__(self) -> Text: + """Returns text representation of event.""" + return f"FollowupAction(action: {self.action_name})" + + def as_story_string(self) -> Text: + """Returns text representation of event.""" + props = json.dumps({"name": self.action_name}) + return f"{self.type_name}{props}" + + @classmethod + def _from_story_string( + cls, parameters: Dict[Text, Any] + ) -> Optional[List["FollowupAction"]]: + + return [ + FollowupAction( + parameters.get("name"), + parameters.get("timestamp"), + parameters.get("metadata"), + ) + ] + + def as_dict(self) -> Dict[Text, Any]: + """Returns serialized event.""" + d = super().as_dict() + d.update({"name": self.action_name}) + return d + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state.""" + tracker.trigger_followup_action(self.action_name) + + +class ConversationPaused(AlwaysEqualEventMixin): + """Ignore messages from the user to let a human take over. + + As a side effect the `Tracker`'s `paused` attribute will + be set to `True`. + """ + + type_name = "pause" + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash(32143124313) + + def as_story_string(self) -> Text: + """Returns text representation of event.""" + return str(self) + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state.""" + tracker._paused = True + + +class ConversationResumed(AlwaysEqualEventMixin): + """Bot takes over conversation. + + Inverse of `PauseConversation`. As a side effect the `Tracker`'s + `paused` attribute will be set to `False`. + """ + + type_name = "resume" + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash(32143124314) + + def as_story_string(self) -> Text: + """Returns text representation of event.""" + return self.type_name + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state.""" + tracker._paused = False + + +class ActionExecuted(Event): + """An operation describes an action taken + its result. + + It comprises an action and a list of events. operations will be appended + to the latest `Turn`` in `Tracker.turns`. + """ + + type_name = "action" + + def __init__( + self, + action_name: Optional[Text] = None, + policy: Optional[Text] = None, + confidence: Optional[float] = None, + timestamp: Optional[float] = None, + metadata: Optional[Dict] = None, + action_text: Optional[Text] = None, + hide_rule_turn: bool = False, + ) -> None: + """Creates event for a successful event execution. + + Args: + action_name: Name of the action which was executed. `None` if it was an + end-to-end prediction. + policy: Policy which predicted action. + confidence: Confidence with which policy predicted action. + timestamp: When the event was created. + metadata: Additional event metadata. + action_text: In case it's an end-to-end action prediction, the text which + was predicted. + hide_rule_turn: If `True`, this action should be hidden in the dialogue + history created for ML-based policies. + """ + self.action_name = action_name + self.policy = policy + self.confidence = confidence + self.unpredictable = False + self.action_text = action_text + self.hide_rule_turn = hide_rule_turn + + if self.action_name is None and self.action_text is None: + raise ValueError( + "Both the name of the action and the end-to-end " + "predicted text are missing. " + "The `ActionExecuted` event cannot be initialised." + ) + + super().__init__(timestamp, metadata) + + def __members__(self) -> Tuple[Optional[Text], Optional[Text], Text]: + meta_no_nones = {k: v for k, v in self.metadata.items() if v is not None} + return (self.action_name, self.action_text, jsonpickle.encode(meta_no_nones)) + + def __repr__(self) -> Text: + """Returns event as string for debugging.""" + return "ActionExecuted(action: {}, policy: {}, confidence: {})".format( + self.action_name, self.policy, self.confidence + ) + + def __str__(self) -> Text: + """Returns event as human readable string.""" + return str(self.action_name) or str(self.action_text) + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash(self.__members__()) + + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + if not isinstance(other, ActionExecuted): + return NotImplemented + + return self.__members__() == other.__members__() + + def as_story_string(self) -> Optional[Text]: + """Returns event in Markdown format.""" + if self.action_text: + raise UnsupportedFeatureException( + f"Printing end-to-end bot utterances is not supported in the " + f"Markdown training format. Please use the YAML training data format " + f"instead. Please see {DOCS_URL_TRAINING_DATA} for more information." + ) + + return self.action_name + + @classmethod + def _from_story_string( + cls, parameters: Dict[Text, Any] + ) -> Optional[List["ActionExecuted"]]: + return [ + ActionExecuted( + parameters.get("name"), + parameters.get("policy"), + parameters.get("confidence"), + parameters.get("timestamp"), + parameters.get("metadata"), + parameters.get("action_text"), + parameters.get("hide_rule_turn", False), + ) + ] + + def as_dict(self) -> Dict[Text, Any]: + """Returns serialized event.""" + d = super().as_dict() + d.update( + { + "name": self.action_name, + "policy": self.policy, + "confidence": self.confidence, + "action_text": self.action_text, + "hide_rule_turn": self.hide_rule_turn, + } + ) + return d + + def as_sub_state(self) -> Dict[Text, Text]: + """Turns ActionExecuted into a dictionary containing action name or action text. + + One action cannot have both set at the same time + + Returns: + a dictionary containing action name or action text with the corresponding + key. + """ + if self.action_name: + return {ACTION_NAME: self.action_name} + else: + # FIXME: we should define the type better here, and require either + # `action_name` or `action_text` + return {ACTION_TEXT: cast(Text, self.action_text)} + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state.""" + tracker.set_latest_action(self.as_sub_state()) + tracker.clear_followup_action() + + +class AgentUttered(SkipEventInMDStoryMixin): + """The agent has said something to the user. + + This class is not used in the story training as it is contained in the + ``ActionExecuted`` class. An entry is made in the ``Tracker``. + """ + + type_name = "agent" + + def __init__( + self, + text: Optional[Text] = None, + data: Optional[Any] = None, + timestamp: Optional[float] = None, + metadata: Optional[Dict[Text, Any]] = None, + ) -> None: + """See docstring of `BotUttered`.""" + self.text = text + self.data = data + super().__init__(timestamp, metadata) + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash((self.text, jsonpickle.encode(self.data))) + + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + if not isinstance(other, AgentUttered): + return NotImplemented + + return (self.text, jsonpickle.encode(self.data)) == ( + other.text, + jsonpickle.encode(other.data), + ) + + def __str__(self) -> Text: + """Returns text representation of event.""" + return "AgentUttered(text: {}, data: {})".format( + self.text, json.dumps(self.data) + ) + + def as_dict(self) -> Dict[Text, Any]: + """Returns serialized event.""" + d = super().as_dict() + d.update({"text": self.text, "data": self.data}) + return d + + @classmethod + def _from_parameters(cls, parameters: Dict[Text, Any]) -> "AgentUttered": + try: + return AgentUttered( + parameters.get("text"), + parameters.get("data"), + parameters.get("timestamp"), + parameters.get("metadata"), + ) + except KeyError as e: + raise ValueError(f"Failed to parse agent uttered event. {e}") + + +class ActiveLoop(Event): + """If `name` is given: activates a loop with `name` else deactivates active loop.""" + + type_name = "active_loop" + + def __init__( + self, + name: Optional[Text], + timestamp: Optional[float] = None, + metadata: Optional[Dict[Text, Any]] = None, + ) -> None: + """Creates event for active loop. + + Args: + name: Name of activated loop or `None` if current loop is deactivated. + timestamp: When the event was created. + metadata: Additional event metadata. + """ + self.name = name + super().__init__(timestamp, metadata) + + def __str__(self) -> Text: + """Returns text representation of event.""" + return f"Loop({self.name})" + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash(self.name) + + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + if not isinstance(other, ActiveLoop): + return NotImplemented + + return self.name == other.name + + def as_story_string(self) -> Text: + """Returns text representation of event.""" + props = json.dumps({LOOP_NAME: self.name}) + return f"{ActiveLoop.type_name}{props}" + + @classmethod + def _from_story_string(cls, parameters: Dict[Text, Any]) -> List["ActiveLoop"]: + """Called to convert a parsed story line into an event.""" + return [ + ActiveLoop( + parameters.get(LOOP_NAME), + parameters.get("timestamp"), + parameters.get("metadata"), + ) + ] + + def as_dict(self) -> Dict[Text, Any]: + """Returns serialized event.""" + d = super().as_dict() + d.update({LOOP_NAME: self.name}) + return d + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state.""" + tracker.change_loop_to(self.name) + + +class LegacyForm(ActiveLoop): + """Legacy handler of old `Form` events. + + The `ActiveLoop` event used to be called `Form`. This class is there to handle old + legacy events which were stored with the old type name `form`. + """ + + type_name = "form" + + def as_dict(self) -> Dict[Text, Any]: + """Returns serialized event.""" + d = super().as_dict() + # Dump old `Form` events as `ActiveLoop` events instead of keeping the old + # event type. + d["event"] = ActiveLoop.type_name + return d + + def fingerprint(self) -> Text: + """Returns the hash of the event.""" + d = self.as_dict() + # Revert event name to legacy subclass name to avoid different event types + # having the same fingerprint. + d["event"] = self.type_name + del d["timestamp"] + return rasa.shared.utils.io.get_dictionary_fingerprint(d) + + +class LoopInterrupted(SkipEventInMDStoryMixin): + """Event added by FormPolicy and RulePolicy. + + Notifies form action whether or not to validate the user input. + """ + + type_name = "loop_interrupted" + + def __init__( + self, + is_interrupted: bool, + timestamp: Optional[float] = None, + metadata: Optional[Dict[Text, Any]] = None, + ) -> None: + """Event to notify that loop was interrupted. + + This e.g. happens when a user is within a form, and is de-railing the + form-filling by asking FAQs. + + Args: + is_interrupted: `True` if the loop execution was interrupted, and ML + policies had to take over the last prediction. + timestamp: When the event was created. + metadata: Additional event metadata. + """ + super().__init__(timestamp, metadata) + self.is_interrupted = is_interrupted + + def __str__(self) -> Text: + """Returns text representation of event.""" + return f"{LoopInterrupted.__name__}({self.is_interrupted})" + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash(self.is_interrupted) + + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + if not isinstance(other, LoopInterrupted): + return NotImplemented + + return self.is_interrupted == other.is_interrupted + + @classmethod + def _from_parameters(cls, parameters: Dict[Text, Any]) -> "LoopInterrupted": + return LoopInterrupted( + parameters.get(LOOP_INTERRUPTED, False), + parameters.get("timestamp"), + parameters.get("metadata"), + ) + + def as_dict(self) -> Dict[Text, Any]: + """Returns serialized event.""" + d = super().as_dict() + d.update({LOOP_INTERRUPTED: self.is_interrupted}) + return d + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state.""" + tracker.interrupt_loop(self.is_interrupted) + + +class LegacyFormValidation(LoopInterrupted): + """Legacy handler of old `FormValidation` events. + + The `LoopInterrupted` event used to be called `FormValidation`. This class is there + to handle old legacy events which were stored with the old type name + `form_validation`. + """ + + type_name = "form_validation" + + def __init__( + self, + validate: bool, + timestamp: Optional[float] = None, + metadata: Optional[Dict[Text, Any]] = None, + ) -> None: + """See parent class docstring.""" + # `validate = True` is the same as `interrupted = False` + super().__init__(not validate, timestamp, metadata) + + @classmethod + def _from_parameters(cls, parameters: Dict) -> "LoopInterrupted": + return LoopInterrupted( + # `validate = True` means `is_interrupted = False` + not parameters.get("validate", True), + parameters.get("timestamp"), + parameters.get("metadata"), + ) + + def as_dict(self) -> Dict[Text, Any]: + """Returns serialized event.""" + d = super().as_dict() + # Dump old `Form` events as `ActiveLoop` events instead of keeping the old + # event type. + d["event"] = LoopInterrupted.type_name + return d + + def fingerprint(self) -> Text: + """Returns hash of the event.""" + d = self.as_dict() + # Revert event name to legacy subclass name to avoid different event types + # having the same fingerprint. + d["event"] = self.type_name + del d["timestamp"] + return rasa.shared.utils.io.get_dictionary_fingerprint(d) + + +class ActionExecutionRejected(SkipEventInMDStoryMixin): + """Notify Core that the execution of the action has been rejected.""" + + type_name = "action_execution_rejected" + + def __init__( + self, + action_name: Text, + policy: Optional[Text] = None, + confidence: Optional[float] = None, + timestamp: Optional[float] = None, + metadata: Optional[Dict[Text, Any]] = None, + ) -> None: + """Creates event. + + Args: + action_name: Action which was rejected. + policy: Policy which predicted the rejected action. + confidence: Confidence with which the reject action was predicted. + timestamp: When the event was created. + metadata: Additional event metadata. + """ + self.action_name = action_name + self.policy = policy + self.confidence = confidence + super().__init__(timestamp, metadata) + + def __str__(self) -> Text: + """Returns text representation of event.""" + return ( + "ActionExecutionRejected(" + "action: {}, policy: {}, confidence: {})" + "".format(self.action_name, self.policy, self.confidence) + ) + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash(self.action_name) + + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + if not isinstance(other, ActionExecutionRejected): + return NotImplemented + + return self.action_name == other.action_name + + @classmethod + def _from_parameters(cls, parameters: Dict[Text, Any]) -> "ActionExecutionRejected": + return ActionExecutionRejected( + parameters.get("name"), + parameters.get("policy"), + parameters.get("confidence"), + parameters.get("timestamp"), + parameters.get("metadata"), + ) + + def as_dict(self) -> Dict[Text, Any]: + """Returns serialized event.""" + d = super().as_dict() + d.update( + { + "name": self.action_name, + "policy": self.policy, + "confidence": self.confidence, + } + ) + return d + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state.""" + tracker.reject_action(self.action_name) + + +class SessionStarted(AlwaysEqualEventMixin): + """Mark the beginning of a new conversation session.""" + + type_name = "session_started" + + def __hash__(self) -> int: + """Returns unique hash for event.""" + return hash(32143124320) + + def as_story_string(self) -> None: + """Skips representing event in stories.""" + logger.warning( + f"'{self.type_name}' events cannot be serialised as story strings." + ) + + def apply_to(self, tracker: "DialogueStateTracker") -> None: + """Applies event to current conversation state.""" + # noinspection PyProtectedMember + tracker._reset() diff --git a/rasa/shared/core/generator.py b/rasa/shared/core/generator.py new file mode 100644 index 0000000..e1b8ffc --- /dev/null +++ b/rasa/shared/core/generator.py @@ -0,0 +1,908 @@ +from collections import defaultdict, namedtuple, deque + +import copy +import logging +import random +from contextlib import contextmanager + +from tqdm import tqdm +from typing import ( + Optional, + List, + Text, + Set, + Dict, + Tuple, + Deque, + DefaultDict, + Any, + Iterable, + Generator, +) + +from rasa.shared.constants import DOCS_URL_STORIES +from rasa.shared.core.constants import SHOULD_NOT_BE_SET +from rasa.shared.core.domain import Domain, State +from rasa.shared.core.events import ( + ActionExecuted, + UserUttered, + ActionReverted, + UserUtteranceReverted, + Restarted, + Event, + SlotSet, + ActiveLoop, +) +from rasa.shared.core.trackers import DialogueStateTracker, FrozenState +from rasa.shared.core.slots import Slot +from rasa.shared.core.training_data.structures import ( + StoryGraph, + STORY_START, + StoryStep, + RuleStep, + GENERATED_CHECKPOINT_PREFIX, +) +from rasa.shared.utils.io import is_logging_disabled +import rasa.shared.utils.io + +logger = logging.getLogger(__name__) + +ExtractorConfig = namedtuple( + "ExtractorConfig", + "remove_duplicates " + "unique_last_num_states " + "augmentation_factor " + "max_number_of_augmented_trackers " + "tracker_limit " + "use_story_concatenation " + "rand", +) + + +class TrackerWithCachedStates(DialogueStateTracker): + """A tracker wrapper that caches the state creation of the tracker.""" + + def __init__( + self, + sender_id: Text, + slots: Optional[Iterable[Slot]], + max_event_history: Optional[int] = None, + domain: Optional[Domain] = None, + is_augmented: bool = False, + is_rule_tracker: bool = False, + ) -> None: + """Initializes a tracker with cached states.""" + super().__init__( + sender_id, slots, max_event_history, is_rule_tracker=is_rule_tracker + ) + self._states_for_hashing: Deque[FrozenState] = deque() + self.domain = domain if domain is not None else Domain.empty() + # T/F property to filter augmented stories + self.is_augmented = is_augmented + self.__skip_states = False + + @classmethod + def from_events( + cls, + sender_id: Text, + evts: List[Event], + slots: Optional[Iterable[Slot]] = None, + max_event_history: Optional[int] = None, + sender_source: Optional[Text] = None, + domain: Optional[Domain] = None, + is_rule_tracker: bool = False, + ) -> "TrackerWithCachedStates": + """Initializes a tracker with given events.""" + tracker = cls( + sender_id, slots, max_event_history, domain, is_rule_tracker=is_rule_tracker + ) + for e in evts: + tracker.update(e) + return tracker + + def past_states_for_hashing( + self, domain: Domain, omit_unset_slots: bool = False + ) -> Deque[FrozenState]: + """Generates and caches the past states of this tracker based on the history. + + Args: + domain: a :class:`rasa.shared.core.domain.Domain` + omit_unset_slots: If `True` do not include the initial values of slots. + + Returns: + A list of states + """ + if domain != self.domain: + raise ValueError( + "TrackerWithCachedStates cannot be used with a domain " + "that is different from the one it was created with." + ) + + if omit_unset_slots: + # the tracker caches states with omit_unset_slots=False + # Retrieving them from cache with omit_unset_slots=True is not possible as + # this information is lost after a position in the event stream is turned + # into a state + states = super().past_states(domain, omit_unset_slots=omit_unset_slots) + states_for_hashing = deque(self.freeze_current_state(s) for s in states) + else: + # if don't have it cached, we use the domain to calculate the states + # from the events + # note: we ignore omit_unset_slots here as the cache was generated + # with the default value + states_for_hashing = self._states_for_hashing + if not states_for_hashing: + states = super().past_states(domain) + states_for_hashing = deque(self.freeze_current_state(s) for s in states) + + self._states_for_hashing = states_for_hashing + + return states_for_hashing + + @staticmethod + def _unfreeze_states(frozen_states: Deque[FrozenState]) -> List[State]: + return [ + {key: dict(value) for key, value in dict(frozen_state).items()} + for frozen_state in frozen_states + ] + + def past_states( + self, + domain: Domain, + omit_unset_slots: bool = False, + ignore_rule_only_turns: bool = False, + rule_only_data: Optional[Dict[Text, Any]] = None, + ) -> List[State]: + """Generates the past states of this tracker based on the history. + + Args: + domain: The Domain. + omit_unset_slots: If `True` do not include the initial values of slots. + ignore_rule_only_turns: If True ignore dialogue turns that are present + only in rules. + rule_only_data: Slots and loops, + which only occur in rules but not in stories. + + Returns: + a list of states + """ + states_for_hashing = self.past_states_for_hashing( + domain, omit_unset_slots=omit_unset_slots + ) + return self._unfreeze_states(states_for_hashing) + + def clear_states(self) -> None: + """Reset the states.""" + self._states_for_hashing = deque() + + def init_copy(self) -> "TrackerWithCachedStates": + """Create a new state tracker with the same initial values.""" + return type(self)( + "", + self.slots.values(), + self._max_event_history, + self.domain, + self.is_augmented, + self.is_rule_tracker, + ) + + @contextmanager + def _skip_states_manager(self) -> Generator[None, None, None]: + self.__skip_states = True + try: + yield + finally: + self.__skip_states = False + + def copy( + self, sender_id: Text = "", sender_source: Text = "" + ) -> "TrackerWithCachedStates": + """Creates a duplicate of this tracker. + + A new tracker will be created and all events + will be replayed. + """ + # This is an optimization, we could use the original copy, but + # the states would be lost and we would need to recalculate them + + tracker = self.init_copy() + tracker.sender_id = sender_id + tracker.sender_source = sender_source + + with tracker._skip_states_manager(): + for event in self.events: + tracker.update(event) + + tracker._states_for_hashing = copy.copy(self._states_for_hashing) + + return tracker + + def _append_current_state(self) -> None: + if self._states_for_hashing is None: + self._states_for_hashing = self.past_states_for_hashing(self.domain) + else: + state = self.domain.get_active_state(self) + frozen_state = self.freeze_current_state(state) + self._states_for_hashing.append(frozen_state) + + def update( + self, + event: Event, + domain: Optional[Domain] = None, + ) -> None: + """Modify the state of the tracker according to an ``Event``.""" + # if `skip_states` is `True`, this function behaves exactly like the + # normal update of the `DialogueStateTracker` + if not self._states_for_hashing and not self.__skip_states: + # rest of this function assumes we have the previous state + # cached. let's make sure it is there. + self._states_for_hashing = self.past_states_for_hashing(self.domain) + + super().update(event) + + if not self.__skip_states: + if isinstance(event, ActionExecuted): + pass + elif isinstance(event, ActionReverted): + self._states_for_hashing.pop() # removes the state after the action + self._states_for_hashing.pop() # removes the state used for the action + elif isinstance(event, UserUtteranceReverted): + self.clear_states() + elif isinstance(event, Restarted): + self.clear_states() + else: + self._states_for_hashing.pop() + + self._append_current_state() + + +# define types +TrackerLookupDict = DefaultDict[Text, List[TrackerWithCachedStates]] + +TrackersTuple = Tuple[List[TrackerWithCachedStates], List[TrackerWithCachedStates]] + + +class TrainingDataGenerator: + """Generates trackers from training data.""" + + def __init__( + self, + story_graph: StoryGraph, + domain: Domain, + remove_duplicates: bool = True, + unique_last_num_states: Optional[int] = None, + augmentation_factor: int = 50, + tracker_limit: Optional[int] = None, + use_story_concatenation: bool = True, + debug_plots: bool = False, + ): + """Given a set of story parts, generates all stories that are possible. + + The different story parts can end and start with checkpoints + and this generator will match start and end checkpoints to + connect complete stories. Afterwards, duplicate stories will be + removed and the data is augmented (if augmentation is enabled). + """ + self.story_graph = story_graph.with_cycles_removed() + if debug_plots: + self.story_graph.visualize("story_blocks_connections.html") + + self.domain = domain + + # 10x factor is a heuristic for augmentation rounds + max_number_of_augmented_trackers = augmentation_factor * 10 + + self.config = ExtractorConfig( + remove_duplicates=remove_duplicates, + unique_last_num_states=unique_last_num_states, + augmentation_factor=augmentation_factor, + max_number_of_augmented_trackers=max_number_of_augmented_trackers, + tracker_limit=tracker_limit, + use_story_concatenation=use_story_concatenation, + rand=random.Random(42), + ) + # hashed featurization of all finished trackers + self.hashed_featurizations: Set[int] = set() + + @staticmethod + def _phase_name(everything_reachable_is_reached: bool, phase: int) -> Text: + if everything_reachable_is_reached: + return f"augmentation round {phase}" + else: + return f"data generation round {phase}" + + def generate(self) -> List[TrackerWithCachedStates]: + """Generate trackers from stories and rules. + + Returns: + The generated trackers. + """ + return self.generate_story_trackers() + self._generate_rule_trackers() + + def generate_story_trackers(self) -> List[TrackerWithCachedStates]: + """Generate trackers from stories (exclude rule trackers). + + Returns: + The generated story trackers. + """ + steps = [ + step + for step in self.story_graph.ordered_steps() + if not isinstance(step, RuleStep) + ] + + return self._generate(steps, is_rule_data=False) + + def _generate_rule_trackers(self) -> List[TrackerWithCachedStates]: + steps = [ + step + for step in self.story_graph.ordered_steps() + if isinstance(step, RuleStep) + ] + + return self._generate(steps, is_rule_data=True) + + def _generate( + self, story_steps: List[StoryStep], is_rule_data: bool = False + ) -> List[TrackerWithCachedStates]: + if not story_steps: + logger.debug(f"No {'rules' if is_rule_data else 'story blocks'} found.") + return [] + + if self.config.remove_duplicates and self.config.unique_last_num_states: + logger.debug( + "Generated trackers will be deduplicated " + "based on their unique last {} states." + "".format(self.config.unique_last_num_states) + ) + self._mark_first_action_in_story_steps_as_unpredictable() + + active_trackers: DefaultDict[Text, List[TrackerWithCachedStates]] = defaultdict( + list + ) + + init_tracker = TrackerWithCachedStates( + "", + self.domain.slots, + max_event_history=self.config.tracker_limit, + domain=self.domain, + is_rule_tracker=is_rule_data, + ) + active_trackers[STORY_START].append(init_tracker) + + # trackers that are sent to a featurizer + finished_trackers = [] + # keep story end trackers separately for augmentation + story_end_trackers = [] + + phase = 0 # one phase is one traversal of all story steps. + + # do not augment rule data + if not is_rule_data: + min_num_aug_phases = 3 if self.config.augmentation_factor > 0 else 0 + logger.debug(f"Number of augmentation rounds is {min_num_aug_phases}") + else: + min_num_aug_phases = 0 + + # placeholder to track gluing process of checkpoints + used_checkpoints: Set[Text] = set() + previous_unused: Set[Text] = set() + everything_reachable_is_reached = False + + # we will continue generating data until we have reached all + # checkpoints that seem to be reachable. This is a heuristic, + # if we did not reach any new checkpoints in an iteration, we + # assume we have reached all and stop. + + while not everything_reachable_is_reached or phase < min_num_aug_phases: + phase_name = self._phase_name(everything_reachable_is_reached, phase) + + num_active_trackers = self._count_trackers(active_trackers) + + if num_active_trackers: + logger.debug( + "Starting {} ... (with {} trackers)" + "".format(phase_name, num_active_trackers) + ) + else: + logger.debug(f"There are no trackers for {phase_name}") + break + + # track unused checkpoints for this phase + unused_checkpoints: Set[Text] = set() + + desc = f"Processed {'rules' if is_rule_data else 'story blocks'}" + pbar = tqdm(story_steps, desc=desc, disable=is_logging_disabled()) + for step in pbar: + incoming_trackers: List[TrackerWithCachedStates] = [] + for start in step.start_checkpoints: + if active_trackers[start.name]: + ts = start.filter_trackers(active_trackers[start.name]) + incoming_trackers.extend(ts) + used_checkpoints.add(start.name) + elif start.name not in used_checkpoints: + # need to skip - there was no previous step that + # had this start checkpoint as an end checkpoint + # it will be processed in next phases + unused_checkpoints.add(start.name) + if not incoming_trackers: + # if there are no trackers, + # we can skip the rest of the loop + continue + + # these are the trackers that reached this story + # step and that need to handle all events of the step + + if self.config.remove_duplicates: + incoming_trackers, end_trackers = self._remove_duplicate_trackers( + incoming_trackers + ) + + # append end trackers to finished trackers + finished_trackers.extend(end_trackers) + + if everything_reachable_is_reached: + # augmentation round + incoming_trackers = self._subsample_trackers( + incoming_trackers, self.config.max_number_of_augmented_trackers + ) + + # update progress bar + pbar.set_postfix({"# trackers": "{:d}".format(len(incoming_trackers))}) + + trackers, end_trackers = self._process_step(step, incoming_trackers) + + # add end trackers to finished trackers + finished_trackers.extend(end_trackers) + + # update our tracker dictionary with the trackers + # that handled the events of the step and + # that can now be used for further story steps + # that start with the checkpoint this step ended with + + for end in step.end_checkpoints: + start_name = self._find_start_checkpoint_name(end.name) + + active_trackers[start_name].extend(trackers) + + if start_name in used_checkpoints: + # add end checkpoint as unused + # if this checkpoint was processed as + # start one before + unused_checkpoints.add(start_name) + + if not step.end_checkpoints: + unique_ends = self._remove_duplicate_story_end_trackers(trackers) + story_end_trackers.extend(unique_ends) + + num_finished = len(finished_trackers) + len(story_end_trackers) + logger.debug(f"Finished phase ({num_finished} training samples found).") + + # prepare next round + phase += 1 + + if not everything_reachable_is_reached: + # check if we reached all nodes that can be reached + # if we reached at least one more node this round + # than last one, we assume there is still + # something left to reach and we continue + + unused_checkpoints = self._add_unused_end_checkpoints( + set(active_trackers.keys()), unused_checkpoints, used_checkpoints + ) + active_trackers = self._filter_active_trackers( + active_trackers, unused_checkpoints + ) + num_active_trackers = self._count_trackers(active_trackers) + + everything_reachable_is_reached = ( + unused_checkpoints == previous_unused or num_active_trackers == 0 + ) + previous_unused = unused_checkpoints + + if everything_reachable_is_reached: + # should happen only once + + previous_unused -= used_checkpoints + # add trackers with unused checkpoints + # to finished trackers + for start_name in previous_unused: + finished_trackers.extend(active_trackers[start_name]) + + logger.debug("Data generation rounds finished.") + logger.debug( + "Found {} unused checkpoints".format(len(previous_unused)) + ) + phase = 0 + else: + logger.debug( + "Found {} unused checkpoints " + "in current phase." + "".format(len(unused_checkpoints)) + ) + logger.debug( + "Found {} active trackers " + "for these checkpoints." + "".format(num_active_trackers) + ) + + if everything_reachable_is_reached: + # augmentation round, so we process only + # story end checkpoints + # reset used checkpoints + used_checkpoints = set() + + # generate active trackers for augmentation + active_trackers = self._create_start_trackers_for_augmentation( + story_end_trackers + ) + + finished_trackers.extend(story_end_trackers) + self._issue_unused_checkpoint_notification(previous_unused) + logger.debug("Found {} training trackers.".format(len(finished_trackers))) + + if self.config.augmentation_factor > 0: + augmented_trackers, original_trackers = [], [] + for t in finished_trackers: + if t.is_augmented: + augmented_trackers.append(t) + else: + original_trackers.append(t) + augmented_trackers = self._subsample_trackers( + augmented_trackers, self.config.max_number_of_augmented_trackers + ) + logger.debug( + "Subsampled to {} augmented training trackers." + "".format(len(augmented_trackers)) + ) + logger.debug( + "There are {} original trackers.".format(len(original_trackers)) + ) + finished_trackers = original_trackers + augmented_trackers + + return finished_trackers + + @staticmethod + def _count_trackers(active_trackers: TrackerLookupDict) -> int: + """Count the number of trackers in the tracker dictionary.""" + return sum(len(ts) for ts in active_trackers.values()) + + def _subsample_trackers( + self, + incoming_trackers: List[TrackerWithCachedStates], + max_number_of_trackers: int, + ) -> List[TrackerWithCachedStates]: + """Subsample the list of trackers to retrieve a random subset.""" + + # if flows get very long and have a lot of forks we + # get into trouble by collecting too many trackers + # hence the sub sampling + if max_number_of_trackers is not None: + return _subsample_array( + incoming_trackers, max_number_of_trackers, rand=self.config.rand + ) + else: + return incoming_trackers + + def _find_start_checkpoint_name(self, end_name: Text) -> Text: + """Find start checkpoint name given end checkpoint name of a cycle""" + return self.story_graph.story_end_checkpoints.get(end_name, end_name) + + @staticmethod + def _add_unused_end_checkpoints( + start_checkpoints: Set[Text], + unused_checkpoints: Set[Text], + used_checkpoints: Set[Text], + ) -> Set[Text]: + """Add unused end checkpoints + if they were never encountered as start checkpoints + """ + + return unused_checkpoints.union( + { + start_name + for start_name in start_checkpoints + if start_name not in used_checkpoints + } + ) + + @staticmethod + def _filter_active_trackers( + active_trackers: TrackerLookupDict, unused_checkpoints: Set[Text] + ) -> TrackerLookupDict: + """Filter active trackers that ended with unused checkpoint + or are parts of loops.""" + next_active_trackers = defaultdict(list) + + for start_name in unused_checkpoints: + # process trackers ended with unused checkpoints further + if start_name != STORY_START: + # there is no point to process STORY_START checkpoint again + next_active_trackers[start_name] = active_trackers.get(start_name, []) + + return next_active_trackers + + def _create_start_trackers_for_augmentation( + self, story_end_trackers: List[TrackerWithCachedStates] + ) -> TrackerLookupDict: + """This is where the augmentation magic happens. + + We will reuse all the trackers that reached the + end checkpoint `None` (which is the end of a + story) and start processing all steps again. So instead + of starting with a fresh tracker, the second and + all following phases will reuse a couple of the trackers + that made their way to a story end. + + We need to do some cleanup before processing them again. + """ + next_active_trackers = defaultdict(list) + + if self.config.use_story_concatenation: + ending_trackers = _subsample_array( + story_end_trackers, + self.config.augmentation_factor, + rand=self.config.rand, + ) + for t in ending_trackers: + # this is a nasty thing - all stories end and + # start with action listen - so after logging the first + # actions in the next phase the trackers would + # contain action listen followed by action listen. + # to fix this we are going to "undo" the last action listen + + # tracker should be copied, + # otherwise original tracker is updated + aug_t = t.copy() + aug_t.is_augmented = True + aug_t.update(ActionReverted()) + next_active_trackers[STORY_START].append(aug_t) + + return next_active_trackers + + def _process_step( + self, step: StoryStep, incoming_trackers: List[TrackerWithCachedStates] + ) -> TrackersTuple: + """Processes a steps events with all trackers. + + The trackers that reached the steps starting checkpoint will + be used to process the events. Collects and returns training + data while processing the story step.""" + + events = step.explicit_events(self.domain) + + trackers = [] + if events: # small optimization + + # need to copy the tracker as multiple story steps + # might start with the same checkpoint and all of them + # will use the same set of incoming trackers + + for tracker in incoming_trackers: + # sender id is used to be able for a human to see where the + # messages and events for this tracker came from - to do this + # we concatenate the story block names of the blocks that + # contribute to the trackers events + if tracker.sender_id: + if ( + step.block_name + and step.block_name not in tracker.sender_id.split(" > ") + ): + new_sender = tracker.sender_id + " > " + step.block_name + else: + new_sender = tracker.sender_id + else: + new_sender = step.block_name + trackers.append(tracker.copy(new_sender, step.source_name)) + + end_trackers = [] + for event in events: + if ( + isinstance(event, ActionExecuted) + and event.action_text + and event.action_text not in self.domain.action_texts + ): + rasa.shared.utils.cli.print_warning( + f"Test story '{step.block_name}' in " + f"'{step.source_name}' contains the bot utterance " + f"'{event.action_text}', which is not part " + f"of the training data / domain." + ) + for tracker in trackers: + if isinstance( + event, (ActionReverted, UserUtteranceReverted, Restarted) + ): + end_trackers.append(tracker.copy(tracker.sender_id)) + if isinstance(step, RuleStep): + # The rules can specify that a form or a slot shouldn't be set, + # therefore we need to distinguish between not set + # and explicitly set to None + if isinstance(event, ActiveLoop) and event.name is None: + event.name = SHOULD_NOT_BE_SET + + if isinstance(event, SlotSet) and event.value is None: + event.value = SHOULD_NOT_BE_SET + + tracker.update(event) + + # end trackers should be returned separately + # to avoid using them for augmentation + return trackers, end_trackers + + def _remove_duplicate_trackers( + self, trackers: List[TrackerWithCachedStates] + ) -> TrackersTuple: + """Removes trackers that create equal featurizations + for current story step. + + From multiple trackers that create equal featurizations + we only need to keep one. Because as we continue processing + events and story steps, all trackers that created the + same featurization once will do so in the future (as we + feed the same events to all trackers).""" + + step_hashed_featurizations = set() + + # collected trackers that created different featurizations + unique_trackers = [] # for current step + end_trackers = [] # for all steps + + for tracker in trackers: + states_for_hashing = tuple(tracker.past_states_for_hashing(self.domain)) + hashed = hash(states_for_hashing) + + # only continue with trackers that created a + # hashed_featurization we haven't observed + if hashed not in step_hashed_featurizations: + if self.config.unique_last_num_states: + last_states = states_for_hashing[ + -self.config.unique_last_num_states : + ] + last_hashed = hash(last_states) + + if last_hashed not in step_hashed_featurizations: + step_hashed_featurizations.add(last_hashed) + unique_trackers.append(tracker) + elif ( + len(states_for_hashing) > len(last_states) + and hashed not in self.hashed_featurizations + ): + self.hashed_featurizations.add(hashed) + end_trackers.append(tracker) + else: + unique_trackers.append(tracker) + + step_hashed_featurizations.add(hashed) + + return unique_trackers, end_trackers + + def _remove_duplicate_story_end_trackers( + self, trackers: List[TrackerWithCachedStates] + ) -> List[TrackerWithCachedStates]: + """Removes trackers that reached story end and + created equal featurizations.""" + + # collected trackers that created different featurizations + unique_trackers = [] # for all steps + + # deduplication of finished trackers is needed, + # otherwise featurization does a lot of unnecessary work + + for tracker in trackers: + states_for_hashing = tuple(tracker.past_states_for_hashing(self.domain)) + hashed = hash(states_for_hashing + (tracker.is_rule_tracker,)) + + # only continue with trackers that created a + # hashed_featurization we haven't observed + + if hashed not in self.hashed_featurizations: + self.hashed_featurizations.add(hashed) + unique_trackers.append(tracker) + + return unique_trackers + + def _mark_first_action_in_story_steps_as_unpredictable(self) -> None: + """Mark actions which shouldn't be used during ML training. + + If a story starts with an action, we can not use + that first action as a training example, as there is no + history. There is one exception though, we do want to + predict action listen. But because stories never + contain action listen events (they are added when a + story gets converted to a dialogue) we need to apply a + small trick to avoid marking actions occurring after + an action listen as unpredictable.""" + + for step in self.story_graph.story_steps: + # TODO: this does not work if a step is the conversational start + # as well as an intermediary part of a conversation. + # This means a checkpoint can either have multiple + # checkpoints OR be the start of a conversation + # but not both. + if STORY_START in {s.name for s in step.start_checkpoints}: + for i, e in enumerate(step.events): + if isinstance(e, UserUttered): + # if there is a user utterance, that means before the + # user uttered something there has to be + # an action listen. therefore, any action that comes + # after this user utterance isn't the first + # action anymore and the tracker used for prediction + # is not empty anymore. Hence, it is fine + # to predict anything that occurs after an utterance. + break + if isinstance(e, ActionExecuted): + e.unpredictable = True + break + + def _issue_unused_checkpoint_notification( + self, unused_checkpoints: Set[Text] + ) -> None: + """Warns about unused story blocks. + + Unused steps are ones having a start or end checkpoint + that no one provided.""" + + if STORY_START in unused_checkpoints: + rasa.shared.utils.io.raise_warning( + "There is no starting story block " + "in the training data. " + "All your story blocks start with some checkpoint. " + "There should be at least one story block " + "that starts without any checkpoint.", + docs=DOCS_URL_STORIES + "#stories", + ) + + # running through the steps first will result in only one warning + # per block (as one block might have multiple steps) + collected_start = set() + collected_end = set() + for step in self.story_graph.story_steps: + for start in step.start_checkpoints: + if start.name in unused_checkpoints: + # After processing, there shouldn't be a story part left. + # This indicates a start checkpoint that doesn't exist + collected_start.add((start.name, step.block_name)) + + for end in step.end_checkpoints: + if end.name in unused_checkpoints: + # After processing, there shouldn't be a story part left. + # This indicates an end checkpoint that doesn't exist + collected_end.add((end.name, step.block_name)) + + for cp, block_name in collected_start: + if not cp.startswith(GENERATED_CHECKPOINT_PREFIX): + rasa.shared.utils.io.raise_warning( + f"Unsatisfied start checkpoint '{cp}' " + f"in block '{block_name}'. " + f"Remove this checkpoint or add " + f"story blocks that end " + f"with this checkpoint.", + docs=DOCS_URL_STORIES + "#checkpoints", + ) + + for cp, block_name in collected_end: + if not cp.startswith(GENERATED_CHECKPOINT_PREFIX): + rasa.shared.utils.io.raise_warning( + f"Unsatisfied end checkpoint '{cp}' " + f"in block '{block_name}'. " + f"Remove this checkpoint or add " + f"story blocks that start " + f"with this checkpoint.", + docs=DOCS_URL_STORIES + "#checkpoints", + ) + + +def _subsample_array( + arr: List[Any], + max_values: int, + can_modify_incoming_array: bool = True, + rand: Optional[random.Random] = None, +) -> List[Any]: + """Shuffles the array and returns `max_values` number of elements.""" + if not can_modify_incoming_array: + arr = arr[:] + if rand is not None: + rand.shuffle(arr) + else: + random.shuffle(arr) + return arr[:max_values] diff --git a/rasa/shared/core/slot_mappings.py b/rasa/shared/core/slot_mappings.py new file mode 100644 index 0000000..44d19a4 --- /dev/null +++ b/rasa/shared/core/slot_mappings.py @@ -0,0 +1,235 @@ +from typing import Text, Dict, Any, List, Optional, TYPE_CHECKING + +from rasa.shared.constants import DOCS_URL_SLOTS, IGNORED_INTENTS +import rasa.shared.utils.io +from rasa.shared.nlu.constants import ( + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_ROLE, + ENTITY_ATTRIBUTE_GROUP, + INTENT, + NOT_INTENT, + INTENT_NAME_KEY, +) +from rasa.shared.core.constants import ( + SLOT_MAPPINGS, + MAPPING_TYPE, + SlotMappingType, + MAPPING_CONDITIONS, +) + +if TYPE_CHECKING: + from rasa.shared.core.trackers import DialogueStateTracker + from rasa.shared.core.domain import Domain + + +class SlotMapping: + """Defines functionality for the available slot mappings.""" + + @staticmethod + def validate(mapping: Dict[Text, Any], slot_name: Text) -> None: + """Validates a slot mapping. + + Args: + mapping: The mapping which is validated. + slot_name: The name of the slot which is mapped by this mapping. + + Raises: + InvalidDomain: In case the slot mapping is not valid. + """ + from rasa.shared.core.domain import InvalidDomain + + if not isinstance(mapping, dict): + raise InvalidDomain( + f"Please make sure that the slot mappings for slot '{slot_name}' in " + f"your domain are valid dictionaries. Please see " + f"{DOCS_URL_SLOTS} for more information." + ) + + try: + mapping_type = SlotMappingType(mapping.get(MAPPING_TYPE)) + except ValueError: + raise InvalidDomain( + f"Your domain uses an invalid slot mapping of type " + f"'{mapping.get(MAPPING_TYPE)}' for slot '{slot_name}'. Please see " + f"{DOCS_URL_SLOTS} for more information." + ) + + validations: Dict[SlotMappingType, List[Text]] = { + SlotMappingType.FROM_ENTITY: ["entity"], + SlotMappingType.FROM_INTENT: ["value"], + SlotMappingType.FROM_TRIGGER_INTENT: ["value"], + SlotMappingType.FROM_TEXT: [], + SlotMappingType.CUSTOM: [], + } + + required_keys = validations[mapping_type] + for required_key in required_keys: + if mapping.get(required_key) is None: + raise InvalidDomain( + f"You need to specify a value for the key " + f"'{required_key}' in the slot mapping of type '{mapping_type}' " + f"for slot '{slot_name}'. Please see " + f"{DOCS_URL_SLOTS} for more information." + ) + + @staticmethod + def _get_active_loop_ignored_intents( + mapping: Dict[Text, Any], domain: "Domain", active_loop_name: Text + ) -> List[Text]: + from rasa.shared.core.constants import ACTIVE_LOOP + + mapping_conditions = mapping.get(MAPPING_CONDITIONS) + active_loop_match = True + ignored_intents = [] + + if mapping_conditions: + match_list = [ + condition.get(ACTIVE_LOOP) == active_loop_name + for condition in mapping_conditions + ] + active_loop_match = any(match_list) + + if active_loop_match: + form_ignored_intents = domain.forms.get(active_loop_name, {}).get( + IGNORED_INTENTS, [] + ) + ignored_intents = SlotMapping.to_list(form_ignored_intents) + + return ignored_intents + + @staticmethod + def intent_is_desired( + mapping: Dict[Text, Any], tracker: "DialogueStateTracker", domain: "Domain" + ) -> bool: + """Checks whether user intent matches slot mapping intent specifications.""" + mapping_intents = SlotMapping.to_list(mapping.get(INTENT, [])) + mapping_not_intents = SlotMapping.to_list(mapping.get(NOT_INTENT, [])) + + active_loop_name = tracker.active_loop_name + if active_loop_name: + mapping_not_intents = ( + mapping_not_intents + + SlotMapping._get_active_loop_ignored_intents( + mapping, domain, active_loop_name + ) + ) + + if tracker.latest_message: + intent = tracker.latest_message.intent.get(INTENT_NAME_KEY) + else: + intent = None + + intent_not_blocked = not mapping_intents and intent not in set( + mapping_not_intents + ) + + return intent_not_blocked or intent in mapping_intents + + # helpers + @staticmethod + def to_list(x: Optional[Any]) -> List[Any]: + """Convert object to a list if it isn't.""" + if x is None: + x = [] + elif not isinstance(x, list): + x = [x] + + return x + + @staticmethod + def entity_is_desired( + mapping: Dict[Text, Any], tracker: "DialogueStateTracker" + ) -> bool: + """Checks whether slot should be filled by an entity in the input or not. + + Args: + mapping: Slot mapping. + tracker: The tracker. + + Returns: + True, if slot should be filled, false otherwise. + """ + slot_fulfils_entity_mapping = False + if tracker.latest_message: + extracted_entities = tracker.latest_message.entities + else: + extracted_entities = [] + + for entity in extracted_entities: + if ( + mapping.get(ENTITY_ATTRIBUTE_TYPE) == entity[ENTITY_ATTRIBUTE_TYPE] + and mapping.get(ENTITY_ATTRIBUTE_ROLE) + == entity.get(ENTITY_ATTRIBUTE_ROLE) + and mapping.get(ENTITY_ATTRIBUTE_GROUP) + == entity.get(ENTITY_ATTRIBUTE_GROUP) + ): + matching_values = tracker.get_latest_entity_values( + mapping.get(ENTITY_ATTRIBUTE_TYPE), + mapping.get(ENTITY_ATTRIBUTE_ROLE), + mapping.get(ENTITY_ATTRIBUTE_GROUP), + ) + slot_fulfils_entity_mapping = matching_values is not None + break + + return slot_fulfils_entity_mapping + + @staticmethod + def check_mapping_validity( + slot_name: Text, + mapping_type: SlotMappingType, + mapping: Dict[Text, Any], + domain: "Domain", + ) -> bool: + """Checks the mapping for validity. + + Args: + slot_name: The name of the slot to be validated. + mapping_type: The type of the slot mapping. + mapping: Slot mapping. + domain: The domain to check against. + + Returns: + True, if intent and entity specified in a mapping exist in domain. + """ + if ( + mapping_type == SlotMappingType.FROM_ENTITY + and mapping.get(ENTITY_ATTRIBUTE_TYPE) not in domain.entities + ): + rasa.shared.utils.io.raise_warning( + f"Slot '{slot_name}' uses a 'from_entity' mapping " + f"for a non-existent entity '{mapping.get(ENTITY_ATTRIBUTE_TYPE)}'. " + f"Skipping slot extraction because of invalid mapping." + ) + return False + + if ( + mapping_type == SlotMappingType.FROM_INTENT + and mapping.get(INTENT) is not None + ): + intent_list = SlotMapping.to_list(mapping.get(INTENT)) + for intent in intent_list: + if intent and intent not in domain.intents: + rasa.shared.utils.io.raise_warning( + f"Slot '{slot_name}' uses a 'from_intent' mapping for " + f"a non-existent intent '{mapping.get('intent')}'. " + f"Skipping slot extraction because of invalid mapping." + ) + return False + + return True + + +def validate_slot_mappings(domain_slots: Dict[Text, Any]) -> None: + """Raises InvalidDomain exception if slot mappings are invalid.""" + rasa.shared.utils.io.raise_warning( + f"Slot auto-fill has been removed in 3.0 and replaced with a " + f"new explicit mechanism to set slots. " + f"Please refer to {DOCS_URL_SLOTS} to learn more.", + UserWarning, + ) + + for slot_name, properties in domain_slots.items(): + mappings = properties.get(SLOT_MAPPINGS) + + for slot_mapping in mappings: + SlotMapping.validate(slot_mapping, slot_name) diff --git a/rasa/shared/core/slots.py b/rasa/shared/core/slots.py new file mode 100644 index 0000000..62d19d0 --- /dev/null +++ b/rasa/shared/core/slots.py @@ -0,0 +1,453 @@ +import logging +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Text, Type + +import rasa.shared.core.constants +from rasa.shared.exceptions import RasaException +import rasa.shared.utils.common +import rasa.shared.utils.io +from rasa.shared.constants import DOCS_URL_SLOTS + +logger = logging.getLogger(__name__) + + +class InvalidSlotTypeException(RasaException): + """Raised if a slot type is invalid.""" + + +class InvalidSlotConfigError(RasaException, ValueError): + """Raised if a slot's config is invalid.""" + + +class Slot(ABC): + """Key-value store for storing information during a conversation.""" + + @property + @abstractmethod + def type_name(self) -> Text: + """Name of the type of slot.""" + ... + + def __init__( + self, + name: Text, + mappings: List[Dict[Text, Any]], + initial_value: Any = None, + value_reset_delay: Optional[int] = None, + influence_conversation: bool = True, + ) -> None: + """Create a Slot. + + Args: + name: The name of the slot. + initial_value: The initial value of the slot. + mappings: List containing slot mappings. + value_reset_delay: After how many turns the slot should be reset to the + initial_value. This is behavior is currently not implemented. + influence_conversation: If `True` the slot will be featurized and hence + influence the predictions of the dialogue polices. + """ + self.name = name + self.mappings = mappings + self._value = initial_value + self.initial_value = initial_value + self._value_reset_delay = value_reset_delay + self.influence_conversation = influence_conversation + self._has_been_set = False + + def feature_dimensionality(self) -> int: + """How many features this single slot creates. + + Returns: + The number of features. `0` if the slot is unfeaturized. The dimensionality + of the array returned by `as_feature` needs to correspond to this value. + """ + if not self.influence_conversation: + return 0 + + return self._feature_dimensionality() + + def _feature_dimensionality(self) -> int: + """See the docstring for `feature_dimensionality`.""" + return 1 + + def has_features(self) -> bool: + """Indicate if the slot creates any features.""" + return self.feature_dimensionality() != 0 + + def value_reset_delay(self) -> Optional[int]: + """After how many turns the slot should be reset to the initial_value. + + If the delay is set to `None`, the slot will keep its value forever.""" + # TODO: FUTURE this needs to be implemented - slots are not reset yet + return self._value_reset_delay + + def as_feature(self) -> List[float]: + if not self.influence_conversation: + return [] + + return self._as_feature() + + @abstractmethod + def _as_feature(self) -> List[float]: + raise NotImplementedError( + "Each slot type needs to specify how its " + "value can be converted to a feature. Slot " + "'{}' is a generic slot that can not be used " + "for predictions. Make sure you add this " + "slot to your domain definition, specifying " + "the type of the slot. If you implemented " + "a custom slot type class, make sure to " + "implement `.as_feature()`." + "".format(self.name) + ) + + def reset(self) -> None: + """Resets the slot's value to the initial value.""" + self.value = self.initial_value + self._has_been_set = False + + @property + def value(self) -> Any: + """Gets the slot's value.""" + return self._value + + @value.setter + def value(self, value: Any) -> None: + """Sets the slot's value.""" + self._value = value + self._has_been_set = True + + @property + def has_been_set(self) -> bool: + """Indicates if the slot's value has been set.""" + return self._has_been_set + + def __str__(self) -> Text: + return f"{self.__class__.__name__}({self.name}: {self.value})" + + def __repr__(self) -> Text: + return f"<{self.__class__.__name__}({self.name}: {self.value})>" + + @staticmethod + def resolve_by_type(type_name: Text) -> Type["Slot"]: + """Returns a slots class by its type name.""" + for cls in rasa.shared.utils.common.all_subclasses(Slot): + if cls.type_name == type_name: + return cls + try: + return rasa.shared.utils.common.class_from_module_path(type_name) + except (ImportError, AttributeError): + raise InvalidSlotTypeException( + f"Failed to find slot type, '{type_name}' is neither a known type nor " + f"user-defined. If you are creating your own slot type, make " + f"sure its module path is correct. " + f"You can find all build in types at {DOCS_URL_SLOTS}" + ) + + def persistence_info(self) -> Dict[str, Any]: + """Returns relevant information to persist this slot.""" + return { + "type": rasa.shared.utils.common.module_path_from_instance(self), + "initial_value": self.initial_value, + "influence_conversation": self.influence_conversation, + "mappings": self.mappings, + } + + def fingerprint(self) -> Text: + """Returns a unique hash for the slot which is stable across python runs. + + Returns: + fingerprint of the slot + """ + data = {"slot_name": self.name, "slot_value": self.value} + data.update(self.persistence_info()) + return rasa.shared.utils.io.get_dictionary_fingerprint(data) + + +class FloatSlot(Slot): + """A slot storing a float value.""" + + type_name = "float" + + def __init__( + self, + name: Text, + mappings: List[Dict[Text, Any]], + initial_value: Optional[float] = None, + value_reset_delay: Optional[int] = None, + max_value: float = 1.0, + min_value: float = 0.0, + influence_conversation: bool = True, + ) -> None: + """Creates a FloatSlot. + + Raises: + InvalidSlotConfigError, if the min-max range is invalid. + UserWarning, if initial_value is outside the min-max range. + """ + super().__init__( + name, mappings, initial_value, value_reset_delay, influence_conversation + ) + self.max_value = max_value + self.min_value = min_value + + if min_value >= max_value: + raise InvalidSlotConfigError( + "Float slot ('{}') created with an invalid range " + "using min ({}) and max ({}) values. Make sure " + "min is smaller than max." + "".format(self.name, self.min_value, self.max_value) + ) + + if initial_value is not None and not (min_value <= initial_value <= max_value): + rasa.shared.utils.io.raise_warning( + f"Float slot ('{self.name}') created with an initial value " + f"{self.value}. This value is outside of the configured min " + f"({self.min_value}) and max ({self.max_value}) values." + ) + + def _as_feature(self) -> List[float]: + try: + capped_value = max(self.min_value, min(self.max_value, float(self.value))) + if abs(self.max_value - self.min_value) > 0: + covered_range = abs(self.max_value - self.min_value) + else: + covered_range = 1 + return [1.0, (capped_value - self.min_value) / covered_range] + except (TypeError, ValueError): + return [0.0, 0.0] + + def persistence_info(self) -> Dict[Text, Any]: + """Returns relevant information to persist this slot.""" + d = super().persistence_info() + d["max_value"] = self.max_value + d["min_value"] = self.min_value + return d + + def _feature_dimensionality(self) -> int: + return len(self.as_feature()) + + +class BooleanSlot(Slot): + """A slot storing a truth value.""" + + type_name = "bool" + + def _as_feature(self) -> List[float]: + try: + if self.value is not None: + return [1.0, float(bool_from_any(self.value))] + else: + return [0.0, 0.0] + except (TypeError, ValueError): + # we couldn't convert the value to float - using default value + return [0.0, 0.0] + + def _feature_dimensionality(self) -> int: + return len(self.as_feature()) + + +def bool_from_any(x: Any) -> bool: + """Converts bool/float/int/str to bool or raises error.""" + if isinstance(x, bool): + return x + elif isinstance(x, (float, int)): + return x == 1.0 + elif isinstance(x, str): + if x.isnumeric(): + return float(x) == 1.0 + elif x.strip().lower() == "true": + return True + elif x.strip().lower() == "false": + return False + else: + raise ValueError("Cannot convert string to bool") + else: + raise TypeError("Cannot convert to bool") + + +class TextSlot(Slot): + type_name = "text" + + def _as_feature(self) -> List[float]: + return [1.0 if self.value is not None else 0.0] + + +class ListSlot(Slot): + type_name = "list" + + def _as_feature(self) -> List[float]: + try: + if self.value is not None and len(self.value) > 0: + return [1.0] + else: + return [0.0] + except (TypeError, ValueError): + # we couldn't convert the value to a list - using default value + return [0.0] + + # FIXME: https://github.com/python/mypy/issues/8085 + @Slot.value.setter # type: ignore[attr-defined,misc] + def value(self, value: Any) -> None: + """Sets the slot's value.""" + if value and not isinstance(value, list): + # Make sure we always store list items + value = [value] + + # Call property setter of superclass + # FIXME: https://github.com/python/mypy/issues/8085 + super(ListSlot, self.__class__).value.fset(self, value) # type: ignore[attr-defined] # noqa: E501 + + +class CategoricalSlot(Slot): + """Slot type which can be used to branch conversations based on its value.""" + + type_name = "categorical" + + def __init__( + self, + name: Text, + mappings: List[Dict[Text, Any]], + values: Optional[List[Any]] = None, + initial_value: Any = None, + value_reset_delay: Optional[int] = None, + influence_conversation: bool = True, + ) -> None: + """Creates a `Categorical Slot` (see parent class for detailed docstring).""" + super().__init__( + name, mappings, initial_value, value_reset_delay, influence_conversation + ) + if values and None in values: + rasa.shared.utils.io.raise_warning( + f"Categorical slot '{self.name}' has `null` listed as a possible value" + f" in the domain file, which translates to `None` in Python. This value" + f" is reserved for when the slot is not set, and should not be listed" + f" as a value in the slot's definition." + f" Rasa will ignore `null` as a possible value for the '{self.name}'" + f" slot. Consider changing this value in your domain file to, for" + f" example, `unset`, or provide the value explicitly as a string by" + f' using quotation marks: "null".', + category=UserWarning, + ) + self.values = ( + [str(v).lower() for v in values if v is not None] if values else [] + ) + + def add_default_value(self) -> None: + """Adds the special default value to the list of possible values.""" + values = set(self.values) + if rasa.shared.core.constants.DEFAULT_CATEGORICAL_SLOT_VALUE not in values: + self.values.append( + rasa.shared.core.constants.DEFAULT_CATEGORICAL_SLOT_VALUE + ) + + def persistence_info(self) -> Dict[Text, Any]: + """Returns serialized slot.""" + d = super().persistence_info() + d["values"] = [ + value + for value in self.values + # Don't add default slot when persisting it. + # We'll re-add it on the fly when creating the domain. + if value != rasa.shared.core.constants.DEFAULT_CATEGORICAL_SLOT_VALUE + ] + return d + + def _as_feature(self) -> List[float]: + r = [0.0] * self.feature_dimensionality() + + # Return the zero-filled array if the slot is unset (i.e. set to None). + # Conceptually, this is similar to the case when the featurisation process + # fails, hence the returned features here are the same as for that case. + if self.value is None: + return r + + try: + for i, v in enumerate(self.values): + if v == str(self.value).lower(): + r[i] = 1.0 + break + else: + if ( + rasa.shared.core.constants.DEFAULT_CATEGORICAL_SLOT_VALUE + in self.values + ): + i = self.values.index( + rasa.shared.core.constants.DEFAULT_CATEGORICAL_SLOT_VALUE + ) + r[i] = 1.0 + else: + rasa.shared.utils.io.raise_warning( + f"Categorical slot '{self.name}' is set to a value " + f"('{self.value}') " + "that is not specified in the domain. " + "Value will be ignored and the slot will " + "behave as if no value is set. " + "Make sure to add all values a categorical " + "slot should store to the domain." + ) + except (TypeError, ValueError): + logger.exception("Failed to featurize categorical slot.") + return r + return r + + def _feature_dimensionality(self) -> int: + return len(self.values) + + +class AnySlot(Slot): + """Slot which can be used to store any value. + + Users need to create a subclass of `Slot` in case + the information is supposed to get featurized. + """ + + type_name = "any" + + def __init__( + self, + name: Text, + mappings: List[Dict[Text, Any]], + initial_value: Any = None, + value_reset_delay: Optional[int] = None, + influence_conversation: bool = False, + ) -> None: + """Creates an `Any Slot` (see parent class for detailed docstring). + + Raises: + InvalidSlotConfigError, if slot is featurized. + """ + if influence_conversation: + raise InvalidSlotConfigError( + f"An {AnySlot.__name__} cannot be featurized. " + f"Please use a different slot type for slot '{name}' instead. If you " + f"need to featurize a data type which is not supported out of the box, " + f"implement a custom slot type by subclassing '{Slot.__name__}'. " + f"See the documentation for more information: {DOCS_URL_SLOTS}" + ) + + super().__init__( + name, mappings, initial_value, value_reset_delay, influence_conversation + ) + + def __eq__(self, other: Any) -> bool: + """Compares object with other object.""" + if not isinstance(other, AnySlot): + return NotImplemented + + return ( + self.name == other.name + and self.initial_value == other.initial_value + and self._value_reset_delay == other._value_reset_delay + and self.value == other.value + ) + + def _as_feature(self) -> List[float]: + raise InvalidSlotConfigError( + f"An {AnySlot.__name__} cannot be featurized. " + f"Please use a different slot type for slot '{self.name}' instead. If you " + f"need to featurize a data type which is not supported out of the box, " + f"implement a custom slot type by subclassing '{Slot.__name__}'. " + f"See the documentation for more information: {DOCS_URL_SLOTS}" + ) diff --git a/rasa/shared/core/trackers.py b/rasa/shared/core/trackers.py new file mode 100644 index 0000000..8b3c6b8 --- /dev/null +++ b/rasa/shared/core/trackers.py @@ -0,0 +1,963 @@ +import copy +import dataclasses +import itertools +import logging +import os +import time +from collections import deque +from enum import Enum +from typing import ( + Dict, + Text, + Any, + Optional, + Iterator, + Generator, + Type, + TypeVar, + List, + Deque, + Iterable, + Union, + FrozenSet, + Tuple, + TYPE_CHECKING, + cast, +) + +import rasa.shared.utils.io +from rasa.shared.constants import ASSISTANT_ID_KEY, DEFAULT_SENDER_ID +from rasa.shared.nlu.constants import ( + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_GROUP, + ENTITY_ATTRIBUTE_ROLE, + ACTION_TEXT, + ACTION_NAME, + ENTITIES, + METADATA_MODEL_ID, +) +from rasa.shared.core import events +from rasa.shared.core.constants import ( + ACTION_LISTEN_NAME, + LOOP_NAME, + SHOULD_NOT_BE_SET, + PREVIOUS_ACTION, + ACTIVE_LOOP, + ACTION_SESSION_START_NAME, + FOLLOWUP_ACTION, +) +from rasa.shared.core.conversation import Dialogue +from rasa.shared.core.events import ( + UserUttered, + ActionExecuted, + Event, + Restarted, + ActionReverted, + UserUtteranceReverted, + BotUttered, + ActiveLoop, + SessionStarted, + ActionExecutionRejected, + DefinePrevUserUtteredFeaturization, +) +from rasa.shared.core.domain import Domain, State +from rasa.shared.core.slots import AnySlot, Slot + +if TYPE_CHECKING: + from rasa.shared.core.events import NLUPredictionData + from rasa.shared.core.training_data.structures import Story + from rasa.shared.core.training_data.story_writer.story_writer import StoryWriter + + EventTypeAlias = TypeVar("EventTypeAlias", bound=Event) + + +@dataclasses.dataclass +class TrackerActiveLoop: + """Dataclass for `DialogueStateTracker.active_loop`.""" + + name: Optional[Text] + is_interrupted: bool + rejected: bool + trigger_message: Optional[Dict] + + +logger = logging.getLogger(__name__) + +# same as State but with Dict[...] substituted with FrozenSet[Tuple[...]] +FrozenState = FrozenSet[Tuple[Text, FrozenSet[Tuple[Text, Tuple[Union[float, Text]]]]]] + + +class EventVerbosity(Enum): + """Filter on which events to include in tracker dumps.""" + + # no events will be included + NONE = 1 + + # all events, that contribute to the trackers state are included + # these are all you need to reconstruct the tracker state + APPLIED = 2 + + # include even more events, in this case everything that comes + # after the most recent restart event. this will also include + # utterances that got reverted and actions that got undone. + AFTER_RESTART = 3 + + # include every logged event + ALL = 4 + + +class AnySlotDict(dict): + """A slot dictionary that pretends every slot exists, by creating slots on demand. + + This only uses the generic slot type! This means certain functionality wont work, + e.g. properly featurizing the slot. + """ + + def __missing__(self, key: Text) -> Slot: + value = self[key] = AnySlot(key, mappings=[]) + return value + + def __contains__(self, key: Any) -> bool: + return True + + +class DialogueStateTracker: + """Maintains the state of a conversation. + + The field max_event_history will only give you these last events, + it can be set in the tracker_store. + """ + + @classmethod + def from_dict( + cls, + sender_id: Text, + events_as_dict: List[Dict[Text, Any]], + slots: Optional[Iterable[Slot]] = None, + max_event_history: Optional[int] = None, + ) -> "DialogueStateTracker": + """Create a tracker from dump. + + The dump should be an array of dumped events. When restoring + the tracker, these events will be replayed to recreate the state. + """ + evts = events.deserialise_events(events_as_dict) + + return cls.from_events(sender_id, evts, slots, max_event_history) + + @classmethod + def from_events( + cls, + sender_id: Text, + evts: List[Event], + slots: Optional[Iterable[Slot]] = None, + max_event_history: Optional[int] = None, + sender_source: Optional[Text] = None, + domain: Optional[Domain] = None, + ) -> "DialogueStateTracker": + """Creates tracker from existing events. + + Args: + sender_id: The ID of the conversation. + evts: Existing events which should be applied to the new tracker. + slots: Slots which can be set. + max_event_history: Maximum number of events which should be stored. + sender_source: File source of the messages. + domain: The current model domain. + + Returns: + Instantiated tracker with its state updated according to the given + events. + """ + tracker = cls(sender_id, slots, max_event_history, sender_source) + + for e in evts: + tracker.update(e, domain) + + return tracker + + def __init__( + self, + sender_id: Text, + slots: Optional[Iterable[Slot]], + max_event_history: Optional[int] = None, + sender_source: Optional[Text] = None, + is_rule_tracker: bool = False, + ) -> None: + """Initialize the tracker. + + A set of events can be stored externally, and we will run through all + of them to get the current state. The tracker will represent all the + information we captured while processing messages of the dialogue. + """ + # maximum number of events to store + self._max_event_history = max_event_history + # list of previously seen events + self.events = self._create_events([]) + # id of the source of the messages + self.sender_id = sender_id + # slots that can be filled in this domain + if slots is not None: + self.slots = {slot.name: copy.copy(slot) for slot in slots} + else: + self.slots = AnySlotDict() + # file source of the messages + self.sender_source = sender_source + # whether the tracker belongs to a rule-based data + self.is_rule_tracker = is_rule_tracker + + ### + # current state of the tracker - MUST be re-creatable by processing + # all the events. This only defines the attributes, values are set in + # `reset()` + ### + # if tracker is paused, no actions should be taken + self._paused = False + # A deterministically scheduled action to be executed next + self.followup_action: Optional[Text] = ACTION_LISTEN_NAME + self.latest_action: Optional[Dict[Text, Text]] = None + # Stores the most recent message sent by the user + self.latest_message: Optional[UserUttered] = None + self.latest_bot_utterance: Optional[BotUttered] = None + self._reset() + self.active_loop: Optional[TrackerActiveLoop] = None + + # Optional model_id to add to all events. + self.model_id: Optional[Text] = None + self.assistant_id: Optional[Text] = None + + ### + # Public tracker interface + ### + def current_state( + self, event_verbosity: EventVerbosity = EventVerbosity.NONE + ) -> Dict[Text, Any]: + """Returns the current tracker state as an object.""" + events = self._events_for_verbosity(event_verbosity) + events_as_dict = [e.as_dict() for e in events] if events is not None else None + latest_event_time = None + if len(self.events) > 0: + latest_event_time = self.events[-1].timestamp + + return { + "sender_id": self.sender_id, + "slots": self.current_slot_values(), + "latest_message": self._latest_message_data(), + "latest_event_time": latest_event_time, + FOLLOWUP_ACTION: self.followup_action, + "paused": self.is_paused(), + "events": events_as_dict, + "latest_input_channel": self.get_latest_input_channel(), + ACTIVE_LOOP: ( + dataclasses.asdict(self.active_loop) if self.active_loop else {} + ), + "latest_action": self.latest_action, + "latest_action_name": self.latest_action_name, + } + + def _events_for_verbosity( + self, event_verbosity: EventVerbosity + ) -> Optional[List[Event]]: + if event_verbosity == EventVerbosity.ALL: + return list(self.events) + if event_verbosity == EventVerbosity.AFTER_RESTART: + return self.events_after_latest_restart() + if event_verbosity == EventVerbosity.APPLIED: + return self.applied_events() + + return None + + def _latest_message_data(self) -> Optional["NLUPredictionData"]: + if not self.latest_message: + return None + + parse_data_with_nlu_state = self.latest_message.parse_data.copy() + # Combine entities predicted by NLU with entities predicted by policies so that + # users can access them together via `latest_message` (e.g. in custom actions) + parse_data_with_nlu_state[ENTITIES] = self.latest_message.entities # type: ignore[literal-required] # noqa: E501 + + return parse_data_with_nlu_state + + @staticmethod + def freeze_current_state(state: State) -> FrozenState: + """Convert State dict into a hashable format FrozenState. + + Args: + state: The state which should be converted + + Return: + hashable form of the state of type `FrozenState` + """ + return frozenset( + { + key: frozenset(values.items()) + if isinstance(values, Dict) + else frozenset(values) + for key, values in state.items() + }.items() + ) + + def past_states( + self, + domain: Domain, + omit_unset_slots: bool = False, + ignore_rule_only_turns: bool = False, + rule_only_data: Optional[Dict[Text, Any]] = None, + ) -> List[State]: + """Generates the past states of this tracker based on the history. + + Args: + domain: The Domain. + omit_unset_slots: If `True` do not include the initial values of slots. + ignore_rule_only_turns: If True ignore dialogue turns that are present + only in rules. + rule_only_data: Slots and loops, + which only occur in rules but not in stories. + + Returns: + A list of states + """ + return domain.states_for_tracker_history( + self, + omit_unset_slots=omit_unset_slots, + ignore_rule_only_turns=ignore_rule_only_turns, + rule_only_data=rule_only_data, + ) + + def change_loop_to(self, loop_name: Optional[Text]) -> None: + """Set the currently active loop. + + Args: + loop_name: The name of loop which should be marked as active. + """ + if loop_name is not None: + self.active_loop = TrackerActiveLoop( + loop_name, + False, + False, + self.latest_message.parse_data if self.latest_message else None, + ) + else: + self.active_loop = None + + def interrupt_loop(self, is_interrupted: bool) -> None: + """Interrupt loop and mark that we entered an unhappy path in the conversation. + + Args: + is_interrupted: `True` if the loop was run after an unhappy path. + """ + if self.active_loop is not None: + self.active_loop.is_interrupted = is_interrupted + + def reject_action(self, action_name: Text) -> None: + """Notify active loop that it was rejected.""" + if self.active_loop is not None and action_name == self.active_loop_name: + self.active_loop.rejected = True + + def set_latest_action(self, action: Dict[Text, Text]) -> None: + """Sets latest action name or text. + + Resets loop validation and rejection parameters. + + Args: + action: Serialized action event. + """ + self.latest_action = action + if self.active_loop is not None and self.active_loop_name: + # reset form validation if some loop is active + self.active_loop.is_interrupted = False + + if ( + self.active_loop is not None + and action.get(ACTION_NAME) == self.active_loop_name + ): + # reset loop rejection if it was predicted again + self.active_loop.rejected = False + + def current_slot_values(self) -> Dict[Text, Any]: + """Return the currently set values of the slots.""" + return {key: slot.value for key, slot in self.slots.items()} + + def get_slot(self, key: Text) -> Optional[Any]: + """Retrieves the value of a slot.""" + if key in self.slots: + return self.slots[key].value + else: + logger.info(f"Tried to access non existent slot '{key}'") + return None + + def get_latest_entity_values( + self, + entity_type: Text, + entity_role: Optional[Text] = None, + entity_group: Optional[Text] = None, + ) -> Iterator[Text]: + """Get entity values found for the passed entity type and optional role and + group in latest message. + + If you are only interested in the first entity of a given type use + `next(tracker.get_latest_entity_values(`"`my_entity_name`"`), None)`. + If no entity is found `None` is the default result. + + Args: + entity_type: the entity type of interest + entity_role: optional entity role of interest + entity_group: optional entity group of interest + + Returns: + Entity values. + """ + if self.latest_message is None: + return iter([]) + + return ( + cast(Text, x[ENTITY_ATTRIBUTE_VALUE]) + for x in self.latest_message.entities + if x.get(ENTITY_ATTRIBUTE_TYPE) == entity_type + and x.get(ENTITY_ATTRIBUTE_GROUP) == entity_group + and x.get(ENTITY_ATTRIBUTE_ROLE) == entity_role + ) + + def get_latest_input_channel(self) -> Optional[Text]: + """Get the name of the input_channel of the latest UserUttered event.""" + for e in reversed(self.events): + if isinstance(e, UserUttered): + return e.input_channel + return None + + def is_paused(self) -> bool: + """State whether the tracker is currently paused.""" + return self._paused + + def idx_after_latest_restart(self) -> int: + """Return the idx of the most recent restart in the list of events. + + If the conversation has not been restarted, ``0`` is returned. + """ + for i, event in enumerate(reversed(self.events)): + if isinstance(event, Restarted): + return len(self.events) - i + + return 0 + + def events_after_latest_restart(self) -> List[Event]: + """Return a list of events after the most recent restart.""" + return list(self.events)[self.idx_after_latest_restart() :] + + def init_copy(self) -> "DialogueStateTracker": + """Creates a new state tracker with the same initial values.""" + return DialogueStateTracker( + self.sender_id or DEFAULT_SENDER_ID, + self.slots.values(), + self._max_event_history, + is_rule_tracker=self.is_rule_tracker, + ) + + def generate_all_prior_trackers( + self, + ) -> Generator[Tuple["DialogueStateTracker", bool], None, None]: + """Returns a generator of the previous trackers of this tracker. + + Returns: + The tuple with the tracker before each action, + and the boolean flag representing whether this action should be hidden + in the dialogue history created for ML-based policies. + """ + tracker = self.init_copy() + + for event in self.applied_events(): + + if isinstance(event, ActionExecuted): + yield tracker, event.hide_rule_turn + + tracker.update(event) + + yield tracker, False + + def applied_events(self) -> List[Event]: + """Returns all actions that should be applied - w/o reverted events. + + Returns: + The events applied to the tracker. + """ + loop_names = [ + event.name + for event in self.events + if isinstance(event, ActiveLoop) and event.name + ] + + applied_events: List[Event] = [] + + for event in self.events: + if isinstance(event, (Restarted, SessionStarted)): + applied_events = [] + elif isinstance(event, ActionReverted): + self._undo_till_previous(ActionExecuted, applied_events) + elif isinstance(event, UserUtteranceReverted): + # Seeing a user uttered event automatically implies there was + # a listen event right before it, so we'll first rewind the + # user utterance, then get the action right before it (also removes + # the `action_listen` action right before it). + self._undo_till_previous(UserUttered, applied_events) + self._undo_till_previous(ActionExecuted, applied_events) + elif ( + isinstance(event, ActionExecuted) + and event.action_name in loop_names + and not self._first_loop_execution_or_unhappy_path( + event.action_name, applied_events + ) + ): + self._undo_till_previous_loop_execution( + event.action_name, applied_events + ) + else: + applied_events.append(event) + + return applied_events + + @staticmethod + def _undo_till_previous(event_type: Type[Event], done_events: List[Event]) -> None: + """Removes events from `done_events`. + + Removes events from `done_events` until the first occurrence `event_type` + is found which is also removed. + """ + # list gets modified - hence we need to copy events! + for e in reversed(done_events[:]): + del done_events[-1] + if isinstance(e, event_type): + break + + def _first_loop_execution_or_unhappy_path( + self, loop_action_name: Text, applied_events: List[Event] + ) -> bool: + next_action: Optional[Text] = None + + for event in reversed(applied_events): + # Stop looking for a previous loop execution if there is a loop deactivation + # event because it means that the current loop is running for the first + # time and previous loop events belong to different loops. + if isinstance(event, ActiveLoop) and event.name is None: + return True + + if self._is_within_unhappy_path(loop_action_name, event, next_action): + return True + + if isinstance(event, ActionExecuted): + # We found a previous execution of the loop and we are not within an + # unhappy path. + if event.action_name == loop_action_name: + return False + + # Remember the action as we need that to check whether we might be + # within an unhappy path. + next_action = event.action_name + + return True + + @staticmethod + def _is_within_unhappy_path( + loop_action_name: Text, event: Event, next_action_in_the_future: Optional[Text] + ) -> bool: + # When actual users are talking to the action has to return an + # `ActionExecutionRejected` in order to enter an unhappy path. + loop_was_rejected_previously = ( + isinstance(event, ActionExecutionRejected) + and event.action_name == loop_action_name + ) + # During the policy training there are no `ActionExecutionRejected` events + # which let us see whether we are within an unhappy path. Hence, we check if a + # different action was executed instead of the loop after last user utterance. + other_action_after_latest_user_utterance = ( + isinstance(event, UserUttered) + and next_action_in_the_future is not None + and next_action_in_the_future != loop_action_name + ) + + return loop_was_rejected_previously or other_action_after_latest_user_utterance + + @staticmethod + def _undo_till_previous_loop_execution( + loop_action_name: Text, done_events: List[Event] + ) -> None: + offset = 0 + for e in reversed(done_events[:]): + if isinstance(e, ActionExecuted) and e.action_name == loop_action_name: + break + + if isinstance( + e, (ActionExecuted, UserUttered, DefinePrevUserUtteredFeaturization) + ): + del done_events[-1 - offset] + else: + # Remember events which aren't unfeaturized to get the index right + offset += 1 + + def replay_events(self) -> None: + """Update the tracker based on a list of events.""" + applied_events = self.applied_events() + for event in applied_events: + event.apply_to(self) + + def recreate_from_dialogue(self, dialogue: Dialogue) -> None: + """Use a serialised `Dialogue` to update the trackers state. + + This uses the state as is persisted in a ``TrackerStore``. If the + tracker is blank before calling this method, the final state will be + identical to the tracker from which the dialogue was created. + """ + if not isinstance(dialogue, Dialogue): + raise ValueError( + f"story {dialogue} is not of type Dialogue. " + f"Have you deserialized it?" + ) + + self._reset() + self.events.extend(dialogue.events) + self.replay_events() + + def copy(self) -> "DialogueStateTracker": + """Creates a duplicate of this tracker.""" + return self.travel_back_in_time(float("inf")) + + def travel_back_in_time(self, target_time: float) -> "DialogueStateTracker": + """Creates a new tracker with a state at a specific timestamp. + + A new tracker will be created and all events previous to the + passed time stamp will be replayed. Events that occur exactly + at the target time will be included. + """ + tracker = self.init_copy() + + for event in self.events: + if event.timestamp <= target_time: + tracker.update(event) + else: + break + + return tracker # yields the final state + + def as_dialogue(self) -> Dialogue: + """Return a ``Dialogue`` object containing all of the turns. + + This can be serialised and later used to recover the state + of this tracker exactly. + """ + return Dialogue(self.sender_id, list(self.events)) + + def update(self, event: Event, domain: Optional[Domain] = None) -> None: + """Modify the state of the tracker according to an ``Event``.""" + if not isinstance(event, Event): # pragma: no cover + raise ValueError("event to log must be an instance of a subclass of Event.") + + if self.model_id and METADATA_MODEL_ID not in event.metadata: + event.metadata = {**event.metadata, METADATA_MODEL_ID: self.model_id} + + if self.assistant_id and ASSISTANT_ID_KEY not in event.metadata: + event.metadata = {**event.metadata, ASSISTANT_ID_KEY: self.assistant_id} + + self.events.append(event) + event.apply_to(self) + + def update_with_events( + self, + new_events: List[Event], + domain: Optional[Domain], + override_timestamp: bool = True, + ) -> None: + """Adds multiple events to the tracker. + + Args: + new_events: Events to apply. + domain: The current model's domain. + override_timestamp: If `True` refresh all timestamps of the events. As the + events are usually created at some earlier point, this makes sure that + all new events come after any current tracker events. + """ + for e in new_events: + if override_timestamp: + e.timestamp = time.time() + self.update(e, domain) + + def as_story(self, include_source: bool = False) -> "Story": + """Dump the tracker as a story in the Rasa Core story format. + + Returns the dumped tracker as a string. + """ + from rasa.shared.core.training_data.structures import Story + + story_name = ( + f"{self.sender_id} ({self.sender_source})" + if include_source + else self.sender_id + ) + return Story.from_events(list(self.events), story_name) + + def export_stories( + self, + writer: "StoryWriter", + e2e: bool = False, + include_source: bool = False, + should_append_stories: bool = False, + ) -> Text: + """Dump the tracker as a story in the Rasa Core story format. + + Returns: + The dumped tracker as a string. + """ + story = self.as_story(include_source) + return writer.dumps( + story.story_steps, is_appendable=should_append_stories, is_test_story=e2e + ) + + def export_stories_to_file(self, export_path: Text = "debug_stories.yml") -> None: + """Dump the tracker as a story to a file.""" + from rasa.shared.core.training_data.story_writer.yaml_story_writer import ( + YAMLStoryWriter, + ) + + append = os.path.exists(export_path) + + rasa.shared.utils.io.write_text_file( + self.export_stories(YAMLStoryWriter(), should_append_stories=append) + "\n", + export_path, + append=append, + ) + + def get_last_event_for( + self, + event_type: Union[Type["EventTypeAlias"], Tuple[Type["EventTypeAlias"], ...]], + action_names_to_exclude: List[Text] = None, + skip: int = 0, + event_verbosity: EventVerbosity = EventVerbosity.APPLIED, + ) -> Optional["EventTypeAlias"]: + """Gets the last event of a given type which was actually applied. + + Args: + event_type: The type of event you want to find. + action_names_to_exclude: Events of type `ActionExecuted` which + should be excluded from the results. Can be used to skip + `action_listen` events. + skip: Skips n possible results before return an event. + event_verbosity: Which `EventVerbosity` should be used to search for events. + + Returns: + event which matched the query or `None` if no event matched. + """ + to_exclude = action_names_to_exclude or [] + + def filter_function(e: Event) -> bool: + has_instance = isinstance(e, event_type) + excluded = isinstance(e, ActionExecuted) and e.action_name in to_exclude + return has_instance and not excluded + + filtered = filter( + filter_function, reversed(self._events_for_verbosity(event_verbosity) or []) + ) + + for i in range(skip): + next(filtered, None) + + return next(filtered, None) + + def last_executed_action_has(self, name: Text, skip: int = 0) -> bool: + """Returns whether last `ActionExecuted` event had a specific name. + + Args: + name: Name of the event which should be matched. + skip: Skips n possible results in between. + + Returns: + `True` if last executed action had name `name`, otherwise `False`. + """ + last: Optional[ActionExecuted] = self.get_last_event_for( + ActionExecuted, action_names_to_exclude=[ACTION_LISTEN_NAME], skip=skip + ) + return last is not None and last.action_name == name + + ### + # Internal methods for the modification of the trackers state. Should + # only be called by events, not directly. Rather update the tracker + # with an event that in its ``apply_to`` method modifies the tracker. + ### + def _reset(self) -> None: + """Reset tracker to initial state - doesn't delete events though!.""" + self._reset_slots() + self._paused = False + self.latest_action = {} + self.latest_message = UserUttered.empty() + self.latest_bot_utterance = BotUttered.empty() + self.followup_action = ACTION_LISTEN_NAME + self.active_loop = None + + def _reset_slots(self) -> None: + """Set all the slots to their initial value.""" + for slot in self.slots.values(): + slot.reset() + + def _set_slot(self, key: Text, value: Any) -> None: + """Sets the value of a slot if that slot exists.""" + if key in self.slots: + slot = self.slots[key] + slot.value = value + else: + logger.error( + f"Tried to set non existent slot '{key}'. Make sure you " + f"added all your slots to your domain file." + ) + + def _create_events(self, evts: List[Event]) -> Deque[Event]: + if evts and not isinstance(evts[0], Event): # pragma: no cover + raise ValueError("events, if given, must be a list of events") + return deque(evts, self._max_event_history) + + def __eq__(self, other: Any) -> bool: + if isinstance(self, type(other)): + return other.events == self.events and self.sender_id == other.sender_id + else: + return False + + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) + + def trigger_followup_action(self, action: Text) -> None: + """Triggers another action following the execution of the current.""" + self.followup_action = action + + def clear_followup_action(self) -> None: + """Clears follow up action when it was executed.""" + self.followup_action = None + + @property + def active_loop_name(self) -> Optional[Text]: + """Get the name of the currently active loop. + + Returns: `None` if no active loop or the name of the currently active loop. + """ + if not self.active_loop or self.active_loop.name == SHOULD_NOT_BE_SET: + return None + + return self.active_loop.name + + @property + def latest_action_name(self) -> Optional[Text]: + """Get the name of the previously executed action or text of e2e action. + + Returns: name of the previously executed action or text of e2e action + """ + if self.latest_action is None: + return None + + return self.latest_action.get(ACTION_NAME) or self.latest_action.get( + ACTION_TEXT + ) + + @property + def is_active_loop_rejected(self) -> bool: + """Return True if there is an active loop and it's rejected.""" + return self.active_loop is not None and self.active_loop.rejected + + @property + def is_active_loop_interrupted(self) -> bool: + """Return True if there is an active loop and it's interrupted.""" + return self.active_loop is not None and self.active_loop.is_interrupted + + def fingerprint(self) -> Text: + """Returns a unique hash for the tracker which is stable across python runs. + + Returns: + fingerprint of the tracker + """ + data: Dict[Text, Any] = {"sender_id": self.sender_id} + + if self.slots: + data.update(self.slots) + + if self.events: + data["events"] = list(self.events) + + return rasa.shared.utils.io.get_dictionary_fingerprint(data) + + +class TrackerEventDiffEngine: + """Computes event difference of two trackers.""" + + @staticmethod + def event_difference( + original: DialogueStateTracker, tracker: DialogueStateTracker + ) -> List[Event]: + """Returns all events from the new tracker which are not present + in the original tracker. + + Args: + tracker: Tracker containing events from the current conversation session. + """ + offset = len(original.events) if original else 0 + events = tracker.events + return list(itertools.islice(events, offset, len(events))) + + +def get_active_loop_name( + state: State, +) -> Optional[Text]: + """Get the name of current active loop. + + Args: + state: The state from which the name of active loop should be extracted + + Return: + the name of active loop or None + """ + if ( + not state.get(ACTIVE_LOOP) + or state[ACTIVE_LOOP].get(LOOP_NAME) == SHOULD_NOT_BE_SET + ): + return None + + # FIXME: better type annotation for `State` would require + # a larger refactoring (e.g. switch to dataclass) + return cast(Optional[Text], state[ACTIVE_LOOP].get(LOOP_NAME)) + + +def is_prev_action_listen_in_state(state: State) -> bool: + """Check if action_listen is the previous executed action. + + Args: + state: The state for which the check should be performed + + Return: + boolean value indicating whether action_listen is previous action + """ + prev_action_name = state.get(PREVIOUS_ACTION, {}).get(ACTION_NAME) + return prev_action_name == ACTION_LISTEN_NAME + + +def get_trackers_for_conversation_sessions( + tracker: DialogueStateTracker, +) -> List[DialogueStateTracker]: + """Generate trackers for `tracker` that are split by conversation sessions. + + Args: + tracker: Instance of `DialogueStateTracker` to split. + + Returns: + The trackers split by conversation sessions. + """ + split_conversations = events.split_events( + tracker.events, + ActionExecuted, + {"action_name": ACTION_SESSION_START_NAME}, + include_splitting_event=True, + ) + + return [ + DialogueStateTracker.from_events( + tracker.sender_id, + evts, + tracker.slots.values(), + sender_source=tracker.sender_source, + max_event_history=tracker._max_event_history, + ) + for evts in split_conversations + ] diff --git a/rasa/shared/core/training_data/__init__.py b/rasa/shared/core/training_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/shared/core/training_data/loading.py b/rasa/shared/core/training_data/loading.py new file mode 100644 index 0000000..2d748fb --- /dev/null +++ b/rasa/shared/core/training_data/loading.py @@ -0,0 +1,90 @@ +import logging +import os +from typing import Text, Optional, List, Union + +import rasa.shared.data +import rasa.shared.utils.io +from rasa.shared.core.domain import Domain +from rasa.shared.core.training_data.story_reader.story_reader import StoryReader +from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, +) +from rasa.shared.core.training_data.structures import StoryStep +from rasa.shared.data import YAML_FILE_EXTENSIONS + +logger = logging.getLogger(__name__) + + +def _get_reader(filename: Text, domain: Domain) -> StoryReader: + if rasa.shared.data.is_likely_yaml_file(filename): + return YAMLStoryReader(domain, filename) + else: + # This is a use case for uploading the story over REST API. + # The source file has a random name. + return _guess_reader(filename, domain) + + +def _guess_reader(filename: Text, domain: Domain) -> StoryReader: + if YAMLStoryReader.is_stories_file(filename): + return YAMLStoryReader(domain, filename) + + raise ValueError( + f"Failed to find a reader class for the story file `{filename}`. " + f"Supported formats are " + f"{', '.join(YAML_FILE_EXTENSIONS)}." + ) + + +def load_data_from_resource( + resource: Union[Text], domain: Domain, exclusion_percentage: Optional[int] = None +) -> List["StoryStep"]: + """Loads core training data from the specified folder. + + Args: + resource: Folder/File with core training data files. + domain: Domain object. + exclusion_percentage: Identifies the percentage of training data that + should be excluded from the training. + + Returns: + Story steps from the training data. + """ + if not os.path.exists(resource): + raise ValueError(f"Resource '{resource}' does not exist.") + + return load_data_from_files( + rasa.shared.utils.io.list_files(resource), domain, exclusion_percentage + ) + + +def load_data_from_files( + story_files: List[Text], domain: Domain, exclusion_percentage: Optional[int] = None +) -> List["StoryStep"]: + """Loads core training data from the specified files. + + Args: + story_files: List of files with training data in it. + domain: Domain object. + exclusion_percentage: Identifies the percentage of training data that + should be excluded from the training. + + Returns: + Story steps from the training data. + """ + story_steps = [] + + for story_file in story_files: + + reader = _get_reader(story_file, domain) + + steps = reader.read_from_file(story_file) + story_steps.extend(steps) + + if exclusion_percentage and exclusion_percentage != 100: + import random + + idx = int(round(exclusion_percentage / 100.0 * len(story_steps))) + random.shuffle(story_steps) + story_steps = story_steps[:-idx] + + return story_steps diff --git a/rasa/shared/core/training_data/story_reader/__init__.py b/rasa/shared/core/training_data/story_reader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/shared/core/training_data/story_reader/story_reader.py b/rasa/shared/core/training_data/story_reader/story_reader.py new file mode 100644 index 0000000..80ffbc9 --- /dev/null +++ b/rasa/shared/core/training_data/story_reader/story_reader.py @@ -0,0 +1,129 @@ +import logging +from pathlib import Path +from typing import Optional, Dict, Text, List, Any, Union + +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import SlotSet, ActionExecuted, Event +from rasa.shared.exceptions import RasaCoreException +from rasa.shared.core.training_data.story_reader.story_step_builder import ( + StoryStepBuilder, +) +from rasa.shared.core.training_data.structures import StoryStep + +logger = logging.getLogger(__name__) + + +class StoryReader: + """Helper class to read a story file.""" + + def __init__( + self, domain: Optional[Domain] = None, source_name: Optional[Text] = None + ) -> None: + """Constructor for the StoryReader. + + Args: + domain: Domain object. + source_name: Name of the training data source. + """ + self.story_steps: List[StoryStep] = [] + self.current_step_builder: Optional[StoryStepBuilder] = None + self.domain = domain + self.source_name = source_name + self._is_parsing_conditions = False + + def read_from_file( + self, filename: Text, skip_validation: bool = False + ) -> List[StoryStep]: + """Reads stories or rules from file. + + Args: + filename: Path to the story/rule file. + skip_validation: `True` if file validation should be skipped. + + Returns: + `StoryStep`s read from `filename`. + """ + raise NotImplementedError + + @staticmethod + def is_stories_file(filename: Union[Text, Path]) -> bool: + """Checks if the specified file is a story file. + + Args: + filename: File to check. + + Returns: + `True` if specified file is a story file, `False` otherwise. + """ + raise NotImplementedError + + def _add_current_stories_to_result(self) -> None: + if self.current_step_builder: + self.current_step_builder.flush() + self.story_steps.extend(self.current_step_builder.story_steps) + + def _new_story_part(self, name: Text, source_name: Optional[Text]) -> None: + self._add_current_stories_to_result() + self.current_step_builder = StoryStepBuilder(name, source_name) + + def _new_rule_part(self, name: Text, source_name: Optional[Text]) -> None: + self._add_current_stories_to_result() + self.current_step_builder = StoryStepBuilder(name, source_name, is_rule=True) + + def _parse_events( + self, event_name: Text, parameters: Dict[Text, Any] + ) -> Optional[List["Event"]]: + # add 'name' only if event is not a SlotSet, + # because there might be a slot with slot_key='name' + if "name" not in parameters and event_name != SlotSet.type_name: + parameters["name"] = event_name + + parsed_events = Event.from_story_string( + event_name, parameters, default=ActionExecuted + ) + if parsed_events is None: + raise StoryParseError( + "Unknown event '{}'. It is Neither an event " + "nor an action).".format(event_name) + ) + + return parsed_events + + def _add_event(self, event_name: Text, parameters: Dict[Text, Any]) -> None: + parsed_events = self._parse_events(event_name, parameters) + if parsed_events is None: + parsed_events = [] + + if self.current_step_builder is None: + raise StoryParseError( + "Failed to handle event '{}'. There is no " + "started story block available. " + "".format(event_name) + ) + + for p in parsed_events: + if self._is_parsing_conditions: + self.current_step_builder.add_event_as_condition(p) + else: + self.current_step_builder.add_event(p) + + def _add_checkpoint( + self, name: Text, conditions: Optional[Dict[Text, Any]] + ) -> None: + + # Ensure story part already has a name + if not self.current_step_builder: + raise StoryParseError( + "Checkpoint '{}' is at an invalid location. " + "Expected a story start.".format(name) + ) + + self.current_step_builder.add_checkpoint(name, conditions) + + +class StoryParseError(RasaCoreException, ValueError): + """Raised if there is an error while parsing a story file.""" + + def __init__(self, message: Text) -> None: + self.message = message + super(StoryParseError, self).__init__() diff --git a/rasa/shared/core/training_data/story_reader/story_step_builder.py b/rasa/shared/core/training_data/story_reader/story_step_builder.py new file mode 100644 index 0000000..6fb5421 --- /dev/null +++ b/rasa/shared/core/training_data/story_reader/story_step_builder.py @@ -0,0 +1,168 @@ +import logging +from typing import Text, Optional, Dict, Any, List + +import rasa.shared.core.training_data.structures +import rasa.shared.utils.io +from rasa.shared.constants import DOCS_URL_STORIES +from rasa.shared.core.events import UserUttered, Event +from rasa.shared.core.training_data.structures import ( + Checkpoint, + GENERATED_CHECKPOINT_PREFIX, + GENERATED_HASH_LENGTH, + STORY_START, + StoryStep, + RuleStep, +) + +logger = logging.getLogger(__name__) + + +class StoryStepBuilder: + def __init__( + self, name: Text, source_name: Optional[Text], is_rule: bool = False + ) -> None: + self.name = name + self.source_name = source_name + self.story_steps: List[StoryStep] = [] + self.current_steps: List[StoryStep] = [] + self.start_checkpoints: List[Checkpoint] = [] + self.is_rule = is_rule + + def add_checkpoint(self, name: Text, conditions: Optional[Dict[Text, Any]]) -> None: + """Add a checkpoint to story steps.""" + # Depending on the state of the story part this + # is either a start or an end check point + if not self.current_steps: + self.start_checkpoints.append(Checkpoint(name, conditions)) + else: + if conditions: + rasa.shared.utils.io.raise_warning( + f"End or intermediate checkpoints " + f"do not support conditions! " + f"(checkpoint: {name})", + docs=DOCS_URL_STORIES + "#checkpoints", + ) + additional_steps = [] + for t in self.current_steps: + if t.end_checkpoints: + tcp = t.create_copy(use_new_id=True) + tcp.end_checkpoints = [Checkpoint(name)] + additional_steps.append(tcp) + else: + t.end_checkpoints = [Checkpoint(name)] + self.current_steps.extend(additional_steps) + + def _prev_end_checkpoints(self) -> List[Checkpoint]: + if not self.current_steps: + return self.start_checkpoints + else: + # makes sure we got each end name only once + end_names = {e.name for s in self.current_steps for e in s.end_checkpoints} + return [Checkpoint(name) for name in end_names] + + def add_user_messages(self, messages: List[UserUttered]) -> None: + """Adds next story steps with the user's utterances. + + Args: + messages: User utterances. + """ + self.add_events(messages) + + def add_events(self, events: List[Event]) -> None: + """Adds next story steps with the specified list of events. + + Args: + events: Events that need to be added. + """ + self.ensure_current_steps() + + if len(events) == 1: + # If there is only one possible event, we'll keep things simple + for t in self.current_steps: + t.add_event(events[0]) + else: + # If there are multiple different events the + # user can use the express the same thing + # we need to copy the blocks and create one + # copy for each possible message + generated_checkpoint = self._generate_checkpoint_name_for_or_statement( + events + ) + updated_steps = [] + for t in self.current_steps: + for event in events: + copied = t.create_copy(use_new_id=True) + copied.add_event(event) + copied.end_checkpoints = [Checkpoint(generated_checkpoint)] + updated_steps.append(copied) + self.current_steps = updated_steps + + def add_event_as_condition(self, event: Event) -> None: + self.add_event(event, True) + + def add_event(self, event: Event, is_condition: bool = False) -> None: + self.ensure_current_steps() + for t in self.current_steps: + # conditions are supported only for the RuleSteps + if isinstance(t, RuleStep) and is_condition: + t.add_event_as_condition(event) + else: + t.add_event(event) + + def ensure_current_steps(self) -> None: + completed = [step for step in self.current_steps if step.end_checkpoints] + unfinished = [step for step in self.current_steps if not step.end_checkpoints] + self.story_steps.extend(completed) + if unfinished: + self.current_steps = unfinished + else: + self.current_steps = self._next_story_steps() + + def flush(self) -> None: + if self.current_steps: + self.story_steps.extend(self.current_steps) + self.current_steps = [] + + def _next_story_steps(self) -> List[StoryStep]: + start_checkpoints = self._prev_end_checkpoints() + if not start_checkpoints: + start_checkpoints = [Checkpoint(STORY_START)] + step_class = RuleStep if self.is_rule else StoryStep + current_turns = [ + step_class( + block_name=self.name, + start_checkpoints=start_checkpoints, + source_name=self.source_name, + ) + ] + return current_turns + + def _generate_checkpoint_name_for_or_statement(self, events: List[Event]) -> str: + """Generates a unique checkpoint name for an or statement. + + The name is based on the current story/rule name, + the current place in the story since the last checkpoint or start, + the name of the starting checkpoints, + and the involved intents/e2e messages. + """ + sorted_events = sorted([str(m) for m in events]) + start_checkpoint_names = sorted( + list({chk.name for s in self.current_steps for chk in s.start_checkpoints}) + ) + event_names = [str(e) for s in self.current_steps for e in s.events] + # name: to identify the current story or rule + # events: to identify what has happened so far + # within the current story/rule + # start checkpoint names: to identify the section + # within the current story/rule when there are + # multiple internal checkpoints + # messages texts or intents: identifying the members of the or statement + unique_id = ( + f"{self.name}_<>_{'@@@'.join(event_names)}" + f"_<>_{'@@@'.join(start_checkpoint_names)}" + f"_<>_{'@@@'.join(sorted_events)}" + ) + hashed_id = rasa.shared.utils.io.get_text_hash(unique_id)[ + :GENERATED_HASH_LENGTH + ] + return f"{GENERATED_CHECKPOINT_PREFIX}OR_{hashed_id}" diff --git a/rasa/shared/core/training_data/story_reader/yaml_story_reader.py b/rasa/shared/core/training_data/story_reader/yaml_story_reader.py new file mode 100644 index 0000000..e86fed5 --- /dev/null +++ b/rasa/shared/core/training_data/story_reader/yaml_story_reader.py @@ -0,0 +1,867 @@ +import copy +import functools +import json +from json import JSONDecodeError +import logging +import structlog +from pathlib import Path +import re +from re import Match, Pattern +from typing import Dict, Text, List, Any, Optional, Union, Tuple + +from rasa.shared.core.domain import Domain +import rasa.shared.data +from rasa.shared.core.slots import TextSlot, ListSlot +from rasa.shared.exceptions import YamlException +import rasa.shared.utils.io +from rasa.shared.core.constants import LOOP_NAME +from rasa.shared.nlu.constants import ( + ENTITIES, + ENTITY_ATTRIBUTE_END, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_VALUE, + INTENT, + INTENT_NAME_KEY, + INTENT_RANKING_KEY, + PREDICTED_CONFIDENCE_KEY, + FULL_RETRIEVAL_INTENT_NAME_KEY, + ACTION_TEXT, + TEXT, + EXTRACTOR, +) +from rasa.shared.nlu.training_data import entities_parser +import rasa.shared.utils.validation + +from rasa.shared.constants import ( + INTENT_MESSAGE_PREFIX, + DOCS_URL_STORIES, + TEST_STORIES_FILE_PREFIX, + DOCS_URL_RULES, + DOCS_URL_SLOTS, +) + +from rasa.shared.core.constants import RULE_SNIPPET_ACTION_NAME +from rasa.shared.core.events import UserUttered, SlotSet, ActiveLoop +from rasa.shared.core.training_data.story_reader.story_reader import StoryReader +from rasa.shared.core.training_data.structures import StoryStep +from rasa.shared.nlu.training_data.message import Message + +logger = logging.getLogger(__name__) +structlogger = structlog.get_logger() + +KEY_STORIES = "stories" +KEY_STORY_NAME = "story" +KEY_RULES = "rules" +KEY_RULE_NAME = "rule" +KEY_STEPS = "steps" +KEY_ENTITIES = "entities" +KEY_USER_INTENT = "intent" +KEY_USER_MESSAGE = "user" +KEY_SLOT_NAME = "slot_was_set" +KEY_ACTIVE_LOOP = "active_loop" +KEY_ACTION = "action" +KEY_BOT_END_TO_END_MESSAGE = "bot" +KEY_CHECKPOINT = "checkpoint" +KEY_CHECKPOINT_SLOTS = "slot_was_set" +KEY_METADATA = "metadata" +KEY_OR = "or" +KEY_RULE_CONDITION = "condition" +KEY_WAIT_FOR_USER_INPUT_AFTER_RULE = "wait_for_user_input" +KEY_RULE_FOR_CONVERSATION_START = "conversation_start" + + +CORE_SCHEMA_FILE = "shared/utils/schemas/stories.yml" +DEFAULT_VALUE_TEXT_SLOTS = "filled" +DEFAULT_VALUE_LIST_SLOTS = [DEFAULT_VALUE_TEXT_SLOTS] + + +class YAMLStoryReader(StoryReader): + """Class that reads Core training data and rule data in YAML format.""" + + @classmethod + def from_reader(cls, reader: "YAMLStoryReader") -> "YAMLStoryReader": + """Create a reader from another reader. + + Args: + reader: Another reader. + + Returns: + A new reader instance. + """ + return cls(reader.domain, reader.source_name) + + def read_from_file( + self, filename: Union[Text, Path], skip_validation: bool = False + ) -> List[StoryStep]: + """Read stories or rules from file. + + Args: + filename: Path to the story/rule file. + skip_validation: `True` if the file was already validated + e.g. when it was stored in the database. + + Returns: + `StoryStep`s read from `filename`. + """ + self.source_name = str(filename) + try: + return self.read_from_string( + rasa.shared.utils.io.read_file( + filename, rasa.shared.utils.io.DEFAULT_ENCODING + ), + skip_validation, + ) + except YamlException as e: + e.filename = str(filename) + raise e + + def read_from_string( + self, string: Text, skip_validation: bool = False + ) -> List[StoryStep]: + """Read stories or rules from a string. + + Args: + string: Unprocessed YAML file content. + skip_validation: `True` if the string was already validated + e.g. when it was stored in the database. + + Returns: + `StoryStep`s read from `string`. + """ + if not skip_validation: + rasa.shared.utils.validation.validate_yaml_schema(string, CORE_SCHEMA_FILE) + + yaml_content = rasa.shared.utils.io.read_yaml(string) + + return self.read_from_parsed_yaml(yaml_content) + + def read_from_parsed_yaml( + self, parsed_content: Dict[Text, Union[Dict, List]] + ) -> List[StoryStep]: + """Read stories from parsed YAML. + + Args: + parsed_content: The parsed YAML as a dictionary. + + Returns: + The parsed stories or rules. + """ + if not rasa.shared.utils.validation.validate_training_data_format_version( + parsed_content, self.source_name + ): + return [] + + for key, parser_class in { + KEY_STORIES: StoryParser, + KEY_RULES: RuleParser, + }.items(): + data = parsed_content.get(key) or [] + parser = parser_class.from_reader(self) + parser.parse_data(data) + self.story_steps.extend(parser.get_steps()) + + return self.story_steps + + @classmethod + def is_stories_file(cls, file_path: Union[Text, Path]) -> bool: + """Check if file contains Core training data or rule data in YAML format. + + Args: + file_path: Path of the file to check. + + Returns: + `True` in case the file is a Core YAML training data or rule data file, + `False` otherwise. + + Raises: + YamlException: if the file seems to be a YAML file (extension) but + can not be read / parsed. + """ + return rasa.shared.data.is_likely_yaml_file( + file_path + ) and rasa.shared.utils.io.is_key_in_yaml(file_path, KEY_STORIES, KEY_RULES) + + @classmethod + def _has_test_prefix(cls, file_path: Text) -> bool: + """Check if the filename of a file at a path has a certain prefix. + + Arguments: + file_path: path to the file + + Returns: + `True` if the filename starts with the prefix, `False` otherwise. + """ + return Path(file_path).name.startswith(TEST_STORIES_FILE_PREFIX) + + @classmethod + def is_test_stories_file(cls, file_path: Union[Text, Path]) -> bool: + """Checks if a file is a test conversations file. + + Args: + file_path: Path of the file which should be checked. + + Returns: + `True` if it's a conversation test file, otherwise `False`. + """ + return cls._has_test_prefix(file_path) and cls.is_stories_file(file_path) + + def get_steps(self) -> List[StoryStep]: + self._add_current_stories_to_result() + return self.story_steps + + def parse_data(self, data: List[Dict[Text, Any]]) -> None: + item_title = self._get_item_title() + + for item in data: + if not isinstance(item, dict): + rasa.shared.utils.io.raise_warning( + f"Unexpected block found in '{self.source_name}':\n" + f"{item}\nItems under the " + f"'{self._get_plural_item_title()}' key must be YAML " + f"dictionaries. It will be skipped.", + docs=self._get_docs_link(), + ) + continue + + if item_title in item.keys(): + self._parse_plain_item(item) + + def _parse_plain_item(self, item: Dict[Text, Any]) -> None: + item_name = item.get(self._get_item_title(), "") + + if not item_name: + rasa.shared.utils.io.raise_warning( + f"Issue found in '{self.source_name}': \n" + f"{item}\n" + f"The {self._get_item_title()} has an empty name. " + f"{self._get_plural_item_title().capitalize()} should " + f"have a name defined under '{self._get_item_title()}' " + f"key. It will be skipped.", + docs=self._get_docs_link(), + ) + + steps: List[Union[Text, Dict[Text, Any]]] = item.get(KEY_STEPS, []) + + if not steps: + rasa.shared.utils.io.raise_warning( + f"Issue found in '{self.source_name}': " + f"The {self._get_item_title()} has no steps. " + f"It will be skipped.", + docs=self._get_docs_link(), + ) + return + + self._new_part(item_name, item) + + for step in steps: + self._parse_step(step) + + self._close_part(item) + + def _new_part(self, item_name: Text, item: Dict[Text, Any]) -> None: + raise NotImplementedError() + + def _close_part(self, item: Dict[Text, Any]) -> None: + pass + + def _parse_step(self, step: Union[Text, Dict[Text, Any]]) -> None: + if isinstance(step, str): + rasa.shared.utils.io.raise_warning( + f"Issue found in '{self.source_name}':\n" + f"Found an unexpected step in the {self._get_item_title()} " + f"description:\n{step}\nThe step is of type `str` " + f"which is only allowed for the rule snippet action " + f"'{RULE_SNIPPET_ACTION_NAME}'. It will be skipped.", + docs=self._get_docs_link(), + ) + elif KEY_USER_INTENT in step.keys() or KEY_USER_MESSAGE in step.keys(): + self._parse_user_utterance(step) + elif KEY_OR in step.keys(): + self._parse_or_statement(step) + elif KEY_ACTION in step.keys(): + self._parse_action(step) + elif KEY_BOT_END_TO_END_MESSAGE in step.keys(): + self._parse_bot_message(step) + elif KEY_CHECKPOINT in step.keys(): + self._parse_checkpoint(step) + # This has to be after the checkpoint test as there can be a slot key within + # a checkpoint. + elif KEY_SLOT_NAME in step.keys(): + self._parse_slot(step) + elif KEY_ACTIVE_LOOP in step.keys(): + self._parse_active_loop(step[KEY_ACTIVE_LOOP]) + elif KEY_METADATA in step.keys(): + pass + else: + rasa.shared.utils.io.raise_warning( + f"Issue found in '{self.source_name}':\n" + f"Found an unexpected step in the {self._get_item_title()} " + f"description:\n{step}\nIt will be skipped.", + docs=self._get_docs_link(), + ) + + def _get_item_title(self) -> Text: + raise NotImplementedError() + + def _get_plural_item_title(self) -> Text: + raise NotImplementedError() + + def _get_docs_link(self) -> Text: + raise NotImplementedError() + + def _parse_user_utterance(self, step: Dict[Text, Any]) -> None: + utterance = self._parse_raw_user_utterance(step) + + if not utterance: + return + + is_end_to_end_utterance = KEY_USER_INTENT not in step + if is_end_to_end_utterance: + utterance.intent = {INTENT_NAME_KEY: None} + else: + self._validate_that_utterance_is_in_domain(utterance) + + if self.current_step_builder is not None: + self.current_step_builder.add_user_messages([utterance]) + + def _validate_that_utterance_is_in_domain(self, utterance: UserUttered) -> None: + intent_name = utterance.intent.get(INTENT_NAME_KEY) + + # check if this is a retrieval intent + # in this case check only for the base intent in domain + intent_name = Message.separate_intent_response_key(intent_name)[0] + + if not self.domain: + logger.debug( + "Skipped validating if intent is in domain as domain " "is `None`." + ) + return + + if intent_name not in self.domain.intents: + rasa.shared.utils.io.raise_warning( + f"Issue found in '{self.source_name}': \n" + f"Found intent '{intent_name}' in stories which is not part of the " + f"domain.", + docs=DOCS_URL_STORIES, + ) + + def _parse_or_statement(self, step: Dict[Text, Any]) -> None: + events: List = [] + + for item in step.get(KEY_OR, []): + if KEY_USER_INTENT in item.keys(): + utterance = self._parse_raw_user_utterance(item) + if utterance: + events.append(utterance) + elif KEY_CHECKPOINT_SLOTS in item.keys(): + for slot in item.get(KEY_CHECKPOINT_SLOTS, []): + if isinstance(slot, dict): + for key, value in slot.items(): + parsed_events = self._parse_events( + SlotSet.type_name, {key: value} + ) + events.extend(parsed_events) + elif isinstance(slot, str): + parsed_events = self._parse_events( + SlotSet.type_name, {slot: self._slot_default_value(slot)} + ) + events.extend(parsed_events) + else: + rasa.shared.utils.io.raise_warning( + f"Issue found in '{self.source_name}':\n" + f"Invalid slot: \n{slot}\n" + f"Items under the '{KEY_CHECKPOINT_SLOTS}' key must be " + f"YAML dictionaries or Strings. " + f"The checkpoint will be skipped.", + docs=self._get_docs_link(), + ) + return + else: + rasa.shared.utils.io.raise_warning( + f"Issue found in '{self.source_name}': \n" + f"`OR` statement can have '{KEY_USER_INTENT}' or '{KEY_SLOT_NAME}'" + f"as a sub-element. This step will be skipped:\n" + f"'{item}'\n", + docs=self._get_docs_link(), + ) + return + + if events and self.current_step_builder is not None: + self.current_step_builder.add_events(events) + + def _user_intent_from_step( + self, step: Dict[Text, Any] + ) -> Tuple[Text, Optional[Text]]: + try: + user_intent = step.get(KEY_USER_INTENT, "").strip() + except AttributeError: + rasa.shared.utils.io.raise_warning( + f"Issue found in '{self.source_name}':\n" + f"Missing intent value in {self._get_item_title()} step: {step} .", + docs=self._get_docs_link(), + ) + user_intent = "" + + if not user_intent and KEY_USER_MESSAGE not in step: + rasa.shared.utils.io.raise_warning( + f"Issue found in '{self.source_name}':\n" + f"User utterance cannot be empty. " + f"This {self._get_item_title()} step will be skipped:\n" + f"{step}", + docs=self._get_docs_link(), + ) + + if user_intent.startswith(INTENT_MESSAGE_PREFIX): + rasa.shared.utils.io.raise_warning( + f"Issue found in '{self.source_name}':\n" + f"User intent '{user_intent}' starts with " + f"'{INTENT_MESSAGE_PREFIX}'. This is not required.", + docs=self._get_docs_link(), + ) + # Remove leading slash + user_intent = user_intent[1:] + + # StoryStep should never contain a full retrieval intent, only the base intent. + # However, users can specify full retrieval intents in their test stories file + # for the NLU testing purposes. + base_intent, response_key = Message.separate_intent_response_key(user_intent) + if response_key and not self.is_test_stories_file(self.source_name): + rasa.shared.utils.io.raise_warning( + f"Issue found in '{self.source_name}' while parsing story " + f"{self._get_item_title()}:\n" + f"User intent '{user_intent}' is a full retrieval intent. " + f"Stories shouldn't contain full retrieval intents. " + f"Rasa Open Source will only use base intent '{base_intent}' " + f"for training.", + docs=self._get_docs_link(), + ) + + return (base_intent, user_intent) if response_key else (base_intent, None) + + def _parse_raw_user_utterance(self, step: Dict[Text, Any]) -> Optional[UserUttered]: + intent_name, full_retrieval_intent = self._user_intent_from_step(step) + intent = { + INTENT_NAME_KEY: intent_name, + FULL_RETRIEVAL_INTENT_NAME_KEY: full_retrieval_intent, + PREDICTED_CONFIDENCE_KEY: 1.0, + } + + if KEY_USER_MESSAGE in step: + user_message = step[KEY_USER_MESSAGE].strip() + entities = entities_parser.find_entities_in_training_example(user_message) + plain_text = entities_parser.replace_entities(user_message) + + if plain_text.startswith(INTENT_MESSAGE_PREFIX): + entities = self.unpack_regex_message(Message({TEXT: plain_text})).get( + ENTITIES, [] + ) + else: + raw_entities = step.get(KEY_ENTITIES, []) + entities = self._parse_raw_entities(raw_entities) + # set plain_text to None because only intent was provided in the stories + plain_text = None + return UserUttered(plain_text, intent, entities) + + @staticmethod + def _parse_raw_entities( + raw_entities: Union[List[Dict[Text, Text]], List[Text]] + ) -> List[Dict[Text, Optional[Text]]]: + final_entities = [] + for entity in raw_entities: + if isinstance(entity, dict): + _entity_type = None + _entity_value = None + _entity_role = None + _entity_group = None + for key, value in entity.items(): + if key == "role": + _entity_role = value + elif key == "group": + _entity_group = value + else: + _entity_type = key + _entity_value = value + + _entity_dict = { + "entity": _entity_type, + "value": _entity_value, + "role": _entity_role, + "group": _entity_group, + } + _entity_dict = {k: v for k, v in _entity_dict.items() if v is not None} + final_entities.append(_entity_dict) + else: + final_entities.append({"entity": entity, "value": ""}) + + return final_entities + + def _parse_slot(self, step: Dict[Text, Any]) -> None: + for slot in step.get(KEY_CHECKPOINT_SLOTS, []): + if isinstance(slot, dict): + for key, value in slot.items(): + self._add_event(SlotSet.type_name, {key: value}) + elif isinstance(slot, str): + self._add_event( + SlotSet.type_name, {slot: self._slot_default_value(slot)} + ) + else: + rasa.shared.utils.io.raise_warning( + f"Issue found in '{self.source_name}':\n" + f"Invalid slot: \n{slot}\n" + f"Items under the '{KEY_CHECKPOINT_SLOTS}' key must be " + f"YAML dictionaries or Strings. The checkpoint will be skipped.", + docs=self._get_docs_link(), + ) + return + + @functools.lru_cache() + def _slot_default_value(self, slot_name: Text) -> Any: + if not self.domain: + return None + + slot_types_with_default_types = { + TextSlot: DEFAULT_VALUE_TEXT_SLOTS, + ListSlot: DEFAULT_VALUE_LIST_SLOTS, + } + slot = next(slot for slot in self.domain.slots if slot.name == slot_name) + + default_value = slot_types_with_default_types.get(type(slot)) + if default_value is None and slot.has_features(): + rasa.shared.utils.io.raise_warning( + f"Slot '{slot_name}' was referenced by its name only. As slot " + f"'{slot_name}' is of type '{slot.type_name}' you need to specify a " + f"value for it. Slot '{slot_name}' will be treated as if it's value " + f"is empty.", + docs=DOCS_URL_SLOTS, + ) + + return default_value + + def _parse_action(self, step: Dict[Text, Any]) -> None: + + action_name = step.get(KEY_ACTION, "") + if not action_name: + rasa.shared.utils.io.raise_warning( + f"Issue found in '{self.source_name}': \n" + f"Action name cannot be empty. " + f"This {self._get_item_title()} step will be skipped:\n" + f"{step}", + docs=self._get_docs_link(), + ) + return + + self._add_event(action_name, {}) + + def _parse_bot_message(self, step: Dict[Text, Any]) -> None: + bot_message = step.get(KEY_BOT_END_TO_END_MESSAGE, "") + self._add_event("", {ACTION_TEXT: bot_message}) + + def _parse_active_loop(self, active_loop_name: Optional[Text]) -> None: + self._add_event(ActiveLoop.type_name, {LOOP_NAME: active_loop_name}) + + def _parse_checkpoint(self, step: Dict[Text, Any]) -> None: + + checkpoint_name = step.get(KEY_CHECKPOINT, "") + slots = step.get(KEY_CHECKPOINT_SLOTS, []) + + slots_dict = {} + + for slot in slots: + if not isinstance(slot, dict): + rasa.shared.utils.io.raise_warning( + f"Issue found in '{self.source_name}':\n" + f"Checkpoint '{checkpoint_name}' has an invalid slot: " + f"{slots}\nItems under the '{KEY_CHECKPOINT_SLOTS}' key must be " + f"YAML dictionaries. The checkpoint will be skipped.", + docs=self._get_docs_link(), + ) + return + + for key, value in slot.items(): + slots_dict[key] = value + + self._add_checkpoint(checkpoint_name, slots_dict) + + @staticmethod + def _regex_message_pattern() -> Pattern: + """Builds the pattern that matches `TEXT`s of messages that need to be unpacked. + + Returns: + pattern with named groups + """ + return re.compile( + f"^{INTENT_MESSAGE_PREFIX}" + f"(?P<{INTENT_NAME_KEY}>[^{{@]+)" # "{{" is a masked "{" in an f-string + f"(?P<{PREDICTED_CONFIDENCE_KEY}>@[0-9.]+)?" + f"(?P<{ENTITIES}>{{.+}})?" # "{{" is a masked "{" in an f-string + f"(?P<rest>.*)" + ) + + @staticmethod + def unpack_regex_message( + message: Message, + domain: Optional[Domain] = None, + entity_extractor_name: Optional[Text] = None, + ) -> Message: + """Unpacks the message if `TEXT` contains an encoding of attributes. + + Args: + message: some message + domain: the domain + entity_extractor_name: An extractor name which should be added for the + entities. + + Returns: + the given message if that message does not need to be unpacked, and a new + message with the extracted attributes otherwise + """ + user_text = message.get(TEXT).strip() + + # If the prefix doesn't match, we don't even need to try to match the pattern. + if not user_text.startswith(INTENT_MESSAGE_PREFIX): + return message + + # Try to match the pattern. + match = YAMLStoryReader._regex_message_pattern().match(user_text) + + # If it doesn't match, then (potentially) something went wrong, because the + # message text did start with the special prefix -- however, a user might + # just have decided to start their text this way. + if not match: + structlogger.warning( + "message.parsing.failed", user_text=copy.deepcopy(user_text) + ) + return message + + # Extract attributes from the match - and validate it via the domain. + intent_name = YAMLStoryReader._intent_name_from_regex_match(match, domain) + confidence = YAMLStoryReader._confidences_from_regex_match(match) + entities = YAMLStoryReader._entities_from_regex_match( + match, domain, entity_extractor_name + ) + + # The intent name is *not* optional, but during parsing we might find out + # that the given intent is unknown (and warn). In this case, stop here. + if intent_name is None: + return message + + if match.group("rest"): + rasa.shared.utils.io.raise_warning( + f"Failed to parse arguments in line '{match.string}'. " + f"Failed to interpret some parts. " + f"Make sure your regex string is in the following format:" + f"{INTENT_MESSAGE_PREFIX}" + f"<intent_name>@<confidence-value><dictionary of entities> " + f"Continuing without {match.group('rest')}. " + ) + + # Add the results to the message. + intent_data = { + INTENT_NAME_KEY: intent_name, + PREDICTED_CONFIDENCE_KEY: confidence, + } + intent_ranking = [ + {INTENT_NAME_KEY: intent_name, PREDICTED_CONFIDENCE_KEY: confidence} + ] + message_data = {} + message_data[TEXT] = user_text + message_data[INTENT] = intent_data + message_data[INTENT_RANKING_KEY] = intent_ranking + message_data[ENTITIES] = entities + return Message(message_data, output_properties=set(message_data.keys())) + + @staticmethod + def _intent_name_from_regex_match(match: Match, domain: Domain) -> Optional[Text]: + intent_name = match.group(INTENT_NAME_KEY).strip() + if domain and intent_name not in domain.intents: + rasa.shared.utils.io.raise_warning( + f"Failed to parse arguments in line '{match.string}'. " + f"Expected the intent to be one of [{domain.intents}] " + f"but found {intent_name}." + f"Continuing with given line as user text.", + docs=DOCS_URL_STORIES, + ) + intent_name = None + return intent_name + + @staticmethod + def _entities_from_regex_match( + match: Match, domain: Domain, extractor_name: Optional[Text] + ) -> List[Dict[Text, Any]]: + """Extracts the optional entity information from the given pattern match. + + If no entities are specified or if the extraction fails, then an empty list + is returned. + + Args: + match: a match produced by `self.pattern` + domain: the domain + extractor_name: A extractor name which should be added for the entities + + Returns: + some list of entities + """ + entities_str = match.group(ENTITIES) + if entities_str is None: + return [] + + try: + parsed_entities = json.loads(entities_str) + if not isinstance(parsed_entities, dict): + raise ValueError( + f"Parsed value isn't a json object " + f"(instead parser found '{type(parsed_entities)}')" + ) + except (JSONDecodeError, ValueError) as e: + rasa.shared.utils.io.raise_warning( + f"Failed to parse arguments in line '{match.string}'. " + f"Failed to decode parameters as a json object (dict). " + f"Make sure the intent is followed by a proper json object (dict). " + f"Continuing without entities. " + f"Error: {e}", + docs=DOCS_URL_STORIES, + ) + parsed_entities = dict() + + # validate the given entity types + if domain: + entity_types = set(parsed_entities.keys()) + unknown_entity_types = entity_types.difference(domain.entities) + if unknown_entity_types: + rasa.shared.utils.io.raise_warning( + f"Failed to parse arguments in line '{match.string}'. " + f"Expected entities from {domain.entities} " + f"but found {unknown_entity_types}. " + f"Continuing without unknown entity types. ", + docs=DOCS_URL_STORIES, + ) + parsed_entities = { + key: value + for key, value in parsed_entities.items() + if key not in unknown_entity_types + } + + # convert them into the list of dictionaries that we expect + entities: List[Dict[Text, Any]] = [] + default_properties = {} + if extractor_name: + default_properties = {EXTRACTOR: extractor_name} + + for entity_type, entity_values in parsed_entities.items(): + if not isinstance(entity_values, list): + entity_values = [entity_values] + + for entity_value in entity_values: + entities.append( + { + ENTITY_ATTRIBUTE_TYPE: entity_type, + ENTITY_ATTRIBUTE_VALUE: entity_value, + ENTITY_ATTRIBUTE_START: match.start(ENTITIES), + ENTITY_ATTRIBUTE_END: match.end(ENTITIES), + **default_properties, + } + ) + return entities + + @staticmethod + def _confidences_from_regex_match(match: Match) -> float: + """Extracts the optional confidence information from the given pattern match. + + If no confidence is specified, then this method returns the maximum + confidence `1.0`. + If a confidence is specified but extraction fails, then this method defaults + to a confidence of `0.0`. + + Args: + match: a match produced by `self.pattern` + domain: the domain + + Returns: + some confidence value + """ + confidence_str = match.group(PREDICTED_CONFIDENCE_KEY) + if confidence_str is None: + return 1.0 + try: + confidence_str = confidence_str.strip()[1:] # remove the "@" + try: + confidence = float(confidence_str) + except ValueError: + confidence = 0.0 + raise ValueError( + f"Expected confidence to be a non-negative decimal number but " + f"found {confidence}. Continuing with 0.0 instead." + ) + if confidence > 1.0: + # Due to the pattern we know that this cannot be a negative number. + original_confidence = confidence + confidence = min(1.0, confidence) + raise ValueError( + f"Expected confidence to be at most 1.0. " + f"but found {original_confidence}. " + f"Continuing with {confidence} instead." + ) + return confidence + + except ValueError as e: + rasa.shared.utils.io.raise_warning( + f"Failed to parse arguments in line '{match.string}'. " + f"Could not extract confidence value from `{confidence_str}'. " + f"Make sure the intent confidence is an @ followed " + f"by a decimal number that not negative and at most 1.0. " + f"Error: {e}", + docs=DOCS_URL_STORIES, + ) + return confidence + + +class StoryParser(YAMLStoryReader): + """Encapsulate story-specific parser behavior.""" + + def _new_part(self, item_name: Text, item: Dict[Text, Any]) -> None: + self._new_story_part(item_name, self.source_name) + + def _get_item_title(self) -> Text: + return KEY_STORY_NAME + + def _get_plural_item_title(self) -> Text: + return KEY_STORIES + + def _get_docs_link(self) -> Text: + return DOCS_URL_STORIES + + +class RuleParser(YAMLStoryReader): + """Encapsulate rule-specific parser behavior.""" + + def _new_part(self, item_name: Text, item: Dict[Text, Any]) -> None: + self._new_rule_part(item_name, self.source_name) + conditions = item.get(KEY_RULE_CONDITION, []) + self._parse_rule_conditions(conditions) + if not item.get(KEY_RULE_FOR_CONVERSATION_START): + self._parse_rule_snippet_action() + + def _parse_rule_conditions( + self, conditions: List[Union[Text, Dict[Text, Any]]] + ) -> None: + self._is_parsing_conditions = True + for condition in conditions: + self._parse_step(condition) + self._is_parsing_conditions = False + + def _close_part(self, item: Dict[Text, Any]) -> None: + if item.get(KEY_WAIT_FOR_USER_INPUT_AFTER_RULE) is False: + self._parse_rule_snippet_action() + + def _get_item_title(self) -> Text: + return KEY_RULE_NAME + + def _get_plural_item_title(self) -> Text: + return KEY_RULES + + def _get_docs_link(self) -> Text: + return DOCS_URL_RULES + + def _parse_rule_snippet_action(self) -> None: + self._add_event(RULE_SNIPPET_ACTION_NAME, {}) diff --git a/rasa/shared/core/training_data/story_writer/__init__.py b/rasa/shared/core/training_data/story_writer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/shared/core/training_data/story_writer/story_writer.py b/rasa/shared/core/training_data/story_writer/story_writer.py new file mode 100644 index 0000000..d82787d --- /dev/null +++ b/rasa/shared/core/training_data/story_writer/story_writer.py @@ -0,0 +1,75 @@ +import typing +from pathlib import Path +from typing import List, Text, Union + +from ruamel import yaml + +if typing.TYPE_CHECKING: + from rasa.shared.core.events import Event + from rasa.shared.core.training_data.structures import StoryStep + + +class StoryWriter: + """Writes story training data to file.""" + + def dumps( + self, + story_steps: List["StoryStep"], + is_appendable: bool = False, + is_test_story: bool = False, + ) -> Text: + """Turns Story steps into an string. + + Args: + story_steps: Original story steps to be converted to the YAML. + is_appendable: Specify if result should not contain + high level keys/definitions and can be appended to + the existing story file. + is_test_story: Identifies if the stories should be exported in test stories + format. + Returns: + String with story steps in the desired format. + """ + raise NotImplementedError + + def dump( + self, + target: Union[Text, Path, yaml.StringIO], + story_steps: List["StoryStep"], + is_appendable: bool = False, + is_test_story: bool = False, + ) -> None: + """Writes Story steps into a target file/stream. + + Args: + target: name of the target file/stream to write the string to. + story_steps: Original story steps to be converted to the string. + is_appendable: Specify if result should not contain + high level keys/definitions and can be appended to + the existing story file. + is_test_story: Identifies if the stories should be exported in test stories + format. + """ + raise NotImplementedError + + @staticmethod + def _filter_event(event: Union["Event", List["Event"]]) -> bool: + """Identifies if the event should be converted/written. + + Args: + event: target event to check. + + Returns: + `True` if the event should be converted/written, `False` otherwise. + """ + from rasa.shared.core.training_data.structures import StoryStep + + # This is an "OR" statement, so we accept it + if isinstance(event, list): + return True + + return ( + not StoryStep.is_action_listen(event) + and not StoryStep.is_action_unlikely_intent(event) + and not StoryStep.is_action_session_start(event) + ) diff --git a/rasa/shared/core/training_data/story_writer/yaml_story_writer.py b/rasa/shared/core/training_data/story_writer/yaml_story_writer.py new file mode 100644 index 0000000..d09bb09 --- /dev/null +++ b/rasa/shared/core/training_data/story_writer/yaml_story_writer.py @@ -0,0 +1,417 @@ +from collections import OrderedDict +from pathlib import Path +from typing import Any, Dict, List, Text, Union, Optional + +from ruamel import yaml +from ruamel.yaml.comments import CommentedMap +from ruamel.yaml.scalarstring import DoubleQuotedScalarString, LiteralScalarString + +import rasa.shared.utils.io +import rasa.shared.core.constants +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION +import rasa.shared.core.events +from rasa.shared.core.events import ( + UserUttered, + ActionExecuted, + SlotSet, + ActiveLoop, + Event, +) + +from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + KEY_STORIES, + KEY_STORY_NAME, + KEY_USER_INTENT, + KEY_ENTITIES, + KEY_ACTION, + KEY_STEPS, + KEY_CHECKPOINT, + KEY_SLOT_NAME, + KEY_CHECKPOINT_SLOTS, + KEY_OR, + KEY_USER_MESSAGE, + KEY_ACTIVE_LOOP, + KEY_BOT_END_TO_END_MESSAGE, + KEY_RULES, + KEY_RULE_FOR_CONVERSATION_START, + KEY_WAIT_FOR_USER_INPUT_AFTER_RULE, + KEY_RULE_CONDITION, + KEY_RULE_NAME, +) + +from rasa.shared.core.training_data.story_writer.story_writer import StoryWriter +from rasa.shared.core.training_data.structures import ( + StoryStep, + Checkpoint, + STORY_START, + RuleStep, +) + + +class YAMLStoryWriter(StoryWriter): + """Writes Core training data into a file in a YAML format.""" + + def dumps( + self, + story_steps: List[StoryStep], + is_appendable: bool = False, + is_test_story: bool = False, + ) -> Text: + """Turns Story steps into an YAML string. + + Args: + story_steps: Original story steps to be converted to the YAML. + is_appendable: Specify if result should not contain + high level keys/definitions and can be appended to + the existing story file. + is_test_story: Identifies if the stories should be exported in test stories + format. + + Returns: + String with story steps in the YAML format. + """ + stream = yaml.StringIO() + self.dump(stream, story_steps, is_appendable, is_test_story) + return stream.getvalue() + + def dump( + self, + target: Union[Text, Path, yaml.StringIO], + story_steps: List[StoryStep], + is_appendable: bool = False, + is_test_story: bool = False, + ) -> None: + """Writes Story steps into a target file/stream. + + Args: + target: name of the target file/stream to write the YAML to. + story_steps: Original story steps to be converted to the YAML. + is_appendable: Specify if result should not contain + high level keys/definitions and can be appended to + the existing story file. + is_test_story: Identifies if the stories should be exported in test stories + format. + """ + result = self.stories_to_yaml(story_steps, is_test_story) + if is_appendable and KEY_STORIES in result: + result = result[KEY_STORIES] + rasa.shared.utils.io.write_yaml(result, target, True) + + def stories_to_yaml( + self, story_steps: List[StoryStep], is_test_story: bool = False + ) -> Dict[Text, Any]: + """Converts a sequence of story steps into yaml format. + + Args: + story_steps: Original story steps to be converted to the YAML. + is_test_story: `True` if the story is an end-to-end conversation test story. + """ + from rasa.shared.utils.validation import KEY_TRAINING_DATA_FORMAT_VERSION + + self._is_test_story = is_test_story + + stories = [] + rules = [] + for story_step in story_steps: + if isinstance(story_step, RuleStep): + rules.append(self.process_rule_step(story_step)) + else: + stories.append(self.process_story_step(story_step)) + + result: OrderedDict[Text, Any] = OrderedDict() + result[KEY_TRAINING_DATA_FORMAT_VERSION] = DoubleQuotedScalarString( + LATEST_TRAINING_DATA_FORMAT_VERSION + ) + + if stories: + result[KEY_STORIES] = stories + if rules: + result[KEY_RULES] = rules + + return result + + def process_story_step(self, story_step: StoryStep) -> OrderedDict: + """Converts a single story step into an ordered dict. + + Args: + story_step: A single story step to be converted to the dict. + + Returns: + Dict with a story step. + """ + result: OrderedDict[Text, Any] = OrderedDict() + result[KEY_STORY_NAME] = story_step.block_name + steps = self.process_checkpoints(story_step.start_checkpoints) + for event in story_step.events: + if not self._filter_event(event): + continue + processed = self.process_event(event) + if processed: + steps.append(processed) + + steps.extend(self.process_checkpoints(story_step.end_checkpoints)) + + result[KEY_STEPS] = steps + + return result + + def process_event(self, event: Union[Event, List[Event]]) -> Optional[OrderedDict]: + """Process an event or list of events.""" + if isinstance(event, list): + return self.process_or_utterances(event) + if isinstance(event, UserUttered): + return self.process_user_utterance(event, self._is_test_story) + if isinstance(event, ActionExecuted): + return self.process_action(event) + if isinstance(event, SlotSet): + return self.process_slot(event) + if isinstance(event, ActiveLoop): + return self.process_active_loop(event) + return None + + @staticmethod + def stories_contain_loops(stories: List[StoryStep]) -> bool: + """Checks if the stories contain at least one active loop. + + Args: + stories: Stories steps. + + Returns: + `True` if the `stories` contain at least one active loop. + `False` otherwise. + """ + return any( + [ + [event for event in story_step.events if isinstance(event, ActiveLoop)] + for story_step in stories + ] + ) + + @staticmethod + def process_user_utterance( + user_utterance: UserUttered, is_test_story: bool = False + ) -> OrderedDict: + """Converts a single user utterance into an ordered dict. + + Args: + user_utterance: Original user utterance object. + is_test_story: Identifies if the user utterance should be added + to the final YAML or not. + + Returns: + Dict with a user utterance. + """ + result = CommentedMap() + if user_utterance.intent_name and not user_utterance.use_text_for_featurization: + result[KEY_USER_INTENT] = ( + user_utterance.full_retrieval_intent_name + if user_utterance.full_retrieval_intent_name + else user_utterance.intent_name + ) + + entities = [] + if len(user_utterance.entities) and not is_test_story: + for entity in user_utterance.entities: + if "value" in entity: + if hasattr(user_utterance, "inline_comment_for_entity"): + # FIXME: to fix this type issue, WronglyClassifiedUserUtterance + # needs to be imported but it's currently outside + # of `rasa.shared` + for predicted in user_utterance.predicted_entities: # type: ignore[attr-defined] # noqa: E501 + if predicted["start"] == entity["start"]: + commented_entity = user_utterance.inline_comment_for_entity( # noqa: E501 + predicted, entity + ) + if commented_entity: + entity_map = CommentedMap( + [(entity["entity"], entity["value"])] + ) + entity_map.yaml_add_eol_comment( + commented_entity, entity["entity"] + ) + entities.append(entity_map) + else: + entities.append( + OrderedDict( + [(entity["entity"], entity["value"])] + ) + ) + else: + entities.append( + OrderedDict([(entity["entity"], entity["value"])]) + ) + else: + entities.append(entity["entity"]) + result[KEY_ENTITIES] = entities + + if hasattr(user_utterance, "inline_comment"): + # FIXME: to fix this type issue, WronglyClassifiedUserUtterance needs to + # be imported but it's currently outside of `rasa.shared` + comment = user_utterance.inline_comment( + force_comment_generation=not entities + ) + if comment: + result.yaml_add_eol_comment(comment, KEY_USER_INTENT) + + if user_utterance.text and ( + # We only print the utterance text if it was an end-to-end prediction + user_utterance.use_text_for_featurization + # or if we want to print a conversation test story. + or is_test_story + ): + result[KEY_USER_MESSAGE] = LiteralScalarString( + rasa.shared.core.events.format_message( + user_utterance.text, + user_utterance.intent_name, + user_utterance.entities, + ) + ) + + return result + + @staticmethod + def process_action(action: ActionExecuted) -> Optional[OrderedDict]: + """Converts a single action into an ordered dict. + + Args: + action: Original action object. + + Returns: + Dict with an action. + """ + if action.action_name == rasa.shared.core.constants.RULE_SNIPPET_ACTION_NAME: + return None + + result = CommentedMap() + if action.action_name: + result[KEY_ACTION] = action.action_name + elif action.action_text: + result[KEY_BOT_END_TO_END_MESSAGE] = action.action_text + + if hasattr(action, "inline_comment"): + # FIXME: to fix this type issue, WarningPredictedAction needs to + # be imported but it's currently outside of `rasa.shared` + comment = action.inline_comment() + if KEY_ACTION in result and comment: + result.yaml_add_eol_comment(comment, KEY_ACTION) + elif KEY_BOT_END_TO_END_MESSAGE in result and comment: + result.yaml_add_eol_comment(comment, KEY_BOT_END_TO_END_MESSAGE) + + return result + + @staticmethod + def process_slot(event: SlotSet) -> OrderedDict: + """Converts a single `SlotSet` event into an ordered dict. + + Args: + event: Original `SlotSet` event. + + Returns: + OrderedDict with an `SlotSet` event. + """ + return OrderedDict([(KEY_SLOT_NAME, [{event.key: event.value}])]) + + @staticmethod + def process_checkpoints(checkpoints: List[Checkpoint]) -> List[OrderedDict]: + """Converts checkpoints event into an ordered dict. + + Args: + checkpoints: List of original checkpoint. + + Returns: + List of converted checkpoints. + """ + result = [] + for checkpoint in checkpoints: + if checkpoint.name == STORY_START: + continue + next_checkpoint: OrderedDict[Text, Any] = OrderedDict( + [(KEY_CHECKPOINT, checkpoint.name)] + ) + if checkpoint.conditions: + next_checkpoint[KEY_CHECKPOINT_SLOTS] = [ + {key: value} for key, value in checkpoint.conditions.items() + ] + result.append(next_checkpoint) + return result + + def process_or_utterances(self, utterances: List[UserUttered]) -> OrderedDict: + """Converts user utterance containing the `OR` statement. + + Args: + utterances: User utterances belonging to the same `OR` statement. + + Returns: + Dict with converted user utterances. + """ + return OrderedDict( + [ + ( + KEY_OR, + [ + self.process_user_utterance(utterance, self._is_test_story) + for utterance in utterances + ], + ) + ] + ) + + @staticmethod + def process_active_loop(event: ActiveLoop) -> OrderedDict: + """Converts ActiveLoop event into an ordered dict. + + Args: + event: ActiveLoop event. + + Returns: + Converted event. + """ + return OrderedDict([(KEY_ACTIVE_LOOP, event.name)]) + + def process_rule_step(self, rule_step: RuleStep) -> OrderedDict: + """Converts a RuleStep into an ordered dict. + + Args: + rule_step: RuleStep object. + + Returns: + Converted rule step. + """ + result: OrderedDict[Text, Any] = OrderedDict() + result[KEY_RULE_NAME] = rule_step.block_name + + condition_steps = [] + condition_events = rule_step.get_rules_condition() + for event in condition_events: + processed = self.process_event(event) + if processed: + condition_steps.append(processed) + if condition_steps: + result[KEY_RULE_CONDITION] = condition_steps + + normal_events = rule_step.get_rules_events() + if normal_events and not ( + isinstance(normal_events[0], ActionExecuted) + and normal_events[0].action_name + == rasa.shared.core.constants.RULE_SNIPPET_ACTION_NAME + ): + result[KEY_RULE_FOR_CONVERSATION_START] = True + + normal_steps = [] + for event in normal_events: + processed = self.process_event(event) + if processed: + normal_steps.append(processed) + if normal_steps: + result[KEY_STEPS] = normal_steps + + if len(normal_events) > 1: + last_event = normal_events[len(normal_events) - 1] + if ( + isinstance(last_event, ActionExecuted) + and last_event.action_name + == rasa.shared.core.constants.RULE_SNIPPET_ACTION_NAME + ): + result[KEY_WAIT_FOR_USER_INPUT_AFTER_RULE] = False + + return result diff --git a/rasa/shared/core/training_data/structures.py b/rasa/shared/core/training_data/structures.py new file mode 100644 index 0000000..78d2705 --- /dev/null +++ b/rasa/shared/core/training_data/structures.py @@ -0,0 +1,836 @@ +import json +import logging +from collections import deque, defaultdict + +import uuid +import typing +from typing import ( + List, + Text, + Deque, + Dict, + Optional, + Tuple, + Any, + Set, + ValuesView, + Union, + Sequence, +) + +import rasa.shared.utils.io +from rasa.shared.core.constants import ( + ACTION_LISTEN_NAME, + ACTION_SESSION_START_NAME, + ACTION_UNLIKELY_INTENT_NAME, +) +from rasa.shared.core.conversation import Dialogue +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + UserUttered, + ActionExecuted, + Event, + SessionStarted, + SlotSet, +) +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.exceptions import RasaCoreException + + +if typing.TYPE_CHECKING: + import networkx as nx + +logger = logging.getLogger(__name__) + +# Checkpoint id used to identify story starting blocks +STORY_START = "STORY_START" + +# Checkpoint id used to identify story end blocks +STORY_END = None + +# need abbreviations otherwise they are not visualized well +GENERATED_CHECKPOINT_PREFIX = "GENR_" +CHECKPOINT_CYCLE_PREFIX = "CYCL_" + +GENERATED_HASH_LENGTH = 5 + +FORM_PREFIX = "form: " +# prefix for storystep ID to get reproducible sorting results +# will get increased with each new instance +STEP_COUNT = 1 + + +class EventTypeError(RasaCoreException, ValueError): + """Represents an error caused by a Rasa Core event not being of the expected + type. + + """ + + +class Checkpoint: + """Represents places where trackers split. + + This currently happens if + - users place manual checkpoints in their stories + - have `or` statements for intents in their stories. + """ + + def __init__( + self, name: Text, conditions: Optional[Dict[Text, Any]] = None + ) -> None: + """Creates `Checkpoint`. + + Args: + name: Name of the checkpoint. + conditions: Slot conditions for this checkpoint. + """ + self.name = name + self.conditions = conditions if conditions else {} + + def as_story_string(self) -> Text: + dumped_conds = json.dumps(self.conditions) if self.conditions else "" + return f"{self.name}{dumped_conds}" + + def filter_trackers( + self, trackers: List[DialogueStateTracker] + ) -> List[DialogueStateTracker]: + """Filters out all trackers that do not satisfy the conditions.""" + if not self.conditions: + return trackers + + for slot_name, slot_value in self.conditions.items(): + trackers = [t for t in trackers if t.get_slot(slot_name) == slot_value] + return trackers + + def __repr__(self) -> Text: + return "Checkpoint(name={!r}, conditions={})".format( + self.name, json.dumps(self.conditions) + ) + + +class StoryStep: + """A StoryStep is a section of a story block between two checkpoints. + + NOTE: Checkpoints are not only limited to those manually written + in the story file, but are also implicitly created at points where + multiple intents are separated in one line by chaining them with "OR"s. + """ + + def __init__( + self, + block_name: Text, + start_checkpoints: Optional[List[Checkpoint]] = None, + end_checkpoints: Optional[List[Checkpoint]] = None, + events: Optional[List[Union[Event, List[Event]]]] = None, + source_name: Optional[Text] = None, + ) -> None: + """Initialise `StoryStep` default attributes.""" + self.end_checkpoints = end_checkpoints if end_checkpoints else [] + self.start_checkpoints = start_checkpoints if start_checkpoints else [] + self.events = events if events else [] + self.block_name = block_name + self.source_name = source_name + # put a counter prefix to uuid to get reproducible sorting results + global STEP_COUNT + self.id = "{}_{}".format(STEP_COUNT, uuid.uuid4().hex) + STEP_COUNT += 1 + + def create_copy(self, use_new_id: bool) -> "StoryStep": + copied = StoryStep( + self.block_name, + self.start_checkpoints, + self.end_checkpoints, + self.events[:], + self.source_name, + ) + if not use_new_id: + copied.id = self.id + return copied + + def add_user_message(self, user_message: UserUttered) -> None: + self.add_event(user_message) + + def add_event(self, event: Event) -> None: + self.events.append(event) + + def add_events(self, events: List[Event]) -> None: + self.events.append(events) + + @staticmethod + def _checkpoint_string(story_step_element: Checkpoint) -> Text: + return f"> {story_step_element.as_story_string()}\n" + + @staticmethod + def _user_string(story_step_element: UserUttered, e2e: bool) -> Text: + return f"* {story_step_element.as_story_string(e2e)}\n" + + @staticmethod + def _bot_string(story_step_element: Event) -> Text: + return f" - {story_step_element.as_story_string()}\n" + + @staticmethod + def _event_to_story_string(event: Event, e2e: bool) -> Optional[Text]: + if isinstance(event, UserUttered): + return event.as_story_string(e2e=e2e) + return event.as_story_string() + + @staticmethod + def _or_string(story_step_element: Sequence[Event], e2e: bool) -> Optional[Text]: + for event in story_step_element: + # OR statement can also contain `slot_was_set`, and + # we're going to ignore this events when representing + # the story as a string + if not isinstance(event, UserUttered) and not isinstance(event, SlotSet): + raise EventTypeError( + "OR statement events must be of type `UserUttered` or `SlotSet`." + ) + + event_as_strings = [ + StoryStep._event_to_story_string(element, e2e) + for element in story_step_element + ] + result = " OR ".join([event for event in event_as_strings if event is not None]) + + return f"* {result}\n" + + def as_story_string(self, flat: bool = False, e2e: bool = False) -> Text: + """Returns a story as a string.""" + # if the result should be flattened, we + # will exclude the caption and any checkpoints. + if flat: + result = "" + else: + result = f"\n## {self.block_name}\n" + for checkpoint in self.start_checkpoints: + if checkpoint.name != STORY_START: + result += self._checkpoint_string(checkpoint) + + for event in self.events: + if ( + self.is_action_listen(event) + or self.is_action_session_start(event) + or self.is_action_unlikely_intent(event) + or isinstance(event, SessionStarted) + ): + continue + + if isinstance(event, UserUttered): + result += self._user_string(event, e2e) + elif isinstance(event, Event): + converted = event.as_story_string() + if converted: + result += self._bot_string(event) + elif isinstance(event, list): + # The story reader classes support reading stories in + # conversion mode. When this mode is enabled, OR statements + # are represented as lists of events. + or_string = self._or_string(event, e2e) + if or_string: + result += or_string + else: + raise Exception(f"Unexpected element in story step: {event}") + + if not flat: + for checkpoint in self.end_checkpoints: + result += self._checkpoint_string(checkpoint) + return result + + @staticmethod + def is_action_listen(event: Event) -> bool: + # this is not an `isinstance` because + # we don't want to allow subclasses here + return type(event) == ActionExecuted and event.action_name == ACTION_LISTEN_NAME + + @staticmethod + def is_action_unlikely_intent(event: Event) -> bool: + """Checks if the executed action is a `action_unlikely_intent`.""" + return ( + type(event) == ActionExecuted + and event.action_name == ACTION_UNLIKELY_INTENT_NAME + ) + + @staticmethod + def is_action_session_start(event: Event) -> bool: + """Checks if the executed action is a `action_session_start`.""" + # this is not an `isinstance` because + # we don't want to allow subclasses here + return ( + type(event) == ActionExecuted + and event.action_name == ACTION_SESSION_START_NAME + ) + + def _add_action_listen(self, events: List[Event]) -> None: + if not events or not self.is_action_listen(events[-1]): + # do not add second action_listen + events.append(ActionExecuted(ACTION_LISTEN_NAME)) + + def explicit_events( + self, domain: Domain, should_append_final_listen: bool = True + ) -> List[Event]: + """Returns events contained in the story step including implicit events. + + Not all events are always listed in the story dsl. This + includes listen actions as well as implicitly + set slots. This functions makes these events explicit and + returns them with the rest of the steps events. + """ + events: List[Event] = [] + + for e in self.events: + if isinstance(e, UserUttered): + self._add_action_listen(events) + events.append(e) + events.extend(domain.slots_for_entities(e.entities)) + else: + events.append(e) + + if not self.end_checkpoints and should_append_final_listen: + self._add_action_listen(events) + + return events + + def __repr__(self) -> Text: + return ( + "StoryStep(" + "block_name={!r}, " + "start_checkpoints={!r}, " + "end_checkpoints={!r}, " + "events={!r})".format( + self.block_name, + self.start_checkpoints, + self.end_checkpoints, + self.events, + ) + ) + + +class RuleStep(StoryStep): + """A Special type of StoryStep representing a Rule.""" + + def __init__( + self, + block_name: Optional[Text] = None, + start_checkpoints: Optional[List[Checkpoint]] = None, + end_checkpoints: Optional[List[Checkpoint]] = None, + events: Optional[List[Union[Event, List[Event]]]] = None, + source_name: Optional[Text] = None, + condition_events_indices: Optional[Set[int]] = None, + ) -> None: + super().__init__( + block_name, start_checkpoints, end_checkpoints, events, source_name + ) + self.condition_events_indices = ( + condition_events_indices if condition_events_indices else set() + ) + + def create_copy(self, use_new_id: bool) -> "StoryStep": + copied = RuleStep( + self.block_name, + self.start_checkpoints, + self.end_checkpoints, + self.events[:], + self.source_name, + self.condition_events_indices, + ) + if not use_new_id: + copied.id = self.id + return copied + + def __repr__(self) -> Text: + return ( + "RuleStep(" + "block_name={!r}, " + "start_checkpoints={!r}, " + "end_checkpoints={!r}, " + "events={!r})".format( + self.block_name, + self.start_checkpoints, + self.end_checkpoints, + self.events, + ) + ) + + def get_rules_condition(self) -> List[Union[Event, List[Event]]]: + """Returns a list of events forming a condition of the Rule.""" + return [ + event + for event_id, event in enumerate(self.events) + if event_id in self.condition_events_indices + ] + + def get_rules_events(self) -> List[Union[Event, List[Event]]]: + """Returns a list of events forming the Rule, that are not conditions.""" + return [ + event + for event_id, event in enumerate(self.events) + if event_id not in self.condition_events_indices + ] + + def add_event_as_condition(self, event: Event) -> None: + """Adds event to the Rule as part of its condition. + + Args: + event: The event to be added. + """ + self.condition_events_indices.add(len(self.events)) + self.events.append(event) + + +class Story: + def __init__( + self, story_steps: List[StoryStep] = None, story_name: Optional[Text] = None + ) -> None: + self.story_steps = story_steps if story_steps else [] + self.story_name = story_name + + @staticmethod + def from_events(events: List[Event], story_name: Optional[Text] = None) -> "Story": + """Create a story from a list of events.""" + story_step = StoryStep(story_name) + for event in events: + story_step.add_event(event) + return Story([story_step], story_name) + + def as_dialogue(self, sender_id: Text, domain: Domain) -> Dialogue: + events = [] + for step in self.story_steps: + events.extend( + step.explicit_events(domain, should_append_final_listen=False) + ) + + events.append(ActionExecuted(ACTION_LISTEN_NAME)) + return Dialogue(sender_id, events) + + def as_story_string(self, flat: bool = False, e2e: bool = False) -> Text: + story_content = "" + for step in self.story_steps: + story_content += step.as_story_string(flat, e2e) + + if flat: + if self.story_name: + name = self.story_name + else: + name = "Generated Story {}".format(hash(story_content)) + return f"## {name}\n{story_content}" + else: + return story_content + + +class StoryGraph: + """Graph of the story-steps pooled from all stories in the training data.""" + + def __init__( + self, + story_steps: List[StoryStep], + story_end_checkpoints: Optional[Dict[Text, Text]] = None, + ) -> None: + self.story_steps = story_steps + self.step_lookup = {s.id: s for s in self.story_steps} + ordered_ids, cyclic_edges = StoryGraph.order_steps(story_steps) + self.ordered_ids = ordered_ids + self.cyclic_edge_ids = cyclic_edges + if story_end_checkpoints: + self.story_end_checkpoints = story_end_checkpoints + else: + self.story_end_checkpoints = {} + + def __hash__(self) -> int: + """Return hash for the story step. + + Returns: + Hash of the story step. + """ + return int(self.fingerprint(), 16) + + def fingerprint(self) -> Text: + """Returns a unique hash for the stories which is stable across python runs. + + Returns: + fingerprint of the stories + """ + from rasa.shared.core.training_data.story_writer.yaml_story_writer import ( + YAMLStoryWriter, + ) + + stories_as_yaml = YAMLStoryWriter().stories_to_yaml(self.story_steps) + return rasa.shared.utils.io.deep_container_fingerprint(stories_as_yaml) + + def ordered_steps(self) -> List[StoryStep]: + """Returns the story steps ordered by topological order of the DAG.""" + return [self._get_step(step_id) for step_id in self.ordered_ids] + + def cyclic_edges(self) -> List[Tuple[Optional[StoryStep], Optional[StoryStep]]]: + """Returns the story steps ordered by topological order of the DAG.""" + return [ + (self._get_step(source), self._get_step(target)) + for source, target in self.cyclic_edge_ids + ] + + def merge(self, other: Optional["StoryGraph"]) -> "StoryGraph": + """Merge two StoryGraph together.""" + if not other: + return self + + steps = self.story_steps.copy() + other.story_steps + story_end_checkpoints = self.story_end_checkpoints.copy().update( + other.story_end_checkpoints + ) + return StoryGraph(steps, story_end_checkpoints) + + @staticmethod + def overlapping_checkpoint_names( + cps: List[Checkpoint], other_cps: List[Checkpoint] + ) -> Set[Text]: + """Find overlapping checkpoints names.""" + return {cp.name for cp in cps} & {cp.name for cp in other_cps} + + def with_cycles_removed(self) -> "StoryGraph": + """Create a graph with the cyclic edges removed from this graph.""" + story_end_checkpoints = self.story_end_checkpoints.copy() + cyclic_edge_ids = self.cyclic_edge_ids + # we need to remove the start steps and replace them with steps ending + # in a special end checkpoint + + story_steps = {s.id: s for s in self.story_steps} + + # collect all overlapping checkpoints + # we will remove unused start ones + all_overlapping_cps = set() + + if self.cyclic_edge_ids: + # we are going to do this in a recursive way. we are going to + # remove one cycle and then we are going to + # let the cycle detection run again + # this is not inherently necessary so if this becomes a performance + # issue, we can change it. It is actually enough to run the cycle + # detection only once and then remove one cycle after another, but + # since removing the cycle is done by adding / removing edges and + # nodes + # the logic is a lot easier if we only need to make sure the + # change is consistent if we only change one compared to + # changing all of them. + + for s, e in cyclic_edge_ids: + cid = generate_id(max_chars=GENERATED_HASH_LENGTH) + prefix = GENERATED_CHECKPOINT_PREFIX + CHECKPOINT_CYCLE_PREFIX + # need abbreviations otherwise they are not visualized well + sink_cp_name = prefix + "SINK_" + cid + connector_cp_name = prefix + "CONN_" + cid + source_cp_name = prefix + "SRC_" + cid + story_end_checkpoints[sink_cp_name] = source_cp_name + + overlapping_cps = self.overlapping_checkpoint_names( + story_steps[s].end_checkpoints, story_steps[e].start_checkpoints + ) + + all_overlapping_cps.update(overlapping_cps) + + # change end checkpoints of starts + start = story_steps[s].create_copy(use_new_id=False) + start.end_checkpoints = [ + cp for cp in start.end_checkpoints if cp.name not in overlapping_cps + ] + start.end_checkpoints.append(Checkpoint(sink_cp_name)) + story_steps[s] = start + + needs_connector = False + + for k, step in list(story_steps.items()): + additional_ends = [] + for original_cp in overlapping_cps: + for cp in step.start_checkpoints: + if cp.name == original_cp: + if k == e: + cp_name = source_cp_name + else: + cp_name = connector_cp_name + needs_connector = True + + if not self._is_checkpoint_in_list( + cp_name, cp.conditions, step.start_checkpoints + ): + # add checkpoint only if it was not added + additional_ends.append( + Checkpoint(cp_name, cp.conditions) + ) + + if additional_ends: + updated = step.create_copy(use_new_id=False) + updated.start_checkpoints.extend(additional_ends) + story_steps[k] = updated + + if needs_connector: + start.end_checkpoints.append(Checkpoint(connector_cp_name)) + + # the process above may generate unused checkpoints + # we need to find them and remove them + self._remove_unused_generated_cps( + story_steps, all_overlapping_cps, story_end_checkpoints + ) + + return StoryGraph(list(story_steps.values()), story_end_checkpoints) + + @staticmethod + def _checkpoint_difference( + cps: List[Checkpoint], cp_name_to_ignore: Set[Text] + ) -> List[Checkpoint]: + """Finds checkpoints which names are + different form names of checkpoints to ignore. + """ + return [cp for cp in cps if cp.name not in cp_name_to_ignore] + + def _remove_unused_generated_cps( + self, + story_steps: Dict[Text, StoryStep], + overlapping_cps: Set[Text], + story_end_checkpoints: Dict[Text, Text], + ) -> None: + """Finds unused generated checkpoints + and remove them from story steps. + """ + unused_cps = self._find_unused_checkpoints( + story_steps.values(), story_end_checkpoints + ) + + unused_overlapping_cps = unused_cps.intersection(overlapping_cps) + + unused_genr_cps = { + cp_name + for cp_name in unused_cps + if cp_name is not None and cp_name.startswith(GENERATED_CHECKPOINT_PREFIX) + } + + k_to_remove = set() + for k, step in story_steps.items(): + # changed all ends + updated = step.create_copy(use_new_id=False) + updated.start_checkpoints = self._checkpoint_difference( + updated.start_checkpoints, unused_overlapping_cps + ) + + # remove generated unused end checkpoints + updated.end_checkpoints = self._checkpoint_difference( + updated.end_checkpoints, unused_genr_cps + ) + + if ( + step.start_checkpoints + and not updated.start_checkpoints + or step.end_checkpoints + and not updated.end_checkpoints + ): + # remove story step if the generated checkpoints + # were the only ones + k_to_remove.add(k) + + story_steps[k] = updated + + # remove unwanted story steps + for k in k_to_remove: + del story_steps[k] + + @staticmethod + def _is_checkpoint_in_list( + checkpoint_name: Text, conditions: Dict[Text, Any], cps: List[Checkpoint] + ) -> bool: + """Checks if checkpoint with name and conditions is + already in the list of checkpoints. + """ + for cp in cps: + if checkpoint_name == cp.name and conditions == cp.conditions: + return True + return False + + @staticmethod + def _find_unused_checkpoints( + story_steps: ValuesView[StoryStep], story_end_checkpoints: Dict[Text, Text] + ) -> Set[Optional[Text]]: + """Finds all unused checkpoints.""" + collected_start = {STORY_END, STORY_START} + collected_end = {STORY_END, STORY_START} + + for step in story_steps: + for start in step.start_checkpoints: + collected_start.add(start.name) + for end in step.end_checkpoints: + start_name = story_end_checkpoints.get(end.name, end.name) + collected_end.add(start_name) + + return collected_end.symmetric_difference(collected_start) + + def _get_step(self, step_id: Text) -> StoryStep: + """Looks a story step up by its id.""" + return self.step_lookup[step_id] + + @staticmethod + def order_steps( + story_steps: List[StoryStep], + ) -> Tuple[deque, List[Tuple[Text, Text]]]: + """Topological sort of the steps returning the ids of the steps.""" + checkpoints = StoryGraph._group_by_start_checkpoint(story_steps) + graph = { + s.id: { + other.id for end in s.end_checkpoints for other in checkpoints[end.name] + } + for s in story_steps + } + return StoryGraph.topological_sort(graph) + + @staticmethod + def _group_by_start_checkpoint( + story_steps: List[StoryStep], + ) -> Dict[Text, List[StoryStep]]: + """Returns all the start checkpoint of the steps.""" + checkpoints = defaultdict(list) + for step in story_steps: + for start in step.start_checkpoints: + checkpoints[start.name].append(step) + return checkpoints + + @staticmethod + def topological_sort( + graph: Dict[Text, Set[Text]] + ) -> Tuple[deque, List[Tuple[Text, Text]]]: + """Creates a top sort of a directed graph. This is an unstable sorting! + + The function returns the sorted nodes as well as the edges that need + to be removed from the graph to make it acyclic (and hence, sortable). + + The graph should be represented as a dictionary, e.g.: + + >>> example_graph = { + ... "a": set("b", "c", "d"), + ... "b": set(), + ... "c": set("d"), + ... "d": set(), + ... "e": set("f"), + ... "f": set()} + >>> StoryGraph.topological_sort(example_graph) + (deque([u'e', u'f', u'a', u'c', u'd', u'b']), []) + """ + # noinspection PyPep8Naming + GRAY, BLACK = 0, 1 + + ordered: Deque = deque() + unprocessed = sorted(set(graph)) + visited_nodes = {} + + removed_edges = set() + + def dfs(node: Text) -> None: + visited_nodes[node] = GRAY + for k in sorted(graph.get(node, set())): + sk = visited_nodes.get(k, None) + if sk == GRAY: + removed_edges.add((node, k)) + continue + if sk == BLACK: + continue + unprocessed.remove(k) + dfs(k) + ordered.appendleft(node) + visited_nodes[node] = BLACK + + while unprocessed: + dfs(unprocessed.pop()) + + return ordered, sorted(removed_edges) + + def visualize(self, output_file: Optional[Text] = None) -> "nx.MultiDiGraph": + import networkx as nx + from rasa.shared.core.training_data import visualization + from colorhash import ColorHash + + graph = nx.MultiDiGraph() + next_node_idx = [0] + nodes = {"STORY_START": 0, "STORY_END": -1} + + def ensure_checkpoint_is_drawn(cp: Checkpoint) -> None: + if cp.name not in nodes: + next_node_idx[0] += 1 + nodes[cp.name] = next_node_idx[0] + + if cp.name.startswith(GENERATED_CHECKPOINT_PREFIX): + # colors generated checkpoints based on their hash + color = ColorHash(cp.name[-GENERATED_HASH_LENGTH:]).hex + graph.add_node( + next_node_idx[0], + label=_cap_length(cp.name), + style="filled", + fillcolor=color, + ) + else: + graph.add_node(next_node_idx[0], label=_cap_length(cp.name)) + + graph.add_node( + nodes["STORY_START"], label="START", fillcolor="green", style="filled" + ) + graph.add_node(nodes["STORY_END"], label="END", fillcolor="red", style="filled") + + for step in self.story_steps: + next_node_idx[0] += 1 + step_idx = next_node_idx[0] + + graph.add_node( + next_node_idx[0], + label=_cap_length(step.block_name), + style="filled", + fillcolor="lightblue", + shape="rect", + ) + + for c in step.start_checkpoints: + ensure_checkpoint_is_drawn(c) + graph.add_edge(nodes[c.name], step_idx) + for c in step.end_checkpoints: + ensure_checkpoint_is_drawn(c) + graph.add_edge(step_idx, nodes[c.name]) + + if not step.end_checkpoints: + graph.add_edge(step_idx, nodes["STORY_END"]) + + if output_file: + visualization.persist_graph(graph, output_file) + + return graph + + def is_empty(self) -> bool: + """Checks if `StoryGraph` is empty.""" + return not self.story_steps + + def __repr__(self) -> Text: + """Returns text representation of object.""" + return f"{self.__class__.__name__}: {len(self.story_steps)} story steps" + + +def generate_id(prefix: Text = "", max_chars: Optional[int] = None) -> Text: + """Generate a random UUID. + + Args: + prefix: String to prefix the ID with. + max_chars: Maximum number of characters. + + Returns: + Generated random UUID. + """ + import uuid + + gid = uuid.uuid4().hex + if max_chars: + gid = gid[:max_chars] + + return f"{prefix}{gid}" + + +def _cap_length(s: Text, char_limit: int = 20, append_ellipsis: bool = True) -> Text: + """Makes sure the string doesn't exceed the passed char limit. + + Appends an ellipsis if the string is too long. + """ + if len(s) > char_limit: + if append_ellipsis: + return s[: char_limit - 3] + "..." + else: + return s[:char_limit] + else: + return s diff --git a/rasa/shared/core/training_data/visualization.html b/rasa/shared/core/training_data/visualization.html new file mode 100644 index 0000000..de7d5aa --- /dev/null +++ b/rasa/shared/core/training_data/visualization.html @@ -0,0 +1,146 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Rasa Core Visualisation + + + + + + +
+ + + + + + + diff --git a/rasa/shared/core/training_data/visualization.py b/rasa/shared/core/training_data/visualization.py new file mode 100644 index 0000000..21176c6 --- /dev/null +++ b/rasa/shared/core/training_data/visualization.py @@ -0,0 +1,607 @@ +from collections import defaultdict, deque + +import random +from typing import ( + Any, + Text, + List, + Deque, + Dict, + Optional, + Set, + TYPE_CHECKING, + Union, + cast, +) + +import rasa.shared.utils.io +from rasa.shared.constants import INTENT_MESSAGE_PREFIX +from rasa.shared.core.constants import ACTION_LISTEN_NAME +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import UserUttered, ActionExecuted, Event +from rasa.shared.core.generator import TrainingDataGenerator +from rasa.shared.core.training_data.structures import StoryGraph, StoryStep +from rasa.shared.nlu.constants import ( + ENTITY_ATTRIBUTE_VALUE, + INTENT, + TEXT, + ENTITY_ATTRIBUTE_TYPE, + INTENT_NAME_KEY, +) + +if TYPE_CHECKING: + from rasa.shared.nlu.training_data.training_data import TrainingData + from rasa.shared.nlu.training_data.message import Message + import networkx + +EDGE_NONE_LABEL = "NONE" + +START_NODE_ID = 0 +END_NODE_ID = -1 +TMP_NODE_ID = -2 + +VISUALIZATION_TEMPLATE_PATH = "/visualization.html" + + +class UserMessageGenerator: + def __init__(self, nlu_training_data: "TrainingData") -> None: + self.nlu_training_data = nlu_training_data + self.mapping = self._create_reverse_mapping(self.nlu_training_data) + + @staticmethod + def _create_reverse_mapping( + data: "TrainingData", + ) -> Dict[Dict[Text, Any], List["Message"]]: + """Create a mapping from intent to messages + + This allows a faster intent lookup.""" + + d = defaultdict(list) + for example in data.training_examples: + if example.get(INTENT, {}) is not None: + d[example.get(INTENT, {})].append(example) + return d + + @staticmethod + def _contains_same_entity(entities: Dict[Text, Any], e: Dict[Text, Any]) -> bool: + return entities.get(e.get(ENTITY_ATTRIBUTE_TYPE)) is None or entities.get( + e.get(ENTITY_ATTRIBUTE_TYPE) + ) != e.get(ENTITY_ATTRIBUTE_VALUE) + + def message_for_data(self, structured_info: Dict[Text, Any]) -> Any: + """Find a data sample with the same intent.""" + if structured_info.get(INTENT) is not None: + intent_name = structured_info.get(INTENT, {}).get(INTENT_NAME_KEY) + usable_examples = self.mapping.get(intent_name, [])[:] + random.shuffle(usable_examples) + + if usable_examples: + return usable_examples[0].get(TEXT) + + return structured_info.get(TEXT) + + +def _fingerprint_node( + graph: "networkx.MultiDiGraph", node: int, max_history: int +) -> Set[Text]: + """Fingerprint a node in a graph. + + Can be used to identify nodes that are similar and can be merged within the + graph. + Generates all paths starting at `node` following the directed graph up to + the length of `max_history`, and returns a set of strings describing the + found paths. If the fingerprint creation for two nodes results in the same + sets these nodes are indistinguishable if we walk along the path and only + remember max history number of nodes we have visited. Hence, if we randomly + walk on our directed graph, always only remembering the last `max_history` + nodes we have visited, we can never remember if we have visited node A or + node B if both have the same fingerprint.""" + + # the candidate list contains all node paths that haven't been + # extended till `max_history` length yet. + candidates: Deque = deque() + candidates.append([node]) + continuations = [] + while len(candidates) > 0: + candidate = candidates.pop() + last = candidate[-1] + empty = True + for _, succ_node in graph.out_edges(last): + next_candidate = candidate[:] + next_candidate.append(succ_node) + # if the path is already long enough, we add it to the results, + # otherwise we add it to the candidates + # that we still need to visit + if len(next_candidate) == max_history: + continuations.append(next_candidate) + else: + candidates.append(next_candidate) + empty = False + if empty: + continuations.append(candidate) + return { + " - ".join([graph.nodes[node]["label"] for node in continuation]) + for continuation in continuations + } + + +def _incoming_edges(graph: "networkx.MultiDiGraph", node: int) -> set: + return {(prev_node, k) for prev_node, _, k in graph.in_edges(node, keys=True)} + + +def _outgoing_edges(graph: "networkx.MultiDiGraph", node: int) -> set: + return {(succ_node, k) for _, succ_node, k in graph.out_edges(node, keys=True)} + + +def _outgoing_edges_are_similar( + graph: "networkx.MultiDiGraph", node_a: int, node_b: int +) -> bool: + """If the outgoing edges from the two nodes are similar enough, + it doesn't matter if you are in a or b. + + As your path will be the same because the outgoing edges will lead you to + the same nodes anyways.""" + + ignored = {node_b, node_a} + a_edges = { + (target, k) + for target, k in _outgoing_edges(graph, node_a) + if target not in ignored + } + b_edges = { + (target, k) + for target, k in _outgoing_edges(graph, node_b) + if target not in ignored + } + return a_edges == b_edges or not a_edges or not b_edges + + +def _nodes_are_equivalent( + graph: "networkx.MultiDiGraph", node_a: int, node_b: int, max_history: int +) -> bool: + """Decides if two nodes are equivalent based on their fingerprints.""" + return graph.nodes[node_a]["label"] == graph.nodes[node_b]["label"] and ( + _outgoing_edges_are_similar(graph, node_a, node_b) + or _incoming_edges(graph, node_a) == _incoming_edges(graph, node_b) + or _fingerprint_node(graph, node_a, max_history) + == _fingerprint_node(graph, node_b, max_history) + ) + + +def _add_edge( + graph: "networkx.MultiDiGraph", + u: int, + v: int, + key: Optional[Text], + label: Optional[Text] = None, + **kwargs: Any, +) -> None: + """Adds an edge to the graph if the edge is not already present. Uses the + label as the key.""" + + if key is None: + key = EDGE_NONE_LABEL + + if key == EDGE_NONE_LABEL: + label = "" + + if not graph.has_edge(u, v, key=EDGE_NONE_LABEL): + graph.add_edge(u, v, key=key, label=label, **kwargs) + else: + d = graph.get_edge_data(u, v, key=EDGE_NONE_LABEL) + _transfer_style(kwargs, d) + + +def _transfer_style( + source: Dict[Text, Any], target: Dict[Text, Any] +) -> Dict[Text, Any]: + """Copy over class names from source to target for all special classes. + + Used if a node is highlighted and merged with another node.""" + + clazzes = source.get("class", "") + + special_classes = {"dashed", "active"} + + if "class" not in target: + target["class"] = "" + + for c in special_classes: + if c in clazzes and c not in target["class"]: + target["class"] += " " + c + + target["class"] = target["class"].strip() + return target + + +def _merge_equivalent_nodes(graph: "networkx.MultiDiGraph", max_history: int) -> None: + """Searches for equivalent nodes in the graph and merges them.""" + + changed = True + # every node merge changes the graph and can trigger previously + # impossible node merges - we need to repeat until + # the graph doesn't change anymore + while changed: + changed = False + remaining_node_ids = [n for n in graph.nodes() if n > 0] + for idx, i in enumerate(remaining_node_ids): + if graph.has_node(i): + # assumes node equivalence is cumulative + for j in remaining_node_ids[idx + 1 :]: + if graph.has_node(j) and _nodes_are_equivalent( + graph, i, j, max_history + ): + # make sure we keep special styles + _transfer_style( + graph.nodes(data=True)[j], graph.nodes(data=True)[i] + ) + + changed = True + # moves all outgoing edges to the other node + j_outgoing_edges = list( + graph.out_edges(j, keys=True, data=True) + ) + for _, succ_node, k, d in j_outgoing_edges: + _add_edge( + graph, + i, + succ_node, + k, + d.get("label"), + **{"class": d.get("class", "")}, + ) + graph.remove_edge(j, succ_node) + # moves all incoming edges to the other node + j_incoming_edges = list(graph.in_edges(j, keys=True, data=True)) + for prev_node, _, k, d in j_incoming_edges: + _add_edge( + graph, + prev_node, + i, + k, + d.get("label"), + **{"class": d.get("class", "")}, + ) + graph.remove_edge(prev_node, j) + graph.remove_node(j) + + +def _replace_edge_labels_with_nodes( + graph: "networkx.MultiDiGraph", next_id: int, nlu_training_data: "TrainingData" +) -> None: + """Replaces edge labels with nodes. + + User messages are created as edge labels. This removes the labels and + creates nodes instead. + + The algorithms (e.g. merging) are simpler if the user messages are labels + on the edges. But it sometimes + looks better if in the final graphs the user messages are nodes instead + of edge labels. + """ + if nlu_training_data: + message_generator = UserMessageGenerator(nlu_training_data) + else: + message_generator = None + + edges = list(graph.edges(keys=True, data=True)) + for s, e, k, d in edges: + if k != EDGE_NONE_LABEL: + label = d.get("label", k) + + if message_generator: + parsed_info = {TEXT: label} + if label.startswith(INTENT_MESSAGE_PREFIX): + parsed_info[INTENT] = {INTENT_NAME_KEY: label[1:]} + + label = message_generator.message_for_data(parsed_info) + next_id += 1 + graph.remove_edge(s, e, k) + graph.add_node( + next_id, + label=label, + shape="rect", + style="filled", + fillcolor="lightblue", + **_transfer_style(d, {"class": "intent"}), + ) + graph.add_edge(s, next_id, **{"class": d.get("class", "")}) + graph.add_edge(next_id, e, **{"class": d.get("class", "")}) + + +def visualization_html_path() -> Text: + import pkg_resources + + return pkg_resources.resource_filename(__name__, VISUALIZATION_TEMPLATE_PATH) + + +def persist_graph(graph: "networkx.Graph", output_file: Text) -> None: + """Plots the graph and persists it into a html file.""" + import networkx as nx + + expg = nx.nx_pydot.to_pydot(graph) + + template = rasa.shared.utils.io.read_file(visualization_html_path()) + + # Insert graph into template + template = template.replace("// { is-client }", "isClient = true", 1) + graph_as_text = expg.to_string() + # escape backslashes + graph_as_text = graph_as_text.replace("\\", "\\\\") + template = template.replace("// { graph-content }", f"graph = `{graph_as_text}`", 1) + + rasa.shared.utils.io.write_text_file(template, output_file) + + +def _length_of_common_action_prefix(this: List[Event], other: List[Event]) -> int: + """Calculate number of actions that two conversations have in common.""" + num_common_actions = 0 + t_cleaned = cast( + List[Union[ActionExecuted, UserUttered]], + [e for e in this if e.type_name in {"user", "action"}], + ) + o_cleaned = cast( + List[Union[ActionExecuted, UserUttered]], + [e for e in other if e.type_name in {"user", "action"}], + ) + + for i, e in enumerate(t_cleaned): + o = o_cleaned[i] + if i == len(o_cleaned): + break + elif isinstance(e, UserUttered) and isinstance(o, UserUttered): + continue + elif ( + isinstance(e, ActionExecuted) + and isinstance(o, ActionExecuted) + and o.action_name == e.action_name + ): + num_common_actions += 1 + else: + break + return num_common_actions + + +def _add_default_nodes(graph: "networkx.MultiDiGraph", fontsize: int = 12) -> None: + """Add the standard nodes we need.""" + + graph.add_node( + START_NODE_ID, + label="START", + fillcolor="green", + style="filled", + fontsize=fontsize, + **{"class": "start active"}, + ) + graph.add_node( + END_NODE_ID, + label="END", + fillcolor="red", + style="filled", + fontsize=fontsize, + **{"class": "end"}, + ) + graph.add_node(TMP_NODE_ID, label="TMP", style="invis", **{"class": "invisible"}) + + +def _create_graph(fontsize: int = 12) -> "networkx.MultiDiGraph": + """Create a graph and adds the default nodes.""" + + import networkx as nx + + graph = nx.MultiDiGraph() + _add_default_nodes(graph, fontsize) + return graph + + +def _add_message_edge( + graph: "networkx.MultiDiGraph", + message: Optional[Dict[Text, Any]], + current_node: int, + next_node_idx: int, + is_current: bool, +) -> None: + """Create an edge based on the user message.""" + + if message: + message_key = message.get("intent", {}).get("name", None) + message_label = message.get("text", None) + else: + message_key = None + message_label = None + + _add_edge( + graph, + current_node, + next_node_idx, + message_key, + message_label, + **{"class": "active" if is_current else ""}, + ) + + +def visualize_neighborhood( + current: Optional[List[Event]], + event_sequences: List[List[Event]], + output_file: Optional[Text] = None, + max_history: int = 2, + nlu_training_data: Optional["TrainingData"] = None, + should_merge_nodes: bool = True, + max_distance: int = 1, + fontsize: int = 12, +) -> "networkx.MultiDiGraph": + """Given a set of event lists, visualizing the flows.""" + graph = _create_graph(fontsize) + _add_default_nodes(graph) + + next_node_idx = START_NODE_ID + special_node_idx = -3 + path_ellipsis_ends = set() + + for events in event_sequences: + if current and max_distance: + prefix = _length_of_common_action_prefix(current, events) + else: + prefix = len(events) + + message = None + current_node = START_NODE_ID + idx = 0 + is_current = events == current + + for idx, el in enumerate(events): + if not prefix: + idx -= 1 + break + if isinstance(el, UserUttered): + message = el.parse_data + message[TEXT] = f"{INTENT_MESSAGE_PREFIX}{el.intent_name}" # type: ignore[literal-required] # noqa: E501 + elif ( + isinstance(el, ActionExecuted) and el.action_name != ACTION_LISTEN_NAME + ): + next_node_idx += 1 + graph.add_node( + next_node_idx, + label=el.action_name, + fontsize=fontsize, + **{"class": "active" if is_current else ""}, + ) + + _add_message_edge( + graph, message, current_node, next_node_idx, is_current + ) + current_node = next_node_idx + + message = None + prefix -= 1 + + # determine what the end node of the conversation is going to be + # this can either be an ellipsis "...", the conversation end node + # "END" or a "TMP" node if this is the active conversation + if is_current: + event_idx = events[idx] + if ( + isinstance(event_idx, ActionExecuted) + and event_idx.action_name == ACTION_LISTEN_NAME + ): + next_node_idx += 1 + if message is None: + label = " ? " + else: + intent = cast(dict, message).get("intent", {}) + label = intent.get("name", " ? ") + graph.add_node( + next_node_idx, + label=label, + shape="rect", + **{"class": "intent dashed active"}, + ) + target = next_node_idx + elif current_node: + d = graph.nodes(data=True)[current_node] + d["class"] = "dashed active" + target = TMP_NODE_ID + else: + target = TMP_NODE_ID + elif idx == len(events) - 1: + target = END_NODE_ID + elif current_node and current_node not in path_ellipsis_ends: + graph.add_node(special_node_idx, label="...", **{"class": "ellipsis"}) + target = special_node_idx + path_ellipsis_ends.add(current_node) + special_node_idx -= 1 + else: + target = END_NODE_ID + + _add_message_edge(graph, message, current_node, target, is_current) + + if should_merge_nodes: + _merge_equivalent_nodes(graph, max_history) + _replace_edge_labels_with_nodes(graph, next_node_idx, nlu_training_data) + + _remove_auxiliary_nodes(graph, special_node_idx) + + if output_file: + persist_graph(graph, output_file) + return graph + + +def _remove_auxiliary_nodes( + graph: "networkx.MultiDiGraph", special_node_idx: int +) -> None: + """Remove any temporary or unused nodes.""" + + graph.remove_node(TMP_NODE_ID) + + if not len(list(graph.predecessors(END_NODE_ID))): + graph.remove_node(END_NODE_ID) + + # remove duplicated "..." nodes after merging + ps = set() + for i in range(special_node_idx + 1, TMP_NODE_ID): + for pred in list(graph.predecessors(i)): + if pred in ps: + graph.remove_node(i) + else: + ps.add(pred) + + +def visualize_stories( + story_steps: List[StoryStep], + domain: Domain, + output_file: Optional[Text], + max_history: int, + nlu_training_data: Optional["TrainingData"] = None, + should_merge_nodes: bool = True, + fontsize: int = 12, +) -> "networkx.MultiDiGraph": + """Given a set of stories, generates a graph visualizing the flows in the stories. + + Visualization is always a trade off between making the graph as small as + possible while + at the same time making sure the meaning doesn't change to "much". The + algorithm will + compress the graph generated from the stories to merge nodes that are + similar. Hence, + the algorithm might create paths through the graph that aren't actually + specified in the + stories, but we try to minimize that. + + Output file defines if and where a file containing the plotted graph + should be stored. + + The history defines how much 'memory' the graph has. This influences in + which situations the + algorithm will merge nodes. Nodes will only be merged if they are equal + within the history, this + means the larger the history is we take into account the less likely it + is we merge any nodes. + + The training data parameter can be used to pass in a Rasa NLU training + data instance. It will + be used to replace the user messages from the story file with actual + messages from the training data. + """ + story_graph = StoryGraph(story_steps) + + g = TrainingDataGenerator( + story_graph, + domain, + use_story_concatenation=False, + tracker_limit=100, + augmentation_factor=0, + ) + completed_trackers = g.generate() + event_sequences = [t.events for t in completed_trackers] + + graph = visualize_neighborhood( + None, + event_sequences, + output_file, + max_history, + nlu_training_data, + should_merge_nodes, + max_distance=1, + fontsize=fontsize, + ) + return graph diff --git a/rasa/shared/data.py b/rasa/shared/data.py new file mode 100644 index 0000000..e21675e --- /dev/null +++ b/rasa/shared/data.py @@ -0,0 +1,192 @@ +import os +import shutil +import tempfile +import uuid +from enum import Enum +from pathlib import Path +from typing import Text, Optional, Union, List, Callable, Set, Iterable + +YAML_FILE_EXTENSIONS = [".yml", ".yaml"] +JSON_FILE_EXTENSIONS = [".json"] +TRAINING_DATA_EXTENSIONS = set(JSON_FILE_EXTENSIONS + YAML_FILE_EXTENSIONS) + + +def yaml_file_extension() -> Text: + """Return YAML file extension.""" + return YAML_FILE_EXTENSIONS[0] + + +def is_likely_yaml_file(file_path: Union[Text, Path]) -> bool: + """Check if a file likely contains yaml. + + Arguments: + file_path: path to the file + + Returns: + `True` if the file likely contains data in yaml format, `False` otherwise. + """ + return Path(file_path).suffix in set(YAML_FILE_EXTENSIONS) + + +def is_likely_json_file(file_path: Text) -> bool: + """Check if a file likely contains json. + + Arguments: + file_path: path to the file + + Returns: + `True` if the file likely contains data in json format, `False` otherwise. + """ + return Path(file_path).suffix in set(JSON_FILE_EXTENSIONS) + + +def get_core_directory(paths: Optional[Union[Text, List[Text]]]) -> Text: + """Recursively collects all Core training files from a list of paths. + + Args: + paths: List of paths to training files or folders containing them. + + Returns: + Path to temporary directory containing all found Core training files. + """ + from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, + ) + + core_files = get_data_files(paths, YAMLStoryReader.is_stories_file) + return _copy_files_to_new_dir(core_files) + + +def get_nlu_directory(paths: Optional[Union[Text, List[Text]]]) -> Text: + """Recursively collects all NLU training files from a list of paths. + + Args: + paths: List of paths to training files or folders containing them. + + Returns: + Path to temporary directory containing all found NLU training files. + """ + nlu_files = get_data_files(paths, is_nlu_file) + return _copy_files_to_new_dir(nlu_files) + + +def get_data_files( + paths: Optional[Union[Text, List[Text]]], filter_predicate: Callable[[Text], bool] +) -> List[Text]: + """Recursively collects all training files from a list of paths. + + Args: + paths: List of paths to training files or folders containing them. + filter_predicate: property to use when filtering the paths, e.g. `is_nlu_file`. + + Returns: + Paths of training data files. + """ + data_files = set() + + if paths is None: + paths = [] + elif isinstance(paths, str): + paths = [paths] + + for path in set(paths): + if not path: + continue + + if is_valid_filetype(path): + if filter_predicate(path): + data_files.add(os.path.abspath(path)) + else: + new_data_files = _find_data_files_in_directory(path, filter_predicate) + data_files.update(new_data_files) + + return sorted(data_files) + + +def _find_data_files_in_directory( + directory: Text, filter_property: Callable[[Text], bool] +) -> Set[Text]: + filtered_files = set() + + for root, _, files in os.walk(directory, followlinks=True): + # we sort the files here to ensure consistent order for repeatable training + # results + for f in sorted(files): + full_path = os.path.join(root, f) + + if not is_valid_filetype(full_path): + continue + + if filter_property(full_path): + filtered_files.add(full_path) + + return filtered_files + + +def is_valid_filetype(path: Union[Path, Text]) -> bool: + """Checks if given file has a supported extension. + + Args: + path: Path to the source file. + + Returns: + `True` is given file has supported extension, `False` otherwise. + """ + return Path(path).is_file() and Path(path).suffix in TRAINING_DATA_EXTENSIONS + + +def is_nlu_file(file_path: Text) -> bool: + """Checks if a file is a Rasa compatible nlu file. + + Args: + file_path: Path of the file which should be checked. + + Returns: + `True` if it's a nlu file, otherwise `False`. + """ + from rasa.shared.nlu.training_data import loading as nlu_loading + + return nlu_loading.guess_format(file_path) != nlu_loading.UNK + + +def is_config_file(file_path: Text) -> bool: + """Checks whether the given file path is a Rasa config file. + + Args: + file_path: Path of the file which should be checked. + + Returns: + `True` if it's a Rasa config file, otherwise `False`. + """ + file_name = os.path.basename(file_path) + + return file_name in ["config.yml", "config.yaml"] + + +def _copy_files_to_new_dir(files: Iterable[Text]) -> Text: + directory = tempfile.mkdtemp() + for f in files: + # makes sure files do not overwrite each other, hence the prefix + unique_prefix = uuid.uuid4().hex + unique_file_name = unique_prefix + "_" + os.path.basename(f) + shutil.copy2(f, os.path.join(directory, unique_file_name)) + + return directory + + +class TrainingType(Enum): + """Enum class for defining explicitly what training types exist.""" + + NLU = 1 + CORE = 2 + BOTH = 3 + END_TO_END = 4 + + @property + def model_type(self) -> Text: + """Returns the type of model which this training yields.""" + if self == TrainingType.NLU: + return "nlu" + if self == TrainingType.CORE: + return "core" + return "rasa" diff --git a/rasa/shared/exceptions.py b/rasa/shared/exceptions.py new file mode 100644 index 0000000..3150a0b --- /dev/null +++ b/rasa/shared/exceptions.py @@ -0,0 +1,118 @@ +import json +from typing import Optional, Text + +import jsonschema +from ruamel.yaml.error import ( + MarkedYAMLError, + MarkedYAMLWarning, + MarkedYAMLFutureWarning, +) + + +class RasaException(Exception): + """Base exception class for all errors raised by Rasa Open Source. + + These exceptions result from invalid use cases and will be reported + to the users, but will be ignored in telemetry. + """ + + +class RasaCoreException(RasaException): + """Basic exception for errors raised by Rasa Core.""" + + +class InvalidParameterException(RasaException, ValueError): + """Raised when an invalid parameter is used.""" + + +class YamlException(RasaException): + """Raised if there is an error reading yaml.""" + + def __init__(self, filename: Optional[Text] = None) -> None: + """Create exception. + + Args: + filename: optional file the error occurred in""" + self.filename = filename + + +class YamlSyntaxException(YamlException): + """Raised when a YAML file can not be parsed properly due to a syntax error.""" + + def __init__( + self, + filename: Optional[Text] = None, + underlying_yaml_exception: Optional[Exception] = None, + ) -> None: + super(YamlSyntaxException, self).__init__(filename) + + self.underlying_yaml_exception = underlying_yaml_exception + + def __str__(self) -> Text: + if self.filename: + exception_text = f"Failed to read '{self.filename}'." + else: + exception_text = "Failed to read YAML." + + if self.underlying_yaml_exception: + if isinstance( + self.underlying_yaml_exception, + (MarkedYAMLError, MarkedYAMLWarning, MarkedYAMLFutureWarning), + ): + self.underlying_yaml_exception.note = None + if isinstance( + self.underlying_yaml_exception, + (MarkedYAMLWarning, MarkedYAMLFutureWarning), + ): + self.underlying_yaml_exception.warn = None + exception_text += f" {self.underlying_yaml_exception}" + + if self.filename: + exception_text = exception_text.replace( + 'in ""', f'in "{self.filename}"' + ) + + exception_text += ( + "\n\nYou can use https://yamlchecker.com/ to validate the " + "YAML syntax of your file." + ) + return exception_text + + +class FileNotFoundException(RasaException, FileNotFoundError): + """Raised when a file, expected to exist, doesn't exist.""" + + +class FileIOException(RasaException): + """Raised if there is an error while doing file IO.""" + + +class InvalidConfigException(ValueError, RasaException): + """Raised if an invalid configuration is encountered.""" + + +class UnsupportedFeatureException(RasaCoreException): + """Raised if a requested feature is not supported.""" + + +class SchemaValidationError(RasaException, jsonschema.ValidationError): + """Raised if schema validation via `jsonschema` failed.""" + + +class InvalidEntityFormatException(RasaException, json.JSONDecodeError): + """Raised if the format of an entity is invalid.""" + + @classmethod + def create_from( + cls, other: json.JSONDecodeError, msg: Text + ) -> "InvalidEntityFormatException": + """Creates `InvalidEntityFormatException` from `JSONDecodeError`.""" + return cls(msg, other.doc, other.pos) + + +class ConnectionException(RasaException): + """Raised when a connection to a 3rd party service fails. + + It's used by our broker and tracker store classes, when + they can't connect to services like postgres, dynamoDB, mongo. + """ diff --git a/rasa/shared/importers/__init__.py b/rasa/shared/importers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/shared/importers/importer.py b/rasa/shared/importers/importer.py new file mode 100644 index 0000000..6a181a5 --- /dev/null +++ b/rasa/shared/importers/importer.py @@ -0,0 +1,591 @@ +from abc import ABC, abstractmethod +from functools import reduce +from typing import Text, Optional, List, Dict, Set, Any, Tuple, Type, Union, cast +import logging + +import rasa.shared.constants +import rasa.shared.utils.common +import rasa.shared.core.constants +import rasa.shared.utils.io +from rasa.shared.core.domain import ( + Domain, + KEY_E2E_ACTIONS, + KEY_INTENTS, + KEY_RESPONSES, + KEY_ACTIONS, +) +from rasa.shared.core.events import ActionExecuted, UserUttered +from rasa.shared.core.training_data.structures import StoryGraph +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.constants import ENTITIES, ACTION_NAME +from rasa.shared.core.domain import IS_RETRIEVAL_INTENT_KEY + +logger = logging.getLogger(__name__) + + +class TrainingDataImporter(ABC): + """Common interface for different mechanisms to load training data.""" + + @abstractmethod + def __init__( + self, + config_file: Optional[Text] = None, + domain_path: Optional[Text] = None, + training_data_paths: Optional[Union[List[Text], Text]] = None, + **kwargs: Any, + ) -> None: + """Initialise the importer.""" + ... + + @abstractmethod + def get_domain(self) -> Domain: + """Retrieves the domain of the bot. + + Returns: + Loaded `Domain`. + """ + ... + + @abstractmethod + def get_stories(self, exclusion_percentage: Optional[int] = None) -> StoryGraph: + """Retrieves the stories that should be used for training. + + Args: + exclusion_percentage: Amount of training data that should be excluded. + + Returns: + `StoryGraph` containing all loaded stories. + """ + ... + + def get_conversation_tests(self) -> StoryGraph: + """Retrieves end-to-end conversation stories for testing. + + Returns: + `StoryGraph` containing all loaded stories. + """ + return self.get_stories() + + @abstractmethod + def get_config(self) -> Dict: + """Retrieves the configuration that should be used for the training. + + Returns: + The configuration as dictionary. + """ + ... + + @abstractmethod + def get_config_file_for_auto_config(self) -> Optional[Text]: + """Returns config file path for auto-config only if there is a single one.""" + ... + + @abstractmethod + def get_nlu_data(self, language: Optional[Text] = "en") -> TrainingData: + """Retrieves the NLU training data that should be used for training. + + Args: + language: Can be used to only load training data for a certain language. + + Returns: + Loaded NLU `TrainingData`. + """ + ... + + @staticmethod + def load_from_config( + config_path: Text, + domain_path: Optional[Text] = None, + training_data_paths: Optional[List[Text]] = None, + args: Optional[Dict[Text, Any]] = {}, + ) -> "TrainingDataImporter": + """Loads a `TrainingDataImporter` instance from a configuration file.""" + config = rasa.shared.utils.io.read_config_file(config_path) + return TrainingDataImporter.load_from_dict( + config, config_path, domain_path, training_data_paths, args + ) + + @staticmethod + def load_core_importer_from_config( + config_path: Text, + domain_path: Optional[Text] = None, + training_data_paths: Optional[List[Text]] = None, + args: Optional[Dict[Text, Any]] = {}, + ) -> "TrainingDataImporter": + """Loads core `TrainingDataImporter` instance. + + Instance loaded from configuration file will only read Core training data. + """ + importer = TrainingDataImporter.load_from_config( + config_path, domain_path, training_data_paths, args + ) + return importer + + @staticmethod + def load_nlu_importer_from_config( + config_path: Text, + domain_path: Optional[Text] = None, + training_data_paths: Optional[List[Text]] = None, + args: Optional[Dict[Text, Any]] = {}, + ) -> "TrainingDataImporter": + """Loads nlu `TrainingDataImporter` instance. + + Instance loaded from configuration file will only read NLU training data. + """ + importer = TrainingDataImporter.load_from_config( + config_path, domain_path, training_data_paths, args + ) + + if isinstance(importer, E2EImporter): + # When we only train NLU then there is no need to enrich the data with + # E2E data from Core training data. + importer = importer.importer + + return NluDataImporter(importer) + + @staticmethod + def load_from_dict( + config: Optional[Dict] = None, + config_path: Optional[Text] = None, + domain_path: Optional[Text] = None, + training_data_paths: Optional[List[Text]] = None, + args: Optional[Dict[Text, Any]] = {}, + ) -> "TrainingDataImporter": + """Loads a `TrainingDataImporter` instance from a dictionary.""" + from rasa.shared.importers.rasa import RasaFileImporter + + config = config or {} + importers = config.get("importers", []) + importers = [ + TrainingDataImporter._importer_from_dict( + importer, config_path, domain_path, training_data_paths, args + ) + for importer in importers + ] + importers = [importer for importer in importers if importer] + if not importers: + importers = [ + RasaFileImporter(config_path, domain_path, training_data_paths) + ] + + return E2EImporter(ResponsesSyncImporter(CombinedDataImporter(importers))) + + @staticmethod + def _importer_from_dict( + importer_config: Dict, + config_path: Text, + domain_path: Optional[Text] = None, + training_data_paths: Optional[List[Text]] = None, + args: Optional[Dict[Text, Any]] = {}, + ) -> Optional["TrainingDataImporter"]: + from rasa.shared.importers.multi_project import MultiProjectImporter + from rasa.shared.importers.rasa import RasaFileImporter + + module_path = importer_config.pop("name", None) + if module_path == RasaFileImporter.__name__: + importer_class: Type[TrainingDataImporter] = RasaFileImporter + elif module_path == MultiProjectImporter.__name__: + importer_class = MultiProjectImporter + else: + try: + importer_class = rasa.shared.utils.common.class_from_module_path( + module_path + ) + except (AttributeError, ImportError): + logging.warning(f"Importer '{module_path}' not found.") + return None + + constructor_arguments = rasa.shared.utils.common.minimal_kwargs( + {**importer_config, **(args or {})}, importer_class + ) + + return importer_class( + config_path, + domain_path, + training_data_paths, + **constructor_arguments, + ) + + def fingerprint(self) -> Text: + """Returns a random fingerprint as data shouldn't be cached.""" + return rasa.shared.utils.io.random_string(25) + + def __repr__(self) -> Text: + """Returns text representation of object.""" + return self.__class__.__name__ + + +class NluDataImporter(TrainingDataImporter): + """Importer that skips any Core-related file reading.""" + + def __init__(self, actual_importer: TrainingDataImporter): + """Initializes the NLUDataImporter.""" + self._importer = actual_importer + + def get_domain(self) -> Domain: + """Retrieves model domain (see parent class for full docstring).""" + return Domain.empty() + + def get_stories(self, exclusion_percentage: Optional[int] = None) -> StoryGraph: + """Retrieves training stories / rules (see parent class for full docstring).""" + return StoryGraph([]) + + def get_conversation_tests(self) -> StoryGraph: + """Retrieves conversation test stories (see parent class for full docstring).""" + return StoryGraph([]) + + def get_config(self) -> Dict: + """Retrieves model config (see parent class for full docstring).""" + return self._importer.get_config() + + def get_nlu_data(self, language: Optional[Text] = "en") -> TrainingData: + """Retrieves NLU training data (see parent class for full docstring).""" + return self._importer.get_nlu_data(language) + + @rasa.shared.utils.common.cached_method + def get_config_file_for_auto_config(self) -> Optional[Text]: + """Returns config file path for auto-config only if there is a single one.""" + return self._importer.get_config_file_for_auto_config() + + +class CombinedDataImporter(TrainingDataImporter): + """A `TrainingDataImporter` that combines multiple importers. + + Uses multiple `TrainingDataImporter` instances + to load the data as if they were a single instance. + """ + + def __init__(self, importers: List[TrainingDataImporter]): + self._importers = importers + + @rasa.shared.utils.common.cached_method + def get_config(self) -> Dict: + """Retrieves model config (see parent class for full docstring).""" + configs = [importer.get_config() for importer in self._importers] + + return reduce(lambda merged, other: {**merged, **(other or {})}, configs, {}) + + @rasa.shared.utils.common.cached_method + def get_domain(self) -> Domain: + """Retrieves model domain (see parent class for full docstring).""" + domains = [importer.get_domain() for importer in self._importers] + + return reduce( + lambda merged, other: merged.merge(other), + domains, + Domain.empty(), + ) + + @rasa.shared.utils.common.cached_method + def get_stories(self, exclusion_percentage: Optional[int] = None) -> StoryGraph: + """Retrieves training stories / rules (see parent class for full docstring).""" + stories = [ + importer.get_stories(exclusion_percentage) for importer in self._importers + ] + + return reduce( + lambda merged, other: merged.merge(other), stories, StoryGraph([]) + ) + + @rasa.shared.utils.common.cached_method + def get_conversation_tests(self) -> StoryGraph: + """Retrieves conversation test stories (see parent class for full docstring).""" + stories = [importer.get_conversation_tests() for importer in self._importers] + + return reduce( + lambda merged, other: merged.merge(other), stories, StoryGraph([]) + ) + + @rasa.shared.utils.common.cached_method + def get_nlu_data(self, language: Optional[Text] = "en") -> TrainingData: + """Retrieves NLU training data (see parent class for full docstring).""" + nlu_data = [importer.get_nlu_data(language) for importer in self._importers] + + return reduce( + lambda merged, other: merged.merge(other), nlu_data, TrainingData() + ) + + @rasa.shared.utils.common.cached_method + def get_config_file_for_auto_config(self) -> Optional[Text]: + """Returns config file path for auto-config only if there is a single one.""" + if len(self._importers) != 1: + rasa.shared.utils.io.raise_warning( + "Auto-config for multiple importers is not supported; " + "using config as is." + ) + return None + return self._importers[0].get_config_file_for_auto_config() + + +class ResponsesSyncImporter(TrainingDataImporter): + """Importer that syncs `responses` between Domain and NLU training data. + + Synchronizes responses between Domain and NLU and + adds retrieval intent properties from the NLU training data + back to the Domain. + """ + + def __init__(self, importer: TrainingDataImporter): + """Initializes the ResponsesSyncImporter.""" + self._importer = importer + + def get_config(self) -> Dict: + """Retrieves model config (see parent class for full docstring).""" + return self._importer.get_config() + + @rasa.shared.utils.common.cached_method + def get_config_file_for_auto_config(self) -> Optional[Text]: + """Returns config file path for auto-config only if there is a single one.""" + return self._importer.get_config_file_for_auto_config() + + @rasa.shared.utils.common.cached_method + def get_domain(self) -> Domain: + """Merge existing domain with properties of retrieval intents in NLU data.""" + existing_domain = self._importer.get_domain() + existing_nlu_data = self._importer.get_nlu_data() + + # Merge responses from NLU data with responses in the domain. + # If NLU data has any retrieval intents, then add corresponding + # retrieval actions with `utter_` prefix automatically to the + # final domain, update the properties of existing retrieval intents. + domain_with_retrieval_intents = self._get_domain_with_retrieval_intents( + existing_nlu_data.retrieval_intents, + existing_nlu_data.responses, + existing_domain, + ) + + existing_domain = existing_domain.merge( + domain_with_retrieval_intents, override=True + ) + existing_domain.check_missing_responses() + + return existing_domain + + @staticmethod + def _construct_retrieval_action_names(retrieval_intents: Set[Text]) -> List[Text]: + """Lists names of all retrieval actions related to passed retrieval intents. + + Args: + retrieval_intents: List of retrieval intents defined in the NLU training + data. + + Returns: Names of corresponding retrieval actions + """ + return [ + f"{rasa.shared.constants.UTTER_PREFIX}{intent}" + for intent in retrieval_intents + ] + + @staticmethod + def _get_domain_with_retrieval_intents( + retrieval_intents: Set[Text], + responses: Dict[Text, List[Dict[Text, Any]]], + existing_domain: Domain, + ) -> Domain: + """Construct a domain consisting of retrieval intents. + + The result domain will have retrieval intents that are listed + in the NLU training data. + + Args: + retrieval_intents: Set of retrieval intents defined in NLU training data. + responses: Responses defined in NLU training data. + existing_domain: Domain which is already loaded from the domain file. + + Returns: Domain with retrieval actions added to action names and properties + for retrieval intents updated. + """ + # Get all the properties already defined + # for each retrieval intent in other domains + # and add the retrieval intent property to them + retrieval_intent_properties = [] + for intent in retrieval_intents: + intent_properties = ( + existing_domain.intent_properties[intent] + if intent in existing_domain.intent_properties + else {} + ) + intent_properties[IS_RETRIEVAL_INTENT_KEY] = True + retrieval_intent_properties.append({intent: intent_properties}) + + action_names = ResponsesSyncImporter._construct_retrieval_action_names( + retrieval_intents + ) + + return Domain.from_dict( + { + KEY_INTENTS: retrieval_intent_properties, + KEY_RESPONSES: responses, + KEY_ACTIONS: action_names, + } + ) + + def get_stories(self, exclusion_percentage: Optional[int] = None) -> StoryGraph: + """Retrieves training stories / rules (see parent class for full docstring).""" + return self._importer.get_stories(exclusion_percentage) + + def get_conversation_tests(self) -> StoryGraph: + """Retrieves conversation test stories (see parent class for full docstring).""" + return self._importer.get_conversation_tests() + + @rasa.shared.utils.common.cached_method + def get_nlu_data(self, language: Optional[Text] = "en") -> TrainingData: + """Updates NLU data with responses for retrieval intents from domain.""" + existing_nlu_data = self._importer.get_nlu_data(language) + existing_domain = self._importer.get_domain() + + return existing_nlu_data.merge( + self._get_nlu_data_with_responses( + existing_domain.retrieval_intent_responses + ) + ) + + @staticmethod + def _get_nlu_data_with_responses( + responses: Dict[Text, List[Dict[Text, Any]]] + ) -> TrainingData: + """Construct training data object with only the responses supplied. + + Args: + responses: Responses the NLU data should + be initialized with. + + Returns: TrainingData object with responses. + + """ + return TrainingData(responses=responses) + + +class E2EImporter(TrainingDataImporter): + """Importer with the following functionality. + + - enhances the NLU training data with actions / user messages from the stories. + - adds potential end-to-end bot messages from stories as actions to the domain + """ + + def __init__(self, importer: TrainingDataImporter) -> None: + """Initializes the E2EImporter.""" + self.importer = importer + + @rasa.shared.utils.common.cached_method + def get_domain(self) -> Domain: + """Retrieves model domain (see parent class for full docstring).""" + original = self.importer.get_domain() + e2e_domain = self._get_domain_with_e2e_actions() + + return original.merge(e2e_domain) + + def _get_domain_with_e2e_actions(self) -> Domain: + + stories = self.get_stories() + + additional_e2e_action_names = set() + for story_step in stories.story_steps: + additional_e2e_action_names.update( + { + event.action_text + for event in story_step.events + if isinstance(event, ActionExecuted) and event.action_text + } + ) + + return Domain.from_dict({KEY_E2E_ACTIONS: list(additional_e2e_action_names)}) + + def get_stories(self, exclusion_percentage: Optional[int] = None) -> StoryGraph: + """Retrieves the stories that should be used for training. + + See parent class for details. + """ + return self.importer.get_stories(exclusion_percentage) + + def get_conversation_tests(self) -> StoryGraph: + """Retrieves conversation test stories (see parent class for full docstring).""" + return self.importer.get_conversation_tests() + + def get_config(self) -> Dict: + """Retrieves model config (see parent class for full docstring).""" + return self.importer.get_config() + + @rasa.shared.utils.common.cached_method + def get_config_file_for_auto_config(self) -> Optional[Text]: + """Returns config file path for auto-config only if there is a single one.""" + return self.importer.get_config_file_for_auto_config() + + @rasa.shared.utils.common.cached_method + def get_nlu_data(self, language: Optional[Text] = "en") -> TrainingData: + """Retrieves NLU training data (see parent class for full docstring).""" + training_datasets = [ + _additional_training_data_from_default_actions(), + self.importer.get_nlu_data(language), + self._additional_training_data_from_stories(), + ] + + return reduce( + lambda merged, other: merged.merge(other), training_datasets, TrainingData() + ) + + def _additional_training_data_from_stories(self) -> TrainingData: + stories = self.get_stories() + + utterances, actions = _unique_events_from_stories(stories) + + # Sort events to guarantee deterministic behavior and to avoid that the NLU + # model has to be retrained due to changes in the event order within + # the stories. + sorted_utterances = sorted( + utterances, key=lambda user: user.intent_name or user.text or "" + ) + sorted_actions = sorted( + actions, key=lambda action: action.action_name or action.action_text or "" + ) + + additional_messages_from_stories = [ + _messages_from_action(action) for action in sorted_actions + ] + [_messages_from_user_utterance(user) for user in sorted_utterances] + + logger.debug( + f"Added {len(additional_messages_from_stories)} training data examples " + f"from the story training data." + ) + return TrainingData(additional_messages_from_stories) + + +def _unique_events_from_stories( + stories: StoryGraph, +) -> Tuple[Set[UserUttered], Set[ActionExecuted]]: + action_events = set() + user_events = set() + + for story_step in stories.story_steps: + for event in story_step.events: + if isinstance(event, ActionExecuted): + action_events.add(event) + elif isinstance(event, UserUttered): + user_events.add(event) + + return user_events, action_events + + +def _messages_from_user_utterance(event: UserUttered) -> Message: + # sub state correctly encodes intent vs text + data = cast(Dict[Text, Any], event.as_sub_state()) + # sub state stores entities differently + if data.get(ENTITIES) and event.entities: + data[ENTITIES] = event.entities + + return Message(data=data) + + +def _messages_from_action(event: ActionExecuted) -> Message: + # sub state correctly encodes action_name vs action_text + return Message(data=event.as_sub_state()) + + +def _additional_training_data_from_default_actions() -> TrainingData: + additional_messages_from_default_actions = [ + Message(data={ACTION_NAME: action_name}) + for action_name in rasa.shared.core.constants.DEFAULT_ACTION_NAMES + ] + + return TrainingData(additional_messages_from_default_actions) diff --git a/rasa/shared/importers/multi_project.py b/rasa/shared/importers/multi_project.py new file mode 100644 index 0000000..7b5f443 --- /dev/null +++ b/rasa/shared/importers/multi_project.py @@ -0,0 +1,203 @@ +import logging +from functools import reduce +from typing import Text, Set, Dict, Optional, List, Union, Any +import os + +import rasa.shared.data +import rasa.shared.utils.io +from rasa.shared.core.domain import Domain +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.importers import utils +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.core.training_data.structures import StoryGraph +from rasa.shared.utils.common import mark_as_experimental_feature +from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, +) + +logger = logging.getLogger(__name__) + + +class MultiProjectImporter(TrainingDataImporter): + def __init__( + self, + config_file: Text, + domain_path: Optional[Text] = None, + training_data_paths: Optional[Union[List[Text], Text]] = None, + project_directory: Optional[Text] = None, + ): + self.config = rasa.shared.utils.io.read_model_configuration(config_file) + if domain_path: + self._domain_paths = [domain_path] + else: + self._domain_paths = [] + self._story_paths = [] + self._e2e_story_paths: List[Text] = [] + self._nlu_paths = [] + self._imports: List[Text] = [] + self._additional_paths = training_data_paths or [] + self._project_directory = project_directory or os.path.dirname(config_file) + + self._init_from_dict(self.config, self._project_directory) + + extra_nlu_files = rasa.shared.data.get_data_files( + training_data_paths, rasa.shared.data.is_nlu_file + ) + extra_story_files = rasa.shared.data.get_data_files( + training_data_paths, YAMLStoryReader.is_stories_file + ) + self._story_paths += extra_story_files + self._nlu_paths += extra_nlu_files + + logger.debug( + "Selected projects: {}".format("".join([f"\n-{i}" for i in self._imports])) + ) + + mark_as_experimental_feature(feature_name="MultiProjectImporter") + + def get_config_file_for_auto_config(self) -> Optional[Text]: + """Returns config file path for auto-config only if there is a single one.""" + return None + + def _init_from_path(self, path: Text) -> None: + if os.path.isfile(path): + self._init_from_file(path) + elif os.path.isdir(path): + self._init_from_directory(path) + + def _init_from_file(self, path: Text) -> None: + path = os.path.abspath(path) + if os.path.exists(path) and rasa.shared.data.is_config_file(path): + config = rasa.shared.utils.io.read_config_file(path) + + parent_directory = os.path.dirname(path) + self._init_from_dict(config, parent_directory) + else: + rasa.shared.utils.io.raise_warning( + f"'{path}' does not exist or is not a valid config file." + ) + + def _init_from_dict(self, _dict: Dict[Text, Any], parent_directory: Text) -> None: + imports = _dict.get("imports") or [] + imports = [os.path.join(parent_directory, i) for i in imports] + # clean out relative paths + imports = [os.path.abspath(i) for i in imports] + + # remove duplication + import_candidates = [] + for i in imports: + if i not in import_candidates and not self._is_explicitly_imported(i): + import_candidates.append(i) + + self._imports.extend(import_candidates) + + # import config files from paths which have not been processed so far + for p in import_candidates: + self._init_from_path(p) + + def _is_explicitly_imported(self, path: Text) -> bool: + return not self.no_skills_selected() and self.is_imported(path) + + def _init_from_directory(self, path: Text) -> None: + for parent, _, files in os.walk(path, followlinks=True): + for file in files: + full_path = os.path.join(parent, file) + if not self.is_imported(full_path): + # Check next file + continue + + if YAMLStoryReader.is_test_stories_file(full_path): + self._e2e_story_paths.append(full_path) + elif Domain.is_domain_file(full_path): + self._domain_paths.append(full_path) + elif rasa.shared.data.is_nlu_file(full_path): + self._nlu_paths.append(full_path) + elif YAMLStoryReader.is_stories_file(full_path): + self._story_paths.append(full_path) + elif rasa.shared.data.is_config_file(full_path): + self._init_from_file(full_path) + + def no_skills_selected(self) -> bool: + return not self._imports + + def training_paths(self) -> Set[Text]: + """Returns the paths which should be searched for training data.""" + + # only include extra paths if they are not part of the current project directory + training_paths = { + i + for i in self._imports + if not self._project_directory or self._project_directory not in i + } + + if self._project_directory: + training_paths.add(self._project_directory) + + return training_paths + + def is_imported(self, path: Text) -> bool: + """ + Checks whether a path is imported by a skill. + Args: + path: File or directory path which should be checked. + + Returns: + `True` if path is imported by a skill, `False` if not. + """ + absolute_path = os.path.abspath(path) + + return ( + self.no_skills_selected() + or self._is_in_project_directory(absolute_path) + or self._is_in_additional_paths(absolute_path) + or self._is_in_imported_paths(absolute_path) + ) + + def _is_in_project_directory(self, path: Text) -> bool: + if os.path.isfile(path): + parent_directory = os.path.abspath(os.path.dirname(path)) + + return parent_directory == self._project_directory + else: + return path == self._project_directory + + def _is_in_additional_paths(self, path: Text) -> bool: + included = path in self._additional_paths + + if not included and os.path.isfile(path): + parent_directory = os.path.abspath(os.path.dirname(path)) + included = parent_directory in self._additional_paths + + return included + + def _is_in_imported_paths(self, path: Text) -> bool: + return any( + [rasa.shared.utils.io.is_subdirectory(path, i) for i in self._imports] + ) + + def get_domain(self) -> Domain: + """Retrieves model domain (see parent class for full docstring).""" + domains = [Domain.load(path) for path in self._domain_paths] + return reduce( + lambda merged, other: merged.merge(other), + domains, + Domain.empty(), + ) + + def get_stories(self, exclusion_percentage: Optional[int] = None) -> StoryGraph: + """Retrieves training stories / rules (see parent class for full docstring).""" + return utils.story_graph_from_paths( + self._story_paths, self.get_domain(), exclusion_percentage + ) + + def get_conversation_tests(self) -> StoryGraph: + """Retrieves conversation test stories (see parent class for full docstring).""" + return utils.story_graph_from_paths(self._e2e_story_paths, self.get_domain()) + + def get_config(self) -> Dict: + """Retrieves model config (see parent class for full docstring).""" + return self.config + + def get_nlu_data(self, language: Optional[Text] = "en") -> TrainingData: + """Retrieves NLU training data (see parent class for full docstring).""" + return utils.training_data_from_paths(self._nlu_paths, language) diff --git a/rasa/shared/importers/rasa.py b/rasa/shared/importers/rasa.py new file mode 100644 index 0000000..53ba35b --- /dev/null +++ b/rasa/shared/importers/rasa.py @@ -0,0 +1,89 @@ +import logging +import os +from typing import Dict, List, Optional, Text, Union + +import rasa.shared.data +import rasa.shared.utils.common +import rasa.shared.utils.io +from rasa.shared.core.training_data.structures import StoryGraph +from rasa.shared.importers import utils +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.core.domain import InvalidDomain, Domain +from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, +) + +logger = logging.getLogger(__name__) + + +class RasaFileImporter(TrainingDataImporter): + """Default `TrainingFileImporter` implementation.""" + + def __init__( + self, + config_file: Optional[Text] = None, + domain_path: Optional[Text] = None, + training_data_paths: Optional[Union[List[Text], Text]] = None, + ): + + self._domain_path = domain_path + + self._nlu_files = rasa.shared.data.get_data_files( + training_data_paths, rasa.shared.data.is_nlu_file + ) + self._story_files = rasa.shared.data.get_data_files( + training_data_paths, YAMLStoryReader.is_stories_file + ) + self._conversation_test_files = rasa.shared.data.get_data_files( + training_data_paths, YAMLStoryReader.is_test_stories_file + ) + + self.config_file = config_file + + def get_config(self) -> Dict: + """Retrieves model config (see parent class for full docstring).""" + if not self.config_file or not os.path.exists(self.config_file): + logger.debug("No configuration file was provided to the RasaFileImporter.") + return {} + + config = rasa.shared.utils.io.read_model_configuration(self.config_file) + return config + + @rasa.shared.utils.common.cached_method + def get_config_file_for_auto_config(self) -> Optional[Text]: + """Returns config file path for auto-config only if there is a single one.""" + return self.config_file + + def get_stories(self, exclusion_percentage: Optional[int] = None) -> StoryGraph: + """Retrieves training stories / rules (see parent class for full docstring).""" + return utils.story_graph_from_paths( + self._story_files, self.get_domain(), exclusion_percentage + ) + + def get_conversation_tests(self) -> StoryGraph: + """Retrieves conversation test stories (see parent class for full docstring).""" + return utils.story_graph_from_paths( + self._conversation_test_files, self.get_domain() + ) + + def get_nlu_data(self, language: Optional[Text] = "en") -> TrainingData: + """Retrieves NLU training data (see parent class for full docstring).""" + return utils.training_data_from_paths(self._nlu_files, language) + + def get_domain(self) -> Domain: + """Retrieves model domain (see parent class for full docstring).""" + domain = Domain.empty() + + # If domain path is None, return an empty domain + if not self._domain_path: + return domain + try: + domain = Domain.load(self._domain_path) + except InvalidDomain as e: + rasa.shared.utils.io.raise_warning( + f"Loading domain from '{self._domain_path}' failed. Using " + f"empty domain. Error: '{e}'" + ) + + return domain diff --git a/rasa/shared/importers/utils.py b/rasa/shared/importers/utils.py new file mode 100644 index 0000000..40e5bb2 --- /dev/null +++ b/rasa/shared/importers/utils.py @@ -0,0 +1,22 @@ +from typing import Iterable, Text, Optional, List + +from rasa.shared.core.domain import Domain +from rasa.shared.core.training_data.structures import StoryGraph +from rasa.shared.nlu.training_data.training_data import TrainingData + + +def training_data_from_paths(paths: Iterable[Text], language: Text) -> TrainingData: + from rasa.shared.nlu.training_data import loading + + training_data_sets = [loading.load_data(nlu_file, language) for nlu_file in paths] + return TrainingData().merge(*training_data_sets) + + +def story_graph_from_paths( + files: List[Text], domain: Domain, exclusion_percentage: Optional[int] = None +) -> StoryGraph: + """Returns the `StoryGraph` from paths.""" + from rasa.shared.core.training_data import loading + + story_steps = loading.load_data_from_files(files, domain, exclusion_percentage) + return StoryGraph(story_steps) diff --git a/rasa/shared/nlu/__init__.py b/rasa/shared/nlu/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/shared/nlu/constants.py b/rasa/shared/nlu/constants.py new file mode 100644 index 0000000..1f0b9b8 --- /dev/null +++ b/rasa/shared/nlu/constants.py @@ -0,0 +1,42 @@ +TEXT = "text" +TEXT_TOKENS = "text_tokens" +INTENT = "intent" +NOT_INTENT = "not_intent" +RESPONSE = "response" +RESPONSE_SELECTOR = "response_selector" +INTENT_RESPONSE_KEY = "intent_response_key" +ACTION_TEXT = "action_text" +ACTION_NAME = "action_name" +INTENT_NAME_KEY = "name" +FULL_RETRIEVAL_INTENT_NAME_KEY = "full_retrieval_intent_name" +METADATA = "metadata" +METADATA_INTENT = "intent" +METADATA_EXAMPLE = "example" +METADATA_MODEL_ID = "model_id" +INTENT_RANKING_KEY = "intent_ranking" +PREDICTED_CONFIDENCE_KEY = "confidence" + +RESPONSE_IDENTIFIER_DELIMITER = "/" + +FEATURE_TYPE_SENTENCE = "sentence" +FEATURE_TYPE_SEQUENCE = "sequence" +VALID_FEATURE_TYPES = [FEATURE_TYPE_SEQUENCE, FEATURE_TYPE_SENTENCE] + +EXTRACTOR = "extractor" +PRETRAINED_EXTRACTORS = {"DucklingEntityExtractor", "SpacyEntityExtractor"} +TRAINABLE_EXTRACTORS = {"MitieEntityExtractor", "CRFEntityExtractor", "DIETClassifier"} + +ENTITIES = "entities" +ENTITY_TAGS = "entity_tags" +ENTITY_ATTRIBUTE_TYPE = "entity" +ENTITY_ATTRIBUTE_GROUP = "group" +ENTITY_ATTRIBUTE_ROLE = "role" +ENTITY_ATTRIBUTE_VALUE = "value" +ENTITY_ATTRIBUTE_START = "start" +ENTITY_ATTRIBUTE_END = "end" +ENTITY_ATTRIBUTE_TEXT = "text" +ENTITY_ATTRIBUTE_CONFIDENCE = "confidence" +NO_ENTITY_TAG = "O" +SPLIT_ENTITIES_BY_COMMA = "split_entities_by_comma" +SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE = True +SINGLE_ENTITY_ALLOWED_INTERLEAVING_CHARSET = {".", ",", " ", ";"} diff --git a/rasa/shared/nlu/interpreter.py b/rasa/shared/nlu/interpreter.py new file mode 100644 index 0000000..5f1acff --- /dev/null +++ b/rasa/shared/nlu/interpreter.py @@ -0,0 +1,10 @@ +class NaturalLanguageInterpreter: + """Remove once all old components are deleted.""" + + pass + + +class RegexInterpreter: + """Remove once all old components are deleted.""" + + pass diff --git a/rasa/shared/nlu/training_data/__init__.py b/rasa/shared/nlu/training_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/shared/nlu/training_data/entities_parser.py b/rasa/shared/nlu/training_data/entities_parser.py new file mode 100644 index 0000000..0b644c4 --- /dev/null +++ b/rasa/shared/nlu/training_data/entities_parser.py @@ -0,0 +1,209 @@ +import re +from json import JSONDecodeError +from typing import Text, List, Dict, Match, Optional, NamedTuple, Any +import logging + +import rasa.shared.nlu.training_data.util +from rasa.shared.constants import DOCS_URL_TRAINING_DATA_NLU +from rasa.shared.exceptions import InvalidEntityFormatException +from rasa.shared.nlu.constants import ( + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_GROUP, + ENTITY_ATTRIBUTE_ROLE, +) +from rasa.shared.nlu.training_data.message import Message + + +GROUP_ENTITY_VALUE = "value" +GROUP_ENTITY_TYPE = "entity" +GROUP_ENTITY_DICT = "entity_dict" +GROUP_ENTITY_DICT_LIST = "list_entity_dicts" +GROUP_ENTITY_TEXT = "entity_text" +GROUP_COMPLETE_MATCH = 0 + +# regex for: `[entity_text]((entity_type(:entity_synonym)?)|{entity_dict}|[list_entity_dicts])` # noqa: E501 +ENTITY_REGEX = re.compile( + r"\[(?P[^\]]+?)\](\((?P[^:)]+?)(?:\:(?P[^)]+))?\)|\{(?P[^}]+?)\}|\[(?P.*?)\])" # noqa: E501 +) + +SINGLE_ENTITY_DICT = re.compile(r"{(?P[^}]+?)\}") + +logger = logging.getLogger(__name__) + + +class EntityAttributes(NamedTuple): + """Attributes of an entity defined in markdown data.""" + + type: Text + value: Text + text: Text + group: Optional[Text] + role: Optional[Text] + + +def find_entities_in_training_example(example: Text) -> List[Dict[Text, Any]]: + """Extracts entities from an annotated utterance. + + Args: + example: Annotated utterance. + + Returns: + Extracted entities. + """ + entities = [] + offset = 0 + + for match in re.finditer(ENTITY_REGEX, example): + logger.debug(f"Entity annotation regex match: {match}") + if match.groupdict()[GROUP_ENTITY_DICT] or match.groupdict()[GROUP_ENTITY_TYPE]: + # Text is annotated with a single entity + entity_attributes = extract_entity_attributes(match) + + start_index = match.start() - offset + end_index = start_index + len(entity_attributes.text) + offset += len(match.group(0)) - len(entity_attributes.text) + + entity = rasa.shared.nlu.training_data.util.build_entity( + start_index, + end_index, + entity_attributes.value, + entity_attributes.type, + entity_attributes.role, + entity_attributes.group, + ) + entities.append(entity) + else: + # Text is annotated with multiple entities for the same text + entity_text = match.groupdict()[GROUP_ENTITY_TEXT] + + start_index = match.start() - offset + end_index = start_index + len(entity_text) + offset += len(match.group(0)) - len(entity_text) + + for match_inner in re.finditer( + SINGLE_ENTITY_DICT, match.groupdict()[GROUP_ENTITY_DICT_LIST] + ): + + entity_attributes = extract_entity_attributes_from_dict( + entity_text=entity_text, match=match_inner + ) + + entity = rasa.shared.nlu.training_data.util.build_entity( + start_index, + end_index, + entity_attributes.value, + entity_attributes.type, + entity_attributes.role, + entity_attributes.group, + ) + entities.append(entity) + + return entities + + +def extract_entity_attributes(match: Match) -> EntityAttributes: + """Extract the entity attributes, i.e. type, value, etc., from the + regex match. + + Args: + match: Regex match to extract the entity attributes from. + + Returns: + EntityAttributes object. + """ + entity_text = match.groupdict()[GROUP_ENTITY_TEXT] + + if match.groupdict()[GROUP_ENTITY_DICT]: + return extract_entity_attributes_from_dict(entity_text, match) + + entity_type = match.groupdict()[GROUP_ENTITY_TYPE] + + if match.groupdict()[GROUP_ENTITY_VALUE]: + entity_value = match.groupdict()[GROUP_ENTITY_VALUE] + else: + entity_value = entity_text + + return EntityAttributes(entity_type, entity_value, entity_text, None, None) + + +def extract_entity_attributes_from_dict( + entity_text: Text, match: Match +) -> EntityAttributes: + """Extract entity attributes from dict format. + + Args: + entity_text: Original entity text. + match: Regex match. + + Returns: + Extracted entity attributes. + """ + entity_dict_str = match.groupdict()[GROUP_ENTITY_DICT] + entity_dict = get_validated_dict(entity_dict_str) + return EntityAttributes( + entity_dict.get(ENTITY_ATTRIBUTE_TYPE), + entity_dict.get(ENTITY_ATTRIBUTE_VALUE, entity_text), + entity_text, + entity_dict.get(ENTITY_ATTRIBUTE_GROUP), + entity_dict.get(ENTITY_ATTRIBUTE_ROLE), + ) + + +def get_validated_dict(json_str: Text) -> Dict[Text, Text]: + """Converts the provided `json_str` to a valid dict containing the entity + attributes. + + Users can specify entity roles, synonyms, groups for an entity in a dict, e.g. + [LA]{"entity": "city", "role": "to", "value": "Los Angeles"}. + + Args: + json_str: The entity dict as string without "{}". + + Raises: + SchemaValidationError if validation of parsed entity fails. + InvalidEntityFormatException if provided entity is not valid json. + + Returns: + Deserialized and validated `json_str`. + """ + import json + import rasa.shared.utils.validation as validation_utils + import rasa.shared.nlu.training_data.schemas.data_schema as schema + + # add {} as they are not part of the regex + try: + data = json.loads(f"{{{json_str}}}") + except JSONDecodeError as e: + raise InvalidEntityFormatException.create_from( + e, + f"Incorrect training data format ('{{{json_str}}}'). " + f"More info at {DOCS_URL_TRAINING_DATA_NLU}", + ) from e + + validation_utils.validate_training_data(data, schema.entity_dict_schema()) + + return data + + +def replace_entities(training_example: Text) -> Text: + """Replace special symbols related to the entities in the provided + training example. + + Args: + training_example: Original training example with special symbols. + + Returns: + String with removed special symbols. + """ + return re.sub( + ENTITY_REGEX, lambda m: m.groupdict()[GROUP_ENTITY_TEXT], training_example + ) + + +def parse_training_example(example: Text, intent: Optional[Text] = None) -> "Message": + """Extract entities and synonyms, and convert to plain text.""" + entities = find_entities_in_training_example(example) + plain_text = replace_entities(example) + + return Message.build(plain_text, intent, entities) diff --git a/rasa/shared/nlu/training_data/features.py b/rasa/shared/nlu/training_data/features.py new file mode 100644 index 0000000..0c5553d --- /dev/null +++ b/rasa/shared/nlu/training_data/features.py @@ -0,0 +1,492 @@ +from __future__ import annotations + +import itertools +from dataclasses import dataclass +from typing import Iterable, Union, Text, Optional, List, Any, Tuple, Dict, Set + +import numpy as np +import scipy.sparse +from safetensors.numpy import save_file, load_file + +import rasa.shared.nlu.training_data.util +import rasa.shared.utils.io +from rasa.shared.nlu.constants import FEATURE_TYPE_SEQUENCE, FEATURE_TYPE_SENTENCE + + +@dataclass +class FeatureMetadata: + data_type: str + attribute: str + origin: Union[str, List[str]] + is_sparse: bool + shape: tuple + safetensors_key: str + + +def save_features( + features_dict: Dict[Text, List[Features]], file_name: str +) -> Dict[str, Any]: + """Save a dictionary of Features lists to disk using safetensors. + + Args: + features_dict: Dictionary mapping strings to lists of Features objects + file_name: File to save the features to + + Returns: + The metadata to reconstruct the features. + """ + # All tensors are stored in a single safetensors file + tensors_to_save = {} + # Metadata will be stored separately + metadata = {} + + for key, features_list in features_dict.items(): + feature_metadata_list = [] + + for idx, feature in enumerate(features_list): + # Create a unique key for this tensor in the safetensors file + safetensors_key = f"{key}_{idx}" + + # Convert sparse matrices to dense if needed + if feature.is_sparse(): + # For sparse matrices, use the COO format + coo = feature.features.tocoo() # type:ignore[union-attr] + # Save data, row indices and col indices separately + tensors_to_save[f"{safetensors_key}_data"] = coo.data + tensors_to_save[f"{safetensors_key}_row"] = coo.row + tensors_to_save[f"{safetensors_key}_col"] = coo.col + else: + tensors_to_save[safetensors_key] = feature.features + + # Store metadata + metadata_item = FeatureMetadata( + data_type=feature.type, + attribute=feature.attribute, + origin=feature.origin, + is_sparse=feature.is_sparse(), + shape=feature.features.shape, + safetensors_key=safetensors_key, + ) + feature_metadata_list.append(vars(metadata_item)) + + metadata[key] = feature_metadata_list + + # Save tensors + save_file(tensors_to_save, file_name) + + return metadata + + +def load_features( + filename: str, metadata: Dict[str, Any] +) -> Dict[Text, List[Features]]: + """Load Features dictionary from disk. + + Args: + filename: File name of the safetensors file. + metadata: Metadata to reconstruct the features. + + Returns: + Dictionary mapping strings to lists of Features objects + """ + # Load tensors + tensors = load_file(filename) + + # Reconstruct the features dictionary + features_dict: Dict[Text, List[Features]] = {} + + for key, feature_metadata_list in metadata.items(): + features_list = [] + + for meta in feature_metadata_list: + safetensors_key = meta["safetensors_key"] + + if meta["is_sparse"]: + # Reconstruct sparse matrix from COO format + data = tensors[f"{safetensors_key}_data"] + row = tensors[f"{safetensors_key}_row"] + col = tensors[f"{safetensors_key}_col"] + + features_matrix = scipy.sparse.coo_matrix( + (data, (row, col)), shape=tuple(meta["shape"]) + ).tocsr() # Convert back to CSR format + else: + features_matrix = tensors[safetensors_key] + + # Reconstruct Features object + features = Features( + features=features_matrix, + feature_type=meta["data_type"], + attribute=meta["attribute"], + origin=meta["origin"], + ) + + features_list.append(features) + + features_dict[key] = features_list + + return features_dict + + +class Features: + """Stores the features produced by any featurizer.""" + + def __init__( + self, + features: Union[np.ndarray, scipy.sparse.spmatrix], + feature_type: Text, + attribute: Text, + origin: Union[Text, List[Text]], + ) -> None: + """Initializes the Features object. + + Args: + features: The features. + feature_type: Type of the feature, e.g. FEATURE_TYPE_SENTENCE. + attribute: Message attribute, e.g. INTENT or TEXT. + origin: Name of the component that created the features. + """ + self.features = features + self.type = feature_type + self.origin = origin + self.attribute = attribute + self._cached_fingerprint: Optional[Text] = None + if not self.is_dense() and not self.is_sparse(): + raise ValueError( + "Features must either be a numpy array for dense " + "features or a scipy sparse matrix for sparse features." + ) + + def __repr__(self) -> Text: + return ( + f"{self.__class__.__name__}(" + f"features={self.features}, " + f"type={self.type}, " + f"origin={self.origin}, " + f"attribute={self.attribute})" + ) + + def __str__(self) -> Text: + return ( + f"{self.__class__.__name__}(" + f"features.shape={self.features.shape}, " + f"is_sparse={self.is_sparse()}, " + f"type={self.type}, " + f"origin={self.origin}, " + f"attribute={self.attribute})" + ) + + def is_sparse(self) -> bool: + """Checks if features are sparse or not. + + Returns: + True, if features are sparse, false otherwise. + """ + return isinstance(self.features, scipy.sparse.spmatrix) + + def is_dense(self) -> bool: + """Checks if features are dense or not. + + Returns: + True, if features are dense, false otherwise. + """ + return not self.is_sparse() + + def combine_with_features(self, additional_features: Optional[Features]) -> None: + """Combine the incoming features with this instance's features. + + Args: + additional_features: additional features to add + + Returns: + Combined features. + """ + if additional_features is None: + return + + if self.is_dense() and additional_features.is_dense(): + self._combine_dense_features(additional_features) + elif self.is_sparse() and additional_features.is_sparse(): + self._combine_sparse_features(additional_features) + else: + raise ValueError("Cannot combine sparse and dense features.") + + def _combine_dense_features(self, additional_features: Features) -> None: + if self.features.ndim != additional_features.features.ndim: + raise ValueError( + f"Cannot combine dense features as sequence dimensions do not " + f"match: {self.features.ndim} != {additional_features.features.ndim}." + ) + self.features = np.concatenate( + (self.features, additional_features.features), axis=-1 + ) + self._cached_fingerprint = None + + def _combine_sparse_features(self, additional_features: Features) -> None: + from scipy.sparse import hstack + + if self.features.shape[0] != additional_features.features.shape[0]: + raise ValueError( + f"Cannot combine sparse features as sequence dimensions do not " + f"match: {self.features.shape[0]} != " + f"{additional_features.features.shape[0]}." + ) + + self.features = hstack([self.features, additional_features.features]) + self._cached_fingerprint = None + + def __key__( + self, + ) -> Tuple[ + Text, Text, Union[np.ndarray, scipy.sparse.spmatrix], Union[Text, List[Text]] + ]: + """Returns a 4-tuple of defining properties. + + Returns: + Tuple of type, attribute, features, and origin properties. + """ + return self.type, self.attribute, self.features, self.origin + + def __eq__(self, other: Any) -> bool: + """Tests if the `self` `Feature` equals to the `other`. + + Args: + other: The other object. + + Returns: + `True` when the other object is a `Feature` and has the same + type, attribute, and feature tensors. + """ + if not isinstance(other, Features): + return False + + return ( + other.type == self.type + and other.attribute == self.attribute + and other.features == self.features + ) + + def fingerprint(self) -> Text: + """Calculate a stable string fingerprint for the features.""" + if self._cached_fingerprint is None: + if self.is_dense(): + f_as_text = self.features.tobytes() + else: + f_as_text = rasa.shared.nlu.training_data.util.sparse_matrix_to_string( + self.features + ) + self._cached_fingerprint = rasa.shared.utils.io.deep_container_fingerprint( + [self.type, self.origin, self.attribute, f_as_text] + ) + return self._cached_fingerprint + + @staticmethod + def filter( + features_list: List[Features], + attributes: Optional[Iterable[Text]] = None, + type: Optional[Text] = None, + origin: Optional[List[Text]] = None, + is_sparse: Optional[bool] = None, + ) -> List[Features]: + """Filters the given list of features. + + Args: + features_list: list of features to be filtered + attributes: List of attributes that we're interested in. Set this to `None` + to disable this filter. + type: The type of feature we're interested in. Set this to `None` + to disable this filter. + origin: If specified, this method will check that the exact order of origins + matches the given list of origins. The reason for this is that if + multiple origins are listed for a Feature, this means that this feature + has been created by concatenating Features from the listed origins in + that particular order. + is_sparse: Defines whether all features that we're interested in should be + sparse. Set this to `None` to disable this filter. + + Returns: + sub-list of features with the desired properties + """ + filtered = features_list + if attributes is not None: + attributes = set(attributes) + filtered = [f for f in filtered if f.attribute in attributes] + if origin is not None: + filtered = [ + f + for f in filtered + if (f.origin if not isinstance(f.origin, Text) else list([f.origin])) + == origin + ] + if type is not None: + filtered = [f for f in filtered if f.type == type] + if is_sparse is not None: + filtered = [f for f in filtered if f.is_sparse() == is_sparse] + return filtered + + @staticmethod + def groupby_attribute( + features_list: List[Features], attributes: Optional[Iterable[Text]] = None + ) -> Dict[Text, List[Features]]: + """Groups the given features according to their attribute. + + Args: + features_list: list of features to be grouped + attributes: If specified, the result will be a grouping with respect to + the given attributes. If some specified attribute has no features attached + to it, then the resulting dictionary will map it to an empty list. + If this is None, the result will be a grouping according to all attributes + for which features can be found. + + Returns: + a mapping from the requested attributes to the list of correspoding + features + """ + # ensure all requested attributes are present in the output - regardless + # of whether we find features later + extracted: Dict[Text, List[Features]] = ( + dict() + if attributes is None + else {attribute: [] for attribute in attributes} + ) + # extract features for all (requested) attributes + for feat in features_list: + if attributes is None or feat.attribute in attributes: + extracted.setdefault(feat.attribute, []).append(feat) + return extracted + + @staticmethod + def combine( + features_list: List[Features], expected_origins: Optional[List[Text]] = None + ) -> Features: + """Combine features of the same type and level that describe the same attribute. + + If sequence features are to be combined, then they must have the same + sequence dimension. + + Args: + features: Non-empty list of Features of the same type and level that + describe the same attribute. + expected_origins: The expected origins of the given features. This method + will check that the origin information of each feature is as expected, i.e. + the origin of the i-th feature in the given list is the i-th origin + in this list of origins. + + Raises: + `ValueError` will be raised + - if the given list is empty + - if there are inconsistencies in the given list of `Features` + - if the origins aren't as expected + """ + if len(features_list) == 0: + raise ValueError("Expected a non-empty list of Features.") + if len(features_list) == 1: + # nothing to combine here + return features_list[0] + + # Un-Pack the Origin information + origin_of_combination = [f.origin for f in features_list] + origin_of_combination = [ + featurizer_name + for origin in origin_of_combination + for featurizer_name in (origin if isinstance(origin, List) else [origin]) + ] + + # Sanity Checks + # (1) origins must be as expected + if expected_origins is not None: + if origin_of_combination is not None: + for idx, (expected, actual) in enumerate( + itertools.zip_longest(expected_origins, origin_of_combination) + ): + if expected != actual: + raise ValueError( + f"Expected '{expected}' to be the origin of the {idx}-th " + f"feature (because of `origin_of_combination`) but found a " + f"feature from '{actual}'." + ) + # (2) attributes (is_sparse, type, attribute) must coincide + # Note: we could also use `filter` for this check, but then the error msgs + # aren't as nice. + sparseness: Set[bool] = set(f.is_sparse() for f in features_list) + if len(sparseness) > 1: + raise ValueError( + "Expected all Features to have the same sparseness property but " + "found both (sparse and dense)." + ) + types: Set[Text] = set(f.type for f in features_list) + if len(types) > 1: + raise ValueError( + f"Expected all Features to have the same type but found the " + f"following types {types}." + ) + attributes: Set[Text] = set(f.attribute for f in features_list) + if len(attributes) > 1: + raise ValueError( + f"Expected all Features to describe the same attribute but found " + f"attributes: {attributes}." + ) + # (3) dimensions must match + # Note: We shouldn't have to check sentence-level features here but it doesn't + # hurt either. + dimensions = set(f.features.shape[0] for f in features_list) + if len(dimensions) > 1: + raise ValueError( + f"Expected all sequence dimensions to match but found {dimensions}." + ) + + # Combine the features + arbitrary_feature = features_list[0] + if not arbitrary_feature.is_sparse(): + features = np.concatenate([f.features for f in features_list], axis=-1) + else: + features = scipy.sparse.hstack([f.features for f in features_list]) + return Features( + features=features, + feature_type=arbitrary_feature.type, + attribute=arbitrary_feature.attribute, + origin=origin_of_combination, + ) + + @staticmethod + def reduce( + features_list: List[Features], expected_origins: Optional[List[Text]] = None + ) -> List[Features]: + """Combines features of same type and level into one Feature. + + Args: + features_list: list of Features which must all describe the same attribute + expected_origins: if specified, this list will be used to validate that + the features from the right featurizers are combined in the right order + (cf. `Features.combine`) + + Returns: + a list of the combined Features, i.e. at most 4 Features, where + - all the sparse features are listed before the dense features + - sequence feature is always listed before the sentence feature with the + same sparseness property + """ + if len(features_list) == 1: + return features_list + # sanity check + different_settings = set(f.attribute for f in features_list) + if len(different_settings) > 1: + raise ValueError( + f"Expected all Features to describe the same attribute but found " + f" {different_settings}." + ) + output = [] + for is_sparse in [True, False]: + # all sparse features before all dense features + for type in [FEATURE_TYPE_SEQUENCE, FEATURE_TYPE_SENTENCE]: + # sequence feature that is (not) sparse before sentence feature that is + # (not) sparse + sublist = Features.filter( + features_list=features_list, type=type, is_sparse=is_sparse + ) + if sublist: + combined_feature = Features.combine( + sublist, expected_origins=expected_origins + ) + output.append(combined_feature) + return output diff --git a/rasa/shared/nlu/training_data/formats/__init__.py b/rasa/shared/nlu/training_data/formats/__init__.py new file mode 100644 index 0000000..8664841 --- /dev/null +++ b/rasa/shared/nlu/training_data/formats/__init__.py @@ -0,0 +1,10 @@ +from rasa.shared.nlu.training_data.formats.rasa_yaml import RasaYAMLReader # noqa: F401 +from rasa.shared.nlu.training_data.formats.dialogflow import ( # noqa: F401 + DialogflowReader, +) +from rasa.shared.nlu.training_data.formats.luis import LuisReader # noqa: F401 +from rasa.shared.nlu.training_data.formats.rasa import ( # noqa: F401 + RasaReader, + RasaWriter, +) +from rasa.shared.nlu.training_data.formats.wit import WitReader # noqa: F401 diff --git a/rasa/shared/nlu/training_data/formats/dialogflow.py b/rasa/shared/nlu/training_data/formats/dialogflow.py new file mode 100644 index 0000000..bdf60bd --- /dev/null +++ b/rasa/shared/nlu/training_data/formats/dialogflow.py @@ -0,0 +1,162 @@ +import logging +import os +from pathlib import Path +from typing import Any, Dict, Optional, Text, List, Tuple, Union + +import rasa.shared.nlu.training_data.util +from rasa.shared.constants import DOCS_BASE_URL +from rasa.shared.nlu.training_data.formats.readerwriter import TrainingDataReader +from rasa.shared.nlu.training_data.util import transform_entity_synonyms +import rasa.shared.utils.io + +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message + +logger = logging.getLogger(__name__) + +DOCS_URL_MIGRATE_GOOGLE = DOCS_BASE_URL + "/migrate-from/google-dialogflow-to-rasa/" + +DIALOGFLOW_PACKAGE = "dialogflow_package" +DIALOGFLOW_AGENT = "dialogflow_agent" +DIALOGFLOW_INTENT = "dialogflow_intent" +DIALOGFLOW_INTENT_EXAMPLES = "dialogflow_intent_examples" +DIALOGFLOW_ENTITIES = "dialogflow_entities" +DIALOGFLOW_ENTITY_ENTRIES = "dialogflow_entity_entries" + + +class DialogflowReader(TrainingDataReader): + """Reader for NLU training data.""" + + def read(self, filename: Union[Text, Path], **kwargs: Any) -> "TrainingData": + """Loads training data stored in the Dialogflow data format.""" + language = kwargs["language"] + fformat = kwargs["fformat"] + + if fformat not in {DIALOGFLOW_INTENT, DIALOGFLOW_ENTITIES}: + raise ValueError( + "fformat must be either {}, or {}" + "".format(DIALOGFLOW_INTENT, DIALOGFLOW_ENTITIES) + ) + + root_js = rasa.shared.utils.io.read_json_file(filename) + + if isinstance(filename, Path): + filename = str(filename) + + examples = self._read_examples(filename, language, fformat) + + if not examples: + rasa.shared.utils.io.raise_warning( + f"No training examples found for dialogflow file {filename}!", + docs=DOCS_URL_MIGRATE_GOOGLE, + ) + return TrainingData() + elif fformat == DIALOGFLOW_INTENT: + return self._read_intent(root_js, examples) + else: # path for DIALOGFLOW_ENTITIES + return self._read_entities(root_js, examples) + + def _read_intent( + self, intent: Dict[Text, Any], examples: List[Dict[Text, Any]] + ) -> "TrainingData": + """Reads the intent and examples from respective jsons.""" + intent_name = intent.get("name") + + training_examples = [] + for ex in examples: + text, entities = self._join_text_chunks(ex["data"]) + training_examples.append(Message.build(text, intent_name, entities)) + + return TrainingData(training_examples) + + def _join_text_chunks( + self, chunks: List[Dict[Text, Any]] + ) -> Tuple[Text, List[Dict[Text, Any]]]: + """Combines text chunks and extracts entities.""" + utterance = "" + entities = [] + for chunk in chunks: + entity = self._extract_entity(chunk, len(utterance)) + if entity: + entities.append(entity) + utterance += chunk["text"] + + return utterance, entities + + @staticmethod + def _extract_entity( + chunk: Dict[Text, Any], current_offset: int + ) -> Optional[Dict[Text, Any]]: + """Extract an entity from a chunk if present.""" + entity = None + if "meta" in chunk or "alias" in chunk: + start = current_offset + text = chunk["text"] + end = start + len(text) + entity_type = chunk.get("alias", chunk["meta"]) + if entity_type != "@sys.ignore": + entity = rasa.shared.nlu.training_data.util.build_entity( + start, end, text, entity_type + ) + + return entity + + @staticmethod + def _flatten(list_of_lists: List[List[Any]]) -> List[Any]: + return [item for items in list_of_lists for item in items] + + @staticmethod + def _extract_lookup_tables( + entity: Dict[Text, Any], examples: List[Dict[Text, Any]] + ) -> Optional[List[Dict[Text, Any]]]: + """Extracts the lookup table from the entity synonyms.""" + synonyms = [e["synonyms"] for e in examples if "synonyms" in e] + synonyms = DialogflowReader._flatten(synonyms) + elements = [synonym for synonym in synonyms if "@" not in synonym] + + if len(elements) == 0: + return None + return [{"name": entity.get("name"), "elements": elements}] + + @staticmethod + def _extract_regex_features( + entity: Dict[Text, Any], examples: List[Dict[Text, Any]] + ) -> List[Dict[Text, Any]]: + """Extract the regex features from the entity synonyms.""" + synonyms = [e["synonyms"] for e in examples if "synonyms" in e] + synonyms = DialogflowReader._flatten(synonyms) + return [ + {"name": entity.get("name"), "pattern": synonym} for synonym in synonyms + ] + + @staticmethod + def _read_entities( + entity: Dict[Text, Any], examples: List[Dict[Text, Any]] + ) -> "TrainingData": + entity_synonyms = transform_entity_synonyms(examples) + + if entity["isRegexp"]: + regex_features = DialogflowReader._extract_regex_features(entity, examples) + return TrainingData([], entity_synonyms, regex_features, []) + else: + lookup_tables = DialogflowReader._extract_lookup_tables(entity, examples) + return TrainingData([], entity_synonyms, [], lookup_tables) + + @staticmethod + def _read_examples( + fn: Text, language: Text, fformat: Text + ) -> Optional[List[Dict[Text, Any]]]: + """Infer and load example file based on root filename and root format.""" + if fformat == DIALOGFLOW_INTENT: + examples_type = "usersays" + else: + examples_type = "entries" + examples_fn_ending = f"_{examples_type}_{language}.json" + examples_fn = fn.replace(".json", examples_fn_ending) + if os.path.isfile(examples_fn): + return rasa.shared.utils.io.read_json_file(examples_fn) + else: + return None + + def reads(self, s: Text, **kwargs: Any) -> "TrainingData": + raise NotImplementedError diff --git a/rasa/shared/nlu/training_data/formats/luis.py b/rasa/shared/nlu/training_data/formats/luis.py new file mode 100644 index 0000000..7753ad1 --- /dev/null +++ b/rasa/shared/nlu/training_data/formats/luis.py @@ -0,0 +1,87 @@ +import logging +from typing import Any, Dict, List, Text + +from rasa.shared.nlu.constants import ( + TEXT, + INTENT, + ENTITIES, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_ROLE, + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + NO_ENTITY_TAG, +) +from rasa.shared.nlu.training_data.formats.readerwriter import JsonTrainingDataReader +import rasa.shared.utils.io + +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message + +logger = logging.getLogger(__name__) + + +class LuisReader(JsonTrainingDataReader): + """Reads LUIS training data.""" + + @staticmethod + def _extract_regex_features(js: Dict[Text, Any]) -> List[Dict[Text, Any]]: + regex_features: List[Dict[Text, Any]] = [] + + for r in js.get("regex_features", []): + if r.get("activated", False): + regex_features.append( + {"name": r.get("name"), "pattern": r.get("pattern")} + ) + + # LUIS removed `regex_features` and exports regular expressions in ` + # regex_entities` now: + # https://stackoverflow.com/questions/48170631/what-happened-to-the-regex-features # noqa: E501 + for r in js.get("regex_entities", []): + regex_features.append( + {"name": r.get("name"), "pattern": r.get("regexPattern")} + ) + + return regex_features + + def read_from_json(self, js: Dict[Text, Any], **kwargs: Any) -> "TrainingData": + """Loads training data stored in the LUIS.ai data format.""" + training_examples = [] + + max_tested_luis_schema_version = 7 + major_version = int(js["luis_schema_version"].split(".")[0]) + if major_version > max_tested_luis_schema_version: + rasa.shared.utils.io.raise_warning( + f"Your luis data schema version {js['luis_schema_version']} " + f"is higher than 7.x.x. " + f"Training may not be performed correctly. " + ) + + for s in js["utterances"]: + text = s.get("text") + intent = s.get("intent") + entities = [] + for e in s.get("entities") or []: + start, end = e["startPos"], e["endPos"] + 1 + val = text[start:end] + + entities.append( + { + ENTITY_ATTRIBUTE_TYPE: e.get("entity"), + ENTITY_ATTRIBUTE_VALUE: val, + ENTITY_ATTRIBUTE_START: start, + ENTITY_ATTRIBUTE_END: end, + ENTITY_ATTRIBUTE_ROLE: e.get("role", NO_ENTITY_TAG), + } + ) + + data = {ENTITIES: entities} + if intent: + data[INTENT] = intent + data[TEXT] = text + + training_examples.append(Message(data=data)) + + return TrainingData( + training_examples, regex_features=self._extract_regex_features(js) + ) diff --git a/rasa/shared/nlu/training_data/formats/rasa.py b/rasa/shared/nlu/training_data/formats/rasa.py new file mode 100644 index 0000000..cfefbde --- /dev/null +++ b/rasa/shared/nlu/training_data/formats/rasa.py @@ -0,0 +1,135 @@ +import logging +from collections import defaultdict +from typing import Any, Dict, Text + +from rasa.shared.constants import DOCS_URL_MIGRATION_GUIDE +from rasa.shared.nlu.constants import TEXT, INTENT, ENTITIES +from rasa.shared.nlu.training_data.formats.readerwriter import ( + JsonTrainingDataReader, + TrainingDataWriter, +) +from rasa.shared.nlu.training_data.util import transform_entity_synonyms +from rasa.shared.utils.io import json_to_string + +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +import rasa.shared.utils.io + +logger = logging.getLogger(__name__) + + +class RasaReader(JsonTrainingDataReader): + """Reader for Rasa NLU training data in JSON format. + + Example: + { + "rasa_nlu_data": { + "regex_features": [ + { + "name": "zipcode", + "pattern": "[0-9]{5}" + } + ], + "entity_synonyms": [ + { + "value": "chinese", + "synonyms": ["Chinese", "Chines", "chines"] + } + ], + "common_examples": [ + { + "text": "hey", + "intent": "greet", + "entities": [] + }, + { + "text": "howdy", + "intent": "greet", + "entities": [] + } + ] + } + } + """ + + def __init__(self) -> None: + """Creates reader.""" + super().__init__() + rasa.shared.utils.io.raise_deprecation_warning( + "NLU data in Rasa JSON format is deprecated and will be removed in Rasa " + "Open Source 4.0.0. Please convert your JSON NLU data to the " + "Rasa YAML format.", + docs=DOCS_URL_MIGRATION_GUIDE, + ) + + def read_from_json(self, js: Dict[Text, Any], **_: Any) -> "TrainingData": + """Loads training data stored in the rasa NLU data format.""" + import rasa.shared.nlu.training_data.schemas.data_schema as schema + import rasa.shared.utils.validation as validation_utils + + validation_utils.validate_training_data(js, schema.rasa_nlu_data_schema()) + + data = js["rasa_nlu_data"] + common_examples = data.get("common_examples", []) + entity_synonyms = data.get("entity_synonyms", []) + regex_features = data.get("regex_features", []) + lookup_tables = data.get("lookup_tables", []) + + entity_synonyms = transform_entity_synonyms(entity_synonyms) + + training_examples = [] + for ex in common_examples: + # taking care of custom entries + msg = Message.build( + text=ex.pop(TEXT, ""), + intent=ex.pop(INTENT, None), + entities=ex.pop(ENTITIES, None), + **ex, + ) + training_examples.append(msg) + + return TrainingData( + training_examples, entity_synonyms, regex_features, lookup_tables + ) + + +class RasaWriter(TrainingDataWriter): + """Dumps NLU data as Rasa JSON string.""" + + def __init__(self) -> None: + """Creates writer.""" + super().__init__() + rasa.shared.utils.io.raise_deprecation_warning( + "NLU data in Rasa JSON format is deprecated and will be removed in Rasa " + "Open Source 4.0.0. Please convert your JSON NLU data to the " + "Rasa YAML format.", + docs=DOCS_URL_MIGRATION_GUIDE, + ) + + def dumps(self, training_data: "TrainingData", **kwargs: Any) -> Text: + """Writes Training Data to a string in json format.""" + js_entity_synonyms = defaultdict(list) + for k, v in training_data.entity_synonyms.items(): + if k != v: + js_entity_synonyms[v].append(k) + + formatted_synonyms = [ + {"value": value, "synonyms": syns} + for value, syns in js_entity_synonyms.items() + ] + + formatted_examples = [ + example.as_dict_nlu() for example in training_data.training_examples + ] + + return json_to_string( + { + "rasa_nlu_data": { + "common_examples": formatted_examples, + "regex_features": training_data.regex_features, + "lookup_tables": training_data.lookup_tables, + "entity_synonyms": formatted_synonyms, + } + }, + **kwargs, + ) diff --git a/rasa/shared/nlu/training_data/formats/rasa_yaml.py b/rasa/shared/nlu/training_data/formats/rasa_yaml.py new file mode 100644 index 0000000..b31253b --- /dev/null +++ b/rasa/shared/nlu/training_data/formats/rasa_yaml.py @@ -0,0 +1,601 @@ +import logging +from collections import OrderedDict +from pathlib import Path +from typing import Text, Any, List, Dict, Tuple, Union, Iterator, Optional, Callable + +import rasa.shared.data +from rasa.shared.core.domain import Domain +from rasa.shared.exceptions import YamlException +from rasa.shared.utils import validation +from ruamel.yaml import StringIO +from ruamel.yaml.scalarstring import LiteralScalarString + +from rasa.shared.constants import ( + DOCS_URL_TRAINING_DATA, + LATEST_TRAINING_DATA_FORMAT_VERSION, +) +from rasa.shared.nlu.constants import METADATA_INTENT, METADATA_EXAMPLE +from rasa.shared.nlu.training_data.formats.readerwriter import ( + TrainingDataReader, + TrainingDataWriter, +) +import rasa.shared.utils.io +import rasa.shared.nlu.training_data.util +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message + + +logger = logging.getLogger(__name__) + +KEY_NLU = "nlu" +KEY_RESPONSES = "responses" +KEY_INTENT = "intent" +KEY_INTENT_EXAMPLES = "examples" +KEY_INTENT_TEXT = "text" +KEY_SYNONYM = "synonym" +KEY_SYNONYM_EXAMPLES = "examples" +KEY_REGEX = "regex" +KEY_REGEX_EXAMPLES = "examples" +KEY_LOOKUP = "lookup" +KEY_LOOKUP_EXAMPLES = "examples" +KEY_METADATA = "metadata" + +MULTILINE_TRAINING_EXAMPLE_LEADING_SYMBOL = "-" + +NLU_SCHEMA_FILE = "shared/nlu/training_data/schemas/nlu.yml" + +STRIP_SYMBOLS = "\n\r " + + +class RasaYAMLReader(TrainingDataReader): + """Reads YAML training data and creates a TrainingData object.""" + + def __init__(self) -> None: + super().__init__() + self.training_examples: List[Message] = [] + self.entity_synonyms: Dict[Text, Text] = {} + self.regex_features: List[Dict[Text, Text]] = [] + self.lookup_tables: List[Dict[Text, Any]] = [] + self.responses: Dict[Text, List[Dict[Text, Any]]] = {} + + def validate(self, string: Text) -> None: + """Check if the string adheres to the NLU yaml data schema. + + If the string is not in the right format, an exception will be raised.""" + try: + validation.validate_yaml_schema(string, NLU_SCHEMA_FILE) + except YamlException as e: + e.filename = self.filename + raise e + + def reads( # type: ignore[override] + self, string: Text, **kwargs: Any + ) -> "TrainingData": + """Reads TrainingData in YAML format from a string. + + Args: + string: String with YAML training data. + **kwargs: Keyword arguments. + + Returns: + New `TrainingData` object with parsed training data. + """ + self.validate(string) + + yaml_content = rasa.shared.utils.io.read_yaml(string) + + if not validation.validate_training_data_format_version( + yaml_content, self.filename + ): + return TrainingData() + + for key, value in yaml_content.items(): + if key == KEY_NLU: + self._parse_nlu(value) + elif key == KEY_RESPONSES: + self.responses = value + + return TrainingData( + self.training_examples, + self.entity_synonyms, + self.regex_features, + self.lookup_tables, + self.responses, + ) + + def _parse_nlu(self, nlu_data: Optional[List[Dict[Text, Any]]]) -> None: + + if not nlu_data: + return + + for nlu_item in nlu_data: + if not isinstance(nlu_item, dict): + rasa.shared.utils.io.raise_warning( + f"Unexpected block found in '{self.filename}':\n" + f"{nlu_item}\n" + f"Items under the '{KEY_NLU}' key must be YAML dictionaries. " + f"This block will be skipped.", + docs=DOCS_URL_TRAINING_DATA, + ) + continue + + if KEY_INTENT in nlu_item.keys(): + self._parse_intent(nlu_item) + elif KEY_SYNONYM in nlu_item.keys(): + self._parse_synonym(nlu_item) + elif KEY_REGEX in nlu_item.keys(): + self._parse_regex(nlu_item) + elif KEY_LOOKUP in nlu_item.keys(): + self._parse_lookup(nlu_item) + else: + rasa.shared.utils.io.raise_warning( + f"Issue found while processing '{self.filename}': " + f"Could not find supported key in the section:\n" + f"{nlu_item}\n" + f"Supported keys are: '{KEY_INTENT}', '{KEY_SYNONYM}', " + f"'{KEY_REGEX}', '{KEY_LOOKUP}'. " + f"This section will be skipped.", + docs=DOCS_URL_TRAINING_DATA, + ) + + def _parse_intent(self, intent_data: Dict[Text, Any]) -> None: + import rasa.shared.nlu.training_data.entities_parser as entities_parser + import rasa.shared.nlu.training_data.synonyms_parser as synonyms_parser + + intent = intent_data.get(KEY_INTENT, "") + if not intent: + rasa.shared.utils.io.raise_warning( + f"Issue found while processing '{self.filename}': " + f"The intent has an empty name. " + f"Intents should have a name defined under the {KEY_INTENT} key. " + f"It will be skipped.", + docs=DOCS_URL_TRAINING_DATA, + ) + return + + examples = intent_data.get(KEY_INTENT_EXAMPLES, "") + intent_metadata = intent_data.get(KEY_METADATA) + for example, entities, metadata in self._parse_training_examples( + examples, intent + ): + + plain_text = entities_parser.replace_entities(example) + + synonyms_parser.add_synonyms_from_entities( + plain_text, entities, self.entity_synonyms + ) + + self.training_examples.append( + Message.build(plain_text, intent, entities, intent_metadata, metadata) + ) + + def _parse_training_examples( + self, examples: Union[Text, List[Dict[Text, Any]]], intent: Text + ) -> List[Tuple[Text, List[Dict[Text, Any]], Optional[Any]]]: + import rasa.shared.nlu.training_data.entities_parser as entities_parser + + if isinstance(examples, list): + example_tuples = [ + ( + example.get(KEY_INTENT_TEXT, "").strip(STRIP_SYMBOLS), + example.get(KEY_METADATA), + ) + for example in examples + if example + ] + elif isinstance(examples, str): + example_tuples = [ + (example, None) + for example in self._parse_multiline_example(intent, examples) + ] + else: + rasa.shared.utils.io.raise_warning( + f"Unexpected block found in '{self.filename}' " + f"while processing intent '{intent}':\n" + f"{examples}\n" + f"This block will be skipped.", + docs=DOCS_URL_TRAINING_DATA, + ) + return [] + + if not example_tuples: + rasa.shared.utils.io.raise_warning( + f"Issue found while processing '{self.filename}': " + f"Intent '{intent}' has no examples.", + docs=DOCS_URL_TRAINING_DATA, + ) + + results = [] + for example, metadata in example_tuples: + entities = entities_parser.find_entities_in_training_example(example) + results.append((example, entities, metadata)) + + return results + + def _parse_synonym(self, nlu_item: Dict[Text, Any]) -> None: + import rasa.shared.nlu.training_data.synonyms_parser as synonyms_parser + + synonym_name = nlu_item[KEY_SYNONYM] + if not synonym_name: + rasa.shared.utils.io.raise_warning( + f"Issue found while processing '{self.filename}': " + f"The synonym has an empty name. " + f"Synonyms should have a name defined under the {KEY_SYNONYM} key. " + f"It will be skipped.", + docs=DOCS_URL_TRAINING_DATA, + ) + return + + examples = nlu_item.get(KEY_SYNONYM_EXAMPLES, "") + + if not examples: + rasa.shared.utils.io.raise_warning( + f"Issue found while processing '{self.filename}': " + f"{KEY_SYNONYM}: {synonym_name} doesn't have any examples. " + f"It will be skipped.", + docs=DOCS_URL_TRAINING_DATA, + ) + return + + if not isinstance(examples, str): + rasa.shared.utils.io.raise_warning( + f"Unexpected block found in '{self.filename}':\n" + f"{examples}\n" + f"It will be skipped.", + docs=DOCS_URL_TRAINING_DATA, + ) + return + + for example in self._parse_multiline_example(synonym_name, examples): + synonyms_parser.add_synonym(example, synonym_name, self.entity_synonyms) + + def _parse_regex(self, nlu_item: Dict[Text, Any]) -> None: + regex_name = nlu_item[KEY_REGEX] + if not regex_name: + rasa.shared.utils.io.raise_warning( + f"Issue found while processing '{self.filename}': " + f"The regex has an empty name." + f"Regex should have a name defined under the '{KEY_REGEX}' key. " + f"It will be skipped.", + docs=DOCS_URL_TRAINING_DATA, + ) + return + + examples = nlu_item.get(KEY_REGEX_EXAMPLES, "") + if not examples: + rasa.shared.utils.io.raise_warning( + f"Issue found while processing '{self.filename}': " + f"'{KEY_REGEX}: {regex_name}' doesn't have any examples. " + f"It will be skipped.", + docs=DOCS_URL_TRAINING_DATA, + ) + return + + if not isinstance(examples, str): + rasa.shared.utils.io.raise_warning( + f"Unexpected block found in '{self.filename}':\n" + f"{examples}\n" + f"This block will be skipped.", + docs=DOCS_URL_TRAINING_DATA, + ) + return + + for example in self._parse_multiline_example(regex_name, examples): + self.regex_features.append({"name": regex_name, "pattern": example}) + + def _parse_lookup(self, nlu_item: Dict[Text, Any]) -> None: + import rasa.shared.nlu.training_data.lookup_tables_parser as lookup_tables_parser # noqa: E501 + + lookup_item_name = nlu_item[KEY_LOOKUP] + if not lookup_item_name: + rasa.shared.utils.io.raise_warning( + f"Issue found while processing '{self.filename}': " + f"The lookup item has an empty name. " + f"Lookup items should have a name defined under the '{KEY_LOOKUP}' " + f"key. It will be skipped.", + docs=DOCS_URL_TRAINING_DATA, + ) + return + + examples = nlu_item.get(KEY_LOOKUP_EXAMPLES, "") + if not examples: + rasa.shared.utils.io.raise_warning( + f"Issue found while processing '{self.filename}': " + f"'{KEY_LOOKUP}: {lookup_item_name}' doesn't have any examples. " + f"It will be skipped.", + docs=DOCS_URL_TRAINING_DATA, + ) + return + + if not isinstance(examples, str): + rasa.shared.utils.io.raise_warning( + f"Unexpected block found in '{self.filename}':\n" + f"{examples}\n" + f"This block will be skipped.", + docs=DOCS_URL_TRAINING_DATA, + ) + return + + for example in self._parse_multiline_example(lookup_item_name, examples): + lookup_tables_parser.add_item_to_lookup_tables( + lookup_item_name, example, self.lookup_tables + ) + + def _parse_multiline_example(self, item: Text, examples: Text) -> Iterator[Text]: + for example in examples.splitlines(): + if not example.startswith(MULTILINE_TRAINING_EXAMPLE_LEADING_SYMBOL): + rasa.shared.utils.io.raise_warning( + f"Issue found while processing '{self.filename}': " + f"The item '{item}' contains an example that doesn't start with a " + f"'{MULTILINE_TRAINING_EXAMPLE_LEADING_SYMBOL}' symbol: " + f"{example}\n" + f"This training example will be skipped.", + docs=DOCS_URL_TRAINING_DATA, + ) + continue + yield example[1:].strip(STRIP_SYMBOLS) + + @staticmethod + def is_yaml_nlu_file(filename: Union[Text, Path]) -> bool: + """Checks if the specified file possibly contains NLU training data in YAML. + + Args: + filename: name of the file to check. + + Returns: + `True` if the `filename` is possibly a valid YAML NLU file, + `False` otherwise. + + Raises: + YamlException: if the file seems to be a YAML file (extension) but + can not be read / parsed. + """ + if not rasa.shared.data.is_likely_yaml_file(filename): + return False + + return rasa.shared.utils.io.is_key_in_yaml(filename, KEY_NLU, KEY_RESPONSES) + + +class RasaYAMLWriter(TrainingDataWriter): + """Writes training data into a file in a YAML format.""" + + def dumps(self, training_data: "TrainingData") -> Text: + """Turns TrainingData into a string.""" + stream = StringIO() + self.dump(stream, training_data) + return stream.getvalue() + + def dump( + self, target: Union[Text, Path, StringIO], training_data: "TrainingData" + ) -> None: + """Writes training data into a file in a YAML format. + + Args: + target: Name of the target object to write the YAML to. + training_data: TrainingData object. + """ + result = self.training_data_to_dict(training_data) + + if result: + rasa.shared.utils.io.write_yaml(result, target, True) + + @classmethod + def training_data_to_dict( + cls, training_data: "TrainingData" + ) -> Optional[OrderedDict]: + """Represents NLU training data to a dict/list structure ready to be + serialized as YAML. + + Args: + training_data: `TrainingData` to convert. + + Returns: + `OrderedDict` containing all training data. + """ + from rasa.shared.utils.validation import KEY_TRAINING_DATA_FORMAT_VERSION + from ruamel.yaml.scalarstring import DoubleQuotedScalarString + + nlu_items = [] + nlu_items.extend(cls.process_intents(training_data)) + nlu_items.extend(cls.process_synonyms(training_data)) + nlu_items.extend(cls.process_regexes(training_data)) + nlu_items.extend(cls.process_lookup_tables(training_data)) + + if not any([nlu_items, training_data.responses]): + return None + + result: OrderedDict[Text, Any] = OrderedDict() + result[KEY_TRAINING_DATA_FORMAT_VERSION] = DoubleQuotedScalarString( + LATEST_TRAINING_DATA_FORMAT_VERSION + ) + + if nlu_items: + result[KEY_NLU] = nlu_items + + if training_data.responses: + result[KEY_RESPONSES] = Domain.get_responses_with_multilines( + training_data.responses + ) + + return result + + @classmethod + def process_intents(cls, training_data: "TrainingData") -> List[OrderedDict]: + """Serializes the intents.""" + return RasaYAMLWriter.process_training_examples_by_key( + cls.prepare_training_examples(training_data), + KEY_INTENT, + KEY_INTENT_EXAMPLES, + TrainingDataWriter.generate_message, + ) + + @classmethod + def process_synonyms(cls, training_data: "TrainingData") -> List[OrderedDict]: + """Serializes the synonyms.""" + inverted_synonyms: Dict[Text, List[Dict]] = OrderedDict() + for example, synonym in training_data.entity_synonyms.items(): + if not inverted_synonyms.get(synonym): + inverted_synonyms[synonym] = [] + inverted_synonyms[synonym].append(example) + + return cls.process_training_examples_by_key( + inverted_synonyms, + KEY_SYNONYM, + KEY_SYNONYM_EXAMPLES, + example_extraction_predicate=lambda x: str(x), + ) + + @classmethod + def process_regexes(cls, training_data: "TrainingData") -> List[OrderedDict]: + """Serializes the regexes.""" + inverted_regexes: Dict[Text, List[Text]] = OrderedDict() + for regex in training_data.regex_features: + if not inverted_regexes.get(regex["name"]): + inverted_regexes[regex["name"]] = [] + inverted_regexes[regex["name"]].append(regex["pattern"]) + + return cls.process_training_examples_by_key( + inverted_regexes, + KEY_REGEX, + KEY_REGEX_EXAMPLES, + example_extraction_predicate=lambda x: str(x), + ) + + @classmethod + def process_lookup_tables(cls, training_data: "TrainingData") -> List[OrderedDict]: + """Serializes the look up tables. + + Args: + training_data: The training data object with potential look up tables. + + Returns: + The serialized lookup tables. + """ + prepared_lookup_tables: Dict[Text, List[Text]] = OrderedDict() + for lookup_table in training_data.lookup_tables: + # this is a lookup table filename + if isinstance(lookup_table["elements"], str): + continue + prepared_lookup_tables[lookup_table["name"]] = lookup_table["elements"] + + return cls.process_training_examples_by_key( + prepared_lookup_tables, + KEY_LOOKUP, + KEY_LOOKUP_EXAMPLES, + example_extraction_predicate=lambda x: str(x), + ) + + @staticmethod + def process_training_examples_by_key( + training_examples: Dict[Text, List[Union[Dict, Text]]], + key_name: Text, + key_examples: Text, + example_extraction_predicate: Callable[[Dict[Text, Any]], Text], + ) -> List[OrderedDict]: + """Prepares training examples to be written to YAML. + + This can be any NLU training data (intent examples, lookup tables, etc.) + + Args: + training_examples: Multiple training examples. Mappings in case additional + values were specified for an example (e.g. metadata) or just the plain + value. + key_name: The top level key which the examples belong to (e.g. `intents`) + key_examples: The sub key which the examples should be added to + (e.g. `examples`). + example_extraction_predicate: Function to extract example value (e.g. the + the text for an intent example) + + Returns: + NLU training data examples prepared for writing to YAML. + """ + intents = [] + + for intent_name, examples in training_examples.items(): + converted, intent_metadata = RasaYAMLWriter._convert_training_examples( + examples, example_extraction_predicate + ) + + intent: OrderedDict[Text, Any] = OrderedDict() + intent[key_name] = intent_name + if intent_metadata: + intent[KEY_METADATA] = intent_metadata + + examples_have_metadata = any(KEY_METADATA in ex for ex in converted) + example_texts_have_escape_chars = any( + rasa.shared.nlu.training_data.util.has_string_escape_chars( + ex.get(KEY_INTENT_TEXT, "") + ) + for ex in converted + ) + + if examples_have_metadata or example_texts_have_escape_chars: + intent[ + key_examples + ] = RasaYAMLWriter._render_training_examples_as_objects(converted) + else: + intent[key_examples] = RasaYAMLWriter._render_training_examples_as_text( + converted + ) + + intents.append(intent) + + return intents + + @staticmethod + def _convert_training_examples( + training_examples: List[Union[Dict, List[Text]]], + example_extraction_predicate: Callable[[Dict[Text, Any]], Text], + ) -> Tuple[List[Dict], Optional[Dict]]: + """Returns converted training examples and potential intent metadata.""" + converted_examples = [] + intent_metadata = None + + for example in training_examples: + converted = { + KEY_INTENT_TEXT: example_extraction_predicate(example).strip( + STRIP_SYMBOLS + ) + } + + if isinstance(example, dict) and KEY_METADATA in example: + metadata = example[KEY_METADATA] + + if METADATA_EXAMPLE in metadata: + converted[KEY_METADATA] = metadata[METADATA_EXAMPLE] + + if intent_metadata is None and METADATA_INTENT in metadata: + intent_metadata = metadata[METADATA_INTENT] + + converted_examples.append(converted) + + return converted_examples, intent_metadata + + @staticmethod + def _render_training_examples_as_objects(examples: List[Dict]) -> List[Dict]: + """Renders training examples as objects. + + The `text` item is rendered as a literal scalar string. + + Given the input of a single example: + {'text': 'how much CO2 will that use?'} + Its return value is a dictionary that will be rendered in YAML as: + ``` + text: | + how much CO2 will that use? + ``` + """ + + def render(example: Dict) -> Dict: + text = example[KEY_INTENT_TEXT] + example[KEY_INTENT_TEXT] = LiteralScalarString(text + "\n") + return example + + return [render(ex) for ex in examples] + + @staticmethod + def _render_training_examples_as_text(examples: List[Dict]) -> LiteralScalarString: + def render(example: Dict) -> Text: + return TrainingDataWriter.generate_list_item(example[KEY_INTENT_TEXT]) + + return LiteralScalarString("".join([render(example) for example in examples])) diff --git a/rasa/shared/nlu/training_data/formats/readerwriter.py b/rasa/shared/nlu/training_data/formats/readerwriter.py new file mode 100644 index 0000000..a45a11d --- /dev/null +++ b/rasa/shared/nlu/training_data/formats/readerwriter.py @@ -0,0 +1,245 @@ +import abc +import json +from collections import OrderedDict +from pathlib import Path + +import rasa.shared.nlu.training_data.util +from rasa.shared.constants import INTENT_MESSAGE_PREFIX + +from rasa.shared.nlu.constants import ( + INTENT, + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_GROUP, + ENTITY_ATTRIBUTE_ROLE, +) + +import rasa.shared.utils.io +import typing +from typing import Text, Dict, Any, Union, List +from collections import defaultdict + +if typing.TYPE_CHECKING: + from rasa.shared.nlu.training_data.training_data import TrainingData + + +def _raise_on_same_start_and_different_end_positions( + aggregated_entities: Dict[int, List[Dict[Text, Any]]], +) -> None: + """Raises a ValueError iff two entities have overlapping but not identical spans. + + Args: + aggregated_entities: Entities for each start position + """ + for entity_list in aggregated_entities.values(): + end = entity_list[0]["end"] + for entity in entity_list[1:]: + # By construction, start positions of all entities in `entity_list` are + # identical + if entity["end"] != end: + raise ValueError( + f"Entities '{entity}' and " + f"'{entity_list[0]}' have identical " + f"start but different end positions" + ) + + +class TrainingDataReader(abc.ABC): + """Reader for NLU training data.""" + + def __init__(self) -> None: + """Creates reader instance.""" + self.filename: Text = "" + + def read(self, filename: Union[Text, Path], **kwargs: Any) -> "TrainingData": + """Reads TrainingData from a file.""" + self.filename = str(filename) + return self.reads(rasa.shared.utils.io.read_file(filename), **kwargs) + + @abc.abstractmethod + def reads(self, s: Text, **kwargs: Any) -> "TrainingData": + """Reads TrainingData from a string.""" + raise NotImplementedError + + +class TrainingDataWriter: + """A class for writing training data to a file.""" + + def dump(self, filename: Text, training_data: "TrainingData") -> None: + """Writes a TrainingData object to a file.""" + s = self.dumps(training_data) + rasa.shared.utils.io.write_text_file(s, filename) + + def dumps(self, training_data: "TrainingData") -> Text: + """Turns TrainingData into a string.""" + raise NotImplementedError + + @staticmethod + def prepare_training_examples( + training_data: "TrainingData", + ) -> Dict[Text, List[Union[Dict, Text]]]: + """Pre-processes training data examples by removing not trainable entities.""" + import rasa.shared.nlu.training_data.util as rasa_nlu_training_data_utils + + training_examples: Dict[Text, List[Union[Dict, Text]]] = OrderedDict() + + # Sort by intent while keeping basic intent order + for example in [e.as_dict_nlu() for e in training_data.training_examples]: + if not example.get(INTENT): + continue + rasa_nlu_training_data_utils.remove_untrainable_entities_from(example) + intent = example[INTENT] + training_examples.setdefault(intent, []) + training_examples[intent].append(example) + + return training_examples + + @staticmethod + def generate_list_item(text: Text) -> Text: + """Generates text for a list item.""" + return f"- {rasa.shared.nlu.training_data.util.encode_string(text)}\n" + + @staticmethod + def generate_message(message: Dict[Text, Any]) -> Text: + """Generates text for a message object. + + Args: + message: A message + + Returns: + The text of the message, annotated with the entity data that is contained + in the message + """ + md = "" + text = message.get("text", "") + + pos = 0 + + # If a message was prefixed with `INTENT_MESSAGE_PREFIX` (this can only happen + # in end-to-end stories) then potential entities were provided in the json + # format (e.g. `/greet{"name": "Rasa"}) and we don't have to add the NLU + # entity annotation + if not text.startswith(INTENT_MESSAGE_PREFIX): + + entities = message.get("entities", []) + entities_with_start_and_end = [ + e for e in entities if "start" in e and "end" in e + ] + # Multiple entities can share the same position span. To account for that, + # group all entities by their start positions + aggregated_entities: Dict[int, List[Dict[Text, Any]]] = defaultdict(list) + for entity in entities_with_start_and_end: + aggregated_entities[entity["start"]].append(entity) + + _raise_on_same_start_and_different_end_positions(aggregated_entities) + + for start, entities in sorted(aggregated_entities.items()): + md += text[pos:start] + md += TrainingDataWriter.generate_entity( + text, + entities[0] if len(entities) == 1 else entities, + ) + pos = entities[0]["end"] + + md += text[pos:] + + return md + + @staticmethod + def generate_entity_attributes( + text: Text, entity: Dict[Text, Any], short_allowed: bool = True + ) -> Text: + """Generates text for the entity attributes. + + Args: + text: The text that is annotated with the entity + entity: Entity data + short_allowed: If `True`, allow shorthand annotation with parenthesis + + Returns: + The annotation text that should follow the given text + """ + entity_text = text + entity_type = entity.get(ENTITY_ATTRIBUTE_TYPE) + entity_value = entity.get(ENTITY_ATTRIBUTE_VALUE) + entity_role = entity.get(ENTITY_ATTRIBUTE_ROLE) + entity_group = entity.get(ENTITY_ATTRIBUTE_GROUP) + + if entity_value and entity_value == entity_text: + entity_value = None + + use_short_syntax = ( + short_allowed + and entity_value is None + and entity_role is None + and entity_group is None + ) + + if use_short_syntax: + return f"({entity_type})" + else: + entity_dict = OrderedDict( + [ + (ENTITY_ATTRIBUTE_TYPE, entity_type), + (ENTITY_ATTRIBUTE_ROLE, entity_role), + (ENTITY_ATTRIBUTE_GROUP, entity_group), + (ENTITY_ATTRIBUTE_VALUE, entity_value), + ] + ) + entity_dict = OrderedDict( + [(k, v) for k, v in entity_dict.items() if v is not None] + ) + + return f"{json.dumps(entity_dict)}" + + @staticmethod + def generate_entity( + text: Text, entity: Union[Dict[Text, Any], List[Dict[Text, Any]]] + ) -> Text: + """Generates text for one or multiple entity objects. + + Args: + text: The un-annotated text + entity: One or multiple entity annotations for one part of this text + + Returns: + Annotated part of the text + """ + if isinstance(entity, list): + entity_text = text[ + entity[0][ENTITY_ATTRIBUTE_START] : entity[0][ENTITY_ATTRIBUTE_END] + ] + return ( + f"[{entity_text}][" + + ", ".join( + [ + TrainingDataWriter.generate_entity_attributes( + text=entity_text, entity=e, short_allowed=False + ) + for e in entity + ] + ) + + "]" + ) + else: + entity_text = text[ + entity[ENTITY_ATTRIBUTE_START] : entity[ENTITY_ATTRIBUTE_END] + ] + return f"[{entity_text}]" + TrainingDataWriter.generate_entity_attributes( + text=entity_text, entity=entity, short_allowed=True + ) + + +class JsonTrainingDataReader(TrainingDataReader): + """A class for reading JSON files.""" + + def reads(self, s: Text, **kwargs: Any) -> "TrainingData": + """Transforms string into json object and passes it on.""" + js = json.loads(s) + return self.read_from_json(js, **kwargs) + + def read_from_json(self, js: Dict[Text, Any], **kwargs: Any) -> "TrainingData": + """Reads TrainingData from a json object.""" + raise NotImplementedError diff --git a/rasa/shared/nlu/training_data/formats/wit.py b/rasa/shared/nlu/training_data/formats/wit.py new file mode 100644 index 0000000..70025a6 --- /dev/null +++ b/rasa/shared/nlu/training_data/formats/wit.py @@ -0,0 +1,52 @@ +import logging +from typing import Any, Dict, Text + +from rasa.shared.core.constants import USER_INTENT_OUT_OF_SCOPE +from rasa.shared.nlu.constants import ( + INTENT, + ENTITIES, + TEXT, + ENTITY_ATTRIBUTE_ROLE, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_VALUE, +) +from rasa.shared.nlu.training_data.formats.readerwriter import JsonTrainingDataReader + +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message + +logger = logging.getLogger(__name__) + + +class WitReader(JsonTrainingDataReader): + def read_from_json(self, js: Dict[Text, Any], **kwargs: Any) -> TrainingData: + """Loads training data stored in the WIT.ai data format.""" + training_examples = [] + + for s in js["utterances"]: + entities = s.get(ENTITIES) + if entities is None: + continue + text = s.get(TEXT) + # Out-of-scope WIT utterances won't have the intent field set, + # and that's the reason why we set it to `USER_INTENT_OUT_OF_SCOPE` by + # default. + intent = s.get("intent", USER_INTENT_OUT_OF_SCOPE) + + for e in entities: + entity_name = e["entity"] + if ":" not in entity_name: + continue + (name, role) = entity_name.rsplit(":", 1) + e[ENTITY_ATTRIBUTE_TYPE] = name + e[ENTITY_ATTRIBUTE_ROLE] = role + e[ENTITY_ATTRIBUTE_VALUE] = e.pop("body", None) + + data = {} + if intent: + data[INTENT] = intent + if entities is not None: + data[ENTITIES] = entities + data[TEXT] = text + training_examples.append(Message(data=data)) + return TrainingData(training_examples) diff --git a/rasa/shared/nlu/training_data/loading.py b/rasa/shared/nlu/training_data/loading.py new file mode 100644 index 0000000..4b05e61 --- /dev/null +++ b/rasa/shared/nlu/training_data/loading.py @@ -0,0 +1,137 @@ +import json +import logging +import os +import typing +from typing import Optional, Text, Callable, Dict, Any, List + +import rasa.shared.utils.io +from rasa.shared.nlu.training_data.formats.dialogflow import ( + DIALOGFLOW_AGENT, + DIALOGFLOW_ENTITIES, + DIALOGFLOW_ENTITY_ENTRIES, + DIALOGFLOW_INTENT, + DIALOGFLOW_INTENT_EXAMPLES, + DIALOGFLOW_PACKAGE, +) +from rasa.shared.nlu.training_data.training_data import TrainingData + +if typing.TYPE_CHECKING: + from rasa.shared.nlu.training_data.formats.readerwriter import TrainingDataReader + +logger = logging.getLogger(__name__) + +# Different supported file formats and their identifier +WIT = "wit" +LUIS = "luis" +RASA = "rasa_nlu" +RASA_YAML = "rasa_yml" +UNK = "unk" +DIALOGFLOW_RELEVANT = {DIALOGFLOW_ENTITIES, DIALOGFLOW_INTENT} + +_json_format_heuristics: Dict[Text, Callable[[Any, Text], bool]] = { + WIT: lambda js, fn: "utterances" in js and "luis_schema_version" not in js, + LUIS: lambda js, fn: "luis_schema_version" in js, + RASA: lambda js, fn: "rasa_nlu_data" in js, + DIALOGFLOW_AGENT: lambda js, fn: "supportedLanguages" in js, + DIALOGFLOW_PACKAGE: lambda js, fn: "version" in js and len(js) == 1, + DIALOGFLOW_INTENT: lambda js, fn: "responses" in js, + DIALOGFLOW_ENTITIES: lambda js, fn: "isEnum" in js, + DIALOGFLOW_INTENT_EXAMPLES: lambda js, fn: "_usersays_" in fn, + DIALOGFLOW_ENTITY_ENTRIES: lambda js, fn: "_entries_" in fn, +} + + +def load_data(resource_name: Text, language: Optional[Text] = "en") -> "TrainingData": + """Load training data from disk. + + Merges them if loaded from disk and multiple files are found.""" + if not os.path.exists(resource_name): + raise ValueError(f"File '{resource_name}' does not exist.") + + if os.path.isfile(resource_name): + files = [resource_name] + else: + files = rasa.shared.utils.io.list_files(resource_name) + + data_sets = [_load(f, language) for f in files] + training_data_sets: List[TrainingData] = [ds for ds in data_sets if ds] + if len(training_data_sets) == 0: + training_data = TrainingData() + elif len(training_data_sets) == 1: + training_data = training_data_sets[0] + else: + training_data = training_data_sets[0].merge(*training_data_sets[1:]) + + return training_data + + +def _reader_factory(fformat: Text) -> Optional["TrainingDataReader"]: + """Generates the appropriate reader class based on the file format.""" + from rasa.shared.nlu.training_data.formats import ( + RasaYAMLReader, + WitReader, + LuisReader, + RasaReader, + DialogflowReader, + ) + + reader: Optional["TrainingDataReader"] = None + if fformat == LUIS: + reader = LuisReader() + elif fformat == WIT: + reader = WitReader() + elif fformat in DIALOGFLOW_RELEVANT: + reader = DialogflowReader() + elif fformat == RASA: + reader = RasaReader() + elif fformat == RASA_YAML: + reader = RasaYAMLReader() + return reader + + +def _load(filename: Text, language: Optional[Text] = "en") -> Optional["TrainingData"]: + """Loads a single training data file from disk.""" + + fformat = guess_format(filename) + if fformat == UNK: + raise ValueError(f"Unknown data format for file '{filename}'.") + + reader = _reader_factory(fformat) + + if reader: + return reader.read(filename, language=language, fformat=fformat) + else: + return None + + +def guess_format(filename: Text) -> Text: + """Applies heuristics to guess the data format of a file. + + Args: + filename: file whose type should be guessed + + Returns: + Guessed file format. + """ + from rasa.shared.nlu.training_data.formats import RasaYAMLReader + + guess = UNK + + if not os.path.isfile(filename): + return guess + + try: + content = rasa.shared.utils.io.read_file(filename) + js = json.loads(content) + except ValueError: + if RasaYAMLReader.is_yaml_nlu_file(filename): + guess = RASA_YAML + else: + for file_format, format_heuristic in _json_format_heuristics.items(): + if format_heuristic(js, filename): + guess = file_format + break + + logger.debug(f"Training data format of '{filename}' is '{guess}'.") + + return guess diff --git a/rasa/shared/nlu/training_data/lookup_tables_parser.py b/rasa/shared/nlu/training_data/lookup_tables_parser.py new file mode 100644 index 0000000..82bbc5b --- /dev/null +++ b/rasa/shared/nlu/training_data/lookup_tables_parser.py @@ -0,0 +1,30 @@ +from typing import Text, List, Dict, Union + + +def add_item_to_lookup_tables( + title: Text, + item: Text, + existing_lookup_tables: List[Dict[Text, Union[Text, List[Text]]]], +) -> None: + """Add an item to a list of existing lookup tables. + + Takes a list of lookup table dictionaries. Finds the one associated + with the current lookup, then adds the item to the list. + + Args: + title: Name of the lookup item. + item: The lookup item. + existing_lookup_tables: Existing lookup items that will be extended. + + Raises: + TypeError: in case we're trying to add a lookup table element to a file. + This is an internal error that is indicative of a parsing error. + """ + matches = [table for table in existing_lookup_tables if table["name"] == title] + if not matches: + existing_lookup_tables.append({"name": title, "elements": [item]}) + else: + elements = matches[0]["elements"] + if not isinstance(elements, list): + raise TypeError("Cannot add a lookup table element to an unloaded file.") + elements.append(item) diff --git a/rasa/shared/nlu/training_data/message.py b/rasa/shared/nlu/training_data/message.py new file mode 100644 index 0000000..ed7bde2 --- /dev/null +++ b/rasa/shared/nlu/training_data/message.py @@ -0,0 +1,479 @@ +from typing import Any, Optional, Tuple, Text, Dict, Set, List + +import typing +import copy + +import rasa.shared.utils.io +from rasa.shared.exceptions import RasaException +from rasa.shared.nlu.constants import ( + TEXT, + INTENT, + RESPONSE, + INTENT_RESPONSE_KEY, + METADATA, + METADATA_INTENT, + METADATA_EXAMPLE, + ENTITIES, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + RESPONSE_IDENTIFIER_DELIMITER, + FEATURE_TYPE_SENTENCE, + FEATURE_TYPE_SEQUENCE, + ACTION_TEXT, + ACTION_NAME, + TEXT_TOKENS, +) +from rasa.shared.constants import DIAGNOSTIC_DATA + +if typing.TYPE_CHECKING: + from rasa.shared.nlu.training_data.features import Features + + +class Message: + """Container for data that can be used to describe a conversation turn. + + The turn is described by a set of attributes such as e.g. `TEXT` and `INTENT` + when describing a user utterance or e.g. `ACTION_NAME` for describing a bot action. + The container includes raw information (`self.data`) as well as features + (`self.features`) for each such attribute. + Moreover, the message has a timestamp and can keep track about information + on a specific subset of attributes (`self.output_properties`). + """ + + def __init__( + self, + data: Optional[Dict[Text, Any]] = None, + output_properties: Optional[Set] = None, + time: Optional[int] = None, + features: Optional[List["Features"]] = None, + **kwargs: Any, + ) -> None: + """Creates an instance of Message.""" + self.time = time + self.data = data.copy() if data else {} + self.features = features if features else [] + + self.data.update(**kwargs) + self._cached_fingerprint: Optional[Text] = None + + if output_properties: + self.output_properties = output_properties + else: + self.output_properties = set() + self.output_properties.add(TEXT) + + def add_features(self, features: Optional["Features"]) -> None: + """Add more vectorized features to the message.""" + if features is not None: + self.features.append(features) + self._cached_fingerprint = None + + def add_diagnostic_data(self, origin: Text, data: Dict[Text, Any]) -> None: + """Adds diagnostic data from the `origin` component. + + Args: + origin: Name of the component that created the data. + data: The diagnostic data. + """ + if origin in self.get(DIAGNOSTIC_DATA, {}): + rasa.shared.utils.io.raise_warning( + f"Please make sure every pipeline component has a distinct name. " + f"The name '{origin}' appears at least twice and diagnostic " + f"data will be overwritten." + ) + self.data.setdefault(DIAGNOSTIC_DATA, {}) + self.data[DIAGNOSTIC_DATA][origin] = data + self._cached_fingerprint = None + + def set(self, prop: Text, info: Any, add_to_output: bool = False) -> None: + """Sets the message's property to the given value. + + Args: + prop: Name of the property to be set. + info: Value to be assigned to that property. + add_to_output: Decides whether to add `prop` to the `output_properties`. + """ + self.data[prop] = info + if add_to_output: + self.output_properties.add(prop) + self._cached_fingerprint = None + + def get(self, prop: Text, default: Optional[Any] = None) -> Any: + """Retrieve message property.""" + return self.data.get(prop, default) + + def as_dict_nlu(self) -> dict: + """Get dict representation of message as it would appear in training data""" + + d = self.as_dict() + if d.get(INTENT, None): + d[INTENT] = self.get_full_intent() + d.pop(RESPONSE, None) + d.pop(INTENT_RESPONSE_KEY, None) + return d + + def as_dict(self, only_output_properties: bool = False) -> Dict: + """Gets dict representation of message.""" + if only_output_properties: + d = {} + for key, value in self.data.items(): + if key in self.output_properties: + if key == TEXT_TOKENS: + d[TEXT_TOKENS] = [(t.start, t.end) for t in value] + else: + d[key] = value + else: + d = self.data + + # Filter all keys with None value. These could have come while building the + # Message object in markdown format + return {key: value for key, value in d.items() if value is not None} + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Message): + return False + else: + return other.fingerprint() == self.fingerprint() + + def __hash__(self) -> int: + """Calculate a hash for the message. + + Returns: + Hash of the message. + """ + return int(self.fingerprint(), 16) + + def fingerprint(self) -> Text: + """Calculate a string fingerprint for the message. + + Returns: + Fingerprint of the message. + """ + if self._cached_fingerprint is None: + self._cached_fingerprint = rasa.shared.utils.io.deep_container_fingerprint( + [self.data, self.features] + ) + return self._cached_fingerprint + + @classmethod + def build( + cls, + text: Text, + intent: Optional[Text] = None, + entities: Optional[List[Dict[Text, Any]]] = None, + intent_metadata: Optional[Any] = None, + example_metadata: Optional[Any] = None, + **kwargs: Any, + ) -> "Message": + """Builds a Message from `UserUttered` data. + + Args: + text: text of a user's utterance + intent: an intent of the user utterance + entities: entities in the user's utterance + intent_metadata: optional metadata for the intent + example_metadata: optional metadata for the intent example + + Returns: + Message + """ + data: Dict[Text, Any] = {TEXT: text} + if intent: + split_intent, response_key = cls.separate_intent_response_key(intent) + if split_intent: + data[INTENT] = split_intent + if response_key: + # intent label can be of the form - {intent}/{response_key}, + # so store the full intent label in intent_response_key + data[INTENT_RESPONSE_KEY] = intent + if entities: + data[ENTITIES] = entities + if intent_metadata is not None: + data[METADATA] = {METADATA_INTENT: intent_metadata} + if example_metadata is not None: + data.setdefault(METADATA, {})[METADATA_EXAMPLE] = example_metadata + + return cls(data, **kwargs) + + def get_full_intent(self) -> Text: + """Get intent as it appears in training data""" + + return ( + self.get(INTENT_RESPONSE_KEY) + if self.get(INTENT_RESPONSE_KEY) + else self.get(INTENT) + ) + + @staticmethod + def separate_intent_response_key( + original_intent: Text, + ) -> Tuple[Text, Optional[Text]]: + """Splits intent into main intent name and optional sub-intent name. + + For example, `"FAQ/how_to_contribute"` would be split into + `("FAQ", "how_to_contribute")`. The response delimiter can + take different values (not just `"/"`) and depends on the + constant - `RESPONSE_IDENTIFIER_DELIMITER`. + If there is no response delimiter in the intent, the second tuple + item is `None`, e.g. `"FAQ"` would be mapped to `("FAQ", None)`. + """ + split_title = original_intent.split(RESPONSE_IDENTIFIER_DELIMITER) + if len(split_title) == 2: + return split_title[0], split_title[1] + elif len(split_title) == 1: + return split_title[0], None + + raise RasaException( + f"Intent name '{original_intent}' is invalid, " + f"it cannot contain more than one '{RESPONSE_IDENTIFIER_DELIMITER}'." + ) + + def get_sparse_features( + self, attribute: Text, featurizers: Optional[List[Text]] = None + ) -> Tuple[Optional["Features"], Optional["Features"]]: + """Gets all sparse features for the attribute given the list of featurizers. + + If no featurizers are provided, all available features will be considered. + + Args: + attribute: message attribute + featurizers: names of featurizers to consider + + Returns: + Sparse features. + """ + if featurizers is None: + featurizers = [] + + sequence_features, sentence_features = self._filter_sparse_features( + attribute, featurizers + ) + + combined_sequence_features = self._combine_features( + sequence_features, featurizers + ) + combined_sentence_features = self._combine_features( + sentence_features, featurizers + ) + + return combined_sequence_features, combined_sentence_features + + def get_sparse_feature_sizes( + self, attribute: Text, featurizers: Optional[List[Text]] = None + ) -> Dict[Text, List[int]]: + """Gets sparse feature sizes for the attribute given the list of featurizers. + + If no featurizers are provided, all available features will be considered. + + Args: + attribute: message attribute + featurizers: names of featurizers to consider + + Returns: + Sparse feature sizes. + """ + if featurizers is None: + featurizers = [] + + sequence_features, sentence_features = self._filter_sparse_features( + attribute, featurizers + ) + sequence_sizes = [f.features.shape[1] for f in sequence_features] + sentence_sizes = [f.features.shape[1] for f in sentence_features] + + return { + FEATURE_TYPE_SEQUENCE: sequence_sizes, + FEATURE_TYPE_SENTENCE: sentence_sizes, + } + + def get_dense_features( + self, attribute: Text, featurizers: Optional[List[Text]] = None + ) -> Tuple[Optional["Features"], Optional["Features"]]: + """Gets all dense features for the attribute given the list of featurizers. + + If no featurizers are provided, all available features will be considered. + + Args: + attribute: message attribute + featurizers: names of featurizers to consider + + Returns: + Dense features. + """ + if featurizers is None: + featurizers = [] + + sequence_features, sentence_features = self._filter_dense_features( + attribute, featurizers + ) + + combined_sequence_features = self._combine_features( + sequence_features, featurizers + ) + combined_sentence_features = self._combine_features( + sentence_features, featurizers + ) + + return combined_sequence_features, combined_sentence_features + + def get_all_features( + self, attribute: Text, featurizers: Optional[List[Text]] = None + ) -> List["Features"]: + """Gets all features for the attribute given the list of featurizers. + + If no featurizers are provided, all available features will be considered. + + Args: + attribute: message attribute + featurizers: names of featurizers to consider + + Returns: + Features. + """ + sparse_features = self.get_sparse_features(attribute, featurizers) + dense_features = self.get_dense_features(attribute, featurizers) + + return [f for f in sparse_features + dense_features if f is not None] + + def features_present( + self, attribute: Text, featurizers: Optional[List[Text]] = None + ) -> bool: + """Checks if there are any features present for the attribute and featurizers. + + If no featurizers are provided, all available features will be considered. + + Args: + attribute: Message attribute. + featurizers: Names of featurizers to consider. + + Returns: + ``True``, if features are present, ``False`` otherwise. + """ + if featurizers is None: + featurizers = [] + + ( + sequence_sparse_features, + sentence_sparse_features, + ) = self._filter_sparse_features(attribute, featurizers) + sequence_dense_features, sentence_dense_features = self._filter_dense_features( + attribute, featurizers + ) + + return ( + len(sequence_sparse_features) > 0 + or len(sentence_sparse_features) > 0 + or len(sequence_dense_features) > 0 + or len(sentence_dense_features) > 0 + ) + + def _filter_dense_features( + self, attribute: Text, featurizers: List[Text] + ) -> Tuple[List["Features"], List["Features"]]: + sentence_features = [ + f + for f in self.features + if f.attribute == attribute + and f.is_dense() + and f.type == FEATURE_TYPE_SENTENCE + and (f.origin in featurizers or not featurizers) + ] + sequence_features = [ + f + for f in self.features + if f.attribute == attribute + and f.is_dense() + and f.type == FEATURE_TYPE_SEQUENCE + and (f.origin in featurizers or not featurizers) + ] + return sequence_features, sentence_features + + def _filter_sparse_features( + self, attribute: Text, featurizers: List[Text] + ) -> Tuple[List["Features"], List["Features"]]: + sentence_features = [ + f + for f in self.features + if f.attribute == attribute + and f.is_sparse() + and f.type == FEATURE_TYPE_SENTENCE + and (f.origin in featurizers or not featurizers) + ] + sequence_features = [ + f + for f in self.features + if f.attribute == attribute + and f.is_sparse() + and f.type == FEATURE_TYPE_SEQUENCE + and (f.origin in featurizers or not featurizers) + ] + + return sequence_features, sentence_features + + @staticmethod + def _combine_features( + features: List["Features"], featurizers: List[Text] + ) -> Optional["Features"]: + combined_features = None + + for f in features: + if combined_features is None: + combined_features = copy.deepcopy(f) + combined_features.origin = featurizers + else: + combined_features.combine_with_features(f) + + return combined_features + + def is_core_or_domain_message(self) -> bool: + """Checks whether the message is a core message or from the domain. + + E.g. a core message is created from a story or a domain action, + not from the NLU data. + + Returns: + True, if message is a core or domain message, false otherwise. + """ + return bool( + self.data.get(ACTION_NAME) + or self.data.get(ACTION_TEXT) + or ( + (self.data.get(INTENT) or self.data.get(RESPONSE)) + and not self.data.get(TEXT) + ) + or ( + self.data.get(TEXT) + and not (self.data.get(INTENT) or self.data.get(RESPONSE)) + ) + ) + + def is_e2e_message(self) -> bool: + """Checks whether the message came from an e2e story. + + Returns: + `True`, if message is a from an e2e story, `False` otherwise. + """ + return bool( + (self.get(ACTION_TEXT) and not self.get(ACTION_NAME)) + or (self.get(TEXT) and not self.get(INTENT)) + ) + + def find_overlapping_entities( + self, + ) -> List[Tuple[Dict[Text, Any], Dict[Text, Any]]]: + """Finds any overlapping entity annotations.""" + entities = self.get(ENTITIES, [])[:] + entities_with_location = [ + e + for e in entities + if (ENTITY_ATTRIBUTE_START in e.keys() and ENTITY_ATTRIBUTE_END in e.keys()) + ] + entities_with_location.sort(key=lambda e: e[ENTITY_ATTRIBUTE_START]) + overlapping_pairs: List[Tuple[Dict[Text, Any], Dict[Text, Any]]] = [] + for i, entity in enumerate(entities_with_location): + for other_entity in entities_with_location[i + 1 :]: + if other_entity[ENTITY_ATTRIBUTE_START] < entity[ENTITY_ATTRIBUTE_END]: + overlapping_pairs.append((entity, other_entity)) + else: + break + return overlapping_pairs diff --git a/rasa/shared/nlu/training_data/schemas/__init__.py b/rasa/shared/nlu/training_data/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/shared/nlu/training_data/schemas/data_schema.py b/rasa/shared/nlu/training_data/schemas/data_schema.py new file mode 100644 index 0000000..9924368 --- /dev/null +++ b/rasa/shared/nlu/training_data/schemas/data_schema.py @@ -0,0 +1,85 @@ +from typing import Dict, Text, Any + + +def entity_dict_schema() -> Dict[Text, Any]: + """Returns: schema for defining entities.""" + return { + "type": "object", + "properties": _common_entity_properties(), + "required": ["entity"], + } + + +def _common_entity_properties() -> Dict[Text, Any]: + return { + "entity": {"type": "string"}, + "role": {"type": "string"}, + "group": {"type": "string"}, + "value": {"type": ["string", "number"]}, + } + + +def rasa_nlu_data_schema() -> Dict[Text, Any]: + """Returns: schema of the Rasa NLU data format (json format).""" + entity_properties = _common_entity_properties() + entity_properties["start"] = {"type": "number"} + entity_properties["end"] = {"type": "number"} + + training_example_schema = { + "type": "object", + "properties": { + "intent": {"type": "string"}, + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": entity_properties, + "required": ["start", "end", "entity"], + }, + }, + }, + } + + regex_feature_schema = { + "type": "object", + "properties": {"name": {"type": "string"}, "pattern": {"type": "string"}}, + } + + lookup_table_schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "elements": { + "oneOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "string"}, + ] + }, + }, + } + + return { + "type": "object", + "properties": { + "rasa_nlu_data": { + "type": "object", + "properties": { + "regex_features": {"type": "array", "items": regex_feature_schema}, + "common_examples": { + "type": "array", + "items": training_example_schema, + }, + "intent_examples": { + "type": "array", + "items": training_example_schema, + }, + "entity_examples": { + "type": "array", + "items": training_example_schema, + }, + "lookup_tables": {"type": "array", "items": lookup_table_schema}, + }, + } + }, + "additionalProperties": False, + } diff --git a/rasa/shared/nlu/training_data/schemas/nlu.yml b/rasa/shared/nlu/training_data/schemas/nlu.yml new file mode 100644 index 0000000..2162251 --- /dev/null +++ b/rasa/shared/nlu/training_data/schemas/nlu.yml @@ -0,0 +1,53 @@ +allowempty: True +mapping: + version: + type: "str" + required: False + allowempty: False + nlu: + type: "seq" + matching: "any" + sequence: + - type: "map" + mapping: + intent: &intent_anchor + type: "str" + allowempty: False + metadata: &metadata_anchor + type: "any" + required: False + examples: &examples_anchor + type: "str" + - type: "map" + mapping: + intent: *intent_anchor + metadata: *metadata_anchor + examples: + type: "seq" + sequence: + - type: "map" + mapping: + text: + type: "str" + allowempty: False + metadata: *metadata_anchor + - type: "map" + mapping: + synonym: + type: "str" + examples: *examples_anchor + - type: "map" + mapping: + regex: + type: "str" + examples: *examples_anchor + - type: "map" + mapping: + lookup: + type: "str" + examples: *examples_anchor + responses: + # see rasa/shared/nlu/training_data/schemas/responses.yml + include: responses + regex;(.*): + type: "any" diff --git a/rasa/shared/nlu/training_data/schemas/responses.yml b/rasa/shared/nlu/training_data/schemas/responses.yml new file mode 100644 index 0000000..ee4d9d5 --- /dev/null +++ b/rasa/shared/nlu/training_data/schemas/responses.yml @@ -0,0 +1,70 @@ +schema;responses: + type: "map" + allowempty: True + mapping: + regex;(.+): + type: "seq" + required: False + nullable: False + func: require_response_keys + sequence: + - type: "map" + required: True + allowempty: False + mapping: + id: + type: "str" + required: False + text: + type: "str" + image: + type: "str" + custom: + type: "map" + allowempty: True + buttons: + type: "seq" + sequence: + - type: "map" + allowempty: True + mapping: + title: + type: "str" + payload: + type: "str" + button_type: + type: "str" + quick_replies: + type: "seq" + sequence: + - type: "map" + allowempty: True + mapping: + title: + type: "str" + payload: + type: "str" + attachment: + type: "map" + allowempty: True + elements: + type: "seq" + sequence: + - type: "map" + allowempty: True + channel: + type: "str" + metadata: + type: "any" + condition: + type: "seq" + sequence: + - type: "map" + allowempty: False + mapping: + type: + type: "str" + name: + type: "str" + value: + type: "any" diff --git a/rasa/shared/nlu/training_data/synonyms_parser.py b/rasa/shared/nlu/training_data/synonyms_parser.py new file mode 100644 index 0000000..7aa8520 --- /dev/null +++ b/rasa/shared/nlu/training_data/synonyms_parser.py @@ -0,0 +1,42 @@ +from typing import Any, Text, List, Dict + +from rasa.shared.nlu.constants import ( + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, +) + + +def add_synonyms_from_entities( + plain_text: Text, entities: List[Dict], existing_synonyms: Dict[Text, Any] +) -> None: + """Adds synonyms found in intent examples. + + Args: + plain_text: Plain (with removed special symbols) user utterance. + entities: Entities that were extracted from the original user utterance. + existing_synonyms: The dict with existing synonyms mappings that will + be extended. + """ + for e in entities: + e_text = plain_text[e[ENTITY_ATTRIBUTE_START] : e[ENTITY_ATTRIBUTE_END]] + if e_text != e[ENTITY_ATTRIBUTE_VALUE]: + add_synonym(e_text, e[ENTITY_ATTRIBUTE_VALUE], existing_synonyms) + + +def add_synonym( + synonym_value: Text, synonym_name: Text, existing_synonyms: Dict[Text, Any] +) -> None: + """Adds a new synonym mapping to the provided list of synonyms. + + Args: + synonym_value: Value of the synonym. + synonym_name: Name of the synonym. + existing_synonyms: Dictionary will synonym mappings that will be extended. + """ + import rasa.shared.nlu.training_data.util as training_data_util + + training_data_util.check_duplicate_synonym( + existing_synonyms, synonym_value, synonym_name, "reading markdown" + ) + existing_synonyms[synonym_value] = synonym_name diff --git a/rasa/shared/nlu/training_data/training_data.py b/rasa/shared/nlu/training_data/training_data.py new file mode 100644 index 0000000..7e7df90 --- /dev/null +++ b/rasa/shared/nlu/training_data/training_data.py @@ -0,0 +1,738 @@ +import logging +import os +from pathlib import Path +import random +from collections import Counter, OrderedDict +import copy +from os.path import relpath +from typing import Any, Dict, List, Optional, Set, Text, Tuple, Callable +import operator + +import rasa.shared.data +from rasa.shared.utils.common import lazy_property +import rasa.shared.utils.io +from rasa.shared.nlu.constants import ( + RESPONSE, + INTENT_RESPONSE_KEY, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_GROUP, + ENTITY_ATTRIBUTE_ROLE, + NO_ENTITY_TAG, + INTENT, + ENTITIES, + TEXT, + ACTION_NAME, +) +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data import util + + +DEFAULT_TRAINING_DATA_OUTPUT_PATH = "training_data.yml" + +logger = logging.getLogger(__name__) + + +class TrainingData: + """Holds loaded intent and entity training data.""" + + # Validation will ensure and warn if these lower limits are not met + MIN_EXAMPLES_PER_INTENT = 2 + MIN_EXAMPLES_PER_ENTITY = 2 + + def __init__( + self, + training_examples: Optional[List[Message]] = None, + entity_synonyms: Optional[Dict[Text, Text]] = None, + regex_features: Optional[List[Dict[Text, Text]]] = None, + lookup_tables: Optional[List[Dict[Text, Any]]] = None, + responses: Optional[Dict[Text, List[Dict[Text, Any]]]] = None, + ) -> None: + + if training_examples: + self.training_examples = self.sanitize_examples(training_examples) + else: + self.training_examples = [] + self.entity_synonyms = entity_synonyms or {} + self.regex_features = regex_features or [] + self.sort_regex_features() + self.lookup_tables = lookup_tables or [] + self.responses = responses or {} + + self._fill_response_phrases() + + @staticmethod + def _load_lookup_table(lookup_table: Dict[Text, Any]) -> Dict[Text, Any]: + """Loads the actual lookup table from file if there is a file specified. + + Checks if the specified lookup table contains a filename in + `elements` and replaces it with actual elements from the file. + Returns the unchanged lookup table otherwise. + It works with JSON training data. + + Params: + lookup_table: A lookup table. + + Returns: + Updated lookup table where filenames are replaced with the contents of + these files. + """ + elements = lookup_table["elements"] + potential_file = elements if isinstance(elements, str) else elements[0] + + if Path(potential_file).is_file(): + try: + lookup_table["elements"] = rasa.shared.utils.io.read_file( + potential_file + ) + return lookup_table + except (FileNotFoundError, UnicodeDecodeError): + return lookup_table + + return lookup_table + + def fingerprint(self) -> Text: + """Fingerprint the training data. + + Returns: + hex string as a fingerprint of the training data. + """ + relevant_attributes = { + "training_examples": list( + sorted(e.fingerprint() for e in self.training_examples) + ), + "entity_synonyms": self.entity_synonyms, + "regex_features": self.regex_features, + "lookup_tables": [ + self._load_lookup_table(table) for table in self.lookup_tables + ], + "responses": self.responses, + } + return rasa.shared.utils.io.deep_container_fingerprint(relevant_attributes) + + def label_fingerprint(self) -> Text: + """Fingerprints the labels in the training data. + + Returns: + hex string as a fingerprint of the training data labels. + """ + labels = { + "intents": sorted(self.intents), + "entities": sorted(self.entities), + "entity_groups": sorted(self.entity_groups), + "entity_roles": sorted(self.entity_roles), + "actions": sorted(self.action_names), + } + return rasa.shared.utils.io.deep_container_fingerprint(labels) + + def merge(self, *others: Optional["TrainingData"]) -> "TrainingData": + """Return merged instance of this data with other training data. + + Args: + others: other training data instances to merge this one with + + Returns: + Merged training data object. Merging is not done in place, this + will be a new instance. + """ + training_examples = copy.deepcopy(self.training_examples) + entity_synonyms = self.entity_synonyms.copy() + regex_features = copy.deepcopy(self.regex_features) + lookup_tables = copy.deepcopy(self.lookup_tables) + responses = copy.deepcopy(self.responses) + + for o in others: + if not o: + continue + + training_examples.extend(copy.deepcopy(o.training_examples)) + regex_features.extend(copy.deepcopy(o.regex_features)) + lookup_tables.extend(copy.deepcopy(o.lookup_tables)) + + for text, syn in o.entity_synonyms.items(): + util.check_duplicate_synonym( + entity_synonyms, text, syn, "merging training data" + ) + + entity_synonyms.update(o.entity_synonyms) + responses.update(o.responses) + + return TrainingData( + training_examples, entity_synonyms, regex_features, lookup_tables, responses + ) + + def filter_training_examples( + self, condition: Callable[[Message], bool] + ) -> "TrainingData": + """Filter training examples. + + Args: + condition: A function that will be applied to filter training examples. + + Returns: + TrainingData: A TrainingData with filtered training examples. + """ + + return TrainingData( + list(filter(condition, self.training_examples)), + self.entity_synonyms, + self.regex_features, + self.lookup_tables, + self.responses, + ) + + def __hash__(self) -> int: + """Calculate hash for the training data object. + + Returns: + Hash of the training data object. + """ + return int(self.fingerprint(), 16) + + @staticmethod + def sanitize_examples(examples: List[Message]) -> List[Message]: + """Makes sure the training data is clean. + + Remove trailing whitespaces from intent and response annotations and drop + duplicate examples. + """ + + for ex in examples: + if ex.get(INTENT): + ex.set(INTENT, ex.get(INTENT).strip()) + + if ex.get(RESPONSE): + ex.set(RESPONSE, ex.get(RESPONSE).strip()) + + return list(OrderedDict.fromkeys(examples)) + + @lazy_property + def nlu_examples(self) -> List[Message]: + """Return examples which have come from NLU training data. + + E.g. If the example came from a story or domain it is not included. + + Returns: + List of NLU training examples. + """ + return [ + ex for ex in self.training_examples if not ex.is_core_or_domain_message() + ] + + @lazy_property + def intent_examples(self) -> List[Message]: + """Returns the list of examples that have intent.""" + return [ex for ex in self.nlu_examples if ex.get(INTENT)] + + @lazy_property + def response_examples(self) -> List[Message]: + """Returns the list of examples that have response.""" + return [ex for ex in self.nlu_examples if ex.get(INTENT_RESPONSE_KEY)] + + @lazy_property + def entity_examples(self) -> List[Message]: + """Returns the list of examples that have entities.""" + return [ex for ex in self.nlu_examples if ex.get(ENTITIES)] + + @lazy_property + def intents(self) -> Set[Text]: + """Returns the set of intents in the training data.""" + return {ex.get(INTENT) for ex in self.training_examples} - {None} + + @lazy_property + def action_names(self) -> Set[Text]: + """Returns the set of action names in the training data.""" + return {ex.get(ACTION_NAME) for ex in self.training_examples} - {None} + + @lazy_property + def retrieval_intents(self) -> Set[Text]: + """Returns the total number of response types in the training data.""" + return { + ex.get(INTENT) + for ex in self.training_examples + if ex.get(INTENT_RESPONSE_KEY) + } + + @lazy_property + def number_of_examples_per_intent(self) -> Dict[Text, int]: + """Calculates the number of examples per intent.""" + intents = [ex.get(INTENT) for ex in self.nlu_examples] + return dict(Counter(intents)) + + @lazy_property + def number_of_examples_per_response(self) -> Dict[Text, int]: + """Calculates the number of examples per response.""" + responses = [ + ex.get(INTENT_RESPONSE_KEY) + for ex in self.training_examples + if ex.get(INTENT_RESPONSE_KEY) + ] + return dict(Counter(responses)) + + @lazy_property + def entities(self) -> Set[Text]: + """Returns the set of entity types in the training data.""" + return {e.get(ENTITY_ATTRIBUTE_TYPE) for e in self.sorted_entities()} + + @lazy_property + def entity_roles(self) -> Set[Text]: + """Returns the set of entity roles in the training data.""" + entity_types = { + e.get(ENTITY_ATTRIBUTE_ROLE) + for e in self.sorted_entities() + if ENTITY_ATTRIBUTE_ROLE in e + } + return entity_types - {NO_ENTITY_TAG} + + @lazy_property + def entity_groups(self) -> Set[Text]: + """Returns the set of entity groups in the training data.""" + entity_types = { + e.get(ENTITY_ATTRIBUTE_GROUP) + for e in self.sorted_entities() + if ENTITY_ATTRIBUTE_GROUP in e + } + return entity_types - {NO_ENTITY_TAG} + + def entity_roles_groups_used(self) -> bool: + """Checks if any entity roles or groups are used in the training data.""" + entity_groups_used = ( + self.entity_groups is not None and len(self.entity_groups) > 0 + ) + entity_roles_used = self.entity_roles is not None and len(self.entity_roles) > 0 + + return entity_groups_used or entity_roles_used + + @lazy_property + def number_of_examples_per_entity(self) -> Dict[Text, int]: + """Calculates the number of examples per entity.""" + + entities = [] + + def _append_entity(entity: Dict[Text, Any], attribute: Text) -> None: + if attribute in entity: + _value = entity.get(attribute) + if _value is not None and _value != NO_ENTITY_TAG: + entities.append(f"{attribute} '{_value}'") + + for entity in self.sorted_entities(): + _append_entity(entity, ENTITY_ATTRIBUTE_TYPE) + _append_entity(entity, ENTITY_ATTRIBUTE_ROLE) + _append_entity(entity, ENTITY_ATTRIBUTE_GROUP) + + return dict(Counter(entities)) + + def sort_regex_features(self) -> None: + """Sorts regex features lexicographically by name+pattern""" + self.regex_features = sorted( + self.regex_features, key=lambda e: "{}+{}".format(e["name"], e["pattern"]) + ) + + def _fill_response_phrases(self) -> None: + """Set response phrase for all examples by looking up NLG stories.""" + for example in self.training_examples: + # if intent_response_key is None, that means the corresponding intent is + # not a retrieval intent and hence no response text needs to be fetched. + # If intent_response_key is set, fetch the corresponding response text + if example.get(INTENT_RESPONSE_KEY) is None: + continue + + # look for corresponding bot utterance + story_lookup_key = util.intent_response_key_to_template_key( + example.get_full_intent() + ) + assistant_utterances = self.responses.get(story_lookup_key, []) + if assistant_utterances: + + # Use the first response text as training label if needed downstream + for assistant_utterance in assistant_utterances: + if assistant_utterance.get(TEXT): + example.set(RESPONSE, assistant_utterance[TEXT]) + + # If no text attribute was found use the key for training + if not example.get(RESPONSE): + example.set(RESPONSE, story_lookup_key) + + def nlu_as_json(self, **kwargs: Any) -> Text: + """Represent this set of training examples as json.""" + from rasa.shared.nlu.training_data.formats import RasaWriter + + return RasaWriter().dumps(self, **kwargs) + + def nlg_as_yaml(self) -> Text: + """Generates yaml representation of the response phrases (NLG) of TrainingData. + + Returns: + responses in yaml format as a string + """ + from rasa.shared.nlu.training_data.formats.rasa_yaml import RasaYAMLWriter + + # only dump responses. at some point it might make sense to remove the + # differentiation between dumping NLU and dumping responses. but we + # can't do that until after we remove markdown support. + return RasaYAMLWriter().dumps(TrainingData(responses=self.responses)) + + def nlu_as_yaml(self) -> Text: + """Generates YAML representation of NLU of TrainingData. + + Returns: + data in YAML format as a string + """ + from rasa.shared.nlu.training_data.formats.rasa_yaml import RasaYAMLWriter + + # avoid dumping NLG data (responses). this is a workaround until we + # can remove the distinction between nlu & nlg when converting to a string + # (so until after we remove markdown support) + no_responses_training_data = copy.copy(self) + no_responses_training_data.responses = {} + + return RasaYAMLWriter().dumps(no_responses_training_data) + + def persist_nlu(self, filename: Text = DEFAULT_TRAINING_DATA_OUTPUT_PATH) -> None: + """Saves NLU to a file.""" + if rasa.shared.data.is_likely_json_file(filename): + rasa.shared.utils.io.write_text_file(self.nlu_as_json(indent=2), filename) + elif rasa.shared.data.is_likely_yaml_file(filename): + rasa.shared.utils.io.write_text_file(self.nlu_as_yaml(), filename) + else: + raise ValueError( + "Unsupported file format detected. " + "Supported file formats are 'json', 'yml' " + "and 'md'." + ) + + def persist_nlg(self, filename: Text) -> None: + """Saves NLG to a file.""" + if rasa.shared.data.is_likely_yaml_file(filename): + rasa.shared.utils.io.write_text_file(self.nlg_as_yaml(), filename) + else: + raise ValueError( + "Unsupported file format detected. 'yml' is the only " + "supported file format." + ) + + @staticmethod + def get_nlg_persist_filename(nlu_filename: Text) -> Text: + """Returns the full filename to persist NLG data.""" + extension = Path(nlu_filename).suffix + if rasa.shared.data.is_likely_json_file(nlu_filename): + # backwards compatibility: previously NLG was always dumped as md. now + # we are going to dump in the same format as the NLU data. unfortunately + # there is a special case: NLU is in json format, in this case we use + # YAML as we do not have a NLG json format + extension = rasa.shared.data.yaml_file_extension() + # Add nlg_ as prefix and change extension to the correct one + filename = ( + Path(nlu_filename) + .with_name("nlg_" + Path(nlu_filename).name) + .with_suffix(extension) + ) + return str(filename) + + def persist( + self, dir_name: Text, filename: Text = DEFAULT_TRAINING_DATA_OUTPUT_PATH + ) -> Dict[Text, Any]: + """Persists this training data to disk and returns necessary + information to load it again.""" + + if not os.path.exists(dir_name): + os.makedirs(dir_name) + + nlu_data_file = os.path.join(dir_name, filename) + self.persist_nlu(nlu_data_file) + self.persist_nlg(self.get_nlg_persist_filename(nlu_data_file)) + + return {"training_data": relpath(nlu_data_file, dir_name)} + + def sorted_entities(self) -> List[Any]: + """Extract all entities from examples and sorts them by entity type.""" + + entity_examples = [ + entity for ex in self.entity_examples for entity in ex.get("entities") + ] + return sorted(entity_examples, key=lambda e: e["entity"]) + + def validate(self) -> None: + """Ensures that the loaded training data is valid. + + Checks that the data has a minimum of certain training examples. + """ + logger.debug("Validating training data...") + if "" in self.intents: + rasa.shared.utils.io.raise_warning( + "Found empty intent, please check your " + "training data. This may result in wrong " + "intent predictions." + ) + + if "" in self.responses: + rasa.shared.utils.io.raise_warning( + "Found empty response, please check your " + "training data. This may result in wrong " + "response predictions." + ) + + # emit warnings for intents with only a few training samples + for intent, count in self.number_of_examples_per_intent.items(): + if count < self.MIN_EXAMPLES_PER_INTENT: + rasa.shared.utils.io.raise_warning( + f"Intent '{intent}' has only {count} training examples! " + f"Minimum is {self.MIN_EXAMPLES_PER_INTENT}, training may fail." + ) + + # emit warnings for entities with only a few training samples + for entity, count in self.number_of_examples_per_entity.items(): + if count < self.MIN_EXAMPLES_PER_ENTITY: + rasa.shared.utils.io.raise_warning( + f"Entity {entity} has only {count} training examples! " + f"The minimum is {self.MIN_EXAMPLES_PER_ENTITY}, because of " + f"this the training may fail." + ) + + # emit warnings for response intents without a response template + for example in self.training_examples: + if example.get(INTENT_RESPONSE_KEY) and not example.get(RESPONSE): + rasa.shared.utils.io.raise_warning( + f"Your training data contains an example " + f"'{example.get(TEXT)[:20]}...' " + f"for the '{example.get_full_intent()}' intent. " + f"You either need to add a response phrase or correct the " + f"intent for this example in your training data. " + f"If you intend to use Response Selector in the pipeline, the " + f"training may fail." + ) + + def train_test_split( + self, train_frac: float = 0.8, random_seed: Optional[int] = None + ) -> Tuple["TrainingData", "TrainingData"]: + """Split into a training and test dataset, + preserving the fraction of examples per intent.""" + + # collect all nlu data + test, train = self.split_nlu_examples(train_frac, random_seed) + + # collect all nlg stories + test_responses = self._needed_responses_for_examples(test) + train_responses = self._needed_responses_for_examples(train) + + data_train = TrainingData( + train, + entity_synonyms=self.entity_synonyms, + regex_features=self.regex_features, + lookup_tables=self.lookup_tables, + responses=train_responses, + ) + + data_test = TrainingData( + test, + entity_synonyms=self.entity_synonyms, + regex_features=self.regex_features, + lookup_tables=self.lookup_tables, + responses=test_responses, + ) + + return data_train, data_test + + def _needed_responses_for_examples( + self, examples: List[Message] + ) -> Dict[Text, List[Dict[Text, Any]]]: + """Get all responses used in any of the examples. + + Args: + examples: messages to select responses by. + + Returns: + All responses that appear at least once in the list of examples. + """ + + responses = {} + for ex in examples: + if ex.get(INTENT_RESPONSE_KEY) and ex.get(RESPONSE): + key = util.intent_response_key_to_template_key(ex.get_full_intent()) + responses[key] = self.responses[key] + return responses + + def split_nlu_examples( + self, train_frac: float, random_seed: Optional[int] = None + ) -> Tuple[list, list]: + """Split the training data into a train and test set. + + Args: + train_frac: percentage of examples to add to the training set. + random_seed: random seed used to shuffle examples. + + Returns: + Test and training examples. + """ + + self.validate() + + # Stratified split: both test and train should have (approximately) the + # same class distribution as the original data. We also require that + # each class is represented in both splits. + + # First check that there is enough data to split at the requested + # rate: we must be able to include one example per class in both + # test and train, so num_classes is the minimum size of either. + smaller_split_frac = train_frac if train_frac < 0.5 else (1.0 - train_frac) + num_classes = ( + len(self.number_of_examples_per_intent.items()) + - len(self.retrieval_intents) + + len(self.number_of_examples_per_response) + ) + num_examples = sum(self.number_of_examples_per_intent.values()) + + if int(smaller_split_frac * num_examples) + 1 < num_classes: + rasa.shared.utils.io.raise_warning( + f"There aren't enough intent examples in your data to include " + f"an example of each class in both test and train splits and " + f"also reserve {train_frac} of the data for training. " + f"The output training fraction will differ." + ) + + # Now simulate traversing the sorted examples, sampling at a rate + # of train_frac, so that after traversing k examples (for all k), we + # have sampled int(k * train_frac) of them for training. + # Corner case that makes this approximate: we require at least one sample + # in test, and at least one in train, so proportions will be less exact + # when classes have few examples, e.g. when a class has only 2 examples + # but the user requests an 80% / 20% split. + + train, test = [], [] + + # helper to simulate the traversal of all examples in a single class + def _split_class( + _examples: List[Message], _running_count: int, _running_train_count: int + ) -> Tuple[int, int]: + if random_seed is not None: + random.Random(random_seed).shuffle(_examples) + else: + random.shuffle(_examples) + + # first determine how many samples we should have in training after + # traversing the examples in this class, if sampling train_frac of + # them. Then adjust so there's at least one example in test and train. + # Adjustment can accumulate until we encounter a frequent class. + exact_train_count = ( + int((_running_count + len(_examples)) * train_frac) + - _running_train_count + ) + approx_train_count = min(len(_examples) - 1, max(1, exact_train_count)) + + train.extend(_examples[:approx_train_count]) + test.extend(_examples[approx_train_count:]) + + return ( + _running_count + len(_examples), + _running_train_count + approx_train_count, + ) + + training_examples = set(self.training_examples) + running_count = 0 + running_train_count = 0 + + # Sort by class frequency so we first handle the tail of the distribution, + # where the percentages in the split are most approximate. Items from + # more frequent classes can then be over/ undersampled as needed to + # meet the requested train_frac. First for responses: + for response, _ in sorted( + self.number_of_examples_per_response.items(), key=operator.itemgetter(1) + ): + examples = [ + e + for e in training_examples + if e.get(INTENT_RESPONSE_KEY) and e.get(INTENT_RESPONSE_KEY) == response + ] + running_count, running_train_count = _split_class( + examples, running_count, running_train_count + ) + training_examples = training_examples - set(examples) + + # Again for intents: + for intent, _ in sorted( + self.number_of_examples_per_intent.items(), key=operator.itemgetter(1) + ): + examples = [ + e + for e in training_examples + if INTENT in e.data and e.data[INTENT] == intent + ] + if len(examples) > 0: # will be 0 for retrieval intents + running_count, running_train_count = _split_class( + examples, running_count, running_train_count + ) + training_examples = training_examples - set(examples) + + return test, train + + def print_stats(self) -> None: + number_of_examples_for_each_intent = [] + for intent_name, example_count in self.number_of_examples_per_intent.items(): + number_of_examples_for_each_intent.append( + f"intent: {intent_name}, training examples: {example_count} " + ) + newline = "\n" + + logger.info("Training data stats:") + logger.info( + f"Number of intent examples: {len(self.intent_examples)} " + f"({len(self.intents)} distinct intents)" + "\n" + ) + # log the number of training examples per intent + + logger.debug(f"{newline.join(number_of_examples_for_each_intent)}") + + if self.intents: + logger.info(f" Found intents: {list_to_str(self.intents)}") + logger.info( + f"Number of response examples: {len(self.response_examples)} " + f"({len(self.responses)} distinct responses)" + ) + logger.info( + f"Number of entity examples: {len(self.entity_examples)} " + f"({len(self.entities)} distinct entities)" + ) + if self.entities: + logger.info(f" Found entity types: {list_to_str(self.entities)}") + if self.entity_roles: + logger.info(f" Found entity roles: {list_to_str(self.entity_roles)}") + if self.entity_groups: + logger.info(f" Found entity groups: {list_to_str(self.entity_groups)}") + + def is_empty(self) -> bool: + """Checks if any training data was loaded.""" + lists_to_check = [ + self.training_examples, + self.entity_synonyms, + self.regex_features, + self.lookup_tables, + ] + return not any([len(lst) > 0 for lst in lists_to_check]) + + def contains_no_pure_nlu_data(self) -> bool: + """Checks if any NLU training data was loaded.""" + lists_to_check = [ + self.nlu_examples, + self.entity_synonyms, + self.regex_features, + self.lookup_tables, + ] + return not any([len(lst) > 0 for lst in lists_to_check]) + + def has_e2e_examples(self) -> bool: + """Checks if there are any training examples from e2e stories.""" + return any(message.is_e2e_message() for message in self.training_examples) + + +def list_to_str(lst: List[Text], delim: Text = ", ", quote: Text = "'") -> Text: + """Converts list to a string. + + Args: + lst: The list to convert. + delim: The delimiter that is used to separate list inputs. + quote: The quote that is used to wrap list inputs. + + Returns: + The string. + """ + return delim.join([quote + e + quote for e in lst]) diff --git a/rasa/shared/nlu/training_data/util.py b/rasa/shared/nlu/training_data/util.py new file mode 100644 index 0000000..56ff34b --- /dev/null +++ b/rasa/shared/nlu/training_data/util.py @@ -0,0 +1,225 @@ +import json +import logging +import os +import re +from typing import Any, Dict, Optional, Text, Match, List + +import scipy.sparse +from rasa.shared.nlu.constants import ( + ENTITIES, + EXTRACTOR, + PRETRAINED_EXTRACTORS, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_ROLE, + ENTITY_ATTRIBUTE_GROUP, +) +from rasa.shared.constants import UTTER_PREFIX +import rasa.shared.utils.io +import rasa.shared.data + +logger = logging.getLogger(__name__) + +ESCAPE_DCT = {"\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"} +ESCAPE_CHARS = set(ESCAPE_DCT.keys()) +ESCAPE = re.compile(f'[{"".join(ESCAPE_DCT.values())}]') +UNESCAPE_DCT = {espaced_char: char for char, espaced_char in ESCAPE_DCT.items()} +UNESCAPE = re.compile(f'[{"".join(UNESCAPE_DCT.values())}]') +GROUP_COMPLETE_MATCH = 0 + + +def transform_entity_synonyms( + synonyms: List[Dict[Text, Any]], known_synonyms: Optional[Dict[Text, Any]] = None +) -> Dict[Text, Any]: + """Transforms the entity synonyms into a text->value dictionary""" + entity_synonyms = known_synonyms if known_synonyms else {} + for s in synonyms: + if "value" in s and "synonyms" in s: + for synonym in s["synonyms"]: + entity_synonyms[synonym] = s["value"] + return entity_synonyms + + +def check_duplicate_synonym( + entity_synonyms: Dict[Text, Any], text: Text, syn: Text, context_str: Text = "" +) -> None: + if text in entity_synonyms and entity_synonyms[text] != syn: + rasa.shared.utils.io.raise_warning( + f"Found inconsistent entity synonyms while {context_str}, " + f"overwriting {text}->{entity_synonyms[text]} " + f"with {text}->{syn} during merge." + ) + + +def get_file_format_extension(resource_name: Text) -> Text: + """ + Get the file extension based on training data format. It supports both a folder and + a file, and tries to guess the format as follows: + + - if the resource is a file and has a known format, return this format's extension + - if the resource is a folder and all the resources have the + same known format, return it's extension + - otherwise, default to DEFAULT_FILE_FORMAT (yml). + + Args: + resource_name: The name of the resource, can be a file or a folder. + Returns: + The resource file format. + """ + from rasa.shared.nlu.training_data import loading + + if resource_name is None or not os.path.exists(resource_name): + raise AttributeError(f"Resource '{resource_name}' does not exist.") + + files = rasa.shared.utils.io.list_files(resource_name) + + file_formats = list(map(lambda f: loading.guess_format(f), files)) + + if not file_formats: + return rasa.shared.data.yaml_file_extension() + + known_file_formats = {loading.RASA_YAML: rasa.shared.data.yaml_file_extension()} + fformat = file_formats[0] + if all(f == fformat for f in file_formats): + return known_file_formats.get(fformat, rasa.shared.data.yaml_file_extension()) + + return rasa.shared.data.yaml_file_extension() + + +def remove_untrainable_entities_from(example: Dict[Text, Any]) -> None: + """Remove untrainable entities from serialised training example `example`. + + Entities with an untrainable extractor will be removed. Untrainable extractors + are defined in `rasa.nlu.constants.PRETRAINED_EXTRACTORS`. + + Args: + example: Serialised training example to inspect. + """ + + example_entities = example.get(ENTITIES) + + if not example_entities: + # example contains no entities, so there's nothing to do + return None + + trainable_entities = [] + + for entity in example_entities: + if entity.get(EXTRACTOR) in PRETRAINED_EXTRACTORS: + logger.debug( + f"Excluding entity '{json.dumps(entity)}' from training data. " + f"Entity examples extracted by the following classes are not " + f"dumped to training data in markdown format: " + f"`{'`, `'.join(sorted(PRETRAINED_EXTRACTORS))}`." + ) + else: + trainable_entities.append(entity) + + example[ENTITIES] = trainable_entities + + +def intent_response_key_to_template_key(intent_response_key: Text) -> Text: + """Resolve the response template key for a given intent response key. + + Args: + intent_response_key: retrieval intent with the response key suffix attached. + + Returns: The corresponding response template. + + """ + return f"{UTTER_PREFIX}{intent_response_key}" + + +def template_key_to_intent_response_key(template_key: Text) -> Text: + """Resolve the intent response key for the given response template. + + Args: + template_key: Name of the response template. + + Returns: The corresponding intent response key. + + """ + return template_key.split(UTTER_PREFIX)[1] + + +def has_string_escape_chars(s: Text) -> bool: + """Checks whether there are any of the escape characters in the string.""" + intersection = ESCAPE_CHARS.intersection(set(s)) + return len(intersection) > 0 + + +def encode_string(s: Text) -> Text: + """Return an encoded python string.""" + + def replace(match: Match) -> Text: + return ESCAPE_DCT[match.group(GROUP_COMPLETE_MATCH)] + + return ESCAPE.sub(replace, s) + + +def decode_string(s: Text) -> Text: + """Return a decoded python string.""" + + def replace(match: Match) -> Text: + return UNESCAPE_DCT[match.group(GROUP_COMPLETE_MATCH)] + + return UNESCAPE.sub(replace, s) + + +def build_entity( + start: int, + end: int, + value: Text, + entity_type: Text, + role: Optional[Text] = None, + group: Optional[Text] = None, + **kwargs: Any, +) -> Dict[Text, Any]: + """Builds a standard entity dictionary. + + Adds additional keyword parameters. + + Args: + start: start position of entity + end: end position of entity + value: text value of the entity + entity_type: name of the entity type + role: role of the entity + group: group of the entity + **kwargs: additional parameters + + Returns: + an entity dictionary + """ + + entity = { + ENTITY_ATTRIBUTE_START: start, + ENTITY_ATTRIBUTE_END: end, + ENTITY_ATTRIBUTE_VALUE: value, + ENTITY_ATTRIBUTE_TYPE: entity_type, + } + + if role: + entity[ENTITY_ATTRIBUTE_ROLE] = role + if group: + entity[ENTITY_ATTRIBUTE_GROUP] = group + + entity.update(kwargs) + return entity + + +def sparse_matrix_to_string(m: scipy.sparse.spmatrix) -> Text: + """Turns a sparse matrix into a string. + + Will return a line "(i,j) v" for each value in the matrix. + + taken from official scipy source to operate on full sparse matrix to not have + to change the `maxprint` property in-place. + https://github.com/scipy/scipy/blob/v1.7.0/scipy/sparse/base.py#L258 + """ + # make sure sparse matrix is in COOrdinate format + m_coo = m.tocoo() + triples = zip(list(zip(m_coo.row, m_coo.col)), m_coo.data) + return "\n".join([(" %s\t%s" % t) for t in triples]) diff --git a/rasa/shared/utils/__init__.py b/rasa/shared/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/shared/utils/cli.py b/rasa/shared/utils/cli.py new file mode 100644 index 0000000..345fbf3 --- /dev/null +++ b/rasa/shared/utils/cli.py @@ -0,0 +1,72 @@ +import sys +from typing import Any, Text, NoReturn + +import rasa.shared.utils.io + + +def print_color(*args: Any, color: Text) -> None: + """Print the given arguments to STDOUT in the specified color. + + Args: + args: A list of objects to be printed. + color: A textual representation of the color. + """ + output = rasa.shared.utils.io.wrap_with_color(*args, color=color) + stream = sys.stdout + if sys.platform == "win32": + # colorama is used to fix a regression where colors can not be printed on + # windows. https://github.com/RasaHQ/rasa/issues/7053 + from colorama import AnsiToWin32 + + stream = AnsiToWin32(sys.stdout).stream + try: + print(output, file=stream) + except BlockingIOError: + rasa.shared.utils.io.handle_print_blocking(output) + + +def print_success(*args: Any) -> None: + """Print the given arguments to STDOUT in green, indicating success. + + Args: + args: A list of objects to be printed. + """ + print_color(*args, color=rasa.shared.utils.io.bcolors.OKGREEN) + + +def print_info(*args: Any) -> None: + """Print the given arguments to STDOUT in blue. + + Args: + args: A list of objects to be printed. + """ + print_color(*args, color=rasa.shared.utils.io.bcolors.OKBLUE) + + +def print_warning(*args: Any) -> None: + """Print the given arguments to STDOUT in a color indicating a warning. + + Args: + args: A list of objects to be printed. + """ + print_color(*args, color=rasa.shared.utils.io.bcolors.WARNING) + + +def print_error(*args: Any) -> None: + """Print the given arguments to STDOUT in a color indicating an error. + + Args: + args: A list of objects to be printed. + """ + print_color(*args, color=rasa.shared.utils.io.bcolors.FAIL) + + +def print_error_and_exit(message: Text, exit_code: int = 1) -> NoReturn: + """Print an error message and exit the application. + + Args: + message: The error message to be printed. + exit_code: The program exit code, defaults to 1. + """ + print_error(message) + sys.exit(exit_code) diff --git a/rasa/shared/utils/common.py b/rasa/shared/utils/common.py new file mode 100644 index 0000000..9f069aa --- /dev/null +++ b/rasa/shared/utils/common.py @@ -0,0 +1,272 @@ +import asyncio +import functools +import importlib +import inspect +import logging +from typing import Text, Dict, Optional, Any, List, Callable, Collection, Type + +from rasa.shared.exceptions import RasaException + +logger = logging.getLogger(__name__) + + +def class_from_module_path( + module_path: Text, lookup_path: Optional[Text] = None +) -> Type: + """Given the module name and path of a class, tries to retrieve the class. + + The loaded class can be used to instantiate new objects. + + Args: + module_path: either an absolute path to a Python class, + or the name of the class in the local / global scope. + lookup_path: a path where to load the class from, if it cannot + be found in the local / global scope. + + Returns: + a Python class + + Raises: + ImportError, in case the Python class cannot be found. + RasaException, in case the imported result is something other than a class + """ + klass = None + if "." in module_path: + module_name, _, class_name = module_path.rpartition(".") + m = importlib.import_module(module_name) + klass = getattr(m, class_name, None) + elif lookup_path: + # try to import the class from the lookup path + m = importlib.import_module(lookup_path) + klass = getattr(m, module_path, None) + + if klass is None: + raise ImportError(f"Cannot retrieve class from path {module_path}.") + + if not inspect.isclass(klass): + raise RasaException( + f"`class_from_module_path()` is expected to return a class, " + f"but for {module_path} we got a {type(klass)}." + ) + return klass + + +def all_subclasses(cls: Any) -> List[Any]: + """Returns all known (imported) subclasses of a class.""" + classes = cls.__subclasses__() + [ + g for s in cls.__subclasses__() for g in all_subclasses(s) + ] + + return [subclass for subclass in classes if not inspect.isabstract(subclass)] + + +def module_path_from_instance(inst: Any) -> Text: + """Return the module path of an instance's class.""" + return inst.__module__ + "." + inst.__class__.__name__ + + +def sort_list_of_dicts_by_first_key(dicts: List[Dict]) -> List[Dict]: + """Sorts a list of dictionaries by their first key.""" + return sorted(dicts, key=lambda d: list(d.keys())[0]) + + +def lazy_property(function: Callable) -> Any: + """Allows to avoid recomputing a property over and over. + + The result gets stored in a local var. Computation of the property + will happen once, on the first call of the property. All + succeeding calls will use the value stored in the private property. + """ + attr_name = "_lazy_" + function.__name__ + + def _lazyprop(self: Any) -> Any: + if not hasattr(self, attr_name): + setattr(self, attr_name, function(self)) + return getattr(self, attr_name) + + return property(_lazyprop) + + +def cached_method(f: Callable[..., Any]) -> Callable[..., Any]: + """Caches method calls based on the call's `args` and `kwargs`. + + Works for `async` and `sync` methods. Don't apply this to functions. + + Args: + f: The decorated method whose return value should be cached. + + Returns: + The return value which the method gives for the first call with the given + arguments. + """ + assert "self" in arguments_of(f), "This decorator can only be used with methods." + + class Cache: + """Helper class to abstract the caching details.""" + + def __init__(self, caching_object: object, args: Any, kwargs: Any) -> None: + self.caching_object = caching_object + self.cache = getattr(caching_object, self._cache_name(), {}) + # noinspection PyUnresolvedReferences + self.cache_key = functools._make_key(args, kwargs, typed=False) + + def _cache_name(self) -> Text: + return f"_cached_{self.caching_object.__class__.__name__}_{f.__name__}" + + def is_cached(self) -> bool: + return self.cache_key in self.cache + + def cache_result(self, result: Any) -> None: + self.cache[self.cache_key] = result + setattr(self.caching_object, self._cache_name(), self.cache) + + def cached_result(self) -> Any: + return self.cache[self.cache_key] + + if asyncio.iscoroutinefunction(f): + + @functools.wraps(f) + async def decorated(self: object, *args: Any, **kwargs: Any) -> Any: + cache = Cache(self, args, kwargs) + if not cache.is_cached(): + # Store the task immediately so that other concurrent calls of the + # method can re-use the same task and don't schedule a second execution. + to_cache = asyncio.ensure_future(f(self, *args, **kwargs)) + cache.cache_result(to_cache) + return await cache.cached_result() + + return decorated + else: + + @functools.wraps(f) + def decorated(self: object, *args: Any, **kwargs: Any) -> Any: + cache = Cache(self, args, kwargs) + if not cache.is_cached(): + to_cache = f(self, *args, **kwargs) + cache.cache_result(to_cache) + return cache.cached_result() + + return decorated + + +def transform_collection_to_sentence(collection: Collection[Text]) -> Text: + """Transforms e.g. a list like ['A', 'B', 'C'] into a sentence 'A, B and C'.""" + x = list(collection) + if len(x) >= 2: + return ", ".join(map(str, x[:-1])) + " and " + x[-1] + return "".join(collection) + + +def minimal_kwargs( + kwargs: Dict[Text, Any], func: Callable, excluded_keys: Optional[List] = None +) -> Dict[Text, Any]: + """Returns only the kwargs which are required by a function. Keys, contained in + the exception list, are not included. + + Args: + kwargs: All available kwargs. + func: The function which should be called. + excluded_keys: Keys to exclude from the result. + + Returns: + Subset of kwargs which are accepted by `func`. + + """ + + excluded_keys = excluded_keys or [] + + possible_arguments = arguments_of(func) + + return { + k: v + for k, v in kwargs.items() + if k in possible_arguments and k not in excluded_keys + } + + +def mark_as_experimental_feature(feature_name: Text) -> None: + """Warns users that they are using an experimental feature.""" + + logger.warning( + f"The {feature_name} is currently experimental and might change or be " + "removed in the future 🔬 Please share your feedback on it in the " + "forum (https://forum.rasa.com) to help us make this feature " + "ready for production." + ) + + +def arguments_of(func: Callable) -> List[Text]: + """Return the parameters of the function `func` as a list of names.""" + import inspect + + return list(inspect.signature(func).parameters.keys()) + + +def extract_duplicates(list1: List[Any], list2: List[Any]) -> List[Any]: + """Extracts duplicates from two lists.""" + if list1: + dict1 = { + (sorted(list(i.keys()))[0] if isinstance(i, dict) else i): i for i in list1 + } + else: + dict1 = {} + + if list2: + dict2 = { + (sorted(list(i.keys()))[0] if isinstance(i, dict) else i): i for i in list2 + } + else: + dict2 = {} + + set1 = set(dict1.keys()) + set2 = set(dict2.keys()) + dupes = set1.intersection(set2) + return sorted(list(dupes)) + + +def clean_duplicates(dupes: Dict[Text, Any]) -> Dict[Text, Any]: + """Removes keys for empty values.""" + duplicates = dupes.copy() + for k in dupes: + if not dupes[k]: + duplicates.pop(k) + + return duplicates + + +def merge_dicts( + tempDict1: Dict[Text, Any], + tempDict2: Dict[Text, Any], + override_existing_values: bool = False, +) -> Dict[Text, Any]: + """Merges two dicts.""" + if override_existing_values: + merged_dicts, b = tempDict1.copy(), tempDict2.copy() + + else: + merged_dicts, b = tempDict2.copy(), tempDict1.copy() + merged_dicts.update(b) + return merged_dicts + + +def merge_lists( + list1: List[Any], list2: List[Any], override: bool = False +) -> List[Any]: + """Merges two lists.""" + return sorted(list(set(list1 + list2))) + + +def merge_lists_of_dicts( + dict_list1: List[Dict], + dict_list2: List[Dict], + override_existing_values: bool = False, +) -> List[Dict]: + """Merges two dict lists.""" + dict1 = { + (sorted(list(i.keys()))[0] if isinstance(i, dict) else i): i for i in dict_list1 + } + dict2 = { + (sorted(list(i.keys()))[0] if isinstance(i, dict) else i): i for i in dict_list2 + } + merged_dicts = merge_dicts(dict1, dict2, override_existing_values) + return list(merged_dicts.values()) diff --git a/rasa/shared/utils/io.py b/rasa/shared/utils/io.py new file mode 100644 index 0000000..3b13b5c --- /dev/null +++ b/rasa/shared/utils/io.py @@ -0,0 +1,656 @@ +from collections import OrderedDict +import errno +import glob +from hashlib import md5 +from io import StringIO +import json +import os +import sys +from pathlib import Path +import re +from typing import Any, Dict, List, Optional, Text, Type, Union +import warnings +import random +import string + +import portalocker + +from ruamel import yaml as yaml +from ruamel.yaml import RoundTripRepresenter, YAMLError +from ruamel.yaml.constructor import DuplicateKeyError, BaseConstructor, ScalarNode + +from rasa.shared.constants import ( + DEFAULT_LOG_LEVEL, + ENV_LOG_LEVEL, + NEXT_MAJOR_VERSION_FOR_DEPRECATIONS, + CONFIG_SCHEMA_FILE, + MODEL_CONFIG_SCHEMA_FILE, +) +from rasa.shared.exceptions import ( + FileIOException, + FileNotFoundException, + YamlSyntaxException, + RasaException, +) +import rasa.shared.utils.validation + +DEFAULT_ENCODING = "utf-8" +YAML_VERSION = (1, 2) + + +class bcolors: + HEADER = "\033[95m" + OKBLUE = "\033[94m" + OKGREEN = "\033[92m" + WARNING = "\033[93m" + FAIL = "\033[91m" + ENDC = "\033[0m" + BOLD = "\033[1m" + UNDERLINE = "\033[4m" + + +def wrap_with_color(*args: Any, color: Text) -> Text: + return color + " ".join(str(s) for s in args) + bcolors.ENDC + + +def raise_warning( + message: Text, + category: Optional[Type[Warning]] = None, + docs: Optional[Text] = None, + **kwargs: Any, +) -> None: + """Emit a `warnings.warn` with sensible defaults and a colored warning msg.""" + original_formatter = warnings.formatwarning + + def should_show_source_line() -> bool: + if "stacklevel" not in kwargs: + if category == UserWarning or category is None: + return False + if category == FutureWarning: + return False + return True + + def formatwarning( + message: Union[Warning, Text], + category: Type[Warning], + filename: Text, + lineno: int, + line: Optional[Text] = None, + ) -> Text: + """Function to format a warning the standard way.""" + if not should_show_source_line(): + if docs: + line = f"More info at {docs}" + else: + line = "" + + formatted_message = original_formatter( + message, category, filename, lineno, line + ) + return wrap_with_color(formatted_message, color=bcolors.WARNING) + + if "stacklevel" not in kwargs: + # try to set useful defaults for the most common warning categories + if category == DeprecationWarning: + kwargs["stacklevel"] = 3 + elif category in (UserWarning, FutureWarning): + kwargs["stacklevel"] = 2 + + warnings.formatwarning = formatwarning + warnings.warn(message, category=category, **kwargs) + warnings.formatwarning = original_formatter + + +def write_text_file( + content: Text, + file_path: Union[Text, Path], + encoding: Text = DEFAULT_ENCODING, + append: bool = False, +) -> None: + """Writes text to a file. + + Args: + content: The content to write. + file_path: The path to which the content should be written. + encoding: The encoding which should be used. + append: Whether to append to the file or to truncate the file. + + """ + mode = "a" if append else "w" + with open(file_path, mode, encoding=encoding) as file: + file.write(content) + + +def read_file(filename: Union[Text, Path], encoding: Text = DEFAULT_ENCODING) -> Any: + """Read text from a file.""" + try: + with open(filename, encoding=encoding) as f: + return f.read() + except FileNotFoundError: + raise FileNotFoundException( + f"Failed to read file, " f"'{os.path.abspath(filename)}' does not exist." + ) + except UnicodeDecodeError: + raise FileIOException( + f"Failed to read file '{os.path.abspath(filename)}', " + f"could not read the file using {encoding} to decode " + f"it. Please make sure the file is stored with this " + f"encoding." + ) + + +def read_json_file(filename: Union[Text, Path]) -> Any: + """Read json from a file.""" + content = read_file(filename) + try: + return json.loads(content) + except ValueError as e: + raise FileIOException( + f"Failed to read json from '{os.path.abspath(filename)}'. Error: {e}" + ) + + +def list_directory(path: Text) -> List[Text]: + """Returns all files and folders excluding hidden files. + + If the path points to a file, returns the file. This is a recursive + implementation returning files in any depth of the path. + """ + if not isinstance(path, str): + raise ValueError( + f"`resource_name` must be a string type. " f"Got `{type(path)}` instead" + ) + + if os.path.isfile(path): + return [path] + elif os.path.isdir(path): + results: List[Text] = [] + for base, dirs, files in os.walk(path, followlinks=True): + # sort files for same order across runs + files = sorted(files, key=_filename_without_prefix) + # add not hidden files + good_files = filter(lambda x: not x.startswith("."), files) + results.extend(os.path.join(base, f) for f in good_files) + # add not hidden directories + good_directories = filter(lambda x: not x.startswith("."), dirs) + results.extend(os.path.join(base, f) for f in good_directories) + return results + else: + raise ValueError(f"Could not locate the resource '{os.path.abspath(path)}'.") + + +def list_files(path: Text) -> List[Text]: + """Returns all files excluding hidden files. + + If the path points to a file, returns the file. + """ + return [fn for fn in list_directory(path) if os.path.isfile(fn)] + + +def _filename_without_prefix(file: Text) -> Text: + """Splits of a filenames prefix until after the first ``_``.""" + return "_".join(file.split("_")[1:]) + + +def list_subdirectories(path: Text) -> List[Text]: + """Returns all folders excluding hidden files. + + If the path points to a file, returns an empty list. + """ + return [fn for fn in glob.glob(os.path.join(path, "*")) if os.path.isdir(fn)] + + +def deep_container_fingerprint( + obj: Union[List[Any], Dict[Any, Any], Any], encoding: Text = DEFAULT_ENCODING +) -> Text: + """Calculate a hash which is stable. + + Works for lists and dictionaries. For keys and values, we recursively call + `hash(...)` on them. In case of a dict, the hash is independent of the containers + key order. Keep in mind that a list with items in a different order + will not create the same hash! + + Args: + obj: dictionary or list to be hashed. + encoding: encoding used for dumping objects as strings + + Returns: + hash of the container. + """ + if isinstance(obj, dict): + return get_dictionary_fingerprint(obj, encoding) + elif isinstance(obj, list): + return get_list_fingerprint(obj, encoding) + elif hasattr(obj, "fingerprint") and callable(obj.fingerprint): + return obj.fingerprint() + else: + return get_text_hash(str(obj), encoding) + + +def get_dictionary_fingerprint( + dictionary: Dict[Any, Any], encoding: Text = DEFAULT_ENCODING +) -> Text: + """Calculate the fingerprint for a dictionary. + + The dictionary can contain any keys and values which are either a dict, + a list or a elements which can be dumped as a string. + + Args: + dictionary: dictionary to be hashed + encoding: encoding used for dumping objects as strings + + Returns: + The hash of the dictionary + """ + stringified = json.dumps( + { + deep_container_fingerprint(k, encoding): deep_container_fingerprint( + v, encoding + ) + for k, v in dictionary.items() + }, + sort_keys=True, + ) + return get_text_hash(stringified, encoding) + + +def get_list_fingerprint( + elements: List[Any], encoding: Text = DEFAULT_ENCODING +) -> Text: + """Calculate a fingerprint for an unordered list. + + Args: + elements: unordered list + encoding: encoding used for dumping objects as strings + + Returns: + the fingerprint of the list + """ + stringified = json.dumps( + [deep_container_fingerprint(element, encoding) for element in elements] + ) + return get_text_hash(stringified, encoding) + + +def get_text_hash(text: Text, encoding: Text = DEFAULT_ENCODING) -> Text: + """Calculate the md5 hash for a text.""" + return md5(text.encode(encoding)).hexdigest() # nosec + + +def json_to_string(obj: Any, **kwargs: Any) -> Text: + """Dumps a JSON-serializable object to string. + + Args: + obj: JSON-serializable object. + kwargs: serialization options. Defaults to 2 space indentation + and disable escaping of non-ASCII characters. + + Returns: + The objects serialized to JSON, as a string. + """ + indent = kwargs.pop("indent", 2) + ensure_ascii = kwargs.pop("ensure_ascii", False) + return json.dumps(obj, indent=indent, ensure_ascii=ensure_ascii, **kwargs) + + +def fix_yaml_loader() -> None: + """Ensure that any string read by yaml is represented as unicode.""" + + def construct_yaml_str(self: BaseConstructor, node: ScalarNode) -> Any: + # Override the default string handling function + # to always return unicode objects + return self.construct_scalar(node) + + yaml.Loader.add_constructor("tag:yaml.org,2002:str", construct_yaml_str) + yaml.SafeLoader.add_constructor("tag:yaml.org,2002:str", construct_yaml_str) + + +def replace_environment_variables() -> None: + """Enable yaml loader to process the environment variables in the yaml.""" + # eg. ${USER_NAME}, ${PASSWORD} + env_var_pattern = re.compile(r"^(.*)\$\{(.*)\}(.*)$") + yaml.Resolver.add_implicit_resolver("!env_var", env_var_pattern, None) + + def env_var_constructor(loader: BaseConstructor, node: ScalarNode) -> Text: + """Process environment variables found in the YAML.""" + value = loader.construct_scalar(node) + expanded_vars = os.path.expandvars(value) + not_expanded = [ + w for w in expanded_vars.split() if w.startswith("$") and w in value + ] + if not_expanded: + raise RasaException( + f"Error when trying to expand the " + f"environment variables in '{value}'. " + f"Please make sure to also set these " + f"environment variables: '{not_expanded}'." + ) + return expanded_vars + + yaml.SafeConstructor.add_constructor("!env_var", env_var_constructor) + + +fix_yaml_loader() +replace_environment_variables() + + +def read_yaml(content: Text, reader_type: Union[Text, List[Text]] = "safe") -> Any: + """Parses yaml from a text. + + Args: + content: A text containing yaml content. + reader_type: Reader type to use. By default "safe" will be used. + + Raises: + ruamel.yaml.parser.ParserError: If there was an error when parsing the YAML. + """ + if _is_ascii(content): + # Required to make sure emojis are correctly parsed + content = ( + content.encode("utf-8") + .decode("raw_unicode_escape") + .encode("utf-16", "surrogatepass") + .decode("utf-16") + ) + + yaml_parser = yaml.YAML(typ=reader_type) + yaml_parser.version = YAML_VERSION # type: ignore[assignment] + yaml_parser.preserve_quotes = True # type: ignore[assignment] + + return yaml_parser.load(content) or {} + + +def _is_ascii(text: Text) -> bool: + return all(ord(character) < 128 for character in text) + + +def read_yaml_file( + filename: Union[Text, Path], reader_type: Union[Text, List[Text]] = "safe" +) -> Union[List[Any], Dict[Text, Any]]: + """Parses a yaml file. + + Raises an exception if the content of the file can not be parsed as YAML. + + Args: + filename: The path to the file which should be read. + reader_type: Reader type to use. By default "safe" will be used. + + Returns: + Parsed content of the file. + """ + try: + return read_yaml(read_file(filename, DEFAULT_ENCODING), reader_type) + except (YAMLError, DuplicateKeyError) as e: + raise YamlSyntaxException(filename, e) + + +def write_yaml( + data: Any, + target: Union[Text, Path, StringIO], + should_preserve_key_order: bool = False, +) -> None: + """Writes a yaml to the file or to the stream. + + Args: + data: The data to write. + target: The path to the file which should be written or a stream object + should_preserve_key_order: Whether to force preserve key order in `data`. + """ + _enable_ordered_dict_yaml_dumping() + + if should_preserve_key_order: + data = convert_to_ordered_dict(data) + + dumper = yaml.YAML() + # no wrap lines + dumper.width = YAML_LINE_MAX_WIDTH # type: ignore[assignment] + + # use `null` to represent `None` + dumper.representer.add_representer( + type(None), + lambda self, _: self.represent_scalar("tag:yaml.org,2002:null", "null"), + ) + + if isinstance(target, StringIO): + dumper.dump(data, target) + return + + with Path(target).open("w", encoding=DEFAULT_ENCODING) as outfile: + dumper.dump(data, outfile) + + +YAML_LINE_MAX_WIDTH = 4096 + + +def is_key_in_yaml(file_path: Union[Text, Path], *keys: Text) -> bool: + """Checks if any of the keys is contained in the root object of the yaml file. + + Arguments: + file_path: path to the yaml file + keys: keys to look for + + Returns: + `True` if at least one of the keys is found, `False` otherwise. + + Raises: + FileNotFoundException: if the file cannot be found. + """ + try: + with open(file_path, encoding=DEFAULT_ENCODING) as file: + return any( + any(line.lstrip().startswith(f"{key}:") for key in keys) + for line in file + ) + except FileNotFoundError: + raise FileNotFoundException( + f"Failed to read file, " f"'{os.path.abspath(file_path)}' does not exist." + ) + + +def convert_to_ordered_dict(obj: Any) -> Any: + """Convert object to an `OrderedDict`. + + Args: + obj: Object to convert. + + Returns: + An `OrderedDict` with all nested dictionaries converted if `obj` is a + dictionary, otherwise the object itself. + """ + if isinstance(obj, OrderedDict): + return obj + # use recursion on lists + if isinstance(obj, list): + return [convert_to_ordered_dict(element) for element in obj] + + if isinstance(obj, dict): + out = OrderedDict() + # use recursion on dictionaries + for k, v in obj.items(): + out[k] = convert_to_ordered_dict(v) + + return out + + # return all other objects + return obj + + +def _enable_ordered_dict_yaml_dumping() -> None: + """Ensure that `OrderedDict`s are dumped so that the order of keys is respected.""" + yaml.add_representer( + OrderedDict, + RoundTripRepresenter.represent_dict, + representer=RoundTripRepresenter, + ) + + +def is_logging_disabled() -> bool: + """Returns `True` if log level is set to WARNING or ERROR, `False` otherwise.""" + log_level = os.environ.get(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL) + + return log_level in ("ERROR", "WARNING") + + +def create_directory_for_file(file_path: Union[Text, Path]) -> None: + """Creates any missing parent directories of this file path.""" + create_directory(os.path.dirname(file_path)) + + +def dump_obj_as_json_to_file(filename: Union[Text, Path], obj: Any) -> None: + """Dump an object as a json string to a file.""" + write_text_file(json.dumps(obj, ensure_ascii=False, indent=2), filename) + + +def dump_obj_as_yaml_to_string( + obj: Any, should_preserve_key_order: bool = False +) -> Text: + """Writes data (python dict) to a yaml string. + + Args: + obj: The object to dump. Has to be serializable. + should_preserve_key_order: Whether to force preserve key order in `data`. + + Returns: + The object converted to a YAML string. + """ + buffer = StringIO() + + write_yaml(obj, buffer, should_preserve_key_order=should_preserve_key_order) + + return buffer.getvalue() + + +def create_directory(directory_path: Text) -> None: + """Creates a directory and its super paths. + + Succeeds even if the path already exists. + """ + try: + os.makedirs(directory_path) + except OSError as e: + # be happy if someone already created the path + if e.errno != errno.EEXIST: + raise + + +def raise_deprecation_warning( + message: Text, + warn_until_version: Text = NEXT_MAJOR_VERSION_FOR_DEPRECATIONS, + docs: Optional[Text] = None, + **kwargs: Any, +) -> None: + """Thin wrapper around `raise_warning()` to raise a deprecation warning. It requires + a version until which we'll warn, and after which the support for the feature will + be removed. + """ + if warn_until_version not in message: + message = f"{message} (will be removed in {warn_until_version})" + + # need the correct stacklevel now + kwargs.setdefault("stacklevel", 3) + # we're raising a `FutureWarning` instead of a `DeprecationWarning` because + # we want these warnings to be visible in the terminal of our users + # https://docs.python.org/3/library/warnings.html#warning-categories + raise_warning(message, FutureWarning, docs, **kwargs) + + +def read_validated_yaml( + filename: Union[Text, Path], + schema: Text, + reader_type: Union[Text, List[Text]] = "safe", +) -> Any: + """Validates YAML file content and returns parsed content. + + Args: + filename: The path to the file which should be read. + schema: The path to the schema file which should be used for validating the + file content. + reader_type: Reader type to use. By default "safe" will be used. + + Returns: + The parsed file content. + + Raises: + YamlValidationException: In case the model configuration doesn't match the + expected schema. + """ + content = read_file(filename) + + rasa.shared.utils.validation.validate_yaml_schema(content, schema) + return read_yaml(content, reader_type) + + +def read_config_file( + filename: Union[Path, Text], reader_type: Union[Text, List[Text]] = "safe" +) -> Dict[Text, Any]: + """Parses a yaml configuration file. Content needs to be a dictionary. + + Args: + filename: The path to the file which should be read. + reader_type: Reader type to use. By default "safe" will be used. + + Raises: + YamlValidationException: In case file content is not a `Dict`. + + Returns: + Parsed config file. + """ + return read_validated_yaml(filename, CONFIG_SCHEMA_FILE, reader_type) + + +def read_model_configuration(filename: Union[Path, Text]) -> Dict[Text, Any]: + """Parses a model configuration file. + + Args: + filename: The path to the file which should be read. + + Raises: + YamlValidationException: In case the model configuration doesn't match the + expected schema. + + Returns: + Parsed config file. + """ + return read_validated_yaml(filename, MODEL_CONFIG_SCHEMA_FILE) + + +def is_subdirectory(path: Text, potential_parent_directory: Text) -> bool: + """Checks if `path` is a subdirectory of `potential_parent_directory`. + + Args: + path: Path to a file or directory. + potential_parent_directory: Potential parent directory. + + Returns: + `True` if `path` is a subdirectory of `potential_parent_directory`. + """ + if path is None or potential_parent_directory is None: + return False + + path = os.path.abspath(path) + potential_parent_directory = os.path.abspath(potential_parent_directory) + + return potential_parent_directory in path + + +def random_string(length: int) -> Text: + """Returns a random string of given length.""" + return "".join(random.choices(string.ascii_uppercase + string.digits, k=length)) + + +def handle_print_blocking(output: Text) -> None: + """Handle print blocking (BlockingIOError) by getting the STDOUT lock. + + Args: + output: Text to be printed to STDOUT. + """ + # Locking again to obtain STDOUT with a lock. + with portalocker.Lock(sys.stdout) as lock: + if sys.platform == "win32": + # colorama is used to fix a regression where colors can not be printed on + # windows. https://github.com/RasaHQ/rasa/issues/7053 + from colorama import AnsiToWin32 + + lock = AnsiToWin32(lock).stream + + print(output, file=lock, flush=True) diff --git a/rasa/shared/utils/pykwalify_extensions.py b/rasa/shared/utils/pykwalify_extensions.py new file mode 100644 index 0000000..5d99820 --- /dev/null +++ b/rasa/shared/utils/pykwalify_extensions.py @@ -0,0 +1,27 @@ +""" +This module regroups custom validation functions, and it is +loaded as an extension of the pykwalify library: + +https://pykwalify.readthedocs.io/en/latest/extensions.html#extensions +""" +from typing import Any, List, Dict, Text, Union + +from pykwalify.errors import SchemaError + + +def require_response_keys( + responses: List[Dict[Text, Any]], _: Dict, __: Text +) -> Union[SchemaError, bool]: + """Validates that response dicts have either the "text" key or the "custom" key.""" + for response in responses: + if not isinstance(response, dict): + # this is handled by other validation rules + continue + + if response.get("text") is None and not response.get("custom"): + return SchemaError( + "Missing 'text' or 'custom' key in response or " + "null 'text' value in response." + ) + + return True diff --git a/rasa/shared/utils/schemas/__init__.py b/rasa/shared/utils/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/shared/utils/schemas/config.yml b/rasa/shared/utils/schemas/config.yml new file mode 100644 index 0000000..6dea8c7 --- /dev/null +++ b/rasa/shared/utils/schemas/config.yml @@ -0,0 +1,2 @@ +allowempty: True +type: map diff --git a/rasa/shared/utils/schemas/domain.yml b/rasa/shared/utils/schemas/domain.yml new file mode 100644 index 0000000..bd615c9 --- /dev/null +++ b/rasa/shared/utils/schemas/domain.yml @@ -0,0 +1,142 @@ +allowempty: True +mapping: + version: + type: "str" + required: False + allowempty: False + intents: + type: "seq" + sequence: + - type: "map" + mapping: + use_entities: + type: "any" + ignore_entities: + type: "any" + allowempty: True + - type: "str" + entities: + type: "seq" + matching: "any" + sequence: + - type: "map" + mapping: + roles: + type: "seq" + sequence: + - type: "str" + groups: + type: "seq" + sequence: + - type: "str" + allowempty: True + - type: "str" + actions: + type: seq + matching: "any" + seq: + - type: str + - type: map + mapping: + regex;([A-Za-z]+): + type: map + mapping: + send_domain: + type: "bool" + responses: + # see shared/nlu/training_data/schemas/responses.yml + include: responses + + slots: + type: "map" + allowempty: True + mapping: + regex;([A-Za-z]+): + type: "map" + allowempty: True + mapping: + influence_conversation: + type: "bool" + required: False + type: + type: "any" + required: True + values: + type: "seq" + sequence: + - type: "any" + required: False + min_value: + type: "number" + required: False + max_value: + type: "number" + required: False + initial_value: + type: "any" + required: False + mappings: + type: "seq" + required: True + allowempty: False + sequence: + - type: "map" + allowempty: True + mapping: + type: + type: "str" + intent: + type: "any" + not_intent: + type: "any" + entity: + type: "str" + role: + type: "str" + group: + type: "str" + value: + type: "any" + action: + type: "str" + conditions: + type: "seq" + sequence: + - type: "map" + mapping: + active_loop: + type: "str" + nullable: True + requested_slot: + type: "str" + forms: + type: "map" + required: False + mapping: + regex;([A-Za-z]+): + type: "map" + mapping: + required_slots: + type: "seq" + sequence: + - type: str + required: False + allowempty: True + ignored_intents: + type: any + config: + type: "map" + allowempty: True + mapping: + store_entities_as_slots: + type: "bool" + session_config: + type: "map" + allowempty: True + mapping: + session_expiration_time: + type: "number" + range: + min: 0 + carry_over_slots_to_new_session: + type: "bool" diff --git a/rasa/shared/utils/schemas/events.py b/rasa/shared/utils/schemas/events.py new file mode 100644 index 0000000..ac9ebee --- /dev/null +++ b/rasa/shared/utils/schemas/events.py @@ -0,0 +1,167 @@ +ENTITIES_SCHEMA = { + "type": "array", + "items": { + "type": "object", + "properties": { + "start": {"type": "integer"}, + "end": {"type": "integer"}, + "entity": {"type": "string"}, + "confidence": {"type": "number"}, + "extractor": {"type": ["string", "null"]}, + "value": {}, + "role": {"type": ["string", "null"]}, + "group": {"type": ["string", "null"]}, + }, + "required": ["entity", "value"], + }, +} + +INTENT = { + "type": "object", + "properties": {"name": {"type": "string"}, "confidence": {"type": "number"}}, +} + +RESPONSE_SCHEMA = { + "type": "object", + "properties": { + "responses": { + "type": "array", + "items": {"type": "object", "properties": {"text": {"type": "string"}}}, + }, + "response_templates": { + "type": "array", + "items": {"type": "object", "properties": {"text": {"type": "string"}}}, + }, + "confidence": {"type": "number"}, + "intent_response_key": {"type": "string"}, + "utter_action": {"type": "string"}, + "template_name": {"type": "string"}, + }, +} + +RANKING_SCHEMA = { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "number"}, + "confidence": {"type": "number"}, + "intent_response_key": {"type": "string"}, + }, + }, +} + +USER_UTTERED = { + "properties": { + "event": {"const": "user"}, + "text": {"type": ["string", "null"]}, + "input_channel": {"type": ["string", "null"]}, + "message_id": {"type": ["string", "null"]}, + "parse_data": { + "type": "object", + "properties": { + "text": {"type": ["string", "null"]}, + "intent_ranking": {"type": "array", "items": INTENT}, + "intent": INTENT, + "entities": ENTITIES_SCHEMA, + "response_selector": { + "type": "object", + "oneOf": [ + {"properties": {"all_retrieval_intents": {"type": "array"}}}, + { + "patternProperties": { + "[\\w/]": { + "type": "object", + "properties": { + "response": RESPONSE_SCHEMA, + "ranking": RANKING_SCHEMA, + }, + } + } + }, + ], + }, + }, + }, + } +} + +ACTION_EXECUTED = { + "properties": { + "event": {"const": "action"}, + "policy": {"type": ["string", "null"]}, + "confidence": {"type": ["number", "null"]}, + "name": {"type": ["string", "null"]}, + "hide_rule_turn": {"type": "boolean"}, + "action_text": {"type": ["string", "null"]}, + } +} + +SLOT_SET = { + "properties": {"event": {"const": "slot"}, "name": {"type": "string"}, "value": {}}, + "required": ["name", "value"], +} + +ENTITIES_ADDED = { + "properties": {"event": {"const": "entities"}, "entities": ENTITIES_SCHEMA}, + "required": ["entities"], +} + +USER_UTTERED_FEATURIZATION = {"properties": {"event": {"const": "user_featurization"}}} +REMINDER_CANCELLED = {"properties": {"event": {"const": "cancel_reminder"}}} +REMINDER_SCHEDULED = {"properties": {"event": {"const": "reminder"}}} +ACTION_EXECUTION_REJECTED = { + "properties": {"event": {"const": "action_execution_rejected"}} +} +FORM_VALIDATION = {"properties": {"event": {"const": "form_validation"}}} +LOOP_INTERRUPTED = {"properties": {"event": {"const": "loop_interrupted"}}} +FORM = {"properties": {"event": {"const": "form"}}} +ACTIVE_LOOP = {"properties": {"event": {"const": "active_loop"}}} +ALL_SLOTS_RESET = {"properties": {"event": {"const": "reset_slots"}}} +CONVERSATION_RESUMED = {"properties": {"event": {"const": "resume"}}} +CONVERSATION_PAUSED = {"properties": {"event": {"const": "pause"}}} +FOLLOWUP_ACTION = {"properties": {"event": {"const": "followup"}}} +STORY_EXPORTED = {"properties": {"event": {"const": "export"}}} +RESTARTED = {"properties": {"event": {"const": "restart"}}} +ACTION_REVERTED = {"properties": {"event": {"const": "undo"}}} +USER_UTTERANCE_REVERTED = {"properties": {"event": {"const": "rewind"}}} +BOT_UTTERED = {"properties": {"event": {"const": "bot"}}} +SESSION_STARTED = {"properties": {"event": {"const": "session_started"}}} +AGENT_UTTERED = {"properties": {"event": {"const": "agent"}}} + +EVENT_SCHEMA = { + "type": "object", + "properties": { + "event": {"type": "string"}, + "timestamp": {"type": ["number", "null"]}, + "metadata": {"type": ["object", "null"]}, + }, + "required": ["event"], + "oneOf": [ + USER_UTTERED, + ACTION_EXECUTED, + SLOT_SET, + ENTITIES_ADDED, + USER_UTTERED_FEATURIZATION, + REMINDER_CANCELLED, + REMINDER_SCHEDULED, + ACTION_EXECUTION_REJECTED, + FORM_VALIDATION, + LOOP_INTERRUPTED, + FORM, + ACTIVE_LOOP, + ALL_SLOTS_RESET, + CONVERSATION_RESUMED, + CONVERSATION_PAUSED, + FOLLOWUP_ACTION, + STORY_EXPORTED, + RESTARTED, + ACTION_REVERTED, + USER_UTTERANCE_REVERTED, + BOT_UTTERED, + SESSION_STARTED, + AGENT_UTTERED, + ], +} + +EVENTS_SCHEMA = {"type": "array", "items": EVENT_SCHEMA} diff --git a/rasa/shared/utils/schemas/model_config.yml b/rasa/shared/utils/schemas/model_config.yml new file mode 100644 index 0000000..68b752c --- /dev/null +++ b/rasa/shared/utils/schemas/model_config.yml @@ -0,0 +1,46 @@ +allowempty: True +mapping: + version: + type: "str" + required: False + allowempty: False + language: + type: "str" + required: False + assistant_id: + type: "str" + required: False + pipeline: + type: "seq" + required: False + sequence: + - type: "map" + # Only validate required items but do not validate each potential config param + # for the the components + allowempty: True + mapping: + name: + type: str + required: True + policies: + type: "seq" + required: False + sequence: + - type: "map" + # Only validate required items but do not validate each potential config param + # for the the policies + allowempty: True + mapping: + name: + type: str + required: True + spaces: + type: "seq" + required: False + sequence: + - type: "map" + allowempty: True + mapping: + name: + type: str + required: True diff --git a/rasa/shared/utils/schemas/stories.yml b/rasa/shared/utils/schemas/stories.yml new file mode 100644 index 0000000..3757fa7 --- /dev/null +++ b/rasa/shared/utils/schemas/stories.yml @@ -0,0 +1,149 @@ +allowempty: True +mapping: + version: + type: "str" + required: False + allowempty: False + stories: + type: "seq" + matching: "any" + sequence: + - type: "map" + mapping: + story: + type: "str" + allowempty: False + metadata: + type: "any" + required: False + steps: + type: "seq" + matching: "any" + sequence: + - type: "map" + mapping: &intent_and_entities + intent: + type: "str" + allowempty: False + user: + type: "str" + allowempty: False + entities: + type: "seq" + matching: "any" + sequence: + - type: "map" + mapping: + regex;(.*): + type: "any" + role: + type: "str" + group: + type: "str" + - type: "str" + - type: "map" + mapping: &active_loop + active_loop: + type: "str" + allowempty: False + - type: "map" + mapping: &action + action: + type: "str" + allowempty: False + - type: "map" + mapping: + bot: + type: "str" + allowempty: False + - type: "map" + mapping: &slot_was_set_seq + slot_was_set: &slot_was_set_seq_value + type: "seq" + matching: "any" + sequence: + - type: "map" + mapping: + regex;(.*): + type: "text" + - type: "map" + mapping: + regex;(.*): + type: "bool" + - type: "map" + mapping: + regex;(.*): + type: "seq" + matching: "any" + sequence: + - type: "map" + mapping: + regex;(.*): + type: "text" + - type: "map" + mapping: + regex;(.*): + type: "bool" + - type: "text" + - type: "str" + - type: "map" + allowempty: True + - type: "map" + matching-rule: 'any' + mapping: + checkpoint: + type: "str" + allowempty: False + slot_was_set: *slot_was_set_seq_value + - type: "map" + mapping: &or_statement + or: + type: "seq" + matching: "any" + sequence: + - type: "map" + mapping: *intent_and_entities + - type: "map" + mapping: *slot_was_set_seq + rules: + type: "seq" + matching: "any" + sequence: + - type: "map" + mapping: + rule: + type: "str" + allowempty: False + metadata: + type: "any" + required: False + steps: + type: "seq" + matching: "any" + sequence: + - type: "map" + mapping: *intent_and_entities + - type: "map" + mapping: *action + - type: "map" + mapping: *active_loop + - type: "map" + mapping: *slot_was_set_seq + - type: "map" + mapping: *or_statement + condition: + type: "seq" + matching: "any" + sequence: + - type: "map" + mapping: *active_loop + - type: "map" + mapping: *slot_was_set_seq + conversation_start: + type: "bool" + allowempty: False + wait_for_user_input: + type: "bool" + allowempty: False + regex;(.*): + type: "any" diff --git a/rasa/shared/utils/validation.py b/rasa/shared/utils/validation.py new file mode 100644 index 0000000..04a0d5b --- /dev/null +++ b/rasa/shared/utils/validation.py @@ -0,0 +1,291 @@ +import logging +import os +from typing import Text, Dict, List, Optional, Any + +from packaging import version +from packaging.version import LegacyVersion +from pykwalify.errors import SchemaError + +from ruamel.yaml.constructor import DuplicateKeyError + +import rasa.shared +from rasa.shared.exceptions import ( + YamlException, + YamlSyntaxException, + SchemaValidationError, +) +import rasa.shared.utils.io +from rasa.shared.constants import ( + DOCS_URL_TRAINING_DATA, + PACKAGE_NAME, + LATEST_TRAINING_DATA_FORMAT_VERSION, + SCHEMA_EXTENSIONS_FILE, + RESPONSES_SCHEMA_FILE, +) + +logger = logging.getLogger(__name__) + +KEY_TRAINING_DATA_FORMAT_VERSION = "version" + + +class YamlValidationException(YamlException, ValueError): + """Raised if a yaml file does not correspond to the expected schema.""" + + def __init__( + self, + message: Text, + validation_errors: Optional[List[SchemaError.SchemaErrorEntry]] = None, + filename: Optional[Text] = None, + content: Any = None, + ) -> None: + """Create The Error. + + Args: + message: error message + validation_errors: validation errors + filename: name of the file which was validated + content: yaml content loaded from the file (used for line information) + """ + super(YamlValidationException, self).__init__(filename) + + self.message = message + self.validation_errors = validation_errors + self.content = content + + def __str__(self) -> Text: + msg = "" + if self.filename: + msg += f"Failed to validate '{self.filename}'. " + else: + msg += "Failed to validate YAML. " + msg += self.message + if self.validation_errors: + unique_errors = {} + for error in self.validation_errors: + line_number = self._line_number_for_path(self.content, error.path) + + if line_number and self.filename: + error_representation = f" in {self.filename}:{line_number}:\n" + elif line_number: + error_representation = f" in Line {line_number}:\n" + else: + error_representation = "" + + error_representation += f" {error}" + unique_errors[str(error)] = error_representation + error_msg = "\n".join(unique_errors.values()) + msg += f":\n{error_msg}" + return msg + + def _line_number_for_path(self, current: Any, path: Text) -> Optional[int]: + """Get line number for a yaml path in the current content. + + Implemented using recursion: algorithm goes down the path navigating to the + leaf in the YAML tree. Unfortunately, not all nodes returned from the + ruamel yaml parser have line numbers attached (arrays have them, dicts have + them), e.g. strings don't have attached line numbers. + If we arrive at a node that has no line number attached, we'll return the + line number of the parent - that is as close as it gets. + + Args: + current: current content + path: path to traverse within the content + + Returns: + the line number of the path in the content. + """ + if not current: + return None + + this_line = current.lc.line + 1 if hasattr(current, "lc") else None + + if not path: + return this_line + + if "/" in path: + head, tail = path.split("/", 1) + else: + head, tail = path, "" + + if head: + if isinstance(current, dict) and head in current: + return self._line_number_for_path(current[head], tail) or this_line + elif isinstance(current, list) and head.isdigit(): + return self._line_number_for_path(current[int(head)], tail) or this_line + else: + return this_line + return self._line_number_for_path(current, tail) or this_line + + +def validate_yaml_schema( + yaml_file_content: Text, schema_path: Text, package_name: Text = PACKAGE_NAME +) -> None: + """Validate yaml content. + + Args: + yaml_file_content: the content of the yaml file to be validated + schema_path: the schema of the yaml file + package_name: the name of the package the schema is located in. defaults + to `rasa`. + """ + from pykwalify.core import Core + from pykwalify.errors import SchemaError + from ruamel.yaml import YAMLError + import pkg_resources + import logging + + log = logging.getLogger("pykwalify") + log.setLevel(logging.CRITICAL) + + try: + # we need "rt" since + # it will add meta information to the parsed output. this meta information + # will include e.g. at which line an object was parsed. this is very + # helpful when we validate files later on and want to point the user to the + # right line + source_data = rasa.shared.utils.io.read_yaml( + yaml_file_content, reader_type=["safe", "rt"] + ) + except (YAMLError, DuplicateKeyError) as e: + raise YamlSyntaxException(underlying_yaml_exception=e) + + schema_file = pkg_resources.resource_filename(package_name, schema_path) + schema_utils_file = pkg_resources.resource_filename( + PACKAGE_NAME, RESPONSES_SCHEMA_FILE + ) + schema_extensions = pkg_resources.resource_filename( + PACKAGE_NAME, SCHEMA_EXTENSIONS_FILE + ) + + # Load schema content using our YAML loader as `pykwalify` uses a global instance + # which can fail when used concurrently + schema_content = rasa.shared.utils.io.read_yaml_file(schema_file) + schema_utils_content = rasa.shared.utils.io.read_yaml_file(schema_utils_file) + schema_content = dict(schema_content, **schema_utils_content) + + c = Core( + source_data=source_data, + schema_data=schema_content, + extensions=[schema_extensions], + ) + + try: + c.validate(raise_exception=True) + except SchemaError: + raise YamlValidationException( + "Please make sure the file is correct and all " + "mandatory parameters are specified. Here are the errors " + "found during validation", + c.errors, + content=source_data, + ) + + +def validate_training_data(json_data: Dict[Text, Any], schema: Dict[Text, Any]) -> None: + """Validate rasa training data format to ensure proper training. + + Args: + json_data: the data to validate + schema: the schema + + Raises: + SchemaValidationError if validation fails. + """ + from jsonschema import validate + from jsonschema import ValidationError + + try: + validate(json_data, schema) + except ValidationError as e: + e.message += ( + f". Failed to validate data, make sure your data " + f"is valid. For more information about the format visit " + f"{DOCS_URL_TRAINING_DATA}." + ) + raise SchemaValidationError.create_from(e) from e + + +def validate_training_data_format_version( + yaml_file_content: Dict[Text, Any], filename: Optional[Text] +) -> bool: + """Validates version on the training data content using `version` field + and warns users if the file is not compatible with the current version of + Rasa Open Source. + + Args: + yaml_file_content: Raw content of training data file as a dictionary. + filename: Name of the validated file. + + Returns: + `True` if the file can be processed by current version of Rasa Open Source, + `False` otherwise. + """ + if filename: + filename = os.path.abspath(filename) + + if not isinstance(yaml_file_content, dict): + raise YamlValidationException( + "YAML content in is not a mapping, can not validate training " + "data schema version.", + filename=filename, + ) + + version_value = yaml_file_content.get(KEY_TRAINING_DATA_FORMAT_VERSION) + + if not version_value: + # not raising here since it's not critical + logger.info( + f"The '{KEY_TRAINING_DATA_FORMAT_VERSION}' key is missing in " + f"the training data file {filename}. " + f"Rasa Open Source will read the file as a " + f"version '{LATEST_TRAINING_DATA_FORMAT_VERSION}' file. " + f"See {DOCS_URL_TRAINING_DATA}." + ) + return True + + try: + if isinstance(version_value, str): + version_value = version_value.strip("\"'") + parsed_version = version.parse(version_value) + latest_version = version.parse(LATEST_TRAINING_DATA_FORMAT_VERSION) + + if isinstance(parsed_version, LegacyVersion): + raise TypeError + + if parsed_version < latest_version: + rasa.shared.utils.io.raise_warning( + f"Training data file {filename} has a lower " + f"format version than your Rasa Open Source installation: " + f"{version_value} < {LATEST_TRAINING_DATA_FORMAT_VERSION}. " + f"Rasa Open Source will read the file as a version " + f"{LATEST_TRAINING_DATA_FORMAT_VERSION} file. " + f"Please update your version key to " + f"{LATEST_TRAINING_DATA_FORMAT_VERSION}. " + f"See {DOCS_URL_TRAINING_DATA}." + ) + + if latest_version >= parsed_version: + + return True + + except TypeError: + rasa.shared.utils.io.raise_warning( + f"Training data file {filename} must specify " + f"'{KEY_TRAINING_DATA_FORMAT_VERSION}' as string, for example:\n" + f"{KEY_TRAINING_DATA_FORMAT_VERSION}: " + f"'{LATEST_TRAINING_DATA_FORMAT_VERSION}'\n" + f"Rasa Open Source will read the file as a " + f"version '{LATEST_TRAINING_DATA_FORMAT_VERSION}' file.", + docs=DOCS_URL_TRAINING_DATA, + ) + return True + + rasa.shared.utils.io.raise_warning( + f"Training data file {filename} has a greater " + f"format version than your Rasa Open Source installation: " + f"{version_value} > {LATEST_TRAINING_DATA_FORMAT_VERSION}. " + f"Please consider updating to the latest version of Rasa Open Source." + f"This file will be skipped.", + docs=DOCS_URL_TRAINING_DATA, + ) + return False diff --git a/rasa/telemetry.py b/rasa/telemetry.py new file mode 100644 index 0000000..eaaf2b7 --- /dev/null +++ b/rasa/telemetry.py @@ -0,0 +1,1086 @@ +import asyncio +import contextlib +from datetime import datetime +from functools import wraps +import hashlib +import json +import logging +import multiprocessing +import os +from pathlib import Path +import platform +import sys +import textwrap +import typing +from typing import Any, Callable, Dict, List, Optional, Text +import uuid +import requests +from terminaltables import SingleTable + +import rasa +from rasa import model +from rasa.constants import ( + CONFIG_FILE_TELEMETRY_KEY, + CONFIG_TELEMETRY_DATE, + CONFIG_TELEMETRY_ENABLED, + CONFIG_TELEMETRY_ID, +) +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.plugin import plugin_manager +from rasa.shared.constants import DOCS_URL_TELEMETRY +from rasa.shared.exceptions import RasaException +import rasa.shared.utils.io +from rasa.utils import common as rasa_utils +import rasa.utils.io + +if typing.TYPE_CHECKING: + from rasa.core.brokers.broker import EventBroker + from rasa.core.tracker_store import TrackerStore + from rasa.core.channels.channel import InputChannel + from rasa.core.agent import Agent + from rasa.shared.nlu.training_data.training_data import TrainingData + from rasa.shared.importers.importer import TrainingDataImporter + from rasa.core.utils import AvailableEndpoints + + +logger = logging.getLogger(__name__) + +SEGMENT_ENDPOINT = "https://api.segment.io/v1/track" +SEGMENT_REQUEST_TIMEOUT = 5 # seconds + +TELEMETRY_ENABLED_ENVIRONMENT_VARIABLE = "RASA_TELEMETRY_ENABLED" +TELEMETRY_DEBUG_ENVIRONMENT_VARIABLE = "RASA_TELEMETRY_DEBUG" + +# the environment variable can be used for local development to set a test key +# e.g. `RASA_TELEMETRY_WRITE_KEY=12354 rasa train` +TELEMETRY_WRITE_KEY_ENVIRONMENT_VARIABLE = "RASA_TELEMETRY_WRITE_KEY" +EXCEPTION_WRITE_KEY_ENVIRONMENT_VARIABLE = "RASA_EXCEPTION_WRITE_KEY" + +TELEMETRY_ID = "metrics_id" +TELEMETRY_ENABLED_BY_DEFAULT = True + +# if one of these environment variables is set, we assume to be running in CI env +CI_ENVIRONMENT_TELL = [ + "bamboo.buildKey", + "BUILD_ID", + "BUILD_NUMBER", + "BUILDKITE", + "CI", + "CIRCLECI", + "CONTINUOUS_INTEGRATION", + "GITHUB_ACTIONS", + "HUDSON_URL", + "JENKINS_URL", + "TEAMCITY_VERSION", + "TRAVIS", + "CODEBUILD_BUILD_ARN", + "CODEBUILD_BUILD_ID", + "CODEBUILD_BATCH_BUILD_IDENTIFIER", +] + +# If updating or creating a new event, remember to update +# https://rasa.com/docs/rasa/telemetry +TRAINING_STARTED_EVENT = "Training Started" +TRAINING_COMPLETED_EVENT = "Training Completed" +TELEMETRY_DISABLED_EVENT = "Telemetry Disabled" +TELEMETRY_DATA_SPLIT_EVENT = "Training Data Split" +TELEMETRY_DATA_VALIDATED_EVENT = "Training Data Validated" +TELEMETRY_DATA_CONVERTED_EVENT = "Training Data Converted" +TELEMETRY_TRACKER_EXPORTED_EVENT = "Tracker Exported" +TELEMETRY_INTERACTIVE_LEARNING_STARTED_EVENT = "Interactive Learning Started" +TELEMETRY_SERVER_STARTED_EVENT = "Server Started" +TELEMETRY_PROJECT_CREATED_EVENT = "Project Created" +TELEMETRY_SHELL_STARTED_EVENT = "Shell Started" +TELEMETRY_VISUALIZATION_STARTED_EVENT = "Story Visualization Started" +TELEMETRY_TEST_CORE_EVENT = "Model Core Tested" +TELEMETRY_TEST_NLU_EVENT = "Model NLU Tested" +TELEMETRY_MARKERS_EXTRACTION_INITIATED_EVENT = "Markers Extraction Initiated" +TELEMETRY_MARKERS_EXTRACTED_EVENT = "Markers Extracted" +TELEMETRY_MARKERS_STATS_COMPUTED_EVENT = "Markers Statistics Computed" +TELEMETRY_MARKERS_PARSED_COUNT = "Markers Parsed" + +# used to calculate the context on the first call and cache it afterwards +TELEMETRY_CONTEXT = None + + +def print_telemetry_reporting_info() -> None: + """Print telemetry information to std out.""" + message = textwrap.dedent( + f""" + Rasa Open Source reports anonymous usage telemetry to help improve the product + for all its users. + + If you'd like to opt-out, you can use `rasa telemetry disable`. + To learn more, check out {DOCS_URL_TELEMETRY}.""" + ).strip() + + table = SingleTable([[message]]) + print(table.table) + + +def _default_telemetry_configuration(is_enabled: bool) -> Dict[Text, Any]: + return { + CONFIG_TELEMETRY_ENABLED: is_enabled, + CONFIG_TELEMETRY_ID: uuid.uuid4().hex, + CONFIG_TELEMETRY_DATE: datetime.now(), + } + + +def _write_default_telemetry_configuration( + is_enabled: bool = TELEMETRY_ENABLED_BY_DEFAULT, +) -> bool: + new_config = _default_telemetry_configuration(is_enabled) + + success = rasa_utils.write_global_config_value( + CONFIG_FILE_TELEMETRY_KEY, new_config + ) + + # Do not show info if user has enabled/disabled telemetry via env var + telemetry_environ = os.environ.get(TELEMETRY_ENABLED_ENVIRONMENT_VARIABLE) + if is_enabled and success and telemetry_environ is None: + print_telemetry_reporting_info() + + return success + + +def _is_telemetry_enabled_in_configuration() -> bool: + """Read telemetry configuration from the user's Rasa config file in $HOME. + + Creates a default configuration if no configuration exists. + + Returns: + `True`, if telemetry is enabled, `False` otherwise. + """ + try: + stored_config = rasa_utils.read_global_config_value( + CONFIG_FILE_TELEMETRY_KEY, unavailable_ok=False + ) + + return stored_config[CONFIG_TELEMETRY_ENABLED] + except ValueError as e: + logger.debug(f"Could not read telemetry settings from configuration file: {e}") + + # seems like there is no config, we'll create one and enable telemetry + success = _write_default_telemetry_configuration() + # if writing the configuration failed, telemetry will be disabled + return TELEMETRY_ENABLED_BY_DEFAULT and success + + +def is_telemetry_enabled() -> bool: + """Check if telemetry is enabled either in configuration or environment. + + Returns: + `True`, if telemetry is enabled, `False` otherwise. + """ + telemetry_environ = os.environ.get(TELEMETRY_ENABLED_ENVIRONMENT_VARIABLE) + + if telemetry_environ is not None: + return telemetry_environ.lower() == "true" + + try: + return rasa_utils.read_global_config_value( + CONFIG_FILE_TELEMETRY_KEY, unavailable_ok=False + )[CONFIG_TELEMETRY_ENABLED] + except ValueError: + return False + + +def initialize_telemetry() -> bool: + """Read telemetry configuration from the user's Rasa config file in $HOME. + + Creates a default configuration if no configuration exists. + + Returns: + `True`, if telemetry is enabled, `False` otherwise. + """ + try: + # calling this even if the environment variable is set makes sure the + # configuration is created and there is a telemetry ID + is_enabled_in_configuration = _is_telemetry_enabled_in_configuration() + + telemetry_environ = os.environ.get(TELEMETRY_ENABLED_ENVIRONMENT_VARIABLE) + + if telemetry_environ is None: + return is_enabled_in_configuration + + return telemetry_environ.lower() == "true" + except Exception as e: # skipcq:PYL-W0703 + logger.exception( + f"Failed to initialize telemetry reporting: {e}." + f"Telemetry reporting will be disabled." + ) + return False + + +def ensure_telemetry_enabled(f: Callable[..., Any]) -> Callable[..., Any]: + """Function decorator for telemetry functions that ensures telemetry is enabled. + + WARNING: does not work as a decorator for async generators. + + Args: + f: function to call if telemetry is enabled + Returns: + Return wrapped function + """ + # allows us to use the decorator for async and non async functions + if asyncio.iscoroutinefunction(f): + + @wraps(f) + async def decorated_coroutine(*args: Any, **kwargs: Any) -> Any: + if is_telemetry_enabled(): + return await f(*args, **kwargs) + return None + + return decorated_coroutine + + @wraps(f) + def decorated(*args: Any, **kwargs: Any) -> Any: + if is_telemetry_enabled(): + return f(*args, **kwargs) + return None + + return decorated + + +def _fetch_write_key(tool: Text, environment_variable: Text) -> Optional[Text]: + """Read the write key from a tool from our set of keys. + + Args: + tool: name of the tool we want to fetch a key for + environment_variable: name of the environment variable to set the key + Returns: + write key, if a key was present. + """ + import pkg_resources + from rasa import __name__ as name + + if os.environ.get(environment_variable): + # a write key set using the environment variable will always + # overwrite any key provided as part of the package (`keys` file) + return os.environ.get(environment_variable) + + write_key_path = pkg_resources.resource_filename(name, "keys") + + # noinspection PyBroadException + try: + with open(write_key_path) as f: + return json.load(f).get(tool) + except Exception: # skipcq:PYL-W0703 + return None + + +def telemetry_write_key() -> Optional[Text]: + """Read the Segment write key from the segment key text file. + + The segment key text file should by present only in wheel/sdist packaged + versions of Rasa Open Source. This avoids running telemetry locally when + developing on Rasa or when running CI builds. + + In local development, this should always return `None` to avoid logging telemetry. + + Returns: + Segment write key, if the key file was present. + """ + return _fetch_write_key("segment", TELEMETRY_WRITE_KEY_ENVIRONMENT_VARIABLE) + + +def sentry_write_key() -> Optional[Text]: + """Read the sentry write key from the sentry key text file. + + Returns: + Sentry write key, if the key file was present. + """ + return _fetch_write_key("sentry", EXCEPTION_WRITE_KEY_ENVIRONMENT_VARIABLE) + + +def _encode_base64(original: Text, encoding: Text = "utf-8") -> Text: + """Encodes a string as a base64 string. + + Args: + original: Text to be encoded. + encoding: Encoding used to convert text to binary. + + Returns: + Encoded text. + """ + import base64 + + return base64.b64encode(original.encode(encoding)).decode(encoding) + + +def segment_request_header(write_key: Text) -> Dict[Text, Any]: + """Use a segment write key to create authentication headers for the segment API. + + Args: + write_key: Authentication key for segment. + + Returns: + Authentication headers for segment. + """ + return { + "Authorization": "Basic {}".format(_encode_base64(write_key + ":")), + "Content-Type": "application/json", + } + + +def segment_request_payload( + distinct_id: Text, + event_name: Text, + properties: Dict[Text, Any], + context: Dict[Text, Any], +) -> Dict[Text, Any]: + """Compose a valid payload for the segment API. + + Args: + distinct_id: Unique telemetry ID. + event_name: Name of the event. + properties: Values to report along the event. + context: Context information about the event. + + Returns: + Valid segment payload. + """ + return { + "userId": distinct_id, + "event": event_name, + "properties": properties, + "context": context, + } + + +def in_continuous_integration() -> bool: + """Returns `True` if currently running inside a continuous integration context.""" + return any(env in os.environ for env in CI_ENVIRONMENT_TELL) + + +def _is_telemetry_debug_enabled() -> bool: + """Check if telemetry debug mode is enabled.""" + return ( + os.environ.get(TELEMETRY_DEBUG_ENVIRONMENT_VARIABLE, "false").lower() == "true" + ) + + +def print_telemetry_event(payload: Dict[Text, Any]) -> None: + """Print a telemetry events payload to the commandline. + + Args: + payload: payload of the event + """ + print("Telemetry Event:") + print(json.dumps(payload, indent=2)) + + +def _send_event( + distinct_id: Text, + event_name: Text, + properties: Dict[Text, Any], + context: Dict[Text, Any], +) -> None: + """Report the contents segmentof an event to the /track Segment endpoint. + + Documentation: https://.com/docs/sources/server/http/ + + Do not call this function from outside telemetry.py! This function does not + check if telemetry is enabled or not. + + Args: + distinct_id: Unique telemetry ID. + event_name: Name of the event. + properties: Values to report along the event. + context: Context information about the event. + """ + payload = segment_request_payload(distinct_id, event_name, properties, context) + + if _is_telemetry_debug_enabled(): + print_telemetry_event(payload) + return + + write_key = telemetry_write_key() + if not write_key: + # If TELEMETRY_WRITE_KEY is empty or `None`, telemetry has not been + # enabled for this build (e.g. because it is running from source) + logger.debug("Skipping request to external service: telemetry key not set.") + return + + if "license_hash" not in context: + # only send telemetry data for customers + logger.debug("Skipping telemetry reporting: no license hash found.") + return + + headers = segment_request_header(write_key) + + resp = requests.post( + SEGMENT_ENDPOINT, headers=headers, json=payload, timeout=SEGMENT_REQUEST_TIMEOUT + ) + # handle different failure cases + if resp.status_code != 200: + logger.debug( + f"Segment telemetry request returned a {resp.status_code} response. " + f"Body: {resp.text}" + ) + else: + data = resp.json() + if not data.get("success"): + logger.debug( + f"Segment telemetry request returned a failure. Response: {data}" + ) + + +def _hash_directory_path(path: Text) -> Optional[Text]: + """Create a hash for the directory. + + Returns: + hash of the directories path + """ + full_path = Path(path).absolute() + return hashlib.sha256(str(full_path).encode("utf-8")).hexdigest() + + +# noinspection PyBroadException +def _is_docker() -> bool: + """Guess if we are running in docker environment. + + Returns: + `True` if we are running inside docker, `False` otherwise. + """ + # first we try to use the env + try: + os.stat("/.dockerenv") + return True + except Exception: # skipcq:PYL-W0703 + pass + + # if that didn't work, try to use proc information + try: + return "docker" in rasa.shared.utils.io.read_file("/proc/self/cgroup", "utf8") + except Exception: # skipcq:PYL-W0703 + return False + + +def with_default_context_fields( + context: Optional[Dict[Text, Any]] = None +) -> Dict[Text, Any]: + """Return a new context dictionary with default and provided field values merged. + + The default fields contain only the OS information for now. + + Args: + context: Context information about the event. + + Return: + A new context. + """ + context = context or {} + + return {**_default_context_fields(), **context} + + +def _default_context_fields() -> Dict[Text, Any]: + """Return a dictionary that contains the default context values. + + Return: + A new context containing information about the runtime environment. + """ + global TELEMETRY_CONTEXT + + if not TELEMETRY_CONTEXT: + # Make sure to update the example in docs/docs/telemetry/telemetry.mdx + # if you change / add context + TELEMETRY_CONTEXT = { + "os": {"name": platform.system(), "version": platform.release()}, + "ci": in_continuous_integration(), + "project": model.project_fingerprint(), + "directory": _hash_directory_path(os.getcwd()), + "python": sys.version.split(" ")[0], + "rasa_open_source": rasa.__version__, + "cpu": multiprocessing.cpu_count(), + "docker": _is_docker(), + } + license_hash = plugin_manager().hook.get_license_hash() + if license_hash: + TELEMETRY_CONTEXT["license_hash"] = license_hash + + # avoid returning the cached dict --> caller could modify the dictionary... + # usually we would use `lru_cache`, but that doesn't return a dict copy and + # doesn't work on inner functions, so we need to roll our own caching... + return TELEMETRY_CONTEXT.copy() + + +def _track( + event_name: Text, + properties: Optional[Dict[Text, Any]] = None, + context: Optional[Dict[Text, Any]] = None, +) -> None: + """Tracks a telemetry event. + + It is OK to use this function from outside telemetry.py, but note that it + is recommended to create a new track_xyz() function for complex telemetry + events, or events that are generated from many parts of the Rasa Open Source code. + + Args: + event_name: Name of the event. + properties: Dictionary containing the event's properties. + context: Dictionary containing some context for this event. + """ + try: + telemetry_id = get_telemetry_id() + + if not telemetry_id: + logger.debug("Will not report telemetry events as no ID was found.") + return + + if not properties: + properties = {} + + properties[TELEMETRY_ID] = telemetry_id + + _send_event( + telemetry_id, event_name, properties, with_default_context_fields(context) + ) + except Exception as e: # skipcq:PYL-W0703 + logger.debug(f"Skipping telemetry reporting: {e}") + + +def get_telemetry_id() -> Optional[Text]: + """Return the unique telemetry identifier for this Rasa Open Source install. + + The identifier can be any string, but it should be a UUID. + + Returns: + The identifier, if it is configured correctly. + """ + try: + telemetry_config = ( + rasa_utils.read_global_config_value(CONFIG_FILE_TELEMETRY_KEY) or {} + ) + + return telemetry_config.get(CONFIG_TELEMETRY_ID) + except Exception as e: # skipcq:PYL-W0703 + logger.debug(f"Unable to retrieve telemetry ID: {e}") + return None + + +def toggle_telemetry_reporting(is_enabled: bool) -> None: + """Write to the configuration if telemetry tracking should be enabled or disabled. + + Args: + is_enabled: `True` if the telemetry reporting should be enabled, + `False` otherwise. + """ + configuration = rasa_utils.read_global_config_value(CONFIG_FILE_TELEMETRY_KEY) + + if configuration: + configuration[CONFIG_TELEMETRY_ENABLED] = is_enabled + else: + configuration = _default_telemetry_configuration(is_enabled) + + rasa_utils.write_global_config_value(CONFIG_FILE_TELEMETRY_KEY, configuration) + + +def filter_errors( + event: Optional[Dict[Text, Any]], hint: Optional[Dict[Text, Any]] = None +) -> Optional[Dict[Text, Any]]: + """Filter errors. + + Args: + event: event to be logged to sentry + hint: some hinting information sent alongside of the event + + Returns: + the event without any sensitive / PII data or `None` if the event constitutes + an `ImportError` which should be discarded. + """ + if hint and "exc_info" in hint: + exc_type, exc_value, tb = hint["exc_info"] + if isinstance(exc_value, ImportError): + return None + return event + + +def before_send( + event: Dict[Text, Any], _unused_hint: Optional[Dict[Text, Any]] = None +) -> Optional[Dict[Text, Any]]: + """Strips the sensitive data and filters errors before sending to sentry. + + Args: + event: event to be logged to sentry + _unused_hint: some hinting information sent alongside of the event + + Returns: + the event without any sensitive / PII data or `None` if the event should + be discarded. + """ + cleaned_event = strip_sensitive_data_from_sentry_event(event, _unused_hint) + return filter_errors(cleaned_event, _unused_hint) + + +def strip_sensitive_data_from_sentry_event( + event: Dict[Text, Any], _unused_hint: Optional[Dict[Text, Any]] = None +) -> Optional[Dict[Text, Any]]: + """Remove any sensitive data from the event (e.g. path names). + + Args: + event: event to be logged to sentry + _unused_hint: some hinting information sent alongside of the event + + Returns: + the event without any sensitive / PII data or `None` if the event should + be discarded. + """ + # removes any paths from stack traces (avoids e.g. sending + # a users home directory name if package is installed there) + for value in event.get("exception", {}).get("values", []): + for frame in value.get("stacktrace", {}).get("frames", []): + frame["abs_path"] = "" + + if f"rasa_sdk{os.path.sep}executor.py" in frame["filename"]: + # this looks a lot like an exception in the SDK and hence custom code + # no need for us to deal with that + return None + elif "site-packages" in frame["filename"]: + # drop site-packages and following slash / backslash + relative_name = frame["filename"].split("site-packages")[-1][1:] + frame["filename"] = os.path.join("site-packages", relative_name) + elif "dist-packages" in frame["filename"]: + # drop dist-packages and following slash / backslash + relative_name = frame["filename"].split("dist-packages")[-1][1:] + frame["filename"] = os.path.join("dist-packages", relative_name) + elif os.path.isabs(frame["filename"]): + # if the file path is absolute, we'll drop the whole event as this is + # very likely custom code. needs to happen after cleaning as + # site-packages / dist-packages paths are also absolute, but fine. + return None + return event + + +@ensure_telemetry_enabled +def initialize_error_reporting() -> None: + """Sets up automated error reporting. + + Exceptions are reported to sentry. We avoid sending any metadata (local + variables, paths, ...) to make sure we don't compromise any data. Only the + exception and its stacktrace is logged and only if the exception origins + from the `rasa` package. + """ + import sentry_sdk + from sentry_sdk import configure_scope + from sentry_sdk.integrations.atexit import AtexitIntegration + from sentry_sdk.integrations.dedupe import DedupeIntegration + from sentry_sdk.integrations.excepthook import ExcepthookIntegration + + # key for local testing can be found at + # https://sentry.io/settings/rasahq/projects/rasa-open-source/install/python/ + # for local testing, set the key using `RASA_EXCEPTION_WRITE_KEY=key rasa ` + key = sentry_write_key() + + if not key: + return + + telemetry_id = get_telemetry_id() + + # this is a very defensive configuration, avoiding as many integrations as + # possible. it also submits very little data (exception with error message + # and line numbers). + sentry_sdk.init( + f"https://{key}.ingest.sentry.io/2801673", + before_send=before_send, + integrations=[ + ExcepthookIntegration(), + DedupeIntegration(), + AtexitIntegration(lambda _, __: None), + ], + send_default_pii=False, # activate PII filter + server_name=telemetry_id or "UNKNOWN", + ignore_errors=[ + # std lib errors + KeyboardInterrupt, # user hit the interrupt key (Ctrl+C) + MemoryError, # machine is running out of memory + NotImplementedError, # user is using a feature that is not implemented + asyncio.CancelledError, # an async operation has been cancelled by the user + # expected Rasa errors + RasaException, + OSError, + ], + in_app_include=["rasa"], # only submit errors in this package + with_locals=False, # don't submit local variables + release=f"rasa-{rasa.__version__}", + default_integrations=False, + environment="development" if in_continuous_integration() else "production", + ) + + if not telemetry_id: + return + + with configure_scope() as scope: + # sentry added these more recently, just a protection in a case where a + # user has installed an older version of sentry + if hasattr(scope, "set_user"): + scope.set_user({"id": telemetry_id}) + + default_context = _default_context_fields() + if hasattr(scope, "set_context"): + if "os" in default_context: + # os is a nested dict, hence we report it separately + scope.set_context("Operating System", default_context.pop("os")) + scope.set_context("Environment", default_context) + + +@contextlib.contextmanager +def track_model_training( + training_data: "TrainingDataImporter", model_type: Text, is_finetuning: bool = False +) -> typing.Generator[None, None, None]: + """Track a model training started. + + WARNING: since this is a generator, it can't use the ensure telemetry + decorator. We need to manually add these checks here. This can be + fixed as soon as we drop python 3.6 support. + + Args: + training_data: Training data used for the training. + model_type: Specifies the type of training, should be either "rasa", "core" + or "nlu". + is_finetuning: `True` if the model is trained by finetuning another model. + """ + if not initialize_telemetry(): + # telemetry reporting is disabled. we won't do any reporting + yield # runs the training + return + + config = training_data.get_config() + stories = training_data.get_stories() + nlu_data = training_data.get_nlu_data() + domain = training_data.get_domain() + count_conditional_responses = domain.count_conditional_response_variations() + ( + count_total_mappings, + count_custom_mappings, + count_conditional_mappings, + ) = domain.count_slot_mapping_statistics() + + training_id = uuid.uuid4().hex + + # Make sure to update the example in docs/docs/telemetry/telemetry.mdx + # if you change / add any properties + _track( + TRAINING_STARTED_EVENT, + { + "language": config.get("language"), + "training_id": training_id, + "type": model_type, + "pipeline": config.get("pipeline"), + "policies": config.get("policies"), + "train_schema": config.get("train_schema"), + "predict_schema": config.get("predict_schema"), + "num_intent_examples": len(nlu_data.intent_examples), + "num_entity_examples": len(nlu_data.entity_examples), + "num_actions": len(domain.action_names_or_texts), + # Old nomenclature from when 'responses' were still called + # 'templates' in the domain + "num_templates": len(domain.responses), + "num_conditional_response_variations": count_conditional_responses, + "num_slot_mappings": count_total_mappings, + "num_custom_slot_mappings": count_custom_mappings, + "num_conditional_slot_mappings": count_conditional_mappings, + "num_slots": len(domain.slots), + "num_forms": len(domain.forms), + "num_intents": len(domain.intents), + "num_entities": len(domain.entities), + "num_story_steps": len(stories.story_steps), + "num_lookup_tables": len(nlu_data.lookup_tables), + "num_synonyms": len(nlu_data.entity_synonyms), + "num_regexes": len(nlu_data.regex_features), + "is_finetuning": is_finetuning, + "recipe": config.get("recipe"), + }, + ) + start = datetime.now() + yield + runtime = datetime.now() - start + + _track( + TRAINING_COMPLETED_EVENT, + { + "training_id": training_id, + "type": model_type, + "runtime": int(runtime.total_seconds()), + }, + ) + + +@ensure_telemetry_enabled +def track_telemetry_disabled() -> None: + """Track when a user disables telemetry.""" + _track(TELEMETRY_DISABLED_EVENT) + + +@ensure_telemetry_enabled +def track_data_split(fraction: float, data_type: Text) -> None: + """Track when a user splits data. + + Args: + fraction: How much data goes into train and how much goes into test + data_type: Is this core, nlu or nlg data + """ + _track(TELEMETRY_DATA_SPLIT_EVENT, {"fraction": fraction, "type": data_type}) + + +@ensure_telemetry_enabled +def track_validate_files(validation_success: bool) -> None: + """Track when a user validates data files. + + Args: + validation_success: Whether the validation was successful + """ + _track(TELEMETRY_DATA_VALIDATED_EVENT, {"validation_success": validation_success}) + + +@ensure_telemetry_enabled +def track_data_convert(output_format: Text, data_type: Text) -> None: + """Track when a user converts data. + + Args: + output_format: Target format for the converter + data_type: Is this core, nlu or nlg data + """ + _track( + TELEMETRY_DATA_CONVERTED_EVENT, + {"output_format": output_format, "type": data_type}, + ) + + +@ensure_telemetry_enabled +def track_tracker_export( + number_of_exported_events: int, + tracker_store: "TrackerStore", + event_broker: "EventBroker", +) -> None: + """Track when a user exports trackers. + + Args: + number_of_exported_events: Number of events that got exported + tracker_store: Store used to retrieve the events from + event_broker: Broker the events are getting published towards + """ + _track( + TELEMETRY_TRACKER_EXPORTED_EVENT, + { + "number_of_exported_events": number_of_exported_events, + "tracker_store": type(tracker_store).__name__, + "event_broker": type(event_broker).__name__, + }, + ) + + +@ensure_telemetry_enabled +def track_interactive_learning_start( + skip_visualization: bool, save_in_e2e: bool +) -> None: + """Track when a user starts an interactive learning session. + + Args: + skip_visualization: Is visualization skipped in this session + save_in_e2e: Is e2e used in this session + """ + _track( + TELEMETRY_INTERACTIVE_LEARNING_STARTED_EVENT, + {"skip_visualization": skip_visualization, "save_in_e2e": save_in_e2e}, + ) + + +@ensure_telemetry_enabled +def track_server_start( + input_channels: List["InputChannel"], + endpoints: Optional["AvailableEndpoints"], + model_directory: Optional[Text], + number_of_workers: int, + is_api_enabled: bool, +) -> None: + """Tracks when a user starts a rasa server. + + Args: + input_channels: Used input channels + endpoints: Endpoint configuration for the server + model_directory: directory of the running model + number_of_workers: number of used Sanic workers + is_api_enabled: whether the rasa API server is enabled + """ + from rasa.core.utils import AvailableEndpoints + + def project_fingerprint_from_model( + _model_directory: Optional[Text], + ) -> Optional[Text]: + """Gets project fingerprint from an app's loaded model.""" + if not model_directory: + return None + + try: + model_archive = model.get_local_model(_model_directory) + metadata = LocalModelStorage.metadata_from_archive(model_archive) + + return metadata.project_fingerprint + except Exception: + return None + + if not endpoints: + endpoints = AvailableEndpoints() + + _track( + TELEMETRY_SERVER_STARTED_EVENT, + { + "input_channels": [i.name() for i in input_channels], + "api_enabled": is_api_enabled, + "number_of_workers": number_of_workers, + "endpoints_nlg": endpoints.nlg.type if endpoints.nlg else None, + "endpoints_nlu": endpoints.nlu.type if endpoints.nlu else None, + "endpoints_action_server": endpoints.action.type + if endpoints.action + else None, + "endpoints_model_server": endpoints.model.type if endpoints.model else None, + "endpoints_tracker_store": endpoints.tracker_store.type + if endpoints.tracker_store + else None, + "endpoints_lock_store": endpoints.lock_store.type + if endpoints.lock_store + else None, + "endpoints_event_broker": endpoints.event_broker.type + if endpoints.event_broker + else None, + "project": project_fingerprint_from_model(model_directory), + }, + ) + + +@ensure_telemetry_enabled +def track_project_init(path: Text) -> None: + """Track when a user creates a project using rasa init. + + Args: + path: Location of the project + """ + _track( + TELEMETRY_PROJECT_CREATED_EVENT, {"init_directory": _hash_directory_path(path)} + ) + + +@ensure_telemetry_enabled +def track_shell_started(model_type: Text) -> None: + """Track when a user starts a bot using rasa shell. + + Args: + model_type: Type of the model, core / nlu or rasa. + """ + _track(TELEMETRY_SHELL_STARTED_EVENT, {"type": model_type}) + + +@ensure_telemetry_enabled +def track_visualization() -> None: + """Track when a user runs the visualization.""" + _track(TELEMETRY_VISUALIZATION_STARTED_EVENT) + + +@ensure_telemetry_enabled +def track_core_model_test(num_story_steps: int, e2e: bool, agent: "Agent") -> None: + """Track when a user tests a core model. + + Args: + num_story_steps: Number of test stories used for the comparison + e2e: indicator if tests running in end to end mode + agent: Agent of the model getting tested + """ + if agent.processor is None: + project_fingerprint = "" + else: + project_fingerprint = agent.processor.model_metadata.project_fingerprint + + _track( + TELEMETRY_TEST_CORE_EVENT, + { + "project": project_fingerprint, + "end_to_end": e2e, + "num_story_steps": num_story_steps, + }, + ) + + +@ensure_telemetry_enabled +def track_nlu_model_test(test_data: "TrainingData") -> None: + """Track when a user tests an nlu model. + + Args: + test_data: Data used for testing + """ + _track( + TELEMETRY_TEST_NLU_EVENT, + { + "num_intent_examples": len(test_data.intent_examples), + "num_entity_examples": len(test_data.entity_examples), + "num_lookup_tables": len(test_data.lookup_tables), + "num_synonyms": len(test_data.entity_synonyms), + "num_regexes": len(test_data.regex_features), + }, + ) + + +@ensure_telemetry_enabled +def track_markers_extraction_initiated( + strategy: Text, only_extract: bool, seed: bool, count: Optional[int] +) -> None: + """Track when a user tries to extract success markers. + + Args: + strategy: The strategy the user is using for tracker selection + only_extract: Indicates if the user is only extracting markers or also + producing stats + seed: Indicates if the user used a seed for this attempt + count: (Optional) The number of trackers the user is trying to select. + """ + _track( + TELEMETRY_MARKERS_EXTRACTION_INITIATED_EVENT, + { + "strategy": strategy, + "only_extract": only_extract, + "seed": seed, + "count": count, + }, + ) + + +@ensure_telemetry_enabled +def track_markers_extracted(trackers_count: int) -> None: + """Track when markers have been extracted by a user. + + Args: + trackers_count: The actual number of trackers processed + """ + _track(TELEMETRY_MARKERS_EXTRACTED_EVENT, {"trackers_count": trackers_count}) + + +@ensure_telemetry_enabled +def track_markers_stats_computed(trackers_count: int) -> None: + """Track when stats over markers have been computed by a user. + + Args: + trackers_count: The actual number of trackers processed + """ + _track(TELEMETRY_MARKERS_STATS_COMPUTED_EVENT, {"trackers_count": trackers_count}) + + +@ensure_telemetry_enabled +def track_markers_parsed_count( + marker_count: int, max_depth: int, branching_factor: int +) -> None: + """Track when markers have been successfully parsed from config. + + Args: + marker_count: The number of markers found in the config + max_depth: The maximum depth of any marker in the config + branching_factor: The maximum number of children of any marker in the config. + """ + _track( + TELEMETRY_MARKERS_PARSED_COUNT, + { + "marker_count": marker_count, + "max_depth": max_depth, + "branching_factor": branching_factor, + }, + ) diff --git a/rasa/utils/__init__.py b/rasa/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/utils/common.py b/rasa/utils/common.py new file mode 100644 index 0000000..f99f540 --- /dev/null +++ b/rasa/utils/common.py @@ -0,0 +1,591 @@ +import copy +import inspect +import logging +import logging.config +import logging.handlers +import os +import shutil +import tempfile +import warnings +from pathlib import Path +from socket import SOCK_DGRAM, SOCK_STREAM +from types import TracebackType +from typing import ( + Any, + Coroutine, + Dict, + List, + Optional, + Text, + Type, + TypeVar, + Union, + ContextManager, + Set, + Tuple, +) + +import numpy as np + +import rasa.shared.utils.io +import rasa.utils.io +from rasa.constants import ( + DEFAULT_LOG_LEVEL_LIBRARIES, + ENV_LOG_LEVEL_LIBRARIES, + ENV_LOG_LEVEL_MATPLOTLIB, + ENV_LOG_LEVEL_RABBITMQ, + ENV_LOG_LEVEL_KAFKA, +) +from rasa.shared.constants import DEFAULT_LOG_LEVEL, ENV_LOG_LEVEL, TCP_PROTOCOL +from rasa.shared.exceptions import RasaException + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + +EXPECTED_PILLOW_DEPRECATION_WARNINGS: List[Tuple[Type[Warning], str]] = [ + # Keras uses deprecated Pillow features + # cf. https://github.com/keras-team/keras/issues/16639 + (DeprecationWarning, f"{method} is deprecated and will be removed in Pillow 10 .*") + for method in ["BICUBIC", "NEAREST", "BILINEAR", "HAMMING", "BOX", "LANCZOS"] +] + +EXPECTED_WARNINGS: List[Tuple[Type[Warning], str]] = [ + # TODO (issue #9932) + ( + np.VisibleDeprecationWarning, + "Creating an ndarray from ragged nested sequences.*", + ), + # cf. https://github.com/tensorflow/tensorflow/issues/38168 + ( + UserWarning, + "Converting sparse IndexedSlices.* to a dense Tensor of unknown " + "shape. This may consume a large amount of memory.", + ), + (UserWarning, "Slot auto-fill has been removed in 3.0 .*"), + # This warning is caused by the flatbuffers package + # The import was fixed on Github, but the latest version + # is not available on PyPi, so we cannot pin the newer version. + # cf. https://github.com/google/flatbuffers/issues/6957 + (DeprecationWarning, "the imp module is deprecated in favour of importlib.*"), + # Cannot fix this deprecation warning since we need to support two + # numpy versions as long as we keep python 37 around + (DeprecationWarning, "the `interpolation=` argument to quantile was renamed"), + # the next two warnings are triggered by adding 3.10 support, + # for more info: https://docs.python.org/3.10/whatsnew/3.10.html#deprecated + (DeprecationWarning, "the load_module*"), + (ImportWarning, "_SixMetaPathImporter.find_spec*"), + # 3.10 specific warning: https://github.com/pytest-dev/pytest-asyncio/issues/212 + (DeprecationWarning, "There is no current event loop"), + # UserWarning which is always issued if the default value for + # assistant_id key in config file is not changed + (UserWarning, "is missing a unique value for the 'assistant_id' mandatory key.*"), + ( + DeprecationWarning, + "non-integer arguments to randrange\\(\\) have been deprecated since", + ), +] + +EXPECTED_WARNINGS.extend(EXPECTED_PILLOW_DEPRECATION_WARNINGS) +PYTHON_LOGGING_SCHEMA_DOCS = ( + "https://docs.python.org/3/library/logging.config.html#dictionary-schema-details" +) + + +class TempDirectoryPath(str, ContextManager): + """Represents a path to an temporary directory. + + When used as a context manager, it erases the contents of the directory on exit. + """ + + def __enter__(self) -> "TempDirectoryPath": + return self + + def __exit__( + self, + _exc: Optional[Type[BaseException]], + _value: Optional[BaseException], + _tb: Optional[TracebackType], + ) -> None: + if os.path.exists(self): + shutil.rmtree(self) + + +def get_temp_dir_name() -> Text: + """Returns the path name of a newly created temporary directory.""" + tempdir_name = tempfile.mkdtemp() + + return decode_bytes(tempdir_name) + + +def decode_bytes(name: Union[Text, bytes]) -> Text: + """Converts bytes object to string.""" + if isinstance(name, bytes): + name = name.decode("UTF-8") + + return name + + +def read_global_config(path: Text) -> Dict[Text, Any]: + """Read global Rasa configuration. + + Args: + path: Path to the configuration + Returns: + The global configuration + """ + # noinspection PyBroadException + try: + return rasa.shared.utils.io.read_config_file(path) + except Exception: + # if things go south we pretend there is no config + return {} + + +def configure_logging_from_file(logging_config_file: Text) -> None: + """Parses YAML file content to configure logging. + + Args: + logging_config_file: YAML file containing logging configuration to handle + custom formatting + """ + logging_config_dict = rasa.shared.utils.io.read_yaml_file(logging_config_file) + + try: + logging.config.dictConfig(logging_config_dict) + except (ValueError, TypeError, AttributeError, ImportError) as e: + logger.debug( + f"The logging config file {logging_config_file} could not " + f"be applied because it failed validation against " + f"the built-in Python logging schema. " + f"More info at {PYTHON_LOGGING_SCHEMA_DOCS}.", + exc_info=e, + ) + + +def configure_logging_and_warnings( + log_level: Optional[int] = None, + logging_config_file: Optional[Text] = None, + warn_only_once: bool = True, + filter_repeated_logs: bool = True, +) -> None: + """Sets log levels of various loggers and sets up filters for warnings and logs. + + Args: + log_level: The log level to be used for the 'Rasa' logger. Pass `None` to use + either the environment variable 'LOG_LEVEL' if it is specified, or the + default log level otherwise. + logging_config_file: YAML file containing logging configuration to handle + custom formatting + warn_only_once: determines whether user warnings should be filtered by the + `warnings` module to appear only "once" + filter_repeated_logs: determines whether `RepeatedLogFilter`s are added to + the handlers of the root logger + """ + if logging_config_file is not None: + configure_logging_from_file(logging_config_file) + + if log_level is None: # Log level NOTSET is 0 so we use `is None` here + log_level_name = os.environ.get(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL) + # Change log level from str to int (note that log_level in function parameter + # int already, coming from CLI argparse parameter). + log_level = logging.getLevelName(log_level_name) + + logging.getLogger("rasa").setLevel(log_level) + # Assign log level to env variable in str format (not int). Why do we assign? + os.environ[ENV_LOG_LEVEL] = logging.getLevelName(log_level) + + configure_library_logging() + + if filter_repeated_logs: + for handler in logging.getLogger().handlers: + handler.addFilter(RepeatedLogFilter()) + + _filter_warnings(log_level=log_level, warn_only_once=warn_only_once) + + +def _filter_warnings(log_level: Optional[int], warn_only_once: bool = True) -> None: + """Sets up filters for warnings. + + Args: + log_level: the current log level. Certain warnings will only be filtered out + if we're not in debug mode. + warn_only_once: determines whether user warnings should be filtered by the + `warnings` module to appear only "once" + """ + if warn_only_once: + warnings.filterwarnings("once", category=UserWarning) + if log_level and log_level > logging.DEBUG: + for warning_type, warning_message in EXPECTED_WARNINGS: + warnings.filterwarnings( + "ignore", message=f".*{warning_message}", category=warning_type + ) + + +def configure_library_logging() -> None: + """Configures log levels of used libraries such as kafka, matplotlib, pika.""" + library_log_level = os.environ.get( + ENV_LOG_LEVEL_LIBRARIES, DEFAULT_LOG_LEVEL_LIBRARIES + ) + update_tensorflow_log_level() + update_asyncio_log_level() + update_apscheduler_log_level() + update_socketio_log_level() + update_matplotlib_log_level(library_log_level) + update_kafka_log_level(library_log_level) + update_rabbitmq_log_level(library_log_level) + + +def update_apscheduler_log_level() -> None: + """Configures the log level of `apscheduler.*` loggers.""" + log_level = os.environ.get(ENV_LOG_LEVEL_LIBRARIES, DEFAULT_LOG_LEVEL_LIBRARIES) + + apscheduler_loggers = [ + "apscheduler", + "apscheduler.scheduler", + "apscheduler.executors", + "apscheduler.executors.default", + ] + + for logger_name in apscheduler_loggers: + logging.getLogger(logger_name).setLevel(log_level) + logging.getLogger(logger_name).propagate = False + + +def update_socketio_log_level() -> None: + """Set the log level of socketio.""" + log_level = os.environ.get(ENV_LOG_LEVEL_LIBRARIES, DEFAULT_LOG_LEVEL_LIBRARIES) + + socketio_loggers = ["websockets.protocol", "engineio.server", "socketio.server"] + + for logger_name in socketio_loggers: + logging.getLogger(logger_name).setLevel(log_level) + logging.getLogger(logger_name).propagate = False + + +def update_tensorflow_log_level() -> None: + """Sets Tensorflow log level based on env variable 'LOG_LEVEL_LIBRARIES'.""" + # Disables libvinfer, tensorRT, cuda, AVX2 and FMA warnings (CPU support). + # This variable needs to be set before the + # first import since some warnings are raised on the first import. + os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" + + log_level = os.environ.get(ENV_LOG_LEVEL_LIBRARIES, DEFAULT_LOG_LEVEL_LIBRARIES) + + if not log_level: + log_level = "ERROR" + + logging.getLogger("tensorflow").setLevel(log_level) + logging.getLogger("tensorflow").propagate = False + + +def update_sanic_log_level( + log_file: Optional[Text] = None, + use_syslog: Optional[bool] = False, + syslog_address: Optional[Text] = None, + syslog_port: Optional[int] = None, + syslog_protocol: Optional[Text] = None, +) -> None: + """Set the log level to 'LOG_LEVEL_LIBRARIES' environment variable .""" + from sanic.log import logger, error_logger, access_logger + + log_level = os.environ.get(ENV_LOG_LEVEL_LIBRARIES, DEFAULT_LOG_LEVEL_LIBRARIES) + + logger.setLevel(log_level) + error_logger.setLevel(log_level) + access_logger.setLevel(log_level) + + logger.propagate = False + error_logger.propagate = False + access_logger.propagate = False + + if log_file is not None: + formatter = logging.Formatter("%(asctime)s [%(levelname)-5.5s] %(message)s") + file_handler = logging.FileHandler(log_file) + file_handler.setFormatter(formatter) + + logger.addHandler(file_handler) + error_logger.addHandler(file_handler) + access_logger.addHandler(file_handler) + if use_syslog: + formatter = logging.Formatter( + "%(asctime)s [%(levelname)-5.5s] [%(process)d]" " %(message)s" + ) + socktype = SOCK_STREAM if syslog_protocol == TCP_PROTOCOL else SOCK_DGRAM + syslog_handler = logging.handlers.SysLogHandler( + address=(syslog_address, syslog_port), socktype=socktype + ) + syslog_handler.setFormatter(formatter) + logger.addHandler(syslog_handler) + error_logger.addHandler(syslog_handler) + access_logger.addHandler(syslog_handler) + + +def update_asyncio_log_level() -> None: + """Set the log level of asyncio to the log level. + + Uses the log level specified in the environment variable 'LOG_LEVEL_LIBRARIES'. + """ + log_level = os.environ.get(ENV_LOG_LEVEL_LIBRARIES, DEFAULT_LOG_LEVEL_LIBRARIES) + logging.getLogger("asyncio").setLevel(log_level) + + +def update_matplotlib_log_level(library_log_level: Text) -> None: + """Set the log level of matplotlib. + + Uses the library specific log level or the general libraries log level. + """ + log_level = os.environ.get(ENV_LOG_LEVEL_MATPLOTLIB, library_log_level) + logging.getLogger("matplotlib").setLevel(log_level) + + +def update_kafka_log_level(library_log_level: Text) -> None: + """Set the log level of kafka. + + Uses the library specific log level or the general libraries log level. + """ + log_level = os.environ.get(ENV_LOG_LEVEL_KAFKA, library_log_level) + logging.getLogger("kafka").setLevel(log_level) + + +def update_rabbitmq_log_level(library_log_level: Text) -> None: + """Set the log level of pika. + + Uses the library specific log level or the general libraries log level. + """ + log_level = os.environ.get(ENV_LOG_LEVEL_RABBITMQ, library_log_level) + logging.getLogger("aio_pika").setLevel(log_level) + logging.getLogger("aiormq").setLevel(log_level) + + +def sort_list_of_dicts_by_first_key(dicts: List[Dict]) -> List[Dict]: + """Sorts a list of dictionaries by their first key.""" + return sorted(dicts, key=lambda d: list(d.keys())[0]) + + +def write_global_config_value(name: Text, value: Any) -> bool: + """Read global Rasa configuration. + + Args: + name: Name of the configuration key + value: Value the configuration key should be set to + + Returns: + `True` if the operation was successful. + """ + # need to use `rasa.constants.GLOBAL_USER_CONFIG_PATH` to allow patching + # in tests + config_path = rasa.constants.GLOBAL_USER_CONFIG_PATH + try: + os.makedirs(os.path.dirname(config_path), exist_ok=True) + + c = read_global_config(config_path) + c[name] = value + rasa.shared.utils.io.write_yaml(c, rasa.constants.GLOBAL_USER_CONFIG_PATH) + return True + except Exception as e: + logger.warning(f"Failed to write global config. Error: {e}. Skipping.") + return False + + +def read_global_config_value(name: Text, unavailable_ok: bool = True) -> Any: + """Read a value from the global Rasa configuration.""" + + def not_found() -> None: + if unavailable_ok: + return None + else: + raise ValueError(f"Configuration '{name}' key not found.") + + # need to use `rasa.constants.GLOBAL_USER_CONFIG_PATH` to allow patching + # in tests + config_path = rasa.constants.GLOBAL_USER_CONFIG_PATH + + if not os.path.exists(config_path): + return not_found() + + c = read_global_config(config_path) + + if name in c: + return c[name] + else: + return not_found() + + +def update_existing_keys( + original: Dict[Any, Any], updates: Dict[Any, Any] +) -> Dict[Any, Any]: + """Iterate through all the updates and update a value in the original dictionary. + + If the updates contain a key that is not present in the original dict, it will + be ignored. + """ + updated = original.copy() + for k, v in updates.items(): + if k in updated: + updated[k] = v + return updated + + +def override_defaults( + defaults: Optional[Dict[Text, Any]], custom: Optional[Dict[Text, Any]] +) -> Dict[Text, Any]: + """Override default config with the given config. + + We cannot use `dict.update` method because configs contain nested dicts. + + Args: + defaults: default config + custom: user config containing new parameters + + Returns: + updated config + """ + config = copy.deepcopy(defaults) if defaults else {} + + if not custom: + return config + + for key in custom.keys(): + if isinstance(config.get(key), dict): + config[key].update(custom[key]) + continue + config[key] = custom[key] + + return config + + +class RepeatedLogFilter(logging.Filter): + """Filter repeated log records.""" + + last_log = None + + def filter(self, record: logging.LogRecord) -> bool: + """Determines whether current log is different to last log.""" + current_log = ( + record.levelno, + record.pathname, + record.lineno, + record.msg, + record.args, + ) + if current_log != self.last_log: + self.last_log = current_log + return True + return False + + +async def call_potential_coroutine( + coroutine_or_return_value: Union[Any, Coroutine] +) -> Any: + """Awaits coroutine or returns value directly if it's not a coroutine. + + Args: + coroutine_or_return_value: Either the return value of a synchronous function + call or a coroutine which needs to be await first. + + Returns: + The return value of the function. + """ + if inspect.iscoroutine(coroutine_or_return_value): + return await coroutine_or_return_value + + return coroutine_or_return_value + + +def directory_size_in_mb( + path: Path, filenames_to_exclude: Optional[List[Text]] = None +) -> float: + """Calculates the size of a directory. + + Args: + path: The path to the directory. + filenames_to_exclude: Allows excluding certain files from the calculation. + + Returns: + Directory size in MiB. + """ + filenames_to_exclude = filenames_to_exclude or [] + size = 0.0 + for root, _dirs, files in os.walk(path): + for filename in files: + if filename in filenames_to_exclude: + continue + size += (Path(root) / filename).stat().st_size + + # bytes to MiB + return size / 1_048_576 + + +def copy_directory(source: Path, destination: Path) -> None: + """Copies the content of one directory into another. + + Args: + source: The directory whose contents should be copied to `destination`. + destination: The directory which should contain the content `source` in the end. + + Raises: + ValueError: If destination is not empty. + """ + if not destination.exists(): + destination.mkdir(parents=True) + + if list(destination.glob("*")): + raise ValueError( + f"Destination path '{destination}' is not empty. Directories " + f"can only be copied to empty directories." + ) + + shutil.copytree(source, destination, dirs_exist_ok=True) + + +def find_unavailable_packages(package_names: List[Text]) -> Set[Text]: + """Tries to import all package names and returns the packages where it failed. + + Args: + package_names: The package names to import. + + Returns: + Package names that could not be imported. + """ + import importlib + + failed_imports = set() + for package in package_names: + try: + importlib.import_module(package) + except ImportError: + failed_imports.add(package) + + return failed_imports + + +def module_path_from_class(clazz: Type) -> Text: + """Return the module path of an instance's class.""" + return clazz.__module__ + "." + clazz.__name__ + + +def get_bool_env_variable(variable_name: str, default_variable_value: bool) -> bool: + """Fetch bool value stored in environment variable. + + If environment variable is set but value is + not of boolean nature, an exception will be raised. + + Args: variable_name: + Name of the environment variable. + default_variable_value: Value to be returned if environment variable is not set. + + Returns: + A boolean value stored in the environment variable + or default value if environment variable is not set. + """ + true_values = (str(True).lower(), str(1).lower()) + false_values = (str(False).lower(), str(0).lower()) + value = os.getenv(variable_name, default=str(default_variable_value)) + + if value.lower() not in true_values + false_values: + raise RasaException( + f"Invalid value `{value}` for variable `{variable_name}`. " + f"Available values are `{true_values + false_values}`" + ) + return value.lower() in true_values diff --git a/rasa/utils/converter.py b/rasa/utils/converter.py new file mode 100644 index 0000000..023682f --- /dev/null +++ b/rasa/utils/converter.py @@ -0,0 +1,52 @@ +from pathlib import Path +from typing import Text + + +class TrainingDataConverter: + """Interface for any training data format conversion.""" + + @classmethod + def filter(cls, source_path: Path) -> bool: + """Checks if the concrete implementation of TrainingDataConverter can convert + training data file. + + Args: + source_path: Path to the training data file. + + Returns: + `True` if the given file can be converted, `False` otherwise + """ + raise NotImplementedError + + @classmethod + async def convert_and_write(cls, source_path: Path, output_path: Path) -> None: + """Converts the given training data file and saves it to the output directory. + + Args: + source_path: Path to the training data file. + output_path: Path to the output directory. + """ + raise NotImplementedError + + @classmethod + def generate_path_for_converted_training_data_file( + cls, source_file_path: Path, output_directory: Path + ) -> Path: + """Generates path for a training data file converted to YAML format. + + Args: + source_file_path: Path to the original file. + output_directory: Path to the target directory. + + Returns: + Path to the target converted training data file. + """ + return ( + output_directory / f"{source_file_path.stem}{cls.converted_file_suffix()}" + ) + + @classmethod + def converted_file_suffix(cls) -> Text: + """Returns suffix that should be appended to the converted + training data file.""" + return "_converted.yml" diff --git a/rasa/utils/endpoints.py b/rasa/utils/endpoints.py new file mode 100644 index 0000000..31d1ea7 --- /dev/null +++ b/rasa/utils/endpoints.py @@ -0,0 +1,305 @@ +import ssl + +import aiohttp +import os +from aiohttp.client_exceptions import ContentTypeError +from sanic.request import Request +from typing import Any, Optional, Text, Dict + +from rasa.shared.exceptions import FileNotFoundException +import rasa.shared.utils.io +import rasa.utils.io +import structlog +from rasa.core.constants import DEFAULT_REQUEST_TIMEOUT + + +structlogger = structlog.get_logger() + + +def read_endpoint_config( + filename: Text, endpoint_type: Text +) -> Optional["EndpointConfig"]: + """Read an endpoint configuration file from disk and extract one config.""" # noqa: E501 + if not filename: + return None + + try: + content = rasa.shared.utils.io.read_config_file(filename) + + if content.get(endpoint_type) is None: + return None + + return EndpointConfig.from_dict(content[endpoint_type]) + except FileNotFoundError: + structlogger.error( + "endpoint.read.failed_no_such_file", + filename=os.path.abspath(filename), + event_info=( + "Failed to read endpoint configuration file - " + "the file was not found." + ), + ) + return None + + +def concat_url(base: Text, subpath: Optional[Text]) -> Text: + """Append a subpath to a base url. + + Strips leading slashes from the subpath if necessary. This behaves + differently than `urlparse.urljoin` and will not treat the subpath + as a base url if it starts with `/` but will always append it to the + `base`. + + Args: + base: Base URL. + subpath: Optional path to append to the base URL. + + Returns: + Concatenated URL with base and subpath. + """ + if not subpath: + if base.endswith("/"): + structlogger.debug( + "endpoint.concat_url.trailing_slash", + url=base, + event_info=( + "The URL has a trailing slash. Please make sure the " + "target server supports trailing slashes for this endpoint." + ), + ) + return base + + url = base + if not base.endswith("/"): + url += "/" + if subpath.startswith("/"): + subpath = subpath[1:] + return url + subpath + + +class EndpointConfig: + """Configuration for an external HTTP endpoint.""" + + def __init__( + self, + url: Optional[Text] = None, + params: Optional[Dict[Text, Any]] = None, + headers: Optional[Dict[Text, Any]] = None, + basic_auth: Optional[Dict[Text, Text]] = None, + token: Optional[Text] = None, + token_name: Text = "token", + cafile: Optional[Text] = None, + **kwargs: Any, + ) -> None: + """Creates an `EndpointConfig` instance.""" + self.url = url + self.params = params or {} + self.headers = headers or {} + self.basic_auth = basic_auth or {} + self.token = token + self.token_name = token_name + self.type = kwargs.pop("store_type", kwargs.pop("type", None)) + self.cafile = cafile + self.kwargs = kwargs + + def session(self) -> aiohttp.ClientSession: + """Creates and returns a configured aiohttp client session.""" + # create authentication parameters + if self.basic_auth: + auth = aiohttp.BasicAuth( + self.basic_auth["username"], self.basic_auth["password"] + ) + else: + auth = None + + return aiohttp.ClientSession( + headers=self.headers, + auth=auth, + timeout=aiohttp.ClientTimeout(total=DEFAULT_REQUEST_TIMEOUT), + ) + + def combine_parameters( + self, kwargs: Optional[Dict[Text, Any]] = None + ) -> Dict[Text, Any]: + # construct GET parameters + params = self.params.copy() + + # set the authentication token if present + if self.token: + params[self.token_name] = self.token + + if kwargs and "params" in kwargs: + params.update(kwargs["params"]) + del kwargs["params"] + return params + + async def request( + self, + method: Text = "post", + subpath: Optional[Text] = None, + content_type: Optional[Text] = "application/json", + compress: bool = False, + **kwargs: Any, + ) -> Optional[Any]: + """Send a HTTP request to the endpoint. Return json response, if available. + + All additional arguments will get passed through + to aiohttp's `session.request`. + """ + # create the appropriate headers + headers = {} + if content_type: + headers["Content-Type"] = content_type + + if "headers" in kwargs: + headers.update(kwargs["headers"]) + del kwargs["headers"] + + if self.headers: + headers.update(self.headers) + + url = concat_url(self.url, subpath) + + sslcontext = None + if self.cafile: + try: + sslcontext = ssl.create_default_context(cafile=self.cafile) + except FileNotFoundError as e: + raise FileNotFoundException( + f"Failed to find certificate file, " + f"'{os.path.abspath(self.cafile)}' does not exist." + ) from e + + async with self.session() as session: + async with session.request( + method, + url, + headers=headers, + params=self.combine_parameters(kwargs), + compress=compress, + ssl=sslcontext, + **kwargs, + ) as response: + if response.status >= 400: + raise ClientResponseError( + response.status, + response.reason, + await response.content.read(), + ) + try: + return await response.json() + except ContentTypeError: + return None + + @classmethod + def from_dict(cls, data: Dict[Text, Any]) -> "EndpointConfig": + return EndpointConfig(**data) + + def copy(self) -> "EndpointConfig": + return EndpointConfig( + self.url, + self.params, + self.headers, + self.basic_auth, + self.token, + self.token_name, + **self.kwargs, + ) + + def __eq__(self, other: Any) -> bool: + if isinstance(self, type(other)): + return ( + other.url == self.url + and other.params == self.params + and other.headers == self.headers + and other.basic_auth == self.basic_auth + and other.token == self.token + and other.token_name == self.token_name + ) + else: + return False + + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) + + +class ClientResponseError(aiohttp.ClientError): + def __init__(self, status: int, message: Text, text: Text) -> None: + self.status = status + self.message = message + self.text = text + super().__init__(f"{status}, {message}, body='{text}'") + + +def bool_arg(request: Request, name: Text, default: bool = True) -> bool: + """Returns a passed boolean argument of the request or a default. + + Checks the `name` parameter of the request if it contains a valid + boolean value. If not, `default` is returned. + + Args: + request: Sanic request. + name: Name of argument. + default: Default value for `name` argument. + + Returns: + A bool value if `name` is a valid boolean, `default` otherwise. + """ + return str(request.args.get(name, default)).lower() == "true" + + +def float_arg( + request: Request, key: Text, default: Optional[float] = None +) -> Optional[float]: + """Returns a passed argument cast as a float or None. + + Checks the `key` parameter of the request if it contains a valid + float value. If not, `default` is returned. + + Args: + request: Sanic request. + key: Name of argument. + default: Default value for `key` argument. + + Returns: + A float value if `key` is a valid float, `default` otherwise. + """ + arg = request.args.get(key, default) + + if arg is default: + return arg + + try: + return float(str(arg)) + except (ValueError, TypeError): + structlogger.warning("endpoint.float_arg.convert_failed", arg=arg, key=key) + return default + + +def int_arg( + request: Request, key: Text, default: Optional[int] = None +) -> Optional[int]: + """Returns a passed argument cast as an int or None. + + Checks the `key` parameter of the request if it contains a valid + int value. If not, `default` is returned. + + Args: + request: Sanic request. + key: Name of argument. + default: Default value for `key` argument. + + Returns: + An int value if `key` is a valid integer, `default` otherwise. + """ + arg = request.args.get(key, default) + + if arg is default: + return arg + + try: + return int(str(arg)) + except (ValueError, TypeError): + + structlogger.warning("endpoint.int_arg.convert_failed", arg=arg, key=key) + return default diff --git a/rasa/utils/io.py b/rasa/utils/io.py new file mode 100644 index 0000000..da0800c --- /dev/null +++ b/rasa/utils/io.py @@ -0,0 +1,203 @@ +import asyncio +import filecmp +import logging +import os +import re +import tempfile +import warnings +from asyncio import AbstractEventLoop +from pathlib import Path +from typing import Text, Any, List, Type, Callable, TYPE_CHECKING, Pattern + +from typing_extensions import Protocol + +import rasa.shared.constants +import rasa.shared.utils.io + +if TYPE_CHECKING: + from prompt_toolkit.validation import Validator + + +class WriteRow(Protocol): + """Describes a csv writer supporting a `writerow` method (workaround for typing).""" + + def writerow(self, row: List[Text]) -> None: + """Write the given row. + + Args: + row: the entries of a row as a list of strings + """ + ... + + +def configure_colored_logging(loglevel: Text) -> None: + """Configures coloredlogs library for specified loglevel. + + Args: + loglevel: The loglevel to configure the library for + """ + import coloredlogs + + loglevel = loglevel or os.environ.get( + rasa.shared.constants.ENV_LOG_LEVEL, rasa.shared.constants.DEFAULT_LOG_LEVEL + ) + + field_styles = coloredlogs.DEFAULT_FIELD_STYLES.copy() + field_styles["asctime"] = {} + level_styles = coloredlogs.DEFAULT_LEVEL_STYLES.copy() + level_styles["debug"] = {} + coloredlogs.install( + level=loglevel, + use_chroot=False, + fmt="%(asctime)s %(levelname)-8s %(name)s - %(message)s", + level_styles=level_styles, + field_styles=field_styles, + ) + + +def enable_async_loop_debugging( + event_loop: AbstractEventLoop, slow_callback_duration: float = 0.1 +) -> AbstractEventLoop: + """Enables debugging on an event loop. + + Args: + event_loop: The event loop to enable debugging on + slow_callback_duration: The threshold at which a callback should be + alerted as slow. + """ + logging.info( + "Enabling coroutine debugging. Loop id {}.".format(id(asyncio.get_event_loop())) + ) + + # Enable debugging + event_loop.set_debug(True) + + # Make the threshold for "slow" tasks very very small for + # illustration. The default is 0.1 (= 100 milliseconds). + event_loop.slow_callback_duration = slow_callback_duration + + # Report all mistakes managing asynchronous resources. + warnings.simplefilter("always", ResourceWarning) + return event_loop + + +def create_temporary_file(data: Any, suffix: Text = "", mode: Text = "w+") -> Text: + """Creates a tempfile.NamedTemporaryFile object for data.""" + encoding = None if "b" in mode else rasa.shared.utils.io.DEFAULT_ENCODING + f = tempfile.NamedTemporaryFile( + mode=mode, suffix=suffix, delete=False, encoding=encoding + ) + f.write(data) + + f.close() + return f.name + + +def create_temporary_directory() -> Text: + """Creates a tempfile.TemporaryDirectory.""" + f = tempfile.TemporaryDirectory() + return f.name + + +def create_path(file_path: Text) -> None: + """Makes sure all directories in the 'file_path' exists.""" + parent_dir = os.path.dirname(os.path.abspath(file_path)) + if not os.path.exists(parent_dir): + os.makedirs(parent_dir) + + +def file_type_validator( + valid_file_types: List[Text], error_message: Text +) -> Type["Validator"]: + """Creates a `Validator` class which can be used with `questionary` to validate + file paths. + """ + + def is_valid(path: Text) -> bool: + return path is not None and any( + [path.endswith(file_type) for file_type in valid_file_types] + ) + + return create_validator(is_valid, error_message) + + +def not_empty_validator(error_message: Text) -> Type["Validator"]: + """Creates a `Validator` class which can be used with `questionary` to validate + that the user entered something other than whitespace. + """ + + def is_valid(input: Text) -> bool: + return input is not None and input.strip() != "" + + return create_validator(is_valid, error_message) + + +def create_validator( + function: Callable[[Text], bool], error_message: Text +) -> Type["Validator"]: + """Helper method to create `Validator` classes from callable functions. Should be + removed when questionary supports `Validator` objects. + """ + from prompt_toolkit.validation import Validator, ValidationError + from prompt_toolkit.document import Document + + class FunctionValidator(Validator): + @staticmethod + def validate(document: Document) -> None: + is_valid = function(document.text) + if not is_valid: + raise ValidationError(message=error_message) + + return FunctionValidator + + +def get_emoji_regex() -> Pattern: + """Returns regex to identify emojis.""" + return re.compile( + "[" + "\U0001F600-\U0001F64F" # emoticons + "\U0001F300-\U0001F5FF" # symbols & pictographs + "\U0001F680-\U0001F6FF" # transport & map symbols + "\U0001F1E0-\U0001F1FF" # flags (iOS) + "\U00002702-\U000027B0" + "\U000024C2-\U0001F251" + "\u200d" # zero width joiner + "\u200c" # zero width non-joiner + "]+", + flags=re.UNICODE, + ) + + +def are_directories_equal(dir1: Path, dir2: Path) -> bool: + """Compares two directories recursively. + + Files in each directory are + assumed to be equal if their names and contents are equal. + + Args: + dir1: The first directory. + dir2: The second directory. + + Returns: + `True` if they are equal, `False` otherwise. + """ + dirs_cmp = filecmp.dircmp(dir1, dir2) + if dirs_cmp.left_only or dirs_cmp.right_only: + return False + + (_, mismatches, errors) = filecmp.cmpfiles( + dir1, dir2, dirs_cmp.common_files, shallow=False + ) + + if mismatches or errors: + return False + + for common_dir in dirs_cmp.common_dirs: + new_dir1 = Path(dir1, common_dir) + new_dir2 = Path(dir2, common_dir) + + is_equal = are_directories_equal(new_dir1, new_dir2) + if not is_equal: + return False + + return True diff --git a/rasa/utils/log_utils.py b/rasa/utils/log_utils.py new file mode 100644 index 0000000..5852a1b --- /dev/null +++ b/rasa/utils/log_utils.py @@ -0,0 +1,139 @@ +from __future__ import annotations +import os +import logging +import sys +from typing import Any, Dict, Optional + +import structlog +from structlog_sentry import SentryProcessor +from structlog.dev import ConsoleRenderer +from structlog.typing import EventDict, WrappedLogger +from rasa.shared.constants import ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL +from rasa.plugin import plugin_manager + + +FORCE_JSON_LOGGING = os.environ.get("FORCE_JSON_LOGGING") + + +class HumanConsoleRenderer(ConsoleRenderer): + """Console renderer that outputs human-readable logs.""" + + def __call__(self, logger: WrappedLogger, name: str, event_dict: EventDict) -> str: + if "event_info" in event_dict: + event_key = event_dict["event"] + event_dict["event"] = event_dict["event_info"] + event_dict["event_key"] = event_key + del event_dict["event_info"] + + return super().__call__(logger, name, event_dict) + + +def _anonymizer( + _: structlog.BoundLogger, __: str, event_dict: Dict[str, Any] +) -> Dict[str, Any]: + """Anonymizes event dict.""" + anonymizable_keys = [ + "text", + "response_text", + "user_text", + "slot_values", + "parse_data_text", + "parse_data_entities", + "prediction_events", + "tracker_latest_message", + "prefilled_slots", + "message", + "response", + "slot_candidates", + "rasa_event", + "rasa_events", + "tracker_states", + "current_states", + "old_states", + "current_states", + "successes", + "current_entity", + "next_entity", + "states", + "entity", + "token_text", + "user_message", + "json_message", + ] + anonymization_pipeline = plugin_manager().hook.get_anonymization_pipeline() + + if anonymization_pipeline: + for key in anonymizable_keys: + if key in event_dict: + anonymized_value = anonymization_pipeline.log_run(event_dict[key]) + event_dict[key] = anonymized_value + return event_dict + + +def configure_structlog( + log_level: Optional[int] = None, +) -> None: + """Configure logging of the server.""" + if log_level is None: # Log level NOTSET is 0 so we use `is None` here + log_level_name = os.environ.get(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL) + # Change log level from str to int (note that log_level in function parameter + # int already, coming from CLI argparse parameter). + log_level = logging.getLevelName(log_level_name) + + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=log_level, + ) + + shared_processors = [ + _anonymizer, + # Processors that have nothing to do with output, + # e.g., add timestamps or log level names. + # If log level is too low, abort pipeline and throw away log entry. + structlog.stdlib.filter_by_level, + structlog.contextvars.merge_contextvars, + # Add the name of the logger to event dict. + # structlog.stdlib.add_logger_name, + # Add log level to event dict. + structlog.processors.add_log_level, + # If the "stack_info" key in the event dict is true, remove it and + # render the current stack trace in the "stack" key. + structlog.processors.StackInfoRenderer(), + # If some value is in bytes, decode it to a unicode str. + structlog.processors.UnicodeDecoder(), + structlog.dev.set_exc_info, + # add structlog sentry integration. only log fatal log entries + # as events as we are tracking exceptions anyways + SentryProcessor(event_level=logging.FATAL), + ] + + if not FORCE_JSON_LOGGING and sys.stderr.isatty(): + # Pretty printing when we run in a terminal session. + # Automatically prints pretty tracebacks when "rich" is installed + processors = shared_processors + [ + HumanConsoleRenderer(), + ] + else: + # Print JSON when we run, e.g., in a Docker container. + # Also print structured tracebacks. + processors = shared_processors + [ + structlog.processors.dict_tracebacks, + structlog.processors.JSONRenderer(), + ] + + structlog.configure( + processors=processors, # type: ignore + context_class=dict, + # `logger_factory` is used to create wrapped loggers that are used for + # OUTPUT. This one returns a `logging.Logger`. The final value (a JSON + # string) from the final processor (`JSONRenderer`) will be passed to + # the method of the same name as that you've called on the bound logger. + logger_factory=structlog.stdlib.LoggerFactory(), + # `wrapper_class` is the bound logger that you get back from + # get_logger(). This one imitates the API of `logging.Logger`. + wrapper_class=structlog.make_filtering_bound_logger(log_level), + # Effectively freeze configuration after creating the first bound + # logger. + cache_logger_on_first_use=True, + ) diff --git a/rasa/utils/plotting.py b/rasa/utils/plotting.py new file mode 100644 index 0000000..bc4fca8 --- /dev/null +++ b/rasa/utils/plotting.py @@ -0,0 +1,362 @@ +import logging +import itertools +import os +from functools import wraps + +import numpy as np +from typing import Any, Callable, List, Optional, Text, TypeVar, Union, Tuple +import matplotlib +from matplotlib.ticker import FormatStrFormatter + +import rasa.shared.utils.io +from rasa.constants import RESULTS_FILE + +logger = logging.getLogger(__name__) + + +def _fix_matplotlib_backend() -> None: + """Tries to fix a broken matplotlib backend.""" + try: + backend = matplotlib.get_backend() + except Exception: # skipcq:PYL-W0703 + logger.error( + "Cannot retrieve Matplotlib backend, likely due to a compatibility " + "issue with system dependencies. Please refer to the documentation: " + "https://matplotlib.org/stable/tutorials/introductory/usage.html#backends" + ) + raise + + # At first, matplotlib will be initialized with default OS-specific + # available backend + if backend == "TkAgg": + try: + # on OSX sometimes the tkinter package is broken and can't be imported. + # we'll try to import it and if it fails we will use a different backend + import tkinter + except (ImportError, ModuleNotFoundError): + logger.debug("Setting matplotlib backend to 'agg'") + matplotlib.use("agg") + + # if no backend is set by default, we'll try to set it up manually + elif backend is None: # pragma: no cover + try: + # If the `tkinter` package is available, we can use the `TkAgg` backend + import tkinter + + logger.debug("Setting matplotlib backend to 'TkAgg'") + matplotlib.use("TkAgg") + except (ImportError, ModuleNotFoundError): + logger.debug("Setting matplotlib backend to 'agg'") + matplotlib.use("agg") + + +ReturnType = TypeVar("ReturnType") +FuncType = Callable[..., ReturnType] +_MATPLOTLIB_BACKEND_FIXED = False + + +def _needs_matplotlib_backend(func: FuncType) -> FuncType: + """Decorator to fix matplotlib backend before calling a function.""" + + @wraps(func) + def inner(*args: Any, **kwargs: Any) -> ReturnType: # type: ignore + """Replacement function that fixes matplotlib backend.""" + global _MATPLOTLIB_BACKEND_FIXED + if not _MATPLOTLIB_BACKEND_FIXED: + _fix_matplotlib_backend() + _MATPLOTLIB_BACKEND_FIXED = True + return func(*args, **kwargs) + + return inner + + +@_needs_matplotlib_backend +def plot_confusion_matrix( + confusion_matrix: np.ndarray, + classes: Union[np.ndarray, List[Text]], + normalize: bool = False, + title: Text = "Confusion matrix", + color_map: Any = None, + zmin: int = 1, + output_file: Optional[Text] = None, +) -> None: + """Print and plot the provided confusion matrix. + Normalization can be applied by setting `normalize=True`. + + Args: + confusion_matrix: confusion matrix to plot + classes: class labels + normalize: If set to true, normalization will be applied. + title: title of the plot + color_map: color mapping + zmin: + output_file: output file to save plot to + + """ + import matplotlib.pyplot as plt + from matplotlib.colors import LogNorm + + zmax = confusion_matrix.max() if len(confusion_matrix) > 0 else 1 + plt.clf() + if not color_map: + color_map = plt.cm.Blues + plt.imshow( + confusion_matrix, + interpolation="nearest", + cmap=color_map, + aspect="auto", + norm=LogNorm(vmin=zmin, vmax=zmax), + ) + plt.title(title) + plt.colorbar() + tick_marks = np.arange(len(classes)) + plt.xticks(tick_marks, classes, rotation=90) + plt.yticks(tick_marks, classes) + + if normalize: + confusion_matrix = ( + confusion_matrix.astype("float") + / confusion_matrix.sum(axis=1)[:, np.newaxis] + ) + logger.info(f"Normalized confusion matrix: \n{confusion_matrix}") + else: + logger.info(f"Confusion matrix, without normalization: \n{confusion_matrix}") + + thresh = zmax / 2.0 + for i, j in itertools.product( + range(confusion_matrix.shape[0]), range(confusion_matrix.shape[1]) + ): + plt.text( + j, + i, + confusion_matrix[i, j], + horizontalalignment="center", + color="white" if confusion_matrix[i, j] > thresh else "black", + ) + + plt.ylabel("True label") + plt.xlabel("Predicted label") + + # save confusion matrix to file before showing it + if output_file: + fig = plt.gcf() + fig.set_size_inches(20, 20) + fig.savefig(output_file, bbox_inches="tight") + + +def _extract_paired_histogram_specification( + histogram_data: List[List[float]], + num_bins: int, + density: bool, + x_pad_fraction: float, + y_pad_fraction: float, +) -> Tuple[List[float], List[List[float]], List[float], Tuple[float, float]]: + """Extracts all information from the data needed to plot a paired histogram. + + Args: + histogram_data: Two data vectors + num_bins: Number of bins to be used for the histogram + density: If true, generate information for a probability density histogram + x_pad_fraction: Percentage of extra space in the horizontal direction + y_pad_fraction: Percentage of extra space in the vertical direction + + Returns: + The bins, values, ranges of either x-axis, and the range of the y-axis + + Raises: + ValueError: If histogram_data does not contain values. + """ + if not histogram_data or not np.concatenate(histogram_data).size: + rasa.shared.utils.io.raise_warning("No data to plot paired histogram.") + raise ValueError("No data to plot paired histogram.") + min_data_value: float = np.min(np.concatenate(histogram_data)) + max_data_value: float = np.max(np.concatenate(histogram_data)) + bin_width = (max_data_value - min_data_value) / num_bins + bins = [ + min_data_value + i * bin_width + # `bins` describes the _boundaries_ of the bins, so we need + # 2 extra - one at the beginning and one at the end + for i in range(num_bins + 2) + ] + histograms = [ + # A list of counts - how often a value in `data` falls into a particular bin + list(np.histogram(data, bins=bins, density=density)[0]) + for data in histogram_data + ] + + y_padding = 0.5 * bin_width + y_pad_fraction * bin_width + + if density: + # Get the maximum count across both histograms, and scale it + # with `x_pad_fraction` + v = max([(1.0 + x_pad_fraction) * max(histogram) for histogram in histograms]) + # When we plot the PDF, let both x-axes run to the same value + # so it's easier to compare visually + x_ranges = [v, v] + else: + # For the left and right histograms, get the largest counts and scale them + # by `x_pad_fraction` to get the maximum x-values displayed + x_ranges = [(1.0 + x_pad_fraction) * max(histogram) for histogram in histograms] + + try: + bin_of_first_non_zero_tally = min( + [[bool(v) for v in histogram].index(True) for histogram in histograms] + ) + except ValueError: + bin_of_first_non_zero_tally = 0 + + y_range = ( + # Start plotting where the data starts (ignore empty bins at the low end) + bins[bin_of_first_non_zero_tally] - y_padding, + # The y_padding adds half a bin width, as we want the bars to be + # _centered_ on the bins. We take the next-to-last element of `bins`, + # because that is the beginning of the last bin. + bins[-2] + y_padding, + ) + + return bins, histograms, x_ranges, y_range + + +@_needs_matplotlib_backend +def plot_paired_histogram( + histogram_data: List[List[float]], + title: Text, + output_file: Optional[Text] = None, + num_bins: int = 25, + colors: Tuple[Text, Text] = ("#009292", "#920000"), # (dark cyan, dark red) + axes_label: Tuple[Text, Text] = ("Correct", "Wrong"), + frame_label: Tuple[Text, Text] = ("Number of Samples", "Confidence"), + density: bool = False, + x_pad_fraction: float = 0.05, + y_pad_fraction: float = 0.10, +) -> None: + """Plots a side-by-side comparative histogram of the confidence distribution. + + Args: + histogram_data: Two data vectors + title: Title to be displayed above the plot + output_file: File to save the plot to + num_bins: Number of bins to be used for the histogram + colors: Left and right bar colors as hex color strings + axes_label: Labels shown above the left and right histogram, + respectively + frame_label: Labels shown below and on the left of the + histogram, respectively + density: If true, generate a probability density histogram + x_pad_fraction: Percentage of extra space in the horizontal direction + y_pad_fraction: Percentage of extra space in the vertical direction + """ + if num_bins <= 2: + rasa.shared.utils.io.raise_warning( + f"Number {num_bins} of paired histogram bins must be at least 3." + ) + return + + try: + bins, tallies, x_ranges, y_range = _extract_paired_histogram_specification( + histogram_data, + num_bins, + density=density, + x_pad_fraction=x_pad_fraction, + y_pad_fraction=y_pad_fraction, + ) + except (ValueError, TypeError) as e: + rasa.shared.utils.io.raise_warning( + f"Unable to plot paired histogram '{title}': {e}" + ) + return + yticks = [float(f"{x:.2f}") for x in bins] + + import matplotlib.pyplot as plt + + plt.gcf().clear() + + fig, axes = plt.subplots(ncols=2, sharey=True) + for side in range(2): + axes[side].barh( + bins[:-1], + tallies[side], + height=np.diff(bins), + align="center", + color=colors[side], + linewidth=1, + edgecolor="white", + ) + axes[side].set(title=axes_label[side]) + axes[side].set(yticks=yticks, xlim=(0, x_ranges[side]), ylim=y_range) + + axes[0].yaxis.set_major_formatter(FormatStrFormatter("%.2f")) + axes[0].yaxis.set_minor_formatter(FormatStrFormatter("%.2f")) + + axes[0].invert_xaxis() + axes[0].yaxis.tick_right() + + # Add the title + fig.suptitle(title, fontsize="x-large", fontweight="bold") + + # Add hidden plot to correctly add x and y labels (frame_label) + fig.add_subplot(111, frameon=False) + + # Hide tick and tick label of the unused axis + plt.tick_params(labelcolor="none", top=False, bottom=False, left=False, right=False) + plt.xlabel(frame_label[0]) + plt.ylabel(frame_label[1]) + + if output_file: + fig = plt.gcf() + fig.set_size_inches(10, 10) + fig.tight_layout(w_pad=0) + fig.savefig(output_file, bbox_inches="tight") + + +@_needs_matplotlib_backend +def plot_curve( + output_directory: Text, + number_of_examples: List[int], + x_label_text: Text, + y_label_text: Text, + graph_path: Text, +) -> None: + """Plot the results from a model comparison. + + Args: + output_directory: Output directory to save resulting plots to + number_of_examples: Number of examples per run + x_label_text: text for the x axis + y_label_text: text for the y axis + graph_path: output path of the plot + """ + import matplotlib.pyplot as plt + + plt.gcf().clear() + + ax = plt.gca() + + # load results from file + data = rasa.shared.utils.io.read_json_file( + os.path.join(output_directory, RESULTS_FILE) + ) + x = number_of_examples + + # compute mean of all the runs for different configs + for label in data.keys(): + if len(data[label]) == 0: + continue + mean = np.mean(data[label], axis=0) + std = np.std(data[label], axis=0) + ax.plot(x, mean, label=label, marker=".") + ax.fill_between( + x, + [m - s for m, s in zip(mean, std)], + [m + s for m, s in zip(mean, std)], + color="#6b2def", + alpha=0.2, + ) + ax.legend(loc=4) + + ax.set_xlabel(x_label_text) + ax.set_ylabel(y_label_text) + + plt.savefig(graph_path, format="pdf") + + logger.info(f"Comparison graph saved to '{graph_path}'.") diff --git a/rasa/utils/tensorflow/__init__.py b/rasa/utils/tensorflow/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rasa/utils/tensorflow/callback.py b/rasa/utils/tensorflow/callback.py new file mode 100644 index 0000000..68ce6de --- /dev/null +++ b/rasa/utils/tensorflow/callback.py @@ -0,0 +1,112 @@ +from pathlib import Path +from typing import Dict, Text, Any, Optional + +import logging +import tensorflow as tf +from tqdm import tqdm + +import rasa.shared.utils.io + +logger = logging.getLogger(__name__) + + +class RasaTrainingLogger(tf.keras.callbacks.Callback): + """Callback for logging the status of training.""" + + def __init__(self, epochs: int, silent: bool) -> None: + """Initializes the callback. + + Args: + epochs: Total number of epochs. + silent: If 'True' the entire progressbar wrapper is disabled. + """ + super().__init__() + + disable = silent or rasa.shared.utils.io.is_logging_disabled() + self.progress_bar = tqdm(range(epochs), desc="Epochs", disable=disable) + + def on_epoch_end(self, epoch: int, logs: Optional[Dict[Text, Any]] = None) -> None: + """Updates the logging output on every epoch end. + + Args: + epoch: The current epoch. + logs: The training metrics. + """ + self.progress_bar.update(1) + self.progress_bar.set_postfix(logs) + + def on_train_end(self, logs: Optional[Dict[Text, Any]] = None) -> None: + """Closes the progress bar after training. + + Args: + logs: The training metrics. + """ + self.progress_bar.close() + + +class RasaModelCheckpoint(tf.keras.callbacks.Callback): + """Callback for saving intermediate model checkpoints.""" + + def __init__(self, checkpoint_dir: Path) -> None: + """Initializes the callback. + + Args: + checkpoint_dir: Directory to store checkpoints to. + """ + super().__init__() + + self.checkpoint_file = checkpoint_dir / "checkpoint.tf_model" + self.best_metrics_so_far: Dict[Text, Any] = {} + + def on_epoch_end(self, epoch: int, logs: Optional[Dict[Text, Any]] = None) -> None: + """Save the model on epoch end if the model has improved. + + Args: + epoch: The current epoch. + logs: The training metrics. + """ + if self._does_model_improve(logs): + logger.debug(f"Creating model checkpoint at epoch={epoch + 1} ...") + self.model.save_weights( + self.checkpoint_file, overwrite=True, save_format="tf" + ) + + def _does_model_improve(self, curr_results: Dict[Text, Any]) -> bool: + """Checks whether the current results are better than the best so far. + + Results are considered better if each metric is equal or better than the best so + far, and at least one is better. + + Args: + curr_results: The training metrics for this epoch. + """ + curr_metric_names = [ + k + for k in curr_results.keys() + if k.startswith("val") and (k.endswith("_acc") or k.endswith("_f1")) + ] + # the "val" prefix is prepended to metrics in fit if _should_eval returns true + # for this particular epoch + if len(curr_metric_names) == 0: + # the metrics are not validation metrics + return False + # initialize best_metrics_so_far with the first results + if not self.best_metrics_so_far: + for metric_name in curr_metric_names: + self.best_metrics_so_far[metric_name] = float(curr_results[metric_name]) + return True + + at_least_one_improved = False + improved_metrics = {} + for metric_name in self.best_metrics_so_far.keys(): + if float(curr_results[metric_name]) < self.best_metrics_so_far[metric_name]: + # at least one of the values is worse + return False + if float(curr_results[metric_name]) > self.best_metrics_so_far[metric_name]: + at_least_one_improved = True + improved_metrics[metric_name] = float(curr_results[metric_name]) + + # all current values >= previous best and at least one is better + if at_least_one_improved: + self.best_metrics_so_far.update(improved_metrics) + return at_least_one_improved diff --git a/rasa/utils/tensorflow/constants.py b/rasa/utils/tensorflow/constants.py new file mode 100644 index 0000000..39d5ea6 --- /dev/null +++ b/rasa/utils/tensorflow/constants.py @@ -0,0 +1,116 @@ +# constants for configuration parameters of our tensorflow models + +LABEL = "label" +IDS = "ids" +# LABEL_PAD_ID is used to pad multi-label training examples. +# It should be < 0 to avoid index out of bounds errors by tf.one_hot. +LABEL_PAD_ID = -1 +HIDDEN_LAYERS_SIZES = "hidden_layers_sizes" +SHARE_HIDDEN_LAYERS = "share_hidden_layers" + +TRANSFORMER_SIZE = "transformer_size" +NUM_TRANSFORMER_LAYERS = "number_of_transformer_layers" +NUM_HEADS = "number_of_attention_heads" +UNIDIRECTIONAL_ENCODER = "unidirectional_encoder" +KEY_RELATIVE_ATTENTION = "use_key_relative_attention" +VALUE_RELATIVE_ATTENTION = "use_value_relative_attention" +MAX_RELATIVE_POSITION = "max_relative_position" + +BATCH_SIZES = "batch_size" +BATCH_STRATEGY = "batch_strategy" +EPOCHS = "epochs" +RANDOM_SEED = "random_seed" +LEARNING_RATE = "learning_rate" + +DENSE_DIMENSION = "dense_dimension" +CONCAT_DIMENSION = "concat_dimension" +EMBEDDING_DIMENSION = "embedding_dimension" +ENCODING_DIMENSION = "encoding_dimension" + +SIMILARITY_TYPE = "similarity_type" +LOSS_TYPE = "loss_type" +NUM_NEG = "number_of_negative_examples" +MAX_POS_SIM = "maximum_positive_similarity" +MAX_NEG_SIM = "maximum_negative_similarity" +USE_MAX_NEG_SIM = "use_maximum_negative_similarity" + +SCALE_LOSS = "scale_loss" +REGULARIZATION_CONSTANT = "regularization_constant" +NEGATIVE_MARGIN_SCALE = "negative_margin_scale" +DROP_RATE = "drop_rate" +DROP_RATE_ATTENTION = "drop_rate_attention" +DROP_RATE_DIALOGUE = "drop_rate_dialogue" +DROP_RATE_LABEL = "drop_rate_label" +CONSTRAIN_SIMILARITIES = "constrain_similarities" + +CONNECTION_DENSITY = "connection_density" + +EVAL_NUM_EPOCHS = "evaluate_every_number_of_epochs" +EVAL_NUM_EXAMPLES = "evaluate_on_number_of_examples" + +INTENT_CLASSIFICATION = "intent_classification" +ENTITY_RECOGNITION = "entity_recognition" +MASKED_LM = "use_masked_language_model" + +SPARSE_INPUT_DROPOUT = "use_sparse_input_dropout" +DENSE_INPUT_DROPOUT = "use_dense_input_dropout" + +RANKING_LENGTH = "ranking_length" +RENORMALIZE_CONFIDENCES = "renormalize_confidences" +MODEL_CONFIDENCE = "model_confidence" + +BILOU_FLAG = "BILOU_flag" + +RETRIEVAL_INTENT = "retrieval_intent" + +USE_TEXT_AS_LABEL = "use_text_as_label" + +SOFTMAX = "softmax" +MARGIN = "margin" +AUTO = "auto" +INNER = "inner" +COSINE = "cosine" +CROSS_ENTROPY = "cross_entropy" + +BALANCED = "balanced" + +SEQUENCE = "sequence" +SEQUENCE_LENGTH = f"{SEQUENCE}_lengths" +SENTENCE = "sentence" + +POOLING = "pooling" +MAX_POOLING = "max" +MEAN_POOLING = "mean" + +TENSORBOARD_LOG_DIR = "tensorboard_log_directory" +TENSORBOARD_LOG_LEVEL = "tensorboard_log_level" + +SEQUENCE_FEATURES = "sequence_features" +SENTENCE_FEATURES = "sentence_features" + +FEATURIZERS = "featurizers" +CHECKPOINT_MODEL = "checkpoint_model" + +MASK = "mask" + +IGNORE_INTENTS_LIST = "ignore_intents_list" + +TOLERANCE = "tolerance" + +POSITIVE_SCORES_KEY = "positive_scores" + +NEGATIVE_SCORES_KEY = "negative_scores" + +RANKING_KEY = "label_ranking" +QUERY_INTENT_KEY = "query_intent" +SCORE_KEY = "score" +THRESHOLD_KEY = "threshold" +SEVERITY_KEY = "severity" +NAME = "name" + +TF_DETERMINISTIC_OPS = "TF_DETERMINISTIC_OPS" +EPOCH_OVERRIDE = "epoch_override" + +USE_GPU = "use_gpu" +RUN_EAGERLY = "run_eagerly" +DROP_SMALL_LAST_BATCH = "drop_small_last_batch" diff --git a/rasa/utils/tensorflow/crf.py b/rasa/utils/tensorflow/crf.py new file mode 100644 index 0000000..1318eed --- /dev/null +++ b/rasa/utils/tensorflow/crf.py @@ -0,0 +1,487 @@ +import tensorflow as tf +from tensorflow import TensorShape +from tensorflow.types.experimental import TensorLike +from typing import Tuple, Any, List, Union, Optional + + +# original code taken from +# https://github.com/tensorflow/addons/blob/b8cab7fd61af4f697a1cdae4f51c37c346b9c6f0/tensorflow_addons/text/crf.py +# (modified to our neeeds) + + +class CrfDecodeForwardRnnCell(tf.keras.layers.AbstractRNNCell): + """Computes the forward decoding in a linear-chain CRF.""" + + def __init__(self, transition_params: TensorLike, **kwargs: Any) -> None: + """Initialize the CrfDecodeForwardRnnCell. + + Args: + transition_params: A [num_tags, num_tags] matrix of binary + potentials. This matrix is expanded into a + [1, num_tags, num_tags] in preparation for the broadcast + summation occurring within the cell. + """ + super().__init__(**kwargs) + self._transition_params = tf.expand_dims(transition_params, 0) + self._num_tags = transition_params.shape[0] + + @property + def state_size(self) -> int: + return self._num_tags + + @property + def output_size(self) -> int: + """Returns count of tags.""" + return self._num_tags + + def build(self, input_shape: Union[TensorShape, List[TensorShape]]) -> None: + """Creates the variables of the layer.""" + super().build(input_shape) + + def call( + self, inputs: TensorLike, state: TensorLike + ) -> Tuple[tf.Tensor, tf.Tensor]: + """Build the CrfDecodeForwardRnnCell. + + Args: + inputs: A [batch_size, num_tags] matrix of unary potentials. + state: A [batch_size, num_tags] matrix containing the previous step's + score values. + + Returns: + output: A [batch_size, num_tags * 2] matrix of backpointers and scores. + new_state: A [batch_size, num_tags] matrix of new score values. + """ + state = tf.expand_dims(state[0], 2) + transition_scores = state + self._transition_params + new_state = inputs + tf.reduce_max(transition_scores, [1]) + + backpointers = tf.argmax(transition_scores, 1) + backpointers = tf.cast(backpointers, tf.float32) + + # apply softmax to transition_scores to get scores in range from 0 to 1 + scores = tf.reduce_max(tf.nn.softmax(transition_scores, axis=1), [1]) + + # In the RNN implementation only the first value that is returned from a cell + # is kept throughout the RNN, so that you will have the values from each time + # step in the final output. As we need the backpointers as well as the scores + # for each time step, we concatenate them. + return tf.concat([backpointers, scores], axis=1), new_state + + +def crf_decode_forward( + inputs: TensorLike, + state: TensorLike, + transition_params: TensorLike, + sequence_lengths: TensorLike, +) -> Tuple[tf.Tensor, tf.Tensor]: + """Computes forward decoding in a linear-chain CRF. + + Args: + inputs: A [batch_size, num_tags] matrix of unary potentials. + state: A [batch_size, num_tags] matrix containing the previous step's + score values. + transition_params: A [num_tags, num_tags] matrix of binary potentials. + sequence_lengths: A [batch_size] vector of true sequence lengths. + + Returns: + output: A [batch_size, num_tags * 2] matrix of backpointers and scores. + new_state: A [batch_size, num_tags] matrix of new score values. + """ + sequence_lengths = tf.cast(sequence_lengths, dtype=tf.int32) + mask = tf.sequence_mask(sequence_lengths, tf.shape(inputs)[1]) + crf_fwd_cell = CrfDecodeForwardRnnCell(transition_params) + crf_fwd_layer = tf.keras.layers.RNN( + crf_fwd_cell, return_sequences=True, return_state=True + ) + return crf_fwd_layer(inputs, state, mask=mask) + + +def crf_decode_backward( + backpointers: TensorLike, scores: TensorLike, state: TensorLike +) -> Tuple[tf.Tensor, tf.Tensor]: + """Computes backward decoding in a linear-chain CRF. + + Args: + backpointers: A [batch_size, num_tags] matrix of backpointer of next step + (in time order). + scores: A [batch_size, num_tags] matrix of scores of next step (in time order). + state: A [batch_size, 1] matrix of tag index of next step. + + Returns: + new_tags: A [batch_size, num_tags] tensor containing the new tag indices. + new_scores: A [batch_size, num_tags] tensor containing the new score values. + """ + backpointers = tf.transpose(backpointers, [1, 0, 2]) + scores = tf.transpose(scores, [1, 0, 2]) + + def _scan_fn(_state: TensorLike, _inputs: TensorLike) -> tf.Tensor: + _state = tf.cast(tf.squeeze(_state, axis=[1]), dtype=tf.int32) + idxs = tf.stack([tf.range(tf.shape(_inputs)[0]), _state], axis=1) + return tf.expand_dims(tf.gather_nd(_inputs, idxs), axis=-1) + + output_tags = tf.scan(_scan_fn, backpointers, state) + # the dtype of the input parameters of tf.scan need to match + # convert state to float32 to match the type of scores + state = tf.cast(state, dtype=tf.float32) + output_scores = tf.scan(_scan_fn, scores, state) + + return tf.transpose(output_tags, [1, 0, 2]), tf.transpose(output_scores, [1, 0, 2]) + + +def crf_decode( + potentials: TensorLike, transition_params: TensorLike, sequence_length: TensorLike +) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor]: + """Decode the highest scoring sequence of tags. + + Args: + potentials: A [batch_size, max_seq_len, num_tags] tensor of + unary potentials. + transition_params: A [num_tags, num_tags] matrix of + binary potentials. + sequence_length: A [batch_size] vector of true sequence lengths. + + Returns: + decode_tags: A [batch_size, max_seq_len] matrix, with dtype `tf.int32`. + Contains the highest scoring tag indices. + decode_scores: A [batch_size, max_seq_len] matrix, containing the score of + `decode_tags`. + best_score: A [batch_size] vector, containing the best score of `decode_tags`. + """ + sequence_length = tf.cast(sequence_length, dtype=tf.int32) + + # If max_seq_len is 1, we skip the algorithm and simply return the + # argmax tag and the max activation. + def _single_seq_fn() -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor]: + decode_tags = tf.cast(tf.argmax(potentials, axis=2), dtype=tf.int32) + decode_scores = tf.reduce_max(tf.nn.softmax(potentials, axis=2), axis=2) + best_score = tf.reshape(tf.reduce_max(potentials, axis=2), shape=[-1]) + return decode_tags, decode_scores, best_score + + def _multi_seq_fn() -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor]: + # Computes forward decoding. Get last score and backpointers. + initial_state = tf.slice(potentials, [0, 0, 0], [-1, 1, -1]) + initial_state = tf.squeeze(initial_state, axis=[1]) + inputs = tf.slice(potentials, [0, 1, 0], [-1, -1, -1]) + + sequence_length_less_one = tf.maximum( + tf.constant(0, dtype=tf.int32), sequence_length - 1 + ) + + output, last_score = crf_decode_forward( + inputs, initial_state, transition_params, sequence_length_less_one + ) + + # output is a matrix of size [batch-size, max-seq-length, num-tags * 2] + # split the matrix on axis 2 to get the backpointers and scores, which are + # both of size [batch-size, max-seq-length, num-tags] + backpointers, scores = tf.split(output, 2, axis=2) + + backpointers = tf.cast(backpointers, dtype=tf.int32) + backpointers = tf.reverse_sequence( + backpointers, sequence_length_less_one, seq_axis=1 + ) + + scores = tf.reverse_sequence(scores, sequence_length_less_one, seq_axis=1) + + initial_state = tf.cast(tf.argmax(last_score, axis=1), dtype=tf.int32) + initial_state = tf.expand_dims(initial_state, axis=-1) + + initial_score = tf.reduce_max(tf.nn.softmax(last_score, axis=1), axis=[1]) + initial_score = tf.expand_dims(initial_score, axis=-1) + + decode_tags, decode_scores = crf_decode_backward( + backpointers, scores, initial_state + ) + + decode_tags = tf.squeeze(decode_tags, axis=[2]) + decode_tags = tf.concat([initial_state, decode_tags], axis=1) + decode_tags = tf.reverse_sequence(decode_tags, sequence_length, seq_axis=1) + + decode_scores = tf.squeeze(decode_scores, axis=[2]) + decode_scores = tf.concat([initial_score, decode_scores], axis=1) + decode_scores = tf.reverse_sequence(decode_scores, sequence_length, seq_axis=1) + + best_score = tf.reduce_max(last_score, axis=1) + + return decode_tags, decode_scores, best_score + + if potentials.shape[1] is not None: + # shape is statically know, so we just execute + # the appropriate code path + if potentials.shape[1] == 1: + return _single_seq_fn() + + return _multi_seq_fn() + + return tf.cond(tf.equal(tf.shape(potentials)[1], 1), _single_seq_fn, _multi_seq_fn) + + +def crf_unary_score( + tag_indices: TensorLike, sequence_lengths: TensorLike, inputs: TensorLike +) -> tf.Tensor: + """Computes the unary scores of tag sequences. + + Args: + tag_indices: A [batch_size, max_seq_len] matrix of tag indices. + sequence_lengths: A [batch_size] vector of true sequence lengths. + inputs: A [batch_size, max_seq_len, num_tags] tensor of unary potentials. + Returns: + unary_scores: A [batch_size] vector of unary scores. + """ + tag_indices = tf.cast(tag_indices, dtype=tf.int32) + sequence_lengths = tf.cast(sequence_lengths, dtype=tf.int32) + + batch_size = tf.shape(inputs)[0] + max_seq_len = tf.shape(inputs)[1] + num_tags = tf.shape(inputs)[2] + + flattened_inputs = tf.reshape(inputs, [-1]) + + offsets = tf.expand_dims(tf.range(batch_size) * max_seq_len * num_tags, 1) + offsets += tf.expand_dims(tf.range(max_seq_len) * num_tags, 0) + # Use int32 or int64 based on tag_indices' dtype. + if tag_indices.dtype == tf.int64: + offsets = tf.cast(offsets, tf.int64) + flattened_tag_indices = tf.reshape(offsets + tag_indices, [-1]) + + unary_scores = tf.reshape( + tf.gather(flattened_inputs, flattened_tag_indices), [batch_size, max_seq_len] + ) + + masks = tf.sequence_mask( + sequence_lengths, maxlen=tf.shape(tag_indices)[1], dtype=unary_scores.dtype + ) + + unary_scores = tf.reduce_sum(unary_scores * masks, 1) + return unary_scores + + +def crf_binary_score( + tag_indices: TensorLike, sequence_lengths: TensorLike, transition_params: TensorLike +) -> tf.Tensor: + """Computes the binary scores of tag sequences. + + Args: + tag_indices: A [batch_size, max_seq_len] matrix of tag indices. + sequence_lengths: A [batch_size] vector of true sequence lengths. + transition_params: A [num_tags, num_tags] matrix of binary potentials. + Returns: + binary_scores: A [batch_size] vector of binary scores. + """ + tag_indices = tf.cast(tag_indices, dtype=tf.int32) + sequence_lengths = tf.cast(sequence_lengths, dtype=tf.int32) + + num_tags = tf.shape(transition_params)[0] + num_transitions = tf.shape(tag_indices)[1] - 1 + + # Truncate by one on each side of the sequence to get the start and end + # indices of each transition. + start_tag_indices = tf.slice(tag_indices, [0, 0], [-1, num_transitions]) + end_tag_indices = tf.slice(tag_indices, [0, 1], [-1, num_transitions]) + + # Encode the indices in a flattened representation. + flattened_transition_indices = start_tag_indices * num_tags + end_tag_indices + flattened_transition_params = tf.reshape(transition_params, [-1]) + + # Get the binary scores based on the flattened representation. + binary_scores = tf.gather(flattened_transition_params, flattened_transition_indices) + + masks = tf.sequence_mask( + sequence_lengths, maxlen=tf.shape(tag_indices)[1], dtype=binary_scores.dtype + ) + truncated_masks = tf.slice(masks, [0, 1], [-1, -1]) + binary_scores = tf.reduce_sum(binary_scores * truncated_masks, 1) + return binary_scores + + +def crf_sequence_score( + inputs: TensorLike, + tag_indices: TensorLike, + sequence_lengths: TensorLike, + transition_params: TensorLike, +) -> tf.Tensor: + """Computes the unnormalized score for a tag sequence. + + Args: + inputs: A [batch_size, max_seq_len, num_tags] tensor of unary potentials + to use as input to the CRF layer. + tag_indices: A [batch_size, max_seq_len] matrix of tag indices for which + we compute the unnormalized score. + sequence_lengths: A [batch_size] vector of true sequence lengths. + transition_params: A [num_tags, num_tags] transition matrix. + Returns: + sequence_scores: A [batch_size] vector of unnormalized sequence scores. + """ + tag_indices = tf.cast(tag_indices, dtype=tf.int32) + sequence_lengths = tf.cast(sequence_lengths, dtype=tf.int32) + + # If max_seq_len is 1, we skip the score calculation and simply gather the + # unary potentials of the single tag. + def _single_seq_fn() -> TensorLike: + batch_size = tf.shape(inputs, out_type=tf.int32)[0] + batch_inds = tf.reshape(tf.range(batch_size), [-1, 1]) + indices = tf.concat([batch_inds, tf.zeros_like(batch_inds)], axis=1) + + tag_inds = tf.gather_nd(tag_indices, indices) + tag_inds = tf.reshape(tag_inds, [-1, 1]) + indices = tf.concat([indices, tag_inds], axis=1) + + sequence_scores = tf.gather_nd(inputs, indices) + + sequence_scores = tf.where( + tf.less_equal(sequence_lengths, 0), + tf.zeros_like(sequence_scores), + sequence_scores, + ) + return sequence_scores + + def _multi_seq_fn() -> TensorLike: + # Compute the scores of the given tag sequence. + unary_scores = crf_unary_score(tag_indices, sequence_lengths, inputs) + binary_scores = crf_binary_score( + tag_indices, sequence_lengths, transition_params + ) + sequence_scores = unary_scores + binary_scores + return sequence_scores + + return tf.cond(tf.equal(tf.shape(inputs)[1], 1), _single_seq_fn, _multi_seq_fn) + + +def crf_forward( + inputs: TensorLike, + state: TensorLike, + transition_params: TensorLike, + sequence_lengths: TensorLike, +) -> tf.Tensor: + """Computes the alpha values in a linear-chain CRF. + + See http://www.cs.columbia.edu/~mcollins/fb.pdf for reference. + + Args: + inputs: A [batch_size, num_tags] matrix of unary potentials. + state: A [batch_size, num_tags] matrix containing the previous alpha + values. + transition_params: A [num_tags, num_tags] matrix of binary potentials. + This matrix is expanded into a [1, num_tags, num_tags] in preparation + for the broadcast summation occurring within the cell. + sequence_lengths: A [batch_size] vector of true sequence lengths. + + Returns: + new_alphas: A [batch_size, num_tags] matrix containing the + new alpha values. + """ + sequence_lengths = tf.cast(sequence_lengths, dtype=tf.int32) + + last_index = tf.maximum( + tf.constant(0, dtype=sequence_lengths.dtype), sequence_lengths - 1 + ) + inputs = tf.transpose(inputs, [1, 0, 2]) + transition_params = tf.expand_dims(transition_params, 0) + + def _scan_fn(_state: TensorLike, _inputs: TensorLike) -> TensorLike: + _state = tf.expand_dims(_state, 2) + transition_scores = _state + transition_params + new_alphas = _inputs + tf.reduce_logsumexp(transition_scores, [1]) + return new_alphas + + all_alphas = tf.transpose(tf.scan(_scan_fn, inputs, state), [1, 0, 2]) + # add first state for sequences of length 1 + all_alphas = tf.concat([tf.expand_dims(state, 1), all_alphas], 1) + + idxs = tf.stack([tf.range(tf.shape(last_index)[0]), last_index], axis=1) + return tf.gather_nd(all_alphas, idxs) + + +def crf_log_norm( + inputs: TensorLike, sequence_lengths: TensorLike, transition_params: TensorLike +) -> tf.Tensor: + """Computes the normalization for a CRF. + + Args: + inputs: A [batch_size, max_seq_len, num_tags] tensor of unary potentials + to use as input to the CRF layer. + sequence_lengths: A [batch_size] vector of true sequence lengths. + transition_params: A [num_tags, num_tags] transition matrix. + Returns: + log_norm: A [batch_size] vector of normalizers for a CRF. + """ + sequence_lengths = tf.cast(sequence_lengths, dtype=tf.int32) + # Split up the first and rest of the inputs in preparation for the forward + # algorithm. + first_input = tf.slice(inputs, [0, 0, 0], [-1, 1, -1]) + first_input = tf.squeeze(first_input, [1]) + + # If max_seq_len is 1, we skip the algorithm and simply reduce_logsumexp + # over the "initial state" (the unary potentials). + def _single_seq_fn() -> TensorLike: + log_norm = tf.reduce_logsumexp(first_input, [1]) + # Mask `log_norm` of the sequences with length <= zero. + log_norm = tf.where( + tf.less_equal(sequence_lengths, 0), tf.zeros_like(log_norm), log_norm + ) + return log_norm + + def _multi_seq_fn() -> TensorLike: + """Forward computation of alpha values.""" + rest_of_input = tf.slice(inputs, [0, 1, 0], [-1, -1, -1]) + # Compute the alpha values in the forward algorithm in order to get the + # partition function. + + alphas = crf_forward( + rest_of_input, first_input, transition_params, sequence_lengths + ) + log_norm = tf.reduce_logsumexp(alphas, [1]) + # Mask `log_norm` of the sequences with length <= zero. + log_norm = tf.where( + tf.less_equal(sequence_lengths, 0), tf.zeros_like(log_norm), log_norm + ) + return log_norm + + return tf.cond(tf.equal(tf.shape(inputs)[1], 1), _single_seq_fn, _multi_seq_fn) + + +def crf_log_likelihood( + inputs: TensorLike, + tag_indices: TensorLike, + sequence_lengths: TensorLike, + transition_params: Optional[TensorLike] = None, +) -> Tuple[tf.Tensor, tf.Tensor]: + """Computes the log-likelihood of tag sequences in a CRF. + + Args: + inputs: A [batch_size, max_seq_len, num_tags] tensor of unary potentials + to use as input to the CRF layer. + tag_indices: A [batch_size, max_seq_len] matrix of tag indices for which + we compute the log-likelihood. + sequence_lengths: A [batch_size] vector of true sequence lengths. + transition_params: A [num_tags, num_tags] transition matrix, + if available. + Returns: + log_likelihood: A [batch_size] `Tensor` containing the log-likelihood of + each example, given the sequence of tag indices. + transition_params: A [num_tags, num_tags] transition matrix. This is + either provided by the caller or created in this function. + """ + inputs = tf.convert_to_tensor(inputs) + + num_tags = inputs.shape[2] + + # cast type to handle different types + tag_indices = tf.cast(tag_indices, dtype=tf.int32) + sequence_lengths = tf.cast(sequence_lengths, dtype=tf.int32) + + if transition_params is None: + initializer = tf.keras.initializers.GlorotUniform() + transition_params = tf.Variable( + initializer([num_tags, num_tags]), "transitions" + ) + transition_params = tf.cast(transition_params, inputs.dtype) + sequence_scores = crf_sequence_score( + inputs, tag_indices, sequence_lengths, transition_params + ) + log_norm = crf_log_norm(inputs, sequence_lengths, transition_params) + + # Normalize the scores to get the log-likelihood per example. + log_likelihood = sequence_scores - log_norm + return log_likelihood, transition_params diff --git a/rasa/utils/tensorflow/data_generator.py b/rasa/utils/tensorflow/data_generator.py new file mode 100644 index 0000000..e54b95d --- /dev/null +++ b/rasa/utils/tensorflow/data_generator.py @@ -0,0 +1,440 @@ +import math +from typing import List, Union, Text, Optional, Any, Tuple, Dict, cast + +import logging +import scipy.sparse +import numpy as np +from tensorflow.keras.utils import Sequence + +from rasa.utils.tensorflow.constants import SEQUENCE, BALANCED +from rasa.utils.tensorflow.model_data import RasaModelData, Data, FeatureArray + +logger = logging.getLogger(__name__) + + +class RasaDataGenerator(Sequence): + """Abstract data generator.""" + + def __init__( + self, + model_data: RasaModelData, + batch_size: Union[int, List[int]], + batch_strategy: Text = SEQUENCE, + shuffle: bool = True, + ): + """Initializes the data generator. + + Args: + model_data: The model data to use. + batch_size: The batch size(s). + batch_strategy: The batch strategy. + shuffle: If 'True', data should be shuffled. + """ + self.model_data = model_data + self.batch_size = batch_size + self.shuffle = shuffle + self.batch_strategy = batch_strategy + + def __len__(self) -> int: + """Number of batches in the Sequence. + + Returns: + The number of batches in the Sequence. + """ + raise NotImplementedError + + def __getitem__(self, index: int) -> Tuple[Any, Any]: + """Gets batch at position `index`. + + Arguments: + index: position of the batch in the Sequence. + + Returns: + A batch (tuple of input data and target data). + """ + raise NotImplementedError + + def on_epoch_end(self) -> None: + """Update the data after every epoch.""" + raise NotImplementedError + + def _shuffle_and_balance(self, batch_size: int) -> Data: + data = self.model_data.data + + if self.shuffle: + data = self.model_data.shuffled_data(data) + + if self.batch_strategy == BALANCED: + data = self.model_data.balanced_data(data, batch_size, self.shuffle) + + # do not override self.model_data.data, because we need original data for + # balancing on the next epoch + return data + + @staticmethod + def prepare_batch( + data: Data, + start: Optional[int] = None, + end: Optional[int] = None, + tuple_sizes: Optional[Dict[Text, int]] = None, + ) -> Tuple[Optional[np.ndarray], ...]: + """Slices model data into batch using given start and end value. + + Args: + data: The data to prepare. + start: The start index of the batch + end: The end index of the batch + tuple_sizes: In case the feature is not present we propagate the batch with + None. Tuple sizes contains the number of how many None values to add for + what kind of feature. + + Returns: + The features of the batch. + """ + batch_data = [] + + for key, attribute_data in data.items(): + for sub_key, f_data in attribute_data.items(): + # add None for not present values during processing + if not f_data: + if tuple_sizes: + batch_data += [None] * tuple_sizes[key] + else: + batch_data.append(None) + continue + + for v in f_data: + if start is not None and end is not None: + _data = v[start:end] + elif start is not None: + _data = v[start:] + elif end is not None: + _data = v[:end] + else: + _data = v[:] + + if cast(FeatureArray, _data).is_sparse: + batch_data.extend( + RasaDataGenerator._scipy_matrix_to_values(_data) + ) + else: + batch_data.append(RasaDataGenerator._pad_dense_data(_data)) + + # len of batch_data is equal to the number of keys in model data + return tuple(batch_data) + + @staticmethod + def _pad_dense_data(array_of_dense: FeatureArray) -> np.ndarray: + """Pad data of different lengths. + + Sequential data is padded with zeros. Zeros are added to the end of data. + + Args: + array_of_dense: The array to pad. + + Returns: + The padded array. + """ + if array_of_dense.number_of_dimensions == 4: + return RasaDataGenerator._pad_4d_dense_data(array_of_dense) + + if array_of_dense[0].ndim < 2: + # data doesn't contain a sequence + return array_of_dense.astype(np.float32) + + data_size = len(array_of_dense) + max_seq_len = max([x.shape[0] for x in array_of_dense]) + + data_padded = np.zeros( + [data_size, max_seq_len, array_of_dense[0].shape[-1]], + dtype=array_of_dense[0].dtype, + ) + for i in range(data_size): + data_padded[i, : array_of_dense[i].shape[0], :] = array_of_dense[i] + + return data_padded.astype(np.float32) + + @staticmethod + def _pad_4d_dense_data(feature_array: FeatureArray) -> np.ndarray: + # in case of dialogue data we may have 4 dimensions + # batch size x dialogue history length x sequence length x number of features + + # as transformers cannot handle 4D tensors pad and reshape the data + # so that the resulting tensor is 3D + # the shape is (sum of dialogue history length for all tensors in the + # batch x max sequence length x number of features) + # the original shape and the original dialogue length is passed on to the model + # it can be used to transform the 3D tensor back into 4D + + # in order to create 4d tensor inputs, we created "fake" zero features + # for nonexistent inputs. To save calculation we filter this features before + # input to tf methods. + number_of_features = feature_array[0][0].shape[-1] + array_of_array_of_dense = RasaDataGenerator._filter_out_fake_inputs( + feature_array + ) + if not array_of_array_of_dense: + # return empty 3d array with appropriate last dims + return np.zeros((0, 0, number_of_features), dtype=np.float32) + + combined_dialogue_len = sum( + len(array_of_dense) for array_of_dense in array_of_array_of_dense + ) + max_seq_len = max( + [ + x.shape[0] + for array_of_dense in array_of_array_of_dense + for x in array_of_dense + ] + ) + + data_padded = np.zeros( + [combined_dialogue_len, max_seq_len, number_of_features], + dtype=array_of_array_of_dense[0][0].dtype, + ) + + current_sum_dialogue_len = 0 + for i, array_of_dense in enumerate(array_of_array_of_dense): + for j, dense in enumerate(array_of_dense): + data_padded[current_sum_dialogue_len + j, : dense.shape[0], :] = dense + current_sum_dialogue_len += len(array_of_dense) + + return data_padded.astype(np.float32) + + @staticmethod + def _scipy_matrix_to_values(array_of_sparse: FeatureArray) -> List[np.ndarray]: + """Convert a scipy matrix into indices, data, and shape. + + Args: + array_of_sparse: The sparse data array. + + Returns: + A list of dense numpy arrays representing the sparse data. + """ + if array_of_sparse.number_of_dimensions == 4: + return RasaDataGenerator._4d_scipy_matrix_to_values(array_of_sparse) + + # we need to make sure that the matrices are coo_matrices otherwise the + # transformation does not work (e.g. you cannot access x.row, x.col) + if not isinstance(array_of_sparse[0], scipy.sparse.coo_matrix): + array_of_sparse = [x.tocoo() for x in array_of_sparse] # type: ignore[assignment] # noqa: E501 + + max_seq_len = max([x.shape[0] for x in array_of_sparse]) + + # get the indices of values + indices = np.hstack( + [ + np.vstack([i * np.ones_like(x.row), x.row, x.col]) + for i, x in enumerate(array_of_sparse) + ] + ).T + + data = np.hstack([x.data for x in array_of_sparse]) + + number_of_features = array_of_sparse[0].shape[-1] + shape = np.array((len(array_of_sparse), max_seq_len, number_of_features)) + + return [ + indices.astype(np.int64), + data.astype(np.float32), + shape.astype(np.int64), + ] + + @staticmethod + def _4d_scipy_matrix_to_values(feature_array: FeatureArray) -> List[np.ndarray]: + # in case of dialogue data we may have 4 dimensions + # batch size x dialogue history length x sequence length x number of features + + # transformers cannot handle 4D tensors, therefore pad and reshape the data + # so that the resulting tensor is 3D + # the shape is (sum of dialogue history length for all tensors in the + # batch x max sequence length x number of features) + # the original shape and the original dialogue length is passed on to the model + # it can be used to transform the 3D tensor back into 4D + + # in order to create 4d tensor inputs, we created "fake" zero features + # for nonexistent inputs. To save calculation we filter this features before + # input to tf methods. + number_of_features = feature_array[0][0].shape[-1] + array_of_array_of_sparse = RasaDataGenerator._filter_out_fake_inputs( + feature_array + ) + if not array_of_array_of_sparse: + # create empty array with appropriate last dims + return [ + np.empty((0, 3), dtype=np.int64), + np.array([], dtype=np.float32), + np.array([0, 0, number_of_features], dtype=np.int64), + ] + + # we need to make sure that the matrices are coo_matrices otherwise the + # transformation does not work (e.g. you cannot access x.row, x.col) + if not isinstance(array_of_array_of_sparse[0][0], scipy.sparse.coo_matrix): + array_of_array_of_sparse = [ + [ + x.tocoo() if isinstance(x, scipy.sparse.spmatrix) else x + for x in array_of_sparse + ] + for array_of_sparse in array_of_array_of_sparse + ] + + dialogue_len = [ + len(array_of_sparse) for array_of_sparse in array_of_array_of_sparse + ] + combined_dialogue_len = sum(dialogue_len) + max_seq_len = max( + [ + x.shape[0] + for array_of_sparse in array_of_array_of_sparse + for x in array_of_sparse + ] + ) + # get the indices of values + indices = np.hstack( + [ + np.vstack( + [sum(dialogue_len[:i]) + j * np.ones_like(x.row), x.row, x.col] + ) + for i, array_of_sparse in enumerate(array_of_array_of_sparse) + for j, x in enumerate(array_of_sparse) + ] + ).T + + data = np.hstack( + [ + x.data + for array_of_sparse in array_of_array_of_sparse + for x in array_of_sparse + ] + ) + + shape = np.array((combined_dialogue_len, max_seq_len, number_of_features)) + + return [ + indices.astype(np.int64), + data.astype(np.float32), + shape.astype(np.int64), + ] + + @staticmethod + def _filter_out_fake_inputs( + array_of_array_of_features: FeatureArray, + ) -> Union[List[List[np.ndarray]], List[List[scipy.sparse.spmatrix]]]: + return list( + filter( + # filter empty lists created by another filter + lambda x: len(x) > 0, + [ + # filter all the "fake" inputs, we know the input is "fake", + # when sequence dimension is `0` + list(filter(lambda x: x.shape[0] > 0, array_of_features)) + for array_of_features in array_of_array_of_features + ], + ) + ) + + +class RasaBatchDataGenerator(RasaDataGenerator): + """Data generator with an optional increasing batch size.""" + + def __init__( + self, + model_data: RasaModelData, + batch_size: Union[List[int], int], + epochs: int = 1, + batch_strategy: Text = SEQUENCE, + shuffle: bool = True, + drop_small_last_batch: bool = False, + ): + """Initializes the increasing batch size data generator. + + Args: + model_data: The model data to use. + batch_size: The batch size. + epochs: The total number of epochs. + batch_strategy: The batch strategy. + shuffle: If 'True', data will be shuffled. + drop_small_last_batch: if 'True', the last batch in an epoch will be dropped + if it has less examples than half the batch size + """ + super().__init__(model_data, batch_size, batch_strategy, shuffle) + + if isinstance(batch_size, list): + logger.debug( + "The provided batch size is a list, this data generator will use a " + "linear increasing batch size." + ) + + self._epochs = epochs + # we use `on_epoch_end` method to prepare data for the next epoch + # set current epoch to `-1`, so that `on_epoch_end` will increase it to `0` + self._current_epoch = -1 + # actual batch size will be set inside `on_epoch_end` + self._current_batch_size = 0 + # create separate data variable that will store modified data for each batch + self._data: Data = {} + self.drop_small_last_batch = drop_small_last_batch + self.on_epoch_end() + + def __len__(self) -> int: + """Number of batches in the Sequence. + + Returns: + The number of batches in the Sequence. + """ + # data was rebalanced, so need to recalculate number of examples + num_examples = self.model_data.number_of_examples(self._data) + batch_size = self._current_batch_size + if self.drop_small_last_batch: + # keep last batch only if it has at least half a batch size of examples + last_batch_half_full = num_examples % batch_size >= math.ceil( + batch_size / 2 + ) + num_batches = num_examples // batch_size + int(last_batch_half_full) + # Return at least 1 if there is an example + return max(num_batches, int(num_examples > 0)) + else: + return num_examples // batch_size + int(num_examples % batch_size > 0) + + def __getitem__(self, index: int) -> Tuple[Any, Any]: + """Gets batch at position `index`. + + Arguments: + index: position of the batch in the Sequence. + + Returns: + A batch (tuple of input data and target data). + """ + start = index * self._current_batch_size + end = start + self._current_batch_size + + # return input and target data, as our target data is inside the input + # data return None for the target data + return self.prepare_batch(self._data, start, end), None + + def on_epoch_end(self) -> None: + """Update the data after every epoch.""" + self._current_epoch += 1 + self._current_batch_size = self._linearly_increasing_batch_size() + self._data = self._shuffle_and_balance(self._current_batch_size) + + def _linearly_increasing_batch_size(self) -> int: + """Linearly increase batch size with every epoch. + + The idea comes from https://arxiv.org/abs/1711.00489. + + Returns: + The batch size to use in this epoch. + """ + if not isinstance(self.batch_size, list): + return int(self.batch_size) + + if self._epochs > 1: + return int( + self.batch_size[0] + + self._current_epoch + * (self.batch_size[1] - self.batch_size[0]) + / (self._epochs - 1) + ) + else: + return int(self.batch_size[0]) diff --git a/rasa/utils/tensorflow/environment.py b/rasa/utils/tensorflow/environment.py new file mode 100644 index 0000000..15984d9 --- /dev/null +++ b/rasa/utils/tensorflow/environment.py @@ -0,0 +1,163 @@ +import logging +import os +from typing import Text, Dict +import typing + +import rasa.shared.utils.io +from rasa.constants import ( + ENV_GPU_CONFIG, + ENV_CPU_INTER_OP_CONFIG, + ENV_CPU_INTRA_OP_CONFIG, +) +from rasa.utils.tensorflow.constants import TF_DETERMINISTIC_OPS +from rasa.shared.utils import io as shared_io_utils + +if typing.TYPE_CHECKING: + from tensorflow import config as tf_config + +logger = logging.getLogger(__name__) + + +def _setup_gpu_environment() -> None: + """Sets configuration for TensorFlow GPU environment based on env variable.""" + gpu_memory_config = os.getenv(ENV_GPU_CONFIG) + + if not gpu_memory_config: + return + + # Import from tensorflow only if necessary (environment variable was set) + from tensorflow import config as tf_config + + parsed_gpu_config = _parse_gpu_config(gpu_memory_config) + physical_gpus = tf_config.list_physical_devices("GPU") + + # Logic taken from https://www.tensorflow.org/guide/gpu + if physical_gpus: + for gpu_id, gpu_id_memory in parsed_gpu_config.items(): + _allocate_gpu_memory(physical_gpus[gpu_id], gpu_id_memory) + + else: + rasa.shared.utils.io.raise_warning( + f"You have an environment variable '{ENV_GPU_CONFIG}' set but no GPUs were " + f"detected to configure." + ) + + +def _allocate_gpu_memory( + gpu_instance: "tf_config.PhysicalDevice", logical_memory: int +) -> None: + """Create a new logical device for the requested amount of memory. + + Args: + gpu_instance: PhysicalDevice instance of a GPU device. + logical_memory: Absolute amount of memory to be allocated to the new logical + device. + """ + + from tensorflow import config as tf_config + + try: + tf_config.experimental.set_virtual_device_configuration( + gpu_instance, + [ + tf_config.experimental.VirtualDeviceConfiguration( + memory_limit=logical_memory + ) + ], + ) + + except RuntimeError: + # Helper explanation of where the error comes from + raise RuntimeError( + "Error while setting up tensorflow environment. " + "Virtual devices must be set before GPUs have been initialized." + ) + + +def _parse_gpu_config(gpu_memory_config: Text) -> Dict[int, int]: + """Parse GPU configuration variable from a string to a dict. + + Args: + gpu_memory_config: String containing the configuration for GPU usage. + + Returns: + Parsed configuration as a dictionary with GPU IDs as keys and requested memory + as the value. + """ + + # gpu_config is of format "gpu_id_1:gpu_id_1_memory, gpu_id_2: gpu_id_2_memory" + # Parse it and store in a dictionary + parsed_gpu_config: Dict[int, int] = {} + + try: + for instance in gpu_memory_config.split(","): + instance_gpu_id, instance_gpu_mem = instance.split(":") + parsed_instance_gpu_id = int(instance_gpu_id) + parsed_instance_gpu_mem = int(instance_gpu_mem) + + parsed_gpu_config[parsed_instance_gpu_id] = parsed_instance_gpu_mem + except ValueError: + # Helper explanation of where the error comes from + raise ValueError( + f"Error parsing GPU configuration. Please cross-check the format of " + f"'{ENV_GPU_CONFIG}' at https://rasa.com/docs/rasa/tuning-your-model" + f"#restricting-absolute-gpu-memory-available ." + ) + + return parsed_gpu_config + + +def _setup_cpu_environment() -> None: + """Set configuration for the CPU environment based on environment variable.""" + inter_op_parallel_threads = os.getenv(ENV_CPU_INTER_OP_CONFIG) + intra_op_parallel_threads = os.getenv(ENV_CPU_INTRA_OP_CONFIG) + + if not inter_op_parallel_threads and not intra_op_parallel_threads: + return + + from tensorflow import config as tf_config + + if inter_op_parallel_threads: + try: + inter_op_parallel_threads_number = int(inter_op_parallel_threads.strip()) + except ValueError: + raise ValueError( + f"Error parsing the environment variable '{ENV_CPU_INTER_OP_CONFIG}'. " + f"Please cross-check the value." + ) + + tf_config.threading.set_inter_op_parallelism_threads( + inter_op_parallel_threads_number + ) + + if intra_op_parallel_threads: + try: + intra_op_parallel_threads_number = int(intra_op_parallel_threads.strip()) + except ValueError: + raise ValueError( + f"Error parsing the environment variable '{ENV_CPU_INTRA_OP_CONFIG}'. " + f"Please cross-check the value." + ) + + tf_config.threading.set_intra_op_parallelism_threads( + intra_op_parallel_threads_number + ) + + +def setup_tf_environment() -> None: + """Setup CPU and GPU related environment settings for TensorFlow.""" + _setup_cpu_environment() + _setup_gpu_environment() + + +def check_deterministic_ops() -> None: + """Warn user if they have set TF_DETERMINISTIC_OPS.""" + if os.getenv(TF_DETERMINISTIC_OPS, False): + shared_io_utils.raise_warning( + f"You have set '{TF_DETERMINISTIC_OPS}' to 1. If you are " + f"using one or more GPU(s) and use any of 'SparseFeaturizer', " + f"'TEDPolicy', 'DIETClassifier', 'UnexpecTEDIntentPolicy', or " + f"'ResponseSelector' training and testing will fail as there are no " + f"deterministic GPU implementations of some underlying TF ops.", + category=UserWarning, + ) diff --git a/rasa/utils/tensorflow/exceptions.py b/rasa/utils/tensorflow/exceptions.py new file mode 100644 index 0000000..53e1cd4 --- /dev/null +++ b/rasa/utils/tensorflow/exceptions.py @@ -0,0 +1,5 @@ +from rasa.shared.exceptions import RasaException + + +class TFLayerConfigException(RasaException): + """Raised when wrong parameters are passed to tensorflow layers.""" diff --git a/rasa/utils/tensorflow/feature_array.py b/rasa/utils/tensorflow/feature_array.py new file mode 100644 index 0000000..9af50e3 --- /dev/null +++ b/rasa/utils/tensorflow/feature_array.py @@ -0,0 +1,370 @@ +from typing import Dict, Any, List, Tuple, Optional, Union + +import numpy as np +import scipy.sparse +from safetensors.numpy import load_file +from safetensors.numpy import save_file + +import rasa.shared.utils.io + + +def _recursive_serialize( + array: Any, prefix: str, data_dict: Dict[str, Any], metadata: List[Dict[str, Any]] +) -> None: + """Recursively serialize arrays and matrices for high dimensional data.""" + if isinstance(array, np.ndarray) and array.ndim <= 2: + data_key = f"{prefix}_array" + data_dict[data_key] = array + metadata.append({"type": "dense", "key": data_key, "shape": array.shape}) + + elif isinstance(array, list) and all([isinstance(v, float) for v in array]): + data_key = f"{prefix}_list" + data_dict[data_key] = np.array(array, dtype=np.float32) + metadata.append({"type": "list", "key": data_key}) + + elif isinstance(array, list) and all([isinstance(v, int) for v in array]): + data_key = f"{prefix}_list" + data_dict[data_key] = np.array(array, dtype=np.int64) + metadata.append({"type": "list", "key": data_key}) + + elif isinstance(array, scipy.sparse.spmatrix): + data_key_data = f"{prefix}_data" + data_key_row = f"{prefix}_row" + data_key_col = f"{prefix}_col" + array = array.tocoo() + data_dict.update( + { + data_key_data: array.data, + data_key_row: array.row, + data_key_col: array.col, + } + ) + metadata.append({"type": "sparse", "key": prefix, "shape": array.shape}) + + elif isinstance(array, list) or isinstance(array, np.ndarray): + group_metadata = {"type": "group", "subcomponents": []} + for idx, item in enumerate(array): + new_prefix = f"{prefix}_{idx}" + _recursive_serialize( + item, new_prefix, data_dict, group_metadata["subcomponents"] + ) + metadata.append(group_metadata) + + +def _serialize_nested_data( + nested_data: Dict[str, Dict[str, List["FeatureArray"]]], + prefix: str, + data_dict: Dict[str, np.ndarray], + metadata: List[Dict[str, Union[str, List]]], +) -> None: + """Handle serialization across dictionary and list levels.""" + for outer_key, inner_dict in nested_data.items(): + inner_metadata = {"key": outer_key, "components": []} + + for inner_key, feature_arrays in inner_dict.items(): + array_metadata = { + "key": inner_key, + "number_of_dimensions": feature_arrays[0].number_of_dimensions, + "features": [], + } + + for idx, feature_array in enumerate(feature_arrays): + feature_prefix = f"{prefix}_{outer_key}_{inner_key}_{idx}" + _recursive_serialize( + feature_array.tolist(), + feature_prefix, + data_dict, + array_metadata["features"], + ) + + inner_metadata["components"].append( # type:ignore[attr-defined] + array_metadata + ) + + metadata.append(inner_metadata) + + +def serialize_nested_feature_arrays( + nested_feature_array: Dict[str, Dict[str, List["FeatureArray"]]], + data_filename: str, + metadata_filename: str, +) -> None: + data_dict: Dict[str, np.ndarray] = {} + metadata: List[Dict[str, Union[str, List]]] = [] + + _serialize_nested_data(nested_feature_array, "component", data_dict, metadata) + + # Save serialized data and metadata + save_file(data_dict, data_filename) + rasa.shared.utils.io.dump_obj_as_json_to_file(metadata_filename, metadata) + + +def _recursive_deserialize( + metadata: List[Dict[str, Any]], data: Dict[str, Any] +) -> List[Any]: + """Recursively deserialize arrays and matrices for high dimensional data.""" + result = [] + + for item in metadata: + if item["type"] == "dense": + key = item["key"] + array = np.asarray(data[key]).reshape(item["shape"]) + result.append(array) + + elif item["type"] == "list": + key = item["key"] + result.append(list(data[key])) + + elif item["type"] == "sparse": + data_vals = data[f"{item['key']}_data"] + row_vals = data[f"{item['key']}_row"] + col_vals = data[f"{item['key']}_col"] + sparse_matrix = scipy.sparse.coo_matrix( + (data_vals, (row_vals, col_vals)), shape=item["shape"] + ) + result.append(sparse_matrix) + elif item["type"] == "group": + sublist = _recursive_deserialize(item["subcomponents"], data) + result.append(sublist) + + return result + + +def _deserialize_nested_data( + metadata: List[Dict[str, Any]], data_dict: Dict[str, Any] +) -> Dict[str, Dict[str, List["FeatureArray"]]]: + """Handle deserialization across all dictionary and list levels.""" + result: Dict[str, Dict[str, List["FeatureArray"]]] = {} + + for outer_item in metadata: + outer_key = outer_item["key"] + result[outer_key] = {} + + for inner_item in outer_item["components"]: + inner_key = inner_item["key"] + feature_arrays = [] + + # Reconstruct the list of FeatureArrays + for feature_item in inner_item["features"]: + # Reconstruct the list of FeatureArrays + feature_array_data = _recursive_deserialize([feature_item], data_dict) + # Prepare the input for the FeatureArray; + # ensure it is np.ndarray compatible + input_array = np.array(feature_array_data[0], dtype=object) + feature_array = FeatureArray( + input_array, inner_item["number_of_dimensions"] + ) + feature_arrays.append(feature_array) + + result[outer_key][inner_key] = feature_arrays + + return result + + +def deserialize_nested_feature_arrays( + data_filename: str, metadata_filename: str +) -> Dict[str, Dict[str, List["FeatureArray"]]]: + metadata = rasa.shared.utils.io.read_json_file(metadata_filename) + data_dict = load_file(data_filename) + + return _deserialize_nested_data(metadata, data_dict) + + +class FeatureArray(np.ndarray): + """Stores any kind of features ready to be used by a RasaModel. + + Next to the input numpy array of features, it also received the number of + dimensions of the features. + As our features can have 1 to 4 dimensions we might have different number of numpy + arrays stacked. The number of dimensions helps us to figure out how to handle this + particular feature array. Also, it is automatically determined whether the feature + array is sparse or not and the number of units is determined as well. + + Subclassing np.array: https://numpy.org/doc/stable/user/basics.subclassing.html + """ + + def __new__( + cls, input_array: np.ndarray, number_of_dimensions: int + ) -> "FeatureArray": + """Create and return a new object. See help(type) for accurate signature.""" + FeatureArray._validate_number_of_dimensions(number_of_dimensions, input_array) + + feature_array = np.asarray(input_array).view(cls) + + if number_of_dimensions <= 2: + feature_array.units = input_array.shape[-1] + feature_array.is_sparse = isinstance(input_array[0], scipy.sparse.spmatrix) + elif number_of_dimensions == 3: + feature_array.units = input_array[0].shape[-1] + feature_array.is_sparse = isinstance(input_array[0], scipy.sparse.spmatrix) + elif number_of_dimensions == 4: + feature_array.units = input_array[0][0].shape[-1] + feature_array.is_sparse = isinstance( + input_array[0][0], scipy.sparse.spmatrix + ) + else: + raise ValueError( + f"Number of dimensions '{number_of_dimensions}' currently not " + f"supported." + ) + + feature_array.number_of_dimensions = number_of_dimensions + + return feature_array + + def __init__( + self, input_array: Any, number_of_dimensions: int, **kwargs: Any + ) -> None: + """Initialize. FeatureArray. + + Needed in order to avoid 'Invalid keyword argument number_of_dimensions + to function FeatureArray.__init__ ' + Args: + input_array: the array that contains features + number_of_dimensions: number of dimensions in input_array + """ + super().__init__(**kwargs) + self.number_of_dimensions = number_of_dimensions + + def __array_finalize__(self, obj: Optional[np.ndarray]) -> None: + """This method is called when the system allocates a new array from obj. + + Args: + obj: A subclass (subtype) of ndarray. + """ + if obj is None: + return + + self.units = getattr(obj, "units", None) + self.number_of_dimensions = getattr( + obj, "number_of_dimensions", None + ) # type: ignore[assignment] + self.is_sparse = getattr(obj, "is_sparse", None) + + default_attributes = { + "units": self.units, + "number_of_dimensions": self.number_of_dimensions, + "is_spare": self.is_sparse, + } + self.__dict__.update(default_attributes) + + # pytype: disable=attribute-error + def __array_ufunc__( + self, ufunc: Any, method: str, *inputs: Any, **kwargs: Any + ) -> Any: + """Overwrite this method as we are subclassing numpy array. + + Args: + ufunc: The ufunc object that was called. + method: A string indicating which Ufunc method was called + (one of "__call__", "reduce", "reduceat", "accumulate", "outer", + "inner"). + *inputs: A tuple of the input arguments to the ufunc. + **kwargs: Any additional arguments + + Returns: + The result of the operation. + """ + f = { + "reduce": ufunc.reduce, + "accumulate": ufunc.accumulate, + "reduceat": ufunc.reduceat, + "outer": ufunc.outer, + "at": ufunc.at, + "__call__": ufunc, + } + # convert the inputs to np.ndarray to prevent recursion, call the function, + # then cast it back as FeatureArray + output = FeatureArray( + f[method](*(i.view(np.ndarray) for i in inputs), **kwargs), + number_of_dimensions=kwargs["number_of_dimensions"], + ) + output.__dict__ = self.__dict__ # carry forward attributes + return output + + def __reduce__(self) -> Tuple[Any, Any, Any]: + """Needed in order to pickle this object. + + Returns: + A tuple. + """ + pickled_state = super(FeatureArray, self).__reduce__() + if isinstance(pickled_state, str): + raise TypeError("np array __reduce__ returned string instead of tuple.") + new_state = pickled_state[2] + ( + self.number_of_dimensions, + self.is_sparse, + self.units, + ) + return pickled_state[0], pickled_state[1], new_state + + def __setstate__(self, state: Any, **kwargs: Any) -> None: + """Sets the state. + + Args: + state: The state argument must be a sequence that contains the following + elements version, shape, dtype, isFortan, rawdata. + **kwargs: Any additional parameter + """ + # Needed in order to load the object + self.number_of_dimensions = state[-3] + self.is_sparse = state[-2] + self.units = state[-1] + super(FeatureArray, self).__setstate__(state[0:-3], **kwargs) + + # pytype: enable=attribute-error + + @staticmethod + def _validate_number_of_dimensions( + number_of_dimensions: int, input_array: np.ndarray + ) -> None: + """Validates if the input array has given number of dimensions. + + Args: + number_of_dimensions: number of dimensions + input_array: input array + + Raises: ValueError in case the dimensions do not match + """ + # when loading the feature arrays from disk, the shape represents + # the correct number of dimensions + if len(input_array.shape) == number_of_dimensions: + return + + _sub_array = input_array + dim = 0 + # Go number_of_dimensions into the given input_array + for i in range(1, number_of_dimensions + 1): + _sub_array = _sub_array[0] + if isinstance(_sub_array, scipy.sparse.spmatrix): + dim = i + break + if isinstance(_sub_array, np.ndarray) and _sub_array.shape[0] == 0: + # sequence dimension is 0, we are dealing with "fake" features + dim = i + break + + # If the resulting sub_array is sparse, the remaining number of dimensions + # should be at least 2 + if isinstance(_sub_array, scipy.sparse.spmatrix): + if dim > 2: + raise ValueError( + f"Given number of dimensions '{number_of_dimensions}' does not " + f"match dimensions of given input array: {input_array}." + ) + elif isinstance(_sub_array, np.ndarray) and _sub_array.shape[0] == 0: + # sequence dimension is 0, we are dealing with "fake" features, + # but they should be of dim 2 + if dim > 2: + raise ValueError( + f"Given number of dimensions '{number_of_dimensions}' does not " + f"match dimensions of given input array: {input_array}." + ) + # If the resulting sub_array is dense, the sub_array should be a single number + elif not np.issubdtype(type(_sub_array), np.integer) and not isinstance( + _sub_array, (np.float32, np.float64) + ): + raise ValueError( + f"Given number of dimensions '{number_of_dimensions}' does not match " + f"dimensions of given input array: {input_array}." + ) diff --git a/rasa/utils/tensorflow/layers.py b/rasa/utils/tensorflow/layers.py new file mode 100644 index 0000000..6ba29ec --- /dev/null +++ b/rasa/utils/tensorflow/layers.py @@ -0,0 +1,1572 @@ +import logging +from typing import List, Optional, Text, Tuple, Callable, Union, Any +import tensorflow as tf + +# TODO: The following is not (yet) available via tf.keras +from keras.utils.control_flow_util import smart_cond +import tensorflow.keras.backend as K + +import rasa.utils.tensorflow.crf +from rasa.utils.tensorflow.constants import ( + SOFTMAX, + MARGIN, + COSINE, + INNER, + CROSS_ENTROPY, + LABEL, + LABEL_PAD_ID, +) +from rasa.core.constants import DIALOGUE +from rasa.shared.nlu.constants import FEATURE_TYPE_SENTENCE, FEATURE_TYPE_SEQUENCE +from rasa.shared.nlu.constants import TEXT, INTENT, ACTION_NAME, ACTION_TEXT + +from rasa.utils.tensorflow.metrics import F1Score +from rasa.utils.tensorflow.exceptions import TFLayerConfigException +import rasa.utils.tensorflow.layers_utils as layers_utils +from rasa.utils.tensorflow.crf import crf_log_likelihood + +logger = logging.getLogger(__name__) + + +POSSIBLE_ATTRIBUTES = [ + TEXT, + INTENT, + LABEL, + DIALOGUE, + ACTION_NAME, + ACTION_TEXT, + f"{LABEL}_{ACTION_NAME}", + f"{LABEL}_{ACTION_TEXT}", +] + + +class SparseDropout(tf.keras.layers.Dropout): + """Applies Dropout to the input. + + Dropout consists in randomly setting + a fraction `rate` of input units to 0 at each update during training time, + which helps prevent overfitting. + + Arguments: + rate: Fraction of the input units to drop (between 0 and 1). + """ + + def call( + self, inputs: tf.SparseTensor, training: Optional[Union[tf.Tensor, bool]] = None + ) -> tf.SparseTensor: + """Apply dropout to sparse inputs. + + Arguments: + inputs: Input sparse tensor (of any rank). + training: Indicates whether the layer should behave in + training mode (adding dropout) or in inference mode (doing nothing). + + Returns: + Output of dropout layer. + + Raises: + A ValueError if inputs is not a sparse tensor + """ + + if not isinstance(inputs, tf.SparseTensor): + raise ValueError("Input tensor should be sparse.") + + if training is None: + training = K.learning_phase() + + def dropped_inputs() -> tf.SparseTensor: + to_retain_prob = tf.random.uniform( + tf.shape(inputs.values), 0, 1, inputs.values.dtype + ) + to_retain = tf.greater_equal(to_retain_prob, self.rate) + return tf.sparse.retain(inputs, to_retain) + + outputs = smart_cond(training, dropped_inputs, lambda: tf.identity(inputs)) + # need to explicitly recreate sparse tensor, because otherwise the shape + # information will be lost after `retain` + # noinspection PyProtectedMember + return tf.SparseTensor(outputs.indices, outputs.values, inputs._dense_shape) + + +class DenseForSparse(tf.keras.layers.Dense): + """Dense layer for sparse input tensor. + + Just your regular densely-connected NN layer but for sparse tensors. + + `Dense` implements the operation: + `output = activation(dot(input, kernel) + bias)` + where `activation` is the element-wise activation function + passed as the `activation` argument, `kernel` is a weights matrix + created by the layer, and `bias` is a bias vector created by the layer + (only applicable if `use_bias` is `True`). + + Note: If the input to the layer has a rank greater than 2, then + it is flattened prior to the initial dot product with `kernel`. + + Arguments: + units: Positive integer, dimensionality of the output space. + activation: Activation function to use. + If you don't specify anything, no activation is applied + (ie. "linear" activation: `a(x) = x`). + use_bias: Indicates whether the layer uses a bias vector. + kernel_initializer: Initializer for the `kernel` weights matrix. + bias_initializer: Initializer for the bias vector. + reg_lambda: regularization factor + bias_regularizer: Regularizer function applied to the bias vector. + activity_regularizer: Regularizer function applied to + the output of the layer (its "activation").. + kernel_constraint: Constraint function applied to + the `kernel` weights matrix. + bias_constraint: Constraint function applied to the bias vector. + + Input shape: + N-D tensor with shape: `(batch_size, ..., input_dim)`. + The most common situation would be + a 2D input with shape `(batch_size, input_dim)`. + + Output shape: + N-D tensor with shape: `(batch_size, ..., units)`. + For instance, for a 2D input with shape `(batch_size, input_dim)`, + the output would have shape `(batch_size, units)`. + """ + + def __init__(self, reg_lambda: float = 0, **kwargs: Any) -> None: + if reg_lambda > 0: + regularizer = tf.keras.regularizers.l2(reg_lambda) + else: + regularizer = None + + super().__init__(kernel_regularizer=regularizer, **kwargs) + + def get_units(self) -> int: + """Returns number of output units.""" + return self.units + + def get_kernel(self) -> tf.Tensor: + """Returns kernel tensor.""" + return self.kernel + + def get_bias(self) -> Union[tf.Tensor, None]: + """Returns bias tensor.""" + if self.use_bias: + return self.bias + return None + + def get_feature_type(self) -> Union[Text, None]: + """Returns a feature type of the data that's fed to the layer. + + In order to correctly return a feature type, the function heavily relies + on the name of `DenseForSparse` layer to contain the feature type. + Acceptable values of feature types are `FEATURE_TYPE_SENTENCE` + and `FEATURE_TYPE_SEQUENCE`. + + Returns: + feature type of dense layer. + """ + for feature_type in [FEATURE_TYPE_SENTENCE, FEATURE_TYPE_SEQUENCE]: + if feature_type in self.name: + return feature_type + return None + + def get_attribute(self) -> Union[Text, None]: + """Returns the attribute for which this layer was constructed. + + For example: TEXT, LABEL, etc. + + In order to correctly return an attribute, the function heavily relies + on the name of `DenseForSparse` layer being in the following format: + f"sparse_to_dense.{attribute}_{feature_type}". + + Returns: + attribute of the layer. + """ + metadata = self.name.split(".") + if len(metadata) > 1: + attribute_splits = metadata[1].split("_")[:-1] + attribute = "_".join(attribute_splits) + if attribute in POSSIBLE_ATTRIBUTES: + return attribute + return None + + def call(self, inputs: tf.SparseTensor) -> tf.Tensor: + """Apply dense layer to sparse inputs. + + Arguments: + inputs: Input sparse tensor (of any rank). + + Returns: + Output of dense layer. + + Raises: + A ValueError if inputs is not a sparse tensor + """ + if not isinstance(inputs, tf.SparseTensor): + raise ValueError("Input tensor should be sparse.") + + # outputs will be 2D + outputs = tf.sparse.sparse_dense_matmul( + tf.sparse.reshape(inputs, [-1, tf.shape(inputs)[-1]]), self.kernel + ) + + if len(inputs.shape) == 3: + # reshape back + outputs = tf.reshape( + outputs, (tf.shape(inputs)[0], tf.shape(inputs)[1], self.units) + ) + + if self.use_bias: + outputs = tf.nn.bias_add(outputs, self.bias) + if self.activation is not None: + return self.activation(outputs) + return outputs + + +class RandomlyConnectedDense(tf.keras.layers.Dense): + """Layer with dense ouputs that are connected to a random subset of inputs. + + `RandomlyConnectedDense` implements the operation: + `output = activation(dot(input, kernel) + bias)` + where `activation` is the element-wise activation function + passed as the `activation` argument, `kernel` is a weights matrix + created by the layer, and `bias` is a bias vector created by the layer + (only applicable if `use_bias` is `True`). + It creates `kernel_mask` to set a fraction of the `kernel` weights to zero. + + Note: If the input to the layer has a rank greater than 2, then + it is flattened prior to the initial dot product with `kernel`. + + The output is guaranteed to be dense (each output is connected to at least one + input), and no input is disconnected (each input is connected to at least one + output). + + At `density = 0.0` the number of trainable weights is `max(input_size, units)`. At + `density = 1.0` this layer is equivalent to `tf.keras.layers.Dense`. + + Input shape: + N-D tensor with shape: `(batch_size, ..., input_dim)`. + The most common situation would be + a 2D input with shape `(batch_size, input_dim)`. + + Output shape: + N-D tensor with shape: `(batch_size, ..., units)`. + For instance, for a 2D input with shape `(batch_size, input_dim)`, + the output would have shape `(batch_size, units)`. + """ + + def __init__(self, density: float = 0.2, **kwargs: Any) -> None: + """Declares instance variables with default values. + + Args: + density: Approximate fraction of trainable weights (between 0 and 1). + units: Positive integer, dimensionality of the output space. + activation: Activation function to use. + If you don't specify anything, no activation is applied + (ie. "linear" activation: `a(x) = x`). + use_bias: Indicates whether the layer uses a bias vector. + kernel_initializer: Initializer for the `kernel` weights matrix. + bias_initializer: Initializer for the bias vector. + kernel_regularizer: Regularizer function applied to + the `kernel` weights matrix. + bias_regularizer: Regularizer function applied to the bias vector. + activity_regularizer: Regularizer function applied to + the output of the layer (its "activation").. + kernel_constraint: Constraint function applied to + the `kernel` weights matrix. + bias_constraint: Constraint function applied to the bias vector. + """ + super().__init__(**kwargs) + + if density < 0.0 or density > 1.0: + raise TFLayerConfigException("Layer density must be in [0, 1].") + + self.density = density + + def build(self, input_shape: tf.TensorShape) -> None: + """Prepares the kernel mask. + + Args: + input_shape: Shape of the inputs to this layer + """ + super().build(input_shape) + + if self.density == 1.0: + self.kernel_mask = None + return + + # Construct mask with given density and guarantee that every output is + # connected to at least one input + kernel_mask = self._minimal_mask() + self._random_mask() + + # We might accidently have added a random connection on top of + # a fixed connection + kernel_mask = tf.clip_by_value(kernel_mask, 0, 1) + + self.kernel_mask = tf.Variable( + initial_value=kernel_mask, trainable=False, name="kernel_mask" + ) + + def _random_mask(self) -> tf.Tensor: + """Creates a random matrix with `num_ones` 1s and 0s otherwise. + + Returns: + A random mask matrix + """ + mask = tf.random.uniform(tf.shape(self.kernel), 0, 1) + mask = tf.cast(tf.math.less(mask, self.density), self.kernel.dtype) + return mask + + def _minimal_mask(self) -> tf.Tensor: + """Creates a matrix with a minimal number of 1s to connect everythinig. + + If num_rows == num_cols, this creates the identity matrix. + If num_rows > num_cols, this creates + 1 0 0 0 + 0 1 0 0 + 0 0 1 0 + 0 0 0 1 + 1 0 0 0 + 0 1 0 0 + 0 0 1 0 + . . . . + . . . . + . . . . + If num_rows < num_cols, this creates + 1 0 0 1 0 0 1 ... + 0 1 0 0 1 0 0 ... + 0 0 1 0 0 1 0 ... + + Returns: + A tiled and croped identity matrix. + """ + kernel_shape = tf.shape(self.kernel) + num_rows = kernel_shape[0] + num_cols = kernel_shape[1] + short_dimension = tf.minimum(num_rows, num_cols) + + mask = tf.tile( + tf.eye(short_dimension, dtype=self.kernel.dtype), + [ + tf.math.ceil(num_rows / short_dimension), + tf.math.ceil(num_cols / short_dimension), + ], + )[:num_rows, :num_cols] + + return mask + + def call(self, inputs: tf.Tensor) -> tf.Tensor: + """Processes the given inputs. + + Args: + inputs: What goes into this layer + + Returns: + The processed inputs. + """ + if self.density < 1.0: + # Set fraction of the `kernel` weights to zero according to precomputed mask + self.kernel.assign(self.kernel * self.kernel_mask) + return super().call(inputs) + + +class Ffnn(tf.keras.layers.Layer): + """Feed-forward network layer. + + Arguments: + layer_sizes: List of integers with dimensionality of the layers. + dropout_rate: Fraction of the input units to drop (between 0 and 1). + reg_lambda: regularization factor. + density: Approximate fraction of trainable weights (between 0 and 1). + layer_name_suffix: Text added to the name of the layers. + + Input shape: + N-D tensor with shape: `(batch_size, ..., input_dim)`. + The most common situation would be + a 2D input with shape `(batch_size, input_dim)`. + + Output shape: + N-D tensor with shape: `(batch_size, ..., layer_sizes[-1])`. + For instance, for a 2D input with shape `(batch_size, input_dim)`, + the output would have shape `(batch_size, layer_sizes[-1])`. + """ + + def __init__( + self, + layer_sizes: List[int], + dropout_rate: float, + reg_lambda: float, + density: float, + layer_name_suffix: Text, + ) -> None: + super().__init__(name=f"ffnn_{layer_name_suffix}") + + l2_regularizer = tf.keras.regularizers.l2(reg_lambda) + self._ffn_layers = [] + for i, layer_size in enumerate(layer_sizes): + self._ffn_layers.append( + RandomlyConnectedDense( + units=layer_size, + density=density, + activation=tf.nn.gelu, + kernel_regularizer=l2_regularizer, + name=f"hidden_layer_{layer_name_suffix}_{i}", + ) + ) + self._ffn_layers.append(tf.keras.layers.Dropout(dropout_rate)) + + def call( + self, x: tf.Tensor, training: Optional[Union[tf.Tensor, bool]] = None + ) -> tf.Tensor: + """Apply feed-forward network layer.""" + for layer in self._ffn_layers: + x = layer(x, training=training) + + return x + + +class Embed(tf.keras.layers.Layer): + """Dense embedding layer. + + Input shape: + N-D tensor with shape: `(batch_size, ..., input_dim)`. + The most common situation would be + a 2D input with shape `(batch_size, input_dim)`. + + Output shape: + N-D tensor with shape: `(batch_size, ..., embed_dim)`. + For instance, for a 2D input with shape `(batch_size, input_dim)`, + the output would have shape `(batch_size, embed_dim)`. + """ + + def __init__( + self, embed_dim: int, reg_lambda: float, layer_name_suffix: Text + ) -> None: + """Initialize layer. + + Args: + embed_dim: Dimensionality of the output space. + reg_lambda: Regularization factor. + layer_name_suffix: Text added to the name of the layers. + """ + super().__init__(name=f"embed_{layer_name_suffix}") + + regularizer = tf.keras.regularizers.l2(reg_lambda) + self._dense = tf.keras.layers.Dense( + units=embed_dim, + activation=None, + kernel_regularizer=regularizer, + name=f"embed_layer_{layer_name_suffix}", + ) + + # noinspection PyMethodOverriding + def call(self, x: tf.Tensor) -> tf.Tensor: + """Apply dense layer.""" + x = self._dense(x) + return x + + +class InputMask(tf.keras.layers.Layer): + """The layer that masks 15% of the input. + + Input shape: + N-D tensor with shape: `(batch_size, ..., input_dim)`. + The most common situation would be + a 2D input with shape `(batch_size, input_dim)`. + + Output shape: + N-D tensor with shape: `(batch_size, ..., input_dim)`. + For instance, for a 2D input with shape `(batch_size, input_dim)`, + the output would have shape `(batch_size, input_dim)`. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + self._masking_prob = 0.85 + self._mask_vector_prob = 0.7 + self._random_vector_prob = 0.1 + + def build(self, input_shape: tf.TensorShape) -> None: + self.mask_vector = self.add_weight( + shape=(1, 1, input_shape[-1]), name="mask_vector" + ) + self.built = True + + # noinspection PyMethodOverriding + def call( + self, + x: tf.Tensor, + mask: tf.Tensor, + training: Optional[Union[tf.Tensor, bool]] = None, + ) -> Tuple[tf.Tensor, tf.Tensor]: + """Randomly mask input sequences. + + Arguments: + x: Input sequence tensor of rank 3. + mask: A tensor representing sequence mask, + contains `1` for inputs and `0` for padding. + training: Indicates whether the layer should run in + training mode (mask inputs) or in inference mode (doing nothing). + + Returns: + A tuple of masked inputs and boolean mask. + """ + + if training is None: + training = K.learning_phase() + + lm_mask_prob = tf.random.uniform(tf.shape(mask), 0, 1, mask.dtype) * mask + lm_mask_bool = tf.greater_equal(lm_mask_prob, self._masking_prob) + + def x_masked() -> tf.Tensor: + x_random_pad = tf.random.uniform( + tf.shape(x), tf.reduce_min(x), tf.reduce_max(x), x.dtype + ) * (1 - mask) + # shuffle over batch dim + x_shuffle = tf.random.shuffle(x * mask + x_random_pad) + + # shuffle over sequence dim + x_shuffle = tf.transpose(x_shuffle, [1, 0, 2]) + x_shuffle = tf.random.shuffle(x_shuffle) + x_shuffle = tf.transpose(x_shuffle, [1, 0, 2]) + + # shuffle doesn't support backprop + x_shuffle = tf.stop_gradient(x_shuffle) + + mask_vector = tf.tile(self.mask_vector, (tf.shape(x)[0], tf.shape(x)[1], 1)) + + other_prob = tf.random.uniform(tf.shape(mask), 0, 1, mask.dtype) + other_prob = tf.tile(other_prob, (1, 1, x.shape[-1])) + x_other = tf.where( + other_prob < self._mask_vector_prob, + mask_vector, + tf.where( + other_prob < self._mask_vector_prob + self._random_vector_prob, + x_shuffle, + x, + ), + ) + + return tf.where(tf.tile(lm_mask_bool, (1, 1, x.shape[-1])), x_other, x) + + return (smart_cond(training, x_masked, lambda: tf.identity(x)), lm_mask_bool) + + +def _scale_loss(log_likelihood: tf.Tensor) -> tf.Tensor: + """Creates scaling loss coefficient depending on the prediction probability. + + Arguments: + log_likelihood: a tensor, log-likelihood of prediction + + Returns: + Scaling tensor. + """ + p = tf.math.exp(log_likelihood) + # only scale loss if some examples are already learned + return tf.cond( + tf.reduce_max(p) > 0.5, + lambda: tf.stop_gradient(tf.pow((1 - p) / 0.5, 4)), + lambda: tf.ones_like(p), + ) + + +class CRF(tf.keras.layers.Layer): + """CRF layer. + + Arguments: + num_tags: Positive integer, number of tags. + reg_lambda: regularization factor. + name: Optional name of the layer. + """ + + def __init__( + self, + num_tags: int, + reg_lambda: float, + scale_loss: bool, + name: Optional[Text] = None, + ) -> None: + super().__init__(name=name) + self.num_tags = num_tags + self.scale_loss = scale_loss + self.transition_regularizer = tf.keras.regularizers.l2(reg_lambda) + self.f1_score_metric = F1Score( + num_classes=num_tags - 1, # `0` prediction is not a prediction + average="micro", + ) + + def build(self, input_shape: tf.TensorShape) -> None: + # the weights should be created in `build` to apply random_seed + self.transition_params = self.add_weight( + shape=(self.num_tags, self.num_tags), + regularizer=self.transition_regularizer, + name="transitions", + ) + self.built = True + + # noinspection PyMethodOverriding + def call( + self, logits: tf.Tensor, sequence_lengths: tf.Tensor + ) -> Tuple[tf.Tensor, tf.Tensor]: + """Decodes the highest scoring sequence of tags. + + Arguments: + logits: A [batch_size, max_seq_len, num_tags] tensor of + unary potentials. + sequence_lengths: A [batch_size] vector of true sequence lengths. + + Returns: + A [batch_size, max_seq_len] matrix, with dtype `tf.int32`. + Contains the highest scoring tag indices. + A [batch_size, max_seq_len] matrix, with dtype `tf.float32`. + Contains the confidence values of the highest scoring tag indices. + """ + predicted_ids, scores, _ = rasa.utils.tensorflow.crf.crf_decode( + logits, self.transition_params, sequence_lengths + ) + # set prediction index for padding to `0` + mask = tf.sequence_mask( + sequence_lengths, + maxlen=tf.shape(predicted_ids)[1], + dtype=predicted_ids.dtype, + ) + + confidence_values = scores * tf.cast(mask, tf.float32) + predicted_ids = predicted_ids * mask + + return predicted_ids, confidence_values + + def loss( + self, logits: tf.Tensor, tag_indices: tf.Tensor, sequence_lengths: tf.Tensor + ) -> tf.Tensor: + """Computes the log-likelihood of tag sequences in a CRF. + + Arguments: + logits: A [batch_size, max_seq_len, num_tags] tensor of unary potentials + to use as input to the CRF layer. + tag_indices: A [batch_size, max_seq_len] matrix of tag indices for which + we compute the log-likelihood. + sequence_lengths: A [batch_size] vector of true sequence lengths. + + Returns: + Negative mean log-likelihood of all examples, + given the sequence of tag indices. + """ + + log_likelihood, _ = crf_log_likelihood( + logits, tag_indices, sequence_lengths, self.transition_params + ) + loss = -log_likelihood + if self.scale_loss: + loss *= _scale_loss(log_likelihood) + + return tf.reduce_mean(loss) + + def f1_score( + self, tag_ids: tf.Tensor, pred_ids: tf.Tensor, mask: tf.Tensor + ) -> tf.Tensor: + """Calculates f1 score for train predictions""" + + mask_bool = tf.cast(mask[:, :, 0], tf.bool) + + # pick only non padding values and flatten sequences + tag_ids_flat = tf.boolean_mask(tag_ids, mask_bool) + pred_ids_flat = tf.boolean_mask(pred_ids, mask_bool) + + # set `0` prediction to not a prediction + num_tags = self.num_tags - 1 + + tag_ids_flat_one_hot = tf.one_hot(tag_ids_flat - 1, num_tags) + pred_ids_flat_one_hot = tf.one_hot(pred_ids_flat - 1, num_tags) + + return self.f1_score_metric(tag_ids_flat_one_hot, pred_ids_flat_one_hot) + + +class DotProductLoss(tf.keras.layers.Layer): + """Abstract dot-product loss layer class. + + Idea based on StarSpace paper: http://arxiv.org/abs/1709.03856 + + Implements similarity methods + * `sim` (computes a similarity between vectors) + * `get_similarities_and_confidences_from_embeddings` (calls `sim` and also computes + confidence values) + + Specific loss functions (single- or multi-label) must be implemented in child + classes. + """ + + def __init__( + self, + num_candidates: int, + scale_loss: bool = False, + constrain_similarities: bool = True, + model_confidence: Text = SOFTMAX, + similarity_type: Text = INNER, + name: Optional[Text] = None, + **kwargs: Any, + ): + """Declares instance variables with default values. + + Args: + num_candidates: Number of labels besides the positive one. Depending on + whether single- or multi-label loss is implemented (done in + sub-classes), these can be all negative example labels, or a mixture of + negative and further positive labels, respectively. + scale_loss: Boolean, if `True` scale loss inverse proportionally to + the confidence of the correct prediction. + constrain_similarities: Boolean, if `True` applies sigmoid on all + similarity terms and adds to the loss function to + ensure that similarity values are approximately bounded. + Used inside _loss_cross_entropy() only. + model_confidence: Normalization of confidence values during inference. + Currently, the only possible value is `SOFTMAX`. + similarity_type: Similarity measure to use, either `cosine` or `inner`. + name: Optional name of the layer. + + Raises: + TFLayerConfigException: When `similarity_type` is not one of `COSINE` or + `INNER`. + """ + super().__init__(name=name) + self.num_neg = num_candidates + self.scale_loss = scale_loss + self.constrain_similarities = constrain_similarities + self.model_confidence = model_confidence + self.similarity_type = similarity_type + if self.similarity_type not in {COSINE, INNER}: + raise TFLayerConfigException( + f"Unsupported similarity type '{self.similarity_type}', " + f"should be '{COSINE}' or '{INNER}'." + ) + + def sim( + self, a: tf.Tensor, b: tf.Tensor, mask: Optional[tf.Tensor] = None + ) -> tf.Tensor: + """Calculates similarity between `a` and `b`. + + Operates on the last dimension. When `a` and `b` are vectors, then `sim` + computes either the dot-product, or the cosine of the angle between `a` and `b`, + depending on `self.similarity_type`. + Specifically, when the similarity type is `INNER`, then we compute the scalar + product `a . b`. When the similarity type is `COSINE`, we compute + `a . b / (|a| |b|)`, i.e. the cosine of the angle between `a` and `b`. + + Args: + a: Any float tensor + b: Any tensor of the same shape and type as `a` + mask: Mask (should contain 1s for inputs and 0s for padding). Note, that + `len(mask.shape) == len(a.shape) - 1` should hold. + + Returns: + Similarities between vectors in `a` and `b`. + """ + if self.similarity_type == COSINE: + a = tf.nn.l2_normalize(a, axis=-1) + b = tf.nn.l2_normalize(b, axis=-1) + sim = tf.reduce_sum(a * b, axis=-1) + if mask is not None: + sim *= tf.expand_dims(mask, 2) + + return sim + + def get_similarities_and_confidences_from_embeddings( + self, + input_embeddings: tf.Tensor, + label_embeddings: tf.Tensor, + mask: Optional[tf.Tensor] = None, + ) -> Tuple[tf.Tensor, tf.Tensor]: + """Computes similary between input and label embeddings and model's confidence. + + First compute the similarity from embeddings and then apply an activation + function if needed to get the confidence. + + Args: + input_embeddings: Embeddings of input. + label_embeddings: Embeddings of labels. + mask: Mask (should contain 1s for inputs and 0s for padding). Note, that + `len(mask.shape) == len(a.shape) - 1` should hold. + + Returns: + similarity between input and label embeddings and model's prediction + confidence for each label. + """ + similarities = self.sim(input_embeddings, label_embeddings, mask) + confidences = similarities + if self.model_confidence == SOFTMAX: + confidences = tf.nn.softmax(similarities) + return similarities, confidences + + def call(self, *args: Any, **kwargs: Any) -> Tuple[tf.Tensor, tf.Tensor]: + """Layer's logic - to be implemented in child class.""" + raise NotImplementedError + + def apply_mask_and_scaling( + self, loss: tf.Tensor, mask: Optional[tf.Tensor] + ) -> tf.Tensor: + """Scales the loss and applies the mask if necessary. + + Args: + loss: The loss tensor + mask: (Optional) A mask to multiply with the loss + + Returns: + The scaled loss, potentially averaged over the sequence + dimension. + """ + if self.scale_loss: + # in case of cross entropy log_likelihood = -loss + loss *= _scale_loss(-loss) + + if mask is not None: + loss *= mask + + if len(loss.shape) == 2: + # average over the sequence + if mask is not None: + loss = tf.reduce_sum(loss, axis=-1) / tf.reduce_sum(mask, axis=-1) + else: + loss = tf.reduce_mean(loss, axis=-1) + + return loss + + +class SingleLabelDotProductLoss(DotProductLoss): + """Single-label dot-product loss layer. + + This loss layer assumes that only one output (label) is correct for any given input. + """ + + def __init__( + self, + num_candidates: int, + scale_loss: bool = False, + constrain_similarities: bool = True, + model_confidence: Text = SOFTMAX, + similarity_type: Text = INNER, + name: Optional[Text] = None, + loss_type: Text = CROSS_ENTROPY, + mu_pos: float = 0.8, + mu_neg: float = -0.2, + use_max_sim_neg: bool = True, + neg_lambda: float = 0.5, + same_sampling: bool = False, + **kwargs: Any, + ) -> None: + """Declares instance variables with default values. + + Args: + num_candidates: Positive integer, the number of incorrect labels; + the algorithm will minimize their similarity to the input. + loss_type: The type of the loss function, either `cross_entropy` or + `margin`. + mu_pos: Indicates how similar the algorithm should + try to make embedding vectors for correct labels; + should be 0.0 < ... < 1.0 for `cosine` similarity type. + mu_neg: Maximum negative similarity for incorrect labels, + should be -1.0 < ... < 1.0 for `cosine` similarity type. + use_max_sim_neg: If `True` the algorithm only minimizes + maximum similarity over incorrect intent labels, + used only if `loss_type` is set to `margin`. + neg_lambda: The scale of how important it is to minimize + the maximum similarity between embeddings of different labels, + used only if `loss_type` is set to `margin`. + scale_loss: If `True` scale loss inverse proportionally to + the confidence of the correct prediction. + similarity_type: Similarity measure to use, either `cosine` or `inner`. + name: Optional name of the layer. + same_sampling: If `True` sample same negative labels + for the whole batch. + constrain_similarities: If `True` and loss_type is `cross_entropy`, a + sigmoid loss term is added to the total loss to ensure that similarity + values are approximately bounded. + model_confidence: Normalization of confidence values during inference. + Currently, the only possible value is `SOFTMAX`. + """ + super().__init__( + num_candidates, + scale_loss=scale_loss, + constrain_similarities=constrain_similarities, + model_confidence=model_confidence, + similarity_type=similarity_type, + name=name, + ) + self.loss_type = loss_type + self.mu_pos = mu_pos + self.mu_neg = mu_neg + self.use_max_sim_neg = use_max_sim_neg + self.neg_lambda = neg_lambda + self.same_sampling = same_sampling + + def _get_bad_mask( + self, labels: tf.Tensor, target_labels: tf.Tensor, idxs: tf.Tensor + ) -> tf.Tensor: + """Calculate bad mask for given indices. + + Checks that input features are different for positive negative samples. + """ + pos_labels = tf.expand_dims(target_labels, axis=-2) + neg_labels = layers_utils.get_candidate_values(labels, idxs) + + return tf.cast( + tf.reduce_all(tf.equal(neg_labels, pos_labels), axis=-1), pos_labels.dtype + ) + + def _get_negs( + self, embeds: tf.Tensor, labels: tf.Tensor, target_labels: tf.Tensor + ) -> Tuple[tf.Tensor, tf.Tensor]: + """Gets negative examples from given tensor.""" + embeds_flat = layers_utils.batch_flatten(embeds) + labels_flat = layers_utils.batch_flatten(labels) + target_labels_flat = layers_utils.batch_flatten(target_labels) + + total_candidates = tf.shape(embeds_flat)[0] + target_size = tf.shape(target_labels_flat)[0] + + neg_ids = layers_utils.random_indices( + target_size, self.num_neg, total_candidates + ) + + neg_embeds = layers_utils.get_candidate_values(embeds_flat, neg_ids) + bad_negs = self._get_bad_mask(labels_flat, target_labels_flat, neg_ids) + + # check if inputs have sequence dimension + if len(target_labels.shape) == 3: + # tensors were flattened for sampling, reshape back + # add sequence dimension if it was present in the inputs + target_shape = tf.shape(target_labels) + neg_embeds = tf.reshape( + neg_embeds, (target_shape[0], target_shape[1], -1, embeds.shape[-1]) + ) + bad_negs = tf.reshape(bad_negs, (target_shape[0], target_shape[1], -1)) + + return neg_embeds, bad_negs + + def _sample_negatives( + self, + inputs_embed: tf.Tensor, + labels_embed: tf.Tensor, + labels: tf.Tensor, + all_labels_embed: tf.Tensor, + all_labels: tf.Tensor, + ) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor, tf.Tensor, tf.Tensor, tf.Tensor]: + """Sample negative examples.""" + + pos_inputs_embed = tf.expand_dims(inputs_embed, axis=-2) + pos_labels_embed = tf.expand_dims(labels_embed, axis=-2) + + # sample negative inputs + neg_inputs_embed, inputs_bad_negs = self._get_negs(inputs_embed, labels, labels) + # sample negative labels + neg_labels_embed, labels_bad_negs = self._get_negs( + all_labels_embed, all_labels, labels + ) + return ( + pos_inputs_embed, + pos_labels_embed, + neg_inputs_embed, + neg_labels_embed, + inputs_bad_negs, + labels_bad_negs, + ) + + def _train_sim( + self, + pos_inputs_embed: tf.Tensor, + pos_labels_embed: tf.Tensor, + neg_inputs_embed: tf.Tensor, + neg_labels_embed: tf.Tensor, + inputs_bad_negs: tf.Tensor, + labels_bad_negs: tf.Tensor, + mask: Optional[tf.Tensor], + ) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor, tf.Tensor, tf.Tensor]: + """Define similarity.""" + + # calculate similarity with several + # embedded actions for the loss + neg_inf = tf.constant(-1e9) + + sim_pos = self.sim(pos_inputs_embed, pos_labels_embed, mask) + sim_neg_il = ( + self.sim(pos_inputs_embed, neg_labels_embed, mask) + + neg_inf * labels_bad_negs + ) + sim_neg_ll = ( + self.sim(pos_labels_embed, neg_labels_embed, mask) + + neg_inf * labels_bad_negs + ) + sim_neg_ii = ( + self.sim(pos_inputs_embed, neg_inputs_embed, mask) + + neg_inf * inputs_bad_negs + ) + sim_neg_li = ( + self.sim(pos_labels_embed, neg_inputs_embed, mask) + + neg_inf * inputs_bad_negs + ) + + # output similarities between user input and bot actions + # and similarities between bot actions and similarities between user inputs + return sim_pos, sim_neg_il, sim_neg_ll, sim_neg_ii, sim_neg_li + + @staticmethod + def _calc_accuracy(sim_pos: tf.Tensor, sim_neg: tf.Tensor) -> tf.Tensor: + """Calculate accuracy.""" + max_all_sim = tf.reduce_max(tf.concat([sim_pos, sim_neg], axis=-1), axis=-1) + sim_pos = tf.squeeze(sim_pos, axis=-1) + return layers_utils.reduce_mean_equal(max_all_sim, sim_pos) + + def _loss_margin( + self, + sim_pos: tf.Tensor, + sim_neg_il: tf.Tensor, + sim_neg_ll: tf.Tensor, + sim_neg_ii: tf.Tensor, + sim_neg_li: tf.Tensor, + mask: Optional[tf.Tensor], + ) -> tf.Tensor: + """Define max margin loss.""" + + # loss for maximizing similarity with correct action + loss = tf.maximum(0.0, self.mu_pos - tf.squeeze(sim_pos, axis=-1)) + + # loss for minimizing similarity with `num_neg` incorrect actions + if self.use_max_sim_neg: + # minimize only maximum similarity over incorrect actions + max_sim_neg_il = tf.reduce_max(sim_neg_il, axis=-1) + loss += tf.maximum(0.0, self.mu_neg + max_sim_neg_il) + else: + # minimize all similarities with incorrect actions + max_margin = tf.maximum(0.0, self.mu_neg + sim_neg_il) + loss += tf.reduce_sum(max_margin, axis=-1) + + # penalize max similarity between pos bot and neg bot embeddings + max_sim_neg_ll = tf.maximum( + 0.0, self.mu_neg + tf.reduce_max(sim_neg_ll, axis=-1) + ) + loss += max_sim_neg_ll * self.neg_lambda + + # penalize max similarity between pos dial and neg dial embeddings + max_sim_neg_ii = tf.maximum( + 0.0, self.mu_neg + tf.reduce_max(sim_neg_ii, axis=-1) + ) + loss += max_sim_neg_ii * self.neg_lambda + + # penalize max similarity between pos bot and neg dial embeddings + max_sim_neg_li = tf.maximum( + 0.0, self.mu_neg + tf.reduce_max(sim_neg_li, axis=-1) + ) + loss += max_sim_neg_li * self.neg_lambda + + if mask is not None: + # mask loss for different length sequences + loss *= mask + # average the loss over sequence length + loss = tf.reduce_sum(loss, axis=-1) / tf.reduce_sum(mask, axis=1) + + # average the loss over the batch + loss = tf.reduce_mean(loss) + + return loss + + def _loss_cross_entropy( + self, + sim_pos: tf.Tensor, + sim_neg_il: tf.Tensor, + sim_neg_ll: tf.Tensor, + sim_neg_ii: tf.Tensor, + sim_neg_li: tf.Tensor, + mask: Optional[tf.Tensor], + ) -> tf.Tensor: + """Defines cross entropy loss.""" + loss = self._compute_softmax_loss( + sim_pos, sim_neg_il, sim_neg_ll, sim_neg_ii, sim_neg_li + ) + + if self.constrain_similarities: + loss += self._compute_sigmoid_loss( + sim_pos, sim_neg_il, sim_neg_ll, sim_neg_ii, sim_neg_li + ) + + loss = self.apply_mask_and_scaling(loss, mask) + + # average the loss over the batch + return tf.reduce_mean(loss) + + @staticmethod + def _compute_sigmoid_loss( + sim_pos: tf.Tensor, + sim_neg_il: tf.Tensor, + sim_neg_ll: tf.Tensor, + sim_neg_ii: tf.Tensor, + sim_neg_li: tf.Tensor, + ) -> tf.Tensor: + # Constrain similarity values in a range by applying sigmoid + # on them individually so that they saturate at extreme values. + sigmoid_logits = tf.concat( + [sim_pos, sim_neg_il, sim_neg_ll, sim_neg_ii, sim_neg_li], axis=-1 + ) + sigmoid_labels = tf.concat( + [ + tf.ones_like(sigmoid_logits[..., :1]), + tf.zeros_like(sigmoid_logits[..., 1:]), + ], + axis=-1, + ) + sigmoid_loss = tf.nn.sigmoid_cross_entropy_with_logits( + labels=sigmoid_labels, logits=sigmoid_logits + ) + # average over logits axis + return tf.reduce_mean(sigmoid_loss, axis=-1) + + def _compute_softmax_loss( + self, + sim_pos: tf.Tensor, + sim_neg_il: tf.Tensor, + sim_neg_ll: tf.Tensor, + sim_neg_ii: tf.Tensor, + sim_neg_li: tf.Tensor, + ) -> tf.Tensor: + # Similarity terms between input and label should be optimized relative + # to each other and hence use them as logits for softmax term + softmax_logits = tf.concat([sim_pos, sim_neg_il, sim_neg_li], axis=-1) + if not self.constrain_similarities: + # Concatenate other similarity terms as well. Due to this, + # similarity values between input and label may not be + # approximately bounded in a defined range. + softmax_logits = tf.concat( + [softmax_logits, sim_neg_ii, sim_neg_ll], axis=-1 + ) + # create label_ids for softmax + softmax_label_ids = tf.zeros_like(softmax_logits[..., 0], tf.int32) + softmax_loss = tf.nn.sparse_softmax_cross_entropy_with_logits( + labels=softmax_label_ids, logits=softmax_logits + ) + return softmax_loss + + @property + def _chosen_loss(self) -> Callable: + """Use loss depending on given option.""" + if self.loss_type == MARGIN: + return self._loss_margin + elif self.loss_type == CROSS_ENTROPY: + return self._loss_cross_entropy + else: + raise TFLayerConfigException( + f"Wrong loss type '{self.loss_type}', " + f"should be '{MARGIN}' or '{CROSS_ENTROPY}'" + ) + + # noinspection PyMethodOverriding + def call( + self, + inputs_embed: tf.Tensor, + labels_embed: tf.Tensor, + labels: tf.Tensor, + all_labels_embed: tf.Tensor, + all_labels: tf.Tensor, + mask: Optional[tf.Tensor] = None, + ) -> Tuple[tf.Tensor, tf.Tensor]: + """Calculate loss and accuracy. + + Args: + inputs_embed: Embedding tensor for the batch inputs; + shape `(batch_size, ..., num_features)` + labels_embed: Embedding tensor for the batch labels; + shape `(batch_size, ..., num_features)` + labels: Tensor representing batch labels; shape `(batch_size, ..., 1)` + all_labels_embed: Embedding tensor for the all labels; + shape `(num_labels, num_features)` + all_labels: Tensor representing all labels; shape `(num_labels, 1)` + mask: Optional mask, contains `1` for inputs and `0` for padding; + shape `(batch_size, 1)` + + Returns: + loss: Total loss. + accuracy: Training accuracy. + """ + ( + pos_inputs_embed, + pos_labels_embed, + neg_inputs_embed, + neg_labels_embed, + inputs_bad_negs, + labels_bad_negs, + ) = self._sample_negatives( + inputs_embed, labels_embed, labels, all_labels_embed, all_labels + ) + + # calculate similarities + sim_pos, sim_neg_il, sim_neg_ll, sim_neg_ii, sim_neg_li = self._train_sim( + pos_inputs_embed, + pos_labels_embed, + neg_inputs_embed, + neg_labels_embed, + inputs_bad_negs, + labels_bad_negs, + mask, + ) + + accuracy = self._calc_accuracy(sim_pos, sim_neg_il) + + loss = self._chosen_loss( + sim_pos, sim_neg_il, sim_neg_ll, sim_neg_ii, sim_neg_li, mask + ) + + return loss, accuracy + + +class MultiLabelDotProductLoss(DotProductLoss): + """Multi-label dot-product loss layer. + + This loss layer assumes that multiple outputs (labels) can be correct for any given + input. To accomodate for this, we use a sigmoid cross-entropy loss here. + """ + + def __init__( + self, + num_candidates: int, + scale_loss: bool = False, + constrain_similarities: bool = True, + model_confidence: Text = SOFTMAX, + similarity_type: Text = INNER, + name: Optional[Text] = None, + **kwargs: Any, + ) -> None: + """Declares instance variables with default values. + + Args: + num_candidates: Positive integer, the number of candidate labels. + scale_loss: If `True` scale loss inverse proportionally to + the confidence of the correct prediction. + similarity_type: Similarity measure to use, either `cosine` or `inner`. + name: Optional name of the layer. + constrain_similarities: Boolean, if `True` applies sigmoid on all + similarity terms and adds to the loss function to + ensure that similarity values are approximately bounded. + Used inside _loss_cross_entropy() only. + model_confidence: Normalization of confidence values during inference. + Currently, the only possible value is `SOFTMAX`. + """ + super().__init__( + num_candidates, + scale_loss=scale_loss, + similarity_type=similarity_type, + name=name, + constrain_similarities=constrain_similarities, + model_confidence=model_confidence, + ) + + def call( + self, + batch_inputs_embed: tf.Tensor, + batch_labels_embed: tf.Tensor, + batch_labels_ids: tf.Tensor, + all_labels_embed: tf.Tensor, + all_labels_ids: tf.Tensor, + mask: Optional[tf.Tensor] = None, + ) -> Tuple[tf.Tensor, tf.Tensor]: + """Calculates loss and accuracy. + + Args: + batch_inputs_embed: Embeddings of the batch inputs (e.g. featurized + trackers); shape `(batch_size, 1, num_features)` + batch_labels_embed: Embeddings of the batch labels (e.g. featurized intents + for IntentTED); + shape `(batch_size, max_num_labels_per_input, num_features)` + batch_labels_ids: Batch label indices (e.g. indices of the intents). We + assume that indices are integers that run from `0` to + `(number of labels) - 1`. + shape `(batch_size, max_num_labels_per_input, 1)` + all_labels_embed: Embeddings for all labels in the domain; + shape `(batch_size, num_features)` + all_labels_ids: Indices for all labels in the domain; + shape `(num_labels, 1)` + mask: Optional sequence mask, which contains `1` for inputs and `0` for + padding. + + Returns: + loss: Total loss (based on StarSpace http://arxiv.org/abs/1709.03856); + scalar + accuracy: Training accuracy; scalar + """ + ( + pos_inputs_embed, # (batch_size, 1, 1, num_features) + pos_labels_embed, # (batch_size, 1, max_num_labels_per_input, num_features) + candidate_labels_embed, # (batch_size, 1, num_candidates, num_features) + pos_neg_labels, # (batch_size, num_candidates) + ) = self._sample_candidates( + batch_inputs_embed, + batch_labels_embed, + batch_labels_ids, + all_labels_embed, + all_labels_ids, + ) + + # Calculate similarities + sim_pos, sim_candidate_il = self._train_sim( + pos_inputs_embed, pos_labels_embed, candidate_labels_embed, mask + ) + + label_padding_mask = self._construct_mask_for_label_padding( + batch_labels_ids, tf.shape(pos_neg_labels)[-1] + ) + + # Repurpose the `mask` argument of `_accuracy` and `_loss_sigmoid` + # to pass the `label_padding_mask`. We can do this right now because + # we don't use `MultiLabelDotProductLoss` for sequence tagging tasks + # yet. Hence, the `mask` argument passed to this function will always + # be empty. Whenever, we come across a use case where `mask` is + # non-empty we'll have to refactor the `_accuracy` and `_loss_sigmoid` + # functions to take into consideration both, sequence level masks as + # well as label padding masks. + + accuracy = self._accuracy( + sim_pos, sim_candidate_il, pos_neg_labels, label_padding_mask + ) + loss = self._loss_sigmoid( + sim_pos, sim_candidate_il, pos_neg_labels, mask=label_padding_mask + ) + + return loss, accuracy + + @staticmethod + def _construct_mask_for_label_padding( + batch_labels_ids: tf.Tensor, num_candidates: tf.Tensor + ) -> tf.Tensor: + """Constructs a mask which indicates indices for valid label ids. + + Indices corresponding to valid label ids have a + `1` and indices corresponding to `LABEL_PAD_ID` + have a `0`. + + Args: + batch_labels_ids: Batch label indices (e.g. indices of the intents). We + assume that indices are integers that run from `0` to + `(number of labels) - 1` with a special + value for padding which is set to `LABEL_PAD_ID`. + shape `(batch_size, max_num_labels_per_input, 1)` + num_candidates: Number of candidates sampled. + + Returns: + Constructed mask. + """ + pos_label_pad_indices = tf.cast( + tf.equal(tf.squeeze(batch_labels_ids, -1), LABEL_PAD_ID), dtype=tf.float32 + ) + + # Flip 1 and 0 to 0 and 1 respectively + pos_label_pad_mask = 1 - pos_label_pad_indices + + # `pos_label_pad_mask` only contains the mask for label ids + # seen in the batch. For sampled candidate label ids, the mask + # should be a tensor of `1`s since all candidate label ids + # are valid. From this, we construct the padding mask for + # all label ids: label ids seen in the batch + label ids sampled. + all_label_pad_mask = tf.concat( + [ + pos_label_pad_mask, + tf.ones( + (tf.shape(batch_labels_ids)[0], num_candidates), dtype=tf.float32 + ), + ], + axis=-1, + ) + + return all_label_pad_mask + + def _train_sim( + self, + pos_inputs_embed: tf.Tensor, + pos_labels_embed: tf.Tensor, + candidate_labels_embed: tf.Tensor, + mask: tf.Tensor, + ) -> Tuple[tf.Tensor, tf.Tensor]: + sim_pos = self.sim( + pos_inputs_embed, pos_labels_embed, mask + ) # (batch_size, 1, max_labels_per_input) + sim_candidate_il = self.sim( + pos_inputs_embed, candidate_labels_embed, mask + ) # (batch_size, 1, num_candidates) + + return sim_pos, sim_candidate_il + + def _sample_candidates( + self, + batch_inputs_embed: tf.Tensor, + batch_labels_embed: tf.Tensor, + batch_labels_ids: tf.Tensor, + all_labels_embed: tf.Tensor, + all_labels_ids: tf.Tensor, + ) -> Tuple[ + tf.Tensor, # (batch_size, 1, 1, num_features) + tf.Tensor, # (batch_size, 1, num_features) + tf.Tensor, # (batch_size, 1, num_candidates, num_features) + tf.Tensor, # (batch_size, num_candidates) + ]: + """Samples candidate examples. + + Args: + batch_inputs_embed: Embeddings of the batch inputs (e.g. featurized + trackers) # (batch_size, 1, num_features) + batch_labels_embed: Embeddings of the batch labels (e.g. featurized intents + for IntentTED) # (batch_size, max_num_labels_per_input, num_features) + batch_labels_ids: Batch label indices (e.g. indices of the + intents) # (batch_size, max_num_labels_per_input, 1) + all_labels_embed: Embeddings for all labels in + the domain # (num_labels, num_features) + all_labels_ids: Indices for all labels in the + domain # (num_labels, 1) + + Returns: + pos_inputs_embed: Embeddings of the batch inputs + pos_labels_embed: Embeddings of the batch labels with an extra + dimension inserted. + candidate_labels_embed: More examples of embeddings of labels, some positive + some negative + pos_neg_indicators: Indicator for which candidates are positives and which + are negatives + """ + pos_inputs_embed = tf.expand_dims( + batch_inputs_embed, axis=-2, name="expand_pos_input" + ) + + pos_labels_embed = tf.expand_dims( + batch_labels_embed, axis=1, name="expand_pos_labels" + ) + + # Pick random examples from the batch + candidate_ids = layers_utils.random_indices( + batch_size=tf.shape(batch_inputs_embed)[0], + n=self.num_neg, + n_max=tf.shape(all_labels_embed)[0], + ) + + # Get the label embeddings corresponding to candidate indices + candidate_labels_embed = layers_utils.get_candidate_values( + all_labels_embed, candidate_ids + ) + candidate_labels_embed = tf.expand_dims(candidate_labels_embed, axis=1) + + # Get binary indicators of whether a candidate is positive or not + pos_neg_indicators = self._get_pos_neg_indicators( + all_labels_ids, batch_labels_ids, candidate_ids + ) + + return ( + pos_inputs_embed, + pos_labels_embed, + candidate_labels_embed, + pos_neg_indicators, + ) + + def _get_pos_neg_indicators( + self, + all_labels_ids: tf.Tensor, + batch_labels_ids: tf.Tensor, + candidate_ids: tf.Tensor, + ) -> tf.Tensor: + """Computes indicators for which candidates are positive labels. + + Args: + all_labels_ids: Indices of all the labels + batch_labels_ids: Indices of the labels in the examples + candidate_ids: Indices of labels that may or may not appear in the examples + + Returns: + Binary indicators of whether or not a label is positive + """ + candidate_labels_ids = layers_utils.get_candidate_values( + all_labels_ids, candidate_ids + ) + candidate_labels_ids = tf.expand_dims(candidate_labels_ids, axis=1) + + # Determine how many distinct labels exist (highest label index) + max_label_id = tf.cast(tf.math.reduce_max(all_labels_ids), dtype=tf.int32) + + # Convert the positive label ids to their one_hot representation. + # Note: -1 indices yield a zeros-only vector. We use -1 as a padding token, + # as the number of positive labels in each example can differ. The padding is + # added in the TrackerFeaturizer. + batch_labels_one_hot = tf.one_hot( + tf.cast(tf.squeeze(batch_labels_ids, axis=-1), tf.int32), + max_label_id + 1, + axis=-1, + ) # (batch_size, max_num_labels_per_input, max_label_id) + + # Collapse the extra dimension and convert to a multi-hot representation + # by aggregating all ones in the one-hot representation. + # We use tf.reduce_any instead of tf.reduce_sum because several examples can + # have the same postivie label. + batch_labels_multi_hot = tf.cast( + tf.math.reduce_any(tf.cast(batch_labels_one_hot, dtype=tf.bool), axis=-2), + tf.float32, + ) # (batch_size, max_label_id) + + # Remove extra dimensions for gather + candidate_labels_ids = tf.squeeze(tf.squeeze(candidate_labels_ids, 1), -1) + + # Collect binary indicators of whether or not a label is positive + return tf.gather( + batch_labels_multi_hot, + tf.cast(candidate_labels_ids, tf.int32), + batch_dims=1, + name="gather_labels", + ) + + def _loss_sigmoid( + self, + sim_pos: tf.Tensor, # (batch_size, 1, max_num_labels_per_input) + sim_candidates_il: tf.Tensor, # (batch_size, 1, num_candidates) + pos_neg_labels: tf.Tensor, # (batch_size, num_candidates) + mask: Optional[ + tf.Tensor + ] = None, # (batch_size, max_num_labels_per_input + num_candidates) + ) -> tf.Tensor: # () + """Computes the sigmoid loss.""" + # Concatenate the guaranteed positive examples with the candidate examples, + # some of which are positives and others are negatives. Which are which + # is stored in `pos_neg_labels`. + logits = tf.concat([sim_pos, sim_candidates_il], axis=-1, name="logit_concat") + logits = tf.squeeze(logits, 1) + + # Create label_ids for sigmoid. `mask` will take care of the + # extra 1s we create as label ids for indices corresponding + # to padding ids. + pos_label_ids = tf.squeeze(tf.ones_like(sim_pos, tf.float32), 1) + label_ids = tf.concat( + [pos_label_ids, pos_neg_labels], axis=-1, name="gt_concat" + ) + + # Compute the sigmoid cross-entropy loss. When minimized, the embeddings + # for the two classes (positive and negative) are pushed away from each + # other in the embedding space, while it is allowed that any input embedding + # corresponds to more than one label. + loss = tf.nn.sigmoid_cross_entropy_with_logits(labels=label_ids, logits=logits) + + loss = self.apply_mask_and_scaling(loss, mask) + + # Average the loss over the batch + return tf.reduce_mean(loss) + + @staticmethod + def _accuracy( + sim_pos: tf.Tensor, # (batch_size, 1, max_num_labels_per_input) + sim_candidates: tf.Tensor, # (batch_size, 1, num_candidates) + pos_neg_indicators: tf.Tensor, # (batch_size, num_candidates) + mask: tf.Tensor, # (batch_size, max_num_labels_per_input + num_candidates) + ) -> tf.Tensor: # () + """Calculates the accuracy.""" + all_preds = tf.concat( + [sim_pos, sim_candidates], axis=-1, name="acc_concat_preds" + ) + all_preds_sigmoid = tf.nn.sigmoid(all_preds) + all_pred_labels = tf.squeeze(tf.math.round(all_preds_sigmoid), 1) + + # Create an indicator for the positive labels by concatenating the 1 for all + # guaranteed positive labels and the `pos_neg_indicators` + all_positives = tf.concat( + [tf.squeeze(tf.ones_like(sim_pos), axis=1), pos_neg_indicators], + axis=-1, + name="acc_concat_gt", + ) + + return layers_utils.reduce_mean_equal(all_pred_labels, all_positives, mask=mask) diff --git a/rasa/utils/tensorflow/layers_utils.py b/rasa/utils/tensorflow/layers_utils.py new file mode 100644 index 0000000..28d705c --- /dev/null +++ b/rasa/utils/tensorflow/layers_utils.py @@ -0,0 +1,113 @@ +import tensorflow as tf +from tensorflow import Tensor +from typing import Union, Optional + + +def random_indices( + batch_size: Union[Tensor, int], n: Union[Tensor, int], n_max: Union[Tensor, int] +) -> Tensor: + """Creates `batch_size * n` random indices that run from `0` to `n_max`. + + Args: + batch_size: Number of items in each batch + n: Number of random indices in each example + n_max: Maximum index (excluded) + + Returns: + A uniformly distributed integer tensor of indices + """ + return tf.random.uniform(shape=(batch_size, n), maxval=n_max, dtype=tf.int32) + + +def batch_flatten(x: Tensor) -> Tensor: + """Flattens all but last dimension of `x` so it becomes 2D. + + Args: + x: Any tensor with at least 2 dimensions + + Returns: + The reshaped tensor, where all but the last dimension + are flattened into the first dimension + """ + return tf.reshape(x, (-1, x.shape[-1])) + + +def get_candidate_values( + x: tf.Tensor, # (batch_size, ...) + candidate_ids: tf.Tensor, # (batch_size, num_candidates) +) -> tf.Tensor: + """Gathers candidate values according to IDs. + + Args: + x: Any tensor with at least one dimension + candidate_ids: Indicator for which candidates to gather + + Returns: + A tensor of shape `(batch_size, 1, num_candidates, tf.shape(x)[-1])`, where + for each batch example, we generate a list of `num_candidates` vectors, and + each candidate is chosen from `x` according to the candidate id. For example: + + ``` + x = [[0 1 2], + [3 4 5], + [6 7 8]] + candidate_ids = [[0, 1], [0, 0], [2, 0]] + gives + [ + [[0 1 2], + [3 4 5]], + [[0 1 2], + [0 1 2]], + [[6 7 8], + [0 1 2]] + ] + ``` + """ + tiled_x = tf.tile( + tf.expand_dims(batch_flatten(x), 0), (tf.shape(candidate_ids)[0], 1, 1) + ) + candidate_values = tf.gather(tiled_x, candidate_ids, batch_dims=1) + + return candidate_values # (batch_size, num_candidates, tf.shape(x)[-1]) + + +def reduce_mean_equal( + x: tf.Tensor, y: tf.Tensor, mask: Optional[tf.Tensor] = None +) -> tf.Tensor: + """Computes the mean number of matches between x and y. + + If `x` and `y` have `n` dimensions, then the mean equal + number of indices is calculated for the last dimension by + only taking the valid indices into consideration + (from the mask) and then it is averaged over all + other `n-1` dimensions. + + For e.g., if: + + x = [[1,2,3,4] + [5,6,7,8]] + y = [[1,2,3,4] + [5,6,0,0]] + mask = [[1,1,1,1], + [1,1,1,0]] + + then the output will be calculated as `((4/4) + 2/3) / 2` + + Args: + x: Any numeric tensor. + y: Another tensor with same shape and type as x. + mask: Tensor with a mask to distinguish actual indices from padding indices. + Shape should be the same as `x` and `y`. + + Returns: + The mean of "x == y" + """ + if mask is None: + mask = tf.ones_like(x, dtype=tf.float32) + + equal_indices = tf.cast(tf.math.equal(x, y), tf.float32) * mask + return tf.reduce_mean( + tf.math.divide_no_nan( + tf.reduce_sum(equal_indices, axis=-1), tf.reduce_sum(mask, axis=-1) + ) + ) diff --git a/rasa/utils/tensorflow/metrics.py b/rasa/utils/tensorflow/metrics.py new file mode 100644 index 0000000..7face21 --- /dev/null +++ b/rasa/utils/tensorflow/metrics.py @@ -0,0 +1,282 @@ +import tensorflow as tf +from tensorflow.keras import backend as K +from tensorflow.types.experimental import TensorLike +from typing import Any, Dict, Optional + + +# original code taken from +# https://github.com/tensorflow/addons/blob/f30df4322b5580b3e5946530a60f7126035dd73b/tensorflow_addons/metrics/f_scores.py +# (modified to our neeeds) + + +class FBetaScore(tf.keras.metrics.Metric): + r"""Computes F-Beta score. + + It is the weighted harmonic mean of precision + and recall. Output range is `[0, 1]`. Works for + both multi-class and multi-label classification. + + $$ + F_{\beta} = (1 + \beta^2) * \frac{\textrm{precision} * \textrm{recall}} + {(\beta^2 \cdot \textrm{precision}) + \textrm{recall}} + $$ + + Args: + num_classes: Number of unique classes in the dataset. + average: Type of averaging to be performed on data. + Acceptable values are `None`, `micro`, `macro` and + `weighted`. Default value is None. + beta: Determines the weight of precision and recall + in harmonic mean. Determines the weight given to the + precision and recall. Default value is 1. + threshold: Elements of `y_pred` greater than threshold are + converted to be 1, and the rest 0. If threshold is + None, the argmax is converted to 1, and the rest 0. + name: (Optional) String name of the metric instance. + dtype: (Optional) Data type of the metric result. + + Returns: + F-Beta Score: float. + + Raises: + ValueError: If the `average` has values other than + `[None, 'micro', 'macro', 'weighted']`. + + ValueError: If the `beta` value is less than or equal + to 0. + + `average` parameter behavior: + + None: Scores for each class are returned. + + micro: True positivies, false positives and + false negatives are computed globally. + + macro: True positivies, false positives and + false negatives are computed for each class + and their unweighted mean is returned. + + weighted: Metrics are computed for each class + and returns the mean weighted by the + number of true instances in each class. + + Usage: + + >>> metric = tfa.metrics.FBetaScore(num_classes=3, beta=2.0, threshold=0.5) + >>> y_true = np.array([[1, 1, 1], + ... [1, 0, 0], + ... [1, 1, 0]], np.int32) + >>> y_pred = np.array([[0.2, 0.6, 0.7], + ... [0.2, 0.6, 0.6], + ... [0.6, 0.8, 0.0]], np.float32) + >>> metric.update_state(y_true, y_pred) + >>> result = metric.result() + >>> result.numpy() + array([0.3846154 , 0.90909094, 0.8333334 ], dtype=float32) + """ + + def __init__( + self, + num_classes: TensorLike, + average: Optional[str] = None, + beta: TensorLike = 1.0, + threshold: Optional[TensorLike] = None, + name: str = "fbeta_score", + dtype: Any = None, + **kwargs: Any, + ) -> None: + super().__init__(name=name, dtype=dtype) + + if average not in (None, "micro", "macro", "weighted"): + raise ValueError( + "Unknown average type. Acceptable values " + "are: [None, 'micro', 'macro', 'weighted']" + ) + + if not isinstance(beta, float): + raise TypeError("The value of beta should be a python float") + + if beta <= 0.0: + raise ValueError("beta value should be greater than zero") + + if threshold is not None: + if not isinstance(threshold, float): + raise TypeError("The value of threshold should be a python float") + if threshold > 1.0 or threshold <= 0.0: + raise ValueError("threshold should be between 0 and 1") + + self.num_classes = num_classes + self.average = average + self.beta = beta + self.threshold = threshold + self.axis = None + self.init_shape = [] + + if self.average != "micro": + self.axis = 0 + self.init_shape = [self.num_classes] + + def _zero_wt_init(name: Any) -> Any: + return self.add_weight( + name, shape=self.init_shape, initializer="zeros", dtype=self.dtype + ) + + self.true_positives = _zero_wt_init("true_positives") + self.false_positives = _zero_wt_init("false_positives") + self.false_negatives = _zero_wt_init("false_negatives") + self.weights_intermediate = _zero_wt_init("weights_intermediate") + + def update_state( + self, + y_true: TensorLike, + y_pred: TensorLike, + sample_weight: Optional[TensorLike] = None, + ) -> None: + if self.threshold is None: + threshold = tf.reduce_max(y_pred, axis=-1, keepdims=True) + # make sure [0, 0, 0] doesn't become [1, 1, 1] + # Use abs(x) > eps, instead of x != 0 to check for zero + y_pred = tf.logical_and(y_pred >= threshold, tf.abs(y_pred) > 1e-12) + else: + y_pred = y_pred > self.threshold + + y_true = tf.cast(y_true, self.dtype) + y_pred = tf.cast(y_pred, self.dtype) + + def _weighted_sum( + val: TensorLike, sample_weight: Optional[TensorLike] + ) -> TensorLike: + if sample_weight is not None: + val = tf.math.multiply(val, tf.expand_dims(sample_weight, 1)) + return tf.reduce_sum(val, axis=self.axis) + + self.true_positives.assign_add(_weighted_sum(y_pred * y_true, sample_weight)) + self.false_positives.assign_add( + _weighted_sum(y_pred * (1 - y_true), sample_weight) + ) + self.false_negatives.assign_add( + _weighted_sum((1 - y_pred) * y_true, sample_weight) + ) + self.weights_intermediate.assign_add(_weighted_sum(y_true, sample_weight)) + + def result(self) -> TensorLike: + precision = tf.math.divide_no_nan( + self.true_positives, self.true_positives + self.false_positives + ) + recall = tf.math.divide_no_nan( + self.true_positives, self.true_positives + self.false_negatives + ) + + mul_value = precision * recall + add_value = (tf.math.square(self.beta) * precision) + recall + mean = tf.math.divide_no_nan(mul_value, add_value) + f1_score = mean * (1 + tf.math.square(self.beta)) + + if self.average == "weighted": + weights = tf.math.divide_no_nan( + self.weights_intermediate, tf.reduce_sum(self.weights_intermediate) + ) + f1_score = tf.reduce_sum(f1_score * weights) + + elif self.average is not None: # [micro, macro] + f1_score = tf.reduce_mean(f1_score) + + return f1_score + + def get_config(self) -> Dict[str, Any]: + """Returns the serializable config of the metric.""" + + config = { + "num_classes": self.num_classes, + "average": self.average, + "beta": self.beta, + "threshold": self.threshold, + } + + base_config = super().get_config() + return {**base_config, **config} + + def reset_state(self) -> None: + reset_value = tf.zeros(self.init_shape, dtype=self.dtype) + K.batch_set_value([(v, reset_value) for v in self.variables]) + + def reset_states(self) -> None: + # Backwards compatibility alias of `reset_state`. New classes should + # only implement `reset_state`. + # Required in Tensorflow < 2.5.0 + return self.reset_state() + + +class F1Score(FBetaScore): + r"""Computes F-1 Score. + + It is the harmonic mean of precision and recall. + Output range is `[0, 1]`. Works for both multi-class + and multi-label classification. + + $$ + F_1 = 2 \cdot \frac{\textrm{precision} \cdot \textrm{recall}}{\textrm{precision} + + \textrm{recall}} + $$ + + Args: + num_classes: Number of unique classes in the dataset. + average: Type of averaging to be performed on data. + Acceptable values are `None`, `micro`, `macro` + and `weighted`. Default value is None. + threshold: Elements of `y_pred` above threshold are + considered to be 1, and the rest 0. If threshold is + None, the argmax is converted to 1, and the rest 0. + name: (Optional) String name of the metric instance. + dtype: (Optional) Data type of the metric result. + + Returns: + F-1 Score: float. + + Raises: + ValueError: If the `average` has values other than + [None, 'micro', 'macro', 'weighted']. + + `average` parameter behavior: + None: Scores for each class are returned + + micro: True positivies, false positives and + false negatives are computed globally. + + macro: True positivies, false positives and + false negatives are computed for each class + and their unweighted mean is returned. + + weighted: Metrics are computed for each class + and returns the mean weighted by the + number of true instances in each class. + + Usage: + + >>> metric = tfa.metrics.F1Score(num_classes=3, threshold=0.5) + >>> y_true = np.array([[1, 1, 1], + ... [1, 0, 0], + ... [1, 1, 0]], np.int32) + >>> y_pred = np.array([[0.2, 0.6, 0.7], + ... [0.2, 0.6, 0.6], + ... [0.6, 0.8, 0.0]], np.float32) + >>> metric.update_state(y_true, y_pred) + >>> result = metric.result() + >>> result.numpy() + array([0.5 , 0.8 , 0.6666667], dtype=float32) + """ + + def __init__( + self, + num_classes: TensorLike, + average: str = None, + threshold: Optional[TensorLike] = None, + name: str = "f1_score", + dtype: Any = None, + ): + super().__init__(num_classes, average, 1.0, threshold, name=name, dtype=dtype) + + def get_config(self) -> Dict[str, Any]: + base_config = super().get_config() + del base_config["beta"] + return base_config diff --git a/rasa/utils/tensorflow/model_data.py b/rasa/utils/tensorflow/model_data.py new file mode 100644 index 0000000..3937569 --- /dev/null +++ b/rasa/utils/tensorflow/model_data.py @@ -0,0 +1,801 @@ +import logging +from typing import ( + Optional, + DefaultDict, + Dict, + Iterable, + Text, + List, + Tuple, + Any, + Union, + NamedTuple, + ItemsView, + overload, + cast, +) +from collections import defaultdict, OrderedDict + +import numpy as np +import scipy.sparse +from sklearn.model_selection import train_test_split + +from rasa.utils.tensorflow.feature_array import FeatureArray + +logger = logging.getLogger(__name__) + + +def ragged_array_to_ndarray(ragged_array: Iterable[np.ndarray]) -> np.ndarray: + """Converts ragged array to numpy array. + + Ragged array, also known as a jagged array, irregular array is an array of + arrays of which the member arrays can be of different lengths. + Try to convert as is (preserves type), if it fails because not all numpy arrays have + the same shape, then creates numpy array of objects. + """ + try: + return np.array(ragged_array) + except ValueError: + return np.array(ragged_array, dtype=object) + + +class FeatureSignature(NamedTuple): + """Signature of feature arrays. + + Stores the number of units, the type (sparse vs dense), and the number of + dimensions of features. + """ + + is_sparse: bool + units: Optional[int] + number_of_dimensions: int + + +# Mapping of attribute name and feature name to a list of feature arrays representing +# the actual features +# For example: +# "text" -> { "sentence": [ +# "feature array containing dense features for every training example", +# "feature array containing sparse features for every training example" +# ]} +Data = Dict[Text, Dict[Text, List[FeatureArray]]] + + +class RasaModelData: + """Data object used for all RasaModels. + + It contains all features needed to train the models. + 'data' is a mapping of attribute name, e.g. TEXT, INTENT, etc., and feature name, + e.g. SENTENCE, SEQUENCE, etc., to a list of feature arrays representing the actual + features. + 'label_key' and 'label_sub_key' point to the labels inside 'data'. For + example, if your intent labels are stored under INTENT -> IDS, 'label_key' would + be "INTENT" and 'label_sub_key' would be "IDS". + """ + + def __init__( + self, + label_key: Optional[Text] = None, + label_sub_key: Optional[Text] = None, + data: Optional[Data] = None, + ) -> None: + """Initializes the RasaModelData object. + + Args: + label_key: the key of a label used for balancing, etc. + label_sub_key: the sub key of a label used for balancing, etc. + data: the data holding the features + """ + self.data = data or defaultdict(lambda: defaultdict(list)) + self.label_key = label_key + self.label_sub_key = label_sub_key + # should be updated when features are added + self.num_examples = self.number_of_examples() + self.sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]] = {} + + @overload + def get(self, key: Text, sub_key: Text) -> List[FeatureArray]: + ... + + @overload + def get(self, key: Text, sub_key: None = ...) -> Dict[Text, List[FeatureArray]]: + ... + + def get( + self, key: Text, sub_key: Optional[Text] = None + ) -> Union[Dict[Text, List[FeatureArray]], List[FeatureArray]]: + """Get the data under the given keys. + + Args: + key: The key. + sub_key: The optional sub key. + + Returns: + The requested data. + """ + if sub_key is None and key in self.data: + return self.data[key] + + if sub_key and key in self.data and sub_key in self.data[key]: + return self.data[key][sub_key] + + return [] + + def items(self) -> ItemsView: + """Return the items of the data attribute. + + Returns: + The items of data. + """ + return self.data.items() + + def values(self) -> Any: + """Return the values of the data attribute. + + Returns: + The values of data. + """ + return self.data.values() + + def keys(self, key: Optional[Text] = None) -> List[Text]: + """Return the keys of the data attribute. + + Args: + key: The optional key. + + Returns: + The keys of the data. + """ + if key is None: + return list(self.data.keys()) + + if key in self.data: + return list(self.data[key].keys()) + + return [] + + def sort(self) -> None: + """Sorts data according to its keys.""" + for key, attribute_data in self.data.items(): + self.data[key] = OrderedDict(sorted(attribute_data.items())) + self.data = OrderedDict(sorted(self.data.items())) + + def first_data_example(self) -> Data: + """Return the data with just one feature example per key, sub-key. + + Returns: + The simplified data. + """ + out_data: Data = {} + for key, attribute_data in self.data.items(): + out_data[key] = {} + for sub_key, features in attribute_data.items(): + feature_slices = [feature[:1] for feature in features] + out_data[key][sub_key] = cast(List[FeatureArray], feature_slices) + return out_data + + def does_feature_exist(self, key: Text, sub_key: Optional[Text] = None) -> bool: + """Check if feature key (and sub-key) is present and features are available. + + Args: + key: The key. + sub_key: The optional sub-key. + + Returns: + False, if no features for the given keys exists, True otherwise. + """ + return not self.does_feature_not_exist(key, sub_key) + + def does_feature_not_exist(self, key: Text, sub_key: Optional[Text] = None) -> bool: + """Check if feature key (and sub-key) is present and features are available. + + Args: + key: The key. + sub_key: The optional sub-key. + + Returns: + True, if no features for the given keys exists, False otherwise. + """ + if sub_key: + return ( + key not in self.data + or not self.data[key] + or sub_key not in self.data[key] + or not self.data[key][sub_key] + ) + + return key not in self.data or not self.data[key] + + def is_empty(self) -> bool: + """Checks if data is set.""" + + return not self.data + + def number_of_examples(self, data: Optional[Data] = None) -> int: + """Obtain number of examples in data. + + Args: + data: The data. + + Raises: A ValueError if number of examples differ for different features. + + Returns: + The number of examples in data. + """ + if not data: + data = self.data + + if not data: + return 0 + + example_lengths = [ + len(f) + for attribute_data in data.values() + for features in attribute_data.values() + for f in features + ] + + if not example_lengths: + return 0 + + # check if number of examples is the same for all values + if not all(length == example_lengths[0] for length in example_lengths): + raise ValueError( + f"Number of examples differs for keys '{data.keys()}'. Number of " + f"examples should be the same for all data." + ) + + return example_lengths[0] + + def number_of_units(self, key: Text, sub_key: Text) -> int: + """Get the number of units of the given key. + + Args: + key: The key. + sub_key: The optional sub-key. + + Returns: + The number of units. + """ + if key not in self.data or sub_key not in self.data[key]: + return 0 + + units = 0 + for features in self.data[key][sub_key]: + if len(features) > 0: + units += features.units # type: ignore[operator] + + return units + + def add_data(self, data: Data, key_prefix: Optional[Text] = None) -> None: + """Add incoming data to data. + + Args: + data: The data to add. + key_prefix: Optional key prefix to use in front of the key value. + """ + for key, attribute_data in data.items(): + for sub_key, features in attribute_data.items(): + if key_prefix: + self.add_features(f"{key_prefix}{key}", sub_key, features) + else: + self.add_features(key, sub_key, features) + + def update_key( + self, from_key: Text, from_sub_key: Text, to_key: Text, to_sub_key: Text + ) -> None: + """Copies the features under the given keys to the new keys and deletes the old. + + Args: + from_key: current feature key + from_sub_key: current feature sub-key + to_key: new key for feature + to_sub_key: new sub-key for feature + """ + if from_key not in self.data or from_sub_key not in self.data[from_key]: + return + + if to_key not in self.data: + self.data[to_key] = {} + self.data[to_key][to_sub_key] = self.get(from_key, from_sub_key) + del self.data[from_key][from_sub_key] + + if not self.data[from_key]: + del self.data[from_key] + + def add_features( + self, key: Text, sub_key: Text, features: Optional[List[FeatureArray]] + ) -> None: + """Add list of features to data under specified key. + + Should update number of examples. + + Args: + key: The key + sub_key: The sub-key + features: The features to add. + """ + if features is None: + return + + for feature_array in features: + if len(feature_array) > 0: + self.data[key][sub_key].append(feature_array) + + if not self.data[key][sub_key]: + del self.data[key][sub_key] + + # update number of examples + self.num_examples = self.number_of_examples() + + def add_lengths( + self, key: Text, sub_key: Text, from_key: Text, from_sub_key: Text + ) -> None: + """Adds a feature array of lengths of sequences to data under given key. + + Args: + key: The key to add the lengths to + sub_key: The sub-key to add the lengths to + from_key: The key to take the lengths from + from_sub_key: The sub-key to take the lengths from + """ + if not self.data.get(from_key) or not self.data.get(from_key, {}).get( + from_sub_key + ): + return + + self.data[key][sub_key] = [] + + for features in self.data[from_key][from_sub_key]: + if len(features) == 0: + continue + + if features.number_of_dimensions == 4: + lengths = FeatureArray( + ragged_array_to_ndarray( + [ + # add one more dim so that dialogue dim + # would be a sequence + np.array([[[x.shape[0]]] for x in _features]) + for _features in features + ] + ), + number_of_dimensions=4, + ) + else: + lengths = FeatureArray( + np.array([x.shape[0] for x in features]), number_of_dimensions=1 + ) + self.data[key][sub_key].extend([lengths]) + break + + def add_sparse_feature_sizes( + self, sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]] + ) -> None: + """Adds a dictionary of feature sizes for different attributes. + + Args: + sparse_feature_sizes: a dictionary of attribute that has sparse + features to a dictionary of a feature type + to a list of different sparse feature sizes. + """ + self.sparse_feature_sizes = sparse_feature_sizes + + def get_sparse_feature_sizes(self) -> Dict[Text, Dict[Text, List[int]]]: + """Get feature sizes of the model. + + sparse_feature_sizes is a dictionary of attribute that has sparse features to + a dictionary of a feature type to a list of different sparse feature sizes. + + Returns: + A dictionary of key and sub-key to a list of feature signatures + (same structure as the data attribute). + """ + return self.sparse_feature_sizes + + def split( + self, number_of_test_examples: int, random_seed: int + ) -> Tuple["RasaModelData", "RasaModelData"]: + """Create random hold out test set using stratified split. + + Args: + number_of_test_examples: Number of test examples. + random_seed: Random seed. + + Returns: + A tuple of train and test RasaModelData. + """ + self._check_label_key() + + if self.label_key is None or self.label_sub_key is None: + # randomly split data as no label key is set + multi_values = [ + v + for attribute_data in self.data.values() + for data in attribute_data.values() + for v in data + ] + solo_values: List[Any] = [ + [] + for attribute_data in self.data.values() + for data in attribute_data.values() + for _ in data + ] + stratify = None + else: + # make sure that examples for each label value are in both split sets + label_ids = self._create_label_ids( + self.data[self.label_key][self.label_sub_key][0] + ) + label_counts: Dict[int, int] = dict( + zip( + *np.unique( + label_ids, + return_counts=True, + axis=0, + ) + ) + ) + + self._check_train_test_sizes(number_of_test_examples, label_counts) + + counts = np.array([label_counts[label] for label in label_ids]) + # we perform stratified train test split, + # which insures every label is present in the train and test data + # this operation can be performed only for labels + # that contain several data points + multi_values = [ + f[counts > 1].view(FeatureArray) + for attribute_data in self.data.values() + for features in attribute_data.values() + for f in features + ] + # collect data points that are unique for their label + solo_values = [ + f[counts == 1] + for attribute_data in self.data.values() + for features in attribute_data.values() + for f in features + ] + + stratify = label_ids[counts > 1] + + output_values = train_test_split( + *multi_values, + test_size=number_of_test_examples, + random_state=random_seed, + stratify=stratify, + ) + + return self._convert_train_test_split(output_values, solo_values) + + def get_signature( + self, data: Optional[Data] = None + ) -> Dict[Text, Dict[Text, List[FeatureSignature]]]: + """Get signature of RasaModelData. + + Signature stores the shape and whether features are sparse or not for every key. + + Returns: + A dictionary of key and sub-key to a list of feature signatures + (same structure as the data attribute). + """ + if not data: + data = self.data + + return { + key: { + sub_key: [ + FeatureSignature(f.is_sparse, f.units, f.number_of_dimensions) + for f in features + ] + for sub_key, features in attribute_data.items() + } + for key, attribute_data in data.items() + } + + def shuffled_data(self, data: Data) -> Data: + """Shuffle model data. + + Args: + data: The data to shuffle + + Returns: + The shuffled data. + """ + ids = np.random.permutation(self.num_examples) + return self._data_for_ids(data, ids) + + def balanced_data(self, data: Data, batch_size: int, shuffle: bool) -> Data: + """Mix model data to account for class imbalance. + + This batching strategy puts rare classes approximately in every other batch, + by repeating them. Mimics stratified batching, but also takes into account + that more populated classes should appear more often. + + Args: + data: The data. + batch_size: The batch size. + shuffle: Boolean indicating whether to shuffle the data or not. + + Returns: + The balanced data. + """ + self._check_label_key() + + # skip balancing if labels are token based + if ( + self.label_key is None + or self.label_sub_key is None + or data[self.label_key][self.label_sub_key][0][0].size > 1 + ): + return data + + label_ids = self._create_label_ids(data[self.label_key][self.label_sub_key][0]) + + unique_label_ids, counts_label_ids = np.unique( + label_ids, return_counts=True, axis=0 + ) + num_label_ids = len(unique_label_ids) + + # group data points by their label + # need to call every time, so that the data is shuffled inside each class + data_by_label = self._split_by_label_ids(data, label_ids, unique_label_ids) + + # running index inside each data grouped by labels + data_idx = [0] * num_label_ids + # number of cycles each label was passed + num_data_cycles = [0] * num_label_ids + # if a label was skipped in current batch + skipped = [False] * num_label_ids + + new_data: DefaultDict[ + Text, DefaultDict[Text, List[List[FeatureArray]]] + ] = defaultdict(lambda: defaultdict(list)) + + while min(num_data_cycles) == 0: + if shuffle: + indices_of_labels = np.random.permutation(num_label_ids) + else: + indices_of_labels = np.asarray(range(num_label_ids)) + + for index in indices_of_labels: + if num_data_cycles[index] > 0 and not skipped[index]: + skipped[index] = True + continue + + skipped[index] = False + + index_batch_size = ( + int(counts_label_ids[index] / self.num_examples * batch_size) + 1 + ) + + for key, attribute_data in data_by_label[index].items(): + for sub_key, features in attribute_data.items(): + for i, f in enumerate(features): + if len(new_data[key][sub_key]) < i + 1: + new_data[key][sub_key].append([]) + new_data[key][sub_key][i].append( + f[data_idx[index] : data_idx[index] + index_batch_size] + ) + + data_idx[index] += index_batch_size + if data_idx[index] >= counts_label_ids[index]: + num_data_cycles[index] += 1 + data_idx[index] = 0 + + if min(num_data_cycles) > 0: + break + + final_data: Data = defaultdict(lambda: defaultdict(list)) + for key, attribute_data in new_data.items(): + for sub_key, features in attribute_data.items(): + for f in features: + final_data[key][sub_key].append( + FeatureArray( + np.concatenate(f), + number_of_dimensions=f[0].number_of_dimensions, + ) + ) + + return final_data + + def _check_train_test_sizes( + self, number_of_test_examples: int, label_counts: Dict[Any, int] + ) -> None: + """Check whether the test data set is too large or too small. + + Args: + number_of_test_examples: number of test examples + label_counts: number of labels + + Raises: + A ValueError if the number of examples does not fit. + """ + if number_of_test_examples >= self.num_examples - len(label_counts): + raise ValueError( + f"Test set of {number_of_test_examples} is too large. Remaining " + f"train set should be at least equal to number of classes " + f"{len(label_counts)}." + ) + if number_of_test_examples < len(label_counts): + raise ValueError( + f"Test set of {number_of_test_examples} is too small. It should " + f"be at least equal to number of classes {label_counts}." + ) + + @staticmethod + def _data_for_ids(data: Optional[Data], ids: np.ndarray) -> Data: + """Filter model data by ids. + + Args: + data: The data to filter + ids: The ids + + Returns: + The filtered data + """ + new_data: Data = defaultdict(lambda: defaultdict(list)) + + if data is None: + return new_data + + for key, attribute_data in data.items(): + for sub_key, features in attribute_data.items(): + for f in features: + new_data[key][sub_key].append(f[ids]) + return new_data + + def _split_by_label_ids( + self, data: Optional[Data], label_ids: np.ndarray, unique_label_ids: np.ndarray + ) -> List["RasaModelData"]: + """Reorganize model data into a list of model data with the same labels. + + Args: + data: The data + label_ids: The label ids + unique_label_ids: The unique label ids + + Returns: + Reorganized RasaModelData + """ + label_data = [] + for label_id in unique_label_ids: + matching_ids = np.array(label_ids) == label_id + label_data.append( + RasaModelData( + self.label_key, + self.label_sub_key, + self._data_for_ids(data, matching_ids), + ) + ) + return label_data + + def _check_label_key(self) -> None: + """Check if the label key exists. + + Raises: + ValueError if the label key and sub-key is not in data. + """ + if ( + self.label_key is not None + and self.label_sub_key is not None + and ( + self.label_key not in self.data + or self.label_sub_key not in self.data[self.label_key] + or len(self.data[self.label_key][self.label_sub_key]) > 1 + ) + ): + raise ValueError( + f"Key '{self.label_key}.{self.label_sub_key}' not in RasaModelData." + ) + + def _convert_train_test_split( + self, output_values: List[Any], solo_values: List[Any] + ) -> Tuple["RasaModelData", "RasaModelData"]: + """Converts the output of sklearn's train_test_split into model data. + + Args: + output_values: output values of sklearn's train_test_split + solo_values: list of solo values + + Returns: + The test and train RasaModelData + """ + data_train: DefaultDict[ + Text, DefaultDict[Text, List[FeatureArray]] + ] = defaultdict(lambda: defaultdict(list)) + data_val: DefaultDict[Text, DefaultDict[Text, List[Any]]] = defaultdict( + lambda: defaultdict(list) + ) + + # output_values = x_train, x_val, y_train, y_val, z_train, z_val, etc. + # order is kept, e.g. same order as model data keys + + # train datasets have an even index + index = 0 + for key, attribute_data in self.data.items(): + for sub_key, features in attribute_data.items(): + for f in features: + data_train[key][sub_key].append( + self._combine_features( + output_values[index * 2], + solo_values[index], + f.number_of_dimensions, + ) + ) + index += 1 + + # val datasets have an odd index + index = 0 + for key, attribute_data in self.data.items(): + for sub_key, features in attribute_data.items(): + for _ in features: + data_val[key][sub_key].append(output_values[(index * 2) + 1]) + index += 1 + + return ( + RasaModelData(self.label_key, self.label_sub_key, data_train), + RasaModelData(self.label_key, self.label_sub_key, data_val), + ) + + @staticmethod + def _combine_features( + feature_1: Union[np.ndarray, scipy.sparse.spmatrix], + feature_2: Union[np.ndarray, scipy.sparse.spmatrix], + number_of_dimensions: Optional[int] = 1, + ) -> FeatureArray: + """Concatenate features. + + Args: + feature_1: Features to concatenate. + feature_2: Features to concatenate. + + Returns: + The combined features. + """ + if isinstance(feature_1, scipy.sparse.spmatrix) and isinstance( + feature_2, scipy.sparse.spmatrix + ): + if feature_2.shape[0] == 0: + return FeatureArray(feature_1, number_of_dimensions) + if feature_1.shape[0] == 0: + return FeatureArray(feature_2, number_of_dimensions) + return FeatureArray( + scipy.sparse.vstack([feature_1, feature_2]), number_of_dimensions + ) + return FeatureArray( + np.concatenate([feature_1, feature_2]), + number_of_dimensions, + ) + + @staticmethod + def _create_label_ids(label_ids: FeatureArray) -> np.ndarray: + """Convert various size label_ids into single dim array. + + For multi-label y, map each distinct row to a string representation + using join because str(row) uses an ellipsis if len(row) > 1000. + Idea taken from sklearn's stratify split. + + Args: + label_ids: The label ids. + + Raises: + ValueError if dimensionality of label ids is not supported + + Returns: + The single dim label array. + """ + if label_ids.ndim == 1: + return label_ids + + if label_ids.ndim == 2 and label_ids.shape[-1] == 1: + return label_ids[:, 0] + + if label_ids.ndim == 2: + return np.array([" ".join(row.astype("str")) for row in label_ids]) + + if label_ids.ndim == 3 and label_ids.shape[-1] == 1: + return np.array([" ".join(row.astype("str")) for row in label_ids[:, :, 0]]) + + raise ValueError("Unsupported label_ids dimensions") diff --git a/rasa/utils/tensorflow/model_data_utils.py b/rasa/utils/tensorflow/model_data_utils.py new file mode 100644 index 0000000..bb15e93 --- /dev/null +++ b/rasa/utils/tensorflow/model_data_utils.py @@ -0,0 +1,500 @@ +import typing +import copy +import numpy as np +import scipy.sparse +from collections import defaultdict, OrderedDict +from typing import List, Optional, Text, Dict, Tuple, Union, Any, DefaultDict, cast + +from rasa.nlu.constants import TOKENS_NAMES +from rasa.utils.tensorflow.model_data import Data, FeatureArray, ragged_array_to_ndarray +from rasa.utils.tensorflow.constants import MASK, IDS +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.constants import ( + TEXT, + ENTITIES, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_GROUP, + ENTITY_ATTRIBUTE_ROLE, +) + +if typing.TYPE_CHECKING: + from rasa.shared.nlu.training_data.features import Features + from rasa.nlu.extractors.extractor import EntityTagSpec + +TAG_ID_ORIGIN = "tag_id_origin" + + +def featurize_training_examples( + training_examples: List[Message], + attributes: List[Text], + entity_tag_specs: Optional[List["EntityTagSpec"]] = None, + featurizers: Optional[List[Text]] = None, + bilou_tagging: bool = False, +) -> Tuple[List[Dict[Text, List["Features"]]], Dict[Text, Dict[Text, List[int]]]]: + """Converts training data into a list of attribute to features. + + Possible attributes are, for example, INTENT, RESPONSE, TEXT, ACTION_TEXT, + ACTION_NAME or ENTITIES. + Also returns sparse feature sizes for each attribute. It could look like this: + {TEXT: {FEATURE_TYPE_SEQUENCE: [16, 32], FEATURE_TYPE_SENTENCE: [16, 32]}}. + + Args: + training_examples: the list of training examples + attributes: the attributes to consider + entity_tag_specs: the entity specs + featurizers: the featurizers to consider + bilou_tagging: indicates whether BILOU tagging should be used or not + + Returns: + A list of attribute to features. + A dictionary of attribute to feature sizes. + """ + output = [] + if not entity_tag_specs: + entity_tag_specs = [] + + for example in training_examples: + attribute_to_features: Dict[Text, List["Features"]] = {} + for attribute in attributes: + if attribute == ENTITIES: + attribute_to_features[attribute] = [] + # in case of entities add the tag_ids + for tag_spec in entity_tag_specs: + attribute_to_features[attribute].append( + get_tag_ids(example, tag_spec, bilou_tagging) + ) + elif attribute in example.data: + attribute_to_features[attribute] = example.get_all_features( + attribute, featurizers + ) + output.append(attribute_to_features) + + sparse_feature_sizes = {} + if output and training_examples: + sparse_feature_sizes = _collect_sparse_feature_sizes( + featurized_example=output[0], + training_example=training_examples[0], + featurizers=featurizers, + ) + return output, sparse_feature_sizes + + +def _collect_sparse_feature_sizes( + featurized_example: Dict[Text, List["Features"]], + training_example: Message, + featurizers: Optional[List[Text]] = None, +) -> Dict[Text, Dict[Text, List[int]]]: + """Collects sparse feature sizes for all attributes that have sparse features. + + Returns sparse feature sizes for each attribute. It could look like this: + {TEXT: {FEATURE_TYPE_SEQUENCE: [16, 32], FEATURE_TYPE_SENTENCE: [16, 32]}}. + + Args: + featurized_example: a featurized example + training_example: a training example + featurizers: the featurizers to consider + + Returns: + A dictionary of attribute to feature sizes. + """ + sparse_feature_sizes = {} + sparse_attributes = [] + for attribute, features in featurized_example.items(): + if features and features[0].is_sparse(): + sparse_attributes.append(attribute) + for attribute in sparse_attributes: + sparse_feature_sizes[attribute] = training_example.get_sparse_feature_sizes( + attribute=attribute, featurizers=featurizers + ) + return sparse_feature_sizes + + +def get_tag_ids( + example: Message, tag_spec: "EntityTagSpec", bilou_tagging: bool +) -> "Features": + """Creates a feature array containing the entity tag ids of the given example. + + Args: + example: the message + tag_spec: entity tag spec + bilou_tagging: indicates whether BILOU tagging should be used or not + + Returns: + A list of features. + """ + from rasa.nlu.test import determine_token_labels + from rasa.nlu.utils.bilou_utils import bilou_tags_to_ids + from rasa.shared.nlu.training_data.features import Features + + if bilou_tagging: + _tags = bilou_tags_to_ids(example, tag_spec.tags_to_ids, tag_spec.tag_name) + else: + _tags = [] + for token in example.get(TOKENS_NAMES[TEXT]): + _tag = determine_token_labels( + token, example.get(ENTITIES), attribute_key=tag_spec.tag_name + ) + _tags.append(tag_spec.tags_to_ids[_tag]) + + # transpose to have seq_len x 1 + return Features(np.array([_tags]).T, IDS, tag_spec.tag_name, TAG_ID_ORIGIN) + + +def _surface_attributes( + features: List[List[Dict[Text, List["Features"]]]], + featurizers: Optional[List[Text]] = None, +) -> DefaultDict[Text, List[List[Optional[List["Features"]]]]]: + """Restructure the input. + + "features" can, for example, be a dictionary of attributes (INTENT, + TEXT, ACTION_NAME, ACTION_TEXT, ENTITIES, SLOTS, FORM) to a list of features for + all dialogue turns in all training trackers. + For NLU training it would just be a dictionary of attributes (either INTENT or + RESPONSE, TEXT, and potentially ENTITIES) to a list of features for all training + examples. + + The incoming "features" contain a dictionary as inner most value. This method + surfaces this dictionary, so that it becomes the outer most value. + + Args: + features: a dictionary of attributes to a list of features for all + examples in the training data + featurizers: the featurizers to consider + + Returns: + A dictionary of attributes to a list of features for all examples. + """ + # collect all attributes + attributes = set( + attribute + for list_of_attribute_to_features in features + for attribute_to_features in list_of_attribute_to_features + for attribute in attribute_to_features.keys() + ) + + output = defaultdict(list) + for list_of_attribute_to_features in features: + intermediate_features = defaultdict(list) + for attribute_to_features in list_of_attribute_to_features: + for attribute in attributes: + attribute_features = attribute_to_features.get(attribute) + if featurizers: + attribute_features = _filter_features( + attribute_features, featurizers + ) + + # if attribute is not present in the example, populate it with None + intermediate_features[attribute].append(attribute_features) + + for key, collection_of_feature_collections in intermediate_features.items(): + output[key].append(collection_of_feature_collections) + + return output + + +def _filter_features( + features: Optional[List["Features"]], featurizers: List[Text] +) -> Optional[List["Features"]]: + """Filter the given features. + + Return only those features that are coming from one of the given featurizers. + + Args: + features: list of features + featurizers: names of featurizers to consider + + Returns: + The filtered list of features. + """ + if features is None or not featurizers: + return features + + # it might be that the list of features also contains some tag_ids + # the origin of the tag_ids is set to TAG_ID_ORIGIN + # add TAG_ID_ORIGIN to the list of featurizers to make sure that we keep the + # tag_ids + featurizers.append(TAG_ID_ORIGIN) + + # filter the features + return [f for f in features if f.origin in featurizers] + + +def _create_fake_features( + all_features: List[List[List["Features"]]], +) -> List["Features"]: + """Computes default feature values. + + All given features should have the same type, e.g. dense or sparse. + + Args: + all_features: list containing all feature values encountered in the dataset + for an attribute. + + Returns: + The default features + """ + example_features = next( + iter( + [ + list_of_features + for list_of_list_of_features in all_features + for list_of_features in list_of_list_of_features + if list_of_features is not None + ] + ) + ) + + # create fake_features for Nones + fake_features = [] + for _features in example_features: + new_features = copy.deepcopy(_features) + if _features.is_dense(): + new_features.features = np.zeros( + (0, _features.features.shape[-1]), _features.features.dtype + ) + if _features.is_sparse(): + new_features.features = scipy.sparse.coo_matrix( + (0, _features.features.shape[-1]), _features.features.dtype + ) + fake_features.append(new_features) + + return fake_features + + +def convert_to_data_format( + features: Union[ + List[List[Dict[Text, List["Features"]]]], List[Dict[Text, List["Features"]]] + ], + fake_features: Optional[Dict[Text, List["Features"]]] = None, + consider_dialogue_dimension: bool = True, + featurizers: Optional[List[Text]] = None, +) -> Tuple[Data, Dict[Text, List["Features"]]]: + """Converts the input into "Data" format. + + "features" can, for example, be a dictionary of attributes (INTENT, + TEXT, ACTION_NAME, ACTION_TEXT, ENTITIES, SLOTS, FORM) to a list of features for + all dialogue turns in all training trackers. + For NLU training it would just be a dictionary of attributes (either INTENT or + RESPONSE, TEXT, and potentially ENTITIES) to a list of features for all training + examples. + + The "Data" format corresponds to Dict[Text, Dict[Text, List[FeatureArray]]]. It's + a dictionary of attributes (e.g. TEXT) to a dictionary of secondary attributes + (e.g. SEQUENCE or SENTENCE) to the list of actual features. + + Args: + features: a dictionary of attributes to a list of features for all + examples in the training data + fake_features: Contains default feature values for attributes + consider_dialogue_dimension: If set to false the dialogue dimension will be + removed from the resulting sequence features. + featurizers: the featurizers to consider + + Returns: + Input in "Data" format and fake features + """ + training = False + if not fake_features: + training = True + fake_features = defaultdict(list) + + # unify format of incoming features + if isinstance(features[0], Dict): + features = cast( + List[List[Dict[Text, List["Features"]]]], [[dicts] for dicts in features] + ) + + attribute_to_features = _surface_attributes(features, featurizers) + + attribute_data = {} + + # During prediction we need to iterate over the fake features attributes to + + # have all keys in the resulting model data + if training: + attributes = list(attribute_to_features.keys()) + else: + attributes = list(fake_features.keys()) + + # In case an attribute is not present during prediction, replace it with + # None values that will then be replaced by fake features + dialogue_length = 1 + num_examples = 1 + for _features in attribute_to_features.values(): + num_examples = max(num_examples, len(_features)) + dialogue_length = max(dialogue_length, len(_features[0])) + absent_features = [[None] * dialogue_length] * num_examples + + for attribute in attributes: + attribute_data[attribute] = _feature_arrays_for_attribute( + attribute, + absent_features, + attribute_to_features, + training, + fake_features, + consider_dialogue_dimension, + ) + + # ensure that all attributes are in the same order + attribute_data = OrderedDict(sorted(attribute_data.items())) + + return attribute_data, fake_features + + +def _feature_arrays_for_attribute( + attribute: Text, + absent_features: List[Any], + attribute_to_features: Dict[Text, List[List[List["Features"]]]], + training: bool, + fake_features: Dict[Text, List["Features"]], + consider_dialogue_dimension: bool, +) -> Dict[Text, List[FeatureArray]]: + """Create the features for the given attribute from the all examples features. + + Args: + attribute: the attribute of Message to be featurized + absent_features: list of Nones, used as features if `attribute_to_features` + does not contain the `attribute` + attribute_to_features: features for every example + training: boolean indicating whether we are currently in training or not + fake_features: zero features + consider_dialogue_dimension: If set to false the dialogue dimension will be + removed from the resulting sequence features. + + Returns: + A dictionary of feature type to actual features for the given attribute. + """ + features = ( + attribute_to_features[attribute] + if attribute in attribute_to_features + else absent_features + ) + + # in case some features for a specific attribute are + # missing, replace them with a feature vector of zeros + if training: + fake_features[attribute] = _create_fake_features(features) + + (attribute_masks, _dense_features, _sparse_features) = _extract_features( + features, fake_features[attribute], attribute + ) + + sparse_features = {} + dense_features = {} + + for key, values in _sparse_features.items(): + if consider_dialogue_dimension: + sparse_features[key] = FeatureArray( + ragged_array_to_ndarray(values), number_of_dimensions=4 + ) + else: + sparse_features[key] = FeatureArray( + ragged_array_to_ndarray([v[0] for v in values]), number_of_dimensions=3 + ) + + for key, values in _dense_features.items(): + if consider_dialogue_dimension: + dense_features[key] = FeatureArray( + ragged_array_to_ndarray(values), number_of_dimensions=4 + ) + else: + dense_features[key] = FeatureArray( + ragged_array_to_ndarray([v[0] for v in values]), number_of_dimensions=3 + ) + attribute_to_feature_arrays = { + MASK: [ + FeatureArray( + ragged_array_to_ndarray(attribute_masks), number_of_dimensions=3 + ) + ] + } + + feature_types = set() + feature_types.update(list(dense_features.keys())) + feature_types.update(list(sparse_features.keys())) + + for feature_type in feature_types: + attribute_to_feature_arrays[feature_type] = [] + if feature_type in sparse_features: + attribute_to_feature_arrays[feature_type].append( + sparse_features[feature_type] + ) + if feature_type in dense_features: + attribute_to_feature_arrays[feature_type].append( + dense_features[feature_type] + ) + + return attribute_to_feature_arrays + + +def _extract_features( + features: List[List[List["Features"]]], + fake_features: List["Features"], + attribute: Text, +) -> Tuple[ + List[np.ndarray], + Dict[Text, List[List[np.ndarray]]], + Dict[Text, List[List[scipy.sparse.spmatrix]]], +]: + """Create masks for feature attributes and split into dense and sparse features. + + Args: + features: all features + fake_features: list of zero features + + Returns: + - a list of attribute masks + - a map of attribute to dense features + - a map of attribute to sparse features + """ + sparse_features = defaultdict(list) + dense_features = defaultdict(list) + attribute_masks = [] + + for list_of_list_of_features in features: + dialogue_sparse_features = defaultdict(list) + dialogue_dense_features = defaultdict(list) + + # create a mask for every state + # to capture which turn has which input + attribute_mask = np.ones(len(list_of_list_of_features), np.float32) + + for i, list_of_features in enumerate(list_of_list_of_features): + + if list_of_features is None: + # use zero features and set mask to zero + attribute_mask[i] = 0 + list_of_features = fake_features + + for feature in list_of_features: + # in case of ENTITIES, if the attribute type matches either 'entity', + # 'role', or 'group' the features correspond to the tag ids of that + # entity type in order to distinguish later on between the different + # tag ids, we use the entity type as key + if attribute == ENTITIES and feature.attribute in [ + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_GROUP, + ENTITY_ATTRIBUTE_ROLE, + ]: + key = feature.attribute + else: + key = feature.type + + # all features should have the same types + if feature.is_sparse(): + dialogue_sparse_features[key].append(feature.features) + else: + dialogue_dense_features[key].append(feature.features) + + for key, value in dialogue_sparse_features.items(): + sparse_features[key].append(value) + for key, value in dialogue_dense_features.items(): + dense_features[key].append(value) + + # add additional dimension to attribute mask + # to get a vector of shape (dialogue length x 1), + # the batch dim will be added later + attribute_mask = np.expand_dims(attribute_mask, -1) + attribute_masks.append(attribute_mask) + + return attribute_masks, dense_features, sparse_features diff --git a/rasa/utils/tensorflow/models.py b/rasa/utils/tensorflow/models.py new file mode 100644 index 0000000..ca2ad9f --- /dev/null +++ b/rasa/utils/tensorflow/models.py @@ -0,0 +1,934 @@ +import time +import random +import tensorflow as tf +import numpy as np +import logging +import os +from collections import defaultdict +from typing import List, Text, Dict, Tuple, Union, Optional, Any, TYPE_CHECKING + +from keras.utils import tf_utils +from keras import Model + +from rasa.shared.constants import DIAGNOSTIC_DATA +from rasa.utils.tensorflow.constants import ( + LABEL, + IDS, + INTENT_CLASSIFICATION, + SENTENCE, + SEQUENCE_LENGTH, + RANDOM_SEED, + EMBEDDING_DIMENSION, + REGULARIZATION_CONSTANT, + SIMILARITY_TYPE, + CONNECTION_DENSITY, + NUM_NEG, + LOSS_TYPE, + MAX_POS_SIM, + MAX_NEG_SIM, + USE_MAX_NEG_SIM, + NEGATIVE_MARGIN_SCALE, + SCALE_LOSS, + LEARNING_RATE, + CONSTRAIN_SIMILARITIES, + MODEL_CONFIDENCE, + RUN_EAGERLY, +) +from rasa.utils.tensorflow.model_data import ( + RasaModelData, + FeatureSignature, + FeatureArray, +) +import rasa.utils.train_utils +from rasa.utils.tensorflow import layers +from rasa.utils.tensorflow import rasa_layers +from rasa.utils.tensorflow.data_generator import ( + RasaDataGenerator, + RasaBatchDataGenerator, +) +from rasa.shared.nlu.constants import TEXT +from rasa.shared.exceptions import RasaException +from rasa.utils.tensorflow.types import BatchData, MaybeNestedBatchData + +if TYPE_CHECKING: + from tensorflow.python.types.core import GenericFunction + +logger = logging.getLogger(__name__) + +LABEL_KEY = LABEL +LABEL_SUB_KEY = IDS + + +# noinspection PyMethodOverriding +class RasaModel(Model): + """Abstract custom Keras model. + + This model overwrites the following methods: + - train_step + - test_step + - predict_step + - save + - load + Cannot be used as tf.keras.Model. + """ + + _training: Optional[bool] + + def __init__(self, random_seed: Optional[int] = None, **kwargs: Any) -> None: + """Initialize the RasaModel. + + Args: + random_seed: set the random seed to get reproducible results + """ + # make sure that keras releases resources from previously trained model + tf.keras.backend.clear_session() + super().__init__(**kwargs) + + self.total_loss = tf.keras.metrics.Mean(name="t_loss") + self.metrics_to_log = ["t_loss"] + + self._training = None # training phase should be defined when building a graph + + if random_seed is None: + random_seed = int(time.time()) + self.random_seed = random_seed + self._set_random_seed() + + self._tf_predict_step: Optional["GenericFunction"] = None + self.prepared_for_prediction = False + + self._checkpoint = tf.train.Checkpoint(model=self) + + def _set_random_seed(self) -> None: + random.seed(self.random_seed) + np.random.seed(self.random_seed) + tf.random.set_seed(self.random_seed) + tf.experimental.numpy.random.seed(self.random_seed) + tf.keras.utils.set_random_seed(self.random_seed) + # Set a fixed value for the hash seed + os.environ["PYTHONHASHSEED"] = str(self.random_seed) + + def batch_loss( + self, batch_in: Union[Tuple[tf.Tensor, ...], Tuple[np.ndarray, ...]] + ) -> tf.Tensor: + """Calculates the loss for the given batch. + + Args: + batch_in: The batch. + + Returns: + The loss of the given batch. + """ + raise NotImplementedError + + def prepare_for_predict(self) -> None: + """Prepares tf graph fpr prediction. + + This method should contain necessary tf calculations + and set self variables that are used in `batch_predict`. + For example, pre calculation of `self.all_labels_embed`. + """ + pass + + def batch_predict( + self, batch_in: Union[Tuple[tf.Tensor, ...], Tuple[np.ndarray, ...]] + ) -> Dict[Text, Union[tf.Tensor, Dict[Text, tf.Tensor]]]: + """Predicts the output of the given batch. + + Args: + batch_in: The batch. + + Returns: + The output to predict. + """ + raise NotImplementedError + + def train_step( + self, batch_in: Union[Tuple[tf.Tensor, ...], Tuple[np.ndarray, ...]] + ) -> Dict[Text, float]: + """Performs a train step using the given batch. + + Args: + batch_in: The batch input. + + Returns: + Training metrics. + """ + self._training = True + + # calculate supervision and regularization losses separately + with tf.GradientTape(persistent=True) as tape: + prediction_loss = self.batch_loss(batch_in) + regularization_loss = tf.math.add_n(self.losses) + total_loss = prediction_loss + regularization_loss + + self.total_loss.update_state(total_loss) + + # calculate the gradients that come from supervision signal + prediction_gradients = tape.gradient(prediction_loss, self.trainable_variables) + # calculate the gradients that come from regularization + regularization_gradients = tape.gradient( + regularization_loss, self.trainable_variables + ) + # delete gradient tape manually + # since it was created with `persistent=True` option + del tape + + gradients = [] + for pred_grad, reg_grad in zip(prediction_gradients, regularization_gradients): + if pred_grad is not None and reg_grad is not None: + # remove regularization gradient for variables + # that don't have prediction gradient + gradients.append( + pred_grad + + tf.where(pred_grad > 0, reg_grad, tf.zeros_like(reg_grad)) + ) + else: + gradients.append(pred_grad) + + self.optimizer.apply_gradients(zip(gradients, self.trainable_variables)) + + self._training = None + + return self._get_metric_results() + + def test_step( + self, batch_in: Union[Tuple[tf.Tensor, ...], Tuple[np.ndarray, ...]] + ) -> Dict[Text, float]: + """Tests the model using the given batch. + + This method is used during validation. + + Args: + batch_in: The batch input. + + Returns: + Testing metrics. + """ + self._training = False + + prediction_loss = self.batch_loss(batch_in) + regularization_loss = tf.math.add_n(self.losses) + total_loss = prediction_loss + regularization_loss + self.total_loss.update_state(total_loss) + + self._training = None + + return self._get_metric_results() + + def predict_step( + self, batch_in: Union[Tuple[tf.Tensor, ...], Tuple[np.ndarray, ...]] + ) -> Dict[Text, tf.Tensor]: + """Predicts the output for the given batch. + + Args: + batch_in: The batch to predict. + + Returns: + Prediction output. + """ + self._training = False + + if not self.prepared_for_prediction: + # in case the model is used for prediction without loading, e.g. directly + # after training, we need to prepare the model for prediction once + self.prepare_for_predict() + self.prepared_for_prediction = True + + return self.batch_predict(batch_in) + + @staticmethod + def _dynamic_signature( + batch_in: Union[Tuple[tf.Tensor, ...], Tuple[np.ndarray, ...]] + ) -> List[List[tf.TensorSpec]]: + element_spec = [] + for tensor in batch_in: + if len(tensor.shape) > 1: + shape: List[Union[None, int]] = [None] * (len(tensor.shape) - 1) + shape += [tensor.shape[-1]] + else: + shape = [None] + element_spec.append(tf.TensorSpec(shape, tensor.dtype)) + # batch_in is a list of tensors, therefore we need to wrap element_spec into + # the list + return [element_spec] + + def _rasa_predict( + self, batch_in: Tuple[np.ndarray, ...] + ) -> Dict[Text, Union[np.ndarray, Dict[Text, Any]]]: + """Custom prediction method that builds tf graph on the first call. + + Args: + batch_in: Prepared batch ready for input to `predict_step` method of model. + + Return: + Prediction output, including diagnostic data. + """ + self._training = False + if not self.prepared_for_prediction: + # in case the model is used for prediction without loading, e.g. directly + # after training, we need to prepare the model for prediction once + self.prepare_for_predict() + self.prepared_for_prediction = True + + if self._run_eagerly: + # Once we take advantage of TF's distributed training, this is where + # scheduled functions will be forced to execute and return actual values. + outputs = tf_utils.sync_to_numpy_or_python_type(self.predict_step(batch_in)) + if DIAGNOSTIC_DATA in outputs: + outputs[DIAGNOSTIC_DATA] = self._empty_lists_to_none_in_dict( + outputs[DIAGNOSTIC_DATA] + ) + return outputs + + if self._tf_predict_step is None: + self._tf_predict_step = tf.function( + self.predict_step, input_signature=self._dynamic_signature(batch_in) + ) + + # Once we take advantage of TF's distributed training, this is where + # scheduled functions will be forced to execute and return actual values. + outputs = tf_utils.sync_to_numpy_or_python_type(self._tf_predict_step(batch_in)) + if DIAGNOSTIC_DATA in outputs: + outputs[DIAGNOSTIC_DATA] = self._empty_lists_to_none_in_dict( + outputs[DIAGNOSTIC_DATA] + ) + return outputs + + def run_inference( + self, + model_data: RasaModelData, + batch_size: Union[int, List[int]] = 1, + output_keys_expected: Optional[List[Text]] = None, + ) -> Dict[Text, Union[np.ndarray, Dict[Text, Any]]]: + """Implements bulk inferencing through the model. + + Args: + model_data: Input data to be fed to the model. + batch_size: Size of batches that the generator should create. + output_keys_expected: Keys which are expected in the output. + The output should be filtered to have only these keys before + merging it with the output across all batches. + + Returns: + Model outputs corresponding to the inputs fed. + """ + outputs: Dict[Text, Union[np.ndarray, Dict[Text, Any]]] = {} + (data_generator, _) = rasa.utils.train_utils.create_data_generators( + model_data=model_data, batch_sizes=batch_size, epochs=1, shuffle=False + ) + data_iterator = iter(data_generator) + while True: + try: + # data_generator is a tuple of 2 elements - input and output. + # We only need input, since output is always None and not + # consumed by our TF graphs. + batch_in = next(data_iterator)[0] + batch_out: Dict[ + Text, Union[np.ndarray, Dict[Text, Any]] + ] = self._rasa_predict(batch_in) + if output_keys_expected: + batch_out = { + key: output + for key, output in batch_out.items() + if key in output_keys_expected + } + outputs = self._merge_batch_outputs(outputs, batch_out) + except StopIteration: + # Generator ran out of batches, time to finish inferencing + break + return outputs + + @staticmethod + def _merge_batch_outputs( + all_outputs: Dict[Text, Union[np.ndarray, Dict[Text, Any]]], + batch_output: Dict[Text, Union[np.ndarray, Dict[Text, np.ndarray]]], + ) -> Dict[Text, Union[np.ndarray, Dict[Text, Any]]]: + """Merges a batch's output into the output for all batches. + + Function assumes that the schema of batch output remains the same, + i.e. keys and their value types do not change from one batch's + output to another. + + Args: + all_outputs: Existing output for all previous batches. + batch_output: Output for a batch. + + Returns: + Merged output with the output for current batch stacked + below the output for all previous batches. + """ + if not all_outputs: + return batch_output + for key, val in batch_output.items(): + if isinstance(val, np.ndarray): + all_outputs[key] = np.concatenate( + [all_outputs[key], batch_output[key]], axis=0 + ) + + elif isinstance(val, dict): + # recurse and merge the inner dict first + all_outputs[key] = RasaModel._merge_batch_outputs(all_outputs[key], val) + + return all_outputs + + @staticmethod + def _empty_lists_to_none_in_dict(input_dict: Dict[Text, Any]) -> Dict[Text, Any]: + """Recursively replaces empty list or np array with None in a dictionary.""" + + def _recurse( + x: Union[Dict[Text, Any], List[Any], np.ndarray] + ) -> Optional[Union[Dict[Text, Any], List[Any], np.ndarray]]: + if isinstance(x, dict): + return {k: _recurse(v) for k, v in x.items()} + elif (isinstance(x, list) or isinstance(x, np.ndarray)) and np.size(x) == 0: + return None + return x + + return {k: _recurse(v) for k, v in input_dict.items()} + + def _get_metric_results(self, prefix: Optional[Text] = "") -> Dict[Text, float]: + return { + f"{prefix}{metric.name}": metric.result() + for metric in self.metrics + if metric.name in self.metrics_to_log + } + + def save(self, model_file_name: Text, overwrite: bool = True) -> None: + """Save the model to the given file. + + Args: + model_file_name: The file name to save the model to. + overwrite: If 'True' an already existing model with the same file name will + be overwritten. + """ + self.save_weights(model_file_name, overwrite=overwrite, save_format="tf") + + @classmethod + def load( + cls, + model_file_name: Text, + model_data_example: RasaModelData, + predict_data_example: Optional[RasaModelData] = None, + finetune_mode: bool = False, + *args: Any, + **kwargs: Any, + ) -> "RasaModel": + """Loads a model from the given weights. + + Args: + model_file_name: Path to file containing model weights. + model_data_example: Example data point to construct the model architecture. + predict_data_example: Example data point to speed up prediction during + inference. + finetune_mode: Indicates whether to load the model for further finetuning. + *args: Any other non key-worded arguments. + **kwargs: Any other key-worded arguments. + + Returns: + Loaded model with weights appropriately set. + """ + logger.debug( + f"Loading the model from {model_file_name} " + f"with finetune_mode={finetune_mode}..." + ) + # create empty model + model = cls(*args, **kwargs) + learning_rate = kwargs.get("config", {}).get(LEARNING_RATE, 0.001) + run_eagerly = kwargs.get("config", {}).get(RUN_EAGERLY) + + # need to train on 1 example to build weights of the correct size + model.compile( + optimizer=tf.keras.optimizers.Adam(learning_rate), run_eagerly=run_eagerly + ) + data_generator = RasaBatchDataGenerator(model_data_example, batch_size=1) + model.fit(data_generator, verbose=False) + # load trained weights + model.load_weights(model_file_name) + + # predict on one data example to speed up prediction during inference + # the first prediction always takes a bit longer to trace tf function + if not finetune_mode and predict_data_example: + model.run_inference(predict_data_example) + + logger.debug("Finished loading the model.") + return model + + @staticmethod + def batch_to_model_data_format( + batch: MaybeNestedBatchData, + data_signature: Dict[Text, Dict[Text, List[FeatureSignature]]], + ) -> Dict[Text, Dict[Text, List[tf.Tensor]]]: + """Convert input batch tensors into batch data format. + + Batch contains any number of batch data. The order is equal to the + key-value pairs in session data. As sparse data were converted into (indices, + data, shape) before, this method converts them into sparse tensors. Dense + data is kept. + """ + # during training batch is a tuple of input and target data + # as our target data is inside the input data, we are just interested in the + # input data + unpacked_batch = batch[0] if isinstance(batch[0], Tuple) else batch + + batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]] = defaultdict( + lambda: defaultdict(list) + ) + + idx = 0 + for key, values in data_signature.items(): + for sub_key, signature in values.items(): + for is_sparse, feature_dimension, number_of_dimensions in signature: + # we converted all 4D features to 3D features before + number_of_dimensions = ( + number_of_dimensions if number_of_dimensions != 4 else 3 + ) + if is_sparse: + tensor, idx = RasaModel._convert_sparse_features( + unpacked_batch, feature_dimension, idx, number_of_dimensions + ) + else: + tensor, idx = RasaModel._convert_dense_features( + unpacked_batch, feature_dimension, idx, number_of_dimensions + ) + batch_data[key][sub_key].append(tensor) + + return batch_data + + @staticmethod + def _convert_dense_features( + batch: BatchData, + feature_dimension: int, + idx: int, + number_of_dimensions: int, + ) -> Tuple[tf.Tensor, int]: + batch_at_idx = batch[idx] + if isinstance(batch_at_idx, tf.Tensor): + # explicitly substitute last dimension in shape with known + # static value + if number_of_dimensions > 1 and ( + batch_at_idx.shape is None or batch_at_idx.shape[-1] is None + ): + shape: List[Optional[int]] = [None] * (number_of_dimensions - 1) + shape.append(feature_dimension) + batch_at_idx.set_shape(shape) + + return batch_at_idx, idx + 1 + + # convert to Tensor + return ( + tf.constant(batch[idx], dtype=tf.float32, shape=batch[idx].shape), + idx + 1, + ) + + @staticmethod + def _convert_sparse_features( + batch: BatchData, + feature_dimension: int, + idx: int, + number_of_dimensions: int, + ) -> Tuple[tf.SparseTensor, int]: + # explicitly substitute last dimension in shape with known + # static value + shape = [batch[idx + 2][i] for i in range(number_of_dimensions - 1)] + [ + feature_dimension + ] + return tf.SparseTensor(batch[idx], batch[idx + 1], shape), idx + 3 + + def call( + self, + inputs: Union[tf.Tensor, List[tf.Tensor]], + training: Optional[tf.Tensor] = None, + mask: Optional[tf.Tensor] = None, + ) -> Union[tf.Tensor, List[tf.Tensor]]: + """Calls the model on new inputs. + + Arguments: + inputs: A tensor or list of tensors. + training: Boolean or boolean scalar tensor, indicating whether to run + the `Network` in training mode or inference mode. + mask: A mask or list of masks. A mask can be + either a tensor or None (no mask). + + Returns: + A tensor if there is a single output, or + a list of tensors if there are more than one outputs. + """ + # This method needs to be implemented, otherwise the super class is raising a + # NotImplementedError('When subclassing the `Model` class, you should + # implement a `call` method.') + pass + + +# noinspection PyMethodOverriding +class TransformerRasaModel(RasaModel): + def __init__( + self, + name: Text, + config: Dict[Text, Any], + data_signature: Dict[Text, Dict[Text, List[FeatureSignature]]], + label_data: RasaModelData, + ) -> None: + super().__init__(name=name, random_seed=config[RANDOM_SEED]) + + self.config = config + self.data_signature = data_signature + self.label_signature = label_data.get_signature() + self._check_data() + + label_batch = RasaDataGenerator.prepare_batch(label_data.data) + self.tf_label_data = self.batch_to_model_data_format( + label_batch, self.label_signature + ) + + # set up tf layers + self._tf_layers: Dict[Text, tf.keras.layers.Layer] = {} + + def adjust_for_incremental_training( + self, + data_example: Dict[Text, Dict[Text, List[FeatureArray]]], + new_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + old_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + ) -> None: + """Adjusts the model for incremental training. + + First we should check if any of the sparse feature sizes has decreased + and raise an exception if this happens. + If none of them have decreased and any of them has increased, then the + function updates `DenseForSparse` layers, compiles the model, fits a sample + data on it to activate adjusted layer(s) and updates the data signatures. + + New and old sparse feature sizes could look like this: + {TEXT: {FEATURE_TYPE_SEQUENCE: [4, 24, 128], FEATURE_TYPE_SENTENCE: [4, 128]}} + + Args: + data_example: a data example that is stored with the ML component. + new_sparse_feature_sizes: sizes of current sparse features. + old_sparse_feature_sizes: sizes of sparse features the model was + previously trained on. + """ + self._check_if_sparse_feature_sizes_decreased( + new_sparse_feature_sizes=new_sparse_feature_sizes, + old_sparse_feature_sizes=old_sparse_feature_sizes, + ) + if self._sparse_feature_sizes_have_increased( + new_sparse_feature_sizes=new_sparse_feature_sizes, + old_sparse_feature_sizes=old_sparse_feature_sizes, + ): + self._update_dense_for_sparse_layers( + new_sparse_feature_sizes, old_sparse_feature_sizes + ) + self._compile_and_fit(data_example) + + @staticmethod + def _check_if_sparse_feature_sizes_decreased( + new_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + old_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + ) -> None: + """Checks if the sizes of sparse features have decreased during fine-tuning. + + Sparse feature sizes might decrease after changing the training data. + This can happen for example with `LexicalSyntacticFeaturizer`. + We don't support this behaviour and we raise an exception if this happens. + + Args: + new_sparse_feature_sizes: sizes of current sparse features. + old_sparse_feature_sizes: sizes of sparse features the model was + previously trained on. + + Raises: + RasaException: When any of the sparse feature sizes decrease + from the last time training was run. + """ + for attribute, new_feature_sizes in new_sparse_feature_sizes.items(): + old_feature_sizes = old_sparse_feature_sizes[attribute] + for feature_type, new_sizes in new_feature_sizes.items(): + old_sizes = old_feature_sizes[feature_type] + for new_size, old_size in zip(new_sizes, old_sizes): + if new_size < old_size: + raise RasaException( + "Sparse feature sizes have decreased from the last time " + "training was run. The training data was changed in a way " + "that resulted in some features not being present in the " + "data anymore. This can happen if you had " + "`LexicalSyntacticFeaturizer` in your pipeline. " + "The pipeline cannot support incremental training " + "in this setting. We recommend you to retrain " + "the model from scratch." + ) + + @staticmethod + def _sparse_feature_sizes_have_increased( + new_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + old_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + ) -> bool: + """Checks if the sizes of sparse features have increased during fine-tuning. + + If there's any sparse feature size that has increased after changing the + training data, we need to look for the corresponding `DenseForSparse` layer + and adjust it. On the other hand, if none of them have increased, we don't + need to change anything. This function helps us with making the decision. + + Note that the function assumes that none of the sparse feature sizes + have decreased. In other words, it should get valid arguments in order + to function well. + + Args: + new_sparse_feature_sizes: sizes of current sparse features. + old_sparse_feature_sizes: sizes of sparse features the model was + previously trained on. + + Returns: + `True` if any of the sparse feature sizes has increased, `False` otherwise. + """ + for attribute, new_feature_sizes in new_sparse_feature_sizes.items(): + old_feature_sizes = old_sparse_feature_sizes[attribute] + for feature_type, new_sizes in new_feature_sizes.items(): + old_sizes = old_feature_sizes[feature_type] + if sum(new_sizes) > sum(old_sizes): + return True + return False + + def _update_dense_for_sparse_layers( + self, + new_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + old_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + ) -> None: + """Updates `DenseForSparse` layers. + + Updates sizes of `DenseForSparse` layers by comparing current sparse feature + sizes to old ones. This must be done before fine-tuning starts to account + for any change in the size of sparse features that might have happened + because of addition of new data. + + Args: + new_sparse_feature_sizes: sizes of current sparse features. + old_sparse_feature_sizes: sizes of sparse features the model was + previously trained on. + """ + for name, layer in self._tf_layers.items(): + # `if` condition is necessary because only `RasaCustomLayer` + # can adjust sparse layers for incremental training by default. + if isinstance(layer, rasa_layers.RasaCustomLayer): + layer.adjust_sparse_layers_for_incremental_training( + new_sparse_feature_sizes, + old_sparse_feature_sizes, + self.config[REGULARIZATION_CONSTANT], + ) + + def _compile_and_fit( + self, data_example: Dict[Text, Dict[Text, List[FeatureArray]]] + ) -> None: + """Compiles modified model and fits a sample data on it. + + Args: + data_example: a data example that is stored with the ML component. + """ + self.compile( + optimizer=tf.keras.optimizers.Adam(self.config[LEARNING_RATE]), + run_eagerly=self.config[RUN_EAGERLY], + ) + label_key = LABEL_KEY if self.config[INTENT_CLASSIFICATION] else None + label_sub_key = LABEL_SUB_KEY if self.config[INTENT_CLASSIFICATION] else None + + model_data = RasaModelData( + label_key=label_key, label_sub_key=label_sub_key, data=data_example + ) + self._update_data_signatures(model_data) + data_generator = RasaBatchDataGenerator(model_data, batch_size=1) + self.fit(data_generator, verbose=False) + + def _update_data_signatures(self, model_data: RasaModelData) -> None: + self.data_signature = model_data.get_signature() + self.predict_data_signature = { + feature_name: features + for feature_name, features in self.data_signature.items() + if TEXT in feature_name + } + + def _check_data(self) -> None: + raise NotImplementedError + + def _prepare_layers(self) -> None: + raise NotImplementedError + + def _prepare_label_classification_layers(self, predictor_attribute: Text) -> None: + """Prepares layers & loss for the final label prediction step.""" + self._prepare_embed_layers(predictor_attribute) + self._prepare_embed_layers(LABEL) + self._prepare_dot_product_loss(LABEL, self.config[SCALE_LOSS]) + + def _prepare_embed_layers(self, name: Text, prefix: Text = "embed") -> None: + self._tf_layers[f"{prefix}.{name}"] = layers.Embed( + self.config[EMBEDDING_DIMENSION], self.config[REGULARIZATION_CONSTANT], name + ) + + def _prepare_ffnn_layer( + self, + name: Text, + layer_sizes: List[int], + drop_rate: float, + prefix: Text = "ffnn", + ) -> None: + self._tf_layers[f"{prefix}.{name}"] = layers.Ffnn( + layer_sizes, + drop_rate, + self.config[REGULARIZATION_CONSTANT], + self.config[CONNECTION_DENSITY], + layer_name_suffix=name, + ) + + def _prepare_dot_product_loss( + self, name: Text, scale_loss: bool, prefix: Text = "loss" + ) -> None: + self._tf_layers[f"{prefix}.{name}"] = self.dot_product_loss_layer( + self.config[NUM_NEG], + loss_type=self.config[LOSS_TYPE], + mu_pos=self.config[MAX_POS_SIM], + mu_neg=self.config[MAX_NEG_SIM], + use_max_sim_neg=self.config[USE_MAX_NEG_SIM], + neg_lambda=self.config[NEGATIVE_MARGIN_SCALE], + scale_loss=scale_loss, + similarity_type=self.config[SIMILARITY_TYPE], + constrain_similarities=self.config[CONSTRAIN_SIMILARITIES], + model_confidence=self.config[MODEL_CONFIDENCE], + ) + + @property + def dot_product_loss_layer(self) -> tf.keras.layers.Layer: + """Returns the dot-product loss layer to use. + + Returns: + The loss layer that is used by `_prepare_dot_product_loss`. + """ + return layers.SingleLabelDotProductLoss + + def _prepare_entity_recognition_layers(self) -> None: + for tag_spec in self._entity_tag_specs: + name = tag_spec.tag_name + num_tags = tag_spec.num_tags + self._tf_layers[f"embed.{name}.logits"] = layers.Embed( + num_tags, self.config[REGULARIZATION_CONSTANT], f"logits.{name}" + ) + self._tf_layers[f"crf.{name}"] = layers.CRF( + num_tags, self.config[REGULARIZATION_CONSTANT], self.config[SCALE_LOSS] + ) + self._tf_layers[f"embed.{name}.tags"] = layers.Embed( + self.config[EMBEDDING_DIMENSION], + self.config[REGULARIZATION_CONSTANT], + f"tags.{name}", + ) + + @staticmethod + def _last_token(x: tf.Tensor, sequence_lengths: tf.Tensor) -> tf.Tensor: + last_sequence_index = tf.maximum(0, sequence_lengths - 1) + batch_index = tf.range(tf.shape(last_sequence_index)[0]) + + indices = tf.stack([batch_index, last_sequence_index], axis=1) + return tf.gather_nd(x, indices) + + def _get_mask_for( + self, + tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], + key: Text, + sub_key: Text, + ) -> Optional[tf.Tensor]: + if key not in tf_batch_data or sub_key not in tf_batch_data[key]: + return None + + sequence_lengths = tf.cast(tf_batch_data[key][sub_key][0], dtype=tf.int32) + return rasa_layers.compute_mask(sequence_lengths) + + def _get_sequence_feature_lengths( + self, tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], key: Text + ) -> tf.Tensor: + """Fetches the sequence lengths of real tokens per input example. + + The number of real tokens for an example is the same as the length of the + sequence of the sequence-level (token-level) features for that input example. + """ + if key in tf_batch_data and SEQUENCE_LENGTH in tf_batch_data[key]: + return tf.cast(tf_batch_data[key][SEQUENCE_LENGTH][0], dtype=tf.int32) + + batch_dim = self._get_batch_dim(tf_batch_data[key]) + return tf.zeros([batch_dim], dtype=tf.int32) + + def _get_sentence_feature_lengths( + self, tf_batch_data: Dict[Text, Dict[Text, List[tf.Tensor]]], key: Text + ) -> tf.Tensor: + """Fetches the sequence lengths of sentence-level features per input example. + + This is needed because we treat sentence-level features as token-level features + with 1 token per input example. Hence, the sequence lengths returned by this + function are all 1s if sentence-level features are present, and 0s otherwise. + """ + batch_dim = self._get_batch_dim(tf_batch_data[key]) + + if key in tf_batch_data and SENTENCE in tf_batch_data[key]: + return tf.ones([batch_dim], dtype=tf.int32) + + return tf.zeros([batch_dim], dtype=tf.int32) + + @staticmethod + def _get_batch_dim(attribute_data: Dict[Text, List[tf.Tensor]]) -> int: + # All the values in the attribute_data dict should be lists of tensors, each + # tensor of the shape (batch_dim, ...). So we take the first non-empty list we + # encounter and infer the batch size from its first tensor. + for key, data in attribute_data.items(): + if data: + return tf.shape(data[0])[0] + + return 0 + + def _calculate_entity_loss( + self, + inputs: tf.Tensor, + tag_ids: tf.Tensor, + mask: tf.Tensor, + sequence_lengths: tf.Tensor, + tag_name: Text, + entity_tags: Optional[tf.Tensor] = None, + ) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor]: + + tag_ids = tf.cast(tag_ids[:, :, 0], tf.int32) + + if entity_tags is not None: + _tags = self._tf_layers[f"embed.{tag_name}.tags"](entity_tags) + inputs = tf.concat([inputs, _tags], axis=-1) + + logits = self._tf_layers[f"embed.{tag_name}.logits"](inputs) + + # should call first to build weights + pred_ids, _ = self._tf_layers[f"crf.{tag_name}"](logits, sequence_lengths) + loss = self._tf_layers[f"crf.{tag_name}"].loss( + logits, tag_ids, sequence_lengths + ) + f1 = self._tf_layers[f"crf.{tag_name}"].f1_score(tag_ids, pred_ids, mask) + + return loss, f1, logits + + def batch_loss( + self, batch_in: Union[Tuple[tf.Tensor, ...], Tuple[np.ndarray, ...]] + ) -> tf.Tensor: + """Calculates the loss for the given batch. + + Args: + batch_in: The batch. + + Returns: + The loss of the given batch. + """ + raise NotImplementedError + + def batch_predict( + self, batch_in: Union[Tuple[tf.Tensor, ...], Tuple[np.ndarray, ...]] + ) -> Dict[Text, Union[tf.Tensor, Dict[Text, tf.Tensor]]]: + """Predicts the output of the given batch. + + Args: + batch_in: The batch. + + Returns: + The output to predict. + """ + raise NotImplementedError diff --git a/rasa/utils/tensorflow/rasa_layers.py b/rasa/utils/tensorflow/rasa_layers.py new file mode 100644 index 0000000..cea20ad --- /dev/null +++ b/rasa/utils/tensorflow/rasa_layers.py @@ -0,0 +1,1094 @@ +import tensorflow as tf +import numpy as np +from typing import Text, List, Dict, Any, Union, Optional, Tuple, Callable + +from rasa.shared.nlu.constants import TEXT +from rasa.utils.tensorflow.model_data import FeatureSignature +from rasa.utils.tensorflow.constants import ( + REGULARIZATION_CONSTANT, + CONNECTION_DENSITY, + NUM_TRANSFORMER_LAYERS, + TRANSFORMER_SIZE, + NUM_HEADS, + UNIDIRECTIONAL_ENCODER, + KEY_RELATIVE_ATTENTION, + VALUE_RELATIVE_ATTENTION, + MAX_RELATIVE_POSITION, + MASKED_LM, + HIDDEN_LAYERS_SIZES, + DROP_RATE, + SPARSE_INPUT_DROPOUT, + DENSE_INPUT_DROPOUT, + DENSE_DIMENSION, + CONCAT_DIMENSION, + DROP_RATE_ATTENTION, + SEQUENCE, + SENTENCE, +) +from rasa.utils.tensorflow import layers +from rasa.utils.tensorflow.exceptions import TFLayerConfigException +from rasa.utils.tensorflow.transformer import TransformerEncoder +from rasa.nlu.constants import DEFAULT_TRANSFORMER_SIZE + + +class RasaCustomLayer(tf.keras.layers.Layer): + """Parent class for all classes in `rasa_layers.py`. + + Allows a shared implementation for adjusting `DenseForSparse` + layers during incremental training. + + During fine-tuning, sparse feature sizes might change due to addition of new data. + If this happens, we need to adjust our `DenseForSparse` layers to a new size. + `ConcatenateSparseDenseFeatures`, `RasaSequenceLayer` and + `RasaFeatureCombiningLayer` all inherit from `RasaCustomLayer` and thus can + change their own `DenseForSparse` layers if it's needed. + """ + + def adjust_sparse_layers_for_incremental_training( + self, + new_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + old_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + reg_lambda: float, + ) -> None: + """Finds and adjusts `DenseForSparse` layers during incremental training. + + Recursively looks through the layers until it finds all the `DenseForSparse` + ones and adjusts those which have their sparse feature sizes increased. + + This function heavily relies on the name of `DenseForSparse` layer being + in the following format - f"sparse_to_dense.{attribute}_{feature_type}" - + in order to correctly extract the attribute and feature type. + + New and old sparse feature sizes could look like this: + {TEXT: {FEATURE_TYPE_SEQUENCE: [4, 24, 128], FEATURE_TYPE_SENTENCE: [4, 128]}} + + Args: + new_sparse_feature_sizes: sizes of current sparse features. + old_sparse_feature_sizes: sizes of sparse features the model was + previously trained on. + reg_lambda: regularization constant. + """ + for name, layer in self._tf_layers.items(): + if isinstance(layer, RasaCustomLayer): + layer.adjust_sparse_layers_for_incremental_training( + new_sparse_feature_sizes=new_sparse_feature_sizes, + old_sparse_feature_sizes=old_sparse_feature_sizes, + reg_lambda=reg_lambda, + ) + elif isinstance(layer, layers.DenseForSparse): + attribute = layer.get_attribute() + feature_type = layer.get_feature_type() + if ( + attribute in new_sparse_feature_sizes + and feature_type in new_sparse_feature_sizes[attribute] + ): + new_feature_sizes = new_sparse_feature_sizes[attribute][ + feature_type + ] + old_feature_sizes = old_sparse_feature_sizes[attribute][ + feature_type + ] + if sum(new_feature_sizes) > sum(old_feature_sizes): + self._tf_layers[name] = self._replace_dense_for_sparse_layer( + layer_to_replace=layer, + new_sparse_feature_sizes=new_feature_sizes, + old_sparse_feature_sizes=old_feature_sizes, + attribute=attribute, + feature_type=feature_type, + reg_lambda=reg_lambda, + ) + + @staticmethod + def _replace_dense_for_sparse_layer( + layer_to_replace: layers.DenseForSparse, + new_sparse_feature_sizes: List[int], + old_sparse_feature_sizes: List[int], + attribute: Text, + feature_type: Text, + reg_lambda: float, + ) -> layers.DenseForSparse: + """Replaces a `DenseForSparse` layer with a new one. + + Replaces an existing `DenseForSparse` layer with a new one + in order to adapt it to incremental training. + + Args: + layer_to_replace: a `DenseForSparse` layer that is used to create a new one. + new_sparse_feature_sizes: sizes of sparse features that will be + the input of the layer. + old_sparse_feature_sizes: sizes of sparse features that used to be + the input of the layer. + attribute: an attribute of the data fed to the layer. + feature_type: a feature type of the data fed to the layer. + reg_lambda: regularization constant. + + Returns: + New `DenseForSparse` layer. + """ + kernel = layer_to_replace.get_kernel().numpy() + bias = layer_to_replace.get_bias() + if bias is not None: + bias = bias.numpy() + units = layer_to_replace.get_units() + # split kernel by feature sizes to update the layer accordingly + kernel_splits = [] + splitting_index = 0 + for size in old_sparse_feature_sizes: + kernel_splits.append(kernel[splitting_index : splitting_index + size, :]) + splitting_index += size + additional_sizes = [ + new_size - old_size + for new_size, old_size in zip( + new_sparse_feature_sizes, old_sparse_feature_sizes + ) + ] + std, mean = np.std(kernel), np.mean(kernel) + additional_weights = [ + np.random.normal(mean, std, size=(num_rows, units)).astype(np.float32) + for num_rows in additional_sizes + ] + merged_weights = [ + np.vstack((existing, new)) + for existing, new in zip(kernel_splits, additional_weights) + ] + # stack each merged weight to form a new weight tensor + new_weights = np.vstack(merged_weights) + kernel_init = tf.constant_initializer(new_weights) + bias_init = tf.constant_initializer(bias) if bias is not None else None + new_layer = layers.DenseForSparse( + name=f"sparse_to_dense.{attribute}_{feature_type}", + reg_lambda=reg_lambda, + units=units, + use_bias=bias is not None, + kernel_initializer=kernel_init, + bias_initializer=bias_init, + ) + return new_layer + + +class ConcatenateSparseDenseFeatures(RasaCustomLayer): + """Combines multiple sparse and dense feature tensors into one dense tensor. + + This layer combines features from various featurisers into a single feature array + per input example. All features must be of the same feature type, i.e. sentence- + level or sequence-level (token-level). + + The layer combines a given list of tensors (whether sparse or dense) by: + 1. converting sparse tensors into dense ones + 2. optionally, applying dropout to sparse tensors before and/or after the conversion + 3. concatenating all tensors along the last dimension + + Arguments: + attribute: Name of attribute (e.g. `text` or `label`) whose features will be + processed. + feature_type: Feature type to be processed -- `sequence` or `sentence`. + feature_type_signature: A list of signatures for the given attribute and feature + type. + config: A model config for correctly parametrising the layer. + + Input shape: + Tuple containing one list of N-D tensors, each with shape: `(batch_size, ..., + input_dim)`. + All dense tensors must have the same shape, except possibly the last dimension. + All sparse tensors must have the same shape, including the last dimension. + + Output shape: + N-D tensor with shape: `(batch_size, ..., units)` where `units` is the sum of + the last dimension sizes across all input tensors, with sparse tensors instead + contributing `config[DENSE_DIMENSION][attribute]` units each. + + Raises: + A `TFLayerConfigException` if no feature signatures are provided. + + Attributes: + output_units: The last dimension size of the layer's output. + """ + + SPARSE_DROPOUT = "sparse_dropout" + SPARSE_TO_DENSE = "sparse_to_dense" + DENSE_DROPOUT = "dense_dropout" + + def __init__( + self, + attribute: Text, + feature_type: Text, + feature_type_signature: List[FeatureSignature], + config: Dict[Text, Any], + ) -> None: + """Creates a new `ConcatenateSparseDenseFeatures` object.""" + if not feature_type_signature: + raise TFLayerConfigException( + "The feature type signature must contain some feature signatures." + ) + + super().__init__( + name=f"concatenate_sparse_dense_features_{attribute}_{feature_type}" + ) + + self._check_sparse_input_units(feature_type_signature) + + self.output_units = self._calculate_output_units( + attribute, feature_type_signature, config + ) + + # Prepare dropout and sparse-to-dense layers if any sparse tensors are expected + self._tf_layers: Dict[Text, tf.keras.layers.Layer] = {} + if any([signature.is_sparse for signature in feature_type_signature]): + self._prepare_layers_for_sparse_tensors(attribute, feature_type, config) + + def _check_sparse_input_units( + self, feature_type_signature: List[FeatureSignature] + ) -> None: + """Checks that all sparse features have the same last dimension size.""" + sparse_units = [ + feature_sig.units + for feature_sig in feature_type_signature + if feature_sig.is_sparse + ] + if len(set(sparse_units)) > 1: + raise TFLayerConfigException( + f"All sparse features must have the same last dimension size but found " + f"different sizes: {set(sparse_units)}." + ) + + def _prepare_layers_for_sparse_tensors( + self, attribute: Text, feature_type: Text, config: Dict[Text, Any] + ) -> None: + """Sets up sparse tensor pre-processing before combining with dense ones.""" + # For optionally applying dropout to sparse tensors + if config[SPARSE_INPUT_DROPOUT]: + self._tf_layers[self.SPARSE_DROPOUT] = layers.SparseDropout( + rate=config[DROP_RATE] + ) + + # For converting sparse tensors to dense + self._tf_layers[self.SPARSE_TO_DENSE] = layers.DenseForSparse( + name=f"sparse_to_dense.{attribute}_{feature_type}", + units=config[DENSE_DIMENSION][attribute], + reg_lambda=config[REGULARIZATION_CONSTANT], + ) + + # For optionally apply dropout to sparse tensors after they're converted to + # dense tensors. + if config[DENSE_INPUT_DROPOUT]: + self._tf_layers[self.DENSE_DROPOUT] = tf.keras.layers.Dropout( + rate=config[DROP_RATE] + ) + + @staticmethod + def _calculate_output_units( + attribute: Text, + feature_type_signature: List[FeatureSignature], + config: Dict[Text, Any], + ) -> int: + """Determines the output units from the provided feature signatures. + + Sparse features will be turned into dense ones, hence they each contribute with + their future dense number of units. + """ + return sum( + [ + config[DENSE_DIMENSION][attribute] + if signature.is_sparse + else signature.units + for signature in feature_type_signature + ] + ) + + def _process_sparse_feature( + self, feature: tf.SparseTensor, training: bool + ) -> tf.Tensor: + """Turns sparse tensor into dense, possibly adds dropout before and/or after.""" + if self.SPARSE_DROPOUT in self._tf_layers: + feature = self._tf_layers[self.SPARSE_DROPOUT](feature, training) + + feature = self._tf_layers[self.SPARSE_TO_DENSE](feature) + + if self.DENSE_DROPOUT in self._tf_layers: + feature = self._tf_layers[self.DENSE_DROPOUT](feature, training) + + return feature + + def call( + self, + inputs: Tuple[List[Union[tf.Tensor, tf.SparseTensor]]], + training: bool = False, + ) -> tf.Tensor: + """Combines sparse and dense feature tensors into one tensor. + + Arguments: + inputs: Contains the input tensors, all of the same rank. + training: A flag indicating whether the layer should behave in training mode + (applying dropout to sparse tensors if applicable) or in inference mode + (not applying dropout). + + Returns: + Single tensor with all input tensors combined along the last dimension. + """ + features = inputs[0] + + dense_features = [] + for f in features: + if isinstance(f, tf.SparseTensor): + f = self._process_sparse_feature(f, training) + dense_features.append(f) + + # Now that all features are made dense, concatenate them along the last (units) + # dimension. + return tf.concat(dense_features, axis=-1) + + +class RasaFeatureCombiningLayer(RasaCustomLayer): + """Combines multiple dense or sparse feature tensors into one. + + This layer combines features by following these steps: + 1. Apply a `ConcatenateSparseDenseFeatures` layer separately to sequence- and + sentence-level features, yielding two tensors (one for each feature type). + 2. Concatenate the sequence- and sentence-level tensors along the sequence dimension + by appending sentence-level features at the first available token position after + the sequence-level (token-level) features. + + Arguments: + attribute: Name of attribute (e.g. `text` or `label`) whose features will be + processed. + attribute_signature: A dictionary containing two lists of feature signatures, + one for each feature type (`sequence` or `sentence`) of the given attribute. + config: A model config used for correctly parameterising the layer and the + `ConcatenateSparseDenseFeatures` layer it uses internally. + + Input shape: + Tuple of three input tensors: + sequence_features: List of 3-D dense or sparse tensors, each with shape + `(batch_size, max_seq_length, input_dim)` where `input_dim` can be + different for sparse vs dense tensors. See the input shape of + `ConcatenateSparseDenseFeatures` for more information. + sentence_features: List of 3-D dense or sparse tensors, each with shape + `(batch_size, 1, input_dim)` where `input_dim` can be different for + sparse vs dense tensors, and can differ from that in + `sequence_features`. See the input shape of + `ConcatenateSparseDenseFeatures` for more information. + sequence_feature_lengths: Dense tensor of shape `(batch_size, )`. + + Output shape: + combined_features: A 3-D tensor with shape `(batch_size, sequence_length, + units)` where `units` is completely determined by the internally applied + `ConcatenateSparseDenseFeatures` layer and `sequence_length` is the combined + length of sequence- and sentence-level features: `max_seq_length + 1` if + both feature types are present, `max_seq_length` if only sequence-level + features are present, and 1 if only sentence-level features are present). + mask_combined_sequence_sentence: A 3-D tensor with shape + `(batch_size, sequence_length, 1)`. + + Raises: + A `TFLayerConfigException` if no feature signatures are provided. + + Attributes: + output_units: The last dimension size of the layer's `combined_features` output. + """ + + def __init__( + self, + attribute: Text, + attribute_signature: Dict[Text, List[FeatureSignature]], + config: Dict[Text, Any], + ) -> None: + """Creates a new `RasaFeatureCombiningLayer` object.""" + if not attribute_signature or not ( + attribute_signature.get(SENTENCE, []) + or attribute_signature.get(SEQUENCE, []) + ): + raise TFLayerConfigException( + "The attribute signature must contain some feature signatures." + ) + + super().__init__(name=f"rasa_feature_combining_layer_{attribute}") + + self._tf_layers: Dict[Text, tf.keras.layers.Layer] = {} + + # Prepare sparse-dense combining layers for each present feature type + self._feature_types_present = self._get_present_feature_types( + attribute_signature + ) + self._prepare_sparse_dense_concat_layers(attribute, attribute_signature, config) + + # Prepare components for combining sequence- and sentence-level features + self._prepare_sequence_sentence_concat(attribute, config) + + self.output_units = self._calculate_output_units(attribute, config) + + @staticmethod + def _get_present_feature_types( + attribute_signature: Dict[Text, List[FeatureSignature]] + ) -> Dict[Text, bool]: + """Determines feature types that are present. + + Knowing which feature types are present is important because many downstream + operations depend on it, e.g. combining sequence- and sentence-level features + is only done if both feature types are present. + """ + return { + feature_type: ( + feature_type in attribute_signature + and len(attribute_signature[feature_type]) > 0 + ) + for feature_type in [SEQUENCE, SENTENCE] + } + + def _prepare_sparse_dense_concat_layers( + self, + attribute: Text, + attribute_signature: Dict[Text, List[FeatureSignature]], + config: Dict[Text, Any], + ) -> None: + """Prepares sparse-dense combining layers for all present feature types.""" + for feature_type, present in self._feature_types_present.items(): + if not present: + continue + self._tf_layers[ + f"sparse_dense.{feature_type}" + ] = ConcatenateSparseDenseFeatures( + attribute=attribute, + feature_type=feature_type, + feature_type_signature=attribute_signature[feature_type], + config=config, + ) + + def _prepare_sequence_sentence_concat( + self, attribute: Text, config: Dict[Text, Any] + ) -> None: + """Sets up combining sentence- and sequence-level features (if needed). + + This boils down to preparing for unifying the units of the sequence- and + sentence-level features if they differ -- the same number of units is required + for combining the features. + """ + if ( + self._feature_types_present[SEQUENCE] + and self._feature_types_present[SENTENCE] + ): + # The output units of this layer will be based on the output sizes of the + # sparse+dense combining layers that are internally applied to all features. + sequence_units = self._tf_layers[f"sparse_dense.{SEQUENCE}"].output_units + sentence_units = self._tf_layers[f"sparse_dense.{SENTENCE}"].output_units + + # Last dimension needs to be unified if sequence- and sentence-level + # features have different sizes, e.g. due to being produced by different + # featurizers. + if sequence_units != sentence_units: + for feature_type in [SEQUENCE, SENTENCE]: + self._tf_layers[ + f"unify_dims_before_seq_sent_concat.{feature_type}" + ] = layers.Ffnn( + layer_name_suffix=f"unify_dims.{attribute}_{feature_type}", + layer_sizes=[config[CONCAT_DIMENSION][attribute]], + dropout_rate=config[DROP_RATE], + reg_lambda=config[REGULARIZATION_CONSTANT], + density=config[CONNECTION_DENSITY], + ) + + def _calculate_output_units(self, attribute: Text, config: Dict[Text, Any]) -> int: + """Calculates the number of output units for this layer class. + + The number depends mainly on whether dimension unification is used or not. + """ + # If dimension unification is used, output units are determined by the unifying + # layers. + if ( + f"unify_dims_before_seq_sent_concat.{SEQUENCE}" in self._tf_layers + or f"unify_dims_before_seq_sent_concat.{SENTENCE}" in self._tf_layers + ): + return config[CONCAT_DIMENSION][attribute] + # Without dimension unification, the units from the underlying sparse_dense + # layers are carried over and should be the same for sequence-level features + # (if present) as for sentence-level features. + elif self._feature_types_present[SEQUENCE]: + return self._tf_layers[f"sparse_dense.{SEQUENCE}"].output_units + return self._tf_layers[f"sparse_dense.{SENTENCE}"].output_units + + def _concat_sequence_sentence_features( + self, + sequence_tensor: tf.Tensor, + sentence_tensor: tf.Tensor, + mask_combined_sequence_sentence: tf.Tensor, + ) -> tf.Tensor: + """Concatenates sequence- & sentence-level features along sequence dimension.""" + # If needed, pass both feature types through a dense layer to bring them to the + # same shape. + if f"unify_dims_before_seq_sent_concat.{SEQUENCE}" in self._tf_layers: + sequence_tensor = self._tf_layers[ + f"unify_dims_before_seq_sent_concat.{SEQUENCE}" + ](sequence_tensor) + if f"unify_dims_before_seq_sent_concat.{SENTENCE}" in self._tf_layers: + sentence_tensor = self._tf_layers[ + f"unify_dims_before_seq_sent_concat.{SENTENCE}" + ](sentence_tensor) + + # mask_combined_sequence_sentence has for each input example a sequence of 1s of + # the length seq_length+1, where seq_length is the number of real tokens. The + # rest is 0s which form a padding up to the max. sequence length + 1 (max. + # number of real tokens + 1). Here the mask is turned into a mask that has 0s + # everywhere and 1 only at the immediate next position after the last real + # token's position for a given input example. Example (batch size = 2, sequence + # lengths = [1, 2]): + # [[[1], [0], [0]], ___\ [[[0], [1], [0]], + # [[1], [1], [0]]] / [[0], [0], [1]]] + sentence_feature_positions_mask = ( + mask_combined_sequence_sentence + * tf.math.cumprod( + 1 - mask_combined_sequence_sentence, + axis=1, + exclusive=True, + reverse=True, + ) + ) + + # The new mask is used to distribute the sentence features at the sequence + # positions marked by 1s. The sentence features' dimensionality effectively + # changes from `(batch_size, 1, feature_dim)` to `(batch_size, max_seq_length+1, + # feature_dim)`, but the array is sparse, with real features present only at + # positions determined by 1s in the mask. + sentence_tensor = sentence_feature_positions_mask * sentence_tensor + + # Padding of sequence-level features is increased by 1 in the sequence + # dimension to match the shape of modified sentence-level features. + sequence_tensor = tf.pad(sequence_tensor, [[0, 0], [0, 1], [0, 0]]) + + # Sequence- and sentence-level features effectively get concatenated by + # summing the two padded feature arrays like this (batch size = 1): + # [[seq1, seq2, seq3, 0, 0]] + [[0, 0, 0, sent1, 0]] = + # = [[seq1, seq2, seq3, sent1, 0]] + return sequence_tensor + sentence_tensor + + def _combine_sequence_level_features( + self, + sequence_features: List[Union[tf.Tensor, tf.SparseTensor]], + mask_sequence: tf.Tensor, + training: bool, + ) -> Optional[tf.Tensor]: + """Processes & combines sequence-level features if any are present.""" + if self._feature_types_present[SEQUENCE]: + sequence_features_combined = self._tf_layers[f"sparse_dense.{SEQUENCE}"]( + (sequence_features,), training=training + ) + + # Apply mask which has 1s at positions of real tokens and 0s at all padded + # token positions. This is needed because the sparse+dense combining layer + # might've turned some fake (padded) features (i.e. 0s) into non-zero + # numbers and we want those to become zeros again. + # This step isn't needed for sentence-level features because those are never + # padded -- the effective sequence length in their case is always 1. + return sequence_features_combined * mask_sequence + + return None + + def _combine_sentence_level_features( + self, + sentence_features: List[Union[tf.Tensor, tf.SparseTensor]], + sequence_feature_lengths: tf.Tensor, + training: bool, + ) -> Tuple[Optional[tf.Tensor], Optional[tf.Tensor]]: + """Processes & combines sentence-level features if any are present.""" + if self._feature_types_present[SENTENCE]: + sentence_features_combined = self._tf_layers[f"sparse_dense.{SENTENCE}"]( + (sentence_features,), training=training + ) + # Sentence-level features have sequence dimension of length 1, add it to + # sequence-level feature lengths. + combined_sequence_sentence_feature_lengths = sequence_feature_lengths + 1 + + else: + sentence_features_combined = None + + # Without sentence-level features, the feature sequence lengths are + # completely determined by sequence-level features. + combined_sequence_sentence_feature_lengths = sequence_feature_lengths + + return sentence_features_combined, combined_sequence_sentence_feature_lengths + + def call( + self, + inputs: Tuple[ + List[Union[tf.Tensor, tf.SparseTensor]], + List[Union[tf.Tensor, tf.SparseTensor]], + tf.Tensor, + ], + training: bool = False, + ) -> Tuple[tf.Tensor, tf.Tensor]: + """Combines multiple 3-D dense/sparse feature tensors into one. + + Arguments: + inputs: Tuple containing: + sequence_features: Dense or sparse tensors representing different + token-level features. + sentence_features: Dense or sparse tensors representing sentence-level + features. + sequence_feature_lengths: A tensor containing the real sequence length + (the number of real -- not padding -- tokens) for each example in + the batch. + training: A flag indicating whether the layer should behave in training mode + (applying dropout to sparse tensors if applicable) or in inference mode + (not applying dropout). + + Returns: + combined features: A tensor containing all the features combined. + mask_combined_sequence_sentence: A binary mask with 1s in place of real + features in the combined feature tensor, and 0s in padded positions with + fake features. + """ + sequence_features = inputs[0] + sentence_features = inputs[1] + sequence_feature_lengths = inputs[2] + + # This mask is specifically for sequence-level features. + mask_sequence = compute_mask(sequence_feature_lengths) + + sequence_features_combined = self._combine_sequence_level_features( + sequence_features, mask_sequence, training + ) + + ( + sentence_features_combined, + combined_sequence_sentence_feature_lengths, + ) = self._combine_sentence_level_features( + sentence_features, sequence_feature_lengths, training + ) + + mask_combined_sequence_sentence = compute_mask( + combined_sequence_sentence_feature_lengths + ) + + # If both feature types are present, combine them. Otherwise, just the present + # feature type will be returned. + if ( + sequence_features_combined is not None + and sentence_features_combined is not None + ): + features_to_return = self._concat_sequence_sentence_features( + sequence_features_combined, + sentence_features_combined, + mask_combined_sequence_sentence, + ) + elif sequence_features_combined is not None: + features_to_return = sequence_features_combined + else: + features_to_return = sentence_features_combined + + return features_to_return, mask_combined_sequence_sentence + + +class RasaSequenceLayer(RasaCustomLayer): + """Creates an embedding from all features for a sequence attribute; facilitates MLM. + + This layer combines all features for an attribute and embeds them using a + transformer, optionally doing masked language modeling. The layer is meant only for + attributes with sequence-level features, such as `text`, `response` and + `action_text`. + + Internally, this layer applies the following steps: + 1. Combine features using `RasaFeatureCombiningLayer`. + 2. Apply a dense layer(s) to the combined features. + 3. Optionally, and only during training for the `text` attribute, apply masking to + the features and create further helper variables for masked language modeling. + 4. Embed the features using a transformer, effectively reducing variable-length + sequences of features to fixed-size embeddings. + + Arguments: + attribute: Name of attribute (e.g. `text` or `label`) whose features will be + processed. + attribute_signature: A dictionary containing two lists of feature signatures, + one for each feature type (`sentence` or `sequence`) of the given attribute. + config: A model config used for correctly parameterising the underlying layers. + + Input shape: + Tuple of three input tensors: + sequence_features: List of 3-D dense or sparse tensors, each with shape + `(batch_size, max_seq_length, input_dim)` where `input_dim` can be + different for sparse vs dense tensors. See the input shape of + `ConcatenateSparseDenseFeatures` for more information. + sentence_features: List of 3-D dense or sparse tensors, each with shape + `(batch_size, 1, input_dim)` where `input_dim` can be different for + sparse vs dense tensors, and can differ from that in + `sequence_features`. See the input shape of + `ConcatenateSparseDenseFeatures` for more information. + sequence_feature_lengths: Dense tensor of shape `(batch_size, )`. + + Output shape: + outputs: `(batch_size, seq_length, units)` where `units` matches the underlying + transformer's output size (if present), otherwise it matches the output size + of the `Ffnn` block applied to the combined features, or it's the output + size of the underlying `RasaFeatureCombiningLayer` if the `Ffnn` block has 0 + layers. `seq_length` is the sum of the sequence dimension + sizes of sequence- and sentence-level features (for details, see the output + shape of `RasaFeatureCombiningLayer`). If both feature types are present, + then `seq_length` will be 1 + the length of the longest sequence of real + tokens across all examples in the given batch. + seq_sent_features: `(batch_size, seq_length, hidden_dim)`, where `hidden_dim` is + the output size of the underlying `Ffnn` block, or the output size of the + underlying `RasaFeatureCombiningLayer` if the `Ffnn` block has 0 layers. + mask_combined_sequence_sentence: `(batch_size, seq_length, 1)` + token_ids: `(batch_size, seq_length, id_dim)`. `id_dim` is 2 when no dense + sequence-level features are present. Otherwise, it's arbitrarily chosen to + match the last dimension size of the first dense sequence-level feature in + the input list of features. + mlm_boolean_mask: `(batch_size, seq_length, 1)`, empty tensor if not doing MLM. + attention_weights: `(transformer_layers, batch_size, num_transformer_heads, + seq_length, seq_length)`, empty tensor if the transformer has 0 layers. + + Raises: + A `TFLayerConfigException` if no feature signatures for sequence-level features + are provided. + + Attributes: + output_units: The last dimension size of the layer's first output (`outputs`). + """ + + FEATURE_COMBINING = "feature_combining" + FFNN = "ffnn" + TRANSFORMER = "transformer" + MLM_INPUT_MASK = "mlm_input_mask" + SPARSE_TO_DENSE_FOR_TOKEN_IDS = "sparse_to_dense_for_token_ids" + + def __init__( + self, + attribute: Text, + attribute_signature: Dict[Text, List[FeatureSignature]], + config: Dict[Text, Any], + ) -> None: + """Creates a new `RasaSequenceLayer` object.""" + if not attribute_signature or not attribute_signature.get(SEQUENCE, []): + raise TFLayerConfigException( + "The attribute signature must contain some sequence-level feature" + "signatures but none were found." + ) + + super().__init__(name=f"rasa_sequence_layer_{attribute}") + + self._tf_layers: Dict[Text, Any] = { + self.FEATURE_COMBINING: RasaFeatureCombiningLayer( + attribute, attribute_signature, config + ), + self.FFNN: layers.Ffnn( + config[HIDDEN_LAYERS_SIZES][attribute], + config[DROP_RATE], + config[REGULARIZATION_CONSTANT], + config[CONNECTION_DENSITY], + layer_name_suffix=attribute, + ), + } + + self._enables_mlm = False + # Note: Within TED, masked language modeling becomes just input dropout, + # since there is no loss term associated with predicting the masked tokens. + self._prepare_masked_language_modeling(attribute, attribute_signature, config) + + transformer_layers, transformer_units = self._prepare_transformer( + attribute, config + ) + self._has_transformer = transformer_layers > 0 + + self.output_units = self._calculate_output_units( + attribute, transformer_layers, transformer_units, config + ) + + @staticmethod + def _get_transformer_dimensions( + attribute: Text, config: Dict[Text, Any] + ) -> Tuple[int, int]: + """Determines # of transformer layers & output size from the model config. + + The config can contain these directly (same for all attributes) or specified + separately for each attribute. + If a transformer is used (e.i. if `number_of_transformer_layers` is positive), + the default `transformer_size` which is `None` breaks things. Thus, + we need to set a reasonable default value so that the model works fine. + """ + transformer_layers = config[NUM_TRANSFORMER_LAYERS] + if isinstance(transformer_layers, dict): + transformer_layers = transformer_layers[attribute] + transformer_units = config[TRANSFORMER_SIZE] + if isinstance(transformer_units, dict): + transformer_units = transformer_units[attribute] + if transformer_layers > 0 and (not transformer_units or transformer_units < 1): + transformer_units = DEFAULT_TRANSFORMER_SIZE + + return transformer_layers, transformer_units + + def _prepare_transformer( + self, attribute: Text, config: Dict[Text, Any] + ) -> Tuple[int, int]: + """Creates a transformer & returns its number of layers and output units.""" + transformer_layers, transformer_units = self._get_transformer_dimensions( + attribute, config + ) + self._tf_layers[self.TRANSFORMER] = prepare_transformer_layer( + attribute_name=attribute, + config=config, + num_layers=transformer_layers, + units=transformer_units, + drop_rate=config[DROP_RATE], + unidirectional=config[UNIDIRECTIONAL_ENCODER], + ) + return transformer_layers, transformer_units + + def _prepare_masked_language_modeling( + self, + attribute: Text, + attribute_signature: Dict[Text, List[FeatureSignature]], + config: Dict[Text, Any], + ) -> None: + """Prepares masking and computing helper variables for masked language modeling. + + Only done for the text attribute and only if sequence-level (token-level) + features are present (MLM requires token-level information). + """ + if attribute == TEXT and SEQUENCE in attribute_signature and config[MASKED_LM]: + self._enables_mlm = True + self._tf_layers[self.MLM_INPUT_MASK] = layers.InputMask() + + # Unique IDs of different token types are needed to construct the possible + # label space for MLM. If dense features are present, they're used as such + # IDs, othwerise sparse features are embedded by a non-trainable + # DenseForSparse layer to create small embeddings that serve as IDs. + expect_dense_seq_features = any( + [not signature.is_sparse for signature in attribute_signature[SEQUENCE]] + ) + if not expect_dense_seq_features: + self._tf_layers[ + self.SPARSE_TO_DENSE_FOR_TOKEN_IDS + ] = layers.DenseForSparse( + units=2, + use_bias=False, + trainable=False, + name=f"{self.SPARSE_TO_DENSE_FOR_TOKEN_IDS}.{attribute}", + ) + + def _calculate_output_units( + self, + attribute: Text, + transformer_layers: int, + transformer_units: int, + config: Dict[Text, Any], + ) -> int: + """Determines the output units based on what layer components are present. + + The size depends on which component is the last created one in the internal + pipeline that is `RasaFeatureCombiningLayer` -> `Ffnn` -> `Transformer`, since + not all the components are always created. + """ + # transformer is the last component + if transformer_layers > 0: + return transformer_units + + # the Ffnn block is the last component + if len(config[HIDDEN_LAYERS_SIZES][attribute]) > 0: + # this is the output size of the last layer of the Ffnn block + return config[HIDDEN_LAYERS_SIZES][attribute][-1] + + # only the RasaFeatureCombiningLayer is present + return self._tf_layers[self.FEATURE_COMBINING].output_units + + def _features_as_token_ids( + self, features: List[Union[tf.Tensor, tf.SparseTensor]] + ) -> Optional[tf.Tensor]: + """Creates dense labels (token IDs) used for negative sampling in MLM.""" + # If there are dense features, we use them as labels - taking the first dense + # feature in the list, but any other dense feature would do the job. + for f in features: + if not isinstance(f, tf.SparseTensor): + return tf.stop_gradient(f) + + # If no dense features are found, use a sparse feature but convert it into + # a dense one first. + for f in features: + if isinstance(f, tf.SparseTensor): + return tf.stop_gradient( + self._tf_layers[self.SPARSE_TO_DENSE_FOR_TOKEN_IDS](f) + ) + + return None + + def _create_mlm_tensors( + self, + sequence_features: List[Union[tf.Tensor, tf.SparseTensor]], + seq_sent_features: tf.Tensor, + mask_sequence: tf.Tensor, + sentence_features_present: bool, + training: bool, + ) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor]: + """Produces helper variables for masked language modelling (only in training). + + The `token_ids` embeddings can be viewed as token-level labels/unique IDs of all + input tokens (to be used later in the MLM loss) because these embeddings aren't + affected by dropout or masking and are effectively always unique for different + input tokens (and same for the same tokens). + `token_ids` share the batch and sequence dimension with the combined sequence- + and sentence-level features, the last dimension is unimportant and mimics the + first dense sequence-level feature in the list of features, or alternatively the + last dimension will have size 2 if there are only sparse sequence features + present. + """ + token_ids = self._features_as_token_ids(sequence_features) + + # Pad in the sequence dimension to match the shape of combined sequence- and + # sentence-level features. This means padding by 1 if sentence-level features + # are present (those effectively have sequence length of 1) and not padding + # otherwise. + if sentence_features_present: + token_ids = tf.pad(token_ids, [[0, 0], [0, 1], [0, 0]]) + mask_sequence = tf.pad(mask_sequence, [[0, 0], [0, 1], [0, 0]]) + + # mlm_boolean_mask has the same shape as the tensor with all combined features + # (except the last dimension), with True meaning tokens that are masked and + # False meaning tokens that aren't masked or that are fake (padded) tokens. + # Note that only sequence-level features are masked, nothing happens to the + # sentence-level features in the combined features tensor. + seq_sent_features, mlm_boolean_mask = self._tf_layers[self.MLM_INPUT_MASK]( + seq_sent_features, mask_sequence, training + ) + + return seq_sent_features, token_ids, mlm_boolean_mask + + def call( + self, + inputs: Tuple[ + List[Union[tf.Tensor, tf.SparseTensor]], + List[Union[tf.Tensor, tf.SparseTensor]], + tf.Tensor, + ], + training: bool = False, + ) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor, tf.Tensor, tf.Tensor, tf.Tensor]: + """Combines all of an attribute's features and embeds using a transformer. + + Arguments: + inputs: Tuple containing: + sequence_features: Dense or sparse tensors representing different + token-level features. + sentence_features: Dense or sparse tensors representing different + sentence-level features. + sequence_feature_lengths: A tensor containing the real sequence length + (the number of real -- not padding -- tokens) for each example in + the batch. + training: A flag indicating whether the layer should behave in training mode + (applying dropout to sparse tensors if applicable) or in inference mode + (not applying dropout). + + Returns: + outputs: Tensor with all features combined, masked (if doing MLM) and + embedded with a transformer. + seq_sent_features: Tensor with all features combined from just before the + masking and transformer is applied + mask_combined_sequence_sentence: A binary mask with 1s in place of real + features in the combined feature tensor, and 0s in padded positions with + fake features. + token_ids: Tensor with dense token-level features which can serve as + IDs (unique embeddings) of all the different tokens found in the batch. + Empty tensor if not doing MLM. + mlm_boolean_mask: A boolean mask with `True` where real tokens in `outputs` + were masked and `False` elsewhere. Empty tensor if not doing MLM. + attention_weights: Tensor containing self-attention weights received + from the underlying transformer. Empty tensor if the transformer has 0 + layers. + """ + sequence_features = inputs[0] + sentence_features = inputs[1] + sequence_feature_lengths = inputs[2] + + # Combine all features (sparse/dense, sequence-/sentence-level) into one tensor, + # also get a binary mask that has 1s at positions with real features and 0s at + # padded positions. + seq_sent_features, mask_combined_sequence_sentence = self._tf_layers[ + self.FEATURE_COMBINING + ]((sequence_features, sentence_features, sequence_feature_lengths)) + + # Apply one or more dense layers. + seq_sent_features = self._tf_layers[self.FFNN](seq_sent_features, training) + + # If using masked language modeling, mask the transformer inputs and get labels + # for the masked tokens and a boolean mask. Note that TED does not use MLM loss, + # hence using masked language modeling (if enabled) becomes just input dropout. + if self._enables_mlm and training: + mask_sequence = compute_mask(sequence_feature_lengths) + ( + seq_sent_features_masked, + token_ids, + mlm_boolean_mask, + ) = self._create_mlm_tensors( + sequence_features, + seq_sent_features, + mask_sequence, + sentence_features_present=len(sentence_features) > 0, + training=training, + ) + else: + # tf.zeros((0,)) is an alternative to None + token_ids = tf.zeros((0,)) + mlm_boolean_mask = tf.zeros((0,)) + seq_sent_features_masked = seq_sent_features + + # Apply the transformer (if present), hence reducing a sequences of features per + # input example into a simple fixed-size embedding. + if self._has_transformer: + mask_padding = 1 - mask_combined_sequence_sentence + outputs, attention_weights = self._tf_layers[self.TRANSFORMER]( + seq_sent_features_masked, mask_padding, training + ) + outputs = tf.nn.gelu(outputs) + else: + # tf.zeros((0,)) is an alternative to None + outputs, attention_weights = seq_sent_features_masked, tf.zeros((0,)) + + return ( + outputs, + seq_sent_features, + mask_combined_sequence_sentence, + token_ids, + mlm_boolean_mask, + attention_weights, + ) + + +def compute_mask(sequence_lengths: tf.Tensor) -> tf.Tensor: + """Computes binary mask given real sequence lengths. + + Takes a 1-D tensor of shape `(batch_size,)` containing the lengths of sequences + (in terms of number of tokens) in the batch. Creates a binary mask of shape + `(batch_size, max_seq_length, 1)` with 1s at positions with real tokens and 0s + elsewhere. + """ + mask = tf.sequence_mask(sequence_lengths, dtype=tf.float32) + return tf.expand_dims(mask, -1) + + +def prepare_transformer_layer( + attribute_name: Text, + config: Dict[Text, Any], + num_layers: int, + units: int, + drop_rate: float, + unidirectional: bool, +) -> Union[ + TransformerEncoder, + Callable[ + [tf.Tensor, Optional[tf.Tensor], Optional[Union[tf.Tensor, bool]]], + Tuple[tf.Tensor, Optional[tf.Tensor]], + ], +]: + """Creates & returns a transformer encoder, potentially with 0 layers.""" + if num_layers > 0: + return TransformerEncoder( + num_layers, + units, + config[NUM_HEADS], + units * 4, + config[REGULARIZATION_CONSTANT], + dropout_rate=drop_rate, + attention_dropout_rate=config[DROP_RATE_ATTENTION], + density=config[CONNECTION_DENSITY], + unidirectional=unidirectional, + use_key_relative_position=config[KEY_RELATIVE_ATTENTION], + use_value_relative_position=config[VALUE_RELATIVE_ATTENTION], + max_relative_position=config[MAX_RELATIVE_POSITION], + name=f"{attribute_name}_encoder", + ) + # create lambda so that it can be used later without the check + return lambda x, mask, training: (x, None) diff --git a/rasa/utils/tensorflow/transformer.py b/rasa/utils/tensorflow/transformer.py new file mode 100644 index 0000000..f2a2d66 --- /dev/null +++ b/rasa/utils/tensorflow/transformer.py @@ -0,0 +1,644 @@ +from typing import Optional, Text, Tuple, Union + +import numpy as np +import tensorflow as tf + +# TODO: The following is not (yet) available via tf.keras +from keras.utils.control_flow_util import smart_cond +from tensorflow.keras import backend as K + +import rasa.shared.utils.cli +from rasa.utils.tensorflow.layers import RandomlyConnectedDense + + +# from https://www.tensorflow.org/tutorials/text/transformer +# and https://github.com/tensorflow/tensor2tensor +class MultiHeadAttention(tf.keras.layers.Layer): + """Multi-headed attention layer. + + Arguments: + units: Positive integer, output dim of hidden layer. + num_heads: Positive integer, number of heads + to repeat the same attention structure. + attention_dropout_rate: Float, dropout rate inside attention for training. + density: Approximate fraction of trainable weights (in + `RandomlyConnectedDense` layers). + unidirectional: Boolean, use a unidirectional or bidirectional encoder. + use_key_relative_position: Boolean, if 'True' use key + relative embeddings in attention. + use_value_relative_position: Boolean, if 'True' use value + relative embeddings in attention. + max_relative_position: Positive integer, max position for relative embeddings. + heads_share_relative_embedding: Boolean, if 'True' + heads will share relative embeddings. + """ + + def __init__( + self, + units: int, + num_heads: int, + attention_dropout_rate: float = 0.0, + density: float = 0.2, + unidirectional: bool = False, + use_key_relative_position: bool = False, + use_value_relative_position: bool = False, + max_relative_position: int = 5, + heads_share_relative_embedding: bool = False, + ) -> None: + super().__init__() + + if units % num_heads != 0: + rasa.shared.utils.cli.print_error_and_exit( + f"Value Error: The given transformer size {units} should be a " + f"multiple of the number of attention heads {num_heads}." + ) + + self.num_heads = num_heads + self.units = units + self.attention_dropout_rate = attention_dropout_rate + self.unidirectional = unidirectional + self.use_key_relative_position = use_key_relative_position + self.use_value_relative_position = use_value_relative_position + self.relative_length = max_relative_position + self.relative_length += 1 # include current time + self.heads_share_relative_embedding = heads_share_relative_embedding + + self._depth = units // self.num_heads + + # process queries + self._query_dense_layer = RandomlyConnectedDense( + units=units, use_bias=False, density=density + ) + # process keys + self._key_dense_layer = RandomlyConnectedDense( + units=units, use_bias=False, density=density + ) + # process values + self._value_dense_layer = RandomlyConnectedDense( + units=units, use_bias=False, density=density + ) + # process attention output + self._output_dense_layer = RandomlyConnectedDense(units=units, density=density) + + self._create_relative_embeddings() + + def _create_relative_embeddings(self) -> None: + """Create relative embeddings.""" + relative_embedding_shape: Optional[ + Union[Tuple[int, int], Tuple[int, int, int]] + ] = None + self.key_relative_embeddings = None + self.value_relative_embeddings = None + + if self.use_key_relative_position or self.use_value_relative_position: + if not self.relative_length: + raise ValueError( + f"Max relative position {self.relative_length} " + f"should be > 0 when using relative attention." + ) + + if self.unidirectional: + relative_length = self.relative_length + else: + relative_length = 2 * self.relative_length - 1 + + if self.heads_share_relative_embedding: + relative_embedding_shape = (relative_length, self._depth) + else: + relative_embedding_shape = ( + self.num_heads, + relative_length, + self._depth, + ) + + if self.use_key_relative_position: + self.key_relative_embeddings = self.add_weight( + shape=relative_embedding_shape, name="key_relative_embeddings" + ) + + if self.use_value_relative_position: + self.value_relative_embeddings = self.add_weight( + shape=relative_embedding_shape, name="value_relative_embeddings" + ) + + def _pad_relative_embeddings(self, x: tf.Tensor, length: tf.Tensor) -> tf.Tensor: + # pad the left side to length + pad_left = x[:, :, :, :1, :] + pad_left = tf.tile(pad_left, (1, 1, 1, length - self.relative_length, 1)) + + # pad the right side to length + if self.unidirectional: + right_relative_length = 1 # current time + pad_right = tf.zeros_like(x[:, :, :, -1:, :]) + else: + right_relative_length = self.relative_length + pad_right = x[:, :, :, -1:, :] + pad_right = tf.tile(pad_right, (1, 1, 1, length - right_relative_length, 1)) + + return tf.concat([pad_left, x, pad_right], axis=-2) + + def _slice_relative_embeddings(self, x: tf.Tensor, length: tf.Tensor) -> tf.Tensor: + if self.unidirectional: + # pad the right side to relative_length + pad_right = tf.zeros_like(x[:, :, :, -1:, :]) + pad_right = tf.tile(pad_right, (1, 1, 1, self.relative_length - 1, 1)) + x = tf.concat([x, pad_right], axis=-2) + + extra_length = self.relative_length - length + full_length = tf.shape(x)[-2] + return x[:, :, :, extra_length : full_length - extra_length, :] + + def _relative_to_absolute_position(self, x: tf.Tensor) -> tf.Tensor: + """Universal method to convert tensor from relative to absolute indexing. + + "Slides" relative embeddings by 45 degree. + + Arguments: + x: A tensor of shape (batch, num_heads, length, relative_length, depth) + or (batch, num_heads, length, relative_length) + + Returns: + A tensor of shape (batch, num_heads, length, length, depth) + or (batch, num_heads, length, length) + """ + + x_dim = len(x.shape) + + if x_dim < 4 or x_dim > 5: + raise ValueError( + f"Relative tensor has a wrong shape {x.shape}, " + f"it should have 4 or 5 dimensions." + ) + if x_dim == 4: + # add fake depth dimension + x = tf.expand_dims(x, axis=-1) + + batch = tf.shape(x)[0] + num_heads = tf.shape(x)[1] + length = tf.shape(x)[2] + depth = tf.shape(x)[-1] + + x = tf.cond( + length > self.relative_length, + lambda: self._pad_relative_embeddings(x, length), + lambda: self._slice_relative_embeddings(x, length), + ) + + # add a column of zeros to "slide" columns to diagonals through reshape + pad_shift = tf.zeros_like(x[:, :, :, -1:, :]) + x = tf.concat([x, pad_shift], axis=-2) + + # flatten length dimensions + x = tf.reshape(x, (batch, num_heads, -1, depth)) + width = 2 * length + + # add zeros so that the result of back reshape is still a matrix + pad_flat = tf.zeros_like( + x[:, :, : ((width - 1) - width * length % (width - 1)) % (width - 1), :] + ) + x = tf.concat([x, pad_flat], axis=-2) + + # "slide" columns to diagonals through reshape + x = tf.reshape(x, (batch, num_heads, -1, width - 1, depth)) + + # slice needed "diagonal" matrix + x = x[:, :, :-1, -length:, :] + + if x_dim == 4: + # remove fake depth dimension + x = tf.squeeze(x, axis=-1) + + return x + + def _matmul_with_relative_keys(self, x: tf.Tensor) -> tf.Tensor: + y = self.key_relative_embeddings + + if self.heads_share_relative_embedding: + matmul = tf.einsum("bhld,md->bhlm", x, y) + else: + matmul = tf.einsum("bhld,hmd->bhlm", x, y) + + return self._relative_to_absolute_position(matmul) + + def _tile_relative_embeddings(self, x: tf.Tensor, length: tf.Tensor) -> tf.Tensor: + if self.heads_share_relative_embedding: + x = tf.expand_dims(x, axis=0) # add head dimension + + x = tf.expand_dims(x, axis=1) # add length dimension + x = tf.tile(x, (1, length, 1, 1)) + return tf.expand_dims(x, axis=0) # add batch dimension + + def _squeeze_relative_embeddings(self, x: tf.Tensor) -> tf.Tensor: + x = tf.squeeze(x, axis=0) # squeeze batch dimension + if self.heads_share_relative_embedding: + x = tf.squeeze(x, axis=1) # squeeze head dimension + return x + + def _matmul_with_relative_values(self, x: tf.Tensor) -> tf.Tensor: + y = self._tile_relative_embeddings( + self.value_relative_embeddings, tf.shape(x)[-2] + ) + y = self._relative_to_absolute_position(y) + y = self._squeeze_relative_embeddings(y) + + if self.heads_share_relative_embedding: + return tf.einsum("bhlm,lmd->bhld", x, y) + else: + return tf.einsum("bhlm,hlmd->bhld", x, y) + + def _drop_attention_logits( + self, logits: tf.Tensor, pad_mask: tf.Tensor, training: tf.Tensor + ) -> tf.Tensor: + def droped_logits() -> tf.Tensor: + keep_prob = tf.random.uniform(tf.shape(logits), 0, 1) + pad_mask + drop_mask = tf.cast( + tf.less(keep_prob, self.attention_dropout_rate), logits.dtype + ) + + return logits + drop_mask * -1e9 + + return smart_cond(training, droped_logits, lambda: tf.identity(logits)) + + def _scaled_dot_product_attention( + self, + query: tf.Tensor, + key: tf.Tensor, + value: tf.Tensor, + pad_mask: tf.Tensor, + training: tf.Tensor, + ) -> Tuple[tf.Tensor, tf.Tensor]: + """Calculate the attention weights. + + query, key, value must have matching leading dimensions. + key, value must have matching penultimate dimension, + i.e.: seq_len_k = seq_len_v. + The mask has different shapes depending on its type (padding or look ahead) + but it must be broadcastable for addition. + + Arguments: + query: A tensor with shape (..., length, depth). + key: A tensor with shape (..., length, depth). + value: A tensor with shape (..., length, depth). + pad_mask: Float tensor with shape broadcastable + to (..., length, length). Defaults to None. + + Returns: + output: A tensor with shape (..., length, depth). + attention_weights: A tensor with shape (..., length, length). + """ + + matmul_qk = tf.matmul(query, key, transpose_b=True) # (..., length, length) + + if self.use_key_relative_position: + matmul_qk += self._matmul_with_relative_keys(query) + + # scale matmul_qk + dk = tf.cast(tf.shape(key)[-1], tf.float32) + logits = matmul_qk / tf.math.sqrt(dk) + + # add the mask to the scaled tensor. + if pad_mask is not None: + logits += pad_mask * -1e9 + + # apply attention dropout before softmax to maintain attention_weights norm as 1 + if self.attention_dropout_rate > 0: + logits = self._drop_attention_logits(logits, pad_mask, training) + + # softmax is normalized on the last axis (length) so that the scores + # add up to 1. + attention_weights = tf.nn.softmax(logits, axis=-1) # (..., length, length) + + output = tf.matmul(attention_weights, value) # (..., length, depth) + if self.use_value_relative_position: + output += self._matmul_with_relative_values(attention_weights) + + return output, attention_weights + + def _split_heads(self, x: tf.Tensor) -> tf.Tensor: + """Split the last dimension into (num_heads, depth). + + Transpose the result such that the shape is + (batch_size, num_heads, length, depth) + """ + + x = tf.reshape(x, (tf.shape(x)[0], -1, self.num_heads, self._depth)) + return tf.transpose(x, perm=[0, 2, 1, 3]) + + def _combine_heads(self, x: tf.Tensor) -> tf.Tensor: + """Inverse of split_heads. + + Args: + x: A Tensor with shape [batch, num_heads, length, units / num_heads] + + Returns: + A Tensor with shape [batch, length, units] + """ + + # (batch_size, length, num_heads, depth) + x = tf.transpose(x, perm=[0, 2, 1, 3]) + # (batch_size, length, units) + return tf.reshape(x, (tf.shape(x)[0], -1, self.units)) + + # noinspection PyMethodOverriding + def call( + self, + query_input: tf.Tensor, + source_input: tf.Tensor, + pad_mask: Optional[tf.Tensor] = None, + training: Optional[Union[tf.Tensor, bool]] = None, + ) -> Tuple[tf.Tensor, tf.Tensor]: + """Apply attention mechanism to query_input and source_input. + + Arguments: + query_input: A tensor with shape [batch_size, length, input_size]. + source_input: A tensor with shape [batch_size, length, input_size]. + pad_mask: Float tensor with shape broadcastable + to (..., length, length). Defaults to None. + training: A bool, whether in training mode or not. + + Returns: + Attention layer output with shape [batch_size, length, units] + """ + if training is None: + training = K.learning_phase() + + query = self._query_dense_layer(query_input) # (batch_size, length, units) + key = self._key_dense_layer(source_input) # (batch_size, length, units) + value = self._value_dense_layer(source_input) # (batch_size, length, units) + + query = self._split_heads(query) # (batch_size, num_heads, length, depth) + key = self._split_heads(key) # (batch_size, num_heads, length, depth) + value = self._split_heads(value) # (batch_size, num_heads, length, depth) + + attention, attention_weights = self._scaled_dot_product_attention( + query, key, value, pad_mask, training + ) + # attention.shape == (batch_size, num_heads, length, depth) + # attention_weights.shape == (batch_size, num_heads, length, length) + attention = self._combine_heads(attention) # (batch_size, length, units) + + output = self._output_dense_layer(attention) # (batch_size, length, units) + + return output, attention_weights + + +class TransformerEncoderLayer(tf.keras.layers.Layer): + """Transformer encoder layer. + + The layer is composed of the sublayers: + 1. Self-attention layer + 2. Feed-forward network (which is 2 fully-connected layers) + + Arguments: + units: Positive integer, output dim of hidden layer. + num_heads: Positive integer, number of heads + to repeat the same attention structure. + filter_units: Positive integer, output dim of the first ffn hidden layer. + dropout_rate: Float between 0 and 1; fraction of the input units to drop. + attention_dropout_rate: Float, dropout rate inside attention for training. + density: Fraction of trainable weights in `RandomlyConnectedDense` layers. + unidirectional: Boolean, use a unidirectional or bidirectional encoder. + use_key_relative_position: Boolean, if 'True' use key + relative embeddings in attention. + use_value_relative_position: Boolean, if 'True' use value + relative embeddings in attention. + max_relative_position: Positive integer, max position for relative embeddings. + heads_share_relative_embedding: Boolean, if 'True' + heads will share relative embeddings. + """ + + def __init__( + self, + units: int, + num_heads: int, + filter_units: int, + dropout_rate: float = 0.1, + attention_dropout_rate: float = 0.0, + density: float = 0.2, + unidirectional: bool = False, + use_key_relative_position: bool = False, + use_value_relative_position: bool = False, + max_relative_position: int = 5, + heads_share_relative_embedding: bool = False, + ) -> None: + super().__init__() + + self._layer_norm = tf.keras.layers.LayerNormalization(epsilon=1e-6) + self._mha = MultiHeadAttention( + units, + num_heads, + attention_dropout_rate, + density, + unidirectional, + use_key_relative_position, + use_value_relative_position, + max_relative_position, + heads_share_relative_embedding, + ) + self._dropout = tf.keras.layers.Dropout(dropout_rate) + + self._ffn_layers = [ + tf.keras.layers.LayerNormalization(epsilon=1e-6), + RandomlyConnectedDense( + units=filter_units, activation=tf.nn.gelu, density=density + ), # (batch_size, length, filter_units) + tf.keras.layers.Dropout(dropout_rate), + RandomlyConnectedDense( + units=units, density=density + ), # (batch_size, length, units) + tf.keras.layers.Dropout(dropout_rate), + ] + + def call( + self, + x: tf.Tensor, + pad_mask: Optional[tf.Tensor] = None, + training: Optional[Union[tf.Tensor, bool]] = None, + ) -> Tuple[tf.Tensor, tf.Tensor]: + """Apply transformer encoder layer. + + Arguments: + x: A tensor with shape [batch_size, length, units]. + pad_mask: Float tensor with shape broadcastable + to (..., length, length). Defaults to None. + training: A bool, whether in training mode or not. + + Returns: + Transformer encoder layer output with shape [batch_size, length, units] + """ + if training is None: + training = K.learning_phase() + + x_norm = self._layer_norm(x) # (batch_size, length, units) + attn_out, attn_weights = self._mha( + x_norm, x_norm, pad_mask=pad_mask, training=training + ) + attn_out = self._dropout(attn_out, training=training) + x += attn_out + + ffn_out = x # (batch_size, length, units) + for layer in self._ffn_layers: + ffn_out = layer(ffn_out, training=training) + x += ffn_out + + # (batch_size, length, units), (batch_size, num_heads, length, length) + return x, attn_weights + + +class TransformerEncoder(tf.keras.layers.Layer): + """Transformer encoder. + + Encoder stack is made up of `num_layers` identical encoder layers. + + Arguments: + num_layers: Positive integer, number of encoder layers. + units: Positive integer, output dim of hidden layer. + num_heads: Positive integer, number of heads + to repeat the same attention structure. + filter_units: Positive integer, output dim of the first ffn hidden layer. + reg_lambda: Float, regularization factor. + dropout_rate: Float between 0 and 1; fraction of the input units to drop. + attention_dropout_rate: Float, dropout rate inside attention for training. + density: Approximate fraction of trainable weights (in + `RandomlyConnectedDense` layers). + unidirectional: Boolean, use a unidirectional or bidirectional encoder. + use_key_relative_position: Boolean, if 'True' use key + relative embeddings in attention. + use_value_relative_position: Boolean, if 'True' use value + relative embeddings in attention. + max_relative_position: Positive integer, max position for relative embeddings. + heads_share_relative_embedding: Boolean, if 'True' + heads will share relative embeddings. + name: Optional name of the layer. + """ + + def __init__( + self, + num_layers: int, + units: int, + num_heads: int, + filter_units: int, + reg_lambda: float, + dropout_rate: float = 0.1, + attention_dropout_rate: float = 0.0, + density: float = 0.2, + unidirectional: bool = False, + use_key_relative_position: bool = False, + use_value_relative_position: bool = False, + max_relative_position: int = 5, + heads_share_relative_embedding: bool = False, + name: Optional[Text] = None, + ) -> None: + super().__init__(name=name) + + self.units = units + self.unidirectional = unidirectional + + l2_regularizer = tf.keras.regularizers.l2(reg_lambda) + self._embedding = RandomlyConnectedDense( + units=units, kernel_regularizer=l2_regularizer, density=density + ) + # positional encoding helpers + self._angles = self._get_angles() + self._even_indices = np.arange(0, self.units, 2, dtype=np.int32)[:, np.newaxis] + self._odd_indices = np.arange(1, self.units, 2, dtype=np.int32)[:, np.newaxis] + + self._dropout = tf.keras.layers.Dropout(dropout_rate) + + self._enc_layers = [ + TransformerEncoderLayer( + units, + num_heads, + filter_units, + dropout_rate, + attention_dropout_rate, + density, + unidirectional, + use_key_relative_position, + use_value_relative_position, + max_relative_position, + heads_share_relative_embedding, + ) + for _ in range(num_layers) + ] + self._layer_norm = tf.keras.layers.LayerNormalization(epsilon=1e-6) + + def _get_angles(self) -> np.ndarray: + array_2d = np.arange(self.units)[np.newaxis, :] + return 1 / np.power(10000, (2 * (array_2d // 2)) / np.float32(self.units)) + + def _positional_encoding(self, max_position: tf.Tensor) -> tf.Tensor: + max_position = tf.cast(max_position, dtype=tf.float32) + angle_rads = tf.range(max_position)[:, tf.newaxis] * self._angles + + # transpose for easy slicing + angle_rads = tf.transpose(angle_rads, perm=[1, 0]) + shape = tf.shape(angle_rads) + # apply sin to even indices in the array; 2i + sin_even = tf.sin(tf.gather_nd(angle_rads, self._even_indices)) + pos_encoding_even = tf.scatter_nd(self._even_indices, sin_even, shape) + # apply cos to odd indices in the array; 2i+1 + cos_odd = tf.cos(tf.gather_nd(angle_rads, self._odd_indices)) + pos_encoding_odd = tf.scatter_nd(self._odd_indices, cos_odd, shape) + # combine even and odd positions and transpose back + pos_encoding = tf.transpose(pos_encoding_even + pos_encoding_odd, perm=[1, 0]) + # add batch dimension + return tf.stop_gradient(pos_encoding[tf.newaxis, ...]) + + @staticmethod + def _look_ahead_pad_mask(max_position: tf.Tensor) -> tf.Tensor: + pad_mask = 1 - tf.linalg.band_part(tf.ones((max_position, max_position)), -1, 0) + return pad_mask[tf.newaxis, tf.newaxis, :, :] # (1, 1, seq_len, seq_len) + + def call( + self, + x: tf.Tensor, + pad_mask: Optional[tf.Tensor] = None, + training: Optional[Union[tf.Tensor, bool]] = None, + ) -> Tuple[tf.Tensor, tf.Tensor]: + """Apply transformer encoder. + + Arguments: + x: A tensor with shape [batch_size, length, input_size]. + pad_mask: Float tensor with shape broadcastable + to (..., length, length). Defaults to None. + training: A bool, whether in training mode or not. + + Returns: + Transformer encoder output with shape [batch_size, length, units] + """ + # adding embedding and position encoding. + x = self._embedding(x) # (batch_size, length, units) + x *= tf.math.sqrt(tf.cast(self.units, tf.float32)) + x += self._positional_encoding(tf.shape(x)[1]) + x = self._dropout(x, training=training) + + if pad_mask is not None: + pad_mask = tf.squeeze(pad_mask, -1) # (batch_size, length) + pad_mask = pad_mask[:, tf.newaxis, tf.newaxis, :] + # pad_mask.shape = (batch_size, 1, 1, length) + if self.unidirectional: + # add look ahead pad mask to emulate unidirectional behavior + pad_mask = tf.minimum( + 1.0, pad_mask + self._look_ahead_pad_mask(tf.shape(pad_mask)[-1]) + ) # (batch_size, 1, length, length) + + layer_attention_weights = [] + + for layer in self._enc_layers: + x, attn_weights = layer(x, pad_mask=pad_mask, training=training) + layer_attention_weights.append(attn_weights) + + # if normalization is done in encoding layers, then it should also be done + # on the output, since the output can grow very large, being the sum of + # a whole stack of unnormalized layer outputs. + x = self._layer_norm(x) # (batch_size, length, units) + + # Keep the batch dimension on the first axis + attention_weights_as_output = tf.transpose( + tf.stack(layer_attention_weights), (1, 0, 2, 3, 4) + ) + + # (batch_size, length, units), + # (batch_size, num_layers, num_heads, length, length) + return x, attention_weights_as_output diff --git a/rasa/utils/tensorflow/types.py b/rasa/utils/tensorflow/types.py new file mode 100644 index 0000000..26cf5a0 --- /dev/null +++ b/rasa/utils/tensorflow/types.py @@ -0,0 +1,6 @@ +from typing import Tuple, Union +import tensorflow as tf +import numpy as np + +BatchData = Union[Tuple[tf.Tensor, ...], Tuple[np.ndarray, ...]] +MaybeNestedBatchData = Union[Tuple[BatchData, ...], BatchData] diff --git a/rasa/utils/train_utils.py b/rasa/utils/train_utils.py new file mode 100644 index 0000000..764507d --- /dev/null +++ b/rasa/utils/train_utils.py @@ -0,0 +1,572 @@ +from pathlib import Path +import numpy as np +from typing import Optional, Text, Dict, Any, Union, List, Tuple, TYPE_CHECKING + +import rasa.shared.utils.common +import rasa.shared.utils.io +import rasa.nlu.utils.bilou_utils +from rasa.shared.constants import NEXT_MAJOR_VERSION_FOR_DEPRECATIONS +from rasa.nlu.constants import NUMBER_OF_SUB_TOKENS +import rasa.utils.io as io_utils +from rasa.utils.tensorflow.constants import ( + LOSS_TYPE, + RANKING_LENGTH, + RENORMALIZE_CONFIDENCES, + SIMILARITY_TYPE, + EVAL_NUM_EXAMPLES, + EVAL_NUM_EPOCHS, + EPOCHS, + SOFTMAX, + MARGIN, + AUTO, + INNER, + COSINE, + SEQUENCE, + CROSS_ENTROPY, + CONSTRAIN_SIMILARITIES, + MODEL_CONFIDENCE, + TOLERANCE, + CHECKPOINT_MODEL, +) +from rasa.utils.tensorflow.callback import RasaTrainingLogger, RasaModelCheckpoint +from rasa.utils.tensorflow.data_generator import RasaBatchDataGenerator +from rasa.utils.tensorflow.model_data import RasaModelData +from rasa.shared.nlu.constants import SPLIT_ENTITIES_BY_COMMA +from rasa.shared.exceptions import InvalidConfigException + +if TYPE_CHECKING: + from rasa.nlu.extractors.extractor import EntityTagSpec + from rasa.nlu.tokenizers.tokenizer import Token + from tensorflow.keras.callbacks import Callback + + +def rank_and_mask( + confidences: np.ndarray, ranking_length: int = 0, renormalize: bool = False +) -> Tuple[np.ndarray, np.ndarray]: + """Computes a ranking of the given confidences. + + First, it computes a list containing the indices that would sort all the given + confidences in decreasing order. + If a `ranking_length` is specified, then only the indices for the `ranking_length` + largest confidences will be returned and all other confidences (i.e. whose indices + we do not return) will be masked by setting them to 0. + Moreover, if `renormalize` is set to `True`, then the confidences will + additionally be renormalised by dividing them by their sum. + + We assume that the given confidences sum up to 1 and, if the + `ranking_length` is 0 or larger than the given number of confidences, + we set the `ranking_length` to the number of confidences. + Hence, in this case the confidences won't be modified. + + Args: + confidences: a 1-d array of confidences that are non-negative and sum up to 1 + ranking_length: the size of the ranking to be computed. If set to 0 or + something larger than the number of given confidences, then this is set + to the exact number of given confidences. + renormalize: determines whether the masked confidences should be renormalised. + return_indices: + Returns: + indices of the top `ranking_length` confidences and an array of the same + shape as the given confidences that contains the possibly masked and + renormalized confidence values + """ + indices = confidences.argsort()[::-1] + confidences = confidences.copy() + + if 0 < ranking_length < len(confidences): + confidences[indices[ranking_length:]] = 0 + + if renormalize and np.sum(confidences) > 0: + confidences = confidences / np.sum(confidences) + + indices = indices[:ranking_length] + + return indices, confidences + + +def update_similarity_type(config: Dict[Text, Any]) -> Dict[Text, Any]: + """If SIMILARITY_TYPE is set to 'auto', update the SIMILARITY_TYPE depending + on the LOSS_TYPE. + + Args: + config: model configuration + + Returns: updated model configuration + """ + if config.get(SIMILARITY_TYPE) == AUTO: + if config[LOSS_TYPE] == CROSS_ENTROPY: + config[SIMILARITY_TYPE] = INNER + elif config[LOSS_TYPE] == MARGIN: + config[SIMILARITY_TYPE] = COSINE + + return config + + +def align_token_features( + list_of_tokens: List[List["Token"]], + in_token_features: np.ndarray, + shape: Optional[Tuple] = None, +) -> np.ndarray: + """Align token features to match tokens. + + ConveRTFeaturizer and LanguageModelFeaturizer might split up tokens into sub-tokens. + We need to take the mean of the sub-token vectors and take that as token vector. + + Args: + list_of_tokens: tokens for examples + in_token_features: token features from ConveRT + shape: shape of feature matrix + + Returns: + Token features. + """ + if shape is None: + shape = in_token_features.shape + out_token_features = np.zeros(shape) + + for example_idx, example_tokens in enumerate(list_of_tokens): + offset = 0 + for token_idx, token in enumerate(example_tokens): + number_sub_words = token.get(NUMBER_OF_SUB_TOKENS, 1) + + if number_sub_words > 1: + token_start_idx = token_idx + offset + token_end_idx = token_idx + offset + number_sub_words + + mean_vec = np.mean( + in_token_features[example_idx][token_start_idx:token_end_idx], + axis=0, + ) + + offset += number_sub_words - 1 + + out_token_features[example_idx][token_idx] = mean_vec + else: + out_token_features[example_idx][token_idx] = in_token_features[ + example_idx + ][token_idx + offset] + + return out_token_features + + +def update_evaluation_parameters(config: Dict[Text, Any]) -> Dict[Text, Any]: + """If EVAL_NUM_EPOCHS is set to -1, evaluate at the end of the training. + + Args: + config: model configuration + + Returns: updated model configuration + """ + if config[EVAL_NUM_EPOCHS] == -1: + config[EVAL_NUM_EPOCHS] = config[EPOCHS] + elif config[EVAL_NUM_EPOCHS] < 1: + raise InvalidConfigException( + f"'{EVAL_NUM_EPOCHS}' is set to " + f"'{config[EVAL_NUM_EPOCHS]}'. " + "Only values either equal to -1 or greater than 0 are allowed for this " + "parameter." + ) + if config[CHECKPOINT_MODEL] and config[EVAL_NUM_EXAMPLES] == 0: + config[CHECKPOINT_MODEL] = False + return config + + +def load_tf_hub_model(model_url: Text) -> Any: + """Load model from cache if possible, otherwise from TFHub.""" + import os + from tensorflow_hub.module_v2 import load as tfhub_load + + # needed to load the ConveRT model + # noinspection PyUnresolvedReferences + import tensorflow_text # noqa: F401 + + # required to take care of cases when other files are already + # stored in the default TFHUB_CACHE_DIR + try: + return tfhub_load(model_url) + except OSError: + directory = io_utils.create_temporary_directory() + os.environ["TFHUB_CACHE_DIR"] = directory + return tfhub_load(model_url) + + +def _replace_deprecated_option( + old_option: Text, + new_option: Union[Text, List[Text]], + config: Dict[Text, Any], + warn_until_version: Text = NEXT_MAJOR_VERSION_FOR_DEPRECATIONS, +) -> Dict[Text, Any]: + if old_option not in config: + return {} + + if isinstance(new_option, str): + rasa.shared.utils.io.raise_deprecation_warning( + f"Option '{old_option}' got renamed to '{new_option}'. " + f"Please update your configuration file.", + warn_until_version=warn_until_version, + ) + return {new_option: config[old_option]} + + rasa.shared.utils.io.raise_deprecation_warning( + f"Option '{old_option}' got renamed to " + f"a dictionary '{new_option[0]}' with a key '{new_option[1]}'. " + f"Please update your configuration file.", + warn_until_version=warn_until_version, + ) + return {new_option[0]: {new_option[1]: config[old_option]}} + + +def check_deprecated_options(config: Dict[Text, Any]) -> Dict[Text, Any]: + """Update the config according to changed config params. + + If old model configuration parameters are present in the provided config, replace + them with the new parameters and log a warning. + + Args: + config: model configuration + + Returns: updated model configuration + """ + # note: call _replace_deprecated_option() here when there are options to deprecate + + return config + + +def check_core_deprecated_options(config: Dict[Text, Any]) -> Dict[Text, Any]: + """Update the core config according to changed config params. + + If old model configuration parameters are present in the provided config, replace + them with the new parameters and log a warning. + + Args: + config: model configuration + + Returns: updated model configuration + """ + # note: call _replace_deprecated_option() here when there are options to deprecate + + return config + + +def entity_label_to_tags( + model_predictions: Dict[Text, Any], + entity_tag_specs: List["EntityTagSpec"], + bilou_flag: bool = False, + prediction_index: int = 0, +) -> Tuple[Dict[Text, List[Text]], Dict[Text, List[float]]]: + """Convert the output predictions for entities to the actual entity tags. + + Args: + model_predictions: the output predictions using the entity tag indices + entity_tag_specs: the entity tag specifications + bilou_flag: if 'True', the BILOU tagging schema was used + prediction_index: the index in the batch of predictions + to use for entity extraction + + Returns: + A map of entity tag type, e.g. entity, role, group, to actual entity tags and + confidences. + """ + predicted_tags = {} + confidence_values = {} + + for tag_spec in entity_tag_specs: + predictions = model_predictions[f"e_{tag_spec.tag_name}_ids"] + confidences = model_predictions[f"e_{tag_spec.tag_name}_scores"] + + if not np.any(predictions): + continue + + confidences = [float(c) for c in confidences[prediction_index]] + tags = [tag_spec.ids_to_tags[p] for p in predictions[prediction_index]] + + if bilou_flag: + ( + tags, + confidences, + ) = rasa.nlu.utils.bilou_utils.ensure_consistent_bilou_tagging( + tags, confidences + ) + + predicted_tags[tag_spec.tag_name] = tags + confidence_values[tag_spec.tag_name] = confidences + + return predicted_tags, confidence_values + + +def create_data_generators( + model_data: RasaModelData, + batch_sizes: Union[int, List[int]], + epochs: int, + batch_strategy: Text = SEQUENCE, + eval_num_examples: int = 0, + random_seed: Optional[int] = None, + shuffle: bool = True, + drop_small_last_batch: bool = False, +) -> Tuple[RasaBatchDataGenerator, Optional[RasaBatchDataGenerator]]: + """Create data generators for train and optional validation data. + + Args: + model_data: The model data to use. + batch_sizes: The batch size(s). + epochs: The number of epochs to train. + batch_strategy: The batch strategy to use. + eval_num_examples: Number of examples to use for validation data. + random_seed: The random seed. + shuffle: Whether to shuffle data inside the data generator. + drop_small_last_batch: whether to drop the last batch if it has fewer than half + a batch size of examples + + Returns: + The training data generator and optional validation data generator. + """ + validation_data_generator = None + if eval_num_examples > 0: + model_data, evaluation_model_data = model_data.split( + eval_num_examples, random_seed + ) + validation_data_generator = RasaBatchDataGenerator( + evaluation_model_data, + batch_size=batch_sizes, + epochs=epochs, + batch_strategy=batch_strategy, + shuffle=shuffle, + drop_small_last_batch=drop_small_last_batch, + ) + + data_generator = RasaBatchDataGenerator( + model_data, + batch_size=batch_sizes, + epochs=epochs, + batch_strategy=batch_strategy, + shuffle=shuffle, + drop_small_last_batch=drop_small_last_batch, + ) + + return data_generator, validation_data_generator + + +def create_common_callbacks( + epochs: int, + tensorboard_log_dir: Optional[Text] = None, + tensorboard_log_level: Optional[Text] = None, + checkpoint_dir: Optional[Path] = None, +) -> List["Callback"]: + """Create common callbacks. + + The following callbacks are created: + - RasaTrainingLogger callback + - Optional TensorBoard callback + - Optional RasaModelCheckpoint callback + + Args: + epochs: the number of epochs to train + tensorboard_log_dir: optional directory that should be used for tensorboard + tensorboard_log_level: defines when training metrics for tensorboard should be + logged. Valid values: 'epoch' and 'batch'. + checkpoint_dir: optional directory that should be used for model checkpointing + + Returns: + A list of callbacks. + """ + import tensorflow as tf + + callbacks = [RasaTrainingLogger(epochs, silent=False)] + + if tensorboard_log_dir: + callbacks.append( + tf.keras.callbacks.TensorBoard( + log_dir=tensorboard_log_dir, + update_freq=tensorboard_log_level, + write_graph=True, + write_images=True, + histogram_freq=10, + ) + ) + + if checkpoint_dir: + callbacks.append(RasaModelCheckpoint(checkpoint_dir)) + + return callbacks + + +def update_confidence_type(component_config: Dict[Text, Any]) -> Dict[Text, Any]: + """Set model confidence to auto if margin loss is used. + + Option `auto` is reserved for margin loss type. It will be removed once margin loss + is deprecated. + + Args: + component_config: model configuration + + Returns: + updated model configuration + """ + if component_config[LOSS_TYPE] == MARGIN: + rasa.shared.utils.io.raise_warning( + f"Overriding defaults by setting {MODEL_CONFIDENCE} to " + f"{AUTO} as {LOSS_TYPE} is set to {MARGIN} in the configuration. " + f"This means that model's confidences will be computed " + f"as cosine similarities. Users are encouraged to shift to " + f"cross entropy loss by setting `{LOSS_TYPE}={CROSS_ENTROPY}`." + ) + component_config[MODEL_CONFIDENCE] = AUTO + return component_config + + +def validate_configuration_settings(component_config: Dict[Text, Any]) -> None: + """Validates that combination of parameters in the configuration are correctly set. + + Args: + component_config: Configuration to validate. + """ + _check_loss_setting(component_config) + _check_confidence_setting(component_config) + _check_similarity_loss_setting(component_config) + _check_tolerance_setting(component_config) + _check_evaluation_setting(component_config) + + +def _check_tolerance_setting(component_config: Dict[Text, Any]) -> None: + if not (0.0 <= component_config.get(TOLERANCE, 0.0) <= 1.0): + raise InvalidConfigException( + f"`{TOLERANCE}` was set to `{component_config.get(TOLERANCE)}` " + f"which is an invalid setting. Please set it to a value " + f"between 0.0 and 1.0 inclusive." + ) + + +def _check_evaluation_setting(component_config: Dict[Text, Any]) -> None: + if ( + EVAL_NUM_EPOCHS in component_config + and component_config[EVAL_NUM_EPOCHS] != -1 + and component_config[EVAL_NUM_EPOCHS] > component_config[EPOCHS] + ): + warning = ( + f"'{EVAL_NUM_EPOCHS}={component_config[EVAL_NUM_EPOCHS]}' is " + f"greater than '{EPOCHS}={component_config[EPOCHS]}'." + f" No evaluation will occur." + ) + if component_config[CHECKPOINT_MODEL]: + warning = ( + f"You have opted to save the best model, but {warning} " + f"No checkpoint model will be saved." + ) + rasa.shared.utils.io.raise_warning(warning) + if CHECKPOINT_MODEL in component_config and component_config[CHECKPOINT_MODEL]: + if ( + component_config[EVAL_NUM_EPOCHS] != -1 + and component_config[EVAL_NUM_EPOCHS] < 1 + ): + rasa.shared.utils.io.raise_warning( + f"You have opted to save the best model, but the value of " + f"'{EVAL_NUM_EPOCHS}' is not -1 or greater than 0. Training will fail." + ) + if ( + EVAL_NUM_EXAMPLES in component_config + and component_config[EVAL_NUM_EXAMPLES] <= 0 + ): + rasa.shared.utils.io.raise_warning( + f"You have opted to save the best model, but the value of " + f"'{EVAL_NUM_EXAMPLES}' is not greater than 0. No checkpoint model " + f"will be saved." + ) + + +def _check_confidence_setting(component_config: Dict[Text, Any]) -> None: + if component_config[MODEL_CONFIDENCE] == COSINE: + raise InvalidConfigException( + f"{MODEL_CONFIDENCE}={COSINE} was introduced in Rasa Open Source 2.3.0 " + f"but post-release experiments revealed that using cosine similarity can " + f"change the order of predicted labels. " + f"Since this is not ideal, using `{MODEL_CONFIDENCE}={COSINE}` has been " + f"removed in versions post `2.3.3`. " + f"Please use `{MODEL_CONFIDENCE}={SOFTMAX}` instead." + ) + if component_config[MODEL_CONFIDENCE] == INNER: + raise InvalidConfigException( + f"{MODEL_CONFIDENCE}={INNER} is deprecated as it produces an unbounded " + f"range of confidences which can break the logic of assistants in various " + f"other places. " + f"Please use `{MODEL_CONFIDENCE}={SOFTMAX}` instead. " + ) + if component_config[MODEL_CONFIDENCE] not in [SOFTMAX, AUTO]: + raise InvalidConfigException( + f"{MODEL_CONFIDENCE}={component_config[MODEL_CONFIDENCE]} is not a valid " + f"setting. Please use `{MODEL_CONFIDENCE}={SOFTMAX}` instead." + ) + if component_config[MODEL_CONFIDENCE] == SOFTMAX: + if component_config[LOSS_TYPE] != CROSS_ENTROPY: + raise InvalidConfigException( + f"{LOSS_TYPE}={component_config[LOSS_TYPE]} and " + f"{MODEL_CONFIDENCE}={SOFTMAX} is not a valid " + f"combination. You can use {MODEL_CONFIDENCE}={SOFTMAX} " + f"only with {LOSS_TYPE}={CROSS_ENTROPY}." + ) + if component_config[SIMILARITY_TYPE] not in [INNER, AUTO]: + raise InvalidConfigException( + f"{SIMILARITY_TYPE}={component_config[SIMILARITY_TYPE]} and " + f"{MODEL_CONFIDENCE}={SOFTMAX} is not a valid " + f"combination. You can use {MODEL_CONFIDENCE}={SOFTMAX} " + f"only with {SIMILARITY_TYPE}={INNER}." + ) + if component_config.get(RENORMALIZE_CONFIDENCES) and component_config.get( + RANKING_LENGTH + ): + if component_config[MODEL_CONFIDENCE] != SOFTMAX: + raise InvalidConfigException( + f"Renormalizing the {component_config[RANKING_LENGTH]} top " + f"predictions should only be done if {MODEL_CONFIDENCE}={SOFTMAX} " + f"Please use {RENORMALIZE_CONFIDENCES}={True} " + f"only with {MODEL_CONFIDENCE}={SOFTMAX}." + ) + + +def _check_loss_setting(component_config: Dict[Text, Any]) -> None: + if ( + not component_config[CONSTRAIN_SIMILARITIES] + and component_config[LOSS_TYPE] == CROSS_ENTROPY + ): + rasa.shared.utils.io.raise_warning( + f"{CONSTRAIN_SIMILARITIES} is set to `False`. It is recommended " + f"to set it to `True` when using cross-entropy loss.", + category=UserWarning, + ) + + +def _check_similarity_loss_setting(component_config: Dict[Text, Any]) -> None: + if ( + component_config[SIMILARITY_TYPE] == COSINE + and component_config[LOSS_TYPE] == CROSS_ENTROPY + or component_config[SIMILARITY_TYPE] == INNER + and component_config[LOSS_TYPE] == MARGIN + ): + rasa.shared.utils.io.raise_warning( + f"`{SIMILARITY_TYPE}={component_config[SIMILARITY_TYPE]}`" + f" and `{LOSS_TYPE}={component_config[LOSS_TYPE]}` " + f"is not a recommended setting as it may not lead to best results." + f"Ideally use `{SIMILARITY_TYPE}={INNER}`" + f" and `{LOSS_TYPE}={CROSS_ENTROPY}` or" + f"`{SIMILARITY_TYPE}={COSINE}` and `{LOSS_TYPE}={MARGIN}`.", + category=UserWarning, + ) + + +def init_split_entities( + split_entities_config: Union[bool, Dict[Text, Any]], default_split_entity: bool +) -> Dict[Text, bool]: + """Initialise the behaviour for splitting entities by comma (or not). + + Returns: + Defines desired behaviour for splitting specific entity types and + default behaviour for splitting any entity types for which no behaviour + is defined. + """ + if isinstance(split_entities_config, bool): + # All entities will be split according to `split_entities_config` + split_entities_config = {SPLIT_ENTITIES_BY_COMMA: split_entities_config} + else: + # All entities not named in split_entities_config will be split + # according to `split_entities_config` + split_entities_config[SPLIT_ENTITIES_BY_COMMA] = default_split_entity + return split_entities_config diff --git a/rasa/validator.py b/rasa/validator.py new file mode 100644 index 0000000..9727193 --- /dev/null +++ b/rasa/validator.py @@ -0,0 +1,437 @@ +import logging +from collections import defaultdict +from typing import Set, Text, Optional, Dict, Any, List + +import rasa.core.training.story_conflict +import rasa.shared.nlu.constants +from rasa.shared.constants import ( + ASSISTANT_ID_DEFAULT_VALUE, + ASSISTANT_ID_KEY, + CONFIG_MANDATORY_KEYS, + DOCS_URL_DOMAINS, + DOCS_URL_FORMS, + UTTER_PREFIX, + DOCS_URL_ACTIONS, + REQUIRED_SLOTS_KEY, +) +from rasa.shared.core import constants +from rasa.shared.core.constants import MAPPING_CONDITIONS, ACTIVE_LOOP +from rasa.shared.core.events import ActionExecuted, ActiveLoop +from rasa.shared.core.events import UserUttered +from rasa.shared.core.domain import Domain +from rasa.shared.core.generator import TrainingDataGenerator +from rasa.shared.core.constants import SlotMappingType, MAPPING_TYPE +from rasa.shared.core.training_data.structures import StoryGraph +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.nlu.training_data.training_data import TrainingData +import rasa.shared.utils.io + +logger = logging.getLogger(__name__) + + +class Validator: + """A class used to verify usage of intents and utterances.""" + + def __init__( + self, + domain: Domain, + intents: TrainingData, + story_graph: StoryGraph, + config: Optional[Dict[Text, Any]], + ) -> None: + """Initializes the Validator object. + + Args: + domain: The domain. + intents: Training data. + story_graph: The story graph. + config: The configuration. + """ + self.domain = domain + self.intents = intents + self.story_graph = story_graph + self.config = config or {} + + @classmethod + def from_importer(cls, importer: TrainingDataImporter) -> "Validator": + """Create an instance from the domain, nlu and story files.""" + domain = importer.get_domain() + story_graph = importer.get_stories() + intents = importer.get_nlu_data() + config = importer.get_config() + + return cls(domain, intents, story_graph, config) + + def _non_default_intents(self) -> List[Text]: + return [ + item + for item in self.domain.intents + if item not in constants.DEFAULT_INTENTS + ] + + def verify_intents(self, ignore_warnings: bool = True) -> bool: + """Compares list of intents in domain with intents in NLU training data.""" + everything_is_alright = True + + nlu_data_intents = {e.data["intent"] for e in self.intents.intent_examples} + + for intent in self._non_default_intents(): + if intent not in nlu_data_intents: + rasa.shared.utils.io.raise_warning( + f"The intent '{intent}' is listed in the domain file, but " + f"is not found in the NLU training data." + ) + everything_is_alright = ignore_warnings or everything_is_alright + + for intent in nlu_data_intents: + if intent not in self.domain.intents: + rasa.shared.utils.io.raise_warning( + f"There is a message in the training data labeled with intent " + f"'{intent}'. This intent is not listed in your domain. You " + f"should need to add that intent to your domain file!", + docs=DOCS_URL_DOMAINS, + ) + everything_is_alright = ignore_warnings + + return everything_is_alright + + def verify_example_repetition_in_intents( + self, ignore_warnings: bool = True + ) -> bool: + """Checks if there is no duplicated example in different intents.""" + + everything_is_alright = True + + duplication_hash = defaultdict(set) + for example in self.intents.intent_examples: + text = example.get(rasa.shared.nlu.constants.TEXT) + duplication_hash[text].add(example.get("intent")) + + for text, intents in duplication_hash.items(): + + if len(duplication_hash[text]) > 1: + everything_is_alright = ignore_warnings + intents_string = ", ".join(sorted(intents)) + rasa.shared.utils.io.raise_warning( + f"The example '{text}' was found labeled with multiple " + f"different intents in the training data. Each annotated message " + f"should only appear with one intent. You should fix that " + f"conflict The example is labeled with: {intents_string}." + ) + return everything_is_alright + + def verify_intents_in_stories(self, ignore_warnings: bool = True) -> bool: + """Checks intents used in stories. + + Verifies if the intents used in the stories are valid, and whether + all valid intents are used in the stories.""" + + everything_is_alright = self.verify_intents(ignore_warnings=ignore_warnings) + + stories_intents = { + event.intent["name"] + for story in self.story_graph.story_steps + for event in story.events + if type(event) == UserUttered and event.intent_name is not None + } + + for story_intent in stories_intents: + if story_intent not in self.domain.intents: + rasa.shared.utils.io.raise_warning( + f"The intent '{story_intent}' is used in your stories, but it " + f"is not listed in the domain file. You should add it to your " + f"domain file!", + docs=DOCS_URL_DOMAINS, + ) + everything_is_alright = ignore_warnings + + for intent in self._non_default_intents(): + if intent not in stories_intents: + rasa.shared.utils.io.raise_warning( + f"The intent '{intent}' is not used in any story or rule." + ) + everything_is_alright = ignore_warnings or everything_is_alright + + return everything_is_alright + + def _gather_utterance_actions(self) -> Set[Text]: + """Return all utterances which are actions. + + Returns: + A set of response names found in the domain and data files, with the + response key stripped in the case of response selector responses. + """ + domain_responses = { + response.split(rasa.shared.nlu.constants.RESPONSE_IDENTIFIER_DELIMITER)[0] + for response in self.domain.responses.keys() + if response in self.domain.action_names_or_texts + } + data_responses = { + response.split(rasa.shared.nlu.constants.RESPONSE_IDENTIFIER_DELIMITER)[0] + for response in self.intents.responses.keys() + } + return domain_responses.union(data_responses) + + def verify_utterances_in_stories(self, ignore_warnings: bool = True) -> bool: + """Verifies usage of utterances in stories. + + Checks whether utterances used in the stories are valid, + and whether all valid utterances are used in stories. + """ + everything_is_alright = True + + utterance_actions = self._gather_utterance_actions() + stories_utterances = set() + + for story in self.story_graph.story_steps: + for event in story.events: + if not isinstance(event, ActionExecuted): + continue + + if not event.action_name: + continue + + if not event.action_name.startswith(UTTER_PREFIX): + # we are only interested in utter actions + continue + + if event.action_name in stories_utterances: + # we already processed this one before, we only want to warn once + continue + + if event.action_name not in utterance_actions: + rasa.shared.utils.io.raise_warning( + f"The action '{event.action_name}' is used in the stories, " + f"but is not a valid utterance action. Please make sure " + f"the action is listed in your domain and there is a " + f"template defined with its name.", + docs=DOCS_URL_ACTIONS + "#utterance-actions", + ) + everything_is_alright = ignore_warnings + stories_utterances.add(event.action_name) + + for utterance in utterance_actions: + if utterance not in stories_utterances: + rasa.shared.utils.io.raise_warning( + f"The utterance '{utterance}' is not used in any story or rule." + ) + everything_is_alright = ignore_warnings or everything_is_alright + + return everything_is_alright + + def verify_forms_in_stories_rules(self) -> bool: + """Verifies that forms referenced in active_loop directives are present.""" + all_forms_exist = True + visited_loops = set() + + for story in self.story_graph.story_steps: + for event in story.events: + if not isinstance(event, ActiveLoop): + continue + + if event.name in visited_loops: + # We've seen this loop before, don't alert on it twice + continue + + if not event.name: + # To support setting `active_loop` to `null` + continue + + if event.name not in self.domain.action_names_or_texts: + rasa.shared.utils.io.raise_warning( + f"The form '{event.name}' is used in the " + f"'{story.block_name}' block, but it " + f"is not listed in the domain file. You should add it to your " + f"domain file!", + docs=DOCS_URL_FORMS, + ) + all_forms_exist = False + visited_loops.add(event.name) + + return all_forms_exist + + def verify_actions_in_stories_rules(self) -> bool: + """Verifies that actions used in stories and rules are present in the domain.""" + everything_is_alright = True + visited = set() + + for story in self.story_graph.story_steps: + for event in story.events: + if not isinstance(event, ActionExecuted): + continue + + if not event.action_name: + continue + + if not event.action_name.startswith("action_"): + continue + + if event.action_name in visited: + # we already processed this one before, we only want to warn once + continue + + if event.action_name not in self.domain.action_names_or_texts: + rasa.shared.utils.io.raise_warning( + f"The action '{event.action_name}' is used in the " + f"'{story.block_name}' block, but it " + f"is not listed in the domain file. You should add it to your " + f"domain file!", + docs=DOCS_URL_DOMAINS, + ) + everything_is_alright = False + visited.add(event.action_name) + + return everything_is_alright + + def verify_story_structure( + self, ignore_warnings: bool = True, max_history: Optional[int] = None + ) -> bool: + """Verifies that the bot behaviour in stories is deterministic. + + Args: + ignore_warnings: When `True`, return `True` even if conflicts were found. + max_history: Maximal number of events to take into account for conflict + identification. + + Returns: + `False` is a conflict was found and `ignore_warnings` is `False`. + `True` otherwise. + """ + + logger.info("Story structure validation...") + + trackers = TrainingDataGenerator( + self.story_graph, + domain=self.domain, + remove_duplicates=False, + augmentation_factor=0, + ).generate_story_trackers() + + # Create a list of `StoryConflict` objects + conflicts = rasa.core.training.story_conflict.find_story_conflicts( + trackers, self.domain, max_history + ) + + if not conflicts: + logger.info("No story structure conflicts found.") + else: + for conflict in conflicts: + logger.warning(conflict) + + return ignore_warnings or not conflicts + + def verify_nlu(self, ignore_warnings: bool = True) -> bool: + """Runs all the validations on intents and utterances.""" + + logger.info("Validating intents...") + intents_are_valid = self.verify_intents_in_stories(ignore_warnings) + + logger.info("Validating uniqueness of intents and stories...") + there_is_no_duplication = self.verify_example_repetition_in_intents( + ignore_warnings + ) + + logger.info("Validating utterances...") + stories_are_valid = self.verify_utterances_in_stories(ignore_warnings) + return intents_are_valid and stories_are_valid and there_is_no_duplication + + def verify_form_slots(self) -> bool: + """Verifies that form slots match the slot mappings in domain.""" + domain_slot_names = [slot.name for slot in self.domain.slots] + everything_is_alright = True + + for form in self.domain.form_names: + form_slots = self.domain.required_slots_for_form(form) + for slot in form_slots: + if slot in domain_slot_names: + continue + else: + rasa.shared.utils.io.raise_warning( + f"The form slot '{slot}' in form '{form}' " + f"is not present in the domain slots." + f"Please add the correct slot or check for typos.", + docs=DOCS_URL_DOMAINS, + ) + everything_is_alright = False + + return everything_is_alright + + def verify_slot_mappings(self) -> bool: + """Verifies that slot mappings match forms.""" + everything_is_alright = True + + for slot in self.domain.slots: + for mapping in slot.mappings: + for condition in mapping.get(MAPPING_CONDITIONS, []): + condition_active_loop = condition.get(ACTIVE_LOOP) + mapping_type = SlotMappingType(mapping.get(MAPPING_TYPE)) + if ( + condition_active_loop + and condition_active_loop not in self.domain.form_names + ): + rasa.shared.utils.io.raise_warning( + f"Slot '{slot.name}' has a mapping condition for form " + f"'{condition_active_loop}' which is not listed in " + f"domain forms. Please add this form to the forms section " + f"or check for typos." + ) + everything_is_alright = False + + form_slots = self.domain.forms.get(condition_active_loop, {}).get( + REQUIRED_SLOTS_KEY, {} + ) + if ( + form_slots + and slot.name not in form_slots + and mapping_type != SlotMappingType.FROM_TRIGGER_INTENT + ): + rasa.shared.utils.io.raise_warning( + f"Slot '{slot.name}' has a mapping condition for form " + f"'{condition_active_loop}', but it's not present in " + f"'{condition_active_loop}' form's '{REQUIRED_SLOTS_KEY}'. " + f"The slot needs to be added to this key." + ) + everything_is_alright = False + + return everything_is_alright + + def verify_domain_validity(self) -> bool: + """Checks whether the domain returned by the importer is empty. + + An empty domain or one that uses deprecated Mapping Policy is invalid. + """ + if self.domain.is_empty(): + return False + + for intent_key, intent_dict in self.domain.intent_properties.items(): + if "triggers" in intent_dict: + rasa.shared.utils.io.raise_warning( + f"The intent {intent_key} in the domain file " + f"is using the MappingPolicy format " + f"which has now been deprecated. " + f"Please migrate to RulePolicy." + ) + return False + + return True + + def warn_if_config_mandatory_keys_are_not_set(self) -> None: + """Raises a warning if mandatory keys are not present in the config. + + Additionally, raises a UserWarning if the assistant_id key is filled with the + default placeholder value. + """ + for key in set(CONFIG_MANDATORY_KEYS): + if key not in self.config: + rasa.shared.utils.io.raise_warning( + f"The config file is missing the '{key}' mandatory key." + ) + + assistant_id = self.config.get(ASSISTANT_ID_KEY) + + if assistant_id is not None and assistant_id == ASSISTANT_ID_DEFAULT_VALUE: + rasa.shared.utils.io.raise_warning( + f"The config file is missing a unique value for the " + f"'{ASSISTANT_ID_KEY}' mandatory key. Please replace the default " + f"placeholder value with a unique identifier." + ) diff --git a/rasa/version.py b/rasa/version.py new file mode 100644 index 0000000..3b96210 --- /dev/null +++ b/rasa/version.py @@ -0,0 +1,3 @@ +# this file will automatically be changed, +# do not add anything but the version number here! +__version__ = "3.6.21" diff --git a/scripts/download_transformer_model.py b/scripts/download_transformer_model.py new file mode 100644 index 0000000..534befd --- /dev/null +++ b/scripts/download_transformer_model.py @@ -0,0 +1,7 @@ +from huggingface_hub import snapshot_download +import sys + +print(f"Downloading model files for {sys.argv[1]}...") +snapshot_download( + repo_id=sys.argv[1], allow_patterns=["*.txt", "*.json", "*.h5", "*.model"] +) diff --git a/scripts/evaluate_release_tag.py b/scripts/evaluate_release_tag.py new file mode 100755 index 0000000..a312a88 --- /dev/null +++ b/scripts/evaluate_release_tag.py @@ -0,0 +1,74 @@ +"""Evaluate release tag for whether docs should be built or not. + +""" +import argparse +from subprocess import check_output +from typing import List + +from pep440_version_utils import Version, is_valid_version + + +def create_argument_parser() -> argparse.ArgumentParser: + """Parse all the command line arguments for the release script.""" + + parser = argparse.ArgumentParser(description="Evaluate whether docs should be built for release tag.") + parser.add_argument( + "tag", + type=str, + help="The tag currently being released.", + ) + + return parser + + +def is_plain_version(version: Version) -> bool: + """Check whether a version is a X.x.x release with no alpha or rc markers""" + return version.base_version == str(version) + + +def git_existing_tag_versions() -> List[Version]: + """Return all existing tags in the local git repo.""" + stdout = check_output(["git", "tag"]) + tags = set(stdout.decode().split("\n")) + versions = [Version(tag) for tag in tags if is_valid_version(tag)] + return versions + + +def git_plain_tag_versions(versions: List[Version]) -> List[Version]: + """Return non-alpha/rc existing tags""" + return [version for version in versions if is_plain_version(version)] + +def filter_ga_relases(tags: List[Version]) -> List[Version]: + """Return all general availability (GA) releases""" + return [tag for tag in tags if tag.is_alpha is False and tag.is_beta is False and tag.is_release_candidate is False] + + +def should_build_docs(tag: Version) -> bool: + """Docs should be built only for the latest GA release tags""" + existing_tags = git_existing_tag_versions() + + ga_releases = filter_ga_relases(existing_tags) + ga_releases.sort() + latest_version = ga_releases[-1] + print(f"The latest General Availability release tag is {latest_version}.") + need_to_build_docs = False + + if not is_plain_version(tag): + print(f"Tag {tag} is an alpha, beta, rc, nightly, or otherwise non-standard version.") + elif tag >= latest_version: + print(f"Tag {tag} is the latest version. Docs should be built.") + need_to_build_docs = True + + return need_to_build_docs + + +def main(args: argparse.Namespace) -> None: + tag = Version(args.tag) + if not should_build_docs(tag): + raise SystemExit(f"Docs should not be built for tag {tag}.") + + +if __name__ == "__main__": + arg_parser = create_argument_parser() + cmdline_args = arg_parser.parse_args() + main(cmdline_args) diff --git a/scripts/lint_changelog_files.sh b/scripts/lint_changelog_files.sh new file mode 100755 index 0000000..7697856 --- /dev/null +++ b/scripts/lint_changelog_files.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -euo pipefail +# Lint changelog filenames to avoid merging of incorrectly named changelog fragment files +# For more info about proper changelog file naming, see https://github.com/RasaHQ/rasa/blob/main/changelog/README.md +FILES=`ls -A changelog/ | grep -v -E "^([0-9]+.(misc|doc|improvement|feature|removal|bugfix).md|README.md|_template.md.jinja2)$" || true` +if [ -z "$FILES" ] +then + echo "Changelog files are properly configured." +else + echo "Unexpected files under the changelog/ folder:" + echo $FILES + exit 1 +fi diff --git a/scripts/lint_python_docstrings.sh b/scripts/lint_python_docstrings.sh new file mode 100755 index 0000000..6255de2 --- /dev/null +++ b/scripts/lint_python_docstrings.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +set -euo pipefail +# Lint docstrings only against the the diff to avoid too many errors. +# Check only production code. Ignore other other errors which are captured by `lint` + +# Compare against `main` if no branch was provided +BRANCH="${1:-main}" +# Diff of committed changes (shows only changes introduced by your branch +FILES_WITH_DIFF=`git diff $BRANCH...HEAD --name-only -- rasa/**/*.py | xargs echo -n` + +if [ ! -z "$FILES_WITH_DIFF" ] +then + poetry run ruff check --select D --diff $FILES_WITH_DIFF +else + echo "No python files in diff." +fi + +# Diff of uncommitted changes for running locally +DEV_FILES_WITH_DIFF=`git diff HEAD --name-only -- rasa | xargs echo -n` + +if [ ! -z "$DEV_FILES_WITH_DIFF" ] +then + poetry run ruff check --select D --diff $DEV_FILES_WITH_DIFF +fi diff --git a/scripts/ping_slack_about_package_release.sh b/scripts/ping_slack_about_package_release.sh new file mode 100644 index 0000000..b8ee68b --- /dev/null +++ b/scripts/ping_slack_about_package_release.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -Eeuo pipefail + +if [[ ${GITHUB_TAG} =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + curl -X POST -H "Content-type: application/json" \ + --data "{\"text\":\"💥 New *Rasa Open Source* version ${GITHUB_TAG} has been released! https://github.com/RasaHQ/rasa/releases/tag/${GITHUB_TAG}\"}" \ + "https://hooks.slack.com/services/T0GHWFTS8/BMTQQL47K/${SLACK_WEBHOOK_TOKEN}" +fi diff --git a/scripts/poetry-version.sh b/scripts/poetry-version.sh new file mode 100755 index 0000000..0ba7a14 --- /dev/null +++ b/scripts/poetry-version.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env python + +"""Extract the poetry version from .github/poetry_version.txt. Used in e.g. github workflows.""" + +import pathlib +import sys +import re + +# The poetry version is stored in the .github/poetry_version.txt file due to the https://github.com/python-poetry/poetry/issues/3316 +poetry_version_txt = pathlib.Path(__file__).parent.parent / ".github" / "poetry_version.txt" + +if __name__ == "__main__": + version_regex = r'poetry-version=(.*)' + with poetry_version_txt.open() as f: + for line in f: + m = re.search(version_regex, line) + if m: + print(m.group(1)) + sys.exit(0) + else: + print("Failed to find poetry version.") + sys.exit(1) diff --git a/scripts/prepare_nightly_release.py b/scripts/prepare_nightly_release.py new file mode 100644 index 0000000..2a7434a --- /dev/null +++ b/scripts/prepare_nightly_release.py @@ -0,0 +1,103 @@ +"""Prepare a Rasa nightly release. + +- increases the version number to dev version provided +""" +import argparse +import os +import sys +import toml +from pathlib import Path +from typing import Text +from pep440_version_utils import Version, is_valid_version + +VERSION_FILE_PATH = "rasa/version.py" + +PYPROJECT_FILE_PATH = "pyproject.toml" + + +def create_argument_parser() -> argparse.ArgumentParser: + """Parse all the command line arguments for the release script.""" + + parser = argparse.ArgumentParser(description="prepare the next nightly release") + parser.add_argument( + "--next_version", type=str, help="Rasa nightly version number", + ) + + return parser + + +def project_root() -> Path: + """Root directory of the project.""" + return Path(os.path.dirname(__file__)).parent + + +def write_version_file(path: Text, version: Version) -> None: + """Dump a new version into the python version file.""" + + version_file = project_root() / path + + with version_file.open("w") as f: + f.write( + f"# this file will automatically be changed,\n" + f"# do not add anything but the version number here!\n" + f'__version__ = "{version}"\n' + ) + + +def write_version_to_pyproject(pyproject_file_path: Text, version: Version) -> None: + """Dump a new version into the pyproject.toml.""" + + pyproject_file = project_root() / pyproject_file_path + + try: + data = toml.load(pyproject_file) + data["tool"]["poetry"]["name"] = "rasa-nightly" + data["tool"]["poetry"]["version"] = str(version) + data["tool"]["poetry"]["packages"] = [{"include" : "rasa"},] + with pyproject_file.open("w", encoding="utf8") as f: + toml.dump(data, f) + except (FileNotFoundError, TypeError): + print(f"Unable to update {pyproject_file}: file not found.") + sys.exit(1) + except toml.TomlDecodeError: + print(f"Unable to parse {pyproject_file}: incorrect TOML file.") + sys.exit(1) + + +def parse_next_version(version: Text) -> Version: + """Find the next version as a proper semantic version string.""" + return Version(version) + + +def next_version(args: argparse.Namespace) -> Version: + """Take cmdline args or ask the user for the next version and return semver.""" + return parse_next_version(args.next_version) + + +def print_done_message(version: Version) -> None: + """Print final information for the user on what to do next.""" + + print() + print(f"\033[94m All done - changes for rasa nightly version {version} are ready! \033[0m") + print() + + +def main(args: argparse.Namespace) -> None: + """Start a nightly release preparation.""" + + print( + "The release script will temporarily change the version number to a nightly version. Let's go!" + ) + + version = next_version(args) + + write_version_file(VERSION_FILE_PATH, version) + write_version_to_pyproject(PYPROJECT_FILE_PATH, version) + + print_done_message(version) + + +if __name__ == "__main__": + arg_parser = create_argument_parser() + cmdline_args = arg_parser.parse_args() + main(cmdline_args) diff --git a/scripts/publish_gh_release_notes.py b/scripts/publish_gh_release_notes.py new file mode 100644 index 0000000..f96d8b0 --- /dev/null +++ b/scripts/publish_gh_release_notes.py @@ -0,0 +1,106 @@ +""" +Script used to publish GitHub release notes extracted from CHANGELOG.mdx. +This script is executed by GitHub after a new release was successfully built. + +Uses the following environment variables: +* GITHUB_TAG: the name of the tag of the current commit. +* GITHUB_TOKEN: a personal access token with 'repo' permissions. + +The script also requires ``pandoc`` to be previously installed in the system. +Requires Python3.6+. + +Based on code from pytest. +https://github.com/pytest-dev/pytest/blob/master/scripts/publish_gh_release_notes.py +Copyright Holger Krekel and others, 2004-2019. + +Distributed under the terms of the MIT license, pytest is free and open source software. +""" +import os +import re +import sys +from pathlib import Path +from typing import Text + +# if this needs any more dependencies, they need to be installed on github deploy stage +import github3 +from pep440_version_utils import Version + + +def create_github_release(slug: Text, token: Text, tag_name: Text, body: Text): + """Create a github release.""" + + github = github3.login(token=token) + owner, repo = slug.split("/") + repo = github.repository(owner, repo) + return repo.create_release(tag_name=tag_name, body=body) + + +def parse_changelog(tag_name: Text) -> Text: + """Read the changelog and extract the most recently release entry.""" + + p = Path(__file__).parent.parent / "CHANGELOG.mdx" + changelog_lines = p.read_text(encoding="UTF-8").splitlines() + + title_regex = re.compile(r"##\s*\[(\d+\.\d+\.\d+)(\S*)\]\s*-\s*\d{4}-\d{2}-\d{2}") + consuming_version = False + version_lines = [] + for line in changelog_lines: + m = title_regex.match(line) + if m: + # found the version we want: start to consume lines + # until we find the next version title + if m.group(1) == tag_name: + consuming_version = True + # found a new version title while parsing the version we want: break out + elif consuming_version: + break + if consuming_version: + version_lines.append(line) + + if consuming_version: + # drop the first lines (version headline, not needed for GH) + return "\n".join(version_lines[2:]).strip() + else: + return None + + +def main(): + tag_name = os.environ.get("GITHUB_TAG") + if not tag_name: + print("environment variable GITHUB_TAG not set", file=sys.stderr) + return 1 + + token = os.environ.get("GITHUB_TOKEN") + if not token: + print("GITHUB_TOKEN not set", file=sys.stderr) + return 1 + + slug = os.environ.get("GITHUB_REPO_SLUG") + if not slug: + print("GITHUB_REPO_SLUG not set", file=sys.stderr) + return 1 + + version = Version(tag_name) + if version.pre: + md_body = "_Pre-release version_" + else: + md_body = parse_changelog(tag_name) + + if md_body is None: + print("Failed to extract changelog entries for version from changelog.") + return 2 + + if not create_github_release(slug, token, tag_name, md_body): + print("Could not publish release notes:", file=sys.stderr) + print(md_body, file=sys.stderr) + return 5 + + print() + print(f"Release notes for {tag_name} published successfully:") + print(f"https://github.com/{slug}/releases/tag/{tag_name}") + print() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/push_docs_to_branch.sh b/scripts/push_docs_to_branch.sh new file mode 100755 index 0000000..0feee23 --- /dev/null +++ b/scripts/push_docs_to_branch.sh @@ -0,0 +1,99 @@ +#!/bin/bash + +# In case this script fails in our CI workflows, you can run it locally. +# For instance, if the doc version for 2.2.10 fails to be pushed, you +# can do: +# +# 1. make install install-docs +# 2. make prepare-docs +# 3. TMP_DOCS_FOLDER=/tmp/documentation DOCS_BRANCH=documentation GITHUB_REF=refs/tags/2.2.10 GITHUB_REPOSITORY=RasaHQ/rasa ./scripts/push_docs_to_branch.sh +# +# This script will push the changes to the `documentation` branch of this repository. + +set -Eeuo pipefail + +TODAY=`date "+%Y%m%d"` +# we build new versions only for minors and majors +PATTERN_FOR_NEW_VERSION="^refs/tags/[0-9]+\\.0\\.0$" +PATTERN_FOR_EXISTING_VERSION="^refs/tags/[0-9]+\\.[0-9]+\\.[0-9]+$" +MAIN_REF=refs/heads/main +VARIABLES_JSON=docs/docs/variables.json +SOURCES_FILES=docs/docs/sources/ +REFERENCE_FILES=docs/docs/reference/ +CHANGELOG=docs/docs/changelog.mdx +TELEMETRY_REFERENCE=docs/docs/telemetry/reference.mdx + +[[ ! $GITHUB_REF =~ $PATTERN_FOR_NEW_VERSION ]] \ +&& [[ ! $GITHUB_REF =~ $PATTERN_FOR_EXISTING_VERSION ]] \ +&& [[ $GITHUB_REF != $MAIN_REF ]] \ +&& echo "Not on main or tagged version, skipping." \ +&& exit 0 + +NEW_VERSION= +EXISTING_VERSION= +if [[ "$GITHUB_REF" =~ $PATTERN_FOR_NEW_VERSION ]] +then + NEW_VERSION=$(echo $GITHUB_REF | sed -E "s/^refs\/tags\/([0-9]+)\.([0-9]+)\.0$/\1.x/") + if [[ -n ${CI} ]]; then echo "New version: ${NEW_VERSION}"; fi +elif [[ "$GITHUB_REF" =~ $PATTERN_FOR_EXISTING_VERSION ]] +then + EXISTING_VERSION=$(echo $GITHUB_REF | sed -E "s/^refs\/tags\/([0-9]+)\.([0-9]+)\.[0-9]+$/\1.x/") + if [[ -n ${CI} ]]; then echo "Existing version: ${EXISTING_VERSION}"; fi +fi + +# clone the $DOCS_BRANCH in a temp directory +git clone --depth=1 --branch=$DOCS_BRANCH git@github.com:$GITHUB_REPOSITORY.git $TMP_DOCS_FOLDER + +if [ ! -z "$NEW_VERSION" ] && [ -d "$TMP_DOCS_FOLDER/docs/versioned_docs/version-$NEW_VERSION/" ] +then + echo "Trying to create a new docs version, but the folder already exist. Updating the $NEW_VERSION version instead..." + EXISTING_VERSION=$NEW_VERSION +fi + +# install yarn dependencies in the temp directory +cd $TMP_DOCS_FOLDER/docs && yarn install && cd - || exit 1 + +if [ ! -z "$EXISTING_VERSION" ] +then + echo "Updating docs for existing version $EXISTING_VERSION..." + # FIXME: this doesn't support all types of docs updates on an existing version at the moment, + # For instance if we were to make significant updates to the documentation pages + # (creating new page, deleting some, updating the sidebar), these changes wouldn't work here. + cp -R docs/docs/* $TMP_DOCS_FOLDER/docs/versioned_docs/version-$EXISTING_VERSION/ +else + echo "Updating the docs..." + # remove everything in the previous docs/ folder, except versioned_docs/*, versioned_sidebars/* + # and versions.js files. + # skip node_modules/ because `yarn install` has run + cd $TMP_DOCS_FOLDER/docs/ && find . ! -path . ! -regex '.*\(version\|node_modules\).*' -exec rm -rf {} + && cd - || exit 1 + # update the docs/ folder with the latest version of the docs + cp -R `ls -A | grep -v "^\.git$"` $TMP_DOCS_FOLDER/ + + if [ ! -z "$NEW_VERSION" ] + then + echo "Generating docs for new version $NEW_VERSION..." + cd $TMP_DOCS_FOLDER/docs && yarn run new-version $NEW_VERSION && cd - || exit 1 + fi +fi + +CURRENTLY_EDITING_VERSION=${EXISTING_VERSION:-$NEW_VERSION} +if [ -n "$CURRENTLY_EDITING_VERSION" ] +then + cd $TMP_DOCS_FOLDER/docs && yarn run update-versioned-sources $CURRENTLY_EDITING_VERSION && cd - || exit 1 +fi + +cd $TMP_DOCS_FOLDER + +if [ -z "$(git status --porcelain)" ] +then + echo "Nothing changed in docs, done 👍" +else + echo "Pushing changes to git..." + git add . + git add --force $VARIABLES_JSON $SOURCES_FILES $CHANGELOG $REFERENCE_FILES $TELEMETRY_REFERENCE + git commit -am "AUTO docusaurus $TODAY" + git fetch --unshallow + git push origin $DOCS_BRANCH + + echo "Done 👌" +fi diff --git a/scripts/read_tensorflow_version.sh b/scripts/read_tensorflow_version.sh new file mode 100755 index 0000000..1c54c12 --- /dev/null +++ b/scripts/read_tensorflow_version.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env python + +"""Extract the TensorFlow version from poetry.lock. Used in e.g. github workflows.""" + +import pathlib +import sys +import toml + +poetry_path = 'poetry.lock' + +if __name__ == "__main__": + if pathlib.Path(poetry_path).is_file(): + # Get the list of dict that contains all information about packages + package_list = toml.load(poetry_path).get('package') + # Extract tensorflow version + tf_version = next(filter(lambda x: x.get('name') == 'tensorflow', package_list)).get('version') + print(tf_version) + sys.exit(0) + + else: + print(f'File {poetry_path} does not exist.') + sys.exit(1) diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..068bd14 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,364 @@ +"""Prepare a Rasa OSS release. + +- creates a release branch +- creates a new changelog section in CHANGELOG.mdx based on all collected changes +- increases the version number +- pushes the new branch to GitHub +""" +import argparse +import os +import re +import sys +from pathlib import Path +from subprocess import CalledProcessError, check_call, check_output +from typing import Text, Set + +import questionary +import toml +from pep440_version_utils import Version, is_valid_version + + +VERSION_FILE_PATH = "rasa/version.py" + +PYPROJECT_FILE_PATH = "pyproject.toml" + +REPO_BASE_URL = "https://github.com/RasaHQ/rasa" + +RELEASE_BRANCH_PREFIX = "prepare-release-" + +PRERELEASE_FLAVORS = ("alpha", "rc") + +RELEASE_BRANCH_PATTERN = re.compile(r"^\d+\.\d+\.x$") + +PUBLIC_REMOTE = "public" +DEFAULT_REMOTE = "origin" +FIRST_CALM_VERSION = "3.7.0" + + +def create_argument_parser() -> argparse.ArgumentParser: + """Parse all the command line arguments for the release script.""" + + parser = argparse.ArgumentParser(description="prepare the next library release") + parser.add_argument( + "--next_version", + type=str, + help="Either next version number or 'major', 'minor', 'micro', 'alpha', 'rc'", + ) + + return parser + + +def project_root() -> Path: + """Root directory of the project.""" + return Path(os.path.dirname(__file__)).parent + + +def version_file_path() -> Path: + """Path to the python file containing the version number.""" + return project_root() / VERSION_FILE_PATH + + +def pyproject_file_path() -> Path: + """Path to the pyproject.toml.""" + return project_root() / PYPROJECT_FILE_PATH + + +def write_version_file(version: Version) -> None: + """Dump a new version into the python version file.""" + + with version_file_path().open("w") as f: + f.write( + f"# this file will automatically be changed,\n" + f"# do not add anything but the version number here!\n" + f'__version__ = "{version}"\n' + ) + check_call(["git", "add", str(version_file_path().absolute())]) + + +def write_version_to_pyproject(version: Version) -> None: + """Dump a new version into the pyproject.toml.""" + pyproject_file = pyproject_file_path() + + try: + data = toml.load(pyproject_file) + data["tool"]["poetry"]["version"] = str(version) + with pyproject_file.open("w", encoding="utf8") as f: + toml.dump(data, f) + except (FileNotFoundError, TypeError): + print(f"Unable to update {pyproject_file}: file not found.") + sys.exit(1) + except toml.TomlDecodeError: + print(f"Unable to parse {pyproject_file}: incorrect TOML file.") + sys.exit(1) + + check_call(["git", "add", str(pyproject_file.absolute())]) + + +def get_current_version() -> Text: + """Return the current library version.""" + + if not version_file_path().is_file(): + raise FileNotFoundError( + f"Failed to find version file at {version_file_path().absolute()}" + ) + + # context in which we evaluate the version py - + # to be able to access the defined version, it already needs to live in the + # context passed to exec + _globals = {"__version__": ""} + with version_file_path().open() as f: + exec(f.read(), _globals) + + return _globals["__version__"] + + +def confirm_version(version: Version) -> bool: + """Allow the user to confirm the version number.""" + + if str(version) in git_existing_tags(): + confirmed = questionary.confirm( + f"Tag with version '{version}' already exists, overwrite?", default=False + ).ask() + else: + confirmed = questionary.confirm( + f"Current version is '{get_current_version()}. " + f"Is the next version '{version}' correct ?", + default=True, + ).ask() + if confirmed: + return True + else: + print("Aborting.") + sys.exit(1) + + +def ask_version() -> Text: + """Allow the user to confirm the version number.""" + + def is_valid_version_number(v: Text) -> bool: + return v in {"major", "minor", "micro", "alpha", "rc"} or is_valid_version(v) + + current_version = Version(get_current_version()) + next_micro_version = str(current_version.next_micro()) + next_alpha_version = str(current_version.next_alpha()) + version = questionary.text( + f"What is the version number you want to release " + f"('major', 'minor', 'micro', 'alpha', 'rc' or valid version number " + f"e.g. '{next_micro_version}' or '{next_alpha_version}')?", + validate=is_valid_version_number, + ).ask() + + if version in PRERELEASE_FLAVORS and not current_version.pre: + # at this stage it's hard to guess the kind of version bump the + # releaser wants, so we ask them + if version == "alpha": + choices = [ + str(current_version.next_alpha("minor")), + str(current_version.next_alpha("micro")), + str(current_version.next_alpha("major")), + ] + else: + choices = [ + str(current_version.next_release_candidate("minor")), + str(current_version.next_release_candidate("micro")), + str(current_version.next_release_candidate("major")), + ] + version = questionary.select( + f"Which {version} do you want to release?", choices=choices + ).ask() + + if version: + return version + else: + print("Aborting.") + sys.exit(1) + + +def get_rasa_sdk_version() -> Text: + """Find out what the referenced version of the Rasa SDK is.""" + + dependencies_filename = "pyproject.toml" + toml_data = toml.load(project_root() / dependencies_filename) + try: + sdk_version = toml_data["tool"]["poetry"]["dependencies"]["rasa-sdk"] + if isinstance(sdk_version, str): + return sdk_version[1:].strip() + else: + return sdk_version["version"][1:].strip() + except AttributeError: + raise Exception(f"Failed to find Rasa SDK version in {dependencies_filename}") + + +def validate_code_is_release_ready(version: Version) -> None: + """Make sure the code base is valid (e.g. Rasa SDK is up to date).""" + + sdk = Version(get_rasa_sdk_version()) + sdk_version = (sdk.major, sdk.minor) + rasa_version = (version.major, version.minor) + + if sdk_version != rasa_version: + print() + print( + f"\033[91m There is a mismatch between the Rasa SDK version ({sdk}) " + f"and the version you want to release ({version}). Before you can " + f"release Rasa OSS, you need to release the SDK and update " + f"the dependency. \033[0m" + ) + print() + sys.exit(1) + + +def git_existing_tags() -> Set[Text]: + """Return all existing tags in the local git repo.""" + + stdout = check_output(["git", "tag"]) + return set(stdout.decode().split("\n")) + + +def git_current_branch() -> Text: + """Returns the current git branch of the local repo.""" + + try: + output = check_output(["git", "symbolic-ref", "--short", "HEAD"]) + return output.decode().strip() + except CalledProcessError: + # e.g. we are in detached head state + return "main" + + +def git_current_branch_is_main_or_release() -> bool: + """ + Returns True if the current local git + branch is main or a release branch e.g. 1.10.x + """ + current_branch = git_current_branch() + return ( + current_branch == "main" + or RELEASE_BRANCH_PATTERN.match(current_branch) is not None + ) + + +def create_release_branch(version: Version) -> Text: + """Create a new branch for this release. Returns the branch name.""" + + branch = f"{RELEASE_BRANCH_PREFIX}{version}" + check_call(["git", "checkout", "-b", branch]) + return branch + + +def create_commit(version: Version) -> None: + """Creates a git commit with all stashed changes.""" + check_call(["git", "commit", "-m", f"prepared release of version {version}"]) + + +def push_changes(remote: str = DEFAULT_REMOTE) -> None: + """Pushes the current branch to the specified remote.""" + check_call(["git", "push", remote, "HEAD"]) + + +def ensure_clean_git() -> None: + """Makes sure the current working git copy is clean.""" + + try: + check_call(["git", "diff-index", "--quiet", "HEAD", "--"]) + except CalledProcessError: + print("Your git is not clean. Release script can only be run from a clean git.") + sys.exit(1) + + +def parse_next_version(version: Text) -> Version: + """Find the next version as a proper semantic version string.""" + if version == "major": + return Version(get_current_version()).next_major() + elif version == "minor": + return Version(get_current_version()).next_minor() + elif version == "micro": + return Version(get_current_version()).next_micro() + elif version == "alpha": + return Version(get_current_version()).next_alpha() + elif version == "rc": + return Version(get_current_version()).next_release_candidate() + elif is_valid_version(version): + return Version(version) + else: + raise Exception(f"Invalid version number '{cmdline_args.next_version}'.") + + +def next_version(args: argparse.Namespace) -> Version: + """Take cmdline args or ask the user for the next version and return semver.""" + return parse_next_version(args.next_version or ask_version()) + + +def generate_changelog(version: Version) -> None: + """Call tonwcrier and create a changelog from all available changelog entries.""" + check_call( + ["towncrier", "build", "--yes", "--version", str(version)], + cwd=str(project_root()), + ) + + +def print_done_message(branch: Text, base: Text, version: Version) -> None: + """Print final information for the user on what to do next.""" + + pull_request_url = f"{REPO_BASE_URL}/compare/{base}...{branch}?expand=1" + + print() + print(f"\033[94m All done - changes for version {version} are ready! \033[0m") + print() + print(f"Please open a PR on GitHub: {pull_request_url}") + + +def print_done_message_same_branch(version: Version) -> None: + """ + Print final information for the user in case changes + are directly committed on this branch. + """ + + print() + print( + f"\033[94m All done - changes for version {version} where committed on this branch \033[0m" + ) + + +def main(args: argparse.Namespace) -> None: + """Start a release preparation.""" + + print( + "The release script will increase the version number, " + "create a changelog and create a release branch. Let's go!" + ) + + ensure_clean_git() + version = next_version(args) + confirm_version(version) + + validate_code_is_release_ready(version) + + write_version_file(version) + write_version_to_pyproject(version) + + if not version.pre: + # never update changelog on a prerelease version + generate_changelog(version) + + remote = PUBLIC_REMOTE if str(version) < FIRST_CALM_VERSION else DEFAULT_REMOTE + # alpha workflow on feature branch when a version bump is required + if version.is_alpha and not git_current_branch_is_main_or_release(): + create_commit(version) + push_changes(remote) + + print_done_message_same_branch(version) + else: + base = git_current_branch() + branch = create_release_branch(version) + + create_commit(version) + push_changes(remote) + + print_done_message(branch, base, version) + + +if __name__ == "__main__": + arg_parser = create_argument_parser() + cmdline_args = arg_parser.parse_args() + main(cmdline_args) diff --git a/scripts/write_keys_file.sh b/scripts/write_keys_file.sh new file mode 100755 index 0000000..fa6a79f --- /dev/null +++ b/scripts/write_keys_file.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env python + +"""Write authentication keys from environment variable to key file for packaging.""" + +import sys +import re +import os +import json + +if __name__ == "__main__": + keys = { + "segment": os.environ.get("RASA_TELEMETRY_WRITE_KEY"), + "sentry": os.environ.get("RASA_EXCEPTION_WRITE_KEY") + } + + with open(os.path.join("rasa", "keys"), "w") as f: + json.dump(keys, f) + + print("Dumped keys to rasa/keys") diff --git a/secrets.tar.enc b/secrets.tar.enc new file mode 100644 index 0000000..1ebd0a5 Binary files /dev/null and b/secrets.tar.enc differ diff --git a/security.txt b/security.txt new file mode 100644 index 0000000..a83874c --- /dev/null +++ b/security.txt @@ -0,0 +1,4 @@ +Contact: mailto:security@rasa.com +Preferred-Languages: en +Canonical: https://github.com/RasaHQ/rasa/blob/main/security.txt +Hiring: https://rasa.com/jobs diff --git a/stubs/aio_pika/__init__.pyi b/stubs/aio_pika/__init__.pyi new file mode 100644 index 0000000..c4cd327 --- /dev/null +++ b/stubs/aio_pika/__init__.pyi @@ -0,0 +1,48 @@ +# FIXME: aio_pika 7.0 comes with types, we might want to upgrade +# https://github.com/mosquito/aio-pika/blob/master/CHANGELOG.md#700 +from aio_pika import patterns, pool +from aio_pika.channel import Channel +from aio_pika.connection import Connection, connect +from aio_pika.exceptions import AMQPException, MessageProcessError +from aio_pika.exchange import Exchange, ExchangeType +from aio_pika.message import DeliveryMode, IncomingMessage, Message +from aio_pika.queue import Queue +from aio_pika.robust_channel import RobustChannel +from aio_pika.robust_connection import RobustConnection, connect_robust +from aio_pika.robust_exchange import RobustExchange +from aio_pika.robust_queue import RobustQueue +from aio_pika.version import ( + __author__, + __version__, + author_info, + package_info, + package_license, + version_info, +) + +__all__ = ( + "__author__", + "__version__", + "author_info", + "connect", + "connect_robust", + "package_info", + "package_license", + "patterns", + "pool", + "version_info", + "AMQPException", + "Channel", + "Connection", + "DeliveryMode", + "Exchange", + "ExchangeType", + "IncomingMessage", + "Message", + "MessageProcessError", + "Queue", + "RobustChannel", + "RobustConnection", + "RobustExchange", + "RobustQueue", +) diff --git a/stubs/aio_pika/robust_channel.pyi b/stubs/aio_pika/robust_channel.pyi new file mode 100644 index 0000000..9fe65ef --- /dev/null +++ b/stubs/aio_pika/robust_channel.pyi @@ -0,0 +1,22 @@ +from typing import Union + +# mypy check fails here but it actually successfully loads the initial module +# so it's probably an internal issue of mypy with no repercussions +from aio_pika.robust_channel import RobustChannel as AioPikaRobustChannel # type: ignore[attr-defined] +from aio_pika.robust_exchange import RobustExchange +from aio_pika.exchange import ExchangeType +from aio_pika.types import TimeoutType + +class RobustChannel(AioPikaRobustChannel): + async def declare_exchange( + self, + name: str, + type: Union[ExchangeType, str] = ExchangeType.DIRECT, + durable: bool = None, + auto_delete: bool = False, + internal: bool = False, + passive: bool = False, + arguments: dict = None, + timeout: TimeoutType = None, + robust: bool = True, + ) -> RobustExchange: ... diff --git a/stubs/sanic.pyi b/stubs/sanic.pyi new file mode 100644 index 0000000..86aa457 --- /dev/null +++ b/stubs/sanic.pyi @@ -0,0 +1,48 @@ +from typing import Any, Dict, Iterable, List, Optional, Text, Union + +from sanic.__version__ import __version__ +from sanic.app import Sanic as _BaseSanic +from sanic.blueprints import Blueprint as _BaseBlueprint +from sanic.constants import HTTPMethod +from sanic.mixins.routes import RouteWrapper +from sanic.request import Request +from sanic.response import HTTPResponse, html, json, text + +__all__ = [ + "__version__", + "Sanic", + "Blueprint", + "HTTPMethod", + "HTTPResponse", + "Request", + "html", + "json", + "text", +] + +class Sanic(_BaseSanic): + def stop(self) -> None: ... + def exception(self, *exceptions: Exception, apply: bool = True) -> RouteWrapper: ... + +class Blueprint(_BaseBlueprint): + def register(self, app: Sanic, options: Dict[Text, Any]) -> None: ... + # FIXME: Sanic uses a lazy() untyped decorator + def route( + self, + uri: Text, + methods: Optional[Iterable[Text]] = None, + host: Optional[Union[Text, List[Text]]] = None, + strict_slashes: Optional[bool] = None, + stream: bool = False, + version: Optional[Union[int, Text, float]] = None, + name: Optional[Text] = None, + ignore_body: bool = False, + apply: bool = True, + subprotocols: Optional[List[Text]] = None, + websocket: bool = False, + unquote: bool = False, + static: bool = False, + version_prefix: Text = "/v", + error_format: Optional[Text] = None, + **ctx_kwargs: Any, + ) -> RouteWrapper: ... diff --git a/stubs/socketio.pyi b/stubs/socketio.pyi new file mode 100644 index 0000000..f3f4214 --- /dev/null +++ b/stubs/socketio.pyi @@ -0,0 +1,52 @@ +from typing import Callable, Text, TypeVar + +from socketio.asgi import ASGIApp +from socketio.asyncio_aiopika_manager import AsyncAioPikaManager +from socketio.asyncio_client import AsyncClient +from socketio.asyncio_manager import AsyncManager +from socketio.asyncio_namespace import AsyncNamespace, AsyncClientNamespace +from socketio.asyncio_redis_manager import AsyncRedisManager +from socketio.asyncio_server import AsyncServer as _BaseAsyncServer +from socketio.base_manager import BaseManager +from socketio.client import Client +from socketio.kafka_manager import KafkaManager +from socketio.kombu_manager import KombuManager +from socketio.middleware import WSGIApp, Middleware +from socketio.namespace import Namespace, ClientNamespace +from socketio.pubsub_manager import PubSubManager +from socketio.redis_manager import RedisManager +from socketio.server import Server +from socketio.tornado import get_tornado_handler +from socketio.zmq_manager import ZmqManager + +_all__ = [ + "ASGIApp", + "AsyncAioPikaManager", + "AsyncClient", + "AsyncClientNamespace", + "AsyncManager", + "AsyncNamespace", + "AsyncRedisManager", + "AsyncServer", + "BaseManager", + "Client", + "ClientNamespace", + "get_tornado_handler", + "KafkaManager", + "KombuManager", + "Middleware", + "Namespace", + "PubSubManager", + "RedisManager", + "Server", + "WSGIApp", + "ZmqManager", +] + +Handler = TypeVar("Handler") +SetHandler = Callable[[Handler], Handler] + +class AsyncServer(_BaseAsyncServer): + def on( + self, event: Text, handler: Handler = None, namespace: Text = None + ) -> SetHandler: ... diff --git a/test_environments/README.md b/test_environments/README.md new file mode 100644 index 0000000..a39a25d --- /dev/null +++ b/test_environments/README.md @@ -0,0 +1,9 @@ +# Test environments for developing + +This directory contains configurations and instructions on how to set up and run various external services +Rasa Open Source supports. + +They can be used by engineers while developing or testing features of Rasa Open Source. + +Setup includes: +* [message brokers](message_and_event_brokers/README.md) \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/README.md b/test_environments/message_and_event_brokers/README.md new file mode 100644 index 0000000..408d4f3 --- /dev/null +++ b/test_environments/message_and_event_brokers/README.md @@ -0,0 +1,6 @@ +# Testing message and event brokers + +This directory contains configurations and instructions on how to set up and run various message and event brokers. + +Supported services include: +* [kafka](kafka/README.md) \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/README.md b/test_environments/message_and_event_brokers/kafka/README.md new file mode 100644 index 0000000..50cec2a --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/README.md @@ -0,0 +1,30 @@ +# Testing Kafka broker + +This is a set of various configurations under which Kafka message broker can be run. +Use them to spin up Kafka as a Docker container in a predefined configuration. + +### Configuration description +Each configuration is described in its own README file. +Typical configuration includes: +* Kafka broker listening on port 909x (x is number between 1 and 9 and depends on the authentication setup which is run) +* Zookeeper listening on port 218x (x is number between 1 and 9 and depends on the authentication setup which is run) + +#### About Zookeeper +Kafka and ZooKeeper work in conjunction to form a complete Kafka Cluster — with ZooKeeper +providing the distributed clustering services, +and Kafka handling the actual data streams and connectivity to clients. + +At a detailed level, ZooKeeper handles the leadership election of Kafka brokers and +manages service discovery as well as cluster topology so each broker knows +when brokers have entered or exited the cluster, when a broker dies and who the +preferred leader node is for a given topic/partition pair. + +It also tracks when topics are created or deleted from the cluster and maintains a topic list. +In general, ZooKeeper provides an in-sync view of the Kafka cluster. + +### Available configurations +* [Kafka without any authentication](no_authentication/README.md) +* [Kafka with SASL_PLAIN authentication without TLS encryption](sasl_plain/no_tls/README.md) +* [Kafka with SASL_PLAIN authentication and TLS encryption](sasl_plain/with_tls/README.md) +* [Kafka with SASL_SCRAM authentication without TLS encryption](sasl_scram/no_tls/README.md) +* [Kafka with SASL_SCRAM authentication and TLS encryption](sasl_scram/with_tls/README.md) diff --git a/test_environments/message_and_event_brokers/kafka/no_authentication/README.md b/test_environments/message_and_event_brokers/kafka/no_authentication/README.md new file mode 100644 index 0000000..9ae19e3 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/no_authentication/README.md @@ -0,0 +1,11 @@ +# Setup Kafka broker with no authentication + +This is a simple setup of Kafka with no authentication. +It is intended to be used to set up test environment in which Kafka brokers do not require any form of +authentication from clients. +Kafka will be listening on port 9092. You can connect to the broker with URL localhost:9092. + +To run: +```shell +docker-compose up -d +``` \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/no_authentication/docker-compose.yml b/test_environments/message_and_event_brokers/kafka/no_authentication/docker-compose.yml new file mode 100644 index 0000000..aba3d65 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/no_authentication/docker-compose.yml @@ -0,0 +1,38 @@ +--- +version: '3.8' +services: + zookeeper: + image: confluentinc/cp-zookeeper:7.3.2 + container_name: zookeeper-no-authentication + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + healthcheck: + test: nc -z localhost 2181 || exit 1 + interval: 10s + retries: 10 + start_period: 15s + timeout: 10s + + kafka-broker: + image: confluentinc/cp-kafka:7.3.2 + container_name: kafka-broker-no-authentication + ports: + - "9092:9092" + depends_on: + zookeeper: + condition: service_healthy + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181' + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + healthcheck: + test: nc -z localhost 9092 || exit 1 + interval: 30s + retries: 10 + start_period: 15s + timeout: 10s diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/no_tls/README.md b/test_environments/message_and_event_brokers/kafka/sasl_plain/no_tls/README.md new file mode 100644 index 0000000..003d189 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/no_tls/README.md @@ -0,0 +1,20 @@ +# Setup Kafka broker with SASL PLAIN authentication and no TLS encryption + +This is a simple setup of Kafka with authentication and without TLS encryption. +It is intended to be used to set up test environment in which Kafka brokers require clients to authenticate. +All communication is done over insecure plain connection (without TLS). +Kafka will be listening on port 9093. You can connect to the broker with URL localhost:9093. + +To run: +```shell +docker-compose up -d +``` + +To connect to the broker from the client, use one of the users: + +| User | Password | +|--------|--------------| +| admin | admin-secret | +| alice | alice-secret | + +These users are defined in the `kafka_jaas.conf` file. \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/no_tls/broker_jaas.conf b/test_environments/message_and_event_brokers/kafka/sasl_plain/no_tls/broker_jaas.conf new file mode 100644 index 0000000..7253e4e --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/no_tls/broker_jaas.conf @@ -0,0 +1,8 @@ +KafkaServer { + org.apache.kafka.common.security.plain.PlainLoginModule required + username="admin" + password="admin-secret" + user_admin="admin-secret" + user_alice="alice-secret"; +}; +Client{}; diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/no_tls/docker-compose.yml b/test_environments/message_and_event_brokers/kafka/sasl_plain/no_tls/docker-compose.yml new file mode 100644 index 0000000..036316c --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/no_tls/docker-compose.yml @@ -0,0 +1,44 @@ +--- +version: '3.8' +services: + zookeeper: + image: confluentinc/cp-zookeeper:7.3.2 + container_name: zookeeper-sasl-plain-no-tls + environment: + ZOO_MY_ID: 1 + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + ZOOKEEPER_SASL_ENABLED: "false" + healthcheck: + test: nc -z localhost 2181 || exit 1 + interval: 10s + retries: 10 + start_period: 15s + timeout: 10s + + kafka-broker: + image: confluentinc/cp-kafka:7.3.2 + container_name: kafka-broker-sasl-plain-no-tls + ports: + - "9093:9093" + depends_on: + zookeeper: + condition: service_healthy + environment: + KAFKA_LISTENERS: SASL_PLAINTEXT://:9092 + KAFKA_ADVERTISED_LISTENERS: SASL_PLAINTEXT://localhost:9093 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + ZOOKEEPER_SASL_ENABLED: "false" + KAFKA_INTER_BROKER_LISTENER_NAME: SASL_PLAINTEXT + KAFKA_SASL_ENABLED_MECHANISMS: PLAIN + KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL: PLAIN + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_OPTS: "-Djava.security.auth.login.config=/etc/kafka/broker_jaas.conf" + volumes: + - ./broker_jaas.conf:/etc/kafka/broker_jaas.conf + healthcheck: + test: nc -z localhost 9093 || exit 1 + interval: 30s + retries: 10 + start_period: 15s + timeout: 10s \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/README.md b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/README.md new file mode 100644 index 0000000..6479795 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/README.md @@ -0,0 +1,108 @@ +# Setup Kafka broker with SASL PLAIN authentication and TLS encryption +This is a simple setup of Kafka with authentication and TLS encryption. +It is intended to be used to set up test environment in which Kafka brokers require clients to authenticate. +All communication is done over secure TLS connection. + +#### About Subject Alternate Name (SAN) +A Subject Alternate Name (or SAN) certificate is a digital security +certificate which allows multiple hostnames or IPs to be protected by a single certificate. + +Examples: +
If TLS certificate has SAN set to `194.3.5.1`, then clients will accept the certificate if IP of Kafka broker +to which they are connecting is `194.3.5.1`. +
If TLS certificate has SAN set to `localhost`, then clients will accept the certificate if the hostname +of the Kafka broker to which we are connecting is `localhost`. +
If TLS certificate has SAN set to `rasa.com`, then clients will accept the certificate if the hostname +of the Kafka broker to which we are connecting is `rasa.com`. +
If TLS certificate has SAN set to 0.0.0.0 then clients will accept the certificate +from any hostname or IP address. This is useful for testing purposes. DO NOT USE THIS IN PRODUCTION!!! + +### Supported Test Environments +Test environments are located in directories: +* `ssl_all_connections` - Broker certificate has SAN set to `0.0.0.0` +* `ssl_localhost` - Broker certificate has SAN set to `localhost` + +All certificates required for TLS are already generated. **They are not intended to be used in production.** +
Certificates in this environment are used to verify the identity of the Kafka broker to the clients. +Pre-generated certificates are valid through `30/3/2024`. +If you need to generate new certificates checkout the README +files in directories `./ssl_all_connections` and `./ssl_localhost`. + + +## How to connect to Kafka broker +To connect to the broker from the client use: +* URL localhost:9094 (clients accept certificate from any IP Kafka broker my run on) or localhost:9095 (clients accept certificate only from Kafka Broker running on localhost) +* one of the users + + | User | Password | + |--------|--------------| + | admin | admin-secret | + | alice | alice-secret | + +Users which are available for clients are defined in the `kafka_jaas.conf` file. + +Add CA certificate to the client certificate pool before starting it. +
+One option is to add it to the OS's certificate pool on the machine you are running the client on. +
+The other option is to add certificate to the client's in-memory certificate pool, which is active only during the client's runtime. +Consult documentation of the library you are using to manage certificates or library which you are +using to connect to Kafka broker on how to add certificate to in-memory certificate pool. +
This is required to verify the identity of the Kafka broker. +
If you want to skip verification of the Kafka broker's identity, +you can instruct your client to skip verification of the certificate. +
+Consult the library you are using to connect to Kafka broker on how to skip verification of the certificate. +If you skip verification of the certificate communication over secure TLS will still be used, +but the identity of the Kafka broker will not be verified. + + +## About certificates + +RSA algorithm is used to generate private and public keys used to sign/verify/encrypt/decrypt data. +
+Certificate Authority (CA) is a trusted entity which issues (signs) certificates from other entities. +Certificate Authority generates a private key and a public key. +
Private key is used to sign certificates from other entities. It must be protected and not shared. +
Public key is used to verify the signature of the certificate. It is shared with the clients. +Clients use the public key to verify that the certificate +it received was signed by the CA and not by a malicious entity. + +In TLS communication we need to have: +* a CA's private key +* a CA's public key, also called `CA certificate` +* a certificate of the Kafka broker signed by CA's private key + +When Kafka broker is contacted by a client, +it sends its certificate to the client. +
Client verifies the certificate using the CA certificate. +If the certificate is valid, the client can connect to the broker. + +More about TLS can be read here: https://www.cloudflare.com/learning/ssl/transport-layer-security-tls/ + +## Troubleshooting + +To inspect content of the keystore, you can use the following command: +```shell +keytool -list -v -keystore server.keystore.jks -storepass 123456 -keypass 123456 +``` + +To check if private key is password protected +```shell +openssl rsa -check -in ca-key -passin pass:123456 +``` + +To check if CA certificate can unlock signed certificate +```shell +openssl verify -CAfile ca-cert signed-server-cert +``` + +To check if TLS connection is working +```shell +# for TLS 1.0 +openssl s_client -debug -connect localhost:29092 -tls1 +# for TLS 1.1 +openssl s_client -debug -connect localhost:29092 -tls1_1 +# for TLS 1.2 +openssl s_client -debug -connect localhost:29092 -tls1_2 +``` \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/.gitignore b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/.gitignore new file mode 100644 index 0000000..ed72d35 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/.gitignore @@ -0,0 +1,3 @@ +ca-cert.srl +ca-key +cert-request diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/README.md b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/README.md new file mode 100644 index 0000000..9b6facc --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/README.md @@ -0,0 +1,47 @@ +# Setup Kafka broker with TLS and SASL_PLAIN (accept connections from all IPs) + +This directory contains `docker-compose.yml` along with pre-generated certificates for TLS bound to IP `0.0.0.0`. +Pre-generated certificates are valid for one year term (not that MacOS allows certificates with maximum validity of two years) +and are due to expire somewhere around March 20th, 2024. +If certificates are expired, you can generate new ones [here](#how-to-generate-certificates-for-tls-bound-to-ip-0000). + +## Description of files in this directory + +* `docker-compose.yml` - docker compose file with Kafka and Zookeeper containers +* `server.keystore.jks` - keystore with server certificates and CA certificate +* `ca-cert` - CA (Certificate Authority) certificate (used to sign the server certificate), it must be imported into the keystore as a trusted certificate. +Client should also import this certificate to verify the identity of the Kafka broker. +* `ca-key` - CA private key (used to generate CA certificate `ca-cert`) +* `cert-request` - certificate request for the broker, it must be signed by the CA before it can be used +* `signed-server-cert` - signed certificate for the broker, it must be imported into the keystore +* `ssl_keystore_password` - file containing the password for the keystore +* `ssk_key_password` - file containing the password for the CA private key, used to unlock the CA certificate +* `broker_jaas.conf` - JAAS configuration file for the broker, contains usernames and passwords a client can use to authenticate + + +## How to generate certificates for TLS bound to IP 0.0.0.0 + +In order to provide TLS encryption for Kafka broker, we need to generate a keystore with a signed certificate for the broker. +We only need to generate certificates once a year and to commit them (along with keystore), as they are valid for one year term. + +Refer to [this](../README.md#about-certificates) section for more details about RSA certificates. + +You can create the certificates and store them in the keystore using the following commands: +```shell +# Create CA +openssl req -x509 -newkey rsa:4096 -keyout ca-key -out ca-cert -days 365 -nodes -subj '/CN=localhost/OU=Atom/O=Rasa/L=Berlin/ST=Germany/C=GE' -passin pass:123456 -passout pass:123456 + +# Create server keystore protected with storepass and keypass +keytool -dname "CN=localhost,OU=Atom,O=Rasa,L=Berlin,S=Germany,C=GE" -keystore server.keystore.jks -alias localhost -validity 365 -genkey -keyalg RSA -storetype pkcs12 -ext SAN=IP:0.0.0.0 -storepass 123456 -keypass 123456 + +# Create a certificate request +keytool -keystore server.keystore.jks -alias localhost -certreq -file cert-request -storepass 123456 -keypass 123456 -ext "SAN=IP:0.0.0.0" + +# Sign the certificate request +openssl x509 -req -CA ca-cert -CAkey ca-key -in cert-request -out signed-server-cert -days 365 -CAcreateserial -passin pass:123456 + +# Import root certificate into keystore +keytool -keystore server.keystore.jks -alias CARoot -import -file ca-cert -storepass 123456 -keypass 123456 +# Import signed certificate into keystore +keytool -noprompt -keystore server.keystore.jks -alias localhost -import -file signed-server-cert -storepass 123456 -keypass 123456 -ext "SAN=IP:0.0.0.0" +``` diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/broker_jaas.conf b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/broker_jaas.conf new file mode 100644 index 0000000..58fbb4f --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/broker_jaas.conf @@ -0,0 +1,8 @@ +KafkaServer { + org.apache.kafka.common.security.plain.PlainLoginModule required + username="admin" + password="admin-secret" + user_admin="admin-secret" + user_alice="alice-secret"; +}; +Client{}; \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/ca-cert b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/ca-cert new file mode 100644 index 0000000..e0bd56b --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/ca-cert @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFpTCCA42gAwIBAgIUJrAhZfqSwi58kw9NnfULth90P2EwDQYJKoZIhvcNAQEL +BQAwYjESMBAGA1UEAwwJbG9jYWxob3N0MQ0wCwYDVQQLDARBdG9tMQ0wCwYDVQQK +DARSYXNhMQ8wDQYDVQQHDAZCZXJsaW4xEDAOBgNVBAgMB0dlcm1hbnkxCzAJBgNV +BAYTAkdFMB4XDTIzMDMzMDIyMDQ1MloXDTI0MDMyOTIyMDQ1MlowYjESMBAGA1UE +AwwJbG9jYWxob3N0MQ0wCwYDVQQLDARBdG9tMQ0wCwYDVQQKDARSYXNhMQ8wDQYD +VQQHDAZCZXJsaW4xEDAOBgNVBAgMB0dlcm1hbnkxCzAJBgNVBAYTAkdFMIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1xu+1WrOwG1v5E3EBEizEie7VI7F +5qqePj5JGa5XYz+phkrQjLCypaaQDLdsOA979bGokYADhjJEmxdgwdCoFPKuA/xN +3fEP35mNMQOl8x3V2nGG3+LvlWYmbu1TV56O9Yb/+ZI1SzWZ9PqcW3ah7C1fDySQ +YRMvtCBOhV75QIGkW4s7X57cFHxI27lF313/2GpunmtPwFVRamj0MNcdTpqsIp18 +j/Oex8R7dYda4UJWsIP6dse9dmsEfG/EEW08XwLVmdZiMECNxugcsymxn7D2mAjV +jx4dodXTF9C80gz3QhcmnnVeUIYDrcUyw5dgWb0MYm+/VXWQSiESM8AgZiHbAg/k +Sk8beGgqr7fnrYxL4OIHXrC8YkGn4dRyM44dmeDKSHV5f9R7AS6hNBbQHa/6Eskz +Cfb5H3/pCMF/EMV7fc1XpErBCjFYfLQy9p35TOatxYGS1/Zs2VL9bloP5mZhaVxE +qedO3Ddl5meGBMxEphAI9A/lHDjANEOcCev6Em4Iwj4pdO/ndstNsFV7wgtqR4hS +c/NamQ2eZcc42axNMKhTDubtOgHTdaXDlYMLzsQj/iIsBh+959tzP+1uIVnwBhcK +OMh9Yg3jtqX83JMQQvT5LkLTCACTYZHJH0KVfso3FD3nU0xZXlGPek1uKYau9lCC +x+DVZPRKOFT0QQkCAwEAAaNTMFEwHQYDVR0OBBYEFNwXAJFNxld5LcaY+hQvX+lV +myGvMB8GA1UdIwQYMBaAFNwXAJFNxld5LcaY+hQvX+lVmyGvMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADggIBALj5PmvaHPRISBcwkr3JgqdKpuLG8ptu +XAwdDK/dmI+UXNU59iP2RzCmjWQJuN3jIYvOhD+V8YvgqSvA4ZHKIlkhH8lNSV6U +cRfFINdnMRKBUxov3Vjg+U7KmznA6toG9ty441ZFqNJF27tr5TlTl0DtoLIO/uLV +MBcOFgRhzeBCBKNkCbvJjZrHrSm/c8RqHawMc3jr2AAnuq6DT6Rhv93qgKTpRNdX +/z526TxSqzWxv4eHBazu7GThuRZMwr+a3q84BleXY0SZ4PzRA/OGNCsv1AHFNwYJ +JSz74GCVfkP38Eiikt7eGRbyrpAZTe8i6VsJvOddQzMdbDT6qTVwgIE7Ov6fS18V +NegV09F8I3NAhvcrdr7ZeGd3Kb1b0r/TzniKGm8/BFpFJvwhrQhDtkr+fE8FhTAT +/elxL42Q2j2PqQ57CCvrcCqCmdP2eduKA7/zH5H4vYVs5nwNwC02Uk6/DN3JO/jI +EQ03lVs6clU+OZRxgZLSkqUSewMQumqcRysyQzp2CuMIZtslGdIT3ftrhy6BCwJt +oynadRv81USDSBOT6mog+nnC9WwzEFnLxjMzuZWYI38ZBpmaQIDXEFw7LKJsu4/K +clw2SnGy/vvT2pJr6U2bVFurUZk09hsGebznX355m2GzNLPJYPvhIYNi38tzNlV/ +IlEloqKnOs2q +-----END CERTIFICATE----- diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/docker-compose.yml b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/docker-compose.yml new file mode 100644 index 0000000..a5bb949 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/docker-compose.yml @@ -0,0 +1,53 @@ +--- +version: '3.8' +services: + zookeeper: + image: confluentinc/cp-zookeeper:7.3.2 + container_name: zookeeper-sasl-plain-tls-all-connections + environment: + ZOO_MY_ID: 1 + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + ZOOKEEPER_SASL_ENABLED: "false" + healthcheck: + test: nc -z localhost 2181 || exit 1 + interval: 10s + retries: 10 + start_period: 15s + timeout: 10s + + kafka-broker: + image: confluentinc/cp-kafka:7.3.2 + container_name: kafka-broker-sasl-plain-tls-all-connections + ports: + - "9094:9094" + - "29094:29094" + depends_on: + zookeeper: + condition: service_healthy + environment: + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + ZOOKEEPER_SASL_ENABLED: "false" + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_LISTENERS: SASL_SSL://0.0.0.0:9094, PLAINTEXT://0.0.0.0:29094 + KAFKA_ADVERTISED_LISTENERS: SASL_SSL://localhost:9094, PLAINTEXT://localhost:29094 + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_SASL_ENABLED_MECHANISMS: PLAIN + KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL: PLAIN + KAFKA_OPTS: "-Djava.security.auth.login.config=/etc/kafka/broker_jaas.conf" + KAFKA_SSL_KEYSTORE_FILENAME: kafka.server.keystore.jks + KAFKA_SSL_KEYSTORE_CREDENTIALS: ssl_keystore_credentials + KAFKA_SSL_KEY_CREDENTIALS: ssl_key_credentials + KAFKA_SSL_ENABLED_PROTOCOLS: TLSv1.2,TLSv1.1,TLSv1 + KAFKA_LOG4J_ROOT_LOGLEVEL: DEBUG + volumes: + - ./broker_jaas.conf:/etc/kafka/broker_jaas.conf + - ./server.keystore.jks:/etc/kafka/secrets/kafka.server.keystore.jks + - ./ssl_key_credentials:/etc/kafka/secrets/ssl_key_credentials + - ./ssl_keystore_credentials:/etc/kafka/secrets/ssl_keystore_credentials + healthcheck: + test: nc -z localhost 9094 || exit 1 + interval: 30s + retries: 10 + start_period: 15s + timeout: 10s diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/server.keystore.jks b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/server.keystore.jks new file mode 100644 index 0000000..004172b Binary files /dev/null and b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/server.keystore.jks differ diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/signed-server-cert b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/signed-server-cert new file mode 100644 index 0000000..fc4887a --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/signed-server-cert @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIESzCCAjMCFCCANOWUiIFMILwlv7+DJ5PTjK+BMA0GCSqGSIb3DQEBCwUAMGIx +EjAQBgNVBAMMCWxvY2FsaG9zdDENMAsGA1UECwwEQXRvbTENMAsGA1UECgwEUmFz +YTEPMA0GA1UEBwwGQmVybGluMRAwDgYDVQQIDAdHZXJtYW55MQswCQYDVQQGEwJH +RTAeFw0yMzAzMzAyMjA2NDZaFw0yNDAzMjkyMjA2NDZaMGIxCzAJBgNVBAYTAkdF +MRAwDgYDVQQIEwdHZXJtYW55MQ8wDQYDVQQHEwZCZXJsaW4xDTALBgNVBAoTBFJh +c2ExDTALBgNVBAsTBEF0b20xEjAQBgNVBAMTCWxvY2FsaG9zdDCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAKCKyjJfEYOc1gQkAHz2JW5M7b6XEsHAr2wo +5dW0FGdBQVr+JWFdF256RtC9fPYXbT7hZlyKzdfHtpYKwQG7f3gd8JV5hFwPFaQz +AuINrexC95Cf583D9/Mywxjg2W6PkexmOquGWeq5qV2aDqdFe2HQsIq0GFIXMZbj +cpQuIs4D8ttvbQ0UnMwWd/OTElEjEUbmKuoMXcn7tuio2q9feW/pbF6dGhv+9d/9 +HaDSK+hU9nzWX+qD1OIavAdUgXKugrDZjzOeRgwhO0eoIeHZTzSe0RFYwsfOG7Zr +9+DyOZ07SNMyIgFgBJ1QHlWFXfCJ8kdMTpDZW+fIOQ9EKiPiPScCAwEAATANBgkq +hkiG9w0BAQsFAAOCAgEAB2HqhEzn/rUj70FwdgbxFLZ1BQqvpafnd4Mzj12y8TNN +2ZepBF1T0+Lmn4dO+9VpXaYNfbfzv7UqZzeasstJlyhtED+pV+KFpWQkzFo/c79l +/E1vhzFDrLyhEdmiHX+uUyDlFLznVWJs5wY2EnDHwFk0rs8zIr2k9Dhbb2RRYOqz +z5NRCUbw8RvBCt/1Z2k0nx/fhK9qeT5pNuLE0ZCNsZV5CT805iQZ1tw6K750gdHq +AI48z0G1rrtljFNIynkS0EFZjhGNV92nvBVXVm8xECbdOQQTHF2Q0s1K+JDE996b +ZlKywJuIBtWD8B3OMJaXlvTqSpNEow/ARf5KfUV137RYWXcILLbvqC8aLXWP9ozw +2xz6V8lJSpdWMc7hrV30zBfUsgoJGojfKHPh9Bzh1aUbEaywf7hkhAQ9kljgpqcX +U63IDc6KNp8ocg1wgpGZsBxoO0saASNZtb9XGSstLcFoOnanEBLe9+Y6v7s4vWY7 +EcHthRnBnlN5MG+IyX9AhRsJ0JvhbRGWv+DeHIgY8UYs6bHrzRhk0DkQbupaFogW +7XraHnSrtiwX6F+Nb74vJ+TxSjHgfEukpHXeqnR2uQyxgd8leQ0/9zF+jV1KaSBl +tMty3NYP7zSwjDsB/o6LYKzemDg6t4sent8jlZ7eBUKOe9I0bK5gKVAqxaCQDxA= +-----END CERTIFICATE----- diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/ssl_key_credentials b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/ssl_key_credentials new file mode 100644 index 0000000..4632e06 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/ssl_key_credentials @@ -0,0 +1 @@ +123456 \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/ssl_keystore_credentials b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/ssl_keystore_credentials new file mode 100644 index 0000000..4632e06 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/ssl_keystore_credentials @@ -0,0 +1 @@ +123456 \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/.gitignore b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/.gitignore new file mode 100644 index 0000000..ed72d35 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/.gitignore @@ -0,0 +1,3 @@ +ca-cert.srl +ca-key +cert-request diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/README.md b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/README.md new file mode 100644 index 0000000..e9b5c68 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/README.md @@ -0,0 +1,44 @@ +# Setup Kafka broker with SASL_PLAIN and TLS bound to localhost + +This directory contains `docker-compose.yml` along with pre-generated certificates for TLS bound to IP `localhost`. + +## Description of files in this directory + +* `docker-compose.yml` - docker compose file with Kafka and Zookeeper containers +* `server.keystore.jks` - keystore with server certificates and CA certificate +* `ca-cert` - CA (Certificate Authority) certificate (used to sign the server certificate), it must be imported into the keystore as a trusted certificate. +Client should also import this certificate to verify the identity of the Kafka broker. +* `ca-key` - CA private key (used to generate CA certificate `ca-cert`) +* `cert-request` - certificate request for the broker, it must be signed by the CA before it can be used +* `signed-server-cert` - signed certificate for the broker, it must be imported into the keystore +* `ssl_keystore_password` - file containing the password for the keystore +* `ssk_key_password` - file containing the password for the CA private key, used to unlock the CA certificate +* `broker_jaas.conf` - JAAS configuration file for the broker, contains usernames and passwords a client can use to authenticate + +## How to generate certificates for TLS bound to DNS localhost + +In order to provide TLS encryption for Kafka broker, we need to generate a keystore with a signed certificate for the broker. +We only need to generate certificates once a year and to commit them (along with keystore), as they are valid for one year term. + +Refer to [this](../README.md#about-certificates) section for more details about RSA certificates. + +You can create the certificates and store them in the keystore using the following commands: +```shell +# Create private and public key (public key is usually reffered to as Certificate Authority's certificate or CA certificate) +openssl req -x509 -newkey rsa:4096 -keyout ca-key -out ca-cert -days 365 -nodes -subj '/CN=localhost/OU=Atom/O=Rasa/L=Berlin/ST=Germany/C=GE' -passin pass:123456 -passout pass:123456 + +# Create server keystore protected with storepass and keypass +keytool -dname "CN=localhost,OU=Atom,O=Rasa,L=Berlin,S=Germany,C=GE" -keystore server.keystore.jks -alias localhost -validity 365 -genkey -keyalg RSA -storetype pkcs12 -ext SAN=IP:localhost -storepass 123456 -keypass 123456 + +# Create a certificate request +keytool -keystore server.keystore.jks -alias localhost -certreq -file cert-request -storepass 123456 -keypass 123456 -ext "SAN=IP:localhost" + +# Sign the certificate request +openssl x509 -req -CA ca-cert -CAkey ca-key -in cert-request -out signed-server-cert -days 365 -CAcreateserial -passin pass:123456 + +# Import CA certificate into keystore +# It will be used to decrypt (unseal) the signed certificate sent by the Kafka broker +keytool -keystore server.keystore.jks -alias CARoot -import -file ca-cert -storepass 123456 -keypass 123456 +# Import signed certificate into keystore +keytool -noprompt -keystore server.keystore.jks -alias localhost -import -file signed-server-cert -storepass 123456 -keypass 123456 -ext "SAN=DNS:localhost" +``` diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/broker_jaas.conf b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/broker_jaas.conf new file mode 100644 index 0000000..58fbb4f --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/broker_jaas.conf @@ -0,0 +1,8 @@ +KafkaServer { + org.apache.kafka.common.security.plain.PlainLoginModule required + username="admin" + password="admin-secret" + user_admin="admin-secret" + user_alice="alice-secret"; +}; +Client{}; \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/ca-cert b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/ca-cert new file mode 100644 index 0000000..13cb609 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/ca-cert @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFpTCCA42gAwIBAgIUTMpoQaXQPXnqXuQtxcoXINk45pYwDQYJKoZIhvcNAQEL +BQAwYjESMBAGA1UEAwwJbG9jYWxob3N0MQ0wCwYDVQQLDARBdG9tMQ0wCwYDVQQK +DARSYXNhMQ8wDQYDVQQHDAZCZXJsaW4xEDAOBgNVBAgMB0dlcm1hbnkxCzAJBgNV +BAYTAkdFMB4XDTIzMDMzMDE0NTcxOVoXDTI0MDMyOTE0NTcxOVowYjESMBAGA1UE +AwwJbG9jYWxob3N0MQ0wCwYDVQQLDARBdG9tMQ0wCwYDVQQKDARSYXNhMQ8wDQYD +VQQHDAZCZXJsaW4xEDAOBgNVBAgMB0dlcm1hbnkxCzAJBgNVBAYTAkdFMIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArnldATnD8mveV99UKbNIYmk0dYyf +k2pXXa1DsLeNzRtvculEbQMD1O5sUIwE0If2HILrdXinLvFb7wdfmhv5FlGjOU0r +o/roxDxw8kOb5akBo6CZ16BYXIuIhqoavjJmIKlMFbtZsgcavj98HmAEGNdCYUb6 +dogGVVB8z15g+ekyI9dTLl/DgxTc0sT9bUeWcLigHiBPgiPKhuLCs0cYJ1Ta7D/E +eGYKegT/DRPqHuqaImMdKpcfvKBin4oQTuW6sja8epigD2d1k0SFxrUchFl0qzo+ +VP/xeL6+07Ds/neL30M9BvRlNk6DdZWziATGnZQc7AaHZzrrXjUALpmHCixJJ377 +PDpkpEKKwWiOeb7us3+i6rjp2kuz7HSYd1dzR3+MhidXhtDbKGC5kHoMbkwg4UES ++gZU3O0w49oXpIQJ9EPOFNsEaRZXr91aXWDFa3Z2aTrbflfW/6QAQfMoiWSL2sX6 +L+VgG4kGVhyV7iQgPIu1WXoMXR9x/Ua5epL6uRY1wvqTIskuzAAq0c4vR3cDEyTT +X/y6UVSkNz7TqHEuMGrJCbFkeDTv5p5wT3SfwZkm6gAipgrKwevPt0/WDI5XyWSo +Mtmh7Nm4YqJeMg3UJTAYGuxNvtc4PV4XvDadX8Ou29SDnCI2CEU6fFQyGjononrP +bobS9Dx+ElXp/ysCAwEAAaNTMFEwHQYDVR0OBBYEFM7czO97lAAMJMta2nosDb1S +GXwGMB8GA1UdIwQYMBaAFM7czO97lAAMJMta2nosDb1SGXwGMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADggIBABxapznhns4Qlm/7aU+fbG/PVh2GQgK9 +l+gMp9+IZoyscQAYfJfKbrLe83QV9oc3vyQ59stQeshlOT8VOzA3SL+MHEw7RhEk +BxHBTDaa6J2PdY14svD0jH1dk+yyj+vfSU1BU2Lm/QFOnbPWTFXkqkDB5qoV3OS/ +5aNzWaK8lYZAAtmCbGTi4WV+ueeE2H0+3yOsOLBc1WwS0T6HTif6ksSl6gimDCK0 +DaRVHd0/aU7hRHiCpavzXys4x/xy3weP3guEsmZiSX1J6E5NkZ1pTL4LyLgcwoIJ +KE9i9dT7/ohCvYutN/SjUCLEdELUv23ftB84PkpoQZLTymYkV5nJ5KkgK1DBH8k2 +ZCj2Lrad/dFEBVTn56hHYB8hU28m3T69mqPxg4CpI36QV7HVQdpLbfgWF/NWQepO +xN0GkVkHpoVkQoiFw6YuDfLLH1Q+Lo4G7TVe8wZn807gF2JQzUjhJk52evByYOWi +sZOhrCB2rkFcgO+SxPDfNSpJu1N2YZLhtjVQXeI+l0P7u5/YTjZy5PVU99GLxvLn +uEq4HImknpgTnTgsguJVRe+F7GLqBmFdPdccncVU9ImjT5pBwjorM0CSFU5kgvl3 +2hMvDCvaeKPWrjnTaZMca7/01o81ypMIs2zQaDzvXcRNEJbNkOY0cL7h8XU6iZTv +3tfaUz2fkOHH +-----END CERTIFICATE----- diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/docker-compose.yml b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/docker-compose.yml new file mode 100644 index 0000000..59de51d --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/docker-compose.yml @@ -0,0 +1,69 @@ +--- +version: '3.8' +services: + zookeeper: + image: confluentinc/cp-zookeeper:7.3.2 + container_name: zookeeper-sasl-plain-tls-localhost + environment: + ZOO_MY_ID: 1 + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + ZOOKEEPER_SASL_ENABLED: "false" + healthcheck: + test: nc -z localhost 2181 || exit 1 + interval: 10s + retries: 10 + start_period: 15s + timeout: 10s + + kafka-broker: + image: confluentinc/cp-kafka:7.3.2 + container_name: broker-sasl-plain-tls-localhost + ports: + - "9095:9095" + - "29095:29095" + depends_on: + zookeeper: + condition: service_healthy + environment: + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + # do not use SASL authentication when communicating with Zookeeper (it will use no authentication and no TLS) + ZOOKEEPER_SASL_ENABLED: "false" + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + # configure two listeners: one for SASL_SSL (TLS with authentication) and one for PLAINTEXT (not TLS and no authentication) + # clients can use SASL_SSL to connect to the broker and authenticate using the username and password + # the broker can use PLAINTEXT to communicate with other brokers + KAFKA_LISTENERS: SASL_SSL://0.0.0.0:9095, PLAINTEXT://0.0.0.0:29095 + # configure advertised listeners (listeners that will be used by clients to connect to the broker) + KAFKA_ADVERTISED_LISTENERS: SASL_SSL://localhost:9095, PLAINTEXT://localhost:29095 + # let the brokers communicate with each other using PLAINTEXT listener (not TLS and no authentication) + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + # enable SASL_PLAIN authentication mechanism (username and password) + KAFKA_SASL_ENABLED_MECHANISMS: PLAIN + # configure Kafka to use JAAS (Java Authentication and Authorization Service) for authentication + # usernames and passwords will be provided by the JAAS file + KAFKA_OPTS: "-Djava.security.auth.login.config=/etc/kafka/broker_jaas.conf" + # our TLS certificate is signed by a CA (Certificate Authority) and stored in a keystore along with CA certificate + KAFKA_SSL_KEYSTORE_FILENAME: kafka.server.keystore.jks + # keystore is protected by password stored in ssl_keystore_credentials file + # this file will be mounted by the confluent KAFKA docker container and password will be set as an environment variable inside container + KAFKA_SSL_KEYSTORE_CREDENTIALS: ssl_keystore_credentials + # our TLS certificate is signed by a CA (Certificate Authority) which is password protected + # to unlock TLS certificate we need to provide CA's password stored in ssl_key_credentials file + # this file will be mounted by the confluent KAFKA docker container and password will be set as an environment variable inside container + KAFKA_SSL_KEY_CREDENTIALS: ssl_key_credentials + # we enable TLSv1.2, TLSv1.1 and TLSv1 encryption protocols + KAFKA_SSL_ENABLED_PROTOCOLS: TLSv1.2,TLSv1.1,TLSv1 + # enable DEBUG logging for Kafka + KAFKA_LOG4J_ROOT_LOGLEVEL: DEBUG + volumes: + - ./broker_jaas.conf:/etc/kafka/broker_jaas.conf + - ./server.keystore.jks:/etc/kafka/secrets/kafka.server.keystore.jks + - ./ssl_key_credentials:/etc/kafka/secrets/ssl_key_credentials + - ./ssl_keystore_credentials:/etc/kafka/secrets/ssl_keystore_credentials + healthcheck: + test: nc -z localhost 9095 || exit 1 + interval: 30s + retries: 10 + start_period: 15s + timeout: 10s diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/server.keystore.jks b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/server.keystore.jks new file mode 100644 index 0000000..3983dd0 Binary files /dev/null and b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/server.keystore.jks differ diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/signed-server-cert b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/signed-server-cert new file mode 100644 index 0000000..c671601 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/signed-server-cert @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIESzCCAjMCFGgkBLOaQbyhVZuiqw5MHSiKJUd7MA0GCSqGSIb3DQEBCwUAMGIx +EjAQBgNVBAMMCWxvY2FsaG9zdDENMAsGA1UECwwEQXRvbTENMAsGA1UECgwEUmFz +YTEPMA0GA1UEBwwGQmVybGluMRAwDgYDVQQIDAdHZXJtYW55MQswCQYDVQQGEwJH +RTAeFw0yMzAzMzAxNDU5MTJaFw0yNDAzMjkxNDU5MTJaMGIxCzAJBgNVBAYTAkdF +MRAwDgYDVQQIEwdHZXJtYW55MQ8wDQYDVQQHEwZCZXJsaW4xDTALBgNVBAoTBFJh +c2ExDTALBgNVBAsTBEF0b20xEjAQBgNVBAMTCWxvY2FsaG9zdDCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBALdp4kGCfYJY68vpmOqdTPDXW6WPGTq8nNlY +qhLbeYEJAHRbXDZaBTVdvvoBQedKS+rEdyl3+mXMX1EfaRvPt9/DT+6w1bipgSom +ZUhH+c1QnNggzlost5oiZAZdJ/ka4is1RW4FOPI6BFXFYgE4wFzdZ3Ji2pZFtvv7 +Z4lxQUWoAipTTwAGENwFI7Hyef94pSbQzWslHtv9WeMy8tyRg6mRWR5TN9FnOYjp +UX9TqrPdzishQvuuAIc8BF9NuZ2n8bM95FQTur9+Mpl4yw03GG1mS4g9Ggt8DOa4 +coOAk/3uf+sIkzR0C/EWl1O7SUm+/ouRt/z8252O66AFKxkn26UCAwEAATANBgkq +hkiG9w0BAQsFAAOCAgEAg0cOSDmdF60S6FLDJ9zC4rJMV6lhdT1JUjcE1F25Y1/i ++znEZqMTFPXxiuDz6BLIehwRteou4eX/Q+hwNr44rVvWt9zArmHijc1Ec0kaBy+U +SAP2Mykx0uK5ffsI6/s/QaNrtSISTTd7LMA2LCCNHJVMx0oqWNAUIRIY/eUow1Oi +ok6nEXZK9GWPkI2jgHBDypA1a/fFbKgekiKNxWTkCi73BjmcuQmkIQJYiHo3ssYo +gCxcSQhyH0ffneFHwcn6C8nhIRexbr6AdcYzNDC9ll8Q9Xt3cS/QymwtnLCR8ReC +Jh0ja0wa7GArpLl9QM/RdtZN1Ztha4R3QYKYCBFhCll1BiyyEqHBYGmNlxsLW70J +lkKQztEXbTqDCp/LuRuAv6dtP26FYv2zXYqI3tERIXNXapT7IyKjex+QYSAGw0r0 ++BYgx5HynsJWfKBJd3fdz0Lj7vHoJ/SAClxAqBfIGVGL95pbB8+INFXw/NehoLFk +1QpbbriG8O2HufalgM9ZrWPndzGFhDhfc2zzl4YrYZ5ZoBM1Sn73bGS2VNvp4uB5 +4AyefF6uyjpw903WKkoNUztBFeYRxlaeGzuDlHU2R1h87K7CwH6atVMQxalv3K7R +HdgKjkPUCWTQ4jzseA7kkRqO805TGDyRdOUm+HUe63jgPAhOGBNoK8crPutNukM= +-----END CERTIFICATE----- diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/ssl_key_credentials b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/ssl_key_credentials new file mode 100644 index 0000000..4632e06 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/ssl_key_credentials @@ -0,0 +1 @@ +123456 \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/ssl_keystore_credentials b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/ssl_keystore_credentials new file mode 100644 index 0000000..4632e06 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/ssl_keystore_credentials @@ -0,0 +1 @@ +123456 \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/README.md b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/README.md new file mode 100644 index 0000000..44cdd64 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/README.md @@ -0,0 +1,32 @@ +# Setup of Kafka with SASL_SCRAM authentication (SHA-256 and SHA-512 algorithm) and without TLS encryption + +This is a simple setup of Kafka with SASL_SCRAM authentication (SHA-256 and SHA-512 algorithm) and without TLS encryption. +It is intended to be used to set up test environment in which Kafka brokers require clients to authenticate. +All communication is done over insecure plain connection (without TLS). +Kafka will be listening on port 9092. You can connect to the broker with URL localhost:9092. + +## How to start the environment + +We provide two different test environments for this setup. One with SHA-256 and one with SHA-512. + +Typical startup of the environment: +1. Start Zookeeper container +2. Add two users to Zookeeper + * One will be used by Kafka to authenticate itself to Zookeeper + * The other will be used by the client to authenticate to Kafka +3. Start Kafka container + +Details of the startup are described in the corresponding README.md files in the directories. + + +## How to connect to the broker + +To connect to the broker from the client, use user: + +| User | Password | +|-------------|----------------| +| kafkaclient | password | + +The user is defined in zookeeper. +You must also set the authentication method to either `SASL SCRAM-SHA-256 or SASL SCRAM-SHA-512` +mechanism in the client's configuration. Which SCRAM SHA you will use depends on which test environment you are running. \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_256/README.md b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_256/README.md new file mode 100644 index 0000000..510ef55 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_256/README.md @@ -0,0 +1,23 @@ +# Setup Kafka broker with SASL SCRAM authentication (SHA-256 algorithm) and without TLS encryption + +This is a simple setup of Kafka with SASL_SCRAM authentication (SHA-256 algorithm) and without TLS encryption. +It is intended to be used to set up test environment in which Kafka brokers require clients to authenticate. +All communication is done over insecure plain connection (without TLS). +Kafka will be listening on port 9096. You can connect to the broker with URL localhost:9096. + +To run: +```shell +# Start Zookeeper +docker-compose up -d zookeeper + +# Create kafkabroker and kafkaclient users on zookeeper +docker exec -it zookeeper-sasl-scram-sha-256-no-tls bash +cd /etc/kafka/client +KAFKA_OPTS="-Djava.security.auth.login.config=zookeeper_client_jaas.conf" kafka-configs --zookeeper localhost:2186 --alter --add-config 'SCRAM-SHA-256=[iterations=4096,password=password]' --entity-type users --entity-name kafkabroker +KAFKA_OPTS="-Djava.security.auth.login.config=zookeeper_client_jaas.conf" kafka-configs --zookeeper localhost:2186 --alter --add-config 'SCRAM-SHA-256=[iterations=4096,password=password]' --entity-type users --entity-name client + +# Exit from zookeeper container +exit + +docker-compose up -d kafka-broker +``` \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_256/broker_jaas.conf b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_256/broker_jaas.conf new file mode 100644 index 0000000..214ad80 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_256/broker_jaas.conf @@ -0,0 +1,15 @@ +KafkaServer { + org.apache.kafka.common.security.scram.ScramLoginModule required + username="kafkabroker" + password="password"; +}; +Client { + org.apache.kafka.common.security.plain.PlainLoginModule required + username="admin" + password="password"; +}; +KafkaClient { + org.apache.kafka.common.security.scram.ScramLoginModule required + username="kafkaclient" + password="password"; +}; \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_256/docker-compose.yml b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_256/docker-compose.yml new file mode 100644 index 0000000..85a1902 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_256/docker-compose.yml @@ -0,0 +1,55 @@ +--- +version: '3.8' +services: + zookeeper: + image: confluentinc/cp-zookeeper:7.3.2 + container_name: zookeeper-sasl-scram-sha-256-no-tls + ports: + - "2186:2186" + environment: + ZOOKEEPER_SERVER_ID: 1 + ZOOKEEPER_CLIENT_PORT: 2186 + ZOOKEEPER_TICK_TIME: 2000 + ZOOKEEPER_LOG4J_ROOT_LOGLEVEL: "DEBUG" + KAFKA_OPTS: -Djava.security.auth.login.config=/etc/kafka/secrets/zookeeper_server_jaas.conf + -Dquorum.auth.enableSasl=true + -Dquorum.auth.learnerRequireSasl=true + -Dquorum.auth.serverRequireSasl=true + -Dquorum.cnxn.threads.size=20 + -Dzookeeper.authProvider.1=org.apache.zookeeper.server.auth.SASLAuthenticationProvider + -Dzookeeper.authProvider.2=org.apache.zookeeper.server.auth.DigestAuthenticationProvider + -DjaasLoginRenew=3600000 + -DrequireClientAuthScheme=sasl + -Dquorum.auth.learner.loginContext=QuorumLearner + -Dquorum.auth.server.loginContext=QuorumServer + volumes: + - ./zookeeper_server_jaas.conf:/etc/kafka/secrets/zookeeper_server_jaas.conf + - ./zookeeper_client_jaas.conf:/etc/kafka/client/zookeeper_client_jaas.conf + + kafka-broker: + image: confluentinc/cp-kafka:7.3.2 + container_name: kafka-broker-sasl-scram-sha-256-no-tls + ports: + - "9096:9096" + depends_on: + - zookeeper + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2186 + KAFKA_ADVERTISED_LISTENERS: SASL_PLAINTEXT://localhost:9096 + KAFKA_MIN_INSYNC_REPLICAS: 1 + KAFKA_SASL_ENABLED_MECHANISMS: SCRAM-SHA-256 + KAFKA_SECURITY_INTER_BROKER_PROTOCOL: SASL_PLAINTEXT + KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL: SCRAM-SHA-256 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + KAFKA_OFFSETS_RETENTION_MINUTES: 172800 + KAFKA_LOG4J_LOGGERS: "kafka.authorizer.logger=DEBUG,kafka.controller=DEBUG" + KAFKA_LOG4J_ROOT_LOGLEVEL: "DEBUG" + KAFKA_SUPER_USERS: User:kafkabroker;User:kafkaclient + KAFKA_ZOOKEEPER_SASL_ENABLED: "true" + KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: "false" + KAFKA_OPTS: -Dzookeeper.sasl.client=true + -Dzookeeper.sasl.clientconfig=Client + -Djava.security.auth.login.config=/etc/kafka/secrets/conf/kafka_server_jaas.conf + volumes: + - ./broker_jaas.conf:/etc/kafka/secrets/conf/kafka_server_jaas.conf \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_256/zookeeper_client_jaas.conf b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_256/zookeeper_client_jaas.conf new file mode 100644 index 0000000..7c73865 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_256/zookeeper_client_jaas.conf @@ -0,0 +1,5 @@ +Client { + org.apache.zookeeper.server.auth.DigestLoginModule required + username="admin" + password="password"; +}; \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_256/zookeeper_server_jaas.conf b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_256/zookeeper_server_jaas.conf new file mode 100644 index 0000000..ad7b043 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_256/zookeeper_server_jaas.conf @@ -0,0 +1,13 @@ +Server { + org.apache.zookeeper.server.auth.DigestLoginModule required + user_admin="password"; +}; +QuorumServer { + org.apache.zookeeper.server.auth.DigestLoginModule required + user_zookeeper="password"; +}; +QuorumLearner { + org.apache.zookeeper.server.auth.DigestLoginModule required + username="zookeeper" + password="password"; +}; \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_512/README.md b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_512/README.md new file mode 100644 index 0000000..ba563ca --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_512/README.md @@ -0,0 +1,23 @@ +# Setup Kafka broker with SASL SCRAM authentication (SHA-512 algorithm) and without TLS encryption + +This is a simple setup of Kafka with SASL_SCRAM authentication (SHA-512 algorithm) and without TLS encryption. +It is intended to be used to set up test environment in which Kafka brokers require clients to authenticate. +All communication is done over insecure plain connection (without TLS). +Kafka will be listening on port 9092. You can connect to the broker with URL localhost:9092. + +To run: +```shell +# Start Zookeeper +docker-compose up -d zookeeper + +# Create kafkabroker and kafkaclient users on zookeeper +docker exec -it zookeeper-sasl-scram-sha-512 bash +cd /etc/kafka/client +KAFKA_OPTS="-Djava.security.auth.login.config=zookeeper_client_jaas.conf" kafka-configs --zookeeper localhost:2187 --alter --add-config 'SCRAM-SHA-512=[iterations=4096,password=password]' --entity-type users --entity-name kafkabroker +KAFKA_OPTS="-Djava.security.auth.login.config=zookeeper_client_jaas.conf" kafka-configs --zookeeper localhost:2187 --alter --add-config 'SCRAM-SHA-512=[iterations=4096,password=password]' --entity-type users --entity-name client + +# Exit from zookeeper container +exit + +docker-compose up -d kafka-broker +``` diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_512/broker_jaas.conf b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_512/broker_jaas.conf new file mode 100644 index 0000000..214ad80 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_512/broker_jaas.conf @@ -0,0 +1,15 @@ +KafkaServer { + org.apache.kafka.common.security.scram.ScramLoginModule required + username="kafkabroker" + password="password"; +}; +Client { + org.apache.kafka.common.security.plain.PlainLoginModule required + username="admin" + password="password"; +}; +KafkaClient { + org.apache.kafka.common.security.scram.ScramLoginModule required + username="kafkaclient" + password="password"; +}; \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_512/docker-compose.yml b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_512/docker-compose.yml new file mode 100644 index 0000000..19f8e7b --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_512/docker-compose.yml @@ -0,0 +1,56 @@ +--- +version: '3.8' +services: + zookeeper: + image: confluentinc/cp-zookeeper:7.3.2 + container_name: zookeeper-sasl-scram-sha-512-no-tls + ports: + - "2187:2187" + environment: + ZOOKEEPER_SERVER_ID: 1 + ZOOKEEPER_CLIENT_PORT: 2187 + ZOOKEEPER_TICK_TIME: 2000 + ZOOKEEPER_LOG4J_ROOT_LOGLEVEL: "DEBUG" + KAFKA_OPTS: -Djava.security.auth.login.config=/etc/kafka/secrets/zookeeper_server_jaas.conf + -Dquorum.auth.enableSasl=true + -Dquorum.auth.learnerRequireSasl=true + -Dquorum.auth.serverRequireSasl=true + -Dquorum.cnxn.threads.size=20 + -Dzookeeper.authProvider.1=org.apache.zookeeper.server.auth.SASLAuthenticationProvider + -Dzookeeper.authProvider.2=org.apache.zookeeper.server.auth.DigestAuthenticationProvider + -DjaasLoginRenew=3600000 + -DrequireClientAuthScheme=sasl + -Dquorum.auth.learner.loginContext=QuorumLearner + -Dquorum.auth.server.loginContext=QuorumServer + volumes: + - ./zookeeper_server_jaas.conf:/etc/kafka/secrets/zookeeper_server_jaas.conf + - ./zookeeper_client_jaas.conf:/etc/kafka/client/zookeeper_client_jaas.conf + + kafka-broker: + image: confluentinc/cp-kafka:7.3.2 + container_name: kafka-broker-sasl-scram-sha-512-no-tls + ports: + - "9097:9097" + depends_on: + - zookeeper + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2187 + KAFKA_ADVERTISED_LISTENERS: SASL_PLAINTEXT://localhost:9097 + KAFKA_MIN_INSYNC_REPLICAS: 1 + KAFKA_SASL_ENABLED_MECHANISMS: SCRAM-SHA-512 + KAFKA_SECURITY_INTER_BROKER_PROTOCOL: SASL_PLAINTEXT + KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL: SCRAM-SHA-512 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + KAFKA_OFFSETS_RETENTION_MINUTES: 172800 + KAFKA_LOG4J_LOGGERS: "kafka.authorizer.logger=DEBUG,kafka.controller=DEBUG" + KAFKA_LOG4J_ROOT_LOGLEVEL: "DEBUG" + KAFKA_SUPER_USERS: User:kafkabroker;User:kafkaclient + KAFKA_ZOOKEEPER_SASL_ENABLED: "true" + KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: "false" + KAFKA_OPTS: -Dzookeeper.sasl.client=true + -Dzookeeper.sasl.clientconfig=Client + -Djava.security.auth.login.config=/etc/kafka/secrets/conf/kafka_server_jaas.conf + volumes: + - ./broker_jaas.conf:/etc/kafka/secrets/conf/kafka_server_jaas.conf + diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_512/zookeeper_client_jaas.conf b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_512/zookeeper_client_jaas.conf new file mode 100644 index 0000000..7c73865 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_512/zookeeper_client_jaas.conf @@ -0,0 +1,5 @@ +Client { + org.apache.zookeeper.server.auth.DigestLoginModule required + username="admin" + password="password"; +}; \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_512/zookeeper_server_jaas.conf b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_512/zookeeper_server_jaas.conf new file mode 100644 index 0000000..ad7b043 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/no_tls/scram_sha_512/zookeeper_server_jaas.conf @@ -0,0 +1,13 @@ +Server { + org.apache.zookeeper.server.auth.DigestLoginModule required + user_admin="password"; +}; +QuorumServer { + org.apache.zookeeper.server.auth.DigestLoginModule required + user_zookeeper="password"; +}; +QuorumLearner { + org.apache.zookeeper.server.auth.DigestLoginModule required + username="zookeeper" + password="password"; +}; \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/README.md b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/README.md new file mode 100644 index 0000000..e195d73 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/README.md @@ -0,0 +1,135 @@ +# Setup Kafka broker with SASL SCRAM authentication and TLS encryption + +This is a simple setup of Kafka with SASL_SCRAM authentication and TLS encryption. +It is intended to be used to set up test environment in which Kafka brokers require clients to authenticate. +All communication is done over secure TLS connection. + +These test environments provide set up for Kafka instance which requires clients to authenticate +over secure TLS connection. + +Test environments are location in directories: +* `scram_sha_256` - Sets up Kafka with SCRAM SHA-256 algorithm enabled. Broker certificate has SAN set to `localhost`. +* `scram_sha_512` - Sets up Kafka with SCRAM SHA-512 algorithm enabled. Broker certificate has SAN set to `localhost`. + +All certificates required for TLS are already generated. **They are not intended to be used in production.** +
Certificates in this environment, are used to verify the identity of the Kafka broker to the clients. +Pre-generated certificates are valid through `30/3/2024`. +If you need to generate new certificates checkout the +[How to generate certificates for TLS bound to DNS localhost](#how-to-generate-certificates-for-tls-bound-to-dns-localhost) +section. + +#### About Subject Alternate Name (SAN) + +A Subject Alternate Name (or SAN) certificate is a digital security +certificate which allows multiple hostnames or IPs to be protected by a single certificate. + +Examples: +
If TLS certificate has SAN set to `194.3.5.1`, then clients will accept the certificate if IP of Kafka broker +to which they are connecting is `194.3.5.1`. +
If TLS certificate has SAN set to `localhost`, then clients will accept the certificate if the hostname +of the Kafka broker to which we are connecting is `localhost`. +
If TLS certificate has SAN set to `rasa.com`, then clients will accept the certificate if the hostname +of the Kafka broker to which we are connecting is `rasa.com`. +
If TLS certificate has SAN set to 0.0.0.0 then clients will accept the certificate +from any hostname or IP address. This is useful for testing purposes. DO NOT USE THIS IN PRODUCTION!!! + + +## How to connect to Kafka broker + +To connect to the broker from the client use: +* URL localhost:9098 (SASL SCRAM SHA 256) or localhost:9099 (SASL SCRAM SHA 512) +* one of the users + + | User | Password | + |-------------|--------------| + | kafkaclient | password | + +You must also enable TLS and set the authentication method to either `SASL SCRAM-SHA-256 or SASL SCRAM-SHA-512` +mechanism in the client's configuration. Which SCRAM SHA you will use depends on which test environment you are running. + +Add CA certificate to the client's certificate pool before starting it. +
This is required to verify the identity of the Kafka broker. +
If you want to skip verification of the Kafka broker's identity, +you can instruct your client to skip verification of the certificate. +If you skip verification of the certificate communication over secure TLS will still be used, +but the identity of the Kafka broker will not be verified. + +## About certificates + +RSA algorithm is used to generate private and public keys used to sign/verify/encrypt/decrypt data. +
+Certificate Authority (CA) is a trusted entity which issues (signs) certificates from other entities. +Certificate Authority generates a private key and a public key. +
Private key is used to sign certificates from other entities. It must be protected and not shared. +
Public key is used to verify the signature of the certificate. It is shared with the clients. +Clients use the public key to verify that the certificate +it received was signed by the CA and not by a malicious entity. + +In TLS communication we need to have: +* a CA's private key +* a CA's public key, also called `CA certificate` +* a certificate of the Kafka broker signed by CA's private key + +When Kafka broker is contacted by a client, +it sends its certificate to the client. +
Client verifies the certificate using the CA certificate. +If the certificate is valid, the client can connect to the broker. + +More about TLS can be read here: https://www.cloudflare.com/learning/ssl/transport-layer-security-tls/ + + +### How to generate certificates for TLS bound to DNS localhost + +This section explains how to produce certificates for the Kafka brokers and store them in the server's keystore. +We create a certificate authority (CA), also known as root certificate, +and use it to sign the certificate request for the Kafka broker. +Kafka broker will send its signed certificate to the client. +Client will use the CA certificate to verify the identity of the Kafka broker. + +You can create the certificates and store them in the keystore using the following commands: +```shell +# Create private and public key (public key is usually reffered to as Certificate Authority's certificate or CA certificate) +openssl req -x509 -newkey rsa:4096 -keyout ca-key -out ca-cert -days 365 -nodes -subj '/CN=localhost/OU=Atom/O=Rasa/L=Berlin/ST=Germany/C=GE' -passin pass:123456 -passout pass:123456 + +# Create server keystore protected with storepass and keypass +keytool -dname "CN=localhost,OU=Atom,O=Rasa,L=Berlin,S=Germany,C=GE" -keystore server.keystore.jks -alias localhost -validity 365 -genkey -keyalg RSA -storetype pkcs12 -ext SAN=IP:localhost -storepass 123456 -keypass 123456 + +# Create a certificate request +keytool -keystore server.keystore.jks -alias localhost -certreq -file cert-request -storepass 123456 -keypass 123456 -ext "SAN=IP:localhost" + +# Sign the certificate request +openssl x509 -req -CA ca-cert -CAkey ca-key -in cert-request -out signed-server-cert -days 365 -CAcreateserial -passin pass:123456 + +# Import root certificate into keystore +keytool -keystore server.keystore.jks -alias CARoot -import -file ca-cert -storepass 123456 -keypass 123456 +# Import signed certificate into keystore +keytool -noprompt -keystore server.keystore.jks -alias localhost -import -file signed-server-cert -storepass 123456 -keypass 123456 -ext "SAN=DNS:localhost" +``` + + +## Troubleshooting + +To inspect content of the keystore, you can use the following command: +```shell +keytool -list -v -keystore server.keystore.jks -storepass 123456 -keypass 123456 +``` + +To check if private key is password protected +```shell +openssl rsa -check -in ca-key -passin pass:123456 +``` + +To check if CA certificate can unlock signed certificate +```shell +openssl verify -CAfile ca-cert signed-server-cert +``` + +To check if TLS connection is working +```shell +# for TLS 1.0 +openssl s_client -debug -connect localhost:29092 -tls1 +# for TLS 1.1 +openssl s_client -debug -connect localhost:29092 -tls1_1 +# for TLS 1.2 +openssl s_client -debug -connect localhost:29092 -tls1_2 +``` \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/README.md b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/README.md new file mode 100644 index 0000000..aed78e5 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/README.md @@ -0,0 +1,28 @@ +# Setup Kafka broker with SASL SCRAM (SHA-256) authentication and TLS encryption + +This is a simple setup of Kafka with SASL_SCRAM (SHA-256 algorithm) authentication and TLS encryption. + +To run: +```shell +# Start Zookeeper +docker-compose up -d zookeeper + +# Create kafkabroker and kafkaclient users on zookeeper +docker exec -it zookeeper-scram-sha-256-tls bash +cd /etc/kafka/client +KAFKA_OPTS="-Djava.security.auth.login.config=zookeeper_client_jaas.conf" kafka-configs --zookeeper localhost:2188 --alter --add-config 'SCRAM-SHA-256=[iterations=4096,password=password]' --entity-type users --entity-name kafkabroker +KAFKA_OPTS="-Djava.security.auth.login.config=zookeeper_client_jaas.conf" kafka-configs --zookeeper localhost:2188 --alter --add-config 'SCRAM-SHA-256=[iterations=4096,password=password]' --entity-type users --entity-name client + +# Exit from zookeeper container +exit + +docker-compose up -d kafka-broker +``` + +To connect to the broker from the client, set the following properties in the client configuration: + +* username and password to `kafkaclient` and `password` respectively +* SASL mechanism to `SCRAM-SHA-256` +* (optional) enable TLS encryption (different clients have different ways of doing this) or skip verification of the certificate +* import the certificate from `ca-cert` from `ssl` directory to the client's certificate pool. + diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/broker_jaas.conf b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/broker_jaas.conf new file mode 100644 index 0000000..214ad80 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/broker_jaas.conf @@ -0,0 +1,15 @@ +KafkaServer { + org.apache.kafka.common.security.scram.ScramLoginModule required + username="kafkabroker" + password="password"; +}; +Client { + org.apache.kafka.common.security.plain.PlainLoginModule required + username="admin" + password="password"; +}; +KafkaClient { + org.apache.kafka.common.security.scram.ScramLoginModule required + username="kafkaclient" + password="password"; +}; \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/docker-compose.yml b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/docker-compose.yml new file mode 100644 index 0000000..3aab3c2 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/docker-compose.yml @@ -0,0 +1,61 @@ +--- +version: '3.5' +services: + zookeeper: + image: confluentinc/cp-zookeeper:7.3.2 + container_name: zookeeper-sasl-scram-sha-256-tls + ports: + - "2188:2188" + environment: + ZOOKEEPER_SERVER_ID: 1 + ZOOKEEPER_CLIENT_PORT: 2188 + ZOOKEEPER_TICK_TIME: 2000 + ZOOKEEPER_LOG4J_ROOT_LOGLEVEL: "DEBUG" + KAFKA_OPTS: -Djava.security.auth.login.config=/etc/kafka/secrets/zookeeper_server_jaas.conf + -Dquorum.auth.enableSasl=true + -Dquorum.auth.learnerRequireSasl=true + -Dquorum.auth.serverRequireSasl=true + -Dquorum.cnxn.threads.size=20 + -Dzookeeper.authProvider.1=org.apache.zookeeper.server.auth.SASLAuthenticationProvider + -Dzookeeper.authProvider.2=org.apache.zookeeper.server.auth.DigestAuthenticationProvider + -DjaasLoginRenew=3600000 + -DrequireClientAuthScheme=sasl + -Dquorum.auth.learner.loginContext=QuorumLearner + -Dquorum.auth.server.loginContext=QuorumServer + volumes: + - ./zookeeper_server_jaas.conf:/etc/kafka/secrets/zookeeper_server_jaas.conf + - ./zookeeper_client_jaas.conf:/etc/kafka/secrets/zookeeper_client_jaas.conf + + kafka-broker: + image: confluentinc/cp-kafka:7.3.2 + container_name: kafka-broker-sasl-scram-sha-256-tls + ports: + - "9098:9098" + - "29098:29098" + depends_on: + - zookeeper + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2188 + KAFKA_LISTENERS: SASL_SSL://0.0.0.0:9098, PLAINTEXT://0.0.0.0:29098 + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: SASL_SSL://localhost:9098, PLAINTEXT://localhost:29098 + KAFKA_SSL_ENABLED_PROTOCOLS: TLSv1.2,TLSv1.1,TLSv1 + KAFKA_SSL_KEYSTORE_FILENAME: server.keystore.jks + KAFKA_SSL_KEYSTORE_CREDENTIALS: ssl_keystore_credentials + KAFKA_SSL_KEY_CREDENTIALS: ssl_key_credentials + KAFKA_SASL_ENABLED_MECHANISMS: SCRAM-SHA-256 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + KAFKA_OFFSETS_RETENTION_MINUTES: 172800 + KAFKA_LOG4J_LOGGERS: "kafka.authorizer.logger=DEBUG,kafka.controller=DEBUG" + KAFKA_LOG4J_ROOT_LOGLEVEL: "DEBUG" + KAFKA_SUPER_USERS: User:kafkabroker;User:kafkaclient + KAFKA_ZOOKEEPER_SASL_ENABLED: "true" + KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: "false" + KAFKA_OPTS: -Dzookeeper.sasl.client=true + -Dzookeeper.sasl.clientconfig=Client + -Djava.security.auth.login.config=/etc/kafka/secrets/conf/kafka_server_jaas.conf + volumes: + - ./ssl:/etc/kafka/secrets + - ./broker_jaas.conf:/etc/kafka/secrets/conf/kafka_server_jaas.conf + diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/ca-cert b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/ca-cert new file mode 100644 index 0000000..13cb609 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/ca-cert @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFpTCCA42gAwIBAgIUTMpoQaXQPXnqXuQtxcoXINk45pYwDQYJKoZIhvcNAQEL +BQAwYjESMBAGA1UEAwwJbG9jYWxob3N0MQ0wCwYDVQQLDARBdG9tMQ0wCwYDVQQK +DARSYXNhMQ8wDQYDVQQHDAZCZXJsaW4xEDAOBgNVBAgMB0dlcm1hbnkxCzAJBgNV +BAYTAkdFMB4XDTIzMDMzMDE0NTcxOVoXDTI0MDMyOTE0NTcxOVowYjESMBAGA1UE +AwwJbG9jYWxob3N0MQ0wCwYDVQQLDARBdG9tMQ0wCwYDVQQKDARSYXNhMQ8wDQYD +VQQHDAZCZXJsaW4xEDAOBgNVBAgMB0dlcm1hbnkxCzAJBgNVBAYTAkdFMIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArnldATnD8mveV99UKbNIYmk0dYyf +k2pXXa1DsLeNzRtvculEbQMD1O5sUIwE0If2HILrdXinLvFb7wdfmhv5FlGjOU0r +o/roxDxw8kOb5akBo6CZ16BYXIuIhqoavjJmIKlMFbtZsgcavj98HmAEGNdCYUb6 +dogGVVB8z15g+ekyI9dTLl/DgxTc0sT9bUeWcLigHiBPgiPKhuLCs0cYJ1Ta7D/E +eGYKegT/DRPqHuqaImMdKpcfvKBin4oQTuW6sja8epigD2d1k0SFxrUchFl0qzo+ +VP/xeL6+07Ds/neL30M9BvRlNk6DdZWziATGnZQc7AaHZzrrXjUALpmHCixJJ377 +PDpkpEKKwWiOeb7us3+i6rjp2kuz7HSYd1dzR3+MhidXhtDbKGC5kHoMbkwg4UES ++gZU3O0w49oXpIQJ9EPOFNsEaRZXr91aXWDFa3Z2aTrbflfW/6QAQfMoiWSL2sX6 +L+VgG4kGVhyV7iQgPIu1WXoMXR9x/Ua5epL6uRY1wvqTIskuzAAq0c4vR3cDEyTT +X/y6UVSkNz7TqHEuMGrJCbFkeDTv5p5wT3SfwZkm6gAipgrKwevPt0/WDI5XyWSo +Mtmh7Nm4YqJeMg3UJTAYGuxNvtc4PV4XvDadX8Ou29SDnCI2CEU6fFQyGjononrP +bobS9Dx+ElXp/ysCAwEAAaNTMFEwHQYDVR0OBBYEFM7czO97lAAMJMta2nosDb1S +GXwGMB8GA1UdIwQYMBaAFM7czO97lAAMJMta2nosDb1SGXwGMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADggIBABxapznhns4Qlm/7aU+fbG/PVh2GQgK9 +l+gMp9+IZoyscQAYfJfKbrLe83QV9oc3vyQ59stQeshlOT8VOzA3SL+MHEw7RhEk +BxHBTDaa6J2PdY14svD0jH1dk+yyj+vfSU1BU2Lm/QFOnbPWTFXkqkDB5qoV3OS/ +5aNzWaK8lYZAAtmCbGTi4WV+ueeE2H0+3yOsOLBc1WwS0T6HTif6ksSl6gimDCK0 +DaRVHd0/aU7hRHiCpavzXys4x/xy3weP3guEsmZiSX1J6E5NkZ1pTL4LyLgcwoIJ +KE9i9dT7/ohCvYutN/SjUCLEdELUv23ftB84PkpoQZLTymYkV5nJ5KkgK1DBH8k2 +ZCj2Lrad/dFEBVTn56hHYB8hU28m3T69mqPxg4CpI36QV7HVQdpLbfgWF/NWQepO +xN0GkVkHpoVkQoiFw6YuDfLLH1Q+Lo4G7TVe8wZn807gF2JQzUjhJk52evByYOWi +sZOhrCB2rkFcgO+SxPDfNSpJu1N2YZLhtjVQXeI+l0P7u5/YTjZy5PVU99GLxvLn +uEq4HImknpgTnTgsguJVRe+F7GLqBmFdPdccncVU9ImjT5pBwjorM0CSFU5kgvl3 +2hMvDCvaeKPWrjnTaZMca7/01o81ypMIs2zQaDzvXcRNEJbNkOY0cL7h8XU6iZTv +3tfaUz2fkOHH +-----END CERTIFICATE----- diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/server.keystore.jks b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/server.keystore.jks new file mode 100644 index 0000000..3983dd0 Binary files /dev/null and b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/server.keystore.jks differ diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/ssl_key_credentials b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/ssl_key_credentials new file mode 100644 index 0000000..4632e06 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/ssl_key_credentials @@ -0,0 +1 @@ +123456 \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/ssl_keystore_credentials b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/ssl_keystore_credentials new file mode 100644 index 0000000..4632e06 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/ssl_keystore_credentials @@ -0,0 +1 @@ +123456 \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/zookeeper_client_jaas.conf b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/zookeeper_client_jaas.conf new file mode 100644 index 0000000..7c73865 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/zookeeper_client_jaas.conf @@ -0,0 +1,5 @@ +Client { + org.apache.zookeeper.server.auth.DigestLoginModule required + username="admin" + password="password"; +}; \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/zookeeper_server_jaas.conf b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/zookeeper_server_jaas.conf new file mode 100644 index 0000000..ad7b043 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/zookeeper_server_jaas.conf @@ -0,0 +1,13 @@ +Server { + org.apache.zookeeper.server.auth.DigestLoginModule required + user_admin="password"; +}; +QuorumServer { + org.apache.zookeeper.server.auth.DigestLoginModule required + user_zookeeper="password"; +}; +QuorumLearner { + org.apache.zookeeper.server.auth.DigestLoginModule required + username="zookeeper" + password="password"; +}; \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/README.md b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/README.md new file mode 100644 index 0000000..0b144ec --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/README.md @@ -0,0 +1,27 @@ +# Setup Kafka broker with SASL SCRAM (SHA-512) authentication and TLS encryption + +This is a simple setup of Kafka with SASL_SCRAM (SHA-512 algorithm) authentication and TLS encryption. + +To run: +```shell +# Start Zookeeper +docker-compose up -d zookeeper + +# Create kafkabroker and kafkaclient users on zookeeper +docker exec -it zookeeper-scram-sha-512-tls bash +cd /etc/kafka/client +KAFKA_OPTS="-Djava.security.auth.login.config=zookeeper_client_jaas.conf" kafka-configs --zookeeper localhost:2189 --alter --add-config 'SCRAM-SHA-512=[iterations=4096,password=password]' --entity-type users --entity-name kafkabroker +KAFKA_OPTS="-Djava.security.auth.login.config=zookeeper_client_jaas.conf" kafka-configs --zookeeper localhost:2189 --alter --add-config 'SCRAM-SHA-512=[iterations=4096,password=password]' --entity-type users --entity-name client + +# Exit from zookeeper container +exit + +docker-compose up -d kafka-broker +``` + +To connect to the broker from the client, set the following properties in the client configuration: + +* username and password to `kafkaclient` and `password` respectively +* SASL mechanism to `SCRAM-SHA-512` +* (optional) enable TLS encryption (different clients have different ways of doing this) or skip verification of the certificate +* import the certificate from `ca-cert` from `ssl` directory to the client's certificate pool. \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/broker_jaas.conf b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/broker_jaas.conf new file mode 100644 index 0000000..214ad80 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/broker_jaas.conf @@ -0,0 +1,15 @@ +KafkaServer { + org.apache.kafka.common.security.scram.ScramLoginModule required + username="kafkabroker" + password="password"; +}; +Client { + org.apache.kafka.common.security.plain.PlainLoginModule required + username="admin" + password="password"; +}; +KafkaClient { + org.apache.kafka.common.security.scram.ScramLoginModule required + username="kafkaclient" + password="password"; +}; \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/docker-compose.yml b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/docker-compose.yml new file mode 100644 index 0000000..403d32c --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/docker-compose.yml @@ -0,0 +1,61 @@ +--- +version: '3.5' +services: + zookeeper: + image: confluentinc/cp-zookeeper:7.3.2 + container_name: zookeeper-sasl-scram-sha-512-tls + ports: + - "2189:2189" + environment: + ZOOKEEPER_SERVER_ID: 1 + ZOOKEEPER_CLIENT_PORT: 2189 + ZOOKEEPER_TICK_TIME: 2000 + ZOOKEEPER_LOG4J_ROOT_LOGLEVEL: "DEBUG" + KAFKA_OPTS: -Djava.security.auth.login.config=/etc/kafka/secrets/zookeeper_server_jaas.conf + -Dquorum.auth.enableSasl=true + -Dquorum.auth.learnerRequireSasl=true + -Dquorum.auth.serverRequireSasl=true + -Dquorum.cnxn.threads.size=20 + -Dzookeeper.authProvider.1=org.apache.zookeeper.server.auth.SASLAuthenticationProvider + -Dzookeeper.authProvider.2=org.apache.zookeeper.server.auth.DigestAuthenticationProvider + -DjaasLoginRenew=3600000 + -DrequireClientAuthScheme=sasl + -Dquorum.auth.learner.loginContext=QuorumLearner + -Dquorum.auth.server.loginContext=QuorumServer + volumes: + - ./zookeeper_server_jaas.conf:/etc/kafka/secrets/zookeeper_server_jaas.conf + - ./zookeeper_client_jaas.conf:/etc/kafka/secrets/zookeeper_client_jaas.conf + + kafka-broker: + image: confluentinc/cp-kafka:7.3.2 + container_name: kafka-broker-scram-sha-512-tls + ports: + - "9099:9099" + - "29099:29099" + depends_on: + - zookeeper + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2189 + KAFKA_LISTENERS: SASL_SSL://0.0.0.0:9099, PLAINTEXT://0.0.0.0:290929 + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: SASL_SSL://localhost:9099, PLAINTEXT://localhost:29099 + KAFKA_SSL_ENABLED_PROTOCOLS: TLSv1.2,TLSv1.1,TLSv1 + KAFKA_SSL_KEYSTORE_FILENAME: server.keystore.jks + KAFKA_SSL_KEYSTORE_CREDENTIALS: ssl_keystore_credentials + KAFKA_SSL_KEY_CREDENTIALS: ssl_key_credentials + KAFKA_SASL_ENABLED_MECHANISMS: SCRAM-SHA-256 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + KAFKA_OFFSETS_RETENTION_MINUTES: 172800 + KAFKA_LOG4J_LOGGERS: "kafka.authorizer.logger=DEBUG,kafka.controller=DEBUG" + KAFKA_LOG4J_ROOT_LOGLEVEL: "DEBUG" + KAFKA_SUPER_USERS: User:kafkabroker;User:kafkaclient + KAFKA_ZOOKEEPER_SASL_ENABLED: "true" + KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: "false" + KAFKA_OPTS: -Dzookeeper.sasl.client=true + -Dzookeeper.sasl.clientconfig=Client + -Djava.security.auth.login.config=/etc/kafka/secrets/conf/kafka_server_jaas.conf + volumes: + - ./ssl:/etc/kafka/secrets + - ./broker_jaas.conf:/etc/kafka/secrets/conf/kafka_server_jaas.conf + diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/ca-cert b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/ca-cert new file mode 100644 index 0000000..13cb609 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/ca-cert @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFpTCCA42gAwIBAgIUTMpoQaXQPXnqXuQtxcoXINk45pYwDQYJKoZIhvcNAQEL +BQAwYjESMBAGA1UEAwwJbG9jYWxob3N0MQ0wCwYDVQQLDARBdG9tMQ0wCwYDVQQK +DARSYXNhMQ8wDQYDVQQHDAZCZXJsaW4xEDAOBgNVBAgMB0dlcm1hbnkxCzAJBgNV +BAYTAkdFMB4XDTIzMDMzMDE0NTcxOVoXDTI0MDMyOTE0NTcxOVowYjESMBAGA1UE +AwwJbG9jYWxob3N0MQ0wCwYDVQQLDARBdG9tMQ0wCwYDVQQKDARSYXNhMQ8wDQYD +VQQHDAZCZXJsaW4xEDAOBgNVBAgMB0dlcm1hbnkxCzAJBgNVBAYTAkdFMIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArnldATnD8mveV99UKbNIYmk0dYyf +k2pXXa1DsLeNzRtvculEbQMD1O5sUIwE0If2HILrdXinLvFb7wdfmhv5FlGjOU0r +o/roxDxw8kOb5akBo6CZ16BYXIuIhqoavjJmIKlMFbtZsgcavj98HmAEGNdCYUb6 +dogGVVB8z15g+ekyI9dTLl/DgxTc0sT9bUeWcLigHiBPgiPKhuLCs0cYJ1Ta7D/E +eGYKegT/DRPqHuqaImMdKpcfvKBin4oQTuW6sja8epigD2d1k0SFxrUchFl0qzo+ +VP/xeL6+07Ds/neL30M9BvRlNk6DdZWziATGnZQc7AaHZzrrXjUALpmHCixJJ377 +PDpkpEKKwWiOeb7us3+i6rjp2kuz7HSYd1dzR3+MhidXhtDbKGC5kHoMbkwg4UES ++gZU3O0w49oXpIQJ9EPOFNsEaRZXr91aXWDFa3Z2aTrbflfW/6QAQfMoiWSL2sX6 +L+VgG4kGVhyV7iQgPIu1WXoMXR9x/Ua5epL6uRY1wvqTIskuzAAq0c4vR3cDEyTT +X/y6UVSkNz7TqHEuMGrJCbFkeDTv5p5wT3SfwZkm6gAipgrKwevPt0/WDI5XyWSo +Mtmh7Nm4YqJeMg3UJTAYGuxNvtc4PV4XvDadX8Ou29SDnCI2CEU6fFQyGjononrP +bobS9Dx+ElXp/ysCAwEAAaNTMFEwHQYDVR0OBBYEFM7czO97lAAMJMta2nosDb1S +GXwGMB8GA1UdIwQYMBaAFM7czO97lAAMJMta2nosDb1SGXwGMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADggIBABxapznhns4Qlm/7aU+fbG/PVh2GQgK9 +l+gMp9+IZoyscQAYfJfKbrLe83QV9oc3vyQ59stQeshlOT8VOzA3SL+MHEw7RhEk +BxHBTDaa6J2PdY14svD0jH1dk+yyj+vfSU1BU2Lm/QFOnbPWTFXkqkDB5qoV3OS/ +5aNzWaK8lYZAAtmCbGTi4WV+ueeE2H0+3yOsOLBc1WwS0T6HTif6ksSl6gimDCK0 +DaRVHd0/aU7hRHiCpavzXys4x/xy3weP3guEsmZiSX1J6E5NkZ1pTL4LyLgcwoIJ +KE9i9dT7/ohCvYutN/SjUCLEdELUv23ftB84PkpoQZLTymYkV5nJ5KkgK1DBH8k2 +ZCj2Lrad/dFEBVTn56hHYB8hU28m3T69mqPxg4CpI36QV7HVQdpLbfgWF/NWQepO +xN0GkVkHpoVkQoiFw6YuDfLLH1Q+Lo4G7TVe8wZn807gF2JQzUjhJk52evByYOWi +sZOhrCB2rkFcgO+SxPDfNSpJu1N2YZLhtjVQXeI+l0P7u5/YTjZy5PVU99GLxvLn +uEq4HImknpgTnTgsguJVRe+F7GLqBmFdPdccncVU9ImjT5pBwjorM0CSFU5kgvl3 +2hMvDCvaeKPWrjnTaZMca7/01o81ypMIs2zQaDzvXcRNEJbNkOY0cL7h8XU6iZTv +3tfaUz2fkOHH +-----END CERTIFICATE----- diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/server.keystore.jks b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/server.keystore.jks new file mode 100644 index 0000000..3983dd0 Binary files /dev/null and b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/server.keystore.jks differ diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/ssl_key_credentials b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/ssl_key_credentials new file mode 100644 index 0000000..4632e06 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/ssl_key_credentials @@ -0,0 +1 @@ +123456 \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/ssl_keystore_credentials b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/ssl_keystore_credentials new file mode 100644 index 0000000..4632e06 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/ssl_keystore_credentials @@ -0,0 +1 @@ +123456 \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/zookeeper_client_jaas.conf b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/zookeeper_client_jaas.conf new file mode 100644 index 0000000..7c73865 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/zookeeper_client_jaas.conf @@ -0,0 +1,5 @@ +Client { + org.apache.zookeeper.server.auth.DigestLoginModule required + username="admin" + password="password"; +}; \ No newline at end of file diff --git a/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/zookeeper_server_jaas.conf b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/zookeeper_server_jaas.conf new file mode 100644 index 0000000..ad7b043 --- /dev/null +++ b/test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/zookeeper_server_jaas.conf @@ -0,0 +1,13 @@ +Server { + org.apache.zookeeper.server.auth.DigestLoginModule required + user_admin="password"; +}; +QuorumServer { + org.apache.zookeeper.server.auth.DigestLoginModule required + user_zookeeper="password"; +}; +QuorumLearner { + org.apache.zookeeper.server.auth.DigestLoginModule required + username="zookeeper" + password="password"; +}; \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000..f299b9a --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,147 @@ +from pathlib import Path + +from subprocess import check_call + +from typing import Callable, Text +import pytest +import shutil +import os + +from pytest import TempdirFactory, Testdir +from _pytest.pytester import RunResult + +from rasa.cli import scaffold +from rasa.shared.utils.io import write_yaml + +RASA_EXE = os.environ.get("RASA_EXECUTABLE", "rasa") + + +@pytest.fixture +def run(testdir: Testdir) -> Callable[..., RunResult]: + def do_run(*args): + args = [shutil.which(RASA_EXE)] + list(args) + return testdir.run(*args) + + return do_run + + +@pytest.fixture +def run_with_stdin(testdir: Testdir) -> Callable[..., RunResult]: + def do_run(*args, stdin): + args = [shutil.which(RASA_EXE)] + list(args) + return testdir.run(*args, stdin=stdin) + + return do_run + + +def create_simple_project(path: Path): + scaffold.create_initial_project(str(path)) + + # create a config file + # for the cli test the resulting model is not important, use components that are + # fast to train + write_yaml( + { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": [{"name": "KeywordIntentClassifier"}], + "policies": [ + {"name": "RulePolicy"}, + {"name": "MemoizationPolicy", "max_history": 3}, + ], + }, + path / "config.yml", + ) + return path + + +def create_simple_project_with_missing_assistant_id(path: Path): + scaffold.create_initial_project(str(path)) + + write_yaml( + { + "language": "en", + "pipeline": [{"name": "KeywordIntentClassifier"}], + "policies": [ + {"name": "RulePolicy"}, + {"name": "MemoizationPolicy", "max_history": 3}, + ], + }, + path / "config.yml", + ) + return path + + +@pytest.fixture(scope="session") +def trained_simple_project(tmpdir_factory: TempdirFactory) -> Text: + path = tmpdir_factory.mktemp("simple") + create_simple_project(path) + + os.environ["LOG_LEVEL"] = "ERROR" + + check_call([shutil.which(RASA_EXE), "train"], cwd=path.strpath) + + return path.strpath + + +@pytest.fixture +def run_in_simple_project(testdir: Testdir) -> Callable[..., RunResult]: + os.environ["LOG_LEVEL"] = "ERROR" + + create_simple_project(testdir.tmpdir) + + def do_run(*args): + args = [shutil.which(RASA_EXE)] + list(args) + return testdir.run(*args) + + return do_run + + +@pytest.fixture +def run_in_simple_project_with_warnings(testdir: Testdir) -> Callable[..., RunResult]: + os.environ["LOG_LEVEL"] = "WARNING" + + create_simple_project(testdir.tmpdir) + + def do_run(*args): + args = [shutil.which(RASA_EXE)] + list(args) + return testdir.run(*args) + + return do_run + + +@pytest.fixture +def run_in_simple_project_with_no_domain(testdir: Testdir) -> Callable[..., RunResult]: + os.environ["LOG_LEVEL"] = "WARNING" + + create_simple_project(testdir.tmpdir) + Path(testdir.tmpdir / "domain.yml").unlink() + + def do_run(*args): + args = [shutil.which(RASA_EXE)] + list(args) + return testdir.run(*args) + + return do_run + + +@pytest.fixture +def run_in_simple_project_with_model( + testdir: Testdir, trained_simple_project: Text +) -> Callable[..., RunResult]: + os.environ["LOG_LEVEL"] = "ERROR" + + # makes sure we do not always retrain an initial model for every "new" project + for file_name in os.listdir(trained_simple_project): + full_file_name = os.path.join(trained_simple_project, file_name) + if os.path.isfile(full_file_name): + shutil.copy(full_file_name, str(testdir.tmpdir)) + else: + shutil.copytree(full_file_name, str(testdir.tmpdir / file_name)) + + def do_run(*args): + args = [shutil.which(RASA_EXE)] + list(args) + result = testdir.run(*args) + os.environ["LOG_LEVEL"] = "INFO" + return result + + return do_run diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py new file mode 100644 index 0000000..028c7ba --- /dev/null +++ b/tests/cli/test_cli.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import Callable + +from pytest import Testdir, RunResult +import pytest +import sys + +from tests.cli.conftest import RASA_EXE + + +def test_cli_start_is_fast(testdir: Testdir): + """ + Checks that a call to ``rasa --help`` does not import any slow imports. + + If this is failing this means, that a simple "rasa --help" commands imports + `tensorflow` which makes our CLI extremely slow. In case this test is failing + you've very likely added a global import of "tensorflow" which should be + avoided. Consider making this import (or the import of its parent module) + a local import. + + If you are clueless where that import happens, you can run + ``` + python -X importtime -m rasa.__main__ --help 2> import.log + tuna import.log + ``` + to get the import chain. + (make sure to run with python >= 3.7, and install tune (pip install tuna)) + """ + + rasa_path = str( + (Path(__file__).parent / ".." / ".." / "rasa" / "__main__.py").absolute() + ) + args = [sys.executable, "-X", "importtime", rasa_path, "--help"] + result = testdir.run(*args) + + assert result.ret == 0 + + # tensorflow is slow -> can't get imported when running basic CLI commands + result.stderr.no_fnmatch_line("*tensorflow.python.eager") + + +def test_data_convert_help(run: Callable[..., RunResult]): + output = run("--help") + + help_text = f"""usage: {RASA_EXE} [-h] [--version] + {{init,run,shell,train,interactive,telemetry,test,visualize,data,export,x,evaluate}} + ...""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = set(output.outlines) + for line in lines: + assert line in printed_help + + +@pytest.mark.xfail( + sys.platform == "win32", reason="--version doesn't print anything on Windows" +) +def test_version_print_lines(run: Callable[..., RunResult]): + output = run("--version") + output_text = "".join(output.outlines) + assert "Rasa Version" in output_text + assert "Python Version" in output_text + assert "Operating System" in output_text + assert "Python Path" in output_text diff --git a/tests/cli/test_rasa_data.py b/tests/cli/test_rasa_data.py new file mode 100644 index 0000000..aecbc3d --- /dev/null +++ b/tests/cli/test_rasa_data.py @@ -0,0 +1,257 @@ +import os +from pathlib import Path +from typing import Callable + +from _pytest.fixtures import FixtureRequest +from _pytest.pytester import RunResult +from rasa.shared.nlu.training_data.formats import RasaYAMLReader +import rasa.shared.utils.io + +from tests.cli.conftest import RASA_EXE + + +def test_data_split_nlu(run_in_simple_project: Callable[..., RunResult]): + responses_yml = ( + "responses:\n" + " chitchat/ask_name:\n" + " - text: my name is Sara, Rasa's documentation bot!\n" + " chitchat/ask_weather:\n" + " - text: the weather is great!\n" + ) + + with open("data/responses.yml", "w") as f: + f.write(responses_yml) + + run_in_simple_project( + "data", + "split", + "nlu", + "-u", + "data/nlu.yml", + "--training-fraction", + "0.75", + "--random-seed", + "12345", + ) + + folder = Path("train_test_split") + assert folder.exists() + + nlu_files = [folder / "test_data.yml", folder / "training_data.yml"] + nlg_files = [folder / "nlg_test_data.yml", folder / "nlg_training_data.yml"] + for yml_file in nlu_files: + assert yml_file.exists(), f"{yml_file} file does not exist" + nlu_data = rasa.shared.utils.io.read_yaml_file(yml_file) + assert "version" in nlu_data + assert nlu_data.get("nlu") + + for yml_file in nlg_files: + assert yml_file.exists(), f"{yml_file} file does not exist" + + +def test_data_convert_nlu_json(run_in_simple_project: Callable[..., RunResult]): + + result = run_in_simple_project( + "data", + "convert", + "nlu", + "--data", + "data/nlu.yml", + "--out", + "out_nlu_data.json", + "-f", + "json", + ) + + assert "NLU data in Rasa JSON format is deprecated" in str(result.stderr) + assert os.path.exists("out_nlu_data.json") + + +def test_data_convert_nlu_yml( + run: Callable[..., RunResult], tmp_path: Path, request: FixtureRequest +): + + target_file = tmp_path / "out.yml" + + # The request rootdir is required as the `testdir` fixture in `run` changes the + # working directory + test_data_dir = Path(request.config.rootdir, "data", "examples", "rasa") + source_file = (test_data_dir / "demo-rasa.json").absolute() + result = run( + "data", + "convert", + "nlu", + "--data", + str(source_file), + "--out", + str(target_file), + "-f", + "yaml", + ) + + assert result.ret == 0 + assert target_file.exists() + + actual_data = RasaYAMLReader().read(target_file) + expected = RasaYAMLReader().read(test_data_dir / "demo-rasa.yml") + + assert len(actual_data.training_examples) == len(expected.training_examples) + assert len(actual_data.entity_synonyms) == len(expected.entity_synonyms) + assert len(actual_data.regex_features) == len(expected.regex_features) + assert len(actual_data.lookup_tables) == len(expected.lookup_tables) + assert actual_data.entities == expected.entities + + +def test_data_split_help(run: Callable[..., RunResult]): + output = run("data", "split", "nlu", "--help") + + help_text = f"""usage: {RASA_EXE} data split nlu [-h] [-v] [-vv] [--quiet]\n + [--logging-config-file LOGGING_CONFIG_FILE]\n + [-u NLU] [--training-fraction TRAINING_FRACTION]\n + [--random-seed RANDOM_SEED] [--out OUT]""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = set(output.outlines) + for line in lines: + assert line in printed_help + + +def test_data_convert_help(run: Callable[..., RunResult]): + output = run("data", "convert", "nlu", "--help") + + help_text = f"""usage: {RASA_EXE} data convert nlu [-h] [-v] [-vv] [--quiet]\n + [--logging-config-file LOGGING_CONFIG_FILE]\n + [-f {"{json,yaml}"}] [--data DATA [DATA ...]]""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help + + +def test_data_validate_help(run: Callable[..., RunResult]): + output = run("data", "validate", "--help") + + help_text = f"""usage: {RASA_EXE} data validate [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] + [--max-history MAX_HISTORY] [-c CONFIG] + [--fail-on-warnings] [-d DOMAIN] + [--data DATA [DATA ...]] + {{stories}} ...""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help + + +def test_data_migrate_help(run: Callable[..., RunResult]): + output = run("data", "migrate", "--help") + printed_help = set(output.outlines) + + help_text = f"""usage: {RASA_EXE} data migrate [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] + [-d DOMAIN] [--out OUT]""" + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help + + +def test_data_validate_default_options( + run_in_simple_project: Callable[..., RunResult], request: FixtureRequest +): + test_data_dir = Path(request.config.rootdir, "data", "test_moodbot", "data") + test_domain = Path(request.config.rootdir, "data", "test_moodbot", "domain.yml") + test_config = Path(request.config.rootdir, "data", "test_moodbot", "config.yml") + + result = run_in_simple_project( + "data", + "validate", + "--data", + str(test_data_dir), + "--domain", + str(test_domain), + "-c", + str(test_config), + ) + + # should not raise any errors + assert result.ret == 0 + + +def test_data_validate_not_used_warning( + run_in_simple_project: Callable[..., RunResult], request: FixtureRequest +): + test_data_dir = Path(request.config.rootdir, "data", "test_validation", "data") + test_domain = Path(request.config.rootdir, "data", "test_validation", "domain.yml") + test_config = Path(request.config.rootdir, "data", "test_moodbot", "config.yml") + + result = run_in_simple_project( + "data", + "validate", + "--data", + str(test_data_dir), + "--domain", + str(test_domain), + "-c", + str(test_config), + ) + + for warning in [ + "The intent 'goodbye' is not used in any story or rule.", + "The utterance 'utter_chatter' is not used in any story or rule.", + ]: + assert warning in str(result.stderr) + + +def test_data_validate_failed_to_load_domain( + run_in_simple_project_with_no_domain: Callable[..., RunResult] +): + result = run_in_simple_project_with_no_domain( + "data", + "validate", + "--domain", + "not-existing-domain.yml", + ) + + assert "The path 'not-existing-domain.yml' does not exist." in str(result.outlines) + assert result.ret == 1 + + +def test_data_split_stories(run_in_simple_project: Callable[..., RunResult]): + stories_yml = ( + "stories:\n" + "- story: story 1\n" + " steps:\n" + " - intent: intent_a\n" + " - action: utter_a\n" + "- story: story 2\n" + " steps:\n" + " - intent: intent_a\n" + " - action: utter_a\n" + ) + + Path("data/stories.yml").write_text(stories_yml) + run_in_simple_project( + "data", "split", "stories", "--random-seed", "123", "--training-fraction", "0.5" + ) + + folder = Path("train_test_split") + assert folder.exists() + + train_file = folder / "train_stories.yml" + assert train_file.exists() + test_file = folder / "test_stories.yml" + assert test_file.exists() + + train_data = rasa.shared.utils.io.read_yaml_file(train_file) + assert len(train_data.get("stories", [])) == 1 + assert train_data["stories"][0].get("story") == "story 1" + test_data = rasa.shared.utils.io.read_yaml_file(test_file) + assert len(test_data.get("stories", [])) == 1 + assert test_data["stories"][0].get("story") == "story 2" diff --git a/tests/cli/test_rasa_evaluate_markers.py b/tests/cli/test_rasa_evaluate_markers.py new file mode 100644 index 0000000..f9610ef --- /dev/null +++ b/tests/cli/test_rasa_evaluate_markers.py @@ -0,0 +1,154 @@ +from pathlib import Path +from typing import Callable, Text, Tuple, Dict, Any +import csv + +import pytest +from _pytest.pytester import RunResult + +import rasa.cli.evaluate + +from rasa.shared.core.events import ActionExecuted, SlotSet, UserUttered +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.constants import ACTION_SESSION_START_NAME +from rasa.shared.core.domain import Domain +from rasa.core.tracker_store import SQLTrackerStore +from rasa.cli.evaluate import STATS_SESSION_SUFFIX, STATS_OVERALL_SUFFIX +from tests.conftest import write_endpoint_config_to_yaml + +from tests.cli.conftest import RASA_EXE + + +@pytest.fixture +def marker_sqlite_tracker(tmp_path: Path) -> Tuple[SQLTrackerStore, Text]: + domain = Domain.empty() + db_path = str(tmp_path / "rasa.db") + tracker_store = SQLTrackerStore(dialect="sqlite", db=db_path) + for i in range(5): + tracker = DialogueStateTracker(str(i), None) + tracker.update_with_events([SlotSet(str(j), "slot") for j in range(5)], domain) + tracker.update(ActionExecuted(ACTION_SESSION_START_NAME)) + tracker.update(UserUttered("hello")) + tracker.update_with_events( + [SlotSet(str(5 + j), "slot") for j in range(5)], domain + ) + tracker_store.save(tracker) + + return tracker_store, db_path + + +def write_markers_config_to_yaml( + path: Path, data: Dict[Text, Any], markers_filename: Text = "markers.yml" +) -> Path: + markers_path = path / markers_filename + + # write markers config to file + rasa.shared.utils.io.write_yaml(data, markers_path) + return markers_path + + +def test_evaluate_markers_help(run: Callable[..., RunResult]): + output = run("evaluate", "markers", "--help") + + help_text = f"""usage: {RASA_EXE} evaluate markers [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] + {{first_n,sample_n,all}} ...""" + + lines = [line.strip() for line in help_text.split("\n")] + # expected help text lines should appear somewhere in the output + printed_help = set([line.strip() for line in output.outlines]) + for line in lines: + assert line in printed_help + + +def test_evaluate_markers_first_n_help(run: Callable[..., RunResult]): + # We need to specify an output_filename as that's the first positional parameter + output = run("evaluate", "markers", "first_n", "--help") + + help_text = f"""usage: {RASA_EXE} evaluate markers first_n [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] + [--config CONFIG] + [--no-stats | --stats-file-prefix [STATS_FILE_PREFIX]] + [--endpoints ENDPOINTS] [-d DOMAIN] + count output_filename""" + + lines = [line.strip() for line in help_text.split("\n")] + # expected help text lines should appear somewhere in the output + printed_help = set([line.strip() for line in output.outlines]) + for line in lines: + assert line in printed_help + + +def test_evaluate_markers_sample_n_help(run: Callable[..., RunResult]): + # We need to specify an output_filename as that's the first positional parameter + output = run("evaluate", "markers", "sample_n", "--help") + + help_text = f"""usage: {RASA_EXE} evaluate markers sample_n [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] + [--seed SEED] [--config CONFIG] + [--no-stats | --stats-file-prefix [STATS_FILE_PREFIX]] + [--endpoints ENDPOINTS] [-d DOMAIN] + count output_filename""" # noqa: E501 + + lines = [line.strip() for line in help_text.split("\n")] + # expected help text lines should appear somewhere in the output + printed_help = set([line.strip() for line in output.outlines]) + for line in lines: + assert line in printed_help + + +def test_evaluate_markers_all_help(run: Callable[..., RunResult]): + # We need to specify an output_filename as that's the first positional parameter + output = run("evaluate", "markers", "all", "--help") + + help_text = f"""usage: {RASA_EXE} evaluate markers all [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] + [--config CONFIG] + [--no-stats | --stats-file-prefix [STATS_FILE_PREFIX]] + [--endpoints ENDPOINTS] [-d DOMAIN] + output_filename""" + + lines = [line.strip() for line in help_text.split("\n")] + # expected help text lines should appear somewhere in the output + printed_help = set([line.strip() for line in output.outlines]) + for line in lines: + assert line in printed_help + + +def test_markers_cli_results_save_correctly( + marker_sqlite_tracker: Tuple[SQLTrackerStore, Text], tmp_path: Path +): + _, db_path = marker_sqlite_tracker + + endpoints_path = write_endpoint_config_to_yaml( + tmp_path, + {"tracker_store": {"type": "sql", "db": db_path.replace("\\", "\\\\")}}, + ) + + markers_path = write_markers_config_to_yaml( + tmp_path, {"marker1": {"slot_was_set": "2"}, "marker2": {"slot_was_set": "7"}} + ) + + results_path = tmp_path / "results.csv" + stats_file_prefix = tmp_path / "statistics" + + rasa.cli.evaluate._run_markers( + seed=None, + count=10, + endpoint_config=endpoints_path, + strategy="first_n", + domain_path=None, + config=markers_path, + output_filename=results_path, + stats_file_prefix=stats_file_prefix, + ) + + for expected_output in [ + results_path, + tmp_path / ("statistics" + STATS_SESSION_SUFFIX), + tmp_path / ("statistics" + STATS_OVERALL_SUFFIX), + ]: + with expected_output.open(mode="r") as results: + result_reader = csv.DictReader(results) + # Loop over entire file to ensure nothing in the file causes any errors + for _ in result_reader: + continue diff --git a/tests/cli/test_rasa_export.py b/tests/cli/test_rasa_export.py new file mode 100644 index 0000000..16feb3c --- /dev/null +++ b/tests/cli/test_rasa_export.py @@ -0,0 +1,292 @@ +import argparse +from pathlib import Path +from typing import Callable, Optional, Text, List, Tuple +from unittest.mock import Mock + +import pytest +from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import RunResult + +import rasa.core.utils as rasa_core_utils +from rasa.cli import export +from rasa.core.brokers.broker import EventBroker +from rasa.core.brokers.pika import PikaEventBroker +from rasa.shared.core.events import UserUttered +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.exceptions import PublishingError, NoEventsToMigrateError +from tests.conftest import ( + MockExporter, + random_user_uttered_event, + write_endpoint_config_to_yaml, + AsyncMock, +) + +from tests.cli.conftest import RASA_EXE + + +def test_export_help(run: Callable[..., RunResult]): + output = run("export", "--help") + + help_text = f"""usage: {RASA_EXE} export [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] + [--endpoints ENDPOINTS] + [--minimum-timestamp MINIMUM_TIMESTAMP] + [--maximum-timestamp MAXIMUM_TIMESTAMP] + [--offset-timestamps-by-seconds OFFSET_TIMESTAMPS_BY_SECONDS] + [--conversation-ids CONVERSATION_IDS]""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help + + +@pytest.mark.parametrize( + "minimum_timestamp,maximum_timestamp", + [(2, 3), (None, 5.5), (None, None), (5, None)], +) +def test_validate_timestamp_options( + minimum_timestamp: Optional[float], maximum_timestamp: Optional[float] +): + args = argparse.Namespace() + args.minimum_timestamp = ( + str(minimum_timestamp) if minimum_timestamp is not None else None + ) + args.maximum_timestamp = ( + str(maximum_timestamp) if maximum_timestamp is not None else None + ) + + # no error is raised + # noinspection PyProtectedMember + export._assert_max_timestamp_is_greater_than_min_timestamp(args) + + +def test_validate_timestamp_options_with_invalid_timestamps(): + args = argparse.Namespace(minimum_timestamp=3, maximum_timestamp=2) + with pytest.raises(SystemExit): + # noinspection PyProtectedMember + export._assert_max_timestamp_is_greater_than_min_timestamp(args) + + +# noinspection PyProtectedMember +async def test_get_event_broker_and_tracker_store_from_endpoint_config(tmp_path: Path): + # write valid config to file + endpoints_path = write_endpoint_config_to_yaml( + tmp_path, + { + "event_broker": { + "type": "sql", + "db": str(tmp_path / "rasa.db").replace("\\", "\\\\"), + }, + "tracker_store": {"type": "sql"}, + }, + ) + + available_endpoints = rasa_core_utils.read_endpoints_from_path(endpoints_path) + + # fetching the event broker is successful + assert await export._get_event_broker(available_endpoints) + assert export._get_tracker_store(available_endpoints) + + +# noinspection PyProtectedMember +async def test_get_event_broker_from_endpoint_config_error_exit(tmp_path: Path): + # write config without event broker to file + endpoints_path = write_endpoint_config_to_yaml( + tmp_path, {"tracker_store": {"type": "sql"}} + ) + + available_endpoints = rasa_core_utils.read_endpoints_from_path(endpoints_path) + + with pytest.raises(SystemExit): + assert await export._get_event_broker(available_endpoints) + + +def test_get_tracker_store_from_endpoint_config_error_exit(tmp_path: Path): + # write config without event broker to file + endpoints_path = write_endpoint_config_to_yaml(tmp_path, {}) + + available_endpoints = rasa_core_utils.read_endpoints_from_path(endpoints_path) + + with pytest.raises(SystemExit): + # noinspection PyProtectedMember + assert export._get_tracker_store(available_endpoints) + + +@pytest.mark.parametrize( + "requested_ids,expected", + [("id1", ["id1"]), ("id1,id2", ["id1", "id2"]), (None, None), ("", None)], +) +def test_get_requested_conversation_ids( + requested_ids: Optional[Text], expected: Optional[List[Text]] +): + # noinspection PyProtectedMember + assert export._get_requested_conversation_ids(requested_ids) == expected + + +def test_prepare_pika_event_broker(): + # mock a pika event broker + pika_broker = Mock(spec=PikaEventBroker) + + # patch the spinner so we can execute the `_prepare_pika_producer()` function + pika_broker.is_ready.return_value = True + + # noinspection PyProtectedMember + export._prepare_event_broker(pika_broker) + + # the attributes are set as expected + assert not pika_broker.should_keep_unpublished_messages + assert pika_broker.raise_on_failure + + +@pytest.mark.parametrize( + "current_timestamp,maximum_timestamp,endpoints_path,requested_ids,expected", + [ + (1.0, None, None, None, "--minimum-timestamp 1.0"), + (1.0, None, None, ["5", "6"], "--minimum-timestamp 1.0 --conversation-ids 5,6"), + (1.0, 3.4, None, None, "--minimum-timestamp 1.0 --maximum-timestamp 3.4"), + ( + 1.0, + 2.5, + "a.yml", + None, + "--endpoints a.yml --minimum-timestamp 1.0 --maximum-timestamp 2.5", + ), + ( + 1.0, + 2.5, + "a.yml", + ["1", "2", "3"], + ( + "--endpoints a.yml --minimum-timestamp 1.0 --maximum-timestamp 2.5 " + "--conversation-ids 1,2,3" + ), + ), + ], +) +def test_get_continuation_command( + current_timestamp: float, + maximum_timestamp: Optional[float], + endpoints_path: Optional[Text], + requested_ids: Optional[List[Text]], + expected: Text, +): + exporter = MockExporter() + exporter.maximum_timestamp = maximum_timestamp + exporter.endpoints_path = endpoints_path + exporter.requested_conversation_ids = requested_ids + + # noinspection PyProtectedMember + assert ( + export._get_continuation_command(exporter, current_timestamp) + == f"rasa export {expected}" + ) + + +def prepare_namespace_and_mocked_tracker_store_with_events( + temporary_path: Path, monkeypatch: MonkeyPatch +) -> Tuple[List[UserUttered], argparse.Namespace]: + endpoints_path = write_endpoint_config_to_yaml( + temporary_path, + {"event_broker": {"type": "pika"}, "tracker_store": {"type": "sql"}}, + ) + + # export these conversation IDs + all_conversation_ids = ["id-1", "id-2", "id-3"] + + requested_conversation_ids = ["id-1", "id-2"] + + # create namespace with a set of cmdline arguments + namespace = argparse.Namespace( + endpoints=endpoints_path, + conversation_ids=",".join(requested_conversation_ids), + minimum_timestamp=1.0, + maximum_timestamp=10.0, + offset_timestamps_by_seconds=100, + ) + + # prepare events from different senders and different timestamps + events = [random_user_uttered_event(timestamp) for timestamp in [1, 2, 3, 4, 11, 5]] + events_for_conversation_id = { + all_conversation_ids[0]: [events[0], events[1]], + all_conversation_ids[1]: [events[2], events[3], events[4]], + all_conversation_ids[2]: [events[5]], + } + + async def _get_tracker(conversation_id: Text) -> DialogueStateTracker: + return DialogueStateTracker.from_events( + conversation_id, events_for_conversation_id[conversation_id] + ) + + # mock tracker store + tracker_store = Mock() + tracker_store.keys = AsyncMock(return_value=all_conversation_ids) + tracker_store.retrieve_full_tracker = _get_tracker + + monkeypatch.setattr(export, "_get_tracker_store", lambda _: tracker_store) + + return events, namespace + + +def test_export_trackers_with_offset(tmp_path: Path, monkeypatch: MonkeyPatch): + events, namespace = prepare_namespace_and_mocked_tracker_store_with_events( + tmp_path, monkeypatch + ) + + # mock event broker so we can check its `publish` method is called + event_broker = Mock() + + async def _get_event_broker(_: rasa_core_utils.AvailableEndpoints) -> EventBroker: + return event_broker + + async def close(): + pass + + event_broker.close = close + monkeypatch.setattr(export, "_get_event_broker", _get_event_broker) + + # run the export function + export.export_trackers(namespace) + + # check that only events 1, 2, 3, and 4 have been published + # event 6 was sent by `id-3` which was not requested, and event 5 + # lies outside the requested time range + calls = event_broker.publish.mock_calls + + # only four events were published (i.e. `publish()` method was called four times) + assert len(calls) == 4 + + # call objects are tuples of (name, pos. args, kwargs) + # args itself is a tuple, and we want to access the first one, hence `call[1][0]` + # check that events 1-4 were published + assert all( + any(call[1][0]["text"] == event.text for call in calls) for event in events[:4] + ) + + # check that the timestamps of the published events are offset by 100 seconds + assert all( + any(call[1][0]["timestamp"] == event.timestamp + 100 for call in calls) + for event in events[:4] + ) + + +@pytest.mark.parametrize("exception", [NoEventsToMigrateError, PublishingError(123)]) +def test_export_trackers_publishing_exceptions( + tmp_path: Path, monkeypatch: MonkeyPatch, exception: Exception +): + events, namespace = prepare_namespace_and_mocked_tracker_store_with_events( + tmp_path, monkeypatch + ) + + # mock event broker so we can check its `publish` method is called + event_broker = Mock() + event_broker.publish.side_effect = exception + + async def _get_event_broker(_: rasa_core_utils.AvailableEndpoints) -> EventBroker: + return event_broker + + monkeypatch.setattr(export, "_get_event_broker", _get_event_broker) + + with pytest.raises(SystemExit): + export.export_trackers(namespace) diff --git a/tests/cli/test_rasa_init.py b/tests/cli/test_rasa_init.py new file mode 100644 index 0000000..b7f45cb --- /dev/null +++ b/tests/cli/test_rasa_init.py @@ -0,0 +1,103 @@ +import argparse +import os +from pathlib import Path +from typing import Callable +from _pytest.pytester import RunResult +from _pytest.monkeypatch import MonkeyPatch + +from rasa.cli import scaffold +from tests.conftest import enable_cache +from tests.core.channels.test_cmdline import mock_stdin + +from tests.cli.conftest import RASA_EXE + + +def test_init_using_init_dir_option(run_with_stdin: Callable[..., RunResult]): + os.makedirs("./workspace") + run_with_stdin( + "init", "--quiet", "--init-dir", "./workspace", stdin=b"N" + ) # avoid training an initial model + + required_files = [ + "actions/__init__.py", + "actions/actions.py", + "domain.yml", + "config.yml", + "credentials.yml", + "endpoints.yml", + "data/nlu.yml", + "data/stories.yml", + "data/rules.yml", + ] + assert all((Path("workspace") / file).exists() for file in required_files) + + # ./__init__.py does not exist anymore + assert not (Path("workspace") / "__init__.py").exists() + + +def test_not_found_init_path(run: Callable[..., RunResult]): + output = run("init", "--no-prompt", "--quiet", "--init-dir", "./workspace") + + assert "Project init path './workspace' not found" in output.outlines[-1] + + +def test_init_help(run: Callable[..., RunResult]): + output = run("init", "--help") + + help_text = f"""usage: {RASA_EXE} init [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] [--no-prompt] + [--init-dir INIT_DIR]""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help + + +def test_user_asked_to_train_model(run_with_stdin: Callable[..., RunResult]): + run_with_stdin("init", stdin=b"\nYN") + assert not os.path.exists("models") + + +def test_train_data_in_project_dir(monkeypatch: MonkeyPatch, tmp_path: Path): + """Test cache directory placement. + + Tests cache directories for training data are in project root, not + where `rasa init` is run. + """ + # We would like to test CLI but can't run it with popen because we want + # to be able to monkeypatch it. Solution is to call functions inside CLI + # module. Initial project folder should have been created before + # `init_project`, that's what we do here. + monkeypatch.chdir(tmp_path) + new_project_folder_path = tmp_path / "new-project-folder" + new_project_folder_path.mkdir() + + # Simulate CLI run arguments. + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers() + scaffold.add_subparser(subparsers, parents=[]) + + args = parser.parse_args(["init", "--no-prompt"]) + + # Simple config which should train fast. + def mock_get_config(*args): + return { + "language": "en", + "pipeline": [{"name": "KeywordIntentClassifier"}], + "policies": [{"name": "RulePolicy"}], + "recipe": "default.v1", + } + + monkeypatch.setattr( + "rasa.shared.importers.importer.CombinedDataImporter.get_config", + mock_get_config, + ) + # Cache dir is auto patched to be a temp directory, this makes it + # go back to local project folder so we can test it is created correctly. + with enable_cache(Path(".rasa", "cache")): + mock_stdin([]) + scaffold.init_project(args, str(new_project_folder_path)) + assert os.getcwd() == str(new_project_folder_path) + assert os.path.exists(".rasa/cache") diff --git a/tests/cli/test_rasa_interactive.py b/tests/cli/test_rasa_interactive.py new file mode 100644 index 0000000..32a10ae --- /dev/null +++ b/tests/cli/test_rasa_interactive.py @@ -0,0 +1,201 @@ +import argparse +from typing import Callable, Text +from unittest.mock import Mock, ANY + +import pytest +from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import RunResult + +import rasa +from rasa.core.train import do_interactive_learning +from rasa.core.training import interactive as interactive_learning +from rasa.cli import interactive, train +from rasa.model_training import TrainingResult + +from tests.cli.conftest import RASA_EXE + + +def test_interactive_help(run: Callable[..., RunResult]): + output = run("interactive", "--help") + + help_text = f"""usage: {RASA_EXE} interactive [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] [--e2e] + [-p PORT] [-m MODEL] [--data DATA [DATA ...]] + [--skip-visualization] + [--conversation-id CONVERSATION_ID] + [--endpoints ENDPOINTS] [-c CONFIG] [-d DOMAIN] + [--out OUT] [--augmentation AUGMENTATION] + [--debug-plots] [--finetune [FINETUNE]] + [--epoch-fraction EPOCH_FRACTION] [--force] + [--persist-nlu-data] + {{core}} ... [model-as-positional-argument]""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help + + +def test_interactive_core_help(run: Callable[..., RunResult]): + output = run("interactive", "core", "--help") + + help_text = f"""usage: {RASA_EXE} interactive core [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] + [-m MODEL] [-s STORIES] [--skip-visualization] + [--conversation-id CONVERSATION_ID] + [--endpoints ENDPOINTS] [-c CONFIG] [-d DOMAIN] + [--out OUT] [--augmentation AUGMENTATION] + [--debug-plots] [--finetune [FINETUNE]] + [--epoch-fraction EPOCH_FRACTION] [-p PORT] + [model-as-positional-argument]""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help + + +def test_pass_arguments_to_rasa_train( + stack_config_path: Text, monkeypatch: MonkeyPatch +) -> None: + # Create parser + parser = argparse.ArgumentParser() + sub_parser = parser.add_subparsers() + interactive.add_subparser(sub_parser, []) + + # Parse interactive command + args = parser.parse_args(["interactive", "--config", stack_config_path]) + interactive._set_not_required_args(args) + + # Mock actual training + mock = Mock(return_value=TrainingResult(code=0)) + monkeypatch.setattr(rasa, "train", mock.method) + + # If the `Namespace` object does not have all required fields this will throw + train.run_training(args) + + # Assert `train` was actually called + mock.method.assert_called_once() + + +def test_train_called_when_no_model_passed( + stack_config_path: Text, monkeypatch: MonkeyPatch +) -> None: + parser = argparse.ArgumentParser() + sub_parser = parser.add_subparsers() + interactive.add_subparser(sub_parser, []) + + args = parser.parse_args( + [ + "interactive", + "--config", + stack_config_path, + "--data", + "data/test_moodbot/data", + ] + ) + interactive._set_not_required_args(args) + + # Mock actual training and interactive learning methods + mock = Mock() + monkeypatch.setattr(train, "run_training", mock.train_model) + monkeypatch.setattr( + interactive, "perform_interactive_learning", mock.perform_interactive_learning + ) + + interactive.interactive(args) + mock.train_model.assert_called_once() + + +def test_train_core_called_when_no_model_passed_and_core( + stack_config_path: Text, monkeypatch: MonkeyPatch +) -> None: + parser = argparse.ArgumentParser() + sub_parser = parser.add_subparsers() + interactive.add_subparser(sub_parser, []) + + args = parser.parse_args( + [ + "interactive", + "core", + "--config", + stack_config_path, + "--stories", + "data/test_moodbot/data/stories.yml", + "--domain", + "data/test_moodbot/domain.yml", + ] + ) + interactive._set_not_required_args(args) + + # Mock actual training and interactive learning methods + mock = Mock() + monkeypatch.setattr(train, "run_core_training", mock.run_core_training) + monkeypatch.setattr( + interactive, "perform_interactive_learning", mock.perform_interactive_learning + ) + + interactive.interactive(args) + mock.run_core_training.assert_called_once() + + +def test_no_interactive_without_core_data( + stack_config_path: Text, monkeypatch: MonkeyPatch, nlu_data_path: Text +) -> None: + parser = argparse.ArgumentParser() + sub_parser = parser.add_subparsers() + interactive.add_subparser(sub_parser, []) + + args = parser.parse_args( + ["interactive", "--config", stack_config_path, "--data", nlu_data_path] + ) + interactive._set_not_required_args(args) + + mock = Mock() + monkeypatch.setattr(train, "run_training", mock.train_model) + monkeypatch.setattr( + interactive, "perform_interactive_learning", mock.perform_interactive_learning + ) + + with pytest.raises(SystemExit): + interactive.interactive(args) + + mock.train_model.assert_not_called() + mock.perform_interactive_learning.assert_not_called() + + +def test_pass_conversation_id_to_interactive_learning(monkeypatch: MonkeyPatch): + parser = argparse.ArgumentParser() + sub_parser = parser.add_subparsers() + interactive.add_subparser(sub_parser, []) + + expected_conversation_id = "🎁" + args = parser.parse_args( + [ + "interactive", + "--conversation-id", + expected_conversation_id, + "--skip-visualization", + ] + ) + + _serve_application = Mock() + monkeypatch.setattr(interactive_learning, "_serve_application", _serve_application) + + do_interactive_learning(args, Mock()) + + _serve_application.assert_called_once_with( + ANY, ANY, True, expected_conversation_id, 5005 + ) + + +def test_generate_conversation_id_for_interactive_learning(monkeypatch: MonkeyPatch): + parser = argparse.ArgumentParser() + sub_parser = parser.add_subparsers() + interactive.add_subparser(sub_parser, []) + + args = parser.parse_args(["interactive"]) + + assert args.conversation_id diff --git a/tests/cli/test_rasa_run.py b/tests/cli/test_rasa_run.py new file mode 100644 index 0000000..ca26f37 --- /dev/null +++ b/tests/cli/test_rasa_run.py @@ -0,0 +1,82 @@ +import os +import sys +from typing import Callable +from _pytest.pytester import RunResult + +from tests.cli.conftest import RASA_EXE + + +def test_run_does_not_start(run_in_simple_project: Callable[..., RunResult]): + os.remove("domain.yml") + + # the server should not start as no model is configured + output = run_in_simple_project("run") + + error = "No model found. You have three options to provide a model:" + + assert any(error in line for line in output.outlines) + + +def test_run_help( + run: Callable[..., RunResult], +): + output = run("run", "--help") + + if sys.version_info.minor >= 9: + # This is required because `argparse` behaves differently on + # Python 3.9 and above. The difference is the changed formatting of help + # output for CLI arguments with `nargs="*" + version_dependent = """[-i INTERFACE] [-p PORT] [-t AUTH_TOKEN] [--cors [CORS ...]] + [--enable-api] [--response-timeout RESPONSE_TIMEOUT]""" # noqa: E501 + else: + version_dependent = """[-i INTERFACE] [-p PORT] [-t AUTH_TOKEN] + [--cors [CORS [CORS ...]]] [--enable-api] + [--response-timeout RESPONSE_TIMEOUT]""" + + help_text = ( + f"""usage: {RASA_EXE} run [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] [-m MODEL] + [--log-file LOG_FILE] [--use-syslog] + [--syslog-address SYSLOG_ADDRESS] [--syslog-port SYSLOG_PORT] + [--syslog-protocol SYSLOG_PROTOCOL] [--endpoints ENDPOINTS] + """ + + version_dependent + + """ + [--remote-storage REMOTE_STORAGE] + [--ssl-certificate SSL_CERTIFICATE] + [--ssl-keyfile SSL_KEYFILE] [--ssl-ca-file SSL_CA_FILE] + [--ssl-password SSL_PASSWORD] [--credentials CREDENTIALS] + [--connector CONNECTOR] [--jwt-secret JWT_SECRET] + [--jwt-method JWT_METHOD] [--jwt-private-key JWT_PRIVATE_KEY] + {actions} ... [model-as-positional-argument]""" + ) + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help + + +def test_run_action_help( + run: Callable[..., RunResult], +): + output = run("run", "actions", "--help") + + if sys.version_info.minor >= 9: + # This is required because `argparse` behaves differently on + # Python 3.9 and above. The difference is the changed formatting of help + # output for CLI arguments with `nargs="*" + help_text = f"""usage: {RASA_EXE} run actions [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] [-p PORT] + [--cors [CORS ...]] [--actions ACTIONS]""" + else: + help_text = f"""usage: {RASA_EXE} run actions [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] [-p PORT] + [--cors [CORS [CORS ...]]] [--actions ACTIONS]""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help diff --git a/tests/cli/test_rasa_shell.py b/tests/cli/test_rasa_shell.py new file mode 100644 index 0000000..e20224b --- /dev/null +++ b/tests/cli/test_rasa_shell.py @@ -0,0 +1,96 @@ +import os +import sys +from pathlib import Path +from typing import Callable + +import pytest +from _pytest.pytester import RunResult + +from rasa.shared.constants import ASSISTANT_ID_KEY +from tests.cli.conftest import RASA_EXE, create_simple_project_with_missing_assistant_id + + +def test_shell_help(run: Callable[..., RunResult]): + output = run("shell", "--help") + + if sys.version_info.minor >= 9: + # This is required because `argparse` behaves differently on + # Python 3.9 and above. The difference is the changed formatting of help + # output for CLI arguments with `nargs="*" + version_dependent = """[-i INTERFACE] [-p PORT] [-t AUTH_TOKEN] [--cors [CORS ...]] + [--enable-api] [--response-timeout RESPONSE_TIMEOUT]""" # noqa: E501 + else: + version_dependent = """[-i INTERFACE] [-p PORT] [-t AUTH_TOKEN] + [--cors [CORS [CORS ...]]] [--enable-api] + [--response-timeout RESPONSE_TIMEOUT]""" + + help_text = ( + f"""usage: {RASA_EXE} shell [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] + [--conversation-id CONVERSATION_ID] [-m MODEL] + [--log-file LOG_FILE] [--use-syslog] + [--syslog-address SYSLOG_ADDRESS] + [--syslog-port SYSLOG_PORT] + [--syslog-protocol SYSLOG_PROTOCOL] [--endpoints ENDPOINTS] + """ + + version_dependent + + """ + [--remote-storage REMOTE_STORAGE] + [--ssl-certificate SSL_CERTIFICATE] + [--ssl-keyfile SSL_KEYFILE] [--ssl-ca-file SSL_CA_FILE] + [--ssl-password SSL_PASSWORD] [--credentials CREDENTIALS] + [--connector CONNECTOR] [--jwt-secret JWT_SECRET] + [--jwt-method JWT_METHOD] + {nlu} ... [model-as-positional-argument]""" + ) + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help + + +def test_shell_nlu_help(run: Callable[..., RunResult]): + output = run("shell", "nlu", "--help") + + help_text = f"""usage: {RASA_EXE} shell nlu [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] [-m MODEL] + [model-as-positional-argument]""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help + + +# FIXME: this test passes locally but fails in the CI with timeout > 300s +@pytest.mark.skip_on_ci +async def test_shell_without_assistant_id_issues_warning( + tmp_path: Path, trained_async: Callable, run: Callable[..., RunResult] +): + os.environ["LOG_LEVEL"] = "ERROR" + + create_simple_project_with_missing_assistant_id(tmp_path) + + domain_path = tmp_path / "domain.yml" + stories_path = tmp_path / "data" / "stories.yml" + nlu_path = tmp_path / "data" / "nlu.yml" + config_path = tmp_path / "config.yml" + + warning_message = ( + f"The model metadata does not contain a value for the '{ASSISTANT_ID_KEY}' " + f"attribute. Check that 'config.yml' file contains a value for " + f"the '{ASSISTANT_ID_KEY}' key and re-train the model." + ) + + model_path = await trained_async( + domain=domain_path, config=config_path, training_files=[stories_path, nlu_path] + ) + + output = run("shell", "--model", str(model_path)) + + printed_output = {line.strip() for line in output.outlines} + + assert any([warning_message in line for line in printed_output]) diff --git a/tests/cli/test_rasa_test.py b/tests/cli/test_rasa_test.py new file mode 100644 index 0000000..b75eedb --- /dev/null +++ b/tests/cli/test_rasa_test.py @@ -0,0 +1,325 @@ +import os +import shutil +from pathlib import Path +from shutil import copyfile + +from pytest import Testdir, Pytester, ExitCode +from _pytest.pytester import RunResult + +from rasa.core.constants import ( + CONFUSION_MATRIX_STORIES_FILE, + STORIES_WITH_WARNINGS_FILE, +) +from rasa.constants import RESULTS_FILE +from rasa.shared.constants import DEFAULT_RESULTS_PATH +from rasa.shared.utils.io import list_files, write_yaml, write_text_file +from typing import Callable + +from tests.cli.conftest import RASA_EXE + + +def test_test_core(run_in_simple_project: Callable[..., RunResult]): + run_in_simple_project("test", "core", "--stories", "data") + + assert os.path.exists("results") + + +def test_test_core_no_plot(run_in_simple_project: Callable[..., RunResult]): + run_in_simple_project("test", "core", "--no-plot") + + assert not os.path.exists(f"results/{CONFUSION_MATRIX_STORIES_FILE}") + + +def test_test_core_warnings(run_in_simple_project_with_model: Callable[..., RunResult]): + write_yaml( + { + "language": "en", + "pipeline": [], + "policies": [ + {"name": "MemoizationPolicy", "max_history": 3}, + {"name": "UnexpecTEDIntentPolicy", "max_history": 5, "epochs": 1}, + { + "name": "TEDPolicy", + "max_history": 5, + "epochs": 1, + "constrain_similarities": True, + }, + {"name": "RulePolicy"}, + ], + }, + "config.yml", + ) + + simple_test_story_yaml = """ +version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +stories: +- story: unlikely path + steps: + - user: | + very terrible + intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy +""" + with open("tests/test_stories.yml", "w") as f: + f.write(simple_test_story_yaml) + + run_in_simple_project_with_model("test", "core", "--no-warnings") + assert not os.path.exists(f"results/{STORIES_WITH_WARNINGS_FILE}") + + run_in_simple_project_with_model("test", "core") + assert os.path.exists(f"results/{STORIES_WITH_WARNINGS_FILE}") + + +def test_test_core_with_no_model(run_in_simple_project: Callable[..., RunResult]): + assert not os.path.exists("models") + + output = run_in_simple_project("test", "core") + + assert ( + "No model provided. Please make sure to specify the model to test with" + in output.outlines[7] + ) + + +def test_test(run_in_simple_project_with_model: Callable[..., RunResult]): + write_yaml( + { + "pipeline": "KeywordIntentClassifier", + "policies": [{"name": "MemoizationPolicy"}], + }, + "config2.yml", + ) + + run_in_simple_project_with_model("test") + + assert os.path.exists("results") + assert os.path.exists("results/intent_histogram.png") + assert os.path.exists("results/intent_confusion_matrix.png") + + +def test_test_with_no_user_utterance( + run_in_simple_project_with_model: Callable[..., RunResult] +): + write_yaml( + {"pipeline": "KeywordIntentClassifier", "policies": [{"name": "TEDPolicy"}]}, + "config.yml", + ) + + simple_test_story_yaml = """ +stories: +- story: happy path 1 + steps: + - intent: greet + - action: utter_greet + - intent: mood_great + - action: utter_happy +""" + with open("tests/test_story_no_utterance.yaml", "w") as f: + f.write(simple_test_story_yaml) + + run_in_simple_project_with_model("test", "--fail-on-prediction-errors") + assert os.path.exists("results") + assert not os.path.exists("results/failed_test_stories.yml") + + +def test_test_no_plot(run_in_simple_project: Callable[..., RunResult]): + run_in_simple_project("test", "--no-plot") + + assert not os.path.exists("results/intent_histogram.png") + assert not os.path.exists("results/intent_confusion_matrix.png") + assert not os.path.exists("results/story_confmat.pdf") + + +def test_test_nlu(run_in_simple_project_with_model: Callable[..., RunResult]): + run_in_simple_project_with_model("test", "nlu", "--nlu", "data", "--successes") + + assert os.path.exists("results/intent_histogram.png") + assert os.path.exists("results/intent_confusion_matrix.png") + assert os.path.exists("results/intent_successes.json") + + +def test_test_nlu_no_plot(run_in_simple_project: Callable[..., RunResult]): + run_in_simple_project("test", "nlu", "--no-plot") + + assert not os.path.exists("results/intent_histogram.png") + assert not os.path.exists("results/intent_confusion_matrix.png") + + +def test_test_nlu_cross_validation(run_in_simple_project: Callable[..., RunResult]): + run_in_simple_project( + "test", "nlu", "--cross-validation", "-c", "config.yml", "-f", "2", "-r", "1" + ) + + assert os.path.exists("results/intent_histogram.png") + assert os.path.exists("results/intent_confusion_matrix.png") + + +def test_test_nlu_cross_validation_with_autoconfig( + testdir: Testdir, moodbot_nlu_data_path: Path +): + os.environ["LOG_LEVEL"] = "ERROR" + config_path = str(testdir.tmpdir / "config.yml") + nlu_path = str(testdir.tmpdir / "nlu.yml") + shutil.copy(str(moodbot_nlu_data_path), nlu_path) + write_yaml( + { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": None, + "policies": None, + }, + config_path, + ) + args = [ + shutil.which(RASA_EXE), + "test", + "nlu", + "--cross-validation", + "-c", + "config.yml", + "--nlu", + "nlu.yml", + ] + + # we don't wanna run the cross validation for real, just want to see that it does + # not crash + try: + run_result = testdir.run(*args, timeout=8.0) + # we'll only get here if the run fails due to an exception + assert run_result.ret != ExitCode.TESTS_FAILED + except Pytester.TimeoutExpired: + pass + + +def test_test_nlu_comparison(run_in_simple_project: Callable[..., RunResult]): + write_yaml({"pipeline": "KeywordIntentClassifier"}, "config.yml") + write_yaml({"pipeline": "KeywordIntentClassifier"}, "config2.yml") + + # TODO: Loading still needs fixing + run_in_simple_project( + "test", + "nlu", + "--config", + "config.yml", + "config2.yml", + "--run", + "2", + "--percentages", + "75", + "25", + ) + + assert os.path.exists("results/run_1") + assert os.path.exists("results/run_2") + + +def test_test_core_comparison( + run_in_simple_project_with_model: Callable[..., RunResult] +): + files = list_files("models") + copyfile(files[0], "models/copy-model.tar.gz") + + run_in_simple_project_with_model( + "test", + "core", + "-m", + files[0], + "models/copy-model.tar.gz", + "--stories", + "data/stories.yml", + ) + + assert os.path.exists(os.path.join(DEFAULT_RESULTS_PATH, RESULTS_FILE)) + + +def test_test_core_comparison_after_train( + run_in_simple_project: Callable[..., RunResult], + trained_rasa_model: str, + tmp_path: Path, +): + path = Path(tmp_path / "comparison_models") + path.mkdir() + + run_one = Path(path / "run_1") + run_one.mkdir() + shutil.copy(trained_rasa_model, run_one) + + run_two = Path(path / "run_2") + run_two.mkdir() + shutil.copy(trained_rasa_model, run_two) + + write_text_file("[1]", path / "num_stories.json") + + run_in_simple_project( + "test", + "core", + "-m", + str(path), + "--stories", + "data/stories", + "--evaluate-model-directory", + ) + + assert os.path.exists(os.path.join(DEFAULT_RESULTS_PATH, RESULTS_FILE)) + assert os.path.exists( + os.path.join(DEFAULT_RESULTS_PATH, "core_model_comparison_graph.pdf") + ) + + +def test_test_help(run: Callable[..., RunResult]): + output = run("test", "--help") + + help_text = f"""usage: {RASA_EXE} test [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] [-m MODEL] + [-s STORIES] [--max-stories MAX_STORIES] + [--endpoints ENDPOINTS] [--fail-on-prediction-errors] + [--url URL] [--evaluate-model-directory] [-u NLU] + [-c CONFIG [CONFIG ...]] [-d DOMAIN] [--cross-validation] + [-f FOLDS] [-r RUNS] [-p PERCENTAGES [PERCENTAGES ...]] + [--no-plot] [--successes] [--no-errors] [--no-warnings] + [--out OUT] + {{core,nlu}} ...""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help + + +def test_test_nlu_help(run: Callable[..., RunResult]): + output = run("test", "nlu", "--help") + + help_text = f"""usage: {RASA_EXE} test nlu [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] [-m MODEL] + [-u NLU] [--out OUT] [-c CONFIG [CONFIG ...]] [-d DOMAIN] + [--cross-validation] [-f FOLDS] [-r RUNS] + [-p PERCENTAGES [PERCENTAGES ...]] [--no-plot] + [--successes] [--no-errors] [--no-warnings]""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help + + +def test_test_core_help(run: Callable[..., RunResult]): + output = run("test", "core", "--help") + + help_text = f"""usage: {RASA_EXE} test core [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] + [-m MODEL [MODEL ...]] [-s STORIES] + [--max-stories MAX_STORIES] [--out OUT] [--e2e] + [--endpoints ENDPOINTS] [--fail-on-prediction-errors] + [--url URL] [--evaluate-model-directory] [--no-plot] + [--successes] [--no-errors] [--no-warnings]""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help diff --git a/tests/cli/test_rasa_train.py b/tests/cli/test_rasa_train.py new file mode 100644 index 0000000..1436f6f --- /dev/null +++ b/tests/cli/test_rasa_train.py @@ -0,0 +1,627 @@ +import os +import sys +from pathlib import Path + +from _pytest.capture import CaptureFixture +import pytest +from typing import Callable, List +from _pytest.pytester import RunResult +from _pytest.tmpdir import TempPathFactory + +import rasa.shared.utils.io +from rasa.constants import NUMBER_OF_TRAINING_STORIES_FILE +from rasa.core.policies.policy import Policy +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.engine.storage.resource import Resource +from rasa.shared.core.domain import Domain +from rasa.model_training import CODE_NEEDS_TO_BE_RETRAINED, CODE_FORCED_TRAINING + +from rasa.shared.constants import ( + LATEST_TRAINING_DATA_FORMAT_VERSION, +) +from rasa.shared.nlu.training_data.training_data import ( + DEFAULT_TRAINING_DATA_OUTPUT_PATH, +) +import rasa.utils.io +from tests.cli.conftest import RASA_EXE + + +@pytest.mark.parametrize( + "optional_arguments", + [ + ["--endpoints", "endpoints.yml"], + ["--endpoints", "non_existent_endpoints.yml"], + [], + ], +) +def test_train( + run_in_simple_project: Callable[..., RunResult], + tmp_path: Path, + optional_arguments: List, +): + temp_dir = os.getcwd() + + run_in_simple_project( + "train", + "-c", + "config.yml", + "-d", + "domain.yml", + "--data", + "data", + "--out", + "train_models", + "--fixed-model-name", + "test-model", + *optional_arguments, + ) + + models_dir = Path(temp_dir, "train_models") + assert models_dir.is_dir() + + models = list(models_dir.glob("*")) + assert len(models) == 1 + + model = models[0] + assert model.name == "test-model.tar.gz" + + _, metadata = LocalModelStorage.from_model_archive(tmp_path, model) + assert metadata.model_id + assert ( + metadata.domain.as_dict() == Domain.load(Path(temp_dir, "domain.yml")).as_dict() + ) + + +def test_train_finetune( + run_in_simple_project: Callable[..., RunResult], capsys: CaptureFixture +): + run_in_simple_project("train", "--finetune") + + output = capsys.readouterr().out + assert "No model for finetuning found" in output + + +def test_train_persist_nlu_data( + run_in_simple_project: Callable[..., RunResult], tmp_path: Path +): + temp_dir = os.getcwd() + + run_in_simple_project( + "train", + "-c", + "config.yml", + "-d", + "domain.yml", + "--data", + "data", + "--out", + "train_models", + "--fixed-model-name", + "test-model", + "--persist-nlu-data", + ) + + models_dir = Path(temp_dir, "train_models") + assert models_dir.is_dir() + + models = list(models_dir.glob("*")) + assert len(models) == 1 + + model = models[0] + assert model.name == "test-model.tar.gz" + + storage, _ = LocalModelStorage.from_model_archive(tmp_path, model) + + with storage.read_from(Resource("nlu_training_data_provider")) as directory: + assert (directory / DEFAULT_TRAINING_DATA_OUTPUT_PATH).exists() + + +def test_train_no_domain_exists( + run_in_simple_project: Callable[..., RunResult], tmp_path: Path +) -> None: + + os.remove("domain.yml") + run_in_simple_project( + "train", + "--skip-validation", + "-c", + "config.yml", + "--data", + "data", + "--out", + "train_models_no_domain", + "--fixed-model-name", + "nlu-model-only", + ) + + model_file = Path("train_models_no_domain", "nlu-model-only.tar.gz") + assert model_file.is_file() + + _, metadata = LocalModelStorage.from_model_archive(tmp_path, model_file) + + assert not any( + issubclass(component.uses, Policy) + for component in metadata.train_schema.nodes.values() + ) + assert not any( + issubclass(component.uses, Policy) + for component in metadata.predict_schema.nodes.values() + ) + + +def test_train_skip_on_model_not_changed( + run_in_simple_project_with_model: Callable[..., RunResult], + tmp_path_factory: TempPathFactory, +): + temp_dir = os.getcwd() + + models_dir = Path(temp_dir, "models") + model_files = list(models_dir.glob("*")) + assert len(model_files) == 1 + old_model = model_files[0] + + run_in_simple_project_with_model("train") + + model_files = list(sorted(models_dir.glob("*"))) + assert len(model_files) == 2 + + new_model = model_files[1] + assert old_model != new_model + + old_dir = tmp_path_factory.mktemp("old") + _, old_metadata = LocalModelStorage.from_model_archive(old_dir, old_model) + + new_dir = tmp_path_factory.mktemp("new") + _, new_metadata = LocalModelStorage.from_model_archive(new_dir, new_model) + + assert old_metadata.model_id != new_metadata.model_id + assert old_metadata.trained_at < new_metadata.trained_at + assert old_metadata.domain.as_dict() == new_metadata.domain.as_dict() + + assert rasa.utils.io.are_directories_equal(old_dir, new_dir) + + +def test_train_force( + run_in_simple_project_with_model: Callable[..., RunResult], + tmp_path_factory: TempPathFactory, +): + temp_dir = os.getcwd() + + assert os.path.exists(os.path.join(temp_dir, "models")) + files = rasa.shared.utils.io.list_files(os.path.join(temp_dir, "models")) + assert len(files) == 1 + + run_in_simple_project_with_model("train", "--force") + + assert os.path.exists(os.path.join(temp_dir, "models")) + files = rasa.shared.utils.io.list_files(os.path.join(temp_dir, "models")) + assert len(files) == 2 + + old_dir = tmp_path_factory.mktemp("old") + _ = LocalModelStorage.from_model_archive(old_dir, files[0]) + + new_dir = tmp_path_factory.mktemp("new") + _ = LocalModelStorage.from_model_archive(new_dir, files[1]) + + assert not rasa.utils.io.are_directories_equal(old_dir, new_dir) + + +def test_train_dry_run(run_in_simple_project_with_model: Callable[..., RunResult]): + temp_dir = os.getcwd() + + assert os.path.exists(os.path.join(temp_dir, "models")) + files = rasa.shared.utils.io.list_files(os.path.join(temp_dir, "models")) + assert len(files) == 1 + + output = run_in_simple_project_with_model("train", "--dry-run") + + assert [s for s in output.outlines if "No training of components required" in s] + assert output.ret == 0 + + +def test_train_dry_run_failure(run_in_simple_project: Callable[..., RunResult]): + temp_dir = os.getcwd() + + domain = ( + "version: '" + LATEST_TRAINING_DATA_FORMAT_VERSION + "'\n" + "session_config:\n" + " session_expiration_time: 60\n" + " carry_over_slots_to_new_session: true\n" + "actions:\n" + "- utter_greet\n" + "- utter_cheer_up" + ) + + with open(os.path.join(temp_dir, "domain.yml"), "w") as f: + f.write(domain) + + output = run_in_simple_project("train", "--dry-run") + + assert not any([s for s in output.outlines if "No training required." in s]) + assert (output.ret & CODE_NEEDS_TO_BE_RETRAINED == CODE_NEEDS_TO_BE_RETRAINED) and ( + output.ret & CODE_FORCED_TRAINING != CODE_FORCED_TRAINING + ) + + +def test_train_dry_run_force( + run_in_simple_project_with_model: Callable[..., RunResult] +): + temp_dir = os.getcwd() + + assert os.path.exists(os.path.join(temp_dir, "models")) + files = rasa.shared.utils.io.list_files(os.path.join(temp_dir, "models")) + assert len(files) == 1 + + output = run_in_simple_project_with_model("train", "--dry-run", "--force") + + assert [s for s in output.outlines if "The training was forced." in s] + assert output.ret == CODE_FORCED_TRAINING + + +def test_train_with_only_nlu_data(run_in_simple_project: Callable[..., RunResult]): + temp_dir = Path.cwd() + + for core_file in ["stories.yml", "rules.yml"]: + assert (temp_dir / "data" / core_file).exists() + (temp_dir / "data" / core_file).unlink() + + run_in_simple_project("train", "--fixed-model-name", "test-model") + + assert os.path.exists(os.path.join(temp_dir, "models")) + files = rasa.shared.utils.io.list_files(os.path.join(temp_dir, "models")) + assert len(files) == 1 + assert os.path.basename(files[0]) == "test-model.tar.gz" + + +def test_train_with_only_core_data(run_in_simple_project: Callable[..., RunResult]): + temp_dir = os.getcwd() + + assert os.path.exists(os.path.join(temp_dir, "data/nlu.yml")) + os.remove(os.path.join(temp_dir, "data/nlu.yml")) + + run_in_simple_project("train", "--fixed-model-name", "test-model") + + assert os.path.exists(os.path.join(temp_dir, "models")) + files = rasa.shared.utils.io.list_files(os.path.join(temp_dir, "models")) + assert len(files) == 1 + assert os.path.basename(files[0]) == "test-model.tar.gz" + + +def test_train_core(run_in_simple_project: Callable[..., RunResult]): + run_in_simple_project( + "train", + "core", + "-c", + "config.yml", + "-d", + "domain.yml", + "--stories", + "data", + "--out", + "train_rasa_models", + "--fixed-model-name", + "rasa-model", + ) + + assert os.path.exists("train_rasa_models/rasa-model.tar.gz") + assert os.path.isfile("train_rasa_models/rasa-model.tar.gz") + + +def test_train_core_no_domain_exists(run_in_simple_project: Callable[..., RunResult]): + os.remove("domain.yml") + run_in_simple_project( + "train", + "core", + "--config", + "config.yml", + "--domain", + "domain1.yml", + "--stories", + "data", + "--out", + "train_rasa_models_no_domain", + "--fixed-model-name", + "rasa-model", + ) + + assert not list(Path("train_rasa_models_no_domain").glob("*")) + + +def test_train_core_compare( + run_in_simple_project: Callable[..., RunResult], tmp_path: Path +): + run_in_simple_project( + "train", + "core", + "-c", + "config.yml", + "config.yml", + "-d", + "domain.yml", + "--stories", + "data", + "--out", + str(tmp_path), + "--runs", + "2", + "--percentages", + "50", + "100", + ) + + for run in range(1, 2): + assert (tmp_path / f"run_{run}" / "config__percentage__50.tar.gz").exists() + assert (tmp_path / f"run_{run}" / "config__percentage__100.tar.gz").exists() + + num_stories = rasa.shared.utils.io.read_yaml_file( + tmp_path / NUMBER_OF_TRAINING_STORIES_FILE + ) + assert num_stories == [3, 0] + + +def test_train_nlu(run_in_simple_project: Callable[..., RunResult], tmp_path: Path): + run_in_simple_project( + "train", + "nlu", + "-c", + "config.yml", + "--nlu", + "data/nlu.yml", + "--out", + "train_models", + ) + + model_dir = Path("train_models") + assert model_dir.is_dir() + + models = list(model_dir.glob("*.tar.gz")) + assert len(models) == 1 + + model_file = models[0] + assert model_file.name.startswith("nlu-") + + _, metadata = LocalModelStorage.from_model_archive(tmp_path, model_file) + + assert not any( + issubclass(component.uses, Policy) + for component in metadata.train_schema.nodes.values() + ) + assert not any( + issubclass(component.uses, Policy) + for component in metadata.predict_schema.nodes.values() + ) + + +def test_train_nlu_persist_nlu_data( + run_in_simple_project: Callable[..., RunResult], tmp_path: Path +) -> None: + run_in_simple_project( + "train", + "nlu", + "-c", + "config.yml", + "--nlu", + "data/nlu.yml", + "--out", + "train_models", + "--persist-nlu-data", + ) + + models_dir = Path("train_models") + assert models_dir.is_dir() + + models = list(models_dir.glob("*")) + assert len(models) == 1 + + model = models[0] + assert model.name.startswith("nlu-") + + storage, _ = LocalModelStorage.from_model_archive(tmp_path, model) + + with storage.read_from(Resource("nlu_training_data_provider")) as directory: + assert (directory / DEFAULT_TRAINING_DATA_OUTPUT_PATH).exists() + + +def test_train_help(run: Callable[..., RunResult]): + output = run("train", "--help") + + help_text = f"""usage: {RASA_EXE} train [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] + [--data DATA [DATA ...]] [-c CONFIG] [-d DOMAIN] [--out OUT] + [--dry-run] [--skip-validation] + [--fail-on-validation-warnings] + [--validation-max-history VALIDATION_MAX_HISTORY] + [--augmentation AUGMENTATION] [--debug-plots] + [--num-threads NUM_THREADS] + [--fixed-model-name FIXED_MODEL_NAME] [--persist-nlu-data] + [--force] [--finetune [FINETUNE]] + [--epoch-fraction EPOCH_FRACTION] [--endpoints ENDPOINTS] + {{core,nlu}} ...""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help + + +def test_train_nlu_help(run: Callable[..., RunResult]): + output = run("train", "nlu", "--help") + + help_text = f"""usage: {RASA_EXE} train nlu [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] [-c CONFIG] + [-d DOMAIN] [--out OUT] [-u NLU] + [--num-threads NUM_THREADS] + [--fixed-model-name FIXED_MODEL_NAME] + [--persist-nlu-data] [--finetune [FINETUNE]] + [--epoch-fraction EPOCH_FRACTION]""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help + + +def test_train_core_help(run: Callable[..., RunResult]): + output = run("train", "core", "--help") + + if sys.version_info.minor >= 9: + # This is required because `argparse` behaves differently on + # Python 3.9 and above. The difference is the changed formatting of help + # output for CLI arguments with `nargs="*" + help_text = f"""usage: {RASA_EXE} train core [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] + [-s STORIES] [-d DOMAIN] [-c CONFIG [CONFIG ...]] + [--out OUT] [--augmentation AUGMENTATION] + [--debug-plots] [--force] + [--fixed-model-name FIXED_MODEL_NAME] + [--percentages [PERCENTAGES ...]] [--runs RUNS] + [--finetune [FINETUNE]] + [--epoch-fraction EPOCH_FRACTION]""" + else: + help_text = f"""usage: {RASA_EXE} train core [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] + [-s STORIES] [-d DOMAIN] [-c CONFIG [CONFIG ...]] + [--out OUT] [--augmentation AUGMENTATION] + [--debug-plots] [--force] + [--fixed-model-name FIXED_MODEL_NAME] + [--percentages [PERCENTAGES [PERCENTAGES ...]]] + [--runs RUNS] [--finetune [FINETUNE]] + [--epoch-fraction EPOCH_FRACTION]""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help + + +def test_train_nlu_finetune_with_model( + run_in_simple_project_with_model: Callable[..., RunResult] +): + temp_dir = os.getcwd() + + files = rasa.shared.utils.io.list_files(os.path.join(temp_dir, "models")) + assert len(files) == 1 + + model_name = os.path.relpath(files[0]) + output = run_in_simple_project_with_model("train", "--finetune", model_name) + assert any( + "Your Rasa model is trained and saved at" in line for line in output.outlines + ) + + +def test_train_validation_warnings( + run_in_simple_project: Callable[..., RunResult], request: pytest.FixtureRequest +): + test_data_dir = Path(request.config.rootdir, "data", "test_validation", "data") + test_domain = Path(request.config.rootdir, "data", "test_validation", "domain.yml") + + result = run_in_simple_project( + "train", + "--data", + str(test_data_dir), + "--domain", + str(test_domain), + "-c", + "config.yml", + ) + + assert result.ret == 0 + for warning in [ + "The intent 'goodbye' is not used in any story or rule.", + "The utterance 'utter_chatter' is not used in any story or rule.", + ]: + assert warning in str(result.stderr) + + +def test_train_validation_fail_on_warnings( + run_in_simple_project_with_warnings: Callable[..., RunResult], + request: pytest.FixtureRequest, +): + test_data_dir = Path(request.config.rootdir, "data", "test_moodbot", "data") + test_domain = Path(request.config.rootdir, "data", "test_domains", "default.yml") + + result = run_in_simple_project_with_warnings( + "train", + "--fail-on-validation-warnings", + "--data", + str(test_data_dir), + "--domain", + str(test_domain), + "-c", + "config.yml", + ) + + assert "Project validation completed with errors." in str(result.outlines) + assert result.ret == 1 + + +def test_train_validation_fail_to_load_domain( + run_in_simple_project: Callable[..., RunResult], +): + result = run_in_simple_project( + "train", + "--domain", + "not_existing_domain.yml", + ) + + assert "Encountered empty domain during validation." in str(result.outlines) + assert result.ret == 1 + + +def test_train_validation_max_history_1( + run_in_simple_project_with_warnings: Callable[..., RunResult], + request: pytest.FixtureRequest, +): + test_data_dir = Path( + request.config.rootdir, + "data", + "test_yaml_stories", + "stories_conflicting_at_1.yml", + ) + test_domain = Path(request.config.rootdir, "data", "test_domains", "default.yml") + + result = run_in_simple_project_with_warnings( + "train", + "--validation-max-history", + "1", + "--data", + str(test_data_dir), + "--domain", + str(test_domain), + "-c", + "config.yml", + ) + + assert "Story structure conflict" in str(result.errlines) + assert result.ret == 0 + + +def test_train_validation_max_history_2( + run_in_simple_project_with_warnings: Callable[..., RunResult], + request: pytest.FixtureRequest, +): + test_data_dir = Path( + request.config.rootdir, + "data", + "test_yaml_stories", + "stories_conflicting_at_1.yml", + ) + test_domain = Path(request.config.rootdir, "data", "test_domains", "default.yml") + + result = run_in_simple_project_with_warnings( + "train", + "--validation-max-history", + "2", + "--data", + str(test_data_dir), + "--domain", + str(test_domain), + "-c", + "config.yml", + ) + + assert "Story structure conflict" not in str(result.errlines) + assert result.ret == 0 diff --git a/tests/cli/test_rasa_visualize.py b/tests/cli/test_rasa_visualize.py new file mode 100644 index 0000000..d910b97 --- /dev/null +++ b/tests/cli/test_rasa_visualize.py @@ -0,0 +1,19 @@ +from typing import Callable +from _pytest.pytester import RunResult + +from tests.cli.conftest import RASA_EXE + + +def test_visualize_help(run: Callable[..., RunResult]): + output = run("visualize", "--help") + + help_text = f"""usage: {RASA_EXE} visualize [-h] [-v] [-vv] [--quiet] + [--logging-config-file LOGGING_CONFIG_FILE] [-d DOMAIN] + [-s STORIES] [--out OUT] [--max-history MAX_HISTORY] + [-u NLU]""" + + lines = help_text.split("\n") + # expected help text lines should appear somewhere in the output + printed_help = {line.strip() for line in output.outlines} + for line in lines: + assert line.strip() in printed_help diff --git a/tests/cli/test_rasa_x.py b/tests/cli/test_rasa_x.py new file mode 100644 index 0000000..8c4084c --- /dev/null +++ b/tests/cli/test_rasa_x.py @@ -0,0 +1,175 @@ +from pathlib import Path +import sys +import argparse + +import pytest +from typing import Callable + +from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import RunResult +from aioresponses import aioresponses + +import rasa.shared.utils.io +from rasa.cli import x +from rasa.utils.endpoints import EndpointConfig +from rasa.core.utils import AvailableEndpoints +import rasa.version + +from tests.cli.conftest import RASA_EXE + + +def test_x_help(run: Callable[..., RunResult]): + output = run("x", "--help") + + if sys.version_info.minor >= 9: + # This is required because `argparse` behaves differently on + # Python 3.9 and above. The difference is the changed formatting of help + # output for CLI arguments with `nargs="*" + version_dependent = [ + "[-i INTERFACE]", + "[-p PORT]", + "[-t AUTH_TOKEN]", + "[--cors [CORS ...]]", + "[--enable-api]", + "[--response-timeout RESPONSE_TIMEOUT]", + ] + else: + version_dependent = [ + "[-i INTERFACE]", + "[-p PORT]", + "[-t AUTH_TOKEN]", + "[--cors [CORS [CORS ...]]]", + "[--enable-api]", + "[--response-timeout RESPONSE_TIMEOUT]", + ] + + help_text = ( + [ + f"{RASA_EXE} x", + "[-h]", + "[-v]", + "[-vv]", + "[--quiet]", + "[-m MODEL]", + "[--no-prompt]", + "[--production]", + "[--config-endpoint CONFIG_ENDPOINT]", + "[--log-file LOG_FILE]", + "[--use-syslog]", + "[--syslog-address SYSLOG_ADDRESS]", + "[--syslog-port SYSLOG_PORT]", + "[--syslog-protocol SYSLOG_PROTOCOL]", + "[--endpoints ENDPOINTS]", + ] + + version_dependent + + [ + "--remote-storage REMOTE_STORAGE]", + "[--ssl-certificate SSL_CERTIFICATE]", + "[--ssl-keyfile SSL_KEYFILE]", + "[--ssl-ca-file SSL_CA_FILE]", + "[--ssl-password SSL_PASSWORD]", + "[--credentials CREDENTIALS]", + "[--connector CONNECTOR]", + "[--jwt-secret JWT_SECRET]", + "[--jwt-method JWT_METHOD]", + ] + ) + + # expected help text lines should appear somewhere in the output + printed_help = " ".join(output.outlines) + for item in help_text: + assert item in printed_help + + +def test_prepare_credentials_for_rasa_x_if_rasa_channel_not_given(tmpdir: Path): + credentials_path = str(tmpdir / "credentials.yml") + + rasa.shared.utils.io.write_yaml({}, credentials_path) + + tmp_credentials = x._prepare_credentials_for_rasa_x( + credentials_path, "http://localhost:5002" + ) + + actual = rasa.shared.utils.io.read_config_file(tmp_credentials) + + assert actual["rasa"]["url"] == "http://localhost:5002" + + +def test_prepare_credentials_if_already_valid(tmpdir: Path): + credentials_path = str(tmpdir / "credentials.yml") + + credentials = { + "rasa": {"url": "my-custom-url"}, + "another-channel": {"url": "some-url"}, + } + rasa.shared.utils.io.write_yaml(credentials, credentials_path) + + x._prepare_credentials_for_rasa_x(credentials_path) + + actual = rasa.shared.utils.io.read_config_file(credentials_path) + + assert actual == credentials + + +def test_reuse_wait_time_between_pulls(): + test_wait_time = 5 + endpoint_config = EndpointConfig( + url="http://localhost:5002/models/default@latest", + wait_time_between_pulls=test_wait_time, + ) + endpoints = AvailableEndpoints(model=endpoint_config) + assert endpoints.model.kwargs["wait_time_between_pulls"] == test_wait_time + + +async def test_pull_runtime_config_from_server(): + config_url = "http://example.com/api/config?token=token" + credentials = "rasa: http://example.com:5002/api" + endpoint_config = """ + event_broker: + url: http://example.com/event_broker + username: some_username + password: PASSWORD + queue: broker_queue + """ + with aioresponses() as mocked: + mocked.get( + config_url, + payload={"credentials": credentials, "endpoints": endpoint_config}, + ) + + endpoints_path, credentials_path = await x._pull_runtime_config_from_server( + config_url, 1, 0 + ) + + assert rasa.shared.utils.io.read_file(endpoints_path) == endpoint_config + assert rasa.shared.utils.io.read_file(credentials_path) == credentials + + +def test_rasa_x_raises_warning_and_exits_without_production_flag(): + + args = argparse.Namespace(loglevel=None, log_file=None, production=None) + with pytest.raises(SystemExit): + with pytest.warns( + UserWarning, + match="Running Rasa X in local mode is no longer supported as Rasa has " + "stopped supporting the Community Edition (free version) of ‘Rasa X’. " + "For more information please see " + "https://rasa.com/blog/rasa-x-community-edition-changes/", + ): + x.rasa_x(args) + + +def test_rasa_x_does_not_raise_warning_with_production_flag( + monkeypatch: MonkeyPatch, +): + def mock_run_in_enterprise_connection_mode(args): + return None + + monkeypatch.setattr( + x, "run_in_enterprise_connection_mode", mock_run_in_enterprise_connection_mode + ) + + args = argparse.Namespace(loglevel=None, log_file=None, production=True) + + with pytest.warns(None): + x.rasa_x(args) diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py new file mode 100644 index 0000000..153d9fc --- /dev/null +++ b/tests/cli/test_utils.py @@ -0,0 +1,651 @@ +import contextlib +import copy +import re +import argparse +import logging +import io +import os +import pathlib +import sys +import tempfile +from typing import Any, Dict, Text +from pathlib import Path +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.utils.common import EXPECTED_WARNINGS +from ruamel.yaml import YAML + +import pytest +from _pytest.logging import LogCaptureFixture + +import rasa.cli.utils +from rasa.shared.constants import ( + ASSISTANT_ID_DEFAULT_VALUE, + ASSISTANT_ID_KEY, + CONFIG_MANDATORY_KEYS, + CONFIG_MANDATORY_KEYS_CORE, + CONFIG_MANDATORY_KEYS_NLU, + DEFAULT_CONFIG_PATH, + LATEST_TRAINING_DATA_FORMAT_VERSION, +) +import rasa.shared.utils.io +from rasa.utils.common import TempDirectoryPath, get_temp_dir_name +from tests.cli.conftest import RASA_EXE +from tests.conftest import AsyncMock + + +@contextlib.contextmanager +def make_actions_subdir(): + """Create a subdir called actions to test model argument handling.""" + with TempDirectoryPath(get_temp_dir_name()) as tempdir: + cwd = os.getcwd() + os.chdir(tempdir) + try: + (pathlib.Path(tempdir) / "actions").mkdir() + yield + finally: + os.chdir(cwd) + + +@pytest.mark.parametrize( + "argv", + [ + [RASA_EXE, "run"], + [RASA_EXE, "run", "actions"], + [RASA_EXE, "run", "core"], + [RASA_EXE, "interactive", "nlu", "--param", "xy"], + ], +) +def test_parse_last_positional_argument_as_model_path(argv): + with make_actions_subdir(): + test_model_dir = get_temp_dir_name() + argv.append(test_model_dir) + + sys.argv = argv.copy() + rasa.cli.utils.parse_last_positional_argument_as_model_path() + + assert sys.argv[-2] == "--model" + assert sys.argv[-1] == test_model_dir + + +@pytest.mark.parametrize( + "argv", + [ + [RASA_EXE, "run"], + [RASA_EXE, "run", "actions"], + [RASA_EXE, "run", "core"], + [RASA_EXE, "test", "nlu", "--param", "xy", "--model", "test"], + ], +) +def test_parse_no_positional_model_path_argument(argv): + with make_actions_subdir(): + sys.argv = argv.copy() + + rasa.cli.utils.parse_last_positional_argument_as_model_path() + + assert sys.argv == argv + + +def test_validate_invalid_path(): + with pytest.raises(SystemExit): + rasa.cli.utils.get_validated_path("test test test", "out", "default") + + +def test_validate_valid_path(tmp_path: pathlib.Path): + assert rasa.cli.utils.get_validated_path(str(tmp_path), "out", "default") == str( + tmp_path + ) + + +def test_validate_if_none_is_valid(): + assert rasa.cli.utils.get_validated_path(None, "out", "default", True) is None + + +def test_validate_with_none_if_default_is_valid( + caplog: LogCaptureFixture, tmp_path: pathlib.Path +): + with caplog.at_level(logging.WARNING, rasa.cli.utils.logger.name): + assert rasa.cli.utils.get_validated_path(None, "out", str(tmp_path)) == str( + tmp_path + ) + + caplog_records = [ + record for record in caplog.records if "ddtrace.internal" not in record.name + ] + + assert caplog_records == [] + + +def test_validate_with_invalid_directory_if_default_is_valid(tmp_path: pathlib.Path): + invalid_directory = "gcfhvjkb" + with pytest.warns(UserWarning) as record: + assert rasa.cli.utils.get_validated_path( + invalid_directory, "out", str(tmp_path) + ) == str(tmp_path) + assert len(record) == 1 + assert "does not seem to exist" in record[0].message.args[0] + + +@pytest.mark.parametrize( + "parameters", + [ + { + "config_data": { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": "supervised", + }, + "default_config": { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": "supervised", + "policies": ["TEDPolicy", "FallbackPolicy"], + }, + "mandatory_keys": CONFIG_MANDATORY_KEYS_CORE, + }, + { + "config_data": { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": "supervised", + "policies": None, + }, + "default_config": { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": "supervised", + "policies": ["TEDPolicy", "FallbackPolicy"], + }, + "mandatory_keys": CONFIG_MANDATORY_KEYS_CORE, + }, + ], +) +def test_get_validated_config_with_valid_input(parameters: Dict[Text, Any]) -> None: + config_path = os.path.join(tempfile.mkdtemp(), "config.yml") + rasa.shared.utils.io.write_yaml(parameters["config_data"], config_path) + + default_config_path = os.path.join(tempfile.mkdtemp(), "default-config.yml") + rasa.shared.utils.io.write_yaml(parameters["default_config"], default_config_path) + + config_path = rasa.cli.utils.get_validated_config( + config_path, parameters["mandatory_keys"], default_config_path + ) + + config_data = rasa.shared.utils.io.read_yaml_file(config_path) + + for k in parameters["mandatory_keys"]: + assert k in config_data + + +@pytest.mark.parametrize( + "parameters", + [ + { + "default_config": { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": "supervised", + "policies": ["TEDPolicy", "FallbackPolicy"], + }, + "mandatory_keys": CONFIG_MANDATORY_KEYS, + }, + { + "default_config": { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": "supervised", + }, + "mandatory_keys": CONFIG_MANDATORY_KEYS_CORE, + }, + ], +) +def test_get_validated_config_with_default_config(parameters: Dict[Text, Any]) -> None: + config_path = None + + default_config_path = os.path.join(tempfile.mkdtemp(), "default-config.yml") + rasa.shared.utils.io.write_yaml(parameters["default_config"], default_config_path) + + config_path = rasa.cli.utils.get_validated_config( + config_path, parameters["mandatory_keys"], default_config_path + ) + + config_data = rasa.shared.utils.io.read_yaml_file(config_path) + + for k in parameters["mandatory_keys"]: + assert k in config_data + + +@pytest.mark.parametrize( + "parameters", + [ + { + "config_data": { + "assistant_id": "placeholder_default", + }, + "default_config": { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": "supervised", + "policies": ["TEDPolicy", "FallbackPolicy"], + }, + "mandatory_keys": CONFIG_MANDATORY_KEYS, + }, + { + "config_data": { + "assistant_id": "placeholder_default", + "policies": ["TEDPolicy", "FallbackPolicy"], + "imports": "other-folder", + }, + "default_config": { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": "supervised", + "policies": ["TEDPolicy", "FallbackPolicy"], + }, + "mandatory_keys": CONFIG_MANDATORY_KEYS_NLU, + }, + ], +) +def test_get_validated_config_with_invalid_input(parameters: Dict[Text, Any]) -> None: + config_path = os.path.join(tempfile.mkdtemp(), "config.yml") + rasa.shared.utils.io.write_yaml(parameters["config_data"], config_path) + + default_config_path = os.path.join(tempfile.mkdtemp(), "default-config.yml") + rasa.shared.utils.io.write_yaml(parameters["default_config"], default_config_path) + + with pytest.raises(SystemExit): + rasa.cli.utils.get_validated_config( + config_path, parameters["mandatory_keys"], default_config_path + ) + + +@pytest.mark.parametrize( + "parameters", + [ + { + "config_data": None, + "default_config": { + "assistant_id": "placeholder_default", + "pipeline": "supervised", + "policies": ["TEDPolicy", "FallbackPolicy"], + }, + "mandatory_keys": CONFIG_MANDATORY_KEYS_NLU, + }, + ], +) +def test_get_validated_config_with_default_and_no_config( + parameters: Dict[Text, Any] +) -> None: + config_path = None + default_config_content = { + "assistant_id": "placeholder_default", + "pipeline": "supervised", + "policies": ["TEDPolicy", "FallbackPolicy"], + } + mandatory_keys = CONFIG_MANDATORY_KEYS_NLU + + default_config_path = os.path.join(tempfile.mkdtemp(), "default-config.yml") + rasa.shared.utils.io.write_yaml(default_config_content, default_config_path) + + with pytest.raises(SystemExit): + rasa.cli.utils.get_validated_config( + config_path, mandatory_keys, default_config_path + ) + + +def test_get_validated_config_with_no_content() -> None: + config_path = None + default_config_path = os.path.join(tempfile.mkdtemp(), DEFAULT_CONFIG_PATH) + mandatory_keys = CONFIG_MANDATORY_KEYS + + with pytest.raises(SystemExit): + rasa.cli.utils.get_validated_config( + config_path, mandatory_keys, default_config_path + ) + + +def test_validate_config_path_with_non_existing_file(): + with pytest.raises(SystemExit): + rasa.cli.utils.validate_config_path("non-existing-file.yml") + + +@pytest.mark.parametrize( + "config_file", + [ + "data/test_config/config_no_assistant_id.yml", + "data/test_config/config_default_assistant_id_value.yml", + ], +) +def test_validate_assistant_id_in_config(config_file: Text) -> None: + copy_config_data = copy.deepcopy(rasa.shared.utils.io.read_yaml_file(config_file)) + + warning_message = ( + f"The config file '{str(config_file)}' is missing a " + f"unique value for the '{ASSISTANT_ID_KEY}' mandatory key." + ) + with pytest.warns(UserWarning, match=warning_message): + rasa.cli.utils.validate_assistant_id_in_config(config_file) + + config_data = rasa.shared.utils.io.read_yaml_file(config_file) + assistant_name = config_data.get(ASSISTANT_ID_KEY) + + assert assistant_name is not None + assert assistant_name != ASSISTANT_ID_DEFAULT_VALUE + + # reset input files to original state + rasa.shared.utils.io.write_yaml(copy_config_data, config_file, True) + + +def test_data_validate_stories_with_max_history_zero(): + importer = TrainingDataImporter.load_from_config( + "data/test_config/config_defaults.yml", + "data/test_moodbot/domain.yml", + "data/test_moodbot/data", + ) + + with pytest.raises(argparse.ArgumentTypeError): + rasa.cli.utils.validate_files( + fail_on_warnings=False, + max_history=0, + importer=importer, + ) + + +@pytest.mark.parametrize( + ("file_type", "data_type"), [("stories", "story"), ("rules", "rule")] +) +def test_validate_files_action_not_found_invalid_domain( + file_type: Text, data_type: Text, tmp_path: Path +): + file_name = tmp_path / f"{file_type}.yml" + file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + {file_type}: + - {data_type}: test path + steps: + - intent: goodbye + - action: action_test + """ + ) + + importer = TrainingDataImporter.load_from_config( + "data/test_config/config_defaults.yml", + "data/test_moodbot/domain.yml", + [file_name], + ) + + with pytest.raises(SystemExit): + rasa.cli.utils.validate_files( + fail_on_warnings=False, + max_history=None, + importer=importer, + ) + + +@pytest.mark.parametrize( + ("file_type", "data_type"), [("stories", "story"), ("rules", "rule")] +) +def test_validate_files_form_not_found_invalid_domain( + file_type: Text, data_type: Text, tmp_path: Path +): + file_name = tmp_path / f"{file_type}.yml" + file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + {file_type}: + - {data_type}: test path + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + """ + ) + + importer = TrainingDataImporter.load_from_config( + "data/test_config/config_defaults.yml", + "data/test_restaurantbot/domain.yml", + [file_name], + ) + with pytest.raises(SystemExit): + rasa.cli.utils.validate_files( + fail_on_warnings=False, + max_history=None, + importer=importer, + ) + + +@pytest.mark.parametrize( + ("file_type", "data_type"), [("stories", "story"), ("rules", "rule")] +) +def test_validate_files_with_active_loop_null( + file_type: Text, data_type: Text, tmp_path: Path +): + domain_file = ( + "data/test_domains/minimal_domain_validate_files_with_active_loop_null.yml" + ) + nlu_file = "data/test_nlu/test_nlu_validate_files_with_active_loop_null.yml" + file_name = tmp_path / f"{file_type}.yml" + file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + {file_type}: + - {data_type}: test path + steps: + - intent: request_restaurant + - action: restaurant_form + - active_loop: restaurant_form + - active_loop: null + - action: action_search_restaurants + """ + ) + + importer = TrainingDataImporter.load_from_config( + "data/test_config/config_unique_assistant_id.yml", + domain_file, + [file_name, nlu_file], + ) + with pytest.warns() as warning_recorder: + rasa.cli.utils.validate_files( + fail_on_warnings=False, + max_history=None, + importer=importer, + ) + + assert not [ + warning.message + for warning in warning_recorder.list + if not any( + type(warning.message) == warning_type + and re.search(warning_message, str(warning.message)) + for warning_type, warning_message in EXPECTED_WARNINGS + ) + ] + + +def test_validate_files_form_slots_not_matching(tmp_path: Path): + domain_file_name = tmp_path / "domain.yml" + domain_file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + forms: + name_form: + required_slots: + - first_name + - last_name + slots: + first_name: + type: text + mappings: + - type: from_text + last_nam: + type: text + mappings: + - type: from_text + """ + ) + + importer = TrainingDataImporter.load_from_config( + "data/test_config/config_defaults.yml", + domain_file_name, + "data/test_moodbot/data", + ) + with pytest.raises(SystemExit): + rasa.cli.utils.validate_files( + fail_on_warnings=False, + max_history=None, + importer=importer, + ) + + +def test_validate_files_exit_early(): + with pytest.raises(SystemExit) as pytest_e: + importer = TrainingDataImporter.load_from_config( + "data/test_config/config_defaults.yml", + "data/test_domains/duplicate_intents.yml", + "data/test_moodbot/data", + ) + rasa.cli.utils.validate_files( + fail_on_warnings=True, + max_history=None, + importer=importer, + ) + + assert pytest_e.type == SystemExit + assert pytest_e.value.code == 1 + + +def test_validate_files_invalid_domain(): + importer = TrainingDataImporter.load_from_config( + "data/test_config/config_defaults.yml", + "data/test_domains/default_with_mapping.yml", + None, + ) + + with pytest.raises(SystemExit): + rasa.cli.utils.validate_files( + fail_on_warnings=False, + max_history=None, + importer=importer, + ) + with pytest.warns(UserWarning) as w: + assert "Please migrate to RulePolicy." in str(w[0].message) + + +def test_validate_files_invalid_slot_mappings(tmp_path: Path): + domain = tmp_path / "domain.yml" + tested_slot = "duration" + form_name = "booking_form" + # form required_slots does not include the tested_slot + domain.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - state_length_of_time + entities: + - city + slots: + {tested_slot}: + type: text + influence_conversation: false + mappings: + - type: from_text + intent: state_length_of_time + conditions: + - active_loop: {form_name} + location: + type: text + mappings: + - type: from_entity + entity: city + forms: + {form_name}: + required_slots: + - location + """ + ) + importer = TrainingDataImporter.load_from_config( + "data/test_config/config_defaults.yml", str(domain), None + ) + with pytest.raises(SystemExit): + rasa.cli.utils.validate_files( + fail_on_warnings=False, + max_history=None, + importer=importer, + ) + + +def test_validate_files_config_default_assistant_id(): + importer = TrainingDataImporter.load_from_config( + "data/test_config/config_defaults.yml", "data/test_moodbot/domain.yml", None + ) + msg = ( + f"The config file is missing a unique value for the " + f"'{ASSISTANT_ID_KEY}' mandatory key. Please replace the default " + f"placeholder value with a unique identifier." + ) + with pytest.warns(UserWarning, match=msg): + rasa.cli.utils.validate_files( + fail_on_warnings=False, + max_history=None, + importer=importer, + ) + + +def test_validate_files_config_missing_assistant_id(): + importer = TrainingDataImporter.load_from_config( + "data/test_config/config_no_assistant_id.yml", + "data/test_moodbot/domain.yml", + None, + ) + msg = f"The config file is missing the '{ASSISTANT_ID_KEY}' mandatory key." + with pytest.warns(UserWarning, match=msg): + rasa.cli.utils.validate_files( + fail_on_warnings=False, + max_history=None, + importer=importer, + ) + + +def test_validate_assistant_id_in_config_preserves_comment() -> None: + config_file = "data/test_config/config_no_assistant_id_with_comments.yml" + reader_type = ["safe", "rt"] + original_config_data = copy.deepcopy( + rasa.shared.utils.io.read_yaml_file(config_file, reader_type=reader_type) + ) + + # append assistant_id to the config file + rasa.cli.utils.validate_assistant_id_in_config(config_file) + + config_data = rasa.shared.utils.io.read_yaml_file( + config_file, reader_type=reader_type + ) + + assert "assistant_id" in config_data + + # get all content of config file including comments + yaml = YAML() + buffer = io.StringIO() + yaml.dump(config_data, buffer) + config_file_content = buffer.getvalue() + + comment = "# Random comments line {}" + for i in range(1, 6): + assert comment.format(i) in config_file_content + + # reset input files to original state + rasa.shared.utils.io.write_yaml(original_config_data, config_file, True) + + +@pytest.mark.parametrize( + "text_input, button", + [ + ("hi this is test text\n", "hi this is test text"), + ("hi this is test text (/button_one)", "/button_one"), + ("hi this is test text (and something) (/button_one)", "/button_one"), + ], +) +async def test_payload_from_button_question(text_input: str, button: str) -> None: + question = AsyncMock() + question.ask_async.return_value = text_input + result = await rasa.cli.utils.payload_from_button_question(question) + assert result == button diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d8a2255 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,931 @@ +import asyncio +import contextlib +import copy +import os +import random +import re +import textwrap + +import jwt +import pytest +import sys +import uuid + +from pytest import TempdirFactory, MonkeyPatch, Function, TempPathFactory +from spacy import Language +from pytest import WarningsRecorder + +from rasa.engine.caching import LocalTrainingCache +from rasa.engine.graph import ExecutionContext, GraphSchema +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.engine.storage.storage import ModelStorage +from sanic.request import Request + +from typing import Generator, Iterator, Callable + +from pathlib import Path +from sanic import Sanic +from typing import Text, List, Optional, Dict, Any +from unittest.mock import Mock + +from rasa.shared.core.constants import ( + ACTION_LISTEN_NAME, + ACTION_RESTART_NAME, + ACTION_SESSION_START_NAME, +) +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.nlu.constants import METADATA_MODEL_ID +import rasa.shared.utils.io +from rasa import server +from rasa.core.agent import Agent, load_agent +from rasa.core.brokers.broker import EventBroker +from rasa.core.channels import channel, RestInput +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer + +from rasa.nlu.utils.spacy_utils import SpacyNLP, SpacyModel +from rasa.shared.constants import ASSISTANT_ID_KEY, LATEST_TRAINING_DATA_FORMAT_VERSION +from rasa.shared.core.domain import SessionConfig, Domain +from rasa.shared.core.events import ( + ActionExecuted, + Event, + Restarted, + SessionStarted, + UserUttered, +) +from rasa.core.exporter import Exporter + +import rasa.core.run +from rasa.core.tracker_store import InMemoryTrackerStore, TrackerStore +from rasa.model_training import train, train_nlu +from rasa.shared.exceptions import RasaException +import rasa.utils.common +import rasa.utils.io + + +# we reuse a bit of pytest's own testing machinery, this should eventually come +# from a separately installable pytest-cli plugin. +pytest_plugins = ["pytester"] + + +# these tests are run separately +collect_ignore_glob = ["docs/*.py"] + +# Defines how tests are parallelized in the CI +PATH_PYTEST_MARKER_MAPPINGS = { + "category_cli": [Path("tests", "cli").absolute()], + "category_core_featurizers": [Path("tests", "core", "featurizers").absolute()], + "category_policies": [ + Path("tests", "core", "test_policies.py").absolute(), + Path("tests", "core", "policies").absolute(), + ], + "category_nlu_featurizers": [ + Path("tests", "nlu", "featurizers").absolute(), + Path("tests", "nlu", "utils").absolute(), + ], + "category_nlu_predictors": [ + Path("tests", "nlu", "classifiers").absolute(), + Path("tests", "nlu", "extractors").absolute(), + Path("tests", "nlu", "selectors").absolute(), + ], + "category_full_model_training": [ + Path("tests", "test_model_training.py").absolute(), + Path("tests", "nlu", "test_train.py").absolute(), + Path("tests", "core", "test_training.py").absolute(), + Path("tests", "core", "test_examples.py").absolute(), + ], + "category_performance": [Path("tests", "test_memory_leak.py").absolute()], +} + + +@pytest.fixture(scope="session") +def nlu_as_json_path() -> Text: + return "data/examples/rasa/demo-rasa.json" + + +@pytest.fixture(scope="session") +def nlu_data_path() -> Text: + return "data/test_moodbot/data/nlu.yml" + + +@pytest.fixture(scope="session") +def config_path() -> Text: + return "rasa/engine/recipes/config_files/default_config.yml" + + +@pytest.fixture(scope="session") +def default_config(config_path: Text) -> Dict[Text, Any]: + return rasa.shared.utils.io.read_yaml_file(config_path) + + +@pytest.fixture(scope="session") +def domain_with_categorical_slot_path() -> Text: + return "data/test_domains/domain_with_categorical_slot.yml" + + +@pytest.fixture(scope="session") +def domain_with_mapping_path() -> Text: + return "data/test_domains/default_with_mapping.yml" + + +@pytest.fixture(scope="session") +def stories_path() -> Text: + return "data/test_yaml_stories/stories_defaultdomain.yml" + + +@pytest.fixture(scope="session") +def e2e_stories_path() -> Text: + return "data/test_yaml_stories/stories_e2e.yml" + + +@pytest.fixture(scope="session") +def simple_stories_path() -> Text: + return "data/test_yaml_stories/stories_simple.yml" + + +@pytest.fixture(scope="session") +def stack_config_path() -> Text: + return "data/test_config/stack_config.yml" + + +@pytest.fixture(scope="session") +def incorrect_nlu_data_path() -> Text: + return "data/test/incorrect_nlu_format.yml" + + +@pytest.fixture(scope="session") +def end_to_end_story_path() -> Text: + return "data/test_evaluations/test_end_to_end_story.yml" + + +@pytest.fixture(scope="session") +def e2e_story_file_unknown_entity_path() -> Text: + return "data/test_evaluations/test_story_unknown_entity.yml" + + +@pytest.fixture(scope="session") +def domain_path() -> Text: + return "data/test_domains/default_with_slots.yml" + + +@pytest.fixture(scope="session") +def simple_config_path(tmp_path_factory: TempPathFactory) -> Text: + project_path = tmp_path_factory.mktemp(uuid.uuid4().hex) + + config = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + assistant_id: placeholder_default + pipeline: + - name: WhitespaceTokenizer + - name: KeywordIntentClassifier + - name: RegexEntityExtractor + policies: + - name: AugmentedMemoizationPolicy + max_history: 3 + - name: RulePolicy + """ + ) + config_path = project_path / "config.yml" + rasa.shared.utils.io.write_text_file(config, config_path) + + return str(config_path) + + +@pytest.fixture(scope="session") +def story_file_trips_circuit_breaker_path() -> Text: + return "data/test_evaluations/test_stories_trip_circuit_breaker.yml" + + +@pytest.fixture(scope="session") +def e2e_story_file_trips_circuit_breaker_path() -> Text: + return "data/test_evaluations/test_end_to_end_trips_circuit_breaker.yml" + + +@pytest.fixture(scope="session") +def endpoints_path() -> Text: + return "data/test_endpoints/example_endpoints.yml" + + +# https://github.com/pytest-dev/pytest-asyncio/issues/68 +# this event_loop is used by pytest-asyncio, and redefining it +# is currently the only way of changing the scope of this fixture +# update: implement fix to RuntimeError Event loop is closed issue described +# here: https://github.com/pytest-dev/pytest-asyncio/issues/371 +@pytest.fixture(scope="session") +def event_loop(request: Request) -> Iterator[asyncio.AbstractEventLoop]: + loop = asyncio.get_event_loop_policy().new_event_loop() + loop._close = loop.close + loop.close = lambda: None + yield loop + loop._close() + + +# override loop fixture to prevent ScopeMismatch pytest error and +# align the result of the loop fixture with that of the event_loop fixture +@pytest.fixture(scope="session") +def loop( + event_loop: asyncio.AbstractEventLoop, +) -> Generator[asyncio.AbstractEventLoop, None, None]: + yield event_loop + + +@pytest.fixture(scope="session") +async def trained_default_agent_model( + stories_path: Text, + domain_path: Text, + nlu_data_path: Text, + trained_async: Callable, + simple_config_path: Text, +) -> Text: + model_path = await trained_async( + domain_path, simple_config_path, [stories_path, nlu_data_path] + ) + + return model_path + + +@pytest.fixture() +def empty_agent() -> Agent: + agent = Agent(domain=Domain.load("data/test_domains/default_with_slots.yml")) + return agent + + +def reset_conversation_state(agent: Agent) -> Agent: + # Clean tracker store after each test so tests don't affect each other + agent.tracker_store = InMemoryTrackerStore(agent.domain) + agent.domain.session_config = SessionConfig.default() + agent.load_model(agent.processor.model_path) + return agent + + +@pytest.fixture +def default_agent(trained_default_agent_model: Text) -> Agent: + return Agent.load(trained_default_agent_model) + + +@pytest.fixture(scope="session") +async def trained_moodbot_path(trained_async: Callable) -> Text: + return await trained_async( + domain="data/test_moodbot/domain.yml", + config="data/test_moodbot/config.yml", + training_files="data/test_moodbot/data/", + ) + + +@pytest.fixture(scope="session") +async def trained_moodbot_core_path(trained_async: Callable) -> Text: + return await trained_async( + domain="data/test_moodbot/domain.yml", + config="data/test_moodbot/config.yml", + training_files="data/test_moodbot/data/stories.yml", + ) + + +@pytest.fixture(scope="session") +async def trained_moodbot_nlu_path(trained_async: Callable) -> Text: + return await trained_async( + domain="data/test_moodbot/domain.yml", + config="data/test_moodbot/config.yml", + training_files="data/test_moodbot/data/nlu.yml", + ) + + +@pytest.fixture(scope="session") +async def trained_unexpected_intent_policy_path(trained_async: Callable) -> Text: + return await trained_async( + domain="data/test_moodbot/domain.yml", + config="data/test_moodbot/unexpected_intent_policy_config.yml", + training_files="data/test_moodbot/data/", + ) + + +@pytest.fixture(scope="session") +def trained_nlu_moodbot_path(trained_nlu: Callable) -> Text: + return trained_nlu( + domain="data/test_moodbot/domain.yml", + config="data/test_moodbot/config.yml", + nlu_data="data/test_moodbot/data/nlu.yml", + ) + + +@pytest.fixture(scope="session") +async def trained_spacybot_path(trained_async: Callable) -> Text: + return await trained_async( + domain="data/test_spacybot/domain.yml", + config="data/test_spacybot/config.yml", + training_files="data/test_spacybot/data/", + ) + + +@pytest.fixture(scope="session") +async def stack_agent(trained_rasa_model: Text) -> Agent: + return await load_agent(model_path=trained_rasa_model) + + +@pytest.fixture(scope="session") +async def core_agent(trained_core_model: Text) -> Agent: + return await load_agent(model_path=trained_core_model) + + +@pytest.fixture(scope="session") +async def nlu_agent(trained_nlu_model: Text) -> Agent: + return await load_agent(model_path=trained_nlu_model) + + +@pytest.fixture(scope="module") +async def unexpected_intent_policy_agent( + trained_unexpected_intent_policy_path: Text, +) -> Agent: + return await load_agent(model_path=trained_unexpected_intent_policy_path) + + +@pytest.fixture(scope="module") +async def mood_agent(trained_moodbot_path: Text) -> Agent: + return await load_agent(model_path=trained_moodbot_path) + + +@pytest.fixture(scope="session") +def _domain(domain_path: Text) -> Domain: + return Domain.load(domain_path) + + +@pytest.fixture() +def domain(_domain: Domain) -> Domain: + return copy.deepcopy(_domain) + + +@pytest.fixture(scope="session") +def trained_async(tmp_path_factory: TempPathFactory) -> Callable: + async def _train( + *args: Any, + output_path: Optional[Text] = None, + cache_dir: Optional[Path] = None, + **kwargs: Any, + ) -> Optional[Text]: + if not cache_dir: + cache_dir = tmp_path_factory.mktemp("cache") + + if output_path is None: + output_path = str(tmp_path_factory.mktemp("models")) + + with enable_cache(cache_dir): + result = train(*args, output=output_path, **kwargs) + + return result.model + + return _train + + +@pytest.fixture(scope="session") +def trained_nlu(tmp_path_factory: TempPathFactory) -> Callable: + def _train_nlu( + *args: Any, output_path: Optional[Text] = None, **kwargs: Any + ) -> Optional[Text]: + if output_path is None: + output_path = str(tmp_path_factory.mktemp("models")) + + return train_nlu(*args, output=output_path, **kwargs) + + return _train_nlu + + +@pytest.fixture(scope="session") +async def trained_rasa_model( + trained_async: Callable, + domain_path: Text, + nlu_data_path: Text, + stories_path: Text, + stack_config_path: Text, +) -> Text: + trained_stack_model_path = await trained_async( + domain=domain_path, + config=stack_config_path, + training_files=[nlu_data_path, stories_path], + ) + + return trained_stack_model_path + + +@pytest.fixture(scope="session") +async def trained_core_model( + trained_async: Callable, + domain_path: Text, + stack_config_path: Text, + stories_path: Text, +) -> Text: + trained_core_model_path = await trained_async( + domain=domain_path, config=stack_config_path, training_files=[stories_path] + ) + + return trained_core_model_path + + +@pytest.fixture(scope="session") +async def trained_nlu_model( + trained_async: Callable, + domain_path: Text, + nlu_data_path: Text, + stack_config_path: Text, +) -> Text: + trained_nlu_model_path = await trained_async( + domain=domain_path, config=stack_config_path, training_files=[nlu_data_path] + ) + + return trained_nlu_model_path + + +@pytest.fixture(scope="session") +def _trained_e2e_model_cache(tmp_path_factory: TempPathFactory) -> Path: + return tmp_path_factory.mktemp("cache") + + +@pytest.fixture() +def trained_e2e_model_cache( + _trained_e2e_model_cache: Path, + tmp_path_factory: TempPathFactory, + monkeypatch: MonkeyPatch, +) -> Path: + copied_cache = tmp_path_factory.mktemp("copy") + rasa.utils.common.copy_directory(_trained_e2e_model_cache, copied_cache) + + with enable_cache(copied_cache): + yield copied_cache + + +@pytest.fixture(scope="session") +async def trained_e2e_model( + trained_async: Callable, + moodbot_domain_path: Text, + e2e_bot_config_file: Path, + nlu_data_path: Text, + e2e_stories_path: Text, + _trained_e2e_model_cache: Path, +) -> Text: + return await trained_async( + domain=moodbot_domain_path, + config=str(e2e_bot_config_file), + training_files=[nlu_data_path, e2e_stories_path], + cache_dir=_trained_e2e_model_cache, + ) + + +@pytest.fixture(scope="session") +def moodbot_domain_path() -> Path: + return Path("data", "test_moodbot", "domain.yml") + + +@pytest.fixture(scope="session") +def moodbot_domain(moodbot_domain_path: Path) -> Domain: + return Domain.load(moodbot_domain_path) + + +@pytest.fixture(scope="session") +def moodbot_nlu_data_path() -> Path: + return Path(os.getcwd()) / "data" / "test_moodbot" / "data" / "nlu.yml" + + +@pytest.fixture +def rasa_server(stack_agent: Agent) -> Sanic: + app = server.create_app(agent=stack_agent) + channel.register([RestInput()], app, "/webhooks/") + return app + + +@pytest.fixture +def rasa_non_trained_server(empty_agent: Agent) -> Sanic: + app = server.create_app(agent=empty_agent) + channel.register([RestInput()], app, "/webhooks/") + return app + + +@pytest.fixture +def rasa_core_server(core_agent: Agent) -> Sanic: + app = server.create_app(agent=core_agent) + channel.register([RestInput()], app, "/webhooks/") + return app + + +@pytest.fixture +def rasa_nlu_server(nlu_agent: Agent) -> Sanic: + app = server.create_app(agent=nlu_agent) + channel.register([RestInput()], app, "/webhooks/") + return app + + +@pytest.fixture +def rasa_server_secured(default_agent: Agent) -> Sanic: + app = server.create_app(agent=default_agent, auth_token="rasa", jwt_secret="core") + channel.register([RestInput()], app, "/webhooks/") + return app + + +@pytest.fixture +def test_public_key() -> Text: + test_public_key = """-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC34ht9inqGq79HecpyOAnu2Cgv +jvgcpFifpFLPmCNdiomAgE48tfUAXJRoOGlVtrqc8KgQWjTFLjqDjUh1sBFF69Fl +wQGt7pgH10ZbERWpMTAbpjI9EoH74gDcmZ6Fy1VgQPbAwty3liw5Q5zqZLj7JhuX +Sa0EqvZQP+Hnayab7QIDAQAB +-----END PUBLIC KEY-----""" + + return test_public_key + + +@pytest.fixture +def test_private_key() -> Text: + test_private_key = """-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQC34ht9inqGq79HecpyOAnu2CgvjvgcpFifpFLPmCNdiomAgE48 +tfUAXJRoOGlVtrqc8KgQWjTFLjqDjUh1sBFF69FlwQGt7pgH10ZbERWpMTAbpjI9 +EoH74gDcmZ6Fy1VgQPbAwty3liw5Q5zqZLj7JhuXSa0EqvZQP+Hnayab7QIDAQAB +AoGBAIfUE25mjh9QWljX0/0O+/db4ENRHmE53OT/otQJk4YTQYKURDaASdvchxt9 +IAHamno3Ik4B9Bz7CuoFwNJ+HiMBf32KwJ75n/NZL17lBKst71z3r0gYCz6jcJxv +brbNs8qsLFyRMQz6NvS4d4GnXpGhc54IoJqtr/vR+Q87UwtZAkEA3AG78E7Fd5zT +sU/BO9E0VisQOysGcwPd9+rQPSyF8ncvaiMJ7STNvVsgrtJuw4DJq2RsMSJ77QgS +Ku6BJxB58wJBANX3dOEiNEZLJR+4LdNYRoR4gx2LcJW5PthwLi8ZOHBZeh9q3f2i +r5X5iPJ5kBRqajtYm634f/j8P4fxSdWzKp8CQQCNimQR92udR3z+HxRvWml0YmIf +3s9YYY2FeUEdii5mznznqMEzGzFt+Fmvf1yZVJrqNEJS3h+iYEXn7ueSbUw3AkBm +xSK4d+tP0AwWvioUlxPX0OJ5MF51K7LJ1qf4K072d6O2r2fMyXU4vdBPVqAjjjFU +K+0qlG8zMkV5kCV8pT/VAkA8bM5KRa73JY0bfGX4i8UZMFHzIq2KGjHlRES4vd+L +h18+hpcBAAyUR/jDT8nnG5YaYFz8rf2DnOy+elmmaYVm +-----END RSA PRIVATE KEY-----""" + + return test_private_key + + +@pytest.fixture +def asymmetric_jwt_method() -> Text: + return "RS256" + + +@pytest.fixture +def rasa_server_secured_asymmetric( + default_agent: Agent, + test_public_key: Text, + test_private_key: Text, + asymmetric_jwt_method: Text, +) -> Sanic: + app = server.create_app( + agent=default_agent, + auth_token="rasa", + jwt_secret=test_public_key, + jwt_private_key=test_private_key, + jwt_method=asymmetric_jwt_method, + ) + channel.register([RestInput()], app, "/webhooks/") + return app + + +@pytest.fixture +def encoded_jwt(test_private_key: Text, asymmetric_jwt_method: Text) -> Text: + payload = {"user": {"username": "myuser", "role": "admin"}} + encoded_jwt = jwt.encode( + payload=payload, + key=test_private_key, + algorithm=asymmetric_jwt_method, + ) + return encoded_jwt + + +@pytest.fixture +def rasa_non_trained_server_secured(empty_agent: Agent) -> Sanic: + app = server.create_app(agent=empty_agent, auth_token="rasa", jwt_secret="core") + channel.register([RestInput()], app, "/webhooks/") + return app + + +@pytest.fixture +def rasa_server_without_api() -> Sanic: + app = rasa.core.run._create_app_without_api() + channel.register([RestInput()], app, "/webhooks/") + return app + + +@pytest.fixture(scope="session") +def project() -> Text: + import tempfile + from rasa.cli.scaffold import create_initial_project + + directory = tempfile.mkdtemp() + create_initial_project(directory) + + return directory + + +@pytest.fixture(scope="session") +def spacy_nlp_component() -> SpacyNLP: + return SpacyNLP.create({"model": "en_core_web_md"}, Mock(), Mock(), Mock()) + + +@pytest.fixture(scope="session") +def spacy_case_sensitive_nlp_component() -> SpacyNLP: + return SpacyNLP.create( + {"model": "en_core_web_md", "case_sensitive": True}, Mock(), Mock(), Mock() + ) + + +@pytest.fixture(scope="session") +def spacy_model(spacy_nlp_component: SpacyNLP) -> SpacyModel: + return spacy_nlp_component.provide() + + +@pytest.fixture(scope="session") +def spacy_nlp(spacy_model: SpacyModel) -> Language: + return spacy_model.model + + +@pytest.fixture(scope="session") +async def response_selector_test_stories() -> Path: + return Path("data/test_response_selector_bot/tests/test_stories.yml") + + +@pytest.fixture(scope="session") +async def trained_response_selector_bot(trained_async: Callable) -> Path: + zipped_model = await trained_async( + domain="data/test_response_selector_bot/domain.yml", + config="data/test_response_selector_bot/config.yml", + training_files=[ + "data/test_response_selector_bot/data/rules.yml", + "data/test_response_selector_bot/data/nlu.yml", + ], + ) + + if not zipped_model: + raise RasaException("Model training for responseselectorbot failed.") + + return Path(zipped_model) + + +@pytest.fixture(scope="session") +def e2e_bot_domain_file() -> Path: + return Path("data/test_e2ebot/domain.yml") + + +@pytest.fixture(scope="session") +def e2e_bot_config_file() -> Path: + return Path("data/test_e2ebot/config.yml") + + +@pytest.fixture(scope="session") +def e2e_bot_training_files() -> List[Path]: + return [ + Path("data/test_e2ebot/data/stories.yml"), + Path("data/test_e2ebot/data/nlu.yml"), + ] + + +@pytest.fixture(scope="session") +def e2e_bot_test_stories_with_unknown_bot_utterances() -> Path: + return Path("data/test_e2ebot/tests/test_stories_with_unknown_bot_utterances.yml") + + +# FIXME: This fixture is very slow, do not use it without fixing that first +@pytest.fixture(scope="session") +async def e2e_bot( + trained_async: Callable, + e2e_bot_domain_file: Path, + e2e_bot_config_file: Path, + e2e_bot_training_files: List[Path], +) -> Path: + zipped_model = await trained_async( + domain=e2e_bot_domain_file, + config=e2e_bot_config_file, + training_files=e2e_bot_training_files, + ) + + if not zipped_model: + raise RasaException("Model training for e2ebot failed.") + + return Path(zipped_model) + + +@pytest.fixture(scope="module") +async def response_selector_agent(trained_response_selector_bot: Path) -> Agent: + return await load_agent(str(trained_response_selector_bot)) + + +@pytest.fixture(scope="module") +async def e2e_bot_agent(e2e_bot: Path) -> Agent: + return await load_agent(str(e2e_bot)) + + +def write_endpoint_config_to_yaml( + path: Path, data: Dict[Text, Any], endpoints_filename: Text = "endpoints.yml" +) -> Path: + endpoints_path = path / endpoints_filename + + # write endpoints config to file + rasa.shared.utils.io.write_yaml(data, endpoints_path) + return endpoints_path + + +def random_user_uttered_event(timestamp: Optional[float] = None) -> UserUttered: + return UserUttered( + uuid.uuid4().hex, + timestamp=timestamp if timestamp is not None else random.random(), + ) + + +def pytest_runtest_setup(item: Function) -> None: + if ( + "skip_on_windows" in [mark.name for mark in item.iter_markers()] + and sys.platform == "win32" + ): + pytest.skip("cannot run on Windows") + if "skip_on_ci" in [mark.name for mark in item.iter_markers()] and os.environ.get( + "CI" + ) in ["true", "True", "yes", "t", "1"]: + pytest.skip("cannot run on CI") + + +class MockExporter(Exporter): + """Mocked `Exporter` class.""" + + def __init__( + self, + tracker_store: TrackerStore = Mock(), + event_broker: EventBroker = Mock(), + endpoints_path: Text = "", + ) -> None: + super().__init__(tracker_store, event_broker, endpoints_path) + + +class AsyncMock(Mock): + """Helper class to mock async functions and methods.""" + + async def __call__(self, *args: Any, **kwargs: Any) -> Any: + return super().__call__(*args, **kwargs) + + +def _get_marker_for_ci_matrix(item: Function) -> Text: + """Returns pytest marker which is used to parallelize the tests in GitHub actions. + + Args: + item: The test case. + + Returns: + A marker for this test based on which directory / python module the test is in. + """ + test_path = Path(item.fspath).absolute() + + matching_markers = [ + marker + for marker, paths_for_marker in PATH_PYTEST_MARKER_MAPPINGS.items() + if any( + path == test_path or path in test_path.parents for path in paths_for_marker + ) + ] + + if not matching_markers: + return "category_other_unit_tests" + if len(matching_markers) > 1: + raise ValueError( + f"Each test should only be in one category. Test '{item.name}' is assigned " + f"to these categories: {matching_markers}. Please fix the " + "mapping in `PATH_PYTEST_MARKER_MAPPINGS`." + ) + + return matching_markers[0] + + +def pytest_collection_modifyitems(items: List[Function]) -> None: + """Adds pytest markers dynamically when the tests are run. + + This is automatically called by pytest during its execution. + + Args: + items: Tests to be run. + """ + for item in items: + marker = _get_marker_for_ci_matrix(item) + item.add_marker(marker) + + +def create_test_file_with_size(directory: Path, size_in_mb: float) -> Path: + file_path = directory / uuid.uuid4().hex + with open(file_path, mode="wb") as f: + f.seek(int(1024 * 1024 * size_in_mb)) + f.write(b"\0") + + return file_path + + +@pytest.fixture() +def default_model_storage(tmp_path: Path, monkeypatch: MonkeyPatch) -> ModelStorage: + return LocalModelStorage.create(tmp_path) + + +@pytest.fixture() +def default_execution_context() -> ExecutionContext: + return ExecutionContext(GraphSchema({}), uuid.uuid4().hex) + + +@pytest.fixture(scope="session", autouse=True) +def temp_cache_for_fixtures(tmp_path_factory: TempPathFactory) -> None: + # This fixture makes sure that wide fixtures which don't have `function` scope + # (session, package, module) don't use the global + # cache. If you want to use the cache in a session scoped fixture, then please + # consider using the `enable_cache` context manager. + LocalTrainingCache._get_cache_location = lambda: tmp_path_factory.mktemp( + f"cache-{uuid.uuid4()}" + ) + + # We can omit reverting the monkeypatch as this fixture is torn down after all the + # tests ran + + +@pytest.fixture(autouse=True) +def use_temp_dir_for_cache( + monkeypatch: MonkeyPatch, tmp_path_factory: TempdirFactory +) -> None: + # This fixture makes sure that a single test function has a constant cache + # cache. + cache_dir = tmp_path_factory.mktemp(uuid.uuid4().hex) + monkeypatch.setattr(LocalTrainingCache, "_get_cache_location", lambda: cache_dir) + + +@contextlib.contextmanager +def enable_cache(cache_dir: Path): + old_get_cache_location = LocalTrainingCache._get_cache_location + LocalTrainingCache._get_cache_location = Mock(return_value=cache_dir) + + yield + + LocalTrainingCache._get_cache_location = old_get_cache_location + + +@pytest.fixture() +def whitespace_tokenizer() -> WhitespaceTokenizer: + return WhitespaceTokenizer(WhitespaceTokenizer.get_default_config()) + + +def with_model_ids(events: List[Event], model_id: Text) -> List[Event]: + return [with_model_id(event, model_id) for event in events] + + +def with_model_id(event: Event, model_id: Text) -> Event: + new_event = copy.deepcopy(event) + new_event.metadata[METADATA_MODEL_ID] = model_id + return new_event + + +def with_assistant_id(event: Event, assistant_id: Text) -> Event: + event.metadata[ASSISTANT_ID_KEY] = assistant_id + return event + + +def with_assistant_ids(events: List[Event], assistant_id: Text) -> List[Event]: + return [with_assistant_id(event, assistant_id) for event in events] + + +@pytest.fixture(autouse=True) +def sanic_test_mode(monkeypatch: MonkeyPatch): + monkeypatch.setattr(Sanic, "test_mode", True) + + +def filter_expected_warnings(records: WarningsRecorder) -> WarningsRecorder: + records_copy = copy.deepcopy(records.list) + + for warning_type, warning_message in rasa.utils.common.EXPECTED_WARNINGS: + for record in records_copy: + if type(record.message) == warning_type and re.search( + warning_message, str(record.message) + ): + records.pop(type(record.message)) + + return records + + +@pytest.fixture +def initial_events_including_restart() -> List[Event]: + return [ + ActionExecuted(ACTION_SESSION_START_NAME, timestamp=1), + SessionStarted(timestamp=2), + ActionExecuted(ACTION_LISTEN_NAME, timestamp=3), + UserUttered("hi", timestamp=4), + ActionExecuted("utter_greet", timestamp=5), + ActionExecuted(ACTION_LISTEN_NAME, timestamp=6), + UserUttered("/restart", timestamp=7), + Restarted(timestamp=8), + ActionExecuted(ACTION_RESTART_NAME, timestamp=9), + ] + + +@pytest.fixture +def events_after_restart() -> List[Event]: + return [ + ActionExecuted(ACTION_SESSION_START_NAME, timestamp=10), + SessionStarted(timestamp=11), + ActionExecuted(ACTION_LISTEN_NAME, timestamp=12), + UserUttered("Let's start again.", timestamp=13), + ] + + +@pytest.fixture +def tracker_with_restarted_event( + initial_events_including_restart: List[Event], + events_after_restart: List[Event], +) -> DialogueStateTracker: + sender_id = uuid.uuid4().hex + events = initial_events_including_restart + events_after_restart + + return DialogueStateTracker.from_events(sender_id=sender_id, evts=events) diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/actions/__init__.py b/tests/core/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/actions/test_forms.py b/tests/core/actions/test_forms.py new file mode 100644 index 0000000..d26ed92 --- /dev/null +++ b/tests/core/actions/test_forms.py @@ -0,0 +1,2147 @@ +import logging +import textwrap +from typing import Dict, Text, List, Any, Union +from unittest.mock import Mock + +import pytest +from _pytest.logging import LogCaptureFixture +from _pytest.monkeypatch import MonkeyPatch +from aioresponses import aioresponses + +from rasa.core.agent import Agent +from rasa.core.policies.policy import PolicyPrediction +from rasa.core.actions import action +from rasa.core.actions.action import ActionExecutionRejection, ActionExtractSlots +from rasa.shared.constants import ( + LATEST_TRAINING_DATA_FORMAT_VERSION, + REQUIRED_SLOTS_KEY, + IGNORED_INTENTS, +) +from rasa.shared.core.constants import ACTION_LISTEN_NAME, REQUESTED_SLOT +from rasa.core.actions.forms import FormAction +from rasa.core.channels import CollectingOutputChannel +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + ActiveLoop, + SlotSet, + UserUttered, + ActionExecuted, + BotUttered, + Restarted, + Event, + ActionExecutionRejected, + DefinePrevUserUtteredFeaturization, +) +from rasa.core.nlg import TemplatedNaturalLanguageGenerator +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.utils.endpoints import EndpointConfig + +ACTION_SERVER_URL = "http://my-action-server:5055/webhook" + + +async def test_activate(): + tracker = DialogueStateTracker.from_events(sender_id="bla", evts=[]) + form_name = "my form" + action = FormAction(form_name, None) + slot_name = "num_people" + domain = textwrap.dedent( + f""" + slots: + {slot_name}: + type: float + mappings: + - type: from_entity + entity: number + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {slot_name} + responses: + utter_ask_num_people: + - text: "How many people?" + """ + ) + domain = Domain.from_yaml(domain) + + events = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert isinstance(events[-1], BotUttered) + assert events[:-1] == [ActiveLoop(form_name), SlotSet(REQUESTED_SLOT, slot_name)] + + +async def test_activate_with_custom_slot_mapping(): + tracker = DialogueStateTracker.from_events(sender_id="bla", evts=[]) + form_name = "my_form" + action_server = EndpointConfig(ACTION_SERVER_URL) + action = FormAction(form_name, action_server) + domain_required_slot_name = "num_people" + slot_set_by_remote_custom_extraction_method = "some_slot" + slot_value_set_by_remote_custom_extraction_method = "anything" + domain = textwrap.dedent( + f""" + slots: + {domain_required_slot_name}: + type: float + mappings: + - type: from_entity + entity: number + {slot_set_by_remote_custom_extraction_method}: + type: any + mappings: + - type: custom + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {domain_required_slot_name} + responses: + utter_ask_num_people: + - text: "How many people?" + actions: + - validate_{form_name} + """ + ) + domain = Domain.from_yaml(domain) + + form_validation_events = [ + { + "event": "slot", + "timestamp": None, + "name": slot_set_by_remote_custom_extraction_method, + "value": slot_value_set_by_remote_custom_extraction_method, + } + ] + with aioresponses() as mocked: + mocked.post( + ACTION_SERVER_URL, + payload={"events": form_validation_events}, + ) + events = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events[:-1] == [ + ActiveLoop(form_name), + SlotSet( + slot_set_by_remote_custom_extraction_method, + slot_value_set_by_remote_custom_extraction_method, + ), + SlotSet(REQUESTED_SLOT, domain_required_slot_name), + ] + assert isinstance(events[-1], BotUttered) + + +async def test_activate_with_mapping_conditions_slot(): + tracker = DialogueStateTracker.from_events(sender_id="bla", evts=[]) + form_name = "my form" + action = FormAction(form_name, None) + slot_name = "num_people" + domain = textwrap.dedent( + f""" + slots: + {slot_name}: + type: float + mappings: + - type: from_entity + entity: number + conditions: + - active_loop: {form_name} + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {slot_name} + responses: + utter_ask_num_people: + - text: "How many people?" + """ + ) + domain = Domain.from_yaml(domain) + + events = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events[:-1] == [ActiveLoop(form_name), SlotSet(REQUESTED_SLOT, slot_name)] + assert isinstance(events[-1], BotUttered) + + +async def test_activate_with_prefilled_slot(): + slot_name = "num_people" + slot_value = 5 + + tracker = DialogueStateTracker.from_events( + sender_id="bla", evts=[SlotSet(slot_name, slot_value)] + ) + form_name = "my_form" + action = FormAction(form_name, None) + + next_slot_to_request = "next slot to request" + domain = f""" + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {slot_name} + - {next_slot_to_request} + slots: + {slot_name}: + type: any + mappings: + - type: from_entity + entity: {slot_name} + {next_slot_to_request}: + type: text + mappings: + - type: from_text + """ + domain = Domain.from_yaml(domain) + events = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == [ + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, next_slot_to_request), + ] + + +async def test_activate_with_prefilled_slot_with_mapping_conditions(): + slot_name = "num_people" + slot_value = 5 + + tracker = DialogueStateTracker.from_events( + sender_id="bla", evts=[SlotSet(slot_name, slot_value)] + ) + form_name = "my form" + action = FormAction(form_name, None) + + next_slot_to_request = "next slot to request" + domain = f""" + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {slot_name} + - {next_slot_to_request} + slots: + {slot_name}: + type: any + mappings: + - type: from_entity + entity: {slot_name} + conditions: + - active_loop: {form_name} + {next_slot_to_request}: + type: text + mappings: + - type: from_text + conditions: + - active_loop: {form_name} + """ + domain = Domain.from_yaml(domain) + events = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == [ + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, next_slot_to_request), + ] + + +async def test_switch_forms_with_same_slot(default_agent: Agent): + """Tests switching of forms, where the first slot is the same in both forms. + + Tests the fix for issue 7710""" + + # Define two forms in the domain, with same first slot + slot_a = "my_slot_a" + + form_1 = "my_form_1" + utter_ask_form_1 = f"Please provide the value for {slot_a} of form 1" + + form_2 = "my_form_2" + utter_ask_form_2 = f"Please provide the value for {slot_a} of form 2" + + domain = f""" +version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +nlu: +- intent: order_status + examples: | + - check status of my order + - when are my shoes coming in +- intent: return + examples: | + - start a return + - I don't want my shoes anymore +slots: + {slot_a}: + type: float + mappings: + - type: from_entity + entity: number +forms: + {form_1}: + {REQUIRED_SLOTS_KEY}: + - {slot_a} + {form_2}: + {REQUIRED_SLOTS_KEY}: + - {slot_a} +responses: + utter_ask_{form_1}_{slot_a}: + - text: {utter_ask_form_1} + utter_ask_{form_2}_{slot_a}: + - text: {utter_ask_form_2} +""" + + domain = Domain.from_yaml(domain) + + # Driving it like rasa/core/processor + processor = default_agent.processor + processor.domain = domain + + # activate the first form + tracker = DialogueStateTracker.from_events( + "some-sender", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("order status", {"name": "form_1", "confidence": 1.0}), + DefinePrevUserUtteredFeaturization(False), + ], + ) + # rasa/core/processor.predict_next_action + prediction = PolicyPrediction([], "some_policy") + action_1 = FormAction(form_1, None) + + await processor._run_action( + action_1, + tracker, + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + prediction, + ) + + events_expected = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("order status", {"name": "form_1", "confidence": 1.0}), + DefinePrevUserUtteredFeaturization(False), + ActionExecuted(form_1), + ActiveLoop(form_1), + SlotSet(REQUESTED_SLOT, slot_a), + BotUttered( + text=utter_ask_form_1, + metadata={"utter_action": f"utter_ask_{form_1}_{slot_a}"}, + ), + ] + assert tracker.applied_events() == events_expected + + next_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("return my shoes", {"name": "form_2", "confidence": 1.0}), + DefinePrevUserUtteredFeaturization(False), + ] + tracker.update_with_events(next_events, domain) + events_expected.extend(next_events) + + # form_1 is still active, and bot will first validate if the user utterance + # provides valid data for the requested slot, which is rejected + await processor._run_action( + action_1, + tracker, + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + prediction, + ) + events_expected.extend([ActionExecutionRejected(action_name=form_1)]) + assert tracker.applied_events() == events_expected + + # Next, bot predicts form_2 + action_2 = FormAction(form_2, None) + await processor._run_action( + action_2, + tracker, + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + prediction, + ) + events_expected.extend( + [ + ActionExecuted(form_2), + ActiveLoop(form_2), + SlotSet(REQUESTED_SLOT, slot_a), + BotUttered( + text=utter_ask_form_2, + metadata={"utter_action": f"utter_ask_{form_2}_{slot_a}"}, + ), + ] + ) + assert tracker.applied_events() == events_expected + + +async def test_activate_and_immediate_deactivate(): + slot_name = "num_people" + slot_value = 5 + + tracker = DialogueStateTracker.from_events( + sender_id="bla", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + "haha", + {"name": "greet"}, + entities=[{"entity": slot_name, "value": slot_value}], + ), + SlotSet(slot_name, slot_value), + ], + ) + form_name = "my form" + action = FormAction(form_name, None) + domain = f""" + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {slot_name} + slots: + {slot_name}: + type: any + mappings: + - type: from_entity + entity: {slot_name} + """ + domain = Domain.from_yaml(domain) + events = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == [ + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ] + + +async def test_set_slot_and_deactivate(): + form_name = "my form" + slot_name = "num_people" + slot_value = "dasdasdfasdf" + events = [ + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, slot_name), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(slot_value), + SlotSet(slot_name, slot_value), + ] + tracker = DialogueStateTracker.from_events(sender_id="bla", evts=events) + + domain = f""" + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {slot_name} + slots: + {slot_name}: + type: text + influence_conversation: false + mappings: + - type: from_text + """ + domain = Domain.from_yaml(domain) + + form_action = FormAction(form_name, None) + events = await form_action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == [SlotSet(REQUESTED_SLOT, None), ActiveLoop(None)] + + +async def test_action_rejection(): + form_name = "my form" + slot_to_fill = "some slot" + tracker = DialogueStateTracker.from_events( + sender_id="bla", + evts=[ + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, slot_to_fill), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": "greet"}), + ], + ) + form_name = "my form" + action = FormAction(form_name, None) + domain = f""" + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {slot_to_fill} + slots: + {slot_to_fill}: + type: any + mappings: + - type: from_entity + entity: some_entity + """ + domain = Domain.from_yaml(domain) + + with pytest.raises(ActionExecutionRejection): + await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + +@pytest.mark.parametrize( + "validate_return_events, expected_events", + [ + # Validate function returns SlotSet events for every slot to fill + ( + [ + {"event": "slot", "name": "num_people", "value": "so_clean"}, + {"event": "slot", "name": "num_tables", "value": 5}, + ], + [ + SlotSet("num_people", "so_clean"), + SlotSet("num_tables", 5), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ], + ), + # Validate function returns extra Slot Event + ( + [ + {"event": "slot", "name": "num_people", "value": "so_clean"}, + {"event": "slot", "name": "some_other_slot", "value": 2}, + ], + [ + SlotSet("num_people", "so_clean"), + SlotSet("some_other_slot", 2), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ], + ), + # Validate function only validates one of the candidates + ( + [{"event": "slot", "name": "num_people", "value": "so_clean"}], + [ + SlotSet("num_people", "so_clean"), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ], + ), + # Validate function says slot is invalid + ( + [{"event": "slot", "name": "num_people", "value": None}], + [SlotSet("num_people", None), SlotSet(REQUESTED_SLOT, "num_people")], + ), + # Validate function decides to request a slot which is not part of the default + # slot mapping + ( + [{"event": "slot", "name": "requested_slot", "value": "is_outside"}], + [SlotSet(REQUESTED_SLOT, "is_outside")], + ), + # Validate function decides that no more slots should be requested + ( + [ + {"event": "slot", "name": "num_people", "value": None}, + {"event": "slot", "name": REQUESTED_SLOT, "value": None}, + ], + [ + SlotSet("num_people", None), + SlotSet(REQUESTED_SLOT, None), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ], + ), + # Validate function deactivates loop + ( + [ + {"event": "slot", "name": "num_people", "value": None}, + {"event": "active_loop", "name": None}, + ], + [ + SlotSet("num_people", None), + ActiveLoop(None), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ], + ), + # User rejected manually + ( + [{"event": "action_execution_rejected", "name": "my form"}], + [ActionExecutionRejected("my form")], + ), + ], +) +async def test_validate_slots( + validate_return_events: List[Dict], expected_events: List[Event] +): + form_name = "my form" + slot_name = "num_people" + slot_value = "hi" + events = [ + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, slot_name), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(slot_value, entities=[{"entity": "num_tables", "value": 5}]), + ] + tracker = DialogueStateTracker.from_events(sender_id="bla", evts=events) + + domain = f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + entities: + - num_tables + - some_entity + - some_other_entity + - some_slot + + slots: + {slot_name}: + type: any + mappings: + - type: from_text + num_tables: + type: any + mappings: + - type: from_entity + entity: num_tables + + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {slot_name} + - num_tables + + actions: + - validate_{form_name} + """ + domain = Domain.from_yaml(textwrap.dedent(domain)) + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert slot_events == [SlotSet(slot_name, slot_value), SlotSet("num_tables", 5)] + tracker.update_with_events(slot_events, domain) + + with aioresponses() as mocked: + mocked.post(ACTION_SERVER_URL, payload={"events": validate_return_events}) + + action_server = EndpointConfig(ACTION_SERVER_URL) + action = FormAction(form_name, action_server) + + events = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == expected_events + + +async def test_request_correct_slots_after_unhappy_path_with_custom_required_slots(): + form_name = "some_form" + slot_name_1 = "slot_1" + slot_name_2 = "slot_2" + + domain = f""" + slots: + {slot_name_1}: + type: any + mappings: + - type: from_intent + intent: some_intent + value: some_value + {slot_name_2}: + type: any + mappings: + - type: from_intent + intent: some_intent + value: some_value + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {slot_name_1} + - {slot_name_2} + actions: + - validate_{form_name} + """ + domain = Domain.from_yaml(domain) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "slot_2"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("hello", intent={"name": "greet", "confidence": 1.0}), + ActionExecutionRejected(form_name), + ActionExecuted("utter_greet"), + ], + ) + + # Custom form validation action changes the order of the requested slots + validate_return_events = [ + {"event": "slot", "name": REQUESTED_SLOT, "value": slot_name_2} + ] + + # The form should ask the same slot again when coming back after unhappy path + expected_events = [SlotSet(REQUESTED_SLOT, slot_name_2)] + + with aioresponses() as mocked: + mocked.post(ACTION_SERVER_URL, payload={"events": validate_return_events}) + + action_server = EndpointConfig(ACTION_SERVER_URL) + action = FormAction(form_name, action_server) + + events = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == expected_events + + +@pytest.mark.parametrize( + "custom_events", + [ + # Custom action returned no events + [], + # Custom action returned events but no `SlotSet` events + [BotUttered("some text").as_dict()], + # Custom action returned only `SlotSet` event for `required_slot` + [SlotSet(REQUESTED_SLOT, "some value").as_dict()], + ], +) +async def test_no_slots_extracted_with_custom_slot_mappings(custom_events: List[Event]): + form_name = "my form" + events = [ + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "num_tables"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("off topic"), + ] + tracker = DialogueStateTracker.from_events(sender_id="bla", evts=events) + + domain = f""" + slots: + num_tables: + type: any + mappings: + - type: from_entity + entity: num_tables + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - num_tables + actions: + - validate_{form_name} + """ + domain = Domain.from_yaml(domain) + + with aioresponses() as mocked: + mocked.post(ACTION_SERVER_URL, payload={"events": custom_events}) + + action_server = EndpointConfig(ACTION_SERVER_URL) + action = FormAction(form_name, action_server) + + with pytest.raises(ActionExecutionRejection): + await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + +async def test_validate_slots_on_activation_with_other_action_after_user_utterance(): + form_name = "my form" + slot_name = "num_people" + slot_value = "hi" + events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(slot_value, entities=[{"entity": "num_tables", "value": 5}]), + ActionExecuted("action_in_between"), + ] + tracker = DialogueStateTracker.from_events(sender_id="bla", evts=events) + + domain = f""" + slots: + {slot_name}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {slot_name} + actions: + - validate_{form_name} + """ + domain = Domain.from_yaml(domain) + expected_slot_value = "✅" + with aioresponses() as mocked: + mocked.post( + ACTION_SERVER_URL, + payload={ + "events": [ + {"event": "slot", "name": slot_name, "value": expected_slot_value} + ] + }, + ) + + action_server = EndpointConfig(ACTION_SERVER_URL) + action_extract_slots = ActionExtractSlots(action_endpoint=None) + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + tracker.update_with_events(slot_events, domain) + + form_action = FormAction(form_name, action_server) + + mocked.post( + ACTION_SERVER_URL, + payload={"events": []}, + ) + events = await form_action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert events == [ + ActiveLoop(form_name), + SlotSet(slot_name, expected_slot_value), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ] + + +@pytest.mark.parametrize( + "utterance_name", ["utter_ask_my_form_num_people", "utter_ask_num_people"] +) +def test_name_of_utterance(utterance_name: Text): + form_name = "my_form" + slot_name = "num_people" + + domain = f""" + slots: + {slot_name}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {slot_name} + responses: + {utterance_name}: + - text: "How many people?" + """ + domain = Domain.from_yaml(domain) + + action = FormAction(form_name, None) + + assert action._name_of_utterance(domain, slot_name) == utterance_name + + +def test_temporary_tracker(): + extra_slot = "some_slot" + sender_id = "test" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + slots: + {extra_slot}: + type: any + mappings: + - type: from_text + """ + ) + + previous_events = [ActionExecuted(ACTION_LISTEN_NAME)] + old_tracker = DialogueStateTracker.from_events( + sender_id, previous_events, slots=domain.slots + ) + new_events = [Restarted()] + form_action = FormAction("some name", None) + temp_tracker = form_action._temporary_tracker(old_tracker, new_events, domain) + + assert extra_slot in temp_tracker.slots.keys() + assert list(temp_tracker.events) == [ + *previous_events, + SlotSet(REQUESTED_SLOT), + ActionExecuted(form_action.name()), + *new_events, + ] + + +async def test_extract_requested_slot_default(): + """Test default extraction of a slot value from entity with the same name.""" + form_name = "some_form" + form = FormAction(form_name, None) + + domain = Domain.from_dict( + { + "slots": { + "some_slot": { + "type": "text", + "mappings": [ + { + "type": "from_entity", + "entity": "some_slot", + "value": "some_value", + } + ], + } + }, + "forms": {form_name: {REQUIRED_SLOTS_KEY: ["some_slot"]}}, + } + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActiveLoop("some form"), + SlotSet(REQUESTED_SLOT, "some_slot"), + UserUttered( + "bla", entities=[{"entity": "some_slot", "value": "some_value"}] + ), + SlotSet("some_slot", "some_value"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + + slot_values = await form.validate( + tracker, + domain, + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + ) + assert slot_values == [] + + +@pytest.mark.parametrize( + "some_other_slot_mapping, some_slot_mapping, entities, " + "intent, expected_slot_events", + [ + ( + [ + { + "type": "from_entity", + "intent": "some_intent", + "entity": "some_entity", + "role": "some_role", + } + ], + [{"type": "from_entity", "intent": "some_intent", "entity": "some_entity"}], + [ + { + "entity": "some_entity", + "value": "some_value", + "role": "some_other_role", + } + ], + "some_intent", + [], + ), + ( + [ + { + "type": "from_entity", + "intent": "some_intent", + "entity": "some_entity", + "role": "some_role", + } + ], + [{"type": "from_entity", "intent": "some_intent", "entity": "some_entity"}], + [{"entity": "some_entity", "value": "some_value", "role": "some_role"}], + "some_intent", + [SlotSet("some_other_slot", "some_value")], + ), + ( + [ + { + "type": "from_entity", + "intent": "some_intent", + "entity": "some_entity", + "group": "some_group", + } + ], + [{"type": "from_entity", "intent": "some_intent", "entity": "some_entity"}], + [ + { + "entity": "some_entity", + "value": "some_value", + "group": "some_other_group", + } + ], + "some_intent", + [], + ), + ( + [ + { + "type": "from_entity", + "intent": "some_intent", + "entity": "some_entity", + "group": "some_group", + } + ], + [{"type": "from_entity", "intent": "some_intent", "entity": "some_entity"}], + [{"entity": "some_entity", "value": "some_value", "group": "some_group"}], + "some_intent", + [SlotSet("some_other_slot", "some_value")], + ), + ( + [ + { + "type": "from_entity", + "intent": "some_intent", + "entity": "some_entity", + "group": "some_group", + "role": "some_role", + } + ], + [{"type": "from_entity", "intent": "some_intent", "entity": "some_entity"}], + [ + { + "entity": "some_entity", + "value": "some_value", + "role": "some_role", + "group": "some_group", + } + ], + "some_intent", + [SlotSet("some_other_slot", "some_value")], + ), + ( + [{"type": "from_entity", "intent": "some_intent", "entity": "some_entity"}], + [ + { + "type": "from_entity", + "intent": "some_intent", + "entity": "some_other_entity", + } + ], + [{"entity": "some_entity", "value": "some_value"}], + "some_intent", + # other slot should be extracted because slot mapping is unique + [SlotSet("some_other_slot", "some_value")], + ), + ( + [ + { + "type": "from_entity", + "intent": "some_intent", + "entity": "some_entity", + "role": "some_role", + } + ], + [ + { + "type": "from_entity", + "intent": "some_intent", + "entity": "some_other_entity", + } + ], + [{"entity": "some_entity", "value": "some_value", "role": "some_role"}], + "some_intent", + # other slot should be extracted because slot mapping is unique + [SlotSet("some_other_slot", "some_value")], + ), + ( + [{"type": "from_entity", "intent": "some_intent", "entity": "some_entity"}], + [ + { + "type": "from_entity", + "intent": "some_intent", + "entity": "some_other_entity", + } + ], + [{"entity": "some_entity", "value": "some_value", "role": "some_role"}], + "some_intent", + # other slot should not be extracted + # because even though slot mapping is unique it doesn't contain the role + [], + ), + ( + [{"type": "from_entity", "intent": "some_intent", "entity": "some_entity"}], + [{"type": "from_entity", "intent": "some_intent", "entity": "some_entity"}], + [{"entity": "some_entity", "value": "some_value"}], + "some_intent", + # other slot should not be extracted because slot mapping is not unique + [SlotSet("some_slot", "some_value")], + ), + ], +) +async def test_extract_other_slots_with_entity( + some_other_slot_mapping: List[Dict[Text, Any]], + some_slot_mapping: List[Dict[Text, Any]], + entities: List[Dict[Text, Any]], + intent: Text, + expected_slot_events: List[SlotSet], +): + """Test extraction of other not requested slots values from entities.""" + form_name = "some_form" + form = FormAction(form_name, None) + + domain = Domain.from_dict( + { + "slots": { + "some_other_slot": {"type": "any", "mappings": some_other_slot_mapping}, + "some_slot": {"type": "any", "mappings": some_slot_mapping}, + }, + "forms": { + form_name: {REQUIRED_SLOTS_KEY: ["some_other_slot", "some_slot"]} + }, + } + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActiveLoop("some_form"), + SlotSet(REQUESTED_SLOT, "some_slot"), + UserUttered( + "bla", intent={"name": intent, "confidence": 1.0}, entities=entities + ), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + tracker.update_with_events(slot_events, domain) + + if slot_events: + slot_values = await form.validate( + tracker, + domain, + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + ) + assert slot_values == expected_slot_events + else: + with pytest.raises(ActionExecutionRejection): + await form.validate( + tracker, + domain, + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + ) + + +@pytest.mark.parametrize( + "domain_dict, expected_action", + [ + ( + { + "actions": ["action_ask_my_form_sun", "action_ask_sun"], + "responses": {"utter_ask_my_form_sun": [{"text": "ask"}]}, + }, + "action_ask_my_form_sun", + ), + ( + { + "actions": ["action_ask_sun"], + "responses": {"utter_ask_my_form_sun": [{"text": "ask"}]}, + }, + "utter_ask_my_form_sun", + ), + ( + { + "actions": ["action_ask_sun"], + "responses": {"utter_ask_sun": [{"text": "hi"}]}, + }, + "action_ask_sun", + ), + ( + { + "actions": ["action_ask_my_form_sun"], + "responses": {"utter_ask_my_form_sun": [{"text": "hi"}]}, + }, + "action_ask_my_form_sun", + ), + ], +) +async def test_ask_for_slot( + domain_dict: Dict, + expected_action: Text, + monkeypatch: MonkeyPatch, + default_nlg: TemplatedNaturalLanguageGenerator, +): + slot_name = "sun" + + action_from_name = Mock(return_value=action.ActionListen()) + endpoint_config = Mock() + monkeypatch.setattr( + action, action.action_for_name_or_text.__name__, action_from_name + ) + + form = FormAction("my_form", endpoint_config) + domain = Domain.from_dict(domain_dict) + await form._ask_for_slot( + domain, + default_nlg, + CollectingOutputChannel(), + slot_name, + DialogueStateTracker.from_events("dasd", []), + ) + + action_from_name.assert_called_once_with(expected_action, domain, endpoint_config) + + +async def test_ask_for_slot_if_not_utter_ask( + monkeypatch: MonkeyPatch, default_nlg: TemplatedNaturalLanguageGenerator +): + action_from_name = Mock(return_value=action.ActionListen()) + endpoint_config = Mock() + monkeypatch.setattr( + action, action.action_for_name_or_text.__name__, action_from_name + ) + + form = FormAction("my_form", endpoint_config) + events = await form._ask_for_slot( + Domain.empty(), + default_nlg, + CollectingOutputChannel(), + "some slot", + DialogueStateTracker.from_events("dasd", []), + ) + + assert not events + action_from_name.assert_not_called() + + +@pytest.mark.parametrize( + "ignored_intents, slot_not_intent", + [ + # for entity_type -> from_entity + ( + # `ignored_intents` as a string and slot's not_intent as an empty list. + "greet", + [], + ), + ( + # `ignored_intents` as an empty list and slot's not_intent has a value. + [], + ["greet"], + ), + ( + # `ignored_intents` as a list of 2 values and slot's not_intent has one + # value different than the ones in `ignored_intents`. + ["chitchat", "greet"], + ["inform"], + ), + ( + # `ignored_intents` as a list of 2 values and slot's not_intent has one + # value that is included also in `ignored_intents`. + ["chitchat", "greet"], + ["chitchat"], + ), + ], +) +async def test_ignored_intents_with_slot_type_from_entity( + ignored_intents: Union[Text, List[Text]], slot_not_intent: Union[Text, List[Text]] +): + form_name = "some_form" + entity_name = "some_slot" + form = FormAction(form_name, None) + + domain = Domain.from_dict( + { + "slots": { + entity_name: { + "type": "any", + "mappings": [ + { + "type": "from_entity", + "entity": entity_name, + "not_intent": slot_not_intent, + } + ], + } + }, + "forms": { + form_name: { + IGNORED_INTENTS: ignored_intents, + REQUIRED_SLOTS_KEY: [entity_name], + } + }, + } + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActiveLoop("some_form"), + SlotSet(REQUESTED_SLOT, "some_slot"), + UserUttered( + "hello", + intent={"name": "greet", "confidence": 1.0}, + entities=[{"entity": entity_name, "value": "some_value"}], + ), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + tracker.update_with_events(slot_events, domain) + + assert slot_events == [] + + with pytest.raises(ActionExecutionRejection): + await form.validate( + tracker, + domain, + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + ) + + +@pytest.mark.parametrize( + "ignored_intents, slot_not_intent", + [ + # same examples for entity_type -> from_text + ( + # `ignored_intents` as a string and slot's not_intent as an empty list. + "greet", + [], + ), + ( + # `ignored_intents` as an empty list and slot's not_intent has a value. + [], + ["greet"], + ), + ( + # `ignored_intents` as a list of 2 values and slot's not_intent has one + # value different than the ones in `ignored_intents`. + ["chitchat", "greet"], + ["inform"], + ), + ( + # `ignored_intents` as a list of 2 values and slot's not_intent has one + # value that is included also in `ignored_intents`. + ["chitchat", "greet"], + ["chitchat"], + ), + ], +) +async def test_ignored_intents_with_slot_type_from_text( + ignored_intents: Union[Text, List[Text]], slot_not_intent: Union[Text, List[Text]] +): + form_name = "some_form" + entity_name = "some_slot" + form = FormAction(form_name, None) + + domain = Domain.from_dict( + { + "slots": { + entity_name: { + "type": "any", + "mappings": [ + { + "type": "from_text", + "intent": "some_intent", + "not_intent": slot_not_intent, + } + ], + } + }, + "forms": { + form_name: { + IGNORED_INTENTS: ignored_intents, + REQUIRED_SLOTS_KEY: [entity_name], + } + }, + } + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + SlotSet(REQUESTED_SLOT, "some_slot"), + UserUttered( + "hello", + intent={"name": "greet", "confidence": 1.0}, + entities=[{"entity": entity_name, "value": "some_value"}], + ), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + tracker.update_with_events(slot_events, domain) + assert slot_events == [] + + form_slot_events = await form.validate( + tracker, + domain, + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + ) + assert form_slot_events == [] + + +@pytest.mark.parametrize( + "ignored_intents, slot_not_intent, entity_type", + [ + # same examples for entity_type -> from_intent + ( + # `ignored_intents` as a string and slot's not_intent as an empty list. + "greet", + [], + "from_intent", + ), + ( + # `ignored_intents` as an empty list and slot's not_intent has a value. + [], + ["greet"], + "from_intent", + ), + ( + # `ignored_intents` as a list of 2 values and slot's not_intent has one + # value different than the ones in `ignored_intents`. + ["chitchat", "greet"], + ["inform"], + "from_intent", + ), + ( + # `ignored_intents` as a list of 2 values and slot's not_intent has one + # value that is included also in `ignored_intents`. + ["chitchat", "greet"], + ["chitchat"], + "from_intent", + ), + # same examples for entity_type -> from_trigger_intent + ( + # `ignored_intents` as a string and slot's not_intent as an empty list. + "greet", + [], + "from_trigger_intent", + ), + ( + # `ignored_intents` as an empty list and slot's not_intent has a value. + [], + ["greet"], + "from_trigger_intent", + ), + ( + # `ignored_intents` as a list of 2 values and slot's not_intent has one + # value different than the ones in `ignored_intents`. + ["chitchat", "greet"], + ["inform"], + "from_trigger_intent", + ), + ( + # `ignored_intents` as a list of 2 values and slot's not_intent has one + # value that is included also in `ignored_intents`. + ["chitchat", "greet"], + ["chitchat"], + "from_trigger_intent", + ), + ], +) +async def test_ignored_intents_with_other_type_of_slots( + ignored_intents: Union[Text, List[Text]], + slot_not_intent: Union[Text, List[Text]], + entity_type: Text, +): + form_name = "some_form" + entity_name = "some_slot" + form = FormAction(form_name, None) + + domain = Domain.from_dict( + { + "slots": { + entity_name: { + "type": "any", + "mappings": [ + { + "type": entity_type, + "value": "affirm", + "intent": "true", + "not_intent": slot_not_intent, + } + ], + } + }, + "forms": { + form_name: { + IGNORED_INTENTS: ignored_intents, + REQUIRED_SLOTS_KEY: [entity_name], + } + }, + } + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + SlotSet(REQUESTED_SLOT, "some_slot"), + UserUttered( + "hello", + intent={"name": "greet", "confidence": 1.0}, + entities=[{"entity": entity_name, "value": "some_value"}], + ), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + tracker.update_with_events(slot_events, domain) + assert slot_events == [] + + form_slot_events = await form.validate( + tracker, + domain, + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + ) + assert form_slot_events == [] + + +async def test_extract_other_slots_with_matched_mapping_conditions(): + form_name = "some_form" + form = FormAction(form_name, None) + + domain = Domain.from_yaml( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intent: + - greet + - inform + entities: + - email + - name + slots: + name: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: name + conditions: + - active_loop: some_form + requested_slot: email + email: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: email + forms: + some_form: + required_slots: + - email + - name + """ + ) + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActiveLoop("some_form"), + SlotSet(REQUESTED_SLOT, "email"), + UserUttered( + "My name is Emily.", + intent={"name": "inform", "confidence": 1.0}, + entities=[{"entity": "name", "value": "Emily"}], + ), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert slot_events == [SlotSet("name", "Emily")] + tracker.update_with_events(slot_events, domain) + + form_slot_events = await form.validate( + tracker, + domain, + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + ) + assert form_slot_events == [] + + +async def test_extract_other_slots_raises_no_matched_conditions(): + form_name = "some_form" + form = FormAction(form_name, None) + + domain = Domain.from_yaml( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intent: + - greet + - inform + entities: + - email + - name + slots: + name: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: name + conditions: + - active_loop: some_form + requested_slot: name + email: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: email + forms: + some_form: + required_slots: + - email + - name + """ + ) + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActiveLoop("some_form"), + SlotSet(REQUESTED_SLOT, "email"), + UserUttered( + "My name is Emily.", + intent={"name": "inform", "confidence": 1.0}, + entities=[{"entity": "name", "value": "Emily"}], + ), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + tracker.update_with_events(slot_events, domain) + + assert slot_events == [] + + with pytest.raises(ActionExecutionRejection): + await form.validate( + tracker, + domain, + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + ) + + +async def test_action_extract_slots_custom_mapping_with_condition(): + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + slots: + custom_slot: + type: text + influence_conversation: false + mappings: + - type: custom + conditions: + - active_loop: some_form + + forms: + some_form: + required_slots: + - custom_slot + + actions: + - validate_some_form + """ + ) + domain = Domain.from_yaml(domain_yaml) + events = [ActiveLoop("some_form"), UserUttered("Hi")] + tracker = DialogueStateTracker.from_events( + sender_id="test_id", evts=events, slots=domain.slots + ) + + with aioresponses() as mocked: + mocked.post( + ACTION_SERVER_URL, + payload={ + "events": [{"event": "slot", "name": "custom_slot", "value": "test"}] + }, + ) + + action_server = EndpointConfig(ACTION_SERVER_URL) + action_extract_slots = ActionExtractSlots(action_server) + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == [] + + form = FormAction("some_form", action_server) + form_events = await form.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert form_events == [ + SlotSet("custom_slot", "test"), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ] + + +async def test_form_slots_empty_with_restart(): + domain = Domain.from_yaml( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intent: + - greet + - inform + entities: + - name + slots: + name: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: name + conditions: + - active_loop: some_form + forms: + some_form: + required_slots: + - name + """ + ) + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + UserUttered("hi", intent={"name": "greet", "confidence": 1.0}), + ActiveLoop("some_form"), + SlotSet(REQUESTED_SLOT, "name"), + UserUttered( + "My name is Emily.", + intent={"name": "inform", "confidence": 1.0}, + entities=[{"entity": "name", "value": "Emily"}], + ), + SlotSet("name", "emily"), + Restarted(), + ActiveLoop("some_form"), + SlotSet(REQUESTED_SLOT, "name"), + UserUttered("hi", intent={"name": "greet", "confidence": 1.0}), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + action = FormAction("some_form", None) + + # FormAction execution is rejected because a slot was requested but none + # were extracted (events before restart are not considered). + with pytest.raises(ActionExecutionRejection): + await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + +async def test_extract_slots_with_mapping_conditions_during_form_activation(): + slot_name = "city" + entity_value = "London" + entity_name = "location" + + form_name = "test_form" + + domain = Domain.from_yaml( + f""" + entities: + - {entity_name} + slots: + {slot_name}: + type: text + mappings: + - type: from_entity + entity: {entity_name} + conditions: + - active_loop: {form_name} + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {slot_name} + """ + ) + + events = [ + ActionExecuted("action_listen"), + UserUttered( + "I live in London", + entities=[{"entity": entity_name, "value": entity_value}], + ), + ] + tracker = DialogueStateTracker.from_events( + sender_id="test", evts=events, domain=domain, slots=domain.slots + ) + assert tracker.active_loop_name is None + + action_extract_slots = ActionExtractSlots(None) + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == [] + + expected_events = [ + ActiveLoop(form_name), + SlotSet(slot_name, entity_value), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ] + + form_action = FormAction(form_name, None) + form_events = await form_action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert form_events == expected_events + + +async def test_form_validation_happens_once(caplog: LogCaptureFixture): + """ + Tests if form validation happens once instead of twice. + Solves the bug presented in https://rasahq.atlassian.net/browse/ENG-117 + """ + tracker = DialogueStateTracker.from_events(sender_id="bla", evts=[]) + form_name = "my_form" + action_server = EndpointConfig(ACTION_SERVER_URL) + action = FormAction(form_name, action_server) + slot_name = "num_people" + domain = textwrap.dedent( + f""" + slots: + {slot_name}: + type: float + mappings: + - type: from_entity + entity: number + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {slot_name} + responses: + utter_ask_num_people: + - text: "How many people?" + actions: + - validate_{form_name} + """ + ) + domain = Domain.from_yaml(domain) + form_validation_events = [ + { + "event": "slot", + "timestamp": None, + "name": "num_people", + "value": 5, + } + ] + with aioresponses() as mocked, caplog.at_level(logging.DEBUG): + mocked.post( + ACTION_SERVER_URL, + payload={"events": form_validation_events}, + ) + _ = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert ( + sum( + [ + 1 + for message in caplog.messages + if f"Calling action endpoint to run action 'validate_{form_name}'." + in message + ] + ) + == 1 + ) + + +async def test_form_validation_happens_at_form_activation(caplog: LogCaptureFixture): + """Test if form validation happens at form activation. + + The particular case is when a non-required slot for the form is also filled + at form activation. + + Fixes the bug in https://rasahq.atlassian.net/browse/ATO-1104. + """ + form = "help_form" + action_server = EndpointConfig(ACTION_SERVER_URL) + form_action = FormAction(form, action_server) + + entity_a = "device" + entity_b = "account_type" + entity_b_value = "bronze" + + context_slot = "device_type" + required_slot = "send_sms" + global_slot = "membership" + + domain = textwrap.dedent( + f""" + intents: + - start_form + entities: + - {entity_a} + - {entity_b} + slots: + {context_slot}: + type: categorical + influence_conversation: false + initial_value: "mobile" + values: + - mobile + - desktop + - other + mappings: + - type: from_entity + entity: {entity_a} + {required_slot}: + type: float + influence_conversation: false + mappings: + - type: custom + {global_slot}: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: {entity_b} + forms: + {form}: + {REQUIRED_SLOTS_KEY}: + - {required_slot} + responses: + utter_ask_{required_slot}: + - text: "Would you like to receive an SMS?" + actions: + - validate_{form} + """ + ) + domain = Domain.from_yaml(domain) + + tracker = DialogueStateTracker.from_events( + sender_id="test", + evts=[ + UserUttered( + "Can you help me with my bronze account ", + intent={"name": "start_form", "confidence": 1.0}, + entities=[{"entity": entity_b, "value": entity_b_value}], + ), + ], + ) + + # Mock the custom validation action to update form required_slots to empty list + form_validation_events = [ + { + "event": "slot", + "timestamp": None, + "name": "requested_slot", + "value": None, + } + ] + with aioresponses() as mocked, caplog.at_level(logging.DEBUG): + mocked.post( + ACTION_SERVER_URL, + payload={"events": form_validation_events}, + ) + events = await form_action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert ( + sum( + [ + 1 + for message in caplog.messages + if f"Calling action endpoint to run action 'validate_{form}'." + in message + ] + ) + == 1 + ) + + assert events == [ + ActiveLoop(form), + SlotSet(REQUESTED_SLOT, None), + SlotSet(global_slot, entity_b_value), + ActiveLoop(None), + ] + + +async def test_form_validation_happens_at_form_activation_with_empty_required_slots( + caplog: LogCaptureFixture, +): + """Test if required_slots get updated dynamically at form activation. + + The particular case is when a form has an empty required_slots list. + """ + form = "booking_form" + action_server = EndpointConfig(ACTION_SERVER_URL) + form_action = FormAction(form, action_server) + + dynamic_slot = "customer_name" + bot_utterance = "What is your name?" + + domain = textwrap.dedent( + f""" + intents: + - start_form + + slots: + {dynamic_slot}: + type: text + influence_conversation: false + mappings: + - type: custom + + forms: + {form}: + {REQUIRED_SLOTS_KEY}: [] + responses: + utter_ask_{dynamic_slot}: + - text: "{bot_utterance}" + actions: + - validate_{form} + """ + ) + domain = Domain.from_yaml(domain) + + tracker = DialogueStateTracker.from_events( + sender_id="test", + evts=[ + UserUttered( + "I'd like to make a booking. ", + intent={"name": "start_form", "confidence": 1.0}, + ), + ], + ) + + # Mock the custom validation action to update form required_slots + form_validation_events = [ + { + "event": "slot", + "timestamp": None, + "name": "requested_slot", + "value": dynamic_slot, + } + ] + with aioresponses() as mocked, caplog.at_level(logging.DEBUG): + mocked.post( + ACTION_SERVER_URL, + payload={"events": form_validation_events}, + ) + events = await form_action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert ( + sum( + [ + 1 + for message in caplog.messages + if f"Calling action endpoint to run action 'validate_{form}'." + in message + ] + ) + == 1 + ) + + assert len(events) == 3 + + assert events[0] == ActiveLoop(form) + assert events[1] == SlotSet(REQUESTED_SLOT, dynamic_slot) + assert isinstance(events[2], BotUttered) is True + assert events[2].text == bot_utterance diff --git a/tests/core/actions/test_loops.py b/tests/core/actions/test_loops.py new file mode 100644 index 0000000..165aea1 --- /dev/null +++ b/tests/core/actions/test_loops.py @@ -0,0 +1,167 @@ +from typing import List, Any, Text + +import pytest +from rasa.core.actions.loops import LoopAction +from rasa.core.channels import CollectingOutputChannel +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + Event, + ActionExecutionRejected, + ActionExecuted, + ActiveLoop, + SlotSet, +) +from rasa.core.nlg import TemplatedNaturalLanguageGenerator +from rasa.shared.core.trackers import DialogueStateTracker + + +async def test_whole_loop(): + expected_activation_events = [ + ActionExecutionRejected("tada"), + ActionExecuted("test"), + ] + + expected_do_events = [ActionExecuted("do")] + expected_deactivation_events = [SlotSet("deactivated")] + + form_name = "my form" + + class MyLoop(LoopAction): + def name(self) -> Text: + return form_name + + async def activate(self, *args: Any) -> List[Event]: + return expected_activation_events + + async def do(self, *args: Any) -> List[Event]: + events_so_far = args[-1] + assert events_so_far == [ActiveLoop(form_name), *expected_activation_events] + + return expected_do_events + + async def deactivate(self, *args) -> List[Event]: + events_so_far = args[-1] + assert events_so_far == [ + ActiveLoop(form_name), + *expected_activation_events, + *expected_do_events, + ActiveLoop(None), + ] + + return expected_deactivation_events + + async def is_done(self, *args) -> bool: + events_so_far = args[-1] + return events_so_far == [ + ActiveLoop(form_name), + *expected_activation_events, + *expected_do_events, + ] + + tracker = DialogueStateTracker.from_events("some sender", []) + domain = Domain.empty() + + action = MyLoop() + actual = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert actual == [ + ActiveLoop(form_name), + *expected_activation_events, + *expected_do_events, + ActiveLoop(None), + *expected_deactivation_events, + ] + + +async def test_loop_without_deactivate(): + expected_activation_events = [ + ActionExecutionRejected("tada"), + ActionExecuted("test"), + ] + + expected_do_events = [ActionExecuted("do")] + form_name = "my form" + + class MyLoop(LoopAction): + def name(self) -> Text: + return form_name + + async def activate(self, *args: Any) -> List[Event]: + return expected_activation_events + + async def do(self, *args: Any) -> List[Event]: + return expected_do_events + + async def deactivate(self, *args) -> List[Event]: + raise ValueError("this shouldn't be called") + + async def is_done(self, *args) -> bool: + return False + + tracker = DialogueStateTracker.from_events("some sender", []) + domain = Domain.empty() + + action = MyLoop() + actual = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert actual == [ + ActiveLoop(form_name), + *expected_activation_events, + *expected_do_events, + ] + + +async def test_loop_without_activate_and_without_deactivate(): + expected_do_events = [ActionExecuted("do")] + form_name = "my form" + + class MyLoop(LoopAction): + def name(self) -> Text: + return form_name + + async def activate(self, *args: Any) -> List[Event]: + raise ValueError("this shouldn't be called") + + async def do(self, *args: Any) -> List[Event]: + return expected_do_events + + async def deactivate(self, *args) -> List[Event]: + return [SlotSet("deactivated")] + + async def is_activated(self, *args: Any) -> bool: + return True + + async def is_done(self, *args) -> bool: + return False + + tracker = DialogueStateTracker.from_events("some sender", []) + domain = Domain.empty() + + action = MyLoop() + actual = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert actual == [*expected_do_events] + + +async def test_raise_not_implemented_error(): + loop = LoopAction() + with pytest.raises(NotImplementedError): + await loop.do(None, None, None, None, []) + + with pytest.raises(NotImplementedError): + await loop.is_done(None, None, None, None, []) diff --git a/tests/core/actions/test_two_stage_fallback.py b/tests/core/actions/test_two_stage_fallback.py new file mode 100644 index 0000000..4b916fd --- /dev/null +++ b/tests/core/actions/test_two_stage_fallback.py @@ -0,0 +1,346 @@ +from typing import List, Text + +import pytest + +from rasa.core.policies.policy import PolicyPrediction +from rasa.core.processor import MessageProcessor +from rasa.shared.constants import ( + DEFAULT_NLU_FALLBACK_INTENT_NAME, + LATEST_TRAINING_DATA_FORMAT_VERSION, +) +from rasa.core.actions.two_stage_fallback import TwoStageFallbackAction +from rasa.core.channels import CollectingOutputChannel +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + ActionExecuted, + UserUttered, + SlotSet, + ActiveLoop, + BotUttered, + UserUtteranceReverted, + Event, +) +from rasa.core.nlg import TemplatedNaturalLanguageGenerator +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.nlu.constants import INTENT_RANKING_KEY +from rasa.shared.core.constants import ( + USER_INTENT_OUT_OF_SCOPE, + ACTION_LISTEN_NAME, + ACTION_TWO_STAGE_FALLBACK_NAME, +) + + +def _message_requiring_fallback() -> List[Event]: + return [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + "hi", + {"name": DEFAULT_NLU_FALLBACK_INTENT_NAME}, + parse_data={ + INTENT_RANKING_KEY: [ + {"name": DEFAULT_NLU_FALLBACK_INTENT_NAME}, + {"name": "greet"}, + {"name": "bye"}, + ] + }, + ), + ] + + +def _two_stage_clarification_request() -> List[Event]: + return [ActionExecuted(ACTION_TWO_STAGE_FALLBACK_NAME), BotUttered("please affirm")] + + +@pytest.mark.parametrize( + "events", + [ + _message_requiring_fallback(), + _message_requiring_fallback() + + [UserUtteranceReverted()] + + _message_requiring_fallback(), + ], +) +async def test_ask_affirmation(events: List[Event]): + tracker = DialogueStateTracker.from_events("some-sender", evts=events) + domain = Domain.empty() + action = TwoStageFallbackAction() + + events = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert len(events) == 2 + assert events[0] == ActiveLoop(ACTION_TWO_STAGE_FALLBACK_NAME) + assert isinstance(events[1], BotUttered) + + +async def test_1st_affirmation_is_successful(default_processor: MessageProcessor): + tracker = DialogueStateTracker.from_events( + "some-sender", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("my name is John", {"name": "say_name", "confidence": 1.0}), + SlotSet("some_slot", "example_value"), + # User sends message with low NLU confidence + *_message_requiring_fallback(), + ActiveLoop(ACTION_TWO_STAGE_FALLBACK_NAME), + # Action asks user to affirm + *_two_stage_clarification_request(), + ActionExecuted(ACTION_LISTEN_NAME), + # User affirms + UserUttered("hi", {"name": "greet", "confidence": 1.0}), + ], + ) + domain = Domain.empty() + action = TwoStageFallbackAction() + + await default_processor._run_action( + action, + tracker, + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + PolicyPrediction([], "some policy"), + ) + + applied_events = tracker.applied_events() + assert applied_events == [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("my name is John", {"name": "say_name", "confidence": 1.0}), + SlotSet("some_slot", "example_value"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("hi", {"name": "greet", "confidence": 1.0}), + ] + + +async def test_give_it_up_after_low_confidence_after_affirm_request(): + tracker = DialogueStateTracker.from_events( + "some-sender", + evts=[ + # User sends message with low NLU confidence + *_message_requiring_fallback(), + ActiveLoop(ACTION_TWO_STAGE_FALLBACK_NAME), + # Action asks user to affirm + *_two_stage_clarification_request(), + # User's affirms with low NLU confidence again + *_message_requiring_fallback(), + ], + ) + domain = Domain.empty() + action = TwoStageFallbackAction() + + events = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert events == [ActiveLoop(None), UserUtteranceReverted()] + + +async def test_ask_rephrase_after_failed_affirmation(): + rephrase_text = "please rephrase" + tracker = DialogueStateTracker.from_events( + "some-sender", + evts=[ + # User sends message with low NLU confidence + *_message_requiring_fallback(), + ActiveLoop(ACTION_TWO_STAGE_FALLBACK_NAME), + # Action asks user to affirm + *_two_stage_clarification_request(), + ActionExecuted(ACTION_LISTEN_NAME), + # User denies suggested intents + UserUttered("hi", {"name": USER_INTENT_OUT_OF_SCOPE}), + ], + ) + + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_ask_rephrase: + - text: {rephrase_text} + """ + ) + action = TwoStageFallbackAction() + + events = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert len(events) == 1 + assert isinstance(events[0], BotUttered) + + bot_utterance = events[0] + assert isinstance(bot_utterance, BotUttered) + assert bot_utterance.text == rephrase_text + + +async def test_ask_rephrasing_successful(default_processor: MessageProcessor): + tracker = DialogueStateTracker.from_events( + "some-sender", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("my name is John", {"name": "say_name", "confidence": 1.0}), + SlotSet("some_slot", "example_value"), + # User sends message with low NLU confidence + *_message_requiring_fallback(), + ActiveLoop(ACTION_TWO_STAGE_FALLBACK_NAME), + # Action asks user to affirm + *_two_stage_clarification_request(), + ActionExecuted(ACTION_LISTEN_NAME), + # User denies suggested intents + UserUttered("hi", {"name": USER_INTENT_OUT_OF_SCOPE}), + *_two_stage_clarification_request(), + # Action asks user to rephrase + ActionExecuted(ACTION_LISTEN_NAME), + # User rephrases successfully + UserUttered("hi", {"name": "greet"}), + ], + ) + domain = Domain.empty() + action = TwoStageFallbackAction() + + await default_processor._run_action( + action, + tracker, + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + PolicyPrediction([], "some policy"), + ) + + applied_events = tracker.applied_events() + assert applied_events == [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("my name is John", {"name": "say_name", "confidence": 1.0}), + SlotSet("some_slot", "example_value"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("hi", {"name": "greet"}), + ] + + +async def test_ask_affirm_after_rephrasing(): + tracker = DialogueStateTracker.from_events( + "some-sender", + evts=[ + # User sends message with low NLU confidence + *_message_requiring_fallback(), + ActiveLoop(ACTION_TWO_STAGE_FALLBACK_NAME), + # Action asks user to affirm + *_two_stage_clarification_request(), + ActionExecuted(ACTION_LISTEN_NAME), + # User denies suggested intents + UserUttered("hi", {"name": USER_INTENT_OUT_OF_SCOPE}), + # Action asks user to rephrase + ActionExecuted(ACTION_TWO_STAGE_FALLBACK_NAME), + BotUttered("please rephrase"), + # User rephrased with low confidence + *_message_requiring_fallback(), + ], + ) + domain = Domain.empty() + action = TwoStageFallbackAction() + + events = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert len(events) == 1 + assert isinstance(events[0], BotUttered) + + +async def test_2nd_affirm_successful(default_processor: MessageProcessor): + tracker = DialogueStateTracker.from_events( + "some-sender", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("my name is John", {"name": "say_name", "confidence": 1.0}), + SlotSet("some_slot", "example_value"), + # User sends message with low NLU confidence + *_message_requiring_fallback(), + ActiveLoop(ACTION_TWO_STAGE_FALLBACK_NAME), + # Action asks user to affirm + *_two_stage_clarification_request(), + ActionExecuted(ACTION_LISTEN_NAME), + # User denies suggested intents + UserUttered("hi", {"name": USER_INTENT_OUT_OF_SCOPE}), + # Action asks user to rephrase + *_two_stage_clarification_request(), + # User rephrased with low confidence + *_message_requiring_fallback(), + *_two_stage_clarification_request(), + # Actions asks user to affirm for the last time + ActionExecuted(ACTION_LISTEN_NAME), + # User affirms successfully + UserUttered("hi", {"name": "greet"}), + ], + ) + domain = Domain.empty() + action = TwoStageFallbackAction() + + await default_processor._run_action( + action, + tracker, + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + PolicyPrediction([], "some policy"), + ) + + applied_events = tracker.applied_events() + + assert applied_events == [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("my name is John", {"name": "say_name", "confidence": 1.0}), + SlotSet("some_slot", "example_value"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("hi", {"name": "greet"}), + ] + + +@pytest.mark.parametrize( + "intent_which_lets_action_give_up", + [USER_INTENT_OUT_OF_SCOPE, DEFAULT_NLU_FALLBACK_INTENT_NAME], +) +async def test_2nd_affirmation_failed(intent_which_lets_action_give_up: Text): + tracker = DialogueStateTracker.from_events( + "some-sender", + evts=[ + # User sends message with low NLU confidence + *_message_requiring_fallback(), + ActiveLoop(ACTION_TWO_STAGE_FALLBACK_NAME), + # Action asks user to affirm + *_two_stage_clarification_request(), + ActionExecuted(ACTION_LISTEN_NAME), + # User denies suggested intents + UserUttered("hi", {"name": USER_INTENT_OUT_OF_SCOPE}), + # Action asks user to rephrase + *_two_stage_clarification_request(), + # User rephrased with low confidence + *_message_requiring_fallback(), + # Actions asks user to affirm for the last time + *_two_stage_clarification_request(), + ActionExecuted(ACTION_LISTEN_NAME), + # User denies suggested intents for the second time + UserUttered("hi", {"name": intent_which_lets_action_give_up}), + ], + ) + domain = Domain.empty() + action = TwoStageFallbackAction() + + events = await action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert events == [ActiveLoop(None), UserUtteranceReverted()] diff --git a/tests/core/channels/__init__.py b/tests/core/channels/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/channels/test_botframework.py b/tests/core/channels/test_botframework.py new file mode 100644 index 0000000..8b237d7 --- /dev/null +++ b/tests/core/channels/test_botframework.py @@ -0,0 +1,151 @@ +import json +import time +from http import HTTPStatus +from unittest import mock +from unittest.mock import Mock + +import freezegun +import jwt +import pytest +from _pytest.logging import LogCaptureFixture +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from jwt.algorithms import RSAAlgorithm + +from rasa.core.channels import BotFrameworkInput + +DAY_IN_SECONDS = 60 * 60 * 24 + +MS_OPENID_CONFIG_RESPONSE = { + "issuer": "https://api.botframework.com", + "authorization_endpoint": "https://invalid.botframework.com", + "jwks_uri": "https://login.botframework.com/v1/.well-known/keys", + "id_token_signing_alg_values_supported": ["RS256"], + "token_endpoint_auth_methods_supported": ["private_key_jwt"], +} + + +@pytest.fixture(scope="function") +def rsa_private_key() -> RSAPrivateKey: + return rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + +@pytest.fixture(scope="function") +@mock.patch("requests.get") +def bot_framework_input(mock_requests: Mock, rsa_private_key: RSAPrivateKey): + jwk = json.loads(RSAAlgorithm.to_jwk(rsa_private_key.public_key())) + jwk["kid"] = "key_id" + + openid_config_mock_response = mock.Mock() + openid_config_mock_response.raise_for_status = mock.Mock() + openid_config_mock_response.json.return_value = MS_OPENID_CONFIG_RESPONSE + + openid_keys_mock_response = mock.Mock() + openid_keys_mock_response.raise_for_status = mock.Mock() + openid_keys_mock_response.json.return_value = {"keys": [jwk]} + + mock_requests.side_effect = [openid_config_mock_response, openid_keys_mock_response] + + return BotFrameworkInput("app_id", "app_password") + + +def test_successful_jwt_signature_verification( + bot_framework_input: BotFrameworkInput, + rsa_private_key: RSAPrivateKey, +): + encoded = jwt.encode( + { + "serviceurl": "https://webchat.botframework.com/", + "nbf": int(time.time()), + "exp": int(time.time()) + DAY_IN_SECONDS, + "iss": "https://api.botframework.com", + "aud": "app_id", + }, + rsa_private_key, + algorithm="RS256", + headers={"kid": "key_id", "alg": "RS256"}, + ) + + with pytest.warns(None): + resp = bot_framework_input._validate_auth(f"Bearer {encoded}") + assert resp is None + + +@mock.patch("requests.get") +def test_jwk_is_updated_daily(mock_requests: Mock): + with freezegun.freeze_time("2012-01-14 08:00:00"): + bot_framework_input = BotFrameworkInput("app_id", "app_password") + # Two calls at the beginning - on to retrieve open ID metadata, + # the second one to actually get the keys. + assert mock_requests.call_count == 2 + + bot_framework_input._validate_auth("Bearer token_invalid") + assert mock_requests.call_count == 2 + + with freezegun.freeze_time("2012-01-14 11:00:00"): + bot_framework_input._validate_auth("Bearer token_invalid") + assert mock_requests.call_count == 2 + + with freezegun.freeze_time("2012-01-15 11:00:00"): + bot_framework_input._validate_auth("Bearer token_invalid") + assert mock_requests.call_count == 4 + + +def test_validate_auth_returns_unauthorized_for_invalid_jwt_token( + bot_framework_input: BotFrameworkInput, + rsa_private_key: RSAPrivateKey, + caplog: LogCaptureFixture, +): + encoded = jwt.encode( + { + "serviceurl": "https://webchat.botframework.com/", + "nbf": int(time.time()) + DAY_IN_SECONDS, + "exp": int(time.time()) + 2 * DAY_IN_SECONDS, + "iss": "https://api.botframework.com", + "aud": "app_id", + }, + rsa_private_key, + algorithm="RS256", + headers={"kid": "key_id", "alg": "RS256"}, + ) + + resp = bot_framework_input._validate_auth(f"Bearer {encoded}") + + assert resp is not None + assert resp.status == HTTPStatus.UNAUTHORIZED + assert resp.body.decode() == "Could not validate JWT token." + assert [msg for msg in caplog.messages] == [ + "Bot framework JWT token could not be verified.", + "The token is not yet valid (nbf)", + ] + + +def test_validate_auth_returns_unauthorized_for_absent_header( + bot_framework_input: BotFrameworkInput, + caplog: LogCaptureFixture, +): + resp = bot_framework_input._validate_auth(None) + assert resp is not None + assert resp.status == HTTPStatus.UNAUTHORIZED + assert resp.body.decode() == "No authorization header provided." + + +def test_validate_auth_returns_unauthorized_for_non_bearer_header( + bot_framework_input: BotFrameworkInput, +): + resp = bot_framework_input._validate_auth("Basic dXNlcm5hbWU6cGFzc3dvcmQ=") + assert resp is not None + assert resp.status == HTTPStatus.UNAUTHORIZED + assert resp.body.decode() == "No Bearer token provided in Authorization header." + + +def test_validate_auth_returns_bad_request_for_invalid_header( + bot_framework_input: BotFrameworkInput, +): + resp = bot_framework_input._validate_auth("Bearer invalid") + assert resp is not None + assert resp.status == HTTPStatus.UNAUTHORIZED + assert resp.body.decode() == "Could not validate JWT token." diff --git a/tests/core/channels/test_cmdline.py b/tests/core/channels/test_cmdline.py new file mode 100644 index 0000000..b194f5a --- /dev/null +++ b/tests/core/channels/test_cmdline.py @@ -0,0 +1,108 @@ +from typing import List, Text + +from _pytest.capture import CaptureFixture +from _pytest.monkeypatch import MonkeyPatch +from prompt_toolkit import PromptSession, Application +from prompt_toolkit.input.defaults import create_pipe_input +from prompt_toolkit.output import DummyOutput +from rasa.core.channels.console import record_messages + +from aioresponses import aioresponses + +ENTER = "\x0a" + + +def mock_stdin(input_from_stdin: List[Text]): + text = "" + for line in input_from_stdin: + text += line + ENTER + "\r" + + inp = create_pipe_input() + inp.send_text(text) + + prompt_session_init = PromptSession.__init__ + + def prompt_session_init_fake(self, *k, **kw): + prompt_session_init(self, input=inp, output=DummyOutput(), *k, **kw) + + PromptSession.__init__ = prompt_session_init_fake + + application_init = Application.__init__ + + def application_init_fake(self, *k, **kw): + kw.pop("input", None) + kw.pop("output", None) + application_init(self, input=inp, output=DummyOutput(), *k, **kw) + + Application.__init__ = application_init_fake + + return inp + + +async def test_record_messages(monkeypatch: MonkeyPatch, capsys: CaptureFixture): + input_output = [ + { + "in": "Give me a question!", + "out": [ + { + "buttons": [ + { + "title": "button 1 title", + "payload": "button 1 payload", + "details": "button 1 details", + } + ], + "text": "This is a button 1", + }, + { + "buttons": [ + { + "title": "button 2 title", + "payload": "button 2 payload", + "details": "button 2 details", + } + ], + "text": "This is a button 2", + }, + { + "buttons": [ + { + "title": "button 3 title", + "payload": "button 3 payload", + "details": "button 3 details", + } + ], + "text": "This is a button 3", + }, + ], + }, + {"in": ENTER, "out": [{"text": "You've pressed the button"}]}, + {"in": "Dummy message", "out": [{"text": "Dummy response"}]}, + ] + + inp = mock_stdin([m["in"] for m in input_output]) + + server_url = "http://example.com" + endpoint = f"{server_url}/webhooks/rest/webhook" + + with aioresponses() as mocked: + + for output in [m["out"] for m in input_output]: + if output: + mocked.post(url=endpoint, payload=output) + + num_of_messages = await record_messages( + "123", + server_url=server_url, + max_message_limit=len(input_output), + use_response_stream=False, + ) + + assert num_of_messages == len(input_output) + + captured = capsys.readouterr() + + assert "button 1 payload" in captured.out + assert "button 2 payload" in captured.out + + inp.close() diff --git a/tests/core/channels/test_facebook.py b/tests/core/channels/test_facebook.py new file mode 100644 index 0000000..a0b7003 --- /dev/null +++ b/tests/core/channels/test_facebook.py @@ -0,0 +1,111 @@ +import logging +import pytest +from rasa.core import utils, run +from rasa.core.channels.facebook import MessengerBot +from fbmessenger import MessengerClient + +logger = logging.getLogger(__name__) + + +def test_facebook_channel(): + from rasa.core.channels.facebook import FacebookInput + + input_channel = FacebookInput( + fb_verify="YOUR_FB_VERIFY", + # you need tell facebook this token, to confirm your URL + fb_secret="YOUR_FB_SECRET", # your app secret + fb_access_token="YOUR_FB_PAGE_ACCESS_TOKEN" + # token for the page you subscribed to + ) + + s = run.configure_app([input_channel], port=5004) + routes_list = utils.list_routes(s) + + assert routes_list["fb_webhook.health"].startswith("/webhooks/facebook") + assert routes_list["fb_webhook.webhook"].startswith("/webhooks/facebook/webhook") + + +@pytest.mark.parametrize( + "test_input, expected", + [ + ( + { + "blocks": [ + {"type": "title", "text": {"text": "Conversation progress"}}, + { + "type": "progression_bar", + "text": {"text": "progression 1", "level": "1"}, + }, + ] + }, + "test_id", + ), + ( + { + "blocks": [ + {"type": "title", "text": {"text": "Conversation progress"}}, + { + "type": "progression_bar", + "text": {"text": "progression 1", "level": "1"}, + }, + ], + "sender": {"id": "test_json_id"}, + }, + "test_json_id", + ), + ( + { + "blocks": { + "type": "progression_bar", + "text": {"text": "progression 1", "level": "1"}, + }, + "sender": {"id": "test_json_id_2"}, + }, + "test_json_id_2", + ), + ( + [ + { + "blocks": { + "type": "progression_bar", + "text": {"text": "progression 1", "level": "1"}, + } + }, + {"sender": {"id": "test_json_id_3"}}, + ], + "test_json_id_3", + ), + ], +) +async def test_facebook_send_custom_json(test_input, expected): + # This function tests cases when the custom json is a list + # The send_custom_json function doesn't return anything. Rather + # it calls an object MessengerClient, that will + # then make a post request. + # Since the purpose is to test the extraction of the recipient_id + # by the MessengerBot.send_custom_json_list we + # modify MessengerClient (from the fbmessenger pypackage) to + # return the recipient ID. + + class TestableMessengerClient(MessengerClient): + def __init__(self, page_access_token, **kwargs): + self.recipient_id = "" + super(TestableMessengerClient, self).__init__(page_access_token, **kwargs) + + def send( + self, + payload, + recipient_id, + messaging_type="RESPONSE", + notification_type="REGULAR", + timeout=None, + tag=None, + ): + self.recipient_id = recipient_id + + messenger_client = TestableMessengerClient(page_access_token="test_token") + messenger_bot = MessengerBot(messenger_client) + await messenger_bot.send_custom_json( + recipient_id="test_id", json_message=test_input + ) + assert messenger_bot.messenger_client.recipient_id == expected diff --git a/tests/core/channels/test_hangouts.py b/tests/core/channels/test_hangouts.py new file mode 100644 index 0000000..995c655 --- /dev/null +++ b/tests/core/channels/test_hangouts.py @@ -0,0 +1,216 @@ +import json +import logging + +import pytest + +from rasa.core import utils + +logger = logging.getLogger(__name__) + + +def test_hangouts_channel(): + + from rasa.core.channels.hangouts import HangoutsInput + import rasa.core + + input_channel = HangoutsInput( + project_id="12345678901", + # intent name for bot added to direct message event + hangouts_user_added_intent_name="/added_dm", + # intent name for bot added to room event + hangouts_room_added_intent_name="/added_room", + # intent name for bot removed from space event + hangouts_removed_intent_name="/removed", + ) + + s = rasa.core.run.configure_app([input_channel], port=5004) + + routes_list = utils.list_routes(s) + + assert routes_list.get("hangouts_webhook.health").startswith("/webhooks/hangouts") + assert routes_list.get("hangouts_webhook.receive").startswith( + "/webhooks/hangouts/webhook" + ) + + +def test_hangouts_extract_functions(): + # from https://developers.google.com/hangouts/chat/reference/message-formats/events#added_to_space # noqa: E501 + ADDED_EVENT = { + "type": "ADDED_TO_SPACE", + "eventTime": "2017-03-02T19:02:59.910959Z", + "space": { + "name": "spaces/AAAAAAAAAAA", + "displayName": "Chuck Norris Discussion Room", + "type": "ROOM", + }, + "user": { + "name": "users/12345678901234567890", + "displayName": "Chuck Norris", + "avatarUrl": "https://lh3.googleusercontent.com/.../photo.jpg", + "email": "chuck@example.com", + }, + } + + # from https://developers.google.com/hangouts/chat/reference/message-formats/events#removed_from_space # noqa: E501 + REMOVED_EVENT = { + "type": "REMOVED_FROM_SPACE", + "eventTime": "2017-03-02T19:02:59.910959Z", + "space": {"name": "spaces/AAAAAAAAAAA", "type": "DM"}, + "user": { + "name": "users/12345678901234567890", + "displayName": "Chuck Norris", + "avatarUrl": "https://lh3.googleusercontent.com/.../photo.jpg", + "email": "chuck@example.com", + }, + } + + # from https://developers.google.com/hangouts/chat/reference/message-formats/events#message # noqa: E501 + MESSAGE = { + "type": "MESSAGE", + "eventTime": "2017-03-02T19:02:59.910959Z", + "space": { + "name": "spaces/AAAAAAAAAAA", + "displayName": "Chuck Norris Discussion Room", + "type": "ROOM", + }, + "message": { + "name": "spaces/AAAAAAAAAAA/messages/CCCCCCCCCCC", + "sender": { + "name": "users/12345678901234567890", + "displayName": "Chuck Norris", + "avatarUrl": "https://lh3.googleusercontent.com/.../photo.jpg", + "email": "chuck@example.com", + }, + "createTime": "2017-03-02T19:02:59.910959Z", + "text": "@TestBot Violence is my last option.", + "argumentText": " Violence is my last option.", + "thread": {"name": "spaces/AAAAAAAAAAA/threads/BBBBBBBBBBB"}, + "annotations": [ + { + "length": 8, + "startIndex": 0, + "userMention": { + "type": "MENTION", + "user": { + "avatarUrl": "https://.../avatar.png", + "displayName": "TestBot", + "name": "users/1234567890987654321", + "type": "BOT", + }, + }, + "type": "USER_MENTION", + } + ], + }, + "user": { + "name": "users/12345678901234567890", + "displayName": "Chuck Norris", + "avatarUrl": "https://lh3.googleusercontent.com/.../photo.jpg", + "email": "chuck@example.com", + }, + } + + from rasa.core.channels.hangouts import HangoutsInput + import rasa.core + + input_channel = HangoutsInput( + project_id="12345678901", + # intent name for bot added to direct message event + hangouts_user_added_intent_name="/added_dm", + # intent name for bot added to room event + hangouts_room_added_intent_name="/added_room", + # intent name for bot removed from space event + hangouts_removed_intent_name="/removed", + ) + + app = rasa.core.run.configure_app([input_channel], port=5004) + + # This causes irritating error even though test passes... + # req, _ = app.test_client.post("/webhooks/hangouts/webhook", + # data=json.dumps(MESSAGE)) + # ..therefore create Request object directly + from sanic.request import Request + + def create_req(app): + return Request( + b"http://127.0.0.1:42101/webhooks/hangouts/webhook", + [], + None, + "POST", + None, + app=app, + ) + + req = create_req(app) + req.body = bytes(json.dumps(MESSAGE), encoding="utf-8") + assert input_channel._extract_sender(req) == "Chuck Norris" + assert input_channel._extract_room(req) == "Chuck Norris Discussion Room" + assert input_channel._extract_message(req) == "@TestBot Violence is my last option." + + req = create_req(app) + req.body = bytes(json.dumps(ADDED_EVENT), encoding="utf-8") + assert input_channel._extract_sender(req) == "Chuck Norris" + assert input_channel._extract_room(req) == "Chuck Norris Discussion Room" + assert input_channel._extract_message(req) == "/added_room" + + req = create_req(app) + req.body = bytes(json.dumps(REMOVED_EVENT), encoding="utf-8") + assert input_channel._extract_sender(req) == "Chuck Norris" + assert input_channel._extract_room(req) is None + assert input_channel._extract_message(req) == "/removed" + + +@pytest.mark.asyncio +async def test_hangouts_output_channel_functions(): + + from rasa.core.channels.hangouts import HangoutsOutput + + output_channel = HangoutsOutput() + + # with every call to _persist_message, the messages attribute (dict) is altered, + # as Hangouts always expects a single dict as response + + await output_channel.send_text_message(recipient_id="Chuck Norris", text="Test:") + + assert len(output_channel.messages) == 1 + assert output_channel.messages["text"] == "Test:" + + await output_channel.send_attachment( + recipient_id="Chuck Norris", attachment="Attachment" + ) + + assert len(output_channel.messages) == 1 + # two text messages are appended with space inbetween + assert output_channel.messages["text"] == "Test: Attachment" + + await output_channel.send_quick_replies( + recipient_id="Chuck Norris", + text="Test passing?", + quick_replies=[ + {"title": "Yes", "payload": "/confirm"}, + {"title": "No", "payload": "/deny"}, + ], + ) + assert len(output_channel.messages) == 1 + # for text and cards, text is turned to card format and two cards are returned + assert ( + output_channel.messages["cards"][1]["sections"][0]["widgets"][0][ + "textParagraph" + ]["text"] + == "Test passing?" + ) + assert ( + output_channel.messages["cards"][1]["sections"][0]["widgets"][1]["buttons"][0][ + "textButton" + ]["onClick"]["action"]["actionMethodName"] + == "/confirm" + ) + + await output_channel.send_image_url(recipient_id="Chuck Norris", image="test.png") + assert len(output_channel.messages) == 1 + assert ( + output_channel.messages["cards"][2]["sections"][0]["widgets"][0]["image"][ + "imageUrl" + ] + == "test.png" + ) diff --git a/tests/core/channels/test_slack.py b/tests/core/channels/test_slack.py new file mode 100644 index 0000000..0f17e44 --- /dev/null +++ b/tests/core/channels/test_slack.py @@ -0,0 +1,987 @@ +from http import HTTPStatus +import json +import logging +import time +from typing import Any, Dict, Text +from unittest import mock +from unittest.mock import Mock, patch + +from aioresponses import aioresponses +import pytest +from sanic.compat import Header +from sanic.request import Request + +from rasa.core.channels import SlackInput +from rasa.core.channels.channel import UserMessage +from rasa.shared.exceptions import InvalidConfigException +from tests.utilities import json_of_latest_request, latest_request + +logger = logging.getLogger(__name__) + +SLACK_TEST_ATTACHMENT = { + "fallback": "Financial Advisor Summary", + "color": "#36a64f", + "author_name": "ABE", + "title": "Financial Advisor Summary", + "title_link": "http://tenfactorialrocks.com", + "image_url": "https://r.com/cancel/r12", + "thumb_url": "https://r.com/cancel/r12", + "actions": [ + { + "type": "button", + "text": "\ud83d\udcc8 Dashboard", + "url": "https://r.com/cancel/r12", + "style": "primary", + }, + { + "type": "button", + "text": "\ud83d\udccb Download XL", + "url": "https://r.com/cancel/r12", + "style": "danger", + }, + { + "type": "button", + "text": "\ud83d\udce7 E-Mail", + "url": "https://r.com/cancel/r12", + "style": "danger", + }, + ], + "footer": "Powered by 1010rocks", + "ts": 1531889719, +} + + +def test_slack_metadata(): + user = "user1" + channel = "channel1" + authed_users = ["XXXXXXX", "YYYYYYY", "ZZZZZZZ"] + ts = "1579802617.000800" + header = {"content-type": "application/json"} + direct_message_event = { + "authed_users": authed_users, + "event": { + "client_msg_id": "XXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + "type": "message", + "text": "hello world", + "user": user, + "ts": ts, + "team": "XXXXXXXXX", + "blocks": [ + { + "type": "rich_text", + "block_id": "XXXXX", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "hi"}], + } + ], + } + ], + "channel": channel, + "event_ts": "1579802617.000800", + "channel_type": "im", + }, + } + + input_channel = SlackInput( + slack_token="YOUR_SLACK_TOKEN", + slack_channel="YOUR_SLACK_CHANNEL", + slack_signing_secret="foobar", + ) + + r = Mock() + r.json = direct_message_event + r.headers = header + metadata = input_channel.get_metadata(request=r) + assert metadata["out_channel"] == channel + assert metadata["users"] == authed_users + assert metadata["thread_id"] == ts + + +def test_slack_form_metadata(): + user = "user1" + channel = "channel1" + authed_user = "XXXXXXX" + ts = "1579802617.000800" + header = {"content-type": "application/x-www-form-urlencoded"} + payload = { + "type": "block_actions", + "user": {"id": authed_user, "username": user, "name": "name"}, + "channel": {"id": channel}, + "message": { + "type": "message", + "text": "text", + "user": authed_user, + "ts": ts, + "blocks": [ + { + "type": "actions", + "block_id": "XXXXX", + "elements": [ + { + "type": "button", + "action_id": "XXXXX", + "text": {"type": "plain_text", "text": "text"}, + "value": "value", + } + ], + } + ], + }, + } + form_event = {"payload": [json.dumps(payload)]} + + input_channel = SlackInput( + slack_token="YOUR_SLACK_TOKEN", + slack_channel="YOUR_SLACK_CHANNEL", + slack_signing_secret="foobar", + ) + + r = Mock() + r.form = form_event + r.headers = header + metadata = input_channel.get_metadata(request=r) + assert metadata["out_channel"] == channel + assert metadata["users"][0] == authed_user + assert metadata["thread_id"] == ts + + +def test_slack_metadata_missing_keys(): + channel = "channel1" + ts = "1579802617.000800" + header = {"content-type": "application/json"} + direct_message_event = { + "event": { + "client_msg_id": "XXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + "type": "message", + "text": "hello world", + "ts": ts, + "team": "XXXXXXXXX", + "blocks": [ + { + "type": "rich_text", + "block_id": "XXXXX", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "hi"}], + } + ], + } + ], + "channel": channel, + "event_ts": "1579802617.000800", + "channel_type": "im", + } + } + + input_channel = SlackInput( + slack_token="YOUR_SLACK_TOKEN", + slack_channel="YOUR_SLACK_CHANNEL", + slack_signing_secret="foobar", + ) + + r = Mock() + r.json = direct_message_event + r.headers = header + metadata = input_channel.get_metadata(request=r) + assert metadata["users"] == [] + assert metadata["out_channel"] == channel + assert metadata["thread_id"] == ts + + +def test_slack_form_metadata_missing_keys(): + channel = "channel1" + ts = "1579802617.000800" + header = {"content-type": "application/x-www-form-urlencoded"} + payload = { + "type": "block_actions", + "channel": {"id": channel}, + "message": { + "type": "message", + "text": "text", + "ts": ts, + "blocks": [ + { + "type": "actions", + "block_id": "XXXXX", + "elements": [ + { + "type": "button", + "action_id": "XXXXX", + "text": {"type": "plain_text", "text": "text"}, + "value": "value", + } + ], + } + ], + }, + } + form_event = {"payload": [json.dumps(payload)]} + + input_channel = SlackInput( + slack_token="YOUR_SLACK_TOKEN", + slack_channel="YOUR_SLACK_CHANNEL", + slack_signing_secret="foobar", + ) + + r = Mock() + r.form = form_event + r.headers = header + metadata = input_channel.get_metadata(request=r) + assert metadata["users"] == [] + assert metadata["out_channel"] == channel + assert metadata["thread_id"] == ts + + +def test_slack_no_metadata(): + input_channel = SlackInput( + slack_token="YOUR_SLACK_TOKEN", + slack_channel="YOUR_SLACK_CHANNEL", + slack_signing_secret="foobar", + ) + + r = Mock() + metadata = input_channel.get_metadata(request=r) + assert metadata == {} + + +def test_slack_message_sanitization(): + test_uid = "17213535" + target_message_1 = "You can sit here if you want" + target_message_2 = "Hey, you can sit here if you want !" + target_message_3 = "Hey, you can sit here if you want!" + target_message_4 = "convert garbled url to vicdb-f.net" + target_message_5 = "convert multiple garbled url to vicdb-f.net. Also eemdb-p.net" + + uid_token = f"<@{test_uid}>" + raw_messages = [ + test.format(uid=uid_token) + for test in [ + "You can sit here {uid} if you want{uid}", + "{uid} You can sit here if you want{uid} ", + "{uid}You can sit here if you want {uid}", + # those last cases may be disputable + # as we're virtually altering the entered text, + # but this seem to be the correct course of action + # (to be decided) + "You can sit here{uid}if you want", + "Hey {uid}, you can sit here if you want{uid}!", + "Hey{uid} , you can sit here if you want {uid}!", + "convert garbled url to ", + "convert multiple garbled url to . " + "Also ", + ] + ] + + target_messages = [ + target_message_1, + target_message_1, + target_message_1, + target_message_1, + target_message_2, + target_message_3, + target_message_4, + target_message_5, + ] + + sanitized_messages = [ + SlackInput._sanitize_user_message(message, [test_uid]) + for message in raw_messages + ] + + # no message that is wrongly sanitized please + assert ( + len( + [ + sanitized + for sanitized, target in zip(sanitized_messages, target_messages) + if sanitized != target + ] + ) + == 0 + ) + + +def test_escape_called(): + with patch("re.escape") as mock_escape: + input_text = "Some text" + uids_to_remove = ["uid1"] + SlackInput._sanitize_user_message(input_text, uids_to_remove) + + # Check if re.escape was called with the expected argument + mock_escape.assert_called_with("uid1") + + +def test_slack_init_token_parameter(): + ch = SlackInput("xoxb-test", slack_signing_secret="foobar") + assert ch.slack_token == "xoxb-test" + assert ch.slack_channel is None + + +def test_slack_init_token_channel_parameters(): + ch = SlackInput("xoxb-test", "test", slack_signing_secret="foobar") + assert ch.slack_token == "xoxb-test" + assert ch.slack_channel == "test" + assert ch.conversation_granularity == "sender" + + +def test_slack_init_token_channel_conversation_granularity_parameters(): + ch = SlackInput( + "xoxb-test", + "test", + slack_signing_secret="foobar", + conversation_granularity="channel", + ) + assert ch.slack_token == "xoxb-test" + assert ch.slack_channel == "test" + assert ch.conversation_granularity == "channel" + + +def test_slack_init_token_channel_threads_parameters(): + ch = SlackInput( + "xoxb-test", + "test", + slack_signing_secret="foobar", + use_threads=True, + conversation_granularity="thread", + ) + assert ch.slack_token == "xoxb-test" + assert ch.slack_channel == "test" + assert ch.use_threads is True + assert ch.conversation_granularity == "thread" + + +def test_get_conversation_id_sender_id(): + ch = SlackInput( + "xoxb-test", + "test", + slack_signing_secret="foobar", + use_threads=True, + conversation_granularity="sender", + ) + conversation_id = ch._get_conversation_id( + "test_sender_id", "test_channel_id", "test_thread_id" + ) + assert conversation_id == "test_sender_id" + + +def test_get_conversation_id_channel_id(): + ch = SlackInput( + "xoxb-test", + "test", + slack_signing_secret="foobar", + use_threads=True, + conversation_granularity="channel", + ) + conversation_id = ch._get_conversation_id("test_sender_id", "test_channel_id", None) + assert conversation_id == "test_sender_id_test_channel_id" + + conversation_id = ch._get_conversation_id("test_sender_id", None, "test_thread_id") + assert conversation_id == "test_sender_id" + + +def test_get_conversation_id_thread_id(): + ch = SlackInput( + "xoxb-test", + "test", + slack_signing_secret="foobar", + use_threads=True, + conversation_granularity="thread", + ) + conversation_id = ch._get_conversation_id( + "test_sender_id", "test_channel_id", "test_thread_id" + ) + assert conversation_id == "test_sender_id_test_channel_id_test_thread_id" + + conversation_id = ch._get_conversation_id("test_sender_id", None, "test_thread_id") + assert conversation_id == "test_sender_id" + + conversation_id = ch._get_conversation_id("test_sender_id", "test_channel_id", None) + assert conversation_id == "test_sender_id" + + conversation_id = ch._get_conversation_id("test_sender_id", None, None) + assert conversation_id == "test_sender_id" + + +def test_is_slack_message_none(): + payload = {} + slack_message = json.loads(json.dumps(payload)) + assert SlackInput._is_user_message(slack_message) is False + + +def test_is_slack_message_true(): + event = { + "type": "message", + "channel": "C2147483705", + "user": "U2147483697", + "text": "Hello world", + "ts": "1355517523", + } + payload = json.dumps({"event": event}) + slack_message = json.loads(payload) + assert SlackInput._is_user_message(slack_message) is True + + +def test_is_slack_message_false(): + event = { + "type": "message", + "channel": "C2147483705", + "user": "U2147483697", + "text": "Hello world", + "ts": "1355517523", + "bot_id": "1355517523", + } + payload = json.dumps({"event": event}) + slack_message = json.loads(payload) + assert SlackInput._is_user_message(slack_message) is False + + +def test_slackbot_init_one_parameter(): + from rasa.core.channels.slack import SlackBot + + ch = SlackBot("DummyToken") + assert ch.client.token == "DummyToken" + assert ch.slack_channel is None + + +def test_slackbot_init_two_parameter(): + from rasa.core.channels.slack import SlackBot + + bot = SlackBot("DummyToken", "General") + assert bot.client.token == "DummyToken" + assert bot.slack_channel == "General" + + +def test_slackbot_init_three_parameter(): + from rasa.core.channels.slack import SlackBot + + bot = SlackBot("DummyToken", "General", thread_id="DummyThread") + assert bot.client.token == "DummyToken" + assert bot.slack_channel == "General" + assert bot.thread_id == "DummyThread" + + +# Use monkeypatch for sending attachments, images and plain text. +@pytest.mark.filterwarnings("ignore:unclosed.*:ResourceWarning") +@pytest.mark.asyncio +async def test_slackbot_send_attachment_only(): + from rasa.core.channels.slack import SlackBot + + with aioresponses() as mocked: + mocked.post( + "https://www.slack.com/api/chat.postMessage", + payload={"ok": True, "purpose": "Testing bots"}, + ) + + bot = SlackBot("DummyToken", "General") + attachment = SLACK_TEST_ATTACHMENT + + await bot.send_attachment("ID", attachment) + + r = latest_request(mocked, "POST", "https://www.slack.com/api/chat.postMessage") + + assert r + + request_params = json_of_latest_request(r) + + assert request_params == { + "channel": "General", + "as_user": True, + "attachments": [attachment], + } + + +@pytest.mark.filterwarnings("ignore:unclosed.*:ResourceWarning") +@pytest.mark.asyncio +async def test_slackbot_send_attachment_only_threaded(): + from rasa.core.channels.slack import SlackBot + + with aioresponses() as mocked: + mocked.post( + "https://www.slack.com/api/chat.postMessage", + payload={"ok": True, "purpose": "Testing bots"}, + ) + + bot = SlackBot("DummyToken", "General", thread_id="DummyThread") + attachment = SLACK_TEST_ATTACHMENT + + await bot.send_attachment("ID", attachment) + + r = latest_request(mocked, "POST", "https://www.slack.com/api/chat.postMessage") + + assert r + + request_params = json_of_latest_request(r) + + assert request_params == { + "channel": "General", + "as_user": True, + "attachments": [attachment], + "thread_ts": "DummyThread", + } + + +@pytest.mark.filterwarnings("ignore:unclosed.*:ResourceWarning") +@pytest.mark.asyncio +async def test_slackbot_send_attachment_with_text(): + from rasa.core.channels.slack import SlackBot + + with aioresponses() as mocked: + mocked.post( + "https://www.slack.com/api/chat.postMessage", + payload={"ok": True, "purpose": "Testing bots"}, + ) + + bot = SlackBot("DummyToken", "General") + attachment = SLACK_TEST_ATTACHMENT + attachment["text"] = "Here is the summary:" + + await bot.send_attachment("ID", attachment) + + r = latest_request(mocked, "POST", "https://www.slack.com/api/chat.postMessage") + + assert r + + request_params = json_of_latest_request(r) + + assert request_params == { + "channel": "General", + "as_user": True, + "attachments": [attachment], + } + + +@pytest.mark.filterwarnings("ignore:unclosed.*:ResourceWarning") +@pytest.mark.asyncio +async def test_slackbot_send_attachment_with_text_threaded(): + from rasa.core.channels.slack import SlackBot + + with aioresponses() as mocked: + mocked.post( + "https://www.slack.com/api/chat.postMessage", + payload={"ok": True, "purpose": "Testing bots"}, + ) + + bot = SlackBot("DummyToken", "General", thread_id="DummyThread") + attachment = SLACK_TEST_ATTACHMENT + attachment["text"] = "Here is the summary:" + + await bot.send_attachment("ID", attachment) + + r = latest_request(mocked, "POST", "https://www.slack.com/api/chat.postMessage") + + assert r + + request_params = json_of_latest_request(r) + + assert request_params == { + "channel": "General", + "as_user": True, + "attachments": [attachment], + "thread_ts": "DummyThread", + } + + +@pytest.mark.filterwarnings("ignore:unclosed.*:ResourceWarning") +@pytest.mark.asyncio +async def test_slackbot_send_image_url(): + from rasa.core.channels.slack import SlackBot + + with aioresponses() as mocked: + mocked.post( + "https://www.slack.com/api/chat.postMessage", + payload={"ok": True, "purpose": "Testing bots"}, + ) + + bot = SlackBot("DummyToken", "General") + url = "http://www.rasa.net" + await bot.send_image_url("ID", url) + + r = latest_request(mocked, "POST", "https://www.slack.com/api/chat.postMessage") + + assert r + + request_params = json_of_latest_request(r) + + assert request_params["as_user"] is True + assert request_params["channel"] == "General" + assert len(request_params["blocks"]) == 1 + assert request_params["blocks"][0].get("type") == "image" + assert request_params["blocks"][0].get("alt_text") == "http://www.rasa.net" + assert request_params["blocks"][0].get("image_url") == "http://www.rasa.net" + + +@pytest.mark.filterwarnings("ignore:unclosed.*:ResourceWarning") +@pytest.mark.asyncio +async def test_slackbot_send_image_url_threaded(): + from rasa.core.channels.slack import SlackBot + + with aioresponses() as mocked: + mocked.post( + "https://www.slack.com/api/chat.postMessage", + payload={"ok": True, "purpose": "Testing bots"}, + ) + + bot = SlackBot("DummyToken", "General", thread_id="DummyThread") + url = "http://www.rasa.net" + await bot.send_image_url("ID", url) + + r = latest_request(mocked, "POST", "https://www.slack.com/api/chat.postMessage") + + assert r + + request_params = json_of_latest_request(r) + + assert request_params["as_user"] is True + assert request_params["channel"] == "General" + assert request_params["thread_ts"] == "DummyThread" + assert len(request_params["blocks"]) == 1 + assert request_params["blocks"][0].get("type") == "image" + assert request_params["blocks"][0].get("alt_text") == "http://www.rasa.net" + assert request_params["blocks"][0].get("image_url") == "http://www.rasa.net" + + +@pytest.mark.filterwarnings("ignore:unclosed.*:ResourceWarning") +@pytest.mark.asyncio +async def test_slackbot_send_text(): + from rasa.core.channels.slack import SlackBot + + with aioresponses() as mocked: + mocked.post( + "https://www.slack.com/api/chat.postMessage", + payload={"ok": True, "purpose": "Testing bots"}, + ) + + bot = SlackBot("DummyToken", "General") + await bot.send_text_message("ID", "my message") + + r = latest_request(mocked, "POST", "https://www.slack.com/api/chat.postMessage") + + assert r + + request_params = json_of_latest_request(r) + + assert request_params == { + "as_user": True, + "channel": "General", + "text": "my message", + "type": "mrkdwn", + } + + +@pytest.mark.filterwarnings("ignore:unclosed.*:ResourceWarning") +@pytest.mark.asyncio +async def test_slackbot_send_text_threaded(): + from rasa.core.channels.slack import SlackBot + + with aioresponses() as mocked: + mocked.post( + "https://www.slack.com/api/chat.postMessage", + payload={"ok": True, "purpose": "Testing bots"}, + ) + + bot = SlackBot("DummyToken", "General", thread_id="DummyThread") + await bot.send_text_message("ID", "my message") + + r = latest_request(mocked, "POST", "https://www.slack.com/api/chat.postMessage") + + assert r + + request_params = json_of_latest_request(r) + + assert request_params == { + "as_user": True, + "channel": "General", + "text": "my message", + "type": "mrkdwn", + "thread_ts": "DummyThread", + } + + +@pytest.mark.filterwarnings("ignore:unclosed.*:ResourceWarning") +@pytest.mark.asyncio +async def test_slackbot_send_text_with_buttons(): + from rasa.core.channels.slack import SlackBot + + with aioresponses() as mocked: + mocked.post( + "https://www.slack.com/api/chat.postMessage", + payload={"ok": True, "purpose": "Testing bots"}, + ) + + bot = SlackBot("DummyToken", "General") + buttons = [{"title": "title", "payload": "payload"}] + + await bot.send_text_with_buttons("ID", "my message", buttons) + + r = latest_request(mocked, "POST", "https://www.slack.com/api/chat.postMessage") + + assert r + + request_params = json_of_latest_request(r) + + text_block = { + "type": "section", + "text": {"type": "plain_text", "text": "my message"}, + } + button_block = { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "title"}, + "value": "payload", + } + ], + } + assert request_params == { + "as_user": True, + "channel": "General", + "text": "my message", + "blocks": [text_block, button_block], + } + + +@pytest.mark.filterwarnings("ignore:unclosed.*:ResourceWarning") +@pytest.mark.asyncio +async def test_slackbot_send_text_with_buttons_threaded(): + from rasa.core.channels.slack import SlackBot + + with aioresponses() as mocked: + mocked.post( + "https://www.slack.com/api/chat.postMessage", + payload={"ok": True, "purpose": "Testing bots"}, + ) + + bot = SlackBot("DummyToken", "General", thread_id="DummyThread") + buttons = [{"title": "title", "payload": "payload"}] + + await bot.send_text_with_buttons("ID", "my message", buttons) + + r = latest_request(mocked, "POST", "https://www.slack.com/api/chat.postMessage") + + assert r + + request_params = json_of_latest_request(r) + + text_block = { + "type": "section", + "text": {"type": "plain_text", "text": "my message"}, + } + button_block = { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "title"}, + "value": "payload", + } + ], + } + assert request_params == { + "as_user": True, + "channel": "General", + "text": "my message", + "blocks": [text_block, button_block], + "thread_ts": "DummyThread", + } + + +@pytest.mark.filterwarnings("ignore:unclosed.*:ResourceWarning") +@pytest.mark.asyncio +async def test_slackbot_send_custom_json(): + from rasa.core.channels.slack import SlackBot + + with aioresponses() as mocked: + mocked.post( + "https://www.slack.com/api/chat.postMessage", + payload={"ok": True, "purpose": "Testing bots"}, + ) + + bot = SlackBot("DummyToken", "General") + await bot.send_custom_json("ID", {"test_key": "test_value"}) + + r = latest_request(mocked, "POST", "https://www.slack.com/api/chat.postMessage") + + assert r + + request_params = json_of_latest_request(r) + + assert request_params == { + "as_user": True, + "channel": "General", + "test_key": "test_value", + } + + +@pytest.mark.filterwarnings("ignore:unclosed.*:ResourceWarning") +@pytest.mark.asyncio +async def test_slackbot_send_custom_json_threaded(): + from rasa.core.channels.slack import SlackBot + + with aioresponses() as mocked: + mocked.post( + "https://www.slack.com/api/chat.postMessage", + payload={"ok": True, "purpose": "Testing bots"}, + ) + + bot = SlackBot("DummyToken", "General", thread_id="DummyThread") + await bot.send_custom_json("ID", {"test_key": "test_value"}) + + r = latest_request(mocked, "POST", "https://www.slack.com/api/chat.postMessage") + + assert r + + request_params = json_of_latest_request(r) + + assert request_params == { + "as_user": True, + "channel": "General", + "thread_ts": "DummyThread", + "test_key": "test_value", + } + + +def prepare_slack_request(headers: Dict[Text, Any]) -> Request: + request = Request( + b"/webhooks/slack/webhook", + headers=Header(headers), + version="1.1", + method="POST", + transport=None, + app=None, + ) + request.body = b"""{"foo": "bar"}""" + return request + + +def test_slack_fails_if_signature_is_missing(): + with pytest.raises(InvalidConfigException): + SlackInput("mytoken") + + +@mock.patch("time.time", mock.MagicMock(return_value=1604586653)) +def test_slack_verify_signature(): + request = prepare_slack_request( + { + "x-slack-signature": "v0=80a3bd62ce5af04d8d80781134f165df" + "185b90342d467abf5c74a27d2d0dd1f5", + "x-slack-request-timestamp": str(int(time.time())), + } + ) + input_with_right_secret = SlackInput("mytoken", slack_signing_secret="foobar") + + assert input_with_right_secret.is_request_from_slack_authentic(request) is True + + +def test_slack_fail_on_old_timestamp(): + request = prepare_slack_request( + { + "x-slack-signature": "v0=80a3bd62ce5af04d8d80781134f165df" + "185b90342d467abf5c74a27d2d0dd1f5", + "x-slack-request-timestamp": str(int(time.time()) - 10 * 60), + } + ) + input_with_right_secret = SlackInput("mytoken", slack_signing_secret="foobar") + + assert input_with_right_secret.is_request_from_slack_authentic(request) is False + + +def test_slack_handles_invalid_timestamp(): + request = prepare_slack_request( + { + "x-slack-signature": "v0=80a3bd62ce5af04d8d80781134f165df" + "185b90342d467abf5c74a27d2d0dd1f5", + "x-slack-request-timestamp": "foobar", + } + ) + input_with_right_secret = SlackInput("mytoken", slack_signing_secret="foobar") + + assert input_with_right_secret.is_request_from_slack_authentic(request) is False + + +def test_slack_verify_wrong_signature(): + request = prepare_slack_request( + { + "x-slack-signature": "v0=80a3bd62ce5af04d8d80781134f165df" + "185b90342d467abf5c74a27d2d0dd1f5", + "x-slack-request-timestamp": str(int(time.time())), + } + ) + input_with_wrong_secret = SlackInput("mytoken", slack_signing_secret="foobaz") + + assert input_with_wrong_secret.is_request_from_slack_authentic(request) is False + + +def test_slack_verify_signature_missing_headers(): + request = prepare_slack_request( + { + # let's check what happens if verification headers are missing + } + ) + slack = SlackInput("mytoken", slack_signing_secret="foobar") + + assert slack.is_request_from_slack_authentic(request) is False + + +async def fake_on_new_message(message: UserMessage): + pass + + +@pytest.mark.asyncio +async def test_slack_process_message_retry(): + input_channel = SlackInput( + slack_token="YOUR_SLACK_TOKEN", + slack_channel="YOUR_SLACK_CHANNEL", + slack_signing_secret="foobar", + ) + + request = Mock() + request.headers = { + input_channel.retry_num_header: 1, + input_channel.retry_reason_header: input_channel.errors_ignore_retry[0], + } + + response = await input_channel.process_message( + request=request, + on_new_message=fake_on_new_message, + text="", + sender_id=None, + metadata=None, + ) + + assert response.status == HTTPStatus.CREATED + assert response.headers == {"X-Slack-No-Retry": "1"} + + +async def fake_on_new_message_sleep(message: UserMessage): + time.sleep(3) + + +@pytest.mark.asyncio +async def test_slack_process_message_timeout(): + input_channel = SlackInput( + slack_token="YOUR_SLACK_TOKEN", + slack_channel="YOUR_SLACK_CHANNEL", + slack_signing_secret="foobar", + ) + + request = Mock() + request.headers = {} + + start = time.time() + response = await input_channel.process_message( + request=request, + on_new_message=fake_on_new_message_sleep, + text="", + sender_id=None, + metadata=None, + ) + end = time.time() + + duration = end - start + + assert duration < 3 + assert response.status == HTTPStatus.OK diff --git a/tests/core/channels/test_telegram.py b/tests/core/channels/test_telegram.py new file mode 100644 index 0000000..04fbfb1 --- /dev/null +++ b/tests/core/channels/test_telegram.py @@ -0,0 +1,64 @@ +import json +import logging +from unittest.mock import patch + +import rasa.core.run +from rasa.core.channels import TelegramInput +from rasa.core.channels.telegram import TelegramOutput +from rasa.core.agent import Agent + +logger = logging.getLogger(__name__) + + +async def noop(*args, **kwargs): + """Just do nothing.""" + pass + + +async def mock_get_me(self): + self.username = "YOUR_TELEGRAM_BOT" + return self + + +@patch.object(TelegramOutput, "set_webhook", noop) +@patch.object(TelegramOutput, "get_me", mock_get_me) +def test_telegram_edit_message(): + telegram_test_edited_message = { + "update_id": 280069275, + "edited_message": { + "message_id": 591, + "from": { + "id": 1760450482, + "is_bot": "False", + "first_name": "Martin", + "last_name": "Man", + "language_code": "en", + }, + "chat": { + "id": 1760450482, + "first_name": "Martin", + "last_name": "Man", + "type": "private", + }, + "date": 1621577771, + "edit_date": 1621580124, + "text": "Hello!", + }, + } + + input_channel = TelegramInput( + # you get this when setting up a bot + access_token="123:YOUR_ACCESS_TOKEN", + # this is your bots username + verify="YOUR_TELEGRAM_BOT", + # the url your bot should listen for messages + webhook_url="YOUR_WEBHOOK_URL", + ) + + app = rasa.core.run.configure_app([input_channel], port=5004) + app.ctx.agent = Agent() + _, res = app.test_client.post( + "/webhooks/telegram/webhook", json=json.dumps(telegram_test_edited_message) + ) + + assert res.status_code == 200 diff --git a/tests/core/channels/test_twilio.py b/tests/core/channels/test_twilio.py new file mode 100644 index 0000000..90bd963 --- /dev/null +++ b/tests/core/channels/test_twilio.py @@ -0,0 +1,21 @@ +import logging +from rasa.core import utils, run +from rasa.core.channels.twilio import TwilioInput + +logger = logging.getLogger(__name__) + + +def test_twilio_channel(): + """Twilio channel test.""" + input_channel = TwilioInput( + account_sid="ACCOUNT_SID", + # Find your Account SID and Auth Token at twilio.com/console + auth_token="AUTH_TOKEN", + # Phone Number you want to use + twilio_number="TWILIO_NUMBER", + ) + s = run.configure_app([input_channel], port=5011) + routes_list = utils.list_routes(s) + + assert routes_list["twilio_webhook.health"].startswith("/webhooks/twilio") + assert routes_list["twilio_webhook.message"].startswith("/webhooks/twilio/webhook") diff --git a/tests/core/channels/test_twilio_voice.py b/tests/core/channels/test_twilio_voice.py new file mode 100644 index 0000000..be2a7ef --- /dev/null +++ b/tests/core/channels/test_twilio_voice.py @@ -0,0 +1,331 @@ +import logging + +import pytest +from http import HTTPStatus + +from rasa import server +from rasa.core.agent import Agent +from rasa.core.channels import channel +from rasa.shared.exceptions import InvalidConfigException, RasaException +from rasa.core.channels.twilio_voice import TwilioVoiceInput +from rasa.core.channels.twilio_voice import TwilioVoiceCollectingOutputChannel +from typing import Text, Any, Dict, Type + +logger = logging.getLogger(__name__) + + +async def test_twilio_voice_twiml_response_text(): + + inputs = { + "initial_prompt": "hello", + "reprompt_fallback_phrase": "i didn't get that", + "speech_model": "default", + "speech_timeout": "5", + "assistant_voice": "woman", + "enhanced": "false", + } + + tv = TwilioVoiceInput(**inputs) + + output_channel = TwilioVoiceCollectingOutputChannel() + + await output_channel.send_text_message(recipient_id="Chuck Norris", text="Test:") + assert len(output_channel.messages) == 1 + assert output_channel.messages[0]["text"] == "Test:" + + twiml = tv._build_twilio_voice_response(output_channel.messages) + assert ( + str(twiml) == '' + '' + "Test:" + ) + + +async def test_twilio_voice_twiml_response_buttons(): + + inputs = { + "initial_prompt": "hello", + "reprompt_fallback_phrase": "i didn't get that", + "speech_model": "default", + "speech_timeout": "5", + "assistant_voice": "woman", + "enhanced": "false", + } + + tv = TwilioVoiceInput(**inputs) + + output_channel = TwilioVoiceCollectingOutputChannel() + await output_channel.send_text_with_buttons( + recipient_id="Chuck Norris", + text="Buttons:", + buttons=[ + {"title": "Yes", "payload": "/affirm"}, + {"title": "No", "payload": "/deny"}, + ], + ) + assert len(output_channel.messages) == 3 + message_str = " ".join([m["text"] for m in output_channel.messages]) + assert message_str == "Buttons: Yes No" + + twiml = tv._build_twilio_voice_response(output_channel.messages) + assert ( + str(twiml) == '' + 'Buttons:' + 'Yes' + '' + 'No' + ) + + +@pytest.mark.parametrize( + "configs, expected", + [ + ( + { + "initial_prompt": "hello", + "reprompt_fallback_phrase": "i didn't get that", + "speech_model": "default", + "speech_timeout": "5", + "assistant_voice": "alien", + "enhanced": "false", + }, + InvalidConfigException, + ), + ( + { + "initial_prompt": "hello", + "reprompt_fallback_phrase": "i didn't get that", + "speech_model": "default", + "speech_timeout": "not a number", + "assistant_voice": "woman", + "enhanced": "false", + }, + InvalidConfigException, + ), + ( + { + "initial_prompt": "hello", + "reprompt_fallback_phrase": "i didn't get that", + "speech_model": "default", + "speech_timeout": "auto", + "assistant_voice": "woman", + "enhanced": "wrong", + }, + InvalidConfigException, + ), + ( + { + "initial_prompt": "hello", + "reprompt_fallback_phrase": "i didn't get that", + "speech_model": "default", + "speech_timeout": "5", + "assistant_voice": "woman", + "enhanced": "true", + }, + InvalidConfigException, + ), + ( + { + "initial_prompt": "hello", + "reprompt_fallback_phrase": "i didn't get that", + "assistant_voice": "woman", + "enhanced": "true", + "speech_model": "default", + "speech_timeout": "auto", + }, + InvalidConfigException, + ), + ( + { + "initial_prompt": "hello", + "reprompt_fallback_phrase": "i didn't get that", + "assistant_voice": "woman", + "enhanced": "true", + "speech_model": "phone_call", + "speech_timeout": "auto", + }, + InvalidConfigException, + ), + ], +) +def test_invalid_configs(configs: Dict[Text, Any], expected: Type[RasaException]): + with pytest.raises(expected): + TwilioVoiceInput(**configs) + + +async def test_twilio_voice_remove_image(): + + with pytest.warns(UserWarning): + output_channel = TwilioVoiceCollectingOutputChannel() + await output_channel.send_response( + recipient_id="Chuck Norris", + message={"image": "https://i.imgur.com/nGF1K8f.jpg", "text": "Some text."}, + ) + + +async def test_twilio_voice_keep_image_text(): + + output_channel = TwilioVoiceCollectingOutputChannel() + await output_channel.send_response( + recipient_id="Chuck Norris", + message={"image": "https://i.imgur.com/nGF1K8f.jpg", "text": "Some text."}, + ) + assert len(output_channel.messages) == 1 + assert output_channel.messages[0]["text"] == "Some text." + + +async def test_twilio_emoji_warning(): + + with pytest.warns(UserWarning): + output_channel = TwilioVoiceCollectingOutputChannel() + await output_channel.send_response( + recipient_id="User", message={"text": "Howdy 😀"} + ) + + +async def test_twilio_voice_multiple_responses(): + + inputs = { + "initial_prompt": "hello", + "reprompt_fallback_phrase": "i didn't get that", + "speech_model": "default", + "speech_timeout": "5", + "assistant_voice": "woman", + "enhanced": "false", + } + + tv = TwilioVoiceInput(**inputs) + + output_channel = TwilioVoiceCollectingOutputChannel() + + await output_channel.send_text_message( + recipient_id="Chuck Norris", text="message 1" + ) + await output_channel.send_text_message( + recipient_id="Chuck Norris", text="message 2" + ) + assert len(output_channel.messages) == 2 + assert output_channel.messages[0]["text"] == "message 1" + assert output_channel.messages[1]["text"] == "message 2" + + twiml = tv._build_twilio_voice_response(output_channel.messages) + + assert ( + str(twiml) == '' + 'message 1' + '' + 'message 2' + ) + + +async def test_twilio_receive_answer(stack_agent: Agent): + app = server.create_app(agent=stack_agent) + + inputs = { + "initial_prompt": "hello", + "reprompt_fallback_phrase": "i didn't get that", + "speech_model": "default", + "speech_timeout": "5", + "assistant_voice": "woman", + "enhanced": "false", + } + + tv = TwilioVoiceInput(**inputs) + channel.register([tv], app, "/webhooks/") + + client = app.asgi_client + + body = {"From": "Tobias", "CallStatus": "ringing"} + _, response = await client.post( + "/webhooks/twilio_voice/webhook", + headers={"Content-type": "application/x-www-form-urlencoded"}, + data=body, + ) + assert response.status == HTTPStatus.OK + # Actual test xml content + assert ( + response.body == b'' + b'' + b'hey there None!' + ) + + +async def test_twilio_receive_no_response(stack_agent: Agent): + app = server.create_app(agent=stack_agent) + + inputs = { + "initial_prompt": "hello", + "reprompt_fallback_phrase": "i didn't get that", + "speech_model": "default", + "speech_timeout": "5", + "assistant_voice": "woman", + "enhanced": "false", + } + + tv = TwilioVoiceInput(**inputs) + channel.register([tv], app, "/webhooks/") + + client = app.asgi_client + + body = {"From": "Matthew", "CallStatus": "ringing"} + _, response = await client.post( + "/webhooks/twilio_voice/webhook", + headers={"Content-type": "application/x-www-form-urlencoded"}, + data=body, + ) + assert response.status == HTTPStatus.OK + assert response.body + + body = {"From": "Matthew", "CallStatus": "answered"} + _, response = await client.post( + "/webhooks/twilio_voice/webhook", + headers={"Content-type": "application/x-www-form-urlencoded"}, + data=body, + ) + + assert response.status == HTTPStatus.OK + assert ( + response.body == b'' + b'' + b'hey there None!' + ) + + +async def test_twilio_receive_no_previous_response(stack_agent: Agent): + app = server.create_app(agent=stack_agent) + + inputs = { + "initial_prompt": "hello", + "reprompt_fallback_phrase": "i didn't get that", + "speech_model": "default", + "speech_timeout": "5", + "assistant_voice": "woman", + "enhanced": "false", + } + + tv = TwilioVoiceInput(**inputs) + channel.register([tv], app, "/webhooks/") + + client = app.asgi_client + + body = {"From": "Ray", "CallStatus": "answered"} + _, response = await client.post( + "/webhooks/twilio_voice/webhook", + headers={"Content-type": "application/x-www-form-urlencoded"}, + data=body, + ) + + assert response.status == HTTPStatus.OK + assert ( + response.body == b'' + b'' + b'i didn\'t get that' + ) diff --git a/tests/core/conftest.py b/tests/core/conftest.py new file mode 100644 index 0000000..ff69c4a --- /dev/null +++ b/tests/core/conftest.py @@ -0,0 +1,188 @@ +import asyncio + +from rasa.utils.endpoints import EndpointConfig +from sanic.request import Request +import uuid +from datetime import datetime + +from typing import Generator, Callable, Dict, Text + +from scipy import sparse + +import pytest + +from rasa.core.agent import Agent +from rasa.core.channels.channel import CollectingOutputChannel, OutputChannel +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ReminderScheduled, UserUttered, ActionExecuted +from rasa.core.nlg import TemplatedNaturalLanguageGenerator, NaturalLanguageGenerator +from rasa.core.processor import MessageProcessor +from rasa.shared.core.slots import Slot +from rasa.core.tracker_store import MongoTrackerStore +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.nlu.training_data.features import Features +from rasa.shared.nlu.constants import INTENT, ACTION_NAME, FEATURE_TYPE_SENTENCE +from tests.dialogues import TEST_MOODBOT_DIALOGUE +from tests.core.utilities import tracker_from_dialogue + + +class CustomSlot(Slot): + type_name = "custom" + + def _as_feature(self): + return [0.5] + + +class MockedMongoTrackerStore(MongoTrackerStore): + """In-memory mocked version of `MongoTrackerStore`.""" + + def __init__(self, _domain: Domain) -> None: + from mongomock import MongoClient + + self.db = MongoClient().rasa + self.collection = "conversations" + + # skipcq: PYL-E1003 + # Skip `MongoTrackerStore` constructor to avoid that actual Mongo connection + # is created. + super(MongoTrackerStore, self).__init__(_domain, None) + + +# https://github.com/pytest-dev/pytest-asyncio/issues/68 +# this event_loop is used by pytest-asyncio, and redefining it +# is currently the only way of changing the scope of this fixture +# update: implement fix to RuntimeError Event loop is closed issue described +# here: https://github.com/pytest-dev/pytest-asyncio/issues/371 +@pytest.fixture(scope="session") +def event_loop(request: Request) -> Generator[asyncio.AbstractEventLoop, None, None]: + loop = asyncio.get_event_loop_policy().new_event_loop() + loop._close = loop.close + loop.close = lambda: None + yield loop + loop._close() + + +# override loop fixture to prevent ScopeMismatch pytest error and +# align the result of the loop fixture with that of the event_loop fixture +@pytest.fixture(scope="session") +def loop( + event_loop: asyncio.AbstractEventLoop, +) -> Generator[asyncio.AbstractEventLoop, None, None]: + yield event_loop + + +@pytest.fixture +def default_channel() -> OutputChannel: + return CollectingOutputChannel() + + +@pytest.fixture +async def default_processor(default_agent: Agent) -> MessageProcessor: + return default_agent.processor + + +@pytest.fixture +async def tracker_with_six_scheduled_reminders( + default_processor: MessageProcessor, +) -> DialogueStateTracker: + reminders = [ + ReminderScheduled("greet", datetime.now(), kill_on_user_message=False), + ReminderScheduled( + intent="greet", + entities=[{"entity": "name", "value": "Jane Doe"}], + trigger_date_time=datetime.now(), + kill_on_user_message=False, + ), + ReminderScheduled( + intent="default", + entities=[{"entity": "name", "value": "Jane Doe"}], + trigger_date_time=datetime.now(), + kill_on_user_message=False, + ), + ReminderScheduled( + intent="greet", + entities=[{"entity": "name", "value": "Bruce Wayne"}], + trigger_date_time=datetime.now(), + kill_on_user_message=False, + ), + ReminderScheduled("default", datetime.now(), kill_on_user_message=False), + ReminderScheduled( + "default", datetime.now(), kill_on_user_message=False, name="special" + ), + ] + sender_id = uuid.uuid4().hex + tracker = await default_processor.tracker_store.get_or_create_tracker(sender_id) + for reminder in reminders: + tracker.update(UserUttered("test")) + tracker.update(ActionExecuted("action_reminder_reminder")) + tracker.update(reminder) + + await default_processor.tracker_store.save(tracker) + + return tracker + + +@pytest.fixture +def default_nlg(domain: Domain) -> NaturalLanguageGenerator: + return TemplatedNaturalLanguageGenerator(domain.responses) + + +@pytest.fixture +def default_tracker(domain: Domain) -> DialogueStateTracker: + return DialogueStateTracker("my-sender", domain.slots) + + +@pytest.fixture(scope="session") +async def trained_formbot(trained_async: Callable) -> Text: + return await trained_async( + domain="examples/formbot/domain.yml", + config="examples/formbot/config.yml", + training_files=[ + "examples/formbot/data/rules.yml", + "examples/formbot/data/stories.yml", + ], + ) + + +@pytest.fixture(scope="module") +async def form_bot_agent(trained_formbot: Text) -> Agent: + endpoint = EndpointConfig("https://example.com/webhooks/actions") + + return Agent.load(trained_formbot, action_endpoint=endpoint) + + +@pytest.fixture +def moodbot_features( + request: Request, moodbot_domain: Domain +) -> Dict[Text, Dict[Text, Features]]: + """Makes intent and action features for the moodbot domain to faciliate + making expected state features. + + Returns: + A dict containing dicts for mapping action and intent names to features. + """ + origin = getattr(request, "param", "SingleStateFeaturizer") + action_shape = (1, len(moodbot_domain.action_names_or_texts)) + actions = {} + for index, action in enumerate(moodbot_domain.action_names_or_texts): + actions[action] = Features( + sparse.coo_matrix(([1.0], [[0], [index]]), shape=action_shape), + FEATURE_TYPE_SENTENCE, + ACTION_NAME, + origin, + ) + intent_shape = (1, len(moodbot_domain.intents)) + intents = {} + for index, intent in enumerate(moodbot_domain.intents): + intents[intent] = Features( + sparse.coo_matrix(([1.0], [[0], [index]]), shape=intent_shape), + FEATURE_TYPE_SENTENCE, + INTENT, + origin, + ) + return {"intents": intents, "actions": actions} + + +@pytest.fixture +def moodbot_tracker(moodbot_domain: Domain) -> DialogueStateTracker: + return tracker_from_dialogue(TEST_MOODBOT_DIALOGUE, moodbot_domain) diff --git a/tests/core/evaluation/__init__.py b/tests/core/evaluation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/evaluation/test_marker.py b/tests/core/evaluation/test_marker.py new file mode 100644 index 0000000..2f3916a --- /dev/null +++ b/tests/core/evaluation/test_marker.py @@ -0,0 +1,749 @@ +import csv +from typing import List, Optional, Set, Text, Tuple, Type, Any +import itertools +from pathlib import Path + +import pytest +import numpy as np + +import rasa.shared.utils.io +from rasa.core.evaluation.marker import ( + IntentDetectedMarker, + SlotSetMarker, + OccurrenceMarker, + ActionExecutedMarker, + AndMarker, + OrMarker, + NotMarker, + SequenceMarker, +) +from rasa.core.evaluation.marker_base import ( + OperatorMarker, + Marker, + ConditionMarker, + InvalidMarkerConfig, +) +from rasa.shared.core.constants import ACTION_SESSION_START_NAME +from rasa.shared.core.events import SlotSet, ActionExecuted, UserUttered, SessionStarted +from rasa.shared.nlu.constants import INTENT_NAME_KEY +from rasa.shared.core.slots import TextSlot +from rasa.core.evaluation.marker_tracker_loader import MarkerTrackerLoader +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.core.tracker_store import InMemoryTrackerStore +from rasa.shared.core.domain import Domain + +CONDITION_MARKERS = [ActionExecutedMarker, SlotSetMarker, IntentDetectedMarker] +OPERATOR_MARKERS = [AndMarker, OrMarker, SequenceMarker, OccurrenceMarker] + + +def test_marker_from_config(): + config = { + AndMarker.positive_tag(): [ + {SlotSetMarker.positive_tag(): "s1"}, + { + OrMarker.positive_tag(): [ + {IntentDetectedMarker.positive_tag(): "4"}, + {IntentDetectedMarker.negated_tag(): "6"}, + ] + }, + ] + } + + marker = Marker.from_config(config) + assert isinstance(marker, AndMarker) + assert isinstance(marker.sub_markers[0], SlotSetMarker) + or_marker = marker.sub_markers[1] + assert isinstance(or_marker, OrMarker) + for sub_marker in or_marker.sub_markers: + assert isinstance(sub_marker, ConditionMarker) + + +@pytest.mark.parametrize("marker_class", CONDITION_MARKERS) +def test_condition_negated_to_str(marker_class: Type[ConditionMarker]): + marker = marker_class("intent1", negated=True) + if marker.negated_tag() is not None: + assert marker.negated_tag() in str(marker) + + +@pytest.mark.parametrize( + "condition_marker_type, negated", + itertools.product(CONDITION_MARKERS, [False, True]), +) +def test_condition(condition_marker_type: Type[ConditionMarker], negated: bool): + """Each marker applies an exact number of times (slots are immediately un-set).""" + marker = condition_marker_type( + text="same-text", name="marker_name", negated=negated + ) + events = [ + UserUttered(intent={"name": "1"}), + UserUttered(intent={"name": "same-text"}), + SlotSet("same-text", value="any"), + SlotSet("same-text", value=None), + ActionExecuted(action_name="same-text"), + ] + num_non_negated_condition_applies = 3 + events = events * num_non_negated_condition_applies + for event in events: + marker.track(event) + assert len(marker.history) == len(events) + expected = ( + num_non_negated_condition_applies + if not negated + else (len(events) - num_non_negated_condition_applies) + ) + assert sum(marker.history) == expected + + +@pytest.mark.parametrize("condition_marker_type", CONDITION_MARKERS) +def test_condition_evaluate_events(condition_marker_type: Type[ConditionMarker]): + """Each marker applies an exact number of times (slots are immediately un-set).""" + events = [ + UserUttered(intent={INTENT_NAME_KEY: "1"}), + UserUttered(intent={INTENT_NAME_KEY: "same-text"}), + SlotSet("same-text", value="any"), + SlotSet("same-text", value=None), + ActionExecuted(action_name="same-text"), + ] + num_applies = 3 + events = events * num_applies + marker = condition_marker_type(text="same-text", name="marker_name") + evaluation = marker.evaluate_events(events) + assert len(evaluation) == 1 + assert "marker_name" in evaluation[0] + if condition_marker_type == IntentDetectedMarker: + expected = [1, 3, 5] + else: + expected = [2, 4, 6] + + actual_preceding_user_turns = [ + meta_data.preceding_user_turns for meta_data in evaluation[0]["marker_name"] + ] + assert actual_preceding_user_turns == expected + + +@pytest.mark.parametrize("marker_class", OPERATOR_MARKERS) +def test_operator_negated_to_str(marker_class: Type[OperatorMarker]): + marker = marker_class([IntentDetectedMarker("bla")], negated=True) + if marker.negated_tag() is not None: + assert marker.negated_tag() in str(marker) + + +@pytest.mark.parametrize( + "operator_class, negated", + [ + (operator_class, negated) + for operator_class, negated in itertools.product( + OPERATOR_MARKERS, [True, False] + ) + if operator_class.expected_number_of_sub_markers() is not None + ], +) +def test_operator_raises_wrong_amount_sub_markers( + operator_class: Type[OperatorMarker], negated: bool +): + expected_number = operator_class.expected_number_of_sub_markers() + one_more_than_expected = [ + IntentDetectedMarker("bla") for _ in range(expected_number + 1) + ] + with pytest.raises(InvalidMarkerConfig): + operator_class(one_more_than_expected, name="marker_name", negated=negated) + + +@pytest.mark.parametrize("negated", [True, False]) +def test_operator_or(negated: bool): + events = [ + UserUttered(intent={INTENT_NAME_KEY: "1"}), + UserUttered(intent={INTENT_NAME_KEY: "unknown"}), + UserUttered(intent={INTENT_NAME_KEY: "2"}), + UserUttered(intent={INTENT_NAME_KEY: "unknown"}), + ] + sub_markers = [IntentDetectedMarker("1"), IntentDetectedMarker("2")] + marker = OrMarker(sub_markers, name="marker_name", negated=negated) + for event in events: + marker.track(event) + expected = [True, False, True, False] + if negated: + expected = [not applies for applies in expected] + assert marker.history == expected + + +@pytest.mark.parametrize("negated", [True, False]) +def test_operator_not(negated: bool): + events = [ + UserUttered(intent={INTENT_NAME_KEY: "1"}), + UserUttered(intent={INTENT_NAME_KEY: "unknown"}), + ] + sub_markers = [IntentDetectedMarker("1")] + marker = NotMarker(sub_markers, name="marker_name", negated=negated) + for event in events: + marker.track(event) + expected = [False, True] + if negated: + expected = [not applies for applies in expected] + assert marker.history == expected + + +@pytest.mark.parametrize("negated", [True, False]) +def test_operator_and(negated: bool): + events_expected = [ + (UserUttered(intent={INTENT_NAME_KEY: "1"}), False), + (SlotSet("2", value="bla"), False), + (UserUttered(intent={INTENT_NAME_KEY: "1"}), True), + (SlotSet("2", value=None), False), + (UserUttered(intent={INTENT_NAME_KEY: "1"}), False), + (SlotSet("2", value="bla"), False), + (UserUttered(intent={INTENT_NAME_KEY: "2"}), False), + ] + events, expected = zip(*events_expected) + sub_markers = [IntentDetectedMarker("1"), SlotSetMarker("2")] + marker = AndMarker(sub_markers, name="marker_name", negated=negated) + for event in events: + marker.track(event) + expected = list(expected) + if negated: + expected = [not applies for applies in expected] + assert marker.history == expected + + +@pytest.mark.parametrize("negated", [False, True]) +def test_operator_seq(negated: bool): + events_expected = [ + (UserUttered(intent={INTENT_NAME_KEY: "1"}), False), + (ActionExecuted("unrelated event that does not interrupt the sequence"), False), + (UserUttered(intent={INTENT_NAME_KEY: "2"}), True), + (UserUttered(intent={INTENT_NAME_KEY: "3"}), False), + (UserUttered(intent={INTENT_NAME_KEY: "1"}), False), + (UserUttered(intent={INTENT_NAME_KEY: "2"}), True), + ] + events, expected = zip(*events_expected) + sub_markers = [IntentDetectedMarker("1"), IntentDetectedMarker("2")] + marker = SequenceMarker(sub_markers, name="marker_name", negated=negated) + for event in events: + marker.track(event) + expected = list(expected) + if negated: + expected = [not applies for applies in expected] + assert marker.history == expected + + +@pytest.mark.parametrize("negated", [False, True]) +def test_operator_seq_does_not_allow_overlap(negated: bool): + events_expected = [ + (UserUttered(intent={INTENT_NAME_KEY: "1"}), False), + (UserUttered(intent={INTENT_NAME_KEY: "2"}), False), + (UserUttered(intent={INTENT_NAME_KEY: "1"}), False), + (UserUttered(intent={INTENT_NAME_KEY: "2"}), False), + (UserUttered(intent={INTENT_NAME_KEY: "3"}), True), + (UserUttered(intent={INTENT_NAME_KEY: "3"}), False), + ] + events, expected = zip(*events_expected) + sub_markers = [ + IntentDetectedMarker("1"), + IntentDetectedMarker("2"), + IntentDetectedMarker("3"), + ] + marker = SequenceMarker(sub_markers, name="marker_name", negated=negated) + for event in events: + marker.track(event) + expected = list(expected) + if negated: + expected = [not applies for applies in expected] + assert marker.history == expected + + +@pytest.mark.parametrize("negated", [True, False]) +def test_operator_occur(negated: bool): + events_expected = [ + (UserUttered(intent={INTENT_NAME_KEY: "0"}), False), + (SlotSet("2", value=None), False), + (UserUttered(intent={INTENT_NAME_KEY: "1"}), True), + (SlotSet("2", value=None), True), + (UserUttered(intent={INTENT_NAME_KEY: "2"}), True), + (SlotSet("2", value="bla"), True), + (UserUttered(intent={INTENT_NAME_KEY: "2"}), True), + ] + events, expected = zip(*events_expected) + sub_marker = OrMarker( + [IntentDetectedMarker("1"), SlotSetMarker("2")], name="or marker", negated=False + ) + marker = OccurrenceMarker([sub_marker], name="marker_name", negated=negated) + for event in events: + marker.track(event) + expected = list(expected) + if negated: + expected = [not applies for applies in expected] + assert marker.history == expected + + assert marker.relevant_events() == [expected.index(True)] + + +def test_operator_occur_never_applied(): + events_expected = [ + (UserUttered(intent={INTENT_NAME_KEY: "2"}), False), + (SlotSet("2", value=None), False), + (UserUttered(intent={INTENT_NAME_KEY: "0"}), False), + (SlotSet("1", value="test"), True), + ] + events, expected = zip(*events_expected) + sub_marker = OrMarker( + [IntentDetectedMarker("1"), SlotSetMarker("2")], + name="and marker", + negated=False, + ) + marker = OccurrenceMarker([sub_marker], name="or_at_some_point", negated=False) + for event in events: + marker.track(event) + + assert marker.relevant_events() == [] + + +def test_operator_occur_never_applied_negated(): + events_expected = [ + (UserUttered(intent={INTENT_NAME_KEY: "1"}), False), + (SlotSet("2", value=None), False), + (UserUttered(intent={INTENT_NAME_KEY: "0"}), False), + (SlotSet("1", value="test"), False), + ] + events, expected = zip(*events_expected) + sub_marker = OrMarker( + [IntentDetectedMarker("1"), SlotSetMarker("2")], name="or marker", negated=False + ) + marker = OccurrenceMarker([sub_marker], name="or never occurred", negated=True) + for event in events: + marker.track(event) + + assert marker.relevant_events() == [] + + +def test_operators_nested_simple(): + events = [ + UserUttered(intent={"name": "1"}), + UserUttered(intent={"name": "2"}), + UserUttered(intent={"name": "3"}), + SlotSet("s1", value="any"), + UserUttered(intent={"name": "4"}), + UserUttered(intent={"name": "5"}), + UserUttered(intent={"name": "6"}), + ] + marker = AndMarker( + markers=[ + SlotSetMarker("s1"), + OrMarker([IntentDetectedMarker("4"), IntentDetectedMarker("6")]), + ], + name="marker_name", + ) + evaluation = marker.evaluate_events(events) + + assert len(evaluation[0]["marker_name"]) == 2 + assert evaluation[0]["marker_name"][0].preceding_user_turns == 3 + assert evaluation[0]["marker_name"][1].preceding_user_turns == 5 + + +def generate_random_marker( + depth: int, + max_branches: int, + rng: np.random.Generator, + constant_condition_text: Optional[Text], + possible_conditions: List[Type[ConditionMarker]], + constant_negated: Optional[bool], + possible_operators: List[Type[OperatorMarker]], +) -> Tuple[Marker, int]: + """Generates an (max_branches)-ary tree with the specified depth.""" + if depth == 0: + condition_class = possible_conditions[rng.choice(len(possible_conditions))] + negated = bool(rng.choice(2)) if constant_negated is None else constant_negated + condition_text = constant_condition_text or f"{rng.choice(1000)}" + return condition_class(text=condition_text, negated=negated), 1 + else: + negated = bool(rng.choice(2)) if constant_negated is None else constant_negated + operator_class = possible_operators[rng.choice(len(possible_operators))] + num_branches = operator_class.expected_number_of_sub_markers() + if num_branches is None: + num_branches = rng.choice(max_branches - 1) + 1 + marker_size = 0 + sub_markers = [] + for _ in range(num_branches): + sub_marker, sub_marker_size = generate_random_marker( + depth=depth - 1, + max_branches=max_branches, + rng=rng, + constant_negated=constant_negated, + constant_condition_text=constant_condition_text, + possible_operators=possible_operators, + possible_conditions=possible_conditions, + ) + marker_size += sub_marker_size + sub_markers.append(sub_marker) + + marker = operator_class(markers=sub_markers, negated=negated) + marker_size += 1 + return marker, marker_size + + +@pytest.mark.parametrize( + "depth, max_branches, seed", [(1, 3, 3456), (4, 3, 345), (4, 5, 2345)] +) +def test_operator_nested_randomly_all_sub_markers_track_events( + depth: int, max_branches: int, seed: int +): + rng = np.random.default_rng(seed=seed) + marker, expected_size = generate_random_marker( + depth=depth, + max_branches=max_branches, + rng=rng, + possible_conditions=CONDITION_MARKERS, + possible_operators=OPERATOR_MARKERS, + constant_condition_text=None, + constant_negated=None, + ) + events = [ + UserUttered(intent={"name": "1"}), + UserUttered(intent={"name": "1"}), + SlotSet("1", value="any"), + SlotSet("1", value=None), + ActionExecuted(action_name="1"), + ] + for event in events: + marker.track(event) + assert len([sub_marker for sub_marker in marker.flatten()]) == expected_size + for sub_marker in marker.flatten(): + assert len(sub_marker.history) == len(events) + + +@pytest.mark.parametrize( + "depth, seed, applies_at_some_point", + [ + (depth, seed, applies_at_some_point) + for depth, seed in [(1, 3456), (4, 345)] + for applies_at_some_point in [True, False] + ], +) +def test_operator_nested_randomly_all_sub_markers_track_events_and_apply_at_some_point( + depth: int, seed: int, applies_at_some_point: bool +): + rng = np.random.default_rng(seed=seed) + constant_condition_text = "1" if applies_at_some_point else "2" + marker, _ = generate_random_marker( + depth=depth, + max_branches=3, + rng=rng, + possible_conditions=CONDITION_MARKERS, + possible_operators=[OrMarker], + constant_negated=False, + constant_condition_text=constant_condition_text, + ) + # By setting the max_branches to 3 and then tracking all permutations of these + # 3 events we ensure that all markers apply at some point (including `seq`) + events = [ + UserUttered(intent={"name": "1"}), + SlotSet("1", value="any"), + ActionExecuted(action_name="1"), + ] + for permuted_events in itertools.permutations(events): + for event in permuted_events: + marker.track(event) + + # by design, every marker applies at some point / never + if applies_at_some_point: + assert all([any(sub_marker.history) for sub_marker in marker.flatten()]) + else: + assert all([not any(sub_marker.history) for sub_marker in marker.flatten()]) + + +def test_sessions_evaluated_separately(): + """Each marker applies an exact number of times (slots are immediately un-set).""" + + events = [ + ActionExecuted(ACTION_SESSION_START_NAME), + UserUttered(intent={INTENT_NAME_KEY: "ignored"}), + UserUttered(intent={INTENT_NAME_KEY: "ignored"}), + UserUttered(intent={INTENT_NAME_KEY: "ignored"}), + SlotSet("same-text", value="any"), + ActionExecuted(action_name=ACTION_SESSION_START_NAME), + UserUttered(intent={INTENT_NAME_KEY: "no-slot-set-here"}), + UserUttered(intent={INTENT_NAME_KEY: "no-slot-set-here"}), + ] + + marker = SlotSetMarker(text="same-text", name="my-marker") + evaluation = marker.evaluate_events(events) + + assert len(evaluation) == 2 + assert len(evaluation[0]["my-marker"]) == 1 + assert evaluation[0]["my-marker"][0].preceding_user_turns == 3 + assert len(evaluation[1]["my-marker"]) == 0 # i.e. slot set does not "leak" + + +def test_sessions_evaluated_returns_event_indices_wrt_tracker_not_dialogue(): + events = [ + ActionExecuted(action_name=ACTION_SESSION_START_NAME), + UserUttered(intent={INTENT_NAME_KEY: "ignored"}), + UserUttered(intent={INTENT_NAME_KEY: "ignored"}), + UserUttered(intent={INTENT_NAME_KEY: "ignored"}), + SlotSet("same-text", value="any"), + ActionExecuted(action_name=ACTION_SESSION_START_NAME), + UserUttered(intent={INTENT_NAME_KEY: "no-slot-set-here"}), + UserUttered(intent={INTENT_NAME_KEY: "no-slot-set-here"}), + SlotSet("same-text", value="any"), + ] + marker = SlotSetMarker(text="same-text", name="my-marker") + evaluation = marker.evaluate_events(events) + assert len(evaluation) == 2 + assert len(evaluation[0]["my-marker"]) == 1 + assert evaluation[0]["my-marker"][0].preceding_user_turns == 3 + assert evaluation[0]["my-marker"][0].idx == 4 + assert len(evaluation[1]["my-marker"]) == 1 + assert evaluation[1]["my-marker"][0].preceding_user_turns == 2 + assert evaluation[1]["my-marker"][0].idx == 8 # i.e. NOT the index in the dialogue + + +async def test_markers_cli_results_save_correctly(tmp_path: Path): + domain = Domain.empty() + store = InMemoryTrackerStore(domain) + + for i in range(5): + tracker = DialogueStateTracker(str(i), None) + tracker.update_with_events([SlotSet(str(j), "slot") for j in range(5)], domain) + tracker.update(ActionExecuted(ACTION_SESSION_START_NAME)) + tracker.update(UserUttered("hello")) + tracker.update_with_events( + [SlotSet(str(5 + j), "slot") for j in range(5)], domain + ) + await store.save(tracker) + + tracker_loader = MarkerTrackerLoader(store, "all") + + results_path = tmp_path / "results.csv" + + markers = OrMarker( + markers=[SlotSetMarker("2", name="marker1"), SlotSetMarker("7", name="marker2")] + ) + await markers.evaluate_trackers(tracker_loader.load(), results_path) + + with open(results_path, "r") as results: + result_reader = csv.DictReader(results) + senders = set() + + for row in result_reader: + senders.add(row["sender_id"]) + if row["marker"] == "marker1": + assert row["session_idx"] == "0" + assert int(row["event_idx"]) >= 2 + assert row["num_preceding_user_turns"] == "0" + + if row["marker"] == "marker2": + assert row["session_idx"] == "1" + assert int(row["event_idx"]) >= 3 + assert row["num_preceding_user_turns"] == "1" + + assert len(senders) == 5 + + +def _collect_parameters( + marker: Marker, condition_type: Type[ConditionMarker] +) -> Set[Text]: + return set( + sub_marker.text + for sub_marker in marker.flatten() + if isinstance(sub_marker, condition_type) + ) + + +@pytest.mark.parametrize( + "depth, max_branches, seed", [(1, 3, 3456), (4, 3, 345), (4, 5, 2345)] +) +def test_domain_validation_with_valid_marker(depth: int, max_branches: int, seed: int): + # We do this a bit backwards, we construct the domain from the marker + # and assert they must match + rng = np.random.default_rng(seed=seed) + marker, expected_size = generate_random_marker( + depth=depth, + max_branches=max_branches, + rng=rng, + possible_conditions=CONDITION_MARKERS, + possible_operators=OPERATOR_MARKERS, + constant_condition_text=None, + constant_negated=None, + ) + + slots = [TextSlot(name, []) for name in _collect_parameters(marker, SlotSetMarker)] + actions = list(_collect_parameters(marker, ActionExecutedMarker)) + intents = _collect_parameters(marker, IntentDetectedMarker) + domain = Domain(intents, [], slots, {}, actions, {}, {}) + + assert marker.validate_against_domain(domain) + + +@pytest.mark.parametrize( + "depth, max_branches, seed", [(1, 3, 3456), (4, 3, 345), (4, 5, 2345)] +) +def test_domain_validation_with_invalid_marker( + depth: int, max_branches: int, seed: int +): + rng = np.random.default_rng(seed=seed) + marker, expected_size = generate_random_marker( + depth=depth, + max_branches=max_branches, + rng=rng, + possible_conditions=CONDITION_MARKERS, + possible_operators=OPERATOR_MARKERS, + constant_condition_text=None, + constant_negated=None, + ) + + domain = Domain.empty() + with pytest.warns(None): + is_valid = marker.validate_against_domain(domain) + assert not is_valid + + +@pytest.mark.parametrize( + "config", + [ + # (1) top level configuration + # - config is list + [SlotSetMarker.positive_tag()], + # - config is list with nested dict + [{SlotSetMarker.positive_tag(): "s1"}], + # - config is just a str + # (2) sub-markers + SlotSetMarker.positive_tag(), + # - sub-marker config is dict not list + {AndMarker.positive_tag(): {SlotSetMarker.positive_tag(): "s1"}}, + # - sub-marker under condition + {SlotSetMarker.positive_tag(): {IntentDetectedMarker.positive_tag(): "blah"}}, + # - no sub-config for operator (see also: "amount_sub_markers" tests) + {AndMarker.positive_tag(): "blah"}, + # (3) tags + # - unknown operator + {"Ard": {IntentDetectedMarker.positive_tag(): "intent1"}}, + # - unknown condition + {AndMarker.positive_tag(): {"intent": "intent1"}}, + # - special tag used by user + {Marker.ANY_MARKER: "blah"}, + ], +) +def test_marker_validation_raises(config: Any): + with pytest.raises(InvalidMarkerConfig): + Marker.from_config(config) + + +def test_marker_from_path_only_reads_yamls(tmp_path: Path): + suffixes = [("yaml", True), ("yml", True), ("yaeml", False), ("config", False)] + for idx, (suffix, allowed) in enumerate(suffixes): + config = {f"marker-{idx}": {IntentDetectedMarker.positive_tag(): "intent"}} + config_file = tmp_path / f"config-{idx}.{suffix}" + rasa.shared.utils.io.write_yaml(data=config, target=config_file) + loaded = Marker.from_path(tmp_path) + assert len(loaded.sub_markers) == sum(allowed for _, allowed in suffixes) + assert set(sub_marker.name for sub_marker in loaded.sub_markers) == set( + f"marker-{idx}" for idx, (_, allowed) in enumerate(suffixes) if allowed + ) + + +@pytest.mark.parametrize( + "configs", + [ + {"my_marker1": {IntentDetectedMarker.positive_tag(): "this-intent"}}, + { + "my_marker1": {IntentDetectedMarker.positive_tag(): "this-intent"}, + "my_marker2": {IntentDetectedMarker.positive_tag(): "this-action"}, + }, + ], +) +def test_marker_from_path_adds_special_or_marker(tmp_path: Path, configs: Any): + + yaml_file = tmp_path / "config.yml" + rasa.shared.utils.io.write_yaml(data=configs, target=yaml_file) + loaded = Marker.from_path(tmp_path) + assert isinstance(loaded, OrMarker) + assert loaded.name == Marker.ANY_MARKER + assert len(loaded.sub_markers) == len(configs) + assert all( + isinstance(sub_marker, IntentDetectedMarker) + for sub_marker in loaded.sub_markers + ) + + +@pytest.mark.parametrize( + "path_config_tuples", + [ + [ # two configs defining the same marker + ( + "folder1/config1.yaml", + {"my_marker1": {IntentDetectedMarker.positive_tag(): "this-intent"}}, + ), + ( + "folder2/config2.yaml", + { + "my_marker1": { + IntentDetectedMarker.positive_tag(): "different-intent" + }, + "my_marker2": {ActionExecutedMarker.positive_tag(): "action"}, + }, + ), + ], + [ # one buggy config + ( + "folder1/config2.yaml", + {"my_marker1": {IntentDetectedMarker.positive_tag(): "intent1"}}, + ), + ("folder2/config2.yaml", {"my_marker2": {"unknown-tag": "intent2"}}), + ], + ], +) +def test_marker_from_path_raises( + tmp_path: Path, path_config_tuples: List[Tuple[Text, Any]] +): + for path_to_yaml, config in path_config_tuples: + full_path = tmp_path / path_to_yaml + folder = full_path.parents[0] + if folder != tmp_path: + Path.mkdir(folder, exist_ok=False) + rasa.shared.utils.io.write_yaml(data=config, target=full_path) + with pytest.raises(InvalidMarkerConfig): + Marker.from_path(tmp_path) + + +@pytest.mark.parametrize( + "marker,expected_depth", + [ + ( + AndMarker( + markers=[ + SlotSetMarker("s1"), + OrMarker([IntentDetectedMarker("4"), IntentDetectedMarker("6")]), + ] + ), + 3, + ), + (SlotSetMarker("s1"), 1), + (AndMarker(markers=[SlotSetMarker("s1"), IntentDetectedMarker("6")]), 2), + ], +) +def test_marker_depth(marker: Marker, expected_depth: int): + assert marker.max_depth() == expected_depth + + +def test_split_sessions(tmp_path): + """Tests loading a tracker with multiple sessions.""" + + events = [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + UserUttered(intent={"name": "this-intent"}), + ] + sessions = Marker._split_sessions(events) + assert len(sessions) == 1 + assert len(sessions[0][0]) == len(events) + + +def test_condition_marker_with_description(): + marker = SlotSetMarker("s1", description="This is a description") + assert marker.description == "This is a description" + + +def test_operator_marker_with_description(): + marker = AndMarker( + markers=[SlotSetMarker("s1")], description="This is a description" + ) + assert marker.description == "This is a description" diff --git a/tests/core/evaluation/test_marker_stats.py b/tests/core/evaluation/test_marker_stats.py new file mode 100644 index 0000000..c99fa3e --- /dev/null +++ b/tests/core/evaluation/test_marker_stats.py @@ -0,0 +1,307 @@ +import csv +from pathlib import Path +from typing import Dict, List, Text, Tuple +import itertools + +import pytest +import numpy as np + +from rasa.core.evaluation.marker_stats import ( + EventMetaData, + MarkerStatistics, + compute_statistics, +) + + +def test_compute_statistics_on_empty_set(): + result = compute_statistics([]) + assert result["count"] == 0 + assert all(np.isnan(value) for key, value in result.items() if key != "count") + # we see the same keys as in those cases where we can evaluate something + dummy_result_non_empty = compute_statistics([1, 2]) + assert set(dummy_result_non_empty.keys()) == set(result.keys()) + + +def test_compute_statistics_simple_check(): + stats = compute_statistics([1, 2, 9, 0]) + assert stats["count"] == 4 + assert stats["min"] == 0 + assert stats["max"] == 9 + assert stats["mean"] == 3 + assert stats["median"] == 1.5 # this is no bug, it is a convention numpy follows + + +def _generate_random_example_for_one_session_and_one_marker( + rng: np.random.Generator, +) -> Tuple[List[EventMetaData], List[int]]: + """Generates a random marker extraction result for a single session and marker. + + Args: + rng: a random number generator + Returns: + the event list representing the marker extraction result as well as + the plain list of numbers used as "preceding user turns" in that extraction + result + """ + applied = int(rng.choice(10)) + all_preceding_user_turn_numbers = [int(rng.choice(20)) for _ in range(applied)] + event_list = [ + EventMetaData( + idx=int(rng.choice(100)), preceding_user_turns=preceding_user_turns + ) + for preceding_user_turns in all_preceding_user_turn_numbers + ] + return event_list, all_preceding_user_turn_numbers + + +# For every evaluated session, we obtain as a result a mapping of the evaluated +# marker names to the meta data for the respective relevant events. +PerMarkerResults = Dict[Text, List[EventMetaData]] +# We collect the "number of preceding user turns" that we generate randomly and +# compare them to the actual results later. In the following, the inner `List[int]` +# contains the "number of preceding user turns" created for one session (and the +# respective dialogue): +PerMarkerCollectedNumbers = Dict[Text, List[List[int]]] + + +def _generate_random_examples( + rng: np.random.Generator, + num_markers: int = 3, + num_sessions_min: int = 2, + num_sessions_max: int = 10, +) -> Tuple[List[PerMarkerResults], PerMarkerCollectedNumbers]: + """Generates a random number of random marker extraction results for some markers. + + Args: + rng: a random number generator + num_markers: the number of markers to be imitated + Returns: + a list containing a dictionary of the marker extraction results per marker, + as well as a collection of the plain list of numbers used as "preceding user + turns" in that extraction results + """ + num_sessions = int(rng.integers(low=num_sessions_min, high=num_sessions_max + 1)) + markers = [f"marker{idx}" for idx in range(num_markers)] + per_session_results: List[PerMarkerResults] = [] + preceding_user_turn_numbers_used_per_marker: PerMarkerCollectedNumbers = { + marker: [] for marker in markers + } + for _ in range(num_sessions - 1): # we append one more later + result_dict = {} + for marker in markers: + ( + event_list, + num_list, + ) = _generate_random_example_for_one_session_and_one_marker(rng=rng) + result_dict[marker] = event_list + preceding_user_turn_numbers_used_per_marker[marker].append(num_list) + per_session_results.append(result_dict) + # append a session where we didn't find any marker + per_session_results.append({marker: [] for marker in markers}) + for marker in preceding_user_turn_numbers_used_per_marker: + preceding_user_turn_numbers_used_per_marker[marker].append([]) + return per_session_results, preceding_user_turn_numbers_used_per_marker + + +@pytest.mark.parametrize("seed", [2345, 5654, 2345234]) +def test_process_results_per_session(seed: int): + rng = np.random.default_rng(seed=seed) + + ( + per_session_results, + preceding_user_turn_numbers_used_per_marker, + ) = _generate_random_examples(num_markers=3, rng=rng) + markers = sorted(preceding_user_turn_numbers_used_per_marker.keys()) + num_sessions = len(per_session_results) + + stats = MarkerStatistics() + sender_ids = [] + session_indices = [] + for session_idx, results in enumerate(per_session_results): + sender_id = str(rng.choice(100)) + session_idx = int(rng.choice(100)) + stats.process( + session_idx=session_idx, + sender_id=sender_id, + meta_data_on_relevant_events_per_marker=results, + ) + sender_ids.append(sender_id) + session_indices.append(session_idx) + + assert stats.num_sessions == len(per_session_results) + for marker in markers: + for idx in range(num_sessions): + expected_stats = compute_statistics( + preceding_user_turn_numbers_used_per_marker[marker][idx] + ) + for stat_name, stat_value in expected_stats.items(): + assert ( + pytest.approx( + stats.session_results[marker][stat_name][idx], nan_ok=True + ) + == stat_value + ) + for idx in range(num_sessions): + assert stats.session_identifier[idx] == (sender_ids[idx], session_indices[idx]) + + +@pytest.mark.parametrize("seed", [2345, 5654, 2345234]) +def test_process_results_overall(seed: int): + rng = np.random.default_rng(seed=seed) + ( + per_session_results, + preceding_user_turn_numbers_used_per_marker, + ) = _generate_random_examples(num_markers=3, rng=rng) + markers = sorted(preceding_user_turn_numbers_used_per_marker.keys()) + num_sessions = len(per_session_results) + + stats = MarkerStatistics() + for session_idx, results in enumerate(per_session_results): + stats.process( + session_idx=session_idx, + sender_id=str(rng.choice(100)), + meta_data_on_relevant_events_per_marker=results, + ) + + assert stats.num_sessions == num_sessions + for marker in markers: + # count how often we generated some results for a session: + number_lists = preceding_user_turn_numbers_used_per_marker[marker] + applied_at_least_once = sum(len(sub_list) > 0 for sub_list in number_lists) + # and compare that to the expected count: + assert stats.count_if_applied_at_least_once[marker] == applied_at_least_once + # check if we collected the all the "preceding user turn numbers" + concatenated_numbers = list( + itertools.chain.from_iterable( + preceding_user_turn_numbers_used_per_marker[marker] + ) + ) + assert stats.num_preceding_user_turns_collected[marker] == concatenated_numbers + + +@pytest.mark.parametrize("seed", [2345, 5654, 2345234]) +def test_overall_statistics_to_csv(tmp_path: Path, seed: int): + rng = np.random.default_rng(seed=seed) + ( + per_session_results, + preceding_user_turn_numbers_used_per_marker, + ) = _generate_random_examples( + num_markers=3, rng=rng, num_sessions_min=10, num_sessions_max=20 + ) + markers = sorted(preceding_user_turn_numbers_used_per_marker.keys()) + num_sessions = len(per_session_results) + + stats = MarkerStatistics() + for session_idx, results in enumerate(per_session_results): + stats.process( + session_idx=session_idx, + sender_id=str(rng.choice(100)), + meta_data_on_relevant_events_per_marker=results, + ) + + tmp_file = tmp_path / "test.csv" + stats.overall_statistic_to_csv(path=tmp_file) + + with tmp_file.open(mode="r") as f: + reader = csv.DictReader(f) + rows = [row for row in reader] + + assert rows[0] == { + "sender_id": "all", + "session_idx": "nan", + "marker": "-", + "statistic": "total_number_of_sessions", + "value": str(num_sessions), + } + + num_digits = 3 + row_idx = 1 + for marker_name in markers: + assert rows[row_idx] == { + "sender_id": "all", + "session_idx": "nan", + "marker": marker_name, + "statistic": "number_of_sessions_where_marker_applied_at_least_once", + "value": str(stats.count_if_applied_at_least_once[marker_name]), + } + row_idx += 1 + assert rows[row_idx] == { + "sender_id": "all", + "session_idx": "nan", + "marker": marker_name, + "statistic": "percentage_of_sessions_where_marker_applied_at_least_once", + "value": str( + round( + stats.count_if_applied_at_least_once[marker_name] + / num_sessions + * 100, + num_digits, + ) + ), + } + row_idx += 1 + + for marker_name in markers: + statistics = compute_statistics( + stats.num_preceding_user_turns_collected[marker_name] + ) + for stat_name, stat_value in statistics.items(): + assert rows[row_idx] == { + "sender_id": "all", + "session_idx": "nan", + "marker": marker_name, + "statistic": MarkerStatistics._add_num_user_turns_str_to(stat_name), + "value": str(round(stat_value, num_digits)), + } + row_idx += 1 + + +@pytest.mark.parametrize("seed", [2345, 5654, 2345234]) +def test_per_session_statistics_to_csv(tmp_path: Path, seed: int): + + rng = np.random.default_rng(seed=seed) + ( + per_session_results, + preceding_user_turn_numbers_used_per_marker, + ) = _generate_random_examples( + num_markers=3, rng=rng, num_sessions_min=10, num_sessions_max=20 + ) + markers = sorted(preceding_user_turn_numbers_used_per_marker.keys()) + + stats = MarkerStatistics() + for session_idx, results in enumerate(per_session_results): + stats.process( + session_idx=session_idx, + sender_id=str(rng.choice(100)), + meta_data_on_relevant_events_per_marker=results, + ) + + tmp_file = tmp_path / "test.csv" + stats.per_session_statistics_to_csv(path=tmp_file) + + with tmp_file.open(mode="r") as f: + reader = csv.DictReader(f) + rows = [row for row in reader] + + actual_information = { + (row["sender_id"], row["session_idx"], row["marker"], row["statistic"]): row[ + "value" + ] + for row in rows + } + num_digits = 3 + expected_information = { + ( + sender_id, + str(session_idx), + marker_name, + MarkerStatistics._add_num_user_turns_str_to(stat_name), + ): str(value) + if np.isnan(value) + else str(round(value, num_digits)) + for marker_name in markers + for stat_name, values in stats.session_results[marker_name].items() + for (sender_id, session_idx), value in zip(stats.session_identifier, values) + } + + assert actual_information == expected_information diff --git a/tests/core/evaluation/test_marker_tracker_loader.py b/tests/core/evaluation/test_marker_tracker_loader.py new file mode 100644 index 0000000..a57ef4f --- /dev/null +++ b/tests/core/evaluation/test_marker_tracker_loader.py @@ -0,0 +1,142 @@ +import pytest +import os +from rasa.shared.core.events import UserUttered, SessionStarted +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.domain import Domain +from rasa.shared.exceptions import RasaException +from rasa.core.evaluation.marker_tracker_loader import ( + MarkerTrackerLoader, + STRATEGY_ALL, + STRATEGY_SAMPLE_N, + STRATEGY_FIRST_N, +) +from rasa.core.tracker_store import InMemoryTrackerStore, TrackerStore, SQLTrackerStore + + +@pytest.fixture +async def marker_trackerstore() -> TrackerStore: + """Sets up a TrackerStore with 5 trackers in it.""" + domain = Domain.empty() + store = InMemoryTrackerStore(domain) + for i in range(5): + tracker = DialogueStateTracker(str(i), None) + tracker.update_with_events([UserUttered(str(j)) for j in range(10)], domain) + await store.save(tracker) + + return store + + +async def test_load_sessions(tmp_path): + """Tests loading a tracker with multiple sessions.""" + domain = Domain.empty() + store = SQLTrackerStore(domain, db=os.path.join(tmp_path, "temp.db")) + tracker = DialogueStateTracker("test123", None) + tracker.update_with_events( + [ + UserUttered("0"), + UserUttered("1"), + SessionStarted(), + UserUttered("2"), + UserUttered("3"), + ], + domain, + ) + await store.save(tracker) + + loader = MarkerTrackerLoader(store, STRATEGY_ALL) + result = [tracker async for tracker in loader.load()] + assert len(result) == 1 # contains only one tracker + assert len(result[0].events) == len(tracker.events) + + +async def test_load_sample(marker_trackerstore: TrackerStore): + """Tests loading trackers using 'sample' strategy.""" + loader = MarkerTrackerLoader(marker_trackerstore, STRATEGY_SAMPLE_N, 3) + result = [tracker async for tracker in loader.load()] + + assert len(result) == 3 + + senders = set() + for item in result: + assert await marker_trackerstore.exists(item.sender_id) + assert item.sender_id not in senders + senders.add(item.sender_id) + + +async def test_load_sample_with_seed(marker_trackerstore: TrackerStore): + """Tests loading trackers using 'sample' strategy with seed set.""" + loader = MarkerTrackerLoader(marker_trackerstore, STRATEGY_SAMPLE_N, 3, seed=3) + result = [tracker async for tracker in loader.load()] + expected_ids = ["1", "4", "3"] + + assert len(result) == 3 + + for item, expected in zip(result, expected_ids): + assert item.sender_id == expected + assert await marker_trackerstore.exists(item.sender_id) + + +async def test_load_first_n(marker_trackerstore: TrackerStore): + """Tests loading trackers using 'first_n' strategy.""" + loader = MarkerTrackerLoader(marker_trackerstore, STRATEGY_FIRST_N, 3) + result = [tracker async for tracker in loader.load()] + + assert len(result) == 3 + + for item in result: + assert await marker_trackerstore.exists(item.sender_id) + + +async def test_load_all(marker_trackerstore: TrackerStore): + """Tests loading trackers using 'all' strategy.""" + loader = MarkerTrackerLoader(marker_trackerstore, STRATEGY_ALL) + result = [tracker async for tracker in loader.load()] + + assert len(result) == len(list(await marker_trackerstore.keys())) + + for item in result: + assert await marker_trackerstore.exists(item.sender_id) + + +def test_exception_invalid_strategy(marker_trackerstore: TrackerStore): + """Tests an exception is thrown when an invalid strategy is used.""" + with pytest.raises(RasaException): + MarkerTrackerLoader(marker_trackerstore, "summon") + + +def test_exception_no_count(marker_trackerstore: TrackerStore): + """Tests an exception is thrown when no count is given for non-'all' strategies.""" + with pytest.raises(RasaException): + MarkerTrackerLoader(marker_trackerstore, STRATEGY_SAMPLE_N) + + +def test_exception_zero_count(marker_trackerstore: TrackerStore): + """Tests an exception is thrown when an invalid count is given.""" + with pytest.raises(RasaException): + MarkerTrackerLoader(marker_trackerstore, STRATEGY_SAMPLE_N, 0) + + +def test_exception_negative_count(marker_trackerstore: TrackerStore): + """Tests an exception is thrown when an invalid count is given.""" + with pytest.raises(RasaException): + MarkerTrackerLoader(marker_trackerstore, STRATEGY_SAMPLE_N, -1) + + +def test_warn_seed_unnecessary(marker_trackerstore: TrackerStore): + """Tests a warning is thrown when 'seed' is set for non-'sample' strategies.""" + with pytest.warns(UserWarning): + MarkerTrackerLoader(marker_trackerstore, STRATEGY_FIRST_N, 3, seed=5) + + +def test_warn_count_all_unnecessary(marker_trackerstore: TrackerStore): + """Tests a warning is thrown when 'count' is set for strategy 'all'.""" + with pytest.warns(UserWarning): + MarkerTrackerLoader(marker_trackerstore, STRATEGY_ALL, 3) + + +async def test_warn_count_exceeds_store(marker_trackerstore: TrackerStore): + """Tests a warning is thrown when 'count' is larger than the number of trackers.""" + loader = MarkerTrackerLoader(marker_trackerstore, STRATEGY_SAMPLE_N, 6) + with pytest.warns(UserWarning): + # Need to force the generator to evaluate to produce the warning + [tracker async for tracker in loader.load()] diff --git a/tests/core/featurizers/__init__.py b/tests/core/featurizers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/featurizers/test_precomputation.py b/tests/core/featurizers/test_precomputation.py new file mode 100644 index 0000000..465fb3b --- /dev/null +++ b/tests/core/featurizers/test_precomputation.py @@ -0,0 +1,527 @@ +import pytest +import numpy as np +import itertools +from typing import List, Text, Optional, Dict + + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.core.featurizers.precomputation import ( + CoreFeaturizationCollector, + MessageContainerForCoreFeaturization, + CoreFeaturizationInputConverter, +) +from rasa.shared.nlu.training_data.features import Features +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.constants import ( + INTENT, + TEXT, + ENTITIES, + ACTION_NAME, + ACTION_TEXT, + INTENT_NAME_KEY, + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_ROLE, + ENTITY_ATTRIBUTE_GROUP, +) +from rasa.shared.core.slots import TextSlot +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import Event, UserUttered, ActionExecuted +from rasa.shared.core.training_data.structures import StoryGraph, StoryStep +from rasa.shared.core.trackers import DialogueStateTracker + + +def _dummy_features(id: int, attribute: Text) -> Features: + return Features( + np.full(shape=(1), fill_value=id), + attribute=attribute, + feature_type="really-anything", + origin="", + ) + + +def _create_entity( + value: Text, type: Text, role: Optional[Text] = None, group: Optional[Text] = None +) -> Dict[Text, Text]: + entity = {} + entity[ENTITY_ATTRIBUTE_VALUE] = value + entity[ENTITY_ATTRIBUTE_TYPE] = type + entity[ENTITY_ATTRIBUTE_ROLE] = role + entity[ENTITY_ATTRIBUTE_GROUP] = group + return entity + + +def test_container_messages(): + message_data_list = [{INTENT: "1"}, {INTENT: "2", "other": 3}, {TEXT: "3"}] + container = MessageContainerForCoreFeaturization() + container.add_all([Message(data=data) for data in message_data_list]) + assert len(container.messages(INTENT)) == 2 + assert len(container.messages(TEXT)) == 1 + + +def test_container_keys(): + message_data_list = [{INTENT: "1"}, {INTENT: "2"}, {TEXT: "3", "other": 3}] + container = MessageContainerForCoreFeaturization() + container.add_all([Message(data=data) for data in message_data_list]) + assert set(container.keys(INTENT)) == {"1", "2"} + assert set(container.keys(TEXT)) == {"3"} + + +def test_container_all_messages(): + message_data_list = [{INTENT: "1"}, {INTENT: "2", "other": 3}, {TEXT: "3"}] + container = MessageContainerForCoreFeaturization() + container.add_all([Message(data=data) for data in message_data_list]) + assert len(container.all_messages()) == 3 + + +def test_container_fingerprints_differ_for_different_containers(): + container1 = MessageContainerForCoreFeaturization() + container1.add(Message(data={INTENT: "1"})) + container2 = MessageContainerForCoreFeaturization() + container2.add(Message(data={INTENT: "2"})) + assert container2.fingerprint() != container1.fingerprint() + + +def test_container_fingerprint_differ_for_containers_with_different_insertion_order(): + # because we use this for training data and order might affect training of + # e.g. featurizers, we want this to differ + container1 = MessageContainerForCoreFeaturization() + container1.add(Message(data={INTENT: "1"})) + container1.add(Message(data={INTENT: "2"})) + container2 = MessageContainerForCoreFeaturization() + container2.add(Message(data={INTENT: "2"})) + container2.add(Message(data={INTENT: "1"})) + assert container2.fingerprint() != container1.fingerprint() + + +@pytest.mark.parametrize( + "no_or_multiple_key_attributes", + [list(), ["other"]] + + list( + itertools.permutations(MessageContainerForCoreFeaturization.KEY_ATTRIBUTES, 2) + ), +) +def test_container_add_fails_if_message_has_wrong_attributes( + no_or_multiple_key_attributes: List[Text], +): + sub_state = {attribute: "dummy" for attribute in no_or_multiple_key_attributes} + with pytest.raises(ValueError, match="Expected exactly one attribute out of"): + MessageContainerForCoreFeaturization().add(Message(sub_state)) + + +def test_container_add_message_copies(): + # construct a set of unique substates and messages + dummy_value = "this-could-be-anything" + substates_with_unique_key_attribute = [ + {INTENT: "greet"}, + {TEXT: "text", ENTITIES: dummy_value}, + {TEXT: "other-text"}, + {ACTION_TEXT: "action_text"}, + {ACTION_NAME: "action_name"}, + ] + unique_messages = [ + Message(sub_state) for sub_state in substates_with_unique_key_attribute + ] + # make some copies + num_copies = 3 + messages = unique_messages * (1 + num_copies) + # build table + lookup_table = MessageContainerForCoreFeaturization() + for message in messages: + lookup_table.add(message) + # assert that we have as many entries as unique keys + assert len(lookup_table) == len(substates_with_unique_key_attribute) + assert set(lookup_table.all_messages()) == set(unique_messages) + assert ( + lookup_table.num_collisions_ignored + == len(substates_with_unique_key_attribute) * num_copies + ) + + +def test_container_add_does_not_fail_if_message_feature_content_differs(): + # construct a set of unique substates + dummy_value = "this-could-be-anything" + substates_with_unique_key_attribute = [ + {INTENT: "greet"}, + {TEXT: "text", ENTITIES: dummy_value}, + {ACTION_TEXT: "action_text"}, + {ACTION_NAME: "action_name"}, + ] + constant_feature = _dummy_features(id=1, attribute="arbitrary") + different_feature = _dummy_features(id=1, attribute="arbitrary") + lookup_table = MessageContainerForCoreFeaturization() + for sub_state in substates_with_unique_key_attribute: + lookup_table.add(Message(data=sub_state, features=[constant_feature])) + length = len(lookup_table) + # with different feature + for sub_state in substates_with_unique_key_attribute: + lookup_table.add(Message(data=sub_state, features=[different_feature])) + assert len(lookup_table) == length + + +def test_container_add_fails_if_messages_are_different_but_have_same_key(): + # construct a set of unique substates + dummy_value = "this-could-be-anything" + substates_with_unique_key_attribute = [ + {INTENT: "greet"}, + {TEXT: "text", ENTITIES: dummy_value}, + {ACTION_TEXT: "action_text"}, + {ACTION_NAME: "action_name"}, + ] + constant_feature = _dummy_features(id=1, attribute="arbitrary") + different_feature = _dummy_features(id=1, attribute="arbitrary") + # adding the unique messages works fine of course,... + lookup_table = MessageContainerForCoreFeaturization() + for sub_state in substates_with_unique_key_attribute: + lookup_table.add(Message(data=sub_state, features=[constant_feature])) + # ... but adding any substate with same key but different content doesn't + new_key = "some-new-key" + expected_error_message = "Expected added message to be consistent" + for sub_state in substates_with_unique_key_attribute: + # with extra attribute + sub_state_with_extra_attribute = sub_state.copy() + sub_state_with_extra_attribute[new_key] = "some-value-for-the-new-key" + with pytest.raises(ValueError, match=expected_error_message): + lookup_table.add(Message(data=sub_state_with_extra_attribute)) + # with new feature + with pytest.raises(ValueError, match=expected_error_message): + lookup_table.add( + Message(data=sub_state, features=[constant_feature, different_feature]) + ) + # without features + with pytest.raises(ValueError, match=expected_error_message): + lookup_table.add(Message(data=sub_state)) + # ... and we could test many more but this should suffice. + + +def test_container_feature_lookup(): + arbitrary_attribute = "other" + messages = [ + Message(data={TEXT: "A"}, features=[_dummy_features(1, TEXT)]), + Message( + data={INTENT: "B", arbitrary_attribute: "C"}, + features=[_dummy_features(2, arbitrary_attribute)], + ), + Message(data={TEXT: "A2"}, features=[_dummy_features(3, TEXT)]), + Message( + data={INTENT: "B2", arbitrary_attribute: "C2"}, + features=[_dummy_features(4, arbitrary_attribute)], + ), + ] + + table = MessageContainerForCoreFeaturization() + table.add_all(messages) + + # If we don't specify a list of attributes, the resulting features dictionary will + # only contain those attributes for which there are features. + sub_state = {TEXT: "A", INTENT: "B", arbitrary_attribute: "C"} + features = table.collect_features(sub_state=sub_state) + for attribute, feature_value in [ + (TEXT, 1), + (INTENT, None), + (arbitrary_attribute, 2), + ]: + if feature_value is not None: + assert attribute in features + assert len(features[attribute]) == 1 + assert feature_value == features[attribute][0].features[0] + else: + assert attribute not in features + + # If we query features for `INTENT`, then a key will be there, even if there are + # no features + features = table.collect_features( + sub_state=sub_state, attributes=list(sub_state.keys()) + ) + assert INTENT in features + assert len(features[INTENT]) == 0 + + # We only get the list of features we want... + features = table.collect_features(sub_state, attributes=[arbitrary_attribute]) + assert TEXT not in features + assert INTENT not in features + assert len(features[arbitrary_attribute]) == 1 + + # ... even if there are no features: + YET_ANOTHER = "another" + features = table.collect_features(sub_state, attributes=[YET_ANOTHER]) + assert len(features[YET_ANOTHER]) == 0 + + +def test_container_feature_lookup_fails_without_key_attribute(): + table = MessageContainerForCoreFeaturization() + with pytest.raises(ValueError, match="Unknown key"): + table.collect_features({TEXT: "A-unknown"}) + + +def test_container_feature_lookup_fails_if_different_features_for_same_attribute(): + broken_table = MessageContainerForCoreFeaturization() + broken_table._table = { + TEXT: {"A": Message(data={}, features=[_dummy_features(2, TEXT)])}, + INTENT: {"B": Message(data={}, features=[_dummy_features(1, TEXT)])}, + } + with pytest.raises( + RuntimeError, match=f"Feature for attribute {TEXT} has already been" + ): + broken_table.collect_features({TEXT: "A", INTENT: "B"}) + + +def test_container_feature_lookup_works_if_messages_are_broken_but_consistent(): + not_broken_but_strange_table = MessageContainerForCoreFeaturization() + not_broken_but_strange_table._table = { + TEXT: {"A": Message(data=dict())}, + INTENT: {"B": Message(data=dict(), features=[_dummy_features(1, TEXT)])}, + } + features = not_broken_but_strange_table.collect_features({TEXT: "A", INTENT: "B"}) + assert TEXT in features and len(features[TEXT]) == 1 + + +def test_container_message_lookup(): + # create some messages with unique key attributes + messages = [ + Message(data={TEXT: "A"}, features=[_dummy_features(1, TEXT)]), + Message(data={TEXT: "B"}), + Message(data={INTENT: "B"}), + Message(data={ACTION_TEXT: "B"}), + Message(data={ACTION_NAME: "B"}), + ] + # add messages to container + table = MessageContainerForCoreFeaturization() + table.add_all(messages) + # lookup messages using existing texts + message = table.lookup_message(user_text="A") + assert message + assert len(message.data) == 1 + assert len(message.features) == 1 + message = table.lookup_message(user_text="B") + assert message + assert len(message.data) == 1 + + +def test_container_message_lookup_fails_if_text_cannot_be_looked_up(): + table = MessageContainerForCoreFeaturization() + with pytest.raises(ValueError, match="Expected a message with key"): + table.lookup_message(user_text="a text not included in the table") + + +@pytest.mark.parametrize( + "events,expected_num_entries", + [ + ( + [ + UserUttered(intent={INTENT_NAME_KEY: "greet"}), + ActionExecuted(action_name="utter_greet"), + ActionExecuted(action_name="utter_greet"), + ], + 2, + ), + ( + [ + UserUttered(text="text", intent={INTENT_NAME_KEY: "greet"}), + ActionExecuted(action_name="utter_greet"), + ], + 3, + ), + ], +) +def test_container_derive_messages_from_events_and_add( + events: List[Event], expected_num_entries: int +): + lookup_table = MessageContainerForCoreFeaturization() + lookup_table.derive_messages_from_events_and_add(events) + assert len(lookup_table) == expected_num_entries + + +def test_container_derive_messages_from_domain_and_add(): + action_names = ["a", "b"] + # action texts, response keys, forms, and action_names must be unique or the + # domain will complain about it ... + action_texts = ["a2", "b2"] + # ... but the response texts could overlap with e.g action texts + responses = {"a3": {TEXT: "a2"}, "b3": {TEXT: "b2"}} + forms = {"a4": "a4"} + # however, intent names can be anything + intents = ["a", "b"] + domain = Domain( + intents=intents, + action_names=action_names, + action_texts=action_texts, + responses=responses, + entities=["e_a", "e_b", "e_c"], + slots=[TextSlot(name="s", mappings=[{}])], + forms=forms, + data={}, + ) + lookup_table = MessageContainerForCoreFeaturization() + lookup_table.derive_messages_from_domain_and_add(domain) + assert len(lookup_table) == ( + len(domain.intent_properties) + len(domain.action_names_or_texts) + ) + + +@pytest.fixture +def input_converter( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + return CoreFeaturizationInputConverter.create( + CoreFeaturizationInputConverter.get_default_config(), + default_model_storage, + Resource("CoreFeaturizationInputConverters"), + default_execution_context, + ) + + +def test_converter_for_training(input_converter: CoreFeaturizationInputConverter): + # create domain and story graph + domain = Domain( + intents=["greet", "inform", "domain-only-intent"], + entities=["entity_name"], + slots=[], + responses=dict(), + action_names=["action_listen", "utter_greet"], + forms=dict(), + data={}, + action_texts=["Hi how are you?"], + ) + events = [ + ActionExecuted(action_name="action_listen"), + UserUttered( + text="hey this has some entities", + intent={INTENT_NAME_KEY: "greet"}, + entities=[_create_entity(value="Bot", type="entity_name")], + ), + ActionExecuted(action_name="utter_greet", action_text="Hi how are you?"), + ActionExecuted(action_name="action_listen"), + UserUttered( + text="some test with an intent!", intent={INTENT_NAME_KEY: "inform"} + ), + ActionExecuted(action_name="action_listen"), + ] + story_graph = StoryGraph([StoryStep("name", events=events)]) + # convert! + training_data = input_converter.convert_for_training( + domain=domain, story_graph=story_graph + ) + messages = training_data.training_examples + # check that messages were created from (story) events as expected + _check_messages_created_from_events_as_expected(events=events, messages=messages) + # check that messages were created from domain as expected + for intent in domain.intent_properties: + assert Message(data={INTENT: intent}) in messages + for action_name_or_text in domain.action_names_or_texts: + if action_name_or_text in domain.action_texts: + assert Message(data={ACTION_TEXT: action_name_or_text}) in messages + else: + assert Message(data={ACTION_NAME: action_name_or_text}) in messages + # check that each message contains only one attribute, which must be a key attribute + _check_messages_contain_attribute_which_is_key_attribute(messages=messages) + + +def _check_messages_created_from_events_as_expected( + events: List[Event], messages: List[Message] +) -> None: + for event in events: + expected = [] + if isinstance(event, UserUttered): + if event.text is not None: + expected.append({TEXT: event.text}) + if event.intent_name is not None: + expected.append({INTENT: event.intent_name}) + if isinstance(event, ActionExecuted): + if event.action_name is not None: + expected.append({ACTION_NAME: event.action_name}) + if event.action_text is not None: + expected.append({ACTION_TEXT: event.action_text}) + for sub_state in expected: + assert Message(sub_state) in messages + + +def _check_messages_contain_attribute_which_is_key_attribute(messages: List[Message]): + for message in messages: + assert len(message.data) == 1 + assert ( + list(message.data.keys())[0] + in MessageContainerForCoreFeaturization.KEY_ATTRIBUTES + ) + + +def test_converter_for_inference(input_converter: CoreFeaturizationInputConverter): + # create tracker + events = [ + UserUttered( + text="some text with entities!", + intent={INTENT_NAME_KEY: "greet"}, + entities=[_create_entity(value="Bot", type="entity_name")], + ), + ActionExecuted(action_name="utter_greet", action_text="Hi how are you?"), + ActionExecuted(action_name="action_listen"), + UserUttered(text="some text with intent!", intent={INTENT_NAME_KEY: "inform"}), + ] + tracker = DialogueStateTracker.from_events(sender_id="arbitrary", evts=events) + # convert! + messages = input_converter.convert_for_inference(tracker) + # check that messages were created from tracker events as expected + _check_messages_created_from_events_as_expected( + events=tracker.events, messages=messages + ) + # check that each message contains only one attribute, which must be a key attribute + _check_messages_contain_attribute_which_is_key_attribute(messages=messages) + + +@pytest.fixture +def collector( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + return CoreFeaturizationCollector.create( + CoreFeaturizationCollector.get_default_config(), + default_model_storage, + Resource("CoreFeaturizationCollector"), + default_execution_context, + ) + + +@pytest.mark.parametrize( + "messages_with_unique_lookup_key", + [ + [ + Message(data={TEXT: "A"}, features=[_dummy_features(1, TEXT)]), + Message(data={ACTION_TEXT: "B"}), + ], + [], + ], +) +def test_collection( + collector: CoreFeaturizationCollector, + messages_with_unique_lookup_key: List[Message], +): + + messages = messages_with_unique_lookup_key + + # pass as training data + training_data = TrainingData(training_examples=messages) + precomputations = collector.collect(training_data) + assert len(precomputations) == len(messages) + + # pass the list of messages directly + precomputations = collector.collect(messages) + assert len(precomputations) == len(messages) + + +def test_collection_fails(collector: CoreFeaturizationCollector): + """The collection expects messages that have a unique lookup key. + + This is because they (should) have been passed through the preparation stage which + will have constructed messages with this property. + """ + messages = [ + Message(data={TEXT: "A", ACTION_TEXT: "B"}, features=[_dummy_features(1, TEXT)]) + ] + training_data = TrainingData(training_examples=messages) + + with pytest.raises(ValueError): + collector.collect(training_data) + with pytest.raises(ValueError): + collector.collect(messages) diff --git a/tests/core/featurizers/test_single_state_featurizers.py b/tests/core/featurizers/test_single_state_featurizers.py new file mode 100644 index 0000000..6f0927a --- /dev/null +++ b/tests/core/featurizers/test_single_state_featurizers.py @@ -0,0 +1,563 @@ +from typing import Text +import numpy as np +import re +import scipy.sparse +import pytest + + +from rasa.nlu.tokenizers.tokenizer import Token +from rasa.nlu.constants import TOKENS_NAMES +from rasa.core.featurizers.precomputation import MessageContainerForCoreFeaturization +from rasa.core.featurizers.single_state_featurizer import SingleStateFeaturizer +from rasa.shared.nlu.training_data.features import Features +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.constants import ( + ACTION_TEXT, + ACTION_NAME, + ENTITIES, + TEXT, + INTENT, + FEATURE_TYPE_SEQUENCE, + FEATURE_TYPE_SENTENCE, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + ENTITY_TAGS, +) +from rasa.shared.core.domain import Domain +from rasa.shared.core.slots import TextSlot +from rasa.shared.core.constants import ( + ACTION_LISTEN_NAME, + ACTIVE_LOOP, + PREVIOUS_ACTION, + SLOTS, + ENTITY_LABEL_SEPARATOR, + USER, +) +from rasa.utils.tensorflow.constants import SENTENCE, SEQUENCE + + +# +# internals +# + + +def test_state_features_for_attribute__raises_on_not_supported_attribute(): + f = SingleStateFeaturizer() + + with pytest.raises(ValueError): + f._state_features_for_attribute({}, "not-supported-attribute") + + +def test_to_sparse_sentence_features(): + features = [ + Features( + scipy.sparse.csr_matrix(np.random.randint(5, size=(5, 10))), + FEATURE_TYPE_SEQUENCE, + TEXT, + "some-featurizer", + ) + ] + + sentence_features = SingleStateFeaturizer._to_sparse_sentence_features(features) + + assert len(sentence_features) == 1 + assert FEATURE_TYPE_SENTENCE == sentence_features[0].type + assert features[0].origin == sentence_features[0].origin + assert features[0].attribute == sentence_features[0].attribute + assert sentence_features[0].features.shape == (1, 10) + + +def test_create_features__dtype_float(): + f = SingleStateFeaturizer() + f._default_feature_states[INTENT] = {"a": 0, "b": 1} + f._default_feature_states[ACTION_NAME] = {"e": 0, "d": 1} + f._default_feature_states[ENTITIES] = {"c": 0} + + encoded = f._create_features({ACTION_NAME: "d"}, attribute=ACTION_NAME) + assert len(encoded) == 1 # cause for some reason this is a list + assert encoded[0].features.dtype == np.float32 + + +# +# preparation +# + + +def test_prepare_for_training(): + domain = Domain( + intents=["greet"], + entities=["name"], + slots=[TextSlot("name", mappings=[{}])], + responses={}, + forms={}, + action_names=["utter_greet", "action_check_weather"], + data={}, + ) + + f = SingleStateFeaturizer() + f.prepare_for_training(domain) + + assert len(f._default_feature_states[INTENT]) > 1 + assert "greet" in f._default_feature_states[INTENT] + assert len(f._default_feature_states[ENTITIES]) == 1 + assert f._default_feature_states[ENTITIES]["name"] == 0 + assert len(f._default_feature_states[SLOTS]) == 1 + assert f._default_feature_states[SLOTS]["name_0"] == 0 + assert len(f._default_feature_states[ACTION_NAME]) > 2 + assert "utter_greet" in f._default_feature_states[ACTION_NAME] + assert "action_check_weather" in f._default_feature_states[ACTION_NAME] + assert len(f._default_feature_states[ACTIVE_LOOP]) == 0 + + +# +# encode actions +# (always needs lookup table build from domain) +# + + +def test_encode_all_labels__encoded_all_action_names_and_texts(): + # ... where "labels" means actions... + domain = Domain( + intents=[], + entities=[], + slots=[], + responses={}, + forms={}, + action_names=["a", "b", "c", "d"], + data={}, + ) + + f = SingleStateFeaturizer() + f.prepare_for_training(domain) + + precomputations = MessageContainerForCoreFeaturization() + precomputations.derive_messages_from_domain_and_add(domain) + + encoded_actions = f.encode_all_labels(domain, precomputations=precomputations) + + assert len(encoded_actions) == len(domain.action_names_or_texts) + assert all( + [ + ACTION_NAME in encoded_action and ACTION_TEXT not in encoded_action + for encoded_action in encoded_actions + ] + ) + + +# +# encode state withOUT lookup table +# + + +def sparse_equals_dense( + sparse_matrix: scipy.sparse.spmatrix, dense_matrix: np.ndarray +) -> bool: + return np.all(sparse_matrix.todense() == dense_matrix) + + +@pytest.mark.parametrize("action_name", [None, "NOT_action_listen", ACTION_LISTEN_NAME]) +def test_encode_state__without_lookup(action_name: Text): + """Tests that `encode_state` creates features for every attribute. + In particular, that this is done even when there is no lookup table. + If there is no action_listen in the state, then no features should be created for + the user sub-state. + """ + f = SingleStateFeaturizer() + f._default_feature_states[INTENT] = {"a": 0, "b": 1} + f._default_feature_states[ACTION_NAME] = { + "c": 0, + "d": 1, + "NOT_action_listen": 2, + ACTION_LISTEN_NAME: 3, + } + f._default_feature_states[SLOTS] = {"e_0": 0, "f_0": 1, "g_0": 2} + f._default_feature_states[ACTIVE_LOOP] = {"h": 0, "i": 1, "j": 2, "k": 3} + + state = { + USER: {INTENT: "a", TEXT: "blah blah blah"}, + PREVIOUS_ACTION: {ACTION_TEXT: "boom"}, + ACTIVE_LOOP: {"name": "i"}, + SLOTS: {"g": (1.0,)}, + } + if action_name is not None: + state[PREVIOUS_ACTION][ACTION_NAME] = action_name + + encoded = f.encode_state(state, precomputations=None) + + # this differs depending on whether action name is ACTION_LISTEN_NAME or "d" + expected_attributes = [ACTIVE_LOOP, SLOTS] + if action_name == ACTION_LISTEN_NAME: + expected_attributes += [INTENT] + if action_name is not None: + expected_attributes += [ACTION_NAME] + assert set(encoded.keys()) == set(expected_attributes) + + # the encoding of action_name of course depends on the sub-state + if action_name is not None: + if action_name == "NOT_action_listen": + action_name_encoding = [0, 0, 1, 0] + else: + action_name_encoding = [0, 0, 0, 1] + assert sparse_equals_dense( + encoded[ACTION_NAME][0].features, np.array([action_name_encoding]) + ) + + # the intent / user substate is only featurized if action_listen is + # with_action_listen + if action_name == ACTION_LISTEN_NAME: + assert sparse_equals_dense(encoded[INTENT][0].features, np.array([[1, 0]])) + + # this is always the same + assert sparse_equals_dense( + encoded[ACTIVE_LOOP][0].features, np.array([[0, 1, 0, 0]]) + ) + assert sparse_equals_dense(encoded[SLOTS][0].features, np.array([[0, 0, 1]])) + + +# +# encode state WITH lookup table +# + + +def dummy_features( + fill_value: int, units: int, attribute: Text, type: Text, is_sparse: bool +) -> Features: + """Create some dummy `Features` with the desired properties.""" + matrix = np.full(shape=(1, units), fill_value=fill_value) + if is_sparse: + matrix = scipy.sparse.coo_matrix(matrix) + return Features( + features=matrix, attribute=attribute, feature_type=type, origin="whatever" + ) + + +@pytest.mark.parametrize("with_action_listen", [True, False]) +def test_encode_state__with_lookup__creates_features_for_intent_and_action_name( + with_action_listen: bool, +): + """Tests that features for intent and action name are created if needed. + Especially tests that this is the case even though no features are present in the + given lookup table for this intent and action_name. + However, if no `action_listen` is in the given sub-state, then the user sub-state + should not be featurized (hence, no features for intent) should be created. + """ + + f = SingleStateFeaturizer() + f._default_feature_states[INTENT] = {"a": 0, "b": 1} + f._default_feature_states[ACTION_NAME] = {"c": 0, "d": 1, ACTION_LISTEN_NAME: 2} + + # create state + action_name = ACTION_LISTEN_NAME if with_action_listen else "c" + state = {USER: {INTENT: "e"}, PREVIOUS_ACTION: {ACTION_NAME: action_name}} + + # create a lookup table with all relevant entries **but no Features** + precomputations = MessageContainerForCoreFeaturization() + precomputations.add(Message(data={INTENT: state[USER][INTENT]})) + precomputations.add( + Message(data={ACTION_NAME: state[PREVIOUS_ACTION][ACTION_NAME]}) + ) + + # encode! + encoded = f.encode_state(state, precomputations=precomputations) + + if with_action_listen: + assert set(encoded.keys()) == set([INTENT, ACTION_NAME]) + assert ( + encoded[INTENT][0].features != scipy.sparse.coo_matrix([[0, 0]]) + ).nnz == 0 + else: + assert set(encoded.keys()) == set([ACTION_NAME]) + + +@pytest.mark.parametrize("action_name", [None, "NOT_action_listen", ACTION_LISTEN_NAME]) +def test_encode_state__with_lookup__looksup_or_creates_features(action_name: Text): + """Tests that features from table are combined or created from scratch. + If the given action name is ... + - ACTION_LISTEN_NAME then the user substate and the action name are encoded + - some "other" action, then the user-substate is not encoed but the action name is + - set to "None", then we remove the action name from the user substate and as a + result there should be no encoding for the action name and for the user substate + """ + f = SingleStateFeaturizer() + f._default_feature_states[INTENT] = {"greet": 0, "inform": 1} + f._default_feature_states[ENTITIES] = { + "city": 0, + "name": 1, + f"city{ENTITY_LABEL_SEPARATOR}to": 2, + f"city{ENTITY_LABEL_SEPARATOR}from": 3, + } + f._default_feature_states[ACTION_NAME] = { + "NOT_action_listen": 0, + "utter_greet": 1, + ACTION_LISTEN_NAME: 2, + } + # `_0` in slots represent feature dimension + f._default_feature_states[SLOTS] = {"slot_1_0": 0, "slot_2_0": 1, "slot_3_0": 2} + f._default_feature_states[ACTIVE_LOOP] = { + "active_loop_1": 0, + "active_loop_2": 1, + "active_loop_3": 2, + "active_loop_4": 3, + } + + # create state + text = "I am flying from London to Paris" + tokens = [ + Token(text=match.group(), start=match.start()) + for match in re.finditer(r"\S+", text) + ] + entity_name_list = ["city", f"city{ENTITY_LABEL_SEPARATOR}to"] + action_text = "throw a ball" + intent = "inform" + state = { + USER: {TEXT: text, INTENT: intent, ENTITIES: entity_name_list}, + PREVIOUS_ACTION: {ACTION_NAME: action_name, ACTION_TEXT: action_text}, + ACTIVE_LOOP: {"name": "active_loop_4"}, + SLOTS: {"slot_1": (1.0,)}, + } + if action_name is None: + del state[PREVIOUS_ACTION][ACTION_NAME] + + # Build lookup table with all relevant information - and dummy features for all + # dense featurizable attributes. + # Note that we don't need to add the `ENTITIES` to the message including `TEXT` + # here because `encode_state` won't featurize the entities using the lookup table + # (only `encode_entities` does that). + units = 300 + precomputations = MessageContainerForCoreFeaturization() + precomputations.add_all( + [ + Message( + data={TEXT: text, TOKENS_NAMES[TEXT]: tokens}, + features=[ + dummy_features( + fill_value=11, + units=units, + attribute=TEXT, + type=SENTENCE, + is_sparse=True, + ), + dummy_features( + fill_value=12, + units=units, + attribute=TEXT, + type=SEQUENCE, + is_sparse=False, + ), + # Note: sparse sequence feature is last here + dummy_features( + fill_value=13, + units=units, + attribute=TEXT, + type=SEQUENCE, + is_sparse=True, + ), + ], + ), + Message(data={INTENT: intent}), + Message( + data={ACTION_TEXT: action_text}, + features=[ + dummy_features( + fill_value=1, + units=units, + attribute=ACTION_TEXT, + type=SEQUENCE, + is_sparse=True, + ) + ], + ), + ] + ) + if action_name is not None: + precomputations.add(Message(data={ACTION_NAME: action_name})) + + # encode the state + encoded = f.encode_state(state, precomputations=precomputations) + + # check all the features are encoded and *_text features are encoded by a + # dense featurizer + expected_attributes = [SLOTS, ACTIVE_LOOP, ACTION_TEXT] + if action_name is not None: # i.e. we did not remove it from the state + expected_attributes += [ACTION_NAME] + if action_name == ACTION_LISTEN_NAME: + expected_attributes += [TEXT, ENTITIES, INTENT] + assert set(encoded.keys()) == set(expected_attributes) + + # Remember, sparse sequence features come first (and `.features` denotes the matrix + # not a `Features` object) + if action_name == ACTION_LISTEN_NAME: + assert encoded[TEXT][0].features.shape[-1] == units + assert encoded[TEXT][0].is_sparse() + assert encoded[ENTITIES][0].features.shape[-1] == 4 + assert sparse_equals_dense(encoded[INTENT][0].features, np.array([[0, 1]])) + assert encoded[ACTION_TEXT][0].features.shape[-1] == units + assert encoded[ACTION_TEXT][0].is_sparse() + if action_name is not None: + if action_name == "NOT_action_listen": + action_name_encoding = [1, 0, 0] + else: # action_listen + action_name_encoding = [0, 0, 1] + assert sparse_equals_dense( + encoded[ACTION_NAME][0].features, np.array([action_name_encoding]) + ) + else: + assert ACTION_NAME not in encoded + assert sparse_equals_dense(encoded[SLOTS][0].features, np.array([[1, 0, 0]])) + assert sparse_equals_dense( + encoded[ACTIVE_LOOP][0].features, np.array([[0, 0, 0, 1]]) + ) + + +# +# encode entities +# (always needs lookup table build from tokenized messages incl. entities) +# + + +def test_encode_entities__with_entity_roles_and_groups(): + + # create fake message that has been tokenized and entities have been extracted + text = "I am flying from London to Paris" + tokens = [ + Token(text=match.group(), start=match.start()) + for match in re.finditer(r"\S+", text) + ] + entity_tags = ["city", f"city{ENTITY_LABEL_SEPARATOR}to"] + entities = [ + { + ENTITY_ATTRIBUTE_TYPE: entity_tags[0], + ENTITY_ATTRIBUTE_VALUE: "London", + ENTITY_ATTRIBUTE_START: 17, + ENTITY_ATTRIBUTE_END: 23, + }, + { + ENTITY_ATTRIBUTE_TYPE: entity_tags[1], + ENTITY_ATTRIBUTE_VALUE: "Paris", + ENTITY_ATTRIBUTE_START: 27, + ENTITY_ATTRIBUTE_END: 32, + }, + ] + message = Message({TEXT: text, TOKENS_NAMES[TEXT]: tokens, ENTITIES: entities}) + + # create a lookup table that has seen this message + precomputations = MessageContainerForCoreFeaturization() + precomputations.add(message) + + # instantiate matching domain and single state featurizer + domain = Domain( + intents=[], + entities=entity_tags, + slots=[], + responses={}, + forms={}, + action_names=[], + data={}, + ) + f = SingleStateFeaturizer() + f.prepare_for_training(domain) + + # encode! + encoded = f.encode_entities( + entity_data={TEXT: text, ENTITIES: entities}, precomputations=precomputations + ) + + # check + assert len(f.entity_tag_specs) == 1 + tags_to_ids = f.entity_tag_specs[0].tags_to_ids + for idx, entity_tag in enumerate(entity_tags): + tags_to_ids[entity_tag] = idx + 1 # hence, city -> 1, city#to -> 2 + assert sorted(list(encoded.keys())) == [ENTITY_TAGS] + assert np.all( + encoded[ENTITY_TAGS][0].features == [[0], [0], [0], [0], [1], [0], [2]] + ) + + +def test_encode_entities__with_bilou_entity_roles_and_groups(): + + # Instantiate domain and configure the single state featurizer for this domain. + # Note that there are 2 entity tags here. + entity_tags = ["city", f"city{ENTITY_LABEL_SEPARATOR}to"] + domain = Domain( + intents=[], + entities=entity_tags, + slots=[], + responses={}, + forms={}, + action_names=[], + data={}, + ) + f = SingleStateFeaturizer() + f.prepare_for_training(domain, bilou_tagging=True) + + # (1) example with both entities + + # create message that has been tokenized and where entities have been extracted + text = "I am flying from London to Paris" + tokens = [ + Token(text=match.group(), start=match.start()) + for match in re.finditer(r"\S+", text) + ] + entities = [ + { + ENTITY_ATTRIBUTE_TYPE: entity_tags[0], + ENTITY_ATTRIBUTE_VALUE: "London", + ENTITY_ATTRIBUTE_START: 17, + ENTITY_ATTRIBUTE_END: 23, + }, + { + ENTITY_ATTRIBUTE_TYPE: entity_tags[1], + ENTITY_ATTRIBUTE_VALUE: "Paris", + ENTITY_ATTRIBUTE_START: 27, + ENTITY_ATTRIBUTE_END: 32, + }, + ] + message = Message({TEXT: text, TOKENS_NAMES[TEXT]: tokens, ENTITIES: entities}) + + # create a lookup table that has seen this message + precomputations = MessageContainerForCoreFeaturization() + precomputations.add(message) + + # encode! + encoded = f.encode_entities( + {TEXT: text, ENTITIES: entities}, + precomputations=precomputations, + bilou_tagging=True, + ) + assert sorted(list(encoded.keys())) == sorted([ENTITY_TAGS]) + assert np.all( + encoded[ENTITY_TAGS][0].features == [[0], [0], [0], [0], [4], [0], [8]] + ) + + # (2) example with only the "city" entity + + # create message that has been tokenized and where entities have been extracted + text = "I am flying to Saint Petersburg" + tokens = [ + Token(text=match.group(), start=match.start()) + for match in re.finditer(r"\S+", text) + ] + entities = [ + { + ENTITY_ATTRIBUTE_TYPE: "city", + ENTITY_ATTRIBUTE_VALUE: "Saint Petersburg", + ENTITY_ATTRIBUTE_START: 15, + ENTITY_ATTRIBUTE_END: 31, + } + ] + message = Message({TEXT: text, TOKENS_NAMES[TEXT]: tokens, ENTITIES: entities}) + + # create a lookup table that has seen this message + precomputations = MessageContainerForCoreFeaturization() + precomputations.add(message) + + # encode! + encoded = f.encode_entities( + {TEXT: text, ENTITIES: entities}, + precomputations=precomputations, + bilou_tagging=True, + ) + assert sorted(list(encoded.keys())) == sorted([ENTITY_TAGS]) + assert np.all(encoded[ENTITY_TAGS][0].features == [[0], [0], [0], [0], [1], [3]]) diff --git a/tests/core/featurizers/test_tracker_featurizer.py b/tests/core/featurizers/test_tracker_featurizer.py new file mode 100644 index 0000000..20a0a08 --- /dev/null +++ b/tests/core/featurizers/test_tracker_featurizer.py @@ -0,0 +1,2353 @@ +from typing import Text, Dict, List, Optional + +import numpy as np +import pytest + +from rasa.core.featurizers.single_state_featurizer import SingleStateFeaturizer +from rasa.core.featurizers.single_state_featurizer import ( + IntentTokenizerSingleStateFeaturizer, +) +from rasa.core.featurizers.tracker_featurizers import ( + TrackerFeaturizer as TrackerFeaturizer, +) +from rasa.core.featurizers.tracker_featurizers import MaxHistoryTrackerFeaturizer +from rasa.core.featurizers.tracker_featurizers import IntentMaxHistoryTrackerFeaturizer +from rasa.core.featurizers.tracker_featurizers import FullDialogueTrackerFeaturizer +from rasa.shared.core.domain import Domain +from tests.core.utilities import user_uttered +from rasa.shared.nlu.training_data.features import Features +from rasa.shared.nlu.constants import INTENT, ACTION_NAME +from rasa.shared.core.constants import ( + ACTION_LISTEN_NAME, + ACTION_UNLIKELY_INTENT_NAME, + USER, + PREVIOUS_ACTION, +) +from rasa.shared.core.events import ActionExecuted +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.utils.tensorflow.constants import LABEL_PAD_ID +from rasa.utils.tensorflow.model_data import ragged_array_to_ndarray +from rasa.core.exceptions import InvalidTrackerFeaturizerUsageError + + +def test_fail_to_load_non_existent_featurizer(): + assert TrackerFeaturizer.load("non_existent_class") is None + + +def test_persist_and_load_full_dialogue_tracker_featurizer( + tmp_path: Text, moodbot_domain: Domain +): + state_featurizer = SingleStateFeaturizer() + state_featurizer.prepare_for_training(moodbot_domain) + tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer) + + tracker_featurizer.persist(tmp_path) + + loaded_tracker_featurizer = TrackerFeaturizer.load(tmp_path) + + assert loaded_tracker_featurizer is not None + assert loaded_tracker_featurizer.state_featurizer is not None + assert loaded_tracker_featurizer.to_dict() == tracker_featurizer.to_dict() + + +def test_persist_and_load_max_history_tracker_featurizer( + tmp_path: Text, moodbot_domain: Domain +): + state_featurizer = SingleStateFeaturizer() + state_featurizer.prepare_for_training(moodbot_domain) + tracker_featurizer = MaxHistoryTrackerFeaturizer(state_featurizer) + + tracker_featurizer.persist(tmp_path) + + loaded_tracker_featurizer = TrackerFeaturizer.load(tmp_path) + + assert loaded_tracker_featurizer is not None + assert loaded_tracker_featurizer.state_featurizer is not None + assert loaded_tracker_featurizer.to_dict() == tracker_featurizer.to_dict() + + +def test_persist_and_load_intent_max_history_tracker_featurizer( + tmp_path: Text, moodbot_domain: Domain +): + state_featurizer = SingleStateFeaturizer() + state_featurizer.prepare_for_training(moodbot_domain) + tracker_featurizer = IntentMaxHistoryTrackerFeaturizer(state_featurizer) + + tracker_featurizer.persist(tmp_path) + + loaded_tracker_featurizer = TrackerFeaturizer.load(tmp_path) + + assert loaded_tracker_featurizer is not None + assert loaded_tracker_featurizer.state_featurizer is not None + assert loaded_tracker_featurizer.to_dict() == tracker_featurizer.to_dict() + + +def test_convert_action_labels_to_ids(domain: Domain): + trackers_as_actions = [ + ["utter_greet", "utter_channel"], + ["utter_greet", "utter_default", "utter_goodbye"], + ] + + tracker_featurizer = TrackerFeaturizer() + + actual_output = tracker_featurizer._convert_labels_to_ids( + trackers_as_actions, domain + ) + expected_output = ragged_array_to_ndarray( + [ + np.array( + [ + domain.action_names_or_texts.index("utter_greet"), + domain.action_names_or_texts.index("utter_channel"), + ] + ), + np.array( + [ + domain.action_names_or_texts.index("utter_greet"), + domain.action_names_or_texts.index("utter_default"), + domain.action_names_or_texts.index("utter_goodbye"), + ] + ), + ] + ) + + assert expected_output.size == actual_output.size + for expected_array, actual_array in zip(expected_output, actual_output): + assert np.all(expected_array == actual_array) + + +def test_convert_intent_labels_to_ids(domain: Domain): + trackers_as_intents = [ + ["next_intent", "nlu_fallback", "out_of_scope", "restart"], + ["greet", "hello", "affirm"], + ] + + tracker_featurizer = IntentMaxHistoryTrackerFeaturizer() + + actual_labels = tracker_featurizer._convert_labels_to_ids( + trackers_as_intents, domain + ) + + expected_labels = np.array( + [ + [ + domain.intents.index("next_intent"), + domain.intents.index("nlu_fallback"), + domain.intents.index("out_of_scope"), + domain.intents.index("restart"), + ], + [ + domain.intents.index("greet"), + domain.intents.index("hello"), + domain.intents.index("affirm"), + LABEL_PAD_ID, + ], + ] + ) + assert expected_labels.size == actual_labels.size + assert expected_labels.shape == actual_labels.shape + assert np.all(expected_labels == actual_labels) + + +def test_featurize_trackers_raises_on_missing_state_featurizer(domain: Domain): + tracker_featurizer = TrackerFeaturizer() + + with pytest.raises(InvalidTrackerFeaturizerUsageError): + tracker_featurizer.featurize_trackers([], domain, precomputations=None) + + +def compare_featurized_states( + states1: List[Dict[Text, List[Features]]], states2: List[Dict[Text, List[Features]]] +) -> bool: + """Compares two lists of featurized states and returns True if they + are identical and False otherwise. + """ + if len(states1) != len(states2): + return False + + for state1, state2 in zip(states1, states2): + if state1.keys() != state2.keys(): + return False + for key in state1.keys(): + for feature1, feature2 in zip(state1[key], state2[key]): + if np.any((feature1.features != feature2.features).toarray()): + return False + if feature1.origin != feature2.origin: + return False + if feature1.attribute != feature2.attribute: + return False + if feature1.type != feature2.type: + return False + return True + + +def test_featurize_trackers_with_full_dialogue_tracker_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], +): + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer) + + actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers( + [moodbot_tracker], moodbot_domain, precomputations=None + ) + + expected_features = [ + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_cheer_up"]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_did_that_help"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["deny"]], + }, + ] + ] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + expected_labels = np.array([[0, 16, 0, 13, 14, 0, 15]]) + assert actual_labels is not None + assert len(actual_labels) == 1 + for actual, expected in zip(actual_labels, expected_labels): + assert np.all(actual == expected) + + # moodbot doesn't contain e2e entities + assert not any([any(turn_tags) for turn_tags in entity_tags]) + + +def test_trackers_ignore_action_unlikely_intent_with_full_dialogue_tracker_featurizer( + moodbot_domain: Domain, moodbot_features: Dict[Text, Dict[Text, Features]] +): + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_unhappy"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_cheer_up"), + ActionExecuted("utter_did_that_help"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("deny"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_goodbye"), + ], + domain=moodbot_domain, + ) + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer) + + actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers( + [tracker], + moodbot_domain, + precomputations=None, + ignore_action_unlikely_intent=True, + ) + + expected_features = [ + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_cheer_up"]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_did_that_help"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["deny"]], + }, + ] + ] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + expected_labels = np.array([[0, 16, 0, 13, 14, 0, 15]]) + assert actual_labels is not None + assert len(actual_labels) == 1 + for actual, expected in zip(actual_labels, expected_labels): + assert np.all(actual == expected) + + # moodbot doesn't contain e2e entities + assert not any([any(turn_tags) for turn_tags in entity_tags]) + + +def test_trackers_keep_action_unlikely_intent_with_full_dialogue_tracker_featurizer( + moodbot_domain: Domain, moodbot_features: Dict[Text, Dict[Text, Features]] +): + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_unhappy"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_cheer_up"), + ActionExecuted("utter_did_that_help"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("deny"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_goodbye"), + ], + domain=moodbot_domain, + ) + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer) + + actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers( + [tracker], moodbot_domain, precomputations=None + ) + + expected_features = [ + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"][ACTION_UNLIKELY_INTENT_NAME]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + {ACTION_NAME: [moodbot_features["actions"][ACTION_UNLIKELY_INTENT_NAME]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_cheer_up"]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_did_that_help"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["deny"]], + }, + {ACTION_NAME: [moodbot_features["actions"][ACTION_UNLIKELY_INTENT_NAME]]}, + ] + ] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + expected_labels = np.array([[0, 9, 16, 0, 9, 13, 14, 0, 9, 15]]) + assert actual_labels is not None + assert len(actual_labels) == 1 + for actual, expected in zip(actual_labels, expected_labels): + assert np.all(actual == expected) + + # moodbot doesn't contain e2e entities + assert not any([any(turn_tags) for turn_tags in entity_tags]) + + +def test_create_state_features_full_dialogue_tracker_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], +): + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer) + state_featurizer.prepare_for_training(moodbot_domain) + actual_features = tracker_featurizer.create_state_features( + [moodbot_tracker], moodbot_domain, precomputations=None + ) + + expected_features = [ + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_cheer_up"]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_did_that_help"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["deny"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_goodbye"]]}, + ] + ] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + +def test_state_features_ignore_action_unlikely_intent_full_dialogue_tracker_featurizer( + moodbot_domain: Domain, moodbot_features: Dict[Text, Dict[Text, Features]] +): + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_great"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_happy"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("goodbye"), + ], + domain=moodbot_domain, + ) + + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer) + state_featurizer.prepare_for_training(moodbot_domain) + actual_features = tracker_featurizer.create_state_features( + [tracker], + moodbot_domain, + precomputations=None, + ignore_action_unlikely_intent=True, + ) + + expected_features = [ + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_great"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_happy"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["goodbye"]], + }, + ] + ] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + +def test_state_features_keep_action_unlikely_intent_full_dialogue_tracker_featurizer( + moodbot_domain: Domain, moodbot_features: Dict[Text, Dict[Text, Features]] +): + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_great"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_happy"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("goodbye"), + ], + domain=moodbot_domain, + ) + + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer) + state_featurizer.prepare_for_training(moodbot_domain) + actual_features = tracker_featurizer.create_state_features( + [tracker], moodbot_domain, precomputations=None + ) + + expected_features = [ + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"][ACTION_UNLIKELY_INTENT_NAME]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_great"]], + }, + {ACTION_NAME: [moodbot_features["actions"][ACTION_UNLIKELY_INTENT_NAME]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_happy"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["goodbye"]], + }, + ] + ] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + +def test_prediction_states_with_full_dialogue_tracker_featurizer( + moodbot_tracker: DialogueStateTracker, moodbot_domain: Domain +): + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer) + actual_states = tracker_featurizer.prediction_states( + [moodbot_tracker], moodbot_domain + ) + + expected_states = [ + [ + {}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "greet"}, + }, + {USER: {INTENT: "greet"}, PREVIOUS_ACTION: {ACTION_NAME: "utter_greet"}}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "mood_unhappy"}, + }, + { + USER: {INTENT: "mood_unhappy"}, + PREVIOUS_ACTION: {ACTION_NAME: "utter_cheer_up"}, + }, + { + USER: {INTENT: "mood_unhappy"}, + PREVIOUS_ACTION: {ACTION_NAME: "utter_did_that_help"}, + }, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "deny"}, + }, + {USER: {INTENT: "deny"}, PREVIOUS_ACTION: {ACTION_NAME: "utter_goodbye"}}, + ] + ] + + assert actual_states is not None + assert len(actual_states) == len(expected_states) + + for actual, expected in zip(actual_states, expected_states): + assert actual == expected + + +def test_prediction_states_hide_rule_states_with_full_dialogue_tracker_featurizer( + moodbot_domain: Domain, +): + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer) + + rule_tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("utter_greet", hide_rule_turn=True), + ActionExecuted(ACTION_LISTEN_NAME, hide_rule_turn=True), + ], + domain=moodbot_domain, + ) + + actual_states = tracker_featurizer.prediction_states( + [rule_tracker], moodbot_domain, ignore_rule_only_turns=True + ) + + expected_states = [ + [ + {}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "greet"}, + }, + ] + ] + + assert actual_states is not None + assert len(actual_states) == len(expected_states) + + for actual, expected in zip(actual_states, expected_states): + assert actual == expected + + embedded_rule_tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("utter_greet", hide_rule_turn=True), + ActionExecuted(ACTION_LISTEN_NAME, hide_rule_turn=True), + user_uttered("mood_great"), + ActionExecuted("utter_happy"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + domain=moodbot_domain, + ) + + actual_states = tracker_featurizer.prediction_states( + [embedded_rule_tracker], moodbot_domain, ignore_rule_only_turns=True + ) + + expected_states = [ + [ + {}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "mood_great"}, + }, + { + USER: {INTENT: "mood_great"}, + PREVIOUS_ACTION: {ACTION_NAME: "utter_happy"}, + }, + { + USER: {INTENT: "mood_great"}, + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + }, + ] + ] + + assert actual_states is not None + assert len(actual_states) == len(expected_states) + + for actual, expected in zip(actual_states, expected_states): + assert actual == expected + + +def test_prediction_states_ignore_action_intent_unlikely_full_dialogue_featurizer( + moodbot_domain: Domain, +): + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_great"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_happy"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("goodbye"), + ], + domain=moodbot_domain, + ) + + actual_states = tracker_featurizer.prediction_states( + [tracker], moodbot_domain, ignore_action_unlikely_intent=True + ) + + expected_states = [ + [ + {}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "greet"}, + }, + {USER: {INTENT: "greet"}, PREVIOUS_ACTION: {ACTION_NAME: "utter_greet"}}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "mood_great"}, + }, + { + USER: {INTENT: "mood_great"}, + PREVIOUS_ACTION: {ACTION_NAME: "utter_happy"}, + }, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "goodbye"}, + }, + ] + ] + + assert actual_states is not None + assert len(actual_states) == len(expected_states) + + for actual, expected in zip(actual_states, expected_states): + assert actual == expected + + +def test_prediction_states_keeps_action_intent_unlikely_full_dialogue_featurizer( + moodbot_domain: Domain, +): + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_great"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_happy"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("goodbye"), + ], + domain=moodbot_domain, + ) + + actual_states = tracker_featurizer.prediction_states([tracker], moodbot_domain) + + expected_states = [ + [ + {}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "greet"}, + }, + { + USER: {INTENT: "greet"}, + PREVIOUS_ACTION: {ACTION_NAME: ACTION_UNLIKELY_INTENT_NAME}, + }, + {USER: {INTENT: "greet"}, PREVIOUS_ACTION: {ACTION_NAME: "utter_greet"}}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "mood_great"}, + }, + { + USER: {INTENT: "mood_great"}, + PREVIOUS_ACTION: {ACTION_NAME: ACTION_UNLIKELY_INTENT_NAME}, + }, + { + USER: {INTENT: "mood_great"}, + PREVIOUS_ACTION: {ACTION_NAME: "utter_happy"}, + }, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "goodbye"}, + }, + ] + ] + + assert actual_states is not None + assert len(actual_states) == len(expected_states) + + for actual, expected in zip(actual_states, expected_states): + assert actual == expected + + +@pytest.mark.parametrize("max_history", [None, 2]) +def test_featurize_trackers_with_max_history_tracker_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], + max_history: Optional[int], +): + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = MaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + + actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers( + [moodbot_tracker], moodbot_domain, precomputations=None + ) + + expected_features = [ + [{}], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + ], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + ], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + ], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_cheer_up"]]}, + ], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_cheer_up"]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_did_that_help"]]}, + ], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_cheer_up"]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_did_that_help"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["deny"]], + }, + ], + ] + if max_history is not None: + expected_features = [x[-max_history:] for x in expected_features] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + expected_labels = np.array([[0, 16, 0, 13, 14, 0, 15]]).T + + assert actual_labels is not None + assert actual_labels.shape == expected_labels.shape + assert np.all(actual_labels == expected_labels) + + # moodbot doesn't contain e2e entities + assert not any([any(turn_tags) for turn_tags in entity_tags]) + + +@pytest.mark.parametrize("max_history", [None, 2]) +def test_featurize_trackers_ignore_action_unlikely_intent_max_history_featurizer( + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], + max_history: Optional[int], +): + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_unhappy"), + ], + domain=moodbot_domain, + ) + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = MaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + + actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers( + [tracker], + moodbot_domain, + precomputations=None, + ignore_action_unlikely_intent=True, + ) + + expected_features = [ + [{}], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + ], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + ], + ] + if max_history is not None: + expected_features = [x[-max_history:] for x in expected_features] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + expected_labels = np.array([[0, 16, 0]]).T + assert actual_labels.shape == expected_labels.shape + for actual, expected in zip(actual_labels, expected_labels): + assert np.all(actual == expected) + + # moodbot doesn't contain e2e entities + assert not any([any(turn_tags) for turn_tags in entity_tags]) + + +@pytest.mark.parametrize("max_history", [None, 2]) +def test_featurize_trackers_keep_action_unlikely_intent_max_history_featurizer( + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], + max_history: Optional[int], +): + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_unhappy"), + ], + domain=moodbot_domain, + ) + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = MaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + + actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers( + [tracker], moodbot_domain, precomputations=None + ) + + expected_features = [ + [{}], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + ], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"][ACTION_UNLIKELY_INTENT_NAME]]}, + ], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"][ACTION_UNLIKELY_INTENT_NAME]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + ], + ] + if max_history is not None: + expected_features = [x[-max_history:] for x in expected_features] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + expected_labels = np.array([[0, 9, 16, 0]]).T + assert actual_labels is not None + assert actual_labels.shape == expected_labels.shape + for actual, expected in zip(actual_labels, expected_labels): + assert np.all(actual == expected) + + # moodbot doesn't contain e2e entities + assert not any([any(turn_tags) for turn_tags in entity_tags]) + + +@pytest.mark.parametrize( + "remove_duplicates,max_history", + [[True, None], [True, 2], [False, None], [False, 2]], +) +def test_deduplicate_featurize_trackers_with_max_history_tracker_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], + remove_duplicates: bool, + max_history: Optional[int], +): + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = MaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history, remove_duplicates=remove_duplicates + ) + + # Add Duplicate moodbot_tracker states should get removed. + actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers( + [moodbot_tracker, moodbot_tracker], moodbot_domain, precomputations=None + ) + + expected_features = [ + [{}], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + ], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + ], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + ], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_cheer_up"]]}, + ], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_cheer_up"]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_did_that_help"]]}, + ], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_cheer_up"]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_did_that_help"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["deny"]], + }, + ], + ] + if max_history is not None: + expected_features = [x[-max_history:] for x in expected_features] + if not remove_duplicates: + expected_features = expected_features * 2 + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + expected_labels = np.array([[0, 16, 0, 13, 14, 0, 15]]).T + if not remove_duplicates: + expected_labels = np.vstack([expected_labels] * 2) + + assert actual_labels is not None + assert actual_labels.shape == expected_labels.shape + assert np.all(actual_labels == expected_labels) + + # moodbot doesn't contain e2e entities + assert not any([any(turn_tags) for turn_tags in entity_tags]) + + +@pytest.mark.parametrize("max_history", [None, 2]) +def test_create_state_features_with_max_history_tracker_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], + max_history: Optional[int], +): + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = MaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + state_featurizer.prepare_for_training(moodbot_domain) + actual_features = tracker_featurizer.create_state_features( + [moodbot_tracker], moodbot_domain, precomputations=None + ) + + expected_features = [ + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_cheer_up"]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_did_that_help"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["deny"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_goodbye"]]}, + ] + ] + if max_history is not None: + expected_features = [x[-max_history:] for x in expected_features] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + +@pytest.mark.parametrize("max_history", [None, 2]) +def test_create_state_features_ignore_action_unlikely_intent_max_history_featurizer( + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], + max_history: Optional[int], +): + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_great"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_happy"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("goodbye"), + ], + domain=moodbot_domain, + ) + + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = MaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + state_featurizer.prepare_for_training(moodbot_domain) + actual_features = tracker_featurizer.create_state_features( + [tracker], + moodbot_domain, + precomputations=None, + ignore_action_unlikely_intent=True, + ) + + expected_features = [ + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_great"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_happy"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["goodbye"]], + }, + ] + ] + if max_history is not None: + expected_features = [x[-max_history:] for x in expected_features] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + +@pytest.mark.parametrize("max_history", [None, 2]) +def test_create_state_features_keep_action_unlikely_intent_max_history_featurizer( + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], + max_history: Optional[int], +): + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_great"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_happy"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("goodbye"), + ], + domain=moodbot_domain, + ) + + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = MaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + state_featurizer.prepare_for_training(moodbot_domain) + actual_features = tracker_featurizer.create_state_features( + [tracker], moodbot_domain, precomputations=None + ) + + expected_features = [ + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"][ACTION_UNLIKELY_INTENT_NAME]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_great"]], + }, + {ACTION_NAME: [moodbot_features["actions"][ACTION_UNLIKELY_INTENT_NAME]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_happy"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["goodbye"]], + }, + ] + ] + if max_history is not None: + expected_features = [x[-max_history:] for x in expected_features] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + +@pytest.mark.parametrize("max_history", [None, 2]) +def test_prediction_states_with_max_history_tracker_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + max_history: Optional[int], +): + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = MaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + actual_states = tracker_featurizer.prediction_states( + [moodbot_tracker], moodbot_domain + ) + + expected_states = [ + [ + {}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "greet"}, + }, + {USER: {INTENT: "greet"}, PREVIOUS_ACTION: {ACTION_NAME: "utter_greet"}}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "mood_unhappy"}, + }, + { + USER: {INTENT: "mood_unhappy"}, + PREVIOUS_ACTION: {ACTION_NAME: "utter_cheer_up"}, + }, + { + USER: {INTENT: "mood_unhappy"}, + PREVIOUS_ACTION: {ACTION_NAME: "utter_did_that_help"}, + }, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "deny"}, + }, + {USER: {INTENT: "deny"}, PREVIOUS_ACTION: {ACTION_NAME: "utter_goodbye"}}, + ] + ] + if max_history is not None: + expected_states = [x[-max_history:] for x in expected_states] + + assert actual_states is not None + assert len(actual_states) == len(expected_states) + + for actual, expected in zip(actual_states, expected_states): + assert actual == expected + + +@pytest.mark.parametrize("max_history", [None, 2]) +def test_prediction_states_hide_rule_states_with_max_history_tracker_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + max_history: Optional[int], +): + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = MaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + + rule_tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("utter_greet", hide_rule_turn=True), + ActionExecuted(ACTION_LISTEN_NAME, hide_rule_turn=True), + ], + domain=moodbot_domain, + ) + + actual_states = tracker_featurizer.prediction_states( + [rule_tracker], moodbot_domain, ignore_rule_only_turns=True + ) + + expected_states = [ + [ + {}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "greet"}, + }, + ] + ] + + assert actual_states is not None + assert len(actual_states) == len(expected_states) + + for actual, expected in zip(actual_states, expected_states): + assert actual == expected + + embedded_rule_tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("utter_greet", hide_rule_turn=True), + ActionExecuted(ACTION_LISTEN_NAME, hide_rule_turn=True), + user_uttered("mood_great"), + ActionExecuted("utter_happy"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + domain=moodbot_domain, + ) + + actual_states = tracker_featurizer.prediction_states( + [embedded_rule_tracker], moodbot_domain, ignore_rule_only_turns=True + ) + + expected_states = [ + [ + {}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "mood_great"}, + }, + { + USER: {INTENT: "mood_great"}, + PREVIOUS_ACTION: {ACTION_NAME: "utter_happy"}, + }, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "mood_great"}, + }, + ] + ] + + if max_history is not None: + expected_states = [x[-max_history:] for x in expected_states] + + assert actual_states is not None + assert len(actual_states) == len(expected_states) + + for actual, expected in zip(actual_states, expected_states): + assert actual == expected + + +@pytest.mark.parametrize("max_history", [None, 3]) +def test_prediction_states_ignores_action_intent_unlikely_max_history_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + max_history: Optional[int], +): + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = MaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_great"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_happy"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("goodbye"), + ], + domain=moodbot_domain, + ) + + actual_states = tracker_featurizer.prediction_states( + [tracker], moodbot_domain, ignore_action_unlikely_intent=True + ) + + expected_states = [ + [ + {}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "greet"}, + }, + {USER: {INTENT: "greet"}, PREVIOUS_ACTION: {ACTION_NAME: "utter_greet"}}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "mood_great"}, + }, + { + USER: {INTENT: "mood_great"}, + PREVIOUS_ACTION: {ACTION_NAME: "utter_happy"}, + }, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "goodbye"}, + }, + ] + ] + + if max_history is not None: + expected_states = [x[-max_history:] for x in expected_states] + + assert actual_states is not None + assert len(actual_states) == len(expected_states) + + for actual, expected in zip(actual_states, expected_states): + assert actual == expected + + +@pytest.mark.parametrize("max_history", [None, 3]) +def test_prediction_states_keeps_action_intent_unlikely_max_history_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + max_history: Optional[int], +): + state_featurizer = SingleStateFeaturizer() + tracker_featurizer = MaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_great"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_happy"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("goodbye"), + ], + domain=moodbot_domain, + ) + + actual_states = tracker_featurizer.prediction_states([tracker], moodbot_domain) + + expected_states = [ + [ + {}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "greet"}, + }, + { + USER: {INTENT: "greet"}, + PREVIOUS_ACTION: {ACTION_NAME: ACTION_UNLIKELY_INTENT_NAME}, + }, + {USER: {INTENT: "greet"}, PREVIOUS_ACTION: {ACTION_NAME: "utter_greet"}}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "mood_great"}, + }, + { + USER: {INTENT: "mood_great"}, + PREVIOUS_ACTION: {ACTION_NAME: ACTION_UNLIKELY_INTENT_NAME}, + }, + { + USER: {INTENT: "mood_great"}, + PREVIOUS_ACTION: {ACTION_NAME: "utter_happy"}, + }, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "goodbye"}, + }, + ] + ] + + if max_history is not None: + expected_states = [x[-max_history:] for x in expected_states] + + assert actual_states is not None + assert len(actual_states) == len(expected_states) + + for actual, expected in zip(actual_states, expected_states): + assert actual == expected + + +@pytest.mark.parametrize( + "max_history,moodbot_features", + [ + [None, "IntentTokenizerSingleStateFeaturizer"], + [2, "IntentTokenizerSingleStateFeaturizer"], + ], + indirect=["moodbot_features"], +) +def test_featurize_trackers_with_intent_max_history_tracker_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], + max_history: Optional[int], +): + state_featurizer = IntentTokenizerSingleStateFeaturizer() + tracker_featurizer = IntentMaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + + actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers( + [moodbot_tracker], moodbot_domain, precomputations=None + ) + + expected_features = [ + [{}], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + ], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_cheer_up"]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_did_that_help"]]}, + ], + ] + if max_history is not None: + expected_features = [x[-max_history:] for x in expected_features] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + expected_labels = np.array([[5, 7, 3]]).T + + assert actual_labels is not None + assert actual_labels.shape == expected_labels.shape + assert np.all(actual_labels == expected_labels) + + # moodbot doesn't contain e2e entities + assert not any([any(turn_tags) for turn_tags in entity_tags]) + + +@pytest.mark.parametrize( + "max_history, moodbot_features", + [ + [None, "IntentTokenizerSingleStateFeaturizer"], + [2, "IntentTokenizerSingleStateFeaturizer"], + ], + indirect=["moodbot_features"], +) +def test_trackers_ignore_action_unlikely_intent_intent_max_history_featurizer( + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], + max_history: Optional[int], +): + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_unhappy"), + ], + domain=moodbot_domain, + ) + state_featurizer = IntentTokenizerSingleStateFeaturizer() + tracker_featurizer = IntentMaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + + actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers( + [tracker], + moodbot_domain, + precomputations=None, + ignore_action_unlikely_intent=True, + ) + + expected_features = [ + [{}], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + ], + ] + + if max_history is not None: + expected_features = [x[-max_history:] for x in expected_features] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + expected_labels = np.array([[5, 7]]).T + assert actual_labels.shape == expected_labels.shape + for actual, expected in zip(actual_labels, expected_labels): + assert np.all(actual == expected) + + # moodbot doesn't contain e2e entities + assert not any([any(turn_tags) for turn_tags in entity_tags]) + + +@pytest.mark.parametrize( + "max_history,moodbot_features", + [ + [None, "IntentTokenizerSingleStateFeaturizer"], + [2, "IntentTokenizerSingleStateFeaturizer"], + ], + indirect=["moodbot_features"], +) +def test_trackers_keep_action_unlikely_intent_intent_max_history_featurizer( + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], + max_history: Optional[int], +): + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_unhappy"), + ], + domain=moodbot_domain, + ) + state_featurizer = IntentTokenizerSingleStateFeaturizer() + tracker_featurizer = IntentMaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + + actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers( + [tracker], moodbot_domain, precomputations=None + ) + + expected_features = [ + [{}], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"][ACTION_UNLIKELY_INTENT_NAME]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + ], + ] + if max_history is not None: + expected_features = [x[-max_history:] for x in expected_features] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + expected_labels = np.array([[5, 7]]).T + assert actual_labels is not None + assert actual_labels.shape == expected_labels.shape + + for actual, expected in zip(actual_labels, expected_labels): + assert np.all(actual == expected) + + # moodbot doesn't contain e2e entities + assert not any([any(turn_tags) for turn_tags in entity_tags]) + + +@pytest.mark.parametrize( + "remove_duplicates,max_history,moodbot_features", + [ + [True, None, "IntentTokenizerSingleStateFeaturizer"], + [True, 2, "IntentTokenizerSingleStateFeaturizer"], + [False, None, "IntentTokenizerSingleStateFeaturizer"], + [False, 2, "IntentTokenizerSingleStateFeaturizer"], + ], + indirect=["moodbot_features"], +) +def test_deduplicate_featurize_trackers_with_intent_max_history_tracker_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], + remove_duplicates: bool, + max_history: Optional[int], +): + state_featurizer = IntentTokenizerSingleStateFeaturizer() + tracker_featurizer = IntentMaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history, remove_duplicates=remove_duplicates + ) + + # Add Duplicate moodbot_tracker states should get removed. + actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers( + [moodbot_tracker, moodbot_tracker], moodbot_domain, precomputations=None + ) + + expected_features = [ + [{}], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + ], + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_cheer_up"]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_did_that_help"]]}, + ], + ] + + if max_history is not None: + expected_features = [x[-max_history:] for x in expected_features] + if not remove_duplicates: + expected_features = expected_features * 2 + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + expected_labels = np.array([[5, 7, 3]]).T + if not remove_duplicates: + expected_labels = np.vstack([expected_labels] * 2) + + assert actual_labels is not None + assert actual_labels.shape == expected_labels.shape + assert np.all(actual_labels == expected_labels) + + # moodbot doesn't contain e2e entities + assert not any([any(turn_tags) for turn_tags in entity_tags]) + + +@pytest.mark.parametrize( + "max_history,moodbot_features", + [ + [None, "IntentTokenizerSingleStateFeaturizer"], + [2, "IntentTokenizerSingleStateFeaturizer"], + ], + indirect=["moodbot_features"], +) +def test_create_state_features_with_intent_max_history_tracker_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], + max_history: Optional[int], +): + # IntentMaxHistoryTrackerFeaturizer prediction is only done after + # a UserUttered event so remove the last BotUttered and + # ActionExecuted events. + moodbot_tracker = moodbot_tracker.copy() + moodbot_tracker.events.pop() + moodbot_tracker.events.pop() + + state_featurizer = IntentTokenizerSingleStateFeaturizer() + tracker_featurizer = IntentMaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + state_featurizer.prepare_for_training(moodbot_domain) + actual_features = tracker_featurizer.create_state_features( + [moodbot_tracker], moodbot_domain, precomputations=None + ) + + expected_features = [ + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_unhappy"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_cheer_up"]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_did_that_help"]]}, + ] + ] + if max_history is not None: + expected_features = [x[-max_history:] for x in expected_features] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + +@pytest.mark.parametrize( + "max_history,moodbot_features", + [ + [None, "IntentTokenizerSingleStateFeaturizer"], + [2, "IntentTokenizerSingleStateFeaturizer"], + ], + indirect=["moodbot_features"], +) +def test_state_features_ignore_action_unlikely_intent_intent_max_history_featurizer( + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], + max_history: Optional[int], +): + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_great"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_happy"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("goodbye"), + ], + domain=moodbot_domain, + ) + state_featurizer = IntentTokenizerSingleStateFeaturizer() + tracker_featurizer = IntentMaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + state_featurizer.prepare_for_training(moodbot_domain) + actual_features = tracker_featurizer.create_state_features( + [tracker], + moodbot_domain, + precomputations=None, + ignore_action_unlikely_intent=True, + ) + + expected_features = [ + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_great"]], + }, + {ACTION_NAME: [moodbot_features["actions"]["utter_happy"]]}, + ] + ] + if max_history is not None: + expected_features = [x[-max_history:] for x in expected_features] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + +@pytest.mark.parametrize( + "max_history,moodbot_features", + [ + [None, "IntentTokenizerSingleStateFeaturizer"], + [2, "IntentTokenizerSingleStateFeaturizer"], + ], + indirect=["moodbot_features"], +) +def test_state_features_keep_action_unlikely_intent_intent_max_history_featurizer( + moodbot_domain: Domain, + moodbot_features: Dict[Text, Dict[Text, Features]], + max_history: Optional[int], +): + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_great"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_happy"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("goodbye"), + ], + domain=moodbot_domain, + ) + state_featurizer = IntentTokenizerSingleStateFeaturizer() + tracker_featurizer = IntentMaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + state_featurizer.prepare_for_training(moodbot_domain) + actual_features = tracker_featurizer.create_state_features( + [tracker], moodbot_domain, precomputations=None + ) + + expected_features = [ + [ + {}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["greet"]], + }, + {ACTION_NAME: [moodbot_features["actions"][ACTION_UNLIKELY_INTENT_NAME]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_greet"]]}, + { + ACTION_NAME: [moodbot_features["actions"][ACTION_LISTEN_NAME]], + INTENT: [moodbot_features["intents"]["mood_great"]], + }, + {ACTION_NAME: [moodbot_features["actions"][ACTION_UNLIKELY_INTENT_NAME]]}, + {ACTION_NAME: [moodbot_features["actions"]["utter_happy"]]}, + ] + ] + + if max_history is not None: + expected_features = [x[-max_history:] for x in expected_features] + + assert actual_features is not None + assert len(actual_features) == len(expected_features) + + for actual, expected in zip(actual_features, expected_features): + assert compare_featurized_states(actual, expected) + + +@pytest.mark.parametrize("max_history", [None, 2]) +def test_prediction_states_with_intent_max_history_tracker_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + max_history: Optional[int], +): + # IntentMaxHistoryTrackerFeaturizer prediction is only done after + # a UserUttered event so remove the last BotUttered and + # ActionExecuted events. + moodbot_tracker = moodbot_tracker.copy() + moodbot_tracker.events.pop() + moodbot_tracker.events.pop() + + state_featurizer = IntentTokenizerSingleStateFeaturizer() + tracker_featurizer = IntentMaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + actual_states = tracker_featurizer.prediction_states( + [moodbot_tracker], moodbot_domain + ) + + expected_states = [ + [ + {}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "greet"}, + }, + {USER: {INTENT: "greet"}, PREVIOUS_ACTION: {ACTION_NAME: "utter_greet"}}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "mood_unhappy"}, + }, + { + USER: {INTENT: "mood_unhappy"}, + PREVIOUS_ACTION: {ACTION_NAME: "utter_cheer_up"}, + }, + { + USER: {INTENT: "mood_unhappy"}, + PREVIOUS_ACTION: {ACTION_NAME: "utter_did_that_help"}, + }, + ] + ] + if max_history is not None: + expected_states = [x[-max_history:] for x in expected_states] + + assert actual_states is not None + assert len(actual_states) == len(expected_states) + + for actual, expected in zip(actual_states, expected_states): + assert actual == expected + + +@pytest.mark.parametrize("max_history", [None, 2]) +def test_prediction_states_hide_rule_states_intent_max_history_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + max_history: Optional[int], +): + state_featurizer = IntentTokenizerSingleStateFeaturizer() + tracker_featurizer = IntentMaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + + rule_tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("utter_greet", hide_rule_turn=True), + ActionExecuted(ACTION_LISTEN_NAME, hide_rule_turn=True), + ], + domain=moodbot_domain, + ) + + actual_states = tracker_featurizer.prediction_states( + [rule_tracker], moodbot_domain, ignore_rule_only_turns=True + ) + + expected_states = [[{}]] + + assert actual_states is not None + assert len(actual_states) == len(expected_states) + + for actual, expected in zip(actual_states, expected_states): + assert actual == expected + + embedded_rule_tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("utter_greet", hide_rule_turn=True), + ActionExecuted(ACTION_LISTEN_NAME, hide_rule_turn=True), + user_uttered("mood_great"), + ], + domain=moodbot_domain, + ) + + actual_states = tracker_featurizer.prediction_states( + [embedded_rule_tracker], moodbot_domain, ignore_rule_only_turns=True + ) + + expected_states = [[{}]] + + assert actual_states is not None + assert len(actual_states) == len(expected_states) + + for actual, expected in zip(actual_states, expected_states): + assert actual == expected + + +@pytest.mark.parametrize("max_history", [None, 3]) +def test_prediction_states_ignores_action_intent_unlikely_intent_max_history_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + max_history: Optional[int], +): + state_featurizer = IntentTokenizerSingleStateFeaturizer() + tracker_featurizer = IntentMaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_great"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_happy"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("goodbye"), + ], + domain=moodbot_domain, + ) + + actual_states = tracker_featurizer.prediction_states( + [tracker], moodbot_domain, ignore_action_unlikely_intent=True + ) + + expected_states = [ + [ + {}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "greet"}, + }, + {USER: {INTENT: "greet"}, PREVIOUS_ACTION: {ACTION_NAME: "utter_greet"}}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "mood_great"}, + }, + { + USER: {INTENT: "mood_great"}, + PREVIOUS_ACTION: {ACTION_NAME: "utter_happy"}, + }, + ] + ] + + if max_history is not None: + expected_states = [x[-max_history:] for x in expected_states] + + assert actual_states is not None + assert len(actual_states) == len(expected_states) + + for actual, expected in zip(actual_states, expected_states): + assert actual == expected + + +@pytest.mark.parametrize("max_history", [None, 3]) +def test_prediction_states_keeps_action_intent_unlikely_intent_max_history_featurizer( + moodbot_tracker: DialogueStateTracker, + moodbot_domain: Domain, + max_history: Optional[int], +): + state_featurizer = IntentTokenizerSingleStateFeaturizer() + tracker_featurizer = IntentMaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_great"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_happy"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("goodbye"), + ], + domain=moodbot_domain, + ) + + actual_states = tracker_featurizer.prediction_states([tracker], moodbot_domain) + + expected_states = [ + [ + {}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "greet"}, + }, + { + USER: {INTENT: "greet"}, + PREVIOUS_ACTION: {ACTION_NAME: ACTION_UNLIKELY_INTENT_NAME}, + }, + {USER: {INTENT: "greet"}, PREVIOUS_ACTION: {ACTION_NAME: "utter_greet"}}, + { + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + USER: {INTENT: "mood_great"}, + }, + { + USER: {INTENT: "mood_great"}, + PREVIOUS_ACTION: {ACTION_NAME: ACTION_UNLIKELY_INTENT_NAME}, + }, + { + USER: {INTENT: "mood_great"}, + PREVIOUS_ACTION: {ACTION_NAME: "utter_happy"}, + }, + ] + ] + + if max_history is not None: + expected_states = [x[-max_history:] for x in expected_states] + + assert actual_states is not None + assert len(actual_states) == len(expected_states) + + for actual, expected in zip(actual_states, expected_states): + assert actual == expected + + +@pytest.mark.parametrize( + "remove_duplicates, max_history", + [[True, None], [True, 2], [False, None], [False, 2]], +) +def test_multilabels_with_intent_max_history_tracker_featurizer( + moodbot_domain: Domain, max_history: Optional[int], remove_duplicates: bool +): + state_featurizer = IntentTokenizerSingleStateFeaturizer() + tracker_featurizer = IntentMaxHistoryTrackerFeaturizer( + state_featurizer, max_history=max_history, remove_duplicates=remove_duplicates + ) + + event_list1 = [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_great"), + ] + tracker1 = DialogueStateTracker.from_events( + "default", event_list1, domain=moodbot_domain + ) + event_list2 = [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("mood_unhappy"), + ] + tracker2 = DialogueStateTracker.from_events( + "default", event_list2, domain=moodbot_domain + ) + + _1, actual_labels, _2 = tracker_featurizer.featurize_trackers( + [tracker1, tracker2], moodbot_domain, precomputations=None + ) + + greet_index = 5 + mood_great_index = 6 + mood_unhappy_index = 7 + + if remove_duplicates: + expected_labels = np.array( + [[greet_index, -1], [mood_great_index, mood_unhappy_index]] + ) + else: + expected_labels = np.array( + [ + [greet_index, -1], + [mood_great_index, mood_unhappy_index], + [greet_index, -1], + [mood_great_index, mood_unhappy_index], + ] + ) + + assert actual_labels is not None + assert actual_labels.shape == expected_labels.shape + + # Order of label indices may be different, + # hence need to sort the indices and then check. + for actual_label_indices, expected_label_indices in zip( + actual_labels, expected_labels + ): + assert sorted(actual_label_indices) == sorted(expected_label_indices) diff --git a/tests/core/nlg/__init__.py b/tests/core/nlg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/nlg/test_callback.py b/tests/core/nlg/test_callback.py new file mode 100644 index 0000000..50a40fe --- /dev/null +++ b/tests/core/nlg/test_callback.py @@ -0,0 +1,144 @@ +import logging + +from pytest import LogCaptureFixture +from rasa.core.nlg.callback import CallbackNaturalLanguageGenerator, nlg_request_format +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import UserUttered +from rasa.shared.core.slots import TextSlot +from rasa.shared.core.trackers import DialogueStateTracker, EventVerbosity + + +def test_nlg_request_format( + default_tracker: DialogueStateTracker, +) -> None: + assert nlg_request_format( + utter_action="utter_one_id", + tracker=default_tracker, + output_channel="test", + ) == { + "response": "utter_one_id", + "id": None, + "arguments": {}, + "channel": {"name": "test"}, + "tracker": default_tracker.current_state(EventVerbosity.ALL), + } + + +def test_nlg_request_format_with_arguments( + default_tracker: DialogueStateTracker, +) -> None: + assert nlg_request_format( + utter_action="utter_one_id", + tracker=default_tracker, + output_channel="test", + some_arg="some_value", + ) == { + "response": "utter_one_id", + "id": None, + "arguments": {"some_arg": "some_value"}, + "channel": {"name": "test"}, + "tracker": default_tracker.current_state(EventVerbosity.ALL), + } + + +def test_nlg_request_format_with_response_ids( + default_tracker: DialogueStateTracker, +) -> None: + assert nlg_request_format( + utter_action="utter_one_id", + tracker=default_tracker, + output_channel="test", + response_id="1", + ) == { + "response": "utter_one_id", + "id": "1", + "arguments": {}, + "channel": {"name": "test"}, + "tracker": default_tracker.current_state(EventVerbosity.ALL), + } + + +def test_callback_nlg_fetch_response_id() -> None: + # Arrange + utter_action = "utter_greet" + + name_slot = TextSlot( + name="name", mappings=[{}], initial_value="Bob", influence_conversation=False + ) + logged_in_slot = TextSlot( + name="logged_in", + mappings=[{}], + initial_value=True, + influence_conversation=False, + ) + tracker = DialogueStateTracker.from_events( + sender_id="interpolated_crv", + evts=[UserUttered("Hello")], + slots=[name_slot, logged_in_slot], + ) + output_channel = "default" + domain = Domain.load("data/test_nlg/domain_with_response_ids.yml") + + # Act + response_id = CallbackNaturalLanguageGenerator.fetch_response_id( + utter_action=utter_action, + tracker=tracker, + output_channel=output_channel, + domain_responses=domain.responses, + ) + + # Assert + assert response_id == "ID_2" + + +def test_callback_nlg_fetch_response_id_with_no_domain_responses( + caplog: LogCaptureFixture, +) -> None: + # Arrange + utter_action = "utter_greet" + + tracker = DialogueStateTracker.from_events( + sender_id="no_domain_responses", + evts=[UserUttered("Hello")], + ) + output_channel = "default" + + # Act + with caplog.at_level(logging.DEBUG): + response_id = CallbackNaturalLanguageGenerator.fetch_response_id( + utter_action=utter_action, + tracker=tracker, + output_channel=output_channel, + domain_responses=None, + ) + + # Assert + assert response_id is None + assert "Failed to fetch response id. Responses not provided." in caplog.text + + +def test_callback_nlg_fetch_response_id_with_no_response_id( + caplog: LogCaptureFixture, +) -> None: + # Arrange + utter_action = "utter_goodbye" + + tracker = DialogueStateTracker.from_events( + sender_id="no_response", + evts=[UserUttered("Goodbye")], + ) + output_channel = "default" + domain = Domain.load("data/test_nlg/domain_with_response_ids.yml") + + # Act + with caplog.at_level(logging.DEBUG): + response_id = CallbackNaturalLanguageGenerator.fetch_response_id( + utter_action=utter_action, + tracker=tracker, + output_channel=output_channel, + domain_responses=domain.responses, + ) + + # Assert + assert response_id is None + assert f"Failed to fetch response id for action '{utter_action}'." in caplog.text diff --git a/tests/core/nlg/test_generator.py b/tests/core/nlg/test_generator.py new file mode 100644 index 0000000..b1e22d6 --- /dev/null +++ b/tests/core/nlg/test_generator.py @@ -0,0 +1,443 @@ +import textwrap +from typing import Text + +import pytest + +from rasa.core.nlg.generator import ResponseVariationFilter +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import UserUttered +from rasa.shared.core.slots import TextSlot +from rasa.shared.core.trackers import DialogueStateTracker + + +def test_response_variation_filter_get_response_variation_id_interpolated_crv() -> None: + """Test that the correct response variation id is retrieved. + + The test uses a conditional response variation with an interpolated slot value. + """ + # Arrange + utter_action = "utter_greet" + + name_slot = TextSlot( + name="name", mappings=[{}], initial_value="Bob", influence_conversation=False + ) + logged_in_slot = TextSlot( + name="logged_in", + mappings=[{}], + initial_value=True, + influence_conversation=False, + ) + tracker = DialogueStateTracker.from_events( + sender_id="interpolated_crv", + evts=[UserUttered("Hello")], + slots=[name_slot, logged_in_slot], + ) + output_channel = "default" + domain = Domain.load("data/test_nlg/domain_with_response_ids.yml") + response_variation_filter = ResponseVariationFilter(domain.responses) + + # Act + response_variation_id = response_variation_filter.get_response_variation_id( + utter_action, tracker, output_channel + ) + + # Assert + assert response_variation_id == "ID_2" + + +def test_response_variation_filter_get_response_id_default_variation() -> None: + # Arrange + utter_action = "utter_greet" + + tracker = DialogueStateTracker.from_events( + sender_id="default_variation", evts=[UserUttered("Hello")], slots=[] + ) + output_channel = "default" + domain = Domain.load("data/test_nlg/domain_with_response_ids.yml") + response_variation_filter = ResponseVariationFilter(domain.responses) + + # Act + response_variation_id = response_variation_filter.get_response_variation_id( + utter_action, tracker, output_channel + ) + + # Assert + assert response_variation_id == "ID_1" + + +def test_response_variation_filter_get_response_id_multiple_conditions() -> None: + """Test that the first response variation id is retrieved if multiple conditions are met.""" # noqa: E501 + # Arrange + utter_action = "utter_greet" + + membership_slot = TextSlot( + name="membership", + mappings=[{}], + initial_value="gold", + influence_conversation=False, + ) + logged_in_slot = TextSlot( + name="logged_in", + mappings=[{}], + initial_value=True, + influence_conversation=False, + ) + tracker = DialogueStateTracker.from_events( + sender_id="multiple_conditions", + evts=[UserUttered("Hello")], + slots=[membership_slot, logged_in_slot], + ) + output_channel = "default" + + domain_yaml = textwrap.dedent( + """ + version: "3.1" + + intents: + - greet + + slots: + membership: + type: text + mappings: + - type: custom + logged_in: + type: bool + influence_conversation: false + mappings: + - type: custom + + responses: + utter_greet: + - text: "Hello" + id: "ID_0" + + - text: "Hello valued customer!" + id: "ID_1" + condition: + - type: slot + name: membership + value: gold + + - text: "Welcome back!" + id: "ID_2" + condition: + - type: slot + name: logged_in + value: true + """ + ) + + domain = Domain.from_yaml(domain_yaml) + response_variation_filter = ResponseVariationFilter(domain.responses) + + # Act + response_variation_id = response_variation_filter.get_response_variation_id( + utter_action, tracker, output_channel + ) + + # Assert + assert response_variation_id == "ID_1" + + +def test_response_variation_filter_get_response_id_chained_conditions() -> None: + # Arrange + utter_action = "utter_greet" + + membership_slot = TextSlot( + name="membership", + mappings=[{}], + initial_value="gold", + influence_conversation=False, + ) + logged_in_slot = TextSlot( + name="logged_in", + mappings=[{}], + initial_value=True, + influence_conversation=False, + ) + tracker = DialogueStateTracker.from_events( + sender_id="chained_conditions", + evts=[UserUttered("Hello")], + slots=[membership_slot, logged_in_slot], + ) + output_channel = "default" + + domain_yaml = textwrap.dedent( + """ + version: "3.1" + + intents: + - greet + + slots: + membership: + type: text + mappings: + - type: custom + logged_in: + type: bool + influence_conversation: false + mappings: + - type: custom + + responses: + utter_greet: + - text: "Hello" + id: "ID_0" + + - text: "Welcome back!" + id: "ID_1" + condition: + - type: slot + name: logged_in + value: true + - type: slot + name: membership + value: gold + """ + ) + + domain = Domain.from_yaml(domain_yaml) + response_variation_filter = ResponseVariationFilter(domain.responses) + + # Act + response_variation_id = response_variation_filter.get_response_variation_id( + utter_action, tracker, output_channel + ) + + # Assert + assert response_variation_id == "ID_1" + + +def test_response_variation_filter_get_response_id_with_channels() -> None: + # Arrange + utter_action = "utter_greet" + + membership_slot = TextSlot( + name="membership", + mappings=[{}], + initial_value="gold", + influence_conversation=False, + ) + tracker = DialogueStateTracker.from_events( + sender_id="crv_channel", evts=[UserUttered("Hello")], slots=[membership_slot] + ) + + domain_yaml = textwrap.dedent( + """ + version: "3.1" + + intents: + - greet + + slots: + membership: + type: text + mappings: + - type: custom + logged_in: + type: bool + influence_conversation: false + mappings: + - type: custom + + responses: + utter_greet: + - text: "How can I help you today?" + id: "ID_0" + + - text: "" + id: "ID_1" + condition: + - type: slot + name: membership + value: gold + channel: app + + - text: "" + id: "ID_2" + condition: + - type: slot + name: membership + value: gold + channel: web + """ + ) + + domain = Domain.from_yaml(domain_yaml) + response_variation_filter = ResponseVariationFilter(domain.responses) + + # Act + response_variation_id = response_variation_filter.get_response_variation_id( + utter_action, tracker, output_channel="app" + ) + + # Assert + assert response_variation_id == "ID_1" + + # Act + response_variation_id = response_variation_filter.get_response_variation_id( + utter_action, tracker, output_channel="web" + ) + + # Assert + assert response_variation_id == "ID_2" + + # Act + response_variation_id = response_variation_filter.get_response_variation_id( + utter_action, tracker, output_channel="default" + ) + + # Assert + assert response_variation_id == "ID_0" + + +@pytest.mark.parametrize( + "initial_value, output_channel, expected_id", + [ + ("gold", "app", "ID_1"), + ("silver", "app", "ID_2"), + ("bronze", "default", "ID_3"), + ("gold", "default", "ID_4"), + ], +) +def test_response_variation_filter_get_response_id_edge_cases( + initial_value: Text, output_channel: Text, expected_id: Text +) -> None: + # Arrange + utter_action = "utter_greet" + + membership_slot = TextSlot( + name="membership", + mappings=[{}], + initial_value=initial_value, + influence_conversation=False, + ) + tracker = DialogueStateTracker.from_events( + sender_id="edge_cases", evts=[UserUttered("Hello")], slots=[membership_slot] + ) + + domain_yaml = textwrap.dedent( + """ + version: "3.1" + + intents: + - greet + + slots: + membership: + type: text + mappings: + - type: custom + logged_in: + type: bool + influence_conversation: false + mappings: + - type: custom + + responses: + utter_greet: + - text: "How can I help you today?" + id: "ID_0" + + - text: "" + id: "ID_1" + condition: + - type: slot + name: membership + value: gold + channel: app + + - text: "" + id: "ID_2" + condition: + - type: slot + name: membership + value: silver + channel: app + + - text: "" + id: "ID_3" + condition: + - type: slot + name: membership + value: bronze + + - text: "" + id: "ID_4" + condition: + - type: slot + name: membership + value: gold + """ + ) + + domain = Domain.from_yaml(domain_yaml) + response_variation_filter = ResponseVariationFilter(domain.responses) + + # Act + response_variation_id = response_variation_filter.get_response_variation_id( + utter_action, tracker, output_channel=output_channel + ) + + # Assert + assert response_variation_id == expected_id + + +def test_response_variation_filter_raises_exception_duplicate_ids() -> None: + # Arrange + utter_action = "utter_greet" + + membership_slot = TextSlot( + name="membership", + mappings=[{}], + initial_value="gold", + influence_conversation=False, + ) + tracker = DialogueStateTracker.from_events( + sender_id="duplicate_ids", evts=[UserUttered("Hello")], slots=[membership_slot] + ) + + domain_yaml = textwrap.dedent( + """ + version: "3.1" + + intents: + - greet + + slots: + membership: + type: text + mappings: + - type: custom + logged_in: + type: bool + influence_conversation: false + mappings: + - type: custom + + responses: + utter_greet: + - text: "How can I help you today?" + id: "ID_0" + + - text: "" + id: "ID_1" + + - text: "" + id: "ID_1" + """ + ) + + domain = Domain.from_yaml(domain_yaml) + response_variation_filter = ResponseVariationFilter(domain.responses) + + expected_message = "Duplicate response id 'ID_1' defined in the domain." + + with pytest.warns(UserWarning, match=expected_message): + result = response_variation_filter.get_response_variation_id( + utter_action, tracker, output_channel="app" + ) + + assert result is None diff --git a/tests/core/nlg/test_response.py b/tests/core/nlg/test_response.py new file mode 100644 index 0000000..7bbccb4 --- /dev/null +++ b/tests/core/nlg/test_response.py @@ -0,0 +1,627 @@ +from typing import Dict, List, Text, Any + +import logging +import pytest +from _pytest.logging import LogCaptureFixture + +from rasa.core.nlg.response import TemplatedNaturalLanguageGenerator +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION +from rasa.shared.core.domain import Domain +from rasa.shared.core.slots import TextSlot, AnySlot, CategoricalSlot, BooleanSlot +from rasa.shared.core.trackers import DialogueStateTracker + + +async def test_nlg_conditional_response_variations_with_no_slots(): + responses = { + "utter_test": [ + { + "text": "Conditional OS Response A", + "condition": [{"type": "slot", "name": "slot test", "value": "A"}], + "channel": "os", + }, + { + "text": "Conditional Response A", + "condition": [{"type": "slot", "name": "slot test", "value": "A"}], + }, + { + "text": "Conditional Response B", + "condition": [{"type": "slot", "name": "slot test", "value": "B"}], + }, + {"text": "Default response"}, + ] + } + t = TemplatedNaturalLanguageGenerator(responses=responses) + no_slots_tracker = DialogueStateTracker(sender_id="nlg_test_default", slots=None) + default_response = await t.generate( + utter_action="utter_test", tracker=no_slots_tracker, output_channel="" + ) + + assert default_response.get("text") == "Default response" + + +async def test_nlg_when_multiple_conditions_satisfied(): + responses = { + "utter_action": [ + { + "text": "example A", + "condition": [{"type": "slot", "name": "test", "value": "A"}], + }, + { + "text": "example B", + "condition": [{"type": "slot", "name": "test_another", "value": "B"}], + }, + { + "text": "non matching example 1", + "condition": [ + {"type": "slot", "name": "test_third_slot", "value": "C"} + ], + }, + { + "text": "non matching example 2", + "condition": [{"type": "slot", "name": "test", "value": "D"}], + }, + ] + } + + t = TemplatedNaturalLanguageGenerator(responses=responses) + slot_a = TextSlot( + name="test", mappings=[{}], initial_value="A", influence_conversation=False + ) + slot_b = TextSlot( + name="test_another", + mappings=[{}], + initial_value="B", + influence_conversation=False, + ) + tracker = DialogueStateTracker(sender_id="test_nlg", slots=[slot_a, slot_b]) + resp = await t.generate( + utter_action="utter_action", tracker=tracker, output_channel="" + ) + assert resp.get("text") in ["example A", "example B"] + + +@pytest.fixture(scope="session") +def test_slots() -> List[object]: + slot_a = CategoricalSlot( + name="test", + mappings=[{"type": "from_text", "value": ["cold", "hot"]}], + initial_value="Cold", + influence_conversation=False, + ) + slot_b = BooleanSlot( + name="test2", + mappings=[{}], + initial_value=False, + influence_conversation=False, + ) + slot_c = CategoricalSlot( + name="test3", + mappings=[{"type": "from_text", "value": ["cold", "hot"]}], + initial_value="hot", + influence_conversation=False, + ) + return [slot_a, slot_b, slot_c] + + +@pytest.fixture(scope="session") +def test_responses() -> List[Dict[Text, List[Dict[Text, Any]]]]: + return [ + { + "utter_action": [ + { + "text": "example a", + "condition": [{"type": "slot", "name": "test", "value": "cold"}], + } + ] + }, + { + "utter_action_multiple_conditions": [ + { + "text": "example b", + "condition": [ + {"type": "slot", "name": "test", "value": "cold"}, + {"type": "slot", "name": "test2", "value": False}, + {"type": "slot", "name": "test3", "value": "hot"}, + ], + } + ] + }, + ] + + +async def test_nlg_slot_case_sensitivity( + test_slots: List[object], + test_responses: List[Dict[Text, List[Dict[Text, Any]]]], +): + utter_action = "utter_action" + t = TemplatedNaturalLanguageGenerator(responses=test_responses[0]) + tracker = DialogueStateTracker(sender_id="test_nlg", slots=[test_slots[0]]) + resp = await t.generate( + utter_action=utter_action, tracker=tracker, output_channel="" + ) + assert resp.get("text") == test_responses[0][utter_action][0]["text"] + + +async def test_matches_filled_slots_multiple_conditions( + test_slots: List[object], + test_responses: List[Dict[Text, List[Dict[Text, Any]]]], +): + utter_action = "utter_action_multiple_conditions" + t = TemplatedNaturalLanguageGenerator(responses=test_responses[1]) + tracker = DialogueStateTracker(sender_id="test_nlg", slots=test_slots) + resp = await t.generate( + utter_action=utter_action, + tracker=tracker, + output_channel="", + ) + assert resp.get("text") == test_responses[1][utter_action][0]["text"] + + +async def test_matches_filled_slots_multiple_conditions_neg_match_boolean_slot( + test_slots: List[object], test_responses: List[Dict[Text, List[Dict[Text, Any]]]] +): + t = TemplatedNaturalLanguageGenerator(responses=test_responses[1]) + test_slots[1].initial_value = True + tracker = DialogueStateTracker(sender_id="test_nlg", slots=test_slots) + resp = await t.generate( + utter_action="utter_action_multiple_conditions", + tracker=tracker, + output_channel="", + ) + assert resp is None + + +async def test_matches_filled_slots_multiple_conditions_neg_match_in_first_slot( + test_slots: List[object], test_responses: List[Dict[Text, List[Dict[Text, Any]]]] +): + t = TemplatedNaturalLanguageGenerator(responses=test_responses[1]) + test_slots[0].initial_value = "junk" + tracker = DialogueStateTracker(sender_id="test_nlg", slots=test_slots) + resp = await t.generate( + utter_action="utter_action_multiple_conditions", + tracker=tracker, + output_channel="", + ) + assert resp is None + + +async def test_matches_filled_slots_multiple_conditions_neg_match_in_last_slot( + test_slots: List[object], test_responses: List[Dict[Text, List[Dict[Text, Any]]]] +): + t = TemplatedNaturalLanguageGenerator(responses=test_responses[1]) + test_slots[2].initial_value = "junk" + tracker = DialogueStateTracker(sender_id="test_nlg", slots=test_slots) + resp = await t.generate( + utter_action="utter_action_multiple_conditions", + tracker=tracker, + output_channel="", + ) + assert resp is None + + +@pytest.mark.parametrize( + ("slot_name", "slot_value", "response_variation"), + (("test", "A", "example one A"), ("test", "B", "example two B")), +) +async def test_nlg_conditional_response_variations_with_interpolated_slots( + slot_name: Text, slot_value: Any, response_variation: Text +): + responses = { + "utter_action": [ + { + "text": "example one {test}", + "condition": [{"type": "slot", "name": "test", "value": "A"}], + }, + { + "text": "example two {test}", + "condition": [{"type": "slot", "name": "test", "value": "B"}], + }, + ] + } + t = TemplatedNaturalLanguageGenerator(responses=responses) + slot = TextSlot( + name=slot_name, + mappings=[{}], + initial_value=slot_value, + influence_conversation=False, + ) + tracker = DialogueStateTracker(sender_id="nlg_interpolated", slots=[slot]) + + r = await t.generate( + utter_action="utter_action", tracker=tracker, output_channel="" + ) + assert r.get("text") == response_variation + + +@pytest.mark.parametrize( + ("slot_name", "slot_value", "bot_message"), + ( + ( + "can_withdraw", + False, + "You are not allowed to withdraw any amounts. Please check permission.", + ), + ( + "account_type", + "secondary", + "Withdrawal was sent for approval to primary account holder.", + ), + ), +) +async def test_nlg_conditional_response_variations_with_yaml_single_condition( + slot_name: Text, slot_value: Any, bot_message: Text +): + domain = Domain.from_file( + path="data/test_domains/conditional_response_variations.yml" + ) + t = TemplatedNaturalLanguageGenerator(responses=domain.responses) + + slot = AnySlot( + name=slot_name, + mappings=[{}], + initial_value=slot_value, + influence_conversation=False, + ) + tracker = DialogueStateTracker(sender_id="conversation_id", slots=[slot]) + + r = await t.generate( + utter_action="utter_withdraw", tracker=tracker, output_channel="" + ) + assert r.get("text") == bot_message + + +async def test_nlg_conditional_response_variations_with_yaml_multi_constraints(): + domain = Domain.from_file( + path="data/test_domains/conditional_response_variations.yml" + ) + t = TemplatedNaturalLanguageGenerator(responses=domain.responses) + + first_slot = CategoricalSlot( + name="account_type", + mappings=[{}], + initial_value="primary", + influence_conversation=False, + ) + second_slot = BooleanSlot( + name="can_withdraw", + mappings=[{}], + initial_value=True, + influence_conversation=False, + ) + tracker = DialogueStateTracker( + sender_id="conversation_id", slots=[first_slot, second_slot] + ) + r = await t.generate( + utter_action="utter_withdraw", tracker=tracker, output_channel="" + ) + assert r.get("text") == "Withdrawal has been approved." + + +async def test_nlg_conditional_response_variations_with_yaml_and_channel(): + domain = Domain.from_file( + path="data/test_domains/conditional_response_variations.yml" + ) + t = TemplatedNaturalLanguageGenerator(responses=domain.responses) + + slot = CategoricalSlot( + name="account_type", + mappings=[{}], + initial_value="primary", + influence_conversation=False, + ) + tracker = DialogueStateTracker(sender_id="conversation_id", slots=[slot]) + + r = await t.generate( + utter_action="utter_check_balance", tracker=tracker, output_channel="os" + ) + assert ( + r.get("text") == "As a primary account holder, you can now set-up " + "your access on mobile app too." + ) + + resp = await t.generate( + utter_action="utter_check_balance", tracker=tracker, output_channel="app" + ) + assert resp.get("text") == "Welcome to your app account overview." + + +@pytest.mark.parametrize( + ("slot_name", "slot_value", "message"), + ( + ("test_bool", True, "example boolean"), + ("test_int", 12, "example integer"), + ("test_list", [], "example list"), + ), +) +async def test_nlg_conditional_response_variations_with_diff_slot_types( + slot_name: Text, slot_value: Any, message: Text +): + responses = { + "utter_action": [ + { + "text": "example boolean", + "condition": [{"type": "slot", "name": "test_bool", "value": True}], + }, + { + "text": "example integer", + "condition": [{"type": "slot", "name": "test_int", "value": 12}], + }, + { + "text": "example list", + "condition": [{"type": "slot", "name": "test_list", "value": []}], + }, + ] + } + t = TemplatedNaturalLanguageGenerator(responses=responses) + slot = AnySlot( + name=slot_name, + mappings=[{}], + initial_value=slot_value, + influence_conversation=False, + ) + tracker = DialogueStateTracker(sender_id="nlg_tracker", slots=[slot]) + + r = await t.generate( + utter_action="utter_action", tracker=tracker, output_channel="" + ) + assert r.get("text") == message + + +async def test_nlg_non_matching_channel(): + domain = Domain.from_yaml( + """ + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_hi: + - text: "Hello" + - text: "Hello Slack" + channel: "slack" + """ + ) + t = TemplatedNaturalLanguageGenerator(domain.responses) + tracker = DialogueStateTracker(sender_id="test", slots=[]) + r = await t.generate("utter_hi", tracker, "signal") + assert r.get("text") == "Hello" + + +async def test_nlg_conditional_response_variations_with_none_slot(): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_action: + - text: "text A" + condition: + - type: slot + name: account + value: "A" + """ + ) + t = TemplatedNaturalLanguageGenerator(domain.responses) + slot = AnySlot( + name="account", mappings=[{}], initial_value=None, influence_conversation=False + ) + tracker = DialogueStateTracker(sender_id="test", slots=[slot]) + r = await t.generate("utter_action", tracker, "") + assert r is None + + +async def test_nlg_conditional_response_variations_with_slot_not_a_constraint(): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_action: + - text: "text A" + condition: + - type: slot + name: account + value: "A" + """ + ) + t = TemplatedNaturalLanguageGenerator(domain.responses) + slot = TextSlot( + name="account", mappings=[{}], initial_value="B", influence_conversation=False + ) + tracker = DialogueStateTracker(sender_id="test", slots=[slot]) + r = await t.generate("utter_action", tracker, "") + assert r is None + + +async def test_nlg_conditional_response_variations_with_null_slot(): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_action: + - text: "text for null" + condition: + - type: slot + name: account + value: null + """ + ) + t = TemplatedNaturalLanguageGenerator(domain.responses) + slot = AnySlot( + name="account", mappings=[{}], initial_value=None, influence_conversation=False + ) + tracker = DialogueStateTracker(sender_id="test", slots=[slot]) + r = await t.generate("utter_action", tracker, "") + assert r.get("text") == "text for null" + + tracker_no_slots = DialogueStateTracker(sender_id="new_test", slots=[]) + r = await t.generate("utter_action", tracker_no_slots, "") + assert r.get("text") == "text for null" + + +async def test_nlg_conditional_response_variations_channel_no_condition_met(): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_action: + - text: "example with channel" + condition: + - type: slot + name: test + value: A + channel: os + - text: "default" + """ + ) + t = TemplatedNaturalLanguageGenerator(domain.responses) + tracker = DialogueStateTracker(sender_id="test", slots=[]) + r = await t.generate("utter_action", tracker, "os") + assert r.get("text") == "default" + + +async def test_nlg_conditional_response_variation_condition_met_channel_mismatch(): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_action: + - text: "example with channel" + condition: + - type: slot + name: test + value: A + channel: os + - text: "app default" + channel: app + """ + ) + t = TemplatedNaturalLanguageGenerator(domain.responses) + slot = TextSlot( + "test", mappings=[{}], initial_value="A", influence_conversation=False + ) + tracker = DialogueStateTracker(sender_id="test", slots=[slot]) + r = await t.generate("utter_action", tracker, "app") + assert r.get("text") == "app default" + + +@pytest.mark.parametrize( + "slots,channel,expected_response", + [ + ( + [ + TextSlot( + "test", + mappings=[{}], + initial_value="B", + influence_conversation=False, + ) + ], + "app", + "condition example B no channel", + ), + ( + [ + TextSlot( + "test", + mappings=[{}], + initial_value="C", + influence_conversation=False, + ) + ], + "", + "default", + ), + ( + [ + TextSlot( + "test", + mappings=[{}], + initial_value="D", + influence_conversation=False, + ) + ], + "app", + "default", + ), + ], +) +async def test_nlg_conditional_edgecases(slots, channel, expected_response): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_action: + - text: "condition example A with channel" + condition: + - type: slot + name: test + value: A + channel: app + + - text: "condition example C with channel" + condition: + - type: slot + name: test + value: C + channel: app + + - text: "condition example A no channel" + condition: + - type: slot + name: test + value: A + + - text: "condition example B no channel" + condition: + - type: slot + name: test + value: B + + - text: "default" + """ + ) + t = TemplatedNaturalLanguageGenerator(domain.responses) + tracker = DialogueStateTracker(sender_id="test", slots=slots) + r = await t.generate("utter_action", tracker, channel) + assert r.get("text") == expected_response + + +async def test_nlg_conditional_response_variations_condition_logging( + caplog: LogCaptureFixture, +): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_action: + - text: "example" + condition: + - type: slot + name: test_A + value: A + - type: slot + name: test_B + value: B + - text: "default" + """ + ) + t = TemplatedNaturalLanguageGenerator(domain.responses) + slot_A = TextSlot( + name="test_A", mappings=[{}], initial_value="A", influence_conversation=False + ) + slot_B = TextSlot( + name="test_B", mappings=[{}], initial_value="B", influence_conversation=False + ) + tracker = DialogueStateTracker(sender_id="test", slots=[slot_A, slot_B]) + + with caplog.at_level(logging.DEBUG): + await t.generate("utter_action", tracker=tracker, output_channel="") + + assert any( + "Selecting response variation with conditions:" in message + for message in caplog.messages + ) + assert any( + "[condition 1] type: slot | name: test_A | value: A" in message + for message in caplog.messages + ) + assert any( + "[condition 2] type: slot | name: test_B | value: B" in message + for message in caplog.messages + ) diff --git a/tests/core/policies/__init__.py b/tests/core/policies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/policies/test_rule_policy.py b/tests/core/policies/test_rule_policy.py new file mode 100644 index 0000000..945a671 --- /dev/null +++ b/tests/core/policies/test_rule_policy.py @@ -0,0 +1,3171 @@ +from pathlib import Path +from typing import Text, Callable, Dict, Any, Optional, cast + +import dataclasses +import pytest + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.graph_components.providers.training_tracker_provider import ( + TrainingTrackerProvider, +) +from rasa.shared.constants import ( + DEFAULT_NLU_FALLBACK_INTENT_NAME, + LATEST_TRAINING_DATA_FORMAT_VERSION, +) + +from rasa.core import training +from rasa.core.actions.action import ActionDefaultFallback +from rasa.core.channels import CollectingOutputChannel +from rasa.shared.core.constants import ( + USER_INTENT_RESTART, + USER_INTENT_BACK, + USER_INTENT_SESSION_START, + ACTION_LISTEN_NAME, + ACTION_RESTART_NAME, + ACTION_SESSION_START_NAME, + ACTION_DEFAULT_FALLBACK_NAME, + ACTION_BACK_NAME, + RULE_SNIPPET_ACTION_NAME, + REQUESTED_SLOT, + USER, + PREVIOUS_ACTION, + ACTIVE_LOOP, + LOOP_NAME, + RULE_ONLY_SLOTS, + RULE_ONLY_LOOPS, + ACTION_UNLIKELY_INTENT_NAME, +) +from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, +) +from rasa.shared.core.training_data.structures import StoryGraph +from rasa.shared.nlu.constants import TEXT, INTENT, ACTION_NAME, ENTITY_ATTRIBUTE_TYPE +from rasa.shared.core.domain import Domain, InvalidDomain +from rasa.shared.core.events import ( + ActionExecuted, + UserUttered, + ActiveLoop, + SlotSet, + ActionExecutionRejected, + LoopInterrupted, + FollowupAction, +) +from rasa.core.nlg import TemplatedNaturalLanguageGenerator +from rasa.core.policies.rule_policy import RulePolicy, InvalidRule, RULES +from rasa.graph_components.providers.rule_only_provider import RuleOnlyDataProvider +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.generator import TrackerWithCachedStates +from tests.core import test_utils + +UTTER_GREET_ACTION = "utter_greet" +GREET_INTENT_NAME = "greet" +GREET_RULE: TrackerWithCachedStates = cast( + TrackerWithCachedStates, + DialogueStateTracker.from_events( + "greet rule", + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + # Greet is a FAQ here and gets triggered in any context + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ), +) +GREET_RULE.is_rule_tracker = True + + +@pytest.fixture() +def resource() -> Resource: + return Resource("rule_policy") + + +@pytest.fixture() +def policy_with_config( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + resource: Resource, +) -> Callable[..., RulePolicy]: + def inner(config: Optional[Dict[Text, Any]] = None) -> RulePolicy: + config = config or {} + policy = RulePolicy.create( + {**RulePolicy.get_default_config(), **config}, + default_model_storage, + resource, + default_execution_context, + ) + + assert isinstance(policy, RulePolicy) + + return policy + + return inner + + +@pytest.fixture() +def policy(policy_with_config: Callable[..., RulePolicy]) -> RulePolicy: + return policy_with_config() + + +def _form_submit_rule( + domain: Domain, submit_action_name: Text, form_name: Text +) -> TrackerWithCachedStates: + return TrackerWithCachedStates.from_events( + "form submit rule", + domain=domain, + slots=domain.slots, + evts=[ + ActiveLoop(form_name), + # Any events in between + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + # Form runs and deactivates itself + ActionExecuted(form_name), + ActiveLoop(None), + SlotSet(REQUESTED_SLOT, None), + ActionExecuted(submit_action_name), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + + +def _form_activation_rule( + domain: Domain, form_name: Text, activation_intent_name: Text +) -> TrackerWithCachedStates: + return TrackerWithCachedStates.from_events( + "form activation rule", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + # The intent `other_intent` activates the form + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": activation_intent_name}), + ActionExecuted(form_name), + ActiveLoop(form_name), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + + +def test_potential_contradiction_resolved_by_conversation_start(policy: RulePolicy): + # Two rules that contradict each other except that one of them applies only at + # conversation start -> ensure that this isn't flagged as a contradiction. + + utter_anti_greet_action = "utter_anti_greet" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - {utter_anti_greet_action} + """ + ) + + greet_rule_at_conversation_start = TrackerWithCachedStates.from_events( + "greet rule at conversation start", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + ], + is_rule_tracker=True, + ) + + anti_greet_rule = TrackerWithCachedStates.from_events( + "anti greet rule", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(utter_anti_greet_action), + ], + is_rule_tracker=True, + ) + + # Contradicting rules abort training, hence policy training here needs to succeed + # since there aren't contradicting rules in this case. + policy.train([greet_rule_at_conversation_start, anti_greet_rule], domain) + + +def test_potential_contradiction_resolved_by_conversation_start_when_slot_initial_value( + policy: RulePolicy, +): + # Two rules that contradict each other except that one of them applies only at + # conversation start -> ensure that this isn't flagged as a contradiction. + # Specifically, this checks that the conversation-start-checking logic doesn't + # depend on initial rule tracker states being empty as these can be non-empty due to + # initial slot values + + utter_anti_greet_action = "utter_anti_greet" + some_slot = "slot1" + some_slot_initial_value = "slot1value" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - {utter_anti_greet_action} + slots: + {some_slot}: + type: text + initial_value: {some_slot_initial_value} + mappings: + - type: from_text + """ + ) + greet_rule_at_conversation_start = TrackerWithCachedStates.from_events( + "greet rule at conversation start", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + ], + is_rule_tracker=True, + ) + + anti_greet_rule = TrackerWithCachedStates.from_events( + "anti greet rule", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(utter_anti_greet_action), + ], + is_rule_tracker=True, + ) + + # Policy training needs to succeed to confirm that no contradictions have been + # detected + policy.train([greet_rule_at_conversation_start, anti_greet_rule], domain) + + # Check that the correct rule is applied when predicting next action in a story. + conversation_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ] + action_probabilities_1 = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "test conversation", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action( + action_probabilities_1, domain, UTTER_GREET_ACTION + ) + + +def test_potential_contradiction_resolved_by_conversation_start_when_slot_initial_value_explicit( # noqa: E501 + policy: RulePolicy, +): + # Two rules that contradict each other except that one of them applies only at + # conversation start -> ensure that this isn't flagged as a contradiction. + # Specifically, this checks that the conversation-start-checking logic doesn't + # depend on whether or not the initial slot value is made explicit in the initial + # state of the conversation tracker + + utter_anti_greet_action = "utter_anti_greet" + some_slot = "slot1" + some_slot_initial_value = "slot1value" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - {utter_anti_greet_action} + slots: + {some_slot}: + type: text + initial_value: {some_slot_initial_value} + mappings: + - type: from_text + """ + ) + + greet_rule_at_conversation_start = TrackerWithCachedStates.from_events( + "greet rule at conversation start", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + ], + is_rule_tracker=True, + ) + + anti_greet_rule = TrackerWithCachedStates.from_events( + "anti greet rule", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(utter_anti_greet_action), + ], + is_rule_tracker=True, + ) + + # Policy training needs to succeed to confirm that no contradictions have been + # detected + policy.train([greet_rule_at_conversation_start, anti_greet_rule], domain) + + conversation_events_with_initial_slot_explicit = [ + SlotSet(some_slot, some_slot_initial_value), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ] + action_probabilities_2 = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "test conversation with initial slot value explicitly set", + evts=conversation_events_with_initial_slot_explicit, + slots=domain.slots, + ), + domain, + ) + test_utils.assert_predicted_action( + action_probabilities_2, domain, UTTER_GREET_ACTION + ) + + +def test_restrict_multiple_user_inputs_in_rules(policy: RulePolicy): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + """ + ) + + greet_events = [ + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + ActionExecuted(ACTION_LISTEN_NAME), + ] + + forbidden_rule = DialogueStateTracker.from_events( + "bla", + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + ] + + greet_events * (policy.ALLOWED_NUMBER_OF_USER_INPUTS + 1), + ) + forbidden_rule.is_rule_tracker = True + with pytest.raises(InvalidRule): + policy.train([forbidden_rule], domain) + + +def test_incomplete_rules_due_to_slots(policy: RulePolicy): + some_action = "some_action" + some_slot = "some_slot" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {some_action} + slots: + {some_slot}: + type: text + mappings: + - type: from_text + """ + ) + + complete_rule = TrackerWithCachedStates.from_events( + "complete_rule", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(some_action), + SlotSet(some_slot, "bla"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + incomplete_rule = TrackerWithCachedStates.from_events( + "incomplete_rule", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(some_action), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + + with pytest.raises(InvalidRule) as execinfo: + policy.train([complete_rule, incomplete_rule], domain) + assert all( + name in execinfo.value.message + for name in {some_action, incomplete_rule.sender_id} + ) + + fixed_incomplete_rule = TrackerWithCachedStates.from_events( + "fixed_incomplete_rule", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(some_action), + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + policy.train([complete_rule, fixed_incomplete_rule], domain) + + +def test_no_incomplete_rules_due_to_slots_after_listen(policy: RulePolicy): + some_action = "some_action" + some_slot = "some_slot" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {some_action} + entities: + - {some_slot} + slots: + {some_slot}: + type: text + mappings: + - type: from_text + """ + ) + + complete_rule = TrackerWithCachedStates.from_events( + "complete_rule", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + intent={"name": GREET_INTENT_NAME}, + entities=[{"entity": some_slot, "value": "bla"}], + ), + SlotSet(some_slot, "bla"), + ActionExecuted(some_action), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + potentially_incomplete_rule = TrackerWithCachedStates.from_events( + "potentially_incomplete_rule", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(some_action), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + policy.train([complete_rule, potentially_incomplete_rule], domain) + + +def test_no_incomplete_rules_due_to_additional_slots_set(policy: RulePolicy): + # Check that rules aren't automatically flagged as incomplete just because an action + # doesn't set all the slots that are set in the same context in a different rule. + # There may be slots that were set by other preceding actions (or by using + # initial_value for a slot), and a rule shouldn't be marked as incomplete if some of + # those other slots aren't set by the action in the rule. + + some_action = "some_action" + some_slot = "some_slot" + some_slot_value = "value1" + some_other_slot = "some_other_slot" + some_other_slot_value = "value2" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {some_action} + slots: + {some_slot}: + type: text + mappings: + - type: from_text + {some_other_slot}: + type: text + mappings: + - type: from_text + """ + ) + + simple_rule = TrackerWithCachedStates.from_events( + "simple rule with an action that sets 1 slot", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(some_action), + SlotSet(some_slot, some_slot_value), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + simple_rule_with_slot_set = TrackerWithCachedStates.from_events( + "simple rule with an additional slot set before it starts", + domain=domain, + slots=domain.slots, + evts=[ + SlotSet(some_other_slot, some_other_slot_value), + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(some_action), + SlotSet(some_slot, some_slot_value), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + + # this should finish without raising any errors about incomplete rules + policy.train([simple_rule, simple_rule_with_slot_set], domain) + + +def test_incomplete_rules_due_to_loops(policy: RulePolicy): + some_form = "some_form" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + forms: + {some_form}: + required_slots: [] + """ + ) + + complete_rule = TrackerWithCachedStates.from_events( + "complete_rule", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(some_form), + ActiveLoop(some_form), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + incomplete_rule = TrackerWithCachedStates.from_events( + "incomplete_rule", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(some_form), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + + with pytest.raises(InvalidRule) as execinfo: + policy.train([complete_rule, incomplete_rule], domain) + assert all( + name in execinfo.value.message + for name in {some_form, incomplete_rule.sender_id} + ) + + fixed_incomplete_rule = TrackerWithCachedStates.from_events( + "fixed_incomplete_rule", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(some_form), + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + policy.train([complete_rule, fixed_incomplete_rule], domain) + + +def test_contradicting_rules(policy: RulePolicy): + utter_anti_greet_action = "utter_anti_greet" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - {utter_anti_greet_action} + """ + ) + + anti_greet_rule = TrackerWithCachedStates.from_events( + "anti greet rule", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(utter_anti_greet_action), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + anti_greet_rule.is_rule_tracker = True + + with pytest.raises(InvalidRule) as execinfo: + policy.train([GREET_RULE, anti_greet_rule], domain) + assert all( + name in execinfo.value.message + for name in { + UTTER_GREET_ACTION, + GREET_RULE.sender_id, + utter_anti_greet_action, + anti_greet_rule.sender_id, + } + ) + + +def test_contradicting_rules_and_stories(policy: RulePolicy): + utter_anti_greet_action = "utter_anti_greet" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - {utter_anti_greet_action} + """ + ) + + anti_greet_story = TrackerWithCachedStates.from_events( + "anti greet story", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(utter_anti_greet_action), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + + with pytest.raises(InvalidRule) as execinfo: + policy.train([GREET_RULE, anti_greet_story], domain) + + assert all( + name in execinfo.value.message + for name in {utter_anti_greet_action, anti_greet_story.sender_id} + ) + + +def test_rule_policy_has_max_history_none(policy: RulePolicy): + assert policy.featurizer.max_history is None + + +def test_all_policy_attributes_are_persisted( + trained_rule_policy: RulePolicy, + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + resource: Resource, +): + lookup = trained_rule_policy.lookup + + loaded = RulePolicy.load( + RulePolicy.get_default_config(), + default_model_storage, + resource, + default_execution_context, + ) + + assert loaded.lookup == lookup + + +def test_rule_policy_finetune( + trained_rule_policy: RulePolicy, + trained_rule_policy_domain: Domain, + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + resource: Resource, +): + loaded_policy = RulePolicy.load( + RulePolicy.get_default_config(), + default_model_storage, + resource, + dataclasses.replace(default_execution_context, is_finetuning=True), + ) + + assert loaded_policy.finetune_mode + + new_rule = TrackerWithCachedStates.from_events( + "stop story", + domain=trained_rule_policy_domain, + slots=trained_rule_policy_domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": "stopp"}), + ActionExecuted("utter_stop"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + + original_data = training.load_data( + "examples/rules/data/rules.yml", trained_rule_policy_domain + ) + + loaded_policy.train(original_data + [new_rule], trained_rule_policy_domain) + + assert ( + len(loaded_policy.lookup["rules"]) + == len(trained_rule_policy.lookup["rules"]) + 1 + ) + assert ( + """[{"prev_action": {"action_name": "action_listen"}, """ + """"user": {"intent": "stopp"}}]""" in loaded_policy.lookup["rules"] + ) + + +def test_rule_policy_contradicting_rule_finetune( + tmp_path: Path, + trained_rule_policy: RulePolicy, + trained_rule_policy_domain: Domain, + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + resource: Resource, +): + loaded_policy = RulePolicy.load( + RulePolicy.get_default_config(), + default_model_storage, + resource, + dataclasses.replace(default_execution_context, is_finetuning=True), + ) + + assert loaded_policy.finetune_mode + + new_rule = TrackerWithCachedStates.from_events( + "stop story", + domain=trained_rule_policy_domain, + slots=trained_rule_policy_domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": "activate_q_form"}), + ActionExecuted("utter_stop"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + + original_data = training.load_data( + "examples/rules/data/rules.yml", trained_rule_policy_domain + ) + + with pytest.raises(InvalidRule) as execinfo: + loaded_policy.train(original_data + [new_rule], trained_rule_policy_domain) + assert all( + name in execinfo.value.message for name in {"utter_stop", "stop story"} + ) + + +def test_faq_rule(policy: RulePolicy): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + """ + ) + + policy.train([GREET_RULE], domain) + # remove first ... action and utter_greet and last action_listen from greet rule + new_conversation = DialogueStateTracker.from_events( + "simple greet", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ], + ) + prediction = policy.predict_action_probabilities(new_conversation, domain) + + test_utils.assert_predicted_action(prediction, domain, UTTER_GREET_ACTION) + + +async def test_predict_form_action_if_in_form(policy: RulePolicy): + form_name = "some_form" + + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - some-action + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + policy.train([GREET_RULE], domain) + + form_conversation = DialogueStateTracker.from_events( + "in a form", + evts=[ + # We are in an activate form + ActionExecuted(form_name), + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "some value"), + ActionExecuted(ACTION_LISTEN_NAME), + # User sends message as response to a requested slot + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ], + slots=domain.slots, + ) + + # RulePolicy triggers form again + prediction = policy.predict_action_probabilities(form_conversation, domain) + test_utils.assert_predicted_action( + prediction, domain, form_name, is_no_user_prediction=True + ) + + +async def test_predict_loop_action_if_in_loop_but_there_is_e2e_rule(policy: RulePolicy): + loop_name = "some_loop" + + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - some-action + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {loop_name}: + required_slots: [] + """ + ) + e2e_rule = TrackerWithCachedStates.from_events( + "bla", + domain=domain, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="haha"), + ActionExecuted(UTTER_GREET_ACTION), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + policy.train([e2e_rule], domain) + + loop_conversation = DialogueStateTracker.from_events( + "in a loop", + evts=[ + # We are in an activate form + ActionExecuted(loop_name), + ActiveLoop(loop_name), + SlotSet(REQUESTED_SLOT, "some value"), + ActionExecuted(ACTION_LISTEN_NAME), + # User sends message as response to a requested slot + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ], + slots=domain.slots, + ) + + # RulePolicy triggers form again + prediction = policy.predict_action_probabilities(loop_conversation, domain) + test_utils.assert_predicted_action( + prediction, domain, loop_name, is_no_user_prediction=True + ) + + +async def test_predict_form_action_if_multiple_turns(policy: RulePolicy): + form_name = "some_form" + other_intent = "bye" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + - {other_intent} + actions: + - {UTTER_GREET_ACTION} + - some-action + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + policy.train([GREET_RULE], domain) + + form_conversation = DialogueStateTracker.from_events( + "in a form", + evts=[ + # We are in an active form + ActionExecuted(form_name), + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "some value"), + # User responds to slot request + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + # Form validates input and requests another slot + ActionExecuted(form_name), + SlotSet(REQUESTED_SLOT, "some other"), + # User responds to 2nd slot request + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": other_intent}), + ], + slots=domain.slots, + ) + + # RulePolicy triggers form again + prediction = policy.predict_action_probabilities(form_conversation, domain) + test_utils.assert_predicted_action( + prediction, domain, form_name, is_no_user_prediction=True + ) + + +async def test_predict_slot_initial_value_not_required_in_rule(policy: RulePolicy): + domain = Domain.from_yaml( + """ +intents: +- i1 +actions: +- action1 +slots: + s_cat1: + type: categorical + values: + - v1 + - v2 + initial_value: v1 + mappings: + - type: from_text +""" + ) + + rule = DialogueStateTracker.from_events( + "slot rule", + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": "i1"}), + ActionExecuted("action1"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + domain=domain, + slots=domain.slots, + ) + rule.is_rule_tracker = True + + policy.train([rule], domain) + + form_conversation = DialogueStateTracker.from_events( + "slot rule test", + evts=[ + SlotSet("s_cat1", "v2"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": "i1"}), + ], + domain=domain, + slots=domain.slots, + ) + + prediction = policy.predict_action_probabilities(form_conversation, domain) + test_utils.assert_predicted_action(prediction, domain, "action1") + + +async def test_predict_slot_with_initial_slot_matches_rule(policy: RulePolicy): + domain = Domain.from_yaml( + """ +intents: +- i1 +actions: +- action1 +slots: + s_cat1: + type: categorical + values: + - v1 + - v2 + initial_value: v1 + mappings: + - type: from_text +""" + ) + + rule = DialogueStateTracker.from_events( + "slot rule", + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + SlotSet("s_cat1", "v1"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": "i1"}), + ActionExecuted("action1"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + domain=domain, + slots=domain.slots, + ) + rule.is_rule_tracker = True + + policy.train([rule], domain) + + form_conversation = DialogueStateTracker.from_events( + "slot rule test", + evts=[ActionExecuted(ACTION_LISTEN_NAME), UserUttered(intent={"name": "i1"})], + domain=domain, + slots=domain.slots, + ) + + prediction = policy.predict_action_probabilities(form_conversation, domain) + test_utils.assert_predicted_action(prediction, domain, "action1") + + +async def test_predict_action_listen_after_form(policy: RulePolicy): + form_name = "some_form" + + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - some-action + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + policy.train([GREET_RULE], domain) + + form_conversation = DialogueStateTracker.from_events( + "in a form", + evts=[ + # We are in an activate form + ActionExecuted(form_name), + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "some value"), + ActionExecuted(ACTION_LISTEN_NAME), + # User sends message as response to a requested slot + UserUttered("haha", {"name": GREET_INTENT_NAME}), + # Form is running again + ActionExecuted(form_name), + ], + slots=domain.slots, + ) + + # RulePolicy predicts action listen + prediction = policy.predict_action_probabilities(form_conversation, domain) + test_utils.assert_predicted_action( + prediction, domain, ACTION_LISTEN_NAME, is_no_user_prediction=True + ) + + +async def test_dont_predict_form_if_already_finished(policy: RulePolicy): + form_name = "some_form" + + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - some-action + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + policy.train([GREET_RULE], domain) + + form_conversation = DialogueStateTracker.from_events( + "in a form", + evts=[ + # We are in an activate form + ActionExecuted(form_name), + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "some value"), + ActionExecuted(ACTION_LISTEN_NAME), + # User sends message as response to a requested slot + UserUttered("haha", {"name": GREET_INTENT_NAME}), + # Form is happy and deactivates itself + ActionExecuted(form_name), + ActiveLoop(None), + SlotSet(REQUESTED_SLOT, None), + # User sends another message. Form is already done. Shouldn't get triggered + # again + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ], + slots=domain.slots, + ) + + # RulePolicy triggers form again + prediction = policy.predict_action_probabilities(form_conversation, domain) + test_utils.assert_predicted_action(prediction, domain, UTTER_GREET_ACTION) + + +async def test_form_unhappy_path(policy: RulePolicy): + form_name = "some_form" + + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - some-action + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + policy.train([GREET_RULE], domain) + + unhappy_form_conversation = DialogueStateTracker.from_events( + "in a form", + evts=[ + # We are in an active form + ActionExecuted(form_name), + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "some value"), + # User responds to slot request + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + # Form isn't happy with the answer and rejects execution + ActionExecutionRejected(form_name), + ], + slots=domain.slots, + ) + + # RulePolicy doesn't trigger form but FAQ + prediction = policy.predict_action_probabilities(unhappy_form_conversation, domain) + test_utils.assert_predicted_action(prediction, domain, UTTER_GREET_ACTION) + + +async def test_form_unhappy_path_from_general_rule(policy: RulePolicy): + form_name = "some_form" + + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - some-action + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + # RulePolicy should memorize that unhappy_rule overrides GREET_RULE + policy.train([GREET_RULE], domain) + + # Check that RulePolicy predicts action to handle unhappy path + conversation_events = [ + ActionExecuted(form_name), + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "some value"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ActionExecutionRejected(form_name), + ] + + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + # check that general rule action is predicted + test_utils.assert_predicted_action(prediction, domain, UTTER_GREET_ACTION) + + # Check that RulePolicy triggers form again after handling unhappy path + conversation_events.append(ActionExecuted(UTTER_GREET_ACTION)) + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + # check that action_listen from general rule is overwritten by form action + test_utils.assert_predicted_action(prediction, domain, form_name) + + +async def test_form_unhappy_path_from_in_form_rule(policy: RulePolicy): + form_name = "some_form" + handle_rejection_action_name = "utter_handle_rejection" + + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - {handle_rejection_action_name} + - some-action + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + unhappy_rule = TrackerWithCachedStates.from_events( + "bla", + domain=domain, + slots=domain.slots, + evts=[ + # We are in an active form + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "bla"), + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + # When a user says "hi", and the form is unhappy, + # we want to run a specific action + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(handle_rejection_action_name), + ActionExecuted(form_name), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + + # RulePolicy should memorize that unhappy_rule overrides GREET_RULE + policy.train([GREET_RULE, unhappy_rule], domain) + + # Check that RulePolicy predicts action to handle unhappy path + conversation_events = [ + ActionExecuted(form_name), + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "some value"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ActionExecutionRejected(form_name), + ] + + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, handle_rejection_action_name) + + # Check that RulePolicy triggers form again after handling unhappy path + conversation_events.append(ActionExecuted(handle_rejection_action_name)) + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, form_name) + + +async def test_form_unhappy_path_from_story(policy: RulePolicy): + form_name = "some_form" + handle_rejection_action_name = "utter_handle_rejection" + + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - {handle_rejection_action_name} + - some-action + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + unhappy_story = TrackerWithCachedStates.from_events( + "bla", + domain=domain, + slots=domain.slots, + evts=[ + # We are in an active form + ActionExecuted(form_name), + ActiveLoop(form_name), + ActionExecuted(ACTION_LISTEN_NAME), + # in training stories there is either intent or text, never both + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + # After our bot says "hi", we want to run a specific action + ActionExecuted(handle_rejection_action_name), + ActionExecuted(form_name), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + + policy.train([GREET_RULE, unhappy_story], domain) + + # Check that RulePolicy predicts action to handle unhappy path + conversation_events = [ + ActionExecuted(form_name), + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "some value"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ActionExecutionRejected(form_name), + ] + + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, UTTER_GREET_ACTION) + + # Check that RulePolicy doesn't trigger form or action_listen + # after handling unhappy path + conversation_events.append(ActionExecuted(handle_rejection_action_name)) + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + assert prediction.max_confidence == policy.config["core_fallback_threshold"] + + +async def test_form_unhappy_path_no_validation_from_rule( + policy_with_config: Callable[..., RulePolicy] +): + form_name = "some_form" + handle_rejection_action_name = "utter_handle_rejection" + + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - {handle_rejection_action_name} + - some-action + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + unhappy_rule = TrackerWithCachedStates.from_events( + "bla", + domain=domain, + slots=domain.slots, + evts=[ + # We are in an active form + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "bla"), + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + # When a user says "hi", and the form is unhappy, + # we want to run a specific action + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(handle_rejection_action_name), + # Next user utterance is an answer to the previous question + # and shouldn't be validated by the form + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(form_name), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + # unhappy rule is multi user turn rule, therefore remove restriction for policy + policy = policy_with_config({"restrict_rules": False}) + # RulePolicy should memorize that unhappy_rule overrides GREET_RULE + policy.train([GREET_RULE, unhappy_rule], domain) + + # Check that RulePolicy predicts action to handle unhappy path + conversation_events = [ + ActionExecuted(form_name), + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "some value"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ActionExecutionRejected(form_name), + ] + + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, handle_rejection_action_name) + + # Check that RulePolicy predicts action_listen + conversation_events.append(ActionExecuted(handle_rejection_action_name)) + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, ACTION_LISTEN_NAME) + + # Check that RulePolicy triggers form again after handling unhappy path + conversation_events.append(ActionExecuted(ACTION_LISTEN_NAME)) + tracker = DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ) + prediction = policy.predict_action_probabilities(tracker, domain) + test_utils.assert_predicted_action(prediction, domain, form_name) + # check that RulePolicy entered unhappy path based on the training story + assert prediction.events == [LoopInterrupted(True)] + + +async def test_form_unhappy_path_no_validation_from_story(policy: RulePolicy): + form_name = "some_form" + handle_rejection_action_name = "utter_handle_rejection" + + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - {handle_rejection_action_name} + - some-action + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + unhappy_story = TrackerWithCachedStates.from_events( + "bla", + domain=domain, + slots=domain.slots, + evts=[ + # We are in an active form + ActionExecuted(form_name), + ActiveLoop(form_name), + ActionExecuted(ACTION_LISTEN_NAME), + # When a user says "hi", and the form is unhappy, + # we want to run a specific action + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(handle_rejection_action_name), + ActionExecuted(ACTION_LISTEN_NAME), + # Next user utterance is an answer to the previous question + # and shouldn't be validated by the form + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(form_name), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + + policy.train([unhappy_story], domain) + + # Check that RulePolicy predicts no validation to handle unhappy path + conversation_events = [ + ActionExecuted(form_name), + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "some value"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ActionExecutionRejected(form_name), + ActionExecuted(handle_rejection_action_name), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ] + + tracker = DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ) + prediction = policy.predict_action_probabilities(tracker, domain) + # there is no rule for next action + assert prediction.max_confidence == policy.config["core_fallback_threshold"] + # check that RulePolicy entered unhappy path based on the training story + assert prediction.events == [LoopInterrupted(True)] + + +async def test_form_unhappy_path_without_rule(policy: RulePolicy): + form_name = "some_form" + other_intent = "bye" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + - {other_intent} + actions: + - {UTTER_GREET_ACTION} + - some-action + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + policy.train([GREET_RULE], domain) + + conversation_events = [ + ActionExecuted(form_name), + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "some value"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": other_intent}), + ActiveLoop(form_name), + ActionExecutionRejected(form_name), + ] + + # Unhappy path is not handled. No rule matches. Let's hope ML fixes our problems 🤞 + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + + assert prediction.max_confidence == policy.config["core_fallback_threshold"] + + +async def test_form_activation_rule(policy: RulePolicy): + form_name = "some_form" + other_intent = "bye" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + - {other_intent} + actions: + - {UTTER_GREET_ACTION} + - some-action + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + form_activation_rule = _form_activation_rule(domain, form_name, other_intent) + policy.train([GREET_RULE, form_activation_rule], domain) + + conversation_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": other_intent}), + ] + + # RulePolicy correctly predicts the form action + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, form_name) + + +async def test_failing_form_activation_due_to_no_rule(policy: RulePolicy): + form_name = "some_form" + other_intent = "bye" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + - {other_intent} + actions: + - {UTTER_GREET_ACTION} + - some-action + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + policy.train([GREET_RULE], domain) + + conversation_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": other_intent}), + ] + + # RulePolicy has no matching rule since no rule for form activation is given + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + + assert prediction.max_confidence == policy.config["core_fallback_threshold"] + + +def test_form_submit_rule(policy: RulePolicy): + form_name = "some_form" + submit_action_name = "utter_submit" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - some-action + - {submit_action_name} + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + form_submit_rule = _form_submit_rule(domain, submit_action_name, form_name) + + policy.train([GREET_RULE, form_submit_rule], domain) + + form_conversation = DialogueStateTracker.from_events( + "in a form", + evts=[ + # Form was activated + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ActionExecuted(form_name), + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "some value"), + ActionExecuted(ACTION_LISTEN_NAME), + # User responds and fills requested slot + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ActionExecuted(form_name), + # Form get's deactivated + ActiveLoop(None), + SlotSet(REQUESTED_SLOT, None), + ], + slots=domain.slots, + ) + + # RulePolicy predicts action which handles submit + prediction = policy.predict_action_probabilities(form_conversation, domain) + test_utils.assert_predicted_action(prediction, domain, submit_action_name) + + +def test_immediate_submit(policy: RulePolicy): + form_name = "some_form" + submit_action_name = "utter_submit" + entity = "some_entity" + slot = "some_slot" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - some-action + - {submit_action_name} + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + {slot}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + entities: + - {entity} + """ + ) + + form_activation_rule = _form_activation_rule(domain, form_name, GREET_INTENT_NAME) + form_submit_rule = _form_submit_rule(domain, submit_action_name, form_name) + + policy.train([form_activation_rule, form_submit_rule], domain) + + form_conversation = DialogueStateTracker.from_events( + "in a form", + evts=[ + # Form was activated + ActionExecuted(ACTION_LISTEN_NAME), + # The same intent which activates the form also deactivates it + UserUttered( + "haha", + {"name": GREET_INTENT_NAME}, + entities=[{"entity": entity, "value": "Bruce Wayne"}], + ), + SlotSet(slot, "Bruce"), + ActionExecuted(form_name), + SlotSet("bla", "bla"), + ActiveLoop(None), + SlotSet(REQUESTED_SLOT, None), + ], + slots=domain.slots, + ) + + # RulePolicy predicts action which handles submit + prediction = policy.predict_action_probabilities(form_conversation, domain) + test_utils.assert_predicted_action(prediction, domain, submit_action_name) + + +@pytest.fixture() +def trained_rule_policy_domain() -> Domain: + return Domain.load("examples/rules/domain.yml") + + +@pytest.fixture() +def trained_rule_policy( + trained_rule_policy_domain: Domain, policy: RulePolicy +) -> RulePolicy: + trackers = training.load_data( + "examples/rules/data/rules.yml", trained_rule_policy_domain + ) + + policy.train(trackers, trained_rule_policy_domain) + + return policy + + +async def test_rule_policy_slot_filling_from_text( + trained_rule_policy: RulePolicy, trained_rule_policy_domain: Domain +): + form_conversation = DialogueStateTracker.from_events( + "in a form", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + # User responds and fills requested slot + UserUttered("/activate_q_form", {"name": "activate_q_form"}), + ActionExecuted("loop_q_form"), + ActiveLoop("loop_q_form"), + SlotSet(REQUESTED_SLOT, "some_slot"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("/bla", {"name": GREET_INTENT_NAME}), + ActionExecuted("loop_q_form"), + SlotSet("some_slot", "/bla"), + ActiveLoop(None), + SlotSet(REQUESTED_SLOT, None), + ], + slots=trained_rule_policy_domain.slots, + ) + + # RulePolicy predicts action which handles submit + prediction = trained_rule_policy.predict_action_probabilities( + form_conversation, trained_rule_policy_domain + ) + test_utils.assert_predicted_action( + prediction, trained_rule_policy_domain, "utter_stop" + ) + + +async def test_one_stage_fallback_rule(policy: RulePolicy): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + - {DEFAULT_NLU_FALLBACK_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + """ + ) + + fallback_recover_rule = TrackerWithCachedStates.from_events( + "bla", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": DEFAULT_NLU_FALLBACK_INTENT_NAME}), + ActionExecuted(ACTION_DEFAULT_FALLBACK_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + + greet_rule_which_only_applies_at_start = TrackerWithCachedStates.from_events( + "bla", + domain=domain, + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + policy.train( + [greet_rule_which_only_applies_at_start, fallback_recover_rule], domain + ) + + # RulePolicy predicts fallback action + conversation_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("dasdakl;fkasd", {"name": DEFAULT_NLU_FALLBACK_INTENT_NAME}), + ] + tracker = DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ) + prediction = policy.predict_action_probabilities(tracker, domain) + test_utils.assert_predicted_action(prediction, domain, ACTION_DEFAULT_FALLBACK_NAME) + + # Fallback action reverts fallback events, next action is `ACTION_LISTEN` + conversation_events += await ActionDefaultFallback().run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + # Rasa is back on track when user rephrased intent + conversation_events += [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ] + tracker = DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ) + + prediction = policy.predict_action_probabilities(tracker, domain) + test_utils.assert_predicted_action(prediction, domain, UTTER_GREET_ACTION) + + +@pytest.mark.parametrize( + "intent_name, expected_action_name", + [ + (USER_INTENT_RESTART, ACTION_RESTART_NAME), + (USER_INTENT_BACK, ACTION_BACK_NAME), + (USER_INTENT_SESSION_START, ACTION_SESSION_START_NAME), + ], +) +def test_default_actions( + intent_name: Text, expected_action_name: Text, policy: RulePolicy +): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + """ + ) + policy.train([GREET_RULE], domain) + new_conversation = DialogueStateTracker.from_events( + "bla2", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": intent_name}), + ], + ) + prediction = policy.predict_action_probabilities(new_conversation, domain) + + test_utils.assert_predicted_action(prediction, domain, expected_action_name) + + +@pytest.mark.parametrize( + "intent_name", [USER_INTENT_RESTART, USER_INTENT_BACK, USER_INTENT_SESSION_START] +) +def test_e2e_beats_default_actions(intent_name: Text, policy: RulePolicy): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + """ + ) + + e2e_rule = TrackerWithCachedStates.from_events( + "bla", + domain=domain, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="haha"), + ActionExecuted(UTTER_GREET_ACTION), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + + policy.train([e2e_rule], domain) + + new_conversation = DialogueStateTracker.from_events( + "bla2", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": intent_name}), + ], + ) + prediction = policy.predict_action_probabilities(new_conversation, domain) + test_utils.assert_predicted_action( + prediction, domain, UTTER_GREET_ACTION, is_end_to_end_prediction=True + ) + + +@pytest.mark.parametrize( + "config, expected_confidence, expected_prediction", + [ + ({}, 0.3, ACTION_DEFAULT_FALLBACK_NAME), + ( + { + "core_fallback_threshold": 0.1, + "core_fallback_action_name": "my_core_fallback", + }, + 0.1, + "my_core_fallback", + ), + ], +) +def test_predict_core_fallback( + policy_with_config: Callable[..., RulePolicy], + config: Dict[Text, Any], + expected_confidence: float, + expected_prediction: Text, +): + other_intent = "other" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + - {other_intent} + actions: + - {UTTER_GREET_ACTION} + - my_core_fallback + """ + ) + rule_policy = policy_with_config(config) + rule_policy.train([GREET_RULE], domain) + + new_conversation = DialogueStateTracker.from_events( + "bla2", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": other_intent}), + ], + ) + + prediction = rule_policy.predict_action_probabilities(new_conversation, domain) + + test_utils.assert_predicted_action( + prediction, domain, expected_prediction, expected_confidence + ) + + +def test_predict_nothing_if_fallback_disabled( + policy_with_config: Callable[..., RulePolicy] +): + other_intent = "other" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + - {other_intent} + actions: + - {UTTER_GREET_ACTION} + """ + ) + policy = policy_with_config({"enable_fallback_prediction": False}) + policy.train([GREET_RULE], domain) + new_conversation = DialogueStateTracker.from_events( + "bla2", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": other_intent}), + ], + ) + prediction = policy.predict_action_probabilities(new_conversation, domain) + + assert prediction.max_confidence == 0 + + +def test_hide_rule_turn(policy: RulePolicy): + chitchat = "chitchat" + action_chitchat = "action_chitchat" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + - {chitchat} + actions: + - {UTTER_GREET_ACTION} + - {action_chitchat} + """ + ) + chitchat_story = TrackerWithCachedStates.from_events( + "chitchat story", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": chitchat}), + ActionExecuted(action_chitchat), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + policy.train([GREET_RULE, chitchat_story], domain) + + conversation_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, UTTER_GREET_ACTION) + assert prediction.hide_rule_turn + + conversation_events += [ + ActionExecuted(UTTER_GREET_ACTION, hide_rule_turn=prediction.hide_rule_turn) + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, ACTION_LISTEN_NAME) + assert prediction.hide_rule_turn + + conversation_events += [ + ActionExecuted(ACTION_LISTEN_NAME, hide_rule_turn=prediction.hide_rule_turn), + UserUttered("haha", {"name": chitchat}), + ] + tracker = DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ) + states = tracker.past_states(domain, ignore_rule_only_turns=True) + assert states == [ + {}, + { + USER: {TEXT: "haha", INTENT: chitchat}, + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + }, + ] + + +def test_hide_rule_turn_with_slots( + policy: RulePolicy, + default_model_storage: ModelStorage, + resource: Resource, + default_execution_context: ExecutionContext, +): + some_action = "some_action" + some_other_action = "some_other_action" + some_intent = "some_intent" + some_other_intent = "some_other_intent" + slot_which_is_only_in_rule = "slot_which_is_only_in_rule" + some_slot_value = "value1" + slot_which_is_also_in_story = "slot_which_is_also_in_story" + some_other_slot_value = "value2" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {some_intent} + - {some_other_intent} + actions: + - {some_action} + - {some_other_action} + slots: + {slot_which_is_only_in_rule}: + type: text + mappings: + - type: from_text + {slot_which_is_also_in_story}: + type: text + mappings: + - type: from_text + """ + ) + + simple_rule = TrackerWithCachedStates.from_events( + "simple rule with an action that sets 1 slot", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": some_intent}), + ActionExecuted(some_action), + SlotSet(slot_which_is_only_in_rule, some_slot_value), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + simple_rule_with_slot_set = TrackerWithCachedStates.from_events( + "simple rule with an additional slot set before it starts", + domain=domain, + slots=domain.slots, + evts=[ + SlotSet(slot_which_is_also_in_story, some_other_slot_value), + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": some_intent}), + ActionExecuted(some_action), + SlotSet(slot_which_is_only_in_rule, some_slot_value), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + simple_story_with_other_slot_set = TrackerWithCachedStates.from_events( + "simple rule with an additional slot set before it starts", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": some_other_intent}), + ActionExecuted(some_other_action), + SlotSet(slot_which_is_also_in_story, some_other_slot_value), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + + policy.train( + [simple_rule, simple_rule_with_slot_set, simple_story_with_other_slot_set], + domain, + ) + assert policy.lookup[RULE_ONLY_SLOTS] == [slot_which_is_only_in_rule] + + conversation_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": some_intent}), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, some_action) + assert prediction.hide_rule_turn + + conversation_events += [ + ActionExecuted(some_action, hide_rule_turn=prediction.hide_rule_turn), + SlotSet(slot_which_is_only_in_rule, some_slot_value), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, ACTION_LISTEN_NAME) + assert prediction.hide_rule_turn + + conversation_events += [ + ActionExecuted(ACTION_LISTEN_NAME, hide_rule_turn=prediction.hide_rule_turn), + UserUttered("haha", {"name": some_other_intent}), + ] + tracker = DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ) + provider = RuleOnlyDataProvider.create( + {}, default_model_storage, resource, default_execution_context + ) + states = tracker.past_states( + domain, ignore_rule_only_turns=True, rule_only_data=provider.provide() + ) + assert states == [ + {}, + { + USER: {TEXT: "haha", INTENT: some_other_intent}, + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + }, + ] + + +def test_hide_rule_turn_no_last_action_listen( + policy: RulePolicy, + default_model_storage: ModelStorage, + resource: Resource, + default_execution_context: ExecutionContext, +): + action_after_chitchat = "action_after_chitchat" + chitchat = "chitchat" + action_chitchat = "action_chitchat" + followup_on_chitchat = "followup_on_chitchat" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {chitchat} + actions: + - {action_chitchat} + - {action_after_chitchat} + slots: + {followup_on_chitchat}: + type: bool + mappings: + - type: from_text + """ + ) + simple_rule_no_last_action_listen = TrackerWithCachedStates.from_events( + "simple rule without action listen in the end", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(action_chitchat), + SlotSet(followup_on_chitchat, True), + ActionExecuted(action_after_chitchat), + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ], + is_rule_tracker=True, + ) + chitchat_story = TrackerWithCachedStates.from_events( + "chitchat story", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": chitchat}), + ActionExecuted(action_chitchat), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + policy.train([simple_rule_no_last_action_listen, chitchat_story], domain) + assert policy.lookup[RULE_ONLY_SLOTS] == [followup_on_chitchat] + + conversation_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": chitchat}), + ActionExecuted(action_chitchat), + SlotSet(followup_on_chitchat, True), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, action_after_chitchat) + assert prediction.hide_rule_turn + + conversation_events += [ + ActionExecuted(action_after_chitchat, hide_rule_turn=prediction.hide_rule_turn) + ] + tracker = DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ) + provider = RuleOnlyDataProvider.create( + {}, default_model_storage, resource, default_execution_context + ) + states = tracker.past_states( + domain, ignore_rule_only_turns=True, rule_only_data=provider.provide() + ) + assert states == [ + {}, + {USER: {INTENT: chitchat}, PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}}, + {USER: {INTENT: chitchat}, PREVIOUS_ACTION: {ACTION_NAME: action_chitchat}}, + ] + + +def test_hide_rule_turn_with_loops( + policy: RulePolicy, + default_model_storage: ModelStorage, + resource: Resource, + default_execution_context: ExecutionContext, +): + form_name = "some_form" + another_form_name = "another_form" + activate_form = "activate_form" + activate_another_form = "activate_another_form" + chitchat = "chitchat" + action_chitchat = "action_chitchat" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + - {activate_form} + - {chitchat} + - {activate_another_form} + actions: + - {UTTER_GREET_ACTION} + - {action_chitchat} + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + {another_form_name}: + required_slots: [] + """ + ) + + form_activation_rule = _form_activation_rule(domain, form_name, activate_form) + + another_form_activation_rule = _form_activation_rule( + domain, another_form_name, activate_another_form + ) + another_form_activation_story = another_form_activation_rule.copy() + another_form_activation_story.is_rule_tracker = False + + chitchat_story = TrackerWithCachedStates.from_events( + "chitchat story", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": chitchat}), + ActionExecuted(action_chitchat), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + policy.train( + [ + form_activation_rule, + chitchat_story, + another_form_activation_rule, + another_form_activation_story, + ], + domain, + ) + assert policy.lookup[RULE_ONLY_LOOPS] == [form_name] + + conversation_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": activate_form}), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, form_name) + assert prediction.hide_rule_turn + + conversation_events += [ + ActionExecuted(form_name, hide_rule_turn=prediction.hide_rule_turn), + ActiveLoop(form_name), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action( + prediction, domain, ACTION_LISTEN_NAME, is_no_user_prediction=True + ) + assert prediction.hide_rule_turn + + conversation_events += [ + ActionExecuted(ACTION_LISTEN_NAME, hide_rule_turn=prediction.hide_rule_turn), + UserUttered("haha", {"name": chitchat}), + ] + tracker = DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ) + + provider = RuleOnlyDataProvider.create( + {}, default_model_storage, resource, default_execution_context + ) + states = tracker.past_states( + domain, ignore_rule_only_turns=True, rule_only_data=provider.provide() + ) + assert states == [ + {}, + { + USER: {TEXT: "haha", INTENT: chitchat}, + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + }, + ] + + +def test_do_not_hide_rule_turn_with_loops_in_stories(policy: RulePolicy): + form_name = "some_form" + activate_form = "activate_form" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {activate_form} + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + form_activation_rule = _form_activation_rule(domain, form_name, activate_form) + form_activation_story = form_activation_rule.copy() + form_activation_story.is_rule_tracker = False + + policy.train([form_activation_rule, form_activation_story], domain) + assert policy.lookup[RULE_ONLY_LOOPS] == [] + + conversation_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": activate_form}), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, form_name) + assert not prediction.hide_rule_turn + + conversation_events += [ + ActionExecuted(form_name, hide_rule_turn=prediction.hide_rule_turn), + ActiveLoop(form_name), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action( + prediction, domain, ACTION_LISTEN_NAME, is_no_user_prediction=True + ) + assert not prediction.hide_rule_turn + + +def test_hide_rule_turn_with_loops_as_followup_action(policy: RulePolicy): + form_name = "some_form" + activate_form = "activate_form" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {GREET_INTENT_NAME} + - {activate_form} + actions: + - {UTTER_GREET_ACTION} + slots: + {REQUESTED_SLOT}: + type: any + mappings: + - type: from_text + forms: + {form_name}: + required_slots: [] + """ + ) + + form_activation_rule = _form_activation_rule(domain, form_name, activate_form) + form_activation_story = form_activation_rule.copy() + form_activation_story.is_rule_tracker = False + + policy.train([form_activation_rule, GREET_RULE, form_activation_story], domain) + assert policy.lookup[RULE_ONLY_LOOPS] == [] + + conversation_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", {"name": activate_form}), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, form_name) + assert not prediction.hide_rule_turn + + conversation_events += [ + ActionExecuted(form_name, hide_rule_turn=prediction.hide_rule_turn), + ActiveLoop(form_name), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action( + prediction, domain, ACTION_LISTEN_NAME, is_no_user_prediction=True + ) + assert not prediction.hide_rule_turn + + conversation_events += [ + ActionExecuted(ACTION_LISTEN_NAME, hide_rule_turn=prediction.hide_rule_turn), + UserUttered("haha", {"name": GREET_INTENT_NAME}), + ActionExecutionRejected(form_name), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, UTTER_GREET_ACTION) + assert prediction.hide_rule_turn + + conversation_events += [ + ActionExecuted(UTTER_GREET_ACTION, hide_rule_turn=prediction.hide_rule_turn), + FollowupAction(form_name), + ActionExecuted(form_name), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action( + prediction, domain, ACTION_LISTEN_NAME, is_no_user_prediction=True + ) + tracker = DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ) + states = tracker.past_states(domain, ignore_rule_only_turns=True) + assert states == [ + {}, + { + USER: {TEXT: "haha", INTENT: activate_form}, + PREVIOUS_ACTION: {ACTION_NAME: ACTION_LISTEN_NAME}, + }, + { + USER: {TEXT: "haha", INTENT: activate_form}, + PREVIOUS_ACTION: {ACTION_NAME: form_name}, + ACTIVE_LOOP: {LOOP_NAME: form_name}, + }, + ] + + +def test_remove_action_listen_prediction_if_contradicts_with_story(policy: RulePolicy): + intent_1 = "intent_1" + utter_1 = "utter_1" + utter_2 = "utter_2" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {intent_1} + actions: + - {utter_1} + - {utter_2} + """ + ) + rule = TrackerWithCachedStates.from_events( + "conditioned on action", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(utter_1), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": intent_1}), + ActionExecuted(utter_2), + ], + is_rule_tracker=True, + ) + story = TrackerWithCachedStates.from_events( + "intent after action", + domain=domain, + slots=domain.slots, + evts=[ + UserUttered(intent={"name": intent_1}), + ActionExecuted(utter_1), + ActionExecuted(utter_2), + ], + ) + policy.train([rule, story], domain) + prediction_source = [{PREVIOUS_ACTION: {ACTION_NAME: utter_1}}] + key = policy._create_feature_key(prediction_source) + assert key not in policy.lookup[RULES] + assert len(policy.lookup[RULES]) == 1 + + +def test_keep_action_listen_prediction_after_predictable_action(policy: RulePolicy): + intent_1 = "intent_1" + utter_1 = "utter_1" + utter_2 = "utter_2" + utter_3 = "utter_3" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {intent_1} + actions: + - {utter_1} + - {utter_2} + - {utter_3} + """ + ) + rule = TrackerWithCachedStates.from_events( + "action_listen after predictable action", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(utter_2), + ActionExecuted(utter_1), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": intent_1}), + ActionExecuted(utter_2), + ], + is_rule_tracker=True, + ) + story = TrackerWithCachedStates.from_events( + "intent after action", + domain=domain, + slots=domain.slots, + evts=[ + UserUttered(intent={"name": intent_1}), + ActionExecuted(utter_2), + ActionExecuted(utter_1), + ActionExecuted(utter_3), + ], + ) + # prediction of action_listen should only be removed if it occurs after the first + # action (unpredictable) + with pytest.raises(InvalidRule): + policy.train([rule, story], domain) + + +def test_keep_action_listen_prediction_if_last_prediction(policy: RulePolicy): + intent_1 = "intent_1" + utter_1 = "utter_1" + utter_2 = "utter_2" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {intent_1} + actions: + - {utter_1} + - {utter_2} + """ + ) + rule = TrackerWithCachedStates.from_events( + "last prediction is action_listen", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(utter_1), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": intent_1}), + ], + is_rule_tracker=True, + ) + story = TrackerWithCachedStates.from_events( + "intent after action", + domain=domain, + slots=domain.slots, + evts=[ + UserUttered(intent={"name": intent_1}), + ActionExecuted(utter_1), + ActionExecuted(utter_2), + ], + ) + # prediction of action_listen should only be removed if it's not the last prediction + with pytest.raises(InvalidRule): + policy.train([rule, story], domain) + + +def test_keep_action_listen_prediction_if_contradicts_with_rule(policy: RulePolicy): + intent_1 = "intent_1" + utter_1 = "utter_1" + utter_2 = "utter_2" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {intent_1} + actions: + - {utter_1} + - {utter_2} + """ + ) + rule = TrackerWithCachedStates.from_events( + "conditioned on action", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(utter_1), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": intent_1}), + ActionExecuted(utter_2), + ], + is_rule_tracker=True, + ) + other_rule = TrackerWithCachedStates.from_events( + "intent after action", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(utter_1), + ActionExecuted(utter_2), + ], + is_rule_tracker=True, + ) + + with pytest.raises(InvalidRule): + policy.train([rule, other_rule], domain) + + +def test_raise_contradiction_if_rule_contradicts_with_story(policy: RulePolicy): + intent_1 = "intent_1" + utter_1 = "utter_1" + utter_2 = "utter_2" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {intent_1} + actions: + - {utter_1} + - {utter_2} + """ + ) + rule = TrackerWithCachedStates.from_events( + "rule without action_listen", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(utter_1), + ActionExecuted(utter_2), + ], + is_rule_tracker=True, + ) + story = TrackerWithCachedStates.from_events( + "contradicts with rule", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(utter_1), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": intent_1}), + ActionExecuted(utter_2), + ], + ) + + with pytest.raises(InvalidRule): + policy.train([rule, story], domain) + + +def test_rule_with_multiple_entities(policy: RulePolicy): + intent_1 = "intent_1" + entity_1 = "entity_1" + entity_2 = "entity_2" + utter_1 = "utter_1" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {intent_1} + entities: + - {entity_1} + - {entity_2} + actions: + - {utter_1} + """ + ) + + rule = TrackerWithCachedStates.from_events( + "rule without action_listen", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + intent={"name": intent_1}, + entities=[ + {ENTITY_ATTRIBUTE_TYPE: entity_1}, + {ENTITY_ATTRIBUTE_TYPE: entity_2}, + ], + ), + ActionExecuted(utter_1), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + + policy.train([rule], domain) + + # the order of entities in the entities list doesn't matter for prediction + conversation_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + "haha", + intent={"name": intent_1}, + entities=[ + {ENTITY_ATTRIBUTE_TYPE: entity_2}, + {ENTITY_ATTRIBUTE_TYPE: entity_1}, + ], + ), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, utter_1) + + +def test_rule_with_multiple_slots(policy: RulePolicy): + intent_1 = "intent_1" + utter_1 = "utter_1" + utter_2 = "utter_2" + value_1 = "value_1" + value_2 = "value_2" + slot_1 = "slot_1" + slot_2 = "slot_2" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {intent_1} + actions: + - {utter_1} + - {utter_2} + + slots: + {slot_1}: + type: categorical + values: + - {value_1} + - {value_2} + mappings: + - type: from_text + {slot_2}: + type: categorical + values: + - {value_1} + - {value_2} + mappings: + - type: from_text + """ + ) + rule = TrackerWithCachedStates.from_events( + "rule without action_listen", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": intent_1}), + SlotSet(slot_1, value_1), + SlotSet(slot_2, value_2), + ActionExecuted(utter_1), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + policy.train([rule], domain) + + # the order of slots set doesn't matter for prediction + conversation_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", intent={"name": intent_1}), + SlotSet(slot_2, value_2), + SlotSet(slot_1, value_1), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, utter_1) + + +def test_include_action_unlikely_intent(policy: RulePolicy): + intent_1 = "intent_1" + intent_2 = "intent_2" + utter_1 = "utter_1" + utter_2 = "utter_2" + value_1 = "value_1" + value_2 = "value_2" + slot_1 = "slot_1" + slot_2 = "slot_2" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {intent_1} + actions: + - {utter_1} + - {utter_2} + + slots: + {slot_1}: + type: categorical + values: + - {value_1} + - {value_2} + mappings: + - type: from_text + {slot_2}: + type: categorical + values: + - {value_1} + - {value_2} + mappings: + - type: from_text + """ + ) + rule_1 = TrackerWithCachedStates.from_events( + "normal rule", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": intent_1}), + SlotSet(slot_1, value_1), + SlotSet(slot_2, value_2), + ActionExecuted(utter_1), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + + rule_2 = TrackerWithCachedStates.from_events( + "rule with action_unlikely_intent", + domain=domain, + slots=domain.slots, + evts=[ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted(utter_2), + ActionExecuted(ACTION_LISTEN_NAME), + ], + is_rule_tracker=True, + ) + policy.train([rule_1, rule_2], domain) + + # Verify rule 1 gets affected by the presence of action_unlikely_intent + # in between. This is slightly hypothetical because an + # action_unlikely_intent can only occur right after UserUttered, + # but if there was already a rule which should have been triggered + # after UserUttered then that would have overruled the action_unlikely_intent + # prediction. The test is to show that rule policy does not + # ignore action_unlikely_intent. + conversation_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("haha", intent={"name": intent_1}), + SlotSet(slot_2, value_2), + SlotSet(slot_1, value_1), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, utter_2) + + # Check if the presence of action_unlikely_intent + # anywhere else also triggers utter_2 + conversation_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("dummy", intent={"name": intent_2}), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ] + prediction = policy.predict_action_probabilities( + DialogueStateTracker.from_events( + "casd", evts=conversation_events, slots=domain.slots + ), + domain, + ) + test_utils.assert_predicted_action(prediction, domain, utter_2) + + +def test_invalid_fallback_action_name(policy_with_config: Callable[..., RulePolicy]): + policy = policy_with_config({"core_fallback_action_name": "bla bla"}) + + with pytest.raises(InvalidDomain): + policy.train([], Domain.empty()) + + +def test_raise_if_incompatible_with_domain(): + config = {"core_fallback_action_name": "bla bla"} + with pytest.raises(InvalidDomain): + RulePolicy.raise_if_incompatible_with_domain(config, Domain.empty()) + + +def test_initial_values_are_not_incorporated_into_rule_policy( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + policy: RulePolicy, +): + reader = YAMLStoryReader() + steps = reader.read_from_file("data/test_yaml_stories/rules_greet_and_goodbye.yml") + + domain = Domain.from_path( + "data/test_domains/initial_slot_values_greet_and_goodbye.yml" + ) + + component = TrainingTrackerProvider.create( + TrainingTrackerProvider.get_default_config(), + default_model_storage, + Resource("xy"), + default_execution_context, + ) + + trackers = component.provide(story_graph=StoryGraph(steps), domain=domain) + + policy.train(trackers, domain) + + assert not any(["has_said_hi" in rule for rule in policy.lookup[RULES]]) diff --git a/tests/core/policies/test_ted_policy.py b/tests/core/policies/test_ted_policy.py new file mode 100644 index 0000000..d5f4c01 --- /dev/null +++ b/tests/core/policies/test_ted_policy.py @@ -0,0 +1,863 @@ +from pathlib import Path +from typing import Optional, List, Type, Dict, Text, Any + +import numpy as np +import pytest +from _pytest.tmpdir import TempPathFactory + +import tests.core.test_policies +from _pytest.monkeypatch import MonkeyPatch +from _pytest.logging import LogCaptureFixture + +from rasa.core.constants import POLICY_MAX_HISTORY +from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer +from rasa.core.featurizers.tracker_featurizers import MaxHistoryTrackerFeaturizer +from rasa.core.featurizers.single_state_featurizer import SingleStateFeaturizer +from rasa.core.policies.policy import Policy as Policy +from rasa.core.policies.ted_policy import TEDPolicy +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.core.constants import ACTION_LISTEN_NAME, ACTION_UNLIKELY_INTENT_NAME +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + ActionExecuted, + UserUttered, + Event, + EntitiesAdded, + ActiveLoop, +) +from rasa.shared.exceptions import RasaException, InvalidConfigException +from rasa.utils.tensorflow.data_generator import RasaBatchDataGenerator +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.model_training import train_core +from rasa.utils.tensorflow.constants import ( + EVAL_NUM_EXAMPLES, + KEY_RELATIVE_ATTENTION, + LOSS_TYPE, + MAX_RELATIVE_POSITION, + RANKING_LENGTH, + RENORMALIZE_CONFIDENCES, + SCALE_LOSS, + SIMILARITY_TYPE, + VALUE_RELATIVE_ATTENTION, + MODEL_CONFIDENCE, + COSINE, + AUTO, + LABEL, + MASK, + SENTENCE, + IDS, + EPOCHS, + EPOCH_OVERRIDE, +) +from rasa.shared.nlu.constants import ACTION_NAME +from rasa.utils.tensorflow import model_data_utils +from tests.core.test_policies import PolicyTestCollection +from rasa.shared.constants import DEFAULT_SENDER_ID, LATEST_TRAINING_DATA_FORMAT_VERSION + +UTTER_GREET_ACTION = "utter_greet" +GREET_INTENT_NAME = "greet" +DOMAIN_YAML = f""" +intents: +- {GREET_INTENT_NAME} +actions: +- {UTTER_GREET_ACTION} +""" + + +def test_diagnostics( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + domain = Domain.from_yaml(DOMAIN_YAML) + policy = TEDPolicy( + TEDPolicy.get_default_config(), + default_model_storage, + Resource("TEDPolicy"), + default_execution_context, + ) + GREET_RULE = DialogueStateTracker.from_events( + "greet rule", + evts=[ + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + precomputations = None + policy.train([GREET_RULE], domain, precomputations) + prediction = policy.predict_action_probabilities( + GREET_RULE, domain, precomputations + ) + + assert prediction.diagnostic_data + assert "attention_weights" in prediction.diagnostic_data + assert isinstance(prediction.diagnostic_data.get("attention_weights"), np.ndarray) + + +class TestTEDPolicy(PolicyTestCollection): + @staticmethod + def _policy_class_to_test() -> Type[TEDPolicy]: + + return TEDPolicy + + def test_train_model_checkpointing( + self, tmp_path: Path, tmp_path_factory: TempPathFactory + ): + train_core( + domain="data/test_domains/default.yml", + stories="data/test_yaml_stories/stories_defaultdomain.yml", + output=str(tmp_path), + fixed_model_name="my_model.tar.gz", + config="data/test_config/config_ted_policy_model_checkpointing.yml", + ) + + storage_dir = tmp_path_factory.mktemp("storage dir") + LocalModelStorage.from_model_archive(storage_dir, tmp_path / "my_model.tar.gz") + model_dir = storage_dir / "train_TEDPolicy0" + all_files = list(model_dir.rglob("*.*")) + assert any(["from_checkpoint" in str(filename) for filename in all_files]) + + def test_doesnt_checkpoint_with_no_checkpointing( + self, tmp_path: Path, tmp_path_factory: TempPathFactory + ): + train_core( + domain="data/test_domains/default.yml", + stories="data/test_yaml_stories/stories_defaultdomain.yml", + output=str(tmp_path), + fixed_model_name="my_model.tar.gz", + config="data/test_config/config_ted_policy_no_model_checkpointing.yml", + ) + + storage_dir = tmp_path_factory.mktemp("storage dir") + LocalModelStorage.from_model_archive(storage_dir, tmp_path / "my_model.tar.gz") + model_dir = storage_dir / "train_TEDPolicy0" + all_files = list(model_dir.rglob("*.*")) + assert not any(["from_checkpoint" in str(filename) for filename in all_files]) + + def test_doesnt_checkpoint_with_zero_eval_num_examples( + self, tmp_path: Path, tmp_path_factory: TempPathFactory + ): + config_file = "config_ted_policy_model_checkpointing_zero_eval_num_examples.yml" + with pytest.warns(UserWarning) as warning: + train_core( + domain="data/test_domains/default.yml", + stories="data/test_yaml_stories/stories_defaultdomain.yml", + output=str(tmp_path), + fixed_model_name="my_model.tar.gz", + config=f"data/test_config/{config_file}", + ) + warn_text = ( + f"You have opted to save the best model, but the value of " + f"'{EVAL_NUM_EXAMPLES}' is not greater than 0. No checkpoint model will be " + f"saved." + ) + + assert len([w for w in warning if warn_text in str(w.message)]) == 1 + + storage_dir = tmp_path_factory.mktemp("storage dir") + LocalModelStorage.from_model_archive(storage_dir, tmp_path / "my_model.tar.gz") + model_dir = storage_dir / "train_TEDPolicy0" + all_files = list(model_dir.rglob("*.*")) + assert not any(["from_checkpoint" in str(filename) for filename in all_files]) + + @pytest.mark.parametrize( + "should_finetune, epoch_override, expected_epoch_value", + [ + ( + True, + TEDPolicy.get_default_config()[EPOCHS] + 1, + TEDPolicy.get_default_config()[EPOCHS] + 1, + ), + ( + False, + TEDPolicy.get_default_config()[EPOCHS] + 1, + TEDPolicy.get_default_config()[EPOCHS], + ), # trained_policy uses default epochs during training + ], + ) + def test_epoch_override_when_loaded( + self, + trained_policy: TEDPolicy, + should_finetune: bool, + epoch_override: int, + expected_epoch_value: int, + resource: Resource, + model_storage: ModelStorage, + execution_context: ExecutionContext, + ): + execution_context.is_finetuning = should_finetune + loaded_policy = trained_policy.__class__.load( + {**self._config(), EPOCH_OVERRIDE: epoch_override}, + model_storage, + resource, + execution_context, + ) + + assert loaded_policy.config[EPOCHS] == expected_epoch_value + + def test_train_fails_with_checkpoint_zero_eval_num_epochs(self, tmp_path: Path): + config_file = "config_ted_policy_model_checkpointing_zero_every_num_epochs.yml" + match_string = ( + "Only values either equal to -1 or greater" + " than 0 are allowed for this parameter." + ) + with pytest.raises(InvalidConfigException, match=match_string): + train_core( + domain="data/test_domains/default.yml", + stories="data/test_yaml_stories/stories_defaultdomain.yml", + output=str(tmp_path), + config=f"data/test_config/{config_file}", + ) + + assert not (tmp_path / "my_model.tar.gz").is_file() + + def test_training_with_no_intent( + self, + featurizer: Optional[TrackerFeaturizer], + default_domain: Domain, + tmp_path: Path, + caplog: LogCaptureFixture, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ): + stories = tmp_path / "stories.yml" + stories.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: test path + steps: + - action: utter_greet + """ + ) + policy = self.create_policy( + featurizer=featurizer, + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + ) + import tests.core.test_policies + + training_trackers = tests.core.test_policies.train_trackers( + default_domain, str(stories), augmentation_factor=20 + ) + with pytest.raises(RasaException) as e: + policy.train(training_trackers, default_domain, precomputations=None) + + assert "No user features specified. Cannot train 'TED' model." == str(e.value) + + def test_similarity_type(self, trained_policy: TEDPolicy): + assert trained_policy.config[SIMILARITY_TYPE] == "inner" + + def test_ranking_length(self, trained_policy: TEDPolicy): + assert trained_policy.config[RANKING_LENGTH] == 0 + + def test_ranking_length_and_renormalization( + self, + trained_policy: TEDPolicy, + tracker: DialogueStateTracker, + default_domain: Domain, + monkeypatch: MonkeyPatch, + ): + precomputations = None + prediction = trained_policy.predict_action_probabilities( + tracker, default_domain, precomputations + ) + + # first check the output is what we expect + assert not prediction.is_end_to_end_prediction + + # check that ranking length is applied - without normalization + if trained_policy.config[RANKING_LENGTH] == 0: + assert sum( + [confidence for confidence in prediction.probabilities] + ) == pytest.approx(1) + assert all(confidence > 0 for confidence in prediction.probabilities) + else: + assert ( + sum([confidence > 0 for confidence in prediction.probabilities]) + == trained_policy.config[RANKING_LENGTH] + ) + assert sum( + [confidence for confidence in prediction.probabilities] + ) != pytest.approx(1) + + def test_label_data_assembly( + self, trained_policy: TEDPolicy, default_domain: Domain + ): + state_featurizer = trained_policy.featurizer.state_featurizer + encoded_all_labels = state_featurizer.encode_all_labels( + default_domain, precomputations=None + ) + + attribute_data, _ = model_data_utils.convert_to_data_format(encoded_all_labels) + assembled_label_data = trained_policy._assemble_label_data( + attribute_data, default_domain + ) + assembled_label_data_signature = assembled_label_data.get_signature() + + assert list(assembled_label_data_signature.keys()) == [ + f"{LABEL}_{ACTION_NAME}", + f"{LABEL}", + ] + assert assembled_label_data.num_examples == default_domain.num_actions + assert list( + assembled_label_data_signature[f"{LABEL}_{ACTION_NAME}"].keys() + ) == [MASK, SENTENCE] + assert list(assembled_label_data_signature[LABEL].keys()) == [IDS] + assert ( + assembled_label_data_signature[f"{LABEL}_{ACTION_NAME}"][SENTENCE][0].units + == default_domain.num_actions + ) + + def test_gen_batch( + self, trained_policy: TEDPolicy, default_domain: Domain, stories_path: Path + ): + training_trackers = tests.core.test_policies.train_trackers( + default_domain, stories_path, augmentation_factor=0 + ) + precomputations = None + training_data, label_ids, entity_tags = trained_policy._featurize_for_training( + training_trackers, default_domain, precomputations + ) + + _, all_labels = trained_policy._create_label_data( + default_domain, precomputations + ) + model_data = trained_policy._create_model_data( + training_data, label_ids, entity_tags, all_labels + ) + batch_size = 2 + data_generator = RasaBatchDataGenerator( + model_data, batch_size=batch_size, shuffle=False, batch_strategy="sequence" + ) + iterator = iter(data_generator) + # model data keys were sorted, so the order is alphabetical + ( + ( + batch_action_name_mask, + _, + _, + batch_action_name_sentence_shape, + batch_dialogue_length, + batch_entities_mask, + _, + _, + batch_entities_sentence_shape, + batch_intent_mask, + _, + _, + batch_intent_sentence_shape, + batch_label_ids, + batch_slots_mask, + _, + _, + batch_slots_sentence_shape, + ), + _, + ) = next(iterator) + + assert ( + batch_label_ids.shape[0] == batch_size + and batch_dialogue_length.shape[0] == batch_size + ) + # batch and dialogue dimensions are NOT combined for masks + assert ( + batch_slots_mask.shape[0] == batch_size + and batch_intent_mask.shape[0] == batch_size + and batch_entities_mask.shape[0] == batch_size + and batch_action_name_mask.shape[0] == batch_size + ) + # some features might be "fake" so there sequence is `0` + seq_len = max( + [ + batch_intent_sentence_shape[1], + batch_action_name_sentence_shape[1], + batch_entities_sentence_shape[1], + batch_slots_sentence_shape[1], + ] + ) + assert ( + batch_intent_sentence_shape[1] == seq_len + or batch_intent_sentence_shape[1] == 0 + ) + assert ( + batch_action_name_sentence_shape[1] == seq_len + or batch_action_name_sentence_shape[1] == 0 + ) + assert ( + batch_entities_sentence_shape[1] == seq_len + or batch_entities_sentence_shape[1] == 0 + ) + assert ( + batch_slots_sentence_shape[1] == seq_len + or batch_slots_sentence_shape[1] == 0 + ) + + data_generator = RasaBatchDataGenerator( + model_data, batch_size=batch_size, shuffle=True, batch_strategy="balanced" + ) + iterator = iter(data_generator) + + ( + ( + batch_action_name_mask, + _, + _, + batch_action_name_sentence_shape, + batch_dialogue_length, + batch_entities_mask, + _, + _, + batch_entities_sentence_shape, + batch_intent_mask, + _, + _, + batch_intent_sentence_shape, + batch_label_ids, + batch_slots_mask, + _, + _, + batch_slots_sentence_shape, + ), + _, + ) = next(iterator) + + assert ( + batch_label_ids.shape[0] == batch_size + and batch_dialogue_length.shape[0] == batch_size + ) + # some features might be "fake" so there sequence is `0` + seq_len = max( + [ + batch_intent_sentence_shape[1], + batch_action_name_sentence_shape[1], + batch_entities_sentence_shape[1], + batch_slots_sentence_shape[1], + ] + ) + assert ( + batch_intent_sentence_shape[1] == seq_len + or batch_intent_sentence_shape[1] == 0 + ) + assert ( + batch_action_name_sentence_shape[1] == seq_len + or batch_action_name_sentence_shape[1] == 0 + ) + assert ( + batch_entities_sentence_shape[1] == seq_len + or batch_entities_sentence_shape[1] == 0 + ) + assert ( + batch_slots_sentence_shape[1] == seq_len + or batch_slots_sentence_shape[1] == 0 + ) + + @pytest.mark.parametrize( + "tracker_events_with_action, tracker_events_without_action", + [ + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ], + ), + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + EntitiesAdded(entities=[{"entity": "name", "value": "Peter"}]), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + EntitiesAdded(entities=[{"entity": "name", "value": "Peter"}]), + ActionExecuted("utter_greet"), + ], + ), + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("some_form"), + ActiveLoop("some_form"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="default", intent={"name": "default"}), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("some_form"), + ActiveLoop("some_form"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="default", intent={"name": "default"}), + ], + ), + ], + ) + def test_ignore_action_unlikely_intent( + self, + trained_policy: TEDPolicy, + default_domain: Domain, + tracker_events_with_action: List[Event], + tracker_events_without_action: List[Event], + ): + precomputations = None + tracker_with_action = DialogueStateTracker.from_events( + "test 1", evts=tracker_events_with_action + ) + tracker_without_action = DialogueStateTracker.from_events( + "test 2", evts=tracker_events_without_action + ) + prediction_with_action = trained_policy.predict_action_probabilities( + tracker_with_action, default_domain, precomputations + ) + prediction_without_action = trained_policy.predict_action_probabilities( + tracker_without_action, default_domain, precomputations + ) + + # If the weights didn't change then both trackers + # should result in same prediction. + assert ( + prediction_with_action.probabilities + == prediction_without_action.probabilities + ) + + @pytest.mark.parametrize( + "featurizer_config, tracker_featurizer, state_featurizer", + [ + (None, MaxHistoryTrackerFeaturizer(), SingleStateFeaturizer), + ([], MaxHistoryTrackerFeaturizer(), SingleStateFeaturizer), + ], + ) + def test_empty_featurizer_configs( + self, + featurizer_config: Optional[Dict[Text, Any]], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + tracker_featurizer: MaxHistoryTrackerFeaturizer, + state_featurizer: Type[SingleStateFeaturizer], + ): + featurizer_config_override = ( + {"featurizer": featurizer_config} if featurizer_config else {} + ) + policy = self.create_policy( + None, + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + config=self._config(featurizer_config_override), + ) + + featurizer = policy.featurizer + assert isinstance(featurizer, tracker_featurizer.__class__) + + if featurizer_config: + expected_max_history = featurizer_config[0].get(POLICY_MAX_HISTORY) + else: + expected_max_history = self._config().get(POLICY_MAX_HISTORY) + + assert featurizer.max_history == expected_max_history + + assert isinstance(featurizer.state_featurizer, state_featurizer) + + +class TestTEDPolicyConfigurationOptions: + """Helper class to skip redundant and long-running tests in subclasses.""" + + @pytest.mark.parametrize("should_finetune", [False]) + @pytest.mark.skip() + def test_persist_and_load( + self, + trained_policy: Policy, + default_domain: Domain, + should_finetune: bool, + stories_path: Text, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ): + """This takes long and does not need to be tested for every config change.""" + pass + + @pytest.mark.skip() + def test_train_model_checkpointing( + self, tmp_path: Path, tmp_path_factory: TempPathFactory + ): + """This takes long and does not need to be tested for every config change.""" + pass + + @pytest.mark.skip() + def test_doesnt_checkpoint_with_no_checkpointing( + self, tmp_path: Path, tmp_path_factory: TempPathFactory + ): + """This takes long and does not need to be tested for every config change.""" + pass + + @pytest.mark.skip() + def test_doesnt_checkpoint_with_zero_eval_num_examples( + self, tmp_path: Path, tmp_path_factory: TempPathFactory + ): + """This takes long and does not need to be tested for every config change.""" + + @pytest.mark.parametrize( + "should_finetune, epoch_override, expected_epoch_value", + [ + ( + True, + TEDPolicy.get_default_config()[EPOCHS] + 1, + TEDPolicy.get_default_config()[EPOCHS] + 1, + ) + ], + ) + @pytest.mark.skip() + def test_epoch_override_when_loaded( + self, + trained_policy: TEDPolicy, + should_finetune: bool, + epoch_override: int, + expected_epoch_value: int, + resource: Resource, + model_storage: ModelStorage, + execution_context: ExecutionContext, + ): + """This takes long and does not need to be tested for every config change.""" + pass + + +class TestTEDPolicyMargin(TestTEDPolicyConfigurationOptions, TestTEDPolicy): + def _config( + self, config_override: Optional[Dict[Text, Any]] = None + ) -> Dict[Text, Any]: + config_override = config_override or {} + return { + **TEDPolicy.get_default_config(), + LOSS_TYPE: "margin", + EPOCHS: 2, + **config_override, + } + + def test_similarity_type(self, trained_policy: TEDPolicy): + assert trained_policy.config[SIMILARITY_TYPE] == COSINE + + def test_confidence_type(self, trained_policy: TEDPolicy): + assert trained_policy.config[MODEL_CONFIDENCE] == AUTO + + def test_ranking_length_and_renormalization( + self, + trained_policy: Policy, + tracker: DialogueStateTracker, + default_domain: Domain, + ): + policy_prediction = trained_policy.predict_action_probabilities( + tracker, default_domain, precomputations=None + ) + assert sum(policy_prediction.probabilities) != pytest.approx(1) + + def test_prediction_on_empty_tracker( + self, trained_policy: Policy, default_domain: Domain + ): + tracker = DialogueStateTracker(DEFAULT_SENDER_ID, default_domain.slots) + prediction = trained_policy.predict_action_probabilities( + tracker, default_domain, precomputations=None + ) + assert not prediction.is_end_to_end_prediction + assert len(prediction.probabilities) == default_domain.num_actions + assert max(prediction.probabilities) <= 1.0 + assert min(prediction.probabilities) >= -1.0 + + +class TestTEDPolicyWithEval(TestTEDPolicyConfigurationOptions, TestTEDPolicy): + def _config( + self, config_override: Optional[Dict[Text, Any]] = None + ) -> Dict[Text, Any]: + config_override = config_override or {} + return { + **TEDPolicy.get_default_config(), + SCALE_LOSS: False, + EVAL_NUM_EXAMPLES: 4, + **config_override, + } + + +class TestTEDPolicyNormalization(TestTEDPolicyConfigurationOptions, TestTEDPolicy): + def _config( + self, config_override: Optional[Dict[Text, Any]] = None + ) -> Dict[Text, Any]: + config_override = config_override or {} + return { + **TEDPolicy.get_default_config(), + RANKING_LENGTH: 4, + RENORMALIZE_CONFIDENCES: True, + **config_override, + } + + def test_ranking_length(self, trained_policy: TEDPolicy): + assert trained_policy.config[RANKING_LENGTH] == 4 + + def test_ranking_length_and_renormalization( + self, + trained_policy: Policy, + tracker: DialogueStateTracker, + default_domain: Domain, + ): + precomputations = None + predicted_probabilities = trained_policy.predict_action_probabilities( + tracker, default_domain, precomputations + ).probabilities + assert all([confidence >= 0 for confidence in predicted_probabilities]) + assert sum([confidence > 0 for confidence in predicted_probabilities]) == 4 + assert sum(predicted_probabilities) == pytest.approx(1) + + +class TestTEDPolicyLowRankingLength(TestTEDPolicyConfigurationOptions, TestTEDPolicy): + def _config( + self, config_override: Optional[Dict[Text, Any]] = None + ) -> Dict[Text, Any]: + config_override = config_override or {} + return {**TEDPolicy.get_default_config(), RANKING_LENGTH: 3, **config_override} + + def test_ranking_length(self, trained_policy: TEDPolicy): + assert trained_policy.config[RANKING_LENGTH] == 3 + + +class TestTEDPolicyHighRankingLength(TestTEDPolicyConfigurationOptions, TestTEDPolicy): + def _config( + self, config_override: Optional[Dict[Text, Any]] = None + ) -> Dict[Text, Any]: + config_override = config_override or {} + return {**TEDPolicy.get_default_config(), RANKING_LENGTH: 11, **config_override} + + def test_ranking_length(self, trained_policy: TEDPolicy): + assert trained_policy.config[RANKING_LENGTH] == 11 + + +class TestTEDPolicyWithStandardFeaturizer( + TestTEDPolicyConfigurationOptions, TestTEDPolicy +): + def _config( + self, config_override: Optional[Dict[Text, Any]] = None + ) -> Dict[Text, Any]: + config_override = config_override or {} + return {**TEDPolicy.get_default_config(), **config_override} + + def create_policy( + self, + featurizer: Optional[TrackerFeaturizer], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + config: Optional[Dict[Text, Any]] = None, + ) -> Policy: + # use standard featurizer from TEDPolicy, + # since it is using MaxHistoryTrackerFeaturizer + # if max_history is not specified + return TEDPolicy( + config=self._config(config), + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + ) + + def test_featurizer( + self, + trained_policy: Policy, + resource: Resource, + model_storage: ModelStorage, + tmp_path: Path, + execution_context: ExecutionContext, + ): + assert isinstance(trained_policy.featurizer, MaxHistoryTrackerFeaturizer) + assert isinstance( + trained_policy.featurizer.state_featurizer, SingleStateFeaturizer + ) + + loaded = trained_policy.__class__.load( + self._config(trained_policy.config), + model_storage, + resource, + execution_context, + ) + + assert isinstance(loaded.featurizer, MaxHistoryTrackerFeaturizer) + assert isinstance(loaded.featurizer.state_featurizer, SingleStateFeaturizer) + + +class TestTEDPolicyWithMaxHistory(TestTEDPolicyConfigurationOptions, TestTEDPolicy): + def _config( + self, config_override: Optional[Dict[Text, Any]] = None + ) -> Dict[Text, Any]: + config_override = config_override or {} + return { + **TEDPolicy.get_default_config(), + POLICY_MAX_HISTORY: self.max_history, + **config_override, + } + + def create_policy( + self, + featurizer: Optional[TrackerFeaturizer], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + config: Optional[Dict[Text, Any]] = None, + ) -> Policy: + # use standard featurizer from TEDPolicy, + # since it is using MaxHistoryTrackerFeaturizer + # if max_history is specified + return TEDPolicy( + config=self._config(config), + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + ) + + +class TestTEDPolicyWithRelativeAttention( + TestTEDPolicyConfigurationOptions, TestTEDPolicy +): + def _config( + self, config_override: Optional[Dict[Text, Any]] = None + ) -> Dict[Text, Any]: + config_override = config_override or {} + return { + **TEDPolicy.get_default_config(), + KEY_RELATIVE_ATTENTION: True, + VALUE_RELATIVE_ATTENTION: True, + MAX_RELATIVE_POSITION: 5, + **config_override, + } + + +class TestTEDPolicyWithRelativeAttentionMaxHistoryOne( + TestTEDPolicyConfigurationOptions, TestTEDPolicy +): + max_history = 1 + + def _config( + self, config_override: Optional[Dict[Text, Any]] = None + ) -> Dict[Text, Any]: + config_override = config_override or {} + return { + **TEDPolicy.get_default_config(), + KEY_RELATIVE_ATTENTION: True, + VALUE_RELATIVE_ATTENTION: True, + MAX_RELATIVE_POSITION: 5, + **config_override, + } diff --git a/tests/core/policies/test_unexpected_intent_policy.py b/tests/core/policies/test_unexpected_intent_policy.py new file mode 100644 index 0000000..c7e6d0c --- /dev/null +++ b/tests/core/policies/test_unexpected_intent_policy.py @@ -0,0 +1,1203 @@ +import json +from pathlib import Path +from typing import Optional, List, Dict, Type +import tensorflow as tf +import numpy as np +import pytest +from _pytest.monkeypatch import MonkeyPatch +from _pytest.logging import LogCaptureFixture +import logging + +from rasa.core.featurizers.single_state_featurizer import ( + IntentTokenizerSingleStateFeaturizer, +) +from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer +from rasa.core.featurizers.tracker_featurizers import IntentMaxHistoryTrackerFeaturizer +from rasa.nlu.classifiers import LABEL_RANKING_LENGTH +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION +from rasa.shared.core.generator import TrackerWithCachedStates +from rasa.core.policies.ted_policy import PREDICTION_FEATURES +from rasa.core.policies.unexpected_intent_policy import ( + UnexpecTEDIntentPolicy, + RankingCandidateMetadata, +) +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.core.constants import ACTION_UNLIKELY_INTENT_NAME, ACTION_LISTEN_NAME +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + ActionExecuted, + UserUttered, + EntitiesAdded, + SlotSet, + ActionExecutionRejected, + ActiveLoop, +) +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.utils.tensorflow.constants import ( + IGNORE_INTENTS_LIST, + LABEL, + MASK, + SENTENCE, + IDS, + POSITIVE_SCORES_KEY, + NEGATIVE_SCORES_KEY, + RANKING_KEY, + RANKING_LENGTH, +) +from rasa.shared.nlu.constants import INTENT +from rasa.shared.core.events import Event +from rasa.utils.tensorflow import model_data_utils +from tests.core.test_policies import train_trackers +from tests.core.policies.test_ted_policy import TestTEDPolicy + + +class TestUnexpecTEDIntentPolicy(TestTEDPolicy): + @staticmethod + def _policy_class_to_test() -> Type[UnexpecTEDIntentPolicy]: + + return UnexpecTEDIntentPolicy + + @pytest.fixture(scope="class") + def featurizer(self) -> TrackerFeaturizer: + featurizer = IntentMaxHistoryTrackerFeaturizer( + IntentTokenizerSingleStateFeaturizer(), max_history=self.max_history + ) + return featurizer + + @staticmethod + def persist_and_load_policy( + trained_policy: UnexpecTEDIntentPolicy, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ): + return trained_policy.__class__.load( + trained_policy.config, model_storage, resource, execution_context + ) + + def test_ranking_length(self, trained_policy: UnexpecTEDIntentPolicy): + assert trained_policy.config[RANKING_LENGTH] == LABEL_RANKING_LENGTH + + def test_ranking_length_and_renormalization( + self, + trained_policy: UnexpecTEDIntentPolicy, + tracker: DialogueStateTracker, + default_domain: Domain, + ): + precomputations = None + prediction_metadata = trained_policy.predict_action_probabilities( + tracker, default_domain, precomputations + ).action_metadata + assert ( + prediction_metadata is None + or len(prediction_metadata[RANKING_KEY]) + == trained_policy.config[RANKING_LENGTH] + ) + + def test_label_data_assembly( + self, trained_policy: UnexpecTEDIntentPolicy, default_domain: Domain + ): + + # Construct input data + state_featurizer = trained_policy.featurizer.state_featurizer + encoded_all_labels = state_featurizer.encode_all_labels( + default_domain, precomputations=None + ) + attribute_data, _ = model_data_utils.convert_to_data_format(encoded_all_labels) + + assembled_label_data = trained_policy._assemble_label_data( + attribute_data, default_domain + ) + assembled_label_data_signature = assembled_label_data.get_signature() + + assert list(assembled_label_data_signature.keys()) == [ + f"{LABEL}_{INTENT}", + LABEL, + ] + assert assembled_label_data.num_examples == len(default_domain.intents) + assert list(assembled_label_data_signature[f"{LABEL}_{INTENT}"].keys()) == [ + MASK, + SENTENCE, + ] + assert list(assembled_label_data_signature[LABEL].keys()) == [IDS] + assert assembled_label_data_signature[f"{LABEL}_{INTENT}"][SENTENCE][ + 0 + ].units == len(default_domain.intents) + + def test_training_with_no_intent( + self, + featurizer: Optional[TrackerFeaturizer], + default_domain: Domain, + tmp_path: Path, + caplog: LogCaptureFixture, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ): + stories = tmp_path / "stories.yml" + stories.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: test path + steps: + - action: utter_greet + """ + ) + policy = self.create_policy( + featurizer=featurizer, + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + ) + import tests.core.test_policies + + training_trackers = tests.core.test_policies.train_trackers( + default_domain, str(stories), augmentation_factor=20 + ) + + with pytest.warns(UserWarning): + policy.train(training_trackers, default_domain, precomputations=None) + + def test_prepared_data_for_threshold_prediction( + self, + trained_policy: UnexpecTEDIntentPolicy, + default_domain: Domain, + stories_path: Path, + ): + training_trackers = train_trackers( + default_domain, stories_path, augmentation_factor=0 + ) + training_model_data, _ = trained_policy._prepare_for_training( + training_trackers, default_domain, precomputations=None + ) + + data_for_prediction = trained_policy._prepare_data_for_prediction( + training_model_data + ) + + assert set(data_for_prediction.data.keys()).issubset(PREDICTION_FEATURES) + + def test_similarities_collection_for_label_ids(self): + label_ids = np.array([[0, 1], [1, -1], [2, -1]]) + outputs = { + "similarities": np.array( + [[[1.2, 0.3, 0.2]], [[0.5, 0.2, 1.6]], [[0.01, 0.1, 1.7]]] + ) + } + label_id_similarities = UnexpecTEDIntentPolicy._collect_label_id_grouped_scores( + outputs, label_ids + ) + + # Should contain similarities for all label ids except padding token. + assert sorted(list(label_id_similarities.keys())) == [0, 1, 2] + + # Cross-check that the collected similarities are correct for each label id. + assert label_id_similarities[0] == { + POSITIVE_SCORES_KEY: [1.2], + NEGATIVE_SCORES_KEY: [0.5, 0.01], + } + assert label_id_similarities[1] == { + POSITIVE_SCORES_KEY: [0.3, 0.2], + NEGATIVE_SCORES_KEY: [0.1], + } + assert label_id_similarities[2] == { + POSITIVE_SCORES_KEY: [1.7], + NEGATIVE_SCORES_KEY: [0.2, 1.6], + } + + def test_label_quantiles_computation(self): + label_id_scores = { + 0: { + POSITIVE_SCORES_KEY: [1.3, 0.2], + NEGATIVE_SCORES_KEY: [ + -0.1, + -1.2, + -2.3, + -4.1, + -0.5, + 0.2, + 0.8, + 0.9, + -3.2, + -2.7, + ], + }, + 3: {POSITIVE_SCORES_KEY: [1.3, 0.2], NEGATIVE_SCORES_KEY: [-0.1]}, + 6: {POSITIVE_SCORES_KEY: [1.3, 0.2], NEGATIVE_SCORES_KEY: []}, + } + expected_thresholds = { + 0: [ + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + -0.1, + -0.1, + -0.5, + -0.5, + -1.2, + -1.2, + -1.2, + -2.3, + -2.3, + -2.7, + -2.7, + -3.2, + -3.2, + -4.1, + -4.1, + ], + 3: [ + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + ], + 6: [ + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + ], + } + thresholds = UnexpecTEDIntentPolicy._compute_label_quantiles(label_id_scores) + assert sorted(list(thresholds.keys())) == sorted( + list(expected_thresholds.keys()) + ) + for label_id, tolerance_thresholds in thresholds.items(): + assert expected_thresholds[label_id] == tolerance_thresholds + + def test_post_training_threshold_computation( + self, + trained_policy: UnexpecTEDIntentPolicy, + default_domain: Domain, + stories_path: Path, + ): + training_trackers = train_trackers( + default_domain, stories_path, augmentation_factor=0 + ) + training_model_data, label_ids = trained_policy._prepare_for_training( + training_trackers, default_domain, precomputations=None + ) + + trained_policy.compute_label_quantiles_post_training( + training_model_data, label_ids + ) + + computed_thresholds = trained_policy.label_quantiles + + # -1 is used for padding and hence is not expected in the keys + expected_keys = list(np.unique(label_ids)) + expected_keys.remove(-1) + + assert sorted(list(computed_thresholds.keys())) == sorted(expected_keys) + + @pytest.mark.parametrize( + "tolerance, expected_thresholds", + [ + (0.0, [0.2, -0.1, 0.2]), + (0.75, [-2.9, -0.1, -4.3]), + (0.72, [-2.7, -0.1, -4.0]), + (0.78, [-2.9, -0.1, -4.3]), + (1.0, [-4.1, -0.1, -5.5]), + ], + ) + def test_pick_thresholds_for_labels( + self, tolerance: float, expected_thresholds: List[float] + ): + label_id_tolerance_thresholds = { + 0: [ + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + -0.1, + -0.1, + -0.5, + -0.5, + -1.2, + -1.2, + -2.3, + -2.3, + -2.7, + -2.9, + -3.2, + -3.2, + -4.1, + -4.1, + ], + 3: [ + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + -0.1, + ], + 4: [0.2 - (index * 0.3) for index in range(20)], + } + thresholds = UnexpecTEDIntentPolicy._pick_thresholds( + label_id_tolerance_thresholds, tolerance + ) + assert sorted(list(thresholds.keys())) == sorted( + list(label_id_tolerance_thresholds.keys()) + ) + computed_values = list(thresholds.values()) + assert expected_thresholds == computed_values + + @pytest.mark.parametrize( + "predicted_similarity, threshold_value, is_unlikely", + [(1.2, 0.2, False), (0.3, -0.1, False), (-1.5, 0.03, True)], + ) + def test_unlikely_intent_check( + self, + trained_policy: UnexpecTEDIntentPolicy, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + default_domain: Domain, + predicted_similarity: float, + threshold_value: float, + is_unlikely: bool, + tmp_path: Path, + ): + loaded_policy = self.persist_and_load_policy( + trained_policy, model_storage, resource, execution_context + ) + # Construct dummy similarities + similarities = np.array([[0.0] * len(default_domain.intents)]) + dummy_intent_index = 4 + similarities[0, dummy_intent_index] = predicted_similarity + + loaded_policy.label_thresholds[dummy_intent_index] = threshold_value + query_intent = default_domain.intents[dummy_intent_index] + + unlikely_intent_prediction = loaded_policy._check_unlikely_intent( + default_domain, similarities, query_intent + ) + + assert is_unlikely == unlikely_intent_prediction + + def test_should_check_for_intent( + self, + trained_policy: UnexpecTEDIntentPolicy, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + default_domain: Domain, + tmp_path: Path, + ): + loaded_policy = self.persist_and_load_policy( + trained_policy, model_storage, resource, execution_context + ) + + intent_index = 0 + assert ( + loaded_policy._should_check_for_intent( + default_domain.intents[intent_index], default_domain + ) + is False + ) + + intent_index = 4 + assert loaded_policy._should_check_for_intent( + default_domain.intents[intent_index], default_domain + ) + + loaded_policy.config[IGNORE_INTENTS_LIST] = [ + default_domain.intents[intent_index] + ] + assert ( + loaded_policy._should_check_for_intent( + default_domain.intents[intent_index], default_domain + ) + is False + ) + + def test_no_action_unlikely_intent_prediction( + self, + trained_policy: UnexpecTEDIntentPolicy, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + default_domain: Domain, + tmp_path: Path, + ): + loaded_policy = self.persist_and_load_policy( + trained_policy, model_storage, resource, execution_context + ) + + expected_probabilities = [0] * default_domain.num_actions + + precomputations = None + tracker = DialogueStateTracker(sender_id="init", slots=default_domain.slots) + prediction = loaded_policy.predict_action_probabilities( + tracker, default_domain, precomputations + ) + + assert prediction.probabilities == expected_probabilities + + tracker.update_with_events( + [ + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted(action_name="utter_greet"), + ], + default_domain, + ) + prediction = loaded_policy.predict_action_probabilities( + tracker, default_domain, precomputations + ) + + assert prediction.probabilities == expected_probabilities + + loaded_policy.model = None + + prediction = loaded_policy.predict_action_probabilities( + tracker, default_domain, precomputations + ) + + assert prediction.probabilities == expected_probabilities + + @pytest.mark.parametrize( + "predicted_similarity, threshold_value, is_unlikely", + [(1.2, 0.2, False), (0.3, -0.1, False), (-1.5, 0.03, True)], + ) + def test_action_unlikely_intent_prediction( + self, + trained_policy: UnexpecTEDIntentPolicy, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + default_domain: Domain, + predicted_similarity: float, + threshold_value: float, + is_unlikely: bool, + monkeypatch: MonkeyPatch, + tmp_path: Path, + ): + loaded_policy = self.persist_and_load_policy( + trained_policy, model_storage, resource, execution_context + ) + + similarities = np.array([[[0.0] * len(default_domain.intents)]]) + + dummy_intent_index = 4 + similarities[0, 0, dummy_intent_index] = predicted_similarity + query_intent = default_domain.intents[dummy_intent_index] + + loaded_policy.label_thresholds[dummy_intent_index] = threshold_value + + precomputations = None + tracker = DialogueStateTracker(sender_id="init", slots=default_domain.slots) + + tracker.update_with_events( + [UserUttered(text="hello", intent={"name": query_intent})], default_domain + ) + + # Preset the model predictions to the similarity values + # so that we don't need to hardcode for particular model predictions. + monkeypatch.setattr( + loaded_policy.model, + "run_inference", + lambda data: {"similarities": similarities}, + ) + + prediction = loaded_policy.predict_action_probabilities( + tracker, default_domain, precomputations + ) + + if not is_unlikely: + assert prediction.probabilities == [0.0] * default_domain.num_actions + else: + assert ( + prediction.probabilities[ + default_domain.index_for_action(ACTION_UNLIKELY_INTENT_NAME) + ] + == 1.0 + ) + + # Make sure metadata is set. The exact structure + # of the metadata is tested separately and + # not as part of this test. + assert prediction.action_metadata is not None + # Assert metadata is serializable + assert json.dumps(prediction.action_metadata) + + @pytest.mark.parametrize( + "tracker_events, should_skip", + [ + ([], True), + ([ActionExecuted("action_listen")], True), + ( + [ + ActionExecuted("action_listen"), + UserUttered("hi", intent={"name": "greet"}), + ], + False, + ), + ( + [ + ActionExecuted("action_listen"), + UserUttered("hi", intent={"name": "greet"}), + EntitiesAdded([{"name": "dummy"}]), + ], + False, + ), + ( + [ + ActionExecuted("action_listen"), + UserUttered("hi", intent={"name": "greet"}), + SlotSet("name"), + ], + False, + ), + ( + [ + ActiveLoop("loop"), + ActionExecuted("action_listen"), + UserUttered("hi", intent={"name": "greet"}), + ActionExecutionRejected("loop"), + ], + False, + ), + ( + [ + ActionExecuted("action_listen"), + UserUttered("hi", intent={"name": "greet"}), + ActionExecuted("utter_greet"), + ], + True, + ), + ], + ) + def test_skip_predictions_to_prevent_loop( + self, + trained_policy: UnexpecTEDIntentPolicy, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + default_domain: Domain, + caplog: LogCaptureFixture, + tracker_events: List[Event], + should_skip: bool, + tmp_path: Path, + ): + """Skips predictions to prevent loop.""" + precomputations = None + tracker = DialogueStateTracker(sender_id="init", slots=default_domain.slots) + tracker.update_with_events(tracker_events, default_domain) + with caplog.at_level(logging.DEBUG): + prediction = trained_policy.predict_action_probabilities( + tracker, default_domain, precomputations + ) + + assert ( + "Skipping predictions for UnexpecTEDIntentPolicy" in caplog.text + ) == should_skip + + if should_skip: + assert prediction.probabilities == trained_policy._default_predictions( + default_domain + ) + + @pytest.mark.parametrize( + "tracker_events", + [ + [ + ActionExecuted("action_listen"), + UserUttered("hi", intent={"name": "inexistent_intent"}), + ], + [ + ActionExecuted("action_listen"), + UserUttered("hi", intent={"name": "inexistent_intent"}), + EntitiesAdded([{"name": "dummy"}]), + ], + [ + ActionExecuted("action_listen"), + UserUttered("hi", intent={"name": "inexistent_intent"}), + SlotSet("name"), + ], + [ + ActiveLoop("loop"), + ActionExecuted("action_listen"), + UserUttered("hi", intent={"name": "inexistent_intent"}), + ActionExecutionRejected("loop"), + ], + ], + ) + def test_skip_predictions_if_new_intent( + self, + trained_policy: UnexpecTEDIntentPolicy, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + default_domain: Domain, + caplog: LogCaptureFixture, + tracker_events: List[Event], + ): + """Skips predictions if there's a new intent created.""" + tracker = DialogueStateTracker(sender_id="init", slots=default_domain.slots) + tracker.update_with_events(tracker_events, default_domain) + + with caplog.at_level(logging.DEBUG): + prediction = trained_policy.predict_action_probabilities( + tracker, default_domain, precomputations=None + ) + + assert "Skipping predictions for UnexpecTEDIntentPolicy" in caplog.text + + assert prediction.probabilities == trained_policy._default_predictions( + default_domain + ) + + @pytest.mark.parametrize( + "tracker_events_with_action, tracker_events_without_action", + [ + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + UserUttered(text="sad", intent={"name": "thank_you"}), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted("utter_greet"), + UserUttered(text="sad", intent={"name": "thank_you"}), + ], + ), + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + EntitiesAdded(entities=[{"entity": "name", "value": "Peter"}]), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("utter_greet"), + UserUttered(text="sad", intent={"name": "thank_you"}), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + EntitiesAdded(entities=[{"entity": "name", "value": "Peter"}]), + ActionExecuted("utter_greet"), + UserUttered(text="sad", intent={"name": "thank_you"}), + ], + ), + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("some_form"), + ActiveLoop("some_form"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="default", intent={"name": "default"}), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + UserUttered(text="sad", intent={"name": "thank_you"}), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted("some_form"), + ActiveLoop("some_form"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="default", intent={"name": "default"}), + UserUttered(text="sad", intent={"name": "thank_you"}), + ], + ), + ], + ) + def test_ignore_action_unlikely_intent( + self, + trained_policy: UnexpecTEDIntentPolicy, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + default_domain: Domain, + tracker_events_with_action: List[Event], + tracker_events_without_action: List[Event], + tmp_path: Path, + ): + precomputations = None + tracker_with_action = DialogueStateTracker.from_events( + "test 1", evts=tracker_events_with_action + ) + tracker_without_action = DialogueStateTracker.from_events( + "test 2", evts=tracker_events_without_action + ) + prediction_with_action = trained_policy.predict_action_probabilities( + tracker_with_action, default_domain, precomputations + ) + prediction_without_action = trained_policy.predict_action_probabilities( + tracker_without_action, default_domain, precomputations + ) + + # If the weights didn't change then both trackers + # should result in same prediction. For `UnexpecTEDIntentPolicy`, the real + # prediction is inside action metadata. + assert ( + prediction_with_action.action_metadata + == prediction_without_action.action_metadata + ) + + def test_label_embedding_collection(self, trained_policy: UnexpecTEDIntentPolicy): + label_ids = tf.constant([[[2], [-1]], [[1], [2]], [[0], [-1]]], dtype=tf.int32) + + all_label_embeddings = np.random.random((10, 20)) + + # `-1` is used as padding label id. The embedding for it + # will be the same as `label_id=0` + expected_extracted_label_embeddings = tf.constant( + np.concatenate( + [ + all_label_embeddings[2], + all_label_embeddings[0], + all_label_embeddings[1], + all_label_embeddings[2], + all_label_embeddings[0], + all_label_embeddings[0], + ] + ).reshape((3, 2, 20)), + dtype=tf.float32, + ) + + actual_extracted_label_embeddings = trained_policy.model._get_labels_embed( + label_ids, tf.constant(all_label_embeddings, dtype=tf.float32) + ) + + assert np.all( + expected_extracted_label_embeddings == actual_extracted_label_embeddings + ) + + @pytest.mark.parametrize( + "query_intent_index, ranking_length", [(0, 0), (1, 3), (2, 1), (5, 0)] + ) + def test_collect_action_metadata( + self, + trained_policy: UnexpecTEDIntentPolicy, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + default_domain: Domain, + tmp_path: Path, + query_intent_index: int, + ranking_length: int, + ): + loaded_policy = self.persist_and_load_policy( + trained_policy, model_storage, resource, execution_context + ) + + def test_individual_label_metadata( + label_metadata: RankingCandidateMetadata, + all_thresholds: Dict[int, float], + all_similarities: np.array, + label_index: int, + ): + + expected_score = all_similarities[0][label_index] + expected_threshold = ( + all_thresholds[label_index] if label_index in all_thresholds else None + ) + expected_severity = ( + expected_threshold - expected_score if expected_threshold else None + ) + + assert label_metadata.score == expected_score + assert label_metadata.threshold == expected_threshold + assert label_metadata.severity == expected_severity + + # Monkey-patch certain attributes of the policy to make the testing easier. + label_thresholds = {0: 1.2, 1: -0.3, 4: -2.3, 5: 0.2} + loaded_policy.label_thresholds = label_thresholds + loaded_policy.config[RANKING_LENGTH] = ranking_length + + # Some dummy similarities + similarities = np.array([[3.2, 0.2, -1.2, -4.3, -5.1, 2.3]]) + + query_intent = default_domain.intents[query_intent_index] + + metadata = loaded_policy._collect_action_metadata( + default_domain, similarities, query_intent=query_intent + ) + + # Test all elements of metadata for query intent + assert metadata.query_intent.name == query_intent + test_individual_label_metadata( + metadata.query_intent, + label_thresholds, + similarities, + query_intent_index, + ) + + # Check if ranking is sorted correctly and truncated to `ranking_length` + sorted_label_similarities = sorted( + [(index, score) for index, score in enumerate(similarities[0])], + key=lambda x: -x[1], + ) + sorted_label_similarities = ( + sorted_label_similarities[:ranking_length] + if ranking_length + else sorted_label_similarities + ) + expected_label_rankings = [ + default_domain.intents[index] for index, _ in sorted_label_similarities + ] + collected_label_rankings = [ + label_metadata.name for label_metadata in metadata.ranking + ] + assert collected_label_rankings == expected_label_rankings + + # Test all elements of metadata for all labels in ranking + for label_metadata in metadata.ranking: + label_index = default_domain.intents.index(label_metadata.name) + test_individual_label_metadata( + label_metadata, label_thresholds, similarities, label_index + ) + + @pytest.mark.parametrize( + "tracker_events_for_training, expected_trackers_with_events", + [ + # Filter because of no intent and action name + ( + [ + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + text="happy to make it work", intent={"name": "goodbye"} + ), + ActionExecuted("utter_goodbye"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello"), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="happy to make it work"), + ActionExecuted(action_text="Great!"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ], + [ + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + text="happy to make it work", intent={"name": "goodbye"} + ), + ActionExecuted("utter_goodbye"), + ActionExecuted(ACTION_LISTEN_NAME), + ] + ], + ), + # Filter because of no action name + ( + [ + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + text="happy to make it work", intent={"name": "goodbye"} + ), + ActionExecuted("utter_goodbye"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello"), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + text="happy to make it work", intent={"name": "goodbye"} + ), + ActionExecuted(action_text="Great!"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ], + [ + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + text="happy to make it work", intent={"name": "goodbye"} + ), + ActionExecuted("utter_goodbye"), + ActionExecuted(ACTION_LISTEN_NAME), + ] + ], + ), + # Filter because of no intent + ( + [ + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + text="happy to make it work", intent={"name": "goodbye"} + ), + ActionExecuted("utter_goodbye"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello"), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="happy to make it work"), + ActionExecuted("utter_goodbye"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ], + [ + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + text="happy to make it work", intent={"name": "goodbye"} + ), + ActionExecuted("utter_goodbye"), + ActionExecuted(ACTION_LISTEN_NAME), + ] + ], + ), + # No filter needed + ( + [ + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + text="happy to make it work", intent={"name": "goodbye"} + ), + ActionExecuted("utter_goodbye"), + ActionExecuted(ACTION_LISTEN_NAME), + ] + ], + [ + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + text="happy to make it work", intent={"name": "goodbye"} + ), + ActionExecuted("utter_goodbye"), + ActionExecuted(ACTION_LISTEN_NAME), + ] + ], + ), + # Filter to return empty list of trackers + ( + [ + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + text="happy to make it work", intent={"name": "goodbye"} + ), + ActionExecuted(action_text="Great!"), + ActionExecuted(ACTION_LISTEN_NAME), + ] + ], + [], + ), + ], + ) + def test_filter_training_trackers( + self, + tracker_events_for_training: List[List[Event]], + expected_trackers_with_events: List[List[Event]], + domain: Domain, + ): + trackers_for_training = [ + TrackerWithCachedStates.from_events( + sender_id=f"{tracker_index}", evts=events, domain=domain + ) + for tracker_index, events in enumerate(tracker_events_for_training) + ] + + filtered_trackers = UnexpecTEDIntentPolicy._get_trackers_for_training( + trackers_for_training + ) + assert len(filtered_trackers) == len(expected_trackers_with_events) + for collected_tracker, expected_tracker_events in zip( + filtered_trackers, expected_trackers_with_events + ): + collected_tracker_events = list(collected_tracker.events) + assert collected_tracker_events == expected_tracker_events + + +@pytest.mark.parametrize( + "tracker_events, skip_training", + [ + ( + [ + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + text="happy to make it work", intent={"name": "goodbye"} + ), + ActionExecuted("utter_goodbye"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello"), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="happy to make it work"), + ActionExecuted(action_text="Great!"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ], + False, + ), + ( + [ + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello"), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="happy to make it work"), + ActionExecuted(action_text="Great!"), + ActionExecuted(ACTION_LISTEN_NAME), + ] + ], + True, + ), + ( + [ + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello"), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="happy to make it work"), + ActionExecuted("utter_goodbye"), + ActionExecuted(ACTION_LISTEN_NAME), + ] + ], + True, + ), + ( + [ + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello"), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + text="happy to make it work", intent={"name": "goodbye"} + ), + ActionExecuted(action_text="Great!"), + ActionExecuted(ACTION_LISTEN_NAME), + ] + ], + True, + ), + ], +) +def test_train_with_e2e_data( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + tracker_events: List[List[Event]], + skip_training: bool, + domain: Domain, +): + policy = UnexpecTEDIntentPolicy( + UnexpecTEDIntentPolicy.get_default_config(), + default_model_storage, + Resource("UnexpecTEDIntentPolicy"), + default_execution_context, + featurizer=IntentMaxHistoryTrackerFeaturizer( + IntentTokenizerSingleStateFeaturizer() + ), + ) + trackers_for_training = [ + TrackerWithCachedStates.from_events( + sender_id=f"{tracker_index}", evts=events, domain=domain + ) + for tracker_index, events in enumerate(tracker_events) + ] + if skip_training: + with pytest.warns(UserWarning): + policy.train(trackers_for_training, domain, precomputations=None) + else: + policy.train(trackers_for_training, domain, precomputations=None) diff --git a/tests/core/test_actions.py b/tests/core/test_actions.py new file mode 100644 index 0000000..1a89759 --- /dev/null +++ b/tests/core/test_actions.py @@ -0,0 +1,3020 @@ +import logging +import textwrap +from datetime import datetime +from typing import List, Text, Any, Dict, Optional +from unittest.mock import Mock + +import pytest +from pytest import MonkeyPatch +from _pytest.logging import LogCaptureFixture +from aioresponses import aioresponses +from jsonschema import ValidationError + +import rasa.core +from rasa.core.actions import action +from rasa.core.actions.action import ( + ActionBack, + ActionDefaultAskAffirmation, + ActionDefaultAskRephrase, + ActionDefaultFallback, + ActionExecutionRejection, + ActionRestart, + ActionBotResponse, + ActionRetrieveResponse, + RemoteAction, + ActionSessionStart, + ActionEndToEndResponse, + ActionExtractSlots, +) +from rasa.core.actions.forms import FormAction +from rasa.core.channels import CollectingOutputChannel, OutputChannel +from rasa.core.channels.slack import SlackBot +from rasa.core.constants import COMPRESS_ACTION_SERVER_REQUEST_ENV_NAME +from rasa.core.nlg import NaturalLanguageGenerator +from rasa.shared.constants import ( + LATEST_TRAINING_DATA_FORMAT_VERSION, + UTTER_PREFIX, + REQUIRED_SLOTS_KEY, +) +from rasa.shared.core.domain import ( + ActionNotFoundException, + SessionConfig, + Domain, + KEY_E2E_ACTIONS, +) +from rasa.shared.core.events import ( + Restarted, + SlotSet, + UserUtteranceReverted, + BotUttered, + ActiveLoop, + SessionStarted, + ActionExecuted, + Event, + UserUttered, + EntitiesAdded, + DefinePrevUserUtteredFeaturization, + AllSlotsReset, + ReminderScheduled, + ReminderCancelled, + ActionReverted, + StoryExported, + FollowupAction, + ConversationPaused, + ConversationResumed, + AgentUttered, + LoopInterrupted, + ActionExecutionRejected, + LegacyFormValidation, + LegacyForm, +) +import rasa.shared.utils.common +from rasa.core.nlg.response import TemplatedNaturalLanguageGenerator +from rasa.shared.core.constants import ( + USER_INTENT_SESSION_START, + ACTION_LISTEN_NAME, + ACTION_RESTART_NAME, + ACTION_SESSION_START_NAME, + ACTION_DEFAULT_FALLBACK_NAME, + ACTION_DEACTIVATE_LOOP_NAME, + ACTION_REVERT_FALLBACK_EVENTS_NAME, + ACTION_DEFAULT_ASK_AFFIRMATION_NAME, + ACTION_DEFAULT_ASK_REPHRASE_NAME, + ACTION_BACK_NAME, + ACTION_TWO_STAGE_FALLBACK_NAME, + ACTION_UNLIKELY_INTENT_NAME, + RULE_SNIPPET_ACTION_NAME, + ACTIVE_LOOP, + FOLLOWUP_ACTION, + REQUESTED_SLOT, + SESSION_START_METADATA_SLOT, + ACTION_EXTRACT_SLOTS, +) +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.exceptions import RasaException +from rasa.utils.endpoints import ClientResponseError, EndpointConfig +from tests.utilities import json_of_latest_request, latest_request + + +@pytest.fixture(scope="module") +def template_nlg() -> TemplatedNaturalLanguageGenerator: + responses = { + "utter_ask_rephrase": [{"text": "can you rephrase that?"}], + "utter_restart": [{"text": "congrats, you've restarted me!"}], + "utter_back": [{"text": "backing up..."}], + "utter_invalid": [{"text": "a response referencing an invalid {variable}."}], + "utter_buttons": [ + { + "text": "button message", + "buttons": [ + {"payload": "button1", "title": "button1"}, + {"payload": "button2", "title": "button2"}, + ], + } + ], + } + return TemplatedNaturalLanguageGenerator(responses) + + +@pytest.fixture(scope="module") +def template_sender_tracker(domain_path: Text): + domain = Domain.load(domain_path) + return DialogueStateTracker("template-sender", domain.slots) + + +def test_domain_action_instantiation(): + domain = Domain( + intents=[{"chitchat": {"is_retrieval_intent": True}}], + entities=[], + slots=[], + responses={}, + action_names=["my_module.ActionTest", "utter_test", "utter_chitchat"], + forms={}, + data={}, + ) + + instantiated_actions = [ + action.action_for_name_or_text(action_name, domain, None) + for action_name in domain.action_names_or_texts + ] + + assert len(instantiated_actions) == 16 + assert instantiated_actions[0].name() == ACTION_LISTEN_NAME + assert instantiated_actions[1].name() == ACTION_RESTART_NAME + assert instantiated_actions[2].name() == ACTION_SESSION_START_NAME + assert instantiated_actions[3].name() == ACTION_DEFAULT_FALLBACK_NAME + assert instantiated_actions[4].name() == ACTION_DEACTIVATE_LOOP_NAME + assert instantiated_actions[5].name() == ACTION_REVERT_FALLBACK_EVENTS_NAME + assert instantiated_actions[6].name() == ACTION_DEFAULT_ASK_AFFIRMATION_NAME + assert instantiated_actions[7].name() == ACTION_DEFAULT_ASK_REPHRASE_NAME + assert instantiated_actions[8].name() == ACTION_TWO_STAGE_FALLBACK_NAME + assert instantiated_actions[9].name() == ACTION_UNLIKELY_INTENT_NAME + assert instantiated_actions[10].name() == ACTION_BACK_NAME + assert instantiated_actions[11].name() == RULE_SNIPPET_ACTION_NAME + assert instantiated_actions[12].name() == ACTION_EXTRACT_SLOTS + assert instantiated_actions[13].name() == "my_module.ActionTest" + assert instantiated_actions[14].name() == "utter_test" + assert instantiated_actions[15].name() == "utter_chitchat" + + +@pytest.mark.parametrize( + "is_compression_enabled, expected_compress_argument", + [ + ("True", True), + ("False", False), + ], +) +async def test_remote_actions_are_compressed( + is_compression_enabled: str, + expected_compress_argument: bool, + default_channel: OutputChannel, + default_nlg: NaturalLanguageGenerator, + default_tracker: DialogueStateTracker, + domain: Domain, + monkeypatch: MonkeyPatch, +): + endpoint = EndpointConfig("https://example.com/webhooks/actions") + remote_action = action.RemoteAction("my_action", endpoint) + monkeypatch.setenv(COMPRESS_ACTION_SERVER_REQUEST_ENV_NAME, is_compression_enabled) + + with aioresponses() as mocked: + mocked.post( + "https://example.com/webhooks/actions", + payload={"events": [], "responses": []}, + ) + + await remote_action.run(default_channel, default_nlg, default_tracker, domain) + + r = latest_request(mocked, "post", "https://example.com/webhooks/actions") + + assert r + assert r[-1].kwargs["compress"] is expected_compress_argument + + +async def test_remote_action_runs( + default_channel: OutputChannel, + default_nlg: NaturalLanguageGenerator, + default_tracker: DialogueStateTracker, + domain: Domain, +): + endpoint = EndpointConfig("https://example.com/webhooks/actions") + remote_action = action.RemoteAction("my_action", endpoint) + + with aioresponses() as mocked: + mocked.post( + "https://example.com/webhooks/actions", + payload={"events": [], "responses": []}, + ) + + await remote_action.run(default_channel, default_nlg, default_tracker, domain) + + r = latest_request(mocked, "post", "https://example.com/webhooks/actions") + + assert r + + assert json_of_latest_request(r) == { + "domain": domain.as_dict(), + "next_action": "my_action", + "sender_id": "my-sender", + "version": rasa.__version__, + "tracker": { + "latest_message": { + "entities": [], + "intent": {}, + "text": None, + "message_id": None, + "metadata": {}, + }, + ACTIVE_LOOP: {}, + "latest_action": {}, + "latest_action_name": None, + "sender_id": "my-sender", + "paused": False, + "latest_event_time": None, + FOLLOWUP_ACTION: "action_listen", + "slots": { + "name": None, + REQUESTED_SLOT: None, + SESSION_START_METADATA_SLOT: None, + }, + "events": [], + "latest_input_channel": None, + }, + } + + +async def test_remote_action_logs_events( + default_channel: OutputChannel, + default_nlg: NaturalLanguageGenerator, + default_tracker: DialogueStateTracker, + domain: Domain, +): + endpoint = EndpointConfig("https://example.com/webhooks/actions") + remote_action = action.RemoteAction("my_action", endpoint) + + response = { + "events": [{"event": "slot", "value": "rasa", "name": "name"}], + "responses": [ + { + "text": "test text", + "response": None, + "buttons": [{"title": "cheap", "payload": "cheap"}], + }, + {"response": "utter_greet"}, + ], + } + + with aioresponses() as mocked: + mocked.post("https://example.com/webhooks/actions", payload=response) + + events = await remote_action.run( + default_channel, default_nlg, default_tracker, domain + ) + + r = latest_request(mocked, "post", "https://example.com/webhooks/actions") + assert r + + assert json_of_latest_request(r) == { + "domain": domain.as_dict(), + "next_action": "my_action", + "sender_id": "my-sender", + "version": rasa.__version__, + "tracker": { + "latest_message": { + "entities": [], + "intent": {}, + "text": None, + "message_id": None, + "metadata": {}, + }, + ACTIVE_LOOP: {}, + "latest_action": {}, + "latest_action_name": None, + "sender_id": "my-sender", + "paused": False, + FOLLOWUP_ACTION: ACTION_LISTEN_NAME, + "latest_event_time": None, + "slots": { + "name": None, + REQUESTED_SLOT: None, + SESSION_START_METADATA_SLOT: None, + }, + "events": [], + "latest_input_channel": None, + }, + } + assert len(events) == 3 # first two events are bot utterances + assert events[0] == BotUttered( + "test text", {"buttons": [{"title": "cheap", "payload": "cheap"}]} + ) + assert events[1] == BotUttered( + "hey there None!", metadata={"utter_action": "utter_greet"} + ) + assert events[2] == SlotSet("name", "rasa") + + +async def test_remote_action_utterances_with_none_values( + default_channel: OutputChannel, + default_tracker: DialogueStateTracker, + domain: Domain, +): + endpoint = EndpointConfig("https://example.com/webhooks/actions") + remote_action = action.RemoteAction("my_action", endpoint) + + response = { + "events": [ + {"event": "form", "name": "restaurant_form", "timestamp": None}, + { + "event": "slot", + "timestamp": None, + "name": "requested_slot", + "value": "cuisine", + }, + ], + "responses": [ + { + "text": None, + "buttons": None, + "elements": [], + "custom": None, + "response": "utter_ask_cuisine", + "image": None, + "attachment": None, + } + ], + } + + nlg = TemplatedNaturalLanguageGenerator( + {"utter_ask_cuisine": [{"text": "what dou want to eat?"}]} + ) + with aioresponses() as mocked: + mocked.post("https://example.com/webhooks/actions", payload=response) + + events = await remote_action.run(default_channel, nlg, default_tracker, domain) + + assert events == [ + BotUttered( + "what dou want to eat?", metadata={"utter_action": "utter_ask_cuisine"} + ), + ActiveLoop("restaurant_form"), + SlotSet("requested_slot", "cuisine"), + ] + + +@pytest.mark.parametrize( + "event", + ( + EntitiesAdded( + entities=[ + {"entity": "city", "value": "London"}, + {"entity": "count", "value": 1}, + ], + timestamp=None, + ), + EntitiesAdded(entities=[]), + EntitiesAdded( + entities=[ + {"entity": "name", "value": "John", "role": "contact", "group": "test"} + ] + ), + DefinePrevUserUtteredFeaturization( + use_text_for_featurization=False, timestamp=None, metadata=None + ), + ReminderCancelled(timestamp=1621590172.3872123), + ReminderScheduled( + timestamp=None, trigger_date_time=datetime.now(), intent="greet" + ), + ActionExecutionRejected(action_name="my_action"), + LegacyFormValidation(validate=True, timestamp=None), + LoopInterrupted(timestamp=None, is_interrupted=False), + ActiveLoop(name="loop"), + LegacyForm(name="my_form"), + AllSlotsReset(), + SlotSet(key="my_slot", value={}), + SlotSet(key="my slot", value=[]), + SlotSet(key="test", value=1), + SlotSet(key="test", value="text"), + ConversationResumed(), + ConversationPaused(), + FollowupAction(name="test"), + StoryExported(), + Restarted(), + ActionReverted(), + UserUtteranceReverted(), + BotUttered(text="Test bot utterance"), + UserUttered( + parse_data={ + "entities": [], + "response_selector": { + "all_retrieval_intents": [], + "chitchat/ask_weather": {"response": {}, "ranking": []}, + }, + } + ), + UserUttered( + text="hello", + parse_data={ + "intent": {"name": "greet", "confidence": 0.9604260921478271}, + "entities": [ + {"entity": "city", "value": "London"}, + {"entity": "count", "value": 1}, + ], + "text": "hi", + "message_id": "3f4c04602a4947098c574b107d3ccc50", + "metadata": {}, + "intent_ranking": [ + {"name": "greet", "confidence": 0.9604260921478271}, + {"name": "goodbye", "confidence": 0.01835782080888748}, + {"name": "deny", "confidence": 0.011255578137934208}, + {"name": "bot_challenge", "confidence": 0.004019865766167641}, + {"name": "affirm", "confidence": 0.002524246694520116}, + {"name": "mood_great", "confidence": 0.002214624546468258}, + {"name": "chitchat", "confidence": 0.0009614597074687481}, + {"name": "mood_unhappy", "confidence": 0.00024030178610701114}, + ], + "response_selector": { + "all_retrieval_intents": [], + "default": { + "response": { + "id": -226546773594344189, + "responses": [{"text": "chitchat/ask_name"}], + "response_templates": [{"text": "chitchat/ask_name"}], + "confidence": 0.9618658423423767, + "intent_response_key": "chitchat/ask_name", + "utter_action": "utter_chitchat/ask_name", + "template_name": "utter_chitchat/ask_name", + }, + "ranking": [ + { + "id": -226546773594344189, + "confidence": 0.9618658423423767, + "intent_response_key": "chitchat/ask_name", + }, + { + "id": 8392727822750416828, + "confidence": 0.03813415765762329, + "intent_response_key": "chitchat/ask_weather", + }, + ], + }, + }, + }, + ), + SessionStarted(), + ActionExecuted(action_name="action_listen"), + AgentUttered(), + ), +) +async def test_remote_action_valid_payload_all_events( + default_channel: OutputChannel, + default_nlg: NaturalLanguageGenerator, + default_tracker: DialogueStateTracker, + domain: Domain, + event: Event, +): + endpoint = EndpointConfig("https://example.com/webhooks/actions") + remote_action = action.RemoteAction("my_action", endpoint) + events = [event.as_dict()] + response = {"events": events, "responses": []} + with aioresponses() as mocked: + mocked.post("https://example.com/webhooks/actions", payload=response) + + events = await remote_action.run( + default_channel, default_nlg, default_tracker, domain + ) + + assert len(events) == 1 + + +@pytest.mark.parametrize( + "event", + ( + { + "event": "user", + "timestamp": 1621590172.3872123, + "parse_data": {"entities": {}}, + }, + {"event": "entities", "timestamp": 1621604905.647361, "entities": {}}, + ), +) +async def test_remote_action_invalid_entities_payload( + default_channel: OutputChannel, + default_nlg: NaturalLanguageGenerator, + default_tracker: DialogueStateTracker, + domain: Domain, + event: Event, +): + endpoint = EndpointConfig("https://example.com/webhooks/actions") + remote_action = action.RemoteAction("my_action", endpoint) + response = {"events": [event], "responses": []} + with aioresponses() as mocked: + mocked.post("https://example.com/webhooks/actions", payload=response) + + with pytest.raises(ValidationError) as e: + await remote_action.run( + default_channel, default_nlg, default_tracker, domain + ) + + assert "Failed to validate Action server response from API" in str(e.value) + + +async def test_remote_action_multiple_events_payload( + default_channel: OutputChannel, + default_nlg: NaturalLanguageGenerator, + default_tracker: DialogueStateTracker, + domain: Domain, +): + endpoint = EndpointConfig("https://example.com/webhooks/actions") + remote_action = action.RemoteAction("my_action", endpoint) + response = { + "events": [ + { + "event": "action", + "name": "action_listen", + "policy": None, + "confidence": None, + "timestamp": None, + }, + {"event": "slot", "name": "name", "value": None, "timestamp": None}, + { + "event": "user", + "timestamp": None, + "text": "hello", + "parse_data": { + "intent": {"name": "greet", "confidence": 0.99}, + "entities": [], + }, + }, + ], + "responses": [], + } + + with aioresponses() as mocked: + mocked.post("https://example.com/webhooks/actions", payload=response) + + events = await remote_action.run( + default_channel, default_nlg, default_tracker, domain + ) + + assert isinstance(events[0], ActionExecuted) + assert events[0].as_dict().get("name") == "action_listen" + + assert isinstance(events[1], SlotSet) + assert events[1].as_dict().get("name") == "name" + + assert isinstance(events[2], UserUttered) + assert events[2].as_dict().get("text") == "hello" + + +async def test_remote_action_without_endpoint( + default_channel, default_nlg, default_tracker, domain: Domain +): + remote_action = action.RemoteAction("my_action", None) + + with pytest.raises(Exception) as execinfo: + await remote_action.run(default_channel, default_nlg, default_tracker, domain) + assert "Failed to execute custom action" in str(execinfo.value) + + +async def test_remote_action_endpoint_not_running( + default_channel, default_nlg, default_tracker, domain: Domain +): + endpoint = EndpointConfig("https://example.com/webhooks/actions") + remote_action = action.RemoteAction("my_action", endpoint) + + with pytest.raises(Exception) as execinfo: + await remote_action.run(default_channel, default_nlg, default_tracker, domain) + assert "Failed to execute custom action" in str(execinfo.value) + + +async def test_remote_action_endpoint_responds_500( + default_channel, default_nlg, default_tracker, domain: Domain +): + endpoint = EndpointConfig("https://example.com/webhooks/actions") + remote_action = action.RemoteAction("my_action", endpoint) + + with aioresponses() as mocked: + mocked.post("https://example.com/webhooks/actions", status=500) + + with pytest.raises(Exception) as execinfo: + await remote_action.run( + default_channel, default_nlg, default_tracker, domain + ) + assert "Failed to execute custom action" in str(execinfo.value) + + +async def test_remote_action_endpoint_responds_400( + default_channel, default_nlg, default_tracker, domain: Domain +): + endpoint = EndpointConfig("https://example.com/webhooks/actions") + remote_action = action.RemoteAction("my_action", endpoint) + + with aioresponses() as mocked: + # noinspection PyTypeChecker + mocked.post( + "https://example.com/webhooks/actions", + exception=ClientResponseError(400, None, '{"action_name": "my_action"}'), + ) + + with pytest.raises(Exception) as execinfo: + await remote_action.run( + default_channel, default_nlg, default_tracker, domain + ) + + assert execinfo.type == ActionExecutionRejection + assert "Custom action 'my_action' rejected to run" in str(execinfo.value) + + +async def test_action_utter_retrieved_response( + default_channel, default_nlg, default_tracker, domain: Domain +): + from rasa.core.channels.channel import UserMessage + + action_name = "utter_chitchat" + default_tracker.latest_message = UserMessage( + "Who are you?", + parse_data={ + "response_selector": { + "chitchat": { + "response": { + "intent_response_key": "chitchat/ask_name", + "responses": [{"text": "I am a bot."}], + "utter_action": "utter_chitchat/ask_name", + } + } + } + }, + ) + + domain.responses.update({"utter_chitchat/ask_name": [{"text": "I am a bot."}]}) + + events = await ActionRetrieveResponse(action_name).run( + default_channel, default_nlg, default_tracker, domain + ) + + assert events[0].as_dict().get("text") == BotUttered("I am a bot.").as_dict().get( + "text" + ) + assert ( + events[0].as_dict().get("metadata").get("utter_action") + == "utter_chitchat/ask_name" + ) + + +async def test_action_utter_default_retrieved_response( + default_channel, default_nlg, default_tracker, domain: Domain +): + from rasa.core.channels.channel import UserMessage + + action_name = "utter_chitchat" + default_tracker.latest_message = UserMessage( + "Who are you?", + parse_data={ + "response_selector": { + "default": { + "response": { + "intent_response_key": "chitchat/ask_name", + "responses": [{"text": "I am a bot."}], + "utter_action": "utter_chitchat/ask_name", + } + } + } + }, + ) + + domain.responses.update({"utter_chitchat/ask_name": [{"text": "I am a bot."}]}) + + events = await ActionRetrieveResponse(action_name).run( + default_channel, default_nlg, default_tracker, domain + ) + + assert events[0].as_dict().get("text") == BotUttered("I am a bot.").as_dict().get( + "text" + ) + + assert ( + events[0].as_dict().get("metadata").get("utter_action") + == "utter_chitchat/ask_name" + ) + + +async def test_action_utter_retrieved_empty_response( + default_channel, default_nlg, default_tracker, domain: Domain +): + from rasa.core.channels.channel import UserMessage + + action_name = "utter_chitchat" + default_tracker.latest_message = UserMessage( + "Who are you?", + parse_data={ + "response_selector": { + "dummy": { + "response": { + "intent_response_key": "chitchat/ask_name", + "responses": [{"text": "I am a bot."}], + "utter_action": "utter_chitchat/ask_name", + } + } + } + }, + ) + + domain.responses.update({"utter_chitchat/ask_name": [{"text": "I am a bot."}]}) + + events = await ActionRetrieveResponse(action_name).run( + default_channel, default_nlg, default_tracker, domain + ) + + assert events == [] + + +async def test_response(default_channel, default_nlg, default_tracker, domain: Domain): + events = await ActionBotResponse("utter_channel").run( + default_channel, default_nlg, default_tracker, domain + ) + + assert events == [ + BotUttered( + "this is a default channel", metadata={"utter_action": "utter_channel"} + ) + ] + + +async def test_response_unknown_response( + default_channel, default_nlg, default_tracker, domain: Domain +): + events = await ActionBotResponse("utter_unknown").run( + default_channel, default_nlg, default_tracker, domain + ) + + assert events == [] + + +async def test_response_with_buttons( + default_channel, template_nlg, template_sender_tracker, domain: Domain +): + events = await ActionBotResponse("utter_buttons").run( + default_channel, template_nlg, template_sender_tracker, domain + ) + + assert events == [ + BotUttered( + "button message", + { + "buttons": [ + {"payload": "button1", "title": "button1"}, + {"payload": "button2", "title": "button2"}, + ] + }, + metadata={"utter_action": "utter_buttons"}, + ) + ] + + +async def test_response_invalid_response( + default_channel, template_nlg, template_sender_tracker, domain: Domain +): + events = await ActionBotResponse("utter_invalid").run( + default_channel, template_nlg, template_sender_tracker, domain + ) + + assert len(events) == 1 + assert isinstance(events[0], BotUttered) + assert events[0].text.startswith("a response referencing an invalid {variable}.") + + +async def test_response_channel_specific(default_nlg, default_tracker, domain: Domain): + + output_channel = SlackBot("DummyToken", "General") + + events = await ActionBotResponse("utter_channel").run( + output_channel, default_nlg, default_tracker, domain + ) + + assert events == [ + BotUttered( + "you're talking to me on slack!", + metadata={"channel": "slack", "utter_action": "utter_channel"}, + ) + ] + + +@pytest.fixture +def domain_with_response_ids() -> Domain: + domain_yaml = """ + responses: + utter_one_id: + - text: test + id: '1' + utter_multiple_ids: + - text: test + id: '2' + - text: test + id: '3' + utter_no_id: + - text: test + """ + domain = Domain.from_yaml(domain_yaml) + return domain + + +async def test_response_with_response_id( + default_channel, domain_with_response_ids: Domain +) -> None: + nlg = TemplatedNaturalLanguageGenerator(domain_with_response_ids.responses) + + events = await ActionBotResponse("utter_one_id").run( + default_channel, + nlg, + DialogueStateTracker("response_id", slots=[]), + domain_with_response_ids, + ) + + assert events == [ + BotUttered( + "test", + metadata={"id": "1", "utter_action": "utter_one_id"}, + ) + ] + + +async def test_action_back( + default_channel, template_nlg, template_sender_tracker, domain: Domain +): + events = await ActionBack().run( + default_channel, template_nlg, template_sender_tracker, domain + ) + + assert events == [ + BotUttered("backing up...", metadata={"utter_action": "utter_back"}), + UserUtteranceReverted(), + UserUtteranceReverted(), + ] + + +async def test_action_restart( + default_channel, template_nlg, template_sender_tracker, domain: Domain +): + events = await ActionRestart().run( + default_channel, template_nlg, template_sender_tracker, domain + ) + + assert events == [ + BotUttered( + "congrats, you've restarted me!", metadata={"utter_action": "utter_restart"} + ), + Restarted(), + ] + + +async def test_action_session_start_without_slots( + default_channel: CollectingOutputChannel, + template_nlg: TemplatedNaturalLanguageGenerator, + template_sender_tracker: DialogueStateTracker, + domain: Domain, +): + events = await ActionSessionStart().run( + default_channel, template_nlg, template_sender_tracker, domain + ) + assert events == [SessionStarted(), ActionExecuted(ACTION_LISTEN_NAME)] + + +@pytest.mark.parametrize( + "session_config, expected_events", + [ + ( + SessionConfig(123, True), + [ + SessionStarted(), + SlotSet("my_slot", "value"), + SlotSet("another-slot", "value2"), + ActionExecuted(action_name=ACTION_LISTEN_NAME), + ], + ), + ( + SessionConfig(123, False), + [SessionStarted(), ActionExecuted(action_name=ACTION_LISTEN_NAME)], + ), + ], +) +async def test_action_session_start_with_slots( + default_channel: CollectingOutputChannel, + template_nlg: TemplatedNaturalLanguageGenerator, + template_sender_tracker: DialogueStateTracker, + domain: Domain, + session_config: SessionConfig, + expected_events: List[Event], +): + # set a few slots on tracker + slot_set_event_1 = SlotSet("my_slot", "value") + slot_set_event_2 = SlotSet("another-slot", "value2") + for event in [slot_set_event_1, slot_set_event_2]: + template_sender_tracker.update(event) + + domain.session_config = session_config + + events = await ActionSessionStart().run( + default_channel, template_nlg, template_sender_tracker, domain + ) + + assert events == expected_events + + # make sure that the list of events has ascending timestamps + assert sorted(events, key=lambda x: x.timestamp) == events + + +async def test_applied_events_after_action_session_start( + default_channel: CollectingOutputChannel, + template_nlg: TemplatedNaturalLanguageGenerator, +): + slot_set = SlotSet("my_slot", "value") + events = [ + slot_set, + ActionExecuted(ACTION_LISTEN_NAME), + # User triggers a restart manually by triggering the intent + UserUttered( + text=f"/{USER_INTENT_SESSION_START}", + intent={"name": USER_INTENT_SESSION_START}, + ), + ] + tracker = DialogueStateTracker.from_events("🕵️‍♀️", events) + + # Mapping Policy kicks in and runs the session restart action + events = await ActionSessionStart().run( + default_channel, template_nlg, tracker, Domain.empty() + ) + for event in events: + tracker.update(event) + + assert tracker.applied_events() == [slot_set, ActionExecuted(ACTION_LISTEN_NAME)] + + +async def test_action_default_fallback( + default_channel, default_nlg, default_tracker, domain: Domain +): + events = await ActionDefaultFallback().run( + default_channel, default_nlg, default_tracker, domain + ) + + assert events == [ + BotUttered( + "sorry, I didn't get that, can you rephrase it?", + metadata={"utter_action": "utter_default"}, + ), + UserUtteranceReverted(), + ] + + +async def test_action_default_ask_affirmation( + default_channel, default_nlg, domain: Domain +): + initial_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + # User triggers a restart manually by triggering the intent + UserUttered( + text="/foobar", + intent={"name": "foobar"}, + parse_data={ + "intent_ranking": [ + {"confidence": 0.9, "name": "foobar"}, + {"confidence": 0.1, "name": "baz"}, + ] + }, + ), + ] + tracker = DialogueStateTracker.from_events("🕵️‍♀️", initial_events) + + events = await ActionDefaultAskAffirmation().run( + default_channel, default_nlg, tracker, domain + ) + + assert events == [ + BotUttered( + "Did you mean 'foobar'?", + { + "buttons": [ + {"title": "Yes", "payload": "/foobar"}, + {"title": "No", "payload": "/out_of_scope"}, + ] + }, + {"utter_action": "action_default_ask_affirmation"}, + ) + ] + + +async def test_action_default_ask_affirmation_on_empty_conversation( + default_channel, default_nlg, default_tracker, domain: Domain +): + events = await ActionDefaultAskAffirmation().run( + default_channel, default_nlg, default_tracker, domain + ) + + assert events == [ + BotUttered( + "Did you mean 'None'?", + { + "buttons": [ + {"title": "Yes", "payload": "/None"}, + {"title": "No", "payload": "/out_of_scope"}, + ] + }, + {"utter_action": "action_default_ask_affirmation"}, + ) + ] + + +async def test_action_default_ask_rephrase( + default_channel, template_nlg, template_sender_tracker, domain: Domain +): + events = await ActionDefaultAskRephrase().run( + default_channel, template_nlg, template_sender_tracker, domain + ) + + assert events == [ + BotUttered( + "can you rephrase that?", metadata={"utter_action": "utter_ask_rephrase"} + ) + ] + + +@pytest.mark.parametrize( + "slot", + [ + """- my_slot + """, + "[]", + ], +) +def test_get_form_action(slot: Text): + form_action_name = "my_business_logic" + + domain = Domain.from_yaml( + textwrap.dedent( + f""" + slots: + my_slot: + type: text + mappings: + - type: from_text + actions: + - my_action + forms: + {form_action_name}: + {REQUIRED_SLOTS_KEY}: + {slot} + """ + ) + ) + + actual = action.action_for_name_or_text(form_action_name, domain, None) + assert isinstance(actual, FormAction) + + +def test_overridden_form_action(): + form_action_name = "my_business_logic" + domain = Domain.from_yaml( + textwrap.dedent( + f""" + actions: + - my_action + - {form_action_name} + forms: + {form_action_name}: + {REQUIRED_SLOTS_KEY}: [] + """ + ) + ) + + actual = action.action_for_name_or_text(form_action_name, domain, None) + assert isinstance(actual, RemoteAction) + + +def test_get_form_action_if_not_in_forms(): + form_action_name = "my_business_logic" + domain = Domain.from_yaml( + textwrap.dedent( + """ + actions: + - my_action + """ + ) + ) + + with pytest.raises(ActionNotFoundException): + assert not action.action_for_name_or_text(form_action_name, domain, None) + + +@pytest.mark.parametrize( + "end_to_end_utterance", ["Hi", f"{UTTER_PREFIX} is a dangerous start"] +) +def test_get_end_to_end_utterance_action(end_to_end_utterance: Text): + domain = Domain.from_yaml( + textwrap.dedent( + f""" + actions: + - my_action + {KEY_E2E_ACTIONS}: + - {end_to_end_utterance} + - Bye Bye +""" + ) + ) + + actual = action.action_for_name_or_text(end_to_end_utterance, domain, None) + + assert isinstance(actual, ActionEndToEndResponse) + assert actual.name() == end_to_end_utterance + + +async def test_run_end_to_end_utterance_action(): + end_to_end_utterance = "Hi" + + domain = Domain.from_yaml( + textwrap.dedent( + f""" + actions: + - my_action + {KEY_E2E_ACTIONS}: + - {end_to_end_utterance} + - Bye Bye +""" + ) + ) + + e2e_action = action.action_for_name_or_text("Hi", domain, None) + events = await e2e_action.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + DialogueStateTracker.from_events("sender", evts=[]), + domain, + ) + + assert events == [ + BotUttered( + end_to_end_utterance, + { + "elements": None, + "quick_replies": None, + "buttons": None, + "attachment": None, + "image": None, + "custom": None, + }, + {}, + ) + ] + + +@pytest.mark.parametrize( + "user, slot_name, slot_value, new_user, updated_value", + [ + ( + UserUttered( + intent={"name": "inform"}, + entities=[{"entity": "city", "value": "London"}], + ), + "location", + "London", + UserUttered( + intent={"name": "inform"}, + entities=[{"entity": "city", "value": "Berlin"}], + ), + "Berlin", + ), + ( + UserUttered(text="test@example.com", intent={"name": "inform"}), + "email", + "test@example.com", + UserUttered(text="updated_test@example.com", intent={"name": "inform"}), + "updated_test@example.com", + ), + ( + UserUttered(intent={"name": "affirm"}), + "cancel_booking", + True, + UserUttered(intent={"name": "deny"}), + False, + ), + ( + UserUttered(intent={"name": "deny"}), + "cancel_booking", + False, + UserUttered(intent={"name": "affirm"}), + True, + ), + ( + UserUttered( + intent={"name": "inform"}, + entities=[ + {"entity": "name", "value": "Bob"}, + {"entity": "name", "value": "Mary"}, + ], + ), + "guest_names", + ["Bob", "Mary"], + UserUttered( + intent={"name": "inform"}, + entities=[{"entity": "name", "value": "John"}], + ), + ["John"], + ), + ], +) +async def test_action_extract_slots_predefined_mappings( + user: Event, slot_name: Text, slot_value: Any, new_user: Event, updated_value: Any +): + domain = Domain.from_yaml( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - inform + - greet + - affirm + - deny + entities: + - city + - name + slots: + location: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: city + email: + type: text + influence_conversation: false + mappings: + - type: from_text + intent: inform + not_intent: greet + cancel_booking: + type: bool + influence_conversation: false + mappings: + - type: from_intent + intent: affirm + value: true + - type: from_intent + intent: deny + value: false + guest_names: + type: list + influence_conversation: false + mappings: + - type: from_entity + entity: name""" + ) + ) + + action_extract_slots = ActionExtractSlots(action_endpoint=None) + tracker = DialogueStateTracker.from_events("sender", evts=[user]) + + with pytest.warns(None): + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert events == [SlotSet(slot_name, slot_value)] + + events.extend([user]) + tracker.update_with_events(events, domain) + + new_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert new_events == [SlotSet(slot_name, slot_value)] + + new_events.extend([BotUttered(), ActionExecuted("action_listen"), new_user]) + tracker.update_with_events(new_events, domain) + + updated_evts = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert updated_evts == [SlotSet(slot_name, updated_value)] + + +async def test_action_extract_slots_with_from_trigger_mappings(): + domain = Domain.from_yaml( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + - inform + - register + slots: + email: + type: text + influence_conversation: false + mappings: + - type: from_text + intent: inform + not_intent: greet + existing_customer: + type: bool + influence_conversation: false + mappings: + - type: from_trigger_intent + intent: register + value: false + forms: + registration_form: + required_slots: + - email""" + ) + ) + + action_extract_slots = ActionExtractSlots(action_endpoint=None) + user_event = UserUttered(text="I'd like to register", intent={"name": "register"}) + tracker = DialogueStateTracker.from_events( + "sender", evts=[user_event, ActiveLoop("registration_form")] + ) + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert events == [SlotSet("existing_customer", False)] + + +@pytest.mark.parametrize( + "slot_mapping, expected_value", + [ + ( + {"type": "from_entity", "entity": "some_slot", "intent": "greet"}, + "some_value", + ), + ( + {"type": "from_intent", "intent": "greet", "value": "other_value"}, + "other_value", + ), + ({"type": "from_text"}, "bla"), + ({"type": "from_text", "intent": "greet"}, "bla"), + ({"type": "from_text", "not_intent": "other"}, "bla"), + ], +) +async def test_action_extract_slots_when_mapping_applies( + slot_mapping: Dict, expected_value: Text +): + form_name = "test_form" + entity_name = "some_slot" + + domain = Domain.from_dict( + { + "intents": ["greet"], + "entities": ["some_slot"], + "slots": {entity_name: {"type": "text", "mappings": [slot_mapping]}}, + "forms": {form_name: {REQUIRED_SLOTS_KEY: [entity_name]}}, + } + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "some_slot"), + UserUttered( + "bla", + intent={"name": "greet", "confidence": 1.0}, + entities=[{"entity": entity_name, "value": "some_value"}], + ), + ], + ) + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + # check that the value was extracted for correct intent + assert slot_events == [SlotSet("some_slot", expected_value)] + + +@pytest.mark.parametrize( + "entities, expected_slot_values", + [ + # Two entities were extracted for `ListSlot` + ( + [ + {"entity": "topping", "value": "mushrooms"}, + {"entity": "topping", "value": "kebab"}, + ], + ["mushrooms", "kebab"], + ), + # Only one entity was extracted for `ListSlot` + ([{"entity": "topping", "value": "kebab"}], ["kebab"]), + ], +) +async def test_action_extract_slots_with_list_slot( + entities: List[Dict[Text, Any]], expected_slot_values: List[Text] +): + form_name = "order_form" + slot_name = "toppings" + + domain = Domain.from_yaml( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + entities: + - topping + + slots: + {slot_name}: + type: list + influence_conversation: false + mappings: + - type: from_entity + entity: topping + + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {slot_name} + """ + ) + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, slot_name), + UserUttered( + "bla", intent={"name": "greet", "confidence": 1.0}, entities=entities + ), + ActionExecuted(ACTION_LISTEN_NAME), + ], + slots=domain.slots, + ) + + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert slot_events == [SlotSet(slot_name, expected_slot_values)] + + +@pytest.mark.parametrize( + "slot_mapping", + [ + {"type": "from_entity", "entity": "some_slot", "intent": "some_intent"}, + {"type": "from_intent", "intent": "some_intent", "value": "some_value"}, + {"type": "from_intent", "intent": "greeted", "value": "some_value"}, + {"type": "from_text", "intent": "other"}, + {"type": "from_text", "not_intent": "greet"}, + {"type": "from_trigger_intent", "intent": "some_intent", "value": "value"}, + ], +) +async def test_action_extract_slots_mapping_does_not_apply(slot_mapping: Dict): + form_name = "some_form" + entity_name = "some_slot" + + domain = Domain.from_dict( + { + "slots": {entity_name: {"type": "text", "mappings": [slot_mapping]}}, + "forms": {form_name: {REQUIRED_SLOTS_KEY: [entity_name]}}, + } + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + UserUttered( + "bla", + intent={"name": "greet", "confidence": 1.0}, + entities=[{"entity": entity_name, "value": "some_value"}], + ), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + # check that the value was not extracted for incorrect intent + assert slot_events == [] + + +async def test_action_extract_slots_with_matched_mapping_condition(): + form_name = "some_form" + + domain = Domain.from_yaml( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intent: + - greet + - inform + slots: + name: + type: text + influence_conversation: true + mappings: + - type: from_text + conditions: + - active_loop: some_form + requested_slot: name + - active_loop: other_form + forms: + {form_name}: + required_slots: + - name + other_form: + required_slots: + - name + """ + ) + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "name"), + UserUttered( + "Emily", intent={"name": "inform", "confidence": 1.0}, entities=[] + ), + ], + ) + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert slot_events == [SlotSet("name", "Emily")] + + +async def test_action_extract_slots_no_matched_mapping_conditions(): + form_name = "some_form" + + domain = Domain.from_yaml( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intent: + - greet + - inform + entities: + - email + - name + slots: + name: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: name + conditions: + - active_loop: some_form + requested_slot: email + email: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: email + forms: + {form_name}: + required_slots: + - email + - name + """ + ) + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "name"), + UserUttered( + "My name is Emily.", + intent={"name": "inform", "confidence": 1.0}, + entities=[{"entity": "name", "value": "Emily"}], + ), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert slot_events == [] + + +@pytest.mark.parametrize( + "mapping_not_intent, mapping_intent, mapping_role, " + "mapping_group, entities, intent, expected_slot_events", + [ + ( + "some_intent", + None, + None, + None, + [{"entity": "some_entity", "value": "some_value"}], + "some_intent", + [], + ), + ( + None, + "some_intent", + None, + None, + [{"entity": "some_entity", "value": "some_value"}], + "some_intent", + [SlotSet("some_slot", "some_value")], + ), + ( + "some_intent", + None, + None, + None, + [{"entity": "some_entity", "value": "some_value"}], + "some_other_intent", + [SlotSet("some_slot", "some_value")], + ), + ( + None, + None, + "some_role", + None, + [{"entity": "some_entity", "value": "some_value"}], + "some_intent", + [], + ), + ( + None, + None, + "some_role", + None, + [{"entity": "some_entity", "value": "some_value", "role": "some_role"}], + "some_intent", + [SlotSet("some_slot", "some_value")], + ), + ( + None, + None, + None, + "some_group", + [{"entity": "some_entity", "value": "some_value"}], + "some_intent", + [], + ), + ( + None, + None, + None, + "some_group", + [{"entity": "some_entity", "value": "some_value", "group": "some_group"}], + "some_intent", + [SlotSet("some_slot", "some_value")], + ), + ( + None, + None, + "some_role", + "some_group", + [ + { + "entity": "some_entity", + "value": "some_value", + "group": "some_group", + "role": "some_role", + } + ], + "some_intent", + [SlotSet("some_slot", "some_value")], + ), + ( + None, + None, + "some_role", + "some_group", + [{"entity": "some_entity", "value": "some_value", "role": "some_role"}], + "some_intent", + [], + ), + ( + None, + None, + None, + None, + [ + { + "entity": "some_entity", + "value": "some_value", + "group": "some_group", + "role": "some_role", + } + ], + "some_intent", + # nothing should be extracted, because entity contain role and group + # but mapping expects them to be None + [], + ), + ], +) +async def test_action_extract_slots_from_entity( + mapping_not_intent: Optional[Text], + mapping_intent: Optional[Text], + mapping_role: Optional[Text], + mapping_group: Optional[Text], + entities: List[Dict[Text, Any]], + intent: Text, + expected_slot_events: List[SlotSet], +): + """Test extraction of a slot value from entity with the different restrictions.""" + form_name = "some form" + form = FormAction(form_name, None) + + mapping = form.from_entity( + entity="some_entity", + role=mapping_role, + group=mapping_group, + intent=mapping_intent, + not_intent=mapping_not_intent, + ) + domain = Domain.from_dict( + { + "entities": ["some_entity"], + "slots": {"some_slot": {"type": "any", "mappings": [mapping]}}, + "forms": {form_name: {REQUIRED_SLOTS_KEY: ["some_slot"]}}, + } + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "some_slot"), + UserUttered( + "bla", intent={"name": intent, "confidence": 1.0}, entities=entities + ), + ], + ) + + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert slot_events == expected_slot_events + + +@pytest.mark.parametrize( + "entities, expected_slot_values", + [ + # Two entities were extracted for `ListSlot` + ( + [ + {"entity": "topping", "value": "mushrooms"}, + {"entity": "topping", "value": "kebab"}, + ], + ["mushrooms", "kebab"], + ), + # Only one entity was extracted for `ListSlot` + ([{"entity": "topping", "value": "kebab"}], ["kebab"]), + ], +) +async def test_extract_other_list_slot_from_entity( + entities: List[Dict[Text, Any]], expected_slot_values: List[Text] +): + form_name = "some_form" + slot_name = "toppings" + domain = Domain.from_yaml( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + entities: + - topping + - some_slot + + intents: + - some_intent + - greeted + + slots: + {slot_name}: + type: list + influence_conversation: false + mappings: + - type: from_entity + entity: topping + + forms: + {form_name}: + {REQUIRED_SLOTS_KEY}: + - {slot_name} + """ + ) + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + SlotSet(REQUESTED_SLOT, "some slot"), + UserUttered( + "bla", intent={"name": "greet", "confidence": 1.0}, entities=entities + ), + ActionExecuted(ACTION_LISTEN_NAME), + ], + slots=domain.slots, + ) + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert slot_events == [SlotSet(slot_name, expected_slot_values)] + + +@pytest.mark.parametrize( + "trigger_slot_mapping, expected_value", + [ + ({"type": "from_trigger_intent", "intent": "greet", "value": "ten"}, "ten"), + ( + { + "type": "from_trigger_intent", + "intent": ["bye", "greet"], + "value": "tada", + }, + "tada", + ), + ], +) +async def test_trigger_slot_mapping_applies( + trigger_slot_mapping: Dict, expected_value: Text +): + form_name = "some_form" + entity_name = "some_slot" + slot_filled_by_trigger_mapping = "other_slot" + + domain = Domain.from_dict( + { + "slots": { + entity_name: { + "type": "text", + "mappings": [ + { + "type": "from_entity", + "entity": entity_name, + "intent": "some_intent", + } + ], + }, + slot_filled_by_trigger_mapping: { + "type": "text", + "mappings": [trigger_slot_mapping], + }, + }, + "forms": {form_name: {REQUIRED_SLOTS_KEY: [entity_name]}}, + } + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + ActiveLoop(form_name), + SlotSet(REQUESTED_SLOT, "some_slot"), + UserUttered( + "bla", + intent={"name": "greet", "confidence": 1.0}, + entities=[{"entity": entity_name, "value": "some_value"}], + ), + ], + ) + + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert slot_events == [SlotSet(slot_filled_by_trigger_mapping, expected_value)] + + +@pytest.mark.parametrize( + "trigger_slot_mapping", + [ + ({"type": "from_trigger_intent", "intent": "bye", "value": "ten"}), + ({"type": "from_trigger_intent", "not_intent": ["greet"], "value": "tada"}), + ], +) +async def test_trigger_slot_mapping_does_not_apply(trigger_slot_mapping: Dict): + form_name = "some_form" + entity_name = "some_slot" + slot_filled_by_trigger_mapping = "other_slot" + + domain = Domain.from_dict( + { + "slots": { + entity_name: { + "type": "text", + "mappings": [ + { + "type": "from_entity", + "entity": entity_name, + "intent": "some_intent", + } + ], + }, + slot_filled_by_trigger_mapping: { + "type": "text", + "mappings": [trigger_slot_mapping], + }, + }, + "forms": { + form_name: { + REQUIRED_SLOTS_KEY: [entity_name, slot_filled_by_trigger_mapping] + } + }, + } + ) + + tracker = DialogueStateTracker.from_events( + "default", + [ + SlotSet(REQUESTED_SLOT, "some_slot"), + UserUttered( + "bla", + intent={"name": "greet", "confidence": 1.0}, + entities=[{"entity": entity_name, "value": "some_value"}], + ), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert slot_events == [] + + +@pytest.mark.parametrize( + "event, validate_return_events, expected_events", + [ + ( + UserUttered( + intent={"name": "inform"}, + entities=[{"entity": "city", "value": "london"}], + ), + [{"event": "slot", "name": "from_entity_slot", "value": "London"}], + [SlotSet("from_entity_slot", "London")], + ), + ( + UserUttered("hi", intent={"name": "greet"}), + [{"event": "slot", "name": "from_text_slot", "value": "Hi"}], + [SlotSet("from_text_slot", "Hi")], + ), + ( + UserUttered(intent={"name": "affirm"}), + [{"event": "slot", "name": "from_intent_slot", "value": True}], + [SlotSet("from_intent_slot", True)], + ), + ( + UserUttered(intent={"name": "chitchat"}), + [{"event": "slot", "name": "custom_slot", "value": True}], + [SlotSet("custom_slot", True)], + ), + (UserUttered("bla"), [], []), + ], +) +async def test_action_extract_slots_execute_validation_action( + event: Event, + validate_return_events: List[Dict[Text, Any]], + expected_events: List[Event], +): + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + intents: + - greet + - inform + - affirm + - chitchat + + entities: + - city + + slots: + from_entity_slot: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: city + from_text_slot: + type: text + influence_conversation: false + mappings: + - type: from_text + intent: greet + from_intent_slot: + type: text + influence_conversation: false + mappings: + - type: from_intent + intent: affirm + value: True + custom_slot: + type: text + influence_conversation: false + mappings: + - type: custom + + actions: + - action_validate_slot_mappings + """ + ) + domain = Domain.from_yaml(domain_yaml) + tracker = DialogueStateTracker.from_events(sender_id="test_id", evts=[event]) + + action_server_url = "http:/my-action-server:5055/webhook" + + with aioresponses() as mocked: + mocked.post(action_server_url, payload={"events": validate_return_events}) + + action_server = EndpointConfig(action_server_url) + action_extract_slots = ActionExtractSlots(action_server) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert slot_events == expected_events + + +async def test_action_extract_slots_custom_action_and_predefined_slot_validation(): + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + intents: + - inform + + entities: + - city + + slots: + location: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: city + custom_slot: + type: text + influence_conversation: false + mappings: + - type: custom + action: action_test + + actions: + - action_validate_slot_mappings + - action_test + """ + ) + domain = Domain.from_yaml(domain_yaml) + event = UserUttered( + intent={"name": "inform"}, entities=[{"entity": "city", "value": "london"}] + ) + tracker = DialogueStateTracker.from_events(sender_id="test_id", evts=[event]) + + action_server_url = "http:/my-action-server:5055/webhook" + + with aioresponses() as mocked: + mocked.post( + action_server_url, + payload={ + "events": [{"event": "slot", "name": "custom_slot", "value": "test"}] + }, + ) + mocked.post( + action_server_url, + payload={ + "events": [{"event": "slot", "name": "location", "value": "London"}] + }, + ) + + action_server = EndpointConfig(action_server_url) + action_extract_slots = ActionExtractSlots(action_server) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert slot_events == [ + SlotSet("location", "London"), + SlotSet("custom_slot", "test"), + ] + + +async def test_action_extract_slots_with_duplicate_custom_actions(): + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + intents: + - inform + + entities: + - city + + slots: + custom_slot_one: + type: float + influence_conversation: false + mappings: + - type: custom + action: action_test + custom_slot_two: + type: float + influence_conversation: false + mappings: + - type: custom + action: action_test + + actions: + - action_test + """ + ) + domain = Domain.from_yaml(domain_yaml) + event = UserUttered("Hi") + tracker = DialogueStateTracker.from_events(sender_id="test_id", evts=[event]) + + action_server_url = "http:/my-action-server:5055/webhook" + + with aioresponses() as mocked: + mocked.post( + action_server_url, + payload={ + "events": [ + {"event": "slot", "name": "custom_slot_one", "value": 1}, + {"event": "slot", "name": "custom_slot_two", "value": 2}, + ] + }, + ) + mocked.post( + action_server_url, + payload={ + "events": [ + {"event": "slot", "name": "custom_slot_one", "value": 1}, + {"event": "slot", "name": "custom_slot_two", "value": 2}, + ] + }, + ) + + action_server = EndpointConfig(action_server_url) + action_extract_slots = ActionExtractSlots(action_server) + + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert len(mocked.requests) == 1 + + assert len(slot_events) == 2 + assert SlotSet("custom_slot_two", 2) in slot_events + assert SlotSet("custom_slot_one", 1) in slot_events + + +async def test_action_extract_slots_disallowed_events(caplog: LogCaptureFixture): + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + slots: + custom_slot_one: + type: float + influence_conversation: false + mappings: + - type: custom + action: action_test + + actions: + - action_test + """ + ) + domain = Domain.from_yaml(domain_yaml) + event = UserUttered("Hi") + tracker = DialogueStateTracker.from_events(sender_id="test_id", evts=[event]) + + action_server_url = "http:/my-action-server:5055/webhook" + + with aioresponses() as mocked: + mocked.post( + action_server_url, + payload={ + "events": [ + {"event": "slot", "name": "custom_slot_one", "value": 1}, + {"event": "reset_slots"}, + ] + }, + ) + + action_server = EndpointConfig(action_server_url) + action_extract_slots = ActionExtractSlots(action_server) + + with caplog.at_level(logging.INFO): + slot_events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + caplog_info_records = list( + filter(lambda x: x[1] == logging.INFO, caplog.record_tuples) + ) + + assert all( + [ + "Running custom action 'action_test' has resulted " + "in an event of type 'reset_slots'." in record[2] + for record in caplog_info_records + ] + ) + assert slot_events == [SlotSet("custom_slot_one", 1)] + + +@pytest.mark.parametrize( + "exception", + [ + RasaException("Test"), + ClientResponseError(400, "Test", '{"action_name": "action_test"}'), + ], +) +async def test_action_extract_slots_warns_custom_action_exceptions( + caplog: LogCaptureFixture, exception: Exception +): + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + slots: + custom_slot_one: + type: float + influence_conversation: false + mappings: + - type: custom + action: action_test + + actions: + - action_test + """ + ) + domain = Domain.from_yaml(domain_yaml) + event = UserUttered("Hi") + tracker = DialogueStateTracker.from_events(sender_id="test_id", evts=[event]) + + action_server_url = "http:/my-action-server:5055/webhook" + + with aioresponses() as mocked: + mocked.post(action_server_url, exception=exception) + + action_server = EndpointConfig(action_server_url) + action_extract_slots = ActionExtractSlots(action_server) + + with caplog.at_level(logging.WARNING): + await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + assert any( + [ + "The default action 'action_extract_slots' failed to fill " + "slots with custom mappings." in message + for message in caplog.messages + ] + ) + + +async def test_action_extract_slots_with_empty_conditions(): + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + entities: + - city + + slots: + location: + type: float + influence_conversation: false + mappings: + - type: from_entity + entity: city + conditions: [] + """ + ) + domain = Domain.from_yaml(domain_yaml) + event = UserUttered("Hi", entities=[{"entity": "city", "value": "Berlin"}]) + tracker = DialogueStateTracker.from_events(sender_id="test_id", evts=[event]) + + action_extract_slots = ActionExtractSlots(None) + + with pytest.warns(None): + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == [SlotSet("location", "Berlin")] + + +async def test_action_extract_slots_with_not_existing_entity(): + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + entities: + - city + + slots: + location: + type: float + influence_conversation: false + mappings: + - type: from_entity + entity: city2 + """ + ) + domain = Domain.from_yaml(domain_yaml) + event = UserUttered("Hi", entities=[{"entity": "city", "value": "Berlin"}]) + tracker = DialogueStateTracker.from_events(sender_id="test_id", evts=[event]) + + action_extract_slots = ActionExtractSlots(None) + + with pytest.warns( + None, + match="Slot 'location' uses a `from_entity` mapping for " + "a non-existent entity 'city2'. " + "Skipping slot extraction because of invalid mapping.", + ): + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == [] + + +async def test_action_extract_slots_with_not_existing_intent(): + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + intents: + - greet + + slots: + location: + type: text + influence_conversation: false + mappings: + - type: from_intent + intent: affirm + value: some_value + """ + ) + domain = Domain.from_yaml(domain_yaml) + event = UserUttered("Hi", entities=[{"entity": "city", "value": "Berlin"}]) + tracker = DialogueStateTracker.from_events(sender_id="test_id", evts=[event]) + + action_extract_slots = ActionExtractSlots(None) + + with pytest.warns( + UserWarning, + match=r"Slot 'location' uses a 'from_intent' mapping for " + r"a non-existent intent 'affirm'. " + r"Skipping slot extraction because of invalid mapping.", + ): + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == [] + + +async def test_action_extract_slots_with_none_value_predefined_mapping(): + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + entities: + - some_entity + + slots: + some_slot: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: some_entity + + custom_slot: + type: text + influence_conversation: false + mappings: + - type: custom + + actions: + - action_validate_slot_mappings + """ + ) + domain = Domain.from_yaml(domain_yaml) + event = UserUttered("Hi", entities=[{"entity": "some_entity", "value": None}]) + tracker = DialogueStateTracker.from_events(sender_id="test_id", evts=[event]) + + action_extract_slots = ActionExtractSlots(None) + + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == [] + + +async def test_action_extract_slots_with_none_value_custom_mapping(): + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + slots: + custom_slot: + type: text + influence_conversation: false + mappings: + - type: custom + + actions: + - action_validate_slot_mappings + """ + ) + domain = Domain.from_yaml(domain_yaml) + event = UserUttered("Hi") + tracker = DialogueStateTracker.from_events( + sender_id="test_id", evts=[event], slots=domain.slots + ) + + action_server_url = "http:/my-action-server:5055/webhook" + + with aioresponses() as mocked: + mocked.post( + action_server_url, + payload={ + "events": [{"event": "slot", "name": "custom_slot", "value": None}] + }, + ) + + action_server = EndpointConfig(action_server_url) + action_extract_slots = ActionExtractSlots(action_server) + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == [SlotSet("custom_slot", None)] + + +async def test_action_extract_slots_returns_bot_uttered(): + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + slots: + custom_slot: + type: text + influence_conversation: false + mappings: + - type: custom + + actions: + - action_validate_slot_mappings + """ + ) + domain = Domain.from_yaml(domain_yaml) + event = UserUttered("Hi") + tracker = DialogueStateTracker.from_events( + sender_id="test_id", evts=[event], slots=domain.slots + ) + + action_server_url = "http:/my-action-server:5055/webhook" + + with aioresponses() as mocked: + mocked.post( + action_server_url, + payload={ + "events": [ + {"event": "slot", "name": "custom_slot", "value": "test"}, + {"event": "bot", "text": "Information recorded."}, + ] + }, + ) + + action_server = EndpointConfig(action_server_url) + action_extract_slots = ActionExtractSlots(action_server) + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert all([isinstance(event, (SlotSet, BotUttered)) for event in events]) + + +async def test_action_extract_slots_does_not_raise_disallowed_warning_for_slot_events( + caplog: LogCaptureFixture, +): + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + slots: + custom_slot_a: + type: text + influence_conversation: false + mappings: + - type: custom + action: custom_extract_action + custom_slot_b: + type: text + influence_conversation: false + mappings: + - type: custom + + actions: + - custom_extract_action + - action_validate_slot_mappings + """ + ) + domain = Domain.from_yaml(domain_yaml) + event = UserUttered("Hi") + tracker = DialogueStateTracker.from_events( + sender_id="test_id", evts=[event], slots=domain.slots + ) + + action_server_url = "http:/my-action-server:5055/webhook" + + with aioresponses() as mocked: + mocked.post( + action_server_url, + payload={ + "events": [ + {"event": "slot", "name": "custom_slot_a", "value": "test_A"} + ] + }, + ) + + mocked.post( + action_server_url, + payload={ + "events": [ + {"event": "slot", "name": "custom_slot_b", "value": "test_B"} + ] + }, + ) + + action_server = EndpointConfig(action_server_url) + action_extract_slots = ActionExtractSlots(action_server) + + with caplog.at_level(logging.INFO): + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + caplog_info_records = list( + filter(lambda x: x[1] == logging.INFO, caplog.record_tuples) + ) + assert len(caplog_info_records) == 0 + + assert events == [ + SlotSet("custom_slot_b", "test_B"), + SlotSet("custom_slot_a", "test_A"), + ] + + +async def test_action_extract_slots_non_required_form_slot_with_from_entity_mapping(): + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + intents: + - form_start + - intent1 + - intent2 + + entities: + - form1_info1 + - form1_slot1 + - form1_slot2 + + slots: + form1_info1: + type: text + mappings: + - type: from_entity + entity: form1_info1 + + form1_slot1: + type: text + influence_conversation: false + mappings: + - type: from_intent + value: Filled + intent: intent1 + conditions: + - active_loop: form1 + requested_slot: form1_slot1 + + form1_slot2: + type: text + influence_conversation: false + mappings: + - type: from_intent + value: Filled + intent: intent2 + conditions: + - active_loop: form1 + requested_slot: form1_slot2 + forms: + form1: + required_slots: + - form1_slot1 + - form1_slot2 + """ + ) + domain = Domain.from_yaml(domain_yaml) + initial_events = [ + UserUttered("Start form."), + ActiveLoop("form1"), + SlotSet(REQUESTED_SLOT, "form1_slot1"), + UserUttered( + "Hi", + intent={"name": "intent1"}, + entities=[{"entity": "form1_info1", "value": "info1"}], + ), + ] + tracker = DialogueStateTracker.from_events(sender_id="test_id", evts=initial_events) + + action_extract_slots = ActionExtractSlots(None) + + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == [SlotSet("form1_info1", "info1"), SlotSet("form1_slot1", "Filled")] + + +@pytest.mark.parametrize( + "starting_value, value_to_set, expect_event", + [ + ("value", None, True), + (None, "value", True), + ("value", "value", True), + (None, None, False), + ], +) +async def test_action_extract_slots_emits_necessary_slot_set_events( + starting_value: Text, value_to_set: Text, expect_event: bool +): + entity_name = "entity" + intent_name = "intent_with_entity" + + domain = textwrap.dedent( + f""" + intents: + - {intent_name} + entities: + - {entity_name} + slots: + {entity_name}: + type: text + mappings: + - type: from_entity + entity: {entity_name} + """ + ) + + domain = Domain.from_yaml(domain) + + tracker = DialogueStateTracker.from_events( + "some-sender", + evts=[ + SlotSet(entity_name, starting_value), + ], + ) + + tracker.update_with_events( + new_events=[ + UserUttered( + text="I am a text", + intent={"name": intent_name}, + entities=[{"entity": entity_name, "value": value_to_set}], + ) + ], + domain=domain, + ) + + action = ActionExtractSlots(None) + + events = await action.run( + output_channel=CollectingOutputChannel(), + nlg=Mock(), + tracker=tracker, + domain=domain, + ) + + if expect_event: + assert len(events) == 1 + assert type(events[0]) == SlotSet + assert events[0].key == entity_name + assert events[0].value == value_to_set + else: + assert len(events) == 0 + + +async def test_action_extract_slots_priority_of_slot_mappings(): + slot_name = "location_slot" + entity_name = "location" + entity_value = "Berlin" + + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + intents: + - inform + + entities: + - {entity_name} + + slots: + {slot_name}: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: {entity_name} + - type: from_intent + value: 42 + intent: inform + + responses: + utter_ask_location: + - text: "where are you located?" + """ + ) + domain = Domain.from_yaml(domain_yaml) + initial_events = [ + UserUttered( + "I am located in Berlin", + intent={"name": "inform"}, + entities=[{"entity": entity_name, "value": entity_value}], + ), + ] + tracker = DialogueStateTracker.from_events(sender_id="test_id", evts=initial_events) + + action_extract_slots = ActionExtractSlots(None) + + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + tracker.update_with_events(events, domain=domain) + assert tracker.get_slot("location_slot") == entity_value + + +async def test_action_extract_slots_allows_slotset_for_same_value( + caplog: LogCaptureFixture, +): + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + slots: + custom_slot_a: + type: text + influence_conversation: false + mappings: + - type: custom + action: custom_extract_action + + actions: + - custom_extract_action + - action_validate_slot_mappings + """ + ) + domain = Domain.from_yaml(domain_yaml) + event = UserUttered("Hi") + tracker = DialogueStateTracker.from_events( + sender_id="test_id", evts=[event], slots=domain.slots + ) + + # Set the value of the slot in the tracker manually + tracker.update(SlotSet("custom_slot_a", "test_A")) + action_server_url = "https://my-action-server:5055/webhook" + + with aioresponses() as mocked: + mocked.post( + action_server_url, + payload={ + "events": [ + {"event": "slot", "name": "custom_slot_a", "value": "test_A"} + ] + }, + ) + + action_server = EndpointConfig(action_server_url) + action_extract_slots = ActionExtractSlots(action_server) + + with caplog.at_level(logging.INFO): + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + + caplog_info_records = list( + filter(lambda x: x[1] == logging.INFO, caplog.record_tuples) + ) + assert len(caplog_info_records) == 0 + assert events == [SlotSet("custom_slot_a", "test_A")] + + +async def test_action_extract_slots_active_loop_none_in_mapping_condition(): + entity = "name" + entity_value = "Julia" + slot = "user_name" + + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + intents: + - greet + + entities: + - {entity} + + slots: + {slot}: + type: text + mappings: + - type: from_entity + entity: {entity} + conditions: + - active_loop: null + """ + ) + domain = Domain.from_yaml(domain_yaml) + initial_events = [ + UserUttered( + "Hi, I'm Julia.", + intent={"name": "greet"}, + entities=[{"entity": entity, "value": entity_value}], + ), + ] + tracker = DialogueStateTracker.from_events(sender_id="test_id", evts=initial_events) + + action_extract_slots = ActionExtractSlots(None) + + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == [SlotSet(slot, entity_value)] + + +async def test_action_extract_slots_active_loop_none_does_not_set_slot_in_form(): + entity = "name" + entity_value = "Julia" + slot = "user_name" + + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + intents: + - greet + + entities: + - {entity} + + slots: + {slot}: + type: text + mappings: + - type: from_entity + entity: {entity} + conditions: + - active_loop: null + + forms: + my_form: + required_slots: [] + """ + ) + domain = Domain.from_yaml(domain_yaml) + initial_events = [ + ActiveLoop("my_form"), + UserUttered( + "Hi, I'm Julia.", + intent={"name": "greet"}, + entities=[{"entity": entity, "value": entity_value}], + ), + ] + tracker = DialogueStateTracker.from_events(sender_id="test_id", evts=initial_events) + + action_extract_slots = ActionExtractSlots(None) + + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + assert events == [] diff --git a/tests/core/test_agent.py b/tests/core/test_agent.py new file mode 100644 index 0000000..21b42d6 --- /dev/null +++ b/tests/core/test_agent.py @@ -0,0 +1,382 @@ +import asyncio +from http import HTTPStatus +import json +from pathlib import Path +from typing import Any, Dict, Text, Callable, Optional +from unittest.mock import patch +import uuid + +from aioresponses import aioresponses +import pytest +from _pytest.monkeypatch import MonkeyPatch +from pytest_sanic.utils import TestClient +from sanic import Sanic, response +from sanic.request import Request +from sanic.response import ResponseStream + +import rasa.core +from rasa.core.exceptions import AgentNotReady +from rasa.core.utils import AvailableEndpoints +from rasa.exceptions import ModelNotFound +from rasa.nlu.persistor import Persistor +from rasa.shared.core.events import ( + ActionExecuted, + BotUttered, + DefinePrevUserUtteredFeaturization, + SessionStarted, + UserUttered, +) +from rasa.shared.nlu.constants import INTENT_NAME_KEY +import rasa.shared.utils.common +import rasa.utils.io +from rasa.core import jobs +from rasa.core.agent import Agent, load_agent +from rasa.core.channels.channel import UserMessage +from rasa.shared.core.domain import Domain +from rasa.shared.constants import INTENT_MESSAGE_PREFIX +from rasa.utils.endpoints import EndpointConfig +from tests.conftest import with_assistant_ids, with_model_ids + + +def model_server_app(model_path: Text, model_hash: Text = "somehash") -> Sanic: + app = Sanic("test_agent") + app.ctx.number_of_model_requests = 0 + + @app.route("/model", methods=["GET"]) + async def model(request: Request) -> ResponseStream: + """Simple HTTP model server responding with a trained model.""" + + if model_hash == request.headers.get("If-None-Match"): + return response.text("", 204) + + app.ctx.number_of_model_requests += 1 + + return await response.file_stream( + location=model_path, + headers={"ETag": model_hash, "filename": Path(model_path).name}, + mime_type="application/gzip", + ) + + return app + + +@pytest.fixture() +def model_server( + loop: asyncio.AbstractEventLoop, sanic_client: Callable, trained_rasa_model: Text +) -> TestClient: + app = model_server_app(trained_rasa_model, model_hash="somehash") + return loop.run_until_complete(sanic_client(app)) + + +async def test_agent_train(default_agent: Agent): + domain = Domain.load("data/test_domains/default_with_slots.yml") + + assert default_agent.domain.action_names_or_texts == domain.action_names_or_texts + assert default_agent.domain.intents == domain.intents + assert default_agent.domain.entities == domain.entities + assert default_agent.domain.responses == domain.responses + assert [s.name for s in default_agent.domain.slots] == [ + s.name for s in domain.slots + ] + + assert default_agent.processor + assert default_agent.processor.graph_runner + + +@pytest.mark.parametrize( + "text_message_data, expected", + [ + ( + '/greet{"name":"Rasa"}', + { + "text": '/greet{"name":"Rasa"}', + "intent": {"name": "greet", "confidence": 1.0}, + "intent_ranking": [{"name": "greet", "confidence": 1.0}], + "entities": [ + { + "entity": "name", + "start": 6, + "end": 21, + "value": "Rasa", + "extractor": "RegexMessageHandler", + } + ], + }, + ) + ], +) +async def test_agent_parse_message( + default_agent: Agent, text_message_data: Text, expected: Dict[Text, Any] +): + result = await default_agent.parse_message(text_message_data) + assert result == expected + + +async def test_agent_handle_text(default_agent: Agent): + text = INTENT_MESSAGE_PREFIX + 'greet{"name":"Rasa"}' + result = await default_agent.handle_text(text, sender_id="test_agent_handle_text") + assert result == [ + {"recipient_id": "test_agent_handle_text", "text": "hey there Rasa!"} + ] + + +async def test_default_agent_handle_message(default_agent: Agent): + text = INTENT_MESSAGE_PREFIX + 'greet{"name":"Rasa"}' + message = UserMessage(text, sender_id="test_agent_handle_message") + result = await default_agent.handle_message(message) + assert result == [ + {"recipient_id": "test_agent_handle_message", "text": "hey there Rasa!"} + ] + + +async def test_agent_wrong_use_of_load(): + training_data_file = "data/test_moodbot/data/stories.yml" + + with pytest.raises(ModelNotFound): + # try to load a model file from a data path, which is nonsense and + # should fail properly + Agent.load(training_data_file) + + +async def test_agent_with_model_server_in_thread( + model_server: TestClient, domain: Domain +): + model_endpoint_config = EndpointConfig.from_dict( + {"url": model_server.make_url("/model"), "wait_time_between_pulls": 2} + ) + + agent = Agent() + agent = await rasa.core.agent.load_from_server( + agent, model_server=model_endpoint_config + ) + + await asyncio.sleep(5) + + assert agent.fingerprint == "somehash" + assert agent.domain.as_dict() == domain.as_dict() + assert agent.processor.graph_runner + + assert model_server.app.ctx.number_of_model_requests == 1 + jobs.kill_scheduler() + + +async def test_wait_time_between_pulls_without_interval( + model_server: TestClient, monkeypatch: MonkeyPatch +): + monkeypatch.setattr( + "rasa.core.agent._schedule_model_pulling", lambda *args: 1 / 0 + ) # will raise an exception + + model_endpoint_config = EndpointConfig.from_dict( + {"url": model_server.make_url("/model"), "wait_time_between_pulls": None} + ) + + agent = Agent() + # should not call _schedule_model_pulling, if it does, this will raise + await rasa.core.agent.load_from_server(agent, model_server=model_endpoint_config) + + +async def test_load_agent(trained_rasa_model: Text): + agent = await load_agent(model_path=trained_rasa_model) + + assert agent.tracker_store is not None + assert agent.lock_store is not None + assert agent.processor is not None + assert agent.processor.graph_runner is not None + + +async def test_load_agent_on_not_existing_path(): + agent = await load_agent(model_path="some-random-path") + + assert agent + assert agent.processor is None + + +async def test_load_from_remote_storage(trained_nlu_model: Text): + class FakePersistor(Persistor): + def _persist_tar(self, filekey: Text, tarname: Text) -> None: + pass + + def _retrieve_tar(self, filename: Text) -> Text: + pass + + def retrieve(self, model_name: Text, target_path: Text) -> None: + self._copy(model_name, target_path) + + with patch("rasa.nlu.persistor.get_persistor", new=lambda _: FakePersistor()): + agent = await load_agent( + remote_storage="some-random-remote", model_path=trained_nlu_model + ) + + assert agent is not None + assert agent.is_ready() + + +@pytest.mark.parametrize( + "model_path", + [ + "non-existing-path", + "data/test_domains/default_with_slots.yml", + "not-existing-model.tar.gz", + None, + ], +) +async def test_agent_load_on_invalid_model_path(model_path: Optional[Text]): + with pytest.raises(ModelNotFound): + Agent.load(model_path) + + +async def test_agent_handle_message_full_model(default_agent: Agent): + model_id = default_agent.model_id + assistant_id = default_agent.processor.model_metadata.assistant_id + sender_id = uuid.uuid4().hex + message = UserMessage("hello", sender_id=sender_id) + await default_agent.handle_message(message) + tracker = await default_agent.tracker_store.get_or_create_tracker(sender_id) + events = with_model_ids( + [ + ActionExecuted(action_name="action_session_start"), + SessionStarted(), + ActionExecuted(action_name="action_listen"), + UserUttered(text="hello", intent={"name": "greet"}), + DefinePrevUserUtteredFeaturization(False), + ActionExecuted(action_name="utter_greet"), + BotUttered( + "hey there None!", + { + "elements": None, + "quick_replies": None, + "buttons": None, + "attachment": None, + "image": None, + "custom": None, + }, + {"utter_action": "utter_greet"}, + ), + ActionExecuted(action_name="action_listen"), + ], + model_id, + ) + expected_events = with_assistant_ids(events, assistant_id) + assert len(tracker.events) == len(expected_events) + for e1, e2 in zip(tracker.events, expected_events): + assert e1 == e2 + + +async def test_agent_handle_message_only_nlu(trained_nlu_model: Text): + agent = await load_agent(model_path=trained_nlu_model) + model_id = agent.model_id + assistant_id = agent.processor.model_metadata.assistant_id + sender_id = uuid.uuid4().hex + message = UserMessage("hello", sender_id=sender_id) + await agent.handle_message(message) + tracker = await agent.tracker_store.get_or_create_tracker(sender_id) + events = with_model_ids( + [ + ActionExecuted(action_name="action_session_start"), + SessionStarted(), + ActionExecuted(action_name="action_listen"), + UserUttered(text="hello", intent={"name": "greet"}), + ], + model_id, + ) + expected_events = with_assistant_ids(events, assistant_id) + assert len(tracker.events) == len(expected_events) + for e1, e2 in zip(tracker.events, expected_events): + assert e1 == e2 + + +async def test_agent_handle_message_only_core(trained_core_model: Text): + agent = await load_agent(model_path=trained_core_model) + model_id = agent.model_id + assistant_id = agent.processor.model_metadata.assistant_id + sender_id = uuid.uuid4().hex + message = UserMessage("/greet", sender_id=sender_id) + await agent.handle_message(message) + tracker = await agent.tracker_store.get_or_create_tracker(sender_id) + events = with_model_ids( + [ + ActionExecuted(action_name="action_session_start"), + SessionStarted(), + ActionExecuted(action_name="action_listen"), + UserUttered(text="/greet", intent={"name": "greet"}), + DefinePrevUserUtteredFeaturization(False), + ActionExecuted(action_name="utter_greet"), + BotUttered( + "hey there None!", + { + "elements": None, + "quick_replies": None, + "buttons": None, + "attachment": None, + "image": None, + "custom": None, + }, + {"utter_action": "utter_greet"}, + ), + ActionExecuted(action_name="action_listen"), + ], + model_id, + ) + expected_events = with_assistant_ids(events, assistant_id) + assert len(tracker.events) == len(expected_events) + for e1, e2 in zip(tracker.events, expected_events): + assert e1 == e2 + + +async def test_agent_update_model(trained_core_model: Text, trained_nlu_model: Text): + agent1 = await load_agent(model_path=trained_core_model) + agent2 = await load_agent(model_path=trained_core_model) + + assert ( + agent1.processor.model_metadata.predict_schema + == agent2.processor.model_metadata.predict_schema + ) + + agent2.load_model(trained_nlu_model) + assert not ( + agent1.processor.model_metadata.predict_schema + == agent2.processor.model_metadata.predict_schema + ) + + +async def test_parse_with_http_interpreter(trained_default_agent_model: Text): + endpoints = AvailableEndpoints(nlu=EndpointConfig("https://interpreter.com")) + agent = await load_agent( + model_path=trained_default_agent_model, endpoints=endpoints + ) + response_body = { + "intent": {INTENT_NAME_KEY: "some_intent", "confidence": 1.0}, + "entities": [], + "text": "lunch?", + } + + with aioresponses() as mocked: + mocked.post( + "https://interpreter.com/model/parse", + repeat=True, + status=HTTPStatus.OK, + body=json.dumps(response_body), + ) + + # mock the parse function with the one defined for this test + result = await agent.parse_message("lunch?") + assert result == response_body + + +@pytest.mark.parametrize( + "method_name", + [ + "parse_message", + "predict_next_for_sender_id", + "predict_next_with_tracker", + "log_message", + "execute_action", + "trigger_intent", + "handle_text", + ], +) +def test_agent_checks_if_ready(method_name): + not_ready_agent = Agent() + with pytest.raises(AgentNotReady): + getattr(not_ready_agent, method_name)() diff --git a/tests/core/test_broker.py b/tests/core/test_broker.py new file mode 100644 index 0000000..f6fde15 --- /dev/null +++ b/tests/core/test_broker.py @@ -0,0 +1,411 @@ +import json +import logging +import textwrap +from pathlib import Path +from typing import Union, Text, List, Optional, Type + +import aio_pika.exceptions +import aiormq.exceptions +import pamqp.exceptions +import confluent_kafka +import pytest +from _pytest.logging import LogCaptureFixture +from _pytest.monkeypatch import MonkeyPatch +from aiormq import ChannelNotFoundEntity + +from rasa.core.brokers import pika +from tests.conftest import AsyncMock + +import rasa.shared.utils.io +import rasa.utils.io +from rasa.core.brokers.broker import EventBroker +from rasa.core.brokers.file import FileEventBroker +from rasa.core.brokers.kafka import KafkaEventBroker, KafkaProducerInitializationError +from rasa.core.brokers.pika import PikaEventBroker, DEFAULT_QUEUE_NAME +from rasa.core.brokers.sql import SQLEventBroker +from rasa.shared.core.events import Event, Restarted, SlotSet, UserUttered +from rasa.shared.exceptions import ConnectionException, RasaException +from rasa.utils.endpoints import EndpointConfig, read_endpoint_config + +TEST_EVENTS = [ + UserUttered("/greet", {"name": "greet", "confidence": 1.0}, []), + SlotSet("name", "rasa"), + Restarted(), +] + + +async def test_pika_broker_from_config(monkeypatch: MonkeyPatch): + # patch PikaEventBroker so it doesn't try to connect to RabbitMQ on init + async def connect(self) -> None: + pass + + monkeypatch.setattr(PikaEventBroker, "connect", connect) + + cfg = read_endpoint_config( + "data/test_endpoints/event_brokers/pika_endpoint.yml", "event_broker" + ) + actual = await EventBroker.create(cfg) + + assert isinstance(actual, PikaEventBroker) + assert actual.host == "localhost" + assert actual.username == "username" + assert actual.queues == ["queue-1"] + assert actual.exchange_name == "exchange" + + +def test_pika_message_property_app_id_without_env_set(monkeypatch: MonkeyPatch): + # unset RASA_ENVIRONMENT env var results in empty App ID + monkeypatch.delenv("RASA_ENVIRONMENT", raising=False) + pika_broker = PikaEventBroker("some host", "username", "password") + + assert not pika_broker._message({}, None).app_id + + +def test_pika_message_property_app_id(monkeypatch: MonkeyPatch): + # setting it to some value results in that value as the App ID + rasa_environment = "some-test-environment" + monkeypatch.setenv("RASA_ENVIRONMENT", rasa_environment) + pika_broker = PikaEventBroker("some host", "username", "password") + + assert pika_broker._message({}, None).app_id == rasa_environment + + +@pytest.mark.parametrize( + "queues_arg,expected,warning", + [ + # default case + (["q1", "q2"], ["q1", "q2"], None), + # `queues` arg supplied, as string + ("q1", ["q1"], None), + # no queues provided. Use default queue and print warning. + (None, [DEFAULT_QUEUE_NAME], UserWarning), + ], +) +def test_pika_queues_from_args( + queues_arg: Union[Text, List[Text], None], + expected: List[Text], + warning: Optional[Type[Warning]], +): + with pytest.warns(warning): + pika_processor = PikaEventBroker( + "host", + "username", + "password", + queues=queues_arg, + get_message=lambda: ("", None), + ) + + assert pika_processor.queues == expected + + +async def test_pika_raise_connection_exception(monkeypatch: MonkeyPatch): + + monkeypatch.setattr( + PikaEventBroker, "connect", AsyncMock(side_effect=ChannelNotFoundEntity()) + ) + + with pytest.raises(ConnectionException): + await EventBroker.create( + EndpointConfig(username="username", password="password", type="pika") + ) + + +@pytest.mark.parametrize( + "exception", + ( + RuntimeError, + ConnectionError, + OSError, + aiormq.exceptions.AMQPError, + pamqp.exceptions.PAMQPException, + pamqp.exceptions.AMQPConnectionForced, + pamqp.exceptions.AMQPNotFound, + pamqp.exceptions.AMQPInternalError, + ), +) +async def test_aio_pika_exceptions_caught( + exception: Exception, monkeypatch: MonkeyPatch +): + monkeypatch.setattr(PikaEventBroker, "connect", AsyncMock(side_effect=exception)) + + with pytest.raises(ConnectionException): + await EventBroker.create( + EndpointConfig(username="username", password="password", type="pika") + ) + + +async def test_no_broker_in_config(endpoints_path: Text): + cfg = read_endpoint_config(endpoints_path, "event_broker") + + actual = await EventBroker.create(cfg) + + assert actual is None + + +async def test_sql_broker_from_config(): + cfg = read_endpoint_config( + "data/test_endpoints/event_brokers/sql_endpoint.yml", "event_broker" + ) + actual = await EventBroker.create(cfg) + + assert isinstance(actual, SQLEventBroker) + assert actual.engine.name == "sqlite" + + +async def test_sql_broker_logs_to_sql_db(): + cfg = read_endpoint_config( + "data/test_endpoints/event_brokers/sql_endpoint.yml", "event_broker" + ) + actual = await EventBroker.create(cfg) + + assert isinstance(actual, SQLEventBroker) + + for e in TEST_EVENTS: + actual.publish(e.as_dict()) + + with actual.session_scope() as session: + events_types = [ + json.loads(event.data)["event"] + for event in session.query(actual.SQLBrokerEvent).all() + ] + + assert events_types == ["user", "slot", "restart"] + + +async def test_file_broker_from_config(tmp_path: Path): + # backslashes need to be encoded (windows...) otherwise we run into unicode issues + path = str(tmp_path / "rasa_test_event.log").replace("\\", "\\\\") + endpoint_config = textwrap.dedent( + f""" + event_broker: + path: "{path}" + type: "file" + """ + ) + rasa.shared.utils.io.write_text_file(endpoint_config, tmp_path / "endpoint.yml") + + cfg = read_endpoint_config(str(tmp_path / "endpoint.yml"), "event_broker") + actual = await EventBroker.create(cfg) + + assert isinstance(actual, FileEventBroker) + assert actual.path.endswith("rasa_test_event.log") + + +async def test_file_broker_logs_to_file(tmp_path: Path): + log_file_path = str(tmp_path / "events.log") + + actual = await EventBroker.create( + EndpointConfig(**{"type": "file", "path": log_file_path}) + ) + + for e in TEST_EVENTS: + actual.publish(e.as_dict()) + + # reading the events from the file one event per line + recovered = [] + with open(log_file_path, "r") as log_file: + for line in log_file: + recovered.append(Event.from_parameters(json.loads(line))) + + assert recovered == TEST_EVENTS + + +async def test_file_broker_properly_logs_newlines(tmp_path: Path): + log_file_path = str(tmp_path / "events.log") + + actual = await EventBroker.create( + EndpointConfig(**{"type": "file", "path": log_file_path}) + ) + + event_with_newline = UserUttered("hello \n there") + + actual.publish(event_with_newline.as_dict()) + + # reading the events from the file one event per line + recovered = [] + with open(log_file_path, "r") as log_file: + for line in log_file: + recovered.append(Event.from_parameters(json.loads(line))) + + assert recovered == [event_with_newline] + + +async def test_load_custom_broker_name(tmp_path: Path): + config = EndpointConfig( + **{ + "type": "rasa.core.brokers.file.FileEventBroker", + "path": str(tmp_path / "rasa_event.log"), + } + ) + broker = await EventBroker.create(config) + assert broker + + +async def test_load_non_existent_custom_broker_name(): + config = EndpointConfig(**{"type": "rasa.core.brokers.my.MyProducer"}) + assert await EventBroker.create(config) is None + + +async def test_kafka_broker_from_config(): + endpoints_path = ( + "data/test_endpoints/event_brokers/kafka_sasl_plaintext_endpoint.yml" + ) + cfg = read_endpoint_config(endpoints_path, "event_broker") + + actual = await KafkaEventBroker.from_endpoint_config(cfg) + + expected = KafkaEventBroker( + "localhost", + sasl_username="username", + sasl_password="password", + sasl_mechanism="PLAIN", + topic="topic", + partition_by_sender=True, + security_protocol="SASL_PLAINTEXT", + ) + + assert actual.url == expected.url + assert actual.sasl_username == expected.sasl_username + assert actual.sasl_password == expected.sasl_password + assert actual.sasl_mechanism == expected.sasl_mechanism + assert actual.topic == expected.topic + assert actual.partition_by_sender == expected.partition_by_sender + + +@pytest.mark.parametrize( + "file,exception", + [ + ("kafka_sasl_plaintext_endpoint.yml", confluent_kafka.KafkaException), + ("kafka_plaintext_endpoint.yml", confluent_kafka.KafkaException), + ("kafka_sasl_ssl_endpoint.yml", KafkaProducerInitializationError), + ("kafka_ssl_endpoint.yml", KafkaProducerInitializationError), + # `ValueError` exception is raised when the `security_protocol` is incorrect + ("kafka_invalid_security_protocol.yml", ValueError), + # `confluent_kafka.KafkaException` exception is raised when there is no + # `url` specified + ("kafka_plaintext_endpoint_no_url.yml", confluent_kafka.KafkaException), + # `KafkaProducerInitializationError` is raised when an invalid + # `sasl_mechanism` is provided + ("kafka_invalid_sasl_mechanism.yml", KafkaProducerInitializationError), + ], +) +async def test_kafka_broker_security_protocols(file: Text, exception: Exception): + endpoints_path = f"data/test_endpoints/event_brokers/{file}" + cfg = read_endpoint_config(endpoints_path, "event_broker") + + actual = await KafkaEventBroker.from_endpoint_config(cfg) + with pytest.raises(exception): + # noinspection PyProtectedMember + producer = actual._create_producer() + + # required action to trigger expected exception because the configuration + # seems correct and the producer gets instantiated but a connection to the + # broker cannot be established + producer.list_topics("topic", timeout=1) + + +@pytest.mark.flaky +async def test_no_pika_logs_if_no_debug_mode(caplog: LogCaptureFixture): + """ + tests that when you run rasa with logging set at INFO, + the debugs from pika dependency are not going to be shown + """ + broker = PikaEventBroker( + "host", "username", "password", retry_delay_in_seconds=1, connection_attempts=1 + ) + + with caplog.at_level(logging.INFO): + with pytest.raises(Exception): + await broker.connect() + + # Only Rasa Open Source logs, but logs from the library itself. + assert all( + record.name + in ["rasa.core.brokers.pika", "asyncio", "ddtrace.internal.writer.writer"] + for record in caplog.records + ) + + +async def test_create_pika_invalid_port(): + + cfg = EndpointConfig( + username="username", password="password", type="pika", port="PORT" + ) + with pytest.raises(RasaException) as e: + await EventBroker.create(cfg) + assert "Port could not be converted to integer." in str(e.value) + + +def test_warning_if_unsupported_ssl_env_variables(monkeypatch: MonkeyPatch): + monkeypatch.setenv("RABBITMQ_SSL_KEY_PASSWORD", "test") + monkeypatch.setenv("RABBITMQ_SSL_CA_FILE", "test") + + with pytest.warns(UserWarning): + pika._create_rabbitmq_ssl_options() + + +async def test_pika_connection_error(monkeypatch: MonkeyPatch): + # patch PikaEventBroker to raise an AMQP connection error + async def connect(self) -> None: + raise aio_pika.exceptions.ProbableAuthenticationError("Oups") + + monkeypatch.setattr(PikaEventBroker, "connect", connect) + cfg = EndpointConfig.from_dict( + { + "type": "pika", + "url": "localhost", + "username": "username", + "password": "password", + "queues": ["queue-1"], + "connection_attempts": 1, + "retry_delay_in_seconds": 0, + } + ) + with pytest.raises(ConnectionException): + await EventBroker.create(cfg) + + +async def test_sql_connection_error(monkeypatch: MonkeyPatch): + cfg = EndpointConfig.from_dict( + { + "type": "sql", + "dialect": "postgresql", + "url": "0.0.0.0", + "port": 42, + "db": "boom", + "username": "user", + "password": "pw", + } + ) + with pytest.raises(ConnectionException): + await EventBroker.create(cfg) + + +@pytest.mark.parametrize( + "host,expected_url", + [ + ("localhost", None), + ("amqp://localhost", "amqp://test_user:test_pass@localhost:5672"), + ( + "amqp://test_user:test_pass@localhost", + "amqp://test_user:test_pass@localhost:5672", + ), + ( + "amqp://test_user:test_pass@localhost/myvhost?connection_timeout=10", + "amqp://test_user:test_pass@localhost:5672/myvhost?connection_timeout=10", + ), + ("amqp://localhost:5672", "amqp://test_user:test_pass@localhost:5672"), + ( + "amqp://test_user:test_pass@localhost:5672/myvhost?connection_timeout=10", + "amqp://test_user:test_pass@localhost:5672/myvhost?connection_timeout=10", + ), + ], +) +def test_pika_event_broker_configure_url( + host: Text, expected_url: Optional[Text] +) -> None: + username = "test_user" + password = "test_pass" + broker = PikaEventBroker(host=host, username=username, password=password) + url = broker._configure_url() + assert url == expected_url diff --git a/tests/core/test_channels.py b/tests/core/test_channels.py new file mode 100644 index 0000000..a3c7875 --- /dev/null +++ b/tests/core/test_channels.py @@ -0,0 +1,718 @@ +import logging + +import jwt +from typing import Dict +from unittest.mock import patch, MagicMock + +import pytest +from _pytest.logging import LogCaptureFixture +from _pytest.monkeypatch import MonkeyPatch +from aiogram.utils.exceptions import TelegramAPIError +from aiohttp import ClientTimeout +from aioresponses import aioresponses +from sanic import Sanic + +import rasa.core.run +import rasa.core.channels.channel +from rasa.core import utils +from rasa.core.channels import RasaChatInput, console +from rasa.core.channels.channel import UserMessage +from rasa.core.channels.rasa_chat import ( + JWT_USERNAME_KEY, + CONVERSATION_ID_KEY, + INTERACTIVE_LEARNING_PERMISSION, +) +from rasa.core.channels.telegram import TelegramOutput +from rasa.shared.exceptions import RasaException +from rasa.utils.endpoints import EndpointConfig +from tests.core import utilities + +# this is needed so that the tests included as code examples look better +from tests.utilities import json_of_latest_request, latest_request + +logger = logging.getLogger(__name__) + + +async def noop(*args, **kwargs): + """Just do nothing.""" + pass + + +async def test_send_response(default_channel, default_tracker): + text_only_message = {"text": "hey"} + multiline_text_message = { + "text": "This message should come first: \n\n" + "This is message two \nThis as well\n\n" + } + image_only_message = {"image": "https://i.imgur.com/nGF1K8f.jpg"} + text_and_image_message = { + "text": "look at this", + "image": "https://i.imgur.com/T5xVo.jpg", + } + custom_json_message = { + "text": "look at this", + "custom": {"some_random_arg": "value", "another_arg": "value2"}, + } + + await default_channel.send_response(default_tracker.sender_id, text_only_message) + await default_channel.send_response( + default_tracker.sender_id, multiline_text_message + ) + await default_channel.send_response(default_tracker.sender_id, image_only_message) + await default_channel.send_response( + default_tracker.sender_id, text_and_image_message + ) + await default_channel.send_response(default_tracker.sender_id, custom_json_message) + collected = default_channel.messages + + assert len(collected) == 8 + + # text only message + assert collected[0] == {"recipient_id": "my-sender", "text": "hey"} + + # multiline text message, should split on '\n\n' + assert collected[1] == { + "recipient_id": "my-sender", + "text": "This message should come first: ", + } + assert collected[2] == { + "recipient_id": "my-sender", + "text": "This is message two \nThis as well", + } + + # image only message + assert collected[3] == { + "recipient_id": "my-sender", + "image": "https://i.imgur.com/nGF1K8f.jpg", + } + + # text & image combined - will result in two messages + assert collected[4] == {"recipient_id": "my-sender", "text": "look at this"} + assert collected[5] == { + "recipient_id": "my-sender", + "image": "https://i.imgur.com/T5xVo.jpg", + } + assert collected[6] == {"recipient_id": "my-sender", "text": "look at this"} + assert collected[7] == { + "recipient_id": "my-sender", + "custom": {"some_random_arg": "value", "another_arg": "value2"}, + } + + +async def test_console_input(): + from rasa.core.channels import console + + # Overwrites the input() function and when someone else tries to read + # something from the command line this function gets called. + with utilities.mocked_cmd_input(console, text="Test Input"): + with aioresponses() as mocked: + mocked.post( + "https://example.com/webhooks/rest/webhook?stream=true", + repeat=True, + payload={}, + ) + + await console.record_messages( + server_url="https://example.com", + max_message_limit=3, + sender_id="default", + ) + + r = latest_request( + mocked, "POST", "https://example.com/webhooks/rest/webhook?stream=true" + ) + + assert r + + b = json_of_latest_request(r) + + assert b == {"message": "Test Input", "sender": "default"} + + +# USED FOR DOCS - don't rename without changing in the docs +def test_webexteams_channel(): + # START DOC INCLUDE + from rasa.core.channels.webexteams import WebexTeamsInput + + input_channel = WebexTeamsInput( + access_token="YOUR_ACCESS_TOKEN", + # this is the `bot access token` + room="YOUR_WEBEX_ROOM" + # the name of your channel to which the bot posts (optional) + ) + + s = rasa.core.run.configure_app([input_channel], port=5004) + # END DOC INCLUDE + # the above marker marks the end of the code snipped included + # in the docs + routes_list = utils.list_routes(s) + assert routes_list["webexteams_webhook.health"].startswith("/webhooks/webexteams") + assert routes_list["webexteams_webhook.webhook"].startswith( + "/webhooks/webexteams/webhook" + ) + + +# USED FOR DOCS - don't rename without changing in the docs +def test_slack_channel(): + # START DOC INCLUDE + from rasa.core.channels.slack import SlackInput + + input_channel = SlackInput( + # this is the Slack Bot Token + slack_token="YOUR_SLACK_TOKEN", + # the name of your channel to which the bot posts (optional) + slack_channel="YOUR_SLACK_CHANNEL", + # signing secret from slack to verify incoming webhook messages + slack_signing_secret="YOUR_SIGNING_SECRET", + ) + + s = rasa.core.run.configure_app([input_channel], port=5004) + # END DOC INCLUDE + # the above marker marks the end of the code snipped included + # in the docs + routes_list = utils.list_routes(s) + assert routes_list["slack_webhook.health"].startswith("/webhooks/slack") + assert routes_list["slack_webhook.webhook"].startswith("/webhooks/slack/webhook") + + +# USED FOR DOCS - don't rename without changing in the docs +def test_mattermost_channel(): + # START DOC INCLUDE + from rasa.core.channels.mattermost import MattermostInput + + input_channel = MattermostInput( + # this is the url of the api for your mattermost instance + url="http://chat.example.com/api/v4", + # the bot token of the bot account that will post messages + token="xxxxx", + # the password of your bot user that will post messages + # the webhook-url your bot should listen for messages + webhook_url="YOUR_WEBHOOK_URL", + ) + + s = rasa.core.run.configure_app([input_channel], port=5004) + # END DOC INCLUDE + # the above marker marks the end of the code snipped included + # in the docs + routes_list = utils.list_routes(s) + assert routes_list["mattermost_webhook.health"].startswith("/webhooks/mattermost") + assert routes_list["mattermost_webhook.webhook"].startswith( + "/webhooks/mattermost/webhook" + ) + + +# USED FOR DOCS - don't rename without changing in the docs +def test_botframework_channel(): + # START DOC INCLUDE + from rasa.core.channels.botframework import BotFrameworkInput + + input_channel = BotFrameworkInput( + # you get this from your Bot Framework account + app_id="MICROSOFT_APP_ID", + # also from your Bot Framework account + app_password="MICROSOFT_APP_PASSWORD", + ) + + s = rasa.core.run.configure_app([input_channel], port=5004) + # END DOC INCLUDE + # the above marker marks the end of the code snipped included + # in the docs + routes_list = utils.list_routes(s) + assert routes_list["botframework_webhook.health"].startswith( + "/webhooks/botframework" + ) + assert routes_list["botframework_webhook.webhook"].startswith( + "/webhooks/botframework/webhook" + ) + + +# USED FOR DOCS - don't rename without changing in the docs +def test_rocketchat_channel(): + # START DOC INCLUDE + from rasa.core.channels.rocketchat import RocketChatInput + + input_channel = RocketChatInput( + # your bots rocket chat user name + user="yourbotname", + # the password for your rocket chat bots account + password="YOUR_PASSWORD", + # url where your rocket chat instance is running + server_url="https://demo.rocket.chat", + ) + + s = rasa.core.run.configure_app([input_channel], port=5004) + # END DOC INCLUDE + # the above marker marks the end of the code snipped included + # in the docs + routes_list = utils.list_routes(s) + assert routes_list["rocketchat_webhook.health"].startswith("/webhooks/rocketchat") + assert routes_list["rocketchat_webhook.webhook"].startswith( + "/webhooks/rocketchat/webhook" + ) + + +# USED FOR DOCS - don't rename without changing in the docs +@pytest.mark.filterwarnings("ignore:unclosed file.*:ResourceWarning") +# telegram channel will try to set a webhook, so we need to mock the api +@patch.object(TelegramOutput, "set_webhook", noop) +def test_telegram_channel(): + # START DOC INCLUDE + from rasa.core.channels.telegram import TelegramInput + + input_channel = TelegramInput( + # you get this when setting up a bot + access_token="123:YOUR_ACCESS_TOKEN", + # this is your bots username + verify="YOUR_TELEGRAM_BOT", + # the url your bot should listen for messages + webhook_url="YOUR_WEBHOOK_URL", + ) + + s = rasa.core.run.configure_app([input_channel], port=5004) + # END DOC INCLUDE + # the above marker marks the end of the code snipped included + # in the docs + routes_list = utils.list_routes(s) + assert routes_list["telegram_webhook.health"].startswith("/webhooks/telegram") + assert routes_list["telegram_webhook.message"].startswith( + "/webhooks/telegram/webhook" + ) + + +def test_telegram_channel_raise_rasa_exception_webhook_not_set( + monkeypatch: MonkeyPatch, +): + from rasa.core.channels.telegram import TelegramInput + + input_channel = TelegramInput( + # you get this when setting up a bot + access_token="123:YOUR_ACCESS_TOKEN", + # this is your bots username + verify="YOUR_TELEGRAM_BOT", + # the url your bot should listen for messages + webhook_url="", + ) + + monkeypatch.setattr( + rasa.core.channels.telegram.TelegramOutput, + "set_webhook", + MagicMock(side_effect=TelegramAPIError("Error from Telegram.")), + ) + + with pytest.raises(RasaException) as e: + rasa.core.run.configure_app([input_channel], port=5004) + + assert "Failed to set channel webhook:" in str(e.value) + + +async def test_handling_of_integer_user_id(): + # needed for telegram to work properly as this channel sends integer ids, + # but we expect the sender_id to be a string everywhere else + + assert UserMessage("hello", sender_id=123).sender_id == "123" + + +# USED FOR DOCS - don't rename without changing in the docs +def test_twilio_channel(): + # START DOC INCLUDE + from rasa.core.channels.twilio import TwilioInput + + input_channel = TwilioInput( + # you get this from your twilio account + account_sid="YOUR_ACCOUNT_SID", + # also from your twilio account + auth_token="YOUR_AUTH_TOKEN", + # a number associated with your twilio account + twilio_number="YOUR_TWILIO_NUMBER", + ) + + s = rasa.core.run.configure_app([input_channel], port=5004) + # END DOC INCLUDE + # the above marker marks the end of the code snipped included + # in the docs + routes_list = utils.list_routes(s) + assert routes_list["twilio_webhook.health"].startswith("/webhooks/twilio") + assert routes_list["twilio_webhook.message"].startswith("/webhooks/twilio/webhook") + + +# USED FOR DOCS - don't rename without changing in the docs +def test_callback_channel(): + # START DOC INCLUDE + from rasa.core.channels.callback import CallbackInput + + input_channel = CallbackInput( + # URL Core will call to send the bot responses + endpoint=EndpointConfig("http://localhost:5004") + ) + + s = rasa.core.run.configure_app([input_channel], port=5004) + # END DOC INCLUDE + # the above marker marks the end of the code snipped included + # in the docs + routes_list = utils.list_routes(s) + assert routes_list["callback_webhook.health"].startswith("/webhooks/callback") + assert routes_list["callback_webhook.webhook"].startswith( + "/webhooks/callback/webhook" + ) + + +# USED FOR DOCS - don't rename without changing in the docs +def test_socketio_channel(): + # START DOC INCLUDE + from rasa.core.channels.socketio import SocketIOInput + + input_channel = SocketIOInput( + # event name for messages sent from the user + user_message_evt="user_uttered", + # event name for messages sent from the bot + bot_message_evt="bot_uttered", + # socket.io namespace to use for the messages + namespace=None, + ) + + s = rasa.core.run.configure_app([input_channel], port=5004) + # END DOC INCLUDE + # the above marker marks the end of the code snipped included + # in the docs + routes_list = utils.list_routes(s) + assert routes_list["socketio_webhook.health"].startswith("/webhooks/socketio") + assert routes_list["handle_request"].startswith("/socket.io") + + +def test_socketio_channel_metadata(): + from rasa.core.channels.socketio import SocketIOInput + + input_channel = SocketIOInput( + # event name for messages sent from the user + user_message_evt="user_uttered", + # event name for messages sent from the bot + bot_message_evt="bot_uttered", + # optional metadata key name + metadata_key="customData", + # socket.io namespace to use for the messages + namespace=None, + ) + + s = rasa.core.run.configure_app([input_channel], port=5004) + # END DOC INCLUDE + # the above marker marks the end of the code snipped included + # in the docs + routes_list = utils.list_routes(s) + assert routes_list["socketio_webhook.health"].startswith("/webhooks/socketio") + assert routes_list["handle_request"].startswith("/socket.io") + + +async def test_socketio_channel_jwt_authentication(): + from rasa.core.channels.socketio import SocketIOInput + + public_key = "random_key123" + jwt_algorithm = "HS256" + auth_token = jwt.encode({"payload": "value"}, public_key, algorithm=jwt_algorithm) + + input_channel = SocketIOInput( + # event name for messages sent from the user + user_message_evt="user_uttered", + # event name for messages sent from the bot + bot_message_evt="bot_uttered", + # socket.io namespace to use for the messages + namespace=None, + # public key for JWT methods + jwt_key=public_key, + # method used for the signature of the JWT authentication payload + jwt_method=jwt_algorithm, + ) + + assert input_channel.jwt_key == public_key + assert input_channel.jwt_algorithm == jwt_algorithm + assert rasa.core.channels.channel.decode_bearer_token( + auth_token, input_channel.jwt_key, input_channel.jwt_algorithm + ) + + +async def test_socketio_channel_jwt_authentication_invalid_key( + caplog: LogCaptureFixture, +): + from rasa.core.channels.socketio import SocketIOInput + + public_key = "random_key123" + invalid_public_key = "my_invalid_key" + jwt_algorithm = "HS256" + invalid_auth_token = jwt.encode( + {"payload": "value"}, invalid_public_key, algorithm=jwt_algorithm + ) + + input_channel = SocketIOInput( + # event name for messages sent from the user + user_message_evt="user_uttered", + # event name for messages sent from the bot + bot_message_evt="bot_uttered", + # socket.io namespace to use for the messages + namespace=None, + # public key for JWT methods + jwt_key=public_key, + # method used for the signature of the JWT authentication payload + jwt_method=jwt_algorithm, + ) + + assert input_channel.jwt_key == public_key + assert input_channel.jwt_algorithm == jwt_algorithm + + with caplog.at_level(logging.ERROR): + rasa.core.channels.channel.decode_bearer_token( + invalid_auth_token, input_channel.jwt_key, input_channel.jwt_algorithm + ) + + assert any("JWT public key invalid." in message for message in caplog.messages) + + +async def test_callback_calls_endpoint(): + from rasa.core.channels.callback import CallbackOutput + + with aioresponses() as mocked: + mocked.post( + "https://example.com/callback", + repeat=True, + headers={"Content-Type": "application/json"}, + ) + + output = CallbackOutput(EndpointConfig("https://example.com/callback")) + + await output.send_response( + "test-id", {"text": "Hi there!", "image": "https://example.com/image.jpg"} + ) + + r = latest_request(mocked, "post", "https://example.com/callback") + + assert r + + image = r[-1].kwargs["json"] + text = r[-2].kwargs["json"] + + assert image["recipient_id"] == "test-id" + assert image["image"] == "https://example.com/image.jpg" + + assert text["recipient_id"] == "test-id" + assert text["text"] == "Hi there!" + + +def test_botframework_attachments(): + from rasa.core.channels.botframework import BotFrameworkInput + from copy import deepcopy + + ch = BotFrameworkInput("app_id", "app_pass") + + payload = { + "type": "message", + "id": "123", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/emea/", + "from": {"id": "12:123", "name": "Rasa", "aadObjectId": "123"}, + "conversation": { + "conversationType": "personal", + "tenantId": "123", + "id": "a:123", + }, + "recipient": {"id": "12:123", "name": "Rasa chat"}, + } + assert ch.add_attachments_to_metadata(payload, None) is None + + attachments = [ + { + "contentType": "application/vnd.microsoft.teams.file.download.info", + "content": { + "downloadUrl": "https://test.sharepoint.com/personal/rasa/123", + "uniqueId": "123", + "fileType": "csv", + }, + "contentUrl": "https://test.sharepoint.com/personal/rasa/123", + "name": "rasa-test.csv", + } + ] + payload["attachments"] = attachments + + assert ch.add_attachments_to_metadata(payload, None) == {"attachments": attachments} + + metadata = {"test": 1, "bigger_test": {"key": "value"}} + updated_metadata = deepcopy(metadata) + updated_metadata.update({"attachments": attachments}) + + assert ch.add_attachments_to_metadata(payload, metadata) == updated_metadata + + +@pytest.mark.filterwarnings("ignore:unclosed.*:ResourceWarning") +def test_channel_inheritance(): + from rasa.core.channels import RestInput + from rasa.core.channels.rasa_chat import RasaChatInput + + rasa_input = RasaChatInput("https://example.com") + + s = rasa.core.run.configure_app([RestInput(), rasa_input], port=5004) + + routes_list = utils.list_routes(s) + assert routes_list["custom_webhook_RasaChatInput.health"].startswith( + "/webhooks/rasa" + ) + assert routes_list["custom_webhook_RasaChatInput.receive"].startswith( + "/webhooks/rasa/webhook" + ) + + +def test_int_sender_id_in_user_message(): + from rasa.core.channels.channel import UserMessage + + # noinspection PyTypeChecker + message = UserMessage("A text", sender_id=1234567890) + + assert message.sender_id == "1234567890" + + +def test_int_message_id_in_user_message(): + from rasa.core.channels.channel import UserMessage + + # noinspection PyTypeChecker + message = UserMessage("B text", message_id=987654321) + + assert message.message_id == "987654321" + + +async def test_send_elements_without_buttons(): + from rasa.core.channels.channel import OutputChannel + + async def test_message(sender, message): + assert sender == "user" + assert message == "a : b" + + channel = OutputChannel() + channel.send_text_message = test_message + await channel.send_elements("user", [{"title": "a", "subtitle": "b"}]) + + +def test_newsline_strip(): + from rasa.core.channels.channel import UserMessage + + message = UserMessage("\n/restart\n") + + assert message.text == "/restart" + + +def test_register_channel_without_route(): + """Check we properly connect the input channel blueprint if route is None""" + from rasa.core.channels import RestInput + import rasa.core + + input_channel = RestInput() + + app = Sanic("test_channels") + rasa.core.channels.channel.register([input_channel], app, route=None) + + routes_list = utils.list_routes(app) + assert routes_list["test_channels.custom_webhook_RestInput.receive"].startswith( + "/webhook" + ) + + +def test_channel_registration_with_absolute_url_prefix_overwrites_route(): + from rasa.core.channels import RestInput + import rasa.core + + input_channel = RestInput() + test_route = "/absolute_route" + input_channel.url_prefix = lambda: test_route + + app = Sanic("test_channels") + ignored_base_route = "/should_be_ignored" + rasa.core.channels.channel.register( + [input_channel], app, route="/should_be_ignored" + ) + + # Assure that an absolute url returned by `url_prefix` overwrites route parameter + # given in `register`. + routes_list = utils.list_routes(app) + assert routes_list["test_channels.custom_webhook_RestInput.health"].startswith( + test_route + ) + assert ignored_base_route not in routes_list.get( + "test_channels.custom_webhook_RestInput.health" + ) + + +@pytest.mark.parametrize( + "test_input, expected", + [ + ({}, "rest"), + ({"input_channel": None}, "rest"), + ({"input_channel": "custom"}, "custom"), + ], +) +def test_extract_input_channel(test_input, expected): + from rasa.core.channels import RestInput + + input_channel = RestInput() + + fake_request = MagicMock() + fake_request.json = test_input + + assert input_channel._extract_input_channel(fake_request) == expected + + +async def test_rasa_chat_input(): + from rasa.core.channels import RasaChatInput + + rasa_x_api_url = "https://rasa-x.com:5002" + rasa_chat_input = RasaChatInput(rasa_x_api_url) + public_key = "random_key123" + jwt_algorithm = "RS256" + with aioresponses() as mocked: + mocked.get( + rasa_x_api_url + "/version", + payload={"keys": [{"key": public_key, "alg": jwt_algorithm}]}, + repeat=True, + status=200, + ) + await rasa_chat_input._fetch_public_key() + assert rasa_chat_input.jwt_key == public_key + assert rasa_chat_input.jwt_algorithm == jwt_algorithm + + +@pytest.mark.parametrize( + "jwt, message", + [ + ({JWT_USERNAME_KEY: "abc"}, {CONVERSATION_ID_KEY: "abc"}), + ( + { + JWT_USERNAME_KEY: "abc", + "scopes": ["a", "b", INTERACTIVE_LEARNING_PERMISSION], + }, + {CONVERSATION_ID_KEY: "test"}, + ), + ], +) +def test_has_user_permission_to_send_messages_to_conversation(jwt: Dict, message: Dict): + assert RasaChatInput._has_user_permission_to_send_messages_to_conversation( + jwt, message + ) + + +@pytest.mark.parametrize( + "jwt, message", + [ + ({JWT_USERNAME_KEY: "abc"}, {CONVERSATION_ID_KEY: "xyz"}), + ( + {JWT_USERNAME_KEY: "abc", "scopes": ["a", "b"]}, + {CONVERSATION_ID_KEY: "test"}, + ), + ], +) +def test_has_user_permission_to_send_messages_to_conversation_without_permission( + jwt: Dict, message: Dict +): + assert not RasaChatInput._has_user_permission_to_send_messages_to_conversation( + jwt, message + ) + + +def test_set_console_stream_reading_timeout(monkeypatch: MonkeyPatch): + expected = 100 + monkeypatch.setenv(console.STREAM_READING_TIMEOUT_ENV, str(100)) + + assert console._get_stream_reading_timeout() == ClientTimeout(expected) diff --git a/tests/core/test_ensemble.py b/tests/core/test_ensemble.py new file mode 100644 index 0000000..91a0135 --- /dev/null +++ b/tests/core/test_ensemble.py @@ -0,0 +1,222 @@ +from rasa.shared.exceptions import InvalidConfigException +import pytest +import itertools +from typing import List, Tuple + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.core.policies.policy import PolicyPrediction +from rasa.core.policies.ensemble import DefaultPolicyPredictionEnsemble +from rasa.shared.core.domain import Domain +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.events import ActionExecutionRejected, UserUttered +from rasa.shared.core.events import ActionExecuted, DefinePrevUserUtteredFeaturization +from rasa.shared.core.constants import ACTION_LISTEN_NAME + + +@pytest.fixture +def default_ensemble( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +) -> DefaultPolicyPredictionEnsemble: + return DefaultPolicyPredictionEnsemble.create( + config=DefaultPolicyPredictionEnsemble.get_default_config(), + model_storage=default_model_storage, + resource=Resource("ensemble"), + execution_context=default_execution_context, + ) + + +def test_default_predict_complains_if_no_predictions_given( + default_ensemble: DefaultPolicyPredictionEnsemble, +): + domain = Domain.load("data/test_domains/default.yml") + tracker = DialogueStateTracker.from_events(sender_id="arbitrary", evts=[]) + with pytest.raises(InvalidConfigException): + default_ensemble.combine_predictions_from_kwargs(domain=domain, tracker=tracker) + + +def test_default_predict_ignores_other_kwargs( + default_ensemble: DefaultPolicyPredictionEnsemble, +): + domain = Domain.load("data/test_domains/default.yml") + tracker = DialogueStateTracker.from_events(sender_id="arbitrary", evts=[]) + prediction = PolicyPrediction( + policy_name="arbitrary", probabilities=[1.0], policy_priority=1 + ) + + final_prediction = default_ensemble.combine_predictions_from_kwargs( + domain=domain, + tracker=tracker, + **{ + "policy-graph-component-1": prediction, + "another-random-component": domain, + "yet-another-component": tracker, + }, + ) + assert final_prediction.policy_name == prediction.policy_name + + +def test_default_predict_excludes_rejected_action( + default_ensemble: DefaultPolicyPredictionEnsemble, +): + domain = Domain.load("data/test_domains/default.yml") + excluded_action = domain.action_names_or_texts[0] + tracker = DialogueStateTracker.from_events( + sender_id="arbitrary", + evts=[ + UserUttered("hi"), + ActionExecuted(excluded_action), + ActionExecutionRejected(excluded_action), # not "Rejection" + ], + ) + num_actions = len(domain.action_names_or_texts) + predictions = [ + PolicyPrediction( + policy_name=str(idx), probabilities=[1.0] * num_actions, policy_priority=idx + ) + for idx in range(2) + ] + index_of_excluded_action = domain.index_for_action(excluded_action) + prediction = default_ensemble.combine_predictions_from_kwargs( + domain=domain, + tracker=tracker, + **{prediction.policy_name: prediction for prediction in predictions}, + ) + assert prediction.probabilities[index_of_excluded_action] == 0.0 + + +@pytest.mark.parametrize( + "predictions_and_expected_winner_idx, last_action_was_action_listen", + itertools.product( + [ + ( + # highest probability and highest priority + [ + PolicyPrediction( + policy_name=str(idx), + probabilities=[idx] * 3, + policy_priority=idx, + ) + for idx in range(4) + ], + 3, + ), + ( + # highest probability wins even if priority is low + [ + PolicyPrediction( + policy_name=str(idx), + probabilities=[idx] * 3, + policy_priority=idx, + ) + for idx in reversed(range(4)) + ], + 0, + ), + ( + # "end to end" prediction supersedes others + [ + PolicyPrediction( + policy_name="policy using user text but max prob 0.0 wins", + probabilities=[0.0], + policy_priority=0, + is_end_to_end_prediction=True, + ), + PolicyPrediction( + policy_name="policy not using user text but max prob 1.0", + probabilities=[1.0], + policy_priority=1, + is_end_to_end_prediction=False, + ), + ], + 0, + ), + ( + # "no user" prediction supsersedes even the end to end ones + [ + PolicyPrediction( + policy_name="'no user' with smallest max. prob", + probabilities=[0.0], + policy_priority=0, + is_no_user_prediction=True, + ), + PolicyPrediction( + policy_name="'end2end' with higher prob and priority", + probabilities=[1.0], + policy_priority=1, + is_end_to_end_prediction=True, + ), + PolicyPrediction( + policy_name="highest prob and highest priority", + probabilities=[2.0], + policy_priority=2, + ), + ], + 0, + ), + ], + [True, False], + ), +) +def test_default_combine_predictions( + default_ensemble: DefaultPolicyPredictionEnsemble, + predictions_and_expected_winner_idx: Tuple[List[PolicyPrediction], int], + last_action_was_action_listen: bool, +): + domain = Domain.load("data/test_domains/default.yml") + predictions, expected_winner_idx = predictions_and_expected_winner_idx + + # add mandatory and optional events to every prediction + for prediction in predictions: + prediction.events = [ActionExecuted(action_name=prediction.policy_name)] + prediction.optional_events = [ + ActionExecuted(action_name=f"optional-{prediction.policy_name}") + ] + + # expected events + expected_events = set( + event for prediction in predictions for event in prediction.events + ) + expected_events.update(predictions[expected_winner_idx].optional_events) + if last_action_was_action_listen: + expected_events.add( + DefinePrevUserUtteredFeaturization( + predictions[expected_winner_idx].is_end_to_end_prediction + ) + ) + + # construct tracker + evts = ( + [ActionExecuted(action_name=ACTION_LISTEN_NAME)] + if last_action_was_action_listen + else [] + ) + tracker = DialogueStateTracker.from_events(sender_id="arbitrary", evts=evts) + + # get the best prediction! + best_prediction = default_ensemble.combine_predictions_from_kwargs( + tracker, + domain=domain, + **{prediction.policy_name: prediction for prediction in predictions}, + ) + + # compare events first ... + assert set(best_prediction.events) == expected_events + assert not best_prediction.optional_events + + # ... then drop events and compare the rest + best_prediction.events = [] + best_prediction.optional_events = [] + predictions[expected_winner_idx].events = [] + predictions[expected_winner_idx].optional_events = [] + + # ... not quite there yet, because old implementation creates a policy with + # best_policy.priority as priority and the first one is a tuple which then + # becomes a tuple with a tuple with an int, so... + predictions[expected_winner_idx].policy_priority = predictions[ + expected_winner_idx + ].policy_priority + + # now, we can compare: + assert best_prediction == predictions[expected_winner_idx] diff --git a/tests/core/test_evaluation.py b/tests/core/test_evaluation.py new file mode 100644 index 0000000..30a9194 --- /dev/null +++ b/tests/core/test_evaluation.py @@ -0,0 +1,643 @@ +import os +import textwrap +from pathlib import Path +import json +import logging +from typing import Any, Text, Dict, Callable + +import pytest + +import rasa.shared.utils.io +import rasa.utils.io +from rasa.core.test import ( + _create_data_generator, + _collect_story_predictions, + test as evaluate_stories, + _clean_entity_results, +) +from rasa.core.constants import ( + CONFUSION_MATRIX_STORIES_FILE, + REPORT_STORIES_FILE, + FAILED_STORIES_FILE, + SUCCESSFUL_STORIES_FILE, + STORIES_WITH_WARNINGS_FILE, +) + +# we need this import to ignore the warning... +# noinspection PyUnresolvedReferences +from rasa.nlu.test import evaluate_entities, run_evaluation # noqa: F401 +from rasa.core.agent import Agent, load_agent +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION +from rasa.shared.exceptions import RasaException + + +@pytest.fixture(scope="module") +async def trained_restaurantbot(trained_async: Callable) -> Path: + zipped_model = await trained_async( + domain="data/test_restaurantbot/domain.yml", + config="data/test_restaurantbot/config.yml", + training_files=[ + "data/test_restaurantbot/data/rules.yml", + "data/test_restaurantbot/data/stories.yml", + "data/test_restaurantbot/data/nlu.yml", + ], + ) + + if not zipped_model: + raise RasaException("Model training for formbot failed.") + + return Path(zipped_model) + + +@pytest.fixture(scope="module") +async def restaurantbot_agent(trained_restaurantbot: Path) -> Agent: + return await load_agent(str(trained_restaurantbot)) + + +async def test_evaluation_file_creation( + tmpdir: Path, default_agent: Agent, stories_path: Text +): + failed_stories_path = str(tmpdir / FAILED_STORIES_FILE) + success_stories_path = str(tmpdir / SUCCESSFUL_STORIES_FILE) + stories_with_warnings_path = str(tmpdir / STORIES_WITH_WARNINGS_FILE) + report_path = str(tmpdir / REPORT_STORIES_FILE) + confusion_matrix_path = str(tmpdir / CONFUSION_MATRIX_STORIES_FILE) + + await evaluate_stories( + stories=stories_path, + agent=default_agent, + out_directory=str(tmpdir), + max_stories=None, + e2e=False, + errors=True, + successes=True, + warnings=True, + ) + + assert os.path.isfile(failed_stories_path) + assert os.path.isfile(success_stories_path) + assert os.path.isfile(stories_with_warnings_path) + assert os.path.isfile(report_path) + assert os.path.isfile(confusion_matrix_path) + + +async def test_end_to_end_evaluation_script( + default_agent: Agent, end_to_end_story_path: Text +): + generator = _create_data_generator( + end_to_end_story_path, default_agent, use_conversation_test_files=True + ) + completed_trackers = generator.generate_story_trackers() + + story_evaluation, num_stories, _ = await _collect_story_predictions( + completed_trackers, default_agent, use_e2e=True + ) + + serialised_store = [ + "utter_greet", + "action_listen", + "utter_greet", + "action_listen", + "utter_default", + "action_listen", + "utter_goodbye", + "action_listen", + "utter_greet", + "action_listen", + "utter_default", + "action_listen", + "greet", + "greet", + "default", + "goodbye", + "greet", + "default", + '[{"name": "Max"}]{"entity": "name", "value": "Max"}', + ] + + assert story_evaluation.evaluation_store.serialise()[0] == serialised_store + assert not story_evaluation.evaluation_store.check_prediction_target_mismatch() + assert len(story_evaluation.failed_stories) == 0 + assert num_stories == 3 + + +async def test_end_to_end_evaluation_script_unknown_entity( + default_agent: Agent, e2e_story_file_unknown_entity_path: Text +): + generator = _create_data_generator( + e2e_story_file_unknown_entity_path, + default_agent, + use_conversation_test_files=True, + ) + completed_trackers = generator.generate_story_trackers() + + story_evaluation, num_stories, _ = await _collect_story_predictions( + completed_trackers, default_agent + ) + + assert story_evaluation.evaluation_store.check_prediction_target_mismatch() + assert len(story_evaluation.failed_stories) == 1 + assert num_stories == 1 + + +@pytest.mark.timeout(300, func_only=True) +async def test_end_to_evaluation_with_forms(form_bot_agent: Agent): + generator = _create_data_generator( + "data/test_evaluations/test_form_end_to_end_stories.yml", + form_bot_agent, + use_conversation_test_files=True, + ) + test_stories = generator.generate_story_trackers() + + story_evaluation, num_stories, _ = await _collect_story_predictions( + test_stories, form_bot_agent + ) + + assert not story_evaluation.evaluation_store.check_prediction_target_mismatch() + + +async def test_source_in_failed_stories( + tmpdir: Path, default_agent: Agent, e2e_story_file_unknown_entity_path: Text +): + stories_path = str(tmpdir / FAILED_STORIES_FILE) + + await evaluate_stories( + stories=e2e_story_file_unknown_entity_path, + agent=default_agent, + out_directory=str(tmpdir), + max_stories=None, + e2e=False, + ) + story_file_unknown_entity = Path(e2e_story_file_unknown_entity_path).absolute() + failed_stories = rasa.shared.utils.io.read_file(stories_path) + + assert ( + f"story: simple_story_with_unknown_entity ({story_file_unknown_entity})" + in failed_stories + ) + + +async def test_end_to_evaluation_trips_circuit_breaker( + e2e_story_file_trips_circuit_breaker_path: Text, + trained_async: Callable, + tmp_path: Path, +): + config = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + assistant_id: placeholder_default + policies: + - name: MemoizationPolicy + max_history: 11 + + pipeline: [] + """ + ) + config_path = tmp_path / "config.yml" + rasa.shared.utils.io.write_text_file(config, config_path) + + model_path = await trained_async( + "data/test_domains/default.yml", + str(config_path), + e2e_story_file_trips_circuit_breaker_path, + ) + + agent = await load_agent(model_path) + generator = _create_data_generator( + e2e_story_file_trips_circuit_breaker_path, + agent, + use_conversation_test_files=True, + ) + test_stories = generator.generate_story_trackers() + + story_evaluation, num_stories, _ = await _collect_story_predictions( + test_stories, agent + ) + + circuit_trip_predicted = [ + "utter_greet", + "utter_greet", + "utter_greet", + "utter_greet", + "utter_greet", + "utter_greet", + "utter_greet", + "utter_greet", + "utter_greet", + "utter_greet", + "circuit breaker tripped", + "circuit breaker tripped", + ] + + assert ( + story_evaluation.evaluation_store.action_predictions == circuit_trip_predicted + ) + assert num_stories == 1 + + +@pytest.mark.parametrize( + "text, entity, expected_entity", + [ + ( + "The first one please.", + { + "extractor": "DucklingEntityExtractor", + "entity": "ordinal", + "confidence": 0.87, + "start": 4, + "end": 9, + "value": 1, + }, + { + "text": "The first one please.", + "entity": "ordinal", + "start": 4, + "end": 9, + "value": "1", + }, + ), + ( + "The first one please.", + { + "extractor": "CRFEntityExtractor", + "entity": "ordinal", + "confidence": 0.87, + "start": 4, + "end": 9, + "value": "1", + }, + { + "text": "The first one please.", + "entity": "ordinal", + "start": 4, + "end": 9, + "value": "1", + }, + ), + ( + "Italian food", + { + "extractor": "DIETClassifier", + "entity": "cuisine", + "confidence": 0.99, + "start": 0, + "end": 7, + "value": "Italian", + }, + { + "text": "Italian food", + "entity": "cuisine", + "start": 0, + "end": 7, + "value": "Italian", + }, + ), + ], +) +def test_event_has_proper_implementation( + text: Text, entity: Dict[Text, Any], expected_entity: Dict[Text, Any] +): + actual_entities = _clean_entity_results(text, [entity]) + + assert actual_entities[0] == expected_entity + + +@pytest.mark.timeout(600, func_only=True) +@pytest.mark.parametrize( + "test_file", + [ + ("data/test_yaml_stories/test_full_retrieval_intent_story.yml"), + ("data/test_yaml_stories/test_base_retrieval_intent_story.yml"), + ], +) +async def test_retrieval_intent(response_selector_agent: Agent, test_file: Text): + generator = _create_data_generator( + test_file, response_selector_agent, use_conversation_test_files=True + ) + test_stories = generator.generate_story_trackers() + + story_evaluation, num_stories, _ = await _collect_story_predictions( + test_stories, response_selector_agent + ) + # check that test story can either specify base intent or full retrieval intent + assert not story_evaluation.evaluation_store.check_prediction_target_mismatch() + + +@pytest.mark.parametrize( + "test_file", + [ + ("data/test_yaml_stories/test_full_retrieval_intent_wrong_prediction.yml"), + ("data/test_yaml_stories/test_base_retrieval_intent_wrong_prediction.yml"), + ], +) +async def test_retrieval_intent_wrong_prediction( + tmpdir: Path, response_selector_agent: Agent, test_file: Text +): + stories_path = str(tmpdir / FAILED_STORIES_FILE) + + await evaluate_stories( + stories=test_file, + agent=response_selector_agent, + out_directory=str(tmpdir), + max_stories=None, + e2e=True, + ) + + failed_stories = rasa.shared.utils.io.read_file(stories_path) + + # check if the predicted entry contains full retrieval intent + assert "# predicted: chitchat/ask_name" in failed_stories + + +# FIXME: these tests take too long to run in the CI, disabling them for now +@pytest.mark.skip_on_ci +@pytest.mark.timeout(240, func_only=True) +async def test_e2e_with_entity_evaluation(e2e_bot_agent: Agent, tmp_path: Path): + test_file = "data/test_e2ebot/tests/test_stories.yml" + + await evaluate_stories( + stories=test_file, + agent=e2e_bot_agent, + out_directory=str(tmp_path), + max_stories=None, + e2e=True, + ) + + report = rasa.shared.utils.io.read_json_file(tmp_path / "TEDPolicy_report.json") + assert report["name"] == { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1, + "confused_with": {}, + } + assert report["mood"] == { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + "confused_with": {}, + } + errors = rasa.shared.utils.io.read_json_file(tmp_path / "TEDPolicy_errors.json") + assert len(errors) == 1 + assert errors[0]["text"] == "today I was very cranky" + + +@pytest.mark.parametrize( + "stories_yaml,expected_results", + [ + [ + """ +stories: + - story: story1 + steps: + - intent: greet + - action: utter_greet + - story: story2 + steps: + - intent: goodbye + - action: utter_goodbye + - story: story3 + steps: + - intent: greet + - action: utter_greet + - intent: goodbye + - action: utter_default + """, + { + "utter_goodbye": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1, + }, + "action_listen": { + "precision": 1.0, + "recall": 0.75, + "f1-score": 0.8571428571428571, + "support": 4, + }, + "utter_greet": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 2, + }, + "utter_default": { + "precision": 0.0, + "recall": 0.0, + "f1-score": 0.0, + "support": 1, + }, + "accuracy": 0.75, + "micro avg": { + "precision": 1.0, + "recall": 0.75, + "f1-score": 0.8571428571428571, + "support": 8, + }, + "macro avg": { + "precision": 0.75, + "recall": 0.6875, + "f1-score": 0.7142857142857143, + "support": 8, + }, + "weighted avg": { + "precision": 0.875, + "recall": 0.75, + "f1-score": 0.8035714285714286, + "support": 8, + }, + "conversation_accuracy": { + "accuracy": 2.0 / 3.0, + "total": 3, + "correct": 2, + "with_warnings": 0, + }, + }, + ] + ], +) +async def test_story_report( + tmpdir: Path, + core_agent: Agent, + stories_yaml: Text, + expected_results: Dict[Text, Dict[Text, Any]], +) -> None: + """Check story_report.json file contains correct result keys/values.""" + + stories_path = tmpdir / "stories.yml" + stories_path.write_text(stories_yaml, "utf8") + out_directory = tmpdir / "results" + out_directory.mkdir() + + await evaluate_stories(stories_path, core_agent, out_directory=out_directory) + story_report_path = out_directory / "story_report.json" + assert story_report_path.exists() + + actual_results = json.loads(story_report_path.read_text("utf8")) + assert actual_results == expected_results + + +async def test_story_report_with_empty_stories(tmpdir: Path, core_agent: Agent) -> None: + stories_path = tmpdir / "stories.yml" + stories_path.write_text("", "utf8") + out_directory = tmpdir / "results" + out_directory.mkdir() + + await evaluate_stories(stories_path, core_agent, out_directory=out_directory) + story_report_path = out_directory / "story_report.json" + assert story_report_path.exists() + + actual_results = json.loads(story_report_path.read_text("utf8")) + assert actual_results == {} + + +@pytest.mark.parametrize( + "skip_field,skip_value", + [ + [None, None], + ["precision", None], + ["f1", None], + ["in_training_data_fraction", None], + ["report", None], + ["include_report", False], + ], +) +async def test_log_evaluation_table(caplog, skip_field, skip_value): + """Check that _log_evaluation_table correctly omits/includes optional args.""" + arr = [1, 1, 1, 0] + acc = 0.75 + kwargs = { + "precision": 0.5, + "f1": 0.6, + "in_training_data_fraction": 0.1, + "report": {"macro f1": 0.7}, + } + if skip_field: + kwargs[skip_field] = skip_value + caplog.set_level(logging.INFO) + rasa.core.test._log_evaluation_table(arr, "CONVERSATION", acc, **kwargs) + + assert f"Correct: {int(len(arr) * acc)} / {len(arr)}" in caplog.text + assert f"Accuracy: {acc:.3f}" in caplog.text + + if skip_field != "f1": + assert f"F1-Score: {kwargs['f1']:5.3f}" in caplog.text + else: + assert "F1-Score:" not in caplog.text + + if skip_field != "precision": + assert f"Precision: {kwargs['precision']:5.3f}" in caplog.text + else: + assert "Precision:" not in caplog.text + + if skip_field != "in_training_data_fraction": + assert ( + f"In-data fraction: {kwargs['in_training_data_fraction']:.3g}" + in caplog.text + ) + else: + assert "In-data fraction:" not in caplog.text + + if skip_field != "report" and skip_field != "include_report": + assert f"Classification report: \n{kwargs['report']}" in caplog.text + else: + assert "Classification report:" not in caplog.text + + +@pytest.mark.skip_on_windows +@pytest.mark.parametrize( + "test_file, correct_intent, correct_entity", + [ + [ + "data/test_yaml_stories/" + "test_prediction_with_correct_intent_wrong_entity.yml", + True, + False, + ], + [ + "data/test_yaml_stories/" + "test_prediction_with_wrong_intent_correct_entity.yml", + False, + True, + ], + [ + "data/test_yaml_stories/" + "test_prediction_with_wrong_intent_wrong_entity.yml", + False, + False, + ], + ], +) +async def test_wrong_predictions_with_intent_and_entities( + tmpdir: Path, + restaurantbot_agent: Agent, + test_file: Text, + correct_intent: bool, + correct_entity: bool, +): + stories_path = str(tmpdir / FAILED_STORIES_FILE) + + await evaluate_stories( + stories=test_file, + agent=restaurantbot_agent, + out_directory=str(tmpdir), + max_stories=None, + e2e=True, + ) + + failed_stories = rasa.shared.utils.io.read_file(stories_path) + + if correct_intent and not correct_entity: + # check if there is no comment on the intent line + assert "- intent: request_restaurant # predicted:" not in failed_stories + # check if there is a comment with the predicted entity on the entity line + assert "# predicted: cuisine: greek" in failed_stories + # check that the correctly predicted entity is printed as well + assert "- seating: outside\n" in failed_stories + # check that it does not double print entities + assert failed_stories.count("\n") == 8 + + elif not correct_intent and correct_entity: + # check if there is a comment with the predicted intent on the intent line + assert "- intent: greet # predicted: request_restaurant" in failed_stories + # check if there is no comment on the entity line + assert "# predicted: cuisine: greek" not in failed_stories + # check that the correctly predicted entity is printed as well + assert "- seating: outside\n" in failed_stories + # check that it does not double print entities + assert failed_stories.count("\n") == 9 + + elif not correct_intent and not correct_entity: + # check if there is a comment with the predicted intent on the intent line + assert "- intent: greet # predicted: request_restaurant" in failed_stories + # check if there is a comment with the predicted entity on the entity line + assert "# predicted: cuisine: greek" in failed_stories + # check that the correctly predicted entity is printed as well + assert "- seating: outside\n" in failed_stories + # check that it does not double print entities + assert failed_stories.count("\n") == 9 + + +@pytest.mark.skip_on_windows +async def test_failed_entity_extraction_comment( + tmpdir: Path, restaurantbot_agent: Agent +): + test_file = "data/test_yaml_stories/test_failed_entity_extraction_comment.yml" + stories_path = str(tmpdir / FAILED_STORIES_FILE) + + await evaluate_stories( + stories=test_file, + agent=restaurantbot_agent, + out_directory=str(tmpdir), + max_stories=None, + e2e=True, + ) + + failed_stories = rasa.shared.utils.io.read_file(stories_path) + assert ( + "- intent: request_restaurant" + " # predicted: request_restaurant: i am looking for [greek](cuisine) food" + in failed_stories + ) diff --git a/tests/core/test_examples.py b/tests/core/test_examples.py new file mode 100644 index 0000000..337eea9 --- /dev/null +++ b/tests/core/test_examples.py @@ -0,0 +1,136 @@ +import json +from typing import Text, Optional, Dict, Any + +import pytest +from aioresponses import aioresponses + +from rasa.core.agent import Agent +from rasa.shared.core.domain import Domain +from rasa.utils.endpoints import ClientResponseError + + +@pytest.mark.timeout(300, func_only=True) +async def test_moodbot_example(trained_moodbot_path: Text): + agent = Agent.load(trained_moodbot_path) + + responses = await agent.handle_text("/greet") + assert responses[0]["text"] == "Hey! How are you?" + + responses.extend(await agent.handle_text("/mood_unhappy")) + assert responses[-1]["text"] in {"Did that help you?"} + + # (there is a 'I am on it' message in the middle we are not checking) + assert len(responses) == 4 + + moodbot_domain = Domain.load("data/test_moodbot/domain.yml") + assert agent.domain.action_names_or_texts == moodbot_domain.action_names_or_texts + assert agent.domain.intents == moodbot_domain.intents + assert agent.domain.entities == moodbot_domain.entities + assert agent.domain.responses == moodbot_domain.responses + assert [s.name for s in agent.domain.slots] == [ + s.name for s in moodbot_domain.slots + ] + + +@pytest.mark.timeout(300, func_only=True) +async def test_formbot_example(form_bot_agent: Agent): + def response_for_slot(slot: Text) -> Dict[Text, Any]: + if slot: + form = "restaurant_form" + response = f"utter_ask_{slot}" + else: + form = None + response = "utter_submit" + + return { + "events": [ + {"event": "form", "name": form, "timestamp": None}, + { + "event": "slot", + "timestamp": None, + "name": "requested_slot", + "value": slot, + }, + ], + "responses": [{"response": response}], + } + + async def mock_form_happy_path( + input_text: Text, output_text: Text, slot: Optional[Text] = None + ) -> None: + with aioresponses() as mocked: + mocked.post( + "https://example.com/webhooks/actions", + payload=response_for_slot(slot), + repeat=True, + ) + responses = await form_bot_agent.handle_text(input_text) + assert responses[0]["text"] == output_text + + async def mock_form_unhappy_path( + input_text: Text, output_text: Text, slot: Optional[Text] + ) -> None: + response_error = { + "error": f"Failed to extract slot {slot} with action restaurant_form", + "action_name": "restaurant_form", + } + with aioresponses() as mocked: + # Request which rejects form execution + mocked.post( + "https://example.com/webhooks/actions", + repeat=False, + exception=ClientResponseError(400, "", json.dumps(response_error)), + ) + # Request after returning from unhappy path which sets next requested slot + mocked.post( + "https://example.com/webhooks/actions", + payload=response_for_slot(slot), + repeat=True, + ) + responses = await form_bot_agent.handle_text(input_text) + assert responses[0]["text"] == output_text + + await mock_form_happy_path("/request_restaurant", "What cuisine?", slot="cuisine") + await mock_form_unhappy_path("/chitchat", "chitchat", slot="cuisine") + await mock_form_happy_path( + '/inform{"cuisine": "mexican"}', "How many people?", slot="num_people" + ) + await mock_form_happy_path( + '/inform{"number": "2"}', "Do you want to sit outside?", slot="outdoor_seating" + ) + await mock_form_happy_path( + "/affirm", "Please provide additional preferences", slot="preferences" + ) + + responses = await form_bot_agent.handle_text("/restart") + assert responses[0]["text"] == "restarted" + + responses = await form_bot_agent.handle_text("/greet") + assert ( + responses[0]["text"] + == "Hello! I am restaurant search assistant! How can I help?" + ) + + await mock_form_happy_path("/request_restaurant", "What cuisine?", slot="cuisine") + await mock_form_happy_path( + '/inform{"cuisine": "mexican"}', "How many people?", slot="num_people" + ) + await mock_form_happy_path( + '/inform{"number": "2"}', "Do you want to sit outside?", slot="outdoor_seating" + ) + await mock_form_unhappy_path( + "/stop", "Do you want to continue?", slot="outdoor_seating" + ) + await mock_form_happy_path( + "/affirm", "Do you want to sit outside?", slot="outdoor_seating" + ) + await mock_form_happy_path( + "/affirm", "Please provide additional preferences", slot="preferences" + ) + await mock_form_happy_path( + "/deny", "Please give your feedback on your experience so far", slot="feedback" + ) + await mock_form_happy_path('/inform{"feedback": "great"}', "All done!") + + responses = await form_bot_agent.handle_text("/thankyou") + assert responses[0]["text"] == "You are welcome :)" diff --git a/tests/core/test_exporter.py b/tests/core/test_exporter.py new file mode 100644 index 0000000..0ea78f4 --- /dev/null +++ b/tests/core/test_exporter.py @@ -0,0 +1,322 @@ +import uuid +from pathlib import Path +from typing import Any, AsyncIterator, Dict, Optional, Text, List +from unittest.mock import Mock + +import pytest + +from rasa.shared.core.constants import ACTION_SESSION_START_NAME +from rasa.shared.core.domain import Domain + +from rasa.core.brokers.pika import PikaEventBroker +from rasa.core.brokers.sql import SQLEventBroker +from rasa.core.constants import RASA_EXPORT_PROCESS_ID_HEADER_NAME +from rasa.shared.core.events import Event, SessionStarted, ActionExecuted +from rasa.core.tracker_store import SQLTrackerStore +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.exceptions import ( + NoConversationsInTrackerStoreError, + NoEventsToMigrateError, + PublishingError, +) +from tests.conftest import MockExporter, random_user_uttered_event, AsyncMock + + +@pytest.mark.parametrize( + "requested_ids,available_ids,expected", + [(["1"], ["1"], ["1"]), (["1", "2"], ["2"], ["2"]), (None, ["2"], ["2"])], +) +async def test_get_conversation_ids_to_process( + requested_ids: Optional[List[Text]], + available_ids: Optional[List[Text]], + expected: Optional[List[Text]], +): + # create and mock tracker store containing `available_ids` as keys + tracker_store = Mock() + tracker_store.keys = AsyncMock(return_value=available_ids) + + exporter = MockExporter(tracker_store) + exporter.requested_conversation_ids = requested_ids + + # noinspection PyProtectedMember + assert await exporter._get_conversation_ids_to_process() == set(expected) + + +@pytest.mark.parametrize( + "requested_ids,available_ids,exception", + [ + (["1"], [], NoConversationsInTrackerStoreError), # no IDs in tracker store + (None, [], NoConversationsInTrackerStoreError), # without requested IDs + ( + ["1", "2", "3"], + ["4", "5", "6"], + NoEventsToMigrateError, + ), # no overlap between requested IDs and those available + ], +) +async def test_get_conversation_ids_to_process_error( + requested_ids: Optional[List[Text]], available_ids: List[Text], exception: Exception +): + # create and mock tracker store containing `available_ids` as keys + tracker_store = Mock() + tracker_store.keys = AsyncMock(return_value=available_ids) + + exporter = MockExporter(tracker_store) + exporter.requested_conversation_ids = requested_ids + + with pytest.raises(exception): + # noinspection PyProtectedMember + await exporter._get_conversation_ids_to_process() + + +async def test_fetch_events_within_time_range(): + conversation_ids = ["some-id", "another-id"] + + # prepare events from different senders and different timestamps + event_1 = random_user_uttered_event(1) + event_2 = random_user_uttered_event(3) + event_3 = random_user_uttered_event(2) + events = {conversation_ids[0]: [event_1, event_2], conversation_ids[1]: [event_3]} + + def _get_tracker(conversation_id: Text) -> DialogueStateTracker: + return DialogueStateTracker.from_events( + conversation_id, events[conversation_id] + ) + + # create mock tracker store + tracker_store = AsyncMock() + tracker_store.retrieve_full_tracker.side_effect = _get_tracker + tracker_store.keys = AsyncMock(return_value=conversation_ids) + + exporter = MockExporter(tracker_store) + exporter.requested_conversation_ids = conversation_ids + + # noinspection PyProtectedMember + fetched_events = [e async for e in exporter._fetch_events_within_time_range()] + + # events should come back for all requested conversation IDs + assert all( + any(_id in event["sender_id"] for event in fetched_events) + for _id in conversation_ids + ) + + for _id in conversation_ids: + # events are sorted by timestamp + event_timestamps = [ + e["timestamp"] for e in fetched_events if e["sender_id"] == _id + ] + assert event_timestamps == [e.timestamp for e in events[_id]] + + +async def test_fetch_events_within_time_range_tracker_does_not_err(): + # create mock tracker store that returns `None` on `retrieve_full_tracker()` + tracker_store = Mock() + + tracker_store.keys = AsyncMock(return_value=[uuid.uuid4()]) + tracker_store.retrieve_full_tracker = AsyncMock(return_value=None) + + exporter = MockExporter(tracker_store) + + assert not [e async for e in exporter._fetch_events_within_time_range()] + + +async def test_fetch_events_within_time_range_tracker_contains_no_events(): + # create mock tracker store that returns `None` on `retrieve_full_tracker()` + tracker_store = Mock() + + tracker_store.keys = AsyncMock(return_value=["a great ID"]) + tracker_store.retrieve_full_tracker = AsyncMock( + return_value=DialogueStateTracker.from_events("a great ID", []) + ) + + exporter = MockExporter(tracker_store) + + assert not [e async for e in exporter._fetch_events_within_time_range()] + + +async def mock_tracker_store( + events: Dict[Text, List[Event]], tmp_path: Path +) -> DialogueStateTracker: + tracker_store = SQLTrackerStore( + dialect="sqlite", + db=str(tmp_path / f"{uuid.uuid4().hex}.db"), + domain=Domain.empty(), + ) + + for conversation_id, conversation_events in events.items(): + tracker = DialogueStateTracker.from_events( + conversation_id, evts=conversation_events + ) + await tracker_store.save(tracker) + return tracker_store + + +async def test_fetch_events_within_time_range_with_session_events(tmp_path: Path): + conversation_id = "test_fetch_events_within_time_range_with_sessions" + + events = { + conversation_id: [ + random_user_uttered_event(1), + SessionStarted(2), + ActionExecuted(timestamp=3, action_name=ACTION_SESSION_START_NAME), + random_user_uttered_event(4), + ] + } + tracker_store = await mock_tracker_store(events, tmp_path) + + exporter = MockExporter(tracker_store=tracker_store) + + # noinspection PyProtectedMember + fetched_events = [e async for e in exporter._fetch_events_within_time_range()] + + assert len(fetched_events) == len(events[conversation_id]) + + +# noinspection PyProtectedMember +async def test_sort_and_select_events_by_timestamp(tmp_path: Path): + conversation_id = "test_sort_and_select_events_by_timestamp" + + conversations = { + conversation_id: [ + random_user_uttered_event(3), + random_user_uttered_event(2), + random_user_uttered_event(1), + ] + } + tracker_store = await mock_tracker_store(conversations, tmp_path) + exporter = MockExporter(tracker_store) + event_ts = [e.timestamp for e in conversations[conversation_id]] + + selected_events = [e async for e in exporter._fetch_events_within_time_range()] + + # events are sorted + assert selected_events == list( + sorted(selected_events, key=lambda e: e["timestamp"]) + ) + + # apply minimum timestamp requirement, expect to get only two events back + exporter.minimum_timestamp = 2.0 + selected_events = [e async for e in exporter._fetch_events_within_time_range()] + assert [e["timestamp"] for e in selected_events] == [ + event_ts[1], + event_ts[0], + ] + exporter.minimum_timestamp = None + + # apply maximum timestamp requirement, expect to get only one + exporter.maximum_timestamp = 1.1 + selected_events = [e async for e in exporter._fetch_events_within_time_range()] + assert [e["timestamp"] for e in selected_events] == [event_ts[2]] + + # apply both requirements, get one event back + exporter.minimum_timestamp = 2.0 + exporter.maximum_timestamp = 2.1 + selected_events = [e async for e in exporter._fetch_events_within_time_range()] + assert [e["timestamp"] for e in selected_events] == [event_ts[1]] + + +# noinspection PyProtectedMember +async def test_sort_and_select_events_by_timestamp_error(tmp_path: Path): + conversation_id = "test_sort_and_select_events_by_timestamp_error" + + conversations = { + conversation_id: [ + random_user_uttered_event(3), + ] + } + tracker_store = await mock_tracker_store(conversations, tmp_path) + exporter = MockExporter(tracker_store) + + # supply list of events, apply timestamp constraint and no events survive + exporter.minimum_timestamp = 3.1 + assert not [e async for e in exporter._fetch_events_within_time_range()] + + +def test_get_message_headers_pika_event_broker(): + event_broker = Mock(spec=PikaEventBroker) + exporter = MockExporter(event_broker=event_broker) + + # noinspection PyProtectedMember + headers = exporter._get_message_headers() + + assert headers.get(RASA_EXPORT_PROCESS_ID_HEADER_NAME) + + +def test_get_message_headers_non_pika_broker(): + event_broker = Mock() + exporter = MockExporter(event_broker=event_broker) + + # noinspection PyProtectedMember + assert exporter._get_message_headers() is None + + +def test_publish_with_headers_pika_event_broker(): + event_broker = Mock(spec=PikaEventBroker) + exporter = MockExporter(event_broker=event_broker) + + headers = {"some": "header"} + event = {"some": "event"} + + # noinspection PyProtectedMember + exporter._publish_with_message_headers(event, headers) + + # the `PikaEventBroker`'s `publish()` method was called with both + # the `event` and `headers` arguments + event_broker.publish.assert_called_with(event=event, headers=headers) + + +def test_publish_with_headers_non_pika_event_broker(): + event_broker = Mock(SQLEventBroker) + exporter = MockExporter(event_broker=event_broker) + + headers = {"some": "header"} + event = {"some": "event"} + + # noinspection PyProtectedMember + exporter._publish_with_message_headers(event, headers) + + # the `SQLEventBroker`'s `publish()` method was called with only the `event` + # argument + event_broker.publish.assert_called_with(event) + + +async def test_publishing_error(): + # mock event broker so it raises on `publish()` + + event_broker = Mock() + event_broker.publish.side_effect = ValueError() + + exporter = MockExporter(event_broker=event_broker) + + user_event = random_user_uttered_event(1).as_dict() + user_event["sender_id"] = uuid.uuid4().hex + + async def _mocked_fetch() -> AsyncIterator[Dict[Text, Any]]: + yield user_event + + # noinspection PyProtectedMember + exporter._fetch_events_within_time_range = _mocked_fetch + + # run the export function + with pytest.raises(PublishingError): + await exporter.publish_events() + + +async def test_closing_broker(): + exporter = MockExporter(event_broker=SQLEventBroker()) + + # noinspection PyProtectedMember + async def _mocked_fetch() -> AsyncIterator[Dict[Text, Any]]: + # need an async generator that is empty + if False: + yield + return + + # noinspection PyProtectedMember + exporter._fetch_events_within_time_range = _mocked_fetch + + # run the export function + with pytest.warns(None) as warnings: + await exporter.publish_events() + + assert len(warnings) == 0 diff --git a/tests/core/test_http_interpreter.py b/tests/core/test_http_interpreter.py new file mode 100644 index 0000000..373277f --- /dev/null +++ b/tests/core/test_http_interpreter.py @@ -0,0 +1,72 @@ +from unittest.mock import patch + +import pytest +from aioresponses import aioresponses +from rasa.core.channels import UserMessage +from rasa.core.http_interpreter import RasaNLUHttpInterpreter +from rasa.utils.endpoints import EndpointConfig +from tests.utilities import json_of_latest_request, latest_request + + +@pytest.mark.parametrize( + "endpoint_url,joined_url", + [ + ("https://example.com", "https://example.com/model/parse"), + ("https://example.com/a", "https://example.com/a/model/parse"), + ("https://example.com/a/", "https://example.com/a/model/parse"), + ], +) +async def test_http_interpreter(endpoint_url, joined_url): + """ + GIVEN an endpoint url + WHEN a RasaNLUHttpInterpreter is created using the endpoint url + THEN the parse method sends a request to the joined url. + """ + with aioresponses() as mocked: + mocked.post(joined_url) + + endpoint = EndpointConfig(endpoint_url) + interpreter = RasaNLUHttpInterpreter(endpoint_config=endpoint) + message = UserMessage(text="message_text", sender_id="message_id") + await interpreter.parse(message) + + r = latest_request(mocked, "POST", joined_url) + + query = json_of_latest_request(r) + response = {"text": "message_text", "token": None, "message_id": "message_id"} + + assert query == response + + +@pytest.fixture +def nlu_interpreter(): + with patch("aiohttp.ClientSession") as mock_session: + endpoint = EndpointConfig("https://example.com/a/") + yield RasaNLUHttpInterpreter(endpoint_config=endpoint) + + # Assert that the session object is initialized correctly + assert mock_session.called + + +async def test_same_session_object_used(nlu_interpreter): + """ + GIVEN a RasaNLUHttpInterpreter + WHEN the parse() method is called multiple times + THEN the same session object is used for all requests. + """ + # Call the parse() method multiple times + session = nlu_interpreter.session + + result1 = await nlu_interpreter.parse( + UserMessage(text="message_text_1", sender_id="message_id_1") + ) + assert nlu_interpreter.session == session + + result2 = await nlu_interpreter.parse( + UserMessage(text="message_text_2", sender_id="message_id_2") + ) + assert nlu_interpreter.session == session + + # Assert that the same session object is used for all requests + assert result1 is not None + assert result2 is not None diff --git a/tests/core/test_lock_store.py b/tests/core/test_lock_store.py new file mode 100644 index 0000000..d90cd26 --- /dev/null +++ b/tests/core/test_lock_store.py @@ -0,0 +1,403 @@ +import asyncio +import logging +import sys +import time +from pathlib import Path +from typing import Text +from unittest.mock import Mock, patch + +import numpy as np +import pytest +import rasa.core.lock_store +from _pytest.logging import LogCaptureFixture +from _pytest.monkeypatch import MonkeyPatch +from rasa.core.agent import Agent +from rasa.core.channels import UserMessage +from rasa.core.constants import DEFAULT_LOCK_LIFETIME +from rasa.core.lock import TicketLock +from rasa.core.lock_store import ( + DEFAULT_REDIS_LOCK_STORE_KEY_PREFIX, + InMemoryLockStore, + LockError, + LockStore, + RedisLockStore, +) +from rasa.shared.constants import INTENT_MESSAGE_PREFIX +from rasa.shared.exceptions import ConnectionException +from rasa.utils.endpoints import EndpointConfig, read_endpoint_config + + +class FakeRedisLockStore(RedisLockStore): + """Fake `RedisLockStore` using `fakeredis` library.""" + + # skipcq: PYL-W0231 + # noinspection PyMissingConstructor + def __init__(self): + import fakeredis + + self.red = fakeredis.FakeStrictRedis() + + # added in redis==3.3.0, but not yet in fakeredis + self.red.connection_pool.connection_class.health_check_interval = 0 + + self.key_prefix = DEFAULT_REDIS_LOCK_STORE_KEY_PREFIX + + +def test_issue_ticket(): + lock = TicketLock("random id 0") + + # no lock issued + assert lock.last_issued == -1 + assert lock.now_serving == 0 + + # no one is waiting + assert not lock.is_someone_waiting() + + # issue ticket + ticket = lock.issue_ticket(1) + assert ticket == 0 + assert lock.last_issued == 0 + assert lock.now_serving == 0 + + # someone is waiting + assert lock.is_someone_waiting() + + +def test_remove_expired_tickets(): + lock = TicketLock("random id 1") + + # issue one long- and one short-lived ticket + _ = list(map(lock.issue_ticket, [k for k in [0.01, 10]])) + + # both tickets are there + assert len(lock.tickets) == 2 + + # sleep and only one ticket should be left + time.sleep(0.02) + lock.remove_expired_tickets() + assert len(lock.tickets) == 1 + + +@pytest.mark.parametrize("lock_store", [InMemoryLockStore(), FakeRedisLockStore()]) +def test_create_lock_store(lock_store: LockStore): + conversation_id = "my id 0" + + # create and lock + lock = lock_store.create_lock(conversation_id) + lock_store.save_lock(lock) + lock = lock_store.get_lock(conversation_id) + assert lock + assert lock.conversation_id == conversation_id + + +def test_raise_connection_exception_redis_lock_store(monkeypatch: MonkeyPatch): + monkeypatch.setattr( + rasa.core.lock_store, "RedisLockStore", Mock(side_effect=ConnectionError()) + ) + + with pytest.raises(ConnectionException): + LockStore.create( + EndpointConfig(username="username", password="password", type="redis") + ) + + +@pytest.mark.parametrize("lock_store", [InMemoryLockStore(), FakeRedisLockStore()]) +def test_serve_ticket(lock_store: LockStore): + conversation_id = "my id 1" + + lock = lock_store.create_lock(conversation_id) + lock_store.save_lock(lock) + + # issue ticket with long lifetime + ticket_0 = lock_store.issue_ticket(conversation_id, 10) + assert ticket_0 == 0 + + lock = lock_store.get_lock(conversation_id) + assert lock.last_issued == ticket_0 + assert lock.now_serving == ticket_0 + assert lock.is_someone_waiting() + + # issue another ticket + ticket_1 = lock_store.issue_ticket(conversation_id, 10) + + # finish serving ticket_0 + lock_store.finish_serving(conversation_id, ticket_0) + + lock = lock_store.get_lock(conversation_id) + + assert lock.last_issued == ticket_1 + assert lock.now_serving == ticket_1 + assert lock.is_someone_waiting() + + # serve second ticket and no one should be waiting + lock_store.finish_serving(conversation_id, ticket_1) + + lock = lock_store.get_lock(conversation_id) + assert not lock.is_someone_waiting() + + +# noinspection PyProtectedMember +@pytest.mark.parametrize("lock_store", [InMemoryLockStore(), FakeRedisLockStore()]) +def test_lock_expiration(lock_store: LockStore): + conversation_id = "my id 2" + lock = lock_store.create_lock(conversation_id) + lock_store.save_lock(lock) + + # issue ticket with long lifetime + ticket = lock.issue_ticket(10) + assert ticket == 0 + assert not lock._ticket_for_ticket_number(ticket).has_expired() + + # issue ticket with short lifetime + ticket = lock.issue_ticket(0.00001) + time.sleep(0.00002) + assert ticket == 1 + assert lock._ticket_for_ticket_number(ticket) is None + + # newly assigned ticket should get number 1 again + assert lock.issue_ticket(10) == 1 + + +async def test_multiple_conversation_ids(default_agent: Agent): + text = INTENT_MESSAGE_PREFIX + 'greet{"name":"Rasa"}' + + conversation_ids = [f"conversation {i}" for i in range(2)] + + # ensure conversations are processed in order + tasks = [default_agent.handle_text(text, sender_id=_id) for _id in conversation_ids] + results = await asyncio.gather(*tasks) + + assert results + processed_ids = [result[0]["recipient_id"] for result in results] + assert processed_ids == conversation_ids + + +@pytest.mark.xfail( + sys.platform == "win32", + reason="This test sometimes fails on Windows. We want to investigate it further", +) +async def test_message_order(tmp_path: Path, default_agent: Agent): + start_time = time.time() + n_messages = 10 + lock_wait = 0.5 + + # let's write the incoming order of messages and the order of results to temp files + results_file = tmp_path / "results_file" + incoming_order_file = tmp_path / "incoming_order_file" + + # We need to mock `Agent.handle_message()` so we can introduce an + # artificial holdup (`wait_time_in_seconds`). In the mocked method, we'll + # record messages as they come and and as they're processed in files so we + # can check the order later on. We don't need the return value of this method so + # we'll just return None. + async def mocked_handle_message(self, message: UserMessage, wait: float) -> None: + # write incoming message to file + with open(str(incoming_order_file), "a+") as f_0: + f_0.write(message.text + "\n") + + async with self.lock_store.lock( + message.sender_id, wait_time_in_seconds=lock_wait + ): + # hold up the message processing after the lock has been acquired + await asyncio.sleep(wait) + + # write message to file as it's processed + with open(str(results_file), "a+") as f_1: + f_1.write(message.text + "\n") + + return None + + # We'll send n_messages from the same sender_id with different blocking times + # after the lock has been acquired. + # We have to ensure that the messages are processed in the right order. + with patch.object(Agent, "handle_message", mocked_handle_message): + # use decreasing wait times so that every message after the first one + # does not acquire its lock immediately + wait_times = np.linspace(0.1, 0.05, n_messages) + tasks = [ + default_agent.handle_message( + UserMessage(f"sender {i}", sender_id="some id"), wait=k + ) + for i, k in enumerate(wait_times) + ] + + # execute futures + await asyncio.gather(*(asyncio.ensure_future(t) for t in tasks)) + + expected_order = [f"sender {i}" for i in range(len(wait_times))] + + # ensure order of incoming messages is as expected + with open(str(incoming_order_file)) as f: + incoming_order = [line for line in f.read().split("\n") if line] + assert incoming_order == expected_order + + # ensure results are processed in expected order + with open(str(results_file)) as f: + results_order = [line for line in f.read().split("\n") if line] + assert results_order == expected_order + + # Every message after the first one will wait `lock_wait` seconds to acquire its + # lock (`wait_time_in_seconds` kwarg in `lock_store.lock()`). + # Let's make sure that this is not blocking and test that total test + # execution time is less than the sum of all wait times plus + # (n_messages - 1) * lock_wait + time_limit = np.sum(wait_times[1:]) + time_limit += (n_messages - 1) * lock_wait + assert time.time() - start_time < time_limit + + +@pytest.mark.xfail( + sys.platform == "win32", + reason="This test sometimes fails on Windows. We want to investigate it further", +) +async def test_lock_error(default_agent: Agent): + lock_lifetime = 0.01 + wait_time_in_seconds = 0.01 + holdup = 0.5 + + # Mock message handler again to add a wait time holding up the lock + # after it's been acquired + async def mocked_handle_message(self, message: UserMessage) -> None: + async with self.lock_store.lock( + message.sender_id, + wait_time_in_seconds=wait_time_in_seconds, + lock_lifetime=lock_lifetime, + ): + # hold up the message processing after the lock has been acquired + await asyncio.sleep(holdup) + + return None + + with patch.object(Agent, "handle_message", mocked_handle_message): + # first message blocks the lock for `holdup`, + # meaning the second message will not be able to acquire a lock + tasks = [ + default_agent.handle_message( + UserMessage(f"sender {i}", sender_id="some id") + ) + for i in range(2) + ] + + with pytest.raises(LockError): + await asyncio.gather(*(asyncio.ensure_future(t) for t in tasks)) + + +async def test_lock_lifetime_environment_variable(monkeypatch: MonkeyPatch): + import rasa.core.lock_store + + # by default lock lifetime is `DEFAULT_LOCK_LIFETIME` + assert rasa.core.lock_store._get_lock_lifetime() == DEFAULT_LOCK_LIFETIME + + # set new lock lifetime as environment variable + new_lock_lifetime = 123 + monkeypatch.setenv("TICKET_LOCK_LIFETIME", str(new_lock_lifetime)) + + assert rasa.core.lock_store._get_lock_lifetime() == new_lock_lifetime + + +@pytest.mark.parametrize("lock_store", [InMemoryLockStore(), FakeRedisLockStore()]) +async def test_acquire_lock_debug_message( + lock_store: LockStore, caplog: LogCaptureFixture +): + conversation_id = "test_acquire_lock_debug_message" + wait_time_in_seconds = 0.01 + + async def locking_task() -> None: + async with lock_store.lock( + conversation_id, wait_time_in_seconds=wait_time_in_seconds + ): + # Do a very short sleep so that the other tasks can try to acquire the lock + # in the meantime + await asyncio.sleep(0.0) + + with caplog.at_level(logging.DEBUG): + await asyncio.gather( + locking_task(), # Gets served immediately + locking_task(), # Gets served second + locking_task(), # Gets served last + ) + + assert any( + f"because 1 other item(s) for this conversation ID have to be finished " + f"processing first. Retrying in {wait_time_in_seconds} seconds ..." in message + for message in caplog.messages + ) + + assert any( + f"because 2 other item(s) for this conversation ID have to be finished " + f"processing first. Retrying in {wait_time_in_seconds} seconds ..." in message + for message in caplog.messages + ) + + +async def test_redis_lock_store_timeout(monkeypatch: MonkeyPatch): + import redis.exceptions + + lock_store = FakeRedisLockStore() + monkeypatch.setattr( + lock_store, + lock_store.get_or_create_lock.__name__, + Mock(side_effect=redis.exceptions.TimeoutError), + ) + + with pytest.raises(LockError): + async with lock_store.lock("some sender"): + pass + + +async def test_redis_lock_store_with_invalid_prefix(monkeypatch: MonkeyPatch): + import redis.exceptions + + lock_store = FakeRedisLockStore() + + prefix = "!asdf234 34#" + lock_store._set_key_prefix(prefix) + assert lock_store.key_prefix == DEFAULT_REDIS_LOCK_STORE_KEY_PREFIX + + monkeypatch.setattr( + lock_store, + lock_store.get_or_create_lock.__name__, + Mock(side_effect=redis.exceptions.TimeoutError), + ) + + with pytest.raises(LockError): + async with lock_store.lock("some sender"): + pass + + +async def test_redis_lock_store_with_valid_prefix(monkeypatch: MonkeyPatch): + import redis.exceptions + + lock_store = FakeRedisLockStore() + + prefix = "chatbot42" + lock_store._set_key_prefix(prefix) + assert lock_store.key_prefix == prefix + ":" + DEFAULT_REDIS_LOCK_STORE_KEY_PREFIX + + monkeypatch.setattr( + lock_store, + lock_store.get_or_create_lock.__name__, + Mock(side_effect=redis.exceptions.TimeoutError), + ) + + with pytest.raises(LockError): + async with lock_store.lock("some sender"): + pass + + +def test_create_lock_store_from_endpoint_config(endpoints_path: Text): + store = read_endpoint_config(endpoints_path, endpoint_type="lock_store") + tracker_store = RedisLockStore( + host="localhost", + port=6379, + db=0, + username="username", + password="password", + use_ssl=True, + ssl_keyfile="keyfile.key", + ssl_certfile="certfile.crt", + ssl_ca_certs="my-bundle.ca-bundle", + key_prefix="lock", + ) + + assert isinstance(tracker_store, type(LockStore.create(store))) diff --git a/tests/core/test_migrate.py b/tests/core/test_migrate.py new file mode 100644 index 0000000..d6a034c --- /dev/null +++ b/tests/core/test_migrate.py @@ -0,0 +1,1118 @@ +import itertools +import shutil +import re +import textwrap +from pathlib import Path +from typing import Text, Any, Tuple, Callable +from unittest.mock import patch, call + +import pytest + +import rasa.shared.utils.io +from rasa.core import migrate +from rasa.shared.core.domain import Domain +from rasa.shared.exceptions import RasaException +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION + + +def prepare_domain_path(directory: Path, domain_content: Text, file_name: Text) -> Path: + original_content = textwrap.dedent(domain_content) + domain_file = directory / file_name + rasa.shared.utils.io.write_text_file(original_content, domain_file) + return domain_file + + +@pytest.fixture() +def domain_out_file(tmp_path: Path) -> Path: + return tmp_path / "custom_new_domain.yml" + + +def test_migrate_domain_format_with_required_slots( + tmp_path: Path, domain_out_file: Path +): + existing_domain_file = prepare_domain_path( + tmp_path, + """ + version: "2.0" + intents: + - greet + - affirm + - inform + entities: + - city + - name + slots: + location: + type: text + influence_conversation: false + name: + type: text + influence_conversation: false + auto_fill: false + email: + type: text + influence_conversation: false + forms: + booking_form: + ignored_intents: + - greet + required_slots: + location: + - type: from_entity + entity: city + email: + - type: from_text + intent: inform + name: + - type: from_entity + entity: surname + """, + "domain.yml", + ) + + migrate.migrate_domain_format(existing_domain_file, domain_out_file) + + domain = Domain.from_path(domain_out_file) + assert domain + + old_domain_path = tmp_path / "original_domain.yml" + assert old_domain_path + + migrated_domain = rasa.shared.utils.io.read_yaml_file(domain_out_file) + + migrated_training_data_version = migrated_domain.get("version") + assert migrated_training_data_version == LATEST_TRAINING_DATA_FORMAT_VERSION + + migrated_slots = migrated_domain.get("slots") + expected_slots = { + "location": { + "type": "text", + "influence_conversation": False, + "mappings": [ + { + "type": "from_entity", + "entity": "city", + "conditions": [{"active_loop": "booking_form"}], + } + ], + }, + "name": { + "type": "text", + "influence_conversation": False, + "mappings": [ + { + "type": "from_entity", + "entity": "surname", + "conditions": [{"active_loop": "booking_form"}], + } + ], + }, + "email": { + "type": "text", + "influence_conversation": False, + "mappings": [ + { + "type": "from_text", + "intent": "inform", + "conditions": [ + {"active_loop": "booking_form", "requested_slot": "email"} + ], + } + ], + }, + } + assert migrated_slots == expected_slots + + migrated_forms = migrated_domain.get("forms") + expected_forms = { + "booking_form": { + "ignored_intents": ["greet"], + "required_slots": ["location", "email", "name"], + } + } + assert migrated_forms == expected_forms + + +def test_migrate_domain_form_without_required_slots( + tmp_path: Path, domain_out_file: Path +): + existing_domain_file = prepare_domain_path( + tmp_path, + """ + version: "2.0" + intents: + - greet + - affirm + - inform + entities: + - city + - name + - surname + slots: + location: + type: text + influence_conversation: false + name: + type: text + influence_conversation: false + auto_fill: false + email: + type: text + influence_conversation: false + forms: + booking_form: + ignored_intents: + - greet + location: + - type: from_entity + entity: city + email: + - type: from_text + intent: inform + name: + - type: from_entity + entity: surname + """, + "domain.yml", + ) + migrate.migrate_domain_format(existing_domain_file, domain_out_file) + + domain = Domain.from_path(domain_out_file) + assert domain + + old_domain_path = tmp_path / "original_domain.yml" + assert old_domain_path + + migrated_domain = rasa.shared.utils.io.read_yaml_file(domain_out_file) + + migrated_slots = migrated_domain.get("slots") + expected_slots = { + "location": { + "type": "text", + "influence_conversation": False, + "mappings": [ + { + "type": "from_entity", + "entity": "city", + "conditions": [{"active_loop": "booking_form"}], + } + ], + }, + "name": { + "type": "text", + "influence_conversation": False, + "mappings": [ + { + "type": "from_entity", + "entity": "surname", + "conditions": [{"active_loop": "booking_form"}], + } + ], + }, + "email": { + "type": "text", + "influence_conversation": False, + "mappings": [ + { + "type": "from_text", + "intent": "inform", + "conditions": [ + {"active_loop": "booking_form", "requested_slot": "email"} + ], + } + ], + }, + } + assert migrated_slots == expected_slots + + migrated_forms = migrated_domain.get("forms") + expected_forms = { + "booking_form": { + "ignored_intents": ["greet"], + "required_slots": ["location", "email", "name"], + } + } + assert migrated_forms == expected_forms + + +@pytest.mark.parametrize( + "slot_type,value", + [ + ("bool", True), + ("float", 1), + ("text", "out"), + ("categorical", "test"), + ("list", ["out"]), + ("any", "etc"), + ], +) +def test_migrate_domain_with_diff_slot_types( + slot_type: Text, value: Any, tmp_path: Path, domain_out_file: Path +): + existing_domain_file = prepare_domain_path( + tmp_path, + f""" + version: "2.0" + entities: + - outdoor + slots: + outdoor_seating: + type: {slot_type} + influence_conversation: false + forms: + reservation_form: + required_slots: + outdoor_seating: + - type: from_intent + value: {value} + intent: confirm + """, + "domain.yml", + ) + migrate.migrate_domain_format(existing_domain_file, domain_out_file) + domain = Domain.from_path(domain_out_file) + assert domain + + migrated_domain = rasa.shared.utils.io.read_yaml_file(domain_out_file) + migrated_slots = migrated_domain.get("slots") + expected_slots = { + "outdoor_seating": { + "type": slot_type, + "influence_conversation": False, + "mappings": [ + { + "type": "from_intent", + "value": value, + "intent": "confirm", + "conditions": [ + { + "active_loop": "reservation_form", + "requested_slot": "outdoor_seating", + } + ], + } + ], + } + } + assert migrated_slots == expected_slots + + +def test_migrate_domain_format_from_dir(tmp_path: Path): + domain_dir = tmp_path / "domain" + domain_dir.mkdir() + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + entities: + - outdoor + slots: + outdoor_seating: + type: bool + influence_conversation: false + """, + "slots.yml", + ) + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + forms: + reservation_form: + required_slots: + outdoor_seating: + - type: from_intent + value: true + intent: confirm + """, + "forms.yml", + ) + + domain_out_dir = tmp_path / "new_domain" + domain_out_dir.mkdir() + + migrate.migrate_domain_format(domain_dir, domain_out_dir) + domain = Domain.from_directory(str(domain_out_dir)) + + assert domain + + old_domain_path = tmp_path / "original_domain" + assert old_domain_path.exists() + + for file in old_domain_path.iterdir(): + assert file.name in ["slots.yml", "forms.yml"] + + for file in domain_out_dir.iterdir(): + assert file.name in ["slots.yml", "forms.yml"] + migrated_file = rasa.shared.utils.io.read_yaml_file(file) + + migrated_training_data_version = migrated_file.get("version") + assert migrated_training_data_version == LATEST_TRAINING_DATA_FORMAT_VERSION + + +def test_migrate_domain_all_keys(tmp_path: Path, domain_out_file: Path): + existing_domain_file = prepare_domain_path( + tmp_path, + """ + version: "2.0" + intents: + - greet + entities: + - city + slots: + city: + type: text + influence_conversation: false + responses: + utter_greet: + - text: "Hi there!" + actions: + - action_check_time + forms: + booking_form: + required_slots: + city: + - type: from_entity + entity: city + """, + "domain.yml", + ) + migrate.migrate_domain_format(existing_domain_file, domain_out_file) + domain = Domain.from_path(domain_out_file) + assert domain + + migrated_domain = rasa.shared.utils.io.read_yaml_file(domain_out_file) + migrated_intents = migrated_domain.get("intents") + assert "greet" in migrated_intents + + migrated_entities = migrated_domain.get("entities") + assert "city" in migrated_entities + + migrated_responses = migrated_domain.get("responses") + assert "utter_greet" in migrated_responses + + migrated_actions = migrated_domain.get("actions") + assert "action_check_time" in migrated_actions + + migrated_training_data_version = migrated_domain.get("version") + assert migrated_training_data_version == LATEST_TRAINING_DATA_FORMAT_VERSION + + +def test_migrate_domain_format_with_custom_slot(tmp_path: Path, domain_out_file: Path): + existing_domain_file = prepare_domain_path( + tmp_path, + """ + version: "2.0" + intents: + - greet + - affirm + - inform + entities: + - city + - name + slots: + location: + type: text + influence_conversation: false + name: + type: text + influence_conversation: false + auto_fill: false + email: + type: text + influence_conversation: false + forms: + booking_form: + ignored_intents: + - greet + required_slots: + location: + - type: from_entity + entity: city + email: + - type: from_text + intent: inform + """, + "domain.yml", + ) + + with pytest.warns(UserWarning, match="A custom mapping was added to slot 'name'."): + migrate.migrate_domain_format(existing_domain_file, domain_out_file) + + domain = Domain.from_path(domain_out_file) + assert domain + + migrated_domain = rasa.shared.utils.io.read_yaml_file(domain_out_file) + migrated_slots = migrated_domain.get("slots") + custom_slot = migrated_slots.get("name") + assert custom_slot == { + "type": "text", + "influence_conversation": False, + "mappings": [{"type": "custom"}], + } + + +def test_migrate_domain_with_no_requested_slot_for_from_entity_mappings( + tmp_path: Path, domain_out_file: Path +): + existing_domain_file = prepare_domain_path( + tmp_path, + """ + version: "2.0" + intents: + - greet + - affirm + - inform + entities: + - city + slots: + location: + type: text + influence_conversation: false + email: + type: text + influence_conversation: false + forms: + some_form: + location: + - entity: city + type: from_entity + - intent: something + type: from_text + """, + "domain.yml", + ) + + migrate.migrate_domain_format(existing_domain_file, domain_out_file) + + domain = Domain.from_path(domain_out_file) + assert domain + + migrated_domain = rasa.shared.utils.io.read_yaml_file(domain_out_file) + migrated_slots = migrated_domain.get("slots") + location_slot = migrated_slots.get("location") + mappings = location_slot.get("mappings") + assert mappings[0] == { + "entity": "city", + "type": "from_entity", + "conditions": [{"active_loop": "some_form"}], + } + assert mappings[1] == { + "intent": "something", + "type": "from_text", + "conditions": [{"active_loop": "some_form", "requested_slot": "location"}], + } + + +def test_migrate_domain_format_duplicated_slots_in_forms( + tmp_path: Path, domain_out_file: Path +): + existing_domain_file = prepare_domain_path( + tmp_path, + """ + version: "2.0" + intents: + - greet + - affirm + - inform + entities: + - city + slots: + name: + type: text + influence_conversation: false + location: + type: text + influence_conversation: false + forms: + form_one: + required_slots: + name: + - type: from_text + intent: inform + location: + - type: from_text + intent: greet + form_two: + required_slots: + name: + - type: from_text + intent: inform + - type: from_intent + intent: deny + value: demo + location: + - type: from_entity + entity: city + """, + "domain.yml", + ) + migrate.migrate_domain_format(existing_domain_file, domain_out_file) + + domain = Domain.from_path(domain_out_file) + assert domain + + migrated_domain = rasa.shared.utils.io.read_yaml_file(domain_out_file) + migrated_slots = migrated_domain.get("slots") + slot_with_duplicate_mappings = migrated_slots.get("name") + assert slot_with_duplicate_mappings == { + "type": "text", + "influence_conversation": False, + "mappings": [ + { + "type": "from_text", + "intent": "inform", + "conditions": [ + {"active_loop": "form_one", "requested_slot": "name"}, + {"active_loop": "form_two", "requested_slot": "name"}, + ], + }, + { + "type": "from_intent", + "intent": "deny", + "value": "demo", + "conditions": [{"active_loop": "form_two", "requested_slot": "name"}], + }, + ], + } + slot_with_different_mapping_conditions = migrated_slots.get("location") + assert slot_with_different_mapping_conditions == { + "type": "text", + "influence_conversation": False, + "mappings": [ + { + "type": "from_text", + "intent": "greet", + "conditions": [ + {"active_loop": "form_one", "requested_slot": "location"} + ], + }, + { + "type": "from_entity", + "entity": "city", + "conditions": [{"active_loop": "form_two"}], + }, + ], + } + + +def test_migrate_domain_dir_with_out_path_None(tmp_path: Path): + domain_dir = tmp_path / "domain" + domain_dir.mkdir() + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + entities: + - outdoor + slots: + outdoor_seating: + type: bool + influence_conversation: false + """, + "slots.yml", + ) + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + forms: + reservation_form: + required_slots: + outdoor_seating: + - type: from_intent + value: true + intent: confirm + """, + "forms.yml", + ) + + migrate.migrate_domain_format(domain_dir, None) + + domain_out = tmp_path / "new_domain" + assert domain_out.exists() + + for file in domain_out.iterdir(): + assert file.name in ["slots.yml", "forms.yml"] + + domain = Domain.from_directory(str(domain_out)) + assert domain + + +def test_migrate_domain_multiple_files_with_duplicate_slots(tmp_path: Path): + domain_dir = tmp_path / "domain" + domain_dir.mkdir() + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + entities: + - outdoor + slots: + outdoor_seating: + type: bool + influence_conversation: false + """, + "slots_one.yml", + ) + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + entities: + - outdoor + slots: + cuisine: + type: text + influence_conversation: false + """, + "slots_two.yml", + ) + + with pytest.raises( + RasaException, + match="Domain files with multiple 'slots' sections were provided.", + ): + migrate.migrate_domain_format(domain_dir, None) + + +def test_migrate_domain_with_multiple_files_with_duplicate_forms(tmp_path: Path): + domain_dir = tmp_path / "domain" + domain_dir.mkdir() + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + forms: + reservation_form: + required_slots: + outdoor_seating: + - type: from_intent + value: true + intent: confirm + """, + "forms_one.yml", + ) + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + forms: + reservation_form: + required_slots: + cuisine: + - type: from_entity + entity: cuisine + """, + "forms_two.yml", + ) + + with pytest.raises( + RasaException, + match="Domain files with multiple 'forms' sections were provided.", + ): + migrate.migrate_domain_format(domain_dir, None) + + +def test_migrate_domain_from_dir_with_other_sections(tmp_path: Path): + domain_dir = tmp_path / "domain" + domain_dir.mkdir() + domain_file_one = "domain_one.yml" + domain_file_two = "domain_two.yml" + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + entities: + - outdoor + slots: + outdoor_seating: + type: bool + influence_conversation: false + """, + domain_file_one, + ) + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + intents: + - greet + forms: + reservation_form: + required_slots: + outdoor_seating: + - type: from_intent + value: true + intent: confirm + """, + domain_file_two, + ) + + new_domain_dir = tmp_path / "migrated_domain" + migrate.migrate_domain_format(domain_dir, new_domain_dir) + domain = Domain.from_directory(new_domain_dir) + assert domain + + for file in new_domain_dir.iterdir(): + migrated = rasa.shared.utils.io.read_yaml_file(file) + + migrated_training_data_version = migrated.get("version") + assert migrated_training_data_version == LATEST_TRAINING_DATA_FORMAT_VERSION + + if file.name == domain_file_one: + assert migrated.get("entities") == ["outdoor"] + elif file.name == domain_file_two: + assert migrated.get("intents") == ["greet"] + + +def test_migrate_domain_raises_exception_for_non_domain_file(tmp_path: Path): + domain_file = prepare_domain_path( + tmp_path, + """ + version: "2.0" + nlu: + - intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + """, + "domain.yml", + ) + + new_domain_file = tmp_path / "new_domain.yml" + + with pytest.raises( + RasaException, + match=f"The file '{domain_file.as_posix()}' could not " + f"be validated as a domain file.", + ): + migrate.migrate_domain_format(domain_file, new_domain_file) + + +def test_migrate_domain_raises_for_non_domain_files(tmp_path: Path): + domain_dir = tmp_path / "domain" + domain_dir.mkdir() + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + nlu: + - intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + """, + "domain.yml", + ) + + with pytest.raises( + RasaException, + match=f"The domain directory '{domain_dir.as_posix()}' does not contain any " + f"domain files. Please make sure to include these for a successful " + f"migration.", + ): + migrate.migrate_domain_format(domain_dir, None) + + +def test_migrate_domain_raises_for_missing_slots_and_forms(tmp_path: Path): + domain_dir = tmp_path / "domain" + domain_dir.mkdir() + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + entities: + - bla + """, + "domain.yml", + ) + + with pytest.raises( + RasaException, + match=f"The files you have provided in '{re.escape(str(domain_dir))}' " + f"are missing slots or forms. " + f"Please make sure to include these for a successful migration.", + ): + migrate.migrate_domain_format(domain_dir, None) + + +def test_migrate_domain_raises_when_migrated_files_are_found(tmp_path: Path): + domain_dir = tmp_path / "domain" + domain_dir.mkdir() + prepare_domain_path( + domain_dir, + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: [] + """, + "domain.yml", + ) + + with pytest.raises( + RasaException, match="Some of the given files (.*) have already been migrated.*" + ): + migrate.migrate_domain_format(domain_dir, None) + + +def test_migrate_folder_only_migrates_domain_files(tmp_path: Path): + domain_dir = tmp_path / "domain" + domain_dir.mkdir() + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + entities: + - outdoor + slots: + outdoor_seating: + type: bool + influence_conversation: false + """, + "slots.yml", + ) + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + forms: + reservation_form: + required_slots: + outdoor_seating: + - type: from_intent + value: true + intent: confirm + """, + "forms.yml", + ) + + prepare_domain_path( + domain_dir, + """ + not a domain file. + """, + "not-a-domain-file.yml", + ) + + out_dir = tmp_path / "out_dir" + migrate.migrate_domain_format(domain_dir, out_dir) + assert set(f.name for f in out_dir.iterdir()) == {"forms.yml", "slots.yml"} + # i.e. the not-a-domain-file is not migrated + + +def example_migrate_folder_fails_because_multiple_slots_sections( + path: Path, +) -> Tuple[Path, Text]: + domain_dir = path / "domain" + domain_dir.mkdir() + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + entities: + - outdoor + slots: + outdoor_seating: + type: bool + influence_conversation: false + """, + "slots_one.yml", + ) + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + entities: + - outdoor + slots: + cuisine: + type: text + influence_conversation: false + """, + "slots_two.yml", + ) + + prepare_domain_path( + domain_dir, + """ + version: "2.0" + responses: + utter_greet: + - text: "Hi there!" + """, + "responses.yml", + ) + + return domain_dir, "Domain files with multiple 'slots' sections were " "provided." + + +@pytest.mark.parametrize( + "test_case, default_out_dir, out_dir_exists", + [ + (test_case, default_out_dir, out_dir_exists) + for test_case in [example_migrate_folder_fails_because_multiple_slots_sections] + for (default_out_dir, out_dir_exists) in [ + (True, True), + (True, False), + (False, False), + ] + ], +) +def test_migrate_domain_cleanups_after_raising( + tmp_path: Path, + test_case: Callable[[Path], Tuple[Text, Text]], + default_out_dir: bool, + out_dir_exists: bool, +): + # input + out_path = None if default_out_dir else (tmp_path / "custom_out_path") + domain_path, error_msg_match = test_case(tmp_path) + migrating_file_only = domain_path.is_file() + domain_files = list(domain_path.iterdir()) + + # paths to be used by migration tool + domain_parent_dir = domain_path.parent + expected_out_path = ( + out_path + if out_path is not None + else (domain_parent_dir / migrate.DEFAULT_NEW_DOMAIN) + ) + expected_backup_path = domain_parent_dir / migrate.ORIGINAL_DOMAIN + if migrating_file_only: + expected_backup_path = f"{expected_backup_path}{migrate.YML_SUFFIX}" + expected_out_path = f"{expected_out_path}{migrate.YML_SUFFIX}" + + # create the folder if needed + if not migrating_file_only and out_dir_exists: + expected_out_path.mkdir(parents=True) + + # migrate! + with pytest.raises(RasaException, match=error_msg_match): + migrate.migrate_domain_format(domain_path, out_path) + assert Path.exists(domain_path) + assert all(Path.exists(file) for file in domain_files) + assert not Path.exists(expected_backup_path) + if not migrating_file_only: + # if and only if the folder didn't exist before, it should not exist afterwards + assert Path.exists(expected_out_path) == out_dir_exists + + # ... and to assert we really did remove something: + expected_to_be_removed = [call(expected_backup_path)] + if not out_dir_exists: # only removed if it didn't exist + expected_to_be_removed.append(call(expected_out_path)) + patching = [shutil, "rmtree"] if not migrating_file_only else [Path, "unlink"] + with patch.object(*patching) as removal: + with pytest.raises(RasaException, match=error_msg_match): + migrate.migrate_domain_format(domain_path, out_path) + assert removal.call_count == 1 + (not out_dir_exists) + removal.assert_has_calls(expected_to_be_removed) + + +@pytest.mark.parametrize("migrate_file_only", [True, False]) +def test_migrate_domain_raises_when_backup_location_exists( + tmp_path: Path, migrate_file_only: bool +): + domain_dir = tmp_path / "domain" + domain_file_name = "domain.yml" + domain_dir.mkdir() + prepare_domain_path( + domain_dir, + """ + version: "2.0" + intents: [] + """, + domain_file_name, + ) + + if not migrate_file_only: + domain_path = domain_dir + backup_location = tmp_path / migrate.ORIGINAL_DOMAIN + backup_location.mkdir() + else: + domain_path = domain_dir / domain_file_name + backup_location = domain_dir / (migrate.ORIGINAL_DOMAIN + migrate.YML_SUFFIX) + with open(backup_location, "w"): + pass + + with pytest.raises( + RasaException, match="The domain could not be migrated since .* exists.*" + ): + migrate.migrate_domain_format(domain_path, None) + + +@pytest.mark.parametrize( + "migrate_file_only, default_location", itertools.combinations([True, False], 2) +) +def test_migrate_domain_raises_when_output_location_is_used( + tmp_path: Path, migrate_file_only: bool, default_location: bool +): + domain_dir = tmp_path / "domain" + domain_file_name = "domain.yml" + domain_dir.mkdir() + prepare_domain_path( + domain_dir, + """ + version: "2.0" + intents: [] + """, + domain_file_name, + ) + + if not migrate_file_only: + domain_path = domain_dir + if default_location: + out_path = None + non_empty_existing_dir = tmp_path / migrate.DEFAULT_NEW_DOMAIN + else: + out_path = tmp_path / "my_custom_new_domain_name" + non_empty_existing_dir = out_path + non_empty_existing_dir.mkdir() + # in contrast to the backup location, the output directory may exist + # but must be empty + with open(non_empty_existing_dir / "bla.txt", "w"): + pass + + else: + domain_path = domain_dir / domain_file_name + if default_location: + out_path = None + existing_file = tmp_path / (migrate.DEFAULT_NEW_DOMAIN + migrate.YML_SUFFIX) + else: + out_path = tmp_path / "my_custom_file_name.yml" + existing_file = out_path + with open(existing_file, "w"): + pass + + with pytest.raises( + RasaException, + match="The domain could not be migrated to .* because .* already exists.*", + ): + migrate.migrate_domain_format(domain_path, out_path) diff --git a/tests/core/test_nlg.py b/tests/core/test_nlg.py new file mode 100644 index 0000000..d08d582 --- /dev/null +++ b/tests/core/test_nlg.py @@ -0,0 +1,331 @@ +import uuid +from typing import Text, Any + +import jsonschema +import pytest +from sanic import Sanic, response + +from rasa.core.nlg.callback import CallbackNaturalLanguageGenerator +from rasa.core.nlg.response import TemplatedNaturalLanguageGenerator +from rasa.utils.endpoints import EndpointConfig, read_endpoint_config +from rasa.core.agent import Agent + + +def nlg_app(base_url="/"): + + app = Sanic("test_nlg") + + @app.route(base_url, methods=["POST"]) + async def generate(request): + """Simple HTTP NLG generator, checks that the incoming request + is format according to the spec.""" + + nlg_request_format_spec = { + "type": "object", + "properties": { + "response": {"type": "string"}, + "id": {"type": ["string", "null"]}, + "arguments": {"type": "object"}, + "tracker": { + "type": "object", + "properties": { + "sender_id": {"type": "string"}, + "slots": {"type": "object"}, + "latest_message": {"type": "object"}, + "latest_event_time": {"type": "number"}, + "paused": {"type": "boolean"}, + "events": {"type": "array"}, + }, + }, + "channel": { + "type": "object", + "properties": {"name": {"type": "string"}}, + }, + }, + } + + nlg_call = request.json + + jsonschema.validate(nlg_call, nlg_request_format_spec) + + if nlg_call.get("response") == "utter_greet": + response_dict = {"text": "Hey there!"} + else: + response_dict = {"text": "Sorry, didn't get that."} + return response.json(response_dict) + + return app + + +# noinspection PyShadowingNames +@pytest.fixture() +def http_nlg(loop, sanic_client): + return loop.run_until_complete(sanic_client(nlg_app())) + + +async def test_nlg(http_nlg, trained_rasa_model: Text): + sender = str(uuid.uuid1()) + + nlg_endpoint = EndpointConfig.from_dict({"url": http_nlg.make_url("/")}) + agent = Agent.load(trained_rasa_model, generator=nlg_endpoint) + + response = await agent.handle_text("/greet", sender_id=sender) + assert len(response) == 1 + assert response[0] == {"text": "Hey there!", "recipient_id": sender} + + +def test_nlg_endpoint_config_loading(endpoints_path: Text): + cfg = read_endpoint_config(endpoints_path, "nlg") + + assert cfg == EndpointConfig.from_dict({"url": "http://localhost:5055/nlg"}) + + +def test_nlg_schema_validation(): + content = {"text": "Hey there!"} + assert CallbackNaturalLanguageGenerator.validate_response(content) + + +def test_nlg_schema_validation_empty_buttons(): + content = {"text": "Hey there!", "buttons": []} + assert CallbackNaturalLanguageGenerator.validate_response(content) + + +def test_nlg_schema_validation_empty_image(): + content = {"text": "Hey there!", "image": None} + assert CallbackNaturalLanguageGenerator.validate_response(content) + + +def test_nlg_schema_validation_empty_custom_dict(): + content = {"custom": {}} + assert CallbackNaturalLanguageGenerator.validate_response(content) + + +@pytest.mark.parametrize( + "slot_name, slot_value", + [ + ("tag_w_underscore", "a"), + ("tag with space", "bacon"), + ("tag.with.dot", "chocolate"), + ("tag-w-dash", "apple pie"), + ("tag-w-$", "banana"), + ("tag-w-@", "one"), + ("tagCamelCase", "two"), + ("tag-w-*", "three"), + ("tag_w_underscore", "a"), + ("tag.with.float.val", 1.3), + ("tag-w-$", "banana"), + ("tagCamelCase", "two"), + ("empty_string", ""), + ("null", None), + ], +) +def test_nlg_fill_response_text(slot_name: Text, slot_value: Any): + response = {"text": f"{{{slot_name}}}"} + t = TemplatedNaturalLanguageGenerator(responses=dict()) + result = t._fill_response(response=response, filled_slots={slot_name: slot_value}) + assert result == {"text": str(slot_value)} + + +@pytest.mark.parametrize( + "img_slot_name, img_slot_value", + [("url", "https://www.exampleimg.com"), ("img1", "https://www.appleimg.com")], +) +def test_nlg_fill_response_image(img_slot_name: Text, img_slot_value: Text): + response = {"image": f"{{{img_slot_name}}}"} + t = TemplatedNaturalLanguageGenerator(responses=dict()) + result = t._fill_response( + response=response, filled_slots={img_slot_name: img_slot_value} + ) + assert result == {"image": str(img_slot_value)} + + +@pytest.mark.parametrize( + "slot_name, slot_value", + [ + ("tag_w_underscore", "a"), + ("tag with space", "bacon"), + ("tag.with.dot", "chocolate"), + ("tag-w-dash", "apple pie"), + ("tag-w-$", "banana"), + ("tag-w-@", "one"), + ("tagCamelCase", "two"), + ("tag-w-*", "three"), + ("tag_w_underscore", "a"), + ("tag.with.float.val", 1.3), + ("tag-w-$", "banana"), + ("tagCamelCase", "two"), + ("empty_string", ""), + ("null", None), + ], +) +def test_nlg_fill_response_custom(slot_name: Text, slot_value: Any): + response = { + "custom": { + "field": f"{{{slot_name}}}", + "properties": {"field_prefixed": f"prefix_{{{slot_name}}}"}, + "bool_field": True, + "int_field:": 42, + "empty_field": None, + } + } + t = TemplatedNaturalLanguageGenerator(responses=dict()) + result = t._fill_response(response=response, filled_slots={slot_name: slot_value}) + + assert result == { + "custom": { + "field": str(slot_value), + "properties": {"field_prefixed": f"prefix_{slot_value}"}, + "bool_field": True, + "int_field:": 42, + "empty_field": None, + } + } + + +def test_nlg_fill_response_custom_with_list(): + response = { + "custom": { + "blocks": [{"fields": [{"text": "*Departure date:*\n{test}"}]}], + "other": ["{test}"], + } + } + t = TemplatedNaturalLanguageGenerator(responses=dict()) + result = t._fill_response(response=response, filled_slots={"test": 5}) + assert result == { + "custom": { + "blocks": [{"fields": [{"text": "*Departure date:*\n5"}]}], + "other": ["5"], + } + } + + +@pytest.mark.parametrize( + "response_text, expected", + [ + ('{{"variable":"{slot_1}"}}', '{"variable":"foo"}'), + ("{slot_1} and {slot_2}", "foo and bar"), + ("{{{slot_1}, {slot_2}!}}", "{foo, bar!}"), + ("{{{slot_1}}}", "{foo}"), + ("{{slot_1}}", "{slot_1}"), + ], +) +def test_nlg_fill_response_text_with_json(response_text, expected): + response = {"text": response_text} + t = TemplatedNaturalLanguageGenerator(responses=dict()) + result = t._fill_response( + response=response, filled_slots={"slot_1": "foo", "slot_2": "bar"} + ) + assert result == {"text": expected} + + +@pytest.mark.parametrize("slot_name, slot_value", [("tag_w_\n", "a")]) +def test_nlg_fill_response_with_bad_slot_name(slot_name, slot_value): + response_text = f"{{{slot_name}}}" + t = TemplatedNaturalLanguageGenerator(responses=dict()) + result = t._fill_response( + response={"text": response_text}, filled_slots={slot_name: slot_value} + ) + assert result["text"] == response_text + + +@pytest.mark.parametrize( + "text_slot_name, text_slot_value, img_slot_name, img_slot_value", + [ + ("tag_w_underscore", "a", "url", "https://www.exampleimg.com"), + ("tag with space", "bacon", "img1", "https://www.appleimg.com"), + ], +) +def test_nlg_fill_response_image_and_text( + text_slot_name, text_slot_value, img_slot_name, img_slot_value +): + response = {"text": f"{{{text_slot_name}}}", "image": f"{{{img_slot_name}}}"} + t = TemplatedNaturalLanguageGenerator(responses=dict()) + result = t._fill_response( + response=response, + filled_slots={text_slot_name: text_slot_value, img_slot_name: img_slot_value}, + ) + assert result == {"text": str(text_slot_value), "image": str(img_slot_value)} + + +@pytest.mark.parametrize( + "text_slot_name, text_slot_value, cust_slot_name, cust_slot_value", + [ + ("tag_w_underscore", "a", "tag.with.dot", "chocolate"), + ("tag with space", "bacon", "tag-w-dash", "apple pie"), + ], +) +def test_nlg_fill_response_text_and_custom( + text_slot_name, text_slot_value, cust_slot_name, cust_slot_value +): + response = { + "text": f"{{{text_slot_name}}}", + "custom": { + "field": f"{{{cust_slot_name}}}", + "properties": {"field_prefixed": f"prefix_{{{cust_slot_name}}}"}, + }, + } + t = TemplatedNaturalLanguageGenerator(responses=dict()) + result = t._fill_response( + response=response, + filled_slots={text_slot_name: text_slot_value, cust_slot_name: cust_slot_value}, + ) + assert result == { + "text": str(text_slot_value), + "custom": { + "field": str(cust_slot_value), + "properties": {"field_prefixed": f"prefix_{str(cust_slot_value)}"}, + }, + } + + +@pytest.mark.parametrize( + "attach_slot_name, attach_slot_value", [("attach_file", "https://attach.pdf")] +) +def test_nlg_fill_response_attachment(attach_slot_name, attach_slot_value): + response = {"attachment": "{" + attach_slot_name + "}"} + t = TemplatedNaturalLanguageGenerator(responses=dict()) + result = t._fill_response( + response=response, filled_slots={attach_slot_name: attach_slot_value} + ) + assert result == {"attachment": str(attach_slot_value)} + + +@pytest.mark.parametrize( + "button_slot_name, button_slot_value", [("button_1", "button_1")] +) +def test_nlg_fill_response_button(button_slot_name, button_slot_value): + response = { + "buttons": [ + { + "payload": f'/choose{{{{"some_slot": "{{{button_slot_name}}}"}}}}', + "title": f"{{{button_slot_name}}}", + } + ] + } + t = TemplatedNaturalLanguageGenerator(responses=dict()) + result = t._fill_response( + response=response, filled_slots={button_slot_name: button_slot_value} + ) + assert result == { + "buttons": [ + { + "payload": f'/choose{{"some_slot": "{button_slot_value}"}}', + "title": f"{button_slot_value}", + } + ] + } + + +@pytest.mark.parametrize( + "quick_replies_slot_name, quick_replies_slot_value", [("qreply", "reply 1")] +) +def test_nlg_fill_response_quick_replies( + quick_replies_slot_name, quick_replies_slot_value +): + response = {"quick_replies": f"{{{quick_replies_slot_name}}}"} + t = TemplatedNaturalLanguageGenerator(responses=dict()) + result = t._fill_response( + response=response, + filled_slots={quick_replies_slot_name: quick_replies_slot_value}, + ) + assert result == {"quick_replies": str(quick_replies_slot_value)} diff --git a/tests/core/test_policies.py b/tests/core/test_policies.py new file mode 100644 index 0000000..0c19f5e --- /dev/null +++ b/tests/core/test_policies.py @@ -0,0 +1,1037 @@ +import uuid +from pathlib import Path +from typing import Type, List, Text, Optional, Dict, Any + +import dataclasses +import numpy as np +import pytest +from _pytest.tmpdir import TempPathFactory + +from rasa.engine.graph import ExecutionContext, GraphSchema +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.constants import DEFAULT_SENDER_ID +from rasa.shared.core.constants import ACTION_LISTEN_NAME, ACTION_UNLIKELY_INTENT_NAME +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + ActionExecuted, + Event, + UserUttered, + EntitiesAdded, + SlotSet, +) +from rasa.core import training +from rasa.core.constants import POLICY_MAX_HISTORY +from rasa.core.featurizers.tracker_featurizers import ( + TrackerFeaturizer, + MaxHistoryTrackerFeaturizer, + IntentMaxHistoryTrackerFeaturizer, +) +from rasa.core.featurizers.single_state_featurizer import ( + SingleStateFeaturizer, + IntentTokenizerSingleStateFeaturizer, +) +from rasa.core.policies.policy import SupportedData, InvalidPolicyConfig, Policy +from rasa.core.policies.rule_policy import RulePolicy +from rasa.core.policies.ted_policy import TEDPolicy +from rasa.core.policies.memoization import AugmentedMemoizationPolicy, MemoizationPolicy + +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.generator import TrackerWithCachedStates +from tests.dialogues import TEST_DEFAULT_DIALOGUE +from tests.core.utilities import get_tracker, tracker_from_dialogue + + +def train_trackers( + domain: Domain, stories_file: Text, augmentation_factor: int = 20 +) -> List[TrackerWithCachedStates]: + return training.load_data( + stories_file, domain, augmentation_factor=augmentation_factor + ) + + +# We are going to use class style testing here since unfortunately pytest +# doesn't support using fixtures as arguments to its own parameterize yet +# (hence, we can't train a policy, declare it as a fixture and use the +# different fixtures of the different policies for the functional tests). +# Therefore, we are going to reverse this and train the policy within a class +# and collect the tests in a base class. +# noinspection PyMethodMayBeStatic +class PolicyTestCollection: + """Tests every policy needs to fulfill. + + Each policy can declare further tests on its own.""" + + @staticmethod + def _policy_class_to_test() -> Type[Policy]: + raise NotImplementedError + + max_history = 3 # this is the amount of history we test on + + @pytest.fixture(scope="class") + def resource(self) -> Resource: + return Resource(uuid.uuid4().hex) + + @pytest.fixture(scope="class") + def model_storage(self, tmp_path_factory: TempPathFactory) -> ModelStorage: + return LocalModelStorage(tmp_path_factory.mktemp(uuid.uuid4().hex)) + + @pytest.fixture(scope="class") + def execution_context(self) -> ExecutionContext: + return ExecutionContext(GraphSchema({}), uuid.uuid4().hex) + + def _config( + self, config_override: Optional[Dict[Text, Any]] = None + ) -> Dict[Text, Any]: + config_override = config_override or {} + config = self._policy_class_to_test().get_default_config() + return {**config, **config_override} + + def create_policy( + self, + featurizer: Optional[TrackerFeaturizer], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + config: Optional[Dict[Text, Any]] = None, + ) -> Policy: + return self._policy_class_to_test()( + config=self._config(config), + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + featurizer=featurizer, + ) + + @pytest.fixture(scope="class") + def featurizer(self) -> TrackerFeaturizer: + featurizer = MaxHistoryTrackerFeaturizer( + SingleStateFeaturizer(), max_history=self.max_history + ) + return featurizer + + @pytest.fixture(scope="class") + def default_domain(self, domain_path: Text) -> Domain: + return Domain.load(domain_path) + + @pytest.fixture(scope="class") + def tracker(self, default_domain: Domain) -> DialogueStateTracker: + return DialogueStateTracker(DEFAULT_SENDER_ID, default_domain.slots) + + @pytest.fixture(scope="class") + def trained_policy( + self, + featurizer: Optional[TrackerFeaturizer], + stories_path: Text, + default_domain: Domain, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> Policy: + policy = self.create_policy( + featurizer, model_storage, resource, execution_context + ) + training_trackers = train_trackers( + default_domain, stories_path, augmentation_factor=20 + ) + policy.train(training_trackers, default_domain) + return policy + + def test_featurizer( + self, + trained_policy: Policy, + resource: Resource, + model_storage: ModelStorage, + tmp_path: Path, + execution_context: ExecutionContext, + ): + assert isinstance(trained_policy.featurizer, MaxHistoryTrackerFeaturizer) + assert trained_policy.featurizer.max_history == self.max_history + assert isinstance( + trained_policy.featurizer.state_featurizer, SingleStateFeaturizer + ) + loaded = trained_policy.__class__.load( + self._config(trained_policy.config), + model_storage, + resource, + execution_context, + ) + + assert isinstance(loaded.featurizer, MaxHistoryTrackerFeaturizer) + assert loaded.featurizer.max_history == self.max_history + assert isinstance(loaded.featurizer.state_featurizer, SingleStateFeaturizer) + + @pytest.mark.timeout(120, func_only=True) + @pytest.mark.parametrize("should_finetune", [False, True]) + def test_persist_and_load( + self, + trained_policy: Policy, + default_domain: Domain, + should_finetune: bool, + stories_path: Text, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ): + loaded = trained_policy.__class__.load( + self._config(trained_policy.config), + model_storage, + resource, + dataclasses.replace(execution_context, is_finetuning=should_finetune), + ) + + assert loaded.finetune_mode == should_finetune + + trackers = train_trackers(default_domain, stories_path, augmentation_factor=20) + + for tracker in trackers: + predicted_probabilities = loaded.predict_action_probabilities( + tracker, default_domain + ) + actual_probabilities = trained_policy.predict_action_probabilities( + tracker, default_domain + ) + assert predicted_probabilities == actual_probabilities + + def test_prediction_on_empty_tracker( + self, trained_policy: Policy, default_domain: Domain + ): + tracker = DialogueStateTracker(DEFAULT_SENDER_ID, default_domain.slots) + prediction = trained_policy.predict_action_probabilities( + tracker, default_domain + ) + assert not prediction.is_end_to_end_prediction + assert len(prediction.probabilities) == default_domain.num_actions + assert max(prediction.probabilities) <= 1.0 + assert min(prediction.probabilities) >= 0.0 + + @pytest.mark.filterwarnings( + "ignore:.*without a trained model present.*:UserWarning" + ) + def test_persist_and_load_empty_policy( + self, + default_domain: Domain, + default_model_storage: ModelStorage, + execution_context: ExecutionContext, + ): + resource = Resource(uuid.uuid4().hex) + empty_policy = self.create_policy( + None, default_model_storage, resource, execution_context + ) + + empty_policy.train([], default_domain) + loaded = empty_policy.__class__.load( + self._config(), default_model_storage, resource, execution_context + ) + + assert loaded is not None + + @staticmethod + def _get_next_action(policy: Policy, events: List[Event], domain: Domain) -> Text: + tracker = get_tracker(events) + scores = policy.predict_action_probabilities(tracker, domain).probabilities + index = scores.index(max(scores)) + return domain.action_names_or_texts[index] + + @pytest.mark.parametrize( + "featurizer_config, tracker_featurizer, state_featurizer", + [ + ( + [ + { + "name": "MaxHistoryTrackerFeaturizer", + "max_history": 12, + "state_featurizer": [], + } + ], + MaxHistoryTrackerFeaturizer(max_history=12), + type(None), + ), + ( + [{"name": "MaxHistoryTrackerFeaturizer", "max_history": 12}], + MaxHistoryTrackerFeaturizer(max_history=12), + type(None), + ), + ( + [ + { + "name": "IntentMaxHistoryTrackerFeaturizer", + "max_history": 12, + "state_featurizer": [ + {"name": "IntentTokenizerSingleStateFeaturizer"} + ], + } + ], + IntentMaxHistoryTrackerFeaturizer(max_history=12), + IntentTokenizerSingleStateFeaturizer, + ), + ], + ) + def test_different_featurizer_configs( + self, + featurizer_config: Optional[Dict[Text, Any]], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + tracker_featurizer: MaxHistoryTrackerFeaturizer, + state_featurizer: Type[SingleStateFeaturizer], + ): + featurizer_config_override = ( + {"featurizer": featurizer_config} if featurizer_config else {} + ) + policy = self.create_policy( + None, + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + config=self._config(featurizer_config_override), + ) + + featurizer = policy.featurizer + assert isinstance(featurizer, tracker_featurizer.__class__) + + if featurizer_config: + expected_max_history = featurizer_config[0].get(POLICY_MAX_HISTORY) + else: + expected_max_history = self._config().get(POLICY_MAX_HISTORY) + + assert featurizer.max_history == expected_max_history + + assert isinstance(featurizer.state_featurizer, state_featurizer) + + @pytest.mark.parametrize( + "featurizer_config", + [ + [ + {"name": "MaxHistoryTrackerFeaturizer", "max_history": 12}, + {"name": "MaxHistoryTrackerFeaturizer", "max_history": 12}, + ], + [ + { + "name": "IntentMaxHistoryTrackerFeaturizer", + "max_history": 12, + "state_featurizer": [ + {"name": "IntentTokenizerSingleStateFeaturizer"}, + {"name": "IntentTokenizerSingleStateFeaturizer"}, + ], + } + ], + ], + ) + def test_different_invalid_featurizer_configs( + self, + trained_policy: Policy, + featurizer_config: Optional[Dict[Text, Any]], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ): + with pytest.raises(InvalidPolicyConfig): + self.create_policy( + None, + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + config={"featurizer": featurizer_config}, + ) + + +class TestMemoizationPolicy(PolicyTestCollection): + @staticmethod + def _policy_class_to_test() -> Type[Policy]: + return MemoizationPolicy + + @pytest.fixture(scope="class") + def featurizer(self) -> TrackerFeaturizer: + featurizer = MaxHistoryTrackerFeaturizer(None, max_history=self.max_history) + return featurizer + + def test_featurizer( + self, + trained_policy: Policy, + resource: Resource, + model_storage: ModelStorage, + tmp_path: Path, + execution_context: ExecutionContext, + ) -> None: + assert isinstance(trained_policy.featurizer, MaxHistoryTrackerFeaturizer) + assert trained_policy.featurizer.state_featurizer is None + loaded = trained_policy.__class__.load( + self._config(trained_policy.config), + model_storage, + resource, + execution_context, + ) + assert isinstance(loaded.featurizer, MaxHistoryTrackerFeaturizer) + assert loaded.featurizer.state_featurizer is None + + def test_memorise( + self, + trained_policy: MemoizationPolicy, + default_domain: Domain, + stories_path: Text, + ): + trackers = train_trackers(default_domain, stories_path, augmentation_factor=20) + + trained_policy.train(trackers, default_domain) + lookup_with_augmentation = trained_policy.lookup + + trackers = [ + t for t in trackers if not hasattr(t, "is_augmented") or not t.is_augmented + ] + + ( + all_states, + all_actions, + ) = trained_policy.featurizer.training_states_and_labels( + trackers, default_domain + ) + + for tracker, states, actions in zip(trackers, all_states, all_actions): + recalled = trained_policy.recall(states, tracker, default_domain, None) + assert recalled == actions[0] + + nums = np.random.randn(default_domain.num_states) + random_states = [{f: num for f, num in zip(default_domain.input_states, nums)}] + assert trained_policy._recall_states(random_states) is None + + # compare augmentation for augmentation_factor of 0 and 20: + trackers_no_augmentation = train_trackers( + default_domain, stories_path, augmentation_factor=0 + ) + + trained_policy.train(trackers_no_augmentation, default_domain) + lookup_no_augmentation = trained_policy.lookup + + assert lookup_no_augmentation == lookup_with_augmentation + + def test_memorise_with_nlu( + self, trained_policy: MemoizationPolicy, default_domain: Domain + ): + tracker = tracker_from_dialogue(TEST_DEFAULT_DIALOGUE, default_domain) + states = trained_policy._prediction_states(tracker, default_domain) + + recalled = trained_policy.recall(states, tracker, default_domain, None) + assert recalled is not None + + def test_finetune_after_load( + self, + trained_policy: MemoizationPolicy, + resource: Resource, + model_storage: ModelStorage, + execution_context: ExecutionContext, + default_domain: Domain, + stories_path: Text, + ): + + execution_context = dataclasses.replace(execution_context, is_finetuning=True) + loaded_policy = MemoizationPolicy.load( + trained_policy.config, model_storage, resource, execution_context + ) + + assert loaded_policy.finetune_mode + + new_story = TrackerWithCachedStates.from_events( + "channel", + domain=default_domain, + slots=default_domain.slots, + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": "why"}), + ActionExecuted("utter_channel"), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ) + original_train_data = train_trackers( + default_domain, stories_path, augmentation_factor=20 + ) + + loaded_policy.train(original_train_data + [new_story], default_domain) + + # Get the hash of the tracker state of new story + new_story_states, _ = loaded_policy.featurizer.training_states_and_labels( + [new_story], default_domain + ) + + # Feature keys for each new state should be present in the lookup + for states in new_story_states: + state_key = loaded_policy._create_feature_key(states) + assert state_key in loaded_policy.lookup + + @pytest.mark.parametrize( + "tracker_events_with_action, tracker_events_without_action", + [ + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + ], + ), + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + EntitiesAdded(entities=[{"entity": "name", "value": "Peter"}]), + SlotSet("name", "Peter"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="hello", intent={"name": "greet"}), + SlotSet("name", "Peter"), + EntitiesAdded(entities=[{"entity": "name", "value": "Peter"}]), + ], + ), + ], + ) + def test_ignore_action_unlikely_intent( + self, + trained_policy: MemoizationPolicy, + default_domain: Domain, + tracker_events_with_action: List[Event], + tracker_events_without_action: List[Event], + ): + tracker_with_action = DialogueStateTracker.from_events( + "test 1", evts=tracker_events_with_action, slots=default_domain.slots + ) + tracker_without_action = DialogueStateTracker.from_events( + "test 2", evts=tracker_events_without_action, slots=default_domain.slots + ) + prediction_with_action = trained_policy.predict_action_probabilities( + tracker_with_action, default_domain + ) + prediction_without_action = trained_policy.predict_action_probabilities( + tracker_without_action, default_domain + ) + + # Memoization shouldn't be affected with the + # presence of action_unlikely_intent. + assert ( + prediction_with_action.probabilities + == prediction_without_action.probabilities + ) + + @pytest.mark.parametrize( + "featurizer_config, tracker_featurizer, state_featurizer", + [ + (None, MaxHistoryTrackerFeaturizer(), type(None)), + ([], MaxHistoryTrackerFeaturizer(), type(None)), + ], + ) + def test_empty_featurizer_configs( + self, + featurizer_config: Optional[Dict[Text, Any]], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + tracker_featurizer: MaxHistoryTrackerFeaturizer, + state_featurizer: Type[SingleStateFeaturizer], + ): + featurizer_config_override = ( + {"featurizer": featurizer_config} if featurizer_config else {} + ) + policy = self.create_policy( + None, + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + config=self._config(featurizer_config_override), + ) + + featurizer = policy.featurizer + assert isinstance(featurizer, tracker_featurizer.__class__) + + if featurizer_config: + expected_max_history = featurizer_config[0].get(POLICY_MAX_HISTORY) + else: + expected_max_history = self._config().get(POLICY_MAX_HISTORY) + + assert featurizer.max_history == expected_max_history + + assert isinstance(featurizer.state_featurizer, state_featurizer) + + @pytest.mark.parametrize("max_history", [1, 2, 3, 4, None]) + def test_prediction( + self, + max_history: Optional[int], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ): + policy = self.create_policy( + featurizer=MaxHistoryTrackerFeaturizer(max_history=max_history), + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + config={POLICY_MAX_HISTORY: max_history}, + ) + + GREET_INTENT_NAME = "greet" + UTTER_GREET_ACTION = "utter_greet" + UTTER_BYE_ACTION = "utter_goodbye" + domain = Domain.from_yaml( + f""" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - {UTTER_BYE_ACTION} + slots: + slot_1: + type: bool + mappings: + - type: from_text + slot_2: + type: bool + mappings: + - type: from_text + slot_3: + type: bool + mappings: + - type: from_text + slot_4: + type: bool + mappings: + - type: from_text + """ + ) + events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + SlotSet("slot_1", True), + ActionExecuted(UTTER_GREET_ACTION), + SlotSet("slot_2", True), + SlotSet("slot_3", True), + ActionExecuted(UTTER_GREET_ACTION), + ActionExecuted(UTTER_GREET_ACTION), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + SlotSet("slot_4", True), + ActionExecuted(UTTER_BYE_ACTION), + ActionExecuted(ACTION_LISTEN_NAME), + ] + training_story = TrackerWithCachedStates.from_events( + "training story", evts=events, domain=domain, slots=domain.slots + ) + test_story = TrackerWithCachedStates.from_events( + "training story", events[:-2], domain=domain, slots=domain.slots + ) + policy.train([training_story], domain) + prediction = policy.predict_action_probabilities(test_story, domain) + assert ( + domain.action_names_or_texts[ + prediction.probabilities.index(max(prediction.probabilities)) + ] + == UTTER_BYE_ACTION + ) + + +class TestAugmentedMemoizationPolicy(TestMemoizationPolicy): + """Test suite for AugmentedMemoizationPolicy.""" + + @staticmethod + def _policy_class_to_test() -> Type[Policy]: + return AugmentedMemoizationPolicy + + @pytest.mark.parametrize("max_history", [1, 2, 3, 4, None]) + def test_augmented_prediction( + self, + max_history: Optional[int], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ): + policy = self.create_policy( + featurizer=MaxHistoryTrackerFeaturizer(max_history=max_history), + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + config={POLICY_MAX_HISTORY: max_history}, + ) + + GREET_INTENT_NAME = "greet" + UTTER_GREET_ACTION = "utter_greet" + UTTER_BYE_ACTION = "utter_goodbye" + domain = Domain.from_yaml( + f""" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - {UTTER_BYE_ACTION} + slots: + slot_1: + type: bool + initial_value: true + mappings: + - type: from_text + slot_2: + type: bool + mappings: + - type: from_text + slot_3: + type: bool + mappings: + - type: from_text + """ + ) + training_story = TrackerWithCachedStates.from_events( + "training story", + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + SlotSet("slot_3", True), + ActionExecuted(UTTER_BYE_ACTION), + ActionExecuted(ACTION_LISTEN_NAME), + ], + domain=domain, + slots=domain.slots, + ) + test_story = TrackerWithCachedStates.from_events( + "test story", + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + SlotSet("slot_1", False), + ActionExecuted(UTTER_GREET_ACTION), + ActionExecuted(UTTER_GREET_ACTION), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + SlotSet("slot_2", True), + ActionExecuted(UTTER_GREET_ACTION), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + SlotSet("slot_3", True), + # ActionExecuted(UTTER_BYE_ACTION), + ], + domain=domain, + slots=domain.slots, + ) + policy.train([training_story], domain) + prediction = policy.predict_action_probabilities(test_story, domain) + assert ( + domain.action_names_or_texts[ + prediction.probabilities.index(max(prediction.probabilities)) + ] + == UTTER_BYE_ACTION + ) + + @pytest.mark.parametrize("max_history", [1, 2, 3, 4, None]) + def test_augmented_prediction_across_max_history_actions( + self, + max_history: Optional[int], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ): + """Tests that the last user utterance is preserved in action states + even when the utterance occurs prior to `max_history` actions in the + past. + """ + policy = self.create_policy( + featurizer=MaxHistoryTrackerFeaturizer(max_history=max_history), + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + config={POLICY_MAX_HISTORY: max_history}, + ) + + GREET_INTENT_NAME = "greet" + UTTER_GREET_ACTION = "utter_greet" + UTTER_ACTION_1 = "utter_1" + UTTER_ACTION_2 = "utter_2" + UTTER_ACTION_3 = "utter_3" + UTTER_ACTION_4 = "utter_4" + UTTER_ACTION_5 = "utter_5" + UTTER_BYE_ACTION = "utter_goodbye" + domain = Domain.from_yaml( + f""" + intents: + - {GREET_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - {UTTER_ACTION_1} + - {UTTER_ACTION_2} + - {UTTER_ACTION_3} + - {UTTER_ACTION_4} + - {UTTER_ACTION_5} + - {UTTER_BYE_ACTION} + """ + ) + training_story = TrackerWithCachedStates.from_events( + "training story", + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_ACTION_1), + ActionExecuted(UTTER_ACTION_2), + ActionExecuted(UTTER_ACTION_3), + ActionExecuted(UTTER_ACTION_4), + ActionExecuted(UTTER_ACTION_5), + ActionExecuted(UTTER_BYE_ACTION), + ActionExecuted(ACTION_LISTEN_NAME), + ], + domain=domain, + slots=domain.slots, + ) + test_story = TrackerWithCachedStates.from_events( + "test story", + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_ACTION_1), + ActionExecuted(UTTER_ACTION_2), + ActionExecuted(UTTER_ACTION_3), + ActionExecuted(UTTER_ACTION_4), + ActionExecuted(UTTER_ACTION_5), + # ActionExecuted(UTTER_BYE_ACTION), + ], + domain=domain, + slots=domain.slots, + ) + policy.train([training_story], domain) + prediction = policy.predict_action_probabilities(test_story, domain) + assert ( + domain.action_names_or_texts[ + prediction.probabilities.index(max(prediction.probabilities)) + ] + == UTTER_BYE_ACTION + ) + + @pytest.mark.parametrize("max_history", [1, 2, 3, 4, None]) + def test_aug_pred_sensitive_to_intent_across_max_history_actions( + self, + max_history: Optional[int], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ): + """Tests that only the most recent user utterance propagates to state + creation of following actions. + """ + policy = self.create_policy( + featurizer=MaxHistoryTrackerFeaturizer(max_history=max_history), + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + config={POLICY_MAX_HISTORY: max_history}, + ) + + GREET_INTENT_NAME = "greet" + GOODBYE_INTENT_NAME = "goodbye" + UTTER_GREET_ACTION = "utter_greet" + UTTER_ACTION_1 = "utter_1" + UTTER_ACTION_2 = "utter_2" + UTTER_ACTION_3 = "utter_3" + UTTER_ACTION_4 = "utter_4" + UTTER_ACTION_5 = "utter_5" + UTTER_BYE_ACTION = "utter_goodbye" + domain = Domain.from_yaml( + f""" + intents: + - {GREET_INTENT_NAME} + - {GOODBYE_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - {UTTER_ACTION_1} + - {UTTER_ACTION_2} + - {UTTER_ACTION_3} + - {UTTER_ACTION_4} + - {UTTER_ACTION_5} + - {UTTER_BYE_ACTION} + """ + ) + training_story = TrackerWithCachedStates.from_events( + "training story", + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_ACTION_1), + ActionExecuted(UTTER_ACTION_2), + ActionExecuted(UTTER_ACTION_3), + ActionExecuted(UTTER_ACTION_4), + ActionExecuted(UTTER_ACTION_5), + ActionExecuted(UTTER_BYE_ACTION), + ActionExecuted(ACTION_LISTEN_NAME), + ], + domain=domain, + slots=domain.slots, + ) + test_story1 = TrackerWithCachedStates.from_events( + "test story", + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GOODBYE_INTENT_NAME}), + ActionExecuted(UTTER_BYE_ACTION), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_ACTION_1), + ActionExecuted(UTTER_ACTION_2), + ActionExecuted(UTTER_ACTION_3), + ActionExecuted(UTTER_ACTION_4), + ActionExecuted(UTTER_ACTION_5), + # ActionExecuted(UTTER_BYE_ACTION), + ], + domain=domain, + slots=domain.slots, + ) + + policy.train([training_story], domain) + prediction1 = policy.predict_action_probabilities(test_story1, domain) + assert ( + domain.action_names_or_texts[ + prediction1.probabilities.index(max(prediction1.probabilities)) + ] + == UTTER_BYE_ACTION + ) + + test_story2_no_match_expected = TrackerWithCachedStates.from_events( + "test story", + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_BYE_ACTION), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GOODBYE_INTENT_NAME}), + ActionExecuted(UTTER_ACTION_1), + ActionExecuted(UTTER_ACTION_2), + ActionExecuted(UTTER_ACTION_3), + ActionExecuted(UTTER_ACTION_4), + ActionExecuted(UTTER_ACTION_5), + # No prediction should be made here. + ], + domain=domain, + slots=domain.slots, + ) + + prediction2 = policy.predict_action_probabilities( + test_story2_no_match_expected, + domain, + ) + assert all([prob == 0.0 for prob in prediction2.probabilities]) + + @pytest.mark.parametrize("max_history", [1, 2, 3, 4, None]) + def test_aug_pred_without_intent( + self, + max_history: Optional[int], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ): + """Tests memoization works for a memoized state sequence that does + not have a user utterance. + """ + policy = self.create_policy( + featurizer=MaxHistoryTrackerFeaturizer(max_history=max_history), + model_storage=model_storage, + resource=resource, + execution_context=execution_context, + config={POLICY_MAX_HISTORY: max_history}, + ) + + GREET_INTENT_NAME = "greet" + GOODBYE_INTENT_NAME = "goodbye" + UTTER_GREET_ACTION = "utter_greet" + UTTER_ACTION_1 = "utter_1" + UTTER_ACTION_2 = "utter_2" + UTTER_ACTION_3 = "utter_3" + UTTER_ACTION_4 = "utter_4" + domain = Domain.from_yaml( + f""" + intents: + - {GREET_INTENT_NAME} + - {GOODBYE_INTENT_NAME} + actions: + - {UTTER_GREET_ACTION} + - {UTTER_ACTION_1} + - {UTTER_ACTION_2} + - {UTTER_ACTION_3} + - {UTTER_ACTION_4} + """ + ) + training_story = TrackerWithCachedStates.from_events( + "training story", + [ + ActionExecuted(UTTER_ACTION_3), + ActionExecuted(UTTER_ACTION_4), + ActionExecuted(ACTION_LISTEN_NAME), + ], + domain=domain, + slots=domain.slots, + ) + + policy.train([training_story], domain) + + test_story = TrackerWithCachedStates.from_events( + "test story", + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_ACTION_1), + ActionExecuted(UTTER_ACTION_2), + ActionExecuted(UTTER_ACTION_3), + # ActionExecuted(UTTER_ACTION_4), + ], + domain=domain, + slots=domain.slots, + ) + prediction = policy.predict_action_probabilities(test_story, domain) + assert ( + domain.action_names_or_texts[ + prediction.probabilities.index(max(prediction.probabilities)) + ] + == UTTER_ACTION_4 + ) + + +@pytest.mark.parametrize( + "policy,supported_data", + [ + (TEDPolicy, SupportedData.ML_DATA), + (RulePolicy, SupportedData.ML_AND_RULE_DATA), + (MemoizationPolicy, SupportedData.ML_DATA), + ], +) +def test_supported_data(policy: Type[Policy], supported_data: SupportedData): + assert policy.supported_data() == supported_data + + +@pytest.mark.parametrize( + "supported_data,n_rule_trackers,n_ml_trackers", + [ + (SupportedData.ML_DATA, 0, 3), + (SupportedData.ML_AND_RULE_DATA, 2, 3), + (SupportedData.RULE_DATA, 2, 0), + ], +) +def test_get_training_trackers_for_policy( + supported_data: SupportedData, n_rule_trackers: int, n_ml_trackers: int +): + # create five trackers (two rule-based and three ML trackers) + trackers = [ + DialogueStateTracker("id1", slots=[], is_rule_tracker=True), + DialogueStateTracker("id2", slots=[], is_rule_tracker=False), + DialogueStateTracker("id3", slots=[], is_rule_tracker=False), + DialogueStateTracker("id4", slots=[], is_rule_tracker=True), + DialogueStateTracker("id5", slots=[], is_rule_tracker=False), + ] + + trackers = SupportedData.trackers_for_supported_data(supported_data, trackers) + + rule_trackers = [tracker for tracker in trackers if tracker.is_rule_tracker] + ml_trackers = [tracker for tracker in trackers if not tracker.is_rule_tracker] + + assert len(rule_trackers) == n_rule_trackers + assert len(ml_trackers) == n_ml_trackers diff --git a/tests/core/test_processor.py b/tests/core/test_processor.py new file mode 100644 index 0000000..d0581b1 --- /dev/null +++ b/tests/core/test_processor.py @@ -0,0 +1,1992 @@ +import asyncio +import datetime +from http import HTTPStatus +import os.path +import shutil +import textwrap +from pathlib import Path + +import freezegun +import pytest +from unittest.mock import MagicMock +from rasa.plugin import plugin_manager + +import time +import uuid +import json +from _pytest.monkeypatch import MonkeyPatch +from _pytest.logging import LogCaptureFixture +from aioresponses import aioresponses +from typing import Optional, Text, List, Callable, Type, Any + +from rasa.core.lock_store import InMemoryLockStore +from rasa.core.policies.ensemble import DefaultPolicyPredictionEnsemble +from rasa.core.tracker_store import InMemoryTrackerStore +import rasa.shared.utils.io +from rasa.core.actions.action import ( + ActionBotResponse, + ActionListen, + ActionExecutionRejection, + ActionUnlikelyIntent, +) +from rasa.core.nlg import NaturalLanguageGenerator, TemplatedNaturalLanguageGenerator +from rasa.core.policies.policy import PolicyPrediction +from tests.conftest import ( + with_assistant_id, + with_assistant_ids, + with_model_id, + with_model_ids, +) +import tests.utilities + +from rasa.core import jobs +from rasa.core.agent import Agent, load_agent +from rasa.core.channels.channel import ( + CollectingOutputChannel, + UserMessage, + OutputChannel, +) +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.storage import ModelStorage +from rasa.exceptions import ActionLimitReached +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +from rasa.shared.constants import ASSISTANT_ID_KEY, LATEST_TRAINING_DATA_FORMAT_VERSION +from rasa.shared.core.domain import SessionConfig, Domain, KEY_ACTIONS +from rasa.shared.core.events import ( + ActionExecuted, + ActiveLoop, + BotUttered, + ReminderCancelled, + ReminderScheduled, + Restarted, + UserUttered, + SessionStarted, + Event, + SlotSet, + DefinePrevUserUtteredFeaturization, + ActionExecutionRejected, + LoopInterrupted, +) +from rasa.core.http_interpreter import RasaNLUHttpInterpreter +from rasa.core.processor import MessageProcessor +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.nlu.constants import ( + INTENT, + INTENT_NAME_KEY, + FULL_RETRIEVAL_INTENT_NAME_KEY, + METADATA_MODEL_ID, +) +from rasa.shared.nlu.training_data.message import Message +from rasa.utils.endpoints import EndpointConfig +from rasa.shared.core.constants import ( + ACTION_EXTRACT_SLOTS, + ACTION_RESTART_NAME, + ACTION_UNLIKELY_INTENT_NAME, + DEFAULT_INTENTS, + ACTION_LISTEN_NAME, + ACTION_SESSION_START_NAME, + EXTERNAL_MESSAGE_PREFIX, + IS_EXTERNAL, + SESSION_START_METADATA_SLOT, +) + +import logging + +logger = logging.getLogger(__name__) + + +async def test_message_processor( + default_channel: CollectingOutputChannel, default_processor: MessageProcessor +): + await default_processor.handle_message( + UserMessage('/greet{"name":"Core"}', default_channel) + ) + assert default_channel.latest_output() == { + "recipient_id": "default", + "text": "hey there Core!", + } + + +async def test_message_id_logging(default_processor: MessageProcessor): + message = UserMessage("If Meg was an egg would she still have a leg?") + tracker = DialogueStateTracker("1", []) + await default_processor._handle_message_with_tracker(message, tracker) + logged_event = tracker.events[-1] + + assert logged_event.message_id == message.message_id + assert logged_event.message_id is not None + + +async def test_parsing(default_processor: MessageProcessor): + message = UserMessage('/greet{"name": "boy"}') + parsed = await default_processor.parse_message(message) + assert parsed["intent"][INTENT_NAME_KEY] == "greet" + assert parsed["entities"][0]["entity"] == "name" + + +async def test_check_for_unseen_feature(default_processor: MessageProcessor): + message = UserMessage('/greet{"name": "Joe"}') + old_domain = default_processor.domain + dict_for_new_domain = old_domain.as_dict() + dict_for_new_domain["intents"] = [ + intent for intent in dict_for_new_domain["intents"] if intent != "greet" + ] + dict_for_new_domain["entities"] = [ + entity for entity in dict_for_new_domain["entities"] if entity != "name" + ] + new_domain = Domain.from_dict(dict_for_new_domain) + default_processor.domain = new_domain + + parsed = await default_processor.parse_message(message) + with pytest.warns(UserWarning) as record: + default_processor._check_for_unseen_features(parsed) + assert len(record) == 2 + + assert record[0].message.args[0].startswith("Parsed an intent 'greet'") + assert record[1].message.args[0].startswith("Parsed an entity 'name'") + + default_processor.domain = old_domain + + +@pytest.mark.parametrize("default_intent", DEFAULT_INTENTS) +async def test_default_intent_recognized( + default_processor: MessageProcessor, default_intent: Text +): + message = UserMessage(f"/{default_intent}") + parsed = await default_processor.parse_message(message) + with pytest.warns(None) as record: + default_processor._check_for_unseen_features(parsed) + assert len(record) == 0 + + +async def test_http_parsing(trained_default_agent_model: Text, domain: Domain): + message = UserMessage("lunch?") + + endpoint = EndpointConfig("https://interpreter.com") + + response_body = { + "intent": {INTENT_NAME_KEY: "some_intent", "confidence": 1.0}, + "entities": [], + "text": "lunch?", + } + + with aioresponses() as mocked: + mocked.post( + "https://interpreter.com/model/parse", + repeat=True, + status=HTTPStatus.OK, + body=json.dumps(response_body), + ) + + inter = RasaNLUHttpInterpreter(endpoint_config=endpoint) + processor = MessageProcessor( + trained_default_agent_model, + InMemoryTrackerStore(domain), + InMemoryLockStore(), + NaturalLanguageGenerator(), + http_interpreter=inter, + ) + data = await processor.parse_message(message) + + r = tests.utilities.latest_request( + mocked, "POST", "https://interpreter.com/model/parse" + ) + + assert r + assert data == response_body + + +async def test_http_parsing_default_response( + trained_default_agent_model: Text, domain: Domain +): + message = UserMessage("lunch?") + + endpoint = EndpointConfig("https://interpreter.com") + + with aioresponses() as mocked: + mocked.post( + "https://interpreter.com/model/parse", + repeat=True, + status=HTTPStatus.OK, + body=None, + ) + + inter = RasaNLUHttpInterpreter(endpoint_config=endpoint) + processor = MessageProcessor( + trained_default_agent_model, + InMemoryTrackerStore(domain), + InMemoryLockStore(), + NaturalLanguageGenerator(), + http_interpreter=inter, + ) + data = await processor.parse_message(message) + + r = tests.utilities.latest_request( + mocked, "POST", "https://interpreter.com/model/parse" + ) + + assert r + assert data == { + "intent": {INTENT_NAME_KEY: "", "confidence": 0.0}, + "entities": [], + "text": "", + } + + +async def test_reminder_scheduled( + default_channel: CollectingOutputChannel, default_processor: MessageProcessor +): + sender_id = uuid.uuid4().hex + + reminder = ReminderScheduled("remind", datetime.datetime.now()) + tracker = await default_processor.tracker_store.get_or_create_tracker(sender_id) + + tracker.update(UserUttered("test")) + tracker.update(ActionExecuted("action_schedule_reminder")) + tracker.update(reminder) + + await default_processor.tracker_store.save(tracker) + + await default_processor.handle_reminder(reminder, sender_id, default_channel) + + # retrieve the updated tracker + t = await default_processor.tracker_store.retrieve(sender_id) + + assert t.events[1] == UserUttered("test") + assert t.events[2] == ActionExecuted("action_schedule_reminder") + assert isinstance(t.events[3], ReminderScheduled) + assert t.events[4] == UserUttered( + f"{EXTERNAL_MESSAGE_PREFIX}remind", + intent={INTENT_NAME_KEY: "remind", IS_EXTERNAL: True}, + ) + + +async def test_reminder_lock( + default_channel: CollectingOutputChannel, + default_processor: MessageProcessor, + caplog: LogCaptureFixture, +): + caplog.clear() + with caplog.at_level(logging.DEBUG): + sender_id = uuid.uuid4().hex + + reminder = ReminderScheduled("remind", datetime.datetime.now()) + tracker = await default_processor.tracker_store.get_or_create_tracker(sender_id) + + tracker.update(UserUttered("test")) + tracker.update(ActionExecuted("action_schedule_reminder")) + tracker.update(reminder) + + await default_processor.tracker_store.save(tracker) + + await default_processor.handle_reminder(reminder, sender_id, default_channel) + + assert f"Deleted lock for conversation '{sender_id}'." in caplog.text + + +async def test_trigger_external_latest_input_channel( + default_channel: CollectingOutputChannel, default_processor: MessageProcessor +): + sender_id = uuid.uuid4().hex + tracker = await default_processor.tracker_store.get_or_create_tracker(sender_id) + input_channel = "test_input_channel_external" + + tracker.update(UserUttered("test1")) + tracker.update(UserUttered("test2", input_channel=input_channel)) + + await default_processor.trigger_external_user_uttered( + "test3", None, tracker, default_channel + ) + + tracker = await default_processor.tracker_store.retrieve(sender_id) + + assert tracker.get_latest_input_channel() == input_channel + + +async def test_reminder_aborted( + default_channel: CollectingOutputChannel, default_processor: MessageProcessor +): + sender_id = uuid.uuid4().hex + + reminder = ReminderScheduled( + "utter_greet", datetime.datetime.now(), kill_on_user_message=True + ) + tracker = await default_processor.tracker_store.get_or_create_tracker(sender_id) + + tracker.update(reminder) + tracker.update(UserUttered("test")) # cancels the reminder + + await default_processor.tracker_store.save(tracker) + await default_processor.handle_reminder(reminder, sender_id, default_channel) + + # retrieve the updated tracker + t = await default_processor.tracker_store.retrieve(sender_id) + assert len(t.events) == 3 # nothing should have been executed + + +async def wait_until_all_jobs_were_executed( + timeout_after_seconds: Optional[float] = None, +) -> None: + total_seconds = 0.0 + while len((await jobs.scheduler()).get_jobs()) > 0 and ( + not timeout_after_seconds or total_seconds < timeout_after_seconds + ): + await asyncio.sleep(0.1) + total_seconds += 0.1 + + if total_seconds >= timeout_after_seconds: + jobs.kill_scheduler() + raise TimeoutError + + +async def test_reminder_cancelled_multi_user( + default_channel: CollectingOutputChannel, default_processor: MessageProcessor +): + sender_ids = [uuid.uuid4().hex, uuid.uuid4().hex] + trackers = [] + for sender_id in sender_ids: + tracker = await default_processor.tracker_store.get_or_create_tracker(sender_id) + + tracker.update(UserUttered("test")) + tracker.update(ActionExecuted("action_reminder_reminder")) + tracker.update( + ReminderScheduled( + "greet", datetime.datetime.now(), kill_on_user_message=True + ) + ) + trackers.append(tracker) + + # cancel all reminders (one) for the first user + trackers[0].update(ReminderCancelled()) + + for tracker in trackers: + await default_processor.tracker_store.save(tracker) + await default_processor._schedule_reminders( + tracker.events, tracker, default_channel + ) + # check that the jobs were added + assert len((await jobs.scheduler()).get_jobs()) == 2 + + for tracker in trackers: + await default_processor._cancel_reminders(tracker.events, tracker) + # check that only one job was removed + assert len((await jobs.scheduler()).get_jobs()) == 1 + + # execute the jobs + await wait_until_all_jobs_were_executed(timeout_after_seconds=5.0) + + tracker_0 = await default_processor.tracker_store.retrieve(sender_ids[0]) + # there should be no utter_greet action + assert ( + UserUttered( + f"{EXTERNAL_MESSAGE_PREFIX}greet", + intent={INTENT_NAME_KEY: "greet", IS_EXTERNAL: True}, + ) + not in tracker_0.events + ) + + tracker_1 = await default_processor.tracker_store.retrieve(sender_ids[1]) + # there should be utter_greet action + assert ( + UserUttered( + f"{EXTERNAL_MESSAGE_PREFIX}greet", + intent={INTENT_NAME_KEY: "greet", IS_EXTERNAL: True}, + ) + in tracker_1.events + ) + + +async def test_reminder_cancelled_cancels_job_with_name( + default_channel: CollectingOutputChannel, default_processor: MessageProcessor +): + sender_id = "][]][xy,,=+2f'[:/;>] <0d]A[e_,02" + + reminder = ReminderScheduled( + intent="greet", trigger_date_time=datetime.datetime.now() + ) + job_name = reminder.scheduled_job_name(sender_id) + reminder_cancelled = ReminderCancelled() + + assert reminder_cancelled.cancels_job_with_name(job_name, sender_id) + assert not reminder_cancelled.cancels_job_with_name(job_name.upper(), sender_id) + + +async def test_reminder_cancelled_cancels_job_with_name_special_name( + default_channel: CollectingOutputChannel, default_processor: MessageProcessor +): + sender_id = "][]][xy,,=+2f'[:/; >]<0d]A[e_,02" + name = "wkjbgr,34(,*&%^^&*(OP#LKMN V#NF# # #R" + + reminder = ReminderScheduled( + intent="greet", trigger_date_time=datetime.datetime.now(), name=name + ) + job_name = reminder.scheduled_job_name(sender_id) + reminder_cancelled = ReminderCancelled(name) + + assert reminder_cancelled.cancels_job_with_name(job_name, sender_id) + assert not reminder_cancelled.cancels_job_with_name(job_name.upper(), sender_id) + + +async def cancel_reminder_and_check( + tracker: DialogueStateTracker, + default_processor: MessageProcessor, + reminder_canceled_event: ReminderCancelled, + num_jobs_before: int, + num_jobs_after: int, +) -> None: + # cancel the sixth reminder + tracker.update(reminder_canceled_event) + + # check that the jobs were added + assert len((await jobs.scheduler()).get_jobs()) == num_jobs_before + + await default_processor._cancel_reminders(tracker.events, tracker) + + # check that only one job was removed + assert len((await jobs.scheduler()).get_jobs()) == num_jobs_after + + +async def test_reminder_cancelled_by_name( + default_channel: CollectingOutputChannel, + default_processor: MessageProcessor, + tracker_with_six_scheduled_reminders: DialogueStateTracker, +): + tracker = tracker_with_six_scheduled_reminders + await default_processor._schedule_reminders( + tracker.events, tracker, default_channel + ) + + # cancel the sixth reminder + await cancel_reminder_and_check( + tracker, default_processor, ReminderCancelled("special"), 6, 5 + ) + + +async def test_reminder_cancelled_by_entities( + default_channel: CollectingOutputChannel, + default_processor: MessageProcessor, + tracker_with_six_scheduled_reminders: DialogueStateTracker, +): + tracker = tracker_with_six_scheduled_reminders + await default_processor._schedule_reminders( + tracker.events, tracker, default_channel + ) + + # cancel the fourth reminder + await cancel_reminder_and_check( + tracker, + default_processor, + ReminderCancelled(entities=[{"entity": "name", "value": "Bruce Wayne"}]), + 6, + 5, + ) + + +async def test_reminder_cancelled_by_intent( + default_channel: CollectingOutputChannel, + default_processor: MessageProcessor, + tracker_with_six_scheduled_reminders: DialogueStateTracker, +): + tracker = tracker_with_six_scheduled_reminders + await default_processor._schedule_reminders( + tracker.events, tracker, default_channel + ) + + # cancel the third, fifth, and sixth reminder + await cancel_reminder_and_check( + tracker, default_processor, ReminderCancelled(intent="default"), 6, 3 + ) + + +async def test_reminder_cancelled_all( + default_channel: CollectingOutputChannel, + default_processor: MessageProcessor, + tracker_with_six_scheduled_reminders: DialogueStateTracker, +): + tracker = tracker_with_six_scheduled_reminders + await default_processor._schedule_reminders( + tracker.events, tracker, default_channel + ) + + # cancel all reminders + await cancel_reminder_and_check( + tracker, default_processor, ReminderCancelled(), 6, 0 + ) + + +async def test_reminder_restart( + default_channel: CollectingOutputChannel, default_processor: MessageProcessor +): + sender_id = uuid.uuid4().hex + + reminder = ReminderScheduled( + "utter_greet", datetime.datetime.now(), kill_on_user_message=False + ) + tracker = await default_processor.tracker_store.get_or_create_tracker(sender_id) + + tracker.update(reminder) + tracker.update(Restarted()) # cancels the reminder + tracker.update(UserUttered("test")) + + await default_processor.tracker_store.save(tracker) + await default_processor.handle_reminder(reminder, sender_id, default_channel) + + # retrieve the updated tracker + t = await default_processor.tracker_store.retrieve(sender_id) + assert len(t.events) == 4 # nothing should have been executed + + +@pytest.mark.parametrize( + "event_to_apply,session_expiration_time_in_minutes,has_expired", + [ + # last user event is way in the past + (UserUttered(timestamp=1), 60, True), + # user event are very recent + (UserUttered("hello", timestamp=time.time()), 120, False), + # there is user event + (ActionExecuted(ACTION_LISTEN_NAME, timestamp=time.time()), 60, False), + # Old event, but sessions are disabled + (UserUttered("hello", timestamp=1), 0, False), + # there is no event + (None, 1, False), + ], +) +async def test_has_session_expired( + event_to_apply: Optional[Event], + session_expiration_time_in_minutes: float, + has_expired: bool, + default_processor: MessageProcessor, +): + sender_id = uuid.uuid4().hex + + default_processor.domain.session_config = SessionConfig( + session_expiration_time_in_minutes, True + ) + # create new tracker without events + tracker = await default_processor.tracker_store.get_or_create_tracker(sender_id) + tracker.events.clear() + + # apply desired event + if event_to_apply: + tracker.update(event_to_apply) + + # noinspection PyProtectedMember + assert default_processor._has_session_expired(tracker) == has_expired + + +# noinspection PyProtectedMember + + +async def test_update_tracker_session( + default_channel: CollectingOutputChannel, + default_processor: MessageProcessor, + monkeypatch: MonkeyPatch, +): + sender_id = uuid.uuid4().hex + tracker = await default_processor.tracker_store.get_or_create_tracker(sender_id) + + # patch `_has_session_expired()` so the `_update_tracker_session()` call actually + # does something + monkeypatch.setattr(default_processor, "_has_session_expired", lambda _: True) + + await default_processor._update_tracker_session(tracker, default_channel) + + # the save is not called in _update_tracker_session() + await default_processor.save_tracker(tracker) + + # inspect tracker and make sure all events are present + tracker = await default_processor.tracker_store.retrieve_full_tracker(sender_id) + + assert list(tracker.events) == [ + ActionExecuted(ACTION_LISTEN_NAME), + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ActionExecuted(ACTION_LISTEN_NAME), + ] + + +async def test_update_tracker_session_with_metadata( + default_processor: MessageProcessor, monkeypatch: MonkeyPatch +): + model_id = default_processor.model_metadata.model_id + assistant_id = default_processor.model_metadata.assistant_id + sender_id = uuid.uuid4().hex + message_metadata = {"metadataTestKey": "metadataTestValue"} + message = UserMessage( + text="hi", + output_channel=CollectingOutputChannel(), + sender_id=sender_id, + metadata=message_metadata, + ) + await default_processor.handle_message(message) + + tracker = await default_processor.tracker_store.retrieve_full_tracker(sender_id) + events = list(tracker.events) + + with_model_ids_expected = with_model_ids( + [ + SlotSet(SESSION_START_METADATA_SLOT, message_metadata), + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + SlotSet(SESSION_START_METADATA_SLOT, message_metadata), + ActionExecuted(ACTION_LISTEN_NAME), + ], + model_id, + ) + final_expected = with_assistant_ids(with_model_ids_expected, assistant_id) + + assert events[0:5] == final_expected[0:5] + assert tracker.slots[SESSION_START_METADATA_SLOT].value == message_metadata + assert events[2].metadata == { + ASSISTANT_ID_KEY: assistant_id, + METADATA_MODEL_ID: model_id, + } + + assert isinstance(events[5], UserUttered) + + +@freezegun.freeze_time("2020-02-01") +async def test_custom_action_session_start_with_metadata( + default_processor: MessageProcessor, +): + domain = Domain.from_dict({KEY_ACTIONS: [ACTION_SESSION_START_NAME]}) + default_processor.domain = domain + model_id = default_processor.model_metadata.model_id + action_server_url = "http://some-url" + default_processor.action_endpoint = EndpointConfig(action_server_url) + + sender_id = uuid.uuid4().hex + metadata = {"metadataTestKey": "metadataTestValue"} + message = UserMessage( + text="hi", + output_channel=CollectingOutputChannel(), + sender_id=sender_id, + metadata=metadata, + ) + + with aioresponses() as mocked: + mocked.post(action_server_url, payload={"events": []}) + await default_processor.handle_message(message) + + last_request = tests.utilities.latest_request(mocked, "post", action_server_url) + tracker_for_custom_action = tests.utilities.json_of_latest_request(last_request)[ + "tracker" + ] + + assert tracker_for_custom_action["events"] == [ + { + "event": "slot", + "timestamp": 1580515200.0, + "name": SESSION_START_METADATA_SLOT, + "value": metadata, + "metadata": {"assistant_id": "placeholder_default", "model_id": model_id}, + } + ] + + +# noinspection PyProtectedMember + + +async def test_update_tracker_session_with_slots( + default_channel: CollectingOutputChannel, + default_processor: MessageProcessor, + monkeypatch: MonkeyPatch, +): + sender_id = uuid.uuid4().hex + tracker = await default_processor.tracker_store.get_or_create_tracker(sender_id) + + # apply a user uttered and five slots + user_event = UserUttered("some utterance") + tracker.update(user_event) + + slot_set_events = [SlotSet(f"slot key {i}", f"test value {i}") for i in range(5)] + + for event in slot_set_events: + tracker.update(event) + + # patch `_has_session_expired()` so the `_update_tracker_session()` call actually + # does something + monkeypatch.setattr(default_processor, "_has_session_expired", lambda _: True) + + await default_processor._update_tracker_session(tracker, default_channel) + + # the save is not called in _update_tracker_session() + await default_processor.save_tracker(tracker) + + # inspect tracker and make sure all events are present + tracker = await default_processor.tracker_store.retrieve_full_tracker(sender_id) + events = list(tracker.events) + + # the first three events should be up to the user utterance + assert events[:2] == [ActionExecuted(ACTION_LISTEN_NAME), user_event] + + # next come the five slots + assert events[2:7] == slot_set_events + + # the next two events are the session start sequence + assert events[7:9] == [ActionExecuted(ACTION_SESSION_START_NAME), SessionStarted()] + assert events[9:14] == slot_set_events + + # finally an action listen, this should also be the last event + assert events[14] == events[-1] == ActionExecuted(ACTION_LISTEN_NAME) + + +async def test_fetch_tracker_and_update_session( + default_channel: CollectingOutputChannel, default_processor: MessageProcessor +): + model_id = default_processor.model_metadata.model_id + assistant_id = default_processor.model_metadata.assistant_id + sender_id = uuid.uuid4().hex + tracker = await default_processor.fetch_tracker_and_update_session( + sender_id, default_channel + ) + + # ensure session start sequence is present + assert list(tracker.events) == with_assistant_ids( + with_model_ids( + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ActionExecuted(ACTION_LISTEN_NAME), + ], + model_id, + ), + assistant_id, + ) + + +@pytest.mark.parametrize( + "initial_events,expected_event_types", + [ + # tracker is initially not empty - when it is fetched, it will just contain + # these four events + ( + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("/greet", {INTENT_NAME_KEY: "greet", "confidence": 1.0}), + ], + [ActionExecuted, SessionStarted, ActionExecuted, UserUttered], + ), + # tracker is initially empty, and contains the session start sequence when + # fetched + ([], [ActionExecuted, SessionStarted, ActionExecuted]), + ], +) +async def test_fetch_tracker_with_initial_session( + default_channel: CollectingOutputChannel, + default_processor: MessageProcessor, + initial_events: List[Event], + expected_event_types: List[Type[Event]], +): + conversation_id = uuid.uuid4().hex + + tracker = DialogueStateTracker.from_events(conversation_id, initial_events) + + await default_processor.tracker_store.save(tracker) + + tracker = await default_processor.fetch_tracker_with_initial_session( + conversation_id, default_channel + ) + + # the events in the fetched tracker are as expected + assert len(tracker.events) == len(expected_event_types) + + assert all( + isinstance(tracker_event, expected_event_type) + for tracker_event, expected_event_type in zip( + tracker.events, expected_event_types + ) + ) + + +async def test_fetch_tracker_with_initial_session_does_not_update_session( + default_channel: CollectingOutputChannel, + default_processor: MessageProcessor, + monkeypatch: MonkeyPatch, +): + conversation_id = uuid.uuid4().hex + + # the domain has a session expiration time of one second + monkeypatch.setattr( + default_processor.tracker_store.domain, + "session_config", + SessionConfig(carry_over_slots=True, session_expiration_time=1 / 60), + ) + + now = time.time() + + # the tracker initially contains events + initial_events = [ + ActionExecuted(ACTION_SESSION_START_NAME, timestamp=now - 10), + SessionStarted(timestamp=now - 9), + ActionExecuted(ACTION_LISTEN_NAME, timestamp=now - 8), + UserUttered( + "/greet", {INTENT_NAME_KEY: "greet", "confidence": 1.0}, timestamp=now - 7 + ), + ] + + tracker = DialogueStateTracker.from_events(conversation_id, initial_events) + + await default_processor.tracker_store.save(tracker) + + tracker = await default_processor.fetch_tracker_with_initial_session( + conversation_id, default_channel + ) + + # the conversation session has expired, but calling + # `fetch_tracker_with_initial_session()` did not update it + assert default_processor._has_session_expired(tracker) + assert [event.as_dict() for event in tracker.events] == [ + event.as_dict() for event in initial_events + ] + + +async def test_handle_message_with_session_start( + default_channel: CollectingOutputChannel, + default_processor: MessageProcessor, + monkeypatch: MonkeyPatch, +): + sender_id = uuid.uuid4().hex + model_id = default_processor.model_metadata.model_id + assistant_id = default_processor.model_metadata.assistant_id + + entity = "name" + slot_1 = {entity: "Core"} + await default_processor.handle_message( + UserMessage(f"/greet{json.dumps(slot_1)}", default_channel, sender_id) + ) + + assert default_channel.latest_output() == { + "recipient_id": sender_id, + "text": "hey there Core!", + } + + # patch processor so a session start is triggered + monkeypatch.setattr(default_processor, "_has_session_expired", lambda _: True) + + slot_2 = {entity: "post-session start hello"} + # handle a new message + await default_processor.handle_message( + UserMessage(f"/greet{json.dumps(slot_2)}", default_channel, sender_id) + ) + + tracker = await default_processor.tracker_store.get_or_create_full_tracker( + sender_id + ) + + # make sure the sequence of events is as expected + with_model_ids_expected = with_model_ids( + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + f"/greet{json.dumps(slot_1)}", + {INTENT_NAME_KEY: "greet", "confidence": 1.0}, + [ + { + "entity": entity, + "start": 6, + "end": 22, + "value": "Core", + "extractor": "RegexMessageHandler", + } + ], + ), + SlotSet(entity, slot_1[entity]), + DefinePrevUserUtteredFeaturization(False), + ActionExecuted("utter_greet"), + BotUttered("hey there Core!", metadata={"utter_action": "utter_greet"}), + ActionExecuted(ACTION_LISTEN_NAME), + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + # the initial SlotSet is reapplied after the SessionStarted sequence + SlotSet(entity, slot_1[entity]), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + f"/greet{json.dumps(slot_2)}", + {INTENT_NAME_KEY: "greet", "confidence": 1.0}, + [ + { + "entity": entity, + "start": 6, + "end": 42, + "value": "post-session start hello", + "extractor": "RegexMessageHandler", + } + ], + ), + SlotSet(entity, slot_2[entity]), + DefinePrevUserUtteredFeaturization(False), + ActionExecuted("utter_greet"), + BotUttered( + "hey there post-session start hello!", + metadata={"utter_action": "utter_greet"}, + ), + ActionExecuted(ACTION_LISTEN_NAME), + ], + model_id, + ) + expected = with_assistant_ids(with_model_ids_expected, assistant_id=assistant_id) + assert list(tracker.events) == expected + + +# noinspection PyProtectedMember +@pytest.mark.parametrize( + "action_name, should_predict_another_action", + [ + (ACTION_LISTEN_NAME, False), + (ACTION_SESSION_START_NAME, False), + ("utter_greet", True), + ], +) +async def test_should_predict_another_action( + default_processor: MessageProcessor, + action_name: Text, + should_predict_another_action: bool, +): + assert ( + default_processor.should_predict_another_action(action_name) + == should_predict_another_action + ) + + +async def test_action_unlikely_intent_metadata(default_processor: MessageProcessor): + tracker = DialogueStateTracker.from_events( + "some-sender", evts=[ActionExecuted(ACTION_LISTEN_NAME)] + ) + domain = Domain.empty() + metadata = {"key1": 1, "key2": "2"} + + await default_processor._run_action( + ActionUnlikelyIntent(), + tracker, + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + PolicyPrediction([], "some policy", action_metadata=metadata), + ) + + applied_events = tracker.applied_events() + assert applied_events == [ + ActionExecuted(ACTION_LISTEN_NAME), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME, metadata=metadata), + ] + assert applied_events[1].metadata == metadata + + +async def test_restart_triggers_session_start( + default_channel: CollectingOutputChannel, + default_processor: MessageProcessor, + monkeypatch: MonkeyPatch, + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, +): + sender_id = uuid.uuid4().hex + model_id = default_processor.model_metadata.model_id + assistant_id = default_processor.model_metadata.assistant_id + + entity = "name" + slot_1 = {entity: "name1"} + await default_processor.handle_message( + UserMessage(f"/greet{json.dumps(slot_1)}", default_channel, sender_id) + ) + + assert default_channel.latest_output() == { + "recipient_id": sender_id, + "text": "hey there name1!", + } + + # This restarts the chat + await default_processor.handle_message( + UserMessage("/restart", default_channel, sender_id) + ) + + tracker = await default_processor.tracker_store.get_or_create_tracker(sender_id) + + with_model_ids_expected = with_model_ids( + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + f"/greet{json.dumps(slot_1)}", + {INTENT_NAME_KEY: "greet", "confidence": 1.0}, + [ + { + "entity": entity, + "start": 6, + "end": 23, + "value": "name1", + "extractor": "RegexMessageHandler", + } + ], + ), + SlotSet(entity, slot_1[entity]), + DefinePrevUserUtteredFeaturization(use_text_for_featurization=False), + ActionExecuted("utter_greet"), + BotUttered("hey there name1!", metadata={"utter_action": "utter_greet"}), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("/restart", {INTENT_NAME_KEY: "restart", "confidence": 1.0}), + DefinePrevUserUtteredFeaturization(use_text_for_featurization=False), + ActionExecuted(ACTION_RESTART_NAME), + Restarted(), + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + # No previous slot is set due to restart. + ActionExecuted(ACTION_LISTEN_NAME), + ], + model_id, + ) + expected = with_assistant_ids(with_model_ids_expected, assistant_id) + for actual, expected in zip(tracker.events, expected): + assert actual == expected + + +async def test_handle_message_if_action_manually_rejects( + default_processor: MessageProcessor, monkeypatch: MonkeyPatch +): + conversation_id = "test" + message = UserMessage("/greet", sender_id=conversation_id) + + rejection_events = [ + SlotSet("my_slot", "test"), + ActionExecutionRejected("utter_greet"), + SlotSet("some slot", "some value"), + ] + + async def mocked_run(self, *args: Any, **kwargs: Any) -> List[Event]: + return rejection_events + + monkeypatch.setattr(ActionBotResponse, ActionBotResponse.run.__name__, mocked_run) + await default_processor.handle_message(message) + + tracker = await default_processor.tracker_store.retrieve(conversation_id) + + logged_events = list(tracker.events) + + assert ActionExecuted("utter_greet") not in logged_events + assert all(event in logged_events for event in rejection_events) + + +async def test_policy_events_are_applied_to_tracker( + default_processor: MessageProcessor, monkeypatch: MonkeyPatch +): + model_id = default_processor.model_metadata.model_id + assistant_id = default_processor.model_metadata.assistant_id + expected_action = ACTION_LISTEN_NAME + policy_events = [LoopInterrupted(True)] + conversation_id = "test_policy_events_are_applied_to_tracker" + user_message = "/greet" + + with_model_ids_expected_events = with_model_ids( + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(user_message, intent={"name": "greet"}), + *policy_events, + ], + model_id, + ) + expected_events = with_assistant_ids(with_model_ids_expected_events, assistant_id) + + def combine_predictions( + self, + predictions: List[PolicyPrediction], + tracker: DialogueStateTracker, + domain: Domain, + **kwargs: Any, + ) -> PolicyPrediction: + prediction = PolicyPrediction.for_action_name( + default_processor.domain, expected_action, "some policy" + ) + prediction.events = policy_events + + return prediction + + monkeypatch.setattr( + DefaultPolicyPredictionEnsemble, "combine_predictions", combine_predictions + ) + + action_received_events = False + + async def mocked_run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + ) -> List[Event]: + # The action already has access to the policy events + nonlocal action_received_events + action_received_events = list(tracker.events) == expected_events + return [] + + monkeypatch.setattr(ActionListen, ActionListen.run.__name__, mocked_run) + + await default_processor.handle_message( + UserMessage(user_message, sender_id=conversation_id) + ) + + assert action_received_events + + tracker = await default_processor.get_tracker(conversation_id) + # The action was logged on the tracker as well + expected_events.append( + with_assistant_id( + with_model_id(ActionExecuted(ACTION_LISTEN_NAME), model_id), assistant_id + ) + ) + + for event, expected in zip(tracker.events, expected_events): + assert event == expected + + +# noinspection PyTypeChecker +@pytest.mark.parametrize( + "reject_fn", + [ + lambda: [ActionExecutionRejected(ACTION_LISTEN_NAME)], + lambda: (_ for _ in ()).throw(ActionExecutionRejection(ACTION_LISTEN_NAME)), + ], +) +async def test_policy_events_not_applied_if_rejected( + default_processor: MessageProcessor, + monkeypatch: MonkeyPatch, + reject_fn: Callable[[], List[Event]], +): + model_id = default_processor.model_metadata.model_id + assistant_id = default_processor.model_metadata.assistant_id + expected_action = ACTION_LISTEN_NAME + expected_events = [LoopInterrupted(True)] + conversation_id = "test_policy_events_are_applied_to_tracker" + user_message = "/greet" + + def combine_predictions( + self, + predictions: List[PolicyPrediction], + tracker: DialogueStateTracker, + domain: Domain, + **kwargs: Any, + ) -> PolicyPrediction: + prediction = PolicyPrediction.for_action_name( + default_processor.domain, expected_action, "some policy" + ) + prediction.events = expected_events + + return prediction + + monkeypatch.setattr( + DefaultPolicyPredictionEnsemble, "combine_predictions", combine_predictions + ) + + async def mocked_run(*args: Any, **kwargs: Any) -> List[Event]: + return reject_fn() + + monkeypatch.setattr(ActionListen, ActionListen.run.__name__, mocked_run) + + await default_processor.handle_message( + UserMessage(user_message, sender_id=conversation_id) + ) + + tracker = await default_processor.get_tracker(conversation_id) + events = with_model_ids( + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(user_message, intent={"name": "greet"}), + ActionExecutionRejected(ACTION_LISTEN_NAME), + ], + model_id, + ) + expected_events = with_assistant_ids(events, assistant_id) + for event, expected in zip(tracker.events, expected_events): + assert event == expected + + +async def test_logging_of_end_to_end_action( + default_processor: MessageProcessor, monkeypatch: MonkeyPatch +): + model_id = default_processor.model_metadata.model_id + assistant_id = default_processor.model_metadata.assistant_id + end_to_end_action = "hi, how are you?" + new_domain = Domain( + intents=["greet"], + entities=[], + slots=[], + responses={}, + action_names=[], + forms={}, + action_texts=[end_to_end_action], + data={}, + ) + + default_processor.domain = new_domain + + conversation_id = "test_logging_of_end_to_end_action" + user_message = "/greet" + + number_of_calls = 0 + + def combine_predictions( + self, + predictions: List[PolicyPrediction], + tracker: DialogueStateTracker, + domain: Domain, + **kwargs: Any, + ) -> PolicyPrediction: + nonlocal number_of_calls + if number_of_calls == 0: + prediction = PolicyPrediction.for_action_name( + new_domain, end_to_end_action, "some policy" + ) + prediction.is_end_to_end_prediction = True + number_of_calls += 1 + return prediction + else: + return PolicyPrediction.for_action_name(new_domain, ACTION_LISTEN_NAME) + + monkeypatch.setattr( + DefaultPolicyPredictionEnsemble, "combine_predictions", combine_predictions + ) + + await default_processor.handle_message( + UserMessage(user_message, sender_id=conversation_id) + ) + + tracker = await default_processor.tracker_store.retrieve(conversation_id) + events = with_model_ids( + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(user_message, intent={"name": "greet"}), + ActionExecuted(action_text=end_to_end_action), + BotUttered("hi, how are you?", {}, {}, 123), + ActionExecuted(ACTION_LISTEN_NAME), + ], + model_id=model_id, + ) + expected_events = with_assistant_ids(events, assistant_id) + for event, expected in zip(tracker.events, expected_events): + assert event == expected + + +async def test_predict_next_action_with_hidden_rules( + trained_async: Callable, tmp_path: Path +): + rule_intent = "rule_intent" + rule_action = "rule_action" + story_intent = "story_intent" + story_action = "story_action" + rule_slot = "rule_slot" + story_slot = "story_slot" + domain_content = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - {rule_intent} + - {story_intent} + actions: + - {rule_action} + - {story_action} + slots: + {rule_slot}: + type: text + mappings: + - type: from_text + {story_slot}: + type: text + mappings: + - type: from_text + """ + ) + domain = Domain.from_yaml(domain_content) + domain_path = tmp_path / "domain.yml" + rasa.shared.utils.io.write_text_file(domain_content, domain_path) + + training_data = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + rules: + - rule: rule + steps: + - intent: {rule_intent} + - action: {rule_action} + - slot_was_set: + - {rule_slot}: {rule_slot} + + stories: + - story: story + steps: + - intent: {story_intent} + - action: {story_action} + - slot_was_set: + - {story_slot}: {story_slot} + """ + ) + training_data_path = tmp_path / "data.yml" + rasa.shared.utils.io.write_text_file(training_data, training_data_path) + + config = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + assistant_id: placeholder_default + policies: + - name: RulePolicy + - name: MemoizationPolicy + + """ + ) + config_path = tmp_path / "config.yml" + rasa.shared.utils.io.write_text_file(config, config_path) + model_path = await trained_async( + str(domain_path), str(config_path), [str(training_data_path)] + ) + agent = await load_agent(model_path=model_path) + processor = agent.processor + + tracker = DialogueStateTracker.from_events( + "casd", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": rule_intent}), + ], + slots=domain.slots, + ) + action, prediction = processor.predict_next_with_tracker_if_should(tracker) + assert action._name == rule_action + assert prediction.hide_rule_turn + + processor._log_action_on_tracker( + tracker, action, [SlotSet(rule_slot, rule_slot)], prediction + ) + + action, prediction = processor.predict_next_with_tracker_if_should(tracker) + assert isinstance(action, ActionListen) + assert prediction.hide_rule_turn + + processor._log_action_on_tracker(tracker, action, None, prediction) + + tracker.events.append(UserUttered(intent={"name": story_intent})) + + # rules are hidden correctly if memo policy predicts next actions correctly + action, prediction = processor.predict_next_with_tracker_if_should(tracker) + assert action._name == story_action + assert not prediction.hide_rule_turn + + processor._log_action_on_tracker( + tracker, action, [SlotSet(story_slot, story_slot)], prediction + ) + + action, prediction = processor.predict_next_with_tracker_if_should(tracker) + assert isinstance(action, ActionListen) + assert not prediction.hide_rule_turn + + +def test_predict_next_action_raises_limit_reached_exception( + default_processor: MessageProcessor, +): + tracker = DialogueStateTracker.from_events( + "test", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("Hi!"), + ActionExecuted("test_action"), + ], + ) + tracker.set_latest_action({"action_name": "test_action"}) + + default_processor.max_number_of_predictions = 1 + with pytest.raises(ActionLimitReached): + default_processor.predict_next_with_tracker_if_should(tracker) + + +async def test_processor_logs_text_tokens_in_tracker( + default_agent: Agent, whitespace_tokenizer: WhitespaceTokenizer +): + text = "Hello there" + tokens = whitespace_tokenizer.tokenize(Message(data={"text": text}), "text") + indices = [(t.start, t.end) for t in tokens] + + message = UserMessage(text) + processor = default_agent.processor + tracker = await processor.log_message(message) + event = tracker.get_last_event_for(event_type=UserUttered) + event_tokens = event.as_dict().get("parse_data").get("text_tokens") + + assert event_tokens == indices + + +async def test_processor_valid_slot_setting(default_agent: Agent): + processor = default_agent.processor + message = UserMessage( + "Hiya Peter", + CollectingOutputChannel(), + "test", + parse_data={ + "intent": {"name": "greet"}, + "entities": [{"entity": "name", "value": "Peter"}], + }, + ) + await processor.handle_message(message) + tracker = await processor.get_tracker("test") + assert SlotSet("name", "Peter") in tracker.events + + +async def test_parse_message_nlu_only(trained_moodbot_nlu_path: Text): + processor = Agent.load(model_path=trained_moodbot_nlu_path).processor + message = UserMessage("/greet") + result = await processor.parse_message(message) + assert result == { + "text": "/greet", + "intent": {"name": "greet", "confidence": 1.0}, + "intent_ranking": [{"name": "greet", "confidence": 1.0}], + "entities": [], + } + + message = UserMessage("Hello") + result = await processor.parse_message(message) + assert result["intent"]["name"] + + +async def test_parse_message_core_only(trained_core_model: Text): + processor = Agent.load(model_path=trained_core_model).processor + message = UserMessage("/greet") + result = await processor.parse_message(message) + assert result == { + "text": "/greet", + "intent": {"name": "greet", "confidence": 1.0}, + "intent_ranking": [{"name": "greet", "confidence": 1.0}], + "entities": [], + } + + message = UserMessage("Hello") + result = await processor.parse_message(message) + assert not result["intent"]["name"] + + +async def test_parse_message_full_model(trained_moodbot_path: Text): + processor = Agent.load(model_path=trained_moodbot_path).processor + message = UserMessage("/greet") + result = await processor.parse_message(message) + assert result == { + "text": "/greet", + "intent": {"name": "greet", "confidence": 1.0}, + "intent_ranking": [{"name": "greet", "confidence": 1.0}], + "entities": [], + } + + message = UserMessage("Hello") + result = await processor.parse_message(message) + assert result["intent"]["name"] + + +def test_predict_next_with_tracker_nlu_only(trained_nlu_model: Text): + processor = Agent.load(model_path=trained_nlu_model).processor + tracker = DialogueStateTracker("some_id", []) + tracker.followup_action = None + result = processor.predict_next_with_tracker(tracker) + assert result is None + + +def test_predict_next_with_tracker_core_only(trained_core_model: Text): + processor = Agent.load(model_path=trained_core_model).processor + tracker = DialogueStateTracker("some_id", []) + tracker.followup_action = None + result = processor.predict_next_with_tracker(tracker) + assert result["policy"] == "MemoizationPolicy" + + +def test_predict_next_with_tracker_full_model(trained_rasa_model: Text): + processor = Agent.load(model_path=trained_rasa_model).processor + tracker = DialogueStateTracker("some_id", []) + tracker.followup_action = None + result = processor.predict_next_with_tracker(tracker) + assert result["policy"] == "MemoizationPolicy" + + +async def test_get_tracker_adds_model_id(default_processor: MessageProcessor): + model_id = default_processor.model_metadata.model_id + tracker = await default_processor.get_tracker("bloop") + assert tracker.model_id == model_id + + +# FIXME: these tests take too long to run in the CI, disabling them for now +@pytest.mark.skip_on_ci +async def _test_processor_e2e_slot_set(e2e_bot_agent: Agent, caplog: LogCaptureFixture): + processor = e2e_bot_agent.processor + message = UserMessage("I am feeling sad.", CollectingOutputChannel(), "test") + with caplog.at_level(logging.DEBUG): + await processor.handle_message(message) + + tracker = await processor.get_tracker("test") + assert SlotSet("mood", "sad") in tracker.events + assert any( + "An end-to-end prediction was made which has triggered the 2nd execution of " + "the default action 'action_extract_slots'." in message + for message in caplog.messages + ) + + +async def test_model_name_is_available(trained_rasa_model: Text): + processor = Agent.load(model_path=trained_rasa_model).processor + assert len(processor.model_filename) > 0 + assert "/" not in processor.model_filename + + +async def test_loads_correct_model_from_path( + trained_core_model: Text, trained_nlu_model: Text, tmp_path: Path +): + # We move both models to the same directory to prove we can load models by name + # from a directory with multiple models. + model_dir = tmp_path / "models" + os.makedirs(model_dir) + + trained_core_model_name = os.path.basename(trained_core_model) + shutil.copy2(trained_core_model, model_dir) + + trained_nlu_model_name = os.path.basename(trained_nlu_model) + shutil.copy2(trained_nlu_model, model_dir) + + core_processor = Agent.load( + model_path=model_dir / trained_core_model_name + ).processor + nlu_processor = Agent.load(model_path=model_dir / trained_nlu_model_name).processor + + assert core_processor.model_filename == trained_core_model_name + assert nlu_processor.model_filename == trained_nlu_model_name + + +@pytest.mark.flaky +@pytest.mark.timeout(180, func_only=True) +async def test_custom_action_triggers_action_extract_slots( + trained_async: Callable, + caplog: LogCaptureFixture, +): + parent_folder = "data/test_custom_action_triggers_action_extract_slots" + domain_path = f"{parent_folder}/domain.yml" + config_path = f"{parent_folder}/config.yml" + stories_path = f"{parent_folder}/stories.yml" + nlu_path = f"{parent_folder}/nlu.yml" + + model_path = await trained_async(domain_path, config_path, [stories_path, nlu_path]) + agent = Agent.load(model_path) + processor = agent.processor + + action_server_url = "http://some-url" + endpoint = EndpointConfig(action_server_url) + processor.action_endpoint = endpoint + + entity_name = "mood" + slot_name = "mood_slot" + slot_value = "happy" + custom_action = "action_force_next_utter" + + sender_id = uuid.uuid4().hex + message = UserMessage( + text="Activate custom action.", + output_channel=CollectingOutputChannel(), + sender_id=sender_id, + parse_data={ + "intent": {"name": "activate_flow", "confidence": 1}, + "entities": [], + }, + ) + + with aioresponses() as mocked: + mocked.post( + action_server_url, + payload={ + "events": [ + {"event": "action", "name": "action_listen"}, + { + "event": "user", + "text": "Feeling so happy", + "parse_data": { + "intent": {"name": "mood_great", "confidence": 1.0}, + "entities": [{"entity": entity_name, "value": slot_value}], + }, + }, + ] + }, + ) + with caplog.at_level(logging.DEBUG): + await processor.handle_message(message) + + caplog_records = [rec.message for rec in caplog.records] + + assert ( + f"A `UserUttered` event was returned by executing " + f"action '{custom_action}'. This will run the default action " + f"'{ACTION_EXTRACT_SLOTS}'." in caplog_records + ) + + tracker = await processor.get_tracker(sender_id) + assert any( + isinstance(e, UserUttered) and e.text == "Feeling so happy" + for e in tracker.events + ) + assert SlotSet(slot_name, slot_value) in tracker.events + assert tracker.get_slot(slot_name) == slot_value + assert any( + isinstance(e, BotUttered) and e.text == "Great, carry on!" + for e in tracker.events + ) + + +async def test_processor_executes_bot_uttered_returned_by_action_extract_slots( + default_agent: Agent, +): + slot_name = "location" + domain_yaml = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + + intents: + - inform + + entities: + - {slot_name} + + slots: + {slot_name}: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: {slot_name} + + actions: + - action_validate_slot_mappings + """ + ) + domain = Domain.from_yaml(domain_yaml) + processor = default_agent.processor + processor.domain = domain + + action_server_url = "http:/my-action-server:5055/webhook" + processor.action_endpoint = EndpointConfig(action_server_url) + + sender_id = uuid.uuid4().hex + message = UserMessage( + text="This is a test.", + output_channel=CollectingOutputChannel(), + sender_id=sender_id, + parse_data={ + "intent": {"name": "inform", "confidence": 1}, + "entities": [{"entity": slot_name, "value": "Lisbon"}], + }, + ) + + bot_uttered_text = "This city is not yet supported." + + with aioresponses() as mocked: + mocked.post( + action_server_url, + payload={ + "events": [ + {"event": "bot", "text": bot_uttered_text}, + {"event": "slot", "name": "location", "value": None}, + ] + }, + ) + responses = await processor.handle_message(message) + assert any(bot_uttered_text in r.get("text") for r in responses) + + tracker = await processor.get_tracker(sender_id) + assert tracker.get_slot(slot_name) is None + + +@pytest.mark.flaky +@pytest.mark.timeout(180, func_only=True) +@pytest.mark.parametrize( + "sender_id, message_text, message_intent", + [ + ("happy_path", "Hi", "greet"), + ("another_form_activation", "switch forms", "switch_another_form"), + ], +) +async def test_from_trigger_intent_with_mapping_conditions_when_form_not_activated( + trained_async: Callable, + sender_id: Text, + message_text: Text, + message_intent: Text, +): + parent_folder = "data/test_from_trigger_intent_with_mapping_conditions" + domain_path = f"{parent_folder}/domain.yml" + config_path = f"{parent_folder}/config.yml" + stories_path = f"{parent_folder}/stories.yml" + nlu_path = f"{parent_folder}/nlu.yml" + + model_path = await trained_async(domain_path, config_path, [stories_path, nlu_path]) + agent = Agent.load(model_path) + processor = agent.processor + + slot_name = "test_trigger" + slot_value = "testing123" + + user_messages = [ + UserMessage( + text=message_text, + output_channel=CollectingOutputChannel(), + sender_id=sender_id, + parse_data={ + "intent": {"name": message_intent, "confidence": 1}, + "entities": [], + }, + ), + UserMessage( + text="great", + output_channel=CollectingOutputChannel(), + sender_id=sender_id, + parse_data={ + "intent": {"name": "mood_great", "confidence": 1}, + "entities": [], + }, + ), + ] + + for msg in user_messages: + await processor.handle_message(msg) + + tracker = await processor.get_tracker(sender_id) + assert SlotSet(slot_name, slot_value) not in tracker.events + assert tracker.get_slot(slot_name) is None + + +@pytest.mark.flaky +@pytest.mark.timeout(120, func_only=True) +async def test_from_trigger_intent_no_form_condition_when_form_not_activated( + trained_async: Callable, +): + parent_folder = "data/test_from_trigger_intent_with_no_mapping_conditions" + domain_path = f"{parent_folder}/domain.yml" + config_path = f"{parent_folder}/config.yml" + stories_path = f"{parent_folder}/stories.yml" + nlu_path = f"{parent_folder}/nlu.yml" + + model_path = await trained_async(domain_path, config_path, [stories_path, nlu_path]) + agent = Agent.load(model_path) + processor = agent.processor + + slot_name = "test_trigger" + slot_value = "testing123" + + sender_id = uuid.uuid4().hex + user_messages = [ + UserMessage( + text="Hi", + output_channel=CollectingOutputChannel(), + sender_id=sender_id, + parse_data={ + "intent": {"name": "greet", "confidence": 1}, + "entities": [], + }, + ), + UserMessage( + text="great", + output_channel=CollectingOutputChannel(), + sender_id=sender_id, + parse_data={ + "intent": {"name": "mood_great", "confidence": 1}, + "entities": [], + }, + ), + ] + for msg in user_messages: + await processor.handle_message(msg) + + tracker = await processor.get_tracker(sender_id) + assert SlotSet(slot_name, slot_value) not in tracker.events + assert tracker.get_slot(slot_name) is None + + # test that the form activation path works as expected + sender_id_form_activation = "test_form_activation" + await processor.handle_message( + UserMessage( + text="great", + output_channel=CollectingOutputChannel(), + sender_id=sender_id_form_activation, + parse_data={ + "intent": {"name": "mood_great", "confidence": 1}, + "entities": [], + }, + ) + ) + + tracker = await processor.get_tracker(sender_id_form_activation) + assert ActiveLoop("test_form") in tracker.events + assert SlotSet(slot_name, slot_value) in tracker.events + assert tracker.get_slot(slot_name) == slot_value + + +@pytest.mark.timeout(120, func_only=True) +async def test_message_processor_raises_warning_if_no_assistant_id( + trained_async: Callable, +): + parent_folder = "data/test_moodbot" + domain_path = f"{parent_folder}/domain.yml" + config_path = "data/test_config/test_moodbot_config_no_assistant_id.yml" + stories_path = f"{parent_folder}/data/stories.yml" + nlu_path = f"{parent_folder}/data/nlu.yml" + + model_path = await trained_async( + domain=domain_path, config=config_path, training_files=[stories_path, nlu_path] + ) + warning_message = ( + f"The model metadata does not contain a value for the '{ASSISTANT_ID_KEY}' " + f"attribute. Check that 'config.yml' file contains a value for " + f"the '{ASSISTANT_ID_KEY}' key and re-train the model. " + f"Failure to do so will result in streaming events without a " + f"unique assistant identifier." + ) + + with pytest.warns(UserWarning, match=warning_message): + Agent.load(model_path) + + +async def test_processor_fetch_full_tracker_with_initial_session_inexistent_tracker( + default_processor: MessageProcessor, +) -> None: + """Test that the tracker is created with the correct initial session data.""" + sender_id = uuid.uuid4().hex + tracker = await default_processor.fetch_full_tracker_with_initial_session(sender_id) + + assert tracker.sender_id == sender_id + assert tracker.latest_message == UserUttered.empty() + assert tracker.latest_action_name == ACTION_LISTEN_NAME + assert len(tracker.events) == 3 + + first_recorded_event = tracker.events[0] + assert isinstance(first_recorded_event, ActionExecuted) + assert first_recorded_event.action_name == ACTION_SESSION_START_NAME + + assert isinstance(tracker.events[1], SessionStarted) + + last_recorded_event = tracker.events[2] + assert isinstance(last_recorded_event, ActionExecuted) + assert last_recorded_event.action_name == ACTION_LISTEN_NAME + + +async def test_processor_fetch_full_tracker_with_initial_session_existing_tracker( + default_processor: MessageProcessor, +): + """Test that an existing tracker is correctly retrieved.""" + sender_id = uuid.uuid4().hex + expected_events = [ + UserUttered("hello"), + Restarted(), + ActionExecuted(ACTION_LISTEN_NAME), + ] + tracker = DialogueStateTracker.from_events(sender_id, evts=expected_events) + await default_processor.save_tracker(tracker) + + tracker = await default_processor.fetch_full_tracker_with_initial_session(sender_id) + assert tracker.sender_id == sender_id + assert all([event in expected_events for event in tracker.events]) + + +async def test_run_anonymization_pipeline_no_pipeline( + monkeypatch: MonkeyPatch, + default_agent: Agent, +) -> None: + processor = default_agent.processor + sender_id = uuid.uuid4().hex + tracker = await processor.tracker_store.get_or_create_tracker(sender_id) + + manager = plugin_manager() + monkeypatch.setattr( + manager.hook, "get_anonymization_pipeline", MagicMock(return_value=None) + ) + event_diff = MagicMock() + monkeypatch.setattr( + "rasa.shared.core.trackers.TrackerEventDiffEngine.event_difference", event_diff + ) + await processor.run_anonymization_pipeline(tracker) + + event_diff.assert_not_called() + + +async def test_run_anonymization_pipeline_mocked_pipeline( + monkeypatch: MonkeyPatch, + default_agent: Agent, +) -> None: + processor = default_agent.processor + sender_id = uuid.uuid4().hex + tracker = await processor.tracker_store.get_or_create_tracker(sender_id) + + manager = plugin_manager() + monkeypatch.setattr( + manager.hook, + "get_anonymization_pipeline", + MagicMock(return_value="mock_pipeline"), + ) + event_diff = MagicMock() + monkeypatch.setattr( + "rasa.shared.core.trackers.TrackerEventDiffEngine.event_difference", event_diff + ) + await processor.run_anonymization_pipeline(tracker) + + event_diff.assert_called_once() + + +async def test_update_full_retrieval_intent( + default_processor: MessageProcessor, +) -> None: + parse_data = { + "text": "I like sunny days in berlin", + "intent": {"name": "chitchat", "confidence": 0.9}, + "entities": [], + "response_selector": { + "all_retrieval_intents": ["faq", "chitchat"], + "faq": { + "response": { + "responses": [{"text": "Our return policy lasts 30 days."}], + "confidence": 1.0, + "intent_response_key": "faq/what_is_return_policy", + "utter_action": "utter_faq/what_is_return_policy", + }, + "ranking": [ + { + "confidence": 1.0, + "intent_response_key": "faq/what_is_return_policy", + }, + { + "confidence": 2.3378809862799945e-19, + "intent_response_key": "faq/how_can_i_track_my_order", + }, + ], + }, + "chitchat": { + "response": { + "responses": [ + { + "text": "The sun is out today! Isn't that great?", + }, + ], + "confidence": 1.0, + "intent_response_key": "chitchat/ask_weather", + "utter_action": "utter_chitchat/ask_weather", + }, + "ranking": [ + { + "confidence": 1.0, + "intent_response_key": "chitchat/ask_weather", + }, + {"confidence": 0.0, "intent_response_key": "chitchat/ask_name"}, + ], + }, + }, + } + + default_processor._update_full_retrieval_intent(parse_data) + + assert parse_data[INTENT][INTENT_NAME_KEY] == "chitchat" + # assert that parse_data["intent"] has a key called response + assert FULL_RETRIEVAL_INTENT_NAME_KEY in parse_data[INTENT] + assert parse_data[INTENT][FULL_RETRIEVAL_INTENT_NAME_KEY] == "chitchat/ask_weather" diff --git a/tests/core/test_restore.py b/tests/core/test_restore.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/test_run.py b/tests/core/test_run.py new file mode 100644 index 0000000..8eda150 --- /dev/null +++ b/tests/core/test_run.py @@ -0,0 +1,89 @@ +import warnings +from unittest.mock import Mock + +import pytest +from typing import Text + +import rasa.shared.core.domain +from sanic import Sanic +from asyncio import AbstractEventLoop +from pathlib import Path +from rasa.core import run +from rasa.core.brokers.sql import SQLEventBroker +from rasa.core.utils import AvailableEndpoints + +CREDENTIALS_FILE = "data/test_moodbot/credentials.yml" + + +def test_create_http_input_channels(): + channels = run.create_http_input_channels(None, CREDENTIALS_FILE) + assert len(channels) == 7 + + # ensure correct order + assert {c.name() for c in channels} == { + "twilio", + "slack", + "telegram", + "mattermost", + "facebook", + "webexteams", + "rocketchat", + } + + +def test_create_single_input_channels(): + channels = run.create_http_input_channels("facebook", CREDENTIALS_FILE) + assert len(channels) == 1 + assert channels[0].name() == "facebook" + + +def test_create_single_input_channels_by_class(): + channels = run.create_http_input_channels( + "rasa.core.channels.rest.RestInput", CREDENTIALS_FILE + ) + assert len(channels) == 1 + assert channels[0].name() == "rest" + + +def test_create_single_input_channels_by_class_wo_credentials(): + channels = run.create_http_input_channels( + "rasa.core.channels.rest.RestInput", credentials_file=None + ) + + assert len(channels) == 1 + assert channels[0].name() == "rest" + + +async def test_load_agent_on_start_with_good_model_file( + trained_rasa_model: Text, rasa_server: Sanic, loop: AbstractEventLoop +): + agent = await run.load_agent_on_start( + trained_rasa_model, AvailableEndpoints(), None, rasa_server, loop + ) + + assert agent.is_ready() + assert isinstance(agent.domain, rasa.shared.core.domain.Domain) + + +async def test_load_agent_on_start_with_bad_model_file( + tmp_path: Path, rasa_non_trained_server: Sanic, loop: AbstractEventLoop +): + fake_model = tmp_path / "fake_model.tar.gz" + fake_model.touch() + fake_model_path = str(fake_model) + + with pytest.warns(UserWarning) as warnings: + await run.load_agent_on_start( + fake_model_path, AvailableEndpoints(), None, rasa_non_trained_server, loop + ) + assert any("No valid model found at" in str(w.message) for w in warnings) + + +async def test_close_resources(loop: AbstractEventLoop): + broker = SQLEventBroker() + app = Mock() + app.ctx.agent.tracker_store.event_broker = broker + + with warnings.catch_warnings() as record: + await run.close_resources(app, loop) + assert record is None diff --git a/tests/core/test_test.py b/tests/core/test_test.py new file mode 100644 index 0000000..f46e5c6 --- /dev/null +++ b/tests/core/test_test.py @@ -0,0 +1,679 @@ +import shutil +import textwrap +from pathlib import Path +from typing import Text, Optional, Dict, Any, List, Callable, Coroutine +import pytest +import rasa.core.test +import rasa.shared.utils.io +from rasa.core.policies.ensemble import DefaultPolicyPredictionEnsemble +from rasa.core.policies.policy import PolicyPrediction +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION +from rasa.shared.core.events import UserUttered +from _pytest.monkeypatch import MonkeyPatch +from _pytest.capture import CaptureFixture +from rasa.core.agent import Agent, load_agent +from rasa.utils.tensorflow.constants import ( + QUERY_INTENT_KEY, + NAME, + THRESHOLD_KEY, + SEVERITY_KEY, + SCORE_KEY, +) +from rasa.core.constants import STORIES_WITH_WARNINGS_FILE +from rasa.shared.core.constants import ACTION_UNLIKELY_INTENT_NAME +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.domain import Domain + +from rasa.core.policies.rule_policy import RulePolicy +from rasa.shared.core.domain import State +from rasa.core.policies.policy import SupportedData +from rasa.shared.utils.io import read_file, read_yaml + + +def _probabilities_with_action_unlikely_intent_for( + intent_names: List[Text], + metadata_for_intent: Optional[Dict[Text, Dict[Text, Any]]] = None, +) -> Callable[ + [DefaultPolicyPredictionEnsemble, DialogueStateTracker, Domain, Any], + PolicyPrediction, +]: + _original = DefaultPolicyPredictionEnsemble.combine_predictions_from_kwargs + + def combine_predictions_from_kwargs( + self, tracker: DialogueStateTracker, domain: Domain, **kwargs: Any + ) -> PolicyPrediction: + latest_event = tracker.events[-1] + if ( + isinstance(latest_event, UserUttered) + and latest_event.parse_data["intent"]["name"] in intent_names + ): + intent_name = latest_event.parse_data["intent"]["name"] + # Here we return `action_unlikely_intent` if the name of the + # latest intent is present in `intent_names`. Accompanying + # metadata is fetched from `metadata_for_intent` if it is present. + # We need to do it because every time the tests are run, + # training will result in different model weights which might + # result in different predictions of `action_unlikely_intent`. + # Because we're not testing `UnexpecTEDIntentPolicy`, + # here we simply trigger it by + # predicting `action_unlikely_intent` in a specified moment + # to make the tests deterministic. + return PolicyPrediction.for_action_name( + domain, + ACTION_UNLIKELY_INTENT_NAME, + action_metadata=metadata_for_intent.get(intent_name) + if metadata_for_intent + else None, + ) + + return _original(self, tracker, domain, **kwargs) + + return combine_predictions_from_kwargs + + +def _custom_prediction_states_for_rules( + ignore_action_unlikely_intent: bool = False, +) -> Callable[[RulePolicy, DialogueStateTracker, Domain, bool], List[State],]: + """Creates prediction states for `RulePolicy`. + + `RulePolicy` does not ignore `action_unlikely_intent` in reality. + We use this helper method to monkey patch it for tests so that we can + test `rasa test`'s behaviour when `action_unlikely_intent` is predicted. + + Args: + ignore_action_unlikely_intent: Whether to ignore `action_unlikely_intent`. + + Returns: + Monkey-patched method to create prediction states. + """ + + def _prediction_states( + self: RulePolicy, + tracker: DialogueStateTracker, + domain: Domain, + use_text_for_last_user_input: bool = False, + rule_only_data: Optional[Dict[Text, Any]] = None, + ) -> List[State]: + return self.featurizer.prediction_states( + [tracker], + domain, + use_text_for_last_user_input=use_text_for_last_user_input, + ignore_rule_only_turns=self.supported_data() == SupportedData.ML_DATA, + rule_only_data=rule_only_data, + ignore_action_unlikely_intent=ignore_action_unlikely_intent, + )[0] + + return _prediction_states + + +# FIXME: these tests take too long to run in the CI, disabling them for now +@pytest.mark.skip_on_ci +async def test_testing_warns_if_action_unknown( + capsys: CaptureFixture, + e2e_bot_agent: Agent, + e2e_bot_test_stories_with_unknown_bot_utterances: Path, + tmp_path: Path, +): + await rasa.core.test.test( + e2e_bot_test_stories_with_unknown_bot_utterances, + e2e_bot_agent, + out_directory=str(tmp_path), + ) + output = capsys.readouterr().out + assert "Test story" in output + assert "contains the bot utterance" in output + assert "which is not part of the training data / domain" in output + + +async def test_testing_with_utilizing_retrieval_intents( + response_selector_agent: Agent, response_selector_test_stories: Path, tmp_path: Path +): + result = await rasa.core.test.test( + stories=response_selector_test_stories, + agent=response_selector_agent, + e2e=True, + out_directory=str(tmp_path), + disable_plotting=True, + warnings=False, + ) + failed_stories_path = tmp_path / "failed_test_stories.yml" + failed_stories = read_yaml(read_file(failed_stories_path, "utf-8")) + # check that the intent is shown correctly in the failed test stories file + target_intents = { + "test 0": "chitchat/ask_name", + "test 1": "chitchat/ask_name", + "test 2": "chitchat", + "test 3": "chitchat", + } + for story in failed_stories["stories"]: + test_name = story["story"].split("-")[0].strip() + assert story["steps"][0]["intent"] == target_intents[test_name] + # check that retrieval intent for actions is retrieved correctly + # and only when it's needed. + target_actions = { + "utter_chitchat": "utter_chitchat", + "utter_chitchat/ask_name": "utter_chitchat/ask_name", + "utter_chitchat/ask_weather": "utter_chitchat/ask_name", + "utter_goodbye": "utter_chitchat/ask_name", + } + predicted_actions = result["actions"][::2] + for predicted_action in predicted_actions: + assert ( + target_actions[predicted_action["action"]] == predicted_action["predicted"] + ) + + +async def test_testing_does_not_warn_if_intent_in_domain( + default_agent: Agent, stories_path: Text, tmp_path: Path +): + with pytest.warns(UserWarning) as record: + await rasa.core.test.test( + Path(stories_path), default_agent, out_directory=str(tmp_path) + ) + + assert not any("Found intent" in r.message.args[0] for r in record) + assert all( + "in stories which is not part of the domain" not in r.message.args[0] + for r in record + ) + + +async def test_testing_valid_with_non_e2e_core_model(core_agent: Agent, tmp_path: Path): + result = await rasa.core.test.test( + "data/test_yaml_stories/test_stories_entity_annotations.yml", + core_agent, + out_directory=str(tmp_path), + ) + assert "report" in result.keys() + + +@pytest.fixture() +async def _train_rule_based_agent( + moodbot_domain: Domain, + tmp_path: Path, + trained_async: Callable, + monkeypatch: MonkeyPatch, + moodbot_domain_path: Path, +) -> Callable[[Path, bool], Coroutine]: + + # We need `RulePolicy` to predict the correct actions + # in a particular conversation context as seen during training. + # Since it can get affected by `action_unlikely_intent` being triggered in + # some cases. We monkey-patch the method which creates + # prediction states to ignore `action_unlikely_intent`s if needed. + + async def inner(file_name: Path, ignore_action_unlikely_intent: bool) -> Agent: + config = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + assistant_id: placeholder_default + pipeline: [] + policies: + - name: RulePolicy + restrict_rules: false + """ + ) + config_path = tmp_path / "config.yml" + rasa.shared.utils.io.write_text_file(config, config_path) + + rule_file = tmp_path / "rules.yml" + shutil.copy2(file_name, rule_file) + training_data = rule_file.read_text() + training_data_for_rules = training_data.replace("stories:", "rules:") + training_data_for_rules = training_data_for_rules.replace("story:", "rule:") + rule_file.write_text(training_data_for_rules) + + model_path = await trained_async( + moodbot_domain_path, str(config_path), str(rule_file) + ) + + monkeypatch.setattr( + RulePolicy, + "_prediction_states", + _custom_prediction_states_for_rules(ignore_action_unlikely_intent), + ) + + return await load_agent(model_path) + + return inner + + +async def test_action_unlikely_intent_warning( + monkeypatch: MonkeyPatch, + tmp_path: Path, + _train_rule_based_agent: Callable[[Path, bool], Coroutine], +): + monkeypatch.setattr( + DefaultPolicyPredictionEnsemble, + "combine_predictions_from_kwargs", + _probabilities_with_action_unlikely_intent_for(["mood_unhappy"]), + ) + + file_name = tmp_path / "test_action_unlikely_intent_1.yml" + file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: unlikely path + steps: + - user: | + very terrible + intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + """ + ) + + # We train on the above story so that RulePolicy can memorize + # it and we don't have to worry about other actions being + # predicted correctly. + agent = await _train_rule_based_agent(file_name, True) + + result = await rasa.core.test.test( + str(file_name), + agent, + out_directory=str(tmp_path), + fail_on_prediction_errors=True, + ) + assert "report" in result.keys() + assert result["report"]["conversation_accuracy"]["correct"] == 1 + assert result["report"]["conversation_accuracy"]["with_warnings"] == 1 + + # Ensure that the story with warning is correctly formatted + with open(str(tmp_path / "stories_with_warnings.yml"), "r") as f: + content = f.read() + assert f"# predicted: {ACTION_UNLIKELY_INTENT_NAME}" in content + + +async def test_action_unlikely_intent_correctly_predicted( + monkeypatch: MonkeyPatch, + tmp_path: Path, + _train_rule_based_agent: Callable[[Path, bool], Coroutine], +): + monkeypatch.setattr( + DefaultPolicyPredictionEnsemble, + "combine_predictions_from_kwargs", + _probabilities_with_action_unlikely_intent_for(["mood_unhappy"]), + ) + + file_name = tmp_path / "test_action_unlikely_intent_2.yml" + file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: unlikely path (with action_unlikely_intent) + steps: + - user: | + very terrible + intent: mood_unhappy + - action: action_unlikely_intent + - action: utter_cheer_up + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + """ + ) + + # We train on the above story so that RulePolicy can memorize + # it and we don't have to worry about other actions being + # predicted correctly. + agent = await _train_rule_based_agent(file_name, False) + + result = await rasa.core.test.test( + str(file_name), + agent, + out_directory=str(tmp_path), + fail_on_prediction_errors=True, + ) + assert "report" in result.keys() + assert result["report"]["conversation_accuracy"]["correct"] == 1 + assert result["report"]["conversation_accuracy"]["with_warnings"] == 0 + + +async def test_wrong_action_after_action_unlikely_intent( + monkeypatch: MonkeyPatch, + tmp_path: Path, + _train_rule_based_agent: Callable[[Path, bool], Coroutine], +): + monkeypatch.setattr( + DefaultPolicyPredictionEnsemble, + "combine_predictions_from_kwargs", + _probabilities_with_action_unlikely_intent_for(["greet", "mood_great"]), + ) + + test_file_name = tmp_path / "test.yml" + test_file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: happy path + steps: + - user: | + hello there! + intent: greet + - action: utter_greet + - user: | + amazing + intent: mood_great + - action: utter_happy + """ + ) + + train_file_name = tmp_path / "train.yml" + train_file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: happy path + steps: + - user: | + hello there! + intent: greet + - action: utter_happy + - user: | + amazing + intent: mood_great + - action: utter_goodbye + """ + ) + + # We train on the above story so that RulePolicy can memorize + # it and we don't have to worry about other actions being + # predicted correctly. + agent = await _train_rule_based_agent(train_file_name, True) + + result = await rasa.core.test.test( + str(test_file_name), + agent, + out_directory=str(tmp_path), + fail_on_prediction_errors=False, + ) + assert "report" in result.keys() + assert result["report"]["conversation_accuracy"]["correct"] == 0 + assert result["report"]["conversation_accuracy"]["with_warnings"] == 0 + assert result["report"]["conversation_accuracy"]["total"] == 1 + + # Ensure that the failed story is correctly formatted + with open(str(tmp_path / "failed_test_stories.yml"), "r") as f: + content = f.read() + assert ( + f"# predicted: utter_happy after {ACTION_UNLIKELY_INTENT_NAME}" in content + ) + assert ( + f"# predicted: action_default_fallback after {ACTION_UNLIKELY_INTENT_NAME}" + in content + ) + + +async def test_action_unlikely_intent_not_found( + monkeypatch: MonkeyPatch, + tmp_path: Path, + _train_rule_based_agent: Callable[[Path, bool], Coroutine], +): + test_file_name = tmp_path / "test_action_unlikely_intent_complete.yml" + test_file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: happy path + steps: + - user: | + hello there! + intent: greet + - action: action_unlikely_intent + - action: utter_greet + - user: | + amazing + intent: mood_great + - action: utter_happy + """ + ) + + train_file_name = tmp_path / "train_without_action_unlikely_intent.yml" + train_file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: happy path + steps: + - user: | + hello there! + intent: greet + - action: utter_greet + - user: | + amazing + intent: mood_great + - action: utter_happy + """ + ) + + # We train on the above story so that RulePolicy can memorize + # it and we don't have to worry about other actions being + # predicted correctly. + agent = await _train_rule_based_agent(train_file_name, False) + + result = await rasa.core.test.test( + str(test_file_name), agent, out_directory=str(tmp_path) + ) + assert "report" in result.keys() + assert result["report"]["conversation_accuracy"]["correct"] == 0 + assert result["report"]["conversation_accuracy"]["with_warnings"] == 0 + assert result["report"]["conversation_accuracy"]["total"] == 1 + + # Ensure that the failed story is correctly formatted + with open(str(tmp_path / "failed_test_stories.yml"), "r") as f: + content = f.read() + assert "# predicted: utter_greet" in content + + +async def test_action_unlikely_intent_warning_and_story_error( + monkeypatch: MonkeyPatch, + tmp_path: Path, + _train_rule_based_agent: Callable[[Path, bool], Coroutine], +): + monkeypatch.setattr( + DefaultPolicyPredictionEnsemble, + "combine_predictions_from_kwargs", + _probabilities_with_action_unlikely_intent_for(["greet"]), + ) + + test_file_name = tmp_path / "test.yml" + test_file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: happy path + steps: + - user: | + hello there! + intent: greet + - action: utter_greet + - user: | + amazing + intent: mood_great + - action: utter_happy + """ + ) + + train_file_name = tmp_path / "train.yml" + train_file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: happy path + steps: + - user: | + hello there! + intent: greet + - action: utter_greet + - user: | + amazing + intent: mood_great + - action: utter_goodbye + """ + ) + + # We train on the above story so that RulePolicy can memorize + # it and we don't have to worry about other actions being + # predicted correctly. + agent = await _train_rule_based_agent(train_file_name, True) + + result = await rasa.core.test.test( + str(test_file_name), agent, out_directory=str(tmp_path) + ) + assert "report" in result.keys() + assert result["report"]["conversation_accuracy"]["correct"] == 0 + assert result["report"]["conversation_accuracy"]["with_warnings"] == 0 + assert result["report"]["conversation_accuracy"]["total"] == 1 + + # Ensure that the failed story is correctly formatted + with open(str(tmp_path / "failed_test_stories.yml"), "r") as f: + content = f.read() + assert f"# predicted: {ACTION_UNLIKELY_INTENT_NAME}" in content + assert "# predicted: utter_goodbye" in content + + +async def test_fail_on_prediction_errors( + monkeypatch: MonkeyPatch, + tmp_path: Path, + _train_rule_based_agent: Callable[[Path, bool], Coroutine], +): + monkeypatch.setattr( + DefaultPolicyPredictionEnsemble, + "combine_predictions_from_kwargs", + _probabilities_with_action_unlikely_intent_for(["mood_unhappy"]), + ) + + file_name = tmp_path / "test_action_unlikely_intent_2.yml" + file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: unlikely path (with action_unlikely_intent) + steps: + - user: | + very terrible + intent: mood_unhappy + - action: utter_cheer_up + - action: action_unlikely_intent + - action: utter_did_that_help + - intent: affirm + - action: utter_happy + """ + ) + + # We train on the above story so that RulePolicy can memorize + # it and we don't have to worry about other actions being + # predicted correctly. + agent = await _train_rule_based_agent(file_name, False) + + with pytest.raises(rasa.core.test.WrongPredictionException): + await rasa.core.test.test( + str(file_name), + agent, + out_directory=str(tmp_path), + fail_on_prediction_errors=True, + ) + + +@pytest.mark.parametrize( + "metadata_for_intents, story_order", + [ + ( + { + "mood_unhappy": { + QUERY_INTENT_KEY: { + NAME: "mood_unhappy", + SEVERITY_KEY: 2.0, + THRESHOLD_KEY: 0.0, + SCORE_KEY: -2.0, + } + }, + "mood_great": { + QUERY_INTENT_KEY: { + NAME: "mood_great", + SEVERITY_KEY: 3.0, + THRESHOLD_KEY: 0.2, + SCORE_KEY: -1.0, + } + }, + "affirm": { + QUERY_INTENT_KEY: { + NAME: "affirm", + SEVERITY_KEY: 4.2, + THRESHOLD_KEY: 0.2, + SCORE_KEY: -4.0, + } + }, + }, + ["path 2", "path 1"], + ), + ( + { + "mood_unhappy": { + QUERY_INTENT_KEY: { + NAME: "mood_unhappy", + SEVERITY_KEY: 2.0, + THRESHOLD_KEY: 0.0, + SCORE_KEY: -2.0, + } + }, + "mood_great": { + QUERY_INTENT_KEY: { + NAME: "mood_great", + SEVERITY_KEY: 5.0, + THRESHOLD_KEY: 0.2, + SCORE_KEY: -1.0, + } + }, + "affirm": { + QUERY_INTENT_KEY: { + NAME: "affirm", + SEVERITY_KEY: 4.2, + THRESHOLD_KEY: 0.2, + SCORE_KEY: -4.0, + } + }, + }, + ["path 1", "path 2"], + ), + ], +) +async def test_multiple_warnings_sorted_on_severity( + monkeypatch: MonkeyPatch, + tmp_path: Path, + _train_rule_based_agent: Callable[[Path, bool], Coroutine], + metadata_for_intents: Dict, + story_order: List[Text], +): + monkeypatch.setattr( + DefaultPolicyPredictionEnsemble, + "combine_predictions_from_kwargs", + _probabilities_with_action_unlikely_intent_for( + list(metadata_for_intents.keys()), metadata_for_intents + ), + ) + + test_story_path = ( + "data/test_yaml_stories/test_multiple_action_unlikely_intent_warnings.yml" + ) + + # We train on the stories as it is so that RulePolicy can memorize + # it and we don't have to worry about other actions being + # predicted correctly. + agent = await _train_rule_based_agent(Path(test_story_path), True) + + await rasa.core.test.test( + test_story_path, + agent, + out_directory=str(tmp_path), + fail_on_prediction_errors=True, + ) + + warnings_file = tmp_path / STORIES_WITH_WARNINGS_FILE + warnings_data = rasa.shared.utils.io.read_yaml_file(warnings_file) + + for index, story_name in enumerate(story_order): + assert warnings_data["stories"][index]["story"].startswith(story_name) diff --git a/tests/core/test_tracker_stores.py b/tests/core/test_tracker_stores.py new file mode 100644 index 0000000..4a05f37 --- /dev/null +++ b/tests/core/test_tracker_stores.py @@ -0,0 +1,1344 @@ +import logging +import warnings +from collections import deque +from contextlib import contextmanager +from pathlib import Path + +import fakeredis +import pytest +import sqlalchemy +import uuid + +from _pytest.capture import CaptureFixture +from _pytest.logging import LogCaptureFixture +from _pytest.monkeypatch import MonkeyPatch +from moto import mock_dynamodb +from pymongo.errors import OperationFailure + +from rasa.core.agent import Agent +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +from rasa.shared.constants import DEFAULT_SENDER_ID +from sqlalchemy.dialects.postgresql.base import PGDialect +from sqlalchemy.dialects.sqlite.base import SQLiteDialect +from sqlalchemy.dialects.oracle.base import OracleDialect +from sqlalchemy.engine.url import URL +from typing import Any, Tuple, Text, Type, Dict, List, Union, Optional, ContextManager +from unittest.mock import MagicMock, Mock + +import rasa.core.tracker_store +from rasa.shared.core.constants import ( + ACTION_LISTEN_NAME, + ACTION_RESTART_NAME, + ACTION_SESSION_START_NAME, +) +from rasa.core.constants import POSTGRESQL_SCHEMA +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + SlotSet, + ActionExecuted, + Restarted, + UserUttered, + SessionStarted, + BotUttered, + Event, +) +from rasa.shared.exceptions import ConnectionException, RasaException +from rasa.core.tracker_store import ( + TrackerStore, + InMemoryTrackerStore, + RedisTrackerStore, + DEFAULT_REDIS_TRACKER_STORE_KEY_PREFIX, + SQLTrackerStore, + DynamoTrackerStore, + FailSafeTrackerStore, + AwaitableTrackerStore, +) +from rasa.shared.core.trackers import DialogueStateTracker, TrackerEventDiffEngine +from rasa.shared.nlu.training_data.message import Message +from rasa.utils.endpoints import EndpointConfig, read_endpoint_config +from tests.conftest import AsyncMock +from tests.core.conftest import MockedMongoTrackerStore + +test_domain = Domain.load("data/test_domains/default.yml") + + +async def get_or_create_tracker_store(store: TrackerStore) -> None: + slot_key = "location" + slot_val = "Easter Island" + + tracker = await store.get_or_create_tracker(DEFAULT_SENDER_ID) + ev = SlotSet(slot_key, slot_val) + tracker.update(ev) + assert tracker.get_slot(slot_key) == slot_val + + await store.save(tracker) + + again = await store.get_or_create_tracker(DEFAULT_SENDER_ID) + assert again.get_slot(slot_key) == slot_val + + +def test_get_or_create(): + get_or_create_tracker_store(InMemoryTrackerStore(test_domain)) + + +# noinspection PyPep8Naming +@mock_dynamodb +def test_dynamo_get_or_create(): + get_or_create_tracker_store(DynamoTrackerStore(test_domain)) + + +@mock_dynamodb +async def test_dynamo_tracker_floats(): + conversation_id = uuid.uuid4().hex + + tracker_store = DynamoTrackerStore(test_domain) + tracker = await tracker_store.get_or_create_tracker( + conversation_id, append_action_listen=False + ) + + # save `slot` event with known `float`-type timestamp + timestamp = 13423.23434623 + tracker.update(SlotSet("key", "val", timestamp=timestamp)) + await tracker_store.save(tracker) + + # retrieve tracker and the event timestamp is retrieved as a `float` + tracker = await tracker_store.get_or_create_tracker(conversation_id) + retrieved_timestamp = tracker.events[0].timestamp + assert isinstance(retrieved_timestamp, float) + assert retrieved_timestamp == timestamp + + +async def test_restart_after_retrieval_from_tracker_store(domain: Domain): + store = InMemoryTrackerStore(domain) + tr = await store.get_or_create_tracker("myuser") + synth = [ActionExecuted("action_listen") for _ in range(4)] + + for e in synth: + tr.update(e) + + tr.update(Restarted()) + latest_restart = tr.idx_after_latest_restart() + + await store.save(tr) + tr2 = await store.retrieve("myuser") + latest_restart_after_loading = tr2.idx_after_latest_restart() + assert latest_restart == latest_restart_after_loading + + +async def test_tracker_store_remembers_max_history(domain: Domain): + store = InMemoryTrackerStore(domain) + tr = await store.get_or_create_tracker("myuser", max_event_history=42) + tr.update(Restarted()) + + await store.save(tr) + tr2 = await store.retrieve("myuser") + assert tr._max_event_history == tr2._max_event_history == 42 + + +def test_tracker_store_endpoint_config_loading(endpoints_path: Text): + cfg = read_endpoint_config(endpoints_path, "tracker_store") + + assert cfg == EndpointConfig.from_dict( + { + "type": "redis", + "url": "localhost", + "port": 6379, + "db": 0, + "username": "username", + "password": "password", + "timeout": 30000, + "use_ssl": True, + "ssl_keyfile": "keyfile.key", + "ssl_certfile": "certfile.crt", + "ssl_ca_certs": "my-bundle.ca-bundle", + } + ) + + +def test_create_tracker_store_from_endpoint_config( + domain: Domain, endpoints_path: Text +): + store = read_endpoint_config(endpoints_path, "tracker_store") + tracker_store = RedisTrackerStore( + domain=domain, + host="localhost", + port=6379, + db=0, + username="username", + password="password", + record_exp=3000, + use_ssl=True, + ssl_keyfile="keyfile.key", + ssl_certfile="certfile.crt", + ssl_ca_certs="my-bundle.ca-bundle", + ) + + assert isinstance(tracker_store, type(TrackerStore.create(store, domain))) + + +def test_redis_tracker_store_invalid_key_prefix(domain: Domain): + + test_invalid_key_prefix = "$$ &!" + + tracker_store = RedisTrackerStore( + domain=domain, + host="localhost", + port=6379, + db=0, + password="password", + key_prefix=test_invalid_key_prefix, + record_exp=3000, + ) + + assert tracker_store._get_key_prefix() == DEFAULT_REDIS_TRACKER_STORE_KEY_PREFIX + + +def test_redis_tracker_store_valid_key_prefix(domain: Domain): + test_valid_key_prefix = "spanish" + + tracker_store = RedisTrackerStore( + domain=domain, + host="localhost", + port=6379, + db=0, + password="password", + key_prefix=test_valid_key_prefix, + record_exp=3000, + ) + + assert ( + tracker_store._get_key_prefix() + == f"{test_valid_key_prefix}:{DEFAULT_REDIS_TRACKER_STORE_KEY_PREFIX}" + ) + + +def test_exception_tracker_store_from_endpoint_config( + domain: Domain, monkeypatch: MonkeyPatch, endpoints_path: Text +): + """Check if tracker store properly handles exceptions. + + If we can not create a tracker store by instantiating the + expected type (e.g. due to an exception) we should fallback to + the default `InMemoryTrackerStore`.""" + + store = read_endpoint_config(endpoints_path, "tracker_store") + mock = Mock(side_effect=Exception("test exception")) + monkeypatch.setattr(rasa.core.tracker_store, "RedisTrackerStore", mock) + + with pytest.raises(Exception) as e: + TrackerStore.create(store, domain) + + assert "test exception" in str(e.value) + + +def test_raise_connection_exception_redis_tracker_store_creation( + domain: Domain, monkeypatch: MonkeyPatch, endpoints_path: Text +): + store = read_endpoint_config(endpoints_path, "tracker_store") + monkeypatch.setattr( + rasa.core.tracker_store, + "RedisTrackerStore", + Mock(side_effect=ConnectionError()), + ) + + with pytest.raises(ConnectionException): + TrackerStore.create(store, domain) + + +def test_mongo_tracker_store_raise_exception(domain: Domain, monkeypatch: MonkeyPatch): + monkeypatch.setattr( + rasa.core.tracker_store, + "MongoTrackerStore", + Mock( + side_effect=OperationFailure("not authorized on logs to execute command.") + ), + ) + with pytest.raises(ConnectionException) as error: + TrackerStore.create( + EndpointConfig(username="username", password="password", type="mongod"), + domain, + ) + + assert "not authorized on logs to execute command." in str(error.value) + + +class HostExampleTrackerStore(RedisTrackerStore): + pass + + +class NonAsyncTrackerStore(TrackerStore): + def retrieve(self, sender_id: Text) -> Optional[DialogueStateTracker]: + pass + + def save(self, tracker: DialogueStateTracker) -> None: + pass + + +def test_tracker_store_with_host_argument_from_string(domain: Domain): + endpoints_path = "data/test_endpoints/custom_tracker_endpoints.yml" + store_config = read_endpoint_config(endpoints_path, "tracker_store") + store_config.type = "tests.core.test_tracker_stores.HostExampleTrackerStore" + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("error") + tracker_store = TrackerStore.create(store_config, domain) + + assert len(record) == 0 + + assert isinstance(tracker_store, HostExampleTrackerStore) + + +def test_tracker_store_from_invalid_module(domain: Domain): + endpoints_path = "data/test_endpoints/custom_tracker_endpoints.yml" + store_config = read_endpoint_config(endpoints_path, "tracker_store") + store_config.type = "a.module.which.cannot.be.found" + + with pytest.warns(UserWarning): + tracker_store = TrackerStore.create(store_config, domain) + + assert isinstance(tracker_store, InMemoryTrackerStore) + + +def test_tracker_store_from_invalid_string(domain: Domain): + endpoints_path = "data/test_endpoints/custom_tracker_endpoints.yml" + store_config = read_endpoint_config(endpoints_path, "tracker_store") + store_config.type = "any string" + + with pytest.warns(UserWarning): + tracker_store = TrackerStore.create(store_config, domain) + + assert isinstance(tracker_store, InMemoryTrackerStore) + + +async def _tracker_store_and_tracker_with_slot_set() -> Tuple[ + InMemoryTrackerStore, DialogueStateTracker +]: + # returns an InMemoryTrackerStore containing a tracker with a slot set + + slot_key = "cuisine" + slot_val = "French" + + store = InMemoryTrackerStore(test_domain) + tracker = await store.get_or_create_tracker(DEFAULT_SENDER_ID) + ev = SlotSet(slot_key, slot_val) + tracker.update(ev) + + return store, tracker + + +async def test_tracker_serialisation(): + store, tracker = await _tracker_store_and_tracker_with_slot_set() + serialised = store.serialise_tracker(tracker) + + assert tracker == store.deserialise_tracker(DEFAULT_SENDER_ID, serialised) + + +@pytest.mark.parametrize( + "full_url", + [ + "postgresql://localhost", + "postgresql://localhost:5432", + "postgresql://user:secret@localhost", + "sqlite:///", + ], +) +def test_get_db_url_with_fully_specified_url(full_url: Text): + assert SQLTrackerStore.get_db_url(host=full_url) == full_url + + +def test_get_db_url_with_port_in_host(): + host = "localhost:1234" + dialect = "postgresql" + db = "mydb" + + expected = f"{dialect}://{host}/{db}" + + assert ( + str(SQLTrackerStore.get_db_url(dialect=dialect, host=host, db=db)) == expected + ) + + +def test_db_get_url_with_sqlite(): + expected = "sqlite:///rasa.db" + assert str(SQLTrackerStore.get_db_url(dialect="sqlite", db="rasa.db")) == expected + + +def test_get_db_url_with_correct_host(): + expected = "postgresql://localhost:5005/mydb" + + assert ( + str( + SQLTrackerStore.get_db_url( + dialect="postgresql", host="localhost", port=5005, db="mydb" + ) + ) + == expected + ) + + +def test_get_db_url_with_query(): + expected = "postgresql://localhost:5005/mydb?driver=my-driver" + + assert ( + str( + SQLTrackerStore.get_db_url( + dialect="postgresql", + host="localhost", + port=5005, + db="mydb", + query={"driver": "my-driver"}, + ) + ) + == expected + ) + + +def test_sql_tracker_store_logs_do_not_show_password(caplog: LogCaptureFixture): + dialect = "postgresql" + host = "localhost" + port = 9901 + db = "some-database" + username = "db-user" + password = "some-password" + + with caplog.at_level(logging.DEBUG): + _ = SQLTrackerStore(None, dialect, host, port, db, username, password) + + # the URL in the logs does not contain the password + assert password not in caplog.text + + # instead the password is displayed as '***' + assert f"postgresql://{username}:***@{host}:{port}/{db}" in caplog.text + + +def test_db_url_with_query_from_endpoint_config(tmp_path: Path): + endpoint_config = """ + tracker_store: + dialect: postgresql + url: localhost + port: 5123 + username: user + password: pw + login_db: login-db + query: + driver: my-driver + another: query + """ + f = tmp_path / "tmp_config_file.yml" + f.write_text(endpoint_config) + store_config = read_endpoint_config(str(f), "tracker_store") + + url = SQLTrackerStore.get_db_url(**store_config.kwargs) + + import itertools + + # order of query dictionary in yaml is random, test against both permutations + connection_url = "postgresql://user:pw@:5123/login-db?" + assert any( + str(url) == connection_url + "&".join(permutation) + for permutation in ( + itertools.permutations(("another=query", "driver=my-driver")) + ) + ) + + +async def test_fail_safe_tracker_store_if_no_errors(): + mocked_tracker_store = Mock() + + tracker_store = FailSafeTrackerStore(mocked_tracker_store, None) + + # test save + mocked_tracker_store.save = AsyncMock() + await tracker_store.save(None) + mocked_tracker_store.save.assert_called_once() + + # test retrieve + expected = [1] + mocked_tracker_store.retrieve = AsyncMock(return_value=expected) + sender_id = "10" + assert await tracker_store.retrieve(sender_id) == expected + mocked_tracker_store.retrieve.assert_called_once_with(sender_id) + + # test keys + expected = ["sender 1", "sender 2"] + mocked_tracker_store.keys = AsyncMock(return_value=expected) + assert await tracker_store.keys() == expected + mocked_tracker_store.keys.assert_called_once() + + +async def test_fail_safe_tracker_store_with_save_error(): + mocked_tracker_store = Mock() + mocked_tracker_store.save = Mock(side_effect=Exception()) + + fallback_tracker_store = Mock() + fallback_tracker_store.save = AsyncMock() + + on_error_callback = Mock() + + tracker_store = FailSafeTrackerStore( + mocked_tracker_store, on_error_callback, fallback_tracker_store + ) + await tracker_store.save(None) + + fallback_tracker_store.save.assert_called_once() + on_error_callback.assert_called_once() + + +async def test_fail_safe_tracker_store_with_keys_error(): + mocked_tracker_store = Mock() + mocked_tracker_store.keys = Mock(side_effect=Exception()) + + on_error_callback = Mock() + + tracker_store = FailSafeTrackerStore(mocked_tracker_store, on_error_callback) + assert await tracker_store.keys() == [] + on_error_callback.assert_called_once() + + +async def test_fail_safe_tracker_store_with_retrieve_error(): + mocked_tracker_store = Mock() + mocked_tracker_store.retrieve = Mock(side_effect=Exception()) + + fallback_tracker_store = Mock() + on_error_callback = Mock() + + tracker_store = FailSafeTrackerStore( + mocked_tracker_store, on_error_callback, fallback_tracker_store + ) + + assert await tracker_store.retrieve("sender_id") is None + on_error_callback.assert_called_once() + + +def test_set_fail_safe_tracker_store_domain(domain: Domain): + tracker_store = InMemoryTrackerStore(domain) + fallback_tracker_store = InMemoryTrackerStore(None) + failsafe_store = FailSafeTrackerStore(tracker_store, None, fallback_tracker_store) + + failsafe_store.domain = domain + assert failsafe_store.domain is domain + assert tracker_store.domain is failsafe_store.domain + assert fallback_tracker_store.domain is failsafe_store.domain + + +async def create_tracker_with_partially_saved_events( + tracker_store: TrackerStore, +) -> Tuple[List[Event], DialogueStateTracker]: + # creates a tracker with two events and saved it to the tracker store + # following that, it adds three more events that are not saved to the tracker store + sender_id = uuid.uuid4().hex + + # create tracker with two events and save it + events = [UserUttered("hello"), BotUttered("what")] + tracker = DialogueStateTracker.from_events(sender_id, events) + await tracker_store.save(tracker) + + # add more events to the tracker, do not yet save it + events = [ActionExecuted(ACTION_LISTEN_NAME), UserUttered("123"), BotUttered("yes")] + for event in events: + tracker.update(event) + + return events, tracker + + +async def _saved_tracker_with_multiple_session_starts( + tracker_store: TrackerStore, sender_id: Text +) -> DialogueStateTracker: + tracker = DialogueStateTracker.from_events( + sender_id, + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + UserUttered("hi"), + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ], + ) + + await tracker_store.save(tracker) + return await tracker_store.retrieve(sender_id) + + +async def test_mongo_additional_events(domain: Domain): + tracker_store = MockedMongoTrackerStore(domain) + events, tracker = await create_tracker_with_partially_saved_events(tracker_store) + + # make sure only new events are returned + # noinspection PyProtectedMember + assert list(tracker_store._additional_events(tracker)) == events + + +async def test_mongo_additional_events_with_session_start(domain: Domain): + sender = "test_mongo_additional_events_with_session_start" + tracker_store = MockedMongoTrackerStore(domain) + tracker = await _saved_tracker_with_multiple_session_starts(tracker_store, sender) + + tracker.update(UserUttered("hi2")) + + # noinspection PyProtectedMember + additional_events = list(tracker_store._additional_events(tracker)) + + assert len(additional_events) == 1 + assert isinstance(additional_events[0], UserUttered) + + +# we cannot parametrise over this and the previous test due to the different ways of +# calling _additional_events() +async def test_sql_additional_events(domain: Domain): + tracker_store = SQLTrackerStore(domain) + additional_events, tracker = await create_tracker_with_partially_saved_events( + tracker_store + ) + + # make sure only new events are returned + with tracker_store.session_scope() as session: + # noinspection PyProtectedMember + assert ( + list(tracker_store._additional_events(session, tracker)) + == additional_events + ) + + +async def test_sql_additional_events_with_session_start(domain: Domain): + sender = "test_sql_additional_events_with_session_start" + tracker_store = SQLTrackerStore(domain) + tracker = await _saved_tracker_with_multiple_session_starts(tracker_store, sender) + + tracker.update(UserUttered("hi2"), domain) + + # make sure only new events are returned + with tracker_store.session_scope() as session: + # noinspection PyProtectedMember + additional_events = list(tracker_store._additional_events(session, tracker)) + assert len(additional_events) == 1 + assert isinstance(additional_events[0], UserUttered) + + +async def test_tracker_store_retrieve_ordered_by_id( + domain: Domain, +): + tracker_store_kwargs = {"host": "sqlite:///"} + tracker_store = SQLTrackerStore(domain, **tracker_store_kwargs) + events = [ + SessionStarted(timestamp=1), + UserUttered("Hola", {"name": "greet"}, timestamp=2), + BotUttered("Hi", timestamp=2), + UserUttered("How are you?", {"name": "greet"}, timestamp=2), + BotUttered("I am good, whats up", timestamp=2), + UserUttered("Ciao", {"name": "greet"}, timestamp=2), + BotUttered("Bye", timestamp=2), + ] + sender_id = "test_sql_tracker_store_events_order" + tracker = DialogueStateTracker.from_events(sender_id, events) + await tracker_store.save(tracker) + + # Save other tracker to ensure that we don't run into problems with other senders + other_tracker = DialogueStateTracker.from_events("other-sender", [SessionStarted()]) + await tracker_store.save(other_tracker) + + # Retrieve tracker with events since latest SessionStarted + tracker = await tracker_store.retrieve(sender_id) + + assert len(tracker.events) == 7 + # assert the order of events is same as the order in which they were added + assert all((event == tracker.events[i] for i, event in enumerate(events))) + + +@pytest.mark.parametrize( + "tracker_store_type,tracker_store_kwargs", + [(MockedMongoTrackerStore, {}), (SQLTrackerStore, {"host": "sqlite:///"})], +) +async def test_tracker_store_retrieve_with_session_started_events( + tracker_store_type: Type[TrackerStore], + tracker_store_kwargs: Dict, + domain: Domain, +): + tracker_store = tracker_store_type(domain, **tracker_store_kwargs) + events = [ + UserUttered("Hola", {"name": "greet"}, timestamp=1), + BotUttered("Hi", timestamp=2), + SessionStarted(timestamp=3), + UserUttered("Ciao", {"name": "greet"}, timestamp=4), + ] + sender_id = "test_sql_tracker_store_with_session_events" + tracker = DialogueStateTracker.from_events(sender_id, events) + await tracker_store.save(tracker) + + # Save other tracker to ensure that we don't run into problems with other senders + other_tracker = DialogueStateTracker.from_events("other-sender", [SessionStarted()]) + await tracker_store.save(other_tracker) + + # Retrieve tracker with events since latest SessionStarted + tracker = await tracker_store.retrieve(sender_id) + + assert len(tracker.events) == 2 + assert all((event == tracker.events[i] for i, event in enumerate(events[2:]))) + + +@pytest.mark.parametrize( + "tracker_store_type,tracker_store_kwargs", + [(MockedMongoTrackerStore, {}), (SQLTrackerStore, {"host": "sqlite:///"})], +) +async def test_tracker_store_retrieve_without_session_started_events( + tracker_store_type: Type[TrackerStore], + tracker_store_kwargs: Dict, + domain, +): + tracker_store = tracker_store_type(domain, **tracker_store_kwargs) + + # Create tracker with a SessionStarted event + events = [ + UserUttered("Hola", {"name": "greet"}), + BotUttered("Hi"), + UserUttered("Ciao", {"name": "greet"}), + BotUttered("Hi2"), + ] + + sender_id = "test_sql_tracker_store_retrieve_without_session_started_events" + tracker = DialogueStateTracker.from_events(sender_id, events) + await tracker_store.save(tracker) + + # Save other tracker to ensure that we don't run into problems with other senders + other_tracker = DialogueStateTracker.from_events("other-sender", [SessionStarted()]) + await tracker_store.save(other_tracker) + + tracker = await tracker_store.retrieve(sender_id) + + assert len(tracker.events) == 4 + assert all(event == tracker.events[i] for i, event in enumerate(events)) + + +@pytest.mark.parametrize( + "tracker_store_type,tracker_store_kwargs", + [ + (MockedMongoTrackerStore, {}), + (SQLTrackerStore, {"host": "sqlite:///"}), + (InMemoryTrackerStore, {}), + ], +) +async def test_tracker_store_retrieve_with_events_from_previous_sessions( + tracker_store_type: Type[TrackerStore], tracker_store_kwargs: Dict +): + tracker_store = tracker_store_type(Domain.empty(), **tracker_store_kwargs) + + conversation_id = uuid.uuid4().hex + tracker = DialogueStateTracker.from_events( + conversation_id, + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + UserUttered("hi"), + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ], + ) + await tracker_store.save(tracker) + + actual = await tracker_store.retrieve_full_tracker(conversation_id) + + assert len(actual.events) == len(tracker.events) + + +def test_session_scope_error( + monkeypatch: MonkeyPatch, capsys: CaptureFixture, domain: Domain +): + tracker_store = SQLTrackerStore(domain) + tracker_store.sessionmaker = Mock() + + requested_schema = uuid.uuid4().hex + + # `ensure_schema_exists()` raises `ValueError` + mocked_ensure_schema_exists = Mock(side_effect=ValueError(requested_schema)) + monkeypatch.setattr( + rasa.core.tracker_store, "ensure_schema_exists", mocked_ensure_schema_exists + ) + + # `SystemExit` is triggered by failing `ensure_schema_exists()` + with pytest.raises(SystemExit): + with tracker_store.session_scope() as _: + pass + + # error message is printed + assert ( + f"Requested PostgreSQL schema '{requested_schema}' was not found in the " + f"database." in capsys.readouterr()[0] + ) + + +@pytest.mark.parametrize( + "url,is_postgres_url", + [ + (f"{PGDialect.name}://admin:pw@localhost:5432/rasa", True), + (f"{SQLiteDialect.name}:///", False), + (URL(PGDialect.name), True), + (URL(SQLiteDialect.name), False), + ], +) +def test_is_postgres_url(url: Union[Text, URL], is_postgres_url: bool): + assert rasa.core.tracker_store.is_postgresql_url(url) == is_postgres_url + + +def set_or_delete_postgresql_schema_env_var( + monkeypatch: MonkeyPatch, value: Optional[Text] +) -> None: + """Set `POSTGRESQL_SCHEMA` environment variable using `MonkeyPatch`. + + Args: + monkeypatch: Instance of `MonkeyPatch` to use for patching. + value: Value of the `POSTGRESQL_SCHEMA` environment variable to set. + """ + if value is None: + monkeypatch.delenv(POSTGRESQL_SCHEMA, raising=False) + else: + monkeypatch.setenv(POSTGRESQL_SCHEMA, value) + + +@pytest.mark.parametrize( + "url,schema_env,kwargs", + [ + # postgres without schema + ( + f"{PGDialect.name}://admin:pw@localhost:5432/rasa", + None, + { + "pool_size": rasa.core.tracker_store.POSTGRESQL_DEFAULT_POOL_SIZE, + "max_overflow": rasa.core.tracker_store.POSTGRESQL_DEFAULT_MAX_OVERFLOW, + }, + ), + # postgres with schema + ( + f"{PGDialect.name}://admin:pw@localhost:5432/rasa", + "schema1", + { + "connect_args": {"options": "-csearch_path=schema1"}, + "pool_size": rasa.core.tracker_store.POSTGRESQL_DEFAULT_POOL_SIZE, + "max_overflow": rasa.core.tracker_store.POSTGRESQL_DEFAULT_MAX_OVERFLOW, + }, + ), + # oracle without schema + (f"{OracleDialect.name}://admin:pw@localhost:5432/rasa", None, {}), + # oracle with schema + (f"{OracleDialect.name}://admin:pw@localhost:5432/rasa", "schema1", {}), + # sqlite + (f"{SQLiteDialect.name}:///", None, {}), + ], +) +def test_create_engine_kwargs( + monkeypatch: MonkeyPatch, + url: Union[Text, URL], + schema_env: Optional[Text], + kwargs: Dict[Text, Dict[Text, Union[Text, int]]], +): + set_or_delete_postgresql_schema_env_var(monkeypatch, schema_env) + + assert rasa.core.tracker_store.create_engine_kwargs(url) == kwargs + + +@contextmanager +def does_not_raise(): + """Contextmanager to be used when an expression is not expected to raise an + exception. + + This contextmanager can be used in parametrized tests, where some input objects + are expected to raise and others are not. + + Example: + + @pytest.mark.parametrize( + "a,b,raises_context", + [ + # 5/6 is a legal divison + (5, 6, does_not_raise()), + # 5/0 raises a `ZeroDivisionError` + (5, 0, pytest.raises(ZeroDivisionError)), + ], + ) + def test_divide( + a: int, b: int, raises_context: ContextManager, + ): + with raises_context: + _ = a / b + + """ + yield + + +@pytest.mark.parametrize( + "is_postgres,schema_env,schema_exists,raises_context", + [ + (True, "schema1", True, does_not_raise()), + (True, "schema1", False, pytest.raises(ValueError)), + (False, "schema1", False, does_not_raise()), + (True, None, False, does_not_raise()), + (False, None, False, does_not_raise()), + ], +) +def test_ensure_schema_exists( + monkeypatch: MonkeyPatch, + is_postgres: bool, + schema_env: Optional[Text], + schema_exists: bool, + raises_context: ContextManager, +): + set_or_delete_postgresql_schema_env_var(monkeypatch, schema_env) + monkeypatch.setattr( + rasa.core.tracker_store, "is_postgresql_url", lambda _: is_postgres + ) + monkeypatch.setattr(sqlalchemy, "exists", Mock()) + + # mock the `session.query().scalar()` query which returns whether the schema + # exists in the db + scalar = Mock(return_value=schema_exists) + query = Mock(scalar=scalar) + session = Mock() + session.query = Mock(return_value=query) + + with raises_context: + rasa.core.tracker_store.ensure_schema_exists(session) + + +def test_current_state_without_events(domain: Domain): + tracker_store = MockedMongoTrackerStore(domain) + + # insert some events + events = [ + UserUttered("Hola", {"name": "greet"}), + BotUttered("Hi"), + UserUttered("Ciao", {"name": "greet"}), + BotUttered("Hi2"), + ] + + sender_id = "test_mongo_tracker_store_current_state_without_events" + tracker = DialogueStateTracker.from_events(sender_id, events) + + # get current state without events + # noinspection PyProtectedMember + state = tracker_store._current_tracker_state_without_events(tracker) + + # `events` key should not be in there + assert state and "events" not in state + + +def test_login_db_with_no_postgresql(tmp_path: Path): + with pytest.warns(UserWarning): + SQLTrackerStore(db=str(tmp_path / "rasa.db"), login_db=str(tmp_path / "other")) + + +@pytest.mark.parametrize( + "config", + [ + { + "type": "mongod", + "url": "mongodb://0.0.0.0:42/?serverSelectionTimeoutMS=5000", + }, + {"type": "dynamo"}, + ], +) +def test_tracker_store_connection_error(config: Dict, domain: Domain): + store = EndpointConfig.from_dict(config) + + with pytest.raises(ConnectionException): + TrackerStore.create(store, domain) + + +async def prepare_token_serialisation( + tracker_store: TrackerStore, response_selector_agent: Agent, sender_id: Text +): + text = "Good morning" + tokenizer = WhitespaceTokenizer(WhitespaceTokenizer.get_default_config()) + tokens = tokenizer.tokenize(Message(data={"text": text}), "text") + indices = [[t.start, t.end] for t in tokens] + + tracker = await tracker_store.get_or_create_tracker(sender_id=sender_id) + parse_data = await response_selector_agent.parse_message(text) + event = UserUttered( + "Good morning", + parse_data.get("intent"), + parse_data.get("entities", []), + parse_data, + ) + + tracker.update(event) + await tracker_store.save(tracker) + + retrieved_tracker = await tracker_store.retrieve(sender_id=sender_id) + event = retrieved_tracker.get_last_event_for(event_type=UserUttered) + event_tokens = event.as_dict().get("parse_data").get("text_tokens") + + assert event_tokens == indices + + +def test_inmemory_tracker_store_with_token_serialisation( + domain: Domain, default_agent: Agent +): + tracker_store = InMemoryTrackerStore(domain) + prepare_token_serialisation(tracker_store, default_agent, "inmemory") + + +def test_mongo_tracker_store_with_token_serialisation( + domain: Domain, response_selector_agent: Agent +): + tracker_store = MockedMongoTrackerStore(domain) + prepare_token_serialisation(tracker_store, response_selector_agent, "mongo") + + +def test_sql_tracker_store_with_token_serialisation( + domain: Domain, response_selector_agent: Agent +): + tracker_store = SQLTrackerStore(domain, **{"host": "sqlite:///"}) + prepare_token_serialisation(tracker_store, response_selector_agent, "sql") + + +def test_sql_tracker_store_creation_with_invalid_port(domain: Domain): + with pytest.raises(RasaException) as error: + TrackerStore.create( + EndpointConfig(port="$DB_PORT", type="sql"), + domain, + ) + assert "port '$DB_PORT' cannot be cast to integer." in str(error.value) + + +def test_create_non_async_tracker_store(domain: Domain): + endpoint_config = EndpointConfig( + type="tests.core.test_tracker_stores.NonAsyncTrackerStore" + ) + with pytest.warns(FutureWarning): + tracker_store = TrackerStore.create(endpoint_config) + assert isinstance(tracker_store, AwaitableTrackerStore) + assert isinstance(tracker_store._tracker_store, NonAsyncTrackerStore) + + +def test_create_awaitable_tracker_store_with_endpoint_config(): + endpoint_config = EndpointConfig( + type="tests.core.test_tracker_stores.NonAsyncTrackerStore" + ) + tracker_store = AwaitableTrackerStore.create(endpoint_config) + + assert isinstance(tracker_store, AwaitableTrackerStore) + assert isinstance(tracker_store._tracker_store, NonAsyncTrackerStore) + + +@pytest.mark.parametrize( + "endpoints_file, expected_type", + [ + (None, InMemoryTrackerStore), + ("data/test_endpoints/endpoints_sql.yml", SQLTrackerStore), + ("data/test_endpoints/endpoints_redis.yml", RedisTrackerStore), + ], +) +def test_create_tracker_store_from_endpoints_file( + endpoints_file: Optional[Text], expected_type: Any, domain: Domain +) -> None: + endpoint_config = read_endpoint_config(endpoints_file, "tracker_store") + tracker_store = rasa.core.tracker_store.create_tracker_store( + endpoint_config, domain + ) + + assert rasa.core.tracker_store.check_if_tracker_store_async(tracker_store) is True + assert isinstance(tracker_store, expected_type) + + +async def test_fail_safe_tracker_store_retrieve_full_tracker( + domain: Domain, tracker_with_restarted_event: DialogueStateTracker +) -> None: + primary_tracker_store = SQLTrackerStore(domain) + sender_id = tracker_with_restarted_event.sender_id + + tracker_store = FailSafeTrackerStore(primary_tracker_store) + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.retrieve_full_tracker(sender_id) + assert tracker == tracker_with_restarted_event + + +async def test_fail_safe_tracker_store_retrieve_full_tracker_with_exception( + caplog: LogCaptureFixture, +) -> None: + primary_tracker_store = MagicMock() + primary_tracker_store.domain = Domain.empty() + primary_tracker_store.event_broker = None + + exception = Exception("Something went wrong") + primary_tracker_store.retrieve_full_tracker = AsyncMock(side_effect=exception) + + tracker_store = FailSafeTrackerStore(primary_tracker_store) + with caplog.at_level(logging.ERROR): + await tracker_store.retrieve_full_tracker("some_id") + + assert "Error happened when trying to retrieve conversation tracker" in caplog.text + assert f"Please investigate the following error: {exception}." in caplog.text + + +async def test_sql_get_or_create_full_tracker_without_action_listen() -> None: + tracker_store = SQLTrackerStore(Domain.empty()) + sender_id = uuid.uuid4().hex + tracker = await tracker_store.get_or_create_full_tracker( + sender_id=sender_id, append_action_listen=False + ) + assert tracker.sender_id == sender_id + assert tracker.events == deque() + + +async def test_sql_get_or_create_full_tracker_with_action_listen() -> None: + tracker_store = SQLTrackerStore(Domain.empty()) + sender_id = uuid.uuid4().hex + tracker = await tracker_store.get_or_create_full_tracker( + sender_id=sender_id, append_action_listen=True + ) + assert tracker.sender_id == sender_id + assert tracker.events == deque([ActionExecuted(ACTION_LISTEN_NAME)]) + + +async def test_sql_get_or_create_full_tracker_with_existing_tracker( + tracker_with_restarted_event: DialogueStateTracker, +) -> None: + sender_id = tracker_with_restarted_event.sender_id + + tracker_store = SQLTrackerStore(Domain.empty()) + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.get_or_create_full_tracker( + sender_id=sender_id, append_action_listen=False + ) + assert tracker.sender_id == sender_id + assert tracker.events == deque(tracker_with_restarted_event.events) + + +async def test_sql_tracker_store_retrieve_full_tracker( + domain: Domain, tracker_with_restarted_event: DialogueStateTracker +) -> None: + tracker_store = SQLTrackerStore(domain) + sender_id = tracker_with_restarted_event.sender_id + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.retrieve_full_tracker(sender_id) + assert tracker == tracker_with_restarted_event + + +async def test_sql_tracker_store_retrieve( + domain: Domain, + tracker_with_restarted_event: DialogueStateTracker, + events_after_restart: List[Event], +) -> None: + tracker_store = SQLTrackerStore(domain) + sender_id = tracker_with_restarted_event.sender_id + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.retrieve(sender_id) + + # the retrieved tracker with the latest session would not contain + # `action_session_start` event because the SQLTrackerStore filters + # only the events after `session_started` event + assert list(tracker.events) == events_after_restart[1:] + + +async def test_in_memory_tracker_store_retrieve_full_tracker( + domain: Domain, + tracker_with_restarted_event: DialogueStateTracker, +) -> None: + tracker_store = InMemoryTrackerStore(domain) + sender_id = tracker_with_restarted_event.sender_id + + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.retrieve_full_tracker(sender_id) + assert tracker == tracker_with_restarted_event + + +async def test_in_memory_tracker_store_retrieve( + domain: Domain, + tracker_with_restarted_event: DialogueStateTracker, + events_after_restart: List[Event], +) -> None: + tracker_store = InMemoryTrackerStore(domain) + sender_id = tracker_with_restarted_event.sender_id + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.retrieve(sender_id) + assert list(tracker.events) == events_after_restart + + +async def test_mongo_tracker_store_retrieve_full_tracker( + domain: Domain, + tracker_with_restarted_event: DialogueStateTracker, +) -> None: + tracker_store = MockedMongoTrackerStore(domain) + sender_id = tracker_with_restarted_event.sender_id + + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.retrieve_full_tracker(sender_id) + assert tracker == tracker_with_restarted_event + + +async def test_mongo_tracker_store_retrieve( + domain: Domain, + tracker_with_restarted_event: DialogueStateTracker, + events_after_restart: List[Event], +) -> None: + tracker_store = MockedMongoTrackerStore(domain) + sender_id = tracker_with_restarted_event.sender_id + + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.retrieve(sender_id) + + # the retrieved tracker with the latest session would not contain + # `action_session_start` event because the MongoTrackerStore filters + # only the events after `session_started` event + assert list(tracker.events) == events_after_restart[1:] + + +class MockedRedisTrackerStore(RedisTrackerStore): + def __init__( + self, + domain: Domain, + ) -> None: + self.red = fakeredis.FakeStrictRedis() + self.key_prefix = DEFAULT_REDIS_TRACKER_STORE_KEY_PREFIX + self.record_exp = None + super(RedisTrackerStore, self).__init__(domain, None) + + +async def test_redis_tracker_store_retrieve_full_tracker( + domain: Domain, + tracker_with_restarted_event: DialogueStateTracker, +) -> None: + tracker_store = MockedRedisTrackerStore(domain) + sender_id = tracker_with_restarted_event.sender_id + + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.retrieve_full_tracker(sender_id) + assert tracker == tracker_with_restarted_event + + +async def test_redis_tracker_store_retrieve( + domain: Domain, + tracker_with_restarted_event: DialogueStateTracker, + events_after_restart: List[Event], +) -> None: + tracker_store = MockedRedisTrackerStore(domain) + sender_id = tracker_with_restarted_event.sender_id + + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.retrieve(sender_id) + assert list(tracker.events) == events_after_restart + + +async def test_redis_tracker_store_merge_trackers_same_session() -> None: + start_session_sequence = [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ActionExecuted(ACTION_LISTEN_NAME), + ] + events: List[Event] = start_session_sequence + [UserUttered("hello")] + prior_tracker = DialogueStateTracker.from_events( + "same-session", + evts=events, + ) + + events += [BotUttered("Hey! How can I help you?")] + + new_tracker = DialogueStateTracker.from_events( + "same-session", + evts=events, + ) + + actual_tracker = RedisTrackerStore._merge_trackers(prior_tracker, new_tracker) + + assert actual_tracker == new_tracker + + +def test_redis_tracker_store_merge_trackers_overlapping_session() -> None: + prior_tracker_events: List[Event] = [ + ActionExecuted(ACTION_SESSION_START_NAME, timestamp=1), + SessionStarted(timestamp=2), + ActionExecuted(ACTION_LISTEN_NAME, timestamp=3), + UserUttered("hello", timestamp=4), + BotUttered("Hey! How can I help you?", timestamp=5), + UserUttered("/restart", timestamp=6), + ActionExecuted(ACTION_RESTART_NAME, timestamp=7), + ] + + new_start_session = [ + ActionExecuted(ACTION_SESSION_START_NAME, timestamp=8), + SessionStarted(timestamp=9), + ActionExecuted(ACTION_LISTEN_NAME, timestamp=10), + ] + + prior_tracker_events += new_start_session + prior_tracker = DialogueStateTracker.from_events( + "overlapping-session", + evts=prior_tracker_events, + ) + + after_restart_event = [UserUttered("hi again", timestamp=11)] + new_tracker_events = new_start_session + after_restart_event + + new_tracker = DialogueStateTracker.from_events( + "overlapping-session", + evts=new_tracker_events, + ) + + actual_tracker = RedisTrackerStore._merge_trackers(prior_tracker, new_tracker) + + expected_events = prior_tracker_events + after_restart_event + + assert list(actual_tracker.events) == expected_events + + +def test_redis_tracker_store_merge_trackers_different_session() -> None: + prior_tracker_events: List[Event] = [ + ActionExecuted(ACTION_SESSION_START_NAME, timestamp=1), + SessionStarted(timestamp=2), + ActionExecuted(ACTION_LISTEN_NAME, timestamp=3), + UserUttered("hello", timestamp=4), + BotUttered("Hey! How can I help you?", timestamp=5), + ] + prior_tracker = DialogueStateTracker.from_events( + "different-session", + evts=prior_tracker_events, + ) + + new_session = [ + ActionExecuted(ACTION_SESSION_START_NAME, timestamp=8), + SessionStarted(timestamp=9), + ActionExecuted(ACTION_LISTEN_NAME, timestamp=10), + UserUttered("I need help.", timestamp=11), + ] + + new_tracker = DialogueStateTracker.from_events( + "different-session", + evts=new_session, + ) + + actual_tracker = RedisTrackerStore._merge_trackers(prior_tracker, new_tracker) + + expected_events = prior_tracker_events + new_session + assert list(actual_tracker.events) == expected_events + + +async def test_tracker_event_diff_engine_event_difference() -> None: + start_session_sequence = [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ActionExecuted(ACTION_LISTEN_NAME), + ] + events: List[Event] = start_session_sequence + [UserUttered("hello")] + prior_tracker = DialogueStateTracker.from_events( + "same-session", + evts=events, + ) + new_events = [BotUttered("Hey! How can I help you?")] + events += new_events + + new_tracker = DialogueStateTracker.from_events( + "same-session", + evts=events, + ) + + event_diff = TrackerEventDiffEngine.event_difference(prior_tracker, new_tracker) + + assert new_events == event_diff diff --git a/tests/core/test_training.py b/tests/core/test_training.py new file mode 100644 index 0000000..7662083 --- /dev/null +++ b/tests/core/test_training.py @@ -0,0 +1,66 @@ +from pathlib import Path +from _pytest.monkeypatch import MonkeyPatch +from typing import Text + +import pytest + +from rasa.core import training +from rasa.core.agent import Agent +from rasa.shared.core.domain import Domain + +import rasa.model_training +import rasa.shared.utils.io + + +def test_load_training_data_reader_not_found_throws(tmp_path: Path, domain: Domain): + (tmp_path / "file").touch() + + with pytest.raises(Exception): + training.load_data(str(tmp_path), domain) + + +def test_training_script_with_restart_stories(tmp_path: Path, domain_path: Text): + model_file = rasa.model_training.train_core( + domain_path, + config="data/test_config/max_hist_config.yml", + stories="data/test_yaml_stories/stories_restart.yml", + output=str(tmp_path), + additional_arguments={}, + ) + + assert Path(model_file).is_file() + + +@pytest.mark.timeout(160, func_only=True) +async def test_random_seed( + tmp_path: Path, monkeypatch: MonkeyPatch, domain_path: Text, stories_path: Text +): + policies_config = { + "assistant_id": "placeholder_default", + "policies": [{"name": "TEDPolicy", "random_seed": 42}, {"name": "RulePolicy"}], + } + config_file = tmp_path / "config.yml" + rasa.shared.utils.io.write_yaml(policies_config, config_file) + + model_file_1 = rasa.model_training.train_core( + domain_path, + config=str(config_file), + stories=stories_path, + output=str(tmp_path), + additional_arguments={}, + ) + + model_file_2 = rasa.model_training.train_core( + domain_path, + config=str(config_file), + stories=stories_path, + output=str(tmp_path), + additional_arguments={}, + ) + + processor_1 = Agent.load(model_file_1).processor + processor_2 = Agent.load(model_file_2).processor + + probs_1 = await processor_1.predict_next_for_sender_id("1") + probs_2 = await processor_2.predict_next_for_sender_id("2") + assert probs_1["confidence"] == probs_2["confidence"] diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py new file mode 100644 index 0000000..1e0178c --- /dev/null +++ b/tests/core/test_utils.py @@ -0,0 +1,209 @@ +import os + +from decimal import Decimal +from typing import Optional, Text, Union, Any +from pathlib import Path + +import pytest + +import rasa.core.lock_store +import rasa.utils.io +from rasa.constants import ENV_SANIC_WORKERS +from rasa.core import utils +from rasa.core.lock_store import LockStore, RedisLockStore, InMemoryLockStore +from rasa.core.policies.policy import PolicyPrediction +from rasa.shared.core.domain import Domain +from rasa.utils.endpoints import EndpointConfig +from tests.conftest import write_endpoint_config_to_yaml + + +class CustomRedisLockStore(RedisLockStore): + """Test class used to test the behavior of custom lock stores.""" + + +def test_one_hot(): + r = utils.one_hot(4, 6) + assert (r[[0, 1, 2, 3, 5]] == 0).all() + assert r[4] == 1 + + +def test_one_hot_out_of_range(): + with pytest.raises(ValueError): + utils.one_hot(4, 3) + + +@pytest.mark.parametrize( + "_input,expected", + [ + # `int` is not converted + (-1, -1), + # `float` is converted + (2.1, round(Decimal(2.1), 4)), + # `float` that's too long is rounded + (1579507733.1107571125030517578125, Decimal("1579507733.110757")), + # strings are not converted + (["one", "two"], ["one", "two"]), + # list of `float`s is converted + ( + [1.0, -2.1, 3.2], + [round(Decimal(1.0), 4), round(Decimal(-2.1), 4), round(Decimal(3.2), 4)], + ), + # dictionary containing list of `float`s and `float`s is converted + ( + {"list_with_floats": [4.5, -5.6], "float": 6.7}, + { + "list_with_floats": [round(Decimal(4.5), 4), round(Decimal(-5.6), 4)], + "float": round(Decimal(6.7), 4), + }, + ), + ], +) +def test_replace_floats_with_decimals(_input: Any, expected: Any): + assert utils.replace_floats_with_decimals(_input) == expected + + +@pytest.mark.parametrize( + "_input,expected", + [ + # `int` is not converted + (-1, -1), + # `float` is converted + (Decimal(2.1), 2.1), + # `float` that's too long is rounded to default 9 decimal places + (Decimal("1579507733.11075711345834582304234"), 1579507733.110757113), + # strings are not converted + (["one", "two"], ["one", "two"]), + # list of `Decimal`s is converted + ([Decimal(1.0), Decimal(-2.1), Decimal(3.2)], [1.0, -2.1, 3.2]), + # dictionary containing list of `Decimal`s and `Decimal`s is converted + ( + {"list_with_floats": [Decimal(4.5), Decimal(-5.6)], "float": Decimal(6.7)}, + {"list_with_floats": [4.5, -5.6], "float": 6.7}, + ), + ], +) +def test_replace_decimals_with_floats(_input: Any, expected: Any): + assert utils.replace_decimals_with_floats(_input) == expected + + +@pytest.mark.parametrize( + "env_value,lock_store,expected", + [ + (1, "redis", 1), + (4, "redis", 4), + (None, "redis", 1), + (0, "redis", 1), + (-4, "redis", 1), + ("illegal value", "redis", 1), + (None, None, 1), + (None, "in_memory", 1), + (5, "in_memory", 1), + (2, None, 1), + (0, "in_memory", 1), + (3, "tests/core/test_utils.CustomRedisLockStore", 3), + (3, RedisLockStore(), 3), + (2, InMemoryLockStore(), 1), + (3, CustomRedisLockStore(), 3), + ], +) +def test_get_number_of_sanic_workers( + env_value: Optional[Text], + lock_store: Union[LockStore, Text, None], + expected: Optional[int], +): + # remember pre-test value of SANIC_WORKERS env var + pre_test_value = os.environ.get(ENV_SANIC_WORKERS) + + # set env var to desired value and make assertion + if env_value is not None: + os.environ[ENV_SANIC_WORKERS] = str(env_value) + + # lock_store may be string or LockStore object + # create EndpointConfig if it's a string, otherwise pass the object + if isinstance(lock_store, str): + lock_store = EndpointConfig(type=lock_store) + + assert utils.number_of_sanic_workers(lock_store) == expected + + # reset env var to pre-test value + os.environ.pop(ENV_SANIC_WORKERS, None) + + if pre_test_value is not None: + os.environ[ENV_SANIC_WORKERS] = pre_test_value + + +@pytest.mark.parametrize( + "lock_store,expected", + [ + (EndpointConfig(type="redis"), True), + (RedisLockStore(), True), + (EndpointConfig(type="in_memory"), False), + (EndpointConfig(type="custom_lock_store"), True), + (None, False), + (InMemoryLockStore(), False), + (CustomRedisLockStore(), True), + ], +) +def test_lock_store_is_multi_worker_compatible( + lock_store: Union[EndpointConfig, LockStore, None], expected: bool +): + # noinspection PyProtectedMember + assert ( + rasa.core.utils._lock_store_is_multi_worker_compatible(lock_store) == expected + ) + + +def test_read_endpoints_from_path(tmp_path: Path): + # write valid config to file + endpoints_path = write_endpoint_config_to_yaml( + tmp_path, {"event_broker": {"type": "pika"}, "tracker_store": {"type": "sql"}} + ) + + # noinspection PyProtectedMember + available_endpoints = utils.read_endpoints_from_path(endpoints_path) + + # assert event broker and tracker store are valid, others are not + assert available_endpoints.tracker_store and available_endpoints.event_broker + assert not all( + ( + available_endpoints.lock_store, + available_endpoints.nlg, + available_endpoints.action, + available_endpoints.model, + available_endpoints.nlu, + ) + ) + + +def test_read_endpoints_from_wrong_path(): + # noinspection PyProtectedMember + available_endpoints = utils.read_endpoints_from_path("/some/wrong/path") + + # endpoint config is still initialised but does not contain anything + assert not all( + ( + available_endpoints.lock_store, + available_endpoints.nlg, + available_endpoints.event_broker, + available_endpoints.tracker_store, + available_endpoints.action, + available_endpoints.model, + available_endpoints.nlu, + ) + ) + + +def assert_predicted_action( + prediction: PolicyPrediction, + domain: Domain, + expected_action_name: Text, + confidence: float = 1.0, + is_end_to_end_prediction: bool = False, + is_no_user_prediction: bool = False, +) -> None: + assert prediction.max_confidence == confidence + index_of_predicted_action = prediction.max_confidence_index + prediction_action_name = domain.action_names_or_texts[index_of_predicted_action] + assert prediction_action_name == expected_action_name + assert prediction.is_end_to_end_prediction == is_end_to_end_prediction + assert prediction.is_no_user_prediction == is_no_user_prediction diff --git a/tests/core/training/__init__.py b/tests/core/training/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/training/test_interactive.py b/tests/core/training/test_interactive.py new file mode 100644 index 0000000..1723228 --- /dev/null +++ b/tests/core/training/test_interactive.py @@ -0,0 +1,910 @@ +import asyncio +import json +import os +import uuid +from collections import deque +from pathlib import Path +from typing import Any, Dict, List, Text, Tuple +from tests.conftest import AsyncMock + +import pytest +from _pytest.monkeypatch import MonkeyPatch +from aioresponses import aioresponses +import unittest.mock +from unittest.mock import Mock + +import rasa.shared.utils.io +import rasa.utils.io +from rasa.core.actions import action +from rasa.core.training import interactive +from rasa.shared.constants import ( + INTENT_MESSAGE_PREFIX, + DEFAULT_SENDER_ID, + DOCS_URL_POLICIES, + LATEST_TRAINING_DATA_FORMAT_VERSION, +) +from rasa.shared.core.constants import ACTION_LISTEN_NAME, ACTION_UNLIKELY_INTENT_NAME +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import BotUttered, ActionExecuted, UserUttered +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, +) +from rasa.shared.importers.rasa import TrainingDataImporter +from rasa.shared.nlu.constants import TEXT +from rasa.shared.nlu.training_data.formats import RasaYAMLReader +from rasa.shared.nlu.training_data.loading import RASA, UNK, RASA_YAML +from rasa.shared.nlu.training_data.message import Message +from rasa.utils.endpoints import EndpointConfig +from tests import utilities + + +@pytest.fixture +def mock_endpoint() -> EndpointConfig: + return EndpointConfig("https://example.com") + + +@pytest.fixture +def mock_file_importer( + stack_config_path: Text, nlu_data_path: Text, stories_path: Text, domain_path: Text +): + domain_path = domain_path + return TrainingDataImporter.load_from_config( + stack_config_path, domain_path, [nlu_data_path, stories_path] + ) + + +@pytest.mark.parametrize( + "domain_file, expected_intents", + [ + ( + "data/test_domains/default_unfeaturized_entities.yml", + [ + "greet", + "default", + "goodbye", + "thank", + "ask", + "why", + "pure_intent", + ], + ), + ( + "data/test_domains/default.yml", + [ + "greet", + "default", + "goodbye", + ], + ), + ], +) +def test_intent_names_from_domain(domain_file, expected_intents): + test_domain = Domain.load(domain_file) + + intents = interactive.intent_names_from_domain(test_domain.as_dict()) + assert set(intents) == set(expected_intents) + + +async def test_send_message(mock_endpoint: EndpointConfig): + sender_id = uuid.uuid4().hex + + url = f"{mock_endpoint.url}/conversations/{sender_id}/messages" + with aioresponses() as mocked: + mocked.post(url, payload={}) + + await interactive.send_message(mock_endpoint, sender_id, "Hello") + + r = utilities.latest_request(mocked, "post", url) + + assert r + + expected = {"sender": "user", "text": "Hello", "parse_data": None} + + assert utilities.json_of_latest_request(r) == expected + + +async def test_request_prediction(mock_endpoint: EndpointConfig): + sender_id = uuid.uuid4().hex + + url = f"{mock_endpoint.url}/conversations/{sender_id}/predict" + + with aioresponses() as mocked: + mocked.post(url, payload={}) + + await interactive.request_prediction(mock_endpoint, sender_id) + + assert utilities.latest_request(mocked, "post", url) is not None + + +def test_bot_output_format(): + message = { + "event": "bot", + "text": "Hello!", + "data": { + "image": "http://example.com/myimage.png", + "attachment": "My Attachment", + "buttons": [ + {"title": "yes", "payload": "/yes"}, + {"title": "no", "payload": "/no", "extra": "extra"}, + ], + "elements": [ + { + "title": "element1", + "buttons": [{"title": "button1", "payload": "/button1"}], + }, + { + "title": "element2", + "buttons": [{"title": "button2", "payload": "/button2"}], + }, + ], + "quick_replies": [ + { + "title": "quick_reply1", + "buttons": [{"title": "button3", "payload": "/button3"}], + }, + { + "title": "quick_reply2", + "buttons": [{"title": "button4", "payload": "/button4"}], + }, + ], + }, + } + from rasa.shared.core.events import Event + + bot_event = Event.from_parameters(message) + + assert isinstance(bot_event, BotUttered) + + formatted = interactive.format_bot_output(bot_event) + assert formatted == ( + "Hello!\n" + "Image: http://example.com/myimage.png\n" + "Attachment: My Attachment\n" + "Buttons:\n" + "1: yes (/yes)\n" + '2: no (/no) - {"extra": "extra"}\n' + "Type out your own message...\n" + "Elements:\n" + '1: element1 - {"buttons": ' + '[{"payload": "/button1", "title": "button1"}]' + '}\n2: element2 - {"buttons": ' + '[{"payload": "/button2", "title": "button2"}]' + "}\nQuick replies:\n" + '1: quick_reply1 - {"buttons": ' + '[{"payload": "/button3", "title": "button3"}' + ']}\n2: quick_reply2 - {"buttons": ' + '[{"payload": "/button4", "title": "button4"}' + "]}" + ) + + +def test_latest_user_message(): + tracker_dump = "data/test_trackers/tracker_moodbot.json" + tracker_json = json.loads(rasa.shared.utils.io.read_file(tracker_dump)) + + m = interactive.latest_user_message(tracker_json.get("events")) + + assert m is not None + assert m["event"] == "user" + assert m["text"] == "/mood_great" + + +def test_latest_user_message_on_no_events(): + m = interactive.latest_user_message([]) + + assert m is None + + +def test_all_events_before_user_msg(): + tracker_dump = "data/test_trackers/tracker_moodbot.json" + tracker_json = json.loads(rasa.shared.utils.io.read_file(tracker_dump)) + evts = tracker_json.get("events") + + m = all_events_before_latest_user_msg(evts) + + assert m is not None + assert m == evts[:4] + + +def all_events_before_latest_user_msg( + events: List[Dict[Text, Any]] +) -> List[Dict[Text, Any]]: + """Return all events that happened before the most recent user message.""" + + for i, e in enumerate(reversed(events)): + if e.get("event") == UserUttered.type_name: + return events[: -(i + 1)] + return events + + +def test_all_events_before_user_msg_on_no_events(): + assert all_events_before_latest_user_msg([]) == [] + + +async def test_print_history(mock_endpoint): + tracker_dump = rasa.shared.utils.io.read_file( + "data/test_trackers/tracker_moodbot.json" + ) + + sender_id = uuid.uuid4().hex + + url = "{}/conversations/{}/tracker?include_events=AFTER_RESTART".format( + mock_endpoint.url, sender_id + ) + with aioresponses() as mocked: + mocked.get(url, body=tracker_dump, headers={"Accept": "application/json"}) + + await interactive._print_history(sender_id, mock_endpoint) + + assert utilities.latest_request(mocked, "get", url) is not None + + +async def test_is_listening_for_messages(mock_endpoint): + tracker_dump = rasa.shared.utils.io.read_file( + "data/test_trackers/tracker_moodbot.json" + ) + + sender_id = uuid.uuid4().hex + + url = "{}/conversations/{}/tracker?include_events=APPLIED".format( + mock_endpoint.url, sender_id + ) + with aioresponses() as mocked: + mocked.get(url, body=tracker_dump, headers={"Content-Type": "application/json"}) + + is_listening = await interactive.is_listening_for_message( + sender_id, mock_endpoint + ) + + assert is_listening + + +def test_splitting_conversation_at_restarts(): + tracker_dump = "data/test_trackers/tracker_moodbot.json" + evts = json.loads(rasa.shared.utils.io.read_file(tracker_dump)).get("events") + evts_wo_restarts = evts[:] + evts.insert(2, {"event": "restart"}) + evts.append({"event": "restart"}) + + split = interactive._split_conversation_at_restarts(evts) + assert len(split) == 2 + assert [e for s in split for e in s] == evts_wo_restarts + assert len(split[0]) == 2 + assert len(split[0]) == 2 + + +def test_as_md_message(): + parse_data = { + "text": "Hello there rasa.", + "entities": [{"start": 12, "end": 16, "entity": "name", "value": "rasa"}], + "intent": {"name": "greeting", "confidence": 0.9}, + } + md = interactive._as_md_message(parse_data) + assert md == "Hello there [rasa](name)." + + +@pytest.mark.parametrize( + "parse_original, parse_annotated, expected_entities", + [ + ( + { + "text": "Hello there rasa, it's me, paula.", + "entities": [ + { + "start": 12, + "end": 16, + "entity": "name1", + "value": "rasa", + "extractor": "batman", + } + ], + "intent": {"name": "greeting", "confidence": 0.9}, + }, + { + "text": "Hello there rasa, it's me, paula.", + "entities": [ + {"start": 12, "end": 16, "entity": "name1", "value": "rasa"}, + {"start": 26, "end": 31, "entity": "name2", "value": "paula"}, + ], + "intent": {"name": "greeting", "confidence": 0.9}, + }, + [ + { + "start": 12, + "end": 16, + "entity": "name1", + "value": "rasa", + "extractor": "batman", + }, + {"start": 26, "end": 31, "entity": "name2", "value": "paula"}, + ], + ), + ( + { + "text": "I am flying from Berlin to London.", + "entities": [ + { + "start": 17, + "end": 23, + "entity": "location", + "role": "from", + "value": "Berlin", + "extractor": "DIETClassifier", + } + ], + "intent": {"name": "inform", "confidence": 0.9}, + }, + { + "text": "I am flying from Berlin to London.", + "entities": [ + { + "start": 17, + "end": 23, + "entity": "location", + "value": "Berlin", + "role": "from", + }, + { + "start": 27, + "end": 33, + "entity": "location", + "value": "London", + "role": "to", + }, + ], + "intent": {"name": "inform", "confidence": 0.9}, + }, + [ + { + "start": 17, + "end": 23, + "entity": "location", + "value": "Berlin", + "role": "from", + }, + { + "start": 27, + "end": 33, + "entity": "location", + "value": "London", + "role": "to", + }, + ], + ), + ( + { + "text": "A large pepperoni and a small mushroom.", + "entities": [ + { + "start": 2, + "end": 7, + "entity": "size", + "group": "1", + "value": "large", + "extractor": "DIETClassifier", + }, + { + "start": 24, + "end": 29, + "entity": "size", + "value": "small", + "extractor": "DIETClassifier", + }, + ], + "intent": {"name": "inform", "confidence": 0.9}, + }, + { + "text": "A large pepperoni and a small mushroom.", + "entities": [ + { + "start": 2, + "end": 7, + "entity": "size", + "group": "1", + "value": "large", + }, + { + "start": 8, + "end": 17, + "entity": "toppings", + "group": "1", + "value": "pepperoni", + }, + { + "start": 30, + "end": 38, + "entity": "toppings", + "group": "1", + "value": "mushroom", + }, + { + "start": 24, + "end": 29, + "entity": "size", + "group": "2", + "value": "small", + }, + ], + "intent": {"name": "inform", "confidence": 0.9}, + }, + [ + { + "start": 2, + "end": 7, + "entity": "size", + "group": "1", + "value": "large", + }, + { + "start": 8, + "end": 17, + "entity": "toppings", + "group": "1", + "value": "pepperoni", + }, + { + "start": 30, + "end": 38, + "entity": "toppings", + "group": "1", + "value": "mushroom", + }, + { + "start": 24, + "end": 29, + "entity": "size", + "group": "2", + "value": "small", + }, + ], + ), + ], +) +def test__merge_annotated_and_original_entities( + parse_original: Dict[Text, Any], + parse_annotated: Dict[Text, Any], + expected_entities: List[Dict[Text, Any]], +): + entities = interactive._merge_annotated_and_original_entities( + parse_annotated, parse_original + ) + assert entities == expected_entities + + +def test_validate_user_message(): + parse_data = { + "text": "Hello there rasa.", + "parse_data": { + "entities": [{"start": 12, "end": 16, "entity": "name", "value": "rasa"}], + "intent": {"name": "greeting", "confidence": 0.9}, + }, + } + assert interactive._validate_user_regex(parse_data, ["greeting", "goodbye"]) + assert not interactive._validate_user_regex(parse_data, ["goodbye"]) + + +async def test_undo_latest_msg(mock_endpoint): + tracker_dump = rasa.shared.utils.io.read_file( + "data/test_trackers/tracker_moodbot.json" + ) + + sender_id = uuid.uuid4().hex + + url = "{}/conversations/{}/tracker?include_events=ALL".format( + mock_endpoint.url, sender_id + ) + append_url = "{}/conversations/{}/tracker/events".format( + mock_endpoint.url, sender_id + ) + with aioresponses() as mocked: + mocked.get(url, body=tracker_dump) + mocked.post(append_url) + + await interactive._undo_latest(sender_id, mock_endpoint) + + r = utilities.latest_request(mocked, "post", append_url) + + assert r + + # this should be the events the interactive call send to the endpoint + # these events should have the last utterance omitted + corrected_event = utilities.json_of_latest_request(r) + assert corrected_event["event"] == "undo" + + +async def test_write_stories_to_file(mock_endpoint: EndpointConfig, tmp_path): + tracker_dump = rasa.shared.utils.io.read_file( + "data/test_trackers/tracker_moodbot_with_new_utterances.json" + ) + + sender_id = uuid.uuid4().hex + + url = f"{mock_endpoint.url}/conversations/{sender_id}/tracker?include_events=ALL" + append_url = f"{mock_endpoint.url}/conversations/{sender_id}/tracker/events" + domain_url = f"{mock_endpoint.url}/domain" + + target_files = [ + { + "name": str(tmp_path / "stories.yml"), + "validator": YAMLStoryReader.is_stories_file, + }, + { + "name": str(tmp_path / "nlu.yml"), + "validator": RasaYAMLReader.is_yaml_nlu_file, + }, + {"name": str(tmp_path / "domain.yml"), "validator": lambda path: True}, + ] + + async def info() -> Tuple[Text, Text, Text]: + return target_files[0]["name"], target_files[1]["name"], target_files[2]["name"] + + with aioresponses() as mocked: + mocked.get(url, body=tracker_dump) + mocked.post(append_url) + mocked.get(domain_url, payload={}) + + interactive._request_export_info = info + await interactive._write_data_to_file(sender_id, mock_endpoint) + + for target_file in target_files: + assert os.path.exists(target_file["name"]) + assert target_file["validator"](target_file["name"]) + + +def test_utter_custom_message(): + test_event = """ + { + "data": { + "attachment": null, + "buttons": null, + "elements": [ + { + "a": "b" + } + ] + }, + "event": "bot", + "text": null, + "timestamp": 1542649219.331037 + } + """ + actual = interactive._chat_history_table([json.loads(test_event)]) + + assert json.dumps({"a": "b"}) in actual + + +async def test_interactive_domain_persistence( + mock_endpoint: EndpointConfig, tmp_path: Path +): + # Test method interactive._write_domain_to_file + + tracker_dump = "data/test_trackers/tracker_moodbot.json" + tracker_json = rasa.shared.utils.io.read_json_file(tracker_dump) + + events = tracker_json.get("events", []) + + domain_path = str(tmp_path / "interactive_domain_save.yml") + + url = f"{mock_endpoint.url}/domain" + with aioresponses() as mocked: + mocked.get(url, payload={}) + + serialised_domain = await interactive.retrieve_domain(mock_endpoint) + old_domain = Domain.from_dict(serialised_domain) + + interactive._write_domain_to_file(domain_path, events, old_domain) + + saved_domain = rasa.shared.utils.io.read_config_file(domain_path) + + for default_action in action.default_actions(): + assert default_action.name() not in saved_domain["actions"] + + +async def test_write_domain_to_file_with_form(tmp_path: Path): + domain_path = str(tmp_path / "domain.yml") + form_name = "my_form" + old_domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + actions: + - utter_greet + - utter_goodbye + forms: + {form_name}: + required_slots: [] + intents: + - greet + """ + ) + + events = [ActionExecuted(form_name), ActionExecuted(ACTION_LISTEN_NAME)] + events = [e.as_dict() for e in events] + + interactive._write_domain_to_file(domain_path, events, old_domain) + + assert set(Domain.from_path(domain_path).action_names_or_texts) == set( + old_domain.action_names_or_texts + ) + + +async def test_filter_intents_before_save_nlu_file(domain_path: Text): + # Test method interactive._filter_messages + from random import choice + + greet = {"text": "How are you?", "intent": "greet", "text_features": [0.5]} + goodbye = {"text": "I am inevitable", "intent": "goodbye", "text_features": [0.5]} + test_msgs = [Message(data=greet), Message(data=goodbye)] + + domain_file = domain_path + domain = Domain.load(domain_file) + intents = domain.intents + + msgs = test_msgs.copy() + if intents: + another_greet = greet.copy() + another_greet[TEXT] = INTENT_MESSAGE_PREFIX + choice(intents) + msgs.append(Message(data=another_greet)) + + assert test_msgs == interactive._filter_messages(msgs) + + +@pytest.mark.parametrize( + "path, expected_format", + [("bla.json", RASA), ("other.yml", RASA_YAML), ("unknown", UNK)], +) +def test_get_nlu_target_format(path: Text, expected_format: Text): + assert interactive._get_nlu_target_format(path) == expected_format + + +@pytest.mark.parametrize( + "trackers, expected_trackers", + [ + ([DialogueStateTracker.from_events("one", [])], [deque([]), DEFAULT_SENDER_ID]), + ( + [ + str(i) + for i in range( + interactive.MAX_NUMBER_OF_TRAINING_STORIES_FOR_VISUALIZATION + 1 + ) + ], + [DEFAULT_SENDER_ID], + ), + ], +) +async def test_initial_plotting_call( + mock_endpoint: EndpointConfig, + monkeypatch: MonkeyPatch, + trackers: List[Text], + expected_trackers: List[Text], + mock_file_importer: TrainingDataImporter, +): + get_training_trackers = Mock(return_value=trackers) + monkeypatch.setattr(interactive, "_get_training_trackers", get_training_trackers) + + monkeypatch.setattr(interactive.utils, "is_limit_reached", lambda _, __: True) + + plot_trackers = Mock() + monkeypatch.setattr(interactive, "_plot_trackers", asyncio.coroutine(plot_trackers)) + + url = f"{mock_endpoint.url}/domain" + with aioresponses() as mocked: + mocked.get(url, payload={}) + + await interactive.record_messages(mock_endpoint, mock_file_importer) + + get_training_trackers.assert_called_once() + plot_trackers.assert_called_once_with( + expected_trackers, interactive.DEFAULT_STORY_GRAPH_FILE, mock_endpoint + ) + + +async def test_not_getting_trackers_when_skipping_visualization( + mock_endpoint: EndpointConfig, monkeypatch: MonkeyPatch +): + get_trackers = Mock() + monkeypatch.setattr(interactive, "_get_tracker_events_to_plot", get_trackers) + + monkeypatch.setattr(interactive.utils, "is_limit_reached", lambda _, __: True) + + url = f"{mock_endpoint.url}/domain" + with aioresponses() as mocked: + mocked.get(url, payload={}) + + await interactive.record_messages( + mock_endpoint, mock_file_importer, skip_visualization=True + ) + + get_trackers.assert_not_called() + + +class QuestionaryConfirmMock: + def __init__(self, tries: int) -> None: + self.tries = tries + + def __call__(self, text: Text) -> "QuestionaryConfirmMock": + return self + + async def ask_async(self) -> bool: + self.tries -= 1 + if self.tries == 0: + return False + else: + return True + + +async def test_retry_on_error_success(monkeypatch: MonkeyPatch): + monkeypatch.setattr(interactive.questionary, "confirm", QuestionaryConfirmMock(3)) + + m = Mock(return_value=None) + await interactive._retry_on_error(m, "export_path", 1, a=2) + m.assert_called_once_with("export_path", 1, a=2) + + +@pytest.mark.parametrize( + "action_name, question, is_marked_as_correct, sent_action_name", + [ + ( + ACTION_UNLIKELY_INTENT_NAME, + f"The bot wants to run 'action_unlikely_intent' " + f"to indicate that the last user message was unexpected " + f"at this point in the conversation. " + f"Check out UnexpecTEDIntentPolicy " + f"({DOCS_URL_POLICIES}#unexpected-intent-policy) " + f"to learn more. Is that correct?", + True, + ACTION_UNLIKELY_INTENT_NAME, + ), + ( + ACTION_UNLIKELY_INTENT_NAME, + f"The bot wants to run 'action_unlikely_intent' " + f"to indicate that the last user message was unexpected " + f"at this point in the conversation. " + f"Check out UnexpecTEDIntentPolicy " + f"({DOCS_URL_POLICIES}#unexpected-intent-policy) " + f"to learn more. Is that correct?", + False, + ACTION_UNLIKELY_INTENT_NAME, + ), + ( + "action_test", + "The bot wants to run 'action_test', correct?", + True, + "action_test", + ), + ( + "action_test", + "The bot wants to run 'action_test', correct?", + False, + "action_another_one", + ), + ], +) +async def test_correct_question_for_action_name_was_asked( + monkeypatch: MonkeyPatch, + mock_endpoint: EndpointConfig, + action_name: Text, + question: Text, + is_marked_as_correct: bool, + sent_action_name: Text, +): + conversation_id = "conversation_id" + policy = "policy" + tracker = DialogueStateTracker.from_events("some_sender", []) + + monkeypatch.setattr( + interactive, "retrieve_tracker", AsyncMock(return_value=tracker.current_state()) + ) + monkeypatch.setattr( + interactive, "_ask_questions", AsyncMock(return_value=is_marked_as_correct) + ) + monkeypatch.setattr( + interactive, + "_request_action_from_user", + AsyncMock(return_value=("action_another_one", False)), + ) + + mocked_send_action = AsyncMock() + monkeypatch.setattr(interactive, "send_action", mocked_send_action) + + mocked_confirm = Mock(return_value=None) + monkeypatch.setattr(interactive.questionary, "confirm", mocked_confirm) + + # validate the action and make sure that the correct question was asked + await interactive._validate_action( + action_name, policy, 1.0, [], mock_endpoint, conversation_id + ) + mocked_confirm.assert_called_once_with(question) + args, kwargs = mocked_send_action.call_args_list[-1] + assert args[2] == sent_action_name + + +async def test_retry_on_error_three_retries(monkeypatch: MonkeyPatch): + monkeypatch.setattr(interactive.questionary, "confirm", QuestionaryConfirmMock(3)) + + m = Mock(side_effect=PermissionError()) + with pytest.raises(PermissionError): + await interactive._retry_on_error(m, "export_path", 1, a=2) + c = unittest.mock.call("export_path", 1, a=2) + m.assert_has_calls([c, c, c]) + + +@pytest.mark.parametrize( + "text,wrapping_width,true_wrapping_width", + [ + ("abcdefgh", 8, 8), + ("abcdefgh", 4, 4), + ("abcdefgh", 50, 50), + ("Well, hello there my friend!", 20, 20), + ("我要去北京", 8, 4), + ( + "牙龈出血的症状对应得疾病可能有:肥大性龈炎、骨髓增生异常综合征、" + "口腔疾病、小儿出血性疾病、边缘性龈炎、获得性维生素K依赖性凝血因子异常、" + "小儿白血病、回归热、坏死性龈口炎、青春期功能失调性子宫出血、郎-奥韦综合征、" + "急性淋巴细胞白血病、感染性血小板减少性紫癜、单纯性牙周炎、单核细胞白血病、" + "δ-贮存池病、小儿特发性血小板减少性紫癜、老年人真性红细胞增多症、" + "急性根尖牙周炎、慢性根尖牙周炎、创伤性口炎、青少年牙周炎、慢性牙周炎", + 120, + 60, + ), + ], +) +def test_calc_true_wrapping_width( + text: Text, wrapping_width: int, true_wrapping_width: int +) -> None: + assert ( + interactive.calc_true_wrapping_width(text, wrapping_width) + == true_wrapping_width + ) + + +def test_no_chat_history_overflow() -> None: + """Should run without crashing. + + originally the long chinese utterance lead to a table width overflow and + available width for new utterances being < 0.""" + events = [ + { + "event": "action", + "timestamp": 1619956299.2875981, + "name": "action_session_start", + "policy": None, + "confidence": 1.0, + "action_text": None, + "hide_rule_turn": False, + }, + {"event": "session_started", "timestamp": 1619956299.287627}, + { + "event": "bot", + "timestamp": 1619956324.2313201, + "metadata": {"utter_action": "utter_long_chinese"}, + "text": "牙龈出血的症状对应得疾病可能有:肥大性龈炎、骨髓增生异常综合征、" + "口腔疾病、小儿出血性疾病、边缘性龈炎、获得性维生素K依赖性凝血因子异常、" + "小儿白血病、回归热、坏死性龈口炎、青春期功能失调性子宫出血、" + "郎-奥韦综合征、急性淋巴细胞白血病、感染性血小板减少性紫癜、" + "单纯性牙周炎、单核细胞白血病、δ-贮存池病、小儿特发性血小板减少性紫癜、" + "老年人真性红细胞增多症、急性根尖牙周炎、慢性根尖牙周炎、创伤性口炎、" + "青少年牙周炎、慢性牙周炎", + }, + { + "event": "user", + "timestamp": 1619956299.509249, + "text": "hi", + "parse_data": { + "intent": { + "id": -2517711783688260279, + "name": "greet", + "confidence": 1.0, + }, + "entities": [], + "text": "hi", + "message_id": "4a95248b3c6b480c9550f9c19062e2bc", + "metadata": {}, + }, + "input_channel": None, + "message_id": "4a95248b3c6b480c9550f9c19062e2bc", + "metadata": {}, + }, + ] + + rasa.core.training.interactive._chat_history_table(events) diff --git a/tests/core/training/test_story_conflict.py b/tests/core/training/test_story_conflict.py new file mode 100644 index 0000000..251efcc --- /dev/null +++ b/tests/core/training/test_story_conflict.py @@ -0,0 +1,199 @@ +from typing import Text, List, Tuple + +from rasa.shared.core.domain import Domain +from rasa.core.training.story_conflict import ( + StoryConflict, + find_story_conflicts, + _get_previous_event, +) +from rasa.shared.core.generator import TrainingDataGenerator, TrackerWithCachedStates +from rasa.validator import Validator +from rasa.shared.importers.rasa import RasaFileImporter +from rasa.shared.core.constants import ( + ACTION_LISTEN_NAME, + PREVIOUS_ACTION, + USER, + ACTION_UNLIKELY_INTENT_NAME, +) + + +def _setup_trackers_for_testing( + domain_path: Text, training_data_file: Text +) -> Tuple[List[TrackerWithCachedStates], Domain]: + importer = RasaFileImporter( + domain_path=domain_path, training_data_paths=[training_data_file] + ) + validator = Validator.from_importer(importer) + + trackers = TrainingDataGenerator( + validator.story_graph, + domain=validator.domain, + remove_duplicates=False, + augmentation_factor=0, + ).generate() + + return trackers, validator.domain + + +async def test_find_no_conflicts(domain_path: Text, stories_path: Text): + trackers, domain = _setup_trackers_for_testing(domain_path, stories_path) + + # Create a list of `StoryConflict` objects + conflicts = find_story_conflicts(trackers, domain, 5) + + assert conflicts == [] + + +async def test_find_conflicts_in_short_history(): + trackers, domain = _setup_trackers_for_testing( + "data/test_domains/default.yml", + "data/test_yaml_stories/stories_conflicting_1.yml", + ) + + # `max_history = 3` is too small, so a conflict must arise + conflicts = find_story_conflicts(trackers, domain, 3) + assert len(conflicts) == 1 + + # With `max_history = 4` the conflict should disappear + conflicts = find_story_conflicts(trackers, domain, 4) + assert len(conflicts) == 0 + + +async def test_check_conflict_description(): + trackers, domain = _setup_trackers_for_testing( + "data/test_domains/default.yml", + "data/test_yaml_stories/stories_conflicting_1.yml", + ) + + # `max_history = 3` is too small, so a conflict must arise + conflicts = find_story_conflicts(trackers, domain, 3) + assert len(conflicts) == 1 + + assert str(conflicts[0]).startswith("Story structure conflict after intent 'greet'") + + +async def test_find_conflicts_checkpoints(): + trackers, domain = _setup_trackers_for_testing( + "data/test_domains/default.yml", + "data/test_yaml_stories/stories_conflicting_2.yml", + ) + + # Create a list of `StoryConflict` objects + conflicts = find_story_conflicts(trackers, domain, 5) + + assert len(conflicts) == 1 + assert conflicts[0].conflicting_actions == ["utter_goodbye", "utter_default"] + + +async def test_find_conflicts_or(): + trackers, domain = _setup_trackers_for_testing( + "data/test_domains/default.yml", + "data/test_yaml_stories/stories_conflicting_3.yml", + ) + + # Create a list of `StoryConflict` objects + conflicts = find_story_conflicts(trackers, domain, 5) + + assert len(conflicts) == 1 + assert conflicts[0].conflicting_actions == ["utter_default", "utter_goodbye"] + + +async def test_find_conflicts_slots_that_break(): + trackers, domain = _setup_trackers_for_testing( + "data/test_domains/default.yml", + "data/test_yaml_stories/stories_conflicting_4.yml", + ) + + # Create a list of `StoryConflict` objects + conflicts = find_story_conflicts(trackers, domain, 5) + + assert len(conflicts) == 1 + assert conflicts[0].conflicting_actions == ["utter_default", "utter_greet"] + + +async def test_find_conflicts_slots_that_dont_break(): + trackers, domain = _setup_trackers_for_testing( + "data/test_domains/default.yml", + "data/test_yaml_stories/stories_conflicting_5.yml", + ) + + # Create a list of `StoryConflict` objects + conflicts = find_story_conflicts(trackers, domain, 5) + + assert len(conflicts) == 0 + + +async def test_find_conflicts_multiple_stories(): + trackers, domain = _setup_trackers_for_testing( + "data/test_domains/default.yml", + "data/test_yaml_stories/stories_conflicting_6.yml", + ) + + # Create a list of `StoryConflict` objects + conflicts = find_story_conflicts(trackers, domain, 5) + + assert len(conflicts) == 1 + assert "and 2 other trackers" in str(conflicts[0]) + + +async def test_find_unlearnable_actions(): + trackers, domain = _setup_trackers_for_testing( + "data/test_domains/default.yml", + "data/test_yaml_stories/stories_unexpected_intent_unlearnable.yml", + ) + + # Create a list of `StoryConflict` objects + conflicts = find_story_conflicts(trackers, domain) + + assert len(conflicts) == 2 + assert ACTION_UNLIKELY_INTENT_NAME in str(conflicts[0]) + assert ACTION_UNLIKELY_INTENT_NAME in str(conflicts[1]) + + +async def test_add_conflicting_action(): + sliced_states = [ + None, + {}, + {"intent_greet": 1.0, "prev_action_listen": 1.0}, + {"prev_utter_greet": 1.0, "intent_greet": 1.0}, + ] + conflict = StoryConflict(sliced_states) + + conflict.add_conflicting_action("utter_greet", "xyz") + conflict.add_conflicting_action("utter_default", "uvw") + assert conflict.conflicting_actions == ["utter_greet", "utter_default"] + + +async def test_has_prior_events(): + sliced_states = [ + None, + {}, + { + PREVIOUS_ACTION: {"action_name": ACTION_LISTEN_NAME}, + USER: {"intent": "greet"}, + }, + {PREVIOUS_ACTION: {"action_name": "utter_greet"}, USER: {"intent": "greet"}}, + ] + conflict = StoryConflict(sliced_states) + assert conflict.conflict_has_prior_events + + +async def test_get_previous_event(): + assert _get_previous_event( + {PREVIOUS_ACTION: {"action_name": "utter_greet"}, USER: {"intent": "greet"}} + ) == ("action", "utter_greet") + assert _get_previous_event( + {PREVIOUS_ACTION: {"action_text": "this is a test"}, USER: {"intent": "greet"}} + ) == ("bot utterance", "this is a test") + assert _get_previous_event( + { + PREVIOUS_ACTION: {"action_name": ACTION_LISTEN_NAME}, + USER: {"intent": "greet"}, + } + ) == ("intent", "greet") + + +async def test_has_no_prior_events(): + sliced_states = [None] + conflict = StoryConflict(sliced_states) + assert not conflict.conflict_has_prior_events diff --git a/tests/core/utilities.py b/tests/core/utilities.py new file mode 100644 index 0000000..e11e0d1 --- /dev/null +++ b/tests/core/utilities.py @@ -0,0 +1,70 @@ +import itertools +import contextlib +import os +import typing +from typing import List, Optional, Text, Any, Dict + +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import UserUttered, Event +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.nlu.constants import INTENT_NAME_KEY + +if typing.TYPE_CHECKING: + from rasa.shared.core.conversation import Dialogue + + +def tracker_from_dialogue(dialogue: "Dialogue", domain: Domain) -> DialogueStateTracker: + tracker = DialogueStateTracker(dialogue.name, domain.slots) + tracker.recreate_from_dialogue(dialogue) + return tracker + + +@contextlib.contextmanager +def cwd(path: Text): + CWD = os.getcwd() + + os.chdir(path) + try: + yield + finally: + os.chdir(CWD) + + +@contextlib.contextmanager +def mocked_cmd_input(package, text: Text): + if isinstance(text, str): + text = [text] + + text_generator = itertools.cycle(text) + i = package._get_user_input + + async def mocked_input(*args, **kwargs): + value = next(text_generator) + print(f"wrote '{value}' to input") + return value + + package._get_user_input = mocked_input + try: + yield + finally: + package._get_user_input = i + + +def user_uttered( + text: Text, + confidence: float = 1.0, + metadata: Dict[Text, Any] = None, + timestamp: Optional[float] = None, +) -> UserUttered: + parse_data = {"intent": {INTENT_NAME_KEY: text, "confidence": confidence}} + return UserUttered( + text="Random", + intent=parse_data["intent"], + parse_data=parse_data, + metadata=metadata, + timestamp=timestamp, + ) + + +def get_tracker(events: List[Event]) -> DialogueStateTracker: + return DialogueStateTracker.from_events("sender", events, [], 20) diff --git a/tests/dialogues.py b/tests/dialogues.py new file mode 100644 index 0000000..412326c --- /dev/null +++ b/tests/dialogues.py @@ -0,0 +1,277 @@ +from rasa.shared.core.conversation import Dialogue +from rasa.shared.core.events import ( + SlotSet, + UserUttered, + ActionExecuted, + ActiveLoop, + BotUttered, +) + + +TEST_DEFAULT_DIALOGUE = Dialogue( + name="default", + events=[ + ActionExecuted(action_name="action_listen", timestamp=1551952977.4850519), + UserUttered( + entities=[{"end": 19, "entity": "name", "start": 14, "value": "Peter"}], + intent={"confidence": 0.0, "name": "greet"}, + message_id=None, + parse_data={ + "entities": [ + {"end": 19, "entity": "name", "start": 14, "value": "Peter"} + ], + "intent": {"confidence": 0.0, "name": "greet"}, + "message_id": None, + "metadata": {}, + "text": "Hi my name is Peter", + }, + text="Hi my name is Peter", + timestamp=1551953035.076376, + ), + SlotSet(key="name", timestamp=1551953035.076385, value="Peter"), + ActionExecuted(action_name="utter_greet", timestamp=1551953040.607782), + BotUttered( + data={"attachment": None, "buttons": None, "elements": None}, + text="hey there Peter!", + timestamp=1551953040.60779, + ), + ], +) +TEST_FORMBOT_DIALOGUE = Dialogue( + name="formbot", + events=[ + ActionExecuted(action_name="action_listen", timestamp=1551884035.892855), + UserUttered( + intent={"confidence": 0.3748943507671356, "name": "greet"}, + parse_data={ + "entities": [], + "intent": {"confidence": 0.3748943507671356, "name": "greet"}, + "text": "Hi I'm desperate to talk to you", + }, + text="Hi I'm desperate to talk to you", + timestamp=1551884050.259948, + ), + ActionExecuted( + action_name="utter_greet", + confidence=1.0, + policy="policy_2_MemoizationPolicy", + timestamp=1551884060.466681, + ), + BotUttered( + data={"attachment": None, "buttons": None, "elements": None}, + text="Hello! I am restaurant search assistant! How can I help?", + timestamp=1551884060.46669, + ), + ActionExecuted( + action_name="action_listen", + confidence=1.0, + policy="policy_2_MemoizationPolicy", + timestamp=1551884061.9350882, + ), + UserUttered( + entities=[ + {"end": 18, "entity": "cuisine", "start": 16, "value": "an"}, + {"end": 48, "entity": "location", "start": 42, "value": "Bombay"}, + ], + intent={"confidence": 0.9414282441139221, "name": "request_restaurant"}, + parse_data={ + "entities": [ + {"end": 18, "entity": "cuisine", "start": 16, "value": "an"}, + {"end": 48, "entity": "location", "start": 42, "value": "Bombay"}, + ], + "intent": { + "confidence": 0.9414282441139221, + "name": "request_restaurant", + }, + "text": "I'm looking for an indian restaurant...in Bombay", + }, + text="I'm looking for an indian restaurant...in Bombay", + timestamp=1551884090.9653602, + ), + ActionExecuted( + action_name="restaurant_form", + confidence=1.0, + policy="policy_2_MemoizationPolicy", + timestamp=1551884095.542748, + ), + ActionExecuted( + action_name="utter_slots_values", + confidence=1.0, + policy="policy_2_MemoizationPolicy", + timestamp=1551884097.570883, + ), + BotUttered( + data={"attachment": None, "buttons": None, "elements": None}, + text=( + "I am going to run a restaurant search " + "using the following parameters:\n" + " - cuisine: None\n - num_people: None\n" + " - outdoor_seating: None\n" + " - preferences: None\n - feedback: None" + ), + timestamp=1551884097.57089, + ), + ActionExecuted( + action_name="action_listen", + confidence=1.0, + policy="policy_2_MemoizationPolicy", + timestamp=1551884098.8006358, + ), + UserUttered( + intent={"confidence": 0.2287036031484604, "name": "affirm"}, + parse_data={ + "entities": [], + "intent": {"confidence": 0.2287036031484604, "name": "affirm"}, + "text": "Let's just pretend everything went correctly", + }, + text="Let's just pretend everything went correctly", + timestamp=1551884208.092693, + ), + ActionExecuted( + action_name="action_deactivate_loop", timestamp=1551884214.951055 + ), + ActiveLoop(name=None, timestamp=1551884214.9510589), + SlotSet(key="requested_slot", timestamp=1551884214.951062, value=None), + ActionExecuted( + action_name="action_listen", + confidence=0.7680902069097734, + policy="policy_0_TEDPolicy", + timestamp=1551884216.705635, + ), + ], +) +TEST_MOODBOT_DIALOGUE = Dialogue( + name="moodbot", + events=[ + ActionExecuted(action_name="action_listen", timestamp=1551883958.346432), + UserUttered( + intent={"confidence": 0.44488201660555066, "name": "greet"}, + parse_data={ + "entities": [], + "intent": {"confidence": 0.44488201660555066, "name": "greet"}, + "intent_ranking": [ + {"confidence": 0.44488201660555066, "name": "greet"}, + {"confidence": 0.29023286595689257, "name": "goodbye"}, + {"confidence": 0.10501227521380094, "name": "mood_great"}, + {"confidence": 0.06879303900502878, "name": "mood_unhappy"}, + {"confidence": 0.04903582960375451, "name": "deny"}, + {"confidence": 0.04204397361497238, "name": "affirm"}, + ], + "text": "Hi talk to me", + }, + text="Hi talk to me", + timestamp=1551883971.410778, + ), + ActionExecuted( + action_name="utter_greet", + confidence=1.0, + policy="policy_2_MemoizationPolicy", + timestamp=1551883975.6456478, + ), + BotUttered( + data={ + "attachment": None, + "buttons": [ + {"payload": "great", "title": "great"}, + {"payload": "super sad", "title": "super sad"}, + ], + "elements": None, + }, + text="Hey! How are you?", + timestamp=1551883975.645656, + ), + ActionExecuted( + action_name="action_listen", + confidence=1.0, + policy="policy_2_MemoizationPolicy", + timestamp=1551883979.098331, + ), + UserUttered( + intent={"confidence": 0.7417646502470048, "name": "mood_unhappy"}, + parse_data={ + "entities": [], + "intent": {"confidence": 0.7417646502470048, "name": "mood_unhappy"}, + "intent_ranking": [ + {"confidence": 0.7417646502470048, "name": "mood_unhappy"}, + {"confidence": 0.1439688162980615, "name": "mood_great"}, + {"confidence": 0.04577343822867981, "name": "goodbye"}, + {"confidence": 0.037760394267609965, "name": "greet"}, + {"confidence": 0.017715563733253295, "name": "affirm"}, + {"confidence": 0.013017137225390567, "name": "deny"}, + ], + "text": "Super sad", + }, + text="Super sad", + timestamp=1551883982.540276, + ), + ActionExecuted( + action_name="utter_cheer_up", + confidence=1.0, + policy="policy_2_MemoizationPolicy", + timestamp=1551883985.031668, + ), + BotUttered( + data={ + "attachment": "https://i.imgur.com/nGF1K8f.jpg", + "buttons": None, + "elements": None, + }, + text="Here is something to cheer you up:", + timestamp=1551883985.0316749, + ), + ActionExecuted( + action_name="utter_did_that_help", + confidence=1.0, + policy="policy_2_MemoizationPolicy", + timestamp=1551883985.940413, + ), + BotUttered( + data={"attachment": None, "buttons": None, "elements": None}, + text="Did that help you?", + timestamp=1551883985.940421, + ), + ActionExecuted( + action_name="action_listen", + confidence=1.0, + policy="policy_2_MemoizationPolicy", + timestamp=1551883986.958556, + ), + UserUttered( + intent={"confidence": 0.8162296627642036, "name": "deny"}, + parse_data={ + "entities": [], + "intent": {"confidence": 0.8162296627642036, "name": "deny"}, + "intent_ranking": [ + {"confidence": 0.8162296627642036, "name": "deny"}, + {"confidence": 0.07152463661481759, "name": "mood_unhappy"}, + {"confidence": 0.05028159510181415, "name": "greet"}, + {"confidence": 0.02662414324721426, "name": "affirm"}, + {"confidence": 0.024343883584915963, "name": "goodbye"}, + {"confidence": 0.010996078687034375, "name": "mood_great"}, + ], + "text": "No", + }, + text="No", + timestamp=1551883989.0720608, + ), + ActionExecuted( + action_name="utter_goodbye", + confidence=1.0, + policy="policy_2_MemoizationPolicy", + timestamp=1551883991.061463, + ), + BotUttered( + data={"attachment": None, "buttons": None, "elements": None}, + text="Bye", + timestamp=1551883991.061471, + ), + ], +) + +TEST_DIALOGUES = [TEST_DEFAULT_DIALOGUE, TEST_FORMBOT_DIALOGUE, TEST_MOODBOT_DIALOGUE] + +TEST_DOMAINS_FOR_DIALOGUES = [ + "data/test_domains/default_with_slots.yml", + "examples/formbot/domain.yml", + "data/test_moodbot/domain.yml", +] diff --git a/tests/docs/__init__.py b/tests/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/docs/test_docs_embedded_modules.py b/tests/docs/test_docs_embedded_modules.py new file mode 100644 index 0000000..a7fb187 --- /dev/null +++ b/tests/docs/test_docs_embedded_modules.py @@ -0,0 +1,18 @@ +import inspect + +from rasa.engine.graph import GraphComponent +from data.test_classes.graph_component_interface import ( + GraphComponent as GraphComponentDocs, +) + + +def test_graph_copy_does_not_diverge(): + """Tests that the module embedded in the docs doesn't diverge from the actual one. + + It is currently not possible to embed a class from within a module in the docs + without embedding the rest of the module file. To avoid this we have copied the + `GraphComponent` to a separate file which is embeded in the docs. + """ + # If this fails then copy the latest implementation of + # `rasa.engine.graph.GraphComponent` to `data.test_classes.graph_component_interface + assert inspect.getsource(GraphComponent) == inspect.getsource(GraphComponentDocs) diff --git a/tests/docs/test_docs_header_hierarchy.py b/tests/docs/test_docs_header_hierarchy.py new file mode 100644 index 0000000..6e04eda --- /dev/null +++ b/tests/docs/test_docs_header_hierarchy.py @@ -0,0 +1,72 @@ +from pathlib import Path +from typing import List, Text, Tuple + +import pytest + + +DOCS_BASE_DIR = Path("docs/") +MDX_DOCS_FILES = list((DOCS_BASE_DIR / "docs").glob("**/*.mdx")) +CODE_BLOCK_OPEN = ["```", "
"] + + +def get_heading_level(heading: Text) -> int: + return len(heading.split(" ", 1)[0]) + + +def get_heading(heading: Text) -> Text: + return heading.split(" ", 1)[1] + + +def line_opens_code_block(line: Text) -> bool: + for open_tag in CODE_BLOCK_OPEN: + if line.startswith(open_tag): + return True + return False + + +def line_closes_code_block(line: Text) -> bool: + for close_tag in CODE_BLOCK_CLOSE: + if line.endswith(close_tag): + return True + return False + + +@pytest.mark.parametrize("mdx_file_path", MDX_DOCS_FILES) +def test_docs_header_hierarchy(mdx_file_path: Path): + with mdx_file_path.open("r") as handle: + mdx_content = handle.read() + + headings: List[Tuple[int, Text, Text]] = [] + inside_code_block = False + for idx, line in enumerate(mdx_content.split("\n")): + line = line.strip() + if not inside_code_block and line_opens_code_block(line): + inside_code_block = True + elif inside_code_block and line_closes_code_block(line): + inside_code_block = False + + if not inside_code_block and line.startswith("#"): + headings.append((idx + 1, get_heading(line), get_heading_level(line))) + + errors: List[Text] = [] + prev_level = 1 + for line_number, title, level in headings: + if level == 1: + errors.append( + f"\n - Title '# {title}' at line {line_number} " + f"cannot be level 1. Use '## {title}' instead." + ) + + if level > prev_level + 1: + errors.append( + f"\n - Title '{'#' * level} {title}' at line {line_number} " + f"is skipping a level. Use '{'#' * (prev_level + 1)} {title}' instead." + ) + # that way we continue to have errors + prev_level = prev_level + 1 + else: + prev_level = level + + if errors: + raise AssertionError(f"({mdx_file_path}):{''.join(errors)}") diff --git a/tests/docs/test_docs_training_data.py b/tests/docs/test_docs_training_data.py new file mode 100644 index 0000000..933c79b --- /dev/null +++ b/tests/docs/test_docs_training_data.py @@ -0,0 +1,67 @@ +from pathlib import Path +from typing import List, Text, Tuple +import re + +import pytest + +import rasa.shared.utils.validation +from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + CORE_SCHEMA_FILE, +) +from rasa.shared.nlu.training_data.formats.rasa_yaml import NLU_SCHEMA_FILE +from rasa.shared.constants import DOMAIN_SCHEMA_FILE + + +DOCS_BASE_DIR = Path("docs/") +MDX_DOCS_FILES = list((DOCS_BASE_DIR / "docs").glob("**/*.mdx")) + +# Exclude the migration guide from this check as the migration guide might +# contain training data which once was valid but is not valid anymore. +# E.g. the migration guide for Rasa Open Source 2 defined a form schema which +# is no longer valid since Rasa Open Source 3. +MDX_DOCS_FILES.remove(Path("docs/docs/migration-guide.mdx")) + +# we're matching codeblocks with either `yaml-rasa` or `yml-rasa` types +# we support title or no title (you'll get a nice error message if there is a title) +TRAINING_DATA_CODEBLOCK_RE = re.compile( + r"```y(?:a)?ml-rasa(?: title=[\"'][^\"']+[\"'])?(?: \((?P.+?)\))?[^\n]*\n(?P.*?)```", # noqa: E501 + re.DOTALL, +) + + +@pytest.mark.parametrize("mdx_file_path", MDX_DOCS_FILES) +def test_docs_training_data(mdx_file_path: Path): + with mdx_file_path.open("r") as handle: + mdx_content = handle.read() + + matches = TRAINING_DATA_CODEBLOCK_RE.finditer(mdx_content) + lines_with_errors: List[Tuple[Text, Text]] = [] + + for match in matches: + yaml_path = match.group("yaml_path") + if yaml_path: + with (DOCS_BASE_DIR / yaml_path).open("r") as handle: + codeblock = handle.read() + else: + codeblock = match.group("codeblock") + + start_index = match.span()[0] + line_number = mdx_content.count("\n", 0, start_index) + 1 + + # the responses schema is automatically checked in validate_yaml_schema, + # don't need to add it here + schemas_to_try = [NLU_SCHEMA_FILE, CORE_SCHEMA_FILE, DOMAIN_SCHEMA_FILE] + for schema in schemas_to_try: + try: + rasa.shared.utils.validation.validate_yaml_schema(codeblock, schema) + except ValueError as error: + lines_with_errors.append((str(line_number), str(error))) + + if lines_with_errors: + error_details = "\n\n" + "\n".join( + f" - At line {line}: {error} " for line, error in lines_with_errors + ) + raise AssertionError( + f"({mdx_file_path}): Invalid training data found " + f"at line{'s' if len(lines_with_errors) > 1 else ''}: {error_details}" + ) diff --git a/tests/docs/test_docs_use_base_url.py b/tests/docs/test_docs_use_base_url.py new file mode 100644 index 0000000..7093a6a --- /dev/null +++ b/tests/docs/test_docs_use_base_url.py @@ -0,0 +1,36 @@ +from pathlib import Path +from typing import List, Text +import re + +import pytest + + +DOCS_BASE_DIR = Path("docs/") +MDX_DOCS_FILES = list((DOCS_BASE_DIR / "docs").glob("**/*.mdx")) +# we're matching anchors with href containing strings, but not starting +# with "http". This also exclude local href already configured using `useBaseUrl()` +ANCHOR_RE = re.compile( + r"<(a|Button)[^>]*href=\"(?P(?!http).+?)\"[^>]*>", re.DOTALL +) + + +@pytest.mark.parametrize("mdx_file_path", MDX_DOCS_FILES) +def test_docs_anchors_base_url(mdx_file_path: Path): + with mdx_file_path.open("r") as handle: + mdx_content = handle.read() + + matches = ANCHOR_RE.finditer(mdx_content) + lines_with_errors: List[Text] = [] + + for match in matches: + href = match.group("href") + start_index = match.span()[0] + line_number = mdx_content.count("\n", 0, start_index) + 1 + lines_with_errors.append(f"{line_number} with href={href}") + + if lines_with_errors: + plural = "s" if len(lines_with_errors) > 1 else "" + raise AssertionError( + f"({mdx_file_path}): Invalid anchor{plural} not using useBaseUrl() " + f"at line{plural} {', '.join(lines_with_errors)}" + ) diff --git a/tests/engine/__init__.py b/tests/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/engine/conftest.py b/tests/engine/conftest.py new file mode 100644 index 0000000..98d3a3c --- /dev/null +++ b/tests/engine/conftest.py @@ -0,0 +1,21 @@ +from pathlib import Path +from typing import Callable + +from _pytest.monkeypatch import MonkeyPatch +import pytest + +from rasa.engine.caching import LocalTrainingCache + + +@pytest.fixture() +def temp_cache(tmp_path: Path, local_cache_creator: Callable) -> LocalTrainingCache: + return local_cache_creator(tmp_path) + + +@pytest.fixture() +def local_cache_creator(monkeypatch: MonkeyPatch) -> Callable[..., LocalTrainingCache]: + def create_local_cache(path: Path) -> LocalTrainingCache: + monkeypatch.setattr(LocalTrainingCache, "_get_cache_location", lambda: path) + return LocalTrainingCache() + + return create_local_cache diff --git a/tests/engine/graph_components_test_classes.py b/tests/engine/graph_components_test_classes.py new file mode 100644 index 0000000..fa21684 --- /dev/null +++ b/tests/engine/graph_components_test_classes.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Dict, Optional, Text, Any, List + +import rasa.shared.utils.io +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.storage.resource import Resource + + +class AddInputs(GraphComponent): + @classmethod + def create( + cls, + config: Dict, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> AddInputs: + return cls() + + def add(self, i1: Any, i2: Any) -> int: + return int(i1) + int(i2) + + +class SubtractByX(GraphComponent): + @staticmethod + def get_default_config() -> Dict[Text, Any]: + return {"x": 0} + + def __init__(self, x: int) -> None: + self._x = x + + @classmethod + def create( + cls, + config: Dict, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> SubtractByX: + return cls(config["x"]) + + def subtract_x(self, i: Any) -> int: + return int(i) - self._x + + +class AssertComponent(GraphComponent): + def __init__(self, value_to_assert: Any) -> None: + self._value_to_assert = value_to_assert + + @classmethod + def create( + cls, + config: Dict, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> AssertComponent: + return cls(config["value_to_assert"]) + + def run_assert(self, i: Any) -> CacheableText: + assert i == self._value_to_assert + return CacheableText("") + + +class ProvideX(GraphComponent): + def __init__(self) -> None: + self.x = 1 + + @classmethod + def create( + cls, + config: Dict, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + x: Optional[int] = None, + **kwargs: Any, + ) -> ProvideX: + instance = cls() + if x: + instance.x = x + return instance + + @classmethod + def create_with_2( + cls, + config: Dict, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> ProvideX: + return cls.create( + config, model_storage, resource, execution_context, 2, **kwargs + ) + + def provide(self) -> int: + return self.x + + +class FileReader(GraphComponent): + def __init__(self, file_path: Path) -> None: + self._file_path = file_path + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> FileReader: + return cls(Path(config["file_path"])) + + def read(self) -> CacheableText: + return CacheableText(self._file_path.read_text()) + + +class ExecutionContextAware(GraphComponent): + def __init__(self, execution_context: ExecutionContext) -> None: + self._execution_context = execution_context + + @classmethod + def create( + cls, + config: Dict, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> ExecutionContextAware: + return cls(execution_context) + + def get_execution_context(self) -> ExecutionContext: + return self._execution_context + + +class PersistableTestComponent(GraphComponent): + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + eager_instantiated_value: Any = None, + ) -> None: + self._model_storage = model_storage + self._resource = resource + self._config = config + self._wrap_cacheable = self._config.get("wrap_output_in_cacheable", False) + self._eager_instantiated_value = eager_instantiated_value + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> PersistableTestComponent: + assert model_storage + assert resource + + return cls(config, model_storage, resource) + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> PersistableTestComponent: + assert model_storage + assert resource + + with model_storage.read_from(resource) as directory: + eager_instantiated_value = rasa.shared.utils.io.read_json_file( + directory / "test.json" + ) + return cls(config, model_storage, resource, eager_instantiated_value) + + def supported_languages(self) -> List[Text]: + return [] + + @staticmethod + def required_packages() -> List[Text]: + return [] + + def train(self) -> Resource: + with self._model_storage.write_to(self._resource) as directory: + rasa.shared.utils.io.dump_obj_as_json_to_file( + directory / "test.json", self._config["test_value"] + ) + sub_dir = directory / "sub_dir" + sub_dir.mkdir() + + rasa.shared.utils.io.dump_obj_as_json_to_file( + sub_dir / "test.json", self._config.get("test_value_for_sub_directory") + ) + + return self._resource + + def run_train_process(self) -> Any: + if self._wrap_cacheable: + return CacheableText(self._eager_instantiated_value) + return self._eager_instantiated_value + + def run_inference(self) -> Any: + if self._wrap_cacheable: + return CacheableText(self._eager_instantiated_value) + return self._eager_instantiated_value + + +class CacheableText: + def __init__(self, text: Text) -> None: + self.text = text + + def to_cache(self, directory: Path, model_storage: ModelStorage) -> None: + rasa.shared.utils.io.write_text_file(self.text, directory / "my_file.txt") + + @classmethod + def from_cache( + cls, + node_name: Text, + directory: Path, + model_storage: ModelStorage, + output_fingerprint: Text, + ) -> CacheableText: + text = rasa.shared.utils.io.read_file(directory / "my_file.txt") + return cls(text=text) + + def __repr__(self) -> Text: + return self.text + + def __int__(self) -> int: + return int(self.text) + + +class CacheableComponent(GraphComponent): + @staticmethod + def get_default_config() -> Dict[Text, Any]: + return {"prefix": "Hello "} + + def __init__(self, prefix: Text): + self.prefix = prefix + + @classmethod + def create( + cls, + config: Dict, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> CacheableComponent: + return cls(config["prefix"]) + + def run(self, suffix: Text): + return CacheableText(self.prefix + str(suffix)) diff --git a/tests/engine/recipes/__init__.py b/tests/engine/recipes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/engine/recipes/test_default_recipe.py b/tests/engine/recipes/test_default_recipe.py new file mode 100644 index 0000000..6a07adf --- /dev/null +++ b/tests/engine/recipes/test_default_recipe.py @@ -0,0 +1,763 @@ +from typing import Text, Dict, Any, Set, List +import shutil + +import pytest +from _pytest.capture import CaptureFixture +from pathlib import Path +from rasa.engine.constants import PLACEHOLDER_TRACKER +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.nlu.training_data.message import Message + +import rasa.shared.utils.io +from rasa.shared.constants import ASSISTANT_ID_KEY, CONFIG_AUTOCONFIGURABLE_KEYS +from rasa.core.policies.ted_policy import TEDPolicy +from rasa.engine.graph import GraphSchema, GraphComponent, ExecutionContext +from rasa.engine.recipes.default_recipe import ( + DefaultV1Recipe, + DefaultV1RecipeRegisterException, +) +from rasa.engine.recipes.recipe import Recipe +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.graph_components.validators.default_recipe_validator import ( + DefaultV1RecipeValidator, +) +from rasa.nlu.classifiers.mitie_intent_classifier import MitieIntentClassifier +from rasa.nlu.classifiers.sklearn_intent_classifier import SklearnIntentClassifier +from rasa.nlu.extractors.mitie_entity_extractor import MitieEntityExtractor +from rasa.shared.exceptions import InvalidConfigException +from rasa.shared.data import TrainingType +import rasa.engine.validation +from rasa.shared.importers.rasa import RasaFileImporter + + +CONFIG_FOLDER = Path("data/test_config") + +SOME_CONFIG = CONFIG_FOLDER / "stack_config.yml" +DEFAULT_CONFIG = Path("rasa/engine/recipes/config_files/default_config.yml") + + +def test_recipe_for_name(): + recipe = Recipe.recipe_for_name("default.v1") + assert isinstance(recipe, DefaultV1Recipe) + + +@pytest.mark.parametrize( + "config_path, expected_train_schema_path, expected_predict_schema_path, " + "training_type, is_finetuning", + [ + # The default config is the config which most users run + ( + "rasa/engine/recipes/config_files/default_config.yml", + "data/graph_schemas/default_config_e2e_train_schema.yml", + "data/graph_schemas/default_config_e2e_predict_schema.yml", + TrainingType.END_TO_END, + False, + ), + # The default config without end to end + ( + "rasa/engine/recipes/config_files/default_config.yml", + "data/graph_schemas/default_config_train_schema.yml", + "data/graph_schemas/default_config_predict_schema.yml", + TrainingType.BOTH, + False, + ), + ( + "rasa/engine/recipes/config_files/default_config.yml", + "data/graph_schemas/default_config_core_train_schema.yml", + "data/graph_schemas/default_config_core_predict_schema.yml", + TrainingType.CORE, + False, + ), + ( + "rasa/engine/recipes/config_files/default_config.yml", + "data/graph_schemas/default_config_nlu_train_schema.yml", + "data/graph_schemas/default_config_nlu_predict_schema.yml", + TrainingType.NLU, + False, + ), + # A config which uses Spacy and Duckling does not have Core model config + ( + "data/test_config/config_pretrained_embeddings_spacy_duckling.yml", + "data/graph_schemas/" + "config_pretrained_embeddings_spacy_duckling_train_schema.yml", + "data/graph_schemas/" + "config_pretrained_embeddings_spacy_duckling_predict_schema.yml", + TrainingType.BOTH, + False, + ), + # A minimal NLU config without Core model + ( + "data/test_config/keyword_classifier_config.yml", + "data/graph_schemas/keyword_classifier_config_train_schema.yml", + "data/graph_schemas/keyword_classifier_config_predict_schema.yml", + TrainingType.BOTH, + False, + ), + # A config which uses Mitie and does not have Core model + ( + "data/test_config/config_pretrained_embeddings_mitie.yml", + "data/graph_schemas/config_pretrained_embeddings_mitie_train_schema.yml", + "data/graph_schemas/" + "config_pretrained_embeddings_mitie_predict_schema.yml", + TrainingType.BOTH, + False, + ), + # A config which uses Mitie and Jiebe and does not have Core model + ( + "data/test_config/config_pretrained_embeddings_mitie_zh.yml", + "data/graph_schemas/config_pretrained_embeddings_mitie_zh_train_schema.yml", + "data/graph_schemas/" + "config_pretrained_embeddings_mitie_zh_predict_schema.yml", + TrainingType.BOTH, + False, + ), + # A core only model because of no pipeline + ( + "data/test_config/max_hist_config.yml", + "data/graph_schemas/max_hist_config_train_schema.yml", + "data/graph_schemas/max_hist_config_predict_schema.yml", + TrainingType.BOTH, + False, + ), + # A full model which wants to be finetuned + ( + "rasa/engine/recipes/config_files/default_config.yml", + "data/graph_schemas/default_config_finetune_schema.yml", + "data/graph_schemas/default_config_predict_schema.yml", + TrainingType.BOTH, + True, + ), + ], +) +def test_generate_graphs( + config_path: Text, + expected_train_schema_path: Text, + expected_predict_schema_path: Text, + training_type: TrainingType, + is_finetuning: bool, +): + expected_schema_as_dict = rasa.shared.utils.io.read_yaml_file( + expected_train_schema_path + ) + expected_train_schema = GraphSchema.from_dict(expected_schema_as_dict) + + expected_schema_as_dict = rasa.shared.utils.io.read_yaml_file( + expected_predict_schema_path + ) + expected_predict_schema = GraphSchema.from_dict(expected_schema_as_dict) + + config = rasa.shared.utils.io.read_yaml_file(config_path) + + recipe = Recipe.recipe_for_name(DefaultV1Recipe.name) + model_config = recipe.graph_config_for_recipe( + config, {}, training_type=training_type, is_finetuning=is_finetuning + ) + + train_schema = model_config.train_schema + for node_name, node in expected_train_schema.nodes.items(): + assert train_schema.nodes[node_name] == node + + assert train_schema == expected_train_schema + + default_v1_validator = DefaultV1RecipeValidator(train_schema) + importer = RasaFileImporter() + # does not raise + default_v1_validator.validate(importer) + + predict_schema = model_config.predict_schema + for node_name, node in expected_predict_schema.nodes.items(): + assert predict_schema.nodes[node_name] == node + + assert predict_schema == expected_predict_schema + + rasa.engine.validation.validate(model_config) + + +@pytest.mark.parametrize( + "cli_parameters, check_node, expected_config", + [ + ( + {}, + "train_MitieIntentClassifier6", + {"num_threads": 200000, "finetuning_epoch_fraction": 0.75}, + ), + ( + {"num_threads": None}, + "train_MitieIntentClassifier6", + {"num_threads": 200000, "finetuning_epoch_fraction": 0.75}, + ), + ( + {"num_threads": 1}, + "train_MitieIntentClassifier6", + {"num_threads": 1, "finetuning_epoch_fraction": 0.75}, + ), + ( + {"num_threads": 1, "finetuning_epoch_fraction": 0.5}, + "train_MitieIntentClassifier6", + # there is no `epochs` value specified so it doesn't get overridden + {"num_threads": 1, "finetuning_epoch_fraction": 0.75}, + ), + ( + {"finetuning_epoch_fraction": 0.5}, + "train_DIETClassifier7", + {"epochs": 150, "num_threads": 200000, "finetuning_epoch_fraction": 0.5}, + ), + ], +) +def test_nlu_config_doesnt_get_overridden( + cli_parameters: Dict[Text, Any], check_node: Text, expected_config: Dict[Text, Any] +): + config = rasa.shared.utils.io.read_yaml_file( + "data/test_config/config_pretrained_embeddings_mitie_diet.yml" + ) + recipe = Recipe.recipe_for_name(DefaultV1Recipe.name) + model_config = recipe.graph_config_for_recipe( + config, cli_parameters, training_type=TrainingType.BOTH, is_finetuning=True + ) + + train_schema = model_config.train_schema + mitie_node = train_schema.nodes.get(check_node) + assert mitie_node.config == expected_config + + +def test_language_returning(): + config = rasa.shared.utils.io.read_yaml( + """ + language: "xy" + version: '2.0' + + policies: + - name: RulePolicy + """ + ) + + recipe = Recipe.recipe_for_name(DefaultV1Recipe.name) + model_config = recipe.graph_config_for_recipe(config, {}) + + assert model_config.language == "xy" + + +def test_tracker_generator_parameter_interpolation(): + config = rasa.shared.utils.io.read_yaml( + """ + version: '2.0' + + policies: + - name: RulePolicy + """ + ) + + augmentation = 0 + debug_plots = True + + recipe = Recipe.recipe_for_name(DefaultV1Recipe.name) + model_config = recipe.graph_config_for_recipe( + config, {"augmentation_factor": augmentation, "debug_plots": debug_plots} + ) + + node = model_config.train_schema.nodes["training_tracker_provider"] + + assert node.config == { + "augmentation_factor": augmentation, + "debug_plots": debug_plots, + } + + +def test_nlu_training_data_persistence(): + config = rasa.shared.utils.io.read_yaml( + """ + version: '2.0' + + pipeline: + - name: KeywordIntentClassifier + """ + ) + + recipe = Recipe.recipe_for_name(DefaultV1Recipe.name) + model_config = recipe.graph_config_for_recipe( + config, {"persist_nlu_training_data": True} + ) + + node = model_config.train_schema.nodes["nlu_training_data_provider"] + + assert node.config == {"language": None, "persist": True} + assert node.is_target + + +def test_num_threads_interpolation(): + expected_schema_as_dict = rasa.shared.utils.io.read_yaml_file( + "data/graph_schemas/config_pretrained_embeddings_mitie_train_schema.yml" + ) + expected_train_schema = GraphSchema.from_dict(expected_schema_as_dict) + + expected_schema_as_dict = rasa.shared.utils.io.read_yaml_file( + "data/graph_schemas/config_pretrained_embeddings_mitie_predict_schema.yml" + ) + expected_predict_schema = GraphSchema.from_dict(expected_schema_as_dict) + + for node_name, node in expected_train_schema.nodes.items(): + if issubclass( + node.uses, + (SklearnIntentClassifier, MitieEntityExtractor, MitieIntentClassifier), + ) and node_name.startswith("train_"): + node.config["num_threads"] = 20 + + config = rasa.shared.utils.io.read_yaml_file( + "data/test_config/config_pretrained_embeddings_mitie.yml" + ) + + recipe = Recipe.recipe_for_name(DefaultV1Recipe.name) + model_config = recipe.graph_config_for_recipe(config, {"num_threads": 20}) + + train_schema = model_config.train_schema + for node_name, node in expected_train_schema.nodes.items(): + assert train_schema.nodes[node_name] == node + + assert train_schema == expected_train_schema + + predict_schema = model_config.predict_schema + for node_name, node in expected_predict_schema.nodes.items(): + assert predict_schema.nodes[node_name] == node + + assert predict_schema == expected_predict_schema + + +def test_epoch_fraction_cli_param(): + expected_schema_as_dict = rasa.shared.utils.io.read_yaml_file( + "data/graph_schemas/default_config_finetune_epoch_fraction_schema.yml" + ) + expected_train_schema = GraphSchema.from_dict(expected_schema_as_dict) + + config = rasa.shared.utils.io.read_yaml_file( + "rasa/engine/recipes/config_files/default_config.yml" + ) + + recipe = Recipe.recipe_for_name(DefaultV1Recipe.name) + model_config = recipe.graph_config_for_recipe( + config, {"finetuning_epoch_fraction": 0.5}, is_finetuning=True + ) + + train_schema = model_config.train_schema + for node_name, node in expected_train_schema.nodes.items(): + assert train_schema.nodes[node_name] == node + + assert train_schema == expected_train_schema + + +def test_epoch_fraction_cli_param_unspecified(): + # TODO: enhance testing of cli instead of imitating expected parsed input + expected_schema_as_dict = rasa.shared.utils.io.read_yaml_file( + "data/graph_schemas/default_config_finetune_epoch_fraction_schema.yml" + ) + expected_train_schema = GraphSchema.from_dict(expected_schema_as_dict) + + # modify the expected schema + for schema_node in expected_train_schema.nodes.values(): + if "finetuning_epoch_fraction" in schema_node.config: + schema_node.config["finetuning_epoch_fraction"] = 1.0 + if "epochs" in schema_node.config: + schema_node.config["epochs"] *= 2 + + config = rasa.shared.utils.io.read_yaml_file( + "rasa/engine/recipes/config_files/default_config.yml" + ) + + recipe = Recipe.recipe_for_name(DefaultV1Recipe.name) + model_config = recipe.graph_config_for_recipe( + config, {"finetuning_epoch_fraction": None}, is_finetuning=True + ) + + train_schema = model_config.train_schema + for node_name, node in expected_train_schema.nodes.items(): + assert train_schema.nodes[node_name] == node + + assert train_schema == expected_train_schema + + +def test_register_component(): + @DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_TOKENIZER, + is_trainable=True, + model_from="Herman", + ) + class MyClassGraphComponent(GraphComponent): + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + return cls() + + assert DefaultV1Recipe._from_registry( + MyClassGraphComponent.__name__ + ) == DefaultV1Recipe.RegisteredComponent( + MyClassGraphComponent, + {DefaultV1Recipe.ComponentType.MESSAGE_TOKENIZER}, + True, + "Herman", + ) + assert MyClassGraphComponent() + + +def test_register_component_using_tracker(): + @DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER, + is_trainable=True, + model_from="Herman", + ) + class MyClassGraphComponent(GraphComponent): + def process( + self, messages: List[Message], tracker: DialogueStateTracker + ) -> List[Message]: + ... + + config = rasa.shared.utils.io.read_yaml( + """ + language: "xy" + version: '2.0' + pipeline: + - name: MyClassGraphComponent + """ + ) + + recipe = Recipe.recipe_for_name(DefaultV1Recipe.name) + model_config = recipe.graph_config_for_recipe(config, {}) + + node_in_graph = model_config.predict_schema.nodes.get("run_MyClassGraphComponent0") + assert node_in_graph is not None + # check that the node was configured to require the tracker as an input + assert node_in_graph.needs.get("tracker") == PLACEHOLDER_TRACKER + + +def test_register_component_with_multiple_types(): + @DefaultV1Recipe.register( + [ + DefaultV1Recipe.ComponentType.MESSAGE_TOKENIZER, + DefaultV1Recipe.ComponentType.MODEL_LOADER, + ], + is_trainable=True, + model_from="Herman", + ) + class MyClassGraphComponent(GraphComponent): + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + return cls() + + assert DefaultV1Recipe._from_registry( + MyClassGraphComponent.__name__ + ) == DefaultV1Recipe.RegisteredComponent( + MyClassGraphComponent, + { + DefaultV1Recipe.ComponentType.MESSAGE_TOKENIZER, + DefaultV1Recipe.ComponentType.MODEL_LOADER, + }, + True, + "Herman", + ) + assert MyClassGraphComponent() + + +def test_register_invalid_component(): + with pytest.raises(DefaultV1RecipeRegisterException): + + @DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_TOKENIZER, False, "Bla" + ) + class MyClass: + pass + + +def test_retrieve_not_registered_class(): + class NotRegisteredClass: + pass + + with pytest.raises(InvalidConfigException): + # noinspection PyTypeChecker + DefaultV1Recipe._from_registry(NotRegisteredClass.__name__) + + +def test_retrieve_via_module_path(): + model_config = DefaultV1Recipe().graph_config_for_recipe( + {"policies": [{"name": "rasa.core.policies.ted_policy.TEDPolicy"}]}, + {}, + TrainingType.CORE, + ) + + assert any( + issubclass(node.uses, TEDPolicy) + for node in model_config.train_schema.nodes.values() + ) + assert any( + issubclass(node.uses, TEDPolicy) + for node in model_config.predict_schema.nodes.values() + ) + + +def test_retrieve_via_invalid_module_path(): + with pytest.raises(ImportError): + path = "rasa.core.policies.ted_policy.TEDPolicy1000" + DefaultV1Recipe().graph_config_for_recipe( + {"policies": [{"name": path}]}, {}, TrainingType.CORE + ) + + +def test_train_nlu_without_nlu_pipeline(): + with pytest.raises(InvalidConfigException): + DefaultV1Recipe().graph_config_for_recipe( + {"pipeline": []}, {}, TrainingType.NLU + ) + + +def test_train_core_without_nlu_pipeline(): + with pytest.raises(InvalidConfigException): + DefaultV1Recipe().graph_config_for_recipe( + {"policies": []}, {}, TrainingType.CORE + ) + + +@pytest.mark.parametrize( + "config_path, expected_keys_to_configure", + [ + (Path("rasa/cli/initial_project/config.yml"), {"pipeline", "policies"}), + (CONFIG_FOLDER / "config_policies_empty.yml", {"policies"}), + (CONFIG_FOLDER / "config_pipeline_empty.yml", {"pipeline"}), + (CONFIG_FOLDER / "config_policies_missing.yml", {"policies"}), + (CONFIG_FOLDER / "config_pipeline_missing.yml", {"pipeline"}), + (SOME_CONFIG, set()), + ], +) +def test_get_configuration( + config_path: Path, expected_keys_to_configure: Set[Text], tmp_path: Path +): + new_config_file = tmp_path / "new_config.yml" + shutil.copyfile(config_path, new_config_file) + + config = rasa.shared.utils.io.read_model_configuration(new_config_file) + _config, _missing_keys, configured_keys = DefaultV1Recipe.auto_configure( + new_config_file, config + ) + + assert sorted(configured_keys) == sorted(expected_keys_to_configure) + + +@pytest.mark.parametrize( + "language, keys_to_configure", + [ + ("en", {"policies"}), + ("en", {"pipeline"}), + ("fr", {"pipeline"}), + ("en", {"policies", "pipeline"}), + ], +) +def test_auto_configure(language: Text, keys_to_configure: Set[Text]): + expected_config = rasa.shared.utils.io.read_config_file(DEFAULT_CONFIG) + + config = DefaultV1Recipe.complete_config({"language": language}, keys_to_configure) + + for k in keys_to_configure: + assert config[k] == expected_config[k] # given keys are configured correctly + + assert config.get("language") == language + config.pop("language") + assert len(config) == len(keys_to_configure) # no other keys are configured + + +@pytest.mark.parametrize( + "config_path, missing_keys", + [ + (CONFIG_FOLDER / "config_language_only.yml", {"pipeline", "policies"}), + (CONFIG_FOLDER / "config_policies_missing.yml", {"policies"}), + (CONFIG_FOLDER / "config_pipeline_missing.yml", {"pipeline"}), + (SOME_CONFIG, []), + ], +) +def test_add_missing_config_keys_to_file( + tmp_path: Path, config_path: Path, missing_keys: Set[Text] +): + config_file = str(tmp_path / "config.yml") + shutil.copyfile(str(config_path), config_file) + + DefaultV1Recipe._add_missing_config_keys_to_file(config_file, missing_keys) + + config_after_addition = rasa.shared.utils.io.read_config_file(config_file) + + assert all(key in config_after_addition for key in missing_keys) + + +def test_dump_config_missing_file(tmp_path: Path, capsys: CaptureFixture): + + config_path = tmp_path / "non_existent_config.yml" + + config = rasa.shared.utils.io.read_config_file(str(SOME_CONFIG)) + + DefaultV1Recipe._dump_config(config, str(config_path), set(), {"policies"}) + + assert not config_path.exists() + + captured = capsys.readouterr() + assert "has been removed or modified" in captured.out + + +# Test a few cases that are known to be potentially tricky (have failed in the past) +@pytest.mark.parametrize( + "input_file, expected_file, autoconfig_keys", + [ + ( + "config_with_comments.yml", + "config_with_comments_after_dumping.yml", + {"policies"}, + ), # comments in various positions + ( + "config_empty_en.yml", + "config_empty_en_after_dumping.yml", + {"policies", "pipeline"}, + ), # no empty lines + ( + "config_empty_fr.yml", + "config_empty_fr_after_dumping.yml", + {"policies", "pipeline"}, + ), # no empty lines, with different language + ( + "config_with_comments_after_dumping.yml", + "config_with_comments_after_dumping.yml", + {"policies"}, + ), # with previous auto config that needs to be overwritten + ], +) +def test_dump_config( + tmp_path: Path, + input_file: Text, + expected_file: Text, + capsys: CaptureFixture, + autoconfig_keys: Set[Text], +): + config_file = str(tmp_path / "config.yml") + shutil.copyfile(str(CONFIG_FOLDER / input_file), config_file) + old_config = rasa.shared.utils.io.read_model_configuration(config_file) + DefaultV1Recipe.auto_configure(config_file, old_config) + new_config = rasa.shared.utils.io.read_model_configuration(config_file) + + expected = rasa.shared.utils.io.read_model_configuration( + CONFIG_FOLDER / expected_file + ) + + assert new_config == expected + + captured = capsys.readouterr() + assert "does not exist or is empty" not in captured.out + + for k in CONFIG_AUTOCONFIGURABLE_KEYS: + if k in autoconfig_keys: + assert k in captured.out + else: + assert k not in captured.out + + +@pytest.mark.parametrize( + "input_file, expected_file, training_type", + [ + ( + "config_empty_en.yml", + "config_empty_en_after_dumping.yml", + TrainingType.BOTH, + ), + ( + "config_empty_en.yml", + "config_empty_en_after_dumping_core.yml", + TrainingType.CORE, + ), + ( + "config_empty_en.yml", + "config_empty_en_after_dumping_nlu.yml", + TrainingType.NLU, + ), + ], +) +def test_get_configuration_for_different_training_types( + tmp_path: Path, + input_file: Text, + expected_file: Text, + training_type: TrainingType, +): + config_file = str(tmp_path / "config.yml") + shutil.copyfile(str(CONFIG_FOLDER / input_file), config_file) + config = rasa.shared.utils.io.read_model_configuration(config_file) + + DefaultV1Recipe.auto_configure(config_file, config, training_type) + + actual = rasa.shared.utils.io.read_file(config_file) + + expected = rasa.shared.utils.io.read_file(str(CONFIG_FOLDER / expected_file)) + + assert actual == expected + + +def test_comment_causing_invalid_autoconfig(tmp_path: Path): + """Regression test for https://github.com/RasaHQ/rasa/issues/6948.""" + config_file = tmp_path / "config.yml" + shutil.copyfile( + str(CONFIG_FOLDER / "config_with_comment_between_suggestions.yml"), config_file + ) + config = rasa.shared.utils.io.read_model_configuration(config_file) + + _ = DefaultV1Recipe.auto_configure(str(config_file), config) + + # This should not throw + dumped = rasa.shared.utils.io.read_yaml_file(config_file) + + assert dumped + + +def test_needs_from_args(): + @DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.MESSAGE_TOKENIZER, + is_trainable=True, + model_from="Herman", + ) + class MyClassGraphComponent(GraphComponent): + @classmethod + def run( + cls, + bar: Any, + resource: Resource, + foo: Any, + training_trackers: Any, + training_data: Any, + tracker: Any, + ) -> int: + return 42 + + assert DefaultV1Recipe()._get_needs_from_args(MyClassGraphComponent, "run") == { + "bar": "bar_provider", + "foo": "foo_provider", + "resource": "resource_provider", + "training_trackers": "training_tracker_provider", + "training_data": "nlu_training_data_provider", + "tracker": PLACEHOLDER_TRACKER, + } + + +@pytest.mark.parametrize( + "config_file", + [ + "data/test_config/config_unique_assistant_id.yml", + "data/test_config/config_defaults.yml", + ], +) +def test_graph_config_for_recipe_with_assistant_id(config_file): + config = rasa.shared.utils.io.read_model_configuration(config_file) + + recipe = Recipe.recipe_for_name(DefaultV1Recipe.name) + model_config = recipe.graph_config_for_recipe(config, {}) + + assert model_config.assistant_id == config.get(ASSISTANT_ID_KEY) diff --git a/tests/engine/recipes/test_graph_recipe.py b/tests/engine/recipes/test_graph_recipe.py new file mode 100644 index 0000000..b492cd2 --- /dev/null +++ b/tests/engine/recipes/test_graph_recipe.py @@ -0,0 +1,176 @@ +from typing import Text + +import pytest +from pathlib import Path + +import rasa.shared.utils.io +from rasa.engine.exceptions import GraphSchemaException +from rasa.engine.graph import GraphSchema +from rasa.engine.recipes.graph_recipe import GraphV1Recipe +from rasa.engine.recipes.recipe import Recipe +from rasa.shared.constants import ASSISTANT_ID_KEY +from rasa.shared.data import TrainingType +import rasa.engine.validation + + +CONFIG_FOLDER = Path("data/test_config") +# The graph config is equivalent to the default config in graph schema format. +GRAPH_CONFIG = CONFIG_FOLDER / "graph_config.yml" +# Short config has a single node for each of train and predict; should be fast to test. +SHORT_CONFIG = CONFIG_FOLDER / "graph_config_short.yml" + + +def test_recipe_for_name(): + recipe = Recipe.recipe_for_name("graph.v1") + assert isinstance(recipe, GraphV1Recipe) + + +@pytest.mark.parametrize( + "config_path, expected_train_schema_path, expected_predict_schema_path, " + "training_type", + [ + ( + GRAPH_CONFIG, + "data/graph_schemas/default_config_train_schema.yml", + "data/graph_schemas/default_config_predict_schema.yml", + TrainingType.END_TO_END, + ), + ( + SHORT_CONFIG, + "data/graph_schemas/graph_config_short_train_schema.yml", + "data/graph_schemas/graph_config_short_predict_schema.yml", + TrainingType.BOTH, + ), + ( + SHORT_CONFIG, + "data/graph_schemas/graph_config_short_train_schema.yml", + "data/graph_schemas/graph_config_short_predict_schema.yml", + TrainingType.NLU, + ), + ( + SHORT_CONFIG, + "data/graph_schemas/graph_config_short_train_schema.yml", + "data/graph_schemas/graph_config_short_predict_schema.yml", + TrainingType.CORE, + ), + ], +) +def test_generate_graphs( + config_path: Text, + expected_train_schema_path: Text, + expected_predict_schema_path: Text, + training_type: TrainingType, +): + expected_schema_as_dict = rasa.shared.utils.io.read_yaml_file( + expected_train_schema_path + ) + expected_train_schema = GraphSchema.from_dict(expected_schema_as_dict) + + expected_schema_as_dict = rasa.shared.utils.io.read_yaml_file( + expected_predict_schema_path + ) + expected_predict_schema = GraphSchema.from_dict(expected_schema_as_dict) + + config = rasa.shared.utils.io.read_yaml_file(config_path) + + recipe = Recipe.recipe_for_name(GraphV1Recipe.name) + model_config = recipe.graph_config_for_recipe( + config, {}, training_type=training_type + ) + + assert model_config.train_schema == expected_train_schema + assert model_config.predict_schema == expected_predict_schema + + if training_type == TrainingType.NLU: + core_target = None + else: + core_target = config.get("core_target", "select_prediction") + + assert model_config.core_target == core_target + assert model_config.nlu_target == config.get( + "nlu_target", "run_RegexMessageHandler" + ) + + rasa.engine.validation.validate(model_config) + + +def test_language_returning(): + config = rasa.shared.utils.io.read_yaml( + """ + language: "xy" + recipe: graph.v1 + core_target: doesnt_validate_or_run + nlu_target: doesnt_validate_or_run + + train_schema: + nodes: {} + predict_schema: + nodes: {} + """ + ) + + recipe = Recipe.recipe_for_name(GraphV1Recipe.name) + model_config = recipe.graph_config_for_recipe(config, {}) + + assert model_config.language == "xy" + + +def test_retrieve_via_invalid_module_path(): + with pytest.raises(GraphSchemaException): + path = "rasa.core.policies.ted_policy.TEDPolicy1000" + GraphV1Recipe().graph_config_for_recipe( + { + "core_target": "doesnt_validate_or_run", + "nlu_target": "doesnt_validate_or_run", + "train_schema": {"nodes": {"some_graph_node": {"uses": path}}}, + "predict_schema": {}, + }, + cli_parameters={}, + training_type=TrainingType.CORE, + ) + + +def test_cli_parameter_warns(): + with pytest.warns( + UserWarning, match="Graph Recipe does not utilize CLI parameters" + ): + GraphV1Recipe().graph_config_for_recipe( + { + "core_target": "doesnt_validate_or_run", + "nlu_target": "doesnt_validate_or_run", + "train_schema": {"nodes": {}}, + "predict_schema": {"nodes": {}}, + }, + cli_parameters={"num_threads": 1, "epochs": 5}, + training_type=TrainingType.BOTH, + ) + + +def test_is_finetuning_warns(): + with pytest.warns( + UserWarning, match="Graph Recipe does not utilize CLI parameters" + ): + GraphV1Recipe().graph_config_for_recipe( + { + "core_target": "doesnt_validate_or_run", + "nlu_target": "doesnt_validate_or_run", + "train_schema": {"nodes": {}}, + "predict_schema": {"nodes": {}}, + }, + cli_parameters={}, + training_type=TrainingType.BOTH, + is_finetuning=True, + ) + + +@pytest.mark.parametrize("assistant_id", ["unique_assistant", "placeholder_default"]) +def test_graph_config_for_recipe_with_assistant_id(assistant_id): + config = rasa.shared.utils.io.read_model_configuration( + "data/test_config/graph_config_short.yml" + ) + config[ASSISTANT_ID_KEY] = assistant_id + + recipe = Recipe.recipe_for_name(GraphV1Recipe.name) + model_config = recipe.graph_config_for_recipe(config, {}) + + assert model_config.assistant_id == assistant_id diff --git a/tests/engine/recipes/test_recipe.py b/tests/engine/recipes/test_recipe.py new file mode 100644 index 0000000..0ead06a --- /dev/null +++ b/tests/engine/recipes/test_recipe.py @@ -0,0 +1,15 @@ +import pytest +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.recipes.recipe import Recipe, InvalidRecipeException + + +def test_invalid_recipe(): + with pytest.raises(InvalidRecipeException): + Recipe.recipe_for_name("dalksldkas") + + +def test_recipe_is_none(): + with pytest.warns(FutureWarning): + recipe = Recipe.recipe_for_name(None) + + assert isinstance(recipe, DefaultV1Recipe) diff --git a/tests/engine/runner/__init__.py b/tests/engine/runner/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/engine/runner/test_dask.py b/tests/engine/runner/test_dask.py new file mode 100644 index 0000000..ec85037 --- /dev/null +++ b/tests/engine/runner/test_dask.py @@ -0,0 +1,328 @@ +from __future__ import annotations +from typing import Optional + +import pytest + +from rasa.engine.graph import ExecutionContext, GraphSchema, SchemaNode +from rasa.engine.exceptions import GraphRunError +from rasa.engine.runner.dask import DaskGraphRunner +from rasa.engine.storage.storage import ModelStorage +from tests.engine.graph_components_test_classes import ( + AddInputs, + AssertComponent, + ExecutionContextAware, + ProvideX, + SubtractByX, + PersistableTestComponent, +) + + +@pytest.mark.parametrize("eager", [True, False]) +def test_multi_node_graph_run(eager: bool, default_model_storage: ModelStorage): + graph_schema = GraphSchema( + { + "add": SchemaNode( + needs={"i1": "first_input", "i2": "second_input"}, + uses=AddInputs, + fn="add", + constructor_name="create", + config={}, + eager=eager, + ), + "subtract_2": SchemaNode( + needs={"i": "add"}, + uses=SubtractByX, + fn="subtract_x", + constructor_name="create", + config={"x": 2}, + eager=eager, + is_target=True, + ), + } + ) + + execution_context = ExecutionContext(graph_schema=graph_schema, model_id="1") + + runner = DaskGraphRunner( + graph_schema=graph_schema, + model_storage=default_model_storage, + execution_context=execution_context, + ) + results = runner.run(inputs={"first_input": 3, "second_input": 4}) + assert results["subtract_2"] == 5 + + +@pytest.mark.parametrize("eager", [True, False]) +def test_target_override(eager: bool, default_model_storage: ModelStorage): + graph_schema = GraphSchema( + { + "add": SchemaNode( + needs={"i1": "first_input", "i2": "second_input"}, + uses=AddInputs, + fn="add", + constructor_name="create", + config={}, + eager=eager, + ), + "subtract_2": SchemaNode( + needs={"i": "add"}, + uses=SubtractByX, + fn="subtract_x", + constructor_name="create", + config={"x": 3}, + eager=eager, + is_target=True, + ), + } + ) + + execution_context = ExecutionContext(graph_schema=graph_schema, model_id="1") + + runner = DaskGraphRunner( + graph_schema=graph_schema, + model_storage=default_model_storage, + execution_context=execution_context, + ) + results = runner.run(inputs={"first_input": 3, "second_input": 4}, targets=["add"]) + assert results == {"add": 7} + + +@pytest.mark.parametrize("x, output", [(None, 5), (0, 5), (1, 4), (2, 3)]) +def test_default_config( + x: Optional[int], output: int, default_model_storage: ModelStorage +): + graph_schema = GraphSchema( + { + "subtract": SchemaNode( + needs={"i": "input"}, + uses=SubtractByX, + fn="subtract_x", + constructor_name="create", + config={"x": x} if x else {}, + is_target=True, + ) + } + ) + + runner = DaskGraphRunner( + graph_schema=graph_schema, + model_storage=default_model_storage, + execution_context=ExecutionContext(graph_schema=graph_schema, model_id="1"), + ) + results = runner.run(inputs={"input": 5}) + assert results["subtract"] == output + + +def test_empty_schema(default_model_storage: ModelStorage): + empty_schema = GraphSchema({}) + runner = DaskGraphRunner( + graph_schema=empty_schema, + model_storage=default_model_storage, + execution_context=ExecutionContext(graph_schema=empty_schema, model_id="1"), + ) + results = runner.run() + assert not results + + +def test_no_inputs(default_model_storage: ModelStorage): + graph_schema = GraphSchema( + { + "provide": SchemaNode( + needs={}, + uses=ProvideX, + fn="provide", + constructor_name="create", + config={}, + is_target=True, + ) + } + ) + runner = DaskGraphRunner( + graph_schema=graph_schema, + model_storage=default_model_storage, + execution_context=ExecutionContext(graph_schema=graph_schema, model_id="1"), + ) + results = runner.run() + assert results["provide"] == 1 + + +def test_no_target(default_model_storage: ModelStorage): + graph_schema = GraphSchema( + { + "provide": SchemaNode( + needs={}, + uses=ProvideX, + fn="provide", + constructor_name="create", + config={}, + ) + } + ) + runner = DaskGraphRunner( + graph_schema=graph_schema, + model_storage=default_model_storage, + execution_context=ExecutionContext(graph_schema=graph_schema, model_id="1"), + ) + results = runner.run() + assert not results + + +def test_unused_node(default_model_storage: ModelStorage): + graph_schema = GraphSchema( + { + "provide": SchemaNode( + needs={}, + uses=ProvideX, + fn="provide", + constructor_name="create", + config={}, + is_target=True, + ), + # This node will not fail as it will be pruned because it is not a target + # or a target's ancestor. + "assert_false": SchemaNode( + needs={"i": "input"}, + uses=AssertComponent, + fn="run_assert", + constructor_name="create", + config={"value_to_assert": "some_value"}, + ), + } + ) + runner = DaskGraphRunner( + graph_schema=graph_schema, + model_storage=default_model_storage, + execution_context=ExecutionContext(graph_schema=graph_schema, model_id="1"), + ) + results = runner.run(inputs={"input": "some_other_value"}) + assert results == {"provide": 1} + + +def test_non_eager_can_use_inputs_for_constructor(default_model_storage: ModelStorage): + graph_schema = GraphSchema( + { + "provide": SchemaNode( + needs={"x": "input"}, + uses=ProvideX, + fn="provide", + constructor_name="create", + config={}, + eager=False, + is_target=True, + ) + } + ) + runner = DaskGraphRunner( + graph_schema=graph_schema, + model_storage=default_model_storage, + execution_context=ExecutionContext(graph_schema=graph_schema, model_id="1"), + ) + results = runner.run(inputs={"input": 5}) + assert results["provide"] == 5 + + +def test_can_use_alternate_constructor(default_model_storage: ModelStorage): + graph_schema = GraphSchema( + { + "provide": SchemaNode( + needs={}, + uses=ProvideX, + fn="provide", + constructor_name="create_with_2", + config={}, + is_target=True, + ) + } + ) + runner = DaskGraphRunner( + graph_schema=graph_schema, + model_storage=default_model_storage, + execution_context=ExecutionContext(graph_schema=graph_schema, model_id="1"), + ) + results = runner.run() + assert results["provide"] == 2 + + +def test_execution_context(default_model_storage: ModelStorage): + graph_schema = GraphSchema( + { + "execution_context_aware": SchemaNode( + needs={}, + uses=ExecutionContextAware, + fn="get_execution_context", + constructor_name="create", + config={}, + is_target=True, + ) + } + ) + context = ExecutionContext(graph_schema=graph_schema, model_id="some_id") + runner = DaskGraphRunner( + graph_schema=graph_schema, + model_storage=default_model_storage, + execution_context=context, + ) + context.model_id = "a_new_id" + result = runner.run()["execution_context_aware"] + assert result.model_id == "some_id" + assert result.node_name == "execution_context_aware" + + +def test_input_value_is_node_name(default_model_storage: ModelStorage): + graph_schema = GraphSchema( + { + "provide": SchemaNode( + needs={}, + uses=ProvideX, + fn="provide", + constructor_name="create", + config={}, + is_target=True, + ) + } + ) + runner = DaskGraphRunner( + graph_schema=graph_schema, + model_storage=default_model_storage, + execution_context=ExecutionContext(graph_schema=graph_schema, model_id="1"), + ) + with pytest.raises(GraphRunError): + runner.run(inputs={"input": "provide"}) + + +def test_loading_from_previous_node(default_model_storage: ModelStorage): + test_value_for_sub_directory = {"test": "test value sub dir"} + test_value = {"test dir": "test value dir"} + + graph_schema = GraphSchema( + { + "train": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="train", + constructor_name="create", + config={ + "test_value": test_value, + "test_value_for_sub_directory": test_value_for_sub_directory, + }, + ), + "load": SchemaNode( + needs={"resource": "train"}, + uses=PersistableTestComponent, + fn="run_inference", + constructor_name="load", + config={}, + is_target=True, + ), + } + ) + + runner = DaskGraphRunner( + graph_schema=graph_schema, + model_storage=default_model_storage, + execution_context=ExecutionContext(graph_schema=graph_schema, model_id="1"), + ) + + results = runner.run() + + assert results["load"] == test_value diff --git a/tests/engine/storage/__init__.py b/tests/engine/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/engine/storage/test_local_model_storage.py b/tests/engine/storage/test_local_model_storage.py new file mode 100644 index 0000000..789aa06 --- /dev/null +++ b/tests/engine/storage/test_local_model_storage.py @@ -0,0 +1,368 @@ +import sys +import uuid +from datetime import datetime +from pathlib import Path + +import freezegun +import pytest +from _pytest.monkeypatch import MonkeyPatch +from _pytest.tmpdir import TempPathFactory +from tarsafe import TarSafe + +import rasa.shared.utils.io +from rasa.engine.graph import GraphModelConfiguration, GraphSchema, SchemaNode +from rasa.engine.storage.local_model_storage import ( + MODEL_ARCHIVE_METADATA_FILE, + LocalModelStorage, +) +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelMetadata, ModelStorage +from rasa.exceptions import UnsupportedModelVersionError +from rasa.shared.core.domain import Domain +from rasa.shared.data import TrainingType +from tests.engine.graph_components_test_classes import PersistableTestComponent + + +def test_write_to_and_read(default_model_storage: ModelStorage): + test_filename = "file.txt" + test_file_content = "hi" + + test_sub_filename = "sub_file" + test_sub_dir_name = "sub_directory" + test_sub_file_content = "sub file" + + resource = Resource("some_node123") + + # Fill model storage for resource + with default_model_storage.write_to(resource) as resource_directory: + file = resource_directory / test_filename + file.write_text(test_file_content) + + sub_directory = resource_directory / test_sub_dir_name + sub_directory.mkdir() + file_in_sub_directory = sub_directory / test_sub_filename + file_in_sub_directory.write_text(test_sub_file_content) + + # Read written resource data from model storage to see whether all expected + # content is there + with default_model_storage.read_from(resource) as resource_directory: + assert (resource_directory / test_filename).read_text() == test_file_content + assert ( + resource_directory / test_sub_dir_name / test_sub_filename + ).read_text() == test_sub_file_content + + +def test_read_from_not_existing_resource(default_model_storage: ModelStorage): + with default_model_storage.write_to(Resource("resource1")) as temporary_directory: + file = temporary_directory / "file.txt" + file.write_text("test") + + with pytest.raises(ValueError): + with default_model_storage.read_from(Resource("a different resource")) as _: + pass + + +def test_read_from_rasa2_resource(tmp_path_factory: TempPathFactory): + # we only search for the fingerprint file - nlu and core folder do not even need + # to exist + model_dir = tmp_path_factory.mktemp("model_dir") + version = "2.8.5" + rasa.shared.utils.io.dump_obj_as_json_to_file( + model_dir / "fingerprint.json", + {"version": version, "irrelevant-other-key": "bla"}, + ) + + model_zips = tmp_path_factory.mktemp("model_zips") + resource_name = "model" + with TarSafe.open(model_zips / resource_name, "w:gz") as tar: + tar.add(model_dir, arcname="") + + storage_dir = tmp_path_factory.mktemp("storage_dir") + storage = LocalModelStorage(storage_path=storage_dir) + with pytest.raises(UnsupportedModelVersionError, match=f".*{version}.*"): + storage.from_model_archive( + storage_path=storage_dir, model_archive_path=model_zips / resource_name + ) + with pytest.raises(UnsupportedModelVersionError, match=f".*{version}.*"): + storage.metadata_from_archive(model_archive_path=model_zips / resource_name) + + +@pytest.mark.skipif( + sys.platform != "win32", + reason="no need to run this test on other platform than Windows", +) +def test_read_long_resource_names_windows( + tmp_path_factory: TempPathFactory, + domain: Domain, +): + model_dir = tmp_path_factory.mktemp("model_dir") + version = "3.6.21" + + # full path length > 260 chars + # but each component of the path needs to be below 255 chars + # https://bugs.python.org/issue542314#msg10256 + long_file_path = model_dir / f"{'custom_file' * 22}.json" + rasa.shared.utils.io.dump_obj_as_json_to_file( + Path(f"\\\\?\\{long_file_path}"), + {"irrelevant-key": "bla"}, + ) + train_schema = GraphSchema( + { + "train": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="train", + constructor_name="create", + config={"some_config": 123455, "some more config": [{"nested": "hi"}]}, + ), + "load": SchemaNode( + needs={"resource": "train"}, + uses=PersistableTestComponent, + fn="run_inference", + constructor_name="load", + config={}, + is_target=True, + ), + } + ) + predict_schema = GraphSchema( + { + "run": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="run", + constructor_name="load", + config={"some_config": 123455, "some more config": [{"nested": "hi"}]}, + ) + } + ) + rasa.shared.utils.io.dump_obj_as_json_to_file( + model_dir / MODEL_ARCHIVE_METADATA_FILE, + ModelMetadata( + trained_at=datetime.utcnow(), + rasa_open_source_version=version, + model_id="xxxxxxx", + assistant_id="test_assistant", + domain=domain, + train_schema=train_schema, + predict_schema=predict_schema, + project_fingerprint="xxxxxxx", + core_target="", + nlu_target="", + language="en", + training_type=TrainingType.BOTH, + ).as_dict(), + ) + + model_zips = tmp_path_factory.mktemp("model_zips") + resource_name = "model" + with TarSafe.open(model_zips / resource_name, "w:gz") as tar: + tar.add(Path(f"\\\\?\\{model_dir}"), arcname="") + + storage_dir = tmp_path_factory.mktemp("storage_dir") + storage = LocalModelStorage(storage_path=storage_dir) + storage.metadata_from_archive(model_archive_path=model_zips / resource_name) + + +def test_create_model_package(tmp_path_factory: TempPathFactory, domain: Domain): + train_model_storage = LocalModelStorage( + tmp_path_factory.mktemp("train model storage") + ) + + train_schema = GraphSchema( + { + "train": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="train", + constructor_name="create", + config={"some_config": 123455, "some more config": [{"nested": "hi"}]}, + ), + "load": SchemaNode( + needs={"resource": "train"}, + uses=PersistableTestComponent, + fn="run_inference", + constructor_name="load", + config={}, + is_target=True, + ), + } + ) + + predict_schema = GraphSchema( + { + "run": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="run", + constructor_name="load", + config={"some_config": 123455, "some more config": [{"nested": "hi"}]}, + ) + } + ) + + # Fill model Storage + with train_model_storage.write_to(Resource("resource1")) as directory: + file = directory / "file.txt" + file.write_text("test") + + # Package model + persisted_model_dir = tmp_path_factory.mktemp("persisted models") + archive_path = persisted_model_dir / "my-model.tar.gz" + + trained_at = datetime.utcnow() + with freezegun.freeze_time(trained_at): + train_model_storage.create_model_package( + archive_path, + GraphModelConfiguration( + train_schema, + predict_schema, + TrainingType.BOTH, + "test_assistant", + None, + None, + "nlu", + ), + domain, + ) + + # Unpack and inspect packaged model + load_model_storage_dir = tmp_path_factory.mktemp("load model storage") + + just_packaged_metadata = LocalModelStorage.metadata_from_archive(archive_path) + + (load_model_storage, packaged_metadata) = LocalModelStorage.from_model_archive( + load_model_storage_dir, archive_path + ) + + assert just_packaged_metadata.trained_at == packaged_metadata.trained_at + + assert packaged_metadata.train_schema == train_schema + assert packaged_metadata.predict_schema == predict_schema + assert packaged_metadata.domain.as_dict() == domain.as_dict() + + assert packaged_metadata.rasa_open_source_version == rasa.__version__ + assert packaged_metadata.trained_at == trained_at + assert packaged_metadata.model_id + assert packaged_metadata.assistant_id == "test_assistant" + assert packaged_metadata.project_fingerprint + + persisted_resources = load_model_storage_dir.glob("*") + assert list(persisted_resources) == [Path(load_model_storage_dir, "resource1")] + + +def test_read_unsupported_model( + monkeypatch: MonkeyPatch, tmp_path_factory: TempPathFactory, domain: Domain +): + train_model_storage = LocalModelStorage( + tmp_path_factory.mktemp("train model storage") + ) + graph_schema = GraphSchema(nodes={}) + + persisted_model_dir = tmp_path_factory.mktemp("persisted models") + archive_path = persisted_model_dir / "my-model.tar.gz" + + # Create outdated model meta data + trained_at = datetime.utcnow() + model_configuration = GraphModelConfiguration( + graph_schema, + graph_schema, + TrainingType.BOTH, + "test_assistant", + None, + None, + "nlu", + ) + outdated_model_meta_data = ModelMetadata( + trained_at=trained_at, + rasa_open_source_version=rasa.__version__, # overwrite later to avoid error + model_id=uuid.uuid4().hex, + assistant_id=model_configuration.assistant_id, + domain=domain, + train_schema=model_configuration.train_schema, + predict_schema=model_configuration.predict_schema, + training_type=model_configuration.training_type, + project_fingerprint=rasa.model.project_fingerprint(), + language=model_configuration.language, + core_target=model_configuration.core_target, + nlu_target=model_configuration.nlu_target, + ) + old_version = "0.0.1" + outdated_model_meta_data.rasa_open_source_version = old_version + + # Package model - and inject the outdated model meta data + monkeypatch.setattr( + LocalModelStorage, + "_create_model_metadata", + lambda *args, **kwargs: outdated_model_meta_data, + ) + train_model_storage.create_model_package( + model_archive_path=archive_path, + model_configuration=model_configuration, + domain=domain, + ) + + # Unpack and inspect packaged model + load_model_storage_dir = tmp_path_factory.mktemp("load model storage") + + expected_message = ( + f"The model version is trained using Rasa Open Source " + f"{old_version} and is not compatible with your current " + f"installation .*" + ) + with pytest.raises(UnsupportedModelVersionError, match=expected_message): + LocalModelStorage.metadata_from_archive(archive_path) + + with pytest.raises(UnsupportedModelVersionError, match=expected_message): + LocalModelStorage.from_model_archive(load_model_storage_dir, archive_path) + + +def test_create_package_with_non_existing_parent(tmp_path: Path): + storage = LocalModelStorage.create(tmp_path) + model_file = tmp_path / "new" / "sub" / "dir" / "file.tar.gz" + + storage.create_model_package( + model_file, + GraphModelConfiguration( + GraphSchema({}), + GraphSchema({}), + TrainingType.BOTH, + "test_assistant", + None, + None, + "nlu", + ), + Domain.empty(), + ) + + assert model_file.is_file() + + +def test_create_model_package_with_non_empty_model_storage(tmp_path: Path): + # Put something in the future model storage directory + (tmp_path / "somefile.json").touch() + + with pytest.raises(ValueError): + # Unpacking into an already filled `ModelStorage` raises an exception. + _ = LocalModelStorage.from_model_archive(tmp_path, Path("does not matter")) + + +def test_create_model_package_with_non_existing_dir( + tmp_path: Path, default_model_storage: ModelStorage +): + path = tmp_path / "some_dir" / "another" / "model.tar.gz" + default_model_storage.create_model_package( + path, + GraphModelConfiguration( + GraphSchema({}), + GraphSchema({}), + TrainingType.BOTH, + "test_assistant", + None, + None, + "nlu", + ), + Domain.empty(), + ) + + assert path.exists() diff --git a/tests/engine/storage/test_resource.py b/tests/engine/storage/test_resource.py new file mode 100644 index 0000000..307b29c --- /dev/null +++ b/tests/engine/storage/test_resource.py @@ -0,0 +1,155 @@ +from pathlib import Path + +import pytest +from _pytest.tmpdir import TempPathFactory + +import rasa.utils.common +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage + + +def test_resource_caching(tmp_path_factory: TempPathFactory): + model_storage = LocalModelStorage(tmp_path_factory.mktemp("initial_model_storage")) + + resource = Resource("my resource") + + # Fill model storage + test_filename = "file.txt" + test_content = "test_resource_caching" + with model_storage.write_to(resource) as temporary_directory: + file = temporary_directory / test_filename + file.write_text(test_content) + + cache_dir = tmp_path_factory.mktemp("cache_dir") + + # Cache resource + resource.to_cache(cache_dir, model_storage) + + # Reload resource from cache and inspect + new_model_storage = LocalModelStorage(tmp_path_factory.mktemp("new_model_storage")) + reinstantiated_resource = Resource.from_cache( + resource.name, cache_dir, new_model_storage, resource.output_fingerprint + ) + + assert reinstantiated_resource == resource + + assert reinstantiated_resource.fingerprint() == resource.fingerprint() + + # Read written resource data from model storage to see whether all expected + # contents are there + with new_model_storage.read_from(resource) as temporary_directory: + assert (temporary_directory / test_filename).read_text() == test_content + + +def test_resource_caching_if_already_restored(tmp_path_factory: TempPathFactory): + initial_storage_dir = tmp_path_factory.mktemp("initial_model_storage") + model_storage = LocalModelStorage(initial_storage_dir) + + resource = Resource("my resource") + + # Fill model storage + test_filename = "file.txt" + test_content = "test_resource_caching" + with model_storage.write_to(resource) as temporary_directory: + file = temporary_directory / test_filename + file.write_text(test_content) + + cache_dir = tmp_path_factory.mktemp("cache_dir") + + # Cache resource + resource.to_cache(cache_dir, model_storage) + + new_storage_dir = tmp_path_factory.mktemp("new dir") + rasa.utils.common.copy_directory(initial_storage_dir, new_storage_dir) + + reinstantiated_resource = Resource.from_cache( + resource.name, + cache_dir, + LocalModelStorage(new_storage_dir), + resource.output_fingerprint, + ) + + assert reinstantiated_resource == resource + + +def test_resource_caching_if_already_restored_with_different_state( + tmp_path_factory: TempPathFactory, +): + initial_storage_dir = tmp_path_factory.mktemp("initial_model_storage") + model_storage = LocalModelStorage(initial_storage_dir) + + resource = Resource("my resource") + + # Fill model storage + test_filename = "file.txt" + test_content = "test_resource_caching" + with model_storage.write_to(resource) as temporary_directory: + file = temporary_directory / test_filename + file.write_text(test_content) + + cache_dir = tmp_path_factory.mktemp("cache_dir") + + # Cache resource + resource.to_cache(cache_dir, model_storage) + + # Pretend there is an additional file which is not in the restored storage. + # This makes the directories and causes the `from_cache` part to fail + # different + (temporary_directory / "another_file").touch() + + new_storage_dir = tmp_path_factory.mktemp("new dir") + rasa.utils.common.copy_directory(initial_storage_dir, new_storage_dir) + + with pytest.raises(ValueError): + Resource.from_cache( + resource.name, + cache_dir, + LocalModelStorage(new_storage_dir), + resource.output_fingerprint, + ) + + +def test_resource_fingerprinting(): + resource1a = Resource("resource 1") + resource1b = Resource("resource 1") + resource2 = Resource("resource 3") + + fingerprint1 = resource1a.fingerprint() + fingerprint2 = resource1b.fingerprint() + fingerprint3 = resource2.fingerprint() + + assert fingerprint1 + assert fingerprint2 + assert fingerprint3 + + assert fingerprint1 != fingerprint2 + assert fingerprint2 != fingerprint3 + assert fingerprint1 != fingerprint3 + + +def test_caching_empty_resource( + default_model_storage: ModelStorage, + tmp_path: Path, + tmp_path_factory: TempPathFactory, +): + resource_name = "my resource" + resource = Resource(resource_name) + + # does not raise + resource.to_cache(tmp_path, default_model_storage) + + with pytest.raises(ValueError): + with default_model_storage.read_from(resource) as _: + pass + + cache_dir = tmp_path_factory.mktemp("cache_dir") + + # this doesn't create an empty directory in `default_model_storage` + Resource.from_cache( + resource_name, cache_dir, default_model_storage, resource.output_fingerprint + ) + + with pytest.raises(ValueError): + with default_model_storage.read_from(resource) as _: + pass diff --git a/tests/engine/storage/test_storage.py b/tests/engine/storage/test_storage.py new file mode 100644 index 0000000..c1884fc --- /dev/null +++ b/tests/engine/storage/test_storage.py @@ -0,0 +1,111 @@ +from datetime import datetime +from pathlib import Path +import pytest + +from rasa.exceptions import UnsupportedModelVersionError +from rasa.shared.data import TrainingType +import rasa.shared.utils.io +from rasa.engine.graph import GraphSchema, SchemaNode +from rasa.engine.storage.storage import ModelMetadata +from rasa.shared.core.domain import Domain +from tests.engine.graph_components_test_classes import PersistableTestComponent + + +def test_metadata_serialization(domain: Domain, tmp_path: Path): + train_schema = GraphSchema( + { + "train": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="train", + constructor_name="create", + config={"some_config": 123455, "some more config": [{"nested": "hi"}]}, + ), + "load": SchemaNode( + needs={"resource": "train"}, + uses=PersistableTestComponent, + fn="run_inference", + constructor_name="load", + config={}, + is_target=True, + ), + } + ) + + predict_schema = GraphSchema( + { + "run": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="run", + constructor_name="load", + config={"some_config": 123455, "some more config": [{"nested": "hi"}]}, + ) + } + ) + + trained_at = datetime.utcnow() + rasa_version = rasa.__version__ + model_id = "some unique model id" + assistant_id = "test_assistant" + metadata = ModelMetadata( + trained_at, + rasa_version, + model_id, + assistant_id, + domain, + train_schema, + predict_schema, + project_fingerprint="some_fingerprint", + training_type=TrainingType.NLU, + core_target="core", + nlu_target="nlu", + language="zh", + ) + + serialized = metadata.as_dict() + + # Dump and Load to make sure it's serializable + dump_path = tmp_path / "metadata.json" + rasa.shared.utils.io.dump_obj_as_json_to_file(dump_path, serialized) + loaded_serialized = rasa.shared.utils.io.read_json_file(dump_path) + + loaded_metadata = ModelMetadata.from_dict(loaded_serialized) + + assert loaded_metadata.domain.as_dict() == domain.as_dict() + assert loaded_metadata.model_id == model_id + assert loaded_metadata.assistant_id == assistant_id + assert loaded_metadata.rasa_open_source_version == rasa_version + assert loaded_metadata.trained_at == trained_at + assert loaded_metadata.train_schema == train_schema + assert loaded_metadata.predict_schema == predict_schema + assert loaded_metadata.project_fingerprint == "some_fingerprint" + assert loaded_metadata.training_type == TrainingType.NLU + assert loaded_metadata.core_target == "core" + assert loaded_metadata.nlu_target == "nlu" + assert loaded_metadata.language == "zh" + + +def test_metadata_version_check(): + trained_at = datetime.utcnow() + old_version = "2.7.2" + expected_message = ( + f"The model version is trained using Rasa Open Source " + f"{old_version} and is not compatible with your current " + f"installation .*" + ) + with pytest.raises(UnsupportedModelVersionError, match=expected_message): + ModelMetadata( + trained_at, + old_version, + "some id", + "test_assistant", + Domain.empty(), + GraphSchema(nodes={}), + GraphSchema(nodes={}), + project_fingerprint="some_fingerprint", + training_type=TrainingType.NLU, + core_target="core", + nlu_target="nlu", + language="zh", + ) diff --git a/tests/engine/test_caching.py b/tests/engine/test_caching.py new file mode 100644 index 0000000..68cfc3c --- /dev/null +++ b/tests/engine/test_caching.py @@ -0,0 +1,555 @@ +import dataclasses +import logging +import shutil +import uuid +from pathlib import Path +from typing import Dict, Text, Optional, Any, Callable +from unittest.mock import Mock + +import pytest +from _pytest.logging import LogCaptureFixture +from _pytest.monkeypatch import MonkeyPatch +from sqlalchemy.exc import OperationalError + +import rasa.shared.utils.io +import rasa.shared.utils.common +from rasa.engine.caching import ( + LocalTrainingCache, + CACHE_LOCATION_ENV, + DEFAULT_CACHE_NAME, + CACHE_SIZE_ENV, + CACHE_DB_NAME_ENV, + TrainingCache, +) +import tests.conftest +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage + + +@dataclasses.dataclass +class TestCacheableOutput: + + value: Dict + size_in_mb: int = 0 + cache_dir: Optional[Path] = dataclasses.field(default=None, compare=False) + + def to_cache(self, directory: Path, model_storage: ModelStorage) -> None: + rasa.shared.utils.io.dump_obj_as_json_to_file( + directory / "cached.json", self.value + ) + + # Can be used to create cache results of desired size + if self.size_in_mb: + tests.conftest.create_test_file_with_size(directory, self.size_in_mb) + + @classmethod + def from_cache( + cls, + node_name: Text, + directory: Path, + model_storage: ModelStorage, + output_fingerprint: Text, + ) -> "TestCacheableOutput": + + value = rasa.shared.utils.io.read_json_file(directory / "cached.json") + + return cls(value, cache_dir=directory) + + +def test_cache_output(temp_cache: TrainingCache, default_model_storage: ModelStorage): + fingerprint_key = uuid.uuid4().hex + output = TestCacheableOutput({"something to cache": "dasdaasda"}) + output_fingerprint = uuid.uuid4().hex + + temp_cache.cache_output( + fingerprint_key, output, output_fingerprint, default_model_storage + ) + + assert ( + temp_cache.get_cached_output_fingerprint(fingerprint_key) == output_fingerprint + ) + + assert ( + temp_cache.get_cached_result( + output_fingerprint, "some_node", default_model_storage + ) + == output + ) + + +def test_get_cached_result_with_miss( + temp_cache: TrainingCache, default_model_storage: ModelStorage +): + # Cache something + temp_cache.cache_output( + uuid.uuid4().hex, + TestCacheableOutput({"something to cache": "dasdaasda"}), + uuid.uuid4().hex, + default_model_storage, + ) + + assert ( + temp_cache.get_cached_result( + uuid.uuid4().hex, "some node", default_model_storage + ) + is None + ) + assert temp_cache.get_cached_output_fingerprint(uuid.uuid4().hex) is None + + +def test_get_cached_result_when_result_no_longer_available( + tmp_path: Path, + local_cache_creator: Callable[..., LocalTrainingCache], + default_model_storage: ModelStorage, +): + cache = local_cache_creator(tmp_path) + + output = TestCacheableOutput({"something to cache": "dasdaasda"}) + output_fingerprint = uuid.uuid4().hex + + cache.cache_output( + uuid.uuid4().hex, output, output_fingerprint, default_model_storage + ) + + # Pretend something deleted the cache in between + for path in tmp_path.glob("*"): + if path.is_dir(): + shutil.rmtree(path) + + assert ( + cache.get_cached_result(output_fingerprint, "some_node", default_model_storage) + is None + ) + + +def test_cache_creates_location_if_missing( + tmp_path: Path, local_cache_creator: Callable[..., LocalTrainingCache] +): + cache_location = tmp_path / "directory does not exist yet" + + _ = local_cache_creator(cache_location) + + assert cache_location.is_dir() + + +def test_caching_something_which_is_not_cacheable( + temp_cache: TrainingCache, default_model_storage: ModelStorage +): + # Cache something + fingerprint_key = uuid.uuid4().hex + output_fingerprint_key = uuid.uuid4().hex + temp_cache.cache_output( + fingerprint_key, None, output_fingerprint_key, default_model_storage + ) + + # Output fingerprint was saved + assert ( + temp_cache.get_cached_output_fingerprint(fingerprint_key) + == output_fingerprint_key + ) + + # But it's not stored to disk + assert ( + temp_cache.get_cached_result( + output_fingerprint_key, "some_node", default_model_storage + ) + is None + ) + + +@pytest.mark.parametrize( + "initial_output_fingerprint, second_output_fingerprint", + [("same same same", "same same same"), ("first output", "second output")], +) +def test_cache_again( + temp_cache: TrainingCache, + default_model_storage: ModelStorage, + initial_output_fingerprint: Text, + second_output_fingerprint: Text, +): + # Cache something + fingerprint_key = uuid.uuid4().hex + temp_cache.cache_output( + fingerprint_key, None, initial_output_fingerprint, default_model_storage + ) + + # Pretend we are caching the same fingerprint again + # Note that it can't happen that we cache a `Cacheable` result twice as we would + # have replaced the component with a `PrecomputedValueProvider` otherwise + temp_cache.cache_output( + fingerprint_key, None, second_output_fingerprint, default_model_storage + ) + + assert ( + temp_cache.get_cached_output_fingerprint(fingerprint_key) + == second_output_fingerprint + ) + + +def test_caching_cacheable_fails( + tmp_path: Path, + caplog: LogCaptureFixture, + temp_cache: TrainingCache, + default_model_storage: ModelStorage, +): + fingerprint_key = uuid.uuid4().hex + + # `tmp_path` is not a dict and will hence fail to be cached + # noinspection PyTypeChecker + output = TestCacheableOutput(tmp_path) + output_fingerprint = uuid.uuid4().hex + + with caplog.at_level(logging.ERROR): + temp_cache.cache_output( + fingerprint_key, output, output_fingerprint, default_model_storage + ) + + caplog_error_records = list( + filter( + lambda x: "failed to send traces to Datadog Agent" not in x[2], + caplog.record_tuples, + ) + ) + assert len(caplog_error_records) == 1 + + assert ( + temp_cache.get_cached_output_fingerprint(fingerprint_key) == output_fingerprint + ) + + assert ( + temp_cache.get_cached_result( + output_fingerprint, "some_node", default_model_storage + ) + is None + ) + + +@pytest.mark.parametrize( + "cached_module", [Mock(side_effect=ValueError()), Mock(return_value=Dict)] +) +def test_restore_cached_output_with_invalid_module( + temp_cache: TrainingCache, + default_model_storage: ModelStorage, + monkeypatch: MonkeyPatch, + cached_module: Any, +): + output = TestCacheableOutput({"something to cache": "dasdaasda"}) + output_fingerprint = uuid.uuid4().hex + + temp_cache.cache_output( + uuid.uuid4().hex, output, output_fingerprint, default_model_storage + ) + + monkeypatch.setattr( + rasa.shared.utils.common, "class_from_module_path", cached_module + ) + + assert ( + temp_cache.get_cached_result( + output_fingerprint, "some_node", default_model_storage + ) + is None + ) + + +def test_removing_no_longer_compatible_cache_entries( + tmp_path: Path, + monkeypatch: MonkeyPatch, + local_cache_creator: Callable[..., LocalTrainingCache], + default_model_storage: ModelStorage, +): + cache = local_cache_creator(tmp_path) + + # Cache an entry including serialized output which will be incompatible later + fingerprint_key1 = uuid.uuid4().hex + output1 = TestCacheableOutput({"something to cache": "dasdaasda"}) + output_fingerprint1 = uuid.uuid4().hex + cache.cache_output( + fingerprint_key1, output1, output_fingerprint1, default_model_storage + ) + + # Cache an entry without serialized output which will be incompatible later + fingerprint_key2 = uuid.uuid4().hex + output_fingerprint2 = uuid.uuid4().hex + cache.cache_output( + fingerprint_key2, None, output_fingerprint2, default_model_storage + ) + + # Cache a second entry with a newer Rasa version + monkeypatch.setattr(rasa, "__version__", "99999.9.5") + fingerprint_key3 = uuid.uuid4().hex + output3 = TestCacheableOutput({"something to cache2": "dasdaasda"}) + output_fingerprint3 = uuid.uuid4().hex + + cache.cache_output( + fingerprint_key3, output3, output_fingerprint3, default_model_storage + ) + + # Pretend we updated Rasa Open Source to a no longer compatible version + monkeypatch.setattr(rasa.engine.caching, "MINIMUM_COMPATIBLE_VERSION", "99999.8.10") + + cache_run_by_future_rasa = LocalTrainingCache() + + # Cached fingerprints can no longer be retrieved + assert ( + cache_run_by_future_rasa.get_cached_output_fingerprint(fingerprint_key1) is None + ) + assert ( + cache_run_by_future_rasa.get_cached_output_fingerprint(fingerprint_key2) is None + ) + + assert ( + cache_run_by_future_rasa.get_cached_result( + output_fingerprint1, "some_node", default_model_storage + ) + is None + ) + assert ( + cache_run_by_future_rasa.get_cached_result( + output_fingerprint2, "some_node", default_model_storage + ) + is None + ) + + # Entry 3 wasn't deleted from cache as it's still compatible + assert ( + cache_run_by_future_rasa.get_cached_output_fingerprint(fingerprint_key3) + == output_fingerprint3 + ) + restored = cache_run_by_future_rasa.get_cached_result( + output_fingerprint3, "some_node", default_model_storage + ) + assert isinstance(restored, TestCacheableOutput) + assert restored == output3 + + # Cached output of no longer compatible stuff was deleted from disk + assert set(tmp_path.glob("*")) == { + tmp_path / DEFAULT_CACHE_NAME, + restored.cache_dir, + } + + +def test_skip_caching_if_cache_size_is_zero( + tmp_path: Path, monkeypatch: MonkeyPatch, default_model_storage: ModelStorage +): + cache_location = tmp_path / "cache" + monkeypatch.setenv(CACHE_LOCATION_ENV, str(cache_location)) + + # Disable cache + monkeypatch.setenv(CACHE_SIZE_ENV, "0") + + cache = LocalTrainingCache() + + # Cache something + fingerprint_key1 = uuid.uuid4().hex + output1 = TestCacheableOutput({"something to cache": "dasdaasda"}) + output_fingerprint1 = uuid.uuid4().hex + cache.cache_output( + fingerprint_key1, output1, output_fingerprint1, default_model_storage + ) + + # not even the database and no subdirectory was created ⛔️ + assert list(tmp_path.glob("*")) == [] + + assert cache.get_cached_output_fingerprint(fingerprint_key1) is None + + assert ( + cache.get_cached_result(output_fingerprint1, "some_node", default_model_storage) + is None + ) + + +def test_skip_caching_if_result_exceeds_max_size( + tmp_path: Path, monkeypatch: MonkeyPatch, default_model_storage: ModelStorage +): + monkeypatch.setenv(CACHE_LOCATION_ENV, str(tmp_path)) + + # Pretend we have a cache of size "1" + monkeypatch.setenv(CACHE_SIZE_ENV, "1") + + cache = LocalTrainingCache() + + # Cache something + fingerprint_key1 = uuid.uuid4().hex + output1 = TestCacheableOutput({"something to cache": "dasdaasda"}, size_in_mb=2) + output_fingerprint1 = uuid.uuid4().hex + cache.cache_output( + fingerprint_key1, output1, output_fingerprint1, default_model_storage + ) + + assert cache.get_cached_output_fingerprint(fingerprint_key1) == output_fingerprint1 + + assert ( + cache.get_cached_result(output_fingerprint1, "some_node", default_model_storage) + is None + ) + + +def test_delete_using_lru_if_cache_exceeds_size( + tmp_path: Path, monkeypatch: MonkeyPatch, default_model_storage: ModelStorage +): + monkeypatch.setenv(CACHE_LOCATION_ENV, str(tmp_path)) + + # Pretend we have a cache of certain size + monkeypatch.setenv(CACHE_SIZE_ENV, "5") + + cache = LocalTrainingCache() + + # Cache an item + fingerprint_key1 = uuid.uuid4().hex + output1 = TestCacheableOutput({"something to cache": "dasdaasda"}, size_in_mb=2) + output_fingerprint1 = uuid.uuid4().hex + cache.cache_output( + fingerprint_key1, output1, output_fingerprint1, default_model_storage + ) + + # Cache an non cacheable item to spice it up 🔥 + fingerprint_key2 = uuid.uuid4().hex + output2 = TestCacheableOutput(None) + output_fingerprint2 = uuid.uuid4().hex + cache.cache_output( + fingerprint_key2, output2, output_fingerprint2, default_model_storage + ) + + # Cache another item + fingerprint_key3 = uuid.uuid4().hex + output3 = TestCacheableOutput({"something to cache": "dasdaasda"}, size_in_mb=2) + output_fingerprint3 = uuid.uuid4().hex + cache.cache_output( + fingerprint_key3, output3, output_fingerprint3, default_model_storage + ) + + # Assert both are there + for output_fingerprint in [output_fingerprint1, output_fingerprint2]: + assert cache.get_cached_result( + output_fingerprint, "some_node", default_model_storage + ) + + # Checkout the first item as this updates `last_used` and hence affects LRU + cache.get_cached_output_fingerprint(fingerprint_key1) + + # Now store something which requires a deletion + fingerprint_key4 = uuid.uuid4().hex + output4 = TestCacheableOutput({"something to cache": "dasdaasda"}, size_in_mb=2) + output_fingerprint4 = uuid.uuid4().hex + cache.cache_output( + fingerprint_key4, output4, output_fingerprint4, default_model_storage + ) + + # Assert cached result 1 and 3 are there + for output_fingerprint in [output_fingerprint1, output_fingerprint4]: + assert cache.get_cached_result( + output_fingerprint, "some_node", default_model_storage + ) + + # Cached result 2 and 3 were deleted + assert cache.get_cached_output_fingerprint(fingerprint_key2) is None + assert ( + cache.get_cached_result(output_fingerprint3, "some_node", default_model_storage) + is None + ) + + +def test_cache_exceeds_size_but_not_in_database( + tmp_path: Path, monkeypatch: MonkeyPatch, default_model_storage: ModelStorage +): + monkeypatch.setenv(CACHE_LOCATION_ENV, str(tmp_path)) + + max_cache_size = 5 + # Pretend we have a cache of size `max_cached_size` + monkeypatch.setenv(CACHE_SIZE_ENV, str(max_cache_size)) + + cache = LocalTrainingCache() + + # Fill cache with something which is not in the cache metadata + sub_dir = cache._cache_location / "some dir" + sub_dir.mkdir() + + # one subdirectory which needs deletion + tests.conftest.create_test_file_with_size(sub_dir, max_cache_size) + # one file which needs deletion + test_file = tests.conftest.create_test_file_with_size( + cache._cache_location, max_cache_size + ) + + # Cache an item + fingerprint_key = uuid.uuid4().hex + output = TestCacheableOutput({"something to cache": "dasdaasda"}, size_in_mb=2) + output_fingerprint = uuid.uuid4().hex + cache.cache_output( + fingerprint_key, output, output_fingerprint, default_model_storage + ) + + assert cache.get_cached_output_fingerprint(fingerprint_key) == output_fingerprint + assert cache.get_cached_result( + output_fingerprint, "some_node", default_model_storage + ) + assert not sub_dir.is_dir() + assert not test_file.is_file() + + +def test_clean_up_of_cached_result_if_database_fails( + tmp_path: Path, + monkeypatch: MonkeyPatch, + default_model_storage: ModelStorage, + local_cache_creator: Callable[..., LocalTrainingCache], +): + database_name = "test.db" + monkeypatch.setenv(CACHE_DB_NAME_ENV, database_name) + + cache = local_cache_creator(tmp_path) + + # Deleting the database will cause an error when caching the result + (tmp_path / database_name).unlink() + + # Cache an item + fingerprint_key = uuid.uuid4().hex + output = TestCacheableOutput({"something to cache": "dasdaasda"}, size_in_mb=2) + output_fingerprint = uuid.uuid4().hex + + with pytest.raises(OperationalError): + cache.cache_output( + fingerprint_key, output, output_fingerprint, default_model_storage + ) + + assert list(tmp_path.glob("*")) == [tmp_path / database_name] + + +def test_resource_with_model_storage( + default_model_storage: ModelStorage, tmp_path: Path, temp_cache: TrainingCache +): + node_name = "some node" + resource = Resource(node_name) + test_filename = "persisted_model.json" + test_content = {"epochs": 500} + + with default_model_storage.write_to(resource) as temporary_directory: + rasa.shared.utils.io.dump_obj_as_json_to_file( + temporary_directory / test_filename, test_content + ) + + test_fingerprint_key = uuid.uuid4().hex + test_output_fingerprint_key = uuid.uuid4().hex + temp_cache.cache_output( + test_fingerprint_key, + resource, + test_output_fingerprint_key, + default_model_storage, + ) + + new_model_storage_location = tmp_path / "new_model_storage" + new_model_storage_location.mkdir() + new_model_storage = LocalModelStorage(new_model_storage_location) + restored_resource = temp_cache.get_cached_result( + test_output_fingerprint_key, node_name, new_model_storage + ) + + assert isinstance(restored_resource, Resource) + assert restored_resource == restored_resource + + with new_model_storage.read_from(restored_resource) as temporary_directory: + cached_content = rasa.shared.utils.io.read_json_file( + temporary_directory / test_filename + ) + assert cached_content == test_content diff --git a/tests/engine/test_graph.py b/tests/engine/test_graph.py new file mode 100644 index 0000000..e634c30 --- /dev/null +++ b/tests/engine/test_graph.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Dict, Text + +import pytest + +from rasa.engine.storage.resource import Resource +import rasa.shared.utils.io +from rasa.engine.exceptions import GraphSchemaException +from rasa.engine.graph import SchemaNode, GraphSchema +from tests.engine.graph_components_test_classes import PersistableTestComponent + + +def test_serialize_graph_schema(tmp_path: Path): + graph_schema = GraphSchema( + { + "train": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="train", + constructor_name="create", + config={"some_config": 123455, "some more config": [{"nested": "hi"}]}, + ), + "load": SchemaNode( + needs={"resource": "train"}, + uses=PersistableTestComponent, + fn="run_inference", + constructor_name="load", + config={}, + is_target=True, + resource=Resource("test resource"), + ), + } + ) + + serialized = graph_schema.as_dict() + + # Dump it to make sure it's actually serializable + file_path = tmp_path / "my_graph.yml" + rasa.shared.utils.io.write_yaml(serialized, file_path) + + serialized_graph_schema_from_file = rasa.shared.utils.io.read_yaml_file(file_path) + graph_schema_from_file = GraphSchema.from_dict(serialized_graph_schema_from_file) + + assert graph_schema_from_file == graph_schema + + +def test_invalid_module_error_when_deserializing_schemas(tmp_path: Path): + graph_schema = GraphSchema( + { + "train": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="train", + constructor_name="create", + config={"some_config": 123455, "some more config": [{"nested": "hi"}]}, + ) + } + ) + + serialized = graph_schema.as_dict() + + # Pretend module is for some reason invalid + serialized["nodes"]["train"]["uses"] = "invalid.class" + + # Dump it to make sure it's actually serializable + file_path = tmp_path / "my_graph.yml" + rasa.shared.utils.io.write_yaml(serialized, file_path) + + serialized_graph_schema_from_file = rasa.shared.utils.io.read_yaml_file(file_path) + + with pytest.raises(GraphSchemaException): + _ = GraphSchema.from_dict(serialized_graph_schema_from_file) + + +def test_minimal_graph_schema(): + def test_schema_node(needs: Dict[Text, Text], target: bool = False) -> SchemaNode: + return SchemaNode( + needs=needs, + uses=None, + fn="", + constructor_name="", + config={}, + is_target=target, + ) + + test_schema = GraphSchema( + { + "1": test_schema_node({"i": "3"}, True), + "2": test_schema_node({"i": "3"}), + "3": test_schema_node({"i": "4"}), + "4": test_schema_node({}), + "5": test_schema_node({"i": "6"}), + "6": test_schema_node({}), + "7": test_schema_node({}), + "8": test_schema_node({"i": "9"}, True), + "9": test_schema_node({"i": "__input__"}), + } + ) + assert test_schema.minimal_graph_schema() == GraphSchema( + { + "1": test_schema_node({"i": "3"}, True), + "3": test_schema_node({"i": "4"}), + "4": test_schema_node({}), + "8": test_schema_node({"i": "9"}, True), + "9": test_schema_node({"i": "__input__"}), + } + ) + + assert test_schema.minimal_graph_schema(targets=["8"]) == GraphSchema( + { + "8": test_schema_node({"i": "9"}, True), + "9": test_schema_node({"i": "__input__"}), + } + ) diff --git a/tests/engine/test_graph_node.py b/tests/engine/test_graph_node.py new file mode 100644 index 0000000..726f102 --- /dev/null +++ b/tests/engine/test_graph_node.py @@ -0,0 +1,369 @@ +from __future__ import annotations +from typing import Any, Dict, Optional, Text +from unittest.mock import Mock + +import pytest + +import rasa.shared +import rasa.shared.utils +import rasa.shared.utils.io +from rasa.engine.exceptions import GraphComponentException +from rasa.engine.graph import ExecutionContext, GraphComponent, GraphNode, GraphSchema +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.storage.resource import Resource +from tests.engine.graph_components_test_classes import ( + AddInputs, + ExecutionContextAware, + ProvideX, + SubtractByX, + PersistableTestComponent, +) + + +def test_calling_component(default_model_storage: ModelStorage): + node = GraphNode( + node_name="add_node", + component_class=AddInputs, + constructor_name="create", + component_config={}, + fn_name="add", + inputs={"i1": "input_node1", "i2": "input_node2"}, + eager=False, + model_storage=default_model_storage, + resource=None, + execution_context=ExecutionContext(GraphSchema({}), "1"), + ) + + result = node(("input_node1", 3), ("input_node2", 4)) + + assert result == ("add_node", 7) + + +@pytest.mark.parametrize("x, output", [(None, 5), (0, 5), (1, 4), (2, 3)]) +def test_component_config( + x: Optional[int], output: int, default_model_storage: ModelStorage +): + node = GraphNode( + node_name="subtract", + component_class=SubtractByX, + constructor_name="create", + component_config={"x": x} if x else {}, + fn_name="subtract_x", + inputs={"i": "input_node"}, + eager=False, + model_storage=default_model_storage, + resource=None, + execution_context=ExecutionContext(GraphSchema({}), "1"), + ) + + result = node(("input_node", 5)) + + assert result == ("subtract", output) + + +def test_can_use_alternate_constructor(default_model_storage: ModelStorage): + node = GraphNode( + node_name="provide", + component_class=ProvideX, + constructor_name="create_with_2", + component_config={}, + fn_name="provide", + inputs={}, + eager=False, + model_storage=default_model_storage, + resource=None, + execution_context=ExecutionContext(GraphSchema({}), "1"), + ) + + result = node() + assert result == ("provide", 2) + + +@pytest.mark.parametrize("eager", [True, False]) +def test_eager_and_not_eager(eager: bool, default_model_storage: ModelStorage): + run_mock = Mock() + create_mock = Mock() + + class SpyComponent(GraphComponent): + @classmethod + def create( + cls, + config: Dict, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> SpyComponent: + create_mock() + return cls() + + def run(self): + return run_mock() + + node = GraphNode( + node_name="spy_node", + component_class=SpyComponent, + constructor_name="create", + component_config={}, + fn_name="run", + inputs={}, + eager=eager, + model_storage=default_model_storage, + resource=None, + execution_context=ExecutionContext(GraphSchema({}), "1"), + ) + + if eager: + assert create_mock.called + else: + assert not create_mock.called + + assert not run_mock.called + + node() + + assert create_mock.call_count == 1 + assert run_mock.called + + +def test_non_eager_can_use_inputs_for_constructor(default_model_storage: ModelStorage): + node = GraphNode( + node_name="provide", + component_class=ProvideX, + constructor_name="create", + component_config={}, + fn_name="provide", + inputs={"x": "input_node"}, + eager=False, + model_storage=default_model_storage, + resource=None, + execution_context=ExecutionContext(GraphSchema({}), "1"), + ) + + result = node(("input_node", 5)) + + assert result == ("provide", 5) + + +def test_execution_context(default_model_storage: ModelStorage): + context = ExecutionContext(GraphSchema({}), "some_id") + node = GraphNode( + node_name="execution_context_aware", + component_class=ExecutionContextAware, + constructor_name="create", + component_config={}, + fn_name="get_execution_context", + inputs={}, + eager=False, + model_storage=default_model_storage, + resource=None, + execution_context=context, + ) + + context.model_id = "a_new_id" + + result = node()[1] + assert result.model_id == "some_id" + assert result.node_name == "execution_context_aware" + + +def test_constructor_exception(default_model_storage: ModelStorage): + class BadConstructor(GraphComponent): + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> BadConstructor: + raise ValueError("oh no!") + + def run(self) -> None: + ... + + with pytest.raises(GraphComponentException): + GraphNode( + node_name="bad_constructor", + component_class=BadConstructor, + constructor_name="create", + component_config={}, + fn_name="run", + inputs={}, + eager=True, + model_storage=default_model_storage, + resource=None, + execution_context=ExecutionContext(GraphSchema({}), "some_id"), + ) + + +def test_fn_exception(default_model_storage: ModelStorage): + class BadFn(GraphComponent): + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> BadFn: + return cls() + + def run(self) -> None: + raise ValueError("Oh no!") + + node = GraphNode( + node_name="bad_fn", + component_class=BadFn, + constructor_name="create", + component_config={}, + fn_name="run", + inputs={}, + eager=True, + model_storage=default_model_storage, + resource=None, + execution_context=ExecutionContext(GraphSchema({}), "some_id"), + ) + + with pytest.raises(GraphComponentException): + node() + + +def test_writing_to_resource_during_training(default_model_storage: ModelStorage): + node_name = "some_name" + + test_value_for_sub_directory = {"test": "test value sub dir"} + test_value = {"test dir": "test value dir"} + + node = GraphNode( + node_name=node_name, + component_class=PersistableTestComponent, + constructor_name="create", + component_config={ + "test_value": test_value, + "test_value_for_sub_directory": test_value_for_sub_directory, + }, + fn_name="train", + inputs={}, + eager=False, + model_storage=default_model_storage, + resource=None, + execution_context=ExecutionContext(GraphSchema({}), "123"), + ) + + _, resource = node() + + assert resource == Resource(node_name) + + with default_model_storage.read_from(resource) as directory: + assert ( + rasa.shared.utils.io.read_json_file(directory / "test.json") == test_value + ) + assert ( + rasa.shared.utils.io.read_json_file(directory / "sub_dir" / "test.json") + == test_value_for_sub_directory + ) + + +def test_loading_from_resource_not_eager(default_model_storage: ModelStorage): + previous_resource = Resource("previous resource") + parent_node_name = "parent" + test_value = {"test": "test value"} + + # Pretend resource persisted itself before + with default_model_storage.write_to(previous_resource) as directory: + rasa.shared.utils.io.dump_obj_as_json_to_file( + directory / "test.json", test_value + ) + + node_name = "some_name" + node = GraphNode( + node_name=node_name, + component_class=PersistableTestComponent, + constructor_name="load", + component_config={}, + fn_name="run_train_process", + inputs={"resource": parent_node_name}, + eager=False, + model_storage=default_model_storage, + # The `GraphComponent` should load from this resource + resource=None, + execution_context=ExecutionContext(GraphSchema({}), "123"), + ) + + _, value = node((parent_node_name, previous_resource)) + + assert value == test_value + + +def test_loading_from_resource_eager(default_model_storage: ModelStorage): + previous_resource = Resource("previous resource") + test_value = {"test": "test value"} + + # Pretend resource persisted itself before + with default_model_storage.write_to(previous_resource) as directory: + rasa.shared.utils.io.dump_obj_as_json_to_file( + directory / "test.json", test_value + ) + + node_name = "some_name" + node = GraphNode( + node_name=node_name, + component_class=PersistableTestComponent, + constructor_name="load", + component_config={}, + fn_name="run_inference", + inputs={}, + eager=True, + model_storage=default_model_storage, + # The `GraphComponent` should load from this resource + resource=previous_resource, + execution_context=ExecutionContext(GraphSchema({}), "123"), + ) + + actual_node_name, value = node() + + assert actual_node_name == node_name + assert value == test_value + + +def test_config_with_nested_dict_override(default_model_storage: ModelStorage): + class ComponentWithNestedDictConfig(GraphComponent): + @staticmethod + def get_default_config() -> Dict[Text, Any]: + return {"nested-dict": {"key1": "value1", "key2": "value2"}} + + @classmethod + def create( + cls, + config: Dict, + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> ComponentWithNestedDictConfig: + return cls() + + def run(self) -> None: + return None + + node = GraphNode( + node_name="nested_dict_config", + component_class=ComponentWithNestedDictConfig, + constructor_name="create", + component_config={"nested-dict": {"key2": "override-value2"}}, + fn_name="run", + inputs={}, + eager=True, + model_storage=default_model_storage, + resource=None, + execution_context=ExecutionContext(GraphSchema({}), "123"), + ) + + expected_config = {"nested-dict": {"key1": "value1", "key2": "override-value2"}} + + for key, value in expected_config.items(): + assert key in node._component_config + if isinstance(value, dict): + for nested_key, nested_value in expected_config[key].items(): + assert nested_key in node._component_config[key] + assert node._component_config[key][nested_key] == nested_value diff --git a/tests/engine/test_loader.py b/tests/engine/test_loader.py new file mode 100644 index 0000000..06dcb65 --- /dev/null +++ b/tests/engine/test_loader.py @@ -0,0 +1,116 @@ +from datetime import datetime +from pathlib import Path + +from _pytest.tmpdir import TempPathFactory +import freezegun + +import rasa +from rasa.engine.caching import TrainingCache +from rasa.engine.graph import GraphModelConfiguration, GraphSchema, SchemaNode +from rasa.engine import loader +from rasa.engine.runner.dask import DaskGraphRunner +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelMetadata, ModelStorage +from rasa.engine.training.graph_trainer import GraphTrainer +from rasa.shared.constants import ASSISTANT_ID_KEY +from rasa.shared.core.domain import Domain +from rasa.shared.data import TrainingType +from rasa.shared.importers.importer import TrainingDataImporter +from tests.engine.graph_components_test_classes import PersistableTestComponent + + +def test_loader_loads_graph_runner( + default_model_storage: ModelStorage, + temp_cache: TrainingCache, + tmp_path: Path, + tmp_path_factory: TempPathFactory, + domain_path: Path, +): + graph_trainer = GraphTrainer( + model_storage=default_model_storage, + cache=temp_cache, + graph_runner_class=DaskGraphRunner, + ) + + test_value = "test_value" + + train_schema = GraphSchema( + { + "train": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="train", + constructor_name="create", + config={"test_value": test_value}, + is_target=True, + ), + "load": SchemaNode( + needs={"resource": "train"}, + uses=PersistableTestComponent, + fn="run_inference", + constructor_name="load", + config={}, + ), + } + ) + predict_schema = GraphSchema( + { + "load": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="run_inference", + constructor_name="load", + config={}, + is_target=True, + resource=Resource("train"), + ) + } + ) + + output_filename = tmp_path / "model.tar.gz" + + importer = TrainingDataImporter.load_from_dict( + training_data_paths=[], + domain_path=str(domain_path), + config_path="data/test_config/config_unique_assistant_id.yml", + ) + config = importer.get_config() + + trained_at = datetime.utcnow() + with freezegun.freeze_time(trained_at): + model_metadata = graph_trainer.train( + GraphModelConfiguration( + train_schema=train_schema, + predict_schema=predict_schema, + training_type=TrainingType.BOTH, + assistant_id=config.get(ASSISTANT_ID_KEY), + language=None, + core_target=None, + nlu_target=None, + ), + importer=importer, + output_filename=output_filename, + ) + + assert isinstance(model_metadata, ModelMetadata) + assert output_filename.is_file() + + loaded_model_storage_path = tmp_path_factory.mktemp("loaded model storage") + + model_metadata, loaded_predict_graph_runner = loader.load_predict_graph_runner( + storage_path=loaded_model_storage_path, + model_archive_path=output_filename, + model_storage_class=LocalModelStorage, + graph_runner_class=DaskGraphRunner, + ) + + assert loaded_predict_graph_runner.run() == {"load": test_value} + + assert model_metadata.predict_schema == predict_schema + assert model_metadata.train_schema == train_schema + assert model_metadata.model_id + assert model_metadata.assistant_id == config.get(ASSISTANT_ID_KEY) + assert model_metadata.domain.as_dict() == Domain.from_path(domain_path).as_dict() + assert model_metadata.rasa_open_source_version == rasa.__version__ + assert model_metadata.trained_at == trained_at diff --git a/tests/engine/test_validation.py b/tests/engine/test_validation.py new file mode 100644 index 0000000..189f28f --- /dev/null +++ b/tests/engine/test_validation.py @@ -0,0 +1,1222 @@ +from typing import Any, Callable, Dict, Text, Tuple, Type, Optional, List +from collections import namedtuple +import itertools +from unittest.mock import Mock + +import pytest +from rasa.core.policies.policy import PolicyPrediction + +from rasa.engine import validation +from rasa.engine.exceptions import GraphSchemaValidationException +from rasa.engine.graph import ( + GraphComponent, + ExecutionContext, + GraphSchema, + SchemaNode, + GraphModelConfiguration, +) +from rasa.engine.constants import PLACEHOLDER_IMPORTER +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.core.domain import Domain +from rasa.shared.data import TrainingType +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData + + +class TestComponentWithoutRun(GraphComponent): + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + return cls() + + +class TestComponentWithRun(TestComponentWithoutRun): + def run(self) -> TrainingData: + pass + + +class TestComponentWithRunAndParam(TestComponentWithoutRun): + def run(self, training_data: TrainingData) -> TrainingData: + pass + + +class TestNLUTarget(TestComponentWithoutRun): + def run(self) -> List[Message]: + pass + + +class TestCoreTarget(TestComponentWithoutRun): + def run(self) -> PolicyPrediction: + pass + + +class TestComponentWithClsTypeHints(GraphComponent): + @classmethod + def create( + cls: "TestComponentWithClsTypeHints", + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + return cls() + + +DEFAULT_PREDICT_SCHEMA = GraphSchema( + { + "nlu_target": SchemaNode( + needs={}, + uses=TestNLUTarget, + eager=True, + constructor_name="load", + fn="run", + config={}, + ) + } +) + + +def create_test_schema( + uses: Type, # The unspecified type is on purpose to enable testing of invalid cases + constructor_name: Text = "create", + run_fn: Text = "run", + needs: Optional[Dict[Text, Text]] = None, + eager: bool = True, + parent: Optional[Type[GraphComponent]] = None, + language: Optional[Text] = None, + is_train_graph: bool = True, +) -> GraphModelConfiguration: + + parent_node = {} + if parent: + parent_node = { + "parent": SchemaNode( + needs={}, uses=parent, constructor_name="create", fn="run", config={} + ) + } + + train_schema = GraphSchema({}) + predict_schema = DEFAULT_PREDICT_SCHEMA + # noinspection PyTypeChecker + schema = GraphSchema( + { + "my_node": SchemaNode( + needs=needs or {}, + uses=uses, + eager=eager, + constructor_name=constructor_name, + fn=run_fn, + config={}, + ), + **DEFAULT_PREDICT_SCHEMA.nodes, + **parent_node, + } + ) + + if is_train_graph: + train_schema = schema + else: + predict_schema = schema + + return GraphModelConfiguration( + train_schema=train_schema, + predict_schema=predict_schema, + training_type=TrainingType.BOTH, + assistant_id="test_assistant", + core_target=None, + nlu_target="nlu_target", + language=language, + ) + + +def test_graph_component_is_no_graph_component(): + class MyComponent: + def other(self) -> TrainingData: + pass + + graph_config = create_test_schema(uses=MyComponent) + + with pytest.raises(GraphSchemaValidationException, match="implement .+ interface"): + validation.validate(graph_config) + + +def test_graph_component_fn_does_not_exist(): + graph_config = create_test_schema(uses=TestComponentWithRun, run_fn="some_fn") + + with pytest.raises( + GraphSchemaValidationException, match="required method 'some_fn'" + ): + validation.validate(graph_config) + + +def test_graph_output_is_not_fingerprintable_int(): + class MyComponent(TestComponentWithoutRun): + def run(self) -> int: + pass + + graph_config = create_test_schema(uses=MyComponent) + + with pytest.raises(GraphSchemaValidationException, match="fingerprintable"): + validation.validate(graph_config) + + +def test_predict_graph_output_is_not_fingerprintable(): + class MyComponent(TestComponentWithoutRun): + def run(self) -> int: + pass + + graph_config = create_test_schema(uses=MyComponent, is_train_graph=False) + + validation.validate(graph_config) + + +def test_graph_output_is_not_fingerprintable_any(): + class MyComponent(TestComponentWithoutRun): + def run(self) -> Any: + pass + + graph_config = create_test_schema(uses=MyComponent) + + with pytest.raises(GraphSchemaValidationException, match="fingerprintable"): + validation.validate(graph_config) + + +def test_graph_output_is_not_fingerprintable_None(): + class MyComponent(TestComponentWithoutRun): + def run(self) -> None: + pass + + graph_config = create_test_schema(uses=MyComponent) + + with pytest.raises(GraphSchemaValidationException, match="fingerprintable"): + validation.validate(graph_config) + + +def test_graph_with_forward_referenced_output_type(): + class MyComponent(TestComponentWithoutRun): + # The non imported type annotation is on purpose so we can provoke a error in + # the test + def run(self) -> "UserUttered": # noqa: F821 + pass + + graph_config = create_test_schema(uses=MyComponent) + + with pytest.raises(GraphSchemaValidationException, match="forward reference"): + validation.validate(graph_config) + + +def test_graph_output_missing_type_annotation(): + class MyComponent(TestComponentWithoutRun): + def run(self): + pass + + graph_config = create_test_schema(uses=MyComponent) + + with pytest.raises( + GraphSchemaValidationException, match="does not have a type annotation" + ): + validation.validate(graph_config) + + +def test_graph_with_fingerprintable_output(): + class MyComponent(TestComponentWithoutRun): + def run(self) -> TrainingData: + pass + + graph_config = create_test_schema(uses=MyComponent) + + validation.validate(graph_config) + + +class MyTrainingData(TrainingData): + pass + + +def test_graph_with_fingerprintable_output_subclass(): + class MyComponent(TestComponentWithoutRun): + def run(self) -> MyTrainingData: + pass + + graph_config = create_test_schema(uses=MyComponent) + + validation.validate(graph_config) + + +def test_graph_constructor_missing(): + class MyComponent(TestComponentWithoutRun): + def run(self) -> TrainingData: + pass + + graph_config = create_test_schema(uses=MyComponent, constructor_name="invalid") + + with pytest.raises( + GraphSchemaValidationException, match="required method 'invalid'" + ): + validation.validate(graph_config) + + +def test_graph_constructor_config_wrong_type(): + class MyComponent(TestComponentWithRun): + @classmethod + def create( + cls, + config: Dict[int, int], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + pass + + graph_config = create_test_schema(uses=MyComponent) + + with pytest.raises(GraphSchemaValidationException, match="incompatible type"): + validation.validate(graph_config) + + +def test_graph_constructor_resource_wrong_type(): + class MyComponent(TestComponentWithRun): + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Dict, + execution_context: ExecutionContext, + ) -> GraphComponent: + pass + + graph_config = create_test_schema(uses=MyComponent) + + with pytest.raises(GraphSchemaValidationException, match="incompatible type"): + validation.validate(graph_config) + + +def test_graph_constructor_model_storage_wrong_type(): + class MyComponent(TestComponentWithRun): + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: Any, + resource: Resource, + execution_context: ExecutionContext, + ) -> GraphComponent: + pass + + graph_config = create_test_schema(uses=MyComponent) + + with pytest.raises(GraphSchemaValidationException, match="incompatible type"): + validation.validate(graph_config) + + +def test_graph_constructor_execution_context_wrong_type(): + class MyComponent(TestComponentWithRun): + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: Any, + ) -> GraphComponent: + pass + + graph_config = create_test_schema(uses=MyComponent) + + with pytest.raises(GraphSchemaValidationException, match="incompatible type"): + validation.validate(graph_config) + + +@pytest.mark.parametrize( + "current_language, supported_languages", + [("de", ["en"]), ("en", ["zh", "fi"]), ("us", [])], +) +def test_graph_constructor_execution_not_supported_language( + current_language: Text, supported_languages: Optional[List[Text]] +): + class MyComponent(TestComponentWithRun): + @staticmethod + def supported_languages() -> Optional[List[Text]]: + return supported_languages + + graph_config = create_test_schema(uses=MyComponent, language=current_language) + + with pytest.raises( + GraphSchemaValidationException, match="does not support .* language" + ): + validation.validate(graph_config) + + +@pytest.mark.parametrize( + "current_language, supported_languages", + [(None, None), ("en", ["zh", "en"]), ("zh", None), (None, ["en"])], +) +def test_graph_constructor_execution_supported_language( + current_language: Optional[Text], supported_languages: Optional[List[Text]] +): + class MyComponent(TestComponentWithRun): + @staticmethod + def supported_languages() -> Optional[List[Text]]: + return supported_languages + + graph_config = create_test_schema(uses=MyComponent, language=current_language) + + validation.validate(graph_config) + + +@pytest.mark.parametrize( + "current_language, not_supported_languages", [("de", ["de", "en"]), ("en", ["en"])] +) +def test_graph_constructor_execution_exclusive_list_not_supported_language( + current_language: Text, not_supported_languages: Optional[List[Text]] +): + class MyComponent(TestComponentWithRun): + @staticmethod + def not_supported_languages() -> Optional[List[Text]]: + return not_supported_languages + + graph_config = create_test_schema( + uses=MyComponent, language=current_language, is_train_graph=False + ) + + with pytest.raises( + GraphSchemaValidationException, match="does not support .* language" + ): + validation.validate(graph_config) + + +@pytest.mark.parametrize( + "current_language, not_supported_languages", + [(None, None), ("en", ["zh"]), ("zh", None), (None, ["de"])], +) +def test_graph_constructor_execution_exclusive_list_supported_language( + current_language: Optional[Text], not_supported_languages: Optional[List[Text]] +): + class MyComponent(TestComponentWithRun): + @staticmethod + def not_supported_languages() -> Optional[List[Text]]: + return not_supported_languages + + graph_config = create_test_schema( + uses=MyComponent, language=current_language, is_train_graph=False + ) + + validation.validate(graph_config) + + +@pytest.mark.parametrize( + "required_packages", [["pytorch"], ["tensorflow", "kubernetes"]] +) +def test_graph_missing_package_requirements(required_packages: List[Text]): + class MyComponent(TestComponentWithRun): + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return required_packages + + graph_config = create_test_schema(uses=MyComponent) + + with pytest.raises(GraphSchemaValidationException, match="not installed"): + validation.validate(graph_config) + + +@pytest.mark.parametrize("required_packages", [["tensorflow"], ["tensorflow", "numpy"]]) +def test_graph_satisfied_package_requirements(required_packages: List[Text]): + class MyComponent(TestComponentWithRun): + @staticmethod + def required_packages() -> List[Text]: + """Any extra python dependencies required for this component to run.""" + return required_packages + + graph_config = create_test_schema(uses=MyComponent) + + validation.validate(graph_config) + + +def test_run_param_not_satisfied(): + class MyComponent(TestComponentWithoutRun): + def run(self, some_param: TrainingData) -> TrainingData: + pass + + graph_config = create_test_schema(uses=MyComponent) + + with pytest.raises(GraphSchemaValidationException, match="needs the param"): + validation.validate(graph_config) + + +def test_run_param_satifisfied_due_to_default(): + class MyComponent(TestComponentWithoutRun): + def run(self, some_param: TrainingData = TrainingData()) -> TrainingData: + pass + + graph_config = create_test_schema(uses=MyComponent) + + validation.validate(graph_config) + + +def test_too_many_supplied_params(): + graph_config = create_test_schema( + uses=TestComponentWithRun, needs={"some_param": "parent"} + ) + + with pytest.raises( + GraphSchemaValidationException, match="does not accept a parameter" + ): + validation.validate(graph_config) + + +def test_too_many_supplied_params_but_kwargs(): + class MyComponent(TestComponentWithoutRun): + def run(self, **kwargs: Any) -> TrainingData: + pass + + graph_config = create_test_schema( + uses=MyComponent, needs={"some_param": "parent"}, parent=TestComponentWithRun + ) + + validation.validate(graph_config) + + +def test_run_fn_with_variable_length_positional_param(): + class MyComponent(TestComponentWithoutRun): + def run(self, *args: Any, some_param: TrainingData) -> TrainingData: + pass + + graph_config = create_test_schema( + uses=MyComponent, needs={"some_param": "parent"}, parent=TestComponentWithRun + ) + + validation.validate(graph_config) + + +def test_matching_params_due_to_constructor(): + class MyComponent(TestComponentWithRun): + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + some_param: TrainingData, + ) -> GraphComponent: + pass + + graph_config = create_test_schema( + uses=MyComponent, + needs={"some_param": "parent"}, + eager=False, + constructor_name="load", + parent=TestComponentWithRun, + ) + + validation.validate(graph_config) + + +def test_matching_params_due_to_constructor_but_eager(): + class MyComponent(TestComponentWithRun): + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + some_param: TrainingData, + ) -> GraphComponent: + pass + + graph_config = create_test_schema( + uses=MyComponent, + needs={"some_param": "parent"}, + eager=True, + constructor_name="load", + ) + + with pytest.raises( + GraphSchemaValidationException, match="which is used during training" + ): + validation.validate(graph_config) + + +@pytest.mark.parametrize( + "eager, error_message", [(True, "during training"), (False, "needs the param")] +) +def test_unsatisfied_constructor(eager: bool, error_message: Text): + class MyComponent(TestComponentWithRun): + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + some_param: TrainingData, + ) -> GraphComponent: + pass + + graph_config = create_test_schema( + uses=MyComponent, eager=eager, constructor_name="load" + ) + + with pytest.raises(GraphSchemaValidationException, match=error_message): + validation.validate(graph_config) + + +def test_parent_is_missing(): + graph_config = create_test_schema( + uses=TestComponentWithRunAndParam, + needs={"training_data": "not existing parent"}, + ) + + with pytest.raises( + GraphSchemaValidationException, match="The component is missing from" + ): + validation.validate(graph_config) + + +def test_parent_supplying_wrong_type(): + class MyUnreliableParent(TestComponentWithoutRun): + def run(self) -> Domain: + pass + + graph_config = create_test_schema( + uses=TestComponentWithRunAndParam, + parent=MyUnreliableParent, + needs={"training_data": "parent"}, + ) + + with pytest.raises( + GraphSchemaValidationException, match="expects an input of type" + ): + validation.validate(graph_config) + + +def test_parent_supplying_wrong_type_to_constructor(): + class MyUnreliableParent(TestComponentWithoutRun): + def run(self) -> Domain: + pass + + class MyComponent(TestComponentWithRun): + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + some_param: TrainingData, + ) -> GraphComponent: + pass + + graph_config = create_test_schema( + uses=MyComponent, + eager=False, + constructor_name="load", + parent=MyUnreliableParent, + needs={"some_param": "parent"}, + ) + + with pytest.raises( + GraphSchemaValidationException, match="expects an input of type" + ): + validation.validate(graph_config) + + +def test_parent_supplying_subtype(): + class Parent(TestComponentWithoutRun): + def run(self) -> MyTrainingData: + pass + + class MyComponent(TestComponentWithoutRun): + def run(self, training_data: TrainingData) -> TrainingData: + pass + + graph_config = create_test_schema( + uses=MyComponent, parent=Parent, needs={"training_data": "parent"} + ) + + validation.validate(graph_config) + + +def test_child_accepting_any_type_from_parent(): + class Parent(TestComponentWithoutRun): + def run(self) -> MyTrainingData: + pass + + class MyComponent(TestComponentWithoutRun): + def run(self, training_data: Any) -> TrainingData: + pass + + graph_config = create_test_schema( + uses=MyComponent, parent=Parent, needs={"training_data": "parent"} + ) + + validation.validate(graph_config) + + +@pytest.mark.parametrize("is_train_graph", [True, False]) +def test_cycle(is_train_graph: bool): + class MyTestComponent(TestComponentWithoutRun): + def run(self, training_data: TrainingData) -> TrainingData: + pass + + train_schema = GraphSchema({}) + predict_schema = DEFAULT_PREDICT_SCHEMA + + schema = GraphSchema( + { + "A": SchemaNode( + needs={"training_data": "B"}, + uses=MyTestComponent, + eager=True, + constructor_name="create", + fn="run", + is_target=True, + config={}, + ), + "B": SchemaNode( + needs={"training_data": "C"}, + uses=MyTestComponent, + eager=True, + constructor_name="create", + fn="run", + config={}, + ), + "C": SchemaNode( + needs={"training_data": "A"}, + uses=MyTestComponent, + eager=True, + constructor_name="create", + fn="run", + config={}, + ), + } + ) + + if is_train_graph: + train_schema = schema + else: + predict_schema = schema + + with pytest.raises(GraphSchemaValidationException, match="Cycles"): + validation.validate( + GraphModelConfiguration( + train_schema=train_schema, + predict_schema=predict_schema, + training_type=TrainingType.BOTH, + assistant_id="test_assistant", + language=None, + core_target=None, + nlu_target="nlu_target", + ) + ) + + +def test_validation_with_placeholders(): + class MyTestComponent(TestComponentWithoutRun): + def run(self, training_data: TrainingDataImporter) -> TrainingDataImporter: + pass + + graph_config = GraphSchema( + { + "A": SchemaNode( + needs={"training_data": "B"}, + uses=MyTestComponent, + eager=True, + constructor_name="create", + fn="run", + is_target=True, + config={}, + ), + "B": SchemaNode( + needs={"training_data": PLACEHOLDER_IMPORTER}, + uses=MyTestComponent, + eager=True, + constructor_name="create", + fn="run", + config={}, + ), + } + ) + + # Does not raise + validation.validate( + GraphModelConfiguration( + train_schema=graph_config, + predict_schema=DEFAULT_PREDICT_SCHEMA, + training_type=TrainingType.BOTH, + assistant_id="test_assistant", + language=None, + core_target=None, + nlu_target="nlu_target", + ) + ) + + +def test_validation_with_missing_nlu_target(): + graph_config = GraphSchema( + { + "A": SchemaNode( + needs={}, + uses=TestNLUTarget, + eager=True, + constructor_name="create", + fn="run", + config={}, + ) + } + ) + + with pytest.raises( + GraphSchemaValidationException, match="no target for the 'nlu_target'" + ): + validation.validate( + GraphModelConfiguration( + train_schema=GraphSchema({}), + predict_schema=graph_config, + training_type=TrainingType.BOTH, + assistant_id="test_assistant", + language=None, + core_target=None, + nlu_target=None, + ) + ) + + +def test_validation_with_nlu_target_used_by_other_node(): + class NLUTargetConsumer(TestComponentWithoutRun): + def run(self, nlu_target_output: List[Message]) -> List[Message]: + pass + + graph_config = GraphSchema( + { + "A": SchemaNode( + needs={}, + uses=TestNLUTarget, + eager=True, + constructor_name="create", + fn="run", + config={}, + ), + "B": SchemaNode( + needs={"nlu_target_output": "A"}, + uses=NLUTargetConsumer, + eager=True, + constructor_name="create", + fn="run", + config={}, + ), + } + ) + + with pytest.raises( + GraphSchemaValidationException, match="uses the NLU target 'A' as input" + ): + validation.validate( + GraphModelConfiguration( + train_schema=GraphSchema({}), + predict_schema=graph_config, + training_type=TrainingType.BOTH, + assistant_id="test_assistant", + language=None, + core_target=None, + nlu_target="A", + ) + ) + + +def test_validation_with_nlu_target_wrong_type(): + graph_config = GraphSchema( + { + "A": SchemaNode( + needs={}, + uses=TestCoreTarget, + eager=True, + constructor_name="create", + fn="run", + config={}, + ) + } + ) + + with pytest.raises(GraphSchemaValidationException, match="invalid return type"): + validation.validate( + GraphModelConfiguration( + train_schema=GraphSchema({}), + predict_schema=graph_config, + training_type=TrainingType.BOTH, + assistant_id="test_assistant", + language=None, + core_target=None, + nlu_target="A", + ) + ) + + +def test_validation_with_missing_core_target(): + graph_config = GraphSchema( + { + "A": SchemaNode( + needs={}, + uses=TestNLUTarget, + eager=True, + constructor_name="create", + fn="run", + config={}, + ) + } + ) + + with pytest.raises(GraphSchemaValidationException, match="invalid Core target"): + validation.validate( + GraphModelConfiguration( + train_schema=GraphSchema({}), + predict_schema=graph_config, + training_type=TrainingType.BOTH, + assistant_id="test_assistant", + language=None, + core_target="B", + nlu_target="A", + ) + ) + + +def test_validation_with_core_target_wrong_type(): + graph_config = GraphSchema( + { + "A": SchemaNode( + needs={}, + uses=TestNLUTarget, + eager=True, + constructor_name="create", + fn="run", + config={}, + ) + } + ) + + with pytest.raises( + GraphSchemaValidationException, match="Core model's .* invalid return type" + ): + validation.validate( + GraphModelConfiguration( + train_schema=GraphSchema({}), + predict_schema=graph_config, + training_type=TrainingType.BOTH, + assistant_id="test_assistant", + language=None, + core_target="A", + nlu_target="A", + ) + ) + + +def test_validation_with_core_target_used_by_other_node(): + class CoreTargetConsumer(TestComponentWithoutRun): + def run(self, core_target_output: PolicyPrediction) -> PolicyPrediction: + pass + + graph_config = GraphSchema( + { + "A": SchemaNode( + needs={}, + uses=TestNLUTarget, + eager=True, + constructor_name="create", + fn="run", + config={}, + ), + "B": SchemaNode( + needs={}, + uses=TestCoreTarget, + eager=True, + constructor_name="create", + fn="run", + config={}, + ), + "C": SchemaNode( + needs={"core_target_output": "B"}, + uses=CoreTargetConsumer, + eager=True, + constructor_name="create", + fn="run", + config={}, + ), + } + ) + + with pytest.raises( + GraphSchemaValidationException, match="uses the Core target 'B' as input" + ): + validation.validate( + GraphModelConfiguration( + train_schema=GraphSchema({}), + predict_schema=graph_config, + training_type=TrainingType.BOTH, + assistant_id="test_assistant", + language=None, + core_target="B", + nlu_target="A", + ) + ) + + +def _create_run_function(num_args) -> Callable[..., TrainingData]: + # Note: setting __annotations__ is not sufficient for the validation and + # creating a function via types.FunctionType is cumbersome, so we just + # explicitly create the function we need: + if num_args == 0: + + def run() -> TrainingData: + return TrainingData() + + elif num_args == 1: + + def run(param0: TrainingData) -> TrainingData: + return TrainingData() + + elif num_args == 2: + + def run(param0: TrainingData, param1: TrainingData) -> TrainingData: + return TrainingData() + + elif num_args == 3: + + def run( + param0: TrainingData, param1: TrainingData, param2: TrainingData + ) -> TrainingData: + return TrainingData() + + else: + assert False, f"This test doesn't work with num_args={num_args} ." + return run + + +def _create_component_type_and_subtype_with_run_function( + component_type_name: Text, needs: List[int] +) -> Tuple[Type[GraphComponent], Type[GraphComponent]]: + main_type = type( + component_type_name, + (TestComponentWithoutRun,), + { + "run": _create_run_function(num_args=len(needs)), + "create": lambda *args, **kwargs: None, + "__init__": lambda: None, + }, + ) + sub_type = type(f"subclass_of_{component_type_name}", (main_type,), {}) + return main_type, sub_type + + +def _create_graph_schema_from_requirements( + node_needs_requires: List[Tuple[int, List[int], List[int]]], + targets: List[int], + use_subclass: bool, +) -> GraphSchema: + # create some component types + component_types = { + node: _create_component_type_and_subtype_with_run_function( + component_type_name=f"class_{node}", needs=needs + ) + for node, needs, _ in node_needs_requires + } + + # add required components + for node, _, required_components in node_needs_requires: + for component_type in component_types[node]: + component_type.required_components = Mock( + return_value=[ + component_types[required][0] for required in required_components + ] + ) + + # create graph schema + graph_schema = GraphSchema( + { + f"node-{node}": SchemaNode( + needs={ + f"param{param}": f"node-{needed_node}" + for param, needed_node in enumerate(needs) + }, + uses=component_types[node][use_subclass], # use subclass if required + fn="run", + constructor_name="create", + config={}, + is_target=node in targets, + ) + for node, needs, _ in node_needs_requires + } + ) + graph_schema.nodes.update(DEFAULT_PREDICT_SCHEMA.nodes) + return graph_schema + + +RequiredComponentsTestCase = namedtuple( + "RequiredComponentsTestCase", + { + "node_needs_requires_tuples": List[Tuple[int, List[int], List[int]]], + "targets": List[int], + "num_unmet_requirements": int, + }, +) +REQUIRED_COMPONENT_TEST_CASES: List[RequiredComponentsTestCase] = [ + RequiredComponentsTestCase( + node_needs_requires_tuples=[(1, [2], [2]), (2, [], [])], + targets=[1], + num_unmet_requirements=0, + ), + RequiredComponentsTestCase( + node_needs_requires_tuples=[ + (1, [2, 3, 4], [2, 3, 4]), + (2, [], []), + (3, [], []), + (4, [], []), + ], + targets=[1], + num_unmet_requirements=0, + ), + RequiredComponentsTestCase( + node_needs_requires_tuples=[ + (1, [3], [4]), + (2, [3], [4]), + (3, [4, 5], []), + (4, [], []), + (5, [6], []), + (6, [], []), + ], + targets=[1, 3], + num_unmet_requirements=0, + ), + RequiredComponentsTestCase( + node_needs_requires_tuples=[(1, [], [2]), (2, [], [])], + targets=[1], + num_unmet_requirements=1, + ), # 2 is not reachable from 1 + RequiredComponentsTestCase( + node_needs_requires_tuples=[ + (1, [3], [4]), + (2, [3], [4]), + (3, [4], [5]), + (4, [], []), + (5, [], []), + ], + targets=[1], + num_unmet_requirements=1, # 5 is not reachable from 3 + ), + RequiredComponentsTestCase( + node_needs_requires_tuples=[ + (1, [2], [3]), + (2, [], [4]), + (3, [], []), + (4, [], []), + ], + targets=[1], + num_unmet_requirements=2, + ), # 3 and 4 are not reachable from 1 and 2 +] + + +@pytest.mark.parametrize( + "test_case, is_train_graph, test_subclass", + itertools.product(REQUIRED_COMPONENT_TEST_CASES, [True, False], [True, False]), +) +def test_validate_validates_required_components( + test_case: List[RequiredComponentsTestCase], + is_train_graph: bool, + test_subclass: bool, +): + train_schema = GraphSchema({}) + predict_schema = DEFAULT_PREDICT_SCHEMA + graph_schema = _create_graph_schema_from_requirements( + node_needs_requires=test_case.node_needs_requires_tuples, + targets=test_case.targets, + use_subclass=test_subclass, + ) + + if is_train_graph: + train_schema = graph_schema + else: + predict_schema = graph_schema + graph_config = GraphModelConfiguration( + train_schema, + predict_schema, + TrainingType.BOTH, + "test_assistant", + None, + None, + "nlu_target", + ) + + num_unmet = test_case.num_unmet_requirements + if num_unmet == 0: + validation.validate(graph_config) + else: + message = f"{num_unmet} components are missing" + with pytest.raises(GraphSchemaValidationException, match=message): + validation.validate(graph_config) + + +@pytest.mark.parametrize( + "test_case, test_subclass", + itertools.product(REQUIRED_COMPONENT_TEST_CASES, [True, False]), +) +def test_validate_required_components( + test_case: List[RequiredComponentsTestCase], test_subclass: bool +): + graph_schema = _create_graph_schema_from_requirements( + node_needs_requires=test_case.node_needs_requires_tuples, + targets=test_case.targets, + use_subclass=test_subclass, + ) + num_unmet = test_case.num_unmet_requirements + if num_unmet == 0: + validation._validate_required_components(schema=graph_schema) + else: + message = f"{num_unmet} components are missing" + with pytest.raises(GraphSchemaValidationException, match=message): + validation._validate_required_components(schema=graph_schema) + + +@pytest.mark.parametrize( + "test_case, test_subclass", + itertools.product( + [ + test_case + for test_case in REQUIRED_COMPONENT_TEST_CASES + if len(test_case.targets) == 1 + ], + [True, False], + ), +) +def test_recursively_validate_required_components( + test_case: List[RequiredComponentsTestCase], test_subclass: bool +): + graph_schema = _create_graph_schema_from_requirements( + node_needs_requires=test_case.node_needs_requires_tuples, + targets=test_case.targets, + use_subclass=test_subclass, + ) + num_unmet = test_case.num_unmet_requirements + + unmet_requirements, _ = validation._recursively_check_required_components( + node_name=f"node-{test_case.targets[0]}", schema=graph_schema + ) + assert len(unmet_requirements) == num_unmet + + +def test_graph_with_cls_type_hint(): + class MyComponent(TestComponentWithClsTypeHints): + def run(self) -> MyTrainingData: + pass + + graph_config = create_test_schema(uses=MyComponent) + + validation.validate(graph_config) diff --git a/tests/engine/training/__init__.py b/tests/engine/training/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/engine/training/test_components.py b/tests/engine/training/test_components.py new file mode 100644 index 0000000..51f160a --- /dev/null +++ b/tests/engine/training/test_components.py @@ -0,0 +1,194 @@ +import dataclasses +from typing import Text +import uuid + + +from rasa.engine.caching import TrainingCache +from rasa.engine.graph import ExecutionContext, GraphNode, GraphSchema, SchemaNode +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.training import fingerprinting +from rasa.engine.training.components import ( + PrecomputedValueProvider, + FingerprintComponent, + FingerprintStatus, +) +from tests.engine.graph_components_test_classes import CacheableText + + +def test_cached_component_returns_value_from_cache(default_model_storage: ModelStorage): + + cached_output = CacheableText("Cache me!!") + + node = GraphNode( + node_name="cached", + component_class=PrecomputedValueProvider, + constructor_name="create", + component_config={"output": cached_output}, + fn_name="get_value", + inputs={}, + eager=False, + model_storage=default_model_storage, + resource=None, + execution_context=ExecutionContext(GraphSchema({}), "1"), + ) + + node_name, returned_output = node() + + assert node_name == "cached" + assert returned_output.text == "Cache me!!" + + +def test_cached_component_replace_schema_node(): + schema_node = SchemaNode( + needs={"i1": "first_input", "i2": "second_input"}, + uses=FingerprintComponent, + fn="add", + constructor_name="load", + config={"a": 1}, + eager=False, + is_input=False, + resource=Resource("hello"), + ) + + PrecomputedValueProvider.replace_schema_node(schema_node, 2) + + assert schema_node == SchemaNode( + needs={"i1": "first_input", "i2": "second_input"}, + uses=PrecomputedValueProvider, + fn="get_value", + constructor_name="create", + config={"output": 2}, + eager=False, + is_input=False, + resource=Resource("hello"), + ) + + +def test_fingerprint_component_replace_schema_node(temp_cache: TrainingCache): + schema_node = SchemaNode( + needs={"i1": "first_input", "i2": "second_input"}, + uses=PrecomputedValueProvider, + fn="add", + constructor_name="load", + config={"a": 1}, + eager=False, + is_input=False, + resource=Resource("hello"), + ) + + FingerprintComponent.replace_schema_node(schema_node, temp_cache) + + assert schema_node == SchemaNode( + needs={"i1": "first_input", "i2": "second_input"}, + uses=FingerprintComponent, + fn="run", + constructor_name="create", + config={ + "config_of_replaced_component": {"a": 1}, + "cache": temp_cache, + "graph_component_class": PrecomputedValueProvider, + }, + eager=True, + is_input=False, + resource=Resource("hello"), + ) + + +@dataclasses.dataclass +class FingerprintableText: + text: Text + + def fingerprint(self) -> Text: + return self.text + + +def test_fingerprint_component_hit( + default_model_storage: ModelStorage, temp_cache: TrainingCache +): + + cached_output = CacheableText("Cache me!!") + output_fingerprint = uuid.uuid4().hex + + # We generate a fingerprint key that will match the one generated by the + # `FingerprintComponent`. + component_config = {"x": 1} + fingerprint_key = fingerprinting.calculate_fingerprint_key( + graph_component_class=PrecomputedValueProvider, + config=component_config, + inputs={ + "param_1": FingerprintableText("input_1"), + "param_2": FingerprintableText("input_2"), + }, + ) + # We cache the output using this fingerprint key. + temp_cache.cache_output( + fingerprint_key=fingerprint_key, + output=cached_output, + output_fingerprint=output_fingerprint, + model_storage=default_model_storage, + ) + + # The node inputs and config match what we used to generate the fingerprint key. + node = GraphNode( + node_name="fingerprint_node", + component_class=FingerprintComponent, + constructor_name="create", + component_config={ + "config_of_replaced_component": component_config, + "cache": temp_cache, + "graph_component_class": PrecomputedValueProvider, + }, + fn_name="run", + inputs={"param_1": "parent_node_1", "param_2": "parent_node_2"}, + eager=False, + model_storage=default_model_storage, + resource=None, + execution_context=ExecutionContext(GraphSchema({}), "1"), + ) + + node_name, returned_output = node( + ("parent_node_1", FingerprintableText("input_1")), + ("parent_node_2", FingerprintStatus(is_hit=True, output_fingerprint="input_2")), + ) + + assert node_name == "fingerprint_node" + assert returned_output.is_hit is True + assert returned_output.output_fingerprint == output_fingerprint + assert returned_output.output_fingerprint == returned_output.fingerprint() + + +def test_fingerprint_component_miss( + default_model_storage: ModelStorage, temp_cache: TrainingCache +): + + component_config = {"x": 1} + + node = GraphNode( + node_name="fingerprint_node", + component_class=FingerprintComponent, + constructor_name="create", + component_config={ + "config_of_replaced_component": component_config, + "cache": temp_cache, + "graph_component_class": PrecomputedValueProvider, + }, + fn_name="run", + inputs={"param_1": "parent_node_1", "param_2": "parent_node_2"}, + eager=False, + model_storage=default_model_storage, + resource=None, + execution_context=ExecutionContext(GraphSchema({}), "1"), + ) + + node_name, returned_output = node( + ("parent_node_1", FingerprintableText("input_1")), + ("parent_node_2", FingerprintStatus(is_hit=True, output_fingerprint="input_2")), + ) + + # As we didnt add anything to the cache, it cannot be a hit. + assert node_name == "fingerprint_node" + assert returned_output.is_hit is False + assert returned_output.output_fingerprint is None + assert returned_output.fingerprint() != returned_output.output_fingerprint + assert returned_output.fingerprint() != returned_output.fingerprint() diff --git a/tests/engine/training/test_fingerprinting.py b/tests/engine/training/test_fingerprinting.py new file mode 100644 index 0000000..845f92a --- /dev/null +++ b/tests/engine/training/test_fingerprinting.py @@ -0,0 +1,126 @@ +import inspect +import os.path +import tempfile +from typing import Dict, Text, Any, Optional +from unittest.mock import Mock +from _pytest.monkeypatch import MonkeyPatch + +import rasa.shared.utils.io +from rasa.core.policies.ted_policy import TEDPolicy +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.training import fingerprinting +from rasa.nlu.classifiers.diet_classifier import DIETClassifier +from rasa.nlu.selectors.response_selector import ResponseSelector +from tests.engine.training.test_components import FingerprintableText + + +def test_fingerprint_stays_same(): + key1 = fingerprinting.calculate_fingerprint_key( + TEDPolicy, TEDPolicy.get_default_config(), {"input": FingerprintableText("Hi")} + ) + key2 = fingerprinting.calculate_fingerprint_key( + TEDPolicy, TEDPolicy.get_default_config(), {"input": FingerprintableText("Hi")} + ) + + assert key1 == key2 + + +def test_fingerprint_changes_due_to_class(): + key1 = fingerprinting.calculate_fingerprint_key( + DIETClassifier, + TEDPolicy.get_default_config(), + {"input": FingerprintableText("Hi")}, + ) + key2 = fingerprinting.calculate_fingerprint_key( + ResponseSelector, + TEDPolicy.get_default_config(), + {"input": FingerprintableText("Hi")}, + ) + + assert key1 != key2 + + +def test_fingerprint_changes_due_to_config(): + key1 = fingerprinting.calculate_fingerprint_key( + TEDPolicy, {}, {"input": FingerprintableText("Hi")} + ) + key2 = fingerprinting.calculate_fingerprint_key( + ResponseSelector, + TEDPolicy.get_default_config(), + {"input": FingerprintableText("Hi")}, + ) + + assert key1 != key2 + + +def test_fingerprint_changes_due_to_inputs(): + key1 = fingerprinting.calculate_fingerprint_key( + TEDPolicy, {}, {"input": FingerprintableText("Hi")} + ) + key2 = fingerprinting.calculate_fingerprint_key( + ResponseSelector, + TEDPolicy.get_default_config(), + {"input": FingerprintableText("bye")}, + ) + + assert key1 != key2 + + +def test_fingerprint_changes_due_to_changed_source(monkeypatch: MonkeyPatch): + key1 = fingerprinting.calculate_fingerprint_key( + TEDPolicy, {}, {"input": FingerprintableText("Hi")} + ) + + get_source_mock = Mock(return_value="other implementation") + monkeypatch.setattr(inspect, inspect.getsource.__name__, get_source_mock) + + key2 = fingerprinting.calculate_fingerprint_key( + TEDPolicy, {}, {"input": FingerprintableText("Hi")} + ) + + assert key1 != key2 + + get_source_mock.assert_called_once_with(TEDPolicy) + + +def test_fingerprint_changes_when_external_file_changes(): + tmp_file = tempfile.mktemp() + + class MinimalComponent(GraphComponent): + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> "MinimalComponent": + return MinimalComponent() + + @classmethod + def fingerprint_addon(cls, config: Dict[str, Any]) -> Optional[str]: + if not os.path.exists(tmp_file): + return None + else: + return rasa.shared.utils.io.get_text_hash(open(tmp_file, "r").read()) + + with open(tmp_file, "w") as external_data: + external_data.write("This is a test.") + + fingerprint_1 = fingerprinting.calculate_fingerprint_key(MinimalComponent, {}, {}) + + fingerprint_2 = fingerprinting.calculate_fingerprint_key(MinimalComponent, {}, {}) + + assert fingerprint_1 == fingerprint_2 + + # overwrite the original external data + with open(tmp_file, "w") as external_data: + external_data.write("This is a test for changes in external data.") + + fingerprint_3 = fingerprinting.calculate_fingerprint_key(MinimalComponent, {}, {}) + + assert fingerprint_3 != fingerprint_1 + + os.remove(tmp_file) diff --git a/tests/engine/training/test_graph_trainer.py b/tests/engine/training/test_graph_trainer.py new file mode 100644 index 0000000..310fa15 --- /dev/null +++ b/tests/engine/training/test_graph_trainer.py @@ -0,0 +1,647 @@ +import logging +from pathlib import Path +from typing import Callable, Dict, Optional, Text, Type, Any +from unittest.mock import Mock + +from _pytest.logging import LogCaptureFixture +from _pytest.monkeypatch import MonkeyPatch +from _pytest.tmpdir import TempPathFactory +import pytest + +from rasa.engine.caching import LocalTrainingCache, TrainingCache +from rasa.engine.exceptions import GraphComponentException +from rasa.engine.graph import ( + GraphComponent, + GraphSchema, + SchemaNode, + GraphModelConfiguration, + GraphNode, + ExecutionContext, + GraphNodeHook, +) +from rasa.engine.runner.dask import DaskGraphRunner +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.training.graph_trainer import GraphTrainer +from rasa.shared.core.domain import Domain +from rasa.shared.data import TrainingType +from rasa.shared.importers.importer import TrainingDataImporter +from tests.engine.graph_components_test_classes import ( + AddInputs, + AssertComponent, + FileReader, + PersistableTestComponent, + ProvideX, + SubtractByX, + CacheableComponent, +) + + +def test_graph_trainer_returns_model_metadata( + default_model_storage: ModelStorage, + temp_cache: TrainingCache, + tmp_path: Path, + domain_path: Path, +): + graph_trainer = GraphTrainer( + model_storage=default_model_storage, + cache=temp_cache, + graph_runner_class=DaskGraphRunner, + ) + + test_value = "test_value" + + train_schema = GraphSchema( + { + "train": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="train", + constructor_name="create", + config={"test_value": test_value}, + is_target=True, + ), + "load": SchemaNode( + needs={"resource": "train"}, + uses=PersistableTestComponent, + fn="run_inference", + constructor_name="load", + config={}, + ), + } + ) + predict_schema = GraphSchema( + { + "load": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="run_inference", + constructor_name="load", + config={}, + is_target=True, + resource=Resource("train"), + ) + } + ) + + output_filename = tmp_path / "model.tar.gz" + model_metadata = graph_trainer.train( + GraphModelConfiguration( + train_schema=train_schema, + predict_schema=predict_schema, + assistant_id="test_assistant_id", + language=None, + core_target=None, + nlu_target="nlu", + training_type=TrainingType.BOTH, + ), + importer=TrainingDataImporter.load_from_dict(domain_path=str(domain_path)), + output_filename=output_filename, + ) + assert model_metadata.model_id + assert model_metadata.assistant_id == "test_assistant_id" + assert model_metadata.domain.as_dict() == Domain.from_path(domain_path).as_dict() + assert model_metadata.train_schema == train_schema + assert model_metadata.predict_schema == predict_schema + + +def test_graph_trainer_fingerprints_and_caches( + temp_cache: TrainingCache, + tmp_path: Path, + train_with_schema: Callable, + spy_on_all_components: Callable, +): + + input_file = tmp_path / "input_file.txt" + input_file.write_text("3") + + train_schema = GraphSchema( + { + "read_file": SchemaNode( + needs={}, + uses=FileReader, + fn="read", + constructor_name="create", + config={"file_path": str(input_file)}, + is_input=True, + ), + "train": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="train", + constructor_name="create", + config={"test_value": "4"}, + is_target=True, + ), + "process": SchemaNode( + needs={"resource": "train"}, + uses=PersistableTestComponent, + fn="run_inference", + constructor_name="load", + config={"wrap_output_in_cacheable": True}, + ), + "add": SchemaNode( + needs={"i1": "read_file", "i2": "process"}, + uses=AddInputs, + fn="add", + constructor_name="create", + config={}, + ), + "assert_node": SchemaNode( + needs={"i": "add"}, + uses=AssertComponent, + fn="run_assert", + constructor_name="create", + config={"value_to_assert": 7}, + is_target=True, + ), + } + ) + + # The first train should call all the components and cache their outputs. + mocks = spy_on_all_components(train_schema) + train_with_schema(train_schema, temp_cache) + assert node_call_counts(mocks) == { + "read_file": 1, + "train": 1, + "process": 1, + "add": 1, + "assert_node": 1, + } + + # Nothing has changed so this time so no components will run + # (just input nodes during fingerprint run). + mocks = spy_on_all_components(train_schema) + train_with_schema(train_schema, temp_cache) + assert node_call_counts(mocks) == { + "read_file": 1, # Inputs nodes are always called during the fingerprint run. + "train": 0, + "process": 0, + "add": 0, + "assert_node": 0, + } + + # As we changed the config of "add", all its descendants will run. + train_schema.nodes["add"].config["something"] = "new" + mocks = spy_on_all_components(train_schema) + train_with_schema(train_schema, temp_cache) + assert node_call_counts(mocks) == { + "read_file": 1, # Inputs nodes are always called during the fingerprint run. + "train": 0, + "process": 0, + "add": 1, + "assert_node": 1, + } + + # We always run everything when the `force_retraining` flag is set to `True` + train_schema.nodes["add"].config["something"] = "new" + mocks = spy_on_all_components(train_schema) + train_with_schema(train_schema, temp_cache, force_retraining=True) + assert node_call_counts(mocks) == { + "read_file": 1, + "train": 1, + "process": 1, + "add": 1, + "assert_node": 1, + } + + +def test_graph_trainer_always_reads_input( + temp_cache: TrainingCache, + tmp_path: Path, + train_with_schema: Callable, + spy_on_all_components: Callable, +): + + input_file = tmp_path / "input_file.txt" + input_file.write_text("3") + + train_schema = GraphSchema( + { + "read_file": SchemaNode( + needs={}, + uses=FileReader, + fn="read", + constructor_name="create", + config={"file_path": str(input_file)}, + is_input=True, + ), + "subtract": SchemaNode( + needs={"i": "read_file"}, + uses=SubtractByX, + fn="subtract_x", + constructor_name="create", + config={"x": 1}, + ), + "assert_node": SchemaNode( + needs={"i": "subtract"}, + uses=AssertComponent, + fn="run_assert", + constructor_name="create", + config={"value_to_assert": 2}, + is_target=True, + ), + } + ) + + # The first train should call all the components and cache their outputs. + mocks = spy_on_all_components(train_schema) + train_with_schema(train_schema, temp_cache) + assert node_call_counts(mocks) == {"read_file": 1, "subtract": 1, "assert_node": 1} + + # Nothing has changed so this time so no components will run + # (just input nodes during fingerprint run). + mocks = spy_on_all_components(train_schema) + train_with_schema(train_schema, temp_cache) + assert node_call_counts(mocks) == {"read_file": 1, "subtract": 0, "assert_node": 0} + + # When we update the input file, all the nodes will run again and the assert_node + # will fail. + input_file.write_text("5") + with pytest.raises(GraphComponentException): + train_with_schema(train_schema, temp_cache) + + +def test_graph_trainer_with_non_cacheable_components( + temp_cache: TrainingCache, + tmp_path: Path, + train_with_schema: Callable, + spy_on_all_components: Callable, +): + + input_file = tmp_path / "input_file.txt" + input_file.write_text("3") + + train_schema = GraphSchema( + { + "input": SchemaNode( + needs={}, + uses=ProvideX, + fn="provide", + constructor_name="create", + config={}, + ), + "subtract": SchemaNode( + needs={"i": "input"}, + uses=SubtractByX, + fn="subtract_x", + constructor_name="create", + config={"x": 1}, + is_target=True, + ), + } + ) + + # The first train should call all the components. + mocks = spy_on_all_components(train_schema) + train_with_schema(train_schema, temp_cache) + assert node_call_counts(mocks) == {"input": 1, "subtract": 1} + + # Nothing has changed but none of the components can cache so all will have to + # run again. + mocks = spy_on_all_components(train_schema) + train_with_schema(train_schema, temp_cache) + assert node_call_counts(mocks) == {"input": 1, "subtract": 1} + + +def node_call_counts(mocks: Dict[Text, Mock]) -> Dict[Text, int]: + return {node_name: mocks[node_name].call_count for node_name, mock in mocks.items()} + + +@pytest.fixture +def train_with_schema( + default_model_storage: ModelStorage, + temp_cache: TrainingCache, + tmp_path: Path, + tmp_path_factory: TempPathFactory, + local_cache_creator: Callable, + domain_path: Path, +): + def inner( + train_schema: GraphSchema, + cache: Optional[TrainingCache] = None, + model_storage: Optional[ModelStorage] = None, + path: Optional[Path] = None, + force_retraining: bool = False, + ) -> Path: + if not path: + path = tmp_path_factory.mktemp("model_storage_path") + if not model_storage: + model_storage = LocalModelStorage.create(path) + if not cache: + cache = local_cache_creator(path) + + graph_trainer = GraphTrainer( + model_storage=model_storage, cache=cache, graph_runner_class=DaskGraphRunner + ) + + output_filename = path / "model.tar.gz" + graph_trainer.train( + GraphModelConfiguration( + train_schema=train_schema, + predict_schema=GraphSchema({}), + assistant_id="test_assistant", + language=None, + core_target=None, + nlu_target="nlu", + training_type=TrainingType.BOTH, + ), + importer=TrainingDataImporter.load_from_dict(domain_path=str(domain_path)), + output_filename=output_filename, + force_retraining=force_retraining, + ) + + assert output_filename.is_file() + return output_filename + + return inner + + +@pytest.fixture() +def spy_on_component(monkeypatch: MonkeyPatch) -> Callable: + def inner(component_class: Type[GraphComponent], fn_name: Text) -> Mock: + mock = Mock(wraps=getattr(component_class, fn_name)) + monkeypatch.setattr(component_class, fn_name, mock) + return mock + + return inner + + +@pytest.fixture() +def spy_on_all_components(spy_on_component) -> Callable: + def inner(schema: GraphSchema) -> Dict[Text, Mock]: + return { + node_name: spy_on_component(schema_node.uses, schema_node.fn) + for node_name, schema_node in schema.nodes.items() + } + + return inner + + +def test_graph_trainer_train_logging( + tmp_path: Path, + temp_cache: TrainingCache, + train_with_schema: Callable, + caplog: LogCaptureFixture, +): + + input_file = tmp_path / "input_file.txt" + input_file.write_text("3") + + train_schema = GraphSchema( + { + "input": SchemaNode( + needs={}, + uses=ProvideX, + fn="provide", + constructor_name="create", + config={}, + ), + "subtract 2": SchemaNode( + needs={}, + uses=ProvideX, + fn="provide", + constructor_name="create", + config={}, + is_target=True, + is_input=True, + ), + "subtract": SchemaNode( + needs={"i": "input"}, + uses=SubtractByX, + fn="subtract_x", + constructor_name="create", + config={"x": 1}, + is_target=True, + is_input=False, + ), + } + ) + + with caplog.at_level(logging.INFO, logger="rasa.engine.training.hooks"): + train_with_schema(train_schema, temp_cache) + + caplog_info_records = list( + filter(lambda x: x[1] == logging.INFO, caplog.record_tuples) + ) + + caplog_messages = list([record[2] for record in caplog_info_records]) + + assert caplog_messages == [ + "Starting to train component 'SubtractByX'.", + "Finished training component 'SubtractByX'.", + ] + + +def test_graph_trainer_train_logging_with_cached_components( + tmp_path: Path, + temp_cache: TrainingCache, + train_with_schema: Callable, + caplog: LogCaptureFixture, +): + input_file = tmp_path / "input_file.txt" + input_file.write_text("3") + + train_schema = GraphSchema( + { + "input": SchemaNode( + needs={}, + uses=ProvideX, + fn="provide", + constructor_name="create", + config={}, + ), + "subtract": SchemaNode( + needs={"i": "input"}, + uses=SubtractByX, + fn="subtract_x", + constructor_name="create", + config={"x": 1}, + is_target=True, + is_input=False, + ), + "cache_able_node": SchemaNode( + needs={"suffix": "input"}, + uses=CacheableComponent, + fn="run", + constructor_name="create", + config={}, + is_target=True, + is_input=False, + ), + } + ) + + # Train to cache + train_with_schema(train_schema, temp_cache) + + # Train a second time + with caplog.at_level(logging.INFO, logger="rasa.engine.training.hooks"): + train_with_schema(train_schema, temp_cache) + + caplog_info_records = list( + filter(lambda x: x[1] == logging.INFO, caplog.record_tuples) + ) + caplog_messages_set = set([record[2] for record in caplog_info_records]) + + assert caplog_messages_set == { + "Starting to train component 'SubtractByX'.", + "Finished training component 'SubtractByX'.", + "Restored component 'CacheableComponent' from cache.", + } + + +def test_resources_fingerprints_are_unique_when_cached( + temp_cache: LocalTrainingCache, train_with_schema: Callable +): + train_schema = GraphSchema( + { + "train": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="train", + constructor_name="create", + config={"test_value": "4"}, + is_target=True, + ), + "process": SchemaNode( + needs={"resource": "train"}, + uses=PersistableTestComponent, + fn="run_inference", + constructor_name="load", + config={}, + ), + "assert_node": SchemaNode( + needs={"i": "process"}, + uses=AssertComponent, + fn="run_assert", + constructor_name="create", + config={"value_to_assert": "4"}, + is_target=True, + ), + } + ) + + # Train to cache + train_with_schema(train_schema, temp_cache) + + train_schema.nodes["train"].config["test_value"] = "5" + train_schema.nodes["assert_node"].config["value_to_assert"] = "5" + train_with_schema(train_schema, temp_cache) + + # Add something to the config so only "assert_node" re-runs. + train_schema.nodes["assert_node"].config["something"] = "something" + # This breaks when `Resource`s use the node name as a fingerprint. + # This is because the `Resource` for the first run is retrieved from the cache which + # returns 4 whereas it should be the second resource which returns 5, and the schema + # assert_node expects 5 now. + train_with_schema(train_schema, temp_cache) + + +def test_resources_fingerprints_remain_after_being_cached( + temp_cache: LocalTrainingCache, train_with_schema: Callable +): + train_schema = GraphSchema( + { + "train": SchemaNode( + needs={}, + uses=PersistableTestComponent, + fn="train", + constructor_name="create", + config={"test_value": "4"}, + is_target=True, + ), + "process": SchemaNode( + needs={"resource": "train"}, + uses=PersistableTestComponent, + fn="run_inference", + constructor_name="load", + config={}, + is_target=True, + ), + } + ) + + # Train and cache. + train_with_schema(train_schema, temp_cache) + + # We can determine if a cached `Resource` has a static fingerprint by comparing two + # subsequent cache entries of a child node. + import sqlalchemy as sa + + with temp_cache._sessionmaker.begin() as session: + # This will get the cache entry for the "process" node. + query_for_most_recently_used_entry = sa.select(temp_cache.CacheEntry).order_by( + temp_cache.CacheEntry.last_used.desc() + ) + entry = session.execute(query_for_most_recently_used_entry).scalars().first() + # The fingerprint key will incorporate the fingerprint of the `Resource` + # provided by the "train" node. We save this key to compare after the next run. + fingerprint_key = entry.fingerprint_key + # Deleting the entry will force it to be recreated next train. + delete_query = sa.delete(temp_cache.CacheEntry).where( + temp_cache.CacheEntry.fingerprint_key == fingerprint_key + ) + session.execute(delete_query) + + # In this second train, the Resource output of "train" will be retrieved from the + # cache. + train_with_schema(train_schema, temp_cache) + + with temp_cache._sessionmaker.begin() as session: + # This will get the new cache entry for the "process" node. + query_for_most_recently_used_entry = sa.select(temp_cache.CacheEntry).order_by( + temp_cache.CacheEntry.last_used.desc() + ) + entry = session.execute(query_for_most_recently_used_entry).scalars().first() + # Assert the fingerprint key of the new entry is the same. This confirms that + # the Resource from the cache has the same fingerprint. + assert entry.fingerprint_key == fingerprint_key + + +@pytest.mark.parametrize( + "on_before, on_after", + [(lambda: True, lambda: 2 / 0), (lambda: 2 / 0, lambda: True)], +) +def test_exception_handling_for_on_before_hook( + on_before: Callable, + on_after: Callable, + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, +): + schema_node = SchemaNode( + needs={}, uses=ProvideX, fn="provide", constructor_name="create", config={} + ) + + class MyHook(GraphNodeHook): + def on_after_node( + self, + node_name: Text, + execution_context: ExecutionContext, + config: Dict[Text, Any], + output: Any, + input_hook_data: Dict, + ) -> None: + on_before() + + def on_before_node( + self, + node_name: Text, + execution_context: ExecutionContext, + config: Dict[Text, Any], + received_inputs: Dict[Text, Any], + ) -> Dict: + on_after() + return {} + + node = GraphNode.from_schema_node( + "some_node", + schema_node, + default_model_storage, + default_execution_context, + hooks=[MyHook()], + ) + + with pytest.raises(GraphComponentException): + node() diff --git a/tests/engine/training/test_hooks.py b/tests/engine/training/test_hooks.py new file mode 100644 index 0000000..13d9fda --- /dev/null +++ b/tests/engine/training/test_hooks.py @@ -0,0 +1,119 @@ +from rasa.engine.caching import TrainingCache +from rasa.engine.graph import ExecutionContext, GraphNode, GraphSchema, SchemaNode +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.training import fingerprinting +from rasa.engine.training.components import PrecomputedValueProvider +from rasa.engine.training.hooks import TrainingHook +from tests.engine.graph_components_test_classes import CacheableComponent, CacheableText + + +def test_training_hook_saves_to_cache( + default_model_storage: ModelStorage, temp_cache: TrainingCache +): + # We need an execution context so the hook can determine the class of the graph + # component + execution_context = ExecutionContext( + GraphSchema( + { + "hello": SchemaNode( + needs={}, + constructor_name="create", + fn="run", + config={}, + uses=CacheableComponent, + ) + } + ), + "1", + ) + node = GraphNode( + node_name="hello", + component_class=CacheableComponent, + constructor_name="create", + component_config={}, + fn_name="run", + inputs={"suffix": "input_node"}, + eager=False, + model_storage=default_model_storage, + resource=None, + execution_context=execution_context, + hooks=[ + TrainingHook( + cache=temp_cache, + model_storage=default_model_storage, + pruned_schema=execution_context.graph_schema, + ) + ], + ) + + node(("input_node", "Joe")) + + # This is the same key that the hook will generate + fingerprint_key = fingerprinting.calculate_fingerprint_key( + graph_component_class=CacheableComponent, + config={"prefix": "Hello "}, + inputs={"suffix": "Joe"}, + ) + + output_fingerprint_key = temp_cache.get_cached_output_fingerprint(fingerprint_key) + assert output_fingerprint_key + + cached_result = temp_cache.get_cached_result( + output_fingerprint_key=output_fingerprint_key, + model_storage=default_model_storage, + node_name="hello", + ) + assert isinstance(cached_result, CacheableText) + assert cached_result.text == "Hello Joe" + + +def test_training_hook_does_not_cache_cached_component( + default_model_storage: ModelStorage, temp_cache: TrainingCache +): + # We need an execution context so the hook can determine the class of the graph + # component + execution_context = ExecutionContext( + GraphSchema( + { + "hello": SchemaNode( + needs={}, + constructor_name="create", + fn="run", + config={}, + uses=PrecomputedValueProvider, + ) + } + ), + "1", + ) + node = GraphNode( + node_name="hello", + component_class=PrecomputedValueProvider, + constructor_name="create", + component_config={"output": CacheableText("hi")}, + fn_name="get_value", + inputs={}, + eager=False, + model_storage=default_model_storage, + resource=None, + execution_context=execution_context, + hooks=[ + TrainingHook( + cache=temp_cache, + model_storage=default_model_storage, + pruned_schema=execution_context.graph_schema, + ) + ], + ) + + node(("input_node", "Joe")) + + # This is the same key that the hook will generate + fingerprint_key = fingerprinting.calculate_fingerprint_key( + graph_component_class=PrecomputedValueProvider, + config={"output": CacheableText("hi")}, + inputs={}, + ) + + # The hook should not cache the output of a PrecomputedValueProvider + assert not temp_cache.get_cached_output_fingerprint(fingerprint_key) diff --git a/tests/examples/test_example_bots_training_data.py b/tests/examples/test_example_bots_training_data.py new file mode 100644 index 0000000..f579058 --- /dev/null +++ b/tests/examples/test_example_bots_training_data.py @@ -0,0 +1,116 @@ +import warnings +from pathlib import Path +from typing import Optional, Text + +import pytest + +from rasa.cli import scaffold +from rasa.shared.importers.importer import TrainingDataImporter +from tests.conftest import filter_expected_warnings + + +@pytest.mark.flaky +@pytest.mark.parametrize( + "config_file, domain_file, data_folder, raise_slot_warning, msg", + [ + ( + "examples/concertbot/config.yml", + "examples/concertbot/domain.yml", + "examples/concertbot/data", + True, + None, + ), + ( + "examples/formbot/config.yml", + "examples/formbot/domain.yml", + "examples/formbot/data", + True, + None, + ), + ( + "examples/knowledgebasebot/config.yml", + "examples/knowledgebasebot/domain.yml", + "examples/knowledgebasebot/data", + True, + "You are using an experimental feature: " + "Action 'action_query_knowledge_base'!", + ), + ( + "data/test_moodbot/config.yml", + "data/test_moodbot/domain.yml", + "data/test_moodbot/data", + False, + None, + ), + ( + "examples/reminderbot/config.yml", + "examples/reminderbot/domain.yml", + "examples/reminderbot/data", + True, + None, + ), + ( + "examples/rules/config.yml", + "examples/rules/domain.yml", + "examples/rules/data", + True, + None, + ), + ], +) +def test_example_bot_training_data_raises_only_auto_fill_warning( + config_file: Text, + domain_file: Text, + data_folder: Text, + raise_slot_warning: bool, + msg: Optional[Text], +): + + importer = TrainingDataImporter.load_from_config( + config_file, domain_file, [data_folder] + ) + + if raise_slot_warning: + with pytest.warns() as record: + warnings.simplefilter(action="ignore", category=DeprecationWarning) + + if msg is not None: + warnings.filterwarnings(action="ignore", message=msg) + + importer.get_nlu_data() + importer.get_stories() + + assert len(record) == 2 + assert all( + [ + "Slot auto-fill has been removed in 3.0 and replaced with " + "a new explicit mechanism to set slots." in r.message.args[0] + for r in record + ] + ) + else: + with warnings.catch_warnings() as record: + importer.get_nlu_data() + importer.get_stories() + + assert record is None + + +def test_example_bot_training_on_initial_project(tmp_path: Path): + # we need to test this one separately, as we can't test it in place + # configuration suggestions would otherwise change the initial file + scaffold.create_initial_project(str(tmp_path)) + + importer = TrainingDataImporter.load_from_config( + str(tmp_path / "config.yml"), + str(tmp_path / "domain.yml"), + str(tmp_path / "data"), + ) + + with warnings.catch_warnings() as record: + importer.get_nlu_data() + importer.get_stories() + + if record is not None: + records = filter_expected_warnings(record) + assert len(records) == 0 diff --git a/tests/graph_components/__init__.py b/tests/graph_components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/graph_components/converters/__init__.py b/tests/graph_components/converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/graph_components/converters/test_nlu_message_converter.py b/tests/graph_components/converters/test_nlu_message_converter.py new file mode 100644 index 0000000..ec1d2d3 --- /dev/null +++ b/tests/graph_components/converters/test_nlu_message_converter.py @@ -0,0 +1,57 @@ +from rasa.core.channels import UserMessage +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.graph_components.converters.nlu_message_converter import NLUMessageConverter +from rasa.shared.nlu.constants import TEXT, TEXT_TOKENS +from rasa.shared.nlu.training_data.message import Message + + +def test_nlu_message_converter_converts_message( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + component = NLUMessageConverter.create( + {**NLUMessageConverter.get_default_config()}, + default_model_storage, + Resource("test"), + default_execution_context, + ) + + message = UserMessage(text="Hello", metadata=None) + nlu_message = component.convert_user_message([message]) + assert len(nlu_message) == 1 + assert isinstance(nlu_message[0], Message) + + assert nlu_message[0].get("text") == "Hello" + assert nlu_message[0].get("metadata") is None + assert nlu_message[0].output_properties == {TEXT_TOKENS, TEXT} + + +def test_nlu_message_converter_converts_message_with_metadata( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + component = NLUMessageConverter.create( + {}, default_model_storage, Resource("with_metadata"), default_execution_context + ) + + message = UserMessage(text="Hello", metadata={"test_key": "test_value"}) + nlu_message = component.convert_user_message([message]) + assert len(nlu_message) == 1 + assert isinstance(nlu_message[0], Message) + + assert nlu_message[0].get("text") == "Hello" + assert nlu_message[0].get("metadata") == {"test_key": "test_value"} + + +def test_nlu_message_converter_handles_no_user_message( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + component = NLUMessageConverter.create( + {}, + default_model_storage, + Resource("no_user_message"), + default_execution_context, + ) + + nlu_message = component.convert_user_message([]) + assert len(nlu_message) == 0 diff --git a/tests/graph_components/providers/__init__.py b/tests/graph_components/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/graph_components/providers/test_domain_for_core_training_provider.py b/tests/graph_components/providers/test_domain_for_core_training_provider.py new file mode 100644 index 0000000..52e5b7e --- /dev/null +++ b/tests/graph_components/providers/test_domain_for_core_training_provider.py @@ -0,0 +1,154 @@ +from typing import Text, Union, Dict + +import pytest +from pathlib import Path + +from _pytest.tmpdir import TempPathFactory + +from rasa import model_training +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.core.domain import ( + KEY_RESPONSES, + Domain, + SESSION_CONFIG_KEY, + KEY_FORMS, + SESSION_EXPIRATION_TIME_KEY, + CARRY_OVER_SLOTS_KEY, +) +from rasa.graph_components.providers.domain_for_core_training_provider import ( + DomainForCoreTrainingProvider, +) +from rasa.shared.constants import REQUIRED_SLOTS_KEY + + +@pytest.mark.parametrize( + "input_domain", + [ + "data/test_domains/conditional_response_variations.yml", # responses + "data/test_domains/default_with_slots.yml", # slots + "data/test_domains/default_with_mapping.yml", # slot mappings + {KEY_FORMS: {"form1": {REQUIRED_SLOTS_KEY: ["slot1"]}}}, # form + { + SESSION_CONFIG_KEY: { + SESSION_EXPIRATION_TIME_KEY: ( + 2 * Domain.empty().session_config.session_expiration_time + ), + CARRY_OVER_SLOTS_KEY: ( + not Domain.empty().session_config.carry_over_slots + ), + } + }, + ], +) +def test_provide_removes_or_replaces_expected_information( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + input_domain: Union[Text, Dict], +): + # prepare input + if isinstance(input_domain, str): + original_domain = Domain.from_file(path=input_domain) + else: + original_domain = Domain.from_dict(input_domain) + + # pass through component + component = DomainForCoreTrainingProvider.create( + {"arbitrary-unused": 234}, + default_model_storage, + Resource("xy"), + default_execution_context, + ) + modified_domain = component.provide(domain=original_domain) + + # convert to dict for comparison + modified_dict = modified_domain.as_dict() + original_dict = original_domain.as_dict() + default_dict = Domain.empty().as_dict() + + assert sorted(original_dict.keys()) == sorted(modified_dict.keys()) + for key in original_dict.keys(): + + # replaced with default values + if key in ["config", SESSION_CONFIG_KEY]: + assert modified_dict[key] == default_dict[key] + + # for responses, we only keep the keys + elif key == KEY_RESPONSES: + assert set(modified_dict[key].keys()) == set(original_dict[key].keys()) + for sub_key in original_dict[key]: + assert modified_dict[key][sub_key] == [] + + # for forms, we only keep the keys (and the Domain will add a default key) + elif key == KEY_FORMS: + assert set(modified_dict[key].keys()) == set(original_dict[key].keys()) + for sub_key in original_dict[key]: + assert set(modified_dict[key][sub_key].keys()) == {REQUIRED_SLOTS_KEY} + assert modified_dict[key][sub_key][REQUIRED_SLOTS_KEY] == [] + + # everything else remains unchanged + else: + assert original_dict[key] == modified_dict[key] + + +def test_train_core_with_original_or_provided_domain_and_compare( + tmp_path_factory: TempPathFactory, + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, +): + # Choose an example where the provider will remove a lot of information: + example = Path("examples/formbot/") + training_files = [example / "data" / "rules.yml"] + + # Choose a configuration with a policy + # Note: This is sufficient to illustrate that the component won't be re-trained + # when the domain changes. We do *not* test here whether removing keys would/ + # should not have any effect. + config = """ + recipe: default.v1 + language: en + + policies: + - name: RulePolicy + """ + config_dir = tmp_path_factory.mktemp("config dir") + config_file = config_dir / "config.yml" + with open(config_file, "w") as f: + f.write(config) + + # Train with the original domain + original_domain_file = example / "domain.yml" + original_output_dir = tmp_path_factory.mktemp("output dir") + model_training.train( + domain=original_domain_file, + config=str(config_file), + training_files=training_files, + output=original_output_dir, + ) + + # Let the provider create a modified domain + original_domain = Domain.from_file(original_domain_file) + component = DomainForCoreTrainingProvider.create( + {"arbitrary-unused": 234}, + default_model_storage, + Resource("xy"), + default_execution_context, + ) + modified_domain = component.provide(domain=original_domain) + + # Dry-run training with the modified domain + modified_domain_dir = tmp_path_factory.mktemp("modified domain dir") + modified_domain_file = modified_domain_dir / "modified_config.yml" + modified_domain.persist(modified_domain_file) + + modified_output_dir = tmp_path_factory.mktemp("modified output dir") + modified_result = model_training.train( + domain=modified_domain_file, + config=str(config_file), + training_files=training_files, + output=modified_output_dir, + dry_run=True, + ) + + assert modified_result.dry_run_results["train_RulePolicy0"].is_hit diff --git a/tests/graph_components/providers/test_domain_provider.py b/tests/graph_components/providers/test_domain_provider.py new file mode 100644 index 0000000..374b05c --- /dev/null +++ b/tests/graph_components/providers/test_domain_provider.py @@ -0,0 +1,61 @@ +from typing import Text + +import pytest +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.graph_components.providers.domain_provider import DomainProvider +from rasa.shared.core.domain import Domain +from rasa.shared.exceptions import InvalidConfigException +from rasa.shared.importers.importer import TrainingDataImporter + + +def test_domain_provider_provides_and_persists_domain( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + config_path: Text, + domain_path: Text, + domain: Domain, +): + resource = Resource("xy") + component = DomainProvider.create( + DomainProvider.get_default_config(), + default_model_storage, + resource, + default_execution_context, + ) + assert isinstance(component, DomainProvider) + + importer = TrainingDataImporter.load_from_config(config_path, domain_path) + training_domain = component.provide_train(importer) + + assert isinstance(training_domain, Domain) + assert domain.fingerprint() == training_domain.fingerprint() + + with default_model_storage.read_from(resource) as d: + match = list(d.glob("**/domain.yml")) + assert len(match) == 1 + assert match[0].is_file() + assert domain.fingerprint() == Domain.from_path(match[0]).fingerprint() + + component_2 = DomainProvider.load( + {}, default_model_storage, resource, default_execution_context + ) + inference_domain = component_2.provide_inference() + + assert isinstance(inference_domain, Domain) + assert domain.fingerprint() == inference_domain.fingerprint() + + +def test_provide_without_domain( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + component = DomainProvider.create( + DomainProvider.get_default_config(), + default_model_storage, + Resource("some resource"), + default_execution_context, + ) + + with pytest.raises(InvalidConfigException): + component.provide_inference() diff --git a/tests/graph_components/providers/test_forms_provider.py b/tests/graph_components/providers/test_forms_provider.py new file mode 100644 index 0000000..6946fcb --- /dev/null +++ b/tests/graph_components/providers/test_forms_provider.py @@ -0,0 +1,25 @@ +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.graph_components.providers.forms_provider import FormsProvider +from rasa.shared.core.domain import Domain + + +def test_provide( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + resource = Resource("some resource") + + domain = Domain.load( + "data/test_from_trigger_intent_with_no_mapping_conditions/domain.yml" + ) + + provider = FormsProvider.create( + {}, default_model_storage, resource, default_execution_context + ) + forms = provider.provide(domain) + + assert forms.data == { + "test_form": {"required_slots": ["question1"]}, + "another_form": {"required_slots": ["q2"]}, + } diff --git a/tests/graph_components/providers/test_nlu_training_data_provider.py b/tests/graph_components/providers/test_nlu_training_data_provider.py new file mode 100644 index 0000000..8eb1073 --- /dev/null +++ b/tests/graph_components/providers/test_nlu_training_data_provider.py @@ -0,0 +1,75 @@ +import os +from typing import Text +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.graph_components.providers.nlu_training_data_provider import ( + NLUTrainingDataProvider, +) +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.nlu.training_data.training_data import ( + DEFAULT_TRAINING_DATA_OUTPUT_PATH, + TrainingData, +) +from rasa.shared.nlu.training_data.loading import load_data + + +def test_nlu_training_data_provider( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + config_path: Text, + nlu_data_path: Text, +): + # create a resource and an importer + resource = Resource("xy") + importer = TrainingDataImporter.load_from_config( + config_path=config_path, training_data_paths=[nlu_data_path] + ) + + # check the default configuration is as expected + config_1 = NLUTrainingDataProvider.get_default_config() + assert config_1["language"] is None + assert config_1["persist"] is False + + # create a provider with persist == True + provider_1 = NLUTrainingDataProvider.create( + {"language": "en", "persist": True}, + default_model_storage, + resource, + default_execution_context, + ) + assert isinstance(provider_1, NLUTrainingDataProvider) + + # check the data provided is as expected + data_0 = provider_1.provide(importer) + data_1 = importer.get_nlu_data(language="en") + assert data_0.fingerprint() == data_1.fingerprint() + + # check the data was persisted + with default_model_storage.read_from(resource) as resource_directory: + data_file = os.path.join( + str(resource_directory), DEFAULT_TRAINING_DATA_OUTPUT_PATH + ) + data = load_data(resource_name=data_file, language="en") + assert os.path.isfile(data_file) + assert isinstance(data, TrainingData) + + # delete the persisted data + os.remove(data_file) + assert not os.path.isfile(data_file) + + # create a provider with persist == False + provider_2 = NLUTrainingDataProvider.create( + {"language": "en", "persist": False}, + default_model_storage, + resource, + default_execution_context, + ) + provider_2.provide(importer) + + # check the data was not persisted + with default_model_storage.read_from(resource) as resource_directory: + data_file = os.path.join( + str(resource_directory), DEFAULT_TRAINING_DATA_OUTPUT_PATH + ) + assert not os.path.isfile(data_file) diff --git a/tests/graph_components/providers/test_responses_provider.py b/tests/graph_components/providers/test_responses_provider.py new file mode 100644 index 0000000..82e8169 --- /dev/null +++ b/tests/graph_components/providers/test_responses_provider.py @@ -0,0 +1,39 @@ +from rasa.graph_components.providers.responses_provider import ResponsesProvider +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.core.domain import Domain + + +def test_provide( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + resource = Resource("some resource") + + domain = Domain.load( + "data/test_from_trigger_intent_with_no_mapping_conditions/domain.yml" + ) + + provider = ResponsesProvider.create( + {}, default_model_storage, resource, default_execution_context + ) + responses = provider.provide(domain) + + assert responses.data == { + "utter_greet": [{"text": "Hey! How are you?"}], + "utter_cheer_up": [ + { + "text": "Here is something to cheer you up:", + "image": "https://i.imgur.com/nGF1K8f.jpg", + } + ], + "utter_did_that_help": [{"text": "Did that help you?"}], + "utter_happy": [{"text": "Great, carry on!"}], + "utter_test_trigger": [ + {"text": "The value of test_trigger slot is: {test_trigger}"} + ], + "utter_goodbye": [{"text": "Bye"}], + "utter_iamabot": [{"text": "I am a bot, powered by Rasa."}], + "utter_ask_test_form_question1": [{"text": "test form - question 1"}], + "utter_submit_test_form": [{"text": "Submit test form"}], + } diff --git a/tests/graph_components/providers/test_rule_only_provider.py b/tests/graph_components/providers/test_rule_only_provider.py new file mode 100644 index 0000000..98100f3 --- /dev/null +++ b/tests/graph_components/providers/test_rule_only_provider.py @@ -0,0 +1,36 @@ +import rasa.core.training +from rasa.core.policies.rule_policy import RulePolicy +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.graph_components.providers.rule_only_provider import RuleOnlyDataProvider +from rasa.shared.core.constants import RULE_ONLY_SLOTS, RULE_ONLY_LOOPS +from rasa.shared.core.domain import Domain + + +def test_provide( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + resource = Resource("some resource") + + domain = Domain.load("examples/rules/domain.yml") + trackers = rasa.core.training.load_data("examples/rules/data/rules.yml", domain) + + policy = RulePolicy.create( + RulePolicy.get_default_config(), + default_model_storage, + resource, + default_execution_context, + ) + + policy.train(trackers, domain) + + provider = RuleOnlyDataProvider.load( + {}, default_model_storage, resource, default_execution_context + ) + rule_only_data = provider.provide() + + assert rule_only_data + + for key in [RULE_ONLY_SLOTS, RULE_ONLY_LOOPS]: + assert rule_only_data[key] == policy.lookup[key] diff --git a/tests/graph_components/providers/test_story_graph_provider.py b/tests/graph_components/providers/test_story_graph_provider.py new file mode 100644 index 0000000..6824bb0 --- /dev/null +++ b/tests/graph_components/providers/test_story_graph_provider.py @@ -0,0 +1,38 @@ +from typing import Dict, Text, Any + +import pytest +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.graph_components.providers.story_graph_provider import StoryGraphProvider +from rasa.shared.core.training_data.structures import StoryGraph +from rasa.shared.importers.importer import TrainingDataImporter + + +@pytest.mark.parametrize( + "config", [{}, {"exclusion_percentage": None}, {"exclusion_percentage": 25}] +) +def test_story_graph_provider_provide( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + config: Dict[Text, Any], + config_path: Text, + domain_path: Text, + stories_path: Text, +): + component = StoryGraphProvider.create( + {**StoryGraphProvider.get_default_config(), **config}, + default_model_storage, + Resource("xy"), + default_execution_context, + ) + importer = TrainingDataImporter.load_from_config( + config_path, domain_path, [stories_path] + ) + + story_graph_from_component = component.provide(importer) + assert isinstance(story_graph_from_component, StoryGraph) + + story_graph = importer.get_stories(**config) + + assert story_graph.fingerprint() == story_graph_from_component.fingerprint() diff --git a/tests/graph_components/providers/test_training_tracker_provider.py b/tests/graph_components/providers/test_training_tracker_provider.py new file mode 100644 index 0000000..9cbe00b --- /dev/null +++ b/tests/graph_components/providers/test_training_tracker_provider.py @@ -0,0 +1,84 @@ +from typing import Dict, Text, Any + +import pytest +from rasa.graph_components.providers.training_tracker_provider import ( + TrainingTrackerProvider, +) +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.core.domain import Domain +from rasa.shared.core.generator import TrackerWithCachedStates +from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, +) +from rasa.shared.core.training_data.structures import StoryGraph + + +@pytest.mark.parametrize( + "config, expected_trackers", + [ + ({}, 507), + ({"augmentation_factor": 0}, 7), + ({"use_story_concatenation": False}, 7), + ( + { + "remove_duplicates": True, + "unique_last_num_states": None, + "augmentation_factor": 50, + "tracker_limit": None, + "use_story_concatenation": True, + "debug_plots": False, + }, + 507, + ), + ], +) +def test_generating_trackers( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + config: Dict[Text, Any], + expected_trackers: int, +): + reader = YAMLStoryReader() + steps = reader.read_from_file("data/test_yaml_stories/stories.yml") + component = TrainingTrackerProvider.create( + {**TrainingTrackerProvider.get_default_config(), **config}, + default_model_storage, + Resource("xy"), + default_execution_context, + ) + + trackers = component.provide(story_graph=StoryGraph(steps), domain=Domain.empty()) + + assert len(trackers) == expected_trackers + assert all(isinstance(t, TrackerWithCachedStates) for t in trackers) + + +def test_generated_trackers_can_omit_unset_slots( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + reader = YAMLStoryReader() + steps = reader.read_from_file("data/test_yaml_stories/rules_greet_and_goodbye.yml") + + domain = Domain.from_path( + "data/test_domains/initial_slot_values_greet_and_goodbye.yml" + ) + + component = TrainingTrackerProvider.create( + TrainingTrackerProvider.get_default_config(), + default_model_storage, + Resource("xy"), + default_execution_context, + ) + + trackers = component.provide(story_graph=StoryGraph(steps), domain=domain) + + assert len(trackers) == 2 + assert all([t.is_rule_tracker for t in trackers]) + + states_without_unset_slots = trackers[0].past_states(domain, omit_unset_slots=True) + assert not any(["slots" in state for state in states_without_unset_slots]) + + states_with_unset_slots = trackers[0].past_states(domain, omit_unset_slots=False) + assert all(["slots" in state for state in states_with_unset_slots]) diff --git a/tests/graph_components/validators/__init__.py b/tests/graph_components/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/graph_components/validators/test_default_recipe_validator.py b/tests/graph_components/validators/test_default_recipe_validator.py new file mode 100644 index 0000000..2234116 --- /dev/null +++ b/tests/graph_components/validators/test_default_recipe_validator.py @@ -0,0 +1,1055 @@ +import warnings +from pathlib import Path + +import rasa.shared.utils.io +from rasa.core.featurizers.precomputation import CoreFeaturizationInputConverter +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.storage.resource import Resource + +from rasa.nlu.extractors.entity_synonyms import EntitySynonymMapper +from typing import Dict, List, Optional, Set, Text, Any, Tuple, Type +import re + +import pytest +from _pytest.monkeypatch import MonkeyPatch +from unittest.mock import Mock +from rasa.engine.graph import GraphComponent, ExecutionContext, GraphSchema, SchemaNode + +from rasa.graph_components.validators.default_recipe_validator import ( + POLICY_CLASSSES, + DefaultV1RecipeValidator, + TRAINABLE_EXTRACTORS, + _types_to_str, +) +from rasa.nlu.constants import FEATURIZER_CLASS_ALIAS +from rasa.nlu.classifiers.diet_classifier import DIETClassifier +from rasa.nlu.extractors.regex_entity_extractor import RegexEntityExtractor +from rasa.nlu.extractors.crf_entity_extractor import ( + CRFEntityExtractor, + CRFEntityExtractorOptions, +) +from rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer import ( + LexicalSyntacticFeaturizer, +) +from rasa.nlu.featurizers.sparse_featurizer.regex_featurizer import RegexFeaturizer +from rasa.nlu.selectors.response_selector import ResponseSelector +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +from rasa.core.policies.memoization import MemoizationPolicy +from rasa.core.policies.rule_policy import RulePolicy +from rasa.core.policies.ted_policy import TEDPolicy +from rasa.core.policies.policy import Policy +from rasa.shared.core.training_data.structures import StoryGraph +from rasa.shared.core.domain import KEY_FORMS, Domain, InvalidDomain +from rasa.shared.exceptions import InvalidConfigException +from rasa.shared.data import TrainingType +from rasa.shared.nlu.constants import ( + ENTITIES, + ENTITY_ATTRIBUTE_GROUP, + ENTITY_ATTRIBUTE_ROLE, + ENTITY_ATTRIBUTE_TYPE, + INTENT_RESPONSE_KEY, + TEXT, + INTENT, + RESPONSE, +) +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.utils.validation import YamlValidationException +import rasa.utils.common +from tests.conftest import filter_expected_warnings + + +class DummyImporter(TrainingDataImporter): + def __init__( + self, + training_data: Optional[TrainingData] = None, + config: Optional[Dict[Text, Any]] = None, + domain: Optional[Domain] = None, + ) -> None: + self.training_data = training_data or TrainingData([]) + self.config = config or {} + self.domain = domain or Domain.empty() + + def get_domain(self) -> Domain: + return self.domain + + def get_nlu_data(self) -> TrainingData: + return self.training_data + + def get_config(self) -> Dict[Text, Any]: + return self.config + + def get_stories(self) -> StoryGraph: + return StoryGraph([]) + + def get_config_file_for_auto_config(self) -> Optional[Text]: + return "config.yml" + + +def _test_validation_warnings_with_default_configs( + training_data: TrainingData, + component_types: List[Type], + warnings: Optional[List[Text]] = None, +): + dummy_importer = DummyImporter(training_data=training_data) + graph_schema = GraphSchema( + { + f"{idx}": SchemaNode( + needs={}, + uses=component_type, + constructor_name="", + fn="", + config=component_type.get_default_config(), + ) + for idx, component_type in enumerate(component_types) + } + ) + validator = DefaultV1RecipeValidator(graph_schema) + if not warnings: + with pytest.warns(None) as records: + validator.validate(dummy_importer) + assert len(records) == 0, [warning.message for warning in records.list] + else: + with pytest.warns(None) as records: + validator.validate(dummy_importer) + assert len(records) == len(warnings), ", ".join( + warning.message.args[0] for warning in records + ) + assert [ + re.match(warning.message.args[0], expected_warning) + for warning, expected_warning in zip(records, warnings) + ] + + +@pytest.mark.parametrize( + "component_type, warns", [(ResponseSelector, False), (None, True)] +) +def test_nlu_warn_if_training_examples_with_intent_response_key_are_unused( + component_type: Type[GraphComponent], warns: bool +): + messages = [ + Message( + { + INTENT: "faq", + INTENT_RESPONSE_KEY: "faq/dummy", + TEXT: "hi", + RESPONSE: "utter_greet", + } + ), + Message( + { + INTENT: "faq", + INTENT_RESPONSE_KEY: "faq/dummy", + TEXT: "hi hi", + RESPONSE: "utter_greet", + } + ), + ] + training_data = TrainingData(training_examples=messages) + warnings = ( + ( + [ + "You have defined training data with examples " + "for training a response selector, " + "but your NLU configuration" + ] + ) + if warns + else None + ) + component_types = [WhitespaceTokenizer] + if component_type: + component_types.append(component_type) + _test_validation_warnings_with_default_configs( + training_data=training_data, component_types=component_types, warnings=warnings + ) + + +@pytest.mark.parametrize( + "component_type, warns", + [(extractor, False) for extractor in TRAINABLE_EXTRACTORS] + [(None, True)], +) +def test_nlu_warn_if_training_examples_with_entities_are_unused( + component_type: Type[GraphComponent], warns: bool +): + messages = [ + Message( + {ENTITIES: [{ENTITY_ATTRIBUTE_TYPE: "dummy"}], INTENT: "dummy", TEXT: "hi"} + ), + Message( + { + ENTITIES: [{ENTITY_ATTRIBUTE_TYPE: "dummy"}], + INTENT: "dummy", + TEXT: "hi hi", + } + ), + ] + training_data = TrainingData(training_examples=messages) + warnings = ( + ( + [ + "You have defined training data consisting of entity examples, " + "but your NLU configuration" + ] + ) + if warns + else None + ) + component_types = [WhitespaceTokenizer] + if component_type: + component_types.append(component_type) + _test_validation_warnings_with_default_configs( + training_data=training_data, component_types=component_types, warnings=warnings + ) + + +@pytest.mark.parametrize( + "component_type, role_instead_of_group, warns", + [ + ( + extractor, + role_instead_of_group, + extractor not in {DIETClassifier, CRFEntityExtractor}, + ) + for extractor in TRAINABLE_EXTRACTORS + for role_instead_of_group in [True, False] + ], +) +def test_nlu_warn_if_training_examples_with_entity_roles_are_unused( + component_type: Type[GraphComponent], role_instead_of_group: bool, warns: bool +): + messages = [ + Message( + { + ENTITIES: [ + { + ENTITY_ATTRIBUTE_TYPE: "dummy", + ( + ENTITY_ATTRIBUTE_ROLE + if role_instead_of_group + else ENTITY_ATTRIBUTE_GROUP + ): "dummy-2", + } + ], + TEXT: f"hi{i}", + INTENT: "dummy", + } + ) + for i in range(2) + ] + training_data = TrainingData(training_examples=messages) + warnings = ( + [ + "You have defined training data with entities that have roles/groups, " + "but your NLU configuration" + ] + if warns + else [] + ) + component_types = [WhitespaceTokenizer] + if component_type: + component_types.append(component_type) + _test_validation_warnings_with_default_configs( + training_data=training_data, component_types=component_types, warnings=warnings + ) + + +@pytest.mark.parametrize( + "component_type, warns", + [(RegexFeaturizer, False), (RegexEntityExtractor, False), (None, True)], +) +def test_nlu_warn_if_regex_features_are_not_used( + component_type: Type[GraphComponent], warns: bool +): + training_data = TrainingData( + training_examples=[Message({TEXT: "hi"}), Message({TEXT: "hi hi"})], + regex_features=[{"name": "dummy", "pattern": "dummy"}], + ) + component_types = [WhitespaceTokenizer] + if component_type: + component_types.append(component_type) + warnings = ( + ["You have defined training data with regexes, but your NLU"] if warns else None + ) + _test_validation_warnings_with_default_configs( + training_data=training_data, component_types=component_types, warnings=warnings + ) + + +@pytest.mark.parametrize( + "featurizer, consumer, warns_featurizer, warns_consumer", + [(None, consumer, True, False) for consumer in [DIETClassifier, CRFEntityExtractor]] + + [ + (RegexFeaturizer, None, False, True), + # (None, RegexEntityExtractor, False, False), # does not work + (None, RegexEntityExtractor, False, True), # instead we get this + ] + + [ + (featurizer, consumer, False, False) + for consumer in [DIETClassifier, CRFEntityExtractor] + for featurizer in [RegexFeaturizer, RegexEntityExtractor] + ], +) +def test_nlu_warn_if_lookup_table_is_not_used( + featurizer: Type[GraphComponent], + consumer: Type[GraphComponent], + warns_featurizer: bool, + warns_consumer: bool, +): + training_data = TrainingData( + training_examples=[Message({TEXT: "hi"}), Message({TEXT: "hi hi"})], + lookup_tables=[{"elements": "this-is-no-file-and-that-does-not-matter"}], + ) + assert training_data.lookup_tables is not None + component_types = [WhitespaceTokenizer, featurizer, consumer] + component_types = [type for type in component_types if type is not None] + + expected_warnings = [] + if warns_featurizer: + warning = ( + "You have defined training data consisting of lookup tables, " + "your NLU configuration does not include a featurizer using the " + "lookup table." + ) + expected_warnings.append(warning) + if warns_consumer: + warning = ( + "You have defined training data consisting of lookup tables, but " + "your NLU configuration does not include any components " + "that uses the features created from the lookup table. " + ) + expected_warnings.append(warning) + _test_validation_warnings_with_default_configs( + training_data=training_data, + component_types=component_types, + warnings=expected_warnings, + ) + + +@pytest.mark.parametrize( + "nodes, warns", + [ + ( + [ + SchemaNode({}, WhitespaceTokenizer, "", "", {}), + SchemaNode({}, RegexFeaturizer, "", "", {}), + SchemaNode({}, CRFEntityExtractor, "", "", {"features": [["pos"]]}), + ], + True, + ), + ( + [ + SchemaNode({}, WhitespaceTokenizer, "", "", {}), + SchemaNode({}, RegexFeaturizer, "", "", {}), + SchemaNode( + {}, + CRFEntityExtractor, + "", + "", + {"features": [["suffix1", "pattern"], ["pos"]]}, + ), + ], + False, + ), + ( + [ + SchemaNode({}, WhitespaceTokenizer, "", "", {}), + SchemaNode({}, RegexFeaturizer, "", "", {}), + SchemaNode({}, CRFEntityExtractor, "", "", {"features": [["pos"]]}), + SchemaNode( + {}, + CRFEntityExtractor, + "", + "", + {"features": [["suffix1", "pattern"], ["pos"]]}, + ), + ], + False, + ), + ], +) +def test_nlu_warn_if_lookup_table_and_crf_extractor_pattern_feature_mismatch( + nodes: List[SchemaNode], warns: bool +): + training_data = TrainingData( + training_examples=[Message({TEXT: "hi"}), Message({TEXT: "hi hi"})], + lookup_tables=[{"elements": "this-is-no-file-and-that-does-not-matter"}], + ) + assert training_data.lookup_tables is not None + importer = DummyImporter(training_data=training_data) + + graph_schema = GraphSchema({f"{idx}": node for idx, node in enumerate(nodes)}) + validator = DefaultV1RecipeValidator(graph_schema) + + if warns: + match = ( + f"You have defined training data consisting of lookup tables, " + f"but your NLU configuration's " + f"'{CRFEntityExtractor.__name__}' does not include the " + f"'{CRFEntityExtractorOptions.PATTERN}' feature" + ) + + with pytest.warns(UserWarning, match=match): + validator.validate(importer) + else: + with pytest.warns(None) as records: + validator.validate(importer) + assert len(records) == 0 + + +@pytest.mark.parametrize( + "components, warns", + [ + ([WhitespaceTokenizer, CRFEntityExtractor], True), + ([WhitespaceTokenizer, CRFEntityExtractor, EntitySynonymMapper], False), + ], +) +def test_nlu_warn_if_entity_synonyms_unused( + components: List[GraphComponent], warns: bool +): + training_data = TrainingData( + training_examples=[Message({TEXT: "hi"}), Message({TEXT: "hi hi"})], + entity_synonyms={"cat": "dog"}, + ) + assert training_data.entity_synonyms is not None + importer = DummyImporter(training_data=training_data) + + graph_schema = GraphSchema( + { + f"{idx}": SchemaNode({}, component, "", "", {}) + for idx, component in enumerate(components) + } + ) + validator = DefaultV1RecipeValidator(graph_schema) + + if warns: + match = ( + f"You have defined synonyms in your training data, but " + f"your NLU configuration does not include an " + f"'{EntitySynonymMapper.__name__}'. " + ) + + with pytest.warns(UserWarning, match=match): + validator.validate(importer) + else: + with pytest.warns(None) as records: + validator.validate(importer) + assert len(records) == 0 + + +@pytest.mark.parametrize( + "nodes", + [ + { + # With end-to-end the tokenizer appears twice due to the Core featurization + "end_to_end": SchemaNode({}, CoreFeaturizationInputConverter, "", "", {}), + "a": SchemaNode({}, WhitespaceTokenizer, "", "", {}), + "b": SchemaNode({}, WhitespaceTokenizer, "", "", {}), + "c": SchemaNode({}, WhitespaceTokenizer, "", "", {}), + }, + { + "a": SchemaNode({}, WhitespaceTokenizer, "", "", {}), + "b": SchemaNode({}, WhitespaceTokenizer, "", "", {}), + }, + ], +) +def test_nlu_raise_if_more_than_one_tokenizer(nodes: Dict[Text, SchemaNode]): + graph_schema = GraphSchema(nodes) + importer = DummyImporter() + validator = DefaultV1RecipeValidator(graph_schema) + with pytest.raises(InvalidConfigException, match=".* more than one tokenizer"): + validator.validate(importer) + + +def test_nlu_do_not_raise_if_two_tokenizers_with_end_to_end(): + config = rasa.shared.utils.io.read_yaml_file( + "rasa/engine/recipes/config_files/default_config.yml" + ) + graph_config = DefaultV1Recipe().graph_config_for_recipe( + config, cli_parameters={}, training_type=TrainingType.END_TO_END + ) + + importer = DummyImporter() + validator = DefaultV1RecipeValidator(graph_config.train_schema) + + # Does not raise + validator.validate(importer) + + +def test_nlu_do_not_raise_if_trainable_tokenizer(): + config = rasa.shared.utils.io.read_yaml_file( + "data/test_config/config_pretrained_embeddings_mitie_zh.yml" + ) + graph_config = DefaultV1Recipe().graph_config_for_recipe(config, cli_parameters={}) + + importer = DummyImporter() + validator = DefaultV1RecipeValidator(graph_config.train_schema) + + # Does not raise + validator.validate(importer) + + +@pytest.mark.parametrize( + "component_types,should_warn", + [ + ( + [ + WhitespaceTokenizer, + LexicalSyntacticFeaturizer, + CRFEntityExtractor, + DIETClassifier, + ], + True, + ), + ([WhitespaceTokenizer, LexicalSyntacticFeaturizer, DIETClassifier], False), + ], +) +def test_nlu_warn_of_competing_extractors( + component_types: List[Type[GraphComponent]], should_warn: bool +): + graph_schema = GraphSchema( + { + f"{idx}": SchemaNode({}, component_type, "", "", {}) + for idx, component_type in enumerate(component_types) + } + ) + importer = DummyImporter() + nlu_validator = DefaultV1RecipeValidator(graph_schema) + if should_warn: + with pytest.warns(UserWarning, match=".*defined multiple entity extractors"): + nlu_validator.validate(importer) + else: + with pytest.warns(None) as records: + nlu_validator.validate(importer) + assert len(records) == 0 + + +@pytest.mark.parametrize( + "component_types,data_path,should_warn", + [ + ( + [ + WhitespaceTokenizer, + LexicalSyntacticFeaturizer, + RegexEntityExtractor, + DIETClassifier, + ], + "data/test/overlapping_regex_entities.yml", + True, + ), + ( + [WhitespaceTokenizer, LexicalSyntacticFeaturizer, RegexEntityExtractor], + "data/test/overlapping_regex_entities.yml", + False, + ), + ( + [WhitespaceTokenizer, LexicalSyntacticFeaturizer, DIETClassifier], + "data/test/overlapping_regex_entities.yml", + False, + ), + ( + [ + WhitespaceTokenizer, + LexicalSyntacticFeaturizer, + RegexEntityExtractor, + DIETClassifier, + ], + "data/examples/rasa/demo-rasa.yml", + False, + ), + ], +) +def test_nlu_warn_of_competition_with_regex_extractor( + monkeypatch: MonkeyPatch, + component_types: List[Dict[Text, Text]], + data_path: Text, + should_warn: bool, +): + importer = TrainingDataImporter.load_from_dict(training_data_paths=[data_path]) + # there are no domain files for the above examples, so: + monkeypatch.setattr(Domain, "check_missing_responses", lambda *args, **kwargs: None) + + graph_schema = GraphSchema( + { + f"{idx}": SchemaNode({}, component_type, "", "", {}) + for idx, component_type in enumerate(component_types) + } + ) + validator = DefaultV1RecipeValidator(graph_schema) + monkeypatch.setattr( + validator, "_warn_if_some_training_data_is_unused", lambda *args, **kwargs: None + ) + + if should_warn: + with pytest.warns( + UserWarning, + match=( + f"You have an overlap between the " + f"'{RegexEntityExtractor.__name__}' and the statistical" + ), + ): + validator.validate(importer) + else: + with warnings.catch_warnings() as records: + validator.validate(importer) + + if records is not None: + records = filter_expected_warnings(records) + assert len(records) == 0 + + +@pytest.mark.parametrize( + "component_types_and_configs, should_raise", + [ + ( + [ + ( + "1", + LexicalSyntacticFeaturizer, + {FEATURIZER_CLASS_ALIAS: "different-class-same-name"}, + "process_training_data", + ), + ( + "2", + RegexFeaturizer, + {FEATURIZER_CLASS_ALIAS: "different-class-same-name"}, + "process_training_data", + ), + ], + True, + ), + ( + [ + ( + "1", + RegexFeaturizer, + {FEATURIZER_CLASS_ALIAS: "same-class-other-name"}, + "process_training_data", + ), + ( + "2", + RegexFeaturizer, + {FEATURIZER_CLASS_ALIAS: "same-class-different-name"}, + "process_training_data", + ), + ], + False, + ), + ( + [ + ( + "1", + RegexFeaturizer, + {FEATURIZER_CLASS_ALIAS: "same-class-same-name"}, + "process_training_data", + ), + ( + "2", + RegexFeaturizer, + {FEATURIZER_CLASS_ALIAS: "same-class-same-name"}, + "train", + ), + ], + False, + ), + ( + [ + ( + "1", + RegexFeaturizer, + {FEATURIZER_CLASS_ALIAS: "same-class-same-name"}, + "process_training_data", + ), + ( + "e2e_1", + RegexFeaturizer, + {FEATURIZER_CLASS_ALIAS: "same-class-same-name"}, + "process_training_data", + ), + ], + False, + ), + ( + [ + ("1", RegexFeaturizer, {}, "process_training_data"), + ("2", RegexFeaturizer, {}, "process_training_data"), + ], + False, + ), + ], +) +def test_nlu_raise_if_featurizers_are_not_compatible( + component_types_and_configs: List[ + Tuple[Type[GraphComponent], Dict[Text, Any], Text] + ], + should_raise: bool, +): + graph_schema = GraphSchema( + { + f"{node_name}": SchemaNode({}, component_type, "", fn, config) + for (node_name, component_type, config, fn) in component_types_and_configs + } + ) + importer = DummyImporter() + validator = DefaultV1RecipeValidator(graph_schema) + if should_raise: + with pytest.raises(InvalidConfigException): + validator.validate(importer) + else: + validator.validate(importer) + + +@pytest.mark.parametrize("policy_type", [TEDPolicy, RulePolicy, MemoizationPolicy]) +def test_core_warn_if_data_but_no_policy( + monkeypatch: MonkeyPatch, policy_type: Optional[Type[Policy]] +): + + importer = TrainingDataImporter.load_from_dict( + domain_path="data/test_e2ebot/domain.yml", + training_data_paths=[ + "data/test_e2ebot/data/nlu.yml", + "data/test_e2ebot/data/stories.yml", + ], + ) + + nodes = { + "tokenizer": SchemaNode({}, WhitespaceTokenizer, "", "", {}), + "nlu-component": SchemaNode({}, DIETClassifier, "", "", {}), + } + if policy_type is not None: + nodes["some-policy"] = SchemaNode({}, policy_type, "", "", {}) + graph_schema = GraphSchema(nodes) + + validator = DefaultV1RecipeValidator(graph_schema) + monkeypatch.setattr( + validator, + "_raise_if_a_rule_policy_is_incompatible_with_domain", + lambda *args, **kwargs: None, + ) + monkeypatch.setattr(validator, "_warn_if_no_rule_policy_is_contained", lambda: None) + monkeypatch.setattr( + validator, + "_warn_if_rule_based_data_is_unused_or_missing", + lambda *args, **kwargs: None, + ) + + if policy_type is None: + with pytest.warns( + UserWarning, match="Found data for training policies but no policy" + ) as records: + validator.validate(importer) + assert len(records) == 1 + else: + with pytest.warns() as records: + validator.validate(importer) + records = filter_expected_warnings(records) + assert len(records) == 0 + + +@pytest.mark.parametrize( + "policy_types, should_warn", + [ + ([TEDPolicy], True), + ([RulePolicy], False), + ([MemoizationPolicy, RulePolicy], False), + ], +) +def test_core_warn_if_no_rule_policy( + monkeypatch: MonkeyPatch, policy_types: List[Type[Policy]], should_warn: bool +): + graph_schema = GraphSchema( + { + f"{idx}": SchemaNode({}, policy_type, "", "", {}) + for idx, policy_type in enumerate(policy_types) + } + ) + importer = DummyImporter() + validator = DefaultV1RecipeValidator(graph_schema=graph_schema) + monkeypatch.setattr( + validator, + "_raise_if_a_rule_policy_is_incompatible_with_domain", + lambda *args, **kwargs: None, + ) + monkeypatch.setattr( + validator, + "_warn_if_rule_based_data_is_unused_or_missing", + lambda *args, **kwargs: None, + ) + + if should_warn: + with pytest.warns( + UserWarning, + match=(f"'{RulePolicy.__name__}' is not " "included in the model's "), + ) as records: + validator.validate(importer) + else: + with pytest.warns(None) as records: + validator.validate(importer) + assert len(records) == 0 + + +class CustomTempRulePolicy(RulePolicy): + pass + + +@pytest.mark.parametrize( + "policy_types, should_raise", + [ + ([TEDPolicy], True), + ([RulePolicy], False), + ([MemoizationPolicy, RulePolicy], False), + ([CustomTempRulePolicy], False), + ], +) +def test_core_raise_if_domain_contains_form_names_but_no_rule_policy_given( + monkeypatch: MonkeyPatch, policy_types: List[Type[Policy]], should_raise: bool +): + domain_with_form = Domain.from_dict( + {KEY_FORMS: {"some-form": {"required_slots": []}}} + ) + importer = DummyImporter(domain=domain_with_form) + graph_schema = GraphSchema( + { + "policy": SchemaNode({}, policy_type, "", "", {}) + for policy_type in policy_types + } + ) + validator = DefaultV1RecipeValidator(graph_schema) + monkeypatch.setattr(validator, "_validate_nlu", lambda *args, **kwargs: None) + monkeypatch.setattr( + validator, "_warn_if_no_rule_policy_is_contained", lambda *args, **kwargs: None + ) + monkeypatch.setattr( + validator, + "_warn_if_rule_based_data_is_unused_or_missing", + lambda *args, **kwargs: None, + ) + if should_raise: + with pytest.raises( + InvalidDomain, + match="You have defined a form action, but have not added the", + ): + validator.validate(importer) + else: + validator.validate(importer) + + +def test_core_raise_if_a_rule_policy_is_incompatible_with_domain( + monkeypatch: MonkeyPatch, +): + + domain = Domain.empty() + + num_instances = 2 + nodes = {} + configs_for_rule_policies = [] + for feature_type in POLICY_CLASSSES: + for idx in range(num_instances): + unique_name = f"{feature_type.__name__}-{idx}" + unique_config = {unique_name: None} + nodes[unique_name] = SchemaNode({}, feature_type, "", "", unique_config) + if feature_type == RulePolicy: + configs_for_rule_policies.append(unique_config) + + mock = Mock() + monkeypatch.setattr(RulePolicy, "raise_if_incompatible_with_domain", mock) + + validator = DefaultV1RecipeValidator(graph_schema=GraphSchema(nodes)) + monkeypatch.setattr( + validator, + "_warn_if_rule_based_data_is_unused_or_missing", + lambda *args, **kwargs: None, + ) + importer = DummyImporter() + validator.validate(importer) + + # Note: this works because we validate nodes in insertion order + mock.all_args_list == [ + {"config": config, "domain": domain} for config in configs_for_rule_policies + ] + + +@pytest.mark.parametrize( + "policy_types, num_duplicates, priority", + [ + (POLICY_CLASSSES, 0, 0), + (POLICY_CLASSSES, 1, 1), + (list(POLICY_CLASSSES) * 2, 2, 3), + ], +) +def test_core_warn_if_policy_priorities_are_not_unique( + monkeypatch: MonkeyPatch, + policy_types: Set[Type[Policy]], + num_duplicates: bool, + priority: int, +): + + assert ( + len(policy_types) >= priority + num_duplicates + ), f"This tests needs at least {priority+num_duplicates} many types." + + # start with a schema where node i has priority i + nodes = { + f"{idx}": SchemaNode("", policy_type, "", "", {"priority": idx}) + for idx, policy_type in enumerate(policy_types) + } + + # give nodes p+1, .., p+num_duplicates-1 priority "priority" + for idx in range(num_duplicates): + nodes[f"{priority+idx+1}"].config["priority"] = priority + + validator = DefaultV1RecipeValidator(graph_schema=GraphSchema(nodes)) + monkeypatch.setattr( + validator, + "_warn_if_rule_based_data_is_unused_or_missing", + lambda *args, **kwargs: None, + ) + + importer = DummyImporter() + + if num_duplicates > 0: + duplicates = [ + node.uses + for idx_str, node in nodes.items() + if priority <= int(idx_str) <= priority + num_duplicates + ] + expected_message = ( + f"Found policies {_types_to_str(duplicates)} with same priority {priority} " + ) + expected_message = re.escape(expected_message) + with pytest.warns(UserWarning, match=expected_message): + validator.validate(importer) + else: + with pytest.warns(None) as records: + validator.validate(importer) + assert len(records) == 0 + + +def test_core_raise_if_policy_has_no_priority(): + class PolicyWithoutPriority(Policy, GraphComponent): + def __init__( + self, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> None: + super().__init__(config, model_storage, resource, execution_context) + + nodes = {"policy": SchemaNode("", PolicyWithoutPriority, "", "", {})} + graph_schema = GraphSchema(nodes) + importer = DummyImporter() + validator = DefaultV1RecipeValidator(graph_schema) + with pytest.raises( + InvalidConfigException, match="Every policy must have a priority value" + ): + validator.validate(importer) + + +@pytest.mark.parametrize("policy_type_consuming_rule_data", [RulePolicy]) +def test_core_warn_if_rule_data_missing(policy_type_consuming_rule_data: Type[Policy]): + + importer = TrainingDataImporter.load_from_dict( + domain_path="data/test_e2ebot/domain.yml", + training_data_paths=[ + "data/test_e2ebot/data/nlu.yml", + "data/test_e2ebot/data/stories.yml", + ], + ) + + graph_schema = GraphSchema( + {"policy": SchemaNode({}, policy_type_consuming_rule_data, "", "", {})} + ) + validator = DefaultV1RecipeValidator(graph_schema) + + with pytest.warns( + UserWarning, + match=( + "Found a rule-based policy in your configuration " + "but no rule-based training data." + ), + ): + validator.validate(importer) + + +@pytest.mark.parametrize( + "policy_type_not_consuming_rule_data", [TEDPolicy, MemoizationPolicy] +) +def test_core_warn_if_rule_data_unused( + policy_type_not_consuming_rule_data: Type[Policy], +): + + importer = TrainingDataImporter.load_from_dict( + domain_path="data/test_moodbot/domain.yml", + training_data_paths=[ + "data/test_moodbot/data/nlu.yml", + "data/test_moodbot/data/rules.yml", + ], + ) + + graph_schema = GraphSchema( + {"policy": SchemaNode({}, policy_type_not_consuming_rule_data, "", "", {})} + ) + validator = DefaultV1RecipeValidator(graph_schema) + + with pytest.warns( + UserWarning, + match=( + "Found rule-based training data but no policy " + "supporting rule-based data." + ), + ): + validator.validate(importer) + + +def test_nlu_training_data_validation(): + importer = DummyImporter( + training_data=TrainingData([Message({TEXT: "some text", INTENT: ""})]) + ) + nlu_validator = DefaultV1RecipeValidator(GraphSchema({})) + + with pytest.warns(UserWarning, match="Found empty intent"): + nlu_validator.validate(importer) + + +def test_no_warnings_with_default_project(tmp_path: Path): + rasa.utils.common.copy_directory(Path("rasa/cli/initial_project"), tmp_path) + + importer = TrainingDataImporter.load_from_config( + config_path=str(tmp_path / "config.yml"), + domain_path=str(tmp_path / "domain.yml"), + training_data_paths=[str(tmp_path / "data")], + ) + + config, _missing_keys, _configured_keys = DefaultV1Recipe.auto_configure( + importer.get_config_file_for_auto_config(), + importer.get_config(), + TrainingType.END_TO_END, + ) + graph_config = DefaultV1Recipe().graph_config_for_recipe( + config, cli_parameters={}, training_type=TrainingType.END_TO_END + ) + validator = DefaultV1RecipeValidator(graph_config.train_schema) + + with warnings.catch_warnings() as records: + validator.validate(importer) + + if records is not None: + records = filter_expected_warnings(records) + assert len(records) == 0 + + +def test_importer_with_invalid_model_config(tmp_path: Path): + invalid = {"version": "2.0", "policies": ["name"]} + config_file = tmp_path / "config.yml" + rasa.shared.utils.io.write_yaml(invalid, config_file) + + with pytest.raises(YamlValidationException): + importer = TrainingDataImporter.load_from_config(str(config_file)) + DefaultV1Recipe.auto_configure( + importer.get_config_file_for_auto_config(), + importer.get_config(), + TrainingType.END_TO_END, + ) diff --git a/tests/graph_components/validators/test_finetuning_validator.py b/tests/graph_components/validators/test_finetuning_validator.py new file mode 100644 index 0000000..627fa6f --- /dev/null +++ b/tests/graph_components/validators/test_finetuning_validator.py @@ -0,0 +1,526 @@ +import os +from pathlib import Path +import copy +from typing import Callable, List, Optional, Text, Dict, Any + +from _pytest.monkeypatch import MonkeyPatch +import pytest + + +from rasa.engine.graph import ExecutionContext, GraphComponent, GraphSchema, SchemaNode +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.graph_components.validators.finetuning_validator import FinetuningValidator +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +from rasa.core.policies.rule_policy import RulePolicy +from rasa.shared.constants import ( + DEFAULT_CONFIG_PATH, + DEFAULT_DATA_PATH, + DEFAULT_DOMAIN_PATH, +) +from rasa.shared.core.domain import KEY_RESPONSES, Domain +from rasa.shared.importers.rasa import RasaFileImporter +from rasa.shared.importers.importer import NluDataImporter, TrainingDataImporter +import rasa.shared.utils.io +from rasa.shared.exceptions import InvalidConfigException +from rasa.shared.nlu.constants import ACTION_NAME, INTENT, TEXT +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData + + +@pytest.fixture +def default_resource() -> Resource: + return Resource("FineTuningValidator") + + +ValidationMethodType = Callable[ + [TrainingDataImporter, Dict[Text, Any]], TrainingDataImporter +] + + +@pytest.fixture +def get_finetuning_validator( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + default_resource: Resource, +) -> Callable[[bool, bool, Dict[Text, Any], GraphSchema], FinetuningValidator]: + def inner( + finetuning: bool, + load: bool, + config: Dict[Text, Any], + graph_schema: Optional[GraphSchema] = None, + ) -> FinetuningValidator: + if load: + constructor = FinetuningValidator.load + else: + constructor = FinetuningValidator.create + if finetuning: + default_execution_context.is_finetuning = finetuning + if graph_schema is not None: + default_execution_context.graph_schema = graph_schema + return constructor( + config={**FinetuningValidator.get_default_config(), **config}, + execution_context=default_execution_context, + model_storage=default_model_storage, + resource=default_resource, + ) + + return inner + + +@pytest.fixture +def get_validation_method( + get_finetuning_validator: Callable[[bool, bool], FinetuningValidator] +) -> Callable[[bool, bool, bool, bool, GraphSchema], ValidationMethodType]: + def inner( + finetuning: bool, + load: bool, + nlu: bool, + core: bool, + graph_schema: Optional[GraphSchema] = None, + ) -> ValidationMethodType: + validator = get_finetuning_validator( + finetuning=finetuning, + load=load, + config={"validate_core": core, "validate_nlu": nlu}, + graph_schema=graph_schema, + ) + + return validator.validate + + return inner + + +class DummyNLUDataImporter(NluDataImporter): + def __init__(self, messages: List[Message]) -> None: + self.training_data = TrainingData(training_examples=messages) + + def get_config(self) -> Dict: + return {} + + def get_nlu_data(self, language: Optional[Text] = "en") -> TrainingData: + return self.training_data + + +class EmptyDataImporter(DummyNLUDataImporter): + def __init__(self) -> None: + super().__init__([]) + + +def _project_files( + project: Text, + config_file: Text = DEFAULT_CONFIG_PATH, + domain: Text = DEFAULT_DOMAIN_PATH, + training_files: Text = DEFAULT_DATA_PATH, +) -> TrainingDataImporter: + paths = { + "config_file": config_file, + "domain_path": domain, + "training_data_paths": training_files, + } + paths = { + k: v if v is None or Path(v).is_absolute() else os.path.join(project, v) + for k, v in paths.items() + } + paths["training_data_paths"] = [paths["training_data_paths"]] + + return RasaFileImporter(**paths) + + +@pytest.mark.parametrize("nlu, core", [(True, False), (False, True), (True, True)]) +def test_validate_after_changing_response_text_in_domain( + get_validation_method: Callable[..., ValidationMethodType], + project: Text, + nlu: bool, + core: bool, +): + # training + importer = _project_files(project) + old_domain = importer.get_domain() + + validate = get_validation_method(finetuning=False, load=False, core=core, nlu=nlu) + validate(importer=importer) + + # Change NLG content but keep actions the same + domain_with_changed_nlg = old_domain.as_dict() + domain_with_changed_nlg[KEY_RESPONSES]["utter_greet"].append({"text": "hi"}) + domain_with_changed_nlg = Domain.from_dict(domain_with_changed_nlg) + importer.get_domain = lambda: domain_with_changed_nlg + + # finetuning + loaded_validate = get_validation_method( + finetuning=False, load=True, core=core, nlu=nlu + ) + assert importer.get_domain() != old_domain + loaded_validate(importer=importer) + + +@pytest.mark.parametrize("nlu, core", [(True, False), (False, True), (True, True)]) +def test_validate_after_adding_action_to_domain( + get_validation_method: Callable[..., ValidationMethodType], + project: Text, + nlu: bool, + core: bool, +): + # training + importer = _project_files(project) + old_domain = importer.get_domain() + + validate = get_validation_method(finetuning=False, load=False, core=core, nlu=nlu) + validate(importer=importer) + + # Add another action - via the response key + domain_with_new_action = old_domain.as_dict() + domain_with_new_action[KEY_RESPONSES]["utter_new"] = [{"text": "hi"}] + domain_with_new_action = Domain.from_dict(domain_with_new_action) + importer.get_domain = lambda: domain_with_new_action + + # finetuning + loaded_validate = get_validation_method( + finetuning=True, load=True, core=core, nlu=nlu + ) + assert importer.get_domain() != old_domain + if core: + with pytest.raises(InvalidConfigException): + loaded_validate(importer=importer) + else: + loaded_validate(importer=importer) + + +def _get_example_schema(num_epochs: int = 5, other_parameter: int = 10) -> GraphSchema: + example_configs = [ + { + "epochs": num_epochs, + "other-parameter": other_parameter, + "some-parameter": "bla", + }, + {"epochs": num_epochs, "yet-other-parameter": 344}, + {"no-epochs-defined-here": None}, + ] + return GraphSchema( + nodes={ + f"node-{idx}": SchemaNode( + needs={}, uses=GraphComponent, constructor_name="", fn="", config=config + ) + for idx, config in enumerate(example_configs) + } + ) + + +@pytest.mark.parametrize("nlu, core", [(True, False), (False, True), (True, True)]) +def test_validate_after_changing_epochs_in_config( + get_validation_method: Callable[..., ValidationMethodType], nlu: bool, core: bool +): + # training + schema1 = _get_example_schema(num_epochs=5) + validate = get_validation_method( + finetuning=False, load=False, nlu=nlu, core=core, graph_schema=schema1 + ) + validate(importer=EmptyDataImporter()) + + # change schema - replace all epoch settings by a different value + schema2 = _get_example_schema(num_epochs=5) + for node in schema2.nodes.values(): + node.constructor_name = "other" + + # finetuning - does not complain + loaded_validate = get_validation_method( + finetuning=True, load=True, nlu=nlu, core=core, graph_schema=schema2 + ) + loaded_validate(importer=EmptyDataImporter()) + + +@pytest.mark.parametrize("nlu, core", [(True, False), (False, True), (True, True)]) +def test_validate_after_changing_constructor( + get_validation_method: Callable[..., ValidationMethodType], nlu: bool, core: bool +): + # training + schema1 = _get_example_schema(num_epochs=5) + validate = get_validation_method( + finetuning=False, load=False, nlu=nlu, core=core, graph_schema=schema1 + ) + validate(importer=EmptyDataImporter()) + + # change schema - replace all epoch settings by a different value + schema2 = _get_example_schema(num_epochs=10) + + # finetuning - does not complain + loaded_validate = get_validation_method( + finetuning=True, load=True, nlu=nlu, core=core, graph_schema=schema2 + ) + loaded_validate(importer=EmptyDataImporter()) + + +@pytest.mark.parametrize("nlu, core", [(True, False), (False, True), (True, True)]) +def test_validate_after_removing_node_from_schema( + get_validation_method: Callable[..., ValidationMethodType], nlu: bool, core: bool +): + # training + schema1 = _get_example_schema(num_epochs=5) + validate = get_validation_method( + finetuning=False, load=False, nlu=nlu, core=core, graph_schema=schema1 + ) + validate(importer=EmptyDataImporter()) + + # change schema - remove a node + schema2 = copy.deepcopy(schema1) + schema2.nodes.pop(next(iter(schema2.nodes.keys()))) + + # finetuning raises - doesn't matter if it's nlu/core/both + loaded_validate = get_validation_method( + finetuning=True, load=True, nlu=nlu, core=core, graph_schema=schema2 + ) + with pytest.raises(InvalidConfigException): + loaded_validate(importer=EmptyDataImporter()) + + +@pytest.mark.parametrize("nlu, core", [(True, False), (False, True), (True, True)]) +def test_validate_after_adding_node_to_schema( + get_validation_method: Callable[..., ValidationMethodType], nlu: bool, core: bool +): + # training + schema1 = _get_example_schema() + schema2 = copy.deepcopy(schema1) + schema2.nodes.pop(next(iter(schema2.nodes.keys()))) + + validate = get_validation_method( + finetuning=False, load=False, nlu=nlu, core=core, graph_schema=schema2 + ) + validate(importer=EmptyDataImporter()) + + # change schema - continue with the schema with one more node than before + assert len(schema1.nodes) > len(schema2.nodes) + + # finetuning raises - doesn't matter if it's nlu/core/both + loaded_validate = get_validation_method( + finetuning=True, load=True, nlu=nlu, core=core, graph_schema=schema1 + ) + with pytest.raises(InvalidConfigException): + loaded_validate(importer=EmptyDataImporter()) + + +@pytest.mark.parametrize( + "nlu, core, what", + [ + (nlu, core, what) + for what in ["uses", "needs", "fn", "config"] + for nlu, core in [(True, False), (False, True), (True, True)] + ], +) +def test_validate_after_replacing_something_in_schema( + get_validation_method: Callable[..., ValidationMethodType], + nlu: bool, + core: bool, + what: Text, +): + # training + schema1 = _get_example_schema() + validate = get_validation_method( + finetuning=False, load=False, nlu=nlu, core=core, graph_schema=schema1 + ) + validate(importer=EmptyDataImporter()) + + # change schema + schema2 = copy.deepcopy(schema1) + schema_node = schema2.nodes["node-0"] + if what == "uses": + schema_node.uses = WhitespaceTokenizer + elif what == "fn": + schema_node.fn = "a-new-function" + elif what == "needs": + schema_node.needs = {"something-new": "node-1"} + elif what == "config": + schema_node.config["other-parameter"] = "some-new-value" + else: + assert False, "Please fix this test." + + # finetuning raises - doesn't matter if it's nlu/core/both + loaded_validate = get_validation_method( + finetuning=True, load=True, nlu=nlu, core=core, graph_schema=schema2 + ) + with pytest.raises(InvalidConfigException): + loaded_validate(importer=EmptyDataImporter()) + + +@pytest.mark.parametrize("nlu, core", [(True, False), (False, True), (True, True)]) +def test_validate_after_adding_adding_default_parameter( + get_validation_method: Callable[..., ValidationMethodType], nlu: bool, core: bool +): + # create a schema and rely on rasa to fill in defaults later + schema1 = _get_example_schema() + schema1.nodes["nlu-node"] = SchemaNode( + needs={}, uses=WhitespaceTokenizer, constructor_name="", fn="", config={} + ) + schema1.nodes["core-node"] = SchemaNode( + needs={}, uses=RulePolicy, constructor_name="", fn="", config={} + ) + + # training + validate = get_validation_method( + finetuning=False, load=False, nlu=nlu, core=core, graph_schema=schema1 + ) + validate(importer=EmptyDataImporter()) + + # same schema -- we just explicitly pass default values + schema2 = copy.deepcopy(schema1) + schema2.nodes["nlu-node"] = SchemaNode( + needs={}, + uses=WhitespaceTokenizer, + constructor_name="", + fn="", + config=WhitespaceTokenizer.get_default_config(), + ) + schema2.nodes["core-node"] = SchemaNode( + needs={}, + uses=RulePolicy, + constructor_name="", + fn="", + config=RulePolicy.get_default_config(), + ) + + # finetuning *does not raise* + loaded_validate = get_validation_method( + finetuning=True, load=True, nlu=nlu, core=core, graph_schema=schema2 + ) + loaded_validate(importer=EmptyDataImporter()) + + +@pytest.mark.parametrize( + "nlu, core,key", + [ + (nlu, core, key) + for nlu, core in [(True, False), (False, True), (True, True)] + for key in [INTENT, ACTION_NAME] + ], +) +def test_validate_after_removing_or_adding_intent_or_action_name( + get_validation_method: Callable[..., ValidationMethodType], + nlu: bool, + core: bool, + key: Text, +): + messages = [Message(data={key: "item-1"}), Message(data={key: "item-2"})] + message_with_new_item = Message(data={key: "item-3"}) + + # training + importer = DummyNLUDataImporter(messages) + validate = get_validation_method(finetuning=False, load=False, nlu=nlu, core=core) + validate(importer=importer) + + # load validate method in finetuning mode + validate = get_validation_method(finetuning=True, load=True, nlu=nlu, core=core) + + # ... apply with something suddenly missing + importer2 = DummyNLUDataImporter(messages[1:]) + if nlu: + with pytest.raises(InvalidConfigException): + validate(importer=importer2) + else: + validate(importer=importer2) + + # ... apply with additional item + importer3 = DummyNLUDataImporter(messages + [message_with_new_item]) + if nlu: + with pytest.raises(InvalidConfigException): + validate(importer=importer3) + else: + validate(importer=importer3) + + +@pytest.mark.parametrize( + "nlu, core,key", + [ + (nlu, core, key) + for nlu, core in [(True, False), (False, True), (True, True)] + for key in [INTENT, ACTION_NAME] + ], +) +def test_validate_with_different_examples_for_intent_or_action_name( + get_validation_method: Callable[..., ValidationMethodType], + nlu: bool, + core: bool, + key: Text, +): + messages = [ + Message(data={key: "item-1", TEXT: "a"}), + Message(data={key: "item-2", TEXT: "b"}), + ] + + # training + importer = DummyNLUDataImporter(messages) + validate = get_validation_method(finetuning=False, load=False, nlu=nlu, core=core) + validate(importer=importer) + + # load validate method in finetuning mode + validate = get_validation_method(finetuning=True, load=True, nlu=nlu, core=core) + + # ... apply with different messages + messages = [ + Message(data={key: "item-1", TEXT: "c"}), + Message(data={key: "item-1", TEXT: "d"}), + Message(data={key: "item-2", TEXT: "e"}), + Message(data={key: "item-2", TEXT: "f"}), + ] + importer2 = DummyNLUDataImporter(messages) + # does not complain: + validate(importer=importer2) + + +@pytest.mark.parametrize( + "nlu, core, min_compatible_version, old_version, can_tune", + [ + (nlu, core, old_version, min_compatible_version, can_tune) + for nlu, core in [(True, False), (False, True), (True, True)] + for old_version, min_compatible_version, can_tune in [ + ("2.1.0", "2.1.0", True), + ("2.0.0", "2.1.0", True), + ("2.1.0", "2.0.0", False), + ] + ], +) +def test_validate_with_other_version( + monkeypatch: MonkeyPatch, + get_validation_method: Callable[..., ValidationMethodType], + nlu: bool, + core: bool, + min_compatible_version: Text, + old_version: Text, + can_tune: bool, +): + monkeypatch.setattr(rasa, "__version__", old_version) + monkeypatch.setattr( + rasa.graph_components.validators.finetuning_validator, + "MINIMUM_COMPATIBLE_VERSION", + min_compatible_version, + ) + + # training + importer = DummyNLUDataImporter([Message(data={INTENT: "dummy"})]) + validate = get_validation_method(finetuning=False, load=False, nlu=nlu, core=core) + validate(importer=importer) + + # finetuning + validate = get_validation_method(finetuning=True, load=True, nlu=nlu, core=core) + if not can_tune: + with pytest.raises(InvalidConfigException): + validate(importer=importer) + else: + validate(importer=importer) + + +@pytest.mark.parametrize("nlu, core", [(True, False), (False, True), (True, True)]) +def test_validate_with_finetuning_fails_without_training( + get_validation_method: Callable[..., ValidationMethodType], nlu: bool, core: bool +): + validate = get_validation_method(finetuning=True, load=False, nlu=nlu, core=core) + with pytest.raises(InvalidConfigException): + validate(importer=EmptyDataImporter()) + + +def test_loading_without_persisting( + get_finetuning_validator: Callable[ + [bool, bool, Dict[Text, bool]], FinetuningValidator + ] +): + with pytest.raises(ValueError): + get_finetuning_validator(finetuning=False, load=True, config={}) diff --git a/tests/integration_tests/__init__.py b/tests/integration_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration_tests/core/__init__.py b/tests/integration_tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration_tests/core/actions/__init__.py b/tests/integration_tests/core/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration_tests/core/actions/conftest.py b/tests/integration_tests/core/actions/conftest.py new file mode 100644 index 0000000..0285059 --- /dev/null +++ b/tests/integration_tests/core/actions/conftest.py @@ -0,0 +1,85 @@ +from typing import Text +import copy +import pytest + +from rasa.shared.core.domain import Domain +from rasa.shared.core.trackers import DialogueStateTracker + + +@pytest.fixture(scope="session") +def domain_yaml_content() -> Text: + return """ +intents: + - greet + - default + - goodbye + - affirm + - thank_you + - change_bank_details + - simple + - hello + - why + - next_intent + +entities: + - name + +slots: + name: + type: text + mappings: + - type: from_entity + entity: name + +responses: + utter_greet: + - text: "hey there {name}!" + utter_channel: + - text: "this is a default channel" + - text: "you're talking to me on slack!" + channel: "slack" + utter_goodbye: + - text: "goodbye 😢" + - text: "bye bye 😢" + utter_default: + - text: "sorry, I didn't get that, can you rephrase it?" + +forms: + some_form: + required_slots: + - name +""" + + +@pytest.fixture(scope="session") +def _domain(domain_yaml_content: Text) -> Domain: + return Domain.from_yaml(domain_yaml_content) + + +@pytest.fixture() +def domain(_domain: Domain) -> Domain: + return copy.deepcopy(_domain) + + +@pytest.fixture +def default_tracker(domain: Domain) -> DialogueStateTracker: + return DialogueStateTracker("my-sender", domain.slots) + + +@pytest.fixture +def domain_with_response_ids() -> Domain: + domain_yaml = """ + responses: + utter_one_id: + - text: test + id: '1' + utter_multiple_ids: + - text: test + id: '2' + - text: test + id: '3' + utter_no_id: + - text: test + """ + domain = Domain.from_yaml(domain_yaml) + return domain diff --git a/tests/integration_tests/core/actions/test_action.py b/tests/integration_tests/core/actions/test_action.py new file mode 100644 index 0000000..01bdb70 --- /dev/null +++ b/tests/integration_tests/core/actions/test_action.py @@ -0,0 +1,137 @@ +import asyncio +from unittest.mock import MagicMock + +import pytest + +from rasa.core.actions.action import ActionBotResponse +from rasa.core.channels import CollectingOutputChannel +from rasa.core.constants import DEFAULT_REQUEST_TIMEOUT +from rasa.core.nlg import CallbackNaturalLanguageGenerator +from rasa.core.nlg.callback import nlg_request_format +from rasa.shared.core.domain import Domain +from rasa.shared.core.trackers import DialogueStateTracker + + +@pytest.fixture +def mock_nlg_endpoint() -> MagicMock: + _mock_nlg_endpoint = MagicMock() + + future = asyncio.Future() + future.set_result({}) + + _mock_nlg_endpoint.request = MagicMock() + _mock_nlg_endpoint.request.return_value = future + return _mock_nlg_endpoint + + +async def test_action_bot_response_callback_nlg( + domain_with_response_ids: Domain, + default_tracker: DialogueStateTracker, + mock_nlg_endpoint: MagicMock, +): + """Test the response returned by the callback NLG endpoint.""" + callback_nlg = CallbackNaturalLanguageGenerator(mock_nlg_endpoint) + + output_channel = CollectingOutputChannel() + + events = await ActionBotResponse("utter_one_id").run( + output_channel, callback_nlg, default_tracker, domain_with_response_ids + ) + + expected_body = nlg_request_format( + "utter_one_id", + default_tracker, + output_channel.name(), + response_id="1", + ) + + mock_nlg_endpoint.request.assert_called_once_with( + method="post", json=expected_body, timeout=DEFAULT_REQUEST_TIMEOUT + ) + + assert len(events) == 1 + assert events[0].metadata == {"utter_action": "utter_one_id"} + + +async def test_action_bot_response_callback_with_multiple_response_id( + domain_with_response_ids: Domain, + default_tracker: DialogueStateTracker, + mock_nlg_endpoint: MagicMock, +) -> None: + callback_nlg = CallbackNaturalLanguageGenerator(mock_nlg_endpoint) + + output_channel = CollectingOutputChannel() + + events = await ActionBotResponse("utter_multiple_ids").run( + output_channel, callback_nlg, default_tracker, domain_with_response_ids + ) + + expected_body = nlg_request_format( + "utter_multiple_ids", + default_tracker, + output_channel.name(), + response_id="2", + ) + + mock_nlg_endpoint.request.assert_called_once_with( + method="post", json=expected_body, timeout=DEFAULT_REQUEST_TIMEOUT + ) + + assert len(events) == 1 + assert events[0].metadata == {"utter_action": "utter_multiple_ids"} + + +async def test_action_bot_response_with_empty_response_id_set( + domain_with_response_ids: Domain, + default_tracker: DialogueStateTracker, + mock_nlg_endpoint: MagicMock, +) -> None: + callback_nlg = CallbackNaturalLanguageGenerator(mock_nlg_endpoint) + + output_channel = CollectingOutputChannel() + + events = await ActionBotResponse("utter_no_id").run( + output_channel, callback_nlg, default_tracker, domain_with_response_ids + ) + + expected_body = nlg_request_format( + "utter_no_id", + default_tracker, + output_channel.name(), + response_id=None, + ) + + mock_nlg_endpoint.request.assert_called_once_with( + method="post", json=expected_body, timeout=DEFAULT_REQUEST_TIMEOUT + ) + + assert len(events) == 1 + assert events[0].metadata == {"utter_action": "utter_no_id"} + + +async def test_action_bot_response_with_non_existing_id_mapping( + domain_with_response_ids: Domain, + default_tracker: DialogueStateTracker, + mock_nlg_endpoint: MagicMock, +) -> None: + callback_nlg = CallbackNaturalLanguageGenerator(mock_nlg_endpoint) + + output_channel = CollectingOutputChannel() + + events = await ActionBotResponse("utter_non_existing").run( + output_channel, callback_nlg, default_tracker, domain_with_response_ids + ) + + expected_body = nlg_request_format( + "utter_non_existing", + default_tracker, + output_channel.name(), + response_id=None, + ) + + mock_nlg_endpoint.request.assert_called_once_with( + method="post", json=expected_body, timeout=DEFAULT_REQUEST_TIMEOUT + ) + + assert len(events) == 1 + assert events[0].metadata == {"utter_action": "utter_non_existing"} diff --git a/tests/integration_tests/core/brokers/__init__.py b/tests/integration_tests/core/brokers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration_tests/core/brokers/conftest.py b/tests/integration_tests/core/brokers/conftest.py new file mode 100644 index 0000000..1cd9cf0 --- /dev/null +++ b/tests/integration_tests/core/brokers/conftest.py @@ -0,0 +1,34 @@ +import os +from typing import Text + +import docker +import pytest + +RABBITMQ_HOST = os.getenv("RABBITMQ_HOST", "localhost") +RABBITMQ_PORT = os.getenv("RABBITMQ_PORT", 5672) +RABBITMQ_USER = os.getenv("RABBITMQ_USER", "") +RABBITMQ_PASSWORD = os.getenv("RABBITMQ_PASSWORD", "") +RABBITMQ_DEFAULT_QUEUE = "queue1" + + +@pytest.fixture +def docker_client() -> docker.DockerClient: + docker_client = docker.from_env() + prev_containers = docker_client.containers.list(all=True) + + for container in prev_containers: + container.stop() + + docker_client.containers.prune() + + return docker_client + + +@pytest.fixture +def rabbitmq_username() -> Text: + return "test_user" + + +@pytest.fixture +def rabbitmq_password() -> Text: + return "test_password" diff --git a/tests/integration_tests/core/brokers/test_kafka.py b/tests/integration_tests/core/brokers/test_kafka.py new file mode 100644 index 0000000..6be6eaa --- /dev/null +++ b/tests/integration_tests/core/brokers/test_kafka.py @@ -0,0 +1,54 @@ +import pytest + +from rasa.core.brokers.kafka import KafkaEventBroker +from pytest import LogCaptureFixture +import logging.config + + +@pytest.mark.broker +async def test_kafka_event_broker_valid(): + broker = KafkaEventBroker( + url="localhost", + topic="rasa", + sasl_username="admin", + sasl_password="password", + partition_by_sender=True, + ) + + try: + broker.publish( + {"sender_id": "valid_test", "event": "user", "text": "hello world!"}, + retries=5, + ) + assert broker.producer.poll() == 1 + finally: + await broker.close() + + +@pytest.mark.broker +async def test_kafka_event_broker_buffer_error_is_handled(caplog: LogCaptureFixture): + broker = KafkaEventBroker( + url="localhost", + topic="rasa", + sasl_username="admin", + sasl_password="password", + partition_by_sender=True, + queue_size=1, + ) + + event_count = 100 + try: + for i in range(event_count): + with caplog.at_level(logging.DEBUG): + broker.publish( + { + "sender_id": "valid_test", + "event": "user", + "text": "hello world!", + }, + retries=5, + ) + assert "Queue full" in caplog.text + assert broker.producer.poll() == 1 + finally: + await broker.close() diff --git a/tests/integration_tests/core/brokers/test_pika.py b/tests/integration_tests/core/brokers/test_pika.py new file mode 100644 index 0000000..b514b1f --- /dev/null +++ b/tests/integration_tests/core/brokers/test_pika.py @@ -0,0 +1,153 @@ +from typing import Text + +import docker +import pytest +import randomname +from pytest import LogCaptureFixture +from structlog.testing import capture_logs + +from rasa.core.brokers.pika import PikaEventBroker, RABBITMQ_EXCHANGE +from .conftest import ( + RABBITMQ_HOST, + RABBITMQ_PORT, + RABBITMQ_USER, + RABBITMQ_PASSWORD, + RABBITMQ_DEFAULT_QUEUE, +) + + +@pytest.mark.broker +async def test_pika_event_broker_connect(): + broker = PikaEventBroker( + host=RABBITMQ_HOST, + username=RABBITMQ_USER, + password=RABBITMQ_PASSWORD, + port=RABBITMQ_PORT, + queues=[RABBITMQ_DEFAULT_QUEUE], + ) + try: + await broker.connect() + assert broker.is_ready() + finally: + await broker.close() + + +@pytest.mark.broker +@pytest.mark.xdist_group("rabbitmq") +async def test_pika_event_broker_publish_after_restart( + docker_client: docker.DockerClient, + caplog: LogCaptureFixture, + rabbitmq_username: Text, + rabbitmq_password: Text, +) -> None: + environment = { + "RABBITMQ_DEFAULT_USER": rabbitmq_username, + "RABBITMQ_DEFAULT_PASS": rabbitmq_password, + } + + rabbitmq_container = docker_client.containers.run( + image="healthcheck/rabbitmq", + detach=True, + environment=environment, + name="rabbitmq", + ports={f"{RABBITMQ_PORT}/tcp": RABBITMQ_PORT}, + ) + rabbitmq_container.reload() + assert rabbitmq_container.status == "running" + + broker = PikaEventBroker( + host=RABBITMQ_HOST, + username=rabbitmq_username, + password=rabbitmq_password, + port=RABBITMQ_PORT, + queues=[RABBITMQ_DEFAULT_QUEUE], + should_keep_unpublished_messages=True, + ) + + await broker.connect() + assert broker.is_ready() + broker.publish({"event": "test"}) + + rabbitmq_container.stop() + rabbitmq_container.reload() + + assert rabbitmq_container.status == "exited" + + with capture_logs() as cap_logs: + event = {"event": "test_while_closed"} + await broker._publish(event) + + assert cap_logs[-1]["log_level"] == "error" + assert "pika.events.publish" in cap_logs[-1]["event"] + assert cap_logs[-1]["rasa_event"] == event + + # reconnect with the same broker + rabbitmq_container.restart() + rabbitmq_container.reload() + assert rabbitmq_container.status == "running" + + with capture_logs() as cap_logs: + await broker.connect() + assert broker.is_ready() + + after_restart_event = {"event": "test_after_restart"} + await broker._publish(after_restart_event) + + assert cap_logs[-1]["log_level"] == "debug" + assert "pika.events.publish" in cap_logs[-1]["event"] + assert cap_logs[-1]["rasa_event"] == after_restart_event + assert cap_logs[-1]["rabbitmq_exchange"] == RABBITMQ_EXCHANGE + + await broker.close() + + rabbitmq_container.stop() + rabbitmq_container.remove() + + +@pytest.mark.broker +@pytest.mark.xdist_group("rabbitmq") +@pytest.mark.parametrize("host_component", ["localhost", "myuser:mypassword@localhost"]) +async def test_pika_event_broker_connect_with_path_and_query_params_in_url( + docker_client: docker.DockerClient, + host_component: Text, +) -> None: + username = "myuser" + password = "mypassword" + vhost = "myvhost" + hostname = "my-rabbitmq" + + environment = { + "RABBITMQ_DEFAULT_USER": username, + "RABBITMQ_DEFAULT_PASS": password, + "RABBITMQ_DEFAULT_VHOST": vhost, + } + + rabbitmq_container = docker_client.containers.run( + image="rabbitmq:3-management", + detach=True, + environment=environment, + name=f"rabbitmq_{randomname.generate(5)}", + hostname=hostname, + ports={f"{RABBITMQ_PORT}/tcp": RABBITMQ_PORT, "15672/tcp": 15672}, + ) + rabbitmq_container.reload() + assert rabbitmq_container.status == "running" + + query_param = "heartbeat=5" + + broker = PikaEventBroker( + host=f"amqp://{host_component}/{vhost}?{query_param}", + username=username, + password=password, + port=RABBITMQ_PORT, + queues=[RABBITMQ_DEFAULT_QUEUE], + ) + + try: + await broker.connect() + assert broker.is_ready() + finally: + await broker.close() + + rabbitmq_container.stop() + rabbitmq_container.remove() diff --git a/tests/integration_tests/core/conftest.py b/tests/integration_tests/core/conftest.py new file mode 100644 index 0000000..c4c9329 --- /dev/null +++ b/tests/integration_tests/core/conftest.py @@ -0,0 +1,67 @@ +import os +from typing import Iterator, Text + +import pytest +import sqlalchemy as sa + +from rasa.core.lock_store import RedisLockStore + + +REDIS_HOST = os.getenv("REDIS_HOST", "localhost") +REDIS_PORT = os.getenv("REDIS_PORT", 6379) +POSTGRES_HOST = os.getenv("POSTGRES_HOST", "localhost") +POSTGRES_PORT = os.getenv("POSTGRES_PORT", 5432) +POSTGRES_USER = os.getenv("POSTGRES_USER", "") +POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "") +POSTGRES_DEFAULT_DB = os.getenv("POSTGRES_DEFAULT_DB", "postgres") +POSTGRES_TRACKER_STORE_DB = "tracker_store_db" +POSTGRES_LOGIN_DB = "login_db" + + +@pytest.fixture +def redis_lock_store() -> Iterator[RedisLockStore]: + # we need one redis database per worker, otherwise + # tests conflicts with each others when databases are flushed + pytest_worker_id = os.getenv("PYTEST_XDIST_WORKER", "gw0") + redis_database = int(pytest_worker_id.replace("gw", "")) + lock_store = RedisLockStore(REDIS_HOST, REDIS_PORT, redis_database) + try: + yield lock_store + finally: + lock_store.red.flushdb() + + +@pytest.fixture +def postgres_login_db_connection() -> Iterator[sa.engine.Connection]: + engine = sa.create_engine( + sa.engine.url.URL( + "postgresql", + host=POSTGRES_HOST, + port=POSTGRES_PORT, + username=POSTGRES_USER, + password=POSTGRES_PASSWORD, + database=POSTGRES_DEFAULT_DB, + ) + ) + + conn = engine.connect() + try: + _create_login_db(conn) + yield conn + finally: + _drop_db(conn, POSTGRES_LOGIN_DB) + _drop_db(conn, POSTGRES_TRACKER_STORE_DB) + conn.close() + engine.dispose() + + +def _create_login_db(connection: sa.engine.Connection) -> None: + connection.execution_options(isolation_level="AUTOCOMMIT").execute( + f"CREATE DATABASE {POSTGRES_LOGIN_DB}" + ) + + +def _drop_db(connection: sa.engine.Connection, database_name: Text) -> None: + connection.execution_options(isolation_level="AUTOCOMMIT").execute( + f"DROP DATABASE IF EXISTS {database_name}" + ) diff --git a/tests/integration_tests/core/test_agent.py b/tests/integration_tests/core/test_agent.py new file mode 100644 index 0000000..b5a34ad --- /dev/null +++ b/tests/integration_tests/core/test_agent.py @@ -0,0 +1,123 @@ +import json +import os +from pathlib import Path +from typing import Any, Text + +import boto3 +import pytest +from moto import mock_iam, mock_s3 +from pytest import MonkeyPatch + +from rasa.core.agent import Agent +from rasa.nlu.persistor import AWSPersistor +from rasa.shared.exceptions import RasaException + + +@pytest.fixture +def bucket_name() -> Text: + """Name of the bucket to use for testing.""" + return "rasa-test" + + +@pytest.fixture +def region_name() -> Text: + """Name of the region to use for testing.""" + return "us-west-2" + + +@pytest.fixture +def aws_endpoint_url() -> Text: + """URL of the moto testing server.""" + return "http://localhost:5000" + + +@mock_iam +def create_user_with_access_key_and_attached_policy(region_name: Text) -> Any: + """Create a user and an access key for them.""" + client = boto3.client("iam", region_name=region_name) + client.create_user(UserName="test_user") + + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllObjectActions", + "Effect": "Allow", + "Action": "s3:*Object", + "Resource": [f"arn:aws:s3:::{bucket_name}/*"], + } + ], + } + + policy_arn = client.create_policy( + PolicyName="test_policy", PolicyDocument=json.dumps(policy_document) + )["Policy"]["Arn"] + client.attach_user_policy(UserName="test_user", PolicyArn=policy_arn) + + return client.create_access_key(UserName="test_user")["AccessKey"] + + +@pytest.fixture +def aws_environment_variables( + bucket_name: Text, + region_name: Text, + aws_endpoint_url: Text, +) -> None: + """Set AWS environment variables for testing.""" + os.environ["BUCKET_NAME"] = bucket_name + os.environ["AWS_ENDPOINT_URL"] = aws_endpoint_url + os.environ["AWS_DEFAULT_REGION"] = region_name + + access_key = create_user_with_access_key_and_attached_policy(region_name) + + os.environ["AWS_ACCESS_KEY_ID"] = access_key["AccessKeyId"] + os.environ["AWS_SECRET_ACCESS_KEY"] = access_key["SecretAccessKey"] + os.environ["AWS_SECURITY_TOKEN"] = "testing" + os.environ["AWS_SESSION_TOKEN"] = "testing" + + os.environ["TEST_SERVER_MODE"] = "true" + + +@mock_s3 +def test_load_model_from_aws_remote_storage( + monkeypatch: MonkeyPatch, + aws_environment_variables: Any, + bucket_name: Text, + region_name: Text, + aws_endpoint_url: Text, + trained_rasa_model: Text, + empty_agent: Agent, +) -> None: + """Test to load model from AWS remote storage.""" + model_name = Path(trained_rasa_model).name + + conn = boto3.resource("s3", region_name=region_name) + # We need to create the bucket in Moto's 'virtual' AWS account + # prior to AWSPersistor instantiation + conn.create_bucket( + Bucket=bucket_name, + CreateBucketConfiguration={"LocationConstraint": region_name}, + ) + # upload model file to bucket + with open(trained_rasa_model, "rb") as f: + conn.meta.client.upload_fileobj(f, bucket_name, model_name) + + def mock_aws_persistor(name: Text) -> AWSPersistor: + aws_persistor = AWSPersistor( + os.environ.get("BUCKET_NAME"), + region_name=os.environ.get("AWS_DEFAULT_REGION"), + ) + monkeypatch.setattr(aws_persistor, "s3", conn) + monkeypatch.setattr(aws_persistor, "bucket", conn.Bucket(bucket_name)) + return aws_persistor + + monkeypatch.setattr("rasa.nlu.persistor.get_persistor", mock_aws_persistor) + + empty_agent.remote_storage = "aws" + + try: + empty_agent.load_model_from_remote_storage(model_name) + assert empty_agent.processor.model_filename == model_name + + except RasaException as exc: + assert False, f"Test to load model from remote storage failed: {exc}" diff --git a/tests/integration_tests/core/test_cli_response.py b/tests/integration_tests/core/test_cli_response.py new file mode 100644 index 0000000..35a0a4f --- /dev/null +++ b/tests/integration_tests/core/test_cli_response.py @@ -0,0 +1,183 @@ +from pathlib import Path +import pytest +from typing import Callable +import shutil +from _pytest.pytester import Pytester +from _pytest.pytester import RunResult +from _pytest.fixtures import FixtureRequest + + +# NOTE this will be extended to test cli logs at process run to validate log level +@pytest.fixture +def run(pytester: Pytester) -> Callable[..., RunResult]: + def do_run(*args): + args = [shutil.which("rasa")] + list(args) + return pytester.run(*args) + + return do_run + + +def test_rasa_validate_debug_no_errors( + run: Callable[..., RunResult], request: FixtureRequest +): + # Test captures the subprocess output for the command run + # validates that the data in 'data/test/test_integration' throws no cli errors + # in 'debug' mode + test_data_dir = Path(request.config.rootdir, "data", "test", "test_integration") + test_config_dir = Path(request.config.rootdir, "data", "test_config") + source_file = (test_data_dir).absolute() + domain_file = (test_data_dir / "domain.yml").absolute() + config_file = (test_config_dir / "config_unique_assistant_id.yml").absolute() + result = run( + "data", + "validate", + "--data", + str(source_file), + "-d", + str(domain_file), + "-c", + str(config_file), + "--debug", + ) + assert result.ret == 0 + assert "DEBUG" in str(result.stderr) + output_text = "".join(result.outlines) + assert "Rasa Open Source reports anonymous usage telemetry" + "to help improve the product" in output_text + assert "for all its users." in output_text + assert "If you'd like to opt-out," + "you can use `rasa telemetry disable`." in output_text + assert "To learn more, check out" + "https://rasa.com/docs/rasa/telemetry/telemetry." in output_text + + +def test_rasa_validate_debug_with_errors( + run: Callable[..., RunResult], request: FixtureRequest +): + # Test captures the subprocess output for the command run + # validates that the data in 'data/test/test_integration_incorrect' + # throws cli errors about intent "greet" + # in 'debug' mode + err = ( + "UserWarning: The intent 'greet' is listed in the domain file, " + "but is not found in the NLU training data." + ) + test_data_dir = Path(request.config.rootdir, "data", "test", "test_integration_err") + test_config_dir = Path(request.config.rootdir, "data", "test_config") + source_file = (test_data_dir).absolute() + domain_file = (test_data_dir / "domain.yml").absolute() + config_file = (test_config_dir / "config_unique_assistant_id.yml").absolute() + result = run( + "data", + "validate", + "--data", + str(source_file), + "-d", + str(domain_file), + "-c", + str(config_file), + "--fail-on-warnings", + "--debug", + ) + assert result.ret == 1 + assert err in str(result.stderr) + + +def test_rasa_validate_verbose_no_errors( + run: Callable[..., RunResult], request: FixtureRequest +): + # Test captures the subprocess output for the command run + # and validates that the data in 'data/test/test_integration' throws no cli errors + # in 'verbose' mode + test_data_dir = Path(request.config.rootdir, "data", "test", "test_integration") + test_config_dir = Path(request.config.rootdir, "data", "test_config") + source_file = (test_data_dir).absolute() + domain_file = (test_data_dir / "domain.yml").absolute() + config_file = (test_config_dir / "config_unique_assistant_id.yml").absolute() + result = run( + "data", + "validate", + "--data", + str(source_file), + "-d", + str(domain_file), + "-c", + str(config_file), + "--verbose", + ) + assert result.ret == 0 + assert "INFO" in str(result.stderr) + output_text = "".join(result.outlines) + assert "Rasa Open Source reports anonymous usage telemetry" + "to help improve the product" in output_text + assert "for all its users." in output_text + assert "If you'd like to opt-out," + "you can use `rasa telemetry disable`." in output_text + assert "To learn more, check out" + "https://rasa.com/docs/rasa/telemetry/telemetry." in output_text + + +def test_rasa_validate_quiet_no_errors( + run: Callable[..., RunResult], request: FixtureRequest +): + # Test captures the subprocess output for the command run + # and validates that the data in 'data/test/test_integration' throws no cli errors + # in 'quiet' mode + test_data_dir = Path(request.config.rootdir, "data", "test", "test_integration") + test_config_dir = Path(request.config.rootdir, "data", "test_config") + source_file = (test_data_dir).absolute() + domain_file = (test_data_dir / "domain.yml").absolute() + config_file = (test_config_dir / "config_unique_assistant_id.yml").absolute() + result = run( + "data", + "validate", + "--data", + str(source_file), + "-d", + str(domain_file), + "-c", + str(config_file), + "--quiet", + ) + assert result.ret == 0 + output_text = "".join(result.outlines) + assert "Rasa Open Source reports anonymous usage telemetry" + "to help improve the product" in output_text + assert "for all its users." in output_text + assert "If you'd like to opt-out," + "you can use `rasa telemetry disable`." in output_text + assert "To learn more, check out" + "https://rasa.com/docs/rasa/telemetry/telemetry." in output_text + + +def test_rasa_validate_null_active_loop_no_errors( + run: Callable[..., RunResult], request: FixtureRequest +): + # Test captures the subprocess output for the command run + # and validates that the data in 'data/test/test_integration' throws no cli errors + + test_data_dir = Path(request.config.rootdir, "data", "test", "test_integration") + test_config_dir = Path(request.config.rootdir, "data", "test_config") + source_file = (test_data_dir / "data").absolute() + domain_file = (test_data_dir / "domain.yml").absolute() + config_file = (test_config_dir / "config_unique_assistant_id.yml").absolute() + result = run( + "data", + "validate", + "--data", + str(source_file), + "-d", + str(domain_file), + "-c", + str(config_file), + ) + assert result.ret == 0 + + stderr_text = str(result.stderr) + assert "INFO" in stderr_text + assert "Validating intents..." in stderr_text + assert "Validating utterances..." in stderr_text + assert "Story structure validation..." in stderr_text + assert "Validating utterances..." in stderr_text + assert "Considering all preceding turns for conflict analysis." in stderr_text + assert "No story structure conflicts found." in stderr_text diff --git a/tests/integration_tests/core/test_exporter.py b/tests/integration_tests/core/test_exporter.py new file mode 100644 index 0000000..2b3e8b8 --- /dev/null +++ b/tests/integration_tests/core/test_exporter.py @@ -0,0 +1,117 @@ +import textwrap +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from pytest import MonkeyPatch + +from rasa.core.brokers.kafka import KafkaEventBroker +from rasa.core.exporter import Exporter +from rasa.core.tracker_store import InMemoryTrackerStore +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ActionExecuted +from rasa.shared.core.trackers import DialogueStateTracker + + +@pytest.mark.broker +async def test_exporter_publishes_to_kafka_broker_success( + tmp_path: Path, +) -> None: + tracker_store = InMemoryTrackerStore(domain=Domain.empty()) + tracker = DialogueStateTracker.from_events( + "test_export", + [ + ActionExecuted("action_listen"), + ], + ) + + await tracker_store.save(tracker) + + kafka_broker = KafkaEventBroker( + url="localhost", + topic="rasa", + sasl_username="admin", + sasl_password="password", + partition_by_sender=True, + ) + + endpoints_file = tmp_path / "endpoints.yml" + endpoints_file.write_text( + textwrap.dedent( + """ + event_broker: + type: kafka + topic: rasa + url: localhost:9092 + client_id: kafka-python-rasa + partition_by_sender: true + security_protocol: SASL_PLAINTEXT + sasl_username: admin + sasl_password: password + sasl_mechanism: PLAIN + """ + ) + ) + + exporter = Exporter(tracker_store, kafka_broker, str(endpoints_file)) + + published_events = await exporter.publish_events() + assert published_events == 1 + + +@pytest.mark.broker +async def test_exporter_publishes_to_kafka_broker_fail( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + tracker_store = InMemoryTrackerStore(domain=Domain.empty()) + tracker = DialogueStateTracker.from_events( + "test_export", + [ + ActionExecuted("action_listen"), + ], + ) + + await tracker_store.save(tracker) + + kafka_broker = KafkaEventBroker( + url="localhost", + topic="rasa", + sasl_username="admin", + sasl_password="password", + partition_by_sender=True, + ) + + endpoints_file = tmp_path / "endpoints.yml" + endpoints_file.write_text( + textwrap.dedent( + """ + event_broker: + type: kafka + topic: rasa + url: localhost:9092 + client_id: kafka-python-rasa + partition_by_sender: true + security_protocol: SASL_PLAINTEXT + sasl_username: admin + sasl_password: password + sasl_mechanism: PLAIN + """ + ) + ) + + exporter = Exporter(tracker_store, kafka_broker, str(endpoints_file)) + + # patch the exporter to raise an exception when publishing events + monkeypatch.setattr(exporter, "publish_events", Mock(side_effect=Exception)) + + with pytest.raises(Exception) as error: + await exporter.publish_events() + assert "Producer terminating with 1 messages" in str(error.value) + assert ( + "still in queue or transit: use flush() to wait for " + "outstanding message delivery" in str(error.value) + ) + # necessary for producer teardown + await kafka_broker.close() diff --git a/tests/integration_tests/core/test_lock_store.py b/tests/integration_tests/core/test_lock_store.py new file mode 100644 index 0000000..166b8c4 --- /dev/null +++ b/tests/integration_tests/core/test_lock_store.py @@ -0,0 +1,194 @@ +import asyncio +import time +from pathlib import Path + +import numpy as np +import pytest +from _pytest.monkeypatch import MonkeyPatch + +from rasa.core.agent import Agent +from rasa.core.channels import UserMessage +from rasa.shared.constants import INTENT_MESSAGE_PREFIX +from rasa.core.lock_store import LockError, RedisLockStore + + +def test_create_lock_store(redis_lock_store: RedisLockStore): + conversation_id = "my id 0" + + # create and lock + lock = redis_lock_store.create_lock(conversation_id) + redis_lock_store.save_lock(lock) + lock = redis_lock_store.get_lock(conversation_id) + assert lock + assert lock.conversation_id == conversation_id + + +def test_serve_ticket(redis_lock_store: RedisLockStore): + conversation_id = "my id 1" + + lock = redis_lock_store.create_lock(conversation_id) + redis_lock_store.save_lock(lock) + + # issue ticket with long lifetime + ticket_0 = redis_lock_store.issue_ticket(conversation_id, 10) + assert ticket_0 == 0 + + lock = redis_lock_store.get_lock(conversation_id) + assert lock.last_issued == ticket_0 + assert lock.now_serving == ticket_0 + assert lock.is_someone_waiting() + + # issue another ticket + ticket_1 = redis_lock_store.issue_ticket(conversation_id, 10) + + # finish serving ticket_0 + redis_lock_store.finish_serving(conversation_id, ticket_0) + + lock = redis_lock_store.get_lock(conversation_id) + + assert lock.last_issued == ticket_1 + assert lock.now_serving == ticket_1 + assert lock.is_someone_waiting() + + # serve second ticket and no one should be waiting + redis_lock_store.finish_serving(conversation_id, ticket_1) + + lock = redis_lock_store.get_lock(conversation_id) + assert not lock.is_someone_waiting() + + +# noinspection PyProtectedMember +def test_lock_expiration(redis_lock_store: RedisLockStore): + conversation_id = "my id 2" + lock = redis_lock_store.create_lock(conversation_id) + redis_lock_store.save_lock(lock) + + # issue ticket with long lifetime + ticket = lock.issue_ticket(10) + assert ticket == 0 + assert not lock._ticket_for_ticket_number(ticket).has_expired() + + # issue ticket with short lifetime + ticket = lock.issue_ticket(0.00001) + time.sleep(0.00002) + assert ticket == 1 + assert lock._ticket_for_ticket_number(ticket) is None + + # newly assigned ticket should get number 1 again + assert lock.issue_ticket(10) == 1 + + +async def test_multiple_conversation_ids(default_agent: Agent): + text = INTENT_MESSAGE_PREFIX + 'greet{"name":"Rasa"}' + + conversation_ids = [f"conversation {i}" for i in range(2)] + + # ensure conversations are processed in order + tasks = [default_agent.handle_text(text, sender_id=_id) for _id in conversation_ids] + results = await asyncio.gather(*tasks) + + assert results + processed_ids = [result[0]["recipient_id"] for result in results] + assert processed_ids == conversation_ids + + +async def test_message_order( + tmp_path: Path, default_agent: Agent, monkeypatch: MonkeyPatch +): + start_time = time.time() + n_messages = 10 + lock_wait = 0.5 + + # let's write the incoming order of messages and the order of results to temp files + results_file = tmp_path / "results_file" + incoming_order_file = tmp_path / "incoming_order_file" + + # We need to mock `Agent.handle_message()` so we can introduce an + # artificial holdup (`wait_time_in_seconds`). In the mocked method, we'll + # record messages as they come and and as they're processed in files so we + # can check the order later on. We don't need the return value of this method so + # we'll just return None. + async def mocked_handle_message(self, message: UserMessage, wait: float) -> None: + # write incoming message to file + with open(str(incoming_order_file), "a+") as f_0: + f_0.write(message.text + "\n") + + async with self.lock_store.lock( + message.sender_id, wait_time_in_seconds=lock_wait + ): + # hold up the message processing after the lock has been acquired + await asyncio.sleep(wait) + + # write message to file as it's processed + with open(str(results_file), "a+") as f_1: + f_1.write(message.text + "\n") + + return None + + # We'll send n_messages from the same sender_id with different blocking times + # after the lock has been acquired. + # We have to ensure that the messages are processed in the right order. + monkeypatch.setattr(Agent, "handle_message", mocked_handle_message) + # use decreasing wait times so that every message after the first one + # does not acquire its lock immediately + wait_times = np.linspace(0.1, 0.05, n_messages) + tasks = [ + default_agent.handle_message( + UserMessage(f"sender {i}", sender_id="some id"), wait=k + ) + for i, k in enumerate(wait_times) + ] + + # execute futures + await asyncio.gather(*(asyncio.ensure_future(t) for t in tasks)) + + expected_order = [f"sender {i}" for i in range(len(wait_times))] + + # ensure order of incoming messages is as expected + with open(str(incoming_order_file)) as f: + incoming_order = [line for line in f.read().split("\n") if line] + assert incoming_order == expected_order + + # ensure results are processed in expected order + with open(str(results_file)) as f: + results_order = [line for line in f.read().split("\n") if line] + assert results_order == expected_order + + # Every message after the first one will wait `lock_wait` seconds to acquire its + # lock (`wait_time_in_seconds` kwarg in `lock_store.lock()`). + # Let's make sure that this is not blocking and test that total test + # execution time is less than the sum of all wait times plus + # (n_messages - 1) * lock_wait + time_limit = np.sum(wait_times[1:]) + time_limit += (n_messages - 1) * lock_wait + assert time.time() - start_time < time_limit + + +async def test_lock_error(default_agent: Agent, monkeypatch: MonkeyPatch): + lock_lifetime = 0.01 + wait_time_in_seconds = 0.01 + holdup = 0.5 + + # Mock message handler again to add a wait time holding up the lock + # after it's been acquired + async def mocked_handle_message(self, message: UserMessage) -> None: + async with self.lock_store.lock( + message.sender_id, + wait_time_in_seconds=wait_time_in_seconds, + lock_lifetime=lock_lifetime, + ): + # hold up the message processing after the lock has been acquired + await asyncio.sleep(holdup) + + return None + + monkeypatch.setattr(Agent, "handle_message", mocked_handle_message) + # first message blocks the lock for `holdup`, + # meaning the second message will not be able to acquire a lock + tasks = [ + default_agent.handle_message(UserMessage(f"sender {i}", sender_id="some id")) + for i in range(2) + ] + + with pytest.raises(LockError): + await asyncio.gather(*(asyncio.ensure_future(t) for t in tasks)) diff --git a/tests/integration_tests/core/test_tracker_store.py b/tests/integration_tests/core/test_tracker_store.py new file mode 100644 index 0000000..8c5e3b4 --- /dev/null +++ b/tests/integration_tests/core/test_tracker_store.py @@ -0,0 +1,271 @@ +import logging +from typing import List +from unittest.mock import Mock + +import pytest +from _pytest.logging import LogCaptureFixture +from _pytest.monkeypatch import MonkeyPatch +import sqlalchemy as sa + +from rasa.core.tracker_store import ( + MongoTrackerStore, + RedisTrackerStore, + SQLTrackerStore, +) +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import Event +from rasa.shared.core.trackers import DialogueStateTracker +from .conftest import ( + POSTGRES_HOST, + POSTGRES_PORT, + POSTGRES_LOGIN_DB, + POSTGRES_TRACKER_STORE_DB, + POSTGRES_USER, + POSTGRES_PASSWORD, +) + +# NOTE about the timeouts in this file. We want to fail fast +# because SQLTrackerStore tries to connect several times +# until it works. If the timeout is hit, it probably means +# that something is wrong in the setup of the test + + +@pytest.mark.sequential +@pytest.mark.timeout(10, func_only=True) +def test_sql_tracker_store_with_login_db( + postgres_login_db_connection: sa.engine.Connection, +): + tracker_store = SQLTrackerStore( + dialect="postgresql", + host=POSTGRES_HOST, + port=POSTGRES_PORT, + username=POSTGRES_USER, + password=POSTGRES_PASSWORD, + db=POSTGRES_TRACKER_STORE_DB, + login_db=POSTGRES_LOGIN_DB, + ) + + matching_rows = ( + postgres_login_db_connection.execution_options(isolation_level="AUTOCOMMIT") + .execute( + sa.text( + "SELECT 1 FROM pg_catalog.pg_database WHERE datname = :database_name" + ), + database_name=POSTGRES_TRACKER_STORE_DB, + ) + .rowcount + ) + assert matching_rows == 1 + assert tracker_store.engine.url.database == POSTGRES_TRACKER_STORE_DB + tracker_store.engine.dispose() + + +@pytest.mark.sequential +@pytest.mark.timeout(10, func_only=True) +def test_sql_tracker_store_with_login_db_db_already_exists( + postgres_login_db_connection: sa.engine.Connection, +): + postgres_login_db_connection.execution_options( + isolation_level="AUTOCOMMIT" + ).execute(f"CREATE DATABASE {POSTGRES_TRACKER_STORE_DB}") + + tracker_store = SQLTrackerStore( + dialect="postgresql", + host=POSTGRES_HOST, + port=POSTGRES_PORT, + username=POSTGRES_USER, + password=POSTGRES_PASSWORD, + db=POSTGRES_TRACKER_STORE_DB, + login_db=POSTGRES_LOGIN_DB, + ) + + matching_rows = ( + postgres_login_db_connection.execution_options(isolation_level="AUTOCOMMIT") + .execute( + sa.text( + "SELECT 1 FROM pg_catalog.pg_database WHERE datname = :database_name" + ), + database_name=POSTGRES_TRACKER_STORE_DB, + ) + .rowcount + ) + assert matching_rows == 1 + tracker_store.engine.dispose() + + +@pytest.mark.sequential +@pytest.mark.timeout(10, func_only=True) +def test_sql_tracker_store_with_login_db_race_condition( + postgres_login_db_connection: sa.engine.Connection, + caplog: LogCaptureFixture, + monkeypatch: MonkeyPatch, +): + original_execute = sa.engine.Connection.execute + + def mock_execute(self, *args, **kwargs): + # this simulates a race condition + if kwargs == {"database_name": POSTGRES_TRACKER_STORE_DB}: + original_execute( + self.execution_options(isolation_level="AUTOCOMMIT"), + f"CREATE DATABASE {POSTGRES_TRACKER_STORE_DB}", + ) + return Mock(rowcount=0) + else: + return original_execute(self, *args, **kwargs) + + with monkeypatch.context() as mp: + mp.setattr(sa.engine.Connection, "execute", mock_execute) + with caplog.at_level(logging.ERROR): + tracker_store = SQLTrackerStore( + dialect="postgresql", + host=POSTGRES_HOST, + port=POSTGRES_PORT, + username=POSTGRES_USER, + password=POSTGRES_PASSWORD, + db=POSTGRES_TRACKER_STORE_DB, + login_db=POSTGRES_LOGIN_DB, + ) + + # IntegrityError has been caught and we log the error + assert any( + [ + f"Could not create database '{POSTGRES_TRACKER_STORE_DB}'" in record.message + for record in caplog.records + ] + ) + matching_rows = ( + postgres_login_db_connection.execution_options(isolation_level="AUTOCOMMIT") + .execute( + sa.text( + "SELECT 1 FROM pg_catalog.pg_database WHERE datname = :database_name" + ), + database_name=POSTGRES_TRACKER_STORE_DB, + ) + .rowcount + ) + assert matching_rows == 1 + tracker_store.engine.dispose() + + +@pytest.mark.sequential +@pytest.mark.timeout(10, func_only=True) +async def test_postgres_tracker_store_retrieve_full_tracker( + tracker_with_restarted_event: DialogueStateTracker, + postgres_login_db_connection: sa.engine.Connection, +) -> None: + sender_id = tracker_with_restarted_event.sender_id + + postgres_login_db_connection.execution_options( + isolation_level="AUTOCOMMIT" + ).execute(f"CREATE DATABASE {POSTGRES_TRACKER_STORE_DB}") + + tracker_store = SQLTrackerStore( + dialect="postgresql", + host=POSTGRES_HOST, + port=POSTGRES_PORT, + username=POSTGRES_USER, + password=POSTGRES_PASSWORD, + db=POSTGRES_TRACKER_STORE_DB, + login_db=POSTGRES_LOGIN_DB, + ) + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.retrieve_full_tracker(sender_id) + assert tracker is not None + assert tracker == tracker_with_restarted_event + + tracker_store.engine.dispose() + + +@pytest.mark.sequential +@pytest.mark.timeout(10, func_only=True) +async def test_postgres_tracker_store_retrieve( + tracker_with_restarted_event: DialogueStateTracker, + events_after_restart: List[Event], + postgres_login_db_connection: sa.engine.Connection, +) -> None: + sender_id = tracker_with_restarted_event.sender_id + + postgres_login_db_connection.execution_options( + isolation_level="AUTOCOMMIT" + ).execute(f"CREATE DATABASE {POSTGRES_TRACKER_STORE_DB}") + + tracker_store = SQLTrackerStore( + dialect="postgresql", + host=POSTGRES_HOST, + port=POSTGRES_PORT, + username=POSTGRES_USER, + password=POSTGRES_PASSWORD, + db=POSTGRES_TRACKER_STORE_DB, + login_db=POSTGRES_LOGIN_DB, + ) + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.retrieve(sender_id) + assert tracker is not None + + # the retrieved tracker with the latest session would not contain + # `action_session_start` event because the SQLTrackerStore filters + # only the events after `session_started` event + + assert list(tracker.events) == events_after_restart[1:] + + tracker_store.engine.dispose() + + +async def test_redis_tracker_store_retrieve_full_tracker( + domain: Domain, + tracker_with_restarted_event: DialogueStateTracker, +) -> None: + tracker_store = RedisTrackerStore(domain) + sender_id = tracker_with_restarted_event.sender_id + + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.retrieve_full_tracker(sender_id) + assert tracker == tracker_with_restarted_event + + +async def test_redis_tracker_store_retrieve( + domain: Domain, + tracker_with_restarted_event: DialogueStateTracker, + events_after_restart: List[Event], +) -> None: + tracker_store = RedisTrackerStore(domain) + sender_id = tracker_with_restarted_event.sender_id + + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.retrieve(sender_id) + assert list(tracker.events) == events_after_restart + + +async def test_mongo_tracker_store_retrieve_full_tracker( + domain: Domain, + tracker_with_restarted_event: DialogueStateTracker, +) -> None: + tracker_store = MongoTrackerStore(domain) + sender_id = tracker_with_restarted_event.sender_id + + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.retrieve_full_tracker(sender_id) + assert tracker == tracker_with_restarted_event + + +async def test_mongo_tracker_store_retrieve( + domain: Domain, + tracker_with_restarted_event: DialogueStateTracker, + events_after_restart: List[Event], +) -> None: + tracker_store = MongoTrackerStore(domain) + sender_id = tracker_with_restarted_event.sender_id + + await tracker_store.save(tracker_with_restarted_event) + + tracker = await tracker_store.retrieve(sender_id) + + # the retrieved tracker with the latest session would not contain + # `action_session_start` event because the MongoTrackerStore filters + # only the events after `session_started` event + assert list(tracker.events) == events_after_restart[1:] diff --git a/tests/nlu/__init__.py b/tests/nlu/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/nlu/classifiers/__init__.py b/tests/nlu/classifiers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/nlu/classifiers/test_diet_classifier.py b/tests/nlu/classifiers/test_diet_classifier.py new file mode 100644 index 0000000..1fd84fd --- /dev/null +++ b/tests/nlu/classifiers/test_diet_classifier.py @@ -0,0 +1,1061 @@ +import copy +from pathlib import Path + +import numpy as np +import pytest +from typing import Callable, List, Optional, Text, Dict, Any, Tuple + +import rasa.utils.common +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.exceptions import InvalidConfigException +from rasa.shared.importers.rasa import RasaFileImporter +from rasa.shared.nlu.training_data.features import Features +from rasa.nlu.constants import BILOU_ENTITIES +from rasa.nlu.classifiers import LABEL_RANKING_LENGTH +from rasa.shared.nlu.constants import ( + TEXT, + INTENT, + ENTITIES, + FEATURE_TYPE_SENTENCE, + FEATURE_TYPE_SEQUENCE, + PREDICTED_CONFIDENCE_KEY, + INTENT_NAME_KEY, +) +from rasa.utils import train_utils +from rasa.utils.tensorflow.constants import ( + LOSS_TYPE, + RANDOM_SEED, + RANKING_LENGTH, + EPOCHS, + MASKED_LM, + RENORMALIZE_CONFIDENCES, + TENSORBOARD_LOG_LEVEL, + TENSORBOARD_LOG_DIR, + EVAL_NUM_EPOCHS, + EVAL_NUM_EXAMPLES, + CONSTRAIN_SIMILARITIES, + CHECKPOINT_MODEL, + BILOU_FLAG, + ENTITY_RECOGNITION, + INTENT_CLASSIFICATION, + MODEL_CONFIDENCE, + HIDDEN_LAYERS_SIZES, + RUN_EAGERLY, +) +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +from rasa.nlu.classifiers.diet_classifier import DIETClassifier +from rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer import ( + CountVectorsFeaturizer, +) +from rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer import ( + LexicalSyntacticFeaturizer, +) +from rasa.nlu.featurizers.sparse_featurizer.regex_featurizer import RegexFeaturizer +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.constants import DIAGNOSTIC_DATA +from rasa.shared.nlu.training_data.loading import load_data +from rasa.utils.tensorflow.model_data_utils import FeatureArray + + +@pytest.fixture() +def default_diet_resource() -> Resource: + return Resource("DIET") + + +@pytest.fixture() +def create_diet( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + default_diet_resource: Resource, +) -> Callable[..., DIETClassifier]: + def inner( + config: Dict[Text, Any], load: bool = False, finetune: bool = False + ) -> DIETClassifier: + if load: + constructor = DIETClassifier.load + else: + constructor = DIETClassifier.create + + default_execution_context.is_finetuning = finetune + return constructor( + config=rasa.utils.common.override_defaults( + DIETClassifier.get_default_config(), config + ), + model_storage=default_model_storage, + execution_context=default_execution_context, + resource=default_diet_resource, + ) + + return inner + + +@pytest.fixture() +def create_train_load_and_process_diet( + nlu_data_path: Text, + create_diet: Callable[..., DIETClassifier], + train_load_and_process_diet: Callable[..., Message], +) -> Callable[..., Message]: + def inner( + diet_config: Dict[Text, Any], + pipeline: Optional[List[Dict[Text, Any]]] = None, + training_data: str = nlu_data_path, + message_text: Text = "Rasa is great!", + expect_intent: bool = True, + ) -> Message: + diet = create_diet(diet_config) + return train_load_and_process_diet( + diet=diet, + pipeline=pipeline, + training_data=training_data, + message_text=message_text, + expect_intent=expect_intent, + ) + + return inner + + +@pytest.fixture() +def train_load_and_process_diet( + nlu_data_path: Text, + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], + process_message: Callable[..., Message], + create_diet: Callable[..., DIETClassifier], + default_model_storage: ModelStorage, +) -> Callable[..., Message]: + def inner( + diet: DIETClassifier, + pipeline: Optional[List[Dict[Text, Any]]] = None, + training_data: str = nlu_data_path, + message_text: Text = "Rasa is great!", + expect_intent: bool = True, + ) -> Message: + + if not pipeline: + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": CountVectorsFeaturizer}, + ] + + training_data, loaded_pipeline = train_and_preprocess(pipeline, training_data) + + diet.train(training_data=training_data) + + message = Message(data={TEXT: message_text}) + message = process_message(loaded_pipeline, message) + + message2 = copy.deepcopy(message) + + classified_message = diet.process([message])[0] + + if expect_intent: + assert classified_message.data["intent"]["name"] + + loaded_diet = create_diet(diet.component_config, load=True) + + classified_message2 = loaded_diet.process([message2])[0] + + assert classified_message2.fingerprint() == classified_message.fingerprint() + + return loaded_diet, classified_message + + return inner + + +def test_compute_default_label_features(): + label_features = [ + Message(data={TEXT: "test a"}), + Message(data={TEXT: "test b"}), + Message(data={TEXT: "test c"}), + Message(data={TEXT: "test d"}), + ] + + output = DIETClassifier._compute_default_label_features(label_features) + + output = output[0] + + for i, o in enumerate(output): + assert isinstance(o, np.ndarray) + assert o[0][i] == 1 + assert o.shape == (1, len(label_features)) + + +@pytest.mark.parametrize( + "messages, expected", + [ + ( + [ + Message( + data={TEXT: "test a"}, + features=[ + Features(np.zeros(1), FEATURE_TYPE_SEQUENCE, TEXT, "test"), + Features(np.zeros(1), FEATURE_TYPE_SENTENCE, TEXT, "test"), + ], + ), + Message( + data={TEXT: "test b"}, + features=[ + Features(np.zeros(1), FEATURE_TYPE_SEQUENCE, TEXT, "test"), + Features(np.zeros(1), FEATURE_TYPE_SENTENCE, TEXT, "test"), + ], + ), + ], + True, + ), + ( + [ + Message( + data={TEXT: "test a"}, + features=[ + Features(np.zeros(1), FEATURE_TYPE_SEQUENCE, INTENT, "test"), + Features(np.zeros(1), FEATURE_TYPE_SENTENCE, INTENT, "test"), + ], + ) + ], + False, + ), + ( + [ + Message( + data={TEXT: "test a"}, + features=[ + Features(np.zeros(2), FEATURE_TYPE_SEQUENCE, INTENT, "test") + ], + ) + ], + False, + ), + ], +) +def test_check_labels_features_exist( + messages: List[Message], expected: bool, create_diet: Callable[..., DIETClassifier] +): + attribute = TEXT + classifier = create_diet({}) + assert classifier._check_labels_features_exist(messages, attribute) == expected + + +@pytest.mark.parametrize( + "messages, entity_expected", + [ + ( + [ + Message( + data={ + TEXT: "test a", + INTENT: "intent a", + ENTITIES: [ + {"start": 0, "end": 4, "value": "test", "entity": "test"} + ], + } + ), + Message( + data={ + TEXT: "test b", + INTENT: "intent b", + ENTITIES: [ + {"start": 0, "end": 4, "value": "test", "entity": "test"} + ], + } + ), + ], + True, + ), + ( + [ + Message(data={TEXT: "test a", INTENT: "intent a"}), + Message(data={TEXT: "test b", INTENT: "intent b"}), + ], + False, + ), + ], +) +def test_model_data_signature_with_entities( + messages: List[Message], + entity_expected: bool, + create_diet: Callable[..., DIETClassifier], + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], +): + classifier = create_diet({"BILOU_flag": False}) + training_data = TrainingData(messages) + + # create tokens and features for entity parsing inside DIET + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": CountVectorsFeaturizer}, + ] + training_data, loaded_pipeline = train_and_preprocess(pipeline, training_data) + model_data = classifier.preprocess_train_data(training_data) + entity_exists = "entities" in model_data.get_signature().keys() + + assert entity_exists == entity_expected + + +@pytest.mark.skip_on_windows +@pytest.mark.timeout(240, func_only=True) +async def test_train_persist_load_with_different_settings_non_windows( + create_train_load_and_process_diet: Callable[..., Message], + create_diet: Callable[..., DIETClassifier], +): + pipeline = [ + { + "component": WhitespaceTokenizer, + "intent_tokenization_flag": True, + "intent_split_symbol": "+", + }, + {"component": CountVectorsFeaturizer}, + ] + config = {MASKED_LM: True, EPOCHS: 1, RUN_EAGERLY: True} + create_train_load_and_process_diet(config, pipeline) + create_diet(config, load=True, finetune=True) + + +@pytest.mark.timeout(240, func_only=True) +async def test_train_persist_load_with_different_settings( + create_train_load_and_process_diet: Callable[..., Message], + create_diet: Callable[..., DIETClassifier], +): + config = {LOSS_TYPE: "margin", EPOCHS: 1, RUN_EAGERLY: True} + create_train_load_and_process_diet(config) + create_diet(config, load=True, finetune=True) + + +@pytest.mark.timeout(240, func_only=True) +async def test_train_persist_load_with_nested_dict_config( + create_train_load_and_process_diet: Callable[..., Message], + create_diet: Callable[..., DIETClassifier], +): + config = { + HIDDEN_LAYERS_SIZES: {"text": [256, 512]}, + ENTITY_RECOGNITION: False, + EPOCHS: 1, + RUN_EAGERLY: True, + } + create_train_load_and_process_diet(config) + create_diet(config, load=True, finetune=True) + + +@pytest.mark.timeout(240, func_only=True) +async def test_train_persist_load_with_masked_lm_and_eval( + nlu_data_path: Text, + create_train_load_and_process_diet: Callable[..., Message], + create_diet: Callable[..., DIETClassifier], +): + # need at least number of intents as eval num examples... + # reading the used data here so that the test doesn't break if data is changed + importer = RasaFileImporter(training_data_paths=[nlu_data_path]) + training_data = importer.get_nlu_data() + config = { + MASKED_LM: True, + EVAL_NUM_EXAMPLES: len(training_data.intents), + EPOCHS: 10, + RUN_EAGERLY: True, + } + create_train_load_and_process_diet(config) + create_diet(config, load=True, finetune=True) + + +@pytest.mark.timeout(210, func_only=True) +async def test_train_persist_load_with_only_entity_recognition( + create_train_load_and_process_diet: Callable[..., Message], + create_diet: Callable[..., DIETClassifier], +): + config = { + ENTITY_RECOGNITION: True, + INTENT_CLASSIFICATION: False, + EPOCHS: 1, + RUN_EAGERLY: True, + } + create_train_load_and_process_diet( + config, + training_data="data/examples/rasa/demo-rasa-multi-intent.yml", + expect_intent=False, + ) + create_diet(config, load=True, finetune=True) + + +@pytest.mark.timeout(120, func_only=True) +async def test_train_persist_load_with_only_intent_classification( + create_train_load_and_process_diet: Callable[..., Message], + create_diet: Callable[..., DIETClassifier], +): + create_train_load_and_process_diet( + { + ENTITY_RECOGNITION: False, + INTENT_CLASSIFICATION: True, + EPOCHS: 1, + RUN_EAGERLY: True, + } + ) + create_diet( + {MASKED_LM: True, EPOCHS: 1, RUN_EAGERLY: True}, load=True, finetune=True + ) + + +@pytest.mark.parametrize( + "classifier_params, data_path, output_length, output_should_sum_to_1", + [ + ( + {}, + "data/test/many_intents.yml", + LABEL_RANKING_LENGTH, + False, + ), # (num_intents > default ranking_length) + ( + {RENORMALIZE_CONFIDENCES: True}, + "data/test/many_intents.yml", + LABEL_RANKING_LENGTH, + True, + ), # (num_intents > default ranking_length) + renormalize + ( + {RANKING_LENGTH: 0}, + "data/test/many_intents.yml", + 16, + True, + ), # (ranking_length := num_intents) + ( + {RANKING_LENGTH: 0, RENORMALIZE_CONFIDENCES: True}, + "data/test/many_intents.yml", + 16, + True, + ), # (ranking_length := num_intents) + (unnecessary) renormalize + ( + {RANKING_LENGTH: LABEL_RANKING_LENGTH + 1}, + "data/test/many_intents.yml", + LABEL_RANKING_LENGTH + 1, + False, + ), # (num_intents > specified ranking_length) + ( + {RANKING_LENGTH: LABEL_RANKING_LENGTH + 1, RENORMALIZE_CONFIDENCES: True}, + "data/test/many_intents.yml", + LABEL_RANKING_LENGTH + 1, + True, + ), # (num_intents > specified ranking_length) + renormalize + ( + {}, + "data/test_moodbot/data/nlu.yml", + 7, + True, + ), # (num_intents < default ranking_length) + ], +) +async def test_softmax_normalization( + classifier_params, + data_path: Text, + output_length, + output_should_sum_to_1, + create_train_load_and_process_diet: Callable[..., Message], +): + classifier_params[RANDOM_SEED] = 42 + classifier_params[EPOCHS] = 1 + classifier_params[RUN_EAGERLY] = True + classifier_params[EVAL_NUM_EPOCHS] = 1 + + _, parsed_message = create_train_load_and_process_diet( + classifier_params, training_data=data_path + ) + parse_data = parsed_message.data + intent_ranking = parse_data.get("intent_ranking") + # check that the output was correctly truncated after normalization + assert len(intent_ranking) == output_length + + # check whether normalization had the expected effect + output_sums_to_1 = sum( + [intent.get("confidence") for intent in intent_ranking] + ) == pytest.approx(1) + assert output_sums_to_1 == output_should_sum_to_1 + + # check whether the normalization of rankings is reflected in intent prediction + assert parse_data.get("intent") == intent_ranking[0] + + +async def test_margin_loss_is_not_normalized( + create_train_load_and_process_diet: Callable[..., Message] +): + _, parsed_message = create_train_load_and_process_diet( + { + LOSS_TYPE: "margin", + RANDOM_SEED: 42, + EPOCHS: 1, + EVAL_NUM_EPOCHS: 1, + RUN_EAGERLY: True, + }, + training_data="data/test/many_intents.yml", + ) + parse_data = parsed_message.data + intent_ranking = parse_data.get("intent_ranking") + + # check that the output was correctly truncated + assert len(intent_ranking) == LABEL_RANKING_LENGTH + + # check that output was not normalized + assert [item["confidence"] for item in intent_ranking] != pytest.approx(1) + + # make sure top ranking is reflected in intent prediction + assert parse_data.get("intent") == intent_ranking[0] + + +@pytest.mark.timeout(120, func_only=True) +async def test_set_random_seed( + create_train_load_and_process_diet: Callable[..., Message] +): + """test if train result is the same for two runs of tf embedding""" + + _, parsed_message1 = create_train_load_and_process_diet( + {ENTITY_RECOGNITION: False, RANDOM_SEED: 1, EPOCHS: 1, RUN_EAGERLY: True} + ) + + _, parsed_message2 = create_train_load_and_process_diet( + {ENTITY_RECOGNITION: False, RANDOM_SEED: 1, EPOCHS: 1, RUN_EAGERLY: True} + ) + + # Different random seed + _, parsed_message3 = create_train_load_and_process_diet( + {ENTITY_RECOGNITION: False, RANDOM_SEED: 2, EPOCHS: 1, RUN_EAGERLY: True} + ) + + assert ( + parsed_message1.data["intent"]["confidence"] + == parsed_message2.data["intent"]["confidence"] + ) + assert ( + parsed_message2.data["intent"]["confidence"] + != parsed_message3.data["intent"]["confidence"] + ) + + +@pytest.mark.parametrize("log_level", ["epoch", "batch"]) +async def test_train_tensorboard_logging( + log_level: Text, + tmpdir: Path, + create_train_load_and_process_diet: Callable[..., Message], +): + tensorboard_log_dir = Path(tmpdir / "tensorboard") + + assert not tensorboard_log_dir.exists() + + pipeline = [ + {"component": WhitespaceTokenizer}, + { + "component": CountVectorsFeaturizer, + "analyzer": "char_wb", + "min_ngram": 3, + "max_ngram": 17, + "max_features": 10, + "min_df": 5, + }, + ] + + create_train_load_and_process_diet( + { + EPOCHS: 1, + TENSORBOARD_LOG_LEVEL: log_level, + TENSORBOARD_LOG_DIR: str(tensorboard_log_dir), + MODEL_CONFIDENCE: "softmax", + CONSTRAIN_SIMILARITIES: True, + EVAL_NUM_EXAMPLES: 15, + EVAL_NUM_EPOCHS: 1, + RUN_EAGERLY: True, + }, + pipeline, + ) + + assert tensorboard_log_dir.exists() + + all_files = list(tensorboard_log_dir.rglob("*.*")) + assert len(all_files) == 2 + + +@pytest.mark.flaky +async def test_train_model_checkpointing( + default_model_storage: ModelStorage, + default_diet_resource: Resource, + create_train_load_and_process_diet: Callable[..., Message], +): + create_train_load_and_process_diet( + { + EPOCHS: 2, + EVAL_NUM_EPOCHS: 1, + EVAL_NUM_EXAMPLES: 10, + CHECKPOINT_MODEL: True, + RUN_EAGERLY: True, + } + ) + with default_model_storage.read_from(default_diet_resource) as model_dir: + all_files = list(model_dir.rglob("*.*")) + assert any(["from_checkpoint" in str(filename) for filename in all_files]) + + +async def test_process_unfeaturized_input( + create_train_load_and_process_diet: Callable[..., Message], +): + classifier, _ = create_train_load_and_process_diet( + diet_config={EPOCHS: 1, RUN_EAGERLY: True} + ) + message_text = "message text" + unfeaturized_message = Message(data={TEXT: message_text}) + classified_message = classifier.process([unfeaturized_message])[0] + + assert classified_message.get(TEXT) == message_text + assert not classified_message.get(INTENT)[INTENT_NAME_KEY] + assert classified_message.get(INTENT)[PREDICTED_CONFIDENCE_KEY] == 0.0 + assert not classified_message.get(ENTITIES) + + +async def test_train_model_not_checkpointing( + default_model_storage: ModelStorage, + default_diet_resource: Resource, + create_train_load_and_process_diet: Callable[..., Message], +): + create_train_load_and_process_diet( + {EPOCHS: 1, CHECKPOINT_MODEL: False, RUN_EAGERLY: True} + ) + + with default_model_storage.read_from(default_diet_resource) as model_dir: + all_files = list(model_dir.rglob("*.*")) + assert not any(["from_checkpoint" in str(filename) for filename in all_files]) + + +async def test_train_fails_with_zero_eval_num_epochs( + create_diet: Callable[..., DIETClassifier] +): + with pytest.raises(InvalidConfigException): + with pytest.warns(UserWarning) as warning: + create_diet( + { + EPOCHS: 1, + CHECKPOINT_MODEL: True, + EVAL_NUM_EPOCHS: 0, + EVAL_NUM_EXAMPLES: 10, + } + ) + + warn_text = ( + f"You have opted to save the best model, but the value of '{EVAL_NUM_EPOCHS}' " + f"is not -1 or greater than 0. Training will fail." + ) + assert len([w for w in warning if warn_text in str(w.message)]) == 1 + + +async def test_doesnt_checkpoint_with_zero_eval_num_examples( + create_diet: Callable[..., DIETClassifier], + default_model_storage: ModelStorage, + default_diet_resource: Resource, + train_load_and_process_diet: Callable[..., Message], +): + with pytest.warns(UserWarning) as warning: + classifier = create_diet( + { + EPOCHS: 2, + CHECKPOINT_MODEL: True, + EVAL_NUM_EXAMPLES: 0, + EVAL_NUM_EPOCHS: 1, + RUN_EAGERLY: True, + } + ) + + warn_text = ( + f"You have opted to save the best model, but the value of " + f"'{EVAL_NUM_EXAMPLES}' is not greater than 0. No checkpoint model " + f"will be saved." + ) + assert len([w for w in warning if warn_text in str(w.message)]) == 1 + + train_load_and_process_diet(classifier) + + with default_model_storage.read_from(default_diet_resource) as model_dir: + all_files = list(model_dir.rglob("*.*")) + assert not any(["from_checkpoint" in str(filename) for filename in all_files]) + + +@pytest.mark.parametrize( + "classifier_params", + [ + {RANDOM_SEED: 1, EPOCHS: 1, BILOU_FLAG: False, RUN_EAGERLY: True}, + {RANDOM_SEED: 1, EPOCHS: 1, BILOU_FLAG: True, RUN_EAGERLY: True}, + ], +) +async def test_train_persist_load_with_composite_entities( + classifier_params: Dict[Text, Any], + create_train_load_and_process_diet: Callable[..., Message], +): + create_train_load_and_process_diet( + classifier_params, + training_data="data/test/demo-rasa-composite-entities.yml", + message_text="I am looking for an italian restaurant", + ) + + +@pytest.mark.parametrize("should_add_diagnostic_data", [True, False]) +async def test_process_gives_diagnostic_data( + create_train_load_and_process_diet: Callable[..., Message], + default_execution_context: ExecutionContext, + should_add_diagnostic_data: bool, +): + default_execution_context.should_add_diagnostic_data = should_add_diagnostic_data + default_execution_context.node_name = "DIETClassifier_node_name" + _, processed_message = create_train_load_and_process_diet( + {EPOCHS: 1, RUN_EAGERLY: True} + ) + + if should_add_diagnostic_data: + # Tests if processing a message returns attention weights as numpy array. + diagnostic_data = processed_message.get(DIAGNOSTIC_DATA) + + # DIETClassifier should add attention weights + name = "DIETClassifier_node_name" + assert isinstance(diagnostic_data, dict) + assert name in diagnostic_data + assert "attention_weights" in diagnostic_data[name] + assert isinstance(diagnostic_data[name].get("attention_weights"), np.ndarray) + assert "text_transformed" in diagnostic_data[name] + assert isinstance(diagnostic_data[name].get("text_transformed"), np.ndarray) + else: + assert DIAGNOSTIC_DATA not in processed_message.data + + +@pytest.mark.parametrize( + "initial_sparse_feature_sizes, final_sparse_feature_sizes, label_attribute", + [ + ( + { + TEXT: {FEATURE_TYPE_SEQUENCE: [10], FEATURE_TYPE_SENTENCE: [20]}, + INTENT: {FEATURE_TYPE_SEQUENCE: [5], FEATURE_TYPE_SENTENCE: []}, + }, + {TEXT: {FEATURE_TYPE_SEQUENCE: [10], FEATURE_TYPE_SENTENCE: [20]}}, + INTENT, + ), + ( + {TEXT: {FEATURE_TYPE_SEQUENCE: [10], FEATURE_TYPE_SENTENCE: [20]}}, + {TEXT: {FEATURE_TYPE_SEQUENCE: [10], FEATURE_TYPE_SENTENCE: [20]}}, + INTENT, + ), + ], +) +def test_removing_label_sparse_feature_sizes( + initial_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + final_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + label_attribute: Text, +): + """Tests if label attribute is removed from sparse feature sizes collection.""" + feature_sizes = DIETClassifier._remove_label_sparse_feature_sizes( + sparse_feature_sizes=initial_sparse_feature_sizes, + label_attribute=label_attribute, + ) + assert feature_sizes == final_sparse_feature_sizes + + +@pytest.mark.timeout(120) +async def test_adjusting_layers_incremental_training( + create_diet: Callable[..., DIETClassifier], + train_load_and_process_diet: Callable[..., Message], +): + """Tests adjusting sparse layers of `DIETClassifier` to increased sparse + feature sizes during incremental training. + + Testing is done by checking the layer sizes. + Checking if they were replaced correctly is also important + and is done in `test_replace_dense_for_sparse_layers` + in `test_rasa_layers.py`. + """ + iter1_data_path = "data/test_incremental_training/iter1/" + iter2_data_path = "data/test_incremental_training/" + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": LexicalSyntacticFeaturizer}, + {"component": RegexFeaturizer}, + {"component": CountVectorsFeaturizer}, + { + "component": CountVectorsFeaturizer, + "analyzer": "char_wb", + "min_ngram": 1, + "max_ngram": 4, + }, + ] + classifier = create_diet({EPOCHS: 1, RUN_EAGERLY: True}) + _, processed_message = train_load_and_process_diet( + classifier, pipeline=pipeline, training_data=iter1_data_path + ) + + old_data_signature = classifier.model.data_signature + old_predict_data_signature = classifier.model.predict_data_signature + old_sparse_feature_sizes = processed_message.get_sparse_feature_sizes( + attribute=TEXT + ) + initial_diet_layers = classifier.model._tf_layers["sequence_layer.text"]._tf_layers[ + "feature_combining" + ] + initial_diet_sequence_layer = initial_diet_layers._tf_layers[ + "sparse_dense.sequence" + ]._tf_layers["sparse_to_dense"] + initial_diet_sentence_layer = initial_diet_layers._tf_layers[ + "sparse_dense.sentence" + ]._tf_layers["sparse_to_dense"] + + initial_diet_sequence_size = initial_diet_sequence_layer.get_kernel().shape[0] + initial_diet_sentence_size = initial_diet_sentence_layer.get_kernel().shape[0] + assert initial_diet_sequence_size == sum( + old_sparse_feature_sizes[FEATURE_TYPE_SEQUENCE] + ) + assert initial_diet_sentence_size == sum( + old_sparse_feature_sizes[FEATURE_TYPE_SENTENCE] + ) + + finetune_classifier = create_diet( + {EPOCHS: 1, RUN_EAGERLY: True}, load=True, finetune=True + ) + assert finetune_classifier.finetune_mode + _, processed_message_finetuned = train_load_and_process_diet( + finetune_classifier, pipeline=pipeline, training_data=iter2_data_path + ) + + new_sparse_feature_sizes = processed_message_finetuned.get_sparse_feature_sizes( + attribute=TEXT + ) + + final_diet_layers = finetune_classifier.model._tf_layers[ + "sequence_layer.text" + ]._tf_layers["feature_combining"] + final_diet_sequence_layer = final_diet_layers._tf_layers[ + "sparse_dense.sequence" + ]._tf_layers["sparse_to_dense"] + final_diet_sentence_layer = final_diet_layers._tf_layers[ + "sparse_dense.sentence" + ]._tf_layers["sparse_to_dense"] + + final_diet_sequence_size = final_diet_sequence_layer.get_kernel().shape[0] + final_diet_sentence_size = final_diet_sentence_layer.get_kernel().shape[0] + assert final_diet_sequence_size == sum( + new_sparse_feature_sizes[FEATURE_TYPE_SEQUENCE] + ) + assert final_diet_sentence_size == sum( + new_sparse_feature_sizes[FEATURE_TYPE_SENTENCE] + ) + # check if the data signatures were correctly updated + new_data_signature = finetune_classifier.model.data_signature + new_predict_data_signature = finetune_classifier.model.predict_data_signature + iter2_data = load_data(iter2_data_path) + expected_sequence_lengths = len(iter2_data.training_examples) + + def test_data_signatures( + new_signature: Dict[Text, Dict[Text, List[FeatureArray]]], + old_signature: Dict[Text, Dict[Text, List[FeatureArray]]], + ): + # Wherever attribute / feature_type signature is not + # expected to change, directly compare it to old data signature. + # Else compute its expected signature and compare + attributes_expected_to_change = [TEXT] + feature_types_expected_to_change = [ + FEATURE_TYPE_SEQUENCE, + FEATURE_TYPE_SENTENCE, + ] + + for attribute, signatures in new_signature.items(): + + for feature_type, feature_signatures in signatures.items(): + + if feature_type == "sequence_lengths": + assert feature_signatures[0].units == expected_sequence_lengths + + elif feature_type not in feature_types_expected_to_change: + assert feature_signatures == old_signature.get(attribute).get( + feature_type + ) + else: + for index, feature_signature in enumerate(feature_signatures): + if ( + feature_signature.is_sparse + and attribute in attributes_expected_to_change + ): + assert feature_signature.units == sum( + new_sparse_feature_sizes.get(feature_type) + ) + else: + # dense signature or attributes that are not + # expected to change can be compared directly + assert ( + feature_signature.units + == old_signature.get(attribute) + .get(feature_type)[index] + .units + ) + + test_data_signatures(new_data_signature, old_data_signature) + test_data_signatures(new_predict_data_signature, old_predict_data_signature) + + +# FIXME: these tests take too long to run in CI on Windows, disabling them for now +@pytest.mark.skip_on_windows +@pytest.mark.timeout(120) +@pytest.mark.parametrize( + "iter1_path, iter2_path, should_raise_exception", + [ + ( + "data/test_incremental_training/", + "data/test_incremental_training/iter1", + True, + ), + ( + "data/test_incremental_training/iter1", + "data/test_incremental_training/", + False, + ), + ], +) +async def test_sparse_feature_sizes_decreased_incremental_training( + iter1_path: Text, + iter2_path: Text, + should_raise_exception: bool, + create_diet: Callable[..., DIETClassifier], + train_load_and_process_diet: Callable[..., Message], +): + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": LexicalSyntacticFeaturizer}, + {"component": RegexFeaturizer}, + {"component": CountVectorsFeaturizer}, + { + "component": CountVectorsFeaturizer, + "analyzer": "char_wb", + "min_ngram": 1, + "max_ngram": 4, + }, + ] + + classifier = create_diet({EPOCHS: 1, RUN_EAGERLY: True}) + assert not classifier.finetune_mode + train_load_and_process_diet(classifier, pipeline=pipeline, training_data=iter1_path) + + finetune_classifier = create_diet( + {EPOCHS: 1, RUN_EAGERLY: True}, load=True, finetune=True + ) + assert finetune_classifier.finetune_mode + + if should_raise_exception: + with pytest.raises(Exception) as exec_info: + train_load_and_process_diet( + finetune_classifier, pipeline=pipeline, training_data=iter2_path + ) + assert "Sparse feature sizes have decreased" in str(exec_info.value) + else: + train_load_and_process_diet( + finetune_classifier, pipeline=pipeline, training_data=iter2_path + ) + + +@pytest.mark.timeout(120, func_only=True) +async def test_no_bilou_when_entity_recognition_off( + create_diet: Callable[..., DIETClassifier], + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], +): + """test diet doesn't produce BILOU tags when ENTITIY_RECOGNITION false.""" + + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": CountVectorsFeaturizer}, + ] + diet = create_diet( + {ENTITY_RECOGNITION: False, RANDOM_SEED: 1, EPOCHS: 1, RUN_EAGERLY: True} + ) + + training_data, loaded_pipeline = train_and_preprocess( + pipeline, training_data="data/test/demo-rasa-composite-entities.yml" + ) + + diet.train(training_data=training_data) + + assert all(msg.get(BILOU_ENTITIES) is None for msg in training_data.nlu_examples) + + +@pytest.mark.timeout(120, func_only=True) +@pytest.mark.parametrize( + "batch_size, expected_num_batches, drop_small_last_batch", + # the training dataset has 48 NLU examples + [ + (1, 48, True), + (8, 6, True), + (15, 3, True), + (16, 3, True), + (18, 3, True), + (20, 2, True), + (32, 2, True), + (64, 1, True), + (128, 1, True), + (256, 1, True), + (1, 48, False), + (8, 6, False), + (15, 4, False), + (16, 3, False), + (18, 3, False), + (20, 3, False), + (32, 2, False), + (64, 1, False), + (128, 1, False), + (256, 1, False), + ], +) +async def test_dropping_of_last_partial_batch( + batch_size: int, + expected_num_batches: int, + drop_small_last_batch: bool, + create_diet: Callable[..., DIETClassifier], + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], +): + """test that diets data processing produces the right amount of batches. + + We introduced a change to only keep the last incomplete batch if + 1. it has more than 50% of examples of batch size + 2. or it is the only batch in the epoch + """ + + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": CountVectorsFeaturizer}, + ] + diet = create_diet( + {ENTITY_RECOGNITION: False, RANDOM_SEED: 1, EPOCHS: 1, RUN_EAGERLY: True} + ) + # This data set has 48 NLU examples + training_data, loaded_pipeline = train_and_preprocess( + pipeline, training_data="data/test/demo-rasa-no-ents.yml" + ) + + model_data = diet.preprocess_train_data(training_data) + data_generator, _ = train_utils.create_data_generators( + model_data, batch_size, 1, drop_small_last_batch=drop_small_last_batch + ) + + assert len(data_generator) == expected_num_batches + + +@pytest.mark.timeout(120, func_only=True) +async def test_dropping_of_last_partial_batch_empty_data( + create_diet: Callable[..., DIETClassifier], + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], +): + """test that diets data processing produces the right amount of batches. + + We introduced a change to only keep the last incomplete batch if + 1. it has more than 50% of examples of batch size + 2. or it is the only batch in the epoch + """ + + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": CountVectorsFeaturizer}, + ] + diet = create_diet( + {ENTITY_RECOGNITION: False, RANDOM_SEED: 1, EPOCHS: 1, RUN_EAGERLY: True} + ) + training_data, loaded_pipeline = train_and_preprocess( + pipeline, training_data=TrainingData() + ) + + model_data = diet.preprocess_train_data(training_data) + data_generator, _ = train_utils.create_data_generators( + model_data, 64, 1, drop_small_last_batch=True + ) + + assert len(data_generator) == 0 diff --git a/tests/nlu/classifiers/test_fallback_classifier.py b/tests/nlu/classifiers/test_fallback_classifier.py new file mode 100644 index 0000000..9262726 --- /dev/null +++ b/tests/nlu/classifiers/test_fallback_classifier.py @@ -0,0 +1,262 @@ +import copy +from typing import Dict, Text, Any + +import pytest + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.classifiers import fallback_classifier +from rasa.shared.constants import DEFAULT_NLU_FALLBACK_INTENT_NAME +from rasa.core.constants import DEFAULT_NLU_FALLBACK_THRESHOLD +from rasa.nlu.classifiers.fallback_classifier import ( + THRESHOLD_KEY, + AMBIGUITY_THRESHOLD_KEY, + FallbackClassifier, +) +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.constants import ( + INTENT, + TEXT, + INTENT_NAME_KEY, + INTENT_RANKING_KEY, + PREDICTED_CONFIDENCE_KEY, +) + + +def create_fallback_classifier( + component_config: Dict[Text, Any], + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, +): + classifier = FallbackClassifier.create( + {**FallbackClassifier.get_default_config(), **component_config}, + default_model_storage, + Resource("fallback"), + default_execution_context, + ) + + return classifier + + +@pytest.mark.parametrize( + "message, component_config", + [ + ( + Message( + data={ + TEXT: "some message", + INTENT: { + INTENT_NAME_KEY: "greet", + PREDICTED_CONFIDENCE_KEY: 0.234891876578331, + }, + INTENT_RANKING_KEY: [ + { + INTENT_NAME_KEY: "greet", + PREDICTED_CONFIDENCE_KEY: 0.234891876578331, + }, + { + INTENT_NAME_KEY: "stop", + PREDICTED_CONFIDENCE_KEY: 0.5 - 0.0001, + }, + {INTENT_NAME_KEY: "affirm", PREDICTED_CONFIDENCE_KEY: 0}, + {INTENT_NAME_KEY: "inform", PREDICTED_CONFIDENCE_KEY: -100}, + { + INTENT_NAME_KEY: "deny", + PREDICTED_CONFIDENCE_KEY: 0.0879683718085289, + }, + ], + } + ), + {THRESHOLD_KEY: 0.5}, + ), + ( + Message( + data={ + TEXT: "some message", + INTENT: {INTENT_NAME_KEY: "greet", PREDICTED_CONFIDENCE_KEY: 1}, + INTENT_RANKING_KEY: [ + {INTENT_NAME_KEY: "greet", PREDICTED_CONFIDENCE_KEY: 1}, + {INTENT_NAME_KEY: "stop", PREDICTED_CONFIDENCE_KEY: 0.9}, + ], + } + ), + {THRESHOLD_KEY: 0.5, AMBIGUITY_THRESHOLD_KEY: 0.1}, + ), + ( + Message( + data={ + TEXT: "some message", + INTENT: {INTENT_NAME_KEY: "greet", PREDICTED_CONFIDENCE_KEY: 1}, + INTENT_RANKING_KEY: [ + {INTENT_NAME_KEY: "greet", PREDICTED_CONFIDENCE_KEY: 1}, + {INTENT_NAME_KEY: "stop", PREDICTED_CONFIDENCE_KEY: 0.5}, + ], + } + ), + {THRESHOLD_KEY: 0.5, AMBIGUITY_THRESHOLD_KEY: 0.51}, + ), + ], +) +def test_predict_fallback_intent( + message: Message, + component_config: Dict, + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, +): + old_message_state = copy.deepcopy(message) + expected_confidence = component_config[THRESHOLD_KEY] + + classifier = create_fallback_classifier( + component_config, default_model_storage, default_execution_context + ) + processed_messages = classifier.process([message]) + processed_msg = processed_messages[0] + + expected_intent = { + INTENT_NAME_KEY: DEFAULT_NLU_FALLBACK_INTENT_NAME, + PREDICTED_CONFIDENCE_KEY: expected_confidence, + } + assert processed_msg.data[INTENT] == expected_intent + + old_intent_ranking = old_message_state.data[INTENT_RANKING_KEY] + current_intent_ranking = processed_msg.data[INTENT_RANKING_KEY] + + assert len(current_intent_ranking) == len(old_intent_ranking) + 1 + assert all(item in current_intent_ranking for item in old_intent_ranking) + assert current_intent_ranking[0] == expected_intent + + +@pytest.mark.parametrize( + "message, component_config", + [ + ( + Message( + data={ + TEXT: "some message", + INTENT: {INTENT_NAME_KEY: "greet", PREDICTED_CONFIDENCE_KEY: 0.5}, + INTENT_RANKING_KEY: [ + { + INTENT_NAME_KEY: "greet", + PREDICTED_CONFIDENCE_KEY: 0.234891876578331, + }, + {INTENT_NAME_KEY: "stop", PREDICTED_CONFIDENCE_KEY: 0.1}, + {INTENT_NAME_KEY: "affirm", PREDICTED_CONFIDENCE_KEY: 0}, + {INTENT_NAME_KEY: "inform", PREDICTED_CONFIDENCE_KEY: -100}, + { + INTENT_NAME_KEY: "deny", + PREDICTED_CONFIDENCE_KEY: 0.0879683718085289, + }, + ], + } + ), + {THRESHOLD_KEY: 0.5}, + ), + ( + Message( + data={ + TEXT: "some message", + INTENT: {INTENT_NAME_KEY: "greet", PREDICTED_CONFIDENCE_KEY: 1}, + INTENT_RANKING_KEY: [ + {INTENT_NAME_KEY: "greet", PREDICTED_CONFIDENCE_KEY: 1}, + {INTENT_NAME_KEY: "stop", PREDICTED_CONFIDENCE_KEY: 0.89}, + ], + } + ), + {THRESHOLD_KEY: 0.5, AMBIGUITY_THRESHOLD_KEY: 0.1}, + ), + ], +) +def test_not_predict_fallback_intent( + message: Message, + component_config: Dict[Text, Any], + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, +): + old_message_state = copy.deepcopy(message) + + classifier = create_fallback_classifier( + component_config, default_model_storage, default_execution_context + ) + processed_messages = classifier.process([message]) + processed_msg = processed_messages[0] + + assert processed_msg == old_message_state + + +def test_defaults( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + classifier = create_fallback_classifier( + {}, default_model_storage, default_execution_context + ) + + assert classifier.component_config[THRESHOLD_KEY] == DEFAULT_NLU_FALLBACK_THRESHOLD + assert classifier.component_config[AMBIGUITY_THRESHOLD_KEY] == 0.1 + + +@pytest.mark.parametrize( + "prediction, expected", + [ + ({INTENT: {INTENT_NAME_KEY: DEFAULT_NLU_FALLBACK_INTENT_NAME}}, True), + ({INTENT: {INTENT_NAME_KEY: "some other intent"}}, False), + ], +) +def test_is_fallback_classifier_prediction(prediction: Dict[Text, Any], expected: bool): + assert fallback_classifier.is_fallback_classifier_prediction(prediction) == expected + + +@pytest.mark.parametrize( + "prediction, expected", + [ + ( + {INTENT: {INTENT_NAME_KEY: DEFAULT_NLU_FALLBACK_INTENT_NAME}}, + {INTENT: {INTENT_NAME_KEY: DEFAULT_NLU_FALLBACK_INTENT_NAME}}, + ), + ( + { + INTENT: {INTENT_NAME_KEY: DEFAULT_NLU_FALLBACK_INTENT_NAME}, + INTENT_RANKING_KEY: [], + }, + { + INTENT: {INTENT_NAME_KEY: DEFAULT_NLU_FALLBACK_INTENT_NAME}, + INTENT_RANKING_KEY: [], + }, + ), + ( + { + INTENT: {INTENT_NAME_KEY: DEFAULT_NLU_FALLBACK_INTENT_NAME}, + INTENT_RANKING_KEY: [ + {INTENT_NAME_KEY: DEFAULT_NLU_FALLBACK_INTENT_NAME} + ], + }, + { + INTENT: {INTENT_NAME_KEY: DEFAULT_NLU_FALLBACK_INTENT_NAME}, + INTENT_RANKING_KEY: [ + {INTENT_NAME_KEY: DEFAULT_NLU_FALLBACK_INTENT_NAME} + ], + }, + ), + ( + { + INTENT: {INTENT_NAME_KEY: DEFAULT_NLU_FALLBACK_INTENT_NAME}, + INTENT_RANKING_KEY: [ + {INTENT_NAME_KEY: DEFAULT_NLU_FALLBACK_INTENT_NAME}, + {INTENT_NAME_KEY: "other", PREDICTED_CONFIDENCE_KEY: 123}, + {INTENT_NAME_KEY: "other2", PREDICTED_CONFIDENCE_KEY: 12}, + ], + }, + { + INTENT: {INTENT_NAME_KEY: "other", PREDICTED_CONFIDENCE_KEY: 123}, + INTENT_RANKING_KEY: [ + {INTENT_NAME_KEY: "other", PREDICTED_CONFIDENCE_KEY: 123}, + {INTENT_NAME_KEY: "other2", PREDICTED_CONFIDENCE_KEY: 12}, + ], + }, + ), + ], +) +def test_undo_fallback_prediction( + prediction: Dict[Text, Any], expected: Dict[Text, Any] +): + assert fallback_classifier.undo_fallback_prediction(prediction) == expected diff --git a/tests/nlu/classifiers/test_keyword_classifier.py b/tests/nlu/classifiers/test_keyword_classifier.py new file mode 100644 index 0000000..a2ccc0f --- /dev/null +++ b/tests/nlu/classifiers/test_keyword_classifier.py @@ -0,0 +1,208 @@ +from typing import Text, Dict, Any, Optional, Union + +import pytest +import copy + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.classifiers.keyword_intent_classifier import KeywordIntentClassifier +from rasa.shared.nlu.constants import ( + TEXT, + INTENT, + PREDICTED_CONFIDENCE_KEY, + INTENT_NAME_KEY, +) + +from rasa.shared.nlu.training_data.formats.rasa import RasaReader +import rasa.shared.nlu.training_data.loading +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData + + +@pytest.fixture() +def training_data(nlu_as_json_path: Text): + return rasa.shared.nlu.training_data.loading.load_data(nlu_as_json_path) + + +@pytest.fixture() +def default_keyword_intent_classifier( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + return KeywordIntentClassifier.create( + KeywordIntentClassifier.get_default_config(), + default_model_storage, + Resource("keyword"), + default_execution_context, + ) + + +@pytest.mark.parametrize( + "config", [{"case_sensitive": True}, {"case_sensitive": False}] +) +def test_persist_and_load( + training_data: TrainingData, + config: Dict[Text, Any], + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, +): + classifier = KeywordIntentClassifier.create( + config, default_model_storage, Resource("keyword"), default_execution_context + ) + classifier.train(training_data) + + loaded_classifier = KeywordIntentClassifier.load( + config, default_model_storage, Resource("keyword"), default_execution_context + ) + + predicted = copy.copy(training_data) + actual = copy.copy(training_data) + loaded_messages = loaded_classifier.process(predicted.training_examples) + trained_messages = classifier.process(actual.training_examples) + for m1, m2 in zip(loaded_messages, trained_messages): + assert m1.get("intent") == m2.get("intent") + + +@pytest.mark.parametrize( + "message, previous_intent, expected_intent", + [ + ( + "hey there joe", + None, + {INTENT_NAME_KEY: "greet", PREDICTED_CONFIDENCE_KEY: 1.0}, + ), # Keyword match + # Keyword match + ( + "hello weiouaosdhalkh", + None, + {INTENT_NAME_KEY: "greet", PREDICTED_CONFIDENCE_KEY: 1.0}, + ), + # Keyword match + ( + "show me chinese restaurants in the north of town", + None, + {INTENT_NAME_KEY: "restaurant_search", PREDICTED_CONFIDENCE_KEY: 1.0}, + ), + ( + "great", + None, + {INTENT_NAME_KEY: "affirm", PREDICTED_CONFIDENCE_KEY: 1.0}, + ), # Keyword match + # Keyword match + ( + "bye bye birdie", + None, + {INTENT_NAME_KEY: "goodbye", PREDICTED_CONFIDENCE_KEY: 1.0}, + ), + # No keyword match, no previous intent + ( + "show me a mexican place", + None, + {INTENT_NAME_KEY: None, PREDICTED_CONFIDENCE_KEY: 0.0}, + ), + # No keyword match, no previous intent + ( + "i", + None, + {INTENT_NAME_KEY: None, PREDICTED_CONFIDENCE_KEY: 0.0}, + ), + # No keyword match, no previous intent + ( + "in", + None, + {INTENT_NAME_KEY: None, PREDICTED_CONFIDENCE_KEY: 0.0}, + ), + # No keyword match, no previous intent + ( + "eet", + None, + {INTENT_NAME_KEY: None, PREDICTED_CONFIDENCE_KEY: 0.0}, + ), + # previous and no keyword match + ( + "The Neapolitan", + {INTENT_NAME_KEY: "greet", PREDICTED_CONFIDENCE_KEY: 0.123}, + {INTENT_NAME_KEY: "greet", PREDICTED_CONFIDENCE_KEY: 0.123}, + ), + # previous and keyword match + ( + "I am searching for a dinner spot", + {INTENT_NAME_KEY: "greet", PREDICTED_CONFIDENCE_KEY: 0.123}, + {INTENT_NAME_KEY: "restaurant_search", PREDICTED_CONFIDENCE_KEY: 1.0}, + ), + ], +) +def test_classification( + message: Text, + previous_intent: Optional[Dict[Text, Union[str, float]]], + expected_intent: Dict[Text, Union[str, float]], + training_data: TrainingData, + default_keyword_intent_classifier: KeywordIntentClassifier, +): + text = Message(data={TEXT: message, INTENT: previous_intent}) + default_keyword_intent_classifier.train(training_data) + messages = default_keyword_intent_classifier.process([text]) + for m in messages: + assert m.get(INTENT) == expected_intent + + +def test_valid_data(default_keyword_intent_classifier: KeywordIntentClassifier): + json_data = { + "rasa_nlu_data": { + "common_examples": [ + {"text": "good", "intent": "affirm", "entities": []}, + {"text": "bye", "intent": "goodbye", "entities": []}, + {"text": "see ya", "intent": "goodbye", "entities": []}, + {"text": "yes", "intent": "affirm", "entities": []}, + {"text": "ciao", "intent": "goodbye", "entities": []}, + ] + } + } + rasa_reader = RasaReader() + data = rasa_reader.read_from_json(json_data) + + with pytest.warns(None) as record: + default_keyword_intent_classifier.train(data) + assert len(record) == 0 + + +@pytest.mark.filterwarnings("ignore:Keyword.* of keywords:UserWarning") +def test_identical_data(default_keyword_intent_classifier: KeywordIntentClassifier): + json_data = { + "rasa_nlu_data": { + "common_examples": [ + {"text": "good", "intent": "affirm", "entities": []}, + {"text": "good", "intent": "goodbye", "entities": []}, + ] + } + } + rasa_reader = RasaReader() + data = rasa_reader.read_from_json(json_data) + + with pytest.warns(UserWarning) as record: + default_keyword_intent_classifier.train(data) + assert len(record) == 1 + assert ( + "Remove (one of) the duplicates from the training data." + in record[0].message.args[0] + ) + + +@pytest.mark.filterwarnings("ignore:Keyword.* of keywords:UserWarning") +def test_ambiguous_data(default_keyword_intent_classifier: KeywordIntentClassifier): + json_data = { + "rasa_nlu_data": { + "common_examples": [ + {"text": "good", "intent": "affirm", "entities": []}, + {"text": "good morning", "intent": "greet", "entities": []}, + {"text": "see you", "intent": "goodbye", "entities": []}, + {"text": "nice to see you", "intent": "greet", "entities": []}, + ] + } + } + rasa_reader = RasaReader() + data = rasa_reader.read_from_json(json_data) + + with pytest.warns(UserWarning) as record: + default_keyword_intent_classifier.train(data) + assert len(record) == 2 diff --git a/tests/nlu/classifiers/test_logistic_regression_classifier.py b/tests/nlu/classifiers/test_logistic_regression_classifier.py new file mode 100644 index 0000000..0c0ae25 --- /dev/null +++ b/tests/nlu/classifiers/test_logistic_regression_classifier.py @@ -0,0 +1,123 @@ +import copy +import pytest +import pathlib +from rasa.shared.nlu.training_data.message import Message +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.engine.graph import ExecutionContext +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +from rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer import ( + CountVectorsFeaturizer, +) +from rasa.nlu.classifiers.logistic_regression_classifier import ( + LogisticRegressionClassifier, +) + + +@pytest.fixture +def featurizer_sparse(tmpdir): + """Generate a featurizer for tests.""" + node_storage = LocalModelStorage(pathlib.Path(tmpdir)) + node_resource = Resource("sparse_feat") + context = ExecutionContext(node_storage, node_resource) + return CountVectorsFeaturizer( + config=CountVectorsFeaturizer.get_default_config(), + resource=node_resource, + model_storage=node_storage, + execution_context=context, + ) + + +tokeniser = WhitespaceTokenizer( + { + "only_alphanum": False, + "intent_tokenization_flag": False, + "intent_split_symbol": "_", + } +) + + +@pytest.fixture() +def training_data(): + # Create training data. + return TrainingData( + [ + Message({"text": "hello", "intent": "greet"}), + Message({"text": "hi there", "intent": "greet"}), + Message({"text": "ciao", "intent": "goodbye"}), + Message({"text": "bye", "intent": "goodbye"}), + Message({"text": "I am so hungry", "intent": "hungry"}), + Message({"text": "I want pizza", "intent": "hungry"}), + Message({"text": "I want pizza, now!!", "intent": "hangry"}), + Message({"text": "just gimme a pizza already", "intent": "hangry"}), + ] + ) + + +def test_predictions_added(training_data, tmpdir, featurizer_sparse): + """Checks if the sizes are appropriate.""" + # Set up classifier + node_storage = LocalModelStorage(pathlib.Path(tmpdir)) + node_resource = Resource("classifier") + context = ExecutionContext(node_storage, node_resource) + ranking_length = 2 + classifier = LogisticRegressionClassifier( + config={"ranking_length": ranking_length}, + name=context.node_name, + resource=node_resource, + model_storage=node_storage, + ) + training_intents = training_data.intents + # First we add tokens. + tokeniser.process(training_data.training_examples) + + # Next we add features. + featurizer_sparse.train(training_data) + featurizer_sparse.process(training_data.training_examples) + + # Train the classifier. + classifier.train(training_data) + + # Make predictions. + classifier.process(training_data.training_examples) + + # Check that the messages have been processed correctly + for msg in training_data.training_examples: + intent = msg.get("intent") + ranking = msg.get("intent_ranking") + # check that first ranking element is the same as the winning intent + assert ( + intent["name"] == ranking[0]["name"] + and intent["confidence"] == ranking[0]["confidence"] + ) + + # check that ranking_length is adhered to + if len(training_intents) > ranking_length: + assert len(ranking) == ranking_length + else: + assert len(ranking) == len(training_intents) + + confidences = [r["confidence"] for r in ranking] + assert all( + [confidences[i] > confidences[i + 1] for i in range(len(confidences) - 1)] + ) + + # check that all ranking names are from training data + assert all([r["name"] in training_intents for r in ranking]) + + # confirm that all confidences are between 0 and 1 + assert all([0 <= r["confidence"] <= 1 for r in ranking]) + + classifier.persist() + + loaded_classifier = LogisticRegressionClassifier.load( + {}, node_storage, node_resource, context + ) + + predicted = copy.copy(training_data) + actual = copy.copy(training_data) + loaded_messages = loaded_classifier.process(predicted.training_examples) + trained_messages = classifier.process(actual.training_examples) + for m1, m2 in zip(loaded_messages, trained_messages): + assert m1.get("intent") == m2.get("intent") diff --git a/tests/nlu/classifiers/test_mitie_intent_classifier.py b/tests/nlu/classifiers/test_mitie_intent_classifier.py new file mode 100644 index 0000000..25c1a65 --- /dev/null +++ b/tests/nlu/classifiers/test_mitie_intent_classifier.py @@ -0,0 +1,118 @@ +import pytest + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.classifiers.mitie_intent_classifier import MitieIntentClassifier +from rasa.nlu.tokenizers.mitie_tokenizer import MitieTokenizer + +from rasa.nlu.utils.mitie_utils import MitieModel, MitieNLP +import rasa.shared.nlu.training_data.loading +from rasa.shared.nlu.constants import ( + TEXT, + INTENT, + INTENT_NAME_KEY, + PREDICTED_CONFIDENCE_KEY, +) +from rasa.shared.nlu.training_data.message import Message + + +@pytest.fixture +def mitie_model( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +) -> MitieModel: + component = MitieNLP.create( + MitieNLP.get_default_config(), + default_model_storage, + Resource("mitie"), + default_execution_context, + ) + + return component.provide() + + +@pytest.mark.flaky +@pytest.mark.timeout(150) +def test_train_load_predict_loop( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + mitie_model: MitieModel, + mitie_tokenizer: MitieTokenizer, +): + resource = Resource("mitie_classifier") + component = MitieIntentClassifier.create( + MitieIntentClassifier.get_default_config(), + default_model_storage, + resource, + default_execution_context, + ) + + training_data = rasa.shared.nlu.training_data.loading.load_data( + "data/examples/rasa/demo-rasa.yml" + ) + # Tokenize message as classifier needs that + mitie_tokenizer.process_training_data(training_data) + + component.train(training_data, mitie_model) + + component = MitieIntentClassifier.load( + MitieIntentClassifier.get_default_config(), + default_model_storage, + resource, + default_execution_context, + ) + + test_message = Message({TEXT: "hi"}) + mitie_tokenizer.process([test_message]) + component.process([test_message], mitie_model) + + assert test_message.data[INTENT][INTENT_NAME_KEY] == "greet" + assert test_message.data[INTENT][PREDICTED_CONFIDENCE_KEY] > 0 + + +def test_load_from_untrained( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + mitie_model: MitieModel, + mitie_tokenizer: MitieTokenizer, +): + resource = Resource("some_resource") + + component = MitieIntentClassifier.load( + MitieIntentClassifier.get_default_config(), + default_model_storage, + resource, + default_execution_context, + ) + + test_message = Message({TEXT: "hi"}) + mitie_tokenizer.process([test_message]) + component.process([test_message], mitie_model) + + assert test_message.data[INTENT] == {"name": None, "confidence": 0.0} + + +def test_load_from_untrained_but_with_resource_existing( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + mitie_model: MitieModel, + mitie_tokenizer: MitieTokenizer, +): + resource = Resource("some_resource") + + with default_model_storage.write_to(resource): + # This makes sure the directory exists but the model file itself doesn't + pass + + component = MitieIntentClassifier.load( + MitieIntentClassifier.get_default_config(), + default_model_storage, + resource, + default_execution_context, + ) + + test_message = Message({TEXT: "hi"}) + mitie_tokenizer.process([test_message]) + component.process([test_message], mitie_model) + + assert test_message.data[INTENT] == {"name": None, "confidence": 0.0} diff --git a/tests/nlu/classifiers/test_regex_message_handler.py b/tests/nlu/classifiers/test_regex_message_handler.py new file mode 100644 index 0000000..ce685b4 --- /dev/null +++ b/tests/nlu/classifiers/test_regex_message_handler.py @@ -0,0 +1,98 @@ +from rasa.nlu.constants import EXTRACTOR +from rasa.shared.nlu.training_data.features import Features +from typing import Text +import numpy as np + +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.core.domain import Domain +from rasa.engine.storage.resource import Resource +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.classifiers.regex_message_handler import RegexMessageHandler +import pytest +from rasa.shared.constants import INTENT_MESSAGE_PREFIX +from rasa.shared.nlu.constants import FEATURE_TYPE_SENTENCE, INTENT, TEXT, ENTITIES + + +@pytest.fixture +def regex_message_handler( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +) -> RegexMessageHandler: + return RegexMessageHandler.create( + config={}, + model_storage=default_model_storage, + resource=Resource("unused"), + execution_context=default_execution_context, + ) + + +@pytest.mark.parametrize( + "text", + [ + "some other text", + "text" + INTENT_MESSAGE_PREFIX, + INTENT_MESSAGE_PREFIX, + INTENT_MESSAGE_PREFIX + "@0.5", + ], +) +def test_process_does_not_do_anything( + regex_message_handler: RegexMessageHandler, text: Text +): + + message = Message( + data={TEXT: text, INTENT: "bla"}, + features=[ + Features( + features=np.zeros((1, 1)), + feature_type=FEATURE_TYPE_SENTENCE, + attribute=TEXT, + origin="nlu-pipeline", + ) + ], + ) + + # construct domain from expected intent/entities + domain = Domain( + intents=["intent"], + entities=["entity"], + slots=[], + responses={}, + action_names=[], + forms={}, + data={}, + ) + + parsed_messages = regex_message_handler.process([message], domain) + + assert parsed_messages[0] == message + + +@pytest.mark.parametrize( + "text", + [ + f"{INTENT_MESSAGE_PREFIX}greet", + f'{INTENT_MESSAGE_PREFIX}greet{{"name": "Leroy"}}', + f'{INTENT_MESSAGE_PREFIX}greet{{"name": "Leroy", "age": "100"}}', + ], +) +def test_regex_message_handler_adds_extractor_name( + regex_message_handler: RegexMessageHandler, text: Text +): + + message = Message( + data={TEXT: text, INTENT: "bla"}, + features=[ + Features( + features=np.zeros((1, 1)), + feature_type=FEATURE_TYPE_SENTENCE, + attribute=TEXT, + origin="nlu-pipeline", + ) + ], + ) + parsed_messages = regex_message_handler.process([message]) + + for message in parsed_messages: + + for entity in message.get(ENTITIES): + assert entity[EXTRACTOR] == RegexMessageHandler.__name__ diff --git a/tests/nlu/classifiers/test_sklearn_classifier.py b/tests/nlu/classifiers/test_sklearn_classifier.py new file mode 100644 index 0000000..dba66b1 --- /dev/null +++ b/tests/nlu/classifiers/test_sklearn_classifier.py @@ -0,0 +1,126 @@ +import copy +import logging +from typing import Callable, List, Text, Tuple + +import pytest +from _pytest.logging import LogCaptureFixture + +from rasa.nlu.featurizers.dense_featurizer.spacy_featurizer import SpacyFeaturizer +from rasa.nlu.tokenizers.spacy_tokenizer import SpacyTokenizer +from rasa.nlu.utils.spacy_utils import SpacyModel, SpacyNLP +import rasa.shared.nlu.training_data.loading +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.classifiers.sklearn_intent_classifier import SklearnIntentClassifier +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.constants import TEXT, INTENT +from rasa.shared.nlu.training_data.message import Message + + +@pytest.fixture() +def training_data(nlu_data_path: Text) -> TrainingData: + return rasa.shared.nlu.training_data.loading.load_data(nlu_data_path) + + +@pytest.fixture() +def default_sklearn_intent_classifier( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + return SklearnIntentClassifier.create( + SklearnIntentClassifier.get_default_config(), + default_model_storage, + Resource("sklearn"), + default_execution_context, + ) + + +def test_persist_and_load( + training_data: TrainingData, + default_sklearn_intent_classifier: SklearnIntentClassifier, + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], + spacy_nlp_component: SpacyNLP, + spacy_model: SpacyModel, +): + training_data = spacy_nlp_component.process_training_data( + training_data, spacy_model + ) + + training_data, loaded_pipeline = train_and_preprocess( + pipeline=[{"component": SpacyTokenizer}, {"component": SpacyFeaturizer}], + training_data=training_data, + ) + default_sklearn_intent_classifier.train(training_data) + + loaded = SklearnIntentClassifier.load( + SklearnIntentClassifier.get_default_config(), + default_model_storage, + Resource("sklearn"), + default_execution_context, + ) + + predicted = copy.deepcopy(training_data) + actual = copy.deepcopy(training_data) + loaded_messages = loaded.process(predicted.training_examples) + trained_messages = default_sklearn_intent_classifier.process( + actual.training_examples + ) + + for m1, m2 in zip(loaded_messages, trained_messages): + assert m1.get("intent") == m2.get("intent") + + +def test_loading_from_storage_fail( + training_data: TrainingData, + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + caplog: LogCaptureFixture, +): + with caplog.at_level(logging.DEBUG): + loaded = SklearnIntentClassifier.load( + SklearnIntentClassifier.get_default_config(), + default_model_storage, + Resource("test"), + default_execution_context, + ) + assert isinstance(loaded, SklearnIntentClassifier) + + assert any( + "Resource 'test' doesn't exist." in message for message in caplog.messages + ) + + +def test_process_unfeaturized_input( + training_data: TrainingData, + default_sklearn_intent_classifier: SklearnIntentClassifier, + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], + spacy_nlp_component: SpacyNLP, + spacy_model: SpacyModel, +): + training_data = spacy_nlp_component.process_training_data( + training_data, spacy_model + ) + training_data, loaded_pipeline = train_and_preprocess( + pipeline=[ + {"component": SpacyTokenizer}, + {"component": SpacyFeaturizer}, + ], + training_data=training_data, + ) + default_sklearn_intent_classifier.train(training_data) + classifier = SklearnIntentClassifier.load( + SklearnIntentClassifier.get_default_config(), + default_model_storage, + Resource("sklearn"), + default_execution_context, + ) + message_text = "message text" + message = Message(data={TEXT: message_text}) + processed_message = classifier.process([message])[0] + + assert processed_message.get(TEXT) == message_text + assert not processed_message.get(INTENT) diff --git a/tests/nlu/conftest.py b/tests/nlu/conftest.py new file mode 100644 index 0000000..69baab3 --- /dev/null +++ b/tests/nlu/conftest.py @@ -0,0 +1,100 @@ +import copy +from typing import Any, Callable, Dict, List, Text, Tuple, Type, Union + +import pytest + +from rasa.engine.graph import ExecutionContext, GraphComponent, GraphSchema +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.featurizers.dense_featurizer.spacy_featurizer import SpacyFeaturizer +from rasa.nlu.tokenizers.mitie_tokenizer import MitieTokenizer +from rasa.nlu.tokenizers.spacy_tokenizer import SpacyTokenizer +from rasa.shared.importers.rasa import RasaFileImporter +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.utils.tensorflow.constants import EPOCHS, RANDOM_SEED + + +@pytest.fixture() +def pretrained_embeddings_spacy_config() -> Dict: + return { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": [ + {"name": "SpacyNLP", "model": "en_core_web_md"}, + {"name": "SpacyTokenizer"}, + {"name": "SpacyFeaturizer"}, + {"name": "RegexFeaturizer"}, + {"name": "CRFEntityExtractor", EPOCHS: 1, RANDOM_SEED: 42}, + {"name": "EntitySynonymMapper"}, + {"name": "SklearnIntentClassifier"}, + ], + } + + +@pytest.fixture() +def train_and_preprocess( + default_model_storage: ModelStorage, +) -> Callable[..., Tuple[TrainingData, List[GraphComponent]]]: + def inner( + pipeline: List[Dict[Text, Any]], training_data: Union[Text, TrainingData] + ) -> Tuple[TrainingData, List[GraphComponent]]: + + if isinstance(training_data, str): + importer = RasaFileImporter(training_data_paths=[training_data]) + training_data: TrainingData = importer.get_nlu_data() + + def create_component( + component_class: Type[GraphComponent], config: Dict[Text, Any], idx: int + ) -> GraphComponent: + node_name = f"{component_class.__name__}_{idx}" + execution_context = ExecutionContext(GraphSchema({}), node_name=node_name) + resource = Resource(node_name) + return component_class.create( + {**component_class.get_default_config(), **config}, + default_model_storage, + resource, + execution_context, + ) + + component_pipeline = [ + create_component(component.pop("component"), component, idx) + for idx, component in enumerate(copy.deepcopy(pipeline)) + ] + + for component in component_pipeline: + if hasattr(component, "train"): + component.train(training_data) + if hasattr(component, "process_training_data"): + component.process_training_data(training_data) + + return training_data, component_pipeline + + return inner + + +@pytest.fixture() +def process_message(default_model_storage: ModelStorage) -> Callable[..., Message]: + def inner(loaded_pipeline: List[GraphComponent], message: Message) -> Message: + + for component in loaded_pipeline: + component.process([message]) + + return message + + return inner + + +@pytest.fixture() +def spacy_tokenizer() -> SpacyTokenizer: + return SpacyTokenizer(SpacyTokenizer.get_default_config()) + + +@pytest.fixture() +def spacy_featurizer() -> SpacyFeaturizer: + return SpacyFeaturizer(SpacyFeaturizer.get_default_config(), name="SpacyFeaturizer") + + +@pytest.fixture() +def mitie_tokenizer() -> MitieTokenizer: + return MitieTokenizer(MitieTokenizer.get_default_config()) diff --git a/tests/nlu/emulators/__init__.py b/tests/nlu/emulators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/nlu/emulators/test_dialogflow.py b/tests/nlu/emulators/test_dialogflow.py new file mode 100644 index 0000000..ddd3295 --- /dev/null +++ b/tests/nlu/emulators/test_dialogflow.py @@ -0,0 +1,32 @@ +def test_dialogflow_request(): + from rasa.nlu.emulators.dialogflow import DialogflowEmulator + + em = DialogflowEmulator() + norm = em.normalise_request_json({"text": ["arb text"]}) + assert norm == {"text": "arb text", "time": None} + + +def test_dialogflow_response(): + from rasa.nlu.emulators.dialogflow import DialogflowEmulator + + em = DialogflowEmulator() + data = { + "text": "I want italian food", + "intent": {"name": "inform", "confidence": 0.4794813722432127}, + "entities": [{"entity": "cuisine", "value": "italian", "start": 7, "end": 14}], + } + norm = em.normalise_response_json(data) + + assert norm == { + "responseId": norm["responseId"], + "queryResult": { + "queryText": "I want italian food", + "action": "inform", + "parameters": {"cuisine": ["italian"]}, + "fulfillmentText": "", + "fulfillmentMessages": [], + "outputContexts": [], + "intent": {"name": "inform", "displayName": "inform"}, + "intentDetectionConfidence": 0.4794813722432127, + }, + } diff --git a/tests/nlu/emulators/test_luis.py b/tests/nlu/emulators/test_luis.py new file mode 100644 index 0000000..fddb440 --- /dev/null +++ b/tests/nlu/emulators/test_luis.py @@ -0,0 +1,132 @@ +from rasa.nlu.emulators.luis import LUISEmulator + + +def test_luis_request(): + em = LUISEmulator() + norm = em.normalise_request_json({"text": ["arb text"]}) + assert norm == {"text": "arb text", "time": None} + + +def test_luis_response(): + em = LUISEmulator() + data = { + "text": "I want italian food", + "intent": {"name": "restaurant_search", "confidence": 0.737014589341683}, + "intent_ranking": [ + {"confidence": 0.737014589341683, "name": "restaurant_search"}, + {"confidence": 0.11605464483122209, "name": "goodbye"}, + {"confidence": 0.08816417744097163, "name": "greet"}, + {"confidence": 0.058766588386123204, "name": "affirm"}, + ], + "entities": [ + { + "entity": "cuisine", + "value": "italian", + "role": "roleCuisine", + "extractor": "SpacyEntityExtractor", + "start": 7, + "end": 14, + } + ], + } + norm = em.normalise_response_json(data) + assert norm == { + "query": data["text"], + "prediction": { + "normalizedQuery": data["text"], + "topIntent": "restaurant_search", + "intents": { + "restaurant_search": {"score": 0.737014589341683}, + "goodbye": {"score": 0.11605464483122209}, + "greet": {"score": 0.08816417744097163}, + "affirm": {"score": 0.058766588386123204}, + }, + "entities": { + "roleCuisine": ["italian"], + "$instance": { + "roleCuisine": [ + { + "role": "roleCuisine", + "type": "cuisine", + "text": "italian", + "startIndex": 7, + "length": len("italian"), + "score": None, + "modelType": "SpacyEntityExtractor", + } + ] + }, + }, + }, + } + + +def test_luis_response_without_role(): + em = LUISEmulator() + data = { + "text": "I want italian food", + "intent": {"name": "restaurant_search", "confidence": 0.737014589341683}, + "intent_ranking": [ + {"confidence": 0.737014589341683, "name": "restaurant_search"} + ], + "entities": [{"entity": "cuisine", "value": "italian"}], + } + norm = em.normalise_response_json(data) + assert norm == { + "query": data["text"], + "prediction": { + "normalizedQuery": data["text"], + "topIntent": "restaurant_search", + "intents": {"restaurant_search": {"score": 0.737014589341683}}, + "entities": { + "cuisine": ["italian"], + "$instance": { + "cuisine": [ + { + "role": None, + "type": "cuisine", + "text": "italian", + "startIndex": None, + "length": None, + "score": None, + "modelType": None, + } + ] + }, + }, + }, + } + + +def test_luis_response_without_intent_ranking(): + em = LUISEmulator() + data = { + "text": "I want italian food", + "intent": {"name": "restaurant_search", "confidence": 0.737014589341683}, + "entities": [{"entity": "cuisine", "value": "italian", "role": "roleCuisine"}], + } + norm = em.normalise_response_json(data) + assert norm == { + "query": data["text"], + "prediction": { + "normalizedQuery": data["text"], + "topIntent": "restaurant_search", + "intents": {"restaurant_search": {"score": 0.737014589341683}}, + "entities": { + "roleCuisine": ["italian"], + "$instance": { + "roleCuisine": [ + { + "role": "roleCuisine", + "type": "cuisine", + "text": "italian", + "startIndex": None, + "length": None, + "score": None, + "modelType": None, + } + ] + }, + }, + }, + } diff --git a/tests/nlu/emulators/test_no_emulator.py b/tests/nlu/emulators/test_no_emulator.py new file mode 100644 index 0000000..95f85c6 --- /dev/null +++ b/tests/nlu/emulators/test_no_emulator.py @@ -0,0 +1,28 @@ +def test_dummy_request(): + from rasa.nlu.emulators.no_emulator import NoEmulator + + em = NoEmulator() + norm = em.normalise_request_json({"text": ["arb text"]}) + assert norm == {"text": "arb text", "time": None} + + norm = em.normalise_request_json({"text": ["arb text"], "time": "1499279161658"}) + assert norm == {"text": "arb text", "time": "1499279161658"} + + +def test_dummy_response(): + from rasa.nlu.emulators.no_emulator import NoEmulator + + em = NoEmulator() + data = {"intent": "greet", "text": "hi", "entities": {}, "confidence": 1.0} + assert em.normalise_response_json(data) == data + + +def test_emulators_can_handle_missing_data(): + from rasa.nlu.emulators.luis import LUISEmulator + + em = LUISEmulator() + norm = em.normalise_response_json( + {"text": "this data doesn't contain an intent result"} + ) + assert norm["prediction"]["topIntent"] is None + assert norm["prediction"]["intents"] == {} diff --git a/tests/nlu/emulators/test_wit.py b/tests/nlu/emulators/test_wit.py new file mode 100644 index 0000000..1c0e2e9 --- /dev/null +++ b/tests/nlu/emulators/test_wit.py @@ -0,0 +1,67 @@ +def test_wit_request(): + from rasa.nlu.emulators.wit import WitEmulator + + em = WitEmulator() + norm = em.normalise_request_json({"text": ["arb text"]}) + assert norm == {"text": "arb text", "time": None} + + +def test_wit_response(): + from rasa.nlu.emulators.wit import WitEmulator + + em = WitEmulator() + data = { + "text": "I want italian food", + "intent": {"name": "inform", "confidence": 0.4794813722432127}, + "entities": [ + { + "entity": "cuisine", + "value": "italian", + "start": 7, + "end": 14, + "confidence_entity": 0.1234, + }, + { + "entity": "cuisine", + "value": "italian", + "role": "desert", + "start": 7, + "end": 14, + "confidence_entity": 0.1234, + }, + ], + } + norm = em.normalise_response_json(data) + + expected = { + "text": "I want italian food", + "intents": [{"name": "inform", "confidence": 0.4794813722432127}], + "entities": { + "cuisine:cuisine": [ + { + "name": "cuisine", + "role": "cuisine", + "start": 7, + "end": 14, + "body": "italian", + "value": "italian", + "confidence": 0.1234, + "entities": [], + } + ], + "cuisine:desert": [ + { + "name": "cuisine", + "role": "desert", + "start": 7, + "end": 14, + "body": "italian", + "value": "italian", + "confidence": 0.1234, + "entities": [], + } + ], + }, + } + + assert norm == expected diff --git a/tests/nlu/extractors/__init__.py b/tests/nlu/extractors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/nlu/extractors/test_crf_entity_extractor.py b/tests/nlu/extractors/test_crf_entity_extractor.py new file mode 100644 index 0000000..bf09538 --- /dev/null +++ b/tests/nlu/extractors/test_crf_entity_extractor.py @@ -0,0 +1,360 @@ +import copy +from typing import Dict, Text, List, Any, Callable + +import numpy as np +import pytest + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.constants import SPACY_DOCS +from rasa.nlu.extractors.crf_entity_extractor import ( + CRFEntityExtractor, + CRFEntityExtractorOptions, + CRFToken, +) +from rasa.nlu.featurizers.dense_featurizer.spacy_featurizer import SpacyFeaturizer +from rasa.nlu.tokenizers.spacy_tokenizer import SpacyTokenizer +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +from rasa.nlu.utils.spacy_utils import SpacyModel, SpacyNLP +from rasa.shared.importers.rasa import RasaFileImporter +from rasa.shared.nlu.constants import TEXT, ENTITIES +from rasa.shared.nlu.training_data.message import Message + + +@pytest.fixture() +def crf_entity_extractor( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +) -> Callable[[Dict[Text, Any]], CRFEntityExtractor]: + def inner(config: Dict[Text, Any]) -> CRFEntityExtractor: + return CRFEntityExtractor.create( + {**CRFEntityExtractor.get_default_config(), **config}, + default_model_storage, + Resource("CRFEntityExtractor"), + default_execution_context, + ) + + return inner + + +def test_all_features_defined(): + assert set(CRFEntityExtractorOptions) == set( + CRFEntityExtractor.function_dict.keys() + ) + + +async def test_train_persist_load_with_composite_entities( + crf_entity_extractor: Callable[[Dict[Text, Any]], CRFEntityExtractor], + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + whitespace_tokenizer: WhitespaceTokenizer, +): + importer = RasaFileImporter( + training_data_paths=["data/test/demo-rasa-composite-entities.yml"] + ) + training_data = importer.get_nlu_data() + + whitespace_tokenizer.process_training_data(training_data) + + crf_extractor = crf_entity_extractor({}) + crf_extractor.train(training_data) + + message = Message(data={TEXT: "I am looking for an italian restaurant"}) + + whitespace_tokenizer.process([message]) + message2 = copy.deepcopy(message) + + processed_message = crf_extractor.process([message])[0] + + loaded_extractor = CRFEntityExtractor.load( + CRFEntityExtractor.get_default_config(), + default_model_storage, + Resource("CRFEntityExtractor"), + default_execution_context, + ) + + processed_message2 = loaded_extractor.process([message2])[0] + + assert processed_message2.fingerprint() == processed_message.fingerprint() + assert list(loaded_extractor.entity_taggers.keys()) == list( + crf_extractor.entity_taggers.keys() + ) + + +@pytest.mark.parametrize( + "config_params", + [ + ( + { + "features": [ + ["low", "title", "upper", "pos", "pos2"], + [ + "low", + "suffix3", + "suffix2", + "upper", + "title", + "digit", + "pos", + "pos2", + ], + ["low", "title", "upper", "pos", "pos2"], + ], + "BILOU_flag": False, + } + ), + ( + { + "features": [ + ["low", "title", "upper", "pos", "pos2"], + [ + "low", + "suffix3", + "suffix2", + "upper", + "title", + "digit", + "pos", + "pos2", + ], + ["low", "title", "upper", "pos", "pos2"], + ], + "BILOU_flag": True, + } + ), + ], +) +async def test_train_persist_with_different_configurations( + crf_entity_extractor: Callable[[Dict[Text, Any]], CRFEntityExtractor], + config_params: Dict[Text, Any], + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + spacy_tokenizer: SpacyTokenizer, + spacy_featurizer: SpacyFeaturizer, + spacy_nlp_component: SpacyNLP, + spacy_model: SpacyModel, +): + + crf_extractor = crf_entity_extractor(config_params) + + importer = RasaFileImporter(training_data_paths=["data/examples/rasa"]) + training_data = importer.get_nlu_data() + + training_data = spacy_nlp_component.process_training_data( + training_data, spacy_model + ) + training_data = spacy_tokenizer.process_training_data(training_data) + training_data = spacy_featurizer.process_training_data(training_data) + crf_extractor.train(training_data) + + message = Message(data={TEXT: "I am looking for an italian restaurant"}) + messages = spacy_nlp_component.process([message], spacy_model) + messages = spacy_tokenizer.process(messages) + message = spacy_featurizer.process(messages)[0] + message2 = copy.deepcopy(message) + + processed_message = crf_extractor.process([message])[0] + + loaded_extractor = CRFEntityExtractor.load( + {**CRFEntityExtractor.get_default_config(), **config_params}, + default_model_storage, + Resource("CRFEntityExtractor"), + default_execution_context, + ) + + processed_message2 = loaded_extractor.process([message2])[0] + + assert processed_message2.fingerprint() == processed_message.fingerprint() + + detected_entities = processed_message2.get(ENTITIES) + + assert len(detected_entities) == 1 + assert detected_entities[0]["entity"] == "cuisine" + assert detected_entities[0]["value"] == "italian" + + +def test_crf_use_dense_features( + crf_entity_extractor: Callable[[Dict[Text, Any]], CRFEntityExtractor], + spacy_nlp: Any, + spacy_featurizer: SpacyFeaturizer, + spacy_tokenizer: SpacyTokenizer, +): + component_config = { + "features": [ + ["low", "title", "upper", "pos", "pos2"], + [ + "low", + "suffix3", + "suffix2", + "upper", + "title", + "digit", + "pos", + "pos2", + "text_dense_features", + ], + ["low", "title", "upper", "pos", "pos2"], + ] + } + crf_extractor = crf_entity_extractor(component_config) + + text = "Rasa is a company in Berlin" + message = Message(data={TEXT: text}) + message.set(SPACY_DOCS[TEXT], spacy_nlp(text)) + + spacy_tokenizer.process([message]) + spacy_featurizer.process([message]) + + text_data = crf_extractor._convert_to_crf_tokens(message) + features = crf_extractor._crf_tokens_to_features(text_data, component_config) + + assert "0:text_dense_features" in features[0] + dense_features, _ = message.get_dense_features(TEXT, []) + if dense_features: + dense_features = dense_features.features + + for i in range(0, len(dense_features[0])): + assert ( + features[0]["0:text_dense_features"]["text_dense_features"][str(i)] + == dense_features[0][i] + ) + + +@pytest.mark.parametrize( + "entity_predictions, expected_label, expected_confidence", + [ + ([{"O": 0.34, "B-person": 0.03, "I-person": 0.85}], ["I-person"], [0.88]), + ([{"O": 0.99, "person": 0.03}], ["O"], [0.99]), + ], +) +def test_most_likely_entity( + crf_entity_extractor: Callable[[Dict[Text, Any]], CRFEntityExtractor], + entity_predictions: List[Dict[Text, float]], + expected_label: Text, + expected_confidence: float, +): + crf_extractor = crf_entity_extractor({"BILOU_flag": True}) + + actual_label, actual_confidence = crf_extractor._most_likely_tag(entity_predictions) + + assert actual_label == expected_label + assert actual_confidence == expected_confidence + + +def test_process_unfeaturized_input( + crf_entity_extractor: Callable[[Dict[Text, Any]], CRFEntityExtractor], +): + crf_extractor = crf_entity_extractor({}) + message_text = "message text" + message = Message(data={TEXT: message_text}) + processed_message = crf_extractor.process([message])[0] + + assert processed_message.get(TEXT) == message_text + assert processed_message.get(ENTITIES) == [] + + +@pytest.fixture +def sample_data(): + return { + "text": "apple", + "pos_tag": "NOUN", + "pattern": {"length": 5, "is_capitalized": False}, + "dense_features": np.array([0.1, 0.2, 0.3]), + "entity_tag": "B-FOOD", + "entity_role_tag": "INGREDIENT", + "entity_group_tag": "ITEM", + } + + +@pytest.fixture +def sample_token(sample_data): + return CRFToken( + sample_data["text"], + sample_data["pos_tag"], + sample_data["pattern"], + sample_data["dense_features"], + sample_data["entity_tag"], + sample_data["entity_role_tag"], + sample_data["entity_group_tag"], + ) + + +def test_crf_token_to_dict(sample_data, sample_token): + token_dict = sample_token.to_dict() + + assert token_dict["text"] == sample_data["text"] + assert token_dict["pos_tag"] == sample_data["pos_tag"] + assert token_dict["pattern"] == sample_data["pattern"] + assert token_dict["dense_features"] == [ + str(x) for x in sample_data["dense_features"] + ] + assert token_dict["entity_tag"] == sample_data["entity_tag"] + assert token_dict["entity_role_tag"] == sample_data["entity_role_tag"] + assert token_dict["entity_group_tag"] == sample_data["entity_group_tag"] + + +def test_crf_token_create_from_dict(sample_data): + dict_data = { + "text": sample_data["text"], + "pos_tag": sample_data["pos_tag"], + "pattern": sample_data["pattern"], + "dense_features": [str(x) for x in sample_data["dense_features"]], + "entity_tag": sample_data["entity_tag"], + "entity_role_tag": sample_data["entity_role_tag"], + "entity_group_tag": sample_data["entity_group_tag"], + } + + token = CRFToken.create_from_dict(dict_data) + + assert token.text == sample_data["text"] + assert token.pos_tag == sample_data["pos_tag"] + assert token.pattern == sample_data["pattern"] + np.testing.assert_array_equal(token.dense_features, sample_data["dense_features"]) + assert token.entity_tag == sample_data["entity_tag"] + assert token.entity_role_tag == sample_data["entity_role_tag"] + assert token.entity_group_tag == sample_data["entity_group_tag"] + + +def test_crf_token_roundtrip_conversion(sample_token): + token_dict = sample_token.to_dict() + new_token = CRFToken.create_from_dict(token_dict) + + assert new_token.text == sample_token.text + assert new_token.pos_tag == sample_token.pos_tag + assert new_token.pattern == sample_token.pattern + np.testing.assert_array_equal(new_token.dense_features, sample_token.dense_features) + assert new_token.entity_tag == sample_token.entity_tag + assert new_token.entity_role_tag == sample_token.entity_role_tag + assert new_token.entity_group_tag == sample_token.entity_group_tag + + +def test_crf_token_empty_dense_features(sample_data): + sample_data["dense_features"] = np.array([]) + token = CRFToken( + sample_data["text"], + sample_data["pos_tag"], + sample_data["pattern"], + sample_data["dense_features"], + sample_data["entity_tag"], + sample_data["entity_role_tag"], + sample_data["entity_group_tag"], + ) + token_dict = token.to_dict() + new_token = CRFToken.create_from_dict(token_dict) + np.testing.assert_array_equal(new_token.dense_features, np.array([])) + + +def test_crf_token_empty_pattern(sample_data): + sample_data["pattern"] = {} + token = CRFToken( + sample_data["text"], + sample_data["pos_tag"], + sample_data["pattern"], + sample_data["dense_features"], + sample_data["entity_tag"], + sample_data["entity_role_tag"], + sample_data["entity_group_tag"], + ) + token_dict = token.to_dict() + new_token = CRFToken.create_from_dict(token_dict) + assert new_token.pattern == {} diff --git a/tests/nlu/extractors/test_duckling_entity_extractor.py b/tests/nlu/extractors/test_duckling_entity_extractor.py new file mode 100644 index 0000000..578e42f --- /dev/null +++ b/tests/nlu/extractors/test_duckling_entity_extractor.py @@ -0,0 +1,239 @@ +from typing import Callable, Dict, Text, Any + +import pytest +import responses + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.extractors.duckling_entity_extractor import DucklingEntityExtractor +from rasa.shared.nlu.constants import TEXT +from rasa.shared.nlu.training_data.message import Message + + +@pytest.fixture() +def create_duckling( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +) -> Callable[[Dict[Text, Any]], DucklingEntityExtractor]: + def inner(config: Dict[Text, Any]) -> DucklingEntityExtractor: + return DucklingEntityExtractor.create( + config={ + **DucklingEntityExtractor.get_default_config(), + "url": "http://localhost:8000", + **config, + }, + model_storage=default_model_storage, + execution_context=default_execution_context, + resource=Resource("duckling"), + ) + + return inner + + +def test_duckling_entity_extractor_with_multiple_extracted_dates( + create_duckling: Callable[[Dict[Text, Any]], DucklingEntityExtractor] +): + duckling = create_duckling({"dimensions": ["time"], "timezone": "UTC"}) + + with responses.RequestsMock() as rsps: + rsps.add( + responses.POST, + "http://localhost:8000/parse", + json=[ + { + "body": "Today", + "start": 0, + "value": { + "values": [ + { + "value": "2018-11-13T00:00:00.000-08:00", + "grain": "day", + "type": "value", + } + ], + "value": "2018-11-13T00:00:00.000-08:00", + "grain": "day", + "type": "value", + }, + "end": 5, + "dim": "time", + "latent": False, + }, + { + "body": "the 5th", + "start": 9, + "value": { + "values": [ + { + "value": "2018-12-05T00:00:00.000-08:00", + "grain": "day", + "type": "value", + }, + { + "value": "2019-01-05T00:00:00.000-08:00", + "grain": "day", + "type": "value", + }, + { + "value": "2019-02-05T00:00:00.000-08:00", + "grain": "day", + "type": "value", + }, + ], + "value": "2018-12-05T00:00:00.000-08:00", + "grain": "day", + "type": "value", + }, + "end": 16, + "dim": "time", + "latent": False, + }, + { + "body": "5th of May", + "start": 13, + "value": { + "values": [ + { + "value": "2019-05-05T00:00:00.000-07:00", + "grain": "day", + "type": "value", + }, + { + "value": "2020-05-05T00:00:00.000-07:00", + "grain": "day", + "type": "value", + }, + { + "value": "2021-05-05T00:00:00.000-07:00", + "grain": "day", + "type": "value", + }, + ], + "value": "2019-05-05T00:00:00.000-07:00", + "grain": "day", + "type": "value", + }, + "end": 23, + "dim": "time", + "latent": False, + }, + { + "body": "tomorrow", + "start": 37, + "value": { + "values": [ + { + "value": "2018-11-14T00:00:00.000-08:00", + "grain": "day", + "type": "value", + } + ], + "value": "2018-11-14T00:00:00.000-08:00", + "grain": "day", + "type": "value", + }, + "end": 45, + "dim": "time", + "latent": False, + }, + ], + ) + + messages = [ + Message(data={TEXT: "Today is the 5th of May. Let us meet tomorrow."}) + ] + parsed_messages = duckling.process(messages) + + assert len(parsed_messages) == 1 + entities = parsed_messages[0].get("entities") + assert len(entities) == 4 + + +def test_duckling_entity_extractor_with_one_extracted_date( + create_duckling: Callable[[Dict[Text, Any]], DucklingEntityExtractor] +): + duckling = create_duckling({"dimensions": ["time"], "timezone": "UTC"}) + + with responses.RequestsMock() as rsps: + rsps.add( + responses.POST, + "http://localhost:8000/parse", + json=[ + { + "body": "tomorrow", + "start": 12, + "value": { + "values": [ + { + "value": "2013-10-13T00:00:00.000Z", + "grain": "day", + "type": "value", + } + ], + "value": "2013-10-13T00:00:00.000Z", + "grain": "day", + "type": "value", + }, + "end": 20, + "dim": "time", + "latent": False, + } + ], + ) + + # 1381536182 == 2013/10/12 02:03:02 + messages = [Message(data={TEXT: "Let us meet tomorrow."}, time=1381536182)] + duckling.process(messages) + + assert len(messages) == 1 + entities = messages[0].get("entities") + assert len(entities) == 1 + assert entities[0]["text"] == "tomorrow" + assert entities[0]["value"] == "2013-10-13T00:00:00.000Z" + + +def test_duckling_entity_extractor_dimension_filtering( + create_duckling: Callable[[Dict[Text, Any]], DucklingEntityExtractor] +): + duckling_number = create_duckling({"dimensions": ["number"]}) + + with responses.RequestsMock() as rsps: + rsps.add( + responses.POST, + "http://localhost:8000/parse", + json=[ + { + "body": "Yesterday", + "start": 0, + "value": { + "values": [ + { + "value": "2019-02-28T00:00:00.000+01:00", + "grain": "day", + "type": "value", + } + ], + "value": "2019-02-28T00:00:00.000+01:00", + "grain": "day", + "type": "value", + }, + "end": 9, + "dim": "time", + }, + { + "body": "5", + "start": 21, + "value": {"value": 5, "type": "value"}, + "end": 22, + "dim": "number", + }, + ], + ) + + messages = [Message(data={TEXT: "Yesterday there were 5 people in a room"})] + duckling_number.process(messages) + assert len(messages) == 1 + entities = messages[0].get("entities") + assert len(entities) == 1 + assert entities[0]["text"] == "5" + assert entities[0]["value"] == 5 diff --git a/tests/nlu/extractors/test_entity_synonyms.py b/tests/nlu/extractors/test_entity_synonyms.py new file mode 100644 index 0000000..135aded --- /dev/null +++ b/tests/nlu/extractors/test_entity_synonyms.py @@ -0,0 +1,147 @@ +from rasa.nlu.extractors.entity_synonyms import EntitySynonymMapper +from rasa.shared.nlu.constants import TEXT, ENTITIES +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource + + +def test_entity_synonyms( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + resource = Resource("xy") + entities = [ + {"entity": "test", "value": "chines", "start": 0, "end": 6}, + {"entity": "test", "value": "chinese", "start": 0, "end": 6}, + {"entity": "test", "value": "china", "start": 0, "end": 6}, + ] + ent_synonyms = {"chines": "chinese", "NYC": "New York City"} + + mapper = EntitySynonymMapper.create( + {}, default_model_storage, resource, default_execution_context, ent_synonyms + ) + mapper.replace_synonyms(entities) + + assert len(entities) == 3 + assert entities[0]["value"] == "chinese" + assert entities[1]["value"] == "chinese" + assert entities[2]["value"] == "china" + + +def test_unintentional_synonyms_capitalized( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + resource = Resource("xy") + mapper = EntitySynonymMapper.create( + {}, default_model_storage, resource, default_execution_context + ) + + examples = [ + Message( + data={ + TEXT: "Any Mexican restaurant will do", + "intent": "restaurant_search", + "entities": [ + {"start": 4, "end": 11, "value": "Mexican", "entity": "cuisine"} + ], + } + ), + Message( + data={ + TEXT: "I want Tacos!", + "intent": "restaurant_search", + "entities": [ + {"start": 7, "end": 12, "value": "Mexican", "entity": "cuisine"} + ], + } + ), + ] + + mapper.train(TrainingData(training_examples=examples)) + + assert mapper.synonyms.get("mexican") == "Mexican" + assert mapper.synonyms.get("tacos") == "Mexican" + + +def test_synonym_mapper_with_ints( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + resource = Resource("xy") + mapper = EntitySynonymMapper.create( + {}, default_model_storage, resource, default_execution_context + ) + entities = [ + { + "start": 21, + "end": 22, + "text": "5", + "value": 5, + "confidence": 1.0, + "additional_info": {"value": 5, "type": "value"}, + "entity": "number", + "extractor": "DucklingEntityExtractorComponent", + } + ] + message = Message(data={TEXT: "He was 6 feet away", ENTITIES: entities}) + + # This doesn't break + mapper.process([message]) + + assert message.get(ENTITIES) == entities + + +def test_synonym_alternate_case( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + resource = Resource("xy") + mapper = EntitySynonymMapper.create( + {}, default_model_storage, resource, default_execution_context + ) + + examples = [ + Message( + data={ + TEXT: "What's the weather in austria?", + "intent": "whats_weather", + "entities": [ + {"start": 22, "end": 29, "value": "austria", "entity": "GPE"} + ], + } + ), + Message( + data={ + TEXT: "weather vienna?", + "intent": "whats_weather", + "entities": [ + {"start": 8, "end": 14, "value": "austria", "entity": "GPE"} + ], + } + ), + ] + entities = [ + {"entity": "test", "value": "austria", "start": 0, "end": 7}, + {"entity": "test", "value": "Austria", "start": 0, "end": 7}, + {"entity": "test", "value": "AUSTRIA", "start": 0, "end": 7}, + {"entity": "test", "value": "ausTRIA", "start": 0, "end": 7}, + {"entity": "test", "value": "Vienna", "start": 0, "end": 7}, + {"entity": "test", "value": "brazil", "start": 0, "end": 7}, + ] + + mapper.train(TrainingData(training_examples=examples)) + mapper.replace_synonyms(entities) + expected_synonym_value = "austria" + + # synonym key for example value is present + assert mapper.synonyms.get("vienna") == expected_synonym_value + + # synonym key for self is present + assert mapper.synonyms.get("austria") == expected_synonym_value + + # all replacement values are correct + assert entities[0]["value"] == expected_synonym_value + assert entities[1]["value"] == expected_synonym_value + assert entities[2]["value"] == expected_synonym_value + assert entities[3]["value"] == expected_synonym_value + assert entities[4]["value"] == expected_synonym_value + assert entities[5]["value"] != expected_synonym_value diff --git a/tests/nlu/extractors/test_extractor.py b/tests/nlu/extractors/test_extractor.py new file mode 100644 index 0000000..d128f76 --- /dev/null +++ b/tests/nlu/extractors/test_extractor.py @@ -0,0 +1,482 @@ +from typing import Any, Text, Dict, List + +import pytest +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION + +from rasa.shared.nlu.constants import TEXT, SPLIT_ENTITIES_BY_COMMA +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.extractors.extractor import EntityExtractorMixin +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +from rasa.shared.nlu.training_data.formats.rasa_yaml import RasaYAMLReader + + +@pytest.mark.parametrize( + "text, tags, confidences, expected_entities", + [ + ( + "I am flying from San Fransisco to Amsterdam", + { + "entity": ["O", "O", "O", "O", "city", "city", "O", "city"], + "role": ["O", "O", "O", "O", "from", "from", "O", "to"], + }, + { + "entity": [1.0, 1.0, 1.0, 1.0, 0.98, 0.78, 1.0, 0.89], + "role": [1.0, 1.0, 1.0, 1.0, 0.98, 0.78, 1.0, 0.89], + }, + [ + { + "entity": "city", + "start": 17, + "end": 30, + "value": "San Fransisco", + "role": "from", + "confidence_entity": 0.78, + "confidence_role": 0.78, + }, + { + "entity": "city", + "start": 34, + "end": 43, + "value": "Amsterdam", + "role": "to", + "confidence_entity": 0.89, + "confidence_role": 0.89, + }, + ], + ), + ( + "I am flying from San Fransisco to Amsterdam", + { + "entity": ["O", "O", "O", "O", "B-city", "L-city", "O", "U-city"], + "role": ["O", "O", "O", "O", "B-from", "L-from", "O", "U-to"], + }, + { + "entity": [1.0, 1.0, 1.0, 1.0, 0.98, 0.78, 1.0, 0.89], + "role": [1.0, 1.0, 1.0, 1.0, 0.98, 0.78, 1.0, 0.89], + }, + [ + { + "entity": "city", + "start": 17, + "end": 30, + "value": "San Fransisco", + "role": "from", + "confidence_entity": 0.78, + "confidence_role": 0.78, + }, + { + "entity": "city", + "start": 34, + "end": 43, + "value": "Amsterdam", + "role": "to", + "confidence_entity": 0.89, + "confidence_role": 0.89, + }, + ], + ), + ( + "I am flying from San Fransisco to Amsterdam", + { + "entity": ["O", "O", "O", "O", "city", "city", "O", "city"], + "group": ["O", "O", "O", "O", "1", "1", "O", "1"], + }, + None, + [ + { + "entity": "city", + "start": 17, + "end": 30, + "value": "San Fransisco", + "group": "1", + }, + { + "entity": "city", + "start": 34, + "end": 43, + "value": "Amsterdam", + "group": "1", + }, + ], + ), + ( + "Amsterdam", + {"entity": ["city"], "role": ["O"], "group": ["O"]}, + None, + [{"entity": "city", "start": 0, "end": 9, "value": "Amsterdam"}], + ), + ( + "New-York", + {"entity": ["city", "city"], "role": ["O", "O"], "group": ["O", "O"]}, + None, + [{"entity": "city", "start": 0, "end": 8, "value": "New-York"}], + ), + ( + "Amsterdam, Berlin, and London", + { + "entity": ["city", "city", "O", "city"], + "role": ["O", "O", "O", "O"], + "group": ["O", "O", "O", "O"], + }, + None, + [ + {"entity": "city", "start": 0, "end": 9, "value": "Amsterdam"}, + {"entity": "city", "start": 11, "end": 17, "value": "Berlin"}, + {"entity": "city", "start": 23, "end": 29, "value": "London"}, + ], + ), + ( + "Amsterdam Berlin and London", + { + "entity": ["U-city", "U-city", "O", "U-city"], + "role": ["O", "O", "O", "O"], + "group": ["O", "O", "O", "O"], + }, + None, + [ + {"entity": "city", "start": 0, "end": 9, "value": "Amsterdam"}, + {"entity": "city", "start": 10, "end": 16, "value": "Berlin"}, + {"entity": "city", "start": 21, "end": 27, "value": "London"}, + ], + ), + ( + "San Fransisco Amsterdam, London", + { + "entity": ["B-city", "L-city", "U-city", "U-city"], + "role": ["O", "O", "O", "O"], + "group": ["O", "O", "O", "O"], + }, + None, + [ + {"entity": "city", "start": 0, "end": 13, "value": "San Fransisco"}, + {"entity": "city", "start": 14, "end": 23, "value": "Amsterdam"}, + {"entity": "city", "start": 25, "end": 31, "value": "London"}, + ], + ), + ( + "New York City Los Angeles and San Diego", + { + "entity": [ + "B-city", + "I-city", + "L-city", + "B-city", + "L-city", + "O", + "B-city", + "L-city", + ], + "role": ["O", "O", "O", "O", "O", "O", "O", "O"], + "group": ["O", "O", "O", "O", "O", "O", "O", "O"], + }, + None, + [ + {"entity": "city", "start": 0, "end": 13, "value": "New York City"}, + {"entity": "city", "start": 14, "end": 25, "value": "Los Angeles"}, + {"entity": "city", "start": 30, "end": 39, "value": "San Diego"}, + ], + ), + ( + "Berlin weather", + {"entity": ["I-city", "O"], "role": ["O", "O"], "group": ["O", "O"]}, + None, + [{"entity": "city", "start": 0, "end": 6, "value": "Berlin"}], + ), + ( + "New-York", + {"entity": ["city", "city"], "role": ["O", "O"], "group": ["O", "O"]}, + None, + [{"entity": "city", "start": 0, "end": 8, "value": "New-York"}], + ), + ( + "Amsterdam, Berlin, and London", + { + "entity": ["city", "city", "O", "city"], + "role": ["O", "O", "O", "O"], + "group": ["O", "O", "O", "O"], + }, + None, + [ + {"entity": "city", "start": 0, "end": 9, "value": "Amsterdam"}, + {"entity": "city", "start": 11, "end": 17, "value": "Berlin"}, + {"entity": "city", "start": 23, "end": 29, "value": "London"}, + ], + ), + ( + "Amsterdam Berlin and London", + { + "entity": ["city", "city", "O", "city"], + "role": ["O", "O", "O", "O"], + "group": ["O", "O", "O", "O"], + }, + None, + [ + {"entity": "city", "start": 0, "end": 16, "value": "Amsterdam Berlin"}, + {"entity": "city", "start": 21, "end": 27, "value": "London"}, + ], + ), + ], +) +def test_convert_tags_to_entities( + text: Text, + tags: Dict[Text, List[Text]], + confidences: Dict[Text, List[float]], + expected_entities: List[Dict[Text, Any]], + whitespace_tokenizer: WhitespaceTokenizer, +): + extractor = EntityExtractorMixin() + + message = Message(data={TEXT: text}) + tokens = whitespace_tokenizer.tokenize(message, TEXT) + + split_entities_config = {SPLIT_ENTITIES_BY_COMMA: True} + actual_entities = extractor.convert_predictions_into_entities( + text, tokens, tags, split_entities_config, confidences + ) + assert actual_entities == expected_entities + + +@pytest.mark.parametrize( + "text, tags, confidences, expected_entities", + [ + ( + "I live at 22 Powderhall Rd., EH7 4GB, Edinburgh, UK", + { + "entity": [ + "O", + "O", + "O", + "address", + "address", + "address", + "address", + "address", + "address", + "address", + ] + }, + {"entity": [1.0, 1.0, 1.0, 1.0, 0.98, 0.78, 1.0, 0.89, 1.0, 1.0, 1.0]}, + [ + { + "entity": "address", + "start": 10, + "end": 51, + "value": "22 Powderhall Rd., EH7 4GB, Edinburgh, UK", + "confidence_entity": 0.78, + } + ], + ), + ( + "The address is Schönhauser Allee 175, 10119 Berlin, DE", + { + "entity": [ + "O", + "O", + "O", + "address", + "address", + "address", + "address", + "address", + "address", + ] + }, + {"entity": [1.0, 1.0, 1.0, 1.0, 1.0, 0.67, 0.77, 1.0, 0.98]}, + [ + { + "entity": "address", + "start": 15, + "end": 54, + "value": "Schönhauser Allee 175, 10119 Berlin, DE", + "confidence_entity": 0.67, + } + ], + ), + ( + "We need to get more of tofu, cauliflower, avocado", + { + "entity": [ + "O", + "O", + "O", + "O", + "O", + "O", + "ingredient", + "ingredient", + "ingredient", + ] + }, + {"entity": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.78]}, + [ + { + "entity": "ingredient", + "start": 23, + "end": 27, + "value": "tofu", + "confidence_entity": 1.0, + }, + { + "entity": "ingredient", + "start": 29, + "end": 40, + "value": "cauliflower", + "confidence_entity": 0.9, + }, + { + "entity": "ingredient", + "start": 42, + "end": 49, + "value": "avocado", + "confidence_entity": 0.78, + }, + ], + ), + ( + "So the list of drinks to get is coffee, Club Mate, Ottakringer", + { + "entity": [ + "O", + "O", + "O", + "O", + "O", + "O", + "O", + "O", + "beverage", + "beverage", + "beverage", + "beverage", + ] + }, + { + "entity": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.69, + 0.88, + 0.84, + 0.79, + ] + }, + [ + { + "entity": "beverage", + "start": 32, + "end": 38, + "value": "coffee", + "confidence_entity": 0.69, + }, + { + "entity": "beverage", + "start": 40, + "end": 49, + "value": "Club Mate", + "confidence_entity": 0.84, + }, + { + "entity": "beverage", + "start": 51, + "end": 62, + "value": "Ottakringer", + "confidence_entity": 0.79, + }, + ], + ), + ], +) +def test_split_entities_by_comma( + text: Text, + tags: Dict[Text, List[Text]], + confidences: Dict[Text, List[float]], + expected_entities: List[Dict[Text, Any]], + whitespace_tokenizer: WhitespaceTokenizer, +): + extractor = EntityExtractorMixin() + + message = Message(data={TEXT: text}) + tokens = whitespace_tokenizer.tokenize(message, TEXT) + + split_entities_config = { + SPLIT_ENTITIES_BY_COMMA: True, + "address": False, + "ingredient": True, + } + actual_entities = extractor.convert_predictions_into_entities( + text, tokens, tags, split_entities_config, confidences + ) + + assert actual_entities == expected_entities + + +@pytest.mark.parametrize( + "text, warnings", + [ + ( + f'version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}"\n' + "nlu:\n" + "- intent: test\n" + " examples: |\n" + " - I want to fly from [Berlin](location) to [ London](location)\n", + 1, + ), + ( + f'version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}"\n' + "nlu:\n" + "- intent: test\n" + " examples: |\n" + " - I want to fly from [Berlin ](location) to [London](location)\n", + 1, + ), + ( + f'version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}"\n' + "nlu:\n" + "- intent: test\n" + " examples: |\n" + " - I want to fly from [Berlin](location) to [London.](location)\n" + " - I have nothing to say.\n", + 1, + ), + ( + f'version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}"\n' + "nlu:\n" + "- intent: test\n" + " examples: |\n" + " - I have nothing to say.\n" + " - I want to fly from [Berlin](location) to[San Fransisco](location)\n", + 1, + ), + ( + f'version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}"\n' + "nlu:\n" + "- intent: test\n" + " examples: |\n" + " - I want to fly from [Berlin](location) to[San Fransisco](location)\n" + " - Book a flight from [London](location) to [Paris.](location)\n", + 2, + ), + ], +) +def test_check_correct_entity_annotations( + text: Text, warnings: int, whitespace_tokenizer: WhitespaceTokenizer +): + reader = RasaYAMLReader() + + training_data = reader.reads(text) + whitespace_tokenizer.process_training_data(training_data) + + with pytest.warns(UserWarning) as record: + EntityExtractorMixin.check_correct_entity_annotations(training_data) + + assert len(record) == warnings + assert all( + [excerpt in record[0].message.args[0]] + for excerpt in ["Misaligned entity annotation in sentence"] + ) diff --git a/tests/nlu/extractors/test_mitie_entity_extractor.py b/tests/nlu/extractors/test_mitie_entity_extractor.py new file mode 100644 index 0000000..d126a3e --- /dev/null +++ b/tests/nlu/extractors/test_mitie_entity_extractor.py @@ -0,0 +1,179 @@ +import logging + +import pytest +from typing import Callable, Dict, Text, Any +import re +import copy + +from _pytest.logging import LogCaptureFixture + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.utils.mitie_utils import MitieModel, MitieNLP +from rasa.nlu.tokenizers.tokenizer import Token +from rasa.nlu.constants import EXTRACTOR, TOKENS_NAMES +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.constants import ( + ENTITIES, + ENTITY_ATTRIBUTE_CONFIDENCE, + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + ENTITY_ATTRIBUTE_TYPE, + INTENT, + TEXT, +) +from rasa.nlu.extractors.mitie_entity_extractor import MitieEntityExtractor + + +@pytest.fixture +def default_resource() -> Resource: + return Resource("mitie") + + +@pytest.fixture +def mitie_model( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + default_resource: Resource, +) -> MitieModel: + component = MitieNLP.create( + MitieNLP.get_default_config(), + default_model_storage, + default_resource, + default_execution_context, + ) + return component.provide() + + +@pytest.fixture +def create_or_load_mitie_extractor( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +) -> Callable[[Dict[Text, Any]], MitieEntityExtractor]: + def inner(config: Dict[Text, Any], load: bool = False) -> MitieEntityExtractor: + if load: + constructor = MitieEntityExtractor.load + else: + constructor = MitieEntityExtractor.create + return constructor( + model_storage=default_model_storage, + execution_context=default_execution_context, + resource=Resource("MitieEntityExtractor"), + config={**MitieEntityExtractor.get_default_config(), **config}, + ) + + return inner + + +@pytest.mark.parametrize("with_trainable_examples", [(True, False)]) +def test_train_extract_load( + create_or_load_mitie_extractor: Callable[[Dict[Text, Any]], MitieEntityExtractor], + mitie_model: MitieModel, + with_trainable_examples: bool, +): + + # some texts where last token is a city + texts_ending_with_city = ["Bert lives in Berlin", "Ernie asks where is Bielefeld"] + + # create some messages with entities + messages_with_entities = [] + for text in texts_ending_with_city: + tokens = [ + Token(text=match.group(), start=match.start(), end=match.end()) + for match in re.finditer(r"\w+", text) + ] + entities = [ + { + ENTITY_ATTRIBUTE_TYPE: "city", + ENTITY_ATTRIBUTE_VALUE: tokens[-1].text, + ENTITY_ATTRIBUTE_START: tokens[-1].start, + ENTITY_ATTRIBUTE_END: tokens[-1].end, + EXTRACTOR: None, # must be None or mitie_entity_extractor.name + } + ] + + message = Message(text=text) + message.data[TOKENS_NAMES[TEXT]] = tokens + message.data[ENTITIES] = entities + if with_trainable_examples: + message.data[INTENT] = "must have intent otherwise not an NLU example" + else: + pass # not adding an intent is sufficient to make this a "core example" + messages_with_entities.append(message) + + # turn them into training data + training_data = TrainingData(messages_with_entities) + + # train the extractor + mitie_entity_extractor = create_or_load_mitie_extractor(config={}, load=False) + mitie_entity_extractor.train(training_data, model=mitie_model) + + # create some messages "without entities" - for processing + messages_without_entities = [ + Message( + data={ + TEXT: message.data[TEXT], + TOKENS_NAMES[TEXT]: message.data[TOKENS_NAMES[TEXT]], + } + ) + for message in messages_with_entities + ] + + # process! + mitie_entity_extractor.process( + messages=messages_without_entities, model=mitie_model + ) + + # check that extractor added the expected entities to the messages + # (that initially were) "with no entities" + if with_trainable_examples: + for processed_message, labeled_message in zip( + messages_without_entities, messages_with_entities + ): # i.e. "without (before process)" + assert ENTITIES in processed_message.data + computed_entities = processed_message.data[ENTITIES] + assert len(computed_entities) == 1 + computed_entity = copy.copy(computed_entities[0]) # we need it later + # check confidence + assert computed_entity.pop(ENTITY_ATTRIBUTE_CONFIDENCE, "surprise") is None + # check extractor + assert computed_entity.pop(EXTRACTOR, None) == mitie_entity_extractor.name + # compare the rest + expected_entity = labeled_message.data[ENTITIES][0] + expected_entity.pop(EXTRACTOR) + assert computed_entity == expected_entity + + else: + for processed_message in messages_without_entities: + assert ENTITIES not in processed_message.data + + # load the same extractor again + loaded_extractor = create_or_load_mitie_extractor(config={}, load=True) + + # check results are the same + same_messages_without_entities = [ + Message( + data={ + TEXT: message.data[TEXT], + TOKENS_NAMES[TEXT]: message.data[TOKENS_NAMES[TEXT]], + } + ) + for message in messages_with_entities + ] + loaded_extractor.process(messages=same_messages_without_entities, model=mitie_model) + assert same_messages_without_entities[0].data == messages_without_entities[0].data + + +def test_load_without_training( + create_or_load_mitie_extractor: Callable[[Dict[Text, Any]], MitieEntityExtractor], + caplog: LogCaptureFixture, +): + with caplog.at_level(logging.DEBUG): + create_or_load_mitie_extractor({}, load=True) + + assert any( + "Failed to load MitieEntityExtractor from model storage." in message + for message in caplog.messages + ) diff --git a/tests/nlu/extractors/test_regex_entity_extractor.py b/tests/nlu/extractors/test_regex_entity_extractor.py new file mode 100644 index 0000000..4b43469 --- /dev/null +++ b/tests/nlu/extractors/test_regex_entity_extractor.py @@ -0,0 +1,331 @@ +import copy +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.storage import ModelStorage +from typing import Any, Text, Dict, List, Callable + +import pytest + +from rasa.engine.storage.resource import Resource +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.constants import ( + ENTITIES, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + TEXT, + INTENT, + EXTRACTOR, +) +from rasa.nlu.extractors.regex_entity_extractor import RegexEntityExtractor + + +@pytest.fixture() +def create_or_load_extractor( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +) -> Callable[..., RegexEntityExtractor]: + def inner(config: Dict[Text, Any], load: bool = False) -> RegexEntityExtractor: + if load: + constructor = RegexEntityExtractor.load + else: + constructor = RegexEntityExtractor.create + return constructor( + config=config, + model_storage=default_model_storage, + resource=Resource("regex"), + execution_context=default_execution_context, + ) + + return inner + + +@pytest.mark.parametrize( + "config, text, lookup, expected_entities, test_loading", + [ + ( + # default config + {}, + "Berlin and London are cities.", + [ + { + "name": "city", + "elements": ["Berlin", "Amsterdam", "New York", "London"], + } + ], + [ + { + ENTITY_ATTRIBUTE_TYPE: "city", + ENTITY_ATTRIBUTE_VALUE: "Berlin", + ENTITY_ATTRIBUTE_START: 0, + ENTITY_ATTRIBUTE_END: 6, + EXTRACTOR: RegexEntityExtractor.__name__, + }, + { + ENTITY_ATTRIBUTE_TYPE: "city", + ENTITY_ATTRIBUTE_VALUE: "London", + ENTITY_ATTRIBUTE_START: 11, + ENTITY_ATTRIBUTE_END: 17, + EXTRACTOR: RegexEntityExtractor.__name__, + }, + ], + True, # test loading + ), + ( + {}, + "Sophie is visiting Thomas in Berlin.", + [ + { + "name": "city", + "elements": ["Berlin", "Amsterdam", "New York", "London"], + }, + {"name": "person", "elements": ["Max", "John", "Sophie", "Lisa"]}, + ], + [ + { + ENTITY_ATTRIBUTE_TYPE: "city", + ENTITY_ATTRIBUTE_VALUE: "Berlin", + ENTITY_ATTRIBUTE_START: 29, + ENTITY_ATTRIBUTE_END: 35, + EXTRACTOR: RegexEntityExtractor.__name__, + }, + { + ENTITY_ATTRIBUTE_TYPE: "person", + ENTITY_ATTRIBUTE_VALUE: "Sophie", + ENTITY_ATTRIBUTE_START: 0, + ENTITY_ATTRIBUTE_END: 6, + EXTRACTOR: RegexEntityExtractor.__name__, + }, + ], + False, + ), + ( + {}, + "Rasa is great.", + [ + { + "name": "city", + "elements": ["Berlin", "Amsterdam", "New York", "London"], + }, + {"name": "person", "elements": ["Max", "John", "Sophie", "Lisa"]}, + ], + [], + False, + ), + # not using word boundaries + ( + {"use_word_boundaries": False}, + "北京和上海都是大城市。", + [{"name": "city", "elements": ["北京", "上海", "广州", "深圳", "杭州"]}], + [ + { + ENTITY_ATTRIBUTE_TYPE: "city", + ENTITY_ATTRIBUTE_VALUE: "北京", + ENTITY_ATTRIBUTE_START: 0, + ENTITY_ATTRIBUTE_END: 2, + EXTRACTOR: RegexEntityExtractor.__name__, + }, + { + ENTITY_ATTRIBUTE_TYPE: "city", + ENTITY_ATTRIBUTE_VALUE: "上海", + ENTITY_ATTRIBUTE_START: 3, + ENTITY_ATTRIBUTE_END: 5, + EXTRACTOR: RegexEntityExtractor.__name__, + }, + ], + True, # test loading + ), + ( + {"use_word_boundaries": False}, + "小明正要去北京拜访老李。", + [ + {"name": "city", "elements": ["北京", "上海", "广州", "深圳", "杭州"]}, + {"name": "person", "elements": ["小明", "小红", "小王", "小李"]}, + ], + [ + { + ENTITY_ATTRIBUTE_TYPE: "city", + ENTITY_ATTRIBUTE_VALUE: "北京", + ENTITY_ATTRIBUTE_START: 5, + ENTITY_ATTRIBUTE_END: 7, + EXTRACTOR: RegexEntityExtractor.__name__, + }, + { + ENTITY_ATTRIBUTE_TYPE: "person", + ENTITY_ATTRIBUTE_VALUE: "小明", + ENTITY_ATTRIBUTE_START: 0, + ENTITY_ATTRIBUTE_END: 2, + EXTRACTOR: RegexEntityExtractor.__name__, + }, + ], + True, + ), + ( + {"use_word_boundaries": False}, + "Rasa 真好用。", + [ + {"name": "city", "elements": ["北京", "上海", "广州", "深圳", "杭州"]}, + {"name": "person", "elements": ["小明", "小红", "小王", "小李"]}, + ], + [], + False, + ), + # case sensitivity + ( + {"case_sensitive": True}, + "berlin and London are cities.", + [ + { + "name": "city", + "elements": ["Berlin", "Amsterdam", "New York", "London"], + } + ], + [ + { + ENTITY_ATTRIBUTE_TYPE: "city", + ENTITY_ATTRIBUTE_VALUE: "London", + ENTITY_ATTRIBUTE_START: 11, + ENTITY_ATTRIBUTE_END: 17, + EXTRACTOR: RegexEntityExtractor.__name__, + } + ], + True, + ), + ( + {"case_sensitive": False}, + "berlin and London are cities.", + [ + { + "name": "city", + "elements": ["Berlin", "Amsterdam", "New York", "london"], + } + ], + [ + { + ENTITY_ATTRIBUTE_TYPE: "city", + ENTITY_ATTRIBUTE_VALUE: "berlin", + ENTITY_ATTRIBUTE_START: 0, + ENTITY_ATTRIBUTE_END: 6, + EXTRACTOR: RegexEntityExtractor.__name__, + }, + { + ENTITY_ATTRIBUTE_TYPE: "city", + ENTITY_ATTRIBUTE_VALUE: "London", + ENTITY_ATTRIBUTE_START: 11, + ENTITY_ATTRIBUTE_END: 17, + EXTRACTOR: RegexEntityExtractor.__name__, + }, + ], + False, + ), + ], +) +def test_train_and_process( + create_or_load_extractor: Callable[..., RegexEntityExtractor], + config: Dict[Text, Any], + text: Text, + lookup: List[Dict[Text, List[Text]]], + expected_entities: List[Dict[Text, Any]], + test_loading: bool, +): + message = Message(data={TEXT: text}) + if test_loading: + message_copy = copy.deepcopy(message) + + training_data = TrainingData() + training_data.lookup_tables = lookup + training_data.training_examples = [ + Message( + data={ + TEXT: "Hi Max!", + INTENT: "greet", + ENTITIES: [{"entity": "person", "value": "Max"}], + } + ), + Message( + data={ + TEXT: "I live in Berlin", + INTENT: "inform", + ENTITIES: [{"entity": "city", "value": "Berlin"}], + } + ), + ] + + entity_extractor = create_or_load_extractor(config) + entity_extractor.train(training_data) + entity_extractor.process([message]) + entities = message.get(ENTITIES) + assert entities == expected_entities + + if test_loading: + loaded_entity_extractor = create_or_load_extractor(config, load=True) + loaded_entity_extractor.process([message_copy]) + loaded_entity_extractor.patterns == entity_extractor.patterns + + +def test_train_process_and_load_with_empty_model( + create_or_load_extractor: Callable[..., RegexEntityExtractor] +): + extractor = create_or_load_extractor({}) + with pytest.warns(UserWarning): + extractor.train(TrainingData([])) + with pytest.warns(UserWarning): + extractor.process(Message(data={TEXT: "arbitrary"})) + with pytest.warns(UserWarning): + create_or_load_extractor({}, load=True) + + +def test_process_does_not_overwrite_any_entities( + create_or_load_extractor: Callable[..., RegexEntityExtractor] +): + + pre_existing_entity = { + ENTITY_ATTRIBUTE_TYPE: "person", + ENTITY_ATTRIBUTE_VALUE: "Max", + ENTITY_ATTRIBUTE_START: 0, + ENTITY_ATTRIBUTE_END: 3, + EXTRACTOR: "other extractor", + } + message = Message(data={TEXT: "Max lives in Berlin.", INTENT: "infrom"}) + message.set(ENTITIES, [copy.deepcopy(pre_existing_entity)]) + + training_data = TrainingData() + training_data.training_examples = [ + Message( + data={ + TEXT: "Hi Max!", + INTENT: "greet", + ENTITIES: [ + {ENTITY_ATTRIBUTE_TYPE: "person", ENTITY_ATTRIBUTE_VALUE: "Max"} + ], + } + ), + Message( + data={ + TEXT: "I live in Berlin", + INTENT: "inform", + ENTITIES: [ + {ENTITY_ATTRIBUTE_TYPE: "city", ENTITY_ATTRIBUTE_VALUE: "Berlin"} + ], + } + ), + ] + training_data.lookup_tables = [ + {"name": "city", "elements": ["London", "Berlin", "Amsterdam"]} + ] + + entity_extractor = create_or_load_extractor(config={}) + entity_extractor.train(training_data) + entity_extractor.process([message]) + + entities = message.get(ENTITIES) + assert entities == [ + pre_existing_entity, + { + ENTITY_ATTRIBUTE_TYPE: "city", + ENTITY_ATTRIBUTE_VALUE: "Berlin", + ENTITY_ATTRIBUTE_START: 13, + ENTITY_ATTRIBUTE_END: 19, + EXTRACTOR: RegexEntityExtractor.__name__, + }, + ] diff --git a/tests/nlu/extractors/test_spacy_entity_extractors.py b/tests/nlu/extractors/test_spacy_entity_extractors.py new file mode 100644 index 0000000..ca1626c --- /dev/null +++ b/tests/nlu/extractors/test_spacy_entity_extractors.py @@ -0,0 +1,52 @@ +from rasa.nlu.extractors.spacy_entity_extractor import SpacyEntityExtractor +from rasa.nlu.utils.spacy_utils import SpacyModel +from rasa.shared.nlu.constants import TEXT +from rasa.shared.nlu.training_data.message import Message + + +def test_spacy_ner_extractor(spacy_nlp): + example = Message( + data={ + TEXT: "anywhere in the U.K.", + "intent": "restaurant_search", + "entities": [], + "text_spacy_doc": spacy_nlp("anywhere in the west"), + } + ) + + component = SpacyEntityExtractor(SpacyEntityExtractor.get_default_config()) + + component.process([example], model=SpacyModel(model=spacy_nlp, model_name="")) + + assert len(example.get("entities", [])) == 1 + assert example.get("entities")[0] == { + "start": 16, + "extractor": "SpacyEntityExtractor", + "end": 20, + "value": "U.K.", + "entity": "GPE", + "confidence": None, + } + + # Test dimension filtering includes only specified dimensions + example = Message( + data={ + TEXT: "anywhere in the West with Sebastian Thrun", + "intent": "example_intent", + "entities": [], + "text_spacy_doc": spacy_nlp("anywhere in the West with Sebastian Thrun"), + } + ) + + component = SpacyEntityExtractor({"dimensions": ["PERSON"]}) + component.process([example], model=SpacyModel(model=spacy_nlp, model_name="")) + + assert len(example.get("entities", [])) == 1 + assert example.get("entities")[0] == { + "start": 26, + "extractor": "SpacyEntityExtractor", + "end": 41, + "value": "Sebastian Thrun", + "entity": "PERSON", + "confidence": None, + } diff --git a/tests/nlu/featurizers/__init__.py b/tests/nlu/featurizers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/nlu/featurizers/test_convert_featurizer.py b/tests/nlu/featurizers/test_convert_featurizer.py new file mode 100644 index 0000000..503cf91 --- /dev/null +++ b/tests/nlu/featurizers/test_convert_featurizer.py @@ -0,0 +1,313 @@ +import numpy as np +import pytest +from typing import Text, Optional, List, Tuple, Dict, Any, Callable +from pathlib import Path +import os +from _pytest.monkeypatch import MonkeyPatch + +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.constants import ( + FEATURIZER_CLASS_ALIAS, + TOKENS_NAMES, + NUMBER_OF_SUB_TOKENS, +) +from rasa.shared.nlu.constants import TEXT, INTENT, RESPONSE +from rasa.nlu.featurizers.dense_featurizer.convert_featurizer import ( + ConveRTFeaturizer, + RESTRICTED_ACCESS_URL, + ORIGINAL_TF_HUB_MODULE_URL, +) +from rasa.exceptions import RasaException +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.storage.resource import Resource + + +@pytest.fixture +def create_or_load_convert_featurizer( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +) -> Callable[[Dict[Text, Any], bool], ConveRTFeaturizer]: + def inner( + config: Dict[Text, Any], load: bool = False + ) -> Callable[[Dict[Text, Any], bool], ConveRTFeaturizer]: + if load: + constructor = ConveRTFeaturizer.load + else: + constructor = ConveRTFeaturizer.create + return constructor( + config, + model_storage=default_model_storage, + execution_context=default_execution_context, + resource=Resource("unused"), + ) + + return inner + + +@pytest.mark.skip_on_windows +def test_convert_featurizer_process( + create_or_load_convert_featurizer: Callable[[Dict[Text, Any]], ConveRTFeaturizer], + monkeypatch: MonkeyPatch, + whitespace_tokenizer: WhitespaceTokenizer, +): + monkeypatch.setattr( + ConveRTFeaturizer, "_validate_model_url", lambda _: RESTRICTED_ACCESS_URL + ) + component_config = { + FEATURIZER_CLASS_ALIAS: "alias", + "model_url": RESTRICTED_ACCESS_URL, + } + featurizer = create_or_load_convert_featurizer(component_config) + sentence = "Hey how are you today ?" + message = Message.build(text=sentence) + + td = TrainingData([message]) + whitespace_tokenizer.process_training_data(td) + tokens = featurizer.tokenize(message, attribute=TEXT) + + featurizer.process([message]) + + expected = np.array([2.2636216, -0.26475656, -1.1358104, -0.49751878, -1.3946456]) + expected_cls = np.array( + [1.0251294, -0.04053932, -0.7018805, -0.82054937, -0.75054353] + ) + + seq_vecs, sent_vecs = message.get_dense_features(TEXT, []) + + seq_vecs = seq_vecs.features + sent_vecs = sent_vecs.features + + assert len(tokens) == len(seq_vecs) + assert np.allclose(seq_vecs[0][:5], expected, atol=1e-5) + assert np.allclose(sent_vecs[-1][:5], expected_cls, atol=1e-5) + + +@pytest.mark.skip_on_windows +@pytest.mark.parametrize("load", [True, False]) +def test_convert_featurizer_train( + create_or_load_convert_featurizer: Callable[[Dict[Text, Any]], ConveRTFeaturizer], + monkeypatch: MonkeyPatch, + load: bool, + whitespace_tokenizer: WhitespaceTokenizer, +): + monkeypatch.setattr(ConveRTFeaturizer, "_validate_model_url", lambda _: None) + component_config = { + FEATURIZER_CLASS_ALIAS: "alias", + "model_url": RESTRICTED_ACCESS_URL, + } + featurizer = create_or_load_convert_featurizer(component_config, load=True) + + sentence = "Hey how are you today ?" + message = Message(data={TEXT: sentence}) + message.set(RESPONSE, sentence) + + td = TrainingData([message]) + whitespace_tokenizer.process_training_data(td) + + tokens = featurizer.tokenize(message, attribute=TEXT) + + message.set(TOKENS_NAMES[TEXT], tokens) + message.set(TOKENS_NAMES[RESPONSE], tokens) + + featurizer.process_training_data(TrainingData([message])) + + expected = np.array([2.2636216, -0.26475656, -1.1358104, -0.49751878, -1.3946456]) + expected_cls = np.array( + [1.0251294, -0.04053932, -0.7018805, -0.82054937, -0.75054353] + ) + + seq_vecs, sent_vecs = message.get_dense_features(TEXT, []) + + seq_vecs = seq_vecs.features + sent_vecs = sent_vecs.features + + assert len(tokens) == len(seq_vecs) + assert np.allclose(seq_vecs[0][:5], expected, atol=1e-5) + assert np.allclose(sent_vecs[-1][:5], expected_cls, atol=1e-5) + + seq_vecs, sent_vecs = message.get_dense_features(RESPONSE, []) + + seq_vecs = seq_vecs.features + sent_vecs = sent_vecs.features + + assert len(tokens) == len(seq_vecs) + assert np.allclose(seq_vecs[0][:5], expected, atol=1e-5) + assert np.allclose(sent_vecs[-1][:5], expected_cls, atol=1e-5) + + seq_vecs, sent_vecs = message.get_dense_features(INTENT, []) + + assert seq_vecs is None + assert sent_vecs is None + + +@pytest.mark.skip_on_windows +@pytest.mark.parametrize( + "sentence, expected_text", + [ + ("hello", "hello"), + ("you're", "you re"), + ("r. n. b.", "r n b"), + ("rock & roll", "rock & roll"), + ("ńöñàśçií", "ńöñàśçií"), + ], +) +def test_convert_featurizer_tokens_to_text( + create_or_load_convert_featurizer: Callable[[Dict[Text, Any]], ConveRTFeaturizer], + sentence: Text, + expected_text: Text, + monkeypatch: MonkeyPatch, + whitespace_tokenizer: WhitespaceTokenizer, +): + monkeypatch.setattr(ConveRTFeaturizer, "_validate_model_url", lambda _: None) + component_config = { + FEATURIZER_CLASS_ALIAS: "alias", + "model_url": RESTRICTED_ACCESS_URL, + } + featurizer = create_or_load_convert_featurizer(component_config) + message = Message.build(text=sentence) + td = TrainingData([message]) + whitespace_tokenizer.process_training_data(td) + tokens = featurizer.tokenize(message, attribute=TEXT) + + actual_text = ConveRTFeaturizer._tokens_to_text([tokens])[0] + + assert expected_text == actual_text + + +@pytest.mark.skip_on_windows +@pytest.mark.parametrize( + "text, expected_tokens, expected_indices", + [ + ( + "forecast for lunch", + ["forecast", "for", "lunch"], + [(0, 8), (9, 12), (13, 18)], + ), + ("hello", ["hello"], [(0, 5)]), + ("you're", ["you", "re"], [(0, 3), (4, 6)]), + ("r. n. b.", ["r", "n", "b"], [(0, 1), (3, 4), (6, 7)]), + ("rock & roll", ["rock", "&", "roll"], [(0, 4), (5, 6), (7, 11)]), + ("ńöñàśçií", ["ńöñàśçií"], [(0, 8)]), + ], +) +def test_convert_featurizer_token_edge_cases( + create_or_load_convert_featurizer: Callable[[Dict[Text, Any]], ConveRTFeaturizer], + text: Text, + expected_tokens: List[Text], + expected_indices: List[Tuple[int, int]], + monkeypatch: MonkeyPatch, + whitespace_tokenizer: WhitespaceTokenizer, +): + monkeypatch.setattr(ConveRTFeaturizer, "_validate_model_url", lambda _: None) + component_config = { + FEATURIZER_CLASS_ALIAS: "alias", + "model_url": RESTRICTED_ACCESS_URL, + } + featurizer = create_or_load_convert_featurizer(component_config) + message = Message.build(text=text) + td = TrainingData([message]) + whitespace_tokenizer.process_training_data(td) + tokens = featurizer.tokenize(message, attribute=TEXT) + + assert [t.text for t in tokens] == expected_tokens + assert [t.start for t in tokens] == [i[0] for i in expected_indices] + assert [t.end for t in tokens] == [i[1] for i in expected_indices] + + +@pytest.mark.skip_on_windows +@pytest.mark.parametrize( + "text, expected_number_of_sub_tokens", + [("Aarhus is a city", [2, 1, 1, 1]), ("sentence embeddings", [1, 3])], +) +def test_convert_featurizer_number_of_sub_tokens( + create_or_load_convert_featurizer: Callable[[Dict[Text, Any]], ConveRTFeaturizer], + text: Text, + expected_number_of_sub_tokens: List[int], + monkeypatch: MonkeyPatch, + whitespace_tokenizer: WhitespaceTokenizer, +): + monkeypatch.setattr(ConveRTFeaturizer, "_validate_model_url", lambda _: None) + component_config = { + FEATURIZER_CLASS_ALIAS: "alias", + "model_url": RESTRICTED_ACCESS_URL, + } + featurizer = create_or_load_convert_featurizer(component_config) + + message = Message.build(text=text) + td = TrainingData([message]) + whitespace_tokenizer.process_training_data(td) + + tokens = featurizer.tokenize(message, attribute=TEXT) + + assert [ + t.get(NUMBER_OF_SUB_TOKENS) for t in tokens + ] == expected_number_of_sub_tokens + + +@pytest.mark.skip_on_windows +@pytest.mark.parametrize( + "model_url, exception_phrase", + [ + (ORIGINAL_TF_HUB_MODULE_URL, "which does not contain the model any longer"), + ( + RESTRICTED_ACCESS_URL, + "which is strictly reserved for pytests of Rasa Open Source only", + ), + (None, "'model_url' was not specified in the configuration"), + ("", "'model_url' was not specified in the configuration"), + ], +) +def test_raise_invalid_urls( + create_or_load_convert_featurizer: Callable[[Dict[Text, Any]], ConveRTFeaturizer], + model_url: Optional[Text], + exception_phrase: Text, +): + component_config = {FEATURIZER_CLASS_ALIAS: "alias", "model_url": model_url} + with pytest.raises(RasaException) as excinfo: + _ = create_or_load_convert_featurizer(component_config) + + assert exception_phrase in str(excinfo.value) + + +@pytest.mark.skip_on_windows +def test_raise_wrong_model_directory( + create_or_load_convert_featurizer: Callable[[Dict[Text, Any]], ConveRTFeaturizer], + tmp_path: Path, +): + component_config = {FEATURIZER_CLASS_ALIAS: "alias", "model_url": str(tmp_path)} + + with pytest.raises(RasaException) as excinfo: + _ = create_or_load_convert_featurizer(component_config) + + assert "Re-check the files inside the directory" in str(excinfo.value) + + +@pytest.mark.skip_on_windows +def test_raise_wrong_model_file( + create_or_load_convert_featurizer: Callable[[Dict[Text, Any]], ConveRTFeaturizer], + tmp_path: Path, +): + # create a dummy file + temp_file = os.path.join(tmp_path, "saved_model.pb") + f = open(temp_file, "wb") + f.close() + component_config = {FEATURIZER_CLASS_ALIAS: "alias", "model_url": temp_file} + + with pytest.raises(RasaException) as excinfo: + _ = create_or_load_convert_featurizer(component_config) + + assert "set to the path of a file which is invalid" in str(excinfo.value) + + +@pytest.mark.skip_on_windows +def test_raise_invalid_path( + create_or_load_convert_featurizer: Callable[[Dict[Text, Any]], ConveRTFeaturizer] +): + component_config = {FEATURIZER_CLASS_ALIAS: "alias", "model_url": "saved_model.pb"} + + with pytest.raises(RasaException) as excinfo: + _ = create_or_load_convert_featurizer(component_config) + + assert "neither a valid remote URL nor a local directory" in str(excinfo.value) diff --git a/tests/nlu/featurizers/test_count_vectors_featurizer.py b/tests/nlu/featurizers/test_count_vectors_featurizer.py new file mode 100644 index 0000000..3bf16fe --- /dev/null +++ b/tests/nlu/featurizers/test_count_vectors_featurizer.py @@ -0,0 +1,843 @@ +from typing import List, Any, Text, Dict, Callable, Optional + +import dataclasses +import numpy as np +import pytest +import scipy.sparse + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.tokenizers.spacy_tokenizer import SpacyTokenizer +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +from rasa.nlu.constants import TOKENS_NAMES, SPACY_DOCS +from rasa.nlu.utils.spacy_utils import SpacyModel +from rasa.shared.nlu.constants import TEXT, INTENT, RESPONSE, ACTION_TEXT, ACTION_NAME +from rasa.nlu.tokenizers.tokenizer import Token +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer import ( + CountVectorsFeaturizer, +) + + +@pytest.fixture() +def create_featurizer( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +) -> Callable[..., CountVectorsFeaturizer]: + def inner(config: Optional[Dict[Text, Any]] = None) -> CountVectorsFeaturizer: + config = config or {} + return CountVectorsFeaturizer.create( + {**CountVectorsFeaturizer.get_default_config(), **config}, + default_model_storage, + Resource("count_vectors_featurizer"), + default_execution_context, + ) + + return inner + + +@pytest.fixture() +def load_featurizer( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +) -> Callable[..., CountVectorsFeaturizer]: + def inner( + config: Optional[Dict[Text, Any]] = None, is_finetuning: bool = False + ) -> CountVectorsFeaturizer: + config = config or {} + return CountVectorsFeaturizer.load( + {**CountVectorsFeaturizer.get_default_config(), **config}, + default_model_storage, + Resource("count_vectors_featurizer"), + dataclasses.replace(default_execution_context, is_finetuning=is_finetuning), + ) + + return inner + + +@pytest.mark.parametrize( + "sentence, expected, expected_cls", + [ + ("hello hello hello hello hello", [[1]], [[5]]), + ("hello goodbye hello", [[0, 1]], [[1, 2]]), + ("a b c d e f", [[1, 0, 0, 0, 0, 0]], [[1, 1, 1, 1, 1, 1]]), + ("a 1 2", [[0, 1]], [[2, 1]]), + ], +) +def test_count_vector_featurizer( + sentence: Text, + expected: List[List[int]], + expected_cls: List[List[int]], + create_featurizer: Callable[..., CountVectorsFeaturizer], + whitespace_tokenizer: WhitespaceTokenizer, +): + ftr = create_featurizer() + + train_message = Message(data={TEXT: sentence}) + test_message = Message(data={TEXT: sentence}) + + whitespace_tokenizer.process([train_message]) + whitespace_tokenizer.process([test_message]) + + ftr.train(TrainingData([train_message])) + + ftr.process([test_message]) + + seq_vecs, sen_vecs = test_message.get_sparse_features(TEXT, []) + if seq_vecs: + seq_vecs = seq_vecs.features + if sen_vecs: + sen_vecs = sen_vecs.features + + assert isinstance(seq_vecs, scipy.sparse.coo_matrix) + assert isinstance(sen_vecs, scipy.sparse.coo_matrix) + + actual_seq_vecs = seq_vecs.toarray() + actual_sen_vecs = sen_vecs.toarray() + + assert np.all(actual_seq_vecs[0] == expected) + assert np.all(actual_sen_vecs[-1] == expected_cls) + + +@pytest.mark.parametrize( + "sentence, intent, response, intent_features, response_features", + [("hello", "greet", None, [[1]], None), ("hello", "greet", "hi", [[1]], [[1]])], +) +def test_count_vector_featurizer_response_attribute_featurization( + sentence: Text, + intent: Text, + response: Optional[Text], + intent_features: List[List[int]], + response_features: Optional[List[List[int]]], + create_featurizer: Callable[..., CountVectorsFeaturizer], + whitespace_tokenizer: WhitespaceTokenizer, +): + ftr = create_featurizer() + + train_message = Message(data={TEXT: sentence}) + # this is needed for a valid training example + train_message.set(INTENT, intent) + train_message.set(RESPONSE, response) + + # add a second example that has some response, so that the vocabulary for + # response exists + second_message = Message(data={TEXT: "hello"}) + second_message.set(RESPONSE, "hi") + second_message.set(INTENT, "greet") + + data = TrainingData([train_message, second_message]) + + whitespace_tokenizer.process_training_data(data) + ftr.train(data) + ftr.process_training_data(data) + + intent_seq_vecs, intent_sen_vecs = train_message.get_sparse_features(INTENT, []) + if intent_seq_vecs: + intent_seq_vecs = intent_seq_vecs.features + if intent_sen_vecs: + intent_sen_vecs = intent_sen_vecs.features + response_seq_vecs, response_sen_vecs = train_message.get_sparse_features( + RESPONSE, [] + ) + if response_seq_vecs: + response_seq_vecs = response_seq_vecs.features + if response_sen_vecs: + response_sen_vecs = response_sen_vecs.features + + if intent_features: + assert intent_seq_vecs.toarray()[0] == intent_features + assert intent_sen_vecs is None + else: + assert intent_seq_vecs is None + assert intent_sen_vecs is None + + if response_features: + assert response_seq_vecs.toarray()[0] == response_features + assert response_sen_vecs is not None + else: + assert response_seq_vecs is None + assert response_sen_vecs is None + + +@pytest.mark.parametrize( + "sentence, intent, response, intent_features, response_features", + [ + ("hello hello hello hello hello ", "greet", None, [[1]], None), + ("hello goodbye hello", "greet", None, [[1]], None), + ("a 1 2", "char", "char char", [[1]], [[1]]), + ], +) +def test_count_vector_featurizer_attribute_featurization( + sentence: Text, + intent: Text, + response: Optional[Text], + intent_features: List[List[int]], + response_features: Optional[List[List[int]]], + create_featurizer: Callable[..., CountVectorsFeaturizer], + whitespace_tokenizer: WhitespaceTokenizer, +): + ftr = create_featurizer() + + train_message = Message(data={TEXT: sentence}) + # this is needed for a valid training example + train_message.set(INTENT, intent) + train_message.set(RESPONSE, response) + + data = TrainingData([train_message]) + + whitespace_tokenizer.process_training_data(data) + ftr.train(data) + ftr.process_training_data(data) + + intent_seq_vecs, intent_sen_vecs = train_message.get_sparse_features(INTENT, []) + if intent_seq_vecs: + intent_seq_vecs = intent_seq_vecs.features + if intent_sen_vecs: + intent_sen_vecs = intent_sen_vecs.features + response_seq_vecs, response_sen_vecs = train_message.get_sparse_features( + RESPONSE, [] + ) + if response_seq_vecs: + response_seq_vecs = response_seq_vecs.features + if response_sen_vecs: + response_sen_vecs = response_sen_vecs.features + if intent_features: + assert intent_seq_vecs.toarray()[0] == intent_features + assert intent_sen_vecs is None + else: + assert intent_seq_vecs is None + assert intent_sen_vecs is None + + if response_features: + assert response_seq_vecs.toarray()[0] == response_features + assert response_sen_vecs is not None + else: + assert response_seq_vecs is None + assert response_sen_vecs is None + + +@pytest.mark.parametrize( + "sentence, intent, response, text_features, intent_features, response_features", + [ + ("hello hello greet ", "greet", "hello", [[0, 1]], [[1, 0]], [[0, 1]]), + ( + "I am fine", + "acknowledge", + "good", + [[0, 0, 0, 0, 1]], + [[1, 0, 0, 0, 0]], + [[0, 0, 0, 1, 0]], + ), + ], +) +def test_count_vector_featurizer_shared_vocab( + sentence: Text, + intent: Text, + response: Text, + text_features: List[List[int]], + intent_features: List[List[int]], + response_features: List[List[int]], + create_featurizer: Callable[..., CountVectorsFeaturizer], + whitespace_tokenizer: WhitespaceTokenizer, +): + ftr = create_featurizer({"use_shared_vocab": True}) + + train_message = Message(data={TEXT: sentence}) + # this is needed for a valid training example + train_message.set(INTENT, intent) + train_message.set(RESPONSE, response) + + data = TrainingData([train_message]) + whitespace_tokenizer.process_training_data(data) + ftr.train(data) + ftr.process_training_data(data) + + seq_vec, sen_vec = train_message.get_sparse_features(TEXT, []) + if seq_vec: + seq_vec = seq_vec.features + if sen_vec: + sen_vec = sen_vec.features + assert np.all(seq_vec.toarray()[0] == text_features) + assert sen_vec is not None + seq_vec, sen_vec = train_message.get_sparse_features(INTENT, []) + if seq_vec: + seq_vec = seq_vec.features + if sen_vec: + sen_vec = sen_vec.features + assert np.all(seq_vec.toarray()[0] == intent_features) + assert sen_vec is None + seq_vec, sen_vec = train_message.get_sparse_features(RESPONSE, []) + if seq_vec: + seq_vec = seq_vec.features + if sen_vec: + sen_vec = sen_vec.features + assert np.all(seq_vec.toarray()[0] == response_features) + assert sen_vec is not None + + +@pytest.mark.parametrize( + "sentence, expected", + [ + ("hello hello hello hello hello __OOV__", [[0, 1]]), + ("hello goodbye hello __oov__", [[0, 0, 1]]), + ("a b c d e f __oov__ __OOV__ __OOV__", [[0, 1, 0, 0, 0, 0, 0]]), + ("__OOV__ a 1 2 __oov__ __OOV__", [[0, 1, 0]]), + ], +) +def test_count_vector_featurizer_oov_token( + sentence: Text, + expected: List[List[int]], + create_featurizer: Callable[..., CountVectorsFeaturizer], + whitespace_tokenizer: WhitespaceTokenizer, +): + ftr = create_featurizer({"OOV_token": "__oov__"}) + train_message = Message(data={TEXT: sentence}) + whitespace_tokenizer.process([train_message]) + + data = TrainingData([train_message]) + ftr.train(data) + ftr.process_training_data(data) + + seq_vec, sen_vec = train_message.get_sparse_features(TEXT, []) + if seq_vec: + seq_vec = seq_vec.features + if sen_vec: + sen_vec = sen_vec.features + assert np.all(seq_vec.toarray()[0] == expected) + assert sen_vec is not None + + +@pytest.mark.parametrize( + "sentence, expected", + [ + ("hello hello hello hello hello oov_word0", [[0, 1]]), + ("hello goodbye hello oov_word0 OOV_word0", [[0, 0, 1]]), + ("a b c d e f __oov__ OOV_word0 oov_word1", [[0, 1, 0, 0, 0, 0, 0]]), + ("__OOV__ a 1 2 __oov__ OOV_word1", [[0, 1, 0]]), + ], +) +def test_count_vector_featurizer_oov_words( + sentence: Text, + expected: List[List[int]], + create_featurizer: Callable[..., CountVectorsFeaturizer], + whitespace_tokenizer: WhitespaceTokenizer, +): + ftr = create_featurizer( + {"OOV_token": "__oov__", "OOV_words": ["oov_word0", "OOV_word1"]} + ) + train_message = Message(data={TEXT: sentence}) + whitespace_tokenizer.process([train_message]) + + data = TrainingData([train_message]) + ftr.train(data) + ftr.process_training_data(data) + + seq_vec, sen_vec = train_message.get_sparse_features(TEXT, []) + if seq_vec: + seq_vec = seq_vec.features + if sen_vec: + sen_vec = sen_vec.features + assert np.all(seq_vec.toarray()[0] == expected) + assert sen_vec is not None + + +@pytest.mark.parametrize( + "tokens, expected", + [ + (["hello", "hello", "hello", "hello", "hello"], [[1]]), + (["你好", "你好", "你好", "你好", "你好"], [[1]]), # test for unicode chars + (["hello", "goodbye", "hello"], [[0, 1]]), + # Note: order has changed in Chinese version of "hello" & "goodbye" + (["你好", "再见", "你好"], [[1, 0]]), # test for unicode chars + (["a", "b", "c", "d", "e", "f"], [[1, 0, 0, 0, 0, 0]]), + (["a", "1", "2"], [[0, 1]]), + ], +) +def test_count_vector_featurizer_using_tokens( + tokens: List[Text], + expected: List[List[int]], + create_featurizer: Callable[..., CountVectorsFeaturizer], +): + ftr = create_featurizer() + + # using empty string instead of real text string to make sure + # count vector only can come from `tokens` feature. + # using `message.text` can not get correct result + + tokens_feature = [Token(i, 0) for i in tokens] + + train_message = Message(data={TEXT: ""}) + train_message.set(TOKENS_NAMES[TEXT], tokens_feature) + + data = TrainingData([train_message]) + + ftr.train(data) + ftr.process_training_data(data) + + seq_vec, sen_vec = train_message.get_sparse_features(TEXT, []) + if seq_vec: + seq_vec = seq_vec.features + if sen_vec: + sen_vec = sen_vec.features + assert np.all(seq_vec.toarray()[0] == expected) + assert sen_vec is not None + + +@pytest.mark.parametrize( + "sentence, expected", + [ + ("ababab", [[3, 3, 3, 2]]), + ("ab ab ab", [[0, 0, 1, 1, 1, 0]]), + ("abc", [[1, 1, 1, 1, 1]]), + ], +) +def test_count_vector_featurizer_char( + sentence: Text, + expected: List[List[int]], + create_featurizer: Callable[..., CountVectorsFeaturizer], + whitespace_tokenizer: WhitespaceTokenizer, +): + ftr = create_featurizer({"min_ngram": 1, "max_ngram": 2, "analyzer": "char"}) + + train_message = Message(data={TEXT: sentence}) + whitespace_tokenizer.process([train_message]) + + data = TrainingData([train_message]) + ftr.train(data) + ftr.process_training_data(data) + + seq_vec, sen_vec = train_message.get_sparse_features(TEXT, []) + if seq_vec: + seq_vec = seq_vec.features + if sen_vec: + sen_vec = sen_vec.features + assert np.all(seq_vec.toarray()[0] == expected) + assert sen_vec is not None + + +def test_count_vector_featurizer_persist_load( + create_featurizer: Callable[..., CountVectorsFeaturizer], + load_featurizer: Callable[..., CountVectorsFeaturizer], + whitespace_tokenizer: WhitespaceTokenizer, +): + # set non default values to config + config = { + "analyzer": "char", + "strip_accents": "ascii", + "stop_words": "stop", + "min_df": 2, + "max_df": 3, + "min_ngram": 2, + "max_ngram": 3, + "max_features": 10, + "lowercase": False, + } + train_ftr = create_featurizer(config) + + sentence1 = "ababab 123 13xc лаомтгцу sfjv oö aà" + sentence2 = "abababalidcn 123123 13xcdc лаомтгцу sfjv oö aà" + + train_message1 = Message(data={TEXT: sentence1}) + train_message2 = Message(data={TEXT: sentence2}) + whitespace_tokenizer.process([train_message1]) + whitespace_tokenizer.process([train_message2]) + + data = TrainingData([train_message1, train_message2]) + train_ftr.train(data) + train_ftr.process_training_data(data) + + # persist featurizer + train_vect_params = { + attribute: vectorizer.get_params() + for attribute, vectorizer in train_ftr.vectorizers.items() + } + + # add trained vocabulary to vectorizer params + for attribute, attribute_vect_params in train_vect_params.items(): + if hasattr(train_ftr.vectorizers[attribute], "vocabulary_"): + train_vect_params[attribute].update( + {"vocabulary": train_ftr.vectorizers[attribute].vocabulary_} + ) + + test_ftr = load_featurizer(config) + test_vect_params = { + attribute: vectorizer.get_params() + for attribute, vectorizer in test_ftr.vectorizers.items() + } + + assert train_vect_params == test_vect_params + + # check if vocaculary was loaded correctly + assert hasattr(test_ftr.vectorizers[TEXT], "vocabulary_") + + test_message1 = Message(data={TEXT: sentence1}) + whitespace_tokenizer.process([test_message1]) + test_ftr.process([test_message1]) + test_message2 = Message(data={TEXT: sentence2}) + whitespace_tokenizer.process([test_message2]) + test_ftr.process([test_message2]) + + test_seq_vec_1, test_sen_vec_1 = test_message1.get_sparse_features(TEXT, []) + if test_seq_vec_1: + test_seq_vec_1 = test_seq_vec_1.features + if test_sen_vec_1: + test_sen_vec_1 = test_sen_vec_1.features + train_seq_vec_1, train_sen_vec_1 = train_message1.get_sparse_features(TEXT, []) + if train_seq_vec_1: + train_seq_vec_1 = train_seq_vec_1.features + if train_sen_vec_1: + train_sen_vec_1 = train_sen_vec_1.features + test_seq_vec_2, test_sen_vec_2 = test_message2.get_sparse_features(TEXT, []) + if test_seq_vec_2: + test_seq_vec_2 = test_seq_vec_2.features + if test_sen_vec_2: + test_sen_vec_2 = test_sen_vec_2.features + train_seq_vec_2, train_sen_vec_2 = train_message2.get_sparse_features(TEXT, []) + if train_seq_vec_2: + train_seq_vec_2 = train_seq_vec_2.features + if train_sen_vec_2: + train_sen_vec_2 = train_sen_vec_2.features + + # check that train features and test features after loading are the same + assert np.all(test_seq_vec_1.toarray() == train_seq_vec_1.toarray()) + assert np.all(test_sen_vec_1.toarray() == train_sen_vec_1.toarray()) + assert np.all(test_seq_vec_2.toarray() == train_seq_vec_2.toarray()) + assert np.all(test_sen_vec_2.toarray() == train_sen_vec_2.toarray()) + + +def test_count_vectors_featurizer_train( + create_featurizer: Callable[..., CountVectorsFeaturizer], + whitespace_tokenizer: WhitespaceTokenizer, +): + featurizer = create_featurizer() + + sentence = "Hey how are you today ?" + message = Message(data={TEXT: sentence}) + message.set(RESPONSE, sentence) + message.set(INTENT, "intent") + whitespace_tokenizer.process_training_data(TrainingData([message])) + + data = TrainingData([message]) + featurizer.train(data) + featurizer.process_training_data(data) + + expected = np.array([0, 1, 0, 0, 0]) + expected_cls = np.array([1, 1, 1, 1, 1]) + + seq_vec, sen_vec = message.get_sparse_features(TEXT, []) + if seq_vec: + seq_vec = seq_vec.features + if sen_vec: + sen_vec = sen_vec.features + + assert (5, 5) == seq_vec.shape + assert (1, 5) == sen_vec.shape + assert np.all(seq_vec.toarray()[0] == expected) + assert np.all(sen_vec.toarray()[-1] == expected_cls) + + seq_vec, sen_vec = message.get_sparse_features(RESPONSE, []) + if seq_vec: + seq_vec = seq_vec.features + if sen_vec: + sen_vec = sen_vec.features + + assert (5, 5) == seq_vec.shape + assert (1, 5) == sen_vec.shape + assert np.all(seq_vec.toarray()[0] == expected) + assert np.all(sen_vec.toarray()[-1] == expected_cls) + + seq_vec, sen_vec = message.get_sparse_features(INTENT, []) + if seq_vec: + seq_vec = seq_vec.features + if sen_vec: + sen_vec = sen_vec.features + + assert sen_vec is None + assert (1, 1) == seq_vec.shape + assert np.all(seq_vec.toarray()[0] == np.array([1])) + + +@pytest.mark.parametrize( + "sentence, sequence_features, sentence_features, use_lemma", + [ + ("go goes went go", [[1, 0, 0]], [[2, 1, 1]], False), + ("go goes went go", [[1]], [[4]], True), + ], +) +def test_count_vector_featurizer_use_lemma( + spacy_nlp: Any, + sentence: Text, + sequence_features: List[List[int]], + sentence_features: List[List[int]], + use_lemma: bool, + create_featurizer: Callable[..., CountVectorsFeaturizer], + load_featurizer: Callable[..., CountVectorsFeaturizer], + spacy_tokenizer: SpacyTokenizer, +): + config = {"use_lemma": use_lemma, "OOV_words": ["drinks"], "OOV_token": "OOV"} + ftr = create_featurizer(config) + + train_message = Message(data={TEXT: sentence}) + train_message.set(SPACY_DOCS[TEXT], spacy_nlp(sentence)) + test_message = Message(data={TEXT: sentence}) + test_message.set(SPACY_DOCS[TEXT], spacy_nlp(sentence)) + + spacy_tokenizer.process([train_message]) + spacy_tokenizer.process([test_message]) + + ftr.train(TrainingData([train_message]), model=SpacyModel(spacy_nlp, "en")) + + ftr.process([test_message]) + + seq_vecs, sen_vecs = test_message.get_sparse_features(TEXT, []) + + assert isinstance(seq_vecs.features, scipy.sparse.coo_matrix) + assert isinstance(sen_vecs.features, scipy.sparse.coo_matrix) + + actual_seq_vecs = seq_vecs.features.toarray() + actual_sen_vecs = sen_vecs.features.toarray() + + assert np.all(actual_seq_vecs[0] == sequence_features) + assert np.all(actual_sen_vecs[-1] == sentence_features) + + loaded = load_featurizer(config) + assert loaded.OOV_words == ftr.OOV_words + + +@pytest.mark.parametrize( + "sentence, action_name, action_text, action_name_features, response_features", + [ + ("hello", "greet", None, [[1]], None), + ("hello", "greet", "hi", [[1]], [[1]]), + ("hello", "", "hi", None, [[1]]), + ], +) +def test_count_vector_featurizer_action_attribute_featurization( + sentence: Text, + action_name: Text, + action_text: Text, + action_name_features: np.ndarray, + response_features: np.ndarray, + create_featurizer: Callable[..., CountVectorsFeaturizer], + whitespace_tokenizer: WhitespaceTokenizer, +): + ftr = create_featurizer({"token_pattern": r"(?u)\b\w+\b"}) + + train_message = Message(data={TEXT: sentence}) + # this is needed for a valid training example + train_message.set(ACTION_NAME, action_name) + train_message.set(ACTION_TEXT, action_text) + + # add a second example that has some response, so that the vocabulary for + # response exists + second_message = Message(data={TEXT: "hello"}) + second_message.set(ACTION_TEXT, "hi") + second_message.set(ACTION_NAME, "greet") + + data = TrainingData([train_message, second_message]) + + whitespace_tokenizer.process_training_data(data) + ftr.train(data) + ftr.process_training_data(data) + + action_name_seq_vecs, action_name_sen_vecs = train_message.get_sparse_features( + ACTION_NAME, [] + ) + if action_name_seq_vecs: + action_name_seq_vecs = action_name_seq_vecs.features + if action_name_sen_vecs: + action_name_sen_vecs = action_name_sen_vecs.features + response_seq_vecs, response_sen_vecs = train_message.get_sparse_features( + ACTION_TEXT, [] + ) + if response_seq_vecs: + response_seq_vecs = response_seq_vecs.features + if response_sen_vecs: + response_sen_vecs = response_sen_vecs.features + + if action_name_features: + assert action_name_seq_vecs.toarray()[0] == action_name_features + assert action_name_sen_vecs is None + else: + assert action_name_seq_vecs is None + assert action_name_sen_vecs is None + + if response_features: + assert response_seq_vecs.toarray()[0] == response_features + assert response_sen_vecs is not None + else: + assert response_seq_vecs is None + assert response_sen_vecs is None + + +@pytest.mark.parametrize( + "sentence, action_name, action_text, action_name_features, response_features", + [ + ("hello", "greet", None, [[1]], None), + ("hello", "greet", "hi", [[1]], [[1]]), + ("hello", "", "hi", [[0]], [[1]]), + ], +) +def test_count_vector_featurizer_process_by_attribute( + sentence: Text, + action_name: Text, + action_text: Text, + action_name_features: np.ndarray, + response_features: np.ndarray, + create_featurizer: Callable[..., CountVectorsFeaturizer], + whitespace_tokenizer: WhitespaceTokenizer, +): + ftr = create_featurizer({"token_pattern": r"(?u)\b\w+\b"}) + + # add a second example that has some response, so that the vocabulary for + # response exists + train_message = Message(data={TEXT: "hello"}) + train_message.set(ACTION_NAME, "greet") + + train_message1 = Message(data={TEXT: "hello"}) + train_message1.set(ACTION_TEXT, "hi") + + data = TrainingData([train_message, train_message1]) + + whitespace_tokenizer.process_training_data(data) + ftr.train(data) + + test_message = Message(data={TEXT: sentence}) + test_message.set(ACTION_NAME, action_name) + test_message.set(ACTION_TEXT, action_text) + + whitespace_tokenizer.process([test_message]) + ftr.process([test_message]) + + action_name_seq_vecs, action_name_sen_vecs = test_message.get_sparse_features( + ACTION_NAME, [] + ) + if action_name_seq_vecs: + action_name_seq_vecs = action_name_seq_vecs.features + if action_name_sen_vecs: + action_name_sen_vecs = action_name_sen_vecs.features + + assert action_name_seq_vecs.toarray()[0] == action_name_features + assert action_name_sen_vecs is None + + +@pytest.mark.parametrize( + "initial_train_text, additional_train_text, " + "initial_vocabulary_size, final_vocabulary_size", + [ + ("am I the coolest person?", "yes, I am", 5, 6), + ("the coolest person", "person the coolest", 3, 3), + ], +) +def test_cvf_incremental_training( + initial_train_text: Text, + additional_train_text: Text, + initial_vocabulary_size: int, + final_vocabulary_size: int, + create_featurizer: Callable[..., CountVectorsFeaturizer], + load_featurizer: Callable[..., CountVectorsFeaturizer], + whitespace_tokenizer: WhitespaceTokenizer, +): + initial_cvf = create_featurizer() + train_message = Message(data={"text": initial_train_text}) + data = TrainingData([train_message]) + + whitespace_tokenizer.process_training_data(data) + initial_cvf.train(data) + + # Check initial vocabulary size + initial_vocab = initial_cvf.vectorizers["text"].vocabulary_ + assert len(initial_vocab) == initial_vocabulary_size + + # persist and load initial cvf + new_cvf = load_featurizer(is_finetuning=True) + + # Check vocabulary size again + assert len(new_cvf.vectorizers["text"].vocabulary_) == initial_vocabulary_size + + additional_train_message = Message(data={"text": additional_train_text}) + data = TrainingData([train_message, additional_train_message]) + whitespace_tokenizer.process_training_data(data) + new_cvf.train(data) + + new_vocab = new_cvf.vectorizers["text"].vocabulary_ + + # Check vocabulary size after finetuning + assert len(new_vocab) == final_vocabulary_size + + # Check indices of initial vocabulary haven't changed in the new vocabulary + for vocab_token, vocab_index in initial_vocab.items(): + assert vocab_token in new_vocab + assert new_vocab.get(vocab_token) == vocab_index + + +@pytest.mark.parametrize( + "initial_train_text, additional_train_text, " "use_shared_vocab", + [("am I the coolest person?", "no", True), ("rasa rasa", "sara sara", False)], +) +def test_use_shared_vocab_exception( + initial_train_text: Text, + additional_train_text: Text, + use_shared_vocab: bool, + create_featurizer: Callable[..., CountVectorsFeaturizer], + load_featurizer: Callable[..., CountVectorsFeaturizer], + whitespace_tokenizer: WhitespaceTokenizer, +): + """Tests if an exception is raised when `use_shared_vocab` is set to True + during incremental training.""" + config = {"use_shared_vocab": use_shared_vocab} + initial_cvf = create_featurizer(config) + train_message = Message(data={"text": initial_train_text}) + data = TrainingData([train_message]) + whitespace_tokenizer.process_training_data(data) + initial_cvf.train(data) + + new_cvf = load_featurizer(config, is_finetuning=True) + + additional_train_message = Message(data={"text": additional_train_text}) + data = TrainingData([train_message, additional_train_message]) + whitespace_tokenizer.process_training_data(data) + if use_shared_vocab: + with pytest.raises(Exception) as exec_info: + new_cvf.train(data) + assert ( + "Using a shared vocabulary in `CountVectorsFeaturizer` is not supported" + in str(exec_info.value) + ) + else: + new_cvf.train(data) + + +@pytest.mark.parametrize("min_df, throws_error", [(1, False), (0.2, False), (5, True)]) +def test_create_independent_vocab_vectorizers_min_df( + min_df: int, + throws_error: bool, + load_featurizer: Callable[..., CountVectorsFeaturizer], + whitespace_tokenizer: WhitespaceTokenizer, +): + config = { + "min_df": min_df, + "analyzer": "word", + "strip_accents": None, + "lowercase": True, + "stop_words": None, + "min_ngram": 1, + "max_ngram": 1, + "max_df": 1, + "max_features": None, + "use_shared_vocab": False, + "finetune_mode": False, + } + cvf = load_featurizer(config) + result = cvf._create_independent_vocab_vectorizers(config) + assert result["action_name"].min_df == 1 + assert result["text"].min_df == min_df + + train_message = Message(data={TEXT: "am I the coolest person?"}) + data = TrainingData([train_message]) + whitespace_tokenizer.process_training_data(data) + if throws_error: + with pytest.raises(Exception): + cvf.train(data) + else: + cvf.train(data) diff --git a/tests/nlu/featurizers/test_featurizer.py b/tests/nlu/featurizers/test_featurizer.py new file mode 100644 index 0000000..4513ac2 --- /dev/null +++ b/tests/nlu/featurizers/test_featurizer.py @@ -0,0 +1,82 @@ +from rasa.shared.exceptions import InvalidConfigException +from rasa.nlu.featurizers.featurizer import Featurizer +from rasa.nlu.constants import FEATURIZER_CLASS_ALIAS +from typing import List, Dict, Any, Text + +import numpy as np +import pytest + +from rasa.nlu.featurizers.dense_featurizer.dense_featurizer import DenseFeaturizer + + +@pytest.mark.parametrize( + "pooling, features, only_non_zero_vectors, expected", + [ + # "mean" + ( + "mean", + np.array([[0.5, 3, 0.4, 0.1], [0, 0, 0, 0], [0.5, 3, 0.4, 0.1]]), + True, + np.array([[0.5, 3, 0.4, 0.1]]), + ), + ( + "mean", + np.array([[1.5, 3, 4.5, 6], [0, 0, 0, 0], [1.5, 3, 4.5, 6]]), + False, + np.array([[1, 2, 3, 4]]), + ), + # "max" + ( + "max", + np.array([[1.0, 3.0, 0.0, 2.0], [4.0, 3.0, 1.0, 0.0]]), + True, + np.array([[4.0, 3.0, 1.0, 2.0]]), + ), + ( + "max", + np.array([[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]]), + True, + np.array([[0.0, 0.0, 0.0, 0.0]]), + ), + # "max" - special cases to be aware of + ("max", np.array([[-1.0], [0.0]]), False, np.array([[0.0]])), + ("max", np.array([[-1.0], [0.0]]), True, np.array([[-1.0]])), + ], +) +def test_calculate_cls_vector(pooling, features, only_non_zero_vectors, expected): + actual = DenseFeaturizer.aggregate_sequence_features( + features, pooling_operation=pooling, only_non_zero_vectors=only_non_zero_vectors + ) + assert np.all(actual == expected) + + +@pytest.mark.parametrize( + "featurizer_configs,passes", + [ + ( + [ + {FEATURIZER_CLASS_ALIAS: "name-1", "same": "other-params"}, + {FEATURIZER_CLASS_ALIAS: "name-2", "same": "other-params"}, + ], + True, + ), + ([{}, {}], True), + ( + [ + {FEATURIZER_CLASS_ALIAS: "same-name", "something": "else"}, + {FEATURIZER_CLASS_ALIAS: "same-name"}, + ], + False, + ), + ], +) +def test_raise_if_featurizer_configs_are_not_compatible( + featurizer_configs: List[Dict[Text, Any]], passes: bool +): + if passes: + Featurizer.raise_if_featurizer_configs_are_not_compatible(featurizer_configs) + else: + with pytest.raises(InvalidConfigException): + Featurizer.raise_if_featurizer_configs_are_not_compatible( + featurizer_configs + ) diff --git a/tests/nlu/featurizers/test_lexical_syntactic_featurizer.py b/tests/nlu/featurizers/test_lexical_syntactic_featurizer.py new file mode 100644 index 0000000..499e1f8 --- /dev/null +++ b/tests/nlu/featurizers/test_lexical_syntactic_featurizer.py @@ -0,0 +1,326 @@ +import numpy as np +import pytest +import re +from typing import Text, Dict, Any, Callable, List, Optional, Union + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.storage.resource import Resource +from rasa.nlu.constants import ( + DENSE_FEATURIZABLE_ATTRIBUTES, + MESSAGE_ATTRIBUTES, + TOKENS_NAMES, +) +from rasa.nlu.tokenizers.spacy_tokenizer import POS_TAG_KEY +from rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer import ( + LexicalSyntacticFeaturizer, + FEATURES, +) +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.constants import FEATURE_TYPE_SEQUENCE, TEXT +from rasa.shared.exceptions import InvalidConfigException +from rasa.nlu.tokenizers.tokenizer import Token + + +@pytest.fixture +def resource_lexical_syntactic_featurizer() -> Resource: + return Resource("LexicalSyntacticFeaturizer") + + +@pytest.fixture +def create_lexical_syntactic_featurizer( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + resource_lexical_syntactic_featurizer: Resource, +) -> Callable[[Dict[Text, Any]], LexicalSyntacticFeaturizer]: + def inner(config: Dict[Text, Any]): + return LexicalSyntacticFeaturizer.create( + config={**LexicalSyntacticFeaturizer.get_default_config(), **config}, + model_storage=default_model_storage, + execution_context=default_execution_context, + resource=resource_lexical_syntactic_featurizer, + ) + + return inner + + +@pytest.mark.parametrize( + "sentence,part_of_speech,feature_config,expected_features", + [ + # simple example 1 + ( + "hello goodbye hello", + None, + [["BOS", "upper"], ["BOS", "EOS", "prefix2", "digit"], ["EOS", "low"]], + [ + [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0], + [0.0, 1.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0], + [1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0], + ], + ), + # simple example 2 + ( + "a 1", + None, + [["BOS", "upper"], ["BOS", "EOS", "prefix2", "digit"], ["EOS", "low"]], + [ + [0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0], + ], + ), + # larger window size + ( + "hello 123 hello 123 hello", + None, + [["upper"], ["digit"], ["low"], ["digit"]], + [0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0], + # Note: + # 1. we just describe the features for first token here + # 2. "123".islower() == "123".isupper() == False, which is why we end + # up with 7 features + ), + # with part of speech + ( + "The sun is shining", + ["DET", "NOUN", "AUX", "VERB"], + [["pos", "pos2"]], + [ + [0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], + ], + ), + ], +) +def test_feature_computation( + create_lexical_syntactic_featurizer: Callable[ + [Dict[Text, Any]], LexicalSyntacticFeaturizer + ], + sentence: Text, + part_of_speech: Optional[List[Text]], + feature_config: List[List[Text]], + expected_features: List[Union[int, List[int]]], +): + featurizer = create_lexical_syntactic_featurizer( + {"alias": "lsf", "features": feature_config} + ) + + # build the message + tokens = [ + Token(text=match[0], start=match.start()) + for match in re.finditer(r"\w+", sentence) + ] + # ... and add part of speech tags (str) to tokens (if specified) + if part_of_speech: + assert len(tokens) == len(part_of_speech) + for token, pos in zip(tokens, part_of_speech): + token.data = {POS_TAG_KEY: pos} + message = Message(data={TOKENS_NAMES[TEXT]: tokens}) + + # train + featurizer.train(TrainingData([message])) + assert not message.features + + # process + featurizer.process([message]) + assert len(message.features) == 1 + feature = message.features[0] + assert feature.attribute == TEXT + assert feature.is_sparse() + assert feature.type == FEATURE_TYPE_SEQUENCE + assert feature.features.shape[0] == len(tokens) + + if isinstance(expected_features[0], List): + assert len(expected_features) == feature.features.shape[0] + # we specified the full matrix + assert np.all(feature.features.todense() == expected_features) + else: + assert len(expected_features) == feature.features.shape[1] + # just check features for the first token + assert np.all(feature.features.todense()[0] == expected_features) + + +def test_features_for_messages_with_missing_part_of_speech_tags( + create_lexical_syntactic_featurizer: Callable[ + [Dict[Text, Any]], LexicalSyntacticFeaturizer + ] +): + # build the message and do NOT add part of speech information + sentence = "hello goodbye hello" + message_data = { + TOKENS_NAMES[TEXT]: [ + Token(text=match[0], start=match.start()) + for match in re.finditer(r"\w+", sentence) + ] + } + message = Message(data=message_data) + + # train and process + featurizer = create_lexical_syntactic_featurizer( + {"alias": "lsf", "features": [["BOS", "pos"]]} + ) + featurizer.train(TrainingData([message])) + featurizer.process([message]) + feature = message.features[0] + assert feature.features.shape[1] == 3 # BOS = True/False, pos = None + + +def test_only_featurizes_text_attribute( + create_lexical_syntactic_featurizer: Callable[ + [Dict[Text, Any]], LexicalSyntacticFeaturizer + ] +): + # build a message with tokens for lots of attributes + sentence = "hello goodbye hello" + tokens = [ + Token(text=match[0], start=match.start()) + for match in re.finditer(r"\w+", sentence) + ] + message_data = {} + for attribute in MESSAGE_ATTRIBUTES + DENSE_FEATURIZABLE_ATTRIBUTES: + message_data[attribute] = sentence + message_data[TOKENS_NAMES[attribute]] = tokens + message = Message(data=message_data) + + # train and process + featurizer = create_lexical_syntactic_featurizer( + {"alias": "lsf", "features": [["BOS"]]} + ) + featurizer.train(TrainingData([message])) + featurizer.process([message]) + assert len(message.features) == 1 + assert message.features[0].attribute == TEXT + + +def test_process_multiple_messages( + create_lexical_syntactic_featurizer: Callable[ + [Dict[Text, Any]], LexicalSyntacticFeaturizer + ] +): + # build a message with tokens for lots of attributes + multiple_messages = [] + for sentence in ["hello", "hello there"]: + tokens = [ + Token(text=match[0], start=match.start()) + for match in re.finditer(r"\w+", sentence) + ] + + multiple_messages.append(Message(data={TOKENS_NAMES[TEXT]: tokens})) + + # train and process + featurizer = create_lexical_syntactic_featurizer( + {"alias": "lsf", "features": [["prefix2"]]} + ) + featurizer.train(TrainingData(multiple_messages)) + featurizer.process(multiple_messages) + for message in multiple_messages: + assert len(message.features) == 1 + assert message.features[0].attribute == TEXT + + # we know both texts where used for training if more than one feature has been + # extracted e.g. for the first message from which only the prefix "he" can be + # extracted + assert multiple_messages[0].features[0].features.shape[-1] > 1 + + +@pytest.mark.parametrize("feature_config", [(["pos", "BOS"],)]) +def test_create_train_load_and_process( + create_lexical_syntactic_featurizer: Callable[ + [Dict[Text, Any]], LexicalSyntacticFeaturizer + ], + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + resource_lexical_syntactic_featurizer: Resource, + feature_config: List[Text], +) -> Callable[..., LexicalSyntacticFeaturizer]: + + config = {"alias": "lsf", "features": feature_config} + featurizer = create_lexical_syntactic_featurizer(config) + + sentence = "Hello how are you" + tokens = [ + Token(text=match[0], start=match.start()) + for match in re.finditer(r"\w+", sentence) + ] + message = Message(data={TOKENS_NAMES[TEXT]: tokens}) + + featurizer.train(TrainingData([message])) + + loaded_featurizer = LexicalSyntacticFeaturizer.load( + config={**LexicalSyntacticFeaturizer.get_default_config(), **config}, + model_storage=default_model_storage, + execution_context=default_execution_context, + resource=resource_lexical_syntactic_featurizer, + ) + + assert loaded_featurizer._feature_to_idx_dict == featurizer._feature_to_idx_dict + + +@pytest.mark.parametrize( + "config,raises", + [ + # do not raise + ({}, False), + ({**LexicalSyntacticFeaturizer.get_default_config()}, False), + ({FEATURES: [["suffix2"]]}, False), + ( + { + "bla": "obviously an unknown extra feature", + "faeturizer": "typos are also unknown features", + }, + False, + ), + # raise + ({FEATURES: ["pos", "suffix2"]}, True), + ({FEATURES: ["suffix1234"]}, True), + ], +) +def test_validate_config(config: Dict[Text, Any], raises: bool): + if not raises: + LexicalSyntacticFeaturizer.validate_config(config) + else: + with pytest.raises(InvalidConfigException): + LexicalSyntacticFeaturizer.validate_config(config) + + +@pytest.mark.parametrize( + "sentence, feature_config, expected_features", + [("The sun is shining", [["pos", "pos2"]], np.ones(shape=(4, 2)))], +) +def test_warn_if_part_of_speech_features_cannot_be_computed( + create_lexical_syntactic_featurizer: Callable[ + [Dict[Text, Any]], LexicalSyntacticFeaturizer + ], + sentence: Text, + feature_config: Dict[Text, Any], + expected_features: np.ndarray, +): + + featurizer = create_lexical_syntactic_featurizer( + {"alias": "lsf", "features": feature_config} + ) + + # build the message - with tokens but *no* part-of-speech tags + tokens = [ + Token(text=match[0], start=match.start()) + for match in re.finditer(r"\w+", sentence) + ] + message = Message(data={TOKENS_NAMES[TEXT]: tokens}) + + # train + with pytest.warns( + UserWarning, + match="Expected training data to include tokens with part-of-speech tags", + ): + featurizer.train(TrainingData([message])) + assert not message.features + + # process + with pytest.warns(None) as records: + featurizer.process([message]) + assert len(records) == 0 + assert len(message.features) == 1 + feature = message.features[0] + assert np.all(feature.features.todense() == expected_features) diff --git a/tests/nlu/featurizers/test_lm_featurizer.py b/tests/nlu/featurizers/test_lm_featurizer.py new file mode 100644 index 0000000..591961d --- /dev/null +++ b/tests/nlu/featurizers/test_lm_featurizer.py @@ -0,0 +1,813 @@ +import os +from typing import Text, List, Dict, Tuple, Any, Callable + +import numpy as np +import pytest +import logging + +from _pytest.monkeypatch import MonkeyPatch +from _pytest.logging import LogCaptureFixture + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.storage.resource import Resource +from rasa.nlu.constants import TOKENS_NAMES, NUMBER_OF_SUB_TOKENS +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.featurizers.dense_featurizer.lm_featurizer import LanguageModelFeaturizer +from rasa.shared.nlu.constants import TEXT, INTENT +from rasa.nlu.tokenizers.tokenizer import Token + + +@pytest.fixture +def resource_language_model_featurizer() -> Resource: + return Resource("LanguageModelFeaturizer") + + +@pytest.fixture +def create_language_model_featurizer( + default_model_storage: ModelStorage, + resource_language_model_featurizer, + default_execution_context: ExecutionContext, +) -> Callable[[Dict[Text, Any]], LanguageModelFeaturizer]: + def inner(config: Dict[Text, Any]) -> LanguageModelFeaturizer: + return LanguageModelFeaturizer.create( + config={**LanguageModelFeaturizer.get_default_config(), **config}, + model_storage=default_model_storage, + resource=resource_language_model_featurizer, + execution_context=default_execution_context, + ) + + return inner + + +def skip_on_CI_with_bert(model_name: Text, model_weights: Text) -> bool: + """Checks whether to skip this configuration on CI. + + Only applies when skip_model_load=False + """ + # First check if CI + return ( + bool(os.environ.get("CI")) + and model_name == "bert" + and (not model_weights or model_weights == "rasa/LaBSE") + ) + + +def create_pretrained_transformers_config( + model_name: Text, model_weights: Text +) -> Dict[Text, Text]: + """Creates a config for LanguageModelFeaturizer. + + If CI, skips model/model_weight combinations that are too large (bert with + LaBSE). + + Args: + model_name: model name + model_weights: model weights name + """ + if skip_on_CI_with_bert(model_name, model_weights): + pytest.skip( + "Reason: this model is too large, loading it results in" + "crashing of GH action workers." + ) + config = {"model_name": model_name} + if model_weights: + config["model_weights"] = model_weights + return config + + +def process_training_text( + texts: List[Text], + model_name: Text, + model_weights: Text, + create_language_model_featurizer: Callable[ + [Dict[Text, Any]], LanguageModelFeaturizer + ], + whitespace_tokenizer: WhitespaceTokenizer, +) -> List[Message]: + """Creates a featurizer and process training data""" + config = create_pretrained_transformers_config(model_name, model_weights) + lm_featurizer = create_language_model_featurizer(config) + + messages = [Message.build(text=text) for text in texts] + td = TrainingData(messages) + + whitespace_tokenizer.process_training_data(td) + lm_featurizer.process_training_data(td) + return messages + + +def process_messages( + texts: List[Text], + model_name: Text, + model_weights: Text, + create_language_model_featurizer: Callable[ + [Dict[Text, Any]], LanguageModelFeaturizer + ], + whitespace_tokenizer: WhitespaceTokenizer, +) -> List[Message]: + """Creates a featurizer and processes messages""" + config = create_pretrained_transformers_config(model_name, model_weights) + lm_featurizer = create_language_model_featurizer(config) + + messages = [] + for text in texts: + message = Message.build(text=text) + whitespace_tokenizer.process([message]) + messages.append(message) + lm_featurizer.process(messages) + return messages + + +@pytest.mark.parametrize( + "model_name, model_weights, texts, expected_shape, " + "expected_sequence_vec, expected_cls_vec", + [ + ( + "bert", + None, + ["Good evening.", "here is the sentence I want embeddings for."], + [(3, 768), (9, 768)], + [ + [0.6569931, 0.77279466], + [0.21718428, 0.34955627, 0.59124136, 0.6869872, 0.16993292], + ], + [ + [0.29528213, 0.5543281, -0.4091331, 0.65817744, 0.81740487], + [-0.17215663, 0.26811457, -0.1922609, -0.63926417, -1.626383], + ], + ), + ( + "bert", + "bert-base-uncased", + ["Good evening.", "here is the sentence I want embeddings for."], + [(3, 768), (9, 768)], + [ + [0.57274431, -0.16078192], + [-0.54851216, 0.09632845, -0.42788929, 0.11438307, 0.18316516], + ], + [ + [0.06880389, 0.32802248, -0.11250392, -0.11338016, -0.37116382], + [0.05909365, 0.06433402, 0.08569094, -0.16530040, -0.11396892], + ], + ), + ( + "gpt", + None, + ["Good evening.", "here is the sentence I want embeddings for."], + [(3, 768), (9, 768)], + [ + [-0.06324312090873718, 0.4072571396827698], + [ + 0.8041259050369263, + -0.08877559006214142, + 0.9976294636726379, + -0.38815218210220337, + 0.08530596643686295, + ], + ], + [ + [ + 0.1720070093870163, + 0.1511477530002594, + 0.39497435092926025, + -0.5745484828948975, + 0.05334469676017761, + ], + [ + 0.4095669686794281, + -0.11725597828626633, + -0.30236583948135376, + -0.4023253917694092, + 0.6285617351531982, + ], + ], + ), + ( + "gpt2", + None, + ["Good evening.", "here is the sentence I want embeddings for."], + [(3, 768), (9, 768)], + [ + [-0.03382749, -0.05373593], + [-0.18434484, -0.5386464, -0.11122551, -0.95434338, 0.28311089], + ], + [ + [ + -0.04710008203983307, + -0.2793063223361969, + -0.23804056644439697, + -0.3212292492389679, + 0.11430201679468155, + ], + [ + -0.1809544414281845, + -0.017152192071080208, + -0.3176477551460266, + -0.008387327194213867, + 0.3365338146686554, + ], + ], + ), + ( + "xlnet", + None, + ["Good evening.", "here is the sentence I want embeddings for."], + [(3, 768), (9, 768)], + [ + [1.7588920593261719, 2.578641176223755], + [ + 0.7821242213249207, + 0.6983698606491089, + 1.5819640159606934, + 1.891527533531189, + 2.511735200881958, + ], + ], + [ + [ + 2.168766498565674, + -1.5277889966964722, + -3.2499680519104004, + 0.23829853534698486, + -1.603652000427246, + ], + [ + 1.643880844116211, + 0.023089325055480003, + -2.497927665710449, + 1.4621683359146118, + -2.5919559001922607, + ], + ], + ), + ( + "distilbert", + None, + ["Good evening.", "here is the sentence I want embeddings for."], + [(3, 768), (9, 768)], + [ + [0.22866562008857727, -0.0575055330991745], + [ + -0.6448041796684265, + -0.5105321407318115, + -0.4892978072166443, + 0.17531153559684753, + 0.22717803716659546, + ], + ], + [ + [ + -0.09814466536045074, + -0.07325993478298187, + 0.22358475625514984, + -0.20274735987186432, + -0.07363069802522659, + ], + [ + -0.146609365940094, + -0.07373693585395813, + 0.016850866377353668, + -0.2407529354095459, + -0.0979844480752945, + ], + ], + ), + ( + "roberta", + None, + ["Good evening.", "here is the sentence I want embeddings for."], + [(3, 768), (9, 768)], + [ + [-0.3092685, 0.09567838], + [0.02152853, -0.08026707, -0.1080862, 0.12423468, -0.05378958], + ], + [ + [ + -0.03930358216166496, + 0.034788478165864944, + 0.12246038764715195, + 0.08401528000831604, + 0.7026961445808411, + ], + [ + -0.018586941063404083, + -0.09835464507341385, + 0.03242188319563866, + 0.09366855770349503, + 0.4458026587963104, + ], + ], + ), + ( + "camembert", + None, + ["J'aime le camembert !"], + [(5, 768)], + [[0.07532623, 0.01274978, -0.08567604, 0.00386575]], + [[0.00233287, -0.08452773, 0.0410389, 0.03026095, -0.06296296]], + ), + ], +) +class TestShapeValuesTrainAndProcess: + @staticmethod + def evaluate_message_shapes( + messages: List[Message], + expected_shape: List[tuple], + expected_sequence_vec: List[List[float]], + expected_cls_vec: List[List[float]], + ) -> None: + for index in range(len(messages)): + (computed_sequence_vec, computed_sentence_vec) = messages[ + index + ].get_dense_features(TEXT, []) + if computed_sequence_vec: + computed_sequence_vec = computed_sequence_vec.features + if computed_sentence_vec: + computed_sentence_vec = computed_sentence_vec.features + + assert computed_sequence_vec.shape[0] == expected_shape[index][0] - 1 + assert computed_sequence_vec.shape[1] == expected_shape[index][1] + assert computed_sentence_vec.shape[0] == 1 + assert computed_sentence_vec.shape[1] == expected_shape[index][1] + + # Look at the value of first dimension for a few starting timesteps + assert np.allclose( + computed_sequence_vec[: len(expected_sequence_vec[index]), 0].astype( + float + ), + expected_sequence_vec[index], + atol=1e-4, + ) + + # Look at the first value of first five dimensions + assert np.allclose( + computed_sentence_vec[0][:5].astype(float), + expected_cls_vec[index], + atol=1e-4, + ) + + (intent_sequence_vec, intent_sentence_vec) = messages[ + index + ].get_dense_features(INTENT, []) + if intent_sequence_vec: + intent_sequence_vec = intent_sequence_vec.features + if intent_sentence_vec: + intent_sentence_vec = intent_sentence_vec.features + + assert intent_sequence_vec is None + assert intent_sentence_vec is None + + @pytest.mark.timeout(120, func_only=True) + def test_lm_featurizer_shapes_in_process_training_data( + self, + model_name: Text, + model_weights: Text, + texts: List[Text], + expected_shape: List[Tuple[int, int]], + expected_sequence_vec: List[List[float]], + expected_cls_vec: List[List[float]], + create_language_model_featurizer: Callable[ + [Dict[Text, Any]], LanguageModelFeaturizer + ], + whitespace_tokenizer: WhitespaceTokenizer, + ): + messages = process_training_text( + texts, + model_name, + model_weights, + create_language_model_featurizer, + whitespace_tokenizer, + ) + self.evaluate_message_shapes( + messages, expected_shape, expected_sequence_vec, expected_cls_vec + ) + + @pytest.mark.timeout(120, func_only=True) + def test_lm_featurizer_shapes_in_process_messages( + self, + model_name: Text, + model_weights: Text, + texts: List[Text], + expected_shape: List[Tuple[int, int]], + expected_sequence_vec: List[List[float]], + expected_cls_vec: List[List[float]], + create_language_model_featurizer: Callable[ + [Dict[Text, Any]], LanguageModelFeaturizer + ], + whitespace_tokenizer: WhitespaceTokenizer, + ): + messages = process_messages( + texts, + model_name, + model_weights, + create_language_model_featurizer, + whitespace_tokenizer, + ) + self.evaluate_message_shapes( + messages, expected_shape, expected_sequence_vec, expected_cls_vec + ) + + +@pytest.mark.parametrize( + "model_name, model_weights, texts, expected_number_of_sub_tokens", + [ + ( + "bert", + None, + [ + "Good evening.", + "you're", + "r. n. b.", + "rock & roll", + "here is the sentence I want embeddings for.", + "sentence embeddings", + ], + [[1, 1], [1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1, 1, 1, 1, 2, 1], [1, 2]], + ), + ( + "bert", + "bert-base-chinese", + [ + "晚上好", # normal & easy case + "没问题!", # `!` is a Chinese punctuation + "去东畈村", # `畈` is a OOV token for bert-base-chinese + "好的😃", + # include a emoji which is common in Chinese text-based chat + ], + [[3], [4], [4], [3]], + ), + ( + "gpt", + None, + [ + "Good evening.", + "hello", + "you're", + "r. n. b.", + "rock & roll", + "here is the sentence I want embeddings for.", + "sentence embeddings", + ], + [ + [1, 1], + [1], + [1, 1], + [1, 1, 1], + [1, 1, 1], + [1, 1, 1, 1, 1, 1, 2, 1], + [1, 2], + ], + ), + ( + "gpt2", + None, + [ + "Good evening.", + "hello", + "you're", + "r. n. b.", + "rock & roll", + "here is the sentence I want embeddings for.", + "sentence embeddings", + ], + [ + [1, 2], + [1], + [1, 1], + [1, 1, 1], + [1, 1, 1], + [1, 1, 1, 2, 1, 1, 3, 1], + [2, 3], + ], + ), + ( + "xlnet", + None, + [ + "Good evening.", + "hello", + "you're", + "r. n. b.", + "rock & roll", + "here is the sentence I want embeddings for.", + "sentence embeddings", + ], + [ + [1, 1], + [1], + [1, 1], + [1, 1, 1], + [1, 1, 1], + [1, 1, 1, 1, 1, 1, 3, 1], + [1, 3], + ], + ), + ( + "distilbert", + None, + [ + "Good evening.", + "you're", + "r. n. b.", + "rock & roll", + "here is the sentence I want embeddings for.", + "sentence embeddings", + ], + [[1, 1], [1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1, 1, 1, 1, 4, 1], [1, 4]], + ), + ( + "roberta", + None, + [ + "Good evening.", + "hello", + "you're", + "r. n. b.", + "rock & roll", + "here is the sentence I want embeddings for.", + "sentence embeddings", + ], + [ + [1, 2], + [1], + [1, 1], + [1, 1, 1], + [1, 1, 1], + [1, 1, 1, 2, 1, 1, 3, 1], + [2, 3], + ], + ), + ( + "bert", + "bert-base-uncased", + [ + "Good evening.", + "you're", + "r. n. b.", + "rock & roll", + "here is the sentence I want embeddings for.", + "sentence embeddings", + ], + [[1, 1], [1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1, 1, 1, 1, 4, 1], [1, 4]], + ), + ( + "camembert", + "camembert-base", + [ + "J'aime le camembert !", + ], + [[1, 1, 1, 3]], + ), + ], +) +class TestSubTokensTrainAndProcess: + @staticmethod + def check_subtokens( + texts: List[Text], + messages: List[Message], + expected_number_of_sub_tokens: List[List[float]], + whitespace_tokenizer: WhitespaceTokenizer, + ): + """Checks that we get the correct number of sub tokens""" + for index, message in enumerate(messages): + assert [ + t.get(NUMBER_OF_SUB_TOKENS) for t in message.get(TOKENS_NAMES[TEXT]) + ] == expected_number_of_sub_tokens[index] + assert len(message.get(TOKENS_NAMES[TEXT])) == len( + whitespace_tokenizer.tokenize(Message.build(text=texts[index]), TEXT) + ) + + @pytest.mark.timeout(120, func_only=True) + def test_lm_featurizer_num_sub_tokens_process_training_data( + self, + model_name: Text, + model_weights: Text, + texts: List[Text], + expected_number_of_sub_tokens: List[List[float]], + create_language_model_featurizer: Callable[ + [Dict[Text, Any]], LanguageModelFeaturizer + ], + whitespace_tokenizer: WhitespaceTokenizer, + ): + """Tests the number of sub tokens when calling the function + process training data""" + messages = process_training_text( + texts, + model_name, + model_weights, + create_language_model_featurizer, + whitespace_tokenizer, + ) + self.check_subtokens( + texts, messages, expected_number_of_sub_tokens, whitespace_tokenizer + ) + + @pytest.mark.timeout(120, func_only=True) + def test_lm_featurizer_num_sub_tokens_process_messages( + self, + model_name: Text, + model_weights: Text, + texts: List[Text], + expected_number_of_sub_tokens: List[List[float]], + create_language_model_featurizer: Callable[ + [Dict[Text, Any]], LanguageModelFeaturizer + ], + whitespace_tokenizer: WhitespaceTokenizer, + ): + """Tests the number of sub tokens when calling the function + process (messages)""" + messages = process_messages( + texts, + model_name, + model_weights, + create_language_model_featurizer, + whitespace_tokenizer, + ) + self.check_subtokens( + texts, messages, expected_number_of_sub_tokens, whitespace_tokenizer + ) + + +@pytest.mark.parametrize( + "input_sequence_length, model_name, should_overflow", + [(20, "bert", False), (1000, "bert", True), (1000, "xlnet", False)], +) +def test_sequence_length_overflow_train( + input_sequence_length: int, + model_name: Text, + should_overflow: bool, + create_language_model_featurizer: Callable[ + [Dict[Text, Any]], LanguageModelFeaturizer + ], + monkeypatch: MonkeyPatch, +): + monkeypatch.setattr(LanguageModelFeaturizer, "_load_model_instance", lambda _: None) + component = create_language_model_featurizer({"model_name": model_name}) + message = Message.build(text=" ".join(["hi"] * input_sequence_length)) + if should_overflow: + with pytest.raises(RuntimeError): + component._validate_sequence_lengths( + [input_sequence_length], [message], "text", inference_mode=False + ) + else: + component._validate_sequence_lengths( + [input_sequence_length], [message], "text", inference_mode=False + ) + + +@pytest.mark.parametrize( + "sequence_embeddings, actual_sequence_lengths, model_name, padding_needed", + [ + (np.ones((1, 512, 5)), [1000], "bert", True), + (np.ones((1, 512, 5)), [1000], "xlnet", False), + (np.ones((1, 256, 5)), [256], "bert", False), + ], +) +def test_long_sequences_extra_padding( + sequence_embeddings: np.ndarray, + actual_sequence_lengths: List[int], + model_name: Text, + padding_needed: bool, + create_language_model_featurizer: Callable[ + [Dict[Text, Any]], LanguageModelFeaturizer + ], + monkeypatch: MonkeyPatch, +): + monkeypatch.setattr(LanguageModelFeaturizer, "_load_model_instance", lambda _: None) + component = create_language_model_featurizer({"model_name": model_name}) + modified_sequence_embeddings = component._add_extra_padding( + sequence_embeddings, actual_sequence_lengths + ) + if not padding_needed: + assert np.all(modified_sequence_embeddings) == np.all(sequence_embeddings) + else: + assert modified_sequence_embeddings.shape[1] == actual_sequence_lengths[0] + assert ( + modified_sequence_embeddings[0].shape[-1] + == sequence_embeddings[0].shape[-1] + ) + zero_embeddings = modified_sequence_embeddings[0][ + sequence_embeddings.shape[1] : + ] + assert np.all(zero_embeddings == 0) + + +@pytest.mark.parametrize( + "token_ids, max_sequence_length_model, resulting_length, padding_added", + [ + ([[1] * 200], 512, 512, True), + ([[1] * 700], 512, 512, False), + ([[1] * 200], 200, 200, False), + ], +) +def test_input_padding( + token_ids: List[List[int]], + max_sequence_length_model: int, + resulting_length: int, + padding_added: bool, + create_language_model_featurizer: Callable[ + [Dict[Text, Any]], LanguageModelFeaturizer + ], + monkeypatch: MonkeyPatch, +): + monkeypatch.setattr(LanguageModelFeaturizer, "_load_model_instance", lambda _: None) + component = create_language_model_featurizer({"model_name": "bert"}) + component.pad_token_id = 0 + padded_input = component._add_padding_to_batch(token_ids, max_sequence_length_model) + assert len(padded_input[0]) == resulting_length + if padding_added: + original_length = len(token_ids[0]) + assert np.all(np.array(padded_input[0][original_length:]) == 0) + + +@pytest.mark.parametrize( + "sequence_length, model_name, model_weights, should_overflow", + [ + (1000, "bert", "bert-base-uncased", True), + (256, "bert", "bert-base-uncased", False), + ], +) +def test_log_longer_sequence( + sequence_length: int, + model_name: Text, + model_weights: Text, + should_overflow: bool, + caplog: LogCaptureFixture, + create_language_model_featurizer: Callable[ + [Dict[Text, Any]], LanguageModelFeaturizer + ], + whitespace_tokenizer: WhitespaceTokenizer, +): + config = {"model_name": model_name, "model_weights": model_weights} + + featurizer = create_language_model_featurizer(config) + + text = " ".join(["hi"] * sequence_length) + message = Message.build(text=text) + td = TrainingData([message]) + whitespace_tokenizer.process_training_data(td) + caplog.set_level(logging.DEBUG) + featurizer.process([message]) + if should_overflow: + assert "hi hi hi" in caplog.text + assert len(message.features) >= 2 + + +@pytest.mark.parametrize( + "actual_sequence_length, max_input_sequence_length, zero_start_index", + [(256, 512, 256), (700, 700, 700), (700, 512, 512)], +) +def test_attention_mask( + actual_sequence_length: int, + max_input_sequence_length: int, + zero_start_index: int, + create_language_model_featurizer: Callable[ + [Dict[Text, Any]], LanguageModelFeaturizer + ], + monkeypatch: MonkeyPatch, +): + monkeypatch.setattr(LanguageModelFeaturizer, "_load_model_instance", lambda _: None) + component = create_language_model_featurizer({"model_name": "bert"}) + + attention_mask = component._compute_attention_mask( + [actual_sequence_length], max_input_sequence_length + ) + mask_ones = attention_mask[0][:zero_start_index] + mask_zeros = attention_mask[0][zero_start_index:] + + assert np.all(mask_ones == 1) + assert np.all(mask_zeros == 0) + + +@pytest.mark.parametrize( + "text, tokens, expected_feature_tokens", + [ + ( + "购买 iPhone 12", # whitespace ' ' is expected to be removed + [("购买", 0), (" ", 2), ("iPhone", 3), (" ", 9), ("12", 10)], + [("购买", 0), ("iPhone", 3), ("12", 10)], + ) + ], +) +def test_lm_featurizer_correctly_handle_whitespace_token( + text: Text, + tokens: List[Tuple[Text, int]], + expected_feature_tokens: List[Tuple[Text, int]], + create_language_model_featurizer: Callable[ + [Dict[Text, Any]], LanguageModelFeaturizer + ], +): + + config = {"model_name": "bert", "model_weights": "bert-base-chinese"} + + lm_featurizer = create_language_model_featurizer(config) + + message = Message.build(text=text) + message.set(TOKENS_NAMES[TEXT], [Token(word, start) for (word, start) in tokens]) + + result, _ = lm_featurizer._tokenize_example(message, TEXT) + + assert [(token.text, token.start) for token in result] == expected_feature_tokens diff --git a/tests/nlu/featurizers/test_mitie_featurizer.py b/tests/nlu/featurizers/test_mitie_featurizer.py new file mode 100644 index 0000000..d68ea01 --- /dev/null +++ b/tests/nlu/featurizers/test_mitie_featurizer.py @@ -0,0 +1,130 @@ +import pytest +import numpy as np + +from typing import Text, Dict, Any, Callable + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.storage import ModelStorage +from rasa.engine.storage.resource import Resource + +from rasa.nlu.constants import TOKENS_NAMES +from rasa.shared.nlu.constants import TEXT, INTENT, RESPONSE +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.tokenizers.mitie_tokenizer import MitieTokenizer +from rasa.nlu.featurizers.dense_featurizer.mitie_featurizer import MitieFeaturizer +from rasa.nlu.utils.mitie_utils import MitieModel, MitieNLP + + +@pytest.fixture +def resource() -> Resource: + return Resource("MitieFeaturizer") + + +@pytest.fixture +def mitie_model( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +) -> MitieModel: + component = MitieNLP.create( + MitieNLP.get_default_config(), + default_model_storage, + Resource("mitie"), + default_execution_context, + ) + + return component.provide() + + +@pytest.fixture +def create( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + resource: Resource, +) -> Callable[[Dict[Text, Any]], MitieFeaturizer]: + def inner(config: Dict[Text, Any]): + return MitieFeaturizer.create( + config={**MitieFeaturizer.get_default_config(), **config}, + model_storage=default_model_storage, + execution_context=default_execution_context, + resource=resource, + ) + + return inner + + +def test_mitie_featurizer( + create: Callable[[Dict[Text, Any]], MitieFeaturizer], + mitie_model: MitieModel, + mitie_tokenizer: MitieTokenizer, +): + + featurizer = create({"alias": "mitie_featurizer"}) + + sentence = "Hey how are you today" + message = Message(data={TEXT: sentence}) + mitie_tokenizer.process([message]) + tokens = message.get(TOKENS_NAMES[TEXT]) + + seq_vec, sen_vec = featurizer.features_for_tokens( + tokens, mitie_model.word_feature_extractor + ) + + expected = np.array( + [0.00000000e00, -5.12735510e00, 4.39929873e-01, -5.60760403e00, -8.26445103e00] + ) + expected_cls = np.array([0.0, -4.4551446, 0.26073121, -1.46632245, -1.84205751]) + + assert 6 == len(seq_vec) + len(sen_vec) + assert np.allclose(seq_vec[0][:5], expected, atol=1e-5) + assert np.allclose(sen_vec[-1][:5], expected_cls, atol=1e-5) + + +def test_mitie_featurizer_train( + create: Callable[[Dict[Text, Any]], MitieFeaturizer], + mitie_model: MitieModel, + mitie_tokenizer: MitieTokenizer, +): + + featurizer = create({"alias": "mitie_featurizer"}) + + sentence = "Hey how are you today" + message = Message(data={TEXT: sentence}) + message.set(RESPONSE, sentence) + message.set(INTENT, "intent") + mitie_tokenizer.process_training_data(TrainingData([message])) + + featurizer.process_training_data(TrainingData([message]), mitie_model) + + expected = np.array( + [0.00000000e00, -5.12735510e00, 4.39929873e-01, -5.60760403e00, -8.26445103e00] + ) + expected_cls = np.array([0.0, -4.4551446, 0.26073121, -1.46632245, -1.84205751]) + + seq_vec, sen_vec = message.get_dense_features(TEXT, []) + if seq_vec: + seq_vec = seq_vec.features + if sen_vec: + sen_vec = sen_vec.features + + assert len(message.get(TOKENS_NAMES[TEXT])) == len(seq_vec) + assert np.allclose(seq_vec[0][:5], expected, atol=1e-5) + assert np.allclose(sen_vec[-1][:5], expected_cls, atol=1e-5) + + seq_vec, sen_vec = message.get_dense_features(RESPONSE, []) + if seq_vec: + seq_vec = seq_vec.features + if sen_vec: + sen_vec = sen_vec.features + + assert len(message.get(TOKENS_NAMES[RESPONSE])) == len(seq_vec) + assert np.allclose(seq_vec[0][:5], expected, atol=1e-5) + assert np.allclose(sen_vec[-1][:5], expected_cls, atol=1e-5) + + seq_vec, sen_vec = message.get_dense_features(INTENT, []) + if seq_vec: + seq_vec = seq_vec.features + if sen_vec: + sen_vec = sen_vec.features + + assert seq_vec is None + assert sen_vec is None diff --git a/tests/nlu/featurizers/test_regex_featurizer.py b/tests/nlu/featurizers/test_regex_featurizer.py new file mode 100644 index 0000000..a664c77 --- /dev/null +++ b/tests/nlu/featurizers/test_regex_featurizer.py @@ -0,0 +1,628 @@ +from typing import Text, List, Any, Tuple, Callable, Dict, Optional + +import dataclasses +import numpy as np +import pytest + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.featurizers.sparse_featurizer.regex_featurizer import RegexFeaturizer +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +from rasa.nlu.constants import SPACY_DOCS, TOKENS_NAMES +from rasa.shared.nlu.constants import TEXT, INTENT, RESPONSE +from rasa.nlu.tokenizers.spacy_tokenizer import SpacyTokenizer + + +@pytest.fixture() +def resource() -> Resource: + return Resource("regex_featurizer") + + +@pytest.fixture() +def create_featurizer( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + resource: Resource, +) -> Callable[..., RegexFeaturizer]: + def inner( + config: Dict[Text, Any] = None, + known_patterns: Optional[List[Dict[Text, Any]]] = None, + ) -> RegexFeaturizer: + config = config or {} + return RegexFeaturizer( + {**RegexFeaturizer.get_default_config(), **config}, + default_model_storage, + resource, + default_execution_context, + known_patterns, + ) + + return inner + + +@pytest.mark.parametrize( + "sentence, expected_sequence_features, expected_sentence_features," + "labeled_tokens", + [ + ( + "hey how are you today", + [ + [0.0, 1.0, 0.0], + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + ], + [0.0, 1.0, 0.0], + [0], + ), + ( + "hey 456 how are you", + [ + [0.0, 1.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + ], + [1.0, 1.0, 0.0], + [1, 0], + ), + ( + "blah balh random eh", + [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [0.0, 0.0, 0.0], + [], + ), + ( + "a 1 digit number", + [[0.0, 0.0, 0.0], [1.0, 0.0, 1.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [1.0, 0.0, 1.0], + [1, 1], + ), + ], +) +def test_regex_featurizer( + sentence: Text, + expected_sequence_features: List[float], + expected_sentence_features: List[float], + labeled_tokens: List[int], + spacy_nlp: Any, + create_featurizer: Callable[..., RegexFeaturizer], + spacy_tokenizer: SpacyTokenizer, +): + patterns = [ + {"pattern": "[0-9]+", "name": "number", "usage": "intent"}, + {"pattern": "\\bhey*", "name": "hello", "usage": "intent"}, + {"pattern": "[0-1]+", "name": "binary", "usage": "intent"}, + ] + ftr = create_featurizer(known_patterns=patterns) + + # adds tokens to the message + message = Message(data={TEXT: sentence, RESPONSE: sentence}) + message.set(SPACY_DOCS[TEXT], spacy_nlp(sentence)) + spacy_tokenizer.process([message]) + + sequence_features, sentence_features = ftr._features_for_patterns(message, TEXT) + assert np.allclose( + sequence_features.toarray(), expected_sequence_features, atol=1e-10 + ) + assert np.allclose( + sentence_features.toarray(), expected_sentence_features, atol=1e-10 + ) + + # the tokenizer should have added tokens + assert len(message.get(TOKENS_NAMES[TEXT], [])) > 0 + # the number of regex matches on each token should match + for i, token in enumerate(message.get(TOKENS_NAMES[TEXT])): + token_matches = token.get("pattern").values() + num_matches = sum(token_matches) + assert num_matches == labeled_tokens.count(i) + + +@pytest.mark.parametrize( + "sentence, tokens, expected_sequence_features, expected_sentence_features," + "labeled_tokens", + [ + ( + "明天上海的天气怎么样?", + [ + ("明天", 0), + ("上海", 2), + ("的", 4), + ("天气", 5), + ("怎么样", 7), + ("?", 10), + ], + [[0.0, 1.0], [1.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]], + [1.0, 1.0], + [0.0, 1.0], + ), + ( + "北京的天气如何?", + [("北京", 0), ("的", 2), ("天气", 3), ("如何", 5), ("?", 7)], + [[1.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]], + [1.0, 0.0], + [0.0], + ), + ( + "昨天和今天的天气都不错", + [ + ("昨天", 0), + ("和", 2), + ("今天", 3), + ("的", 5), + ("天气", 6), + ("都", 8), + ("不错", 9), + ], + [ + [0.0, 1.0], + [0.0, 0.0], + [0.0, 1.0], + [0.0, 0.0], + [0.0, 0.0], + [0.0, 0.0], + [0.0, 0.0], + ], + [0.0, 1.0], + [0.0, 2.0], + ), + ( + "后天呢?", + [("后天", 0), ("呢", 2), ("?", 3)], + [[0.0, 1.0], [0.0, 0.0], [0.0, 0.0]], + [0.0, 1.0], + [0.0], + ), + ], +) +def test_lookup_tables_without_use_word_boundaries( + sentence: Text, + tokens: List[Tuple[Text, float]], + expected_sequence_features: List[float], + expected_sentence_features: List[float], + labeled_tokens: List[float], + create_featurizer: Callable[..., RegexFeaturizer], +): + from rasa.nlu.tokenizers.tokenizer import Token + + lookups = [ + {"name": "cites", "elements": ["北京", "上海", "广州", "深圳", "杭州"]}, + {"name": "dates", "elements": ["昨天", "今天", "明天", "后天"]}, + ] + ftr = create_featurizer({"use_word_boundaries": False}) + training_data = TrainingData() + training_data.lookup_tables = lookups + ftr.train(training_data) + + # adds tokens to the message + message = Message(data={TEXT: sentence}) + message.set(TOKENS_NAMES[TEXT], [Token(word, start) for (word, start) in tokens]) + + sequence_features, sentence_features = ftr._features_for_patterns(message, TEXT) + assert np.allclose( + sequence_features.toarray(), expected_sequence_features, atol=1e-10 + ) + assert np.allclose( + sentence_features.toarray(), expected_sentence_features, atol=1e-10 + ) + + # the number of regex matches on each token should match + for i, token in enumerate(message.get(TOKENS_NAMES[TEXT])): + token_matches = token.get("pattern").values() + num_matches = sum(token_matches) + assert num_matches == labeled_tokens.count(i) + + +@pytest.mark.parametrize( + "sentence, expected_sequence_features, expected_sentence_features, " + "labeled_tokens", + [ + ( + "lemonade and mapo tofu", + [[1.0, 0.0], [0.0, 0.0], [0.0, 1.0], [0.0, 1.0]], + [1.0, 1.0], + [0.0, 2.0, 3.0], + ), + ( + "a cup of tea", + [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [1.0, 0.0]], + [1.0, 0.0], + [3.0], + ), + ( + "Is burrito my favorite food?", + [[0.0, 0.0], [0.0, 1.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]], + [0.0, 1.0], + [1.0], + ), + ("I want club?mate", [[0.0, 0.0], [0.0, 0.0], [1.0, 0.0]], [1.0, 0.0], [2.0]), + ], +) +def test_lookup_tables( + sentence: Text, + expected_sequence_features: List[float], + expected_sentence_features: List[float], + labeled_tokens: List[float], + spacy_nlp: Any, + spacy_tokenizer: SpacyTokenizer, + create_featurizer: Callable[..., RegexFeaturizer], +): + lookups = [ + { + "name": "drinks", + "elements": ["mojito", "lemonade", "sweet berry wine", "tea", "club?mate"], + }, + {"name": "plates", "elements": "data/test/lookup_tables/plates.txt"}, + ] + ftr = create_featurizer() + training_data = TrainingData() + training_data.lookup_tables = lookups + ftr.train(training_data) + ftr.process_training_data(training_data) + + # adds tokens to the message + message = Message(data={TEXT: sentence}) + message.set("text_spacy_doc", spacy_nlp(sentence)) + spacy_tokenizer.process([message]) + + sequence_features, sentence_features = ftr._features_for_patterns(message, TEXT) + assert np.allclose( + sequence_features.toarray(), expected_sequence_features, atol=1e-10 + ) + assert np.allclose( + sentence_features.toarray(), expected_sentence_features, atol=1e-10 + ) + + # the tokenizer should have added tokens + assert len(message.get(TOKENS_NAMES[TEXT], [])) > 0 + # the number of regex matches on each token should match + for i, token in enumerate(message.get(TOKENS_NAMES[TEXT])): + token_matches = token.get("pattern").values() + num_matches = sum(token_matches) + assert num_matches == labeled_tokens.count(i) + + +@pytest.mark.parametrize( + "sentence, expected_sequence_features, expected_sentence_features", + [ + ("hey how are you today", [0.0, 1.0, 0.0], [0.0, 1.0, 0.0]), + ("hey 456 how are you", [0.0, 1.0, 0.0], [1.0, 1.0, 0.0]), + ("blah balh random eh", [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]), + ("a 1 digit number", [0.0, 0.0, 0.0], [1.0, 0.0, 1.0]), + ], +) +def test_regex_featurizer_no_sequence( + sentence: Text, + expected_sequence_features: List[float], + expected_sentence_features: List[float], + spacy_nlp: Any, + create_featurizer: Callable[..., RegexFeaturizer], + spacy_tokenizer: SpacyTokenizer, +): + + patterns = [ + {"pattern": "[0-9]+", "name": "number", "usage": "intent"}, + {"pattern": "\\bhey*", "name": "hello", "usage": "intent"}, + {"pattern": "[0-1]+", "name": "binary", "usage": "intent"}, + ] + ftr = create_featurizer(known_patterns=patterns) + + # adds tokens to the message + message = Message(data={TEXT: sentence}) + message.set(SPACY_DOCS[TEXT], spacy_nlp(sentence)) + spacy_tokenizer.process([message]) + + sequence_features, sentence_features = ftr._features_for_patterns(message, TEXT) + assert np.allclose( + sequence_features.toarray()[0], expected_sequence_features, atol=1e-10 + ) + assert np.allclose( + sentence_features.toarray()[-1], expected_sentence_features, atol=1e-10 + ) + + +def test_regex_featurizer_train( + create_featurizer: Callable[..., RegexFeaturizer], + whitespace_tokenizer: WhitespaceTokenizer, +): + patterns = [ + {"pattern": "[0-9]+", "name": "number", "usage": "intent"}, + {"pattern": "\\bhey*", "name": "hello", "usage": "intent"}, + {"pattern": "[0-1]+", "name": "binary", "usage": "intent"}, + ] + + featurizer = create_featurizer() + sentence = "hey how are you today 19.12.2019 ?" + message = Message(data={TEXT: sentence}) + message.set(RESPONSE, sentence) + message.set(INTENT, "intent") + + whitespace_tokenizer.process_training_data(TrainingData([message])) + training_data = TrainingData([message], regex_features=patterns) + + featurizer.train(training_data) + featurizer.process_training_data(training_data) + + expected = np.array([0, 1, 0]) + expected_cls = np.array([1, 1, 1]) + + seq_vecs, sen_vec = message.get_sparse_features(TEXT, []) + if seq_vecs: + seq_vecs = seq_vecs.features + if sen_vec: + sen_vec = sen_vec.features + + assert (6, 3) == seq_vecs.shape + assert (1, 3) == sen_vec.shape + assert np.all(seq_vecs.toarray()[0] == expected) + assert np.all(sen_vec.toarray()[-1] == expected_cls) + + seq_vecs, sen_vec = message.get_sparse_features(RESPONSE, []) + if seq_vecs: + seq_vecs = seq_vecs.features + if sen_vec: + sen_vec = sen_vec.features + + assert (6, 3) == seq_vecs.shape + assert (1, 3) == sen_vec.shape + assert np.all(seq_vecs.toarray()[0] == expected) + assert np.all(sen_vec.toarray()[-1] == expected_cls) + + seq_vecs, sen_vec = message.get_sparse_features(INTENT, []) + if seq_vecs: + seq_vecs = seq_vecs.features + if sen_vec: + sen_vec = sen_vec.features + + assert seq_vecs is None + assert sen_vec is None + + +@pytest.mark.parametrize( + "sentence, expected_sequence_features, expected_sentence_features," + "case_sensitive", + [ + ("Hey How are you today", [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], True), + ("Hey How are you today", [0.0, 1.0, 0.0], [0.0, 1.0, 0.0], False), + ("Hey 456 How are you", [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], True), + ("Hey 456 How are you", [0.0, 1.0, 0.0], [1.0, 1.0, 0.0], False), + ], +) +def test_regex_featurizer_case_sensitive( + sentence: Text, + expected_sequence_features: List[float], + expected_sentence_features: List[float], + case_sensitive: bool, + spacy_nlp: Any, + create_featurizer: Callable[..., RegexFeaturizer], + spacy_tokenizer: SpacyTokenizer, +): + + patterns = [ + {"pattern": "[0-9]+", "name": "number", "usage": "intent"}, + {"pattern": "\\bhey*", "name": "hello", "usage": "intent"}, + {"pattern": "[0-1]+", "name": "binary", "usage": "intent"}, + ] + ftr = create_featurizer({"case_sensitive": case_sensitive}, known_patterns=patterns) + + # adds tokens to the message + message = Message(data={TEXT: sentence}) + message.set(SPACY_DOCS[TEXT], spacy_nlp(sentence)) + spacy_tokenizer.process([message]) + + sequence_features, sentence_features = ftr._features_for_patterns(message, TEXT) + assert np.allclose( + sequence_features.toarray()[0], expected_sequence_features, atol=1e-10 + ) + assert np.allclose( + sentence_features.toarray()[-1], expected_sentence_features, atol=1e-10 + ) + + +@pytest.mark.parametrize( + "sentence, expected_sequence_features, expected_sentence_features," + "labeled_tokens, use_word_boundaries", + [ + ("how are you", [[1.0], [0.0], [0.0]], [1.0], [0.0], True), + ("how are you", [[1.0], [0.0], [0.0]], [1.0], [0.0], False), + ("Take a shower", [[0.0], [0.0], [0.0]], [0.0], [], True), + ("Take a shower", [[0.0], [0.0], [1.0]], [1.0], [2.0], False), + ("What a show", [[0.0], [0.0], [0.0]], [0.0], [], True), + ("What a show", [[0.0], [0.0], [1.0]], [1.0], [2.0], False), + ("The wolf howled", [[0.0], [0.0], [0.0]], [0.0], [], True), + ("The wolf howled", [[0.0], [0.0], [1.0]], [1.0], [2.0], False), + ], +) +def test_lookup_with_and_without_boundaries( + sentence: Text, + expected_sequence_features: List[List[float]], + expected_sentence_features: List[float], + labeled_tokens: List[float], + use_word_boundaries: bool, + spacy_nlp: Any, + create_featurizer: Callable[..., RegexFeaturizer], + spacy_tokenizer: SpacyTokenizer, +): + ftr = create_featurizer({"use_word_boundaries": use_word_boundaries}) + training_data = TrainingData() + + # we use lookups because the "use_word_boundaries" flag is only used when + # producing patterns from lookup tables + lookups = [{"name": "how", "elements": ["how"]}] + training_data.lookup_tables = lookups + ftr.train(training_data) + + # adds tokens to the message + message = Message(data={TEXT: sentence}) + message.set(SPACY_DOCS[TEXT], spacy_nlp(sentence)) + spacy_tokenizer.process([message]) + + (sequence_features, sentence_features) = ftr._features_for_patterns(message, TEXT) + + sequence_features = sequence_features.toarray() + sentence_features = sentence_features.toarray() + num_of_patterns = sum([len(lookup["elements"]) for lookup in lookups]) + assert sequence_features.shape == ( + len(message.get(TOKENS_NAMES[TEXT])), + num_of_patterns, + ) + num_of_lookup_tables = len(lookups) + assert sentence_features.shape == (num_of_lookup_tables, num_of_patterns) + + # sequence_features should be {0,1} for each token: 1 if match, 0 if not + assert np.allclose(sequence_features, expected_sequence_features, atol=1e-10) + # sentence_features should be {0,1} for each lookup table: 1 if sentence + # contains match from that table, 0 if not + assert np.allclose(sentence_features, expected_sentence_features, atol=1e-10) + + # the tokenizer should have added tokens + assert len(message.get(TOKENS_NAMES[TEXT], [])) > 0 + + # the number of regex matches on each token should match + for i, token in enumerate(message.get(TOKENS_NAMES[TEXT])): + token_matches = token.get("pattern").values() + num_matches = sum(token_matches) + # labeled_tokens should list the token(s) which match a pattern + assert num_matches == labeled_tokens.count(i) + + +def test_persist_load_for_finetuning( + create_featurizer: Callable[..., RegexFeaturizer], + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + resource: Resource, + whitespace_tokenizer: WhitespaceTokenizer, +): + patterns = [ + {"pattern": "[0-9]+", "name": "number", "usage": "intent"}, + {"pattern": "\\bhey*", "name": "hello", "usage": "intent"}, + {"pattern": "[0-1]+", "name": "binary", "usage": "intent"}, + ] + + featurizer = create_featurizer() + + sentence = "hey how are you today 19.12.2019 ?" + message = Message(data={TEXT: sentence}) + message.set(RESPONSE, sentence) + message.set(INTENT, "intent") + training_data = TrainingData([message], regex_features=patterns) + whitespace_tokenizer.process_training_data(training_data) + + featurizer.train(training_data) + + loaded_featurizer = RegexFeaturizer.load( + RegexFeaturizer.get_default_config(), + default_model_storage, + resource, + dataclasses.replace(default_execution_context, is_finetuning=True), + ) + + # Test component loaded in finetune mode and also with + # same patterns as before and vocabulary statistics + assert loaded_featurizer.known_patterns == featurizer.known_patterns + assert loaded_featurizer.finetune_mode + + new_lookups = [{"name": "plates", "elements": "data/test/lookup_tables/plates.txt"}] + + training_data = TrainingData() + training_data.lookup_tables = new_lookups + loaded_featurizer.train(training_data) + + # Test merging of a new pattern to an already trained component. + assert len(loaded_featurizer.known_patterns) == 4 + + +def test_vocabulary_expand_for_finetuning( + create_featurizer: Callable[..., RegexFeaturizer], + default_model_storage: ModelStorage, + resource: Resource, + default_execution_context: ExecutionContext, + whitespace_tokenizer: WhitespaceTokenizer, +): + patterns = [ + {"pattern": "[0-9]+", "name": "number", "usage": "intent"}, + {"pattern": "\\bhey*", "name": "hello", "usage": "intent"}, + ] + + featurizer = create_featurizer() + + sentence = "hey hey 2020" + message = Message(data={TEXT: sentence}) + message.set(RESPONSE, sentence) + message.set(INTENT, "intent") + training_data = TrainingData([message], regex_features=patterns) + + whitespace_tokenizer.process_training_data(training_data) + + featurizer.train(training_data) + featurizer.process_training_data(training_data) + + # Test featurization of message + expected = np.array([1, 0]) + expected_cls = np.array([1, 1]) + seq_vecs, sen_vec = message.get_sparse_features(TEXT, []) + if seq_vecs: + seq_vecs = seq_vecs.features + if sen_vec: + sen_vec = sen_vec.features + + assert (3, 2) == seq_vecs.shape + assert (1, 2) == sen_vec.shape + assert np.all(seq_vecs.toarray()[0] == expected) + assert np.all(sen_vec.toarray()[-1] == expected_cls) + + loaded_featurizer = RegexFeaturizer.load( + RegexFeaturizer.get_default_config(), + default_model_storage, + resource, + dataclasses.replace(default_execution_context, is_finetuning=True), + ) + + new_patterns = [ + {"pattern": "\\btoday*", "name": "day", "usage": "intent"}, + {"pattern": "\\bhey+", "name": "hello", "usage": "intent"}, + ] + new_sentence = "hey today" + message = Message(data={TEXT: new_sentence}) + message.set(RESPONSE, new_sentence) + message.set(INTENT, "intent") + new_training_data = TrainingData([message], regex_features=patterns + new_patterns) + + whitespace_tokenizer.process_training_data(new_training_data) + + loaded_featurizer.train(new_training_data) + loaded_featurizer.process_training_data(new_training_data) + + # Test featurization of message, this time for the extra pattern as well. + expected_token_1 = np.array([1, 0, 0]) + expected_token_2 = np.array([0, 0, 1]) + expected_cls = np.array([1, 0, 1]) + + seq_vecs, sen_vec = message.get_sparse_features(TEXT, []) + if seq_vecs: + seq_vecs = seq_vecs.features + if sen_vec: + sen_vec = sen_vec.features + + assert (2, 3) == seq_vecs.shape + assert (1, 3) == sen_vec.shape + assert np.all(seq_vecs.toarray()[0] == expected_token_1) + assert np.all(seq_vecs.toarray()[1] == expected_token_2) + assert np.all(sen_vec.toarray()[-1] == expected_cls) + + # let's check if the order of patterns is preserved + for old_index, pattern in enumerate(featurizer.known_patterns): + assert pattern["name"] == loaded_featurizer.known_patterns[old_index]["name"] + + # we also modified a pattern, check if that is correctly modified + pattern_to_check = [ + pattern + for pattern in loaded_featurizer.known_patterns + if pattern["name"] == "hello" + ] + assert pattern_to_check == [new_patterns[1]] diff --git a/tests/nlu/featurizers/test_spacy_featurizer.py b/tests/nlu/featurizers/test_spacy_featurizer.py new file mode 100644 index 0000000..b56a461 --- /dev/null +++ b/tests/nlu/featurizers/test_spacy_featurizer.py @@ -0,0 +1,225 @@ +from typing import Any, Dict, Text + +import numpy as np +import pytest + +from rasa.nlu.utils.spacy_utils import SpacyModel, SpacyNLP +from rasa.shared.nlu.training_data import loading +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.featurizers.dense_featurizer.spacy_featurizer import SpacyFeaturizer +from rasa.nlu.constants import SPACY_DOCS +from rasa.shared.nlu.constants import TEXT, INTENT, RESPONSE + + +def create_spacy_featurizer(config: Dict[Text, Any]) -> SpacyFeaturizer: + return SpacyFeaturizer( + {**SpacyFeaturizer.get_default_config(), **config}, "spacy_featurizer" + ) + + +def test_spacy_featurizer_cls_vector(spacy_nlp): + featurizer = create_spacy_featurizer({}) + + sentence = "Hey how are you today" + message = Message(data={TEXT: sentence}) + message.set(SPACY_DOCS[TEXT], spacy_nlp(sentence)) + + featurizer._set_spacy_features(message) + + seq_vecs, sen_vecs = message.get_dense_features(TEXT, []) + if seq_vecs: + seq_vecs = seq_vecs.features + if sen_vecs: + sen_vecs = sen_vecs.features + + assert 5 == len(seq_vecs) + assert 1 == len(sen_vecs) + + +@pytest.mark.parametrize("sentence", ["hey how are you today"]) +def test_spacy_featurizer(sentence, spacy_nlp): + + ftr = create_spacy_featurizer({}) + + doc = spacy_nlp(sentence) + vecs = ftr._features_for_doc(doc) + expected = [t.vector for t in doc] + + assert np.allclose(vecs, expected, atol=1e-5) + + +def test_spacy_training_sample_alignment( + spacy_nlp_component: SpacyNLP, spacy_model: SpacyModel +): + from spacy.tokens import Doc + + m1 = Message.build(text="I have a feeling", intent="feeling") + m2 = Message.build(text="", intent="feeling") + m3 = Message.build(text="I am the last message", intent="feeling") + td = TrainingData(training_examples=[m1, m2, m3]) + + attribute_docs = spacy_nlp_component._docs_for_training_data(spacy_model.model, td) + + assert isinstance(attribute_docs["text"][0], Doc) + assert isinstance(attribute_docs["text"][1], Doc) + assert isinstance(attribute_docs["text"][2], Doc) + + assert [t.text for t in attribute_docs["text"][0]] == ["i", "have", "a", "feeling"] + assert [t.text for t in attribute_docs["text"][1]] == [] + assert [t.text for t in attribute_docs["text"][2]] == [ + "i", + "am", + "the", + "last", + "message", + ] + + +def test_spacy_intent_featurizer( + spacy_nlp_component: SpacyNLP, spacy_model: SpacyModel +): + td = loading.load_data("data/examples/rasa/demo-rasa.json") + spacy_nlp_component.process_training_data(td, spacy_model) + spacy_featurizer = create_spacy_featurizer({}) + spacy_featurizer.process_training_data(td) + + intent_features_exist = np.array( + [ + True if example.get("intent_features") is not None else False + for example in td.intent_examples + ] + ) + + # no intent features should have been set + assert not any(intent_features_exist) + + +def test_spacy_featurizer_sequence(spacy_nlp): + sentence = "hey how are you today" + doc = spacy_nlp(sentence) + token_vectors = [t.vector for t in doc] + + ftr = create_spacy_featurizer({}) + + greet = {TEXT: sentence, "intent": "greet", "text_features": [0.5]} + + message = Message(data=greet) + message.set(SPACY_DOCS[TEXT], doc) + + ftr._set_spacy_features(message) + + seq_vecs, sen_vecs = message.get_dense_features(TEXT, []) + if seq_vecs: + seq_vecs = seq_vecs.features + if sen_vecs: + sen_vecs = sen_vecs.features + + vecs = seq_vecs[0][:5] + + assert np.allclose(token_vectors[0][:5], vecs, atol=1e-4) + assert sen_vecs is not None + + +def test_spacy_featurizer_default_case_insensitive(spacy_nlp_component): + ftr = create_spacy_featurizer({}) + spacy_nlp = spacy_nlp_component.provide().model + td = loading.load_data("data/examples/rasa/demo-rasa.json") + for e in td.intent_examples: + doc = spacy_nlp_component._doc_for_text(spacy_nlp, e.get(TEXT)) + doc_capitalized = spacy_nlp_component._doc_for_text( + spacy_nlp, e.get(TEXT).capitalize() + ) + + vecs = ftr._features_for_doc(doc) + vecs_capitalized = ftr._features_for_doc(doc_capitalized) + + assert np.allclose( + vecs, vecs_capitalized, atol=1e-5 + ), "Vectors are unequal for texts '{}' and '{}'".format( + e.get(TEXT), e.get(TEXT).capitalize() + ) + + +def test_spacy_featurizer_can_be_case_sensitive(spacy_case_sensitive_nlp_component): + ftr = create_spacy_featurizer({}) + spacy_nlp = spacy_case_sensitive_nlp_component.provide().model + td = loading.load_data("data/examples/rasa/demo-rasa.json") + example_is_case_insentive = [] + for e in td.intent_examples: + doc = spacy_case_sensitive_nlp_component._doc_for_text(spacy_nlp, e.get(TEXT)) + doc_capitalized = spacy_case_sensitive_nlp_component._doc_for_text( + spacy_nlp, e.get(TEXT).capitalize() + ) + + vecs = ftr._features_for_doc(doc) + vecs_capitalized = ftr._features_for_doc(doc_capitalized) + + example_is_case_insentive.append(np.allclose(vecs, vecs_capitalized, atol=1e-5)) + assert not all(example_is_case_insentive) + + +def test_spacy_featurizer_train(spacy_nlp): + + featurizer = create_spacy_featurizer({}) + + sentence = "Hey how are you today" + message = Message(data={TEXT: sentence}) + message.set(RESPONSE, sentence) + message.set(INTENT, "intent") + message.set(SPACY_DOCS[TEXT], spacy_nlp(sentence)) + message.set(SPACY_DOCS[RESPONSE], spacy_nlp(sentence)) + + featurizer.process_training_data(TrainingData([message])) + + seq_vecs, sen_vecs = message.get_dense_features(TEXT, []) + if seq_vecs: + seq_vecs = seq_vecs.features + if sen_vecs: + sen_vecs = sen_vecs.features + + assert 5 == len(seq_vecs) + assert 1 == len(sen_vecs) + + seq_vecs, sen_vecs = message.get_dense_features(RESPONSE, []) + if seq_vecs: + seq_vecs = seq_vecs.features + if sen_vecs: + sen_vecs = sen_vecs.features + + assert 5 == len(seq_vecs) + assert 1 == len(sen_vecs) + + seq_vecs, sen_vecs = message.get_dense_features(INTENT, []) + if seq_vecs: + seq_vecs = seq_vecs.features + if sen_vecs: + sen_vecs = sen_vecs.features + + assert seq_vecs is None + assert sen_vecs is None + + +def test_spacy_featurizer_using_empty_model(): + import spacy + + sentence = "This test is using an empty spaCy model" + + model = spacy.blank("en") + doc = model(sentence) + + ftr = create_spacy_featurizer({}) + + message = Message(data={TEXT: sentence}) + message.set(SPACY_DOCS[TEXT], doc) + + ftr._set_spacy_features(message) + + seq_vecs, sen_vecs = message.get_dense_features(TEXT, []) + if seq_vecs: + seq_vecs = seq_vecs.features + if sen_vecs: + sen_vecs = sen_vecs.features + + assert seq_vecs is None + assert sen_vecs is None diff --git a/tests/nlu/selectors/__init__.py b/tests/nlu/selectors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/nlu/selectors/test_selectors.py b/tests/nlu/selectors/test_selectors.py new file mode 100644 index 0000000..2cd5ed4 --- /dev/null +++ b/tests/nlu/selectors/test_selectors.py @@ -0,0 +1,883 @@ +import copy + +import pytest +import numpy as np +from typing import List, Dict, Text, Any, Optional, Tuple, Union, Callable + +import rasa.model +from rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer import ( + CountVectorsFeaturizer, +) +from rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer import ( + LexicalSyntacticFeaturizer, +) +from rasa.nlu.featurizers.sparse_featurizer.regex_featurizer import RegexFeaturizer +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.importers.rasa import RasaFileImporter +from rasa.shared.nlu.training_data import util +import rasa.shared.nlu.training_data.loading +from rasa.utils.tensorflow.constants import ( + EPOCHS, + MASKED_LM, + NUM_TRANSFORMER_LAYERS, + RENORMALIZE_CONFIDENCES, + TRANSFORMER_SIZE, + CONSTRAIN_SIMILARITIES, + CHECKPOINT_MODEL, + MODEL_CONFIDENCE, + RANDOM_SEED, + RANKING_LENGTH, + LOSS_TYPE, + HIDDEN_LAYERS_SIZES, + LABEL, + EVAL_NUM_EXAMPLES, + EVAL_NUM_EPOCHS, + RUN_EAGERLY, +) +from rasa.shared.nlu.constants import ( + TEXT, + FEATURE_TYPE_SENTENCE, + FEATURE_TYPE_SEQUENCE, + INTENT_RESPONSE_KEY, + PREDICTED_CONFIDENCE_KEY, +) +from rasa.utils.tensorflow.model_data_utils import FeatureArray +from rasa.shared.nlu.training_data.loading import load_data +from rasa.shared.constants import DIAGNOSTIC_DATA +from rasa.nlu.selectors.response_selector import ResponseSelector +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.nlu.constants import ( + DEFAULT_TRANSFORMER_SIZE, + RESPONSE_SELECTOR_PROPERTY_NAME, + RESPONSE_SELECTOR_DEFAULT_INTENT, + RESPONSE_SELECTOR_PREDICTION_KEY, + RESPONSE_SELECTOR_RESPONSES_KEY, +) + + +@pytest.fixture() +def response_selector_training_data() -> TrainingData: + # use data that include some responses + training_data = rasa.shared.nlu.training_data.loading.load_data( + "data/examples/rasa/demo-rasa.yml" + ) + training_data_responses = rasa.shared.nlu.training_data.loading.load_data( + "data/examples/rasa/demo-rasa-responses.yml" + ) + training_data = training_data.merge(training_data_responses) + + return training_data + + +@pytest.fixture() +def default_response_selector_resource() -> Resource: + return Resource("response_selector") + + +@pytest.fixture +def create_response_selector( + default_model_storage: ModelStorage, + default_response_selector_resource: Resource, + default_execution_context: ExecutionContext, +) -> Callable[[Dict[Text, Any]], ResponseSelector]: + def inner(config_params: Dict[Text, Any]) -> ResponseSelector: + return ResponseSelector.create( + {**ResponseSelector.get_default_config(), **config_params}, + default_model_storage, + default_response_selector_resource, + default_execution_context, + ) + + return inner + + +@pytest.fixture() +def load_response_selector( + default_model_storage: ModelStorage, + default_response_selector_resource: Resource, + default_execution_context: ExecutionContext, +) -> Callable[[Dict[Text, Any]], ResponseSelector]: + def inner(config_params: Dict[Text, Any]) -> ResponseSelector: + return ResponseSelector.load( + {**ResponseSelector.get_default_config(), **config_params}, + default_model_storage, + default_response_selector_resource, + default_execution_context, + ) + + return inner + + +@pytest.fixture() +def train_persist_load_with_different_settings( + create_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], + load_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], + default_execution_context: ExecutionContext, + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], + process_message: Callable[..., Message], +): + def inner( + pipeline: List[Dict[Text, Any]], + config_params: Dict[Text, Any], + should_finetune: bool, + ): + + training_data, loaded_pipeline = train_and_preprocess( + pipeline, "data/examples/rasa/demo-rasa.yml" + ) + + response_selector = create_response_selector(config_params) + response_selector.train(training_data=training_data) + + if should_finetune: + default_execution_context.is_finetuning = True + + message = Message(data={TEXT: "hello"}) + message = process_message(loaded_pipeline, message) + + message2 = copy.deepcopy(message) + + classified_message = response_selector.process([message])[0] + + loaded_selector = load_response_selector(config_params) + + classified_message2 = loaded_selector.process([message2])[0] + + assert classified_message2.fingerprint() == classified_message.fingerprint() + + return loaded_selector + + return inner + + +@pytest.mark.parametrize( + "config_params", + [ + {EPOCHS: 1, RUN_EAGERLY: True}, + { + EPOCHS: 1, + MASKED_LM: True, + TRANSFORMER_SIZE: 256, + NUM_TRANSFORMER_LAYERS: 1, + RUN_EAGERLY: True, + }, + ], +) +def test_train_selector( + response_selector_training_data: TrainingData, + config_params: Dict[Text, Any], + create_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], + default_model_storage: ModelStorage, + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], + process_message: Callable[..., Message], +): + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": CountVectorsFeaturizer}, + ] + response_selector_training_data, loaded_pipeline = train_and_preprocess( + pipeline, response_selector_training_data + ) + + response_selector = create_response_selector(config_params) + response_selector.train(training_data=response_selector_training_data) + + message = Message(data={TEXT: "hello"}) + message = process_message(loaded_pipeline, message) + + classified_message = response_selector.process([message])[0] + + assert classified_message is not None + assert ( + classified_message.get("response_selector").get("all_retrieval_intents") + ) == ["chitchat"] + assert ( + classified_message.get("response_selector") + .get("default") + .get("response") + .get("intent_response_key") + ) is not None + assert ( + classified_message.get("response_selector") + .get("default") + .get("response") + .get("utter_action") + ) is not None + assert ( + classified_message.get("response_selector") + .get("default") + .get("response") + .get("responses") + ) is not None + + ranking = classified_message.get("response_selector").get("default").get("ranking") + assert ranking is not None + + for rank in ranking: + assert rank.get("confidence") is not None + assert rank.get("intent_response_key") is not None + + +def test_preprocess_selector_multiple_retrieval_intents( + response_selector_training_data: TrainingData, + create_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], +): + + training_data_extra_intent = TrainingData( + [ + Message.build( + text="Is it possible to detect the version?", intent="faq/q1" + ), + Message.build(text="How can I get a new virtual env", intent="faq/q2"), + ] + ) + training_data = response_selector_training_data.merge(training_data_extra_intent) + + response_selector = create_response_selector({}) + + response_selector.preprocess_train_data(training_data) + + assert sorted(response_selector.all_retrieval_intents) == ["chitchat", "faq"] + + +@pytest.mark.parametrize( + "use_text_as_label, label_values", + [ + [False, ["chitchat/ask_name", "chitchat/ask_weather"]], + [True, ["I am Mr. Bot", "It's sunny where I live"]], + ], +) +def test_ground_truth_for_training( + use_text_as_label, + label_values, + response_selector_training_data: TrainingData, + create_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], +): + response_selector = create_response_selector( + {"use_text_as_label": use_text_as_label} + ) + response_selector.preprocess_train_data(response_selector_training_data) + + assert response_selector.responses == response_selector_training_data.responses + assert ( + sorted(list(response_selector.index_label_id_mapping.values())) == label_values + ) + + +@pytest.mark.parametrize( + "predicted_label, train_on_text, resolved_intent_response_key", + [ + ["chitchat/ask_name", False, "chitchat/ask_name"], + ["It's sunny where I live", True, "chitchat/ask_weather"], + ], +) +def test_resolve_intent_response_key_from_label( + predicted_label: Text, + train_on_text: bool, + resolved_intent_response_key: Text, + response_selector_training_data: TrainingData, + create_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], +): + + response_selector = create_response_selector({"use_text_as_label": train_on_text}) + response_selector.preprocess_train_data(response_selector_training_data) + + label_intent_response_key = response_selector._resolve_intent_response_key( + {"id": hash(predicted_label), "name": predicted_label} + ) + assert resolved_intent_response_key == label_intent_response_key + assert ( + response_selector.responses[ + util.intent_response_key_to_template_key(label_intent_response_key) + ] + == response_selector_training_data.responses[ + util.intent_response_key_to_template_key(resolved_intent_response_key) + ] + ) + + +def test_train_model_checkpointing( + create_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], + default_model_storage: ModelStorage, + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], +): + pipeline = [ + {"component": WhitespaceTokenizer}, + { + "component": CountVectorsFeaturizer, + "analyzer": "char_wb", + "min_ngram": 3, + "max_ngram": 17, + "max_features": 10, + "min_df": 5, + }, + ] + + training_data, loaded_pipeline = train_and_preprocess( + pipeline, "data/test_selectors" + ) + + config_params = { + EPOCHS: 2, + MODEL_CONFIDENCE: "softmax", + CONSTRAIN_SIMILARITIES: True, + CHECKPOINT_MODEL: True, + EVAL_NUM_EPOCHS: 1, + EVAL_NUM_EXAMPLES: 10, + RUN_EAGERLY: True, + } + + response_selector = create_response_selector(config_params) + assert response_selector.component_config[CHECKPOINT_MODEL] + + resource = response_selector.train(training_data=training_data) + + with default_model_storage.read_from(resource) as model_dir: + all_files = list(model_dir.rglob("*.*")) + assert any(["from_checkpoint" in str(filename) for filename in all_files]) + + +@pytest.mark.skip_on_windows +def test_train_persist_load( + create_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], + load_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], + default_execution_context: ExecutionContext, + train_persist_load_with_different_settings, +): + + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": CountVectorsFeaturizer}, + ] + config_params = {EPOCHS: 1, RUN_EAGERLY: True} + + train_persist_load_with_different_settings(pipeline, config_params, False) + + train_persist_load_with_different_settings(pipeline, config_params, True) + + +async def test_process_gives_diagnostic_data( + default_execution_context: ExecutionContext, + create_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], + process_message: Callable[..., Message], +): + """Tests if processing a message returns attention weights as numpy array.""" + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": CountVectorsFeaturizer}, + ] + config_params = {EPOCHS: 1, RUN_EAGERLY: True} + + importer = RasaFileImporter( + config_file="data/test_response_selector_bot/config.yml", + domain_path="data/test_response_selector_bot/domain.yml", + training_data_paths=[ + "data/test_response_selector_bot/data/rules.yml", + "data/test_response_selector_bot/data/stories.yml", + "data/test_response_selector_bot/data/nlu.yml", + ], + ) + training_data = importer.get_nlu_data() + + training_data, loaded_pipeline = train_and_preprocess(pipeline, training_data) + + default_execution_context.should_add_diagnostic_data = True + + response_selector = create_response_selector(config_params) + response_selector.train(training_data=training_data) + + message = Message(data={TEXT: "hello"}) + message = process_message(loaded_pipeline, message) + + classified_message = response_selector.process([message])[0] + diagnostic_data = classified_message.get(DIAGNOSTIC_DATA) + + assert isinstance(diagnostic_data, dict) + for _, values in diagnostic_data.items(): + assert "text_transformed" in values + assert isinstance(values.get("text_transformed"), np.ndarray) + # The `attention_weights` key should exist, regardless of there + # being a transformer + assert "attention_weights" in values + # By default, ResponseSelector has `number_of_transformer_layers = 0` + # in which case the attention weights should be None. + assert values.get("attention_weights") is None + + +@pytest.mark.parametrize( + "classifier_params", + [({LOSS_TYPE: "margin", RANDOM_SEED: 42, EPOCHS: 1, RUN_EAGERLY: True})], +) +async def test_margin_loss_is_not_normalized( + classifier_params: Dict[Text, int], + create_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], + process_message: Callable[..., Message], +): + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": CountVectorsFeaturizer}, + ] + training_data, loaded_pipeline = train_and_preprocess( + pipeline, "data/test_selectors" + ) + + response_selector = create_response_selector(classifier_params) + response_selector.train(training_data=training_data) + + message = Message(data={TEXT: "hello"}) + message = process_message(loaded_pipeline, message) + + classified_message = response_selector.process([message])[0] + + response_ranking = ( + classified_message.get("response_selector").get("default").get("ranking") + ) + + # check that output was not normalized + assert [item["confidence"] for item in response_ranking] != pytest.approx(1) + + # check that the output was correctly truncated + assert len(response_ranking) == 9 + + +@pytest.mark.parametrize( + "classifier_params, output_length, sums_up_to_1", + [ + ({}, 9, True), + ({EPOCHS: 1, RUN_EAGERLY: True}, 9, True), + ({RANKING_LENGTH: 2}, 2, False), + ({RANKING_LENGTH: 2, RENORMALIZE_CONFIDENCES: True}, 2, True), + ], +) +async def test_softmax_ranking( + classifier_params: Dict[Text, int], + output_length: int, + sums_up_to_1: bool, + create_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], + process_message: Callable[..., Message], +): + classifier_params[RANDOM_SEED] = 42 + classifier_params[EPOCHS] = 1 + classifier_params[RUN_EAGERLY] = True + + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": CountVectorsFeaturizer}, + ] + training_data, loaded_pipeline = train_and_preprocess( + pipeline, "data/test_selectors" + ) + + response_selector = create_response_selector(classifier_params) + response_selector.train(training_data=training_data) + + message = Message(data={TEXT: "hello"}) + message = process_message(loaded_pipeline, message) + + classified_message = response_selector.process([message])[0] + + response_ranking = ( + classified_message.get("response_selector").get("default").get("ranking") + ) + # check that the output was correctly truncated after normalization + assert len(response_ranking) == output_length + output_sums_to_1 = sum( + [intent.get("confidence") for intent in response_ranking] + ) == pytest.approx(1) + assert output_sums_to_1 == sums_up_to_1 + + +@pytest.mark.parametrize( + "config, should_raise_warning", + [ + # hidden layers left at defaults + ({}, False), + ({NUM_TRANSFORMER_LAYERS: 5}, True), + ({NUM_TRANSFORMER_LAYERS: 0}, False), + ({NUM_TRANSFORMER_LAYERS: -1}, False), + # hidden layers explicitly enabled + ({HIDDEN_LAYERS_SIZES: {TEXT: [10], LABEL: [11]}}, False), + ( + {NUM_TRANSFORMER_LAYERS: 5, HIDDEN_LAYERS_SIZES: {TEXT: [10], LABEL: [11]}}, + True, + ), + ( + {NUM_TRANSFORMER_LAYERS: 0, HIDDEN_LAYERS_SIZES: {TEXT: [10], LABEL: [11]}}, + False, + ), + ( + { + NUM_TRANSFORMER_LAYERS: -1, + HIDDEN_LAYERS_SIZES: {TEXT: [10], LABEL: [11]}, + }, + False, + ), + # hidden layers explicitly disabled + ({HIDDEN_LAYERS_SIZES: {TEXT: [], LABEL: []}}, False), + ( + {NUM_TRANSFORMER_LAYERS: 5, HIDDEN_LAYERS_SIZES: {TEXT: [], LABEL: []}}, + False, + ), + ( + {NUM_TRANSFORMER_LAYERS: 0, HIDDEN_LAYERS_SIZES: {TEXT: [], LABEL: []}}, + False, + ), + ( + {NUM_TRANSFORMER_LAYERS: -1, HIDDEN_LAYERS_SIZES: {TEXT: [], LABEL: []}}, + False, + ), + ], +) +def test_warning_when_transformer_and_hidden_layers_enabled( + config: Dict[Text, Union[int, Dict[Text, List[int]]]], + should_raise_warning: bool, + create_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], +): + """ResponseSelector recommends disabling hidden layers if transformer is enabled.""" + with pytest.warns(UserWarning) as records: + _ = create_response_selector(config) + warning_str = "We recommend to disable the hidden layers when using a transformer" + + if should_raise_warning: + assert len(records) > 0 + # Check all warnings since there may be multiple other warnings we don't care + # about in this test case. + assert any(warning_str in record.message.args[0] for record in records) + else: + # Check all warnings since there may be multiple other warnings we don't care + # about in this test case. + assert not any(warning_str in record.message.args[0] for record in records) + + +@pytest.mark.parametrize( + "config, should_set_default_transformer_size", + [ + # transformer enabled + ({NUM_TRANSFORMER_LAYERS: 5}, True), + ({TRANSFORMER_SIZE: 0, NUM_TRANSFORMER_LAYERS: 5}, True), + ({TRANSFORMER_SIZE: -1, NUM_TRANSFORMER_LAYERS: 5}, True), + ({TRANSFORMER_SIZE: None, NUM_TRANSFORMER_LAYERS: 5}, True), + ({TRANSFORMER_SIZE: 10, NUM_TRANSFORMER_LAYERS: 5}, False), + # transformer disabled (by default) + ({}, False), + ({TRANSFORMER_SIZE: 0}, False), + ({TRANSFORMER_SIZE: -1}, False), + ({TRANSFORMER_SIZE: None}, False), + ({TRANSFORMER_SIZE: 10}, False), + # transformer disabled explicitly + ({NUM_TRANSFORMER_LAYERS: 0}, False), + ({TRANSFORMER_SIZE: 0, NUM_TRANSFORMER_LAYERS: 0}, False), + ({TRANSFORMER_SIZE: -1, NUM_TRANSFORMER_LAYERS: 0}, False), + ({TRANSFORMER_SIZE: None, NUM_TRANSFORMER_LAYERS: 0}, False), + ({TRANSFORMER_SIZE: 10, NUM_TRANSFORMER_LAYERS: 0}, False), + ], +) +def test_sets_integer_transformer_size_when_needed( + config: Dict[Text, Optional[int]], + should_set_default_transformer_size: bool, + create_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], +): + """ResponseSelector ensures sensible transformer size when transformer enabled.""" + with pytest.warns(UserWarning) as records: + selector = create_response_selector(config) + + warning_str = f"positive size is required when using `{NUM_TRANSFORMER_LAYERS} > 0`" + + if should_set_default_transformer_size: + assert len(records) > 0 + # check that the specific warning was raised + assert any(warning_str in record.message.args[0] for record in records) + # check that transformer size got set to the new default + assert selector.component_config[TRANSFORMER_SIZE] == DEFAULT_TRANSFORMER_SIZE + else: + # check that the specific warning was not raised + assert not any(warning_str in record.message.args[0] for record in records) + # check that transformer size was not changed + assert selector.component_config[TRANSFORMER_SIZE] == config.get( + TRANSFORMER_SIZE, None # None is the default transformer size + ) + + +def test_transformer_size_gets_corrected(train_persist_load_with_different_settings): + """Tests that the default value of `transformer_size` which is `None` is + corrected if transformer layers are enabled in `ResponseSelector`. + """ + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": CountVectorsFeaturizer}, + ] + config_params = {EPOCHS: 1, NUM_TRANSFORMER_LAYERS: 1, RUN_EAGERLY: True} + + selector = train_persist_load_with_different_settings( + pipeline, config_params, False + ) + assert selector.component_config[TRANSFORMER_SIZE] == DEFAULT_TRANSFORMER_SIZE + + +async def test_process_unfeaturized_input( + create_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], + process_message: Callable[..., Message], +): + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": CountVectorsFeaturizer}, + ] + training_data, loaded_pipeline = train_and_preprocess( + pipeline, "data/test_selectors" + ) + response_selector = create_response_selector({EPOCHS: 1, RUN_EAGERLY: True}) + response_selector.train(training_data=training_data) + + message_text = "message text" + unfeaturized_message = Message(data={TEXT: message_text}) + classified_message = response_selector.process([unfeaturized_message])[0] + output = ( + classified_message.get(RESPONSE_SELECTOR_PROPERTY_NAME) + .get(RESPONSE_SELECTOR_DEFAULT_INTENT) + .get(RESPONSE_SELECTOR_PREDICTION_KEY) + ) + + assert classified_message.get(TEXT) == message_text + assert not output.get(RESPONSE_SELECTOR_RESPONSES_KEY) + assert output.get(PREDICTED_CONFIDENCE_KEY) == 0.0 + assert not output.get(INTENT_RESPONSE_KEY) + + +@pytest.mark.timeout(120) +async def test_adjusting_layers_incremental_training( + create_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], + load_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], + process_message: Callable[..., Message], +): + """Tests adjusting sparse layers of `ResponseSelector` to increased sparse + feature sizes during incremental training. + + Testing is done by checking the layer sizes. + Checking if they were replaced correctly is also important + and is done in `test_replace_dense_for_sparse_layers` + in `test_rasa_layers.py`. + """ + iter1_data_path = "data/test_incremental_training/iter1/" + iter2_data_path = "data/test_incremental_training/" + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": LexicalSyntacticFeaturizer}, + {"component": RegexFeaturizer}, + {"component": CountVectorsFeaturizer}, + { + "component": CountVectorsFeaturizer, + "analyzer": "char_wb", + "min_ngram": 1, + "max_ngram": 4, + }, + ] + training_data, loaded_pipeline = train_and_preprocess(pipeline, iter1_data_path) + response_selector = create_response_selector({EPOCHS: 1, RUN_EAGERLY: True}) + response_selector.train(training_data=training_data) + + old_data_signature = response_selector.model.data_signature + old_predict_data_signature = response_selector.model.predict_data_signature + + message = Message(data={TEXT: "Rasa is great!"}) + message = process_message(loaded_pipeline, message) + + message2 = copy.deepcopy(message) + + classified_message = response_selector.process([message])[0] + + old_sparse_feature_sizes = classified_message.get_sparse_feature_sizes( + attribute=TEXT + ) + + initial_rs_layers = response_selector.model._tf_layers[ + "sequence_layer.text" + ]._tf_layers["feature_combining"] + initial_rs_sequence_layer = initial_rs_layers._tf_layers[ + "sparse_dense.sequence" + ]._tf_layers["sparse_to_dense"] + initial_rs_sentence_layer = initial_rs_layers._tf_layers[ + "sparse_dense.sentence" + ]._tf_layers["sparse_to_dense"] + + initial_rs_sequence_size = initial_rs_sequence_layer.get_kernel().shape[0] + initial_rs_sentence_size = initial_rs_sentence_layer.get_kernel().shape[0] + assert initial_rs_sequence_size == sum( + old_sparse_feature_sizes[FEATURE_TYPE_SEQUENCE] + ) + assert initial_rs_sentence_size == sum( + old_sparse_feature_sizes[FEATURE_TYPE_SENTENCE] + ) + + loaded_selector = load_response_selector({EPOCHS: 1, RUN_EAGERLY: True}) + + classified_message2 = loaded_selector.process([message2])[0] + + assert classified_message2.fingerprint() == classified_message.fingerprint() + + training_data2, loaded_pipeline2 = train_and_preprocess(pipeline, iter2_data_path) + + response_selector.train(training_data=training_data2) + + new_message = Message.build(text="Rasa is great!") + new_message = process_message(loaded_pipeline2, new_message) + + classified_new_message = response_selector.process([new_message])[0] + new_sparse_feature_sizes = classified_new_message.get_sparse_feature_sizes( + attribute=TEXT + ) + + final_rs_layers = response_selector.model._tf_layers[ + "sequence_layer.text" + ]._tf_layers["feature_combining"] + final_rs_sequence_layer = final_rs_layers._tf_layers[ + "sparse_dense.sequence" + ]._tf_layers["sparse_to_dense"] + final_rs_sentence_layer = final_rs_layers._tf_layers[ + "sparse_dense.sentence" + ]._tf_layers["sparse_to_dense"] + + final_rs_sequence_size = final_rs_sequence_layer.get_kernel().shape[0] + final_rs_sentence_size = final_rs_sentence_layer.get_kernel().shape[0] + assert final_rs_sequence_size == sum( + new_sparse_feature_sizes[FEATURE_TYPE_SEQUENCE] + ) + assert final_rs_sentence_size == sum( + new_sparse_feature_sizes[FEATURE_TYPE_SENTENCE] + ) + # check if the data signatures were correctly updated + new_data_signature = response_selector.model.data_signature + new_predict_data_signature = response_selector.model.predict_data_signature + iter2_data = load_data(iter2_data_path) + expected_sequence_lengths = len( + [ + message + for message in iter2_data.training_examples + if message.get(INTENT_RESPONSE_KEY) + ] + ) + + def test_data_signatures( + new_signature: Dict[Text, Dict[Text, List[FeatureArray]]], + old_signature: Dict[Text, Dict[Text, List[FeatureArray]]], + ): + # Wherever attribute / feature_type signature is not + # expected to change, directly compare it to old data signature. + # Else compute its expected signature and compare + attributes_expected_to_change = [TEXT] + feature_types_expected_to_change = [ + FEATURE_TYPE_SEQUENCE, + FEATURE_TYPE_SENTENCE, + ] + + for attribute, signatures in new_signature.items(): + + for feature_type, feature_signatures in signatures.items(): + + if feature_type == "sequence_lengths": + assert feature_signatures[0].units == expected_sequence_lengths + + elif feature_type not in feature_types_expected_to_change: + assert feature_signatures == old_signature.get(attribute).get( + feature_type + ) + else: + for index, feature_signature in enumerate(feature_signatures): + if ( + feature_signature.is_sparse + and attribute in attributes_expected_to_change + ): + assert feature_signature.units == sum( + new_sparse_feature_sizes.get(feature_type) + ) + else: + # dense signature or attributes that are not + # expected to change can be compared directly + assert ( + feature_signature.units + == old_signature.get(attribute) + .get(feature_type)[index] + .units + ) + + test_data_signatures(new_data_signature, old_data_signature) + test_data_signatures(new_predict_data_signature, old_predict_data_signature) + + +@pytest.mark.timeout(120) +@pytest.mark.parametrize( + "iter1_path, iter2_path, should_raise_exception", + [ + ( + "data/test_incremental_training/", + "data/test_incremental_training/iter1", + True, + ), + ( + "data/test_incremental_training/iter1", + "data/test_incremental_training/", + False, + ), + ], +) +async def test_sparse_feature_sizes_decreased_incremental_training( + iter1_path: Text, + iter2_path: Text, + should_raise_exception: bool, + create_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], + load_response_selector: Callable[[Dict[Text, Any]], ResponseSelector], + default_execution_context: ExecutionContext, + train_and_preprocess: Callable[..., Tuple[TrainingData, List[GraphComponent]]], + process_message: Callable[..., Message], +): + pipeline = [ + {"component": WhitespaceTokenizer}, + {"component": LexicalSyntacticFeaturizer}, + {"component": RegexFeaturizer}, + {"component": CountVectorsFeaturizer}, + { + "component": CountVectorsFeaturizer, + "analyzer": "char_wb", + "min_ngram": 1, + "max_ngram": 4, + }, + ] + training_data, loaded_pipeline = train_and_preprocess(pipeline, iter1_path) + + response_selector = create_response_selector({EPOCHS: 1, RUN_EAGERLY: True}) + response_selector.train(training_data=training_data) + + message = Message(data={TEXT: "Rasa is great!"}) + message = process_message(loaded_pipeline, message) + + message2 = copy.deepcopy(message) + + classified_message = response_selector.process([message])[0] + + default_execution_context.is_finetuning = True + + loaded_selector = load_response_selector({EPOCHS: 1, RUN_EAGERLY: True}) + + classified_message2 = loaded_selector.process([message2])[0] + + assert classified_message2.fingerprint() == classified_message.fingerprint() + + if should_raise_exception: + with pytest.raises(Exception) as exec_info: + training_data2, loaded_pipeline2 = train_and_preprocess( + pipeline, iter2_path + ) + loaded_selector.train(training_data=training_data2) + assert "Sparse feature sizes have decreased" in str(exec_info.value) + else: + training_data2, loaded_pipeline2 = train_and_preprocess(pipeline, iter2_path) + loaded_selector.train(training_data=training_data2) + assert loaded_selector.model diff --git a/tests/nlu/test_evaluation.py b/tests/nlu/test_evaluation.py new file mode 100644 index 0000000..380d347 --- /dev/null +++ b/tests/nlu/test_evaluation.py @@ -0,0 +1,1398 @@ +import json +import os +import sys +import textwrap + +from pathlib import Path +from typing import Text, List, Dict, Any, Set, Optional + +from rasa.core.agent import Agent +from rasa.core.channels import UserMessage + +import pytest +from _pytest.monkeypatch import MonkeyPatch +from unittest.mock import Mock, MagicMock + +from rasa.nlu.extractors.crf_entity_extractor import CRFEntityExtractor +from rasa.nlu.extractors.mitie_entity_extractor import MitieEntityExtractor +from rasa.nlu.extractors.spacy_entity_extractor import SpacyEntityExtractor +from rasa.shared.core.trackers import DialogueStateTracker +from tests.conftest import AsyncMock + +import rasa.nlu.test +import rasa.shared.nlu.training_data.loading +import rasa.shared.utils.io +import rasa.utils.io +import rasa.model + +from rasa.nlu.test import ( + is_token_within_entity, + do_entities_overlap, + merge_labels, + remove_empty_intent_examples, + remove_empty_response_examples, + _get_active_entity_extractors, + drop_intents_below_freq, + cross_validate, + run_evaluation, + substitute_labels, + IntentEvaluationResult, + EntityEvaluationResult, + ResponseSelectionEvaluationResult, + evaluate_intents, + evaluate_entities, + evaluate_response_selections, + NO_ENTITY, + collect_successful_entity_predictions, + collect_incorrect_entity_predictions, + merge_confidences, + _get_entity_confidences, + get_eval_data, + does_token_cross_borders, + align_entity_predictions, + determine_intersection, + determine_token_labels, + _remove_entities_of_extractors, +) +from rasa.nlu.tokenizers.tokenizer import Token +from rasa.shared.constants import DEFAULT_NLU_FALLBACK_INTENT_NAME +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.nlu.constants import ( + NO_ENTITY_TAG, + INTENT, + INTENT_RANKING_KEY, + INTENT_NAME_KEY, + PREDICTED_CONFIDENCE_KEY, + ENTITIES, +) +from rasa.shared.nlu.constants import ( + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_VALUE, + EXTRACTOR, +) +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.model_testing import compare_nlu_models +from rasa.utils.tensorflow.constants import EPOCHS, RUN_EAGERLY + +# https://github.com/pytest-dev/pytest-asyncio/issues/68 +# this event_loop is used by pytest-asyncio, and redefining it +# is currently the only way of changing the scope of this fixture +from tests.nlu.utilities import write_file_config + + +# Chinese Example +# "对面食过敏" -> To be allergic to wheat-based food +CH_wrong_segmentation = [ + Token("对面", 0), + Token("食", 2), + Token("过敏", 3), # opposite, food, allergy +] +CH_correct_segmentation = [ + Token("对", 0), + Token("面食", 1), + Token("过敏", 3), # towards, wheat-based food, allergy +] +CH_wrong_entity = {"start": 0, "end": 2, "value": "对面", "entity": "direction"} +CH_correct_entity = {"start": 1, "end": 3, "value": "面食", "entity": "food_type"} + +# EN example +# "Hey Robot, I would like to eat pizza near Alexanderplatz tonight" +EN_indices = [0, 4, 9, 11, 13, 19, 24, 27, 31, 37, 42, 57] +EN_tokens = [ + "Hey", + "Robot", + ",", + "I", + "would", + "like", + "to", + "eat", + "pizza", + "near", + "Alexanderplatz", + "tonight", +] +EN_tokens = [Token(t, i) for t, i in zip(EN_tokens, EN_indices)] + +EN_targets = [ + {"start": 31, "end": 36, "value": "pizza", "entity": "food"}, + {"start": 37, "end": 56, "value": "near Alexanderplatz", "entity": "location"}, + {"start": 57, "end": 64, "value": "tonight", "entity": "datetime"}, +] + +EN_predicted = [ + { + "start": 4, + "end": 9, + "value": "Robot", + "entity": "person", + "extractor": "EntityExtractorA", + }, + { + "start": 31, + "end": 36, + "value": "pizza", + "entity": "food", + "extractor": "EntityExtractorA", + }, + { + "start": 42, + "end": 56, + "value": "Alexanderplatz", + "entity": "location", + "extractor": "EntityExtractorA", + }, + { + "start": 42, + "end": 64, + "value": "Alexanderplatz tonight", + "entity": "movie", + "extractor": "EntityExtractorB", + }, +] + +EN_entity_result = EntityEvaluationResult( + EN_targets, EN_predicted, EN_tokens, " ".join([t.text for t in EN_tokens]) +) + +EN_entity_result_no_tokens = EntityEvaluationResult(EN_targets, EN_predicted, [], "") + +TRAINING_DATA = rasa.shared.nlu.training_data.loading.load_data( + "data/test/demo-rasa-more-ents-and-multiplied.yml" +) +NLU_CONFIG = { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": [ + {"name": "WhitespaceTokenizer"}, + {"name": "CountVectorsFeaturizer"}, + {"name": "LogisticRegressionClassifier"}, + ], +} +N_FOLDS = 2 + + +@pytest.fixture +def mocks_for_test_cross_validate(monkeypatch: MonkeyPatch): + mock_write_yaml = MagicMock() + mock_write_yaml.return_value = "write yaml" + monkeypatch.setattr("rasa.shared.utils.io.write_yaml", mock_write_yaml) + + mock_train_nlu = MagicMock() + mock_train_nlu.return_value = "train nlu" + monkeypatch.setattr("rasa.model_training.train_nlu", mock_train_nlu) + + mock_agent_load = MagicMock() + mock_agent_load.return_value = Agent() + monkeypatch.setattr("rasa.nlu.test.Agent.load", mock_agent_load) + + mock_RasaYAMLWriter = MagicMock(dump=MagicMock()) + monkeypatch.setattr("rasa.nlu.test.RasaYAMLWriter", mock_RasaYAMLWriter) + + monkeypatch.setattr("rasa.nlu.test.combine_result", AsyncMock()) + + mock_evaluate_intents = MagicMock() + monkeypatch.setattr("rasa.nlu.test.evaluate_intents", mock_evaluate_intents) + + return mock_evaluate_intents + + +async def mock_combine_result( + intent_metrics={}, + entity_metrics={}, + response_selection_metrics={}, + processor=None, + data=None, + intent_results=[], + entity_results=None, + response_selection_results=None, +): + if intent_results is not None: + intent_results += IntentEvaluationResult(1, 2, 3, 4) + + +async def test_cross_validate_evaluate_intents_not_called( + monkeypatch: MonkeyPatch, mocks_for_test_cross_validate +): + await cross_validate( + TRAINING_DATA, + N_FOLDS, + NLU_CONFIG, + successes=False, + errors=False, + disable_plotting=True, + report_as_dict=True, + ) + mocks_for_test_cross_validate.assert_not_called() + + +async def test_cross_validate_evaluate_intents_called( + monkeypatch: MonkeyPatch, mocks_for_test_cross_validate +): + monkeypatch.setattr( + "rasa.nlu.test.combine_result", MagicMock(side_effect=mock_combine_result) + ) + await cross_validate( + TRAINING_DATA, + N_FOLDS, + NLU_CONFIG, + successes=False, + errors=False, + disable_plotting=True, + report_as_dict=True, + ) + + mocks_for_test_cross_validate.assert_called_once() + + +def test_token_entity_intersection(): + # included + intsec = determine_intersection(CH_correct_segmentation[1], CH_correct_entity) + assert intsec == len(CH_correct_segmentation[1].text) + + # completely outside + intsec = determine_intersection(CH_correct_segmentation[2], CH_correct_entity) + assert intsec == 0 + + # border crossing + intsec = determine_intersection(CH_correct_segmentation[1], CH_wrong_entity) + assert intsec == 1 + + +def test_token_entity_boundaries(): + # smaller and included + assert is_token_within_entity(CH_wrong_segmentation[1], CH_correct_entity) + assert not does_token_cross_borders(CH_wrong_segmentation[1], CH_correct_entity) + + # exact match + assert is_token_within_entity(CH_correct_segmentation[1], CH_correct_entity) + assert not does_token_cross_borders(CH_correct_segmentation[1], CH_correct_entity) + + # completely outside + assert not is_token_within_entity(CH_correct_segmentation[0], CH_correct_entity) + assert not does_token_cross_borders(CH_correct_segmentation[0], CH_correct_entity) + + # border crossing + assert not is_token_within_entity(CH_wrong_segmentation[0], CH_correct_entity) + assert does_token_cross_borders(CH_wrong_segmentation[0], CH_correct_entity) + + +def test_entity_overlap(): + assert do_entities_overlap([CH_correct_entity, CH_wrong_entity]) + assert not do_entities_overlap(EN_targets) + + +def test_determine_token_labels_throws_error(): + with pytest.raises(ValueError): + determine_token_labels( + CH_correct_segmentation[0], + [CH_correct_entity, CH_wrong_entity], + {CRFEntityExtractor.__name__}, + ) + + +def test_determine_token_labels_no_extractors(): + label = determine_token_labels( + CH_correct_segmentation[0], [CH_correct_entity, CH_wrong_entity], None + ) + assert label == "direction" + + +def test_determine_token_labels_no_extractors_no_overlap(): + label = determine_token_labels(CH_correct_segmentation[0], EN_targets, None) + assert label == NO_ENTITY_TAG + + +def test_determine_token_labels_with_extractors(): + label = determine_token_labels( + CH_correct_segmentation[0], + [CH_correct_entity, CH_wrong_entity], + {SpacyEntityExtractor.__name__, MitieEntityExtractor.__name__}, + ) + assert label == "direction" + + +@pytest.mark.parametrize( + "token, entities, extractors, expected_confidence", + [ + ( + Token("pizza", 4), + [ + { + "start": 4, + "end": 9, + "value": "pizza", + "entity": "food", + "extractor": "EntityExtractorA", + } + ], + {"EntityExtractorA"}, + 0.0, + ), + (Token("pizza", 4), [], ["EntityExtractorA"], 0.0), + ( + Token("pizza", 4), + [ + { + "start": 4, + "end": 9, + "value": "pizza", + "entity": "food", + "confidence_entity": 0.87, + "extractor": "CRFEntityExtractor", + } + ], + {"CRFEntityExtractor"}, + 0.87, + ), + ( + Token("pizza", 4), + [ + { + "start": 4, + "end": 9, + "value": "pizza", + "entity": "food", + "confidence_entity": 0.87, + "extractor": "DIETClassifier", + } + ], + {"DIETClassifier"}, + 0.87, + ), + ], +) +def test_get_entity_confidences( + token: Token, + entities: List[Dict[Text, Any]], + extractors: Set[Text], + expected_confidence: float, +): + confidence = _get_entity_confidences(token, entities, extractors) + + assert confidence == expected_confidence + + +def test_label_merging(): + import numpy as np + + aligned_predictions = [ + { + "target_labels": ["O", "O"], + "extractor_labels": {"EntityExtractorA": ["O", "O"]}, + }, + { + "target_labels": ["LOC", "O", "O"], + "extractor_labels": {"EntityExtractorA": ["O", "O", "O"]}, + }, + ] + + assert np.all(merge_labels(aligned_predictions) == ["O", "O", "LOC", "O", "O"]) + assert np.all( + merge_labels(aligned_predictions, "EntityExtractorA") + == ["O", "O", "O", "O", "O"] + ) + + +def test_confidence_merging(): + import numpy as np + + aligned_predictions = [ + { + "target_labels": ["O", "O"], + "extractor_labels": {"EntityExtractorA": ["O", "O"]}, + "confidences": {"EntityExtractorA": [0.0, 0.0]}, + }, + { + "target_labels": ["LOC", "O", "O"], + "extractor_labels": {"EntityExtractorA": ["O", "O", "O"]}, + "confidences": {"EntityExtractorA": [0.98, 0.0, 0.0]}, + }, + ] + + assert np.all( + merge_confidences(aligned_predictions, "EntityExtractorA") + == [0.0, 0.0, 0.98, 0.0, 0.0] + ) + + +def test_drop_intents_below_freq(): + td = rasa.shared.nlu.training_data.loading.load_data( + "data/examples/rasa/demo-rasa.json" + ) + # include some lookup tables and make sure new td has them + td = td.merge(TrainingData(lookup_tables=[{"lookup_table": "lookup_entry"}])) + clean_td = drop_intents_below_freq(td, 0) + assert clean_td.intents == { + "affirm", + "goodbye", + "greet", + "restaurant_search", + "chitchat", + } + + clean_td = drop_intents_below_freq(td, 10) + assert clean_td.intents == {"affirm", "restaurant_search"} + assert clean_td.lookup_tables == td.lookup_tables + + +@pytest.mark.timeout( + 300, func_only=True +) # these can take a longer time than the default timeout +async def test_run_evaluation(default_agent: Agent, nlu_as_json_path: Text): + result = await run_evaluation( + nlu_as_json_path, + default_agent.processor, + errors=False, + successes=False, + disable_plotting=True, + ) + + assert result.get("intent_evaluation") + assert all( + prediction["confidence"] is not None + for prediction in result["response_selection_evaluation"]["predictions"] + ) + + +@pytest.mark.timeout( + 300, func_only=True +) # these can take a longer time than the default timeout +async def test_run_evaluation_with_regex_message(mood_agent: Agent, tmp_path: Path): + training_data = textwrap.dedent( + """ + version: '2.0' + nlu: + - intent: goodbye + examples: | + - Bye + - /goodbye{"location": "29432"} + """ + ) + + data_path = tmp_path / "test.yml" + rasa.shared.utils.io.write_text_file(training_data, data_path) + + # Does not raise + await run_evaluation( + str(data_path), + mood_agent.processor, + errors=False, + successes=False, + disable_plotting=True, + ) + + +async def test_eval_data(tmp_path: Path, project: Text, trained_rasa_model: Text): + config_path = os.path.join(project, "config.yml") + data_importer = TrainingDataImporter.load_nlu_importer_from_config( + config_path, + training_data_paths=[ + "data/examples/rasa/demo-rasa.yml", + "data/examples/rasa/demo-rasa-responses.yml", + ], + ) + + processor = Agent.load(trained_rasa_model).processor + + data = data_importer.get_nlu_data() + (intent_results, response_selection_results, entity_results) = await get_eval_data( + processor, data + ) + + assert len(intent_results) == 46 + assert len(response_selection_results) == 46 + assert len(entity_results) == 46 + + +# FIXME: these tests take too long to run in CI on Windows, disabling them for now +@pytest.mark.skip_on_windows +@pytest.mark.timeout( + 240, func_only=True +) # these can take a longer time than the default timeout +async def test_run_cv_evaluation(): + td = rasa.shared.nlu.training_data.loading.load_data( + "data/test/demo-rasa-more-ents-and-multiplied.yml" + ) + + nlu_config = { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": [ + {"name": "WhitespaceTokenizer"}, + {"name": "CountVectorsFeaturizer"}, + {"name": "LogisticRegressionClassifier", EPOCHS: 2}, + ], + } + + n_folds = 2 + intent_results, entity_results, response_selection_results = await cross_validate( + td, + n_folds, + nlu_config, + successes=False, + errors=False, + disable_plotting=True, + report_as_dict=True, + ) + + assert len(intent_results.train["Accuracy"]) == n_folds + assert len(intent_results.train["Precision"]) == n_folds + assert len(intent_results.train["F1-score"]) == n_folds + assert len(intent_results.test["Accuracy"]) == n_folds + assert len(intent_results.test["Precision"]) == n_folds + assert len(intent_results.test["F1-score"]) == n_folds + assert all(key in intent_results.evaluation for key in ["errors", "report"]) + assert any( + isinstance(intent_report, dict) + and intent_report.get("confused_with") is not None + for intent_report in intent_results.evaluation["report"].values() + ) + for extractor_evaluation in entity_results.evaluation.values(): + assert all(key in extractor_evaluation for key in ["errors", "report"]) + + +# FIXME: these tests take too long to run in CI on Windows, disabling them for now +@pytest.mark.skip_on_windows +@pytest.mark.timeout( + 180, func_only=True +) # these can take a longer time than the default timeout +async def test_run_cv_evaluation_no_entities(): + td = rasa.shared.nlu.training_data.loading.load_data( + "data/test/demo-rasa-no-ents.yml" + ) + + nlu_config = { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": [ + {"name": "WhitespaceTokenizer"}, + {"name": "CountVectorsFeaturizer"}, + {"name": "LogisticRegressionClassifier", EPOCHS: 25}, + ], + } + + n_folds = 2 + intent_results, entity_results, response_selection_results = await cross_validate( + td, + n_folds, + nlu_config, + successes=False, + errors=False, + disable_plotting=True, + report_as_dict=True, + ) + + assert len(intent_results.train["Accuracy"]) == n_folds + assert len(intent_results.train["Precision"]) == n_folds + assert len(intent_results.train["F1-score"]) == n_folds + assert len(intent_results.test["Accuracy"]) == n_folds + assert len(intent_results.test["Precision"]) == n_folds + assert len(intent_results.test["F1-score"]) == n_folds + assert all(key in intent_results.evaluation for key in ["errors", "report"]) + assert any( + isinstance(intent_report, dict) + and intent_report.get("confused_with") is not None + for intent_report in intent_results.evaluation["report"].values() + ) + + assert len(entity_results.train) == 0 + assert len(entity_results.test) == 0 + assert len(entity_results.evaluation) == 0 + + +# FIXME: these tests take too long to run in CI on Windows, disabling them for now +@pytest.mark.skip_on_windows +@pytest.mark.timeout( + 280, func_only=True +) # these can take a longer time than the default timeout +async def test_run_cv_evaluation_with_response_selector(): + training_data_obj = rasa.shared.nlu.training_data.loading.load_data( + "data/test/demo-rasa-more-ents-and-multiplied.yml" + ) + training_data_responses_obj = rasa.shared.nlu.training_data.loading.load_data( + "data/examples/rasa/demo-rasa-responses.yml" + ) + training_data_obj = training_data_obj.merge(training_data_responses_obj) + + nlu_config = { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": [ + {"name": "WhitespaceTokenizer"}, + {"name": "CountVectorsFeaturizer"}, + {"name": "LogisticRegressionClassifier", EPOCHS: 25}, + {"name": "CRFEntityExtractor", EPOCHS: 25}, + {"name": "ResponseSelector", EPOCHS: 2, RUN_EAGERLY: True}, + ], + } + + n_folds = 2 + intent_results, entity_results, response_selection_results = await cross_validate( + training_data_obj, + n_folds, + nlu_config, + successes=False, + errors=False, + disable_plotting=True, + report_as_dict=True, + ) + + assert len(intent_results.train["Accuracy"]) == n_folds + assert len(intent_results.train["Precision"]) == n_folds + assert len(intent_results.train["F1-score"]) == n_folds + assert len(intent_results.test["Accuracy"]) == n_folds + assert len(intent_results.test["Precision"]) == n_folds + assert len(intent_results.test["F1-score"]) == n_folds + assert all(key in intent_results.evaluation for key in ["errors", "report"]) + assert any( + isinstance(intent_report, dict) + and intent_report.get("confused_with") is not None + for intent_report in intent_results.evaluation["report"].values() + ) + + assert len(response_selection_results.train["Accuracy"]) == n_folds + assert len(response_selection_results.train["Precision"]) == n_folds + assert len(response_selection_results.train["F1-score"]) == n_folds + assert len(response_selection_results.test["Accuracy"]) == n_folds + assert len(response_selection_results.test["Precision"]) == n_folds + assert len(response_selection_results.test["F1-score"]) == n_folds + assert all( + key in response_selection_results.evaluation for key in ["errors", "report"] + ) + + assert all( + prediction["confidence"] is not None and prediction["confidence"] != 0.0 + for prediction in response_selection_results.evaluation["predictions"] + ) + + assert any( + isinstance(intent_report, dict) + and intent_report.get("confused_with") is not None + for intent_report in response_selection_results.evaluation["report"].values() + ) + + entity_extractor_name = "CRFEntityExtractor" + assert len(entity_results.train[entity_extractor_name]["Accuracy"]) == n_folds + assert len(entity_results.train[entity_extractor_name]["Precision"]) == n_folds + assert len(entity_results.train[entity_extractor_name]["F1-score"]) == n_folds + + assert len(entity_results.test[entity_extractor_name]["Accuracy"]) == n_folds + assert len(entity_results.test[entity_extractor_name]["Precision"]) == n_folds + assert len(entity_results.test[entity_extractor_name]["F1-score"]) == n_folds + for extractor_evaluation in entity_results.evaluation.values(): + assert all(key in extractor_evaluation for key in ["errors", "report"]) + + +# FIXME: these tests take too long to run in CI on Windows, disabling them for now +@pytest.mark.skip_on_windows +@pytest.mark.timeout( + 280, func_only=True +) # these can take a longer time than the default timeout +async def test_run_cv_evaluation_lookup_tables(): + td = rasa.shared.nlu.training_data.loading.load_data( + "data/test/demo-rasa-lookup-ents.yml" + ) + + nlu_config = { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": [ + {"name": "WhitespaceTokenizer"}, + {"name": "CountVectorsFeaturizer"}, + {"name": "LogisticRegressionClassifier", EPOCHS: 1}, + {"name": "RegexEntityExtractor", "use_lookup_tables": True}, + ], + } + + n_folds = 2 + intent_results, entity_results, response_selection_results = await cross_validate( + td, + n_folds, + nlu_config, + successes=False, + errors=False, + disable_plotting=True, + report_as_dict=True, + ) + + regex_extractor_name = "RegexEntityExtractor" + assert regex_extractor_name in entity_results.test + + assert len(entity_results.test[regex_extractor_name]["Accuracy"]) == n_folds + assert len(entity_results.test[regex_extractor_name]["Precision"]) == n_folds + assert len(entity_results.test[regex_extractor_name]["F1-score"]) == n_folds + + # All entities in the test set appear in the lookup table, + # so should get perfect scores + for fold in range(n_folds): + assert entity_results.test[regex_extractor_name]["Accuracy"][fold] == 1.0 + assert entity_results.test[regex_extractor_name]["Precision"][fold] == 1.0 + assert entity_results.test[regex_extractor_name]["F1-score"][fold] == 1.0 + + +def test_intent_evaluation_report(tmp_path: Path): + path = tmp_path / "evaluation" + path.mkdir() + report_folder = str(path / "reports") + report_filename = os.path.join(report_folder, "intent_report.json") + + rasa.shared.utils.io.create_directory(report_folder) + + intent_results = [ + IntentEvaluationResult("", "restaurant_search", "I am hungry", 0.12345), + IntentEvaluationResult("greet", "greet", "hello", 0.98765), + ] + + result = evaluate_intents( + intent_results, + report_folder, + successes=True, + errors=True, + disable_plotting=False, + ) + + report = json.loads(rasa.shared.utils.io.read_file(report_filename)) + + greet_results = { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1, + "confused_with": {}, + } + + prediction = { + "text": "hello", + "intent": "greet", + "predicted": "greet", + "confidence": 0.98765, + } + + assert len(report.keys()) == 5 + assert report["greet"] == greet_results + assert result["predictions"][0] == prediction + + assert os.path.exists(os.path.join(report_folder, "intent_confusion_matrix.png")) + assert os.path.exists(os.path.join(report_folder, "intent_histogram.png")) + assert not os.path.exists(os.path.join(report_folder, "intent_errors.json")) + assert os.path.exists(os.path.join(report_folder, "intent_successes.json")) + + +def test_intent_evaluation_report_large(tmp_path: Path): + path = tmp_path / "evaluation" + path.mkdir() + report_folder = path / "reports" + report_filename = report_folder / "intent_report.json" + + rasa.shared.utils.io.create_directory(str(report_folder)) + + def correct(label: Text) -> IntentEvaluationResult: + return IntentEvaluationResult(label, label, "", 1.0) + + def incorrect(label: Text, _label: Text) -> IntentEvaluationResult: + return IntentEvaluationResult(label, _label, "", 1.0) + + a_results = [correct("A")] * 10 + b_results = [correct("B")] * 7 + [incorrect("B", "C")] * 3 + c_results = [correct("C")] * 3 + [incorrect("C", "D")] + [incorrect("C", "E")] + d_results = [correct("D")] * 29 + [incorrect("D", "B")] * 3 + e_results = [incorrect("E", "C")] * 5 + [incorrect("E", "")] * 5 + + intent_results = a_results + b_results + c_results + d_results + e_results + + evaluate_intents( + intent_results, + str(report_folder), + successes=False, + errors=False, + disable_plotting=True, + ) + + report = json.loads(rasa.shared.utils.io.read_file(str(report_filename))) + + a_results = { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 10, + "confused_with": {}, + } + + e_results = { + "precision": 0.0, + "recall": 0.0, + "f1-score": 0.0, + "support": 10, + "confused_with": {"C": 5, "": 5}, + } + + c_confused_with = {"D": 1, "E": 1} + + assert len(report.keys()) == 9 + assert report["A"] == a_results + assert report["E"] == e_results + assert report["C"]["confused_with"] == c_confused_with + + +def test_response_evaluation_report(tmp_path: Path): + path = tmp_path / "evaluation" + path.mkdir() + report_folder = str(path / "reports") + report_filename = os.path.join(report_folder, "response_selection_report.json") + + rasa.shared.utils.io.create_directory(report_folder) + + response_results = [ + ResponseSelectionEvaluationResult( + "chitchat/ask_weather", + "chitchat/ask_weather", + "What's the weather", + 0.65432, + ), + ResponseSelectionEvaluationResult( + "chitchat/ask_name", "chitchat/ask_name", "What's your name?", 0.98765 + ), + ] + + result = evaluate_response_selections( + response_results, + report_folder, + successes=True, + errors=True, + disable_plotting=False, + ) + + report = json.loads(rasa.shared.utils.io.read_file(report_filename)) + + name_query_results = { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1, + "confused_with": {}, + } + + prediction = { + "text": "What's your name?", + "intent_response_key_target": "chitchat/ask_name", + "intent_response_key_prediction": "chitchat/ask_name", + "confidence": 0.98765, + } + + assert len(report.keys()) == 6 + assert report["chitchat/ask_name"] == name_query_results + assert result["predictions"][1] == prediction + + assert os.path.exists( + os.path.join(report_folder, "response_selection_confusion_matrix.png") + ) + assert os.path.exists( + os.path.join(report_folder, "response_selection_histogram.png") + ) + assert not os.path.exists( + os.path.join(report_folder, "response_selection_errors.json") + ) + assert os.path.exists( + os.path.join(report_folder, "response_selection_successes.json") + ) + + +@pytest.mark.parametrize( + "entity_results, expected_extractors", + [ + ([], set()), + ([EN_entity_result], {"EntityExtractorA", "EntityExtractorB"}), + ( + [EN_entity_result, EN_entity_result], + {"EntityExtractorA", "EntityExtractorB"}, + ), + ], +) +def test_get_active_entity_extractors( + entity_results: List[EntityEvaluationResult], expected_extractors: Set[Text] +): + extractors = _get_active_entity_extractors(entity_results) + assert extractors == expected_extractors + + +def test_entity_evaluation_report(tmp_path: Path): + + path = tmp_path / "evaluation" + path.mkdir() + report_folder = str(path / "reports") + + report_filename_a = os.path.join(report_folder, "EntityExtractorA_report.json") + report_filename_b = os.path.join(report_folder, "EntityExtractorB_report.json") + + rasa.shared.utils.io.create_directory(report_folder) + + extractors = _get_active_entity_extractors([EN_entity_result]) + result = evaluate_entities( + [EN_entity_result], + extractors, + report_folder, + errors=True, + successes=True, + disable_plotting=False, + ) + + report_a = json.loads(rasa.shared.utils.io.read_file(report_filename_a)) + report_b = json.loads(rasa.shared.utils.io.read_file(report_filename_b)) + + assert len(report_a) == 7 + assert report_a["datetime"]["support"] == 1.0 + assert report_b["macro avg"]["recall"] == 0.0 + assert report_a["macro avg"]["recall"] == 0.5 + assert result["EntityExtractorA"]["accuracy"] == 0.75 + + assert os.path.exists( + os.path.join(report_folder, "EntityExtractorA_confusion_matrix.png") + ) + assert os.path.exists(os.path.join(report_folder, "EntityExtractorA_errors.json")) + assert os.path.exists( + os.path.join(report_folder, "EntityExtractorA_successes.json") + ) + assert not os.path.exists( + os.path.join(report_folder, "EntityExtractorA_histogram.png") + ) + + +def test_empty_intent_removal(): + intent_results = [ + IntentEvaluationResult("", "restaurant_search", "I am hungry", 0.12345), + IntentEvaluationResult("greet", "greet", "hello", 0.98765), + ] + intent_results = remove_empty_intent_examples(intent_results) + + assert len(intent_results) == 1 + assert intent_results[0].intent_target == "greet" + assert intent_results[0].intent_prediction == "greet" + assert intent_results[0].confidence == 0.98765 + assert intent_results[0].message == "hello" + + +def test_empty_response_removal(): + response_results = [ + ResponseSelectionEvaluationResult(None, None, "What's the weather", 0.65432), + ResponseSelectionEvaluationResult( + "chitchat/ask_name", "chitchat/ask_name", "What's your name?", 0.98765 + ), + # This happens if response selection test data is present but no response + # selector is part of the model + ResponseSelectionEvaluationResult( + "chitchat/ask_name", None, "What's your name?", None + ), + ] + response_results = remove_empty_response_examples(response_results) + + assert len(response_results) == 2 + assert response_results[0].intent_response_key_target == "chitchat/ask_name" + assert response_results[0].intent_response_key_prediction == "chitchat/ask_name" + assert response_results[0].confidence == 0.98765 + assert response_results[0].message == "What's your name?" + + assert response_results[1].intent_response_key_target == "chitchat/ask_name" + assert response_results[1].intent_response_key_prediction == "" + assert response_results[1].confidence == 0.0 + assert response_results[1].message == "What's your name?" + + +def test_evaluate_entities_cv_empty_tokens(): + mock_extractors = ["EntityExtractorA", "EntityExtractorB"] + result = align_entity_predictions(EN_entity_result_no_tokens, mock_extractors) + + assert result == { + "target_labels": [], + "extractor_labels": {"EntityExtractorA": [], "EntityExtractorB": []}, + "confidences": {"EntityExtractorA": [], "EntityExtractorB": []}, + }, "Wrong entity prediction alignment" + + +def test_evaluate_entities_cv(): + mock_extractors = ["EntityExtractorA", "EntityExtractorB"] + result = align_entity_predictions(EN_entity_result, mock_extractors) + + assert result == { + "target_labels": [ + "O", + "O", + "O", + "O", + "O", + "O", + "O", + "O", + "food", + "location", + "location", + "datetime", + ], + "extractor_labels": { + "EntityExtractorA": [ + "O", + "person", + "O", + "O", + "O", + "O", + "O", + "O", + "food", + "O", + "location", + "O", + ], + "EntityExtractorB": [ + "O", + "O", + "O", + "O", + "O", + "O", + "O", + "O", + "O", + "O", + "movie", + "movie", + ], + }, + "confidences": { + "EntityExtractorA": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + "EntityExtractorB": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + }, + }, "Wrong entity prediction alignment" + + +def test_label_replacement(): + original_labels = ["O", "location"] + target_labels = ["no_entity", "location"] + assert substitute_labels(original_labels, "O", "no_entity") == target_labels + + +async def test_nlu_comparison( + tmp_path: Path, monkeypatch: MonkeyPatch, nlu_as_json_path: Text +): + config = { + "assistant_id": "placeholder_default", + "language": "en", + "pipeline": [ + {"name": "WhitespaceTokenizer"}, + {"name": "KeywordIntentClassifier"}, + {"name": "RegexEntityExtractor"}, + ], + } + # the configs need to be at a different path, otherwise the results are + # combined on the same dictionary key and cannot be plotted properly + configs = [write_file_config(config).name, write_file_config(config).name] + + monkeypatch.setattr( + sys.modules["rasa.nlu.test"], + "get_eval_data", + AsyncMock(return_value=(1, None, (None,))), + ) + monkeypatch.setattr( + sys.modules["rasa.nlu.test"], + "evaluate_intents", + Mock(return_value={"f1_score": 1}), + ) + + output = str(tmp_path) + test_data_importer = TrainingDataImporter.load_from_dict( + training_data_paths=[nlu_as_json_path] + ) + test_data = test_data_importer.get_nlu_data() + await compare_nlu_models( + configs, test_data, output, runs=2, exclusion_percentages=[50, 80] + ) + + assert set(os.listdir(output)) == { + "run_1", + "run_2", + "results.json", + "nlu_model_comparison_graph.pdf", + } + + run_1_path = os.path.join(output, "run_1") + assert set(os.listdir(run_1_path)) == {"50%_exclusion", "80%_exclusion", "test.yml"} + + exclude_50_path = os.path.join(run_1_path, "50%_exclusion") + modelnames = [os.path.splitext(os.path.basename(config))[0] for config in configs] + + modeloutputs = set( + ["train"] + + [f"{m}_report" for m in modelnames] + + [f"{m}.tar.gz" for m in modelnames] + ) + assert set(os.listdir(exclude_50_path)) == modeloutputs + + +@pytest.mark.parametrize( + "entity_results,targets,predictions,successes,errors", + [ + ( + [ + EntityEvaluationResult( + entity_targets=[ + { + "start": 17, + "end": 24, + "value": "Italian", + "entity": "cuisine", + } + ], + entity_predictions=[ + { + "start": 17, + "end": 24, + "value": "Italian", + "entity": "cuisine", + } + ], + tokens=[ + "I", + "want", + "to", + "book", + "an", + "Italian", + "restaurant", + ".", + ], + message="I want to book an Italian restaurant.", + ), + EntityEvaluationResult( + entity_targets=[ + { + "start": 8, + "end": 15, + "value": "Mexican", + "entity": "cuisine", + }, + { + "start": 31, + "end": 32, + "value": "4", + "entity": "number_people", + }, + ], + entity_predictions=[], + tokens=[ + "Book", + "an", + "Mexican", + "restaurant", + "for", + "4", + "people", + ".", + ], + message="Book an Mexican restaurant for 4 people.", + ), + ], + [ + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + "cuisine", + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + "cuisine", + NO_ENTITY, + NO_ENTITY, + "number_people", + NO_ENTITY, + NO_ENTITY, + ], + [ + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + "cuisine", + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + NO_ENTITY, + ], + [ + { + "text": "I want to book an Italian restaurant.", + "entities": [ + { + "start": 17, + "end": 24, + "value": "Italian", + "entity": "cuisine", + } + ], + "predicted_entities": [ + { + "start": 17, + "end": 24, + "value": "Italian", + "entity": "cuisine", + } + ], + } + ], + [ + { + "text": "Book an Mexican restaurant for 4 people.", + "entities": [ + { + "start": 8, + "end": 15, + "value": "Mexican", + "entity": "cuisine", + }, + { + "start": 31, + "end": 32, + "value": "4", + "entity": "number_people", + }, + ], + "predicted_entities": [], + } + ], + ) + ], +) +def test_collect_entity_predictions( + entity_results: List[EntityEvaluationResult], + targets: List[Text], + predictions: List[Text], + successes: List[Dict[Text, Any]], + errors: List[Dict[Text, Any]], +): + actual = collect_successful_entity_predictions(entity_results, targets, predictions) + + assert len(successes) == len(actual) + assert successes == actual + + actual = collect_incorrect_entity_predictions(entity_results, targets, predictions) + + assert len(errors) == len(actual) + assert errors == actual + + +class ConstantProcessor: + def __init__(self, prediction_to_return: Dict[Text, Any]) -> None: + self.prediction = prediction_to_return + self.model_metadata = None + + async def parse_message( + self, + message: UserMessage, + tracker: Optional[DialogueStateTracker] = None, + only_output_properties: bool = True, + ) -> Dict[Text, Any]: + return self.prediction + + +async def test_replacing_fallback_intent(): + expected_intent = "greet" + expected_confidence = 0.345 + fallback_prediction = { + INTENT: { + INTENT_NAME_KEY: DEFAULT_NLU_FALLBACK_INTENT_NAME, + PREDICTED_CONFIDENCE_KEY: 1, + }, + INTENT_RANKING_KEY: [ + { + INTENT_NAME_KEY: DEFAULT_NLU_FALLBACK_INTENT_NAME, + PREDICTED_CONFIDENCE_KEY: 1, + }, + { + INTENT_NAME_KEY: expected_intent, + PREDICTED_CONFIDENCE_KEY: expected_confidence, + }, + {INTENT_NAME_KEY: "some", PREDICTED_CONFIDENCE_KEY: 0.1}, + ], + } + + processor = ConstantProcessor(fallback_prediction) + training_data = TrainingData( + [Message.build("hi", "greet"), Message.build("bye", "bye")] + ) + + intent_evaluations, _, _ = await get_eval_data(processor, training_data) + + assert all( + prediction.intent_prediction == expected_intent + and prediction.confidence == expected_confidence + for prediction in intent_evaluations + ) + + +async def test_remove_entities_of_extractors(): + extractor = "TestExtractor" + extractor_2 = "DIET" + extractor_3 = "YetAnotherExtractor" + # shouldn't crash when there are no annotations + _remove_entities_of_extractors({}, [extractor]) + + # add some entities + entities = [ + { + ENTITY_ATTRIBUTE_TYPE: "time", + ENTITY_ATTRIBUTE_VALUE: "12:00", + EXTRACTOR: extractor, + }, + { + ENTITY_ATTRIBUTE_TYPE: "location", + ENTITY_ATTRIBUTE_VALUE: "Berlin - Alexanderplatz", + EXTRACTOR: extractor_3, + }, + { + ENTITY_ATTRIBUTE_TYPE: "name", + ENTITY_ATTRIBUTE_VALUE: "Joe", + EXTRACTOR: extractor_2, + }, + ] + result_dict = {ENTITIES: entities} + _remove_entities_of_extractors(result_dict, [extractor, extractor_3]) + + assert len(result_dict[ENTITIES]) == 1 + remaining_entity = result_dict[ENTITIES][0] + assert remaining_entity[EXTRACTOR] == extractor_2 diff --git a/tests/nlu/test_persistor.py b/tests/nlu/test_persistor.py new file mode 100644 index 0000000..ef41d15 --- /dev/null +++ b/tests/nlu/test_persistor.py @@ -0,0 +1,71 @@ +from typing import Text + +import pytest +from unittest.mock import patch + +from moto import mock_s3 + +from rasa.nlu import persistor +from rasa.nlu.persistor import Persistor + + +class Object: + pass + + +# noinspection PyPep8Naming +def test_retrieve_tar_archive_with_s3_namespace(): + with mock_s3(): + model = "/my/s3/project/model.tar.gz" + destination = "dst" + with patch.object(persistor.AWSPersistor, "_copy") as copy: + with patch.object(persistor.AWSPersistor, "_retrieve_tar") as retrieve: + persistor.AWSPersistor("rasa-test", region_name="foo").retrieve( + model, destination + ) + copy.assert_called_once_with("model.tar.gz", destination) + retrieve.assert_called_once_with(model) + + +# noinspection PyPep8Naming +def test_s3_private_retrieve_tar(): + with mock_s3(): + # Ensure the S3 persistor writes to a filename `model.tar.gz`, whilst + # passing the fully namespaced path to boto3 + model = "/my/s3/project/model.tar.gz" + awsPersistor = persistor.AWSPersistor("rasa-test", region_name="foo") + with patch.object(awsPersistor.bucket, "download_fileobj") as download_fileobj: + # noinspection PyProtectedMember + awsPersistor._retrieve_tar(model) + retrieveArgs = download_fileobj.call_args[0] + assert retrieveArgs[0] == model + assert retrieveArgs[1].name == "model.tar.gz" + + +class TestPersistor(Persistor): + def _retrieve_tar(self, filename: Text) -> Text: + pass + + def _persist_tar(self, filekey: Text, tarname: Text) -> None: + pass + + +def test_get_external_persistor(): + p = persistor.get_persistor("tests.nlu.test_persistor.TestPersistor") + assert isinstance(p, TestPersistor) + + +def test_raise_exception_in_get_external_persistor(): + with pytest.raises(ImportError): + _ = persistor.get_persistor("unknown.persistor") + + +# noinspection PyPep8Naming +@pytest.mark.parametrize( + "model, archive", [("model.tar.gz", "model.tar.gz"), ("model", "model.tar.gz")] +) +def test_retrieve_tar_archive(model: Text, archive: Text): + with patch.object(TestPersistor, "_copy") as f: + with patch.object(TestPersistor, "_retrieve_tar") as f: + TestPersistor().retrieve(model, "dst") + f.assert_called_once_with(archive) diff --git a/tests/nlu/test_spacy_utils.py b/tests/nlu/test_spacy_utils.py new file mode 100644 index 0000000..b6297a0 --- /dev/null +++ b/tests/nlu/test_spacy_utils.py @@ -0,0 +1,90 @@ +from typing import Optional, Text + +import pytest + +import spacy.tokens.doc + +from rasa.nlu.constants import DENSE_FEATURIZABLE_ATTRIBUTES, SPACY_DOCS +from rasa.nlu.model import InvalidModelError +from rasa.nlu.utils.spacy_utils import SpacyNLP, SpacyModel +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.nlu.constants import ACTION_TEXT, RESPONSE, TEXT +from rasa.shared.nlu.training_data.message import Message + + +def create_spacy_nlp_component( + model_name: Text = "en_core_web_md", case_sensitive: Optional[bool] = None +) -> SpacyNLP: + component = SpacyNLP.create( + {"model": model_name, "case_sensitive": case_sensitive}, None, None, None + ) + return component + + +@pytest.mark.parametrize( + "model_name,msg", + [ + ( + "dinosaurhead", + "Please confirm that dinosaurhead is an available spaCy model", + ), + (None, "Missing model configuration for `SpacyNLP` in `config.yml`"), + ], +) +def test_model_raises_error_not_exist(model_name, msg): + """It should throw a direct error when a bad model setting goes in.""" + with pytest.raises(InvalidModelError) as err: + create_spacy_nlp_component(model_name) + assert msg in str(err.value) + + +def test_spacy_spacy_model_provider(): + provider_component = create_spacy_nlp_component() + spacy_model = provider_component.provide() + assert spacy_model.model + assert spacy_model.model_name == "en_core_web_md" + + +@pytest.mark.parametrize("case_sensitive", [True, False]) +def test_spacy_preprocessor_adds_attributes_when_processing( + case_sensitive: bool, spacy_model: SpacyModel +): + preprocessor = create_spacy_nlp_component(case_sensitive=case_sensitive) + message_data = { + TEXT: "Hello my name is Joe", + RESPONSE: "Some response", + ACTION_TEXT: "Action Text", + } + message = Message(data=message_data) + preprocessor.process([message], spacy_model) + + for attr, text in message_data.items(): + doc = message.data[SPACY_DOCS[attr]] + + assert isinstance(doc, spacy.tokens.doc.Doc) + + if case_sensitive: + assert doc.text == text + else: + assert doc.text == text.lower() + + +def test_spacy_preprocessor_process_training_data( + spacy_nlp_component: SpacyNLP, spacy_model: SpacyModel +): + training_data = TrainingDataImporter.load_from_dict( + training_data_paths=[ + "data/test_e2ebot/data/nlu.yml", + "data/test_e2ebot/data/stories.yml", + ] + ).get_nlu_data() + + spacy_nlp_component.process_training_data(training_data, spacy_model) + + for message in training_data.training_examples: + for attr in DENSE_FEATURIZABLE_ATTRIBUTES: + attr_text = message.data.get(attr) + if attr_text: + doc = message.data[SPACY_DOCS[attr]] + assert isinstance(doc, spacy.tokens.doc.Doc) + assert doc.text == attr_text.lower() diff --git a/tests/nlu/test_train.py b/tests/nlu/test_train.py new file mode 100644 index 0000000..72211b8 --- /dev/null +++ b/tests/nlu/test_train.py @@ -0,0 +1,289 @@ +from pathlib import Path + +import pytest +from _pytest.tmpdir import TempPathFactory + +from rasa.core.agent import Agent +from rasa.core.policies.policy import Policy +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.shared.nlu.training_data.formats import RasaYAMLReader +from rasa.utils.tensorflow.constants import EPOCHS, RUN_EAGERLY +from typing import Any, Dict, List, Tuple, Text, Union, Optional +import rasa.model_training +import rasa.shared.utils.io +import rasa.engine.recipes.default_components + +COMPONENTS_TEST_PARAMS = { + "DIETClassifier": {EPOCHS: 1, RUN_EAGERLY: True}, + "ResponseSelector": {EPOCHS: 1, RUN_EAGERLY: True}, + "LanguageModelFeaturizer": { + "model_name": "bert", + "model_weights": "sentence-transformers/all-MiniLM-L6-v2", + }, +} + + +def get_test_params_for_component(component: Text) -> Dict[Text, Union[Text, int]]: + return ( + COMPONENTS_TEST_PARAMS[component] if component in COMPONENTS_TEST_PARAMS else {} + ) + + +def as_pipeline(*components) -> List[Dict[Text, Dict]]: + return [ + {"name": c, **get_test_params_for_component(c)} if isinstance(c, str) else c + for c in components + ] + + +def pipelines_for_tests() -> List[Tuple[Text, List[Dict[Text, Any]]]]: + # these pipelines really are just for testing + # every component should be in here so train-persist-load-use cycle can be + # tested they still need to be in a useful order - hence we can not simply + # generate this automatically. + + # Create separate test pipelines for dense featurizers + # because they can't co-exist in the same pipeline together, + # as their tokenizers break the incoming message into different number of tokens. + + # first is language followed by list of components + return [ + ("en", as_pipeline("KeywordIntentClassifier")), + ( + "en", + as_pipeline( + "WhitespaceTokenizer", + "RegexFeaturizer", + "LexicalSyntacticFeaturizer", + "CountVectorsFeaturizer", + "CRFEntityExtractor", + "DucklingEntityExtractor", + "DIETClassifier", + "ResponseSelector", + "EntitySynonymMapper", + ), + ), + ( + "en", + as_pipeline( + {"name": "SpacyNLP", "model": "en_core_web_md"}, + "SpacyTokenizer", + "SpacyFeaturizer", + "SpacyEntityExtractor", + "SklearnIntentClassifier", + ), + ), + ( + "en", + as_pipeline( + {"name": "SpacyNLP", "model": "en_core_web_md"}, + "SpacyTokenizer", + "SpacyFeaturizer", + "CountVectorsFeaturizer", + "LogisticRegressionClassifier", + ), + ), + ( + "en", + as_pipeline( + "WhitespaceTokenizer", "LanguageModelFeaturizer", "DIETClassifier" + ), + ), + ("fallback", as_pipeline("KeywordIntentClassifier", "FallbackClassifier")), + ] + + +def pipelines_for_non_windows_tests() -> List[Tuple[Text, List[Dict[Text, Any]]]]: + # these templates really are just for testing + + # because some of the components are not available on Windows, we specify pipelines + # containing them separately + + # first is language followed by list of components + return [ + ( + "en", + as_pipeline( + {"name": "SpacyNLP", "model": "en_core_web_md"}, + "SpacyTokenizer", + "SpacyFeaturizer", + "DIETClassifier", + ), + ), + ( + "en", + as_pipeline( + "MitieNLP", + "MitieTokenizer", + "MitieFeaturizer", + "MitieIntentClassifier", + "RegexEntityExtractor", + ), + ), + ( + "zh", + as_pipeline( + "MitieNLP", "JiebaTokenizer", "MitieFeaturizer", "MitieEntityExtractor" + ), + ), + ] + + +def test_all_components_are_in_at_least_one_test_pipeline(): + """There is a template that includes all components to + test the train-persist-load-use cycle. Ensures that + really all components are in there. + """ + all_pipelines = pipelines_for_tests() + pipelines_for_non_windows_tests() + all_components = [c["name"] for _, p in all_pipelines for c in p] + + all_registered_components = ( + rasa.engine.recipes.default_components.DEFAULT_COMPONENTS + ) + all_registered_nlu_components = [ + c for c in all_registered_components if not issubclass(c, Policy) + ] + + for cls in all_registered_nlu_components: + if "convert" in cls.__name__.lower(): + # TODO + # skip ConveRTFeaturizer as the ConveRT model is not + # publicly available anymore + # (see https://github.com/RasaHQ/rasa/issues/6806) + continue + assert ( + cls.__name__ in all_components + ), "`all_components` template is missing component." + + +@pytest.mark.timeout(600, func_only=True) +@pytest.mark.parametrize("language, pipeline", pipelines_for_tests()) +async def test_train_persist_load_parse( + language: Optional[Text], + pipeline: List[Dict], + tmp_path: Path, + nlu_as_json_path: Text, +): + config_file = tmp_path / "config.yml" + rasa.shared.utils.io.dump_obj_as_json_to_file( + config_file, + { + "pipeline": pipeline, + "language": language, + "assistant_id": "placeholder_default", + }, + ) + + persisted_path = rasa.model_training.train_nlu( + str(config_file), nlu_as_json_path, output=str(tmp_path) + ) + + assert Path(persisted_path).is_file() + + agent = Agent.load(persisted_path) + assert agent.processor + assert agent.is_ready() + assert await agent.parse_message("Rasa is great!") is not None + + +@pytest.mark.timeout(600, func_only=True) +@pytest.mark.parametrize("language, pipeline", pipelines_for_non_windows_tests()) +@pytest.mark.skip_on_windows +def test_train_persist_load_parse_non_windows( + language, pipeline, tmp_path, nlu_as_json_path: Text +): + test_train_persist_load_parse(language, pipeline, tmp_path, nlu_as_json_path) + + +def test_train_model_empty_pipeline(nlu_as_json_path: Text, tmp_path: Path): + config_file = tmp_path / "config.yml" + rasa.shared.utils.io.dump_obj_as_json_to_file( + config_file, {"pipeline": [], "assistant_id": "placeholder_default"} + ) + + with pytest.raises(ValueError): + rasa.model_training.train_nlu( + str(config_file), nlu_as_json_path, output=str(tmp_path) + ) + + +def test_handles_pipeline_with_non_existing_component( + tmp_path: Path, pretrained_embeddings_spacy_config: Dict, nlu_as_json_path: Text +): + pretrained_embeddings_spacy_config["pipeline"].append( + {"name": "my_made_up_component"} + ) + + config_file = tmp_path / "config.yml" + rasa.shared.utils.io.dump_obj_as_json_to_file( + config_file, pretrained_embeddings_spacy_config + ) + + with pytest.raises( + Exception, match="Can't load class for name 'my_made_up_component'" + ): + rasa.model_training.train_nlu( + str(config_file), nlu_as_json_path, output=str(tmp_path) + ) + + +def test_train_model_training_data_persisted( + tmp_path: Path, nlu_as_json_path: Text, tmp_path_factory: TempPathFactory +): + config_file = tmp_path / "config.yml" + rasa.shared.utils.io.dump_obj_as_json_to_file( + config_file, + { + "pipeline": [{"name": "KeywordIntentClassifier"}], + "language": "en", + "assistant_id": "placeholder_default", + }, + ) + + persisted_path = rasa.model_training.train_nlu( + str(config_file), + nlu_as_json_path, + output=str(tmp_path), + persist_nlu_training_data=True, + ) + + assert Path(persisted_path).is_file() + + model_dir = tmp_path_factory.mktemp("loaded") + storage, _ = LocalModelStorage.from_model_archive(model_dir, Path(persisted_path)) + + nlu_data_dir = model_dir / "nlu_training_data_provider" + + assert nlu_data_dir.is_dir() + + assert not RasaYAMLReader().read(nlu_data_dir / "training_data.yml").is_empty() + + +def test_train_model_no_training_data_persisted( + tmp_path: Path, nlu_as_json_path: Text, tmp_path_factory: TempPathFactory +): + config_file = tmp_path / "config.yml" + rasa.shared.utils.io.dump_obj_as_json_to_file( + config_file, + { + "pipeline": [{"name": "KeywordIntentClassifier"}], + "language": "en", + "assistant_id": "placeholder_default", + }, + ) + + persisted_path = rasa.model_training.train_nlu( + str(config_file), + nlu_as_json_path, + output=str(tmp_path), + persist_nlu_training_data=False, + ) + + assert Path(persisted_path).is_file() + + model_dir = tmp_path_factory.mktemp("loaded") + storage, _ = LocalModelStorage.from_model_archive(model_dir, Path(persisted_path)) + + nlu_data_dir = model_dir / "nlu_training_data_provider" + + assert not nlu_data_dir.is_dir() diff --git a/tests/nlu/test_utils.py b/tests/nlu/test_utils.py new file mode 100644 index 0000000..338ab79 --- /dev/null +++ b/tests/nlu/test_utils.py @@ -0,0 +1,84 @@ +import os +import pickle +import pytest +import tempfile +import shutil +from typing import Text + +import rasa.shared.nlu.training_data.message +import rasa.shared.utils.io +from rasa.nlu import utils + + +@pytest.fixture(scope="function") +def empty_model_dir(): + temp_path = tempfile.mkdtemp() + yield temp_path + if os.path.exists(temp_path): + shutil.rmtree(temp_path) + + +@pytest.fixture +def fake_model_dir(empty_model_dir): + metadata_file = "metadata.json" + metadata_content = {"pipeline": "pretrained_embeddings_spacy", "language": "en"} + metadata_path = os.path.join(empty_model_dir, metadata_file) + utils.write_json_to_file(metadata_path, metadata_content) + + fake_obj = {"Fake", "model"} + fake_obj_path = os.path.join(empty_model_dir, "component.pkl") + with open(fake_obj_path, "wb") as f: + pickle.dump(fake_obj, f) + return empty_model_dir # not empty anymore ;) + + +def test_list_files_invalid_resource(): + with pytest.raises(ValueError) as execinfo: + rasa.shared.utils.io.list_files(None) + assert "must be a string type" in str(execinfo.value) + + +def test_list_files_non_existing_dir(): + with pytest.raises(ValueError) as execinfo: + rasa.shared.utils.io.list_files("my/made_up/path") + assert "Could not locate the resource" in str(execinfo.value) + + +def test_list_files_ignores_hidden_files(tmpdir): + # create a hidden file + open(os.path.join(tmpdir.strpath, ".hidden"), "a").close() + # create a normal file + normal_file = os.path.join(tmpdir.strpath, "normal_file") + open(normal_file, "a").close() + assert rasa.shared.utils.io.list_files(tmpdir.strpath) == [normal_file] + + +def test_creation_of_existing_dir(tmpdir): + # makes sure there is no exception + assert rasa.shared.utils.io.create_directory(tmpdir.strpath) is None + + +@pytest.mark.parametrize( + "url, result", + [ + ("a/b/c", False), + ("a", False), + ("https://192.168.1.1", True), + ("http://192.168.1.1", True), + ("https://google.com", True), + ("https://www.google.com", True), + ("http://google.com", True), + ("http://www.google.com", True), + ("http://www.google.com?foo=bar", True), + ("http://a/b/c", True), + ("http://localhost:5002/api/projects/default/models/tags/production", True), + ("http://rasa-x:5002/api/projects/default/models/tags/production", True), + ( + "http://rasa-x:5002/api/projects/default/models/tags/production?foo=bar", + True, + ), + ("file:///some/path/file", True), + ], +) +def test_is_url(url: Text, result: bool): + assert result == utils.is_url(url) diff --git a/tests/nlu/tokenizers/__init__.py b/tests/nlu/tokenizers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/nlu/tokenizers/test_jieba_tokenizer.py b/tests/nlu/tokenizers/test_jieba_tokenizer.py new file mode 100644 index 0000000..c0628f9 --- /dev/null +++ b/tests/nlu/tokenizers/test_jieba_tokenizer.py @@ -0,0 +1,132 @@ +import logging +from typing import Dict, Optional + +from _pytest.logging import LogCaptureFixture +from _pytest.tmpdir import TempPathFactory + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.tokenizers.jieba_tokenizer import JiebaTokenizer + +import pytest + +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.constants import TOKENS_NAMES +from rasa.shared.nlu.constants import TEXT, INTENT + + +def create_jieba(config: Optional[Dict] = None) -> JiebaTokenizer: + config = config if config else {} + return JiebaTokenizer.create( + {**JiebaTokenizer.get_default_config(), **config}, None, None, None + ) + + +@pytest.mark.parametrize( + "text, expected_tokens, expected_indices", + [ + ( + "我想去吃兰州拉面", + ["我", "想", "去", "吃", "兰州", "拉面"], + [(0, 1), (1, 2), (2, 3), (3, 4), (4, 6), (6, 8)], + ), + ( + "Micheal你好吗?", + ["Micheal", "你好", "吗", "?"], + [(0, 7), (7, 9), (9, 10), (10, 11)], + ), + ], +) +def test_jieba(text, expected_tokens, expected_indices): + tk = create_jieba() + + tokens = tk.tokenize(Message(data={TEXT: text}), attribute=TEXT) + + assert [t.text for t in tokens] == expected_tokens + assert [t.start for t in tokens] == [i[0] for i in expected_indices] + assert [t.end for t in tokens] == [i[1] for i in expected_indices] + + +def test_jieba_load_and_persist_dictionary( + tmp_path_factory: TempPathFactory, + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + caplog: LogCaptureFixture, +): + dictionary_directory = tmp_path_factory.mktemp("dictionaries") + dictionary_path = dictionary_directory / "dictionary_1" + + dictionary_contents = """ +创新办 3 i +云计算 5 +凱特琳 nz +台中 + """ + dictionary_path.write_text(dictionary_contents, encoding="utf-8") + + component_config = {"dictionary_path": dictionary_directory} + + resource = Resource("jieba") + tk = JiebaTokenizer.create( + {**JiebaTokenizer.get_default_config(), **component_config}, + default_model_storage, + resource, + default_execution_context, + ) + + tk.process_training_data(TrainingData([Message(data={TEXT: ""})])) + + # The dictionary has not been persisted yet. + with caplog.at_level(logging.DEBUG): + JiebaTokenizer.load( + {**JiebaTokenizer.get_default_config(), **component_config}, + default_model_storage, + resource, + default_execution_context, + ) + assert any( + "Failed to load JiebaTokenizer from model storage." in message + for message in caplog.messages + ) + + tk.persist() + + # Check the persisted dictionary matches the original file. + with default_model_storage.read_from(resource) as resource_dir: + contents = (resource_dir / "dictionary_1").read_text(encoding="utf-8") + assert contents == dictionary_contents + + # Delete original files to show that we read from the model storage. + dictionary_path.unlink() + dictionary_directory.rmdir() + + JiebaTokenizer.load( + {**JiebaTokenizer.get_default_config(), **component_config}, + default_model_storage, + resource, + default_execution_context, + ) + + tk.process([Message(data={TEXT: ""})]) + + +@pytest.mark.parametrize( + "text, expected_tokens", + [ + ("Forecast_for_LUNCH", ["Forecast_for_LUNCH"]), + ("Forecast for LUNCH", ["Forecast for LUNCH"]), + ], +) +def test_custom_intent_symbol(text, expected_tokens): + component_config = {"intent_tokenization_flag": True, "intent_split_symbol": "+"} + + tk = create_jieba(component_config) + + message = Message(data={TEXT: text}) + message.set(INTENT, text) + + tk.process_training_data(TrainingData([message])) + + assert [t.text for t in message.get(TOKENS_NAMES[INTENT])] == expected_tokens diff --git a/tests/nlu/tokenizers/test_mitie_tokenizer.py b/tests/nlu/tokenizers/test_mitie_tokenizer.py new file mode 100644 index 0000000..7af0668 --- /dev/null +++ b/tests/nlu/tokenizers/test_mitie_tokenizer.py @@ -0,0 +1,81 @@ +from typing import Dict, Text, Any, Tuple, List, Callable + +import pytest + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.constants import TOKENS_NAMES +from rasa.shared.nlu.constants import TEXT, INTENT +from rasa.nlu.tokenizers.mitie_tokenizer import MitieTokenizer + + +@pytest.fixture() +def tokenizer( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +) -> Callable[[Dict[Text, Any]], MitieTokenizer]: + def inner(config: Dict[Text, Any]) -> MitieTokenizer: + return MitieTokenizer.create( + {**MitieTokenizer.get_default_config(), **config}, + default_model_storage, + Resource("mitie_tokenizer"), + default_execution_context, + ) + + return inner + + +@pytest.mark.parametrize( + "text, expected_tokens, expected_indices", + [ + ( + "Forecast for lunch", + ["Forecast", "for", "lunch"], + [(0, 8), (9, 12), (13, 18)], + ), + ( + "hey ńöñàśçií how're you?", + ["hey", "ńöñàśçií", "how", "'re", "you", "?"], + [(0, 3), (4, 12), (13, 16), (16, 19), (20, 23), (23, 24)], + ), + ], +) +def test_mitie( + text: Text, + expected_tokens: List[Text], + expected_indices: List[Tuple[int, int]], + tokenizer: Callable[[Dict[Text, Any]], MitieTokenizer], +): + tk = tokenizer({}) + + tokens = tk.tokenize(Message.build(text=text), attribute=TEXT) + + assert [t.text for t in tokens] == expected_tokens + assert [t.start for t in tokens] == [i[0] for i in expected_indices] + assert [t.end for t in tokens] == [i[1] for i in expected_indices] + + +@pytest.mark.parametrize( + "text, expected_tokens", + [ + ("Forecast_for_LUNCH", ["Forecast_for_LUNCH"]), + ("Forecast for LUNCH", ["Forecast for LUNCH"]), + ], +) +def test_custom_intent_symbol( + text: Text, + expected_tokens: List[Text], + tokenizer: Callable[[Dict[Text, Any]], MitieTokenizer], +): + component_config = {"intent_tokenization_flag": True, "intent_split_symbol": "+"} + + tk = tokenizer(component_config) + + message = Message.build(text=text) + message.set(INTENT, text) + + tk.process_training_data(TrainingData([message])) + + assert [t.text for t in message.get(TOKENS_NAMES[INTENT])] == expected_tokens diff --git a/tests/nlu/tokenizers/test_spacy_tokenizer.py b/tests/nlu/tokenizers/test_spacy_tokenizer.py new file mode 100644 index 0000000..1224a2b --- /dev/null +++ b/tests/nlu/tokenizers/test_spacy_tokenizer.py @@ -0,0 +1,99 @@ +import pytest + +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.constants import SPACY_DOCS, TOKENS_NAMES +from rasa.shared.nlu.constants import TEXT, INTENT, RESPONSE +from rasa.nlu.tokenizers.spacy_tokenizer import SpacyTokenizer + + +@pytest.mark.parametrize( + "text, expected_tokens, expected_indices", + [ + ( + "Forecast for lunch", + ["Forecast", "for", "lunch"], + [(0, 8), (9, 12), (13, 18)], + ), + ( + "hey ńöñàśçií how're you?", + ["hey", "ńöñàśçií", "how", "'re", "you", "?"], + [(0, 3), (4, 12), (13, 16), (16, 19), (20, 23), (23, 24)], + ), + ], +) +def test_spacy(text, expected_tokens, expected_indices, spacy_nlp): + tk = SpacyTokenizer(SpacyTokenizer.get_default_config()) + + message = Message.build(text=text) + message.set(SPACY_DOCS[TEXT], spacy_nlp(text)) + + tokens = tk.tokenize(message, attribute=TEXT) + + assert [t.text for t in tokens] == expected_tokens + assert [t.start for t in tokens] == [i[0] for i in expected_indices] + assert [t.end for t in tokens] == [i[1] for i in expected_indices] + + +@pytest.mark.parametrize( + "text, expected_pos_tags", + [ + ("I like dogs", ["PRP", "VBP", "NNS"]), + ("Hello, how are you?", ["UH", ",", "WRB", "VBP", "PRP", "."]), + ], +) +def test_spacy_pos_tags(text, expected_pos_tags, spacy_nlp): + tk = SpacyTokenizer(SpacyTokenizer.get_default_config()) + + message = Message.build(text=text) + message.set(SPACY_DOCS[TEXT], spacy_nlp(text)) + + tokens = tk.tokenize(message, attribute=TEXT) + + assert [t.data.get("pos") for t in tokens] == expected_pos_tags + + +@pytest.mark.parametrize( + "text, expected_tokens, expected_indices", + [("Forecast for lunch", ["Forecast", "for", "lunch"], [(0, 8), (9, 12), (13, 18)])], +) +def test_train_tokenizer(text, expected_tokens, expected_indices, spacy_nlp): + tk = SpacyTokenizer(SpacyTokenizer.get_default_config()) + + message = Message.build(text=text) + message.set(SPACY_DOCS[TEXT], spacy_nlp(text)) + message.set(RESPONSE, text) + message.set(SPACY_DOCS[RESPONSE], spacy_nlp(text)) + + training_data = TrainingData() + training_data.training_examples = [message] + + tk.process_training_data(training_data) + + for attribute in [RESPONSE, TEXT]: + tokens = training_data.training_examples[0].get(TOKENS_NAMES[attribute]) + + assert [t.text for t in tokens] == expected_tokens + assert [t.start for t in tokens] == [i[0] for i in expected_indices] + assert [t.end for t in tokens] == [i[1] for i in expected_indices] + + +@pytest.mark.parametrize( + "text, expected_tokens", + [ + ("Forecast_for_LUNCH", ["Forecast_for_LUNCH"]), + ("Forecast for LUNCH", ["Forecast for LUNCH"]), + ], +) +def test_custom_intent_symbol(text, expected_tokens, spacy_nlp): + component_config = {"intent_tokenization_flag": True, "intent_split_symbol": "+"} + + tk = SpacyTokenizer(component_config) + + message = Message.build(text=text) + message.set(SPACY_DOCS[TEXT], spacy_nlp(text)) + message.set(INTENT, text) + + tk.process_training_data(TrainingData([message])) + + assert [t.text for t in message.get(TOKENS_NAMES[INTENT])] == expected_tokens diff --git a/tests/nlu/tokenizers/test_tokenizer.py b/tests/nlu/tokenizers/test_tokenizer.py new file mode 100644 index 0000000..e2a9071 --- /dev/null +++ b/tests/nlu/tokenizers/test_tokenizer.py @@ -0,0 +1,341 @@ +from typing import Any, Dict, Optional, Tuple, Text, List +import pytest + +from rasa.nlu.tokenizers.tokenizer import Token +from rasa.nlu.constants import TOKENS_NAMES +from rasa.shared.nlu.constants import ( + TEXT, + INTENT, + RESPONSE, + INTENT_RESPONSE_KEY, + ACTION_TEXT, + ACTION_NAME, +) +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer + + +def create_whitespace_tokenizer( + config: Optional[Dict[Text, Any]] = None +) -> WhitespaceTokenizer: + return WhitespaceTokenizer( + {**WhitespaceTokenizer.get_default_config(), **(config if config else {})} + ) + + +def test_tokens_comparison(): + x = Token("hello", 0) + y = Token("Hello", 0) + + assert x == x + assert y < x + + assert x != 1 + + with pytest.raises(TypeError): + assert y < "a" + + +@pytest.mark.parametrize( + "text, expected_tokens, expected_indices", + [("Forecast for lunch", ["Forecast", "for", "lunch"], [(0, 8), (9, 12), (13, 18)])], +) +def test_train_tokenizer( + text: Text, expected_tokens: List[Text], expected_indices: List[Tuple[int, int]] +): + tk = create_whitespace_tokenizer() + + message = Message.build(text=text) + message.set(RESPONSE, text) + message.set(INTENT, text) + + training_data = TrainingData() + training_data.training_examples = [message] + + tk.process_training_data(training_data) + + for attribute in [RESPONSE, TEXT]: + tokens = training_data.training_examples[0].get(TOKENS_NAMES[attribute]) + + assert [t.text for t in tokens] == expected_tokens + assert [t.start for t in tokens] == [i[0] for i in expected_indices] + assert [t.end for t in tokens] == [i[1] for i in expected_indices] + + # check intent attribute + tokens = training_data.training_examples[0].get(TOKENS_NAMES[INTENT]) + + assert [t.text for t in tokens] == [text] + + +@pytest.mark.parametrize( + "text, expected_tokens, expected_indices", + [("Forecast for lunch", ["Forecast", "for", "lunch"], [(0, 8), (9, 12), (13, 18)])], +) +def test_train_tokenizer_e2e_actions( + text: Text, expected_tokens: List[Text], expected_indices: List[Tuple[int, int]] +): + tk = create_whitespace_tokenizer() + + message = Message.build(text=text) + message.set(ACTION_TEXT, text) + message.set(ACTION_NAME, text) + + training_data = TrainingData() + training_data.training_examples = [message] + + tk.process_training_data(training_data) + + for attribute in [ACTION_TEXT, TEXT]: + tokens = training_data.training_examples[0].get(TOKENS_NAMES[attribute]) + + assert [t.text for t in tokens] == expected_tokens + assert [t.start for t in tokens] == [i[0] for i in expected_indices] + assert [t.end for t in tokens] == [i[1] for i in expected_indices] + + +@pytest.mark.parametrize( + "text, expected_tokens, expected_indices", + [("Forecast for lunch", ["Forecast", "for", "lunch"], [(0, 8), (9, 12), (13, 18)])], +) +def test_train_tokenizer_action_name( + text: Text, expected_tokens: List[Text], expected_indices: List[Tuple[int, int]] +): + tk = create_whitespace_tokenizer() + + message = Message.build(text=text) + message.set(ACTION_NAME, text) + + training_data = TrainingData() + training_data.training_examples = [message] + + tk.process_training_data(training_data) + + # check action_name attribute + tokens = training_data.training_examples[0].get(TOKENS_NAMES[ACTION_NAME]) + + assert [t.text for t in tokens] == [text] + + +@pytest.mark.parametrize( + "text, expected_tokens, expected_indices", + [("Forecast for lunch", ["Forecast", "for", "lunch"], [(0, 8), (9, 12), (13, 18)])], +) +def test_process_tokenizer( + text: Text, expected_tokens: List[Text], expected_indices: List[Tuple[int, int]] +): + tk = create_whitespace_tokenizer() + + message = Message.build(text=text) + + tk.process([message]) + + tokens = message.get(TOKENS_NAMES[TEXT]) + + assert [t.text for t in tokens] == expected_tokens + assert [t.start for t in tokens] == [i[0] for i in expected_indices] + assert [t.end for t in tokens] == [i[1] for i in expected_indices] + + +@pytest.mark.parametrize( + "text, expected_tokens", [("action_listen", ["action", "listen"])] +) +def test_process_tokenizer_action_name(text: Text, expected_tokens: List[Text]): + tk = create_whitespace_tokenizer({"intent_tokenization_flag": True}) + + message = Message.build(text=text) + message.set(ACTION_NAME, text) + + tk.process([message]) + + tokens = message.get(TOKENS_NAMES[ACTION_NAME]) + + assert [t.text for t in tokens] == expected_tokens + + +@pytest.mark.parametrize( + "text, expected_tokens", [("I am hungry", ["I", "am", "hungry"])] +) +def test_process_tokenizer_action_test(text: Text, expected_tokens: List[Text]): + tk = create_whitespace_tokenizer({"intent_tokenization_flag": True}) + + message = Message.build(text=text) + message.set(ACTION_NAME, text) + message.set(ACTION_TEXT, text) + + tk.process([message]) + + tokens = message.get(TOKENS_NAMES[ACTION_TEXT]) + assert [t.text for t in tokens] == expected_tokens + + message.set(ACTION_TEXT, "") + tk.process([message]) + tokens = message.get(TOKENS_NAMES[ACTION_NAME]) + assert [t.text for t in tokens] == [text] + + +@pytest.mark.parametrize( + "text, expected_tokens", + [ + ("Forecast_for_LUNCH", ["Forecast_for_LUNCH"]), + ("Forecast for LUNCH", ["Forecast for LUNCH"]), + ], +) +def test_split_intent(text: Text, expected_tokens: List[Text]): + component_config = {"intent_tokenization_flag": True, "intent_split_symbol": "+"} + + tk = create_whitespace_tokenizer(component_config) + + message = Message.build(text=text) + message.set(INTENT, text) + + assert [t.text for t in tk._split_name(message, INTENT)] == expected_tokens + + +@pytest.mark.parametrize( + "text, expected_tokens", + [ + ("faq/ask_language", ["faq", "ask_language"]), + ("faq/ask+language", ["faq", "ask", "language"]), + ], +) +def test_split_intent_response_key(text, expected_tokens): + component_config = {"intent_tokenization_flag": True, "intent_split_symbol": "+"} + + tk = create_whitespace_tokenizer(component_config) + + message = Message.build(text=text) + message.set(INTENT_RESPONSE_KEY, text) + + assert [ + t.text for t in tk._split_name(message, attribute=INTENT_RESPONSE_KEY) + ] == expected_tokens + + +@pytest.mark.parametrize( + "token_pattern, tokens, expected_tokens", + [ + ( + None, + [Token("hello", 0), Token("there", 6)], + [Token("hello", 0), Token("there", 6)], + ), + ( + "", + [Token("hello", 0), Token("there", 6)], + [Token("hello", 0), Token("there", 6)], + ), + ( + r"(?u)\b\w\w+\b", + [Token("role-based", 0), Token("access-control", 11)], + [ + Token("role", 0), + Token("based", 5), + Token("access", 11), + Token("control", 18), + ], + ), + ( + r".*", + [Token("role-based", 0), Token("access-control", 11)], + [Token("role-based", 0), Token("access-control", 11)], + ), + ( + r"(test)", + [Token("role-based", 0), Token("access-control", 11)], + [Token("role-based", 0), Token("access-control", 11)], + ), + ], +) +def test_apply_token_pattern( + token_pattern: Text, tokens: List[Token], expected_tokens: List[Token] +): + component_config = {"token_pattern": token_pattern} + + tokenizer = create_whitespace_tokenizer(component_config) + actual_tokens = tokenizer._apply_token_pattern(tokens) + + assert len(actual_tokens) == len(expected_tokens) + for actual_token, expected_token in zip(actual_tokens, expected_tokens): + assert actual_token.text == expected_token.text + assert actual_token.start == expected_token.start + assert actual_token.end == expected_token.end + + +@pytest.mark.parametrize( + "text, expected_tokens", + [ + ("Forecast_for_LUNCH", ["Forecast_for_LUNCH"]), + ("Forecast for LUNCH", ["Forecast for LUNCH"]), + ("Forecast+for+LUNCH", ["Forecast", "for", "LUNCH"]), + ], +) +def test_split_action_name(text: Text, expected_tokens: List[Text]): + component_config = {"intent_tokenization_flag": True, "intent_split_symbol": "+"} + + tk = create_whitespace_tokenizer(component_config) + + message = Message.build(text=text) + message.set(ACTION_NAME, text) + + assert [t.text for t in tk._split_name(message, ACTION_NAME)] == expected_tokens + + +def test_token_fingerprints_are_unique(): + """Tests that token fingerprints are consistent across runs and machines.""" + tokens = [ + Token("testing", 2, 9, {"x": 3}, "test"), + Token("testing", 3, 10, {"x": 3}, "test"), + Token("working", 2, 9, {"x": 3}, "work"), + Token("testing", 2, 9, None, "test"), + Token("testing", 2, 9), + Token("testing", 3), + ] + fingerprints = {t.fingerprint() for t in tokens} + assert len(fingerprints) == len(tokens) + + +@pytest.mark.parametrize( + "text, attribute, expected_tokens", + [ + ("Forecast_for_LUNCH", INTENT, ["Forecast_for_LUNCH"]), + ("Forecast for LUNCH", INTENT, ["Forecast for LUNCH"]), + ("Forecast+for+LUNCH", INTENT, ["Forecast", "for", "LUNCH"]), + ("PREFIX!Forecast+for+LUNCH", INTENT, ["PREFIX", "Forecast", "for", "LUNCH"]), + ("prefix!Forecast_for_LUNCH", INTENT, ["prefix", "Forecast_for_LUNCH"]), + ( + "prefix!faq/ask_language", + INTENT_RESPONSE_KEY, + ["prefix", "faq", "ask_language"], + ), + ( + "prefix!faq/ask+language", + INTENT_RESPONSE_KEY, + ["prefix", "faq", "ask", "language"], + ), + ("prefix_forecast_for_LUNCH", INTENT, ["prefix_forecast_for_LUNCH"]), + ( + "main+other!Forecast+for+LUNCH", + INTENT, + ["main", "other", "Forecast", "for", "LUNCH"], + ), + ( + "main+other!faq/ask+language", + INTENT_RESPONSE_KEY, + ["main", "other", "faq", "ask", "language"], + ), + ], +) +def test_split_intent_with_prefix(text: Text, attribute, expected_tokens: List[Text]): + component_config = { + "intent_tokenization_flag": True, + "intent_split_symbol": "+", + "prefix_separator_symbol": "!", + } + + tk = create_whitespace_tokenizer(component_config) + + message = Message.build(text=text) + message.set(attribute, text) + + assert [t.text for t in tk._split_name(message, attribute)] == expected_tokens diff --git a/tests/nlu/tokenizers/test_whitespace_tokenizer.py b/tests/nlu/tokenizers/test_whitespace_tokenizer.py new file mode 100644 index 0000000..4a2c6c5 --- /dev/null +++ b/tests/nlu/tokenizers/test_whitespace_tokenizer.py @@ -0,0 +1,221 @@ +import pytest +from typing import Dict, Optional + +import rasa.shared.utils.io +from rasa.nlu.constants import TOKENS_NAMES +from rasa.shared.nlu.constants import TEXT, INTENT, ACTION_TEXT, ACTION_NAME +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer + + +def create_whitespace_tokenizer(config: Optional[Dict] = None) -> WhitespaceTokenizer: + config = config if config else {} + return WhitespaceTokenizer({**WhitespaceTokenizer.get_default_config(), **config}) + + +@pytest.mark.parametrize( + "text, expected_tokens, expected_indices", + [ + ( + "Forecast for lunch", + ["Forecast", "for", "lunch"], + [(0, 8), (9, 12), (13, 18)], + ), + ( + "hey ńöñàśçií how're you?", + ["hey", "ńöñàśçií", "how", "re", "you"], + [(0, 3), (4, 12), (13, 16), (17, 19), (20, 23)], + ), + ( + "50 क्या आपके पास डेरी मिल्क 10 वाले बॉक्स मिल सकते है", + [ + "50", + "क्या", + "आपके", + "पास", + "डेरी", + "मिल्क", + "10", + "वाले", + "बॉक्स", + "मिल", + "सकते", + "है", + ], + [ + (0, 2), + (3, 7), + (8, 12), + (13, 16), + (17, 21), + (22, 27), + (28, 30), + (31, 35), + (36, 41), + (42, 45), + (46, 50), + (51, 53), + ], + ), + ( + "https://www.google.com/search?client=safari&rls=en&q=i+like+rasa&ie=UTF-8&oe=UTF-8 " # noqa: E501 + "https://rasa.com/docs/rasa/components#whitespacetokenizer", + [ + "https://www.google.com/search?" + "client=safari&rls=en&q=i+like+rasa&ie=UTF-8&oe=UTF-8", + "https://rasa.com/docs/rasa/components#whitespacetokenizer", + ], + [(0, 82), (83, 140)], + ), + ( + "Joselico gracias Dois 🙏🇺🇸🏦🛠🔥⭐️🦅👑💪", + ["Joselico", "gracias", "Dois"], + [(0, 8), (9, 16), (17, 21)], + ), + (":)", [":)"], [(0, 2)]), + ("Hi :-)", ["Hi"], [(0, 2)]), + ("👍", ["👍"], [(0, 1)]), + ("", [""], [(0, 0)]), + ], +) +def test_whitespace(text, expected_tokens, expected_indices): + + tk = create_whitespace_tokenizer() + + tokens = tk.tokenize(Message.build(text=text), attribute=TEXT) + + assert [t.text for t in tokens] == expected_tokens + assert [t.start for t in tokens] == [i[0] for i in expected_indices] + assert [t.end for t in tokens] == [i[1] for i in expected_indices] + + +@pytest.mark.parametrize( + "text, expected_tokens", + [ + ("Forecast_for_LUNCH", ["Forecast_for_LUNCH"]), + ("Forecast for LUNCH", ["Forecast for LUNCH"]), + ], +) +def test_custom_intent_symbol(text, expected_tokens): + component_config = {"intent_tokenization_flag": True, "intent_split_symbol": "+"} + + tk = create_whitespace_tokenizer(component_config) + + message = Message.build(text=text) + message.set(INTENT, text) + + tk.process_training_data(TrainingData([message])) + + assert [t.text for t in message.get(TOKENS_NAMES[INTENT])] == expected_tokens + + +def test_whitespace_training(): + examples = [ + Message( + data={ + TEXT: "Any Mexican restaurant will do", + "intent": "restaurant_search", + "entities": [ + {"start": 4, "end": 11, "value": "Mexican", "entity": "cuisine"} + ], + } + ), + Message( + data={ + TEXT: "I want Tacos!", + "intent": "restaurant_search", + "entities": [ + {"start": 7, "end": 12, "value": "Mexican", "entity": "cuisine"} + ], + } + ), + Message(data={TEXT: "action_restart", "action_name": "action_restart"}), + Message( + data={ + TEXT: "Where are you going?", + ACTION_NAME: "Where are you going?", + ACTION_TEXT: "Where are you going?", + } + ), + ] + + component_config = {"case_sensitive": False, "intent_tokenization_flag": True} + tk = create_whitespace_tokenizer(component_config) + + tk.process_training_data(TrainingData(training_examples=examples)) + + assert examples[0].data.get(TOKENS_NAMES[TEXT])[0].text == "Any" + assert examples[0].data.get(TOKENS_NAMES[TEXT])[1].text == "Mexican" + assert examples[0].data.get(TOKENS_NAMES[TEXT])[2].text == "restaurant" + assert examples[0].data.get(TOKENS_NAMES[TEXT])[3].text == "will" + assert examples[0].data.get(TOKENS_NAMES[TEXT])[4].text == "do" + assert examples[1].data.get(TOKENS_NAMES[TEXT])[0].text == "I" + assert examples[1].data.get(TOKENS_NAMES[TEXT])[1].text == "want" + assert examples[1].data.get(TOKENS_NAMES[TEXT])[2].text == "Tacos" + assert examples[2].data.get(TOKENS_NAMES[ACTION_NAME])[0].text == "action" + assert examples[2].data.get(TOKENS_NAMES[ACTION_NAME])[1].text == "restart" + assert examples[2].data.get(TOKENS_NAMES[TEXT])[0].text == "action_restart" + assert examples[2].data.get(TOKENS_NAMES[ACTION_TEXT]) is None + assert examples[3].data.get(TOKENS_NAMES[ACTION_TEXT])[0].text == "Where" + assert examples[3].data.get(TOKENS_NAMES[ACTION_TEXT])[1].text == "are" + assert examples[3].data.get(TOKENS_NAMES[ACTION_TEXT])[2].text == "you" + assert examples[3].data.get(TOKENS_NAMES[ACTION_TEXT])[3].text == "going" + + +def test_whitespace_does_not_throw_error(): + texts = rasa.shared.utils.io.read_json_file( + "data/test_tokenizers/naughty_strings.json" + ) + + tk = create_whitespace_tokenizer() + + for text in texts: + tk.tokenize(Message.build(text=text), attribute=TEXT) + + +@pytest.mark.parametrize("language, is_not_supported", [("en", False), ("zh", True)]) +def test_whitespace_language_support(language, is_not_supported): + assert ( + language in WhitespaceTokenizer.not_supported_languages() + ) == is_not_supported + + +def test_whitespace_processing_with_attribute(): + message = Message( + data={ + TEXT: "Any Mexican restaurant will do", + "intent": "restaurant_search", + "entities": [ + {"start": 4, "end": 11, "value": "Mexican", "entity": "cuisine"} + ], + } + ) + expected_tokens_intent = ["restaurant_search"] + expected_tokens_text = ["Any", "Mexican", "restaurant", "will", "do"] + component_config = {"case_sensitive": False} + tk = create_whitespace_tokenizer(component_config) + tk.process([message]) + tokens_intent = message.get(TOKENS_NAMES[INTENT]) + tk.process([message]) + tokens_text = message.get(TOKENS_NAMES[TEXT]) + assert [t.text for t in tokens_intent] == expected_tokens_intent + assert [t.text for t in tokens_text] == expected_tokens_text + + message = Message( + data={ + TEXT: "Where are you going?", + ACTION_NAME: "Where are you going?", + ACTION_TEXT: "Where are you going?", + } + ) + expected_action_tokens_text = ["Where", "are", "you", "going"] + + component_config = {"case_sensitive": False} + tk = create_whitespace_tokenizer(component_config) + tk.process([message]) + tokens_action_text = message.get(TOKENS_NAMES[ACTION_TEXT]) + tk.process([message]) + tokens_text = message.get(TOKENS_NAMES[TEXT]) + assert [t.text for t in tokens_action_text] == expected_action_tokens_text + assert [t.text for t in tokens_text] == expected_action_tokens_text diff --git a/tests/nlu/utilities.py b/tests/nlu/utilities.py new file mode 100644 index 0000000..02be18e --- /dev/null +++ b/tests/nlu/utilities.py @@ -0,0 +1,18 @@ +import tempfile +import ruamel.yaml as yaml + + +def write_file_config(file_config): + with tempfile.NamedTemporaryFile( + "w+", suffix="_tmp_config_file.yml", delete=False + ) as f: + f.write(yaml.safe_dump(file_config)) + f.flush() + return f + + +class ResponseTest: + def __init__(self, endpoint, expected_response, payload=None): + self.endpoint = endpoint + self.expected_response = expected_response + self.payload = payload diff --git a/tests/nlu/utils/__init__.py b/tests/nlu/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/nlu/utils/test_bilou_utils.py b/tests/nlu/utils/test_bilou_utils.py new file mode 100644 index 0000000..b40b201 --- /dev/null +++ b/tests/nlu/utils/test_bilou_utils.py @@ -0,0 +1,245 @@ +import logging +from typing import Text, List, Optional +from _pytest.logging import LogCaptureFixture +import pytest + +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +import rasa.nlu.utils.bilou_utils as bilou_utils +from rasa.nlu.constants import BILOU_ENTITIES +from rasa.shared.nlu.constants import ENTITIES +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message + + +@pytest.mark.parametrize( + "tag, expected", + [ + ("B-person", "person"), + ("I-location", "location"), + ("location", "location"), + ("U-company", "company"), + ("L-company", "company"), + ], +) +def test_entity_name_from_tag(tag, expected): + actual = bilou_utils.tag_without_prefix(tag) + + assert actual == expected + + +@pytest.mark.parametrize( + "tag, expected", + [ + ("B-person", "B-"), + ("I-location", "I-"), + ("location", None), + ("U-company", "U-"), + ("L-company", "L-"), + ("O-company", None), + ], +) +def test_bilou_from_tag(tag, expected): + actual = bilou_utils.bilou_prefix_from_tag(tag) + + assert actual == expected + + +def test_tags_to_ids(): + message = Message.build(text="Germany is part of the European Union") + message.set( + BILOU_ENTITIES, + ["U-location", "O", "O", "O", "O", "B-organisation", "L-organisation"], + ) + + tag_id_dict = {"O": 0, "U-location": 1, "B-organisation": 2, "L-organisation": 3} + + tags = bilou_utils.bilou_tags_to_ids(message, tag_id_dict) + + assert tags == [1, 0, 0, 0, 0, 2, 3] + + +def test_build_tag_id_dict(): + message_1 = Message.build( + text="Germany is part of the European Union", intent="inform" + ) + message_1.set( + BILOU_ENTITIES, + ["U-location", "O", "O", "O", "O", "B-organisation", "L-organisation"], + ) + + message_2 = Message.build(text="Berlin is the capital of Germany", intent="inform") + message_2.set(BILOU_ENTITIES, ["U-location", "O", "O", "O", "O", "U-location"]) + + training_data = TrainingData([message_1, message_2]) + + tag_id_dict = bilou_utils.build_tag_id_dict(training_data) + + assert tag_id_dict == { + "O": 0, + "B-location": 1, + "I-location": 2, + "L-location": 3, + "U-location": 4, + "B-organisation": 5, + "I-organisation": 6, + "L-organisation": 7, + "U-organisation": 8, + } + + +def test_apply_bilou_schema(whitespace_tokenizer: WhitespaceTokenizer): + + message_1 = Message.build( + text="Germany is part of the European Union", intent="inform" + ) + message_1.set( + ENTITIES, + [ + {"start": 0, "end": 7, "value": "Germany", "entity": "location"}, + { + "start": 23, + "end": 37, + "value": "European Union", + "entity": "organisation", + }, + ], + ) + + message_2 = Message.build(text="Berlin is the capital of Germany", intent="inform") + message_2.set( + ENTITIES, + [ + {"start": 0, "end": 6, "value": "Berlin", "entity": "location"}, + {"start": 25, "end": 32, "value": "Germany", "entity": "location"}, + ], + ) + + training_data = TrainingData([message_1, message_2]) + + whitespace_tokenizer.process_training_data(training_data) + + bilou_utils.apply_bilou_schema(training_data) + + assert message_1.get(BILOU_ENTITIES) == [ + "U-location", + "O", + "O", + "O", + "O", + "B-organisation", + "L-organisation", + ] + assert message_2.get(BILOU_ENTITIES) == [ + "U-location", + "O", + "O", + "O", + "O", + "U-location", + ] + + +@pytest.mark.parametrize( + "tags, confidences, expected_tags, expected_confidences, debug_message", + [ + ( + ["O", "B-person", "I-person", "L-person", "O", "U-person", "O"], + [0.99, 0.89, 0.93, 0.99, 0.89, 0.97, 0.87], + ["O", "B-person", "I-person", "L-person", "O", "U-person", "O"], + [0.99, 0.89, 0.93, 0.99, 0.89, 0.97, 0.87], + None, + ), + ( + ["O", "B-person", "B-location", "I-location", "O"], + [0.99, 0.89, 0.93, 0.78, 0.89], + ["O", "U-person", "B-location", "L-location", "O"], + [0.99, 0.89, 0.93, 0.78, 0.89], + "B- tag not closed", + ), + ( + ["O", "B-person", "I-location", "L-person"], + [0.99, 0.89, 0.77, 0.87], + ["O", "B-person", "I-person", "L-person"], + [0.99, 0.89, 0.76, 0.87], + "B- tag, L- tag pair encloses multiple entity classes", + ), + ( + ["O", "B-person", "I-location", "L-location"], + [0.99, 0.78, 0.93, 0.96], + ["O", "B-location", "I-location", "L-location"], + [0.99, 0.79, 0.93, 0.96], + "B- tag, L- tag pair encloses multiple entity classes", + ), + ( + ["O", "B-person", "I-location", "L-location"], + [0.99, 0.99, 0.77, 0.77], + ["O", "B-location", "I-location", "L-location"], + [0.99, 0.72, 0.77, 0.77], + "B- tag, L- tag pair encloses multiple entity classes", + ), + ( + ["O", "B-person", "I-location", "L-location", "B-person", "L-person"], + [0.99, 0.78, 0.93, 0.96, 0.93, 0.96], + ["O", "B-location", "I-location", "L-location", "B-person", "L-person"], + [0.99, 0.79, 0.93, 0.96, 0.93, 0.96], + "B- tag, L- tag pair encloses multiple entity classes", + ), + ( + ["O", "B-person", "O"], + [0.99, 0.89, 0.87], + ["O", "U-person", "O"], + [0.99, 0.89, 0.87], + "B- tag not closed", + ), + ( + ["O", "B-person"], + [0.99, 0.89], + ["O", "U-person"], + [0.99, 0.89], + "B- tag not closed", + ), + ( + ["O", "B-person", "I-person"], + [0.99, 0.89, 0.87], + ["O", "B-person", "L-person"], + [0.99, 0.89, 0.87], + "B- tag not closed", + ), + ( + ["O", "B-person", "I-location"], + [0.99, 0.89, 0.78], + ["O", "B-person", "L-person"], + [0.99, 0.89, 0.64], + "B- tag not closed", + ), + ( + ["O", "B-person", "B-location"], + [0.99, 0.89, 0.89], + ["O", "U-person", "U-location"], + [0.99, 0.89, 0.89], + "B- tag not closed", + ), + ], +) +def test_check_consistent_bilou_tagging( + tags: List[Text], + confidences: List[float], + expected_tags: List[Text], + expected_confidences: List[float], + debug_message: Optional[Text], + caplog: LogCaptureFixture, +): + + with caplog.at_level(logging.DEBUG): + actual_tags, actual_confidences = bilou_utils.ensure_consistent_bilou_tagging( + tags, confidences + ) + + if debug_message: + assert len(caplog.records) > 0 + assert debug_message in caplog.text + else: + assert len(caplog.records) == 0 + + assert actual_tags == expected_tags + assert actual_confidences == expected_confidences diff --git a/tests/nlu/utils/test_mitie_utils.py b/tests/nlu/utils/test_mitie_utils.py new file mode 100644 index 0000000..b481d47 --- /dev/null +++ b/tests/nlu/utils/test_mitie_utils.py @@ -0,0 +1,65 @@ +import shutil +from pathlib import Path + +import pytest + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.utils.mitie_utils import MitieNLP +import mitie + +from rasa.shared.exceptions import RasaException + + +def test_provide( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + component = MitieNLP.create( + MitieNLP.get_default_config(), + default_model_storage, + Resource("mitie"), + default_execution_context, + ) + + model = component.provide() + + expected_path = Path("data", "total_word_feature_extractor.dat") + assert model.model_path == expected_path + assert isinstance(model.word_feature_extractor, mitie.total_word_feature_extractor) + + assert model.fingerprint() == str(expected_path) + + +def test_provide_different_path( + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, + tmp_path: Path, +): + new_path = shutil.copy(Path("data", "total_word_feature_extractor.dat"), tmp_path) + + component = MitieNLP.create( + {"model": new_path}, + default_model_storage, + Resource("mitie"), + default_execution_context, + ) + + model = component.provide() + + assert model.model_path == Path(new_path) + assert isinstance(model.word_feature_extractor, mitie.total_word_feature_extractor) + + assert model.fingerprint() == str(new_path) + + +def test_invalid_path( + default_model_storage: ModelStorage, default_execution_context: ExecutionContext +): + with pytest.raises(RasaException): + MitieNLP.create( + {"model": "some-path"}, + default_model_storage, + Resource("mitie"), + default_execution_context, + ) diff --git a/tests/nlu/utils/test_pattern_utils.py b/tests/nlu/utils/test_pattern_utils.py new file mode 100644 index 0000000..c53d7bf --- /dev/null +++ b/tests/nlu/utils/test_pattern_utils.py @@ -0,0 +1,220 @@ +from typing import Dict, List, Text + +import pytest + +import rasa.nlu.utils.pattern_utils as pattern_utils +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.message import Message + + +@pytest.mark.parametrize( + "lookup_tables, regex_features, expected_patterns", + [ + ( + {"name": "person", "elements": ["Max", "John"]}, + {}, + [{"name": "person", "pattern": "(\\bMax\\b|\\bJohn\\b)"}], + ), + ({}, {}, []), + ( + {}, + {"name": "zipcode", "pattern": "[0-9]{5}"}, + [{"name": "zipcode", "pattern": "[0-9]{5}"}], + ), + ( + {"name": "person", "elements": ["Max", "John"]}, + {"name": "zipcode", "pattern": "[0-9]{5}"}, + [ + {"name": "zipcode", "pattern": "[0-9]{5}"}, + {"name": "person", "pattern": "(\\bMax\\b|\\bJohn\\b)"}, + ], + ), + ( + {"name": "plates", "elements": "data/test/lookup_tables/plates.txt"}, + {"name": "zipcode", "pattern": "[0-9]{5}"}, + [ + {"name": "zipcode", "pattern": "[0-9]{5}"}, + { + "name": "plates", + "pattern": "(\\btacos\\b|\\bbeef\\b|\\bmapo\\ " + "tofu\\b|\\bburrito\\b|\\blettuce\\ wrap\\b)", + }, + ], + ), + ], +) +def test_extract_patterns( + lookup_tables: Dict[Text, List[Text]], + regex_features: Dict[Text, Text], + expected_patterns: Dict[Text, Text], +): + training_data = TrainingData() + if lookup_tables: + training_data.lookup_tables = [lookup_tables] + if regex_features: + training_data.regex_features = [regex_features] + + actual_patterns = pattern_utils.extract_patterns(training_data) + + assert actual_patterns == expected_patterns + + +@pytest.mark.parametrize( + "entity, regex_features, expected_patterns", + [ + ("", {}, []), + ( + "zipcode", + {"name": "zipcode", "pattern": "[0-9]{5}"}, + [{"name": "zipcode", "pattern": "[0-9]{5}"}], + ), + ("entity", {"name": "zipcode", "pattern": "[0-9]{5}"}, []), + ], +) +def test_extract_patterns_use_only_entities_regexes( + entity: Text, regex_features: Dict[Text, Text], expected_patterns: Dict[Text, Text] +): + training_data = TrainingData() + if entity: + training_data.training_examples = [ + Message( + data={ + "text": "text", + "intent": "greet", + "entities": [{"entity": entity, "value": "text"}], + } + ) + ] + if regex_features: + training_data.regex_features = [regex_features] + + actual_patterns = pattern_utils.extract_patterns( + training_data, use_only_entities=True + ) + + assert actual_patterns == expected_patterns + + +@pytest.mark.parametrize( + "entity, lookup_tables, expected_patterns", + [ + ("", {}, []), + ( + "person", + {"name": "person", "elements": ["Max", "John"]}, + [{"name": "person", "pattern": "(\\bMax\\b|\\bJohn\\b)"}], + ), + ("entity", {"name": "person", "elements": ["Max", "John"]}, []), + ], +) +def test_extract_patterns_use_only_entities_lookup_tables( + entity: Text, lookup_tables: Dict[Text, Text], expected_patterns: Dict[Text, Text] +): + training_data = TrainingData() + if entity: + training_data.training_examples = [ + Message( + data={ + "text": "text", + "intent": "greet", + "entities": [{"entity": entity, "value": "text"}], + } + ) + ] + if lookup_tables: + training_data.lookup_tables = [lookup_tables] + + actual_patterns = pattern_utils.extract_patterns( + training_data, use_only_entities=True + ) + + assert actual_patterns == expected_patterns + + +@pytest.mark.parametrize( + "lookup_tables, regex_features, use_lookup_tables, " + "use_regex_features, expected_patterns", + [ + ({"name": "person", "elements": ["Max", "John"]}, {}, False, True, []), + ({}, {}, True, True, []), + ({}, {"name": "zipcode", "pattern": "[0-9]{5}"}, True, False, []), + ( + {"name": "person", "elements": ["Max", "John"]}, + {"name": "zipcode", "pattern": "[0-9]{5}"}, + False, + False, + [], + ), + ( + {"name": "person", "elements": ["Max", "John"]}, + {"name": "zipcode", "pattern": "[0-9]{5}"}, + True, + False, + [{"name": "person", "pattern": "(\\bMax\\b|\\bJohn\\b)"}], + ), + ( + {"name": "person", "elements": ["Max", "John"]}, + {"name": "zipcode", "pattern": "[0-9]{5}"}, + False, + True, + [{"name": "zipcode", "pattern": "[0-9]{5}"}], + ), + ], +) +def test_extract_patterns_use_only_lookup_tables_or_regex_features( + lookup_tables: Dict[Text, List[Text]], + regex_features: Dict[Text, Text], + use_lookup_tables: bool, + use_regex_features: bool, + expected_patterns: Dict[Text, Text], +): + training_data = TrainingData() + if lookup_tables: + training_data.lookup_tables = [lookup_tables] + if regex_features: + training_data.regex_features = [regex_features] + + actual_patterns = pattern_utils.extract_patterns( + training_data, + use_lookup_tables=use_lookup_tables, + use_regexes=use_regex_features, + ) + + assert actual_patterns == expected_patterns + + +@pytest.mark.parametrize( + "lookup_tables, regex_features, use_lookup_tables, use_regex_features", + [ + ( + {"name": "person", "elements": ["Max", "John"]}, + {"name": "zipcode", "pattern": "*[0-9]{5}"}, + True, + True, + ) + ], +) +def test_regex_validation( + lookup_tables: Dict[Text, List[Text]], + regex_features: Dict[Text, Text], + use_lookup_tables: bool, + use_regex_features: bool, +): + """Tests if exception is raised when regex patterns are invalid.""" + + training_data = TrainingData() + if lookup_tables: + training_data.lookup_tables = [lookup_tables] + if regex_features: + training_data.regex_features = [regex_features] + + with pytest.raises(Exception) as e: + pattern_utils.extract_patterns( + training_data, + use_lookup_tables=use_lookup_tables, + use_regexes=use_regex_features, + ) + + assert "Model training failed." in str(e.value) + assert "not a valid regex." in str(e.value) + assert "Please update your nlu training data configuration" in str(e.value) diff --git a/tests/nlu/utils/test_spacy_utils.py b/tests/nlu/utils/test_spacy_utils.py new file mode 100644 index 0000000..d7593dc --- /dev/null +++ b/tests/nlu/utils/test_spacy_utils.py @@ -0,0 +1,45 @@ +import pytest +from pytest import MonkeyPatch +from unittest.mock import MagicMock + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.model import InvalidModelError +from rasa.nlu.utils.spacy_utils import SpacyNLP +import spacy + + +def test_spacy_runtime_model_version_compatibility( + monkeypatch: MonkeyPatch, + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, +): + def mock_info_return(mock_model_name): + return {"spacy_version": ">=3.4.0,<3.5.0", "dummy_keys": "dummy_values"} + + def mock_spacy_obj_return(*args, **kwargs): + return MagicMock() + + monkeypatch.setattr(spacy, "info", mock_info_return) + monkeypatch.setattr(spacy, "load", mock_spacy_obj_return) + + # Test case that runs model on incompatible spacy runtime. + spacy.about.__version__ = "3.10" + with pytest.raises(InvalidModelError): + _ = SpacyNLP.create( + {"model": "some_model"}, + default_model_storage, + Resource("spacy"), + default_execution_context, + ) + + # Test case that runs model on compatible spacy runtime + spacy.about.__version__ = "3.4.1" + component = SpacyNLP.create( + {"model": "some_model"}, + default_model_storage, + Resource("spacy"), + default_execution_context, + ) + assert isinstance(component, SpacyNLP) diff --git a/tests/regressions/test_action_extract_slots_11333.py b/tests/regressions/test_action_extract_slots_11333.py new file mode 100644 index 0000000..6bf9d08 --- /dev/null +++ b/tests/regressions/test_action_extract_slots_11333.py @@ -0,0 +1,60 @@ +import os +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Text + +import pytest + +from rasa import train +from rasa.core.agent import load_agent +from rasa.core.channels import UserMessage, CollectingOutputChannel + +SENDER = "sender" + +BOT_DIRECTORY = ( + Path(os.path.dirname(__file__)).parent.parent + / "data" + / "test_action_extract_slots_11333" +) + + +@pytest.fixture +def model_file(): + if not (BOT_DIRECTORY / "models" / "model.tar.gz").exists(): + output_directory = TemporaryDirectory() + print(output_directory) + + train( + domain=str(BOT_DIRECTORY / "domain.yml"), + config=str(BOT_DIRECTORY / "config.yml"), + training_files=str(BOT_DIRECTORY / "data"), + output=str(BOT_DIRECTORY / "models"), + fixed_model_name="model", + ) + + return str(BOT_DIRECTORY / "models" / "model.tar.gz") + + +@pytest.mark.flaky +async def test_retaining_slot_values_with_augmented_memoization(model_file: Text): + agent = await load_agent(model_path=model_file) + + output_channel = CollectingOutputChannel() + + await agent.handle_message( + _build_user_message(output_channel, "Block my savings account") + ) + await agent.handle_message(_build_user_message(output_channel, "Hi")) + await agent.handle_message(_build_user_message(output_channel, "Hi")) + await agent.handle_message( + _build_user_message(output_channel, "Block my savings account") + ) + + assert output_channel.messages[-1] == { + "recipient_id": SENDER, + "text": "your account has been blocked", + } + + +def _build_user_message(output_channel, text): + return UserMessage(text=text, sender_id=SENDER, output_channel=output_channel) diff --git a/tests/regressions/test_action_two_stage_fallback_11294.py b/tests/regressions/test_action_two_stage_fallback_11294.py new file mode 100644 index 0000000..a7d1603 --- /dev/null +++ b/tests/regressions/test_action_two_stage_fallback_11294.py @@ -0,0 +1,174 @@ +import textwrap +from pathlib import Path +from typing import Callable + +import rasa.core.agent +from rasa.core.channels import UserMessage +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION +from rasa.shared.core.events import ActionExecuted, ActiveLoop, BotUttered, UserUttered + + +async def test_action_two_stage_fallback_does_not_return_key_error( + tmp_path: Path, + trained_async: Callable, +): + config = tmp_path / "config.yml" + + config.write_text( + textwrap.dedent( + """ + recipe: default.v1 + assistant_id: placeholder_default + + language: en + + pipeline: + - name: WhitespaceTokenizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: DIETClassifier + epochs: 20 + constrain_similarities: true + - name: FallbackClassifier + threshold: 0.7 + ambiguity_threshold: 0.1 + + policies: + - name: RulePolicy + enable_fallback_prediction: True + - name: AugmentedMemoizationPolicy + - name: TEDPolicy + epochs: 20 + constrain_similarities: true + """ + ) + ) + + nlu_file = tmp_path / "nlu.yml" + nlu_file.write_text( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + nlu: + - intent: greet + examples: | + - hey + - hello + - hi + + - intent: affirm + examples: | + - yes + - y + - indeed + - of course + - that sounds good + - correct + + - intent: deny + examples: | + - no + - n + - never + - I don't think so + - don't like that + - no way + - not really + + - intent: mood_great + examples: | + - amazing + - perfect + - wonderful + + - intent: goodbye + examples: | + - cu + - goodbye + - good night + - have a nice day + - see you around + - bye bye + """ + ) + ) + + utter_default_text = "I'm sorry, I can't help you. Bypass to agent" + domain = tmp_path / "domain.yml" + domain.write_text( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + - affirm + - deny + - mood_great + + slots: + custom_slot: + type: text + influence_conversation: true + mappings: + - type: custom + + responses: + utter_greet: + - text: "Hey! How are you?" + + utter_ask_rephrase: + - text: "I'm sorry, I didn't understand that. Could you rephrase?" + + utter_default: + - text: {utter_default_text} + """ + ) + ) + rules_file = tmp_path / "rules.yml" + rules_file.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + rules: + - rule: test + steps: + - intent: nlu_fallback + - action: action_two_stage_fallback + - active_loop: action_two_stage_fallback + """ + ) + model = await trained_async( + domain=domain, + config=config, + training_files=[nlu_file, rules_file], + ) + agent = await rasa.core.agent.load_agent(model) + agent.load_model(model) + + tracker = await agent.tracker_store.get_or_create_tracker(sender_id="test_11294") + tracker.update_with_events( + [ + UserUttered("Hi"), + ActionExecuted("utter_greet"), + BotUttered("Hey! How are you?"), + ActionExecuted("action_listen"), + UserUttered("wunderbar"), + ActiveLoop("action_two_stage_fallback"), + ], + domain, + ) + await agent.tracker_store.save(tracker) + + message = UserMessage( + parse_data={ + "entities": [], + "intent": {"confidence": 0.94, "name": "deny"}, + "message_id": None, + "metadata": {}, + "text": "/out_of_scope", + }, + sender_id="test_11294", + ) + responses = await agent.handle_message(message) + + assert any(bot_msg["text"] == utter_default_text for bot_msg in responses) diff --git a/tests/regressions/test_domain_merge_multiple_files_with_custom_session_config_and_no_session_config.py b/tests/regressions/test_domain_merge_multiple_files_with_custom_session_config_and_no_session_config.py new file mode 100644 index 0000000..a224b13 --- /dev/null +++ b/tests/regressions/test_domain_merge_multiple_files_with_custom_session_config_and_no_session_config.py @@ -0,0 +1,40 @@ +from rasa.shared.core.domain import SessionConfig +from rasa.shared.importers.importer import TrainingDataImporter + + +def test_merge_domain_with_custom_session_config_and_no_session_config(): + expected_session_expiration_time = 0 + expected_carry_over_slots = False + + config_path = ( + "data/test_domains/" + "test_domain_files_with_no_session_config_and_custom_session_config/config.yml" + ) + domain_path = ( + "data/test_domains/" + "test_domain_files_with_no_session_config_and_custom_session_config/domain.yml" + ) + training_data_paths = [ + "data/test_domains/" + "test_domain_files_with_no_session_config_and_custom_session_config/data" + ] + file_importer = TrainingDataImporter.load_from_config( + config_path, domain_path, training_data_paths + ) + + domain = file_importer.get_domain() + + assert ( + domain.session_config.session_expiration_time + != SessionConfig.default().session_expiration_time + ) + assert ( + domain.session_config.carry_over_slots + != SessionConfig.default().carry_over_slots + ) + + assert ( + domain.session_config.session_expiration_time + == expected_session_expiration_time + ) + assert domain.session_config.carry_over_slots == expected_carry_over_slots diff --git a/tests/scripts/__init__.py b/tests/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scripts/test_evaluate_release_tag.py b/tests/scripts/test_evaluate_release_tag.py new file mode 100644 index 0000000..a7aed17 --- /dev/null +++ b/tests/scripts/test_evaluate_release_tag.py @@ -0,0 +1,90 @@ +from scripts.evaluate_release_tag import filter_ga_relases, should_build_docs +import pytest +from pep440_version_utils import Version +from typing import List +from unittest.mock import patch + + +@pytest.mark.parametrize( + "releases, expected", + [ + ( + [Version("1.1.0"), Version("2.2.0")], + [Version("1.1.0"), Version("2.2.0")], + ), + ( + [Version("1.1.0"), Version("2.2.0"), Version("1.1.1a")], + [Version("1.1.0"), Version("2.2.0")], + ), + ( + [ + Version("1.1.0"), + Version("2.2.0"), + Version("1.1.1a1"), + Version("1.1.1b1"), + Version("1.1.1rc1"), + ], + [Version("1.1.0"), Version("2.2.0")], + ), + ], +) +def test_filter_ga_releases(releases: List[Version], expected: List[Version]): + result = filter_ga_relases(releases) + assert result == expected + + +@pytest.mark.parametrize( + "releases, tag, expected", + [ + ( + [Version("1.1.0"), Version("2.2.0")], + Version("2.3.0"), + True, + ), + ( + [Version("1.1.0"), Version("2.2.0"), Version("2.3.0a1")], + Version("2.2.1"), + True, + ), + ( + [ + Version("1.1.0"), + Version("2.2.0"), + Version("2.3.0b1"), + Version("2.4.0a1"), + ], + Version("2.2.1"), + True, + ), + ( + [Version("1.1.0"), Version("2.2.0"), Version("2.3.0")], + Version("1.2.0"), + False, + ), + ( + [Version("1.1.0"), Version("1.2.0a1"), Version("2.3.0")], + Version("1.1.2"), + False, + ), + ( + [Version("1.1.0"), Version("2.2.0"), Version("2.3.0")], + Version("2.2.1"), + False, + ), + ( + [Version("1.1.0"), Version("2.2.0"), Version("2.3.0")], + Version("2.2.1a1"), + False, + ), + ], +) +@patch("scripts.evaluate_release_tag.git_existing_tag_versions") +def test_should_build_docs( + mock_get_existing_tag_versions, + releases: List[Version], + tag: Version, + expected: bool, +) -> None: + mock_get_existing_tag_versions.return_value = releases + result = should_build_docs(tag) + assert result == expected diff --git a/tests/shared/__init__.py b/tests/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/shared/core/__init__.py b/tests/shared/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/shared/core/test_constants.py b/tests/shared/core/test_constants.py new file mode 100644 index 0000000..b3c8126 --- /dev/null +++ b/tests/shared/core/test_constants.py @@ -0,0 +1,23 @@ +from typing import Text, Type + +import pytest + +from rasa.core.policies.rule_policy import RulePolicy +from rasa.nlu.classifiers.fallback_classifier import FallbackClassifier +from rasa.shared.core.constants import CLASSIFIER_NAME_FALLBACK, POLICY_NAME_RULE + + +@pytest.mark.parametrize( + "name_in_constant, policy_class", + [(POLICY_NAME_RULE, RulePolicy), (CLASSIFIER_NAME_FALLBACK, FallbackClassifier)], +) +def test_policy_names(name_in_constant: Text, policy_class: Type): + assert name_in_constant == policy_class.__name__ + + +@pytest.mark.parametrize( + "name_in_constant, classifier_class", + [(CLASSIFIER_NAME_FALLBACK, FallbackClassifier)], +) +def test_classifier_names(name_in_constant: Text, classifier_class: Type): + assert name_in_constant == classifier_class.__name__ diff --git a/tests/shared/core/test_dialogues.py b/tests/shared/core/test_dialogues.py new file mode 100644 index 0000000..7388faa --- /dev/null +++ b/tests/shared/core/test_dialogues.py @@ -0,0 +1,37 @@ +import json + +import pytest + +from rasa.shared.core.conversation import Dialogue +from rasa.shared.core.domain import Domain +from rasa.core.tracker_store import InMemoryTrackerStore +from tests.dialogues import ( + TEST_DIALOGUES, + TEST_DEFAULT_DIALOGUE, + TEST_DOMAINS_FOR_DIALOGUES, +) +from tests.core.utilities import tracker_from_dialogue + + +@pytest.mark.parametrize("pair", zip(TEST_DIALOGUES, TEST_DOMAINS_FOR_DIALOGUES)) +async def test_inmemory_tracker_store(pair): + dialogue, domainpath = pair + domain = Domain.load(domainpath) + tracker = tracker_from_dialogue(dialogue, domain) + tracker_store = InMemoryTrackerStore(domain) + await tracker_store.save(tracker) + restored = await tracker_store.retrieve(tracker.sender_id) + assert restored == tracker + + +def test_tracker_default(domain: Domain): + tracker = tracker_from_dialogue(TEST_DEFAULT_DIALOGUE, domain) + assert tracker.get_slot("name") == "Peter" + assert tracker.get_slot("price") is None # slot doesn't exist! + + +def test_dialogue_from_parameters(domain: Domain): + tracker = tracker_from_dialogue(TEST_DEFAULT_DIALOGUE, domain) + serialised_dialogue = InMemoryTrackerStore.serialise_tracker(tracker) + deserialised_dialogue = Dialogue.from_parameters(json.loads(serialised_dialogue)) + assert tracker.as_dialogue().as_dict() == deserialised_dialogue.as_dict() diff --git a/tests/shared/core/test_domain.py b/tests/shared/core/test_domain.py new file mode 100644 index 0000000..ccf8ff2 --- /dev/null +++ b/tests/shared/core/test_domain.py @@ -0,0 +1,2382 @@ +import copy +import json +import re +import textwrap +from pathlib import Path +import random +from typing import Dict, List, Text, Any, Union, Set, Optional + +import pytest +from pytest import WarningsRecorder + +from rasa.shared.exceptions import YamlSyntaxException, YamlException +import rasa.shared.utils.io +from rasa.shared.constants import ( + DEFAULT_SESSION_EXPIRATION_TIME_IN_MINUTES, + LATEST_TRAINING_DATA_FORMAT_VERSION, + IGNORED_INTENTS, +) +from rasa.core import training +from rasa.core.featurizers.tracker_featurizers import MaxHistoryTrackerFeaturizer +from rasa.shared.core.slots import InvalidSlotTypeException, TextSlot +from rasa.shared.core.constants import ( + DEFAULT_INTENTS, + SLOT_LISTED_ITEMS, + SLOT_LAST_OBJECT, + SLOT_LAST_OBJECT_TYPE, + DEFAULT_KNOWLEDGE_BASE_ACTION, + ENTITY_LABEL_SEPARATOR, + DEFAULT_ACTION_NAMES, +) +from rasa.shared.core.domain import ( + InvalidDomain, + SessionConfig, + EntityProperties, + ENTITY_ROLES_KEY, + USED_ENTITIES_KEY, + USE_ENTITIES_KEY, + IGNORE_ENTITIES_KEY, + State, + Domain, + KEY_FORMS, + KEY_E2E_ACTIONS, + KEY_INTENTS, + KEY_ENTITIES, + KEY_SLOTS, +) +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.events import ActionExecuted, SlotSet, UserUttered +from rasa.shared.utils.validation import YamlValidationException +from rasa.utils.common import EXPECTED_WARNINGS + + +def test_slots_states_before_user_utterance(domain: Domain): + featurizer = MaxHistoryTrackerFeaturizer() + tracker = DialogueStateTracker.from_events( + "bla", + evts=[ + SlotSet(domain.slots[0].name, "some_value"), + ActionExecuted("utter_default"), + ], + slots=domain.slots, + ) + trackers_as_states, _ = featurizer.training_states_and_labels([tracker], domain) + expected_states = [[{"slots": {"name": (1.0,)}}]] + assert trackers_as_states == expected_states + + +def test_create_train_data_no_history(domain: Domain, stories_path: Text): + featurizer = MaxHistoryTrackerFeaturizer(max_history=1) + training_trackers = training.load_data(stories_path, domain, augmentation_factor=0) + + assert len(training_trackers) == 4 + (decoded, _) = featurizer.training_states_and_labels(training_trackers, domain) + + # decoded needs to be sorted + hashed = [] + for states in decoded: + hashed.append(json.dumps(states, sort_keys=True)) + hashed = sorted(hashed, reverse=True) + + assert hashed == [ + "[{}]", + '[{"prev_action": {"action_name": "utter_greet"}, "user": {"intent": "greet"}}]', + '[{"prev_action": {"action_name": "utter_greet"}, "slots": {"name": [1.0]}, "user": {"entities": ["name"], "intent": "greet"}}]', + '[{"prev_action": {"action_name": "utter_goodbye"}, "user": {"intent": "goodbye"}}]', + '[{"prev_action": {"action_name": "utter_default"}, "user": {"intent": "default"}}]', + '[{"prev_action": {"action_name": "utter_default"}, "slots": {"name": [1.0]}, "user": {"intent": "default"}}]', + '[{"prev_action": {"action_name": "action_listen"}, "user": {"intent": "greet"}}]', + '[{"prev_action": {"action_name": "action_listen"}, "user": {"intent": "goodbye"}}]', + '[{"prev_action": {"action_name": "action_listen"}, "user": {"intent": "default"}}]', + '[{"prev_action": {"action_name": "action_listen"}, "slots": {"name": [1.0]}, "user": {"intent": "default"}}]', + '[{"prev_action": {"action_name": "action_listen"}, "slots": {"name": [1.0]}, "user": {"entities": ["name"], "intent": "greet"}}]', + ] + + +def test_create_train_data_with_history(domain: Domain, stories_path: Text): + featurizer = MaxHistoryTrackerFeaturizer(max_history=4) + training_trackers = training.load_data(stories_path, domain, augmentation_factor=0) + assert len(training_trackers) == 4 + (decoded, _) = featurizer.training_states_and_labels(training_trackers, domain) + + # decoded needs to be sorted + hashed = [] + for states in decoded: + hashed.append(json.dumps(states, sort_keys=True)) + hashed = sorted(hashed) + + assert hashed == [ + '[{"prev_action": {"action_name": "action_listen"}, "slots": {"name": [1.0]}, "user": {"entities": ["name"], "intent": "greet"}}, {"prev_action": {"action_name": "utter_greet"}, "slots": {"name": [1.0]}, "user": {"entities": ["name"], "intent": "greet"}}, {"prev_action": {"action_name": "action_listen"}, "slots": {"name": [1.0]}, "user": {"intent": "default"}}, {"prev_action": {"action_name": "utter_default"}, "slots": {"name": [1.0]}, "user": {"intent": "default"}}]', + '[{"prev_action": {"action_name": "action_listen"}, "user": {"intent": "default"}}, {"prev_action": {"action_name": "utter_default"}, "user": {"intent": "default"}}, {"prev_action": {"action_name": "action_listen"}, "user": {"intent": "goodbye"}}, {"prev_action": {"action_name": "utter_goodbye"}, "user": {"intent": "goodbye"}}]', + '[{"prev_action": {"action_name": "action_listen"}, "user": {"intent": "greet"}}, {"prev_action": {"action_name": "utter_greet"}, "user": {"intent": "greet"}}, {"prev_action": {"action_name": "action_listen"}, "user": {"intent": "default"}}, {"prev_action": {"action_name": "utter_default"}, "user": {"intent": "default"}}]', + '[{"prev_action": {"action_name": "utter_greet"}, "user": {"intent": "greet"}}, {"prev_action": {"action_name": "action_listen"}, "user": {"intent": "default"}}, {"prev_action": {"action_name": "utter_default"}, "user": {"intent": "default"}}, {"prev_action": {"action_name": "action_listen"}, "user": {"intent": "goodbye"}}]', + '[{}, {"prev_action": {"action_name": "action_listen"}, "slots": {"name": [1.0]}, "user": {"entities": ["name"], "intent": "greet"}}, {"prev_action": {"action_name": "utter_greet"}, "slots": {"name": [1.0]}, "user": {"entities": ["name"], "intent": "greet"}}, {"prev_action": {"action_name": "action_listen"}, "slots": {"name": [1.0]}, "user": {"intent": "default"}}]', + '[{}, {"prev_action": {"action_name": "action_listen"}, "slots": {"name": [1.0]}, "user": {"entities": ["name"], "intent": "greet"}}, {"prev_action": {"action_name": "utter_greet"}, "slots": {"name": [1.0]}, "user": {"entities": ["name"], "intent": "greet"}}]', + '[{}, {"prev_action": {"action_name": "action_listen"}, "slots": {"name": [1.0]}, "user": {"entities": ["name"], "intent": "greet"}}]', + '[{}, {"prev_action": {"action_name": "action_listen"}, "user": {"intent": "goodbye"}}, {"prev_action": {"action_name": "utter_goodbye"}, "user": {"intent": "goodbye"}}]', + '[{}, {"prev_action": {"action_name": "action_listen"}, "user": {"intent": "goodbye"}}]', + '[{}, {"prev_action": {"action_name": "action_listen"}, "user": {"intent": "greet"}}, {"prev_action": {"action_name": "utter_greet"}, "user": {"intent": "greet"}}, {"prev_action": {"action_name": "action_listen"}, "user": {"intent": "default"}}]', + '[{}, {"prev_action": {"action_name": "action_listen"}, "user": {"intent": "greet"}}, {"prev_action": {"action_name": "utter_greet"}, "user": {"intent": "greet"}}]', + '[{}, {"prev_action": {"action_name": "action_listen"}, "user": {"intent": "greet"}}]', + "[{}]", + ] + + +def check_for_too_many_entities_and_remove_them(state: State) -> State: + # we ignore entities where there are > 1 of them: + # entities come from dictionary keys; as a result, they are stored + # in different order in the tuple which makes the test unstable + if ( + state.get("user") + and state.get("user", {}).get("entities") + and len(state.get("user").get("entities")) > 1 + ): + state.get("user")["entities"] = () + return state + + +def test_create_train_data_unfeaturized_entities(): + domain_file = "data/test_domains/default_unfeaturized_entities.yml" + stories_file = "data/test_yaml_stories/stories_unfeaturized_entities.yml" + domain = Domain.load(domain_file) + featurizer = MaxHistoryTrackerFeaturizer(max_history=1) + training_trackers = training.load_data(stories_file, domain, augmentation_factor=0) + + assert len(training_trackers) == 2 + (decoded, _) = featurizer.training_states_and_labels(training_trackers, domain) + + # decoded needs to be sorted + hashed = [] + for states in decoded: + new_states = [ + check_for_too_many_entities_and_remove_them(state) for state in states + ] + + hashed.append(json.dumps(new_states, sort_keys=True)) + hashed = sorted(hashed, reverse=True) + + assert hashed == [ + "[{}]", + '[{"prev_action": {"action_name": "utter_greet"}, "user": {"intent": "greet"}}]', + '[{"prev_action": {"action_name": "utter_greet"}, "user": {"entities": ["name"], "intent": "greet"}}]', + '[{"prev_action": {"action_name": "utter_goodbye"}, "user": {"intent": "goodbye"}}]', + '[{"prev_action": {"action_name": "utter_default"}, "user": {"intent": "why"}}]', + '[{"prev_action": {"action_name": "utter_default"}, "user": {"intent": "thank"}}]', + '[{"prev_action": {"action_name": "utter_default"}, "user": {"entities": [], "intent": "default"}}]', + '[{"prev_action": {"action_name": "utter_default"}, "user": {"entities": [], "intent": "ask"}}]', + '[{"prev_action": {"action_name": "action_listen"}, "user": {"intent": "why"}}]', + '[{"prev_action": {"action_name": "action_listen"}, "user": {"intent": "thank"}}]', + '[{"prev_action": {"action_name": "action_listen"}, "user": {"intent": "greet"}}]', + '[{"prev_action": {"action_name": "action_listen"}, "user": {"intent": "goodbye"}}]', + '[{"prev_action": {"action_name": "action_listen"}, "user": {"entities": [], "intent": "default"}}]', + '[{"prev_action": {"action_name": "action_listen"}, "user": {"entities": [], "intent": "ask"}}]', + '[{"prev_action": {"action_name": "action_listen"}, "user": {"entities": ["name"], "intent": "greet"}}]', + ] + + +def test_domain_from_template(domain: Domain): + assert not domain.is_empty() + assert len(domain.intents) == 10 + len(DEFAULT_INTENTS) + assert len(domain.action_names_or_texts) == 18 + + +def test_avoid_action_repetition(domain: Domain): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + actions: + - utter_greet + responses: + utter_greet: + - text: "hi" + """ + ) + + assert len(domain.action_names_or_texts) == len(DEFAULT_ACTION_NAMES) + 1 + + +def test_custom_slot_type(tmpdir: Path): + domain_path = str(tmpdir / "domain.yml") + rasa.shared.utils.io.write_text_file( + """ + slots: + custom: + type: tests.core.conftest.CustomSlot + mappings: + - type: from_text + + responses: + utter_greet: + - text: hey there! """, + domain_path, + ) + Domain.load(domain_path) + + +@pytest.mark.parametrize( + "domain_unkown_slot_type", + [ + """ + slots: + custom: + type: tests.core.conftest.Unknown + mappings: + - type: from_text + + responses: + utter_greet: + - text: hey there!""", + """ + slots: + custom: + type: blubblubblub + mappings: + - type: from_text + + responses: + utter_greet: + - text: hey there!""", + ], +) +def test_domain_fails_on_unknown_custom_slot_type(tmpdir, domain_unkown_slot_type): + domain_path = str(tmpdir / "domain.yml") + rasa.shared.utils.io.write_text_file(domain_unkown_slot_type, domain_path) + with pytest.raises(InvalidSlotTypeException): + Domain.load(domain_path) + + +def test_custom_slot_type_with_custom_key(): + domain = Domain.load("data/test_domains/custom_slot_domain.yml") + + assert domain.slots[0].limit == 1000 + + +@pytest.mark.parametrize( + "domain_invalid_type_for_slot_key", + [ + """slots: + limit: + type: text + influence_conversation: yes + mappings: + - type: from_entity + entity: limit""", + """slots: + limit: + type: text + values: notalist + mappings: + - type: from_entity + entity: limit""", + """slots: + limit: + type: text + min_value: notanumber + mappings: + - type: from_entity + entity: limit""", + """slots: + limit: + type: text + max_value: notanumber + mappings: + - type: from_entity + entity: limit""", + ], +) +def test_domain_fails_on_invalid_type_for_known_slot_key( + tmpdir, domain_invalid_type_for_slot_key +): + domain_path = str(tmpdir / "domain.yml") + rasa.shared.utils.io.write_text_file(domain_invalid_type_for_slot_key, domain_path) + with pytest.raises(YamlValidationException): + Domain.load(domain_path) + + +def test_domain_to_dict(): + test_yaml = textwrap.dedent( + f""" + actions: + - action_save_world + config: + store_entities_as_slots: true + entities: [] + forms: + some_form: + required_slots: [] + intents: [] + responses: + utter_greet: + - text: hey there! + session_config: + carry_over_slots_to_new_session: true + session_expiration_time: 60 + {KEY_E2E_ACTIONS}: + - Hello, dear user + - what's up + slots: + some_slot: + type: categorical + values: + - high + - low + mappings: + - type: from_text""" + ) + + domain_as_dict = Domain.from_yaml(test_yaml).as_dict() + + assert domain_as_dict == { + "version": LATEST_TRAINING_DATA_FORMAT_VERSION, + "actions": ["action_save_world"], + "config": {"store_entities_as_slots": True}, + KEY_E2E_ACTIONS: ["Hello, dear user", "what's up"], + "forms": {"some_form": {"required_slots": []}}, + "responses": {"utter_greet": [{"text": "hey there!"}]}, + "session_config": { + "carry_over_slots_to_new_session": True, + "session_expiration_time": 60, + }, + "slots": { + "some_slot": { + "values": ["high", "low"], + "mappings": [{"type": "from_text"}], + "type": "categorical", + } + }, + } + + +def test_domain_to_yaml(): + test_yaml = f""" +version: '{LATEST_TRAINING_DATA_FORMAT_VERSION}' +actions: +- action_save_world +config: + store_entities_as_slots: true +e2e_actions: [] +entities: [] +forms: {{}} +intents: [] +responses: + utter_greet: + - text: hey there! +session_config: + carry_over_slots_to_new_session: true + session_expiration_time: {DEFAULT_SESSION_EXPIRATION_TIME_IN_MINUTES} +slots: {{}} +""" + + domain = Domain.from_yaml(test_yaml) + actual_yaml = domain.as_yaml() + + expected_yaml = f""" +version: '{LATEST_TRAINING_DATA_FORMAT_VERSION}' +actions: +- action_save_world +config: + store_entities_as_slots: true +responses: + utter_greet: + - text: hey there! +session_config: + carry_over_slots_to_new_session: true + session_expiration_time: {DEFAULT_SESSION_EXPIRATION_TIME_IN_MINUTES} +""" + + actual = rasa.shared.utils.io.read_yaml(actual_yaml) + expected = rasa.shared.utils.io.read_yaml(expected_yaml) + assert actual == expected + + +def test_merge_yaml_domains(): + test_yaml_1 = f"""config: + store_entities_as_slots: true +entities: [] +intents: [] +slots: {{}} +responses: + utter_greet: + - text: hey there! +{KEY_E2E_ACTIONS}: +- Hi""" + + test_yaml_2 = f"""config: + store_entities_as_slots: false +session_config: + session_expiration_time: 20 + carry_over_slots: true +entities: +- cuisine +intents: +- greet +slots: + cuisine: + type: text + mappings: + - type: from_text +{KEY_E2E_ACTIONS}: +- Bye +responses: + utter_goodbye: + - text: bye! + utter_greet: + - text: hey you!""" + + domain_1 = Domain.from_yaml(test_yaml_1) + domain_2 = Domain.from_yaml(test_yaml_2) + + domain = domain_1.merge(domain_2) + + # single attribute should be taken from domain_1 + assert domain.store_entities_as_slots + # conflicts should be taken from domain_1 + assert domain.responses == { + "utter_greet": [{"text": "hey there!"}], + "utter_goodbye": [{"text": "bye!"}], + } + # lists should be deduplicated and merged + assert domain.intents == sorted(["greet", *DEFAULT_INTENTS]) + assert domain.entities == ["cuisine"] + assert isinstance(domain.slots[0], TextSlot) + assert domain.slots[0].name == "cuisine" + assert sorted(domain.user_actions) == sorted(["utter_greet", "utter_goodbye"]) + assert domain.session_config == SessionConfig(20, True) + + domain = domain_1.merge(domain_2, override=True) + # single attribute should be taken from domain_2 + assert not domain.store_entities_as_slots + # conflicts should take value from domain_2 + assert domain.responses == { + "utter_greet": [{"text": "hey you!"}], + "utter_goodbye": [{"text": "bye!"}], + } + assert domain.session_config == SessionConfig(20, True) + assert domain.action_texts == ["Bye", "Hi"] + + +@pytest.mark.parametrize("default_intent", DEFAULT_INTENTS) +def test_merge_yaml_domains_with_default_intents(default_intent: Text): + test_yaml_1 = """intents: []""" + + # this domain contains an overridden default intent + test_yaml_2 = f"""intents: +- greet +- {default_intent}""" + + domain_1 = Domain.from_yaml(test_yaml_1) + domain_2 = Domain.from_yaml(test_yaml_2) + + domain = domain_1.merge(domain_2) + + # check that the default intents were merged correctly + assert default_intent in domain.intents + assert domain.intents == sorted(["greet", *DEFAULT_INTENTS]) + + # ensure that the default intent is contain the domain's dictionary dump + assert default_intent in domain.as_dict()[KEY_INTENTS] + + +def test_merge_session_config_if_first_is_not_default(): + yaml1 = """ +session_config: + session_expiration_time: 20 + carry_over_slots: true""" + + yaml2 = """ + session_config: + session_expiration_time: 40 + carry_over_slots: true + """ + + domain1 = Domain.from_yaml(yaml1) + domain2 = Domain.from_yaml(yaml2) + + merged = domain1.merge(domain2) + assert merged.session_config == SessionConfig(20, True) + + merged = domain1.merge(domain2, override=True) + assert merged.session_config == SessionConfig(40, True) + + +def test_merge_with_empty_domain(): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + config: + store_entities_as_slots: false + session_config: + session_expiration_time: 20 + carry_over_slots: true + entities: + - cuisine + intents: + - greet + slots: + cuisine: + type: text + mappings: + - type: from_text + responses: + utter_goodbye: + - text: bye! + utter_greet: + - text: hey you! + """ + ) + empty_domain = Domain.empty() + merged = empty_domain.merge(domain, override=True) + assert merged.as_dict() == domain.as_dict() + + +@pytest.mark.parametrize("other", [Domain.empty(), None]) +def test_merge_with_empty_other_domain(other: Optional[Domain]): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + config: + store_entities_as_slots: false + session_config: + session_expiration_time: 20 + carry_over_slots: true + entities: + - cuisine + intents: + - greet + slots: + cuisine: + type: text + mappings: + - type: from_text + responses: + utter_goodbye: + - text: bye! + utter_greet: + - text: hey you! + """ + ) + + merged = domain.merge(other, override=True) + + assert merged.as_dict() == domain.as_dict() + + +def test_merge_domain_with_forms(): + test_yaml_1 = """ + slots: + slot1: + type: text + mappings: + - type: from_text + forms: + my_form: + required_slots: + - slot1 + + my_form2: + required_slots: [] + """ + + test_yaml_2 = """ + slots: + slot1: + type: text + mappings: + - type: from_text + forms: + my_form3: + required_slots: + - slot1 + """ + + domain_1 = Domain.from_yaml(test_yaml_1) + domain_2 = Domain.from_yaml(test_yaml_2) + + domain = domain_1.merge(domain_2) + + expected_number_of_forms = 3 + assert len(domain.form_names) == expected_number_of_forms + assert len(domain.forms) == expected_number_of_forms + + +@pytest.mark.parametrize( + "intents, entity_properties, intent_properties", + [ + ( + ["greet", "goodbye"], + { + "entities": ["entity", "other", "third"], + "roles": {"entity": ["role-1", "role-2"]}, + "groups": {}, + "default_ignored_entities": [], + }, + { + "greet": { + USED_ENTITIES_KEY: [ + "entity", + f"entity{ENTITY_LABEL_SEPARATOR}role-1", + f"entity{ENTITY_LABEL_SEPARATOR}role-2", + "other", + "third", + ] + }, + "goodbye": { + USED_ENTITIES_KEY: [ + "entity", + f"entity{ENTITY_LABEL_SEPARATOR}role-1", + f"entity{ENTITY_LABEL_SEPARATOR}role-2", + "other", + "third", + ] + }, + }, + ), + ( + [{"greet": {USE_ENTITIES_KEY: []}}, "goodbye"], + { + "entities": ["entity", "other", "third"], + "roles": {}, + "groups": {"other": ["1", "2"]}, + "default_ignored_entities": [], + }, + { + "greet": {USED_ENTITIES_KEY: []}, + "goodbye": { + USED_ENTITIES_KEY: [ + "entity", + "other", + f"other{ENTITY_LABEL_SEPARATOR}1", + f"other{ENTITY_LABEL_SEPARATOR}2", + "third", + ] + }, + }, + ), + ( + [ + { + "greet": { + "triggers": "utter_goodbye", + USE_ENTITIES_KEY: ["entity"], + IGNORE_ENTITIES_KEY: ["other"], + } + }, + "goodbye", + ], + { + "entities": ["entity", "other", "third", "unused"], + "roles": {"entity": ["role"], "other": ["role"]}, + "groups": {}, + "default_ignored_entities": ["unused"], + }, + { + "greet": { + "triggers": "utter_goodbye", + USED_ENTITIES_KEY: [ + "entity", + f"entity{ENTITY_LABEL_SEPARATOR}role", + ], + }, + "goodbye": { + USED_ENTITIES_KEY: [ + "entity", + f"entity{ENTITY_LABEL_SEPARATOR}role", + "other", + f"other{ENTITY_LABEL_SEPARATOR}role", + "third", + ] + }, + }, + ), + ( + [ + {"greet": {"triggers": "utter_goodbye", USE_ENTITIES_KEY: None}}, + {"goodbye": {USE_ENTITIES_KEY: [], IGNORE_ENTITIES_KEY: []}}, + ], + { + "entities": ["entity", "other", "third"], + "roles": {}, + "groups": {}, + "default_ignored_entities": [], + }, + { + "greet": {USED_ENTITIES_KEY: [], "triggers": "utter_goodbye"}, + "goodbye": {USED_ENTITIES_KEY: []}, + }, + ), + ( + [ + "greet", + "goodbye", + {"chitchat": {"is_retrieval_intent": True, "use_entities": None}}, + ], + { + "entities": ["entity", "other", "third"], + "roles": {}, + "groups": {}, + "default_ignored_entities": [], + }, + { + "greet": {USED_ENTITIES_KEY: ["entity", "other", "third"]}, + "goodbye": {USED_ENTITIES_KEY: ["entity", "other", "third"]}, + "chitchat": {USED_ENTITIES_KEY: [], "is_retrieval_intent": True}, + }, + ), + ], +) +def test_collect_intent_properties( + intents: Union[Set[Text], List[Union[Text, Dict[Text, Any]]]], + entity_properties: Dict[Text, Union[List[Text], Dict[Text, List[Text]]]], + intent_properties: Dict[Text, Dict[Text, Union[bool, List]]], +): + entity_properties = EntityProperties(**entity_properties) + Domain._add_default_intents(intent_properties, entity_properties) + + assert ( + Domain.collect_intent_properties(intents, entity_properties) + == intent_properties + ) + + +@pytest.mark.parametrize( + "entities, entity_properties", + [ + ( + ["plain_entity", {"ignored_entity": {"influence_conversation": False}}], + { + "entities": ["plain_entity", "ignored_entity"], + "roles": {}, + "groups": {}, + "default_ignored_entities": ["ignored_entity"], + }, + ), + ], +) +def test_collect_entity_properties( + entities: List[Union[Text, Dict[Text, Any]]], + entity_properties: Dict[Text, Union[List[Text], Dict[Text, List[Text]]]], +): + expected_entity_properties = EntityProperties(**entity_properties) + assert Domain.collect_entity_properties(entities) == expected_entity_properties + + +def test_load_domain_from_directory_tree(): + domain_path = "data/test_domains/test_domain_from_directory_tree" + + actual = Domain.load(domain_path) + expected_intents = [ + "utter_root", + "utter_root2", + "utter_skill_1", + "utter_skill_2", + "utter_subskill", + ] + expected_entities = ["ball", "chess", "monopoly", "cluedo", "pandemic"] + expected_responses = { + "utter_greet": [{"text": "Hey! How are you?"}], + "utter_cheer_up": [ + { + "text": "Here is something to cheer you up:", + "image": "https://i.imgur.com/nGF1K8f.jpg", + } + ], + } + assert set(expected_intents).issubset(set(actual.intents)) + assert set(expected_entities) == (set(actual.entities)) + assert set(expected_responses) == (set(actual.responses)) + + +def test_domain_from_multiple_files(): + domain_path = "data/test_domains/test_domain_from_multiple_files" + domain = Domain.load(domain_path) + + expected_intents = [ + "affirm", + "are_you_there", + "back", + "bot_challenge", + "cure_network", + "delay", + "deny", + "device_selection_scaffold", + "drum_clocks", + "drum_lampshades", + "drum_robot", + "drum_robot_chocolate", + "drum_soups", + "drum_wallets", + "endless_love", + "exchange_wallet", + "finish_humble_selection", + "finish_selection", + "finish_selection_line", + "greeting", + "humble_selection", + "humble_selection_scaffold", + "main_menu", + "nlu_fallback", + "open_wallet", + "out_of_scope", + "profanity", + "restart", + "run_finish", + "run_finish_recent", + "run_finished", + "selection_troubleshooting", + "self_selection", + "session_start", + "thanks", + "unsure_selection_scaffold", + "view_offers", + ] + expected_entities = [ + "caramel_robot", + "chocolate_robot", + "other_robot", + "pistachio_robot", + "rum_and_raisin_robot", + "strawberry_robot", + "vanilla_robot", + ] + expected_actions = [ + "action_increase_15", + "action_prevent_20", + "action_utter_cure_standard", + "action_utter_drum_menu", + "action_utter_main_menu", + "action_utter_previous_message", + "action_utter_robot_menu", + "action_utter_smalltalk_greeting", + "utter_anythingelse_menu", + "utter_bot_challenge", + "utter_cure_specific", + "utter_cure_standard", + "utter_drumclocks", + "utter_drumlampshades", + "utter_drumsoups", + "utter_drumwallets", + "utter_finish_humble_selection", + "utter_finish_selection", + "utter_finish_selection_line", + "utter_greengrey_wallet", + "utter_horn_selection_scaffold", + "utter_humble_selection", + "utter_humble_selection_scaffold", + "utter_im_here", + "utter_non_standard", + "utter_open_wallet_options", + "utter_profanity", + "utter_run_finish", + "utter_run_finish_recent", + "utter_run_finished", + "utter_selection_issues", + "utter_smalltalk_greeting", + "utter_std_drum_menu", + "utter_thanks_response", + "utter_tmo_love", + "utter_amazement", + "utter_default", + "utter_goodbye", + "utter_greet", + ] + expected_forms = { + "robot_form": {"required_slots": ["propose_simulation", "display_cure_method"]} + } + expected_responses = { + "utter_greet": [{"text": "hey there!"}], + "utter_goodbye": [{"text": "goodbye :("}], + "utter_default": [{"text": "default message"}], + "utter_amazement": [{"text": "awesomness!"}], + } + expected_slots = [ + "activate_double_simulation", + "activate_simulation", + "display_cure_method", + "display_drum_cure_horns", + "display_method_artwork", + "drumAllClocks", + "drumAllLampshades", + "drumAllSoups", + "drumChocolateWallets", + "drumClockAdapters", + "drumClockCovers", + "drumClocksChocolate", + "drumClocksStrawberry", + "drumMindspace", + "drumOtherWallets", + "drumSnareWallets", + "drumSoupChocolate", + "drumSoupStrawberry", + "drumStrawberryWallets", + "greenOrGrey", + "humbleSelection", + "humbleSelectionManagement", + "humbleSelectionStatus", + "offers", + "requested_slot", + "session_started_metadata", + ] + + domain_slots = [] + + for slot in domain.slots: + domain_slots.append(slot.name) + + assert expected_intents == domain.intents + assert expected_entities == sorted(domain.entities) + assert sorted(expected_actions) == sorted(domain.user_actions) + assert expected_responses == domain.responses + assert expected_forms == domain.forms + assert domain.session_config.session_expiration_time == 360 + assert expected_slots == sorted(domain_slots) + + +def test_domain_warnings(domain: Domain): + warning_types = [ + "action_warnings", + "intent_warnings", + "entity_warnings", + "slot_warnings", + ] + + actions = ["action_1", "action_2"] + intents = ["intent_1", "intent_2"] + entities = ["entity_1", "entity_2"] + slots = ["slot_1", "slot_2"] + domain_warnings = domain.domain_warnings( + intents=intents, entities=entities, actions=actions, slots=slots + ) + + # elements not found in domain should be in `in_training_data` diff + for _type, elements in zip(warning_types, [actions, intents, entities]): + assert set(domain_warnings[_type]["in_training_data"]) == set(elements) + + # all other domain elements should be in `in_domain` diff + for _type, elements in zip( + warning_types, + [domain.user_actions + domain.form_names, domain.intents, domain.entities], + ): + assert set(domain_warnings[_type]["in_domain"]) == set(elements) + + # fully aligned domain and elements should yield empty diff + domain_warnings = domain.domain_warnings( + intents=domain.intents, + entities=domain.entities, + actions=domain.user_actions + domain.form_names, + slots=[s.name for s in domain._user_slots], + ) + + for diff_dict in domain_warnings.values(): + assert all(not diff_set for diff_set in diff_dict.values()) + + +def test_unfeaturized_slot_in_domain_warnings(): + # create empty domain + featurized_slot_name = "text_slot" + unfeaturized_slot_name = "unfeaturized_slot" + domain = Domain.from_dict( + { + "slots": { + featurized_slot_name: { + "initial_value": "value2", + "type": "text", + "mappings": [{"type": "from_text"}], + }, + unfeaturized_slot_name: { + "type": "text", + "initial_value": "value1", + "influence_conversation": False, + "mappings": [{"type": "from_text"}], + }, + } + } + ) + + # ensure both are in domain + for slot in (featurized_slot_name, unfeaturized_slot_name): + assert slot in [slot.name for slot in domain.slots] + + # text slot should appear in domain warnings, unfeaturized slot should not + in_domain_slot_warnings = domain.domain_warnings()["slot_warnings"]["in_domain"] + assert featurized_slot_name in in_domain_slot_warnings + assert unfeaturized_slot_name not in in_domain_slot_warnings + + +def test_check_domain_sanity_on_invalid_domain(): + with pytest.raises(InvalidDomain): + Domain( + intents={}, + entities=[], + slots=[], + responses={}, + action_names=["random_name", "random_name"], + forms={}, + data={}, + ) + + with pytest.raises(InvalidDomain): + Domain( + intents={}, + entities=[], + slots=[ + TextSlot("random_name", mappings=[{}]), + TextSlot("random_name", mappings=[{}]), + ], + responses={}, + action_names=[], + forms={}, + data={}, + ) + + with pytest.raises(InvalidDomain): + Domain( + intents={}, + entities=["random_name", "random_name", "other_name", "other_name"], + slots=[], + responses={}, + action_names=[], + forms={}, + data={}, + ) + + +def test_load_on_invalid_domain_duplicate_intents(): + with pytest.raises(InvalidDomain): + Domain.load("data/test_domains/duplicate_intents.yml") + + +def test_load_on_invalid_domain_duplicate_actions(): + with pytest.raises(InvalidDomain): + Domain.load("data/test_domains/duplicate_actions.yml") + + +def test_schema_error_with_forms_as_lists(): + with pytest.raises(YamlException): + Domain.from_yaml( + """ + version: '3.0' + forms: [] + """ + ) + + +def test_schema_error_with_forms_and_slots_but_without_required_slots_key(): + with pytest.raises(YamlException): + Domain.from_yaml( + """ + version: '3.0' + forms: + my_form: + cool_slot: + - type: from_text + """ + ) + + +def test_load_on_invalid_domain_duplicate_responses(): + with pytest.raises(YamlSyntaxException): + Domain.load("data/test_domains/duplicate_responses.yml") + + +def test_load_on_invalid_domain_duplicate_entities(): + with pytest.raises(InvalidDomain): + Domain.load("data/test_domains/duplicate_entities.yml") + + +def test_load_domain_with_entity_roles_groups(): + domain = Domain.load("data/test_domains/travel_form.yml") + + assert domain.entities is not None + assert "GPE" in domain.entities + assert "name" in domain.entities + assert "name" not in domain.entity_properties.roles + assert "GPE" in domain.entity_properties.roles + assert "origin" in domain.entity_properties.roles["GPE"] + assert "destination" in domain.entity_properties.roles["GPE"] + + +def test_is_empty(): + assert Domain.empty().is_empty() + + +def test_load_intents_from_as_dict_representation(): + domain_path = "data/test_domains/default_unfeaturized_entities.yml" + domain = Domain.load(domain_path) + transformed = domain.as_dict().get(KEY_INTENTS) + + expected = [ + {"ask": {USE_ENTITIES_KEY: True}}, + {"default": {IGNORE_ENTITIES_KEY: ["unrelated_recognized_entity"]}}, + {"goodbye": {USE_ENTITIES_KEY: []}}, + {"greet": {USE_ENTITIES_KEY: ["name", "used_entity"]}}, + "pure_intent", + {"thank": {USE_ENTITIES_KEY: []}}, + {"why": {USE_ENTITIES_KEY: []}}, + ] + + assert transformed == expected + + +def test_load_intents_with_entities_from_as_dict(): + domain_path = "data/test_domains/test_domain_from_directory_for_entities" + domain = Domain.load(domain_path) + transformed = domain.as_dict().get(KEY_INTENTS) + + expected = [ + {"certify": {USE_ENTITIES_KEY: True}}, + {"play": {USE_ENTITIES_KEY: ["ball", "chess"]}}, + "question", + {"stow_away": {USE_ENTITIES_KEY: True}}, + { + "support_encouraging": { + USE_ENTITIES_KEY: ["automatic_cupcakes", "anti_freeze_blankets"] + } + }, + {"vacationing": {"ignore_entities": ["tornadoes"]}}, + ] + + assert transformed == expected + + +def test_load_intents_for_file_from_as_dict(): + domain_path = "data/test_domains/default_with_mapping.yml" + domain = Domain.load(domain_path) + transformed = domain.as_dict().get(KEY_INTENTS) + + expected = [ + {"default": {"triggers": "utter_default"}}, + "goodbye", + {"greet": {"triggers": "utter_greet"}}, + ] + + assert transformed == expected + + +def test_load_intents_with_entity_roles_groups_from_as_dict(): + domain_path = "data/test_domains/travel_form.yml" + domain = Domain.load(domain_path) + transformed = domain.as_dict().get(KEY_INTENTS) + + expected = [ + {"greet": {IGNORE_ENTITIES_KEY: ["GPE"]}}, + {"inform": {USE_ENTITIES_KEY: ["GPE"]}}, + ] + + assert transformed == expected + + +def test_load_entities_from_as_dict(): + domain_path = "data/test_domains/travel_form.yml" + domain = Domain.load(domain_path) + transformed = domain.as_dict().get(KEY_ENTITIES) + + expected = [{"GPE": {ENTITY_ROLES_KEY: ["destination", "origin"]}}, "name"] + + assert transformed == expected + + +def test_not_add_knowledge_base_slots(): + test_domain = Domain.empty() + + slot_names = [s.name for s in test_domain.slots] + + assert SLOT_LISTED_ITEMS not in slot_names + assert SLOT_LAST_OBJECT not in slot_names + assert SLOT_LAST_OBJECT_TYPE not in slot_names + + +def test_add_knowledge_base_slots(): + test_domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + actions: + - {DEFAULT_KNOWLEDGE_BASE_ACTION} + """ + ) + + slot_names = [s.name for s in test_domain.slots] + + assert SLOT_LISTED_ITEMS in slot_names + assert SLOT_LAST_OBJECT in slot_names + assert SLOT_LAST_OBJECT_TYPE in slot_names + + +@pytest.mark.parametrize( + "input_domain, expected_session_expiration_time, expected_carry_over_slots", + [ + ( + f"""session_config: + session_expiration_time: {DEFAULT_SESSION_EXPIRATION_TIME_IN_MINUTES} + carry_over_slots_to_new_session: true""", + DEFAULT_SESSION_EXPIRATION_TIME_IN_MINUTES, + True, + ), + ("", DEFAULT_SESSION_EXPIRATION_TIME_IN_MINUTES, True), + ( + """session_config: + carry_over_slots_to_new_session: false""", + DEFAULT_SESSION_EXPIRATION_TIME_IN_MINUTES, + False, + ), + ( + """session_config: + session_expiration_time: 20.2 + carry_over_slots_to_new_session: False""", + 20.2, + False, + ), + ("""session_config: {}""", DEFAULT_SESSION_EXPIRATION_TIME_IN_MINUTES, True), + ], +) +def test_session_config( + input_domain, + expected_session_expiration_time: float, + expected_carry_over_slots: bool, +): + domain = Domain.from_yaml(input_domain) + assert ( + domain.session_config.session_expiration_time + == expected_session_expiration_time + ) + assert domain.session_config.carry_over_slots == expected_carry_over_slots + + +def test_domain_as_dict_with_session_config(): + session_config = SessionConfig(123, False) + domain = Domain([], [], [], {}, [], {}, {}, None, True, session_config) + + serialized = domain.as_dict() + deserialized = Domain.from_dict(serialized) + + assert deserialized.session_config == session_config + + +@pytest.mark.parametrize( + "session_config, enabled", + [ + (SessionConfig(0, True), False), + (SessionConfig(1, True), True), + (SessionConfig(-1, False), False), + ], +) +def test_are_sessions_enabled(session_config: SessionConfig, enabled: bool): + assert session_config.are_sessions_enabled() == enabled + + +def test_domain_from_dict_does_not_change_input(): + input_before = { + "intents": [ + {"greet": {USE_ENTITIES_KEY: ["name"]}}, + {"default": {IGNORE_ENTITIES_KEY: ["unrelated_recognized_entity"]}}, + {"goodbye": {USE_ENTITIES_KEY: None}}, + {"thank": {USE_ENTITIES_KEY: False}}, + {"ask": {USE_ENTITIES_KEY: True}}, + {"why": {USE_ENTITIES_KEY: []}}, + "pure_intent", + ], + "entities": ["name", "unrelated_recognized_entity", "other"], + "slots": {"name": {"type": "text", "mappings": [{"type": "from_text"}]}}, + "responses": { + "utter_greet": [{"text": "hey there {name}!"}], + "utter_goodbye": [{"text": "goodbye 😢"}, {"text": "bye bye 😢"}], + "utter_default": [{"text": "default message"}], + }, + } + + input_after = copy.deepcopy(input_before) + Domain.from_dict(input_after) + + assert input_after == input_before + + +@pytest.mark.parametrize( + "domain_dict", [{}, {"intents": DEFAULT_INTENTS}, {"intents": [DEFAULT_INTENTS[0]]}] +) +def test_add_default_intents(domain_dict: Dict): + domain = Domain.from_dict(domain_dict) + + assert all(intent_name in domain.intents for intent_name in DEFAULT_INTENTS) + + +def test_domain_deepcopy(domain: Domain): + new_domain = copy.deepcopy(domain) + + assert isinstance(new_domain, Domain) + + # equalities + assert new_domain.intent_properties == domain.intent_properties + assert new_domain.overridden_default_intents == domain.overridden_default_intents + assert new_domain.entities == domain.entities + assert new_domain.forms == domain.forms + assert new_domain.form_names == domain.form_names + assert new_domain.responses == domain.responses + assert new_domain.action_texts == domain.action_texts + assert new_domain.session_config == domain.session_config + assert new_domain._custom_actions == domain._custom_actions + assert new_domain.user_actions == domain.user_actions + assert new_domain.action_names_or_texts == domain.action_names_or_texts + assert new_domain.store_entities_as_slots == domain.store_entities_as_slots + + # not the same objects + assert new_domain is not domain + assert new_domain.intent_properties is not domain.intent_properties + assert ( + new_domain.overridden_default_intents is not domain.overridden_default_intents + ) + assert new_domain.entities is not domain.entities + assert new_domain.forms is not domain.forms + assert new_domain.form_names is not domain.form_names + assert new_domain.slots is not domain.slots + assert new_domain.responses is not domain.responses + assert new_domain.action_texts is not domain.action_texts + assert new_domain.session_config is not domain.session_config + assert new_domain._custom_actions is not domain._custom_actions + assert new_domain.user_actions is not domain.user_actions + assert new_domain.action_names_or_texts is not domain.action_names_or_texts + + +@pytest.mark.parametrize( + "response_key, validation", + [("utter_chitchat/faq", True), ("utter_chitchat", False)], +) +def test_is_retrieval_intent_response(response_key, validation, domain: Domain): + assert domain.is_retrieval_intent_response((response_key, [{}])) == validation + + +def test_retrieval_intent_response_seggregation(): + domain = Domain.load("data/test_domains/mixed_retrieval_intents.yml") + assert domain.responses != domain.retrieval_intent_responses + assert domain.responses and domain.retrieval_intent_responses + assert list(domain.retrieval_intent_responses.keys()) == [ + "utter_chitchat/ask_weather", + "utter_chitchat/ask_name", + ] + + +def test_get_featurized_entities(): + domain = Domain.load("data/test_domains/travel_form.yml") + + user_uttered = UserUttered( + text="Hello, I am going to London", + intent={"name": "greet", "confidence": 1.0}, + entities=[{"entity": "GPE", "value": "London", "role": "destination"}], + ) + + featurized_entities = domain._get_featurized_entities(user_uttered) + + assert featurized_entities == set() + + user_uttered = UserUttered( + text="I am going to London", + intent={"inform": "greet", "confidence": 1.0}, + entities=[{"entity": "GPE", "value": "London", "role": "destination"}], + ) + + featurized_entities = domain._get_featurized_entities(user_uttered) + + assert featurized_entities == {"GPE", f"GPE{ENTITY_LABEL_SEPARATOR}destination"} + + +def test_featurized_entities_ordered_consistently(): + """Check that entities get ordered -- needed for consistent state representations. + + Previously, no ordering was applied to entities, but they were ordered implicitly + due to how python sets work -- a set of all entity names was internally created, + which was ordered by the hashes of the entity names. Now, entities are sorted alpha- + betically. Since even sorting based on randomised hashing can produce alphabetical + ordering once in a while, we here check with a large number of entities, pushing to + ~0 the probability of correctly sorting the elements just by accident, without + actually doing proper sorting. + """ + # Create a sorted list of entity names from 'a' to 'z', and two randomly shuffled + # copies. + entity_names_sorted = [chr(i) for i in range(ord("a"), ord("z") + 1)] + entity_names_shuffled1 = entity_names_sorted.copy() + random.shuffle(entity_names_shuffled1) + entity_names_shuffled2 = entity_names_sorted.copy() + random.shuffle(entity_names_shuffled2) + + domain = Domain.from_dict( + {KEY_INTENTS: ["inform"], KEY_ENTITIES: entity_names_shuffled1} + ) + + tracker = DialogueStateTracker.from_events( + "story123", + [ + UserUttered( + text="hey there", + intent={"name": "inform", "confidence": 1.0}, + entities=[ + {"entity": e, "value": e.upper()} for e in entity_names_shuffled2 + ], + ) + ], + ) + state = domain.get_active_state(tracker) + + # Whatever order the entities were listed in, they should get sorted alphabetically + # so the states' representations are consistent and entity-order-agnostic. + assert state["user"]["entities"] == tuple(entity_names_sorted) + + +@pytest.mark.parametrize( + "domain_as_dict", + [ + # No slots + {KEY_SLOTS: {}}, + # Valid slot mappings + { + KEY_SLOTS: { + "slot_x": { + "type": "text", + "mappings": [{"type": "from_entity", "entity": "name"}], + } + } + }, + { + KEY_SLOTS: { + "slot_x": { + "type": "float", + "mappings": [{"type": "from_intent", "value": 5}], + } + } + }, + { + KEY_SLOTS: { + "slot_x": { + "type": "text", + "mappings": [{"type": "from_intent", "value": "some value"}], + } + } + }, + { + KEY_SLOTS: { + "slot_x": { + "type": "bool", + "mappings": [{"type": "from_intent", "value": False}], + } + } + }, + { + KEY_SLOTS: { + "slot_x": { + "type": "float", + "mappings": [{"type": "from_trigger_intent", "value": 5}], + } + }, + KEY_FORMS: {"some_form": {"required_slots": ["slot_x"]}}, + }, + { + KEY_SLOTS: { + "slot_x": { + "type": "text", + "mappings": [ + {"type": "from_trigger_intent", "value": "some value"} + ], + } + }, + KEY_FORMS: {"some_form": {"required_slots": ["slot_x"]}}, + }, + {KEY_SLOTS: {"slot_x": {"type": "text", "mappings": [{"type": "from_text"}]}}}, + ], +) +def test_valid_slot_mappings(domain_as_dict: Dict[Text, Any]): + Domain.from_dict(domain_as_dict) + + +@pytest.mark.parametrize( + "domain_as_dict", + [ + # Wrong type for forms + {KEY_FORMS: []}, + # Wrong type for required_slots + {KEY_FORMS: {"my_form": []}}, + {KEY_FORMS: {"my_form": 5}}, + # ignored_intent in forms, but no required_slots + {KEY_FORMS: {"my_form": {"ignored_intents": ["greet"]}}}, + ], +) +def test_form_invalid_mappings(domain_as_dict: Dict[Text, Any]): + with pytest.raises(InvalidDomain): + Domain.from_dict(domain_as_dict) + + +def test_form_invalid_required_slots_raises(): + with pytest.raises(YamlValidationException): + Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + entities: + - some_entity + forms: + my_form: + required_slots: + some_slot: + - type: from_entity + entity: some_entity + """ + ) + + +@pytest.mark.parametrize( + "domain_as_dict", + [ + # Unknown mapping + {KEY_SLOTS: {"my_slot": {"type": "text", "mappings": [{"type": "test"}]}}}, + # Mappings with missing keys + { + KEY_SLOTS: { + "my_slot": { + "type": "text", + "mappings": [{"type": "from_entity", "intent": "greet"}], + } + } + }, + { + KEY_SLOTS: { + "my_slot": {"type": "text", "mappings": [{"type": "from_intent"}]} + } + }, + { + KEY_SLOTS: { + "my_slot": { + "type": "text", + "mappings": [{"type": "from_intent", "value": None}], + } + } + }, + { + KEY_SLOTS: { + "my_slot": { + "type": "text", + "mappings": [{"type": "from_trigger_intent"}], + } + } + }, + { + KEY_SLOTS: { + "my_slot": { + "type": "text", + "mappings": [{"type": "from_trigger_intent", "value": None}], + } + } + }, + ], +) +def test_slot_invalid_mappings(domain_as_dict: Dict[Text, Any]): + with pytest.raises(InvalidDomain): + Domain.from_dict(domain_as_dict) + + +@pytest.mark.parametrize( + "domain_yaml", + [ + # Wrong type for slots + ( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + slots: + [] + """ + ), + # Wrong type for slot names + ( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + slots: + some_slot: 5 + """ + ), + ( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + slots: + some_slot: [] + """ + ), + ], +) +def test_invalid_slots_raises_yaml_exception(domain_yaml: Text): + with pytest.raises(YamlValidationException): + Domain.from_yaml(domain_yaml) + + +def test_slot_order_is_preserved(): + test_yaml = f"""version: '{LATEST_TRAINING_DATA_FORMAT_VERSION}' +session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true +slots: + confirm: + type: bool + influence_conversation: false + mappings: + - type: custom + previous_email: + type: text + influence_conversation: false + mappings: + - type: from_text + caller_id: + type: text + influence_conversation: false + mappings: + - type: from_text + email: + type: text + influence_conversation: false + mappings: + - type: from_text + incident_title: + type: text + influence_conversation: false + mappings: + - type: from_text + priority: + type: text + influence_conversation: false + mappings: + - type: from_text + problem_description: + type: text + influence_conversation: false + mappings: + - type: from_text + requested_slot: + type: text + influence_conversation: false + mappings: + - type: from_text + handoff_to: + type: text + influence_conversation: false + mappings: + - type: from_text +""" + + domain = Domain.from_yaml(test_yaml) + assert domain.as_yaml() == test_yaml + + +def test_slot_order_is_preserved_when_merging(): + slot_1 = """ + b: + type: text + influence_conversation: false + mappings: + - type: from_text + a: + type: text + influence_conversation: false + mappings: + - type: from_text""" + + test_yaml_1 = f""" +slots:{slot_1} +""" + + slot_2 = """ + d: + type: text + influence_conversation: false + mappings: + - type: from_text + c: + type: text + influence_conversation: false + mappings: + - type: from_text""" + + test_yaml_2 = f""" +slots:{slot_2} +""" + + test_yaml_merged = f"""version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +slots:{slot_2}{slot_1} +session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true +""" + + domain_1 = Domain.from_yaml(test_yaml_1) + domain_2 = Domain.from_yaml(test_yaml_2) + domain_merged = domain_1.merge(domain_2) + + assert domain_merged.as_yaml() == test_yaml_merged + + +def test_responses_text_multiline_is_preserved(): + test_yaml = f"""version: '{LATEST_TRAINING_DATA_FORMAT_VERSION}' +session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true +responses: + utter_confirm: + - text: |- + First line + Second line + Third line + - text: One more response + utter_cancel: + - text: First line + - text: Second line +""" + + domain = Domain.from_yaml(test_yaml) + assert domain.as_yaml() == test_yaml + + +def test_is_valid_domain_doesnt_raise_with_valid_domain(tmpdir: Path): + domain_path = str(tmpdir / "domain.yml") + rasa.shared.utils.io.write_text_file( + """ + responses: + utter_greet: + - text: hey there! """, + domain_path, + ) + assert Domain.is_domain_file(domain_path) + + +def test_is_valid_domain_doesnt_raise_with_invalid_domain(tmpdir: Path): + domain_path = str(tmpdir / "domain.yml") + rasa.shared.utils.io.write_text_file( + """ + invalid""", + domain_path, + ) + assert not Domain.is_domain_file(domain_path) + + +def test_is_valid_domain_doesnt_raise_with_invalid_yaml(tmpdir: Path): + potential_domain_path = str(tmpdir / "domain.yml") + rasa.shared.utils.io.write_text_file( + """ + script: + - echo "Latest SDK version is ${RASA_SDK_VERSION}""", + potential_domain_path, + ) + assert not Domain.is_domain_file(potential_domain_path) + + +def test_domain_with_empty_intent_mapping(): + # domain.yml with intent (intent_name) that has a `:` character + # and nothing after it. + test_yaml = """intents: + - intent_name:""" + + with pytest.raises(InvalidDomain): + Domain.from_yaml(test_yaml).as_dict() + + +def test_domain_with_empty_entity_mapping(): + # domain.yml with entity (entity_name) that has a `:` character + # and nothing after it. + test_yaml = """entities: + - entity_name:""" + + with pytest.raises(InvalidDomain): + Domain.from_yaml(test_yaml).as_dict() + + +def test_ignored_intents_slot_mappings_invalid_domain(): + domain_as_dict = { + KEY_FORMS: { + "my_form": { + IGNORED_INTENTS: "some_not_intent", + "slot_x": [ + { + "type": "from_entity", + "entity": "name", + "not_intent": "other_not_intent", + } + ], + } + } + } + with pytest.raises(InvalidDomain): + Domain.from_dict(domain_as_dict) + + +def test_domain_count_conditional_response_variations(): + domain = Domain.from_file( + path="data/test_domains/conditional_response_variations.yml" + ) + count_conditional_responses = domain.count_conditional_response_variations() + assert count_conditional_responses == 5 + + +def test_domain_with_no_form_slots(): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + forms: + contract_form: + required_slots: [] + """ + ) + assert domain.required_slots_for_form("contract_form") == [] + + +def test_domain_with_empty_required_slots(): + with pytest.raises(YamlException): + Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + forms: + contract_form: + """ + ) + + +def test_domain_invalid_yml_in_folder(): + """ + Check if invalid YAML files in a domain folder lead to the proper UserWarning + """ + with pytest.warns(UserWarning, match="The file .* your file\\."): + Domain.from_directory("data/test_domains/test_domain_from_directory/") + + +def test_invalid_domain_dir_with_duplicates(recwarn: WarningsRecorder): + """ + Raises InvalidDomain if a domain is loaded from a directory with duplicated slots, + responses and intents in domain files. + """ + Domain.from_directory("data/test_domains/test_domain_with_duplicates/") + + error_message = ( + "The following duplicated intents have been found across multiple domain files: greet \n" + "The following duplicated responses have been found across multiple domain files: " + "utter_did_that_help, utter_greet \n" + "The following duplicated slots have been found across multiple domain files: mood" + ) + for warning in recwarn.list: + # filter expected warnings + if not any( + type(warning.message) == warning_type + and re.search(warning_message, str(warning.message)) + for warning_type, warning_message in EXPECTED_WARNINGS + ): + assert error_message == warning.message.args[0] + + +def test_domain_fingerprint_consistency_across_runs(): + domain_yaml = f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + - goodbye + entities: + - name + slots: + name: + type: text + mappings: + - type: from_entity + entity: name + responses: + utter_greet: + - text: "Hi" + forms: + test_form: + required_slots: + - name + actions: + - action_test + """ + domain1 = Domain.from_yaml(domain_yaml) + domain2 = Domain.from_yaml(domain_yaml) + + f1 = domain1.fingerprint() + f2 = domain2.fingerprint() + assert f1 == f2 + + +def test_domain_fingerprint_uniqueness(): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + - goodbye + actions: + - action_test + """ + ) + f1 = domain.fingerprint() + + domain_with_extra_intent = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + - goodbye + - test + actions: + - action_test + """ + ) + f2 = domain_with_extra_intent.fingerprint() + assert f1 != f2 + + domain_with_extra_action = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + - goodbye + actions: + - action_test + - action_double_test + """ + ) + f3 = domain_with_extra_action.fingerprint() + assert f1 != f3 + + domain_with_extra_responses = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + - goodbye + responses: + utter_greet: + - text: "Hi!" + actions: + - action_test + """ + ) + f4 = domain_with_extra_responses.fingerprint() + assert f1 != f4 + + +def test_domain_slots_for_entities_with_mapping_conditions_no_slot_set(): + domain = Domain.from_yaml( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + entities: + - city + slots: + location: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: city + conditions: + - active_loop: booking_form + forms: + booking_form: + required_slots: + - location + """ + ) + ) + events = domain.slots_for_entities([{"entity": "city", "value": "Berlin"}]) + assert len(events) == 0 + + +def test_domain_slots_for_entities_with_mapping_conditions_no_active_loop(): + domain = Domain.from_yaml( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + entities: + - city + slots: + location: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: city + conditions: + - active_loop: null + forms: + booking_form: + required_slots: + - location + """ + ) + ) + events = domain.slots_for_entities([{"entity": "city", "value": "Berlin"}]) + assert events == [SlotSet("location", "Berlin")] + + +def test_domain_slots_for_entities_sets_valid_slot(): + domain = Domain.from_yaml( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + entities: + - city + slots: + location: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: city + """ + ) + ) + events = domain.slots_for_entities([{"entity": "city", "value": "Berlin"}]) + assert events == [SlotSet("location", "Berlin")] + + +def test_domain_slots_for_entities_sets_valid_list_slot(): + domain = Domain.from_yaml( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + entities: + - topping + slots: + toppings: + type: list + influence_conversation: false + mappings: + - type: from_entity + entity: topping + """ + ) + ) + events = domain.slots_for_entities( + [ + {"entity": "topping", "value": "parmesan"}, + {"entity": "topping", "value": "prosciutto"}, + ] + ) + assert events == [SlotSet("toppings", ["parmesan", "prosciutto"])] + + +def test_domain_slots_for_entities_with_entity_mapping_to_multiple_slots(): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + entities: + - city + slots: + departure_city: + type: text + mappings: + - type: from_entity + entity: city + role: from + arrival_city: + type: text + mappings: + - type: from_entity + entity: city + role: to + """ + ) + events = domain.slots_for_entities( + [ + {"entity": "city", "value": "London", "role": "from"}, + {"entity": "city", "value": "Berlin", "role": "to"}, + ] + ) + assert events == [ + SlotSet("departure_city", "London"), + SlotSet("arrival_city", "Berlin"), + ] + + +def test_merge_domain_with_separate_session_config(): + domain_dir = "data/test_domains/test_domain_with_separate_session_config" + domain = Domain.load(domain_dir) + + expected_session_expiration_time = 1 + + assert ( + domain.session_config.session_expiration_time + == expected_session_expiration_time + ) + + +@pytest.mark.parametrize( + "actions, expected_result", + [ + ( + [ + {"action_hello_world": {"send_domain": False}}, + {"action_say_something": {"send_domain": True}}, + {"action_calculate": {"send_domain": True}}, + "action_no_domain", + "validate_my_form", + ], + ["action_say_something", "action_calculate", "validate_my_form"], + ), + ( + [ + "action_no_domain", + "validate_my_form", + ], + ["validate_my_form"], + ), + ( + [ + {"action_hello_world": {"send_domain": False}}, + {"action_say_something": {"send_domain": False}}, + {"action_calculate": {"send_domain": False}}, + ], + [], + ), + ( + [ + {"action_hello_world": {"send_domain": True}}, + {"action_say_something": {"send_domain": True}}, + {"action_calculate": {"send_domain": True}}, + "validate_my_form", + ], + [ + "action_hello_world", + "action_say_something", + "action_calculate", + "validate_my_form", + ], + ), + ([], []), + ( + ["action_say_something", "action_calculate"], + [], + ), + ], +) +def test_collect_actions_which_explicitly_need_domain( + actions: List[Union[Dict[Text, Any], str]], expected_result: List[str] +): + result = Domain._collect_actions_which_explicitly_need_domain(actions) + + # assert that two unordered lists have same elements + assert sorted(result) == sorted(expected_result) + + +@pytest.mark.parametrize( + "actions, expected_result", + [ + ( + [ + {"action_hello_world": {"send_domain": False}}, + {"action_say_something": {"send_domain": True}}, + {"action_calculate": {"send_domain": True}}, + "action_no_domain", + "validate_my_form", + ], + [ + "action_hello_world", + "action_say_something", + "action_calculate", + "action_no_domain", + "validate_my_form", + ], + ) + ], +) +def test_collect_actions( + actions: List[Union[Dict[Text, Any], str]], expected_result: List[str] +): + result = Domain._collect_action_names(actions) + + # assert that two unordered lists have same elements + assert sorted(result) == sorted(expected_result) + + +@pytest.mark.parametrize( + "content, expected_user_actions, expected_actions_which_explicitly_need_domain", + [ + ( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + + entities: + - name + + responses: + utter_greet: + - text: hey there! + + actions: + - action_hello: {{send_domain: True}} + - action_bye: {{send_domain: True}} + - action_no_domain + - validate_my_form + """, + ["action_hello", "action_bye", "action_no_domain", "validate_my_form"], + ["action_hello", "action_bye", "validate_my_form"], + ), + ( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + + entities: + - name + + responses: + utter_greet: + - text: hey there! + + actions: + - action_hello + - action_bye + - action_no_domain + - validate_my_form + """, + [ + "action_hello", + "action_bye", + "action_no_domain", + "validate_my_form", + ], + [ + "validate_my_form", + ], + ), + ( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + + entities: + - name + + responses: + utter_greet: + - text: hey there! + + actions: + - action_hello + - action_bye + - action_no_domain + """, + ["action_hello", "action_bye", "action_no_domain"], + [], + ), + ], +) +def test_domain_loads_actions_which_explicitly_need_domain( + content: str, + expected_user_actions: List[str], + expected_actions_which_explicitly_need_domain: List[str], +): + domain = Domain.from_yaml(content) + assert domain._custom_actions == expected_user_actions + assert ( + domain._actions_which_explicitly_need_domain + == expected_actions_which_explicitly_need_domain + ) + + +def test_merge_yaml_domains_loads_actions_which_explicitly_need_domain(): + test_yaml_1 = textwrap.dedent( + """ + actions: + - action_hello + - action_bye + - action_send_domain: {send_domain: True}""" + ) + + test_yaml_2 = textwrap.dedent( + """ + actions: + - action_find_restaurants: + send_domain: True""" + ) + + domain_1 = Domain.from_yaml(test_yaml_1) + domain_2 = Domain.from_yaml(test_yaml_2) + + domain = domain_1.merge(domain_2) + + # single attribute should be taken from domain_1 + expected_actions = [ + "action_hello", + "action_bye", + "action_send_domain", + "action_find_restaurants", + ] + expected_actions_that_need_domain = [ + "action_send_domain", + "action_find_restaurants", + ] + assert sorted(domain._custom_actions) == sorted(expected_actions) + assert sorted(domain._actions_which_explicitly_need_domain) == sorted( + expected_actions_that_need_domain + ) + + +@pytest.mark.parametrize( + "domain_yaml, expected", + [ + ( + """ + responses: + utter_greet: + - text: hey there! + id: '1233' + - text: hey ho! + id: '1234' + """, + { + "utter_greet": [ + { + "text": "hey there!", + "id": "1233", + }, + { + "text": "hey ho!", + "id": "1234", + }, + ], + }, + ), + ( + """ + responses: + utter_greet: + - text: hey there! + - text: hey ho! + id: '1234' + """, + { + "utter_greet": [ + { + "text": "hey there!", + }, + { + "text": "hey ho!", + "id": "1234", + }, + ], + }, + ), + ( + """ + responses: + utter_greet: + - text: hey there! + - text: hey ho! + """, + { + "utter_greet": [ + { + "text": "hey there!", + }, + { + "text": "hey ho!", + }, + ], + }, + ), + ], +) +def test_domain_responses_with_ids_are_loaded(domain_yaml, expected) -> None: + domain = Domain.from_yaml(domain_yaml) + assert domain.responses == expected diff --git a/tests/shared/core/test_events.py b/tests/shared/core/test_events.py new file mode 100644 index 0000000..f1009ef --- /dev/null +++ b/tests/shared/core/test_events.py @@ -0,0 +1,829 @@ +import copy + +import pytest +import pytz +import time +from datetime import datetime +from dateutil import parser +from typing import Type, Optional, Text, List, Any, Dict + +import rasa.shared.utils.common +import rasa.shared.core.events +from rasa.core.test import ( + WronglyClassifiedUserUtterance, + WarningPredictedAction, + WronglyPredictedAction, + EndToEndUserUtterance, + EvaluationStore, +) +from rasa.shared.exceptions import UnsupportedFeatureException +from rasa.shared.core.constants import ( + ACTION_LISTEN_NAME, + ACTION_SESSION_START_NAME, + ACTION_UNLIKELY_INTENT_NAME, +) +from rasa.shared.core.events import ( + Event, + UserUttered, + SlotSet, + Restarted, + ActionExecuted, + AllSlotsReset, + ReminderScheduled, + ReminderCancelled, + ConversationResumed, + ConversationPaused, + StoryExported, + ActionReverted, + BotUttered, + FollowupAction, + UserUtteranceReverted, + AgentUttered, + SessionStarted, + EntitiesAdded, + DefinePrevUserUtteredFeaturization, + ActiveLoop, + LegacyForm, + LoopInterrupted, + ActionExecutionRejected, + LegacyFormValidation, + format_message, +) +from rasa.shared.nlu.constants import INTENT_NAME_KEY, METADATA_MODEL_ID +from tests.core.policies.test_rule_policy import GREET_INTENT_NAME, UTTER_GREET_ACTION + + +@pytest.mark.parametrize( + "one_event,another_event", + [ + ( + UserUttered("/greet", {"name": "greet", "confidence": 1.0}, []), + UserUttered("/goodbye", {"name": "goodbye", "confidence": 1.0}, []), + ), + (SlotSet("my_slot", "value"), SlotSet("my__other_slot", "value")), + (Restarted(), None), + (AllSlotsReset(), None), + (ConversationPaused(), None), + (ConversationResumed(), None), + (StoryExported(), None), + (ActionReverted(), None), + (UserUtteranceReverted(), None), + (SessionStarted(), None), + (ActionExecuted("my_action"), ActionExecuted("my_other_action")), + (FollowupAction("my_action"), FollowupAction("my_other_action")), + ( + BotUttered("my_text", {"my_data": 1}), + BotUttered("my_other_test", {"my_other_data": 1}), + ), + ( + AgentUttered("my_text", "my_data"), + AgentUttered("my_other_test", "my_other_data"), + ), + ( + ReminderScheduled("my_intent", datetime.now()), + ReminderScheduled("my_other_intent", datetime.now()), + ), + ], +) +def test_event_has_proper_implementation(one_event, another_event): + # equals tests + assert ( + one_event != another_event + ), "Same events with different values need to be different" + assert one_event == copy.deepcopy(one_event), "Event copies need to be the same" + assert one_event != 42, "Events aren't equal to 42!" + + # hash test + assert hash(one_event) == hash( + copy.deepcopy(one_event) + ), "Same events should have the same hash" + assert hash(one_event) != hash( + another_event + ), "Different events should have different hashes" + + # str test + assert "object at 0x" not in str(one_event), "Event has a proper str method" + + +@pytest.mark.parametrize( + "one_event", + [ + UserUttered("/greet", {"name": "greet", "confidence": 1.0}, []), + UserUttered(metadata={"type": "text"}), + UserUttered(metadata=None), + UserUttered(text="hi", message_id="1", metadata={"type": "text"}), + SlotSet("name", "rasa"), + Restarted(), + AllSlotsReset(), + ConversationPaused(), + ConversationResumed(), + StoryExported(), + ActionReverted(), + UserUtteranceReverted(), + SessionStarted(), + ActionExecuted("my_action"), + ActionExecuted("my_action", "policy_1_TEDPolicy", 0.8), + FollowupAction("my_action"), + BotUttered("my_text", {"my_data": 1}), + AgentUttered("my_text", "my_data"), + ReminderScheduled("my_intent", datetime.now()), + ReminderScheduled("my_intent", datetime.now(pytz.timezone("US/Central"))), + ], +) +def test_dict_serialisation(one_event): + evt_dict = one_event.as_dict() + recovered_event = Event.from_parameters(evt_dict) + assert hash(one_event) == hash(recovered_event) + + +def test_json_parse_setslot(): + evt = {"event": "slot", "name": "departure_airport", "value": "BER"} + assert Event.from_parameters(evt) == SlotSet("departure_airport", "BER") + + +def test_json_parse_restarted(): + evt = {"event": "restart"} + assert Event.from_parameters(evt) == Restarted() + + +def test_json_parse_session_started(): + evt = {"event": "session_started"} + assert Event.from_parameters(evt) == SessionStarted() + + +def test_json_parse_reset(): + evt = {"event": "reset_slots"} + assert Event.from_parameters(evt) == AllSlotsReset() + + +def test_json_parse_user(): + evt = { + "event": "user", + "text": "Hey", + "parse_data": {"intent": {"name": "greet", "confidence": 0.9}, "entities": []}, + "metadata": {}, + } + assert Event.from_parameters(evt) == UserUttered( + "Hey", + intent={"name": "greet", "confidence": 0.9}, + entities=[], + parse_data={"intent": {"name": "greet", "confidence": 0.9}, "entities": []}, + metadata={}, + ) + + +def test_json_parse_action_executed_with_no_hide_rule(): + evt = { + "event": "action", + "name": "action_listen", + "policy": None, + "confidence": None, + "timestamp": None, + } + deserialised: ActionExecuted = Event.from_parameters(evt) + expected = ActionExecuted("action_listen") + assert deserialised == expected + assert deserialised.hide_rule_turn == expected.hide_rule_turn + + +def test_json_parse_bot(): + evt = {"event": "bot", "text": "Hey there!", "data": {}} + assert Event.from_parameters(evt) == BotUttered("Hey there!", {}) + + +def test_json_parse_rewind(): + evt = {"event": "rewind"} + assert Event.from_parameters(evt) == UserUtteranceReverted() + + +def test_json_parse_reminder(): + evt = { + "event": "reminder", + "intent": "my_intent", + "entities": {"entity1": "value1", "entity2": "value2"}, + "date_time": "2018-09-03T11:41:10.128172", + "name": "my_reminder", + "kill_on_user_msg": True, + } + assert Event.from_parameters(evt) == ReminderScheduled( + "my_intent", + parser.parse("2018-09-03T11:41:10.128172"), + name="my_reminder", + kill_on_user_message=True, + ) + + +def test_json_parse_reminder_cancelled(): + evt = { + "event": "cancel_reminder", + "name": "my_reminder", + "intent": "my_intent", + "entities": [ + {"entity": "entity1", "value": "value1"}, + {"entity": "entity2", "value": "value2"}, + ], + "date_time": "2018-09-03T11:41:10.128172", + } + assert Event.from_parameters(evt) == ReminderCancelled( + name="my_reminder", + intent="my_intent", + entities=[ + {"entity": "entity1", "value": "value1"}, + {"entity": "entity2", "value": "value2"}, + ], + timestamp=parser.parse("2018-09-03T11:41:10.128172"), + ) + + +def test_json_parse_undo(): + evt = {"event": "undo"} + assert Event.from_parameters(evt) == ActionReverted() + + +def test_json_parse_export(): + evt = {"event": "export"} + assert Event.from_parameters(evt) == StoryExported() + + +def test_json_parse_followup(): + evt = {"event": "followup", "name": "my_action"} + assert Event.from_parameters(evt) == FollowupAction("my_action") + + +def test_json_parse_pause(): + evt = {"event": "pause"} + assert Event.from_parameters(evt) == ConversationPaused() + + +def test_json_parse_resume(): + evt = {"event": "resume"} + assert Event.from_parameters(evt) == ConversationResumed() + + +def test_json_parse_action(): + evt = {"event": "action", "name": "my_action"} + assert Event.from_parameters(evt) == ActionExecuted("my_action") + + +def test_json_parse_agent(): + evt = {"event": "agent", "text": "Hey, how are you?"} + assert Event.from_parameters(evt) == AgentUttered("Hey, how are you?") + + +@pytest.mark.parametrize( + "event_class", + [ + UserUttered, + BotUttered, + ActionReverted, + Restarted, + AllSlotsReset, + ConversationResumed, + ConversationPaused, + StoryExported, + UserUtteranceReverted, + AgentUttered, + ], +) +def test_correct_timestamp_setting_for_events_with_no_required_params(event_class): + event = event_class() + time.sleep(0.01) + event2 = event_class() + + assert event.timestamp < event2.timestamp + + +@pytest.mark.parametrize("event_class", [SlotSet, ActionExecuted, FollowupAction]) +def test_correct_timestamp_setting(event_class): + event = event_class("test") + time.sleep(0.01) + event2 = event_class("test") + + assert event.timestamp < event2.timestamp + + +@pytest.mark.parametrize("event_class", rasa.shared.utils.common.all_subclasses(Event)) +def test_event_metadata_dict(event_class: Type[Event]): + metadata = {"foo": "bar", "quux": 42} + parameters = { + "metadata": metadata, + "event": event_class.type_name, + "parse_data": {}, + "date_time": "2019-11-20T16:09:16Z", + } + # `ActionExecuted` class and its subclasses require either that action_name + # is not None if it is not an end-to-end predicted action + if event_class.type_name in ["action", "wrong_action", "warning_predicted"]: + parameters["name"] = "test" + + # Create the event from a `dict` that will be accepted by the + # `_from_parameters` method of any `Event` subclass (the values themselves + # are not important). + event = Event.from_parameters(parameters) + assert event.as_dict()["metadata"] == metadata + + +@pytest.mark.parametrize("event_class", rasa.shared.utils.common.all_subclasses(Event)) +def test_event_default_metadata(event_class: Type[Event]): + parameters = { + "event": event_class.type_name, + "parse_data": {}, + "date_time": "2019-11-20T16:09:16Z", + } + # `ActionExecuted` class and its subclasses require either that action_name + # is not None if it is not an end-to-end predicted action + if event_class.type_name in ["action", "wrong_action", "warning_predicted"]: + parameters["name"] = "test" + + # Create an event without metadata. When converting the `Event` to a + # `dict`, it should not include a `metadata` property - unless it's a + # `UserUttered` or a `BotUttered` event (or subclasses of them), in which + # case the metadata should be included with a default value of {}. + event = Event.from_parameters(parameters) + + if isinstance(event, BotUttered) or isinstance(event, UserUttered): + assert event.as_dict()["metadata"] == {} + else: + assert "metadata" not in event.as_dict() + + +@pytest.mark.parametrize( + "event, intent_name", + [ + (UserUttered("text", {}), None), + (UserUttered("dasd", {"name": None}), None), + (UserUttered("adasd", {"name": "intent"}), "intent"), + ], +) +def test_user_uttered_intent_name(event: UserUttered, intent_name: Optional[Text]): + assert event.intent_name == intent_name + + +def test_md_format_message(): + assert format_message("Hello there!", intent="greet", entities=[]) == "Hello there!" + + +def test_md_format_message_empty(): + assert format_message("", intent=None, entities=[]) == "" + + +def test_md_format_message_using_short_entity_syntax(): + formatted = format_message( + "I am from Berlin.", + intent="location", + entities=[{"start": 10, "end": 16, "entity": "city", "value": "Berlin"}], + ) + assert formatted == """I am from [Berlin](city).""" + + +def test_md_format_message_using_short_entity_syntax_no_start_end(): + formatted = format_message( + "hello", intent="location", entities=[{"entity": "city", "value": "Berlin"}] + ) + assert formatted == "hello" + + +def test_md_format_message_no_text(): + formatted = format_message("", intent="location", entities=[]) + assert formatted == "" + + +def test_md_format_message_using_short_entity_syntax_no_start_end_or_text(): + formatted = format_message( + "", intent="location", entities=[{"entity": "city", "value": "Berlin"}] + ) + assert formatted == "" + + +def test_md_format_message_using_long_entity_syntax(): + formatted = format_message( + "I am from Berlin in Germany.", + intent="location", + entities=[ + {"start": 10, "end": 16, "entity": "city", "value": "Berlin"}, + { + "start": 20, + "end": 27, + "entity": "country", + "value": "Germany", + "role": "destination", + }, + ], + ) + assert ( + formatted == """I am from [Berlin](city) in [Germany]""" + """{"entity": "country", "role": "destination"}.""" + ) + + +def test_md_format_message_using_long_entity_syntax_no_start_end(): + formatted = format_message( + "I am from Berlin.", + intent="location", + entities=[ + {"start": 10, "end": 16, "entity": "city", "value": "Berlin"}, + {"entity": "country", "value": "Germany", "role": "destination"}, + ], + ) + assert formatted == "I am from [Berlin](city)." + + +@pytest.mark.parametrize( + ( + "events_to_split,event_type_to_split_on,additional_splitting_conditions," + "n_resulting_lists,include_splitting_event" + ), + [ + # splitting on an action that is not contained in the list results in + # the same list of events + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + ], + BotUttered, + {}, + 1, + False, + ), + # splitting on UserUttered in general results in two lists + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + ], + UserUttered, + {}, + 2, + True, + ), + # we have the same number of lists when not including the event we're + # splitting on + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + ], + UserUttered, + {}, + 2, + False, + ), + # splitting on a specific UserUttered event does not result in a split + # if it does not match + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + ], + UserUttered, + {"intent": "wrong-intent"}, + 1, + True, + ), + # splitting on the right UserUttered event does result in the right split + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + ], + UserUttered, + {"intent": {"name": GREET_INTENT_NAME}}, + 2, + True, + ), + # splitting on a specific ActionExecuted works as well + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": GREET_INTENT_NAME}), + ActionExecuted(UTTER_GREET_ACTION), + ], + ActionExecuted, + {"action_name": UTTER_GREET_ACTION}, + 2, + True, + ), + ], +) +def test_split_events( + events_to_split: List[Event], + event_type_to_split_on: Type[Event], + additional_splitting_conditions: Dict[Text, Any], + n_resulting_lists: int, + include_splitting_event: bool, +): + split_events = rasa.shared.core.events.split_events( + events_to_split, + event_type_to_split_on, + additional_splitting_conditions, + include_splitting_event=include_splitting_event, + ) + assert len(split_events) == n_resulting_lists + + # if we're not including the splitting event, that event type should not be + # contained in the resulting events + if not include_splitting_event: + assert all( + not any(isinstance(event, event_type_to_split_on) for event in events) + for events in split_events + ) + + # make sure the event we're splitting on is the first one if a split happened + if len(split_events) > 1 and include_splitting_event: + assert all( + isinstance(events[0], event_type_to_split_on) for events in split_events[1:] + ) + + +@pytest.mark.parametrize( + "test_events,begin_with_session_start", + [ + # a typical session start + ( + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ActionExecuted(ACTION_LISTEN_NAME), + ], + True, + ), + # also a session start, but with timestamps + ( + [ + ActionExecuted(ACTION_SESSION_START_NAME, timestamp=1), + SessionStarted(timestamp=2), + ActionExecuted(ACTION_LISTEN_NAME, timestamp=3), + ], + True, + ), + # also a session start, but with metadata + ( + [ + ActionExecuted( + ACTION_SESSION_START_NAME, + timestamp=1, + metadata={METADATA_MODEL_ID: "123"}, + ), + SessionStarted(timestamp=2, metadata={METADATA_MODEL_ID: "123"}), + ActionExecuted( + ACTION_LISTEN_NAME, timestamp=3, metadata={METADATA_MODEL_ID: "123"} + ), + ], + True, + ), + # providing a single `action_listen` is not a session start + ([ActionExecuted(ACTION_LISTEN_NAME, timestamp=3)], False), + # providing a single `action_session_start` is not a session start + ([ActionExecuted(ACTION_SESSION_START_NAME)], False), + # providing no events is not a session start + ([], False), + ], +) +def test_events_begin_with_session_start( + test_events: List[Event], begin_with_session_start: bool +): + assert ( + rasa.shared.core.events.do_events_begin_with_session_start(test_events) + == begin_with_session_start + ) + + +@pytest.mark.parametrize( + "end_to_end_event", + [ + ActionExecuted(action_text="I insist on using Markdown"), + UserUttered(text="Markdown is much more readable"), + UserUttered( + text="but YAML ❤️", + intent={INTENT_NAME_KEY: "use_yaml"}, + use_text_for_featurization=True, + ), + ], +) +def test_print_end_to_end_events(end_to_end_event: Event): + with pytest.raises(UnsupportedFeatureException): + end_to_end_event.as_story_string() + + +@pytest.mark.parametrize( + "events,comparison_result", + [ + ( + [ + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ], + True, + ), + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ], + False, + ), + ( + [ + ActionExecuted( + ACTION_UNLIKELY_INTENT_NAME, metadata={"test": {"data1": 1}} + ), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME), + ], + False, + ), + ( + [ + ActionExecuted( + ACTION_UNLIKELY_INTENT_NAME, metadata={"test": {"data1": 1}} + ), + ActionExecuted( + ACTION_UNLIKELY_INTENT_NAME, metadata={"test": {"data1": 1}} + ), + ], + True, + ), + ( + [ + ActionExecuted(ACTION_LISTEN_NAME, metadata={"test": {"data1": 1}}), + ActionExecuted( + ACTION_UNLIKELY_INTENT_NAME, metadata={"test": {"data1": 1}} + ), + ], + False, + ), + ], +) +def test_event_executed_comparison(events: List[Event], comparison_result: bool): + result = all(event == events[0] for event in events) + assert result == comparison_result + + +tested_events = [ + EntitiesAdded( + entities=[ + { + "entity": "city", + "value": "London", + "role": "destination", + "group": "test", + }, + {"entity": "count", "value": 1}, + ], + timestamp=None, + ), + DefinePrevUserUtteredFeaturization( + use_text_for_featurization=False, timestamp=None, metadata=None + ), + ReminderCancelled(timestamp=1621590172.3872123), + ReminderScheduled(timestamp=None, trigger_date_time=datetime.now(), intent="greet"), + ActionExecutionRejected(action_name="my_action"), + LegacyFormValidation(validate=True, timestamp=None), + LoopInterrupted(timestamp=None, is_interrupted=False), + ActiveLoop(name="loop"), + LegacyForm(name="my_form"), + AllSlotsReset(), + SlotSet(key="my_slot", value={}), + SlotSet(key="my slot", value=[]), + SlotSet(key="test", value=1), + SlotSet(key="test", value="text"), + ConversationResumed(), + ConversationPaused(), + FollowupAction(name="test"), + StoryExported(), + Restarted(), + ActionReverted(), + UserUtteranceReverted(), + BotUttered(text="Test bot utterance"), + UserUttered( + parse_data={ + "entities": [], + "response_selector": { + "all_retrieval_intents": [], + "chitchat/ask_weather": {"response": {}, "ranking": []}, + }, + } + ), + UserUttered( + text="hello", + parse_data={ + "intent": {"name": "greet", "confidence": 0.9604260921478271}, + "entities": [ + {"entity": "city", "value": "London"}, + {"entity": "count", "value": 1}, + ], + "text": "hi", + "message_id": "3f4c04602a4947098c574b107d3ccc50", + "metadata": {}, + "intent_ranking": [ + {"name": "greet", "confidence": 0.9604260921478271}, + {"name": "goodbye", "confidence": 0.01835782080888748}, + {"name": "deny", "confidence": 0.011255578137934208}, + {"name": "bot_challenge", "confidence": 0.004019865766167641}, + {"name": "affirm", "confidence": 0.002524246694520116}, + {"name": "mood_great", "confidence": 0.002214624546468258}, + {"name": "chitchat", "confidence": 0.0009614597074687481}, + {"name": "mood_unhappy", "confidence": 0.00024030178610701114}, + ], + "response_selector": { + "all_retrieval_intents": [], + "default": { + "response": { + "id": 11, + "responses": [{"text": "chitchat/ask_name"}], + "response_templates": [{"text": "chitchat/ask_name"}], + "confidence": 0.9618658423423767, + "intent_response_key": "chitchat/ask_name", + "utter_action": "utter_chitchat/ask_name", + "template_name": "utter_chitchat/ask_name", + }, + "ranking": [ + { + "id": 11, + "confidence": 0.9618658423423767, + "intent_response_key": "chitchat/ask_name", + }, + { + "id": 12, + "confidence": 0.03813415765762329, + "intent_response_key": "chitchat/ask_weather", + }, + ], + }, + }, + }, + ), + SessionStarted(), + ActionExecuted(action_name="action_listen"), + AgentUttered(), + EndToEndUserUtterance(), + WronglyClassifiedUserUtterance( + event=UserUttered(), eval_store=EvaluationStore(intent_targets=["test"]) + ), + WronglyPredictedAction( + action_name_prediction="test", + action_name_target="demo", + action_text_target="example", + ), + WarningPredictedAction(action_name="action_listen", action_name_prediction="test"), +] + + +@pytest.mark.parametrize("event", tested_events) +def test_event_fingerprint_consistency(event: Event): + f1 = event.fingerprint() + + event2 = copy.deepcopy(event) + f2 = event2.fingerprint() + + assert f1 == f2 + + +@pytest.mark.parametrize("event_class", rasa.shared.utils.common.all_subclasses(Event)) +def test_event_subclasses_are_tested(event_class: Type[Event]): + subclasses = [event.__class__ for event in tested_events] + assert event_class in subclasses + + +@pytest.mark.parametrize("event", tested_events) +def test_event_fingerprint_uniqueness(event: Event): + f1 = event.fingerprint() + event.type_name = "test" + f2 = event.fingerprint() + + assert f1 != f2 + + +def test_session_started_event_is_not_serialised(): + assert SessionStarted().as_story_string() is None + + +@pytest.mark.parametrize( + "event", + [ + { + "event": "user", + "text": "Hey", + "parse_data": { + "intent": {"name": "greet", "confidence": 0.9}, + "entities": [], + }, + "metadata": {}, + }, + { + "event": "action", + "name": "action_listen", + "policy": None, + "confidence": None, + "timestamp": None, + }, + ], +) +def test_remove_parse_data(event: Dict[Text, Any]): + reduced_event = rasa.shared.core.events.remove_parse_data(event) + assert "parse_data" not in reduced_event diff --git a/tests/shared/core/test_generator.py b/tests/shared/core/test_generator.py new file mode 100644 index 0000000..e30b2c9 --- /dev/null +++ b/tests/shared/core/test_generator.py @@ -0,0 +1,20 @@ +import rasa.shared.core.generator + + +def test_subsample_array_read_only(): + t = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + r = rasa.shared.core.generator._subsample_array( + t, 5, can_modify_incoming_array=False + ) + + assert len(r) == 5 + assert set(r).issubset(t) + + +def test_subsample_array(): + t = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + # this will modify the original array and shuffle it + r = rasa.shared.core.generator._subsample_array(t, 5) + + assert len(r) == 5 + assert set(r).issubset(t) diff --git a/tests/shared/core/test_slot_mappings.py b/tests/shared/core/test_slot_mappings.py new file mode 100644 index 0000000..7e67f38 --- /dev/null +++ b/tests/shared/core/test_slot_mappings.py @@ -0,0 +1,253 @@ +from typing import Text + +import pytest +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION +from rasa.shared.core.constants import SlotMappingType + +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import UserUttered, ActiveLoop +from rasa.shared.core.slot_mappings import SlotMapping +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.utils.validation import YamlValidationException + + +@pytest.mark.parametrize( + "slot_name, expected", [("GPE_destination", True), ("GPE_origin", False)] +) +def test_slot_mapping_entity_is_desired(slot_name: Text, expected: bool): + domain = Domain.from_file("data/test_domains/travel_form.yml") + tracker = DialogueStateTracker("test_id", slots=domain.slots) + event = UserUttered( + text="I'm travelling to Vancouver.", + intent={"name": "inform", "confidence": 0.9604260921478271}, + entities=[{"entity": "GPE", "value": "Vancouver", "role": "destination"}], + ) + tracker.update(event, domain) + slot_mappings = domain.as_dict().get("slots").get(slot_name).get("mappings") + assert SlotMapping.entity_is_desired(slot_mappings[0], tracker) is expected + + +def test_slot_mapping_intent_is_desired(domain: Domain): + domain = Domain.from_file("examples/formbot/domain.yml") + tracker = DialogueStateTracker("sender_id_test", slots=domain.slots) + event1 = UserUttered( + text="I'd like to book a restaurant for 2 people.", + intent={"name": "request_restaurant", "confidence": 0.9604260921478271}, + entities=[{"entity": "number", "value": 2}], + ) + tracker.update(event1, domain) + mappings_for_num_people = ( + domain.as_dict().get("slots").get("num_people").get("mappings") + ) + assert SlotMapping.intent_is_desired(mappings_for_num_people[0], tracker, domain) + + event2 = UserUttered( + text="Yes, 2 please", + intent={"name": "affirm", "confidence": 0.9604260921478271}, + entities=[{"entity": "number", "value": 2}], + ) + tracker.update(event2, domain) + assert ( + SlotMapping.intent_is_desired(mappings_for_num_people[0], tracker, domain) + is False + ) + + event3 = UserUttered( + text="Yes, please", + intent={"name": "affirm", "confidence": 0.9604260921478271}, + entities=[], + ) + tracker.update(event3, domain) + mappings_for_preferences = ( + domain.as_dict().get("slots").get("preferences").get("mappings") + ) + assert ( + SlotMapping.intent_is_desired(mappings_for_preferences[0], tracker, domain) + is False + ) + + +def test_slot_mappings_ignored_intents_during_active_loop(): + domain = Domain.from_yaml( + """ + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + - chitchat + slots: + cuisine: + type: text + mappings: + - type: from_text + conditions: + - active_loop: restaurant_form + forms: + restaurant_form: + ignored_intents: + - chitchat + required_slots: + - cuisine + """ + ) + tracker = DialogueStateTracker("sender_id", slots=domain.slots) + event1 = ActiveLoop("restaurant_form") + event2 = UserUttered( + text="The weather is sunny today", + intent={"name": "chitchat", "confidence": 0.9604260921478271}, + entities=[], + ) + tracker.update_with_events([event1, event2], domain) + mappings_for_cuisine = domain.as_dict().get("slots").get("cuisine").get("mappings") + assert ( + SlotMapping.intent_is_desired(mappings_for_cuisine[0], tracker, domain) is False + ) + + +def test_missing_slot_mappings_raises(): + with pytest.raises(YamlValidationException): + Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + slots: + some_slot: + type: text + influence_conversation: False + """ + ) + + +def test_slot_mappings_invalid_type_raises(): + with pytest.raises(YamlValidationException): + Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + entities: + - from_entity + slots: + some_slot: + type: text + influence_conversation: False + mappings: + type: from_entity + entity: some_entity + """ + ) + + +def test_slot_mappings_check_mapping_validity_from_intent(): + slot_name = "mood" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + - goodbye + - mood_great + - mood_unhappy + + slots: + {slot_name}: + type: categorical + values: + - great + - sad + mappings: + - type: from_intent + not_intent: mood_unhappy + value: great + - type: from_intent + intent: mood_unhappy + value: sad + """ + ) + mappings_for_slot = domain.as_dict().get("slots").get(slot_name).get("mappings") + assert SlotMapping.check_mapping_validity( + slot_name=slot_name, + mapping_type=SlotMappingType.FROM_INTENT, + mapping=mappings_for_slot[0], + domain=domain, + ) + + +@pytest.mark.parametrize( + "intent, expected", + [ + (["goodbye", "mood_great", "greet"], True), + ([], True), + ("", True), + ({}, True), + ("null", True), + ], +) +def test_slot_mappings_check_mapping_validity_valid_intent_list( + intent: Text, expected: bool +): + slot_name = "mood" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + - goodbye + - mood_great + - mood_unhappy + slots: + {slot_name}: + type: any + influence_conversation: false + mappings: + - type: from_intent + value: "testing 123" + intent: {intent} + forms: + test_form: + required_slots: + - test_slot + """ + ) + mappings_for_slot = domain.as_dict().get("slots").get(slot_name).get("mappings") + assert ( + SlotMapping.check_mapping_validity( + slot_name=slot_name, + mapping_type=SlotMappingType.FROM_INTENT, + mapping=mappings_for_slot[0], + domain=domain, + ) + is expected + ) + + +def test_slot_mappings_check_mapping_validity_invalid_intent_list(): + slot_name = "mood" + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + - goodbye + - mood_great + - mood_unhappy + slots: + {slot_name}: + type: any + influence_conversation: false + mappings: + - type: from_intent + value: "testing 123" + intent: + - aaaa + - bbbb + - cccc + forms: + test_form: + required_slots: + - test_slot + """ + ) + mappings_for_slot = domain.as_dict().get("slots").get(slot_name).get("mappings") + assert not SlotMapping.check_mapping_validity( + slot_name=slot_name, + mapping_type=SlotMappingType.FROM_INTENT, + mapping=mappings_for_slot[0], + domain=domain, + ) diff --git a/tests/shared/core/test_slots.py b/tests/shared/core/test_slots.py new file mode 100644 index 0000000..25744b2 --- /dev/null +++ b/tests/shared/core/test_slots.py @@ -0,0 +1,397 @@ +from typing import Any, Tuple, List, Dict, Text + +import pytest +from _pytest.fixtures import SubRequest + +import rasa.shared.core.constants +from rasa.shared.core.events import SlotSet +from rasa.shared.core.slots import ( + InvalidSlotTypeException, + Slot, + TextSlot, + BooleanSlot, + FloatSlot, + ListSlot, + CategoricalSlot, + bool_from_any, + AnySlot, + InvalidSlotConfigError, +) +from rasa.shared.core.trackers import DialogueStateTracker + + +class SlotTestCollection: + """Tests every slot needs to fulfill. + + Each slot can declare further tests on its own.""" + + def create_slot( + self, mappings: List[Dict[Text, Any]], influence_conversation: bool + ) -> Slot: + raise NotImplementedError + + def value_feature_pair(self, request: SubRequest) -> Tuple[Any, List[float]]: + """Values where featurization is defined and should be tested.""" + raise NotImplementedError + + def invalid_value(self, request: SubRequest) -> Any: + """Values, that should be handled gracefully but where the + featurization is not defined.""" + raise NotImplementedError + + @pytest.fixture() + def mappings(self) -> List[Dict[Text, Any]]: + return [{}] + + def test_featurization( + self, + value_feature_pair: Tuple[Any, List[float]], + mappings: List[Dict[Text, Any]], + ): + slot = self.create_slot(mappings=mappings, influence_conversation=True) + value, expected = value_feature_pair + slot.value = value + assert slot.as_feature() == expected + assert ( + len(slot.as_feature()) == slot.feature_dimensionality() + ), "Wrong feature dimensionality" + + # now reset the slot to get initial value again + slot.reset() + assert ( + slot.value == slot.initial_value + ), "Slot should be reset to its initial value" + + def test_empty_slot_featurization(self, mappings: List[Dict[Text, Any]]): + slot = self.create_slot(mappings=mappings, influence_conversation=True) + assert ( + slot.value == slot.initial_value + ), "An empty slot should be set to the initial value" + assert len(slot.as_feature()) == slot.feature_dimensionality() + + def test_featurization_if_marked_as_unfeaturized( + self, + value_feature_pair: Tuple[Any, List[float]], + mappings: List[Dict[Text, Any]], + ): + slot = self.create_slot(mappings=mappings, influence_conversation=False) + value, _ = value_feature_pair + slot.value = value + + features = slot.as_feature() + assert features == [] + + dimensions = slot.feature_dimensionality() + assert dimensions == 0 + + def test_has_a_type_name(self, mappings: List[Dict[Text, Any]]): + slot = self.create_slot(mappings=mappings, influence_conversation=True) + assert slot.type_name is not None + assert type(slot) == Slot.resolve_by_type(slot.type_name) + + def test_handles_invalid_values( + self, invalid_value: Any, mappings: List[Dict[Text, Any]] + ): + slot = self.create_slot(mappings=mappings, influence_conversation=True) + slot.value = invalid_value + assert slot.as_feature() is not None + assert len(slot.as_feature()) == slot.feature_dimensionality() + + @pytest.mark.parametrize("influence_conversation", [True, False]) + def test_serialization( + self, influence_conversation: bool, mappings: List[Dict[Text, Any]] + ): + slot = self.create_slot(mappings, influence_conversation) + + persistence_info = slot.persistence_info() + + slot_type = Slot.resolve_by_type(persistence_info.get("type")) + recreated = slot_type( + slot.name, **{k: v for k, v in persistence_info.items() if k != "type"} + ) + + assert isinstance(slot, slot_type) + assert recreated.persistence_info() == persistence_info + + @pytest.mark.parametrize("influence_conversation", [True, False]) + def test_slot_has_been_set( + self, + influence_conversation: bool, + value_feature_pair: Tuple[Any, List[float]], + mappings: List[Dict[Text, Any]], + ): + slot = self.create_slot(mappings, influence_conversation) + assert not slot.has_been_set + value, _ = value_feature_pair + slot.value = value + assert slot.has_been_set + slot.reset() + assert not slot.has_been_set + + @pytest.mark.parametrize( + "influence_conversation, slot_mappings", + [ + (True, []), + (True, [{"type": "from_entity", "entity": "test"}]), + (False, []), + (False, [{"type": "from_entity", "entity": "test"}]), + ], + ) + def test_slot_fingerprint_consistency( + self, influence_conversation: bool, slot_mappings: List[Dict[Text, Any]] + ): + slot1 = self.create_slot(slot_mappings, influence_conversation) + slot2 = self.create_slot(slot_mappings, influence_conversation) + f1 = slot1.fingerprint() + f2 = slot2.fingerprint() + assert f1 == f2 + + @pytest.mark.parametrize("influence_conversation", [True, False]) + def test_slot_fingerprint_uniqueness( + self, influence_conversation: bool, mappings: List[Dict[Text, Any]] + ): + slot = self.create_slot(mappings, influence_conversation) + f1 = slot.fingerprint() + slot.value = "changed" + f2 = slot.fingerprint() + assert f1 != f2 + + +class TestTextSlot(SlotTestCollection): + def create_slot( + self, mappings: List[Dict[Text, Any]], influence_conversation: bool + ) -> Slot: + return TextSlot( + "test", mappings=mappings, influence_conversation=influence_conversation + ) + + @pytest.fixture(params=[1, {"a": "b"}, 2.0, [], True]) + def invalid_value(self, request: SubRequest) -> Any: + return request.param + + @pytest.fixture( + params=[ + (None, [0]), + ("", [1]), + ("some test string", [1]), + ("some test string 🌴", [1]), + ] + ) + def value_feature_pair(self, request: SubRequest) -> Tuple[Any, List[float]]: + return request.param + + +class TestBooleanSlot(SlotTestCollection): + def create_slot( + self, mappings: List[Dict[Text, Any]], influence_conversation: bool + ) -> Slot: + return BooleanSlot( + "test", mappings=mappings, influence_conversation=influence_conversation + ) + + @pytest.fixture(params=[{"a": "b"}, [], "asd", "🌴"]) + def invalid_value(self, request: SubRequest) -> Any: + return request.param + + @pytest.fixture( + params=[ + (None, [0, 0]), + (True, [1, 1]), + ("9", [1, 0]), + (12, [1, 0]), + (False, [1, 0]), + ("0", [1, 0]), + (0, [1, 0]), + ("true", [1, 1]), + ("True", [1, 1]), + ("false", [1, 0]), + ("False", [1, 0]), + ] + ) + def value_feature_pair(self, request: SubRequest) -> Tuple[Any, List[float]]: + return request.param + + +def test_bool_from_any_raises_value_error(): + with pytest.raises(ValueError): + bool_from_any("abc") + + +def test_bool_from_any_raises_type_error(): + with pytest.raises(TypeError): + bool_from_any(None) + + +class TestFloatSlot(SlotTestCollection): + def create_slot( + self, mappings: List[Dict[Text, Any]], influence_conversation: bool = False + ) -> Slot: + return FloatSlot( + "test", mappings=mappings, influence_conversation=influence_conversation + ) + + @pytest.fixture(params=[{"a": "b"}, [], "asd", "🌴"]) + def invalid_value(self, request: SubRequest) -> Any: + return request.param + + @pytest.fixture( + params=[ + (None, [0, 0]), + (True, [1, 1]), + (2.0, [1, 1]), + (1.0, [1, 1]), + (0.5, [1, 0.5]), + (0, [1, 0]), + (-0.5, [1, 0.0]), + ] + ) + def value_feature_pair(self, request: SubRequest) -> Tuple[Any, List[float]]: + return request.param + + +class TestListSlot(SlotTestCollection): + def create_slot( + self, mappings: List[Dict[Text, Any]], influence_conversation: bool + ) -> Slot: + return ListSlot( + "test", mappings=mappings, influence_conversation=influence_conversation + ) + + @pytest.fixture(params=[{"a": "b"}, 1, True, "asd", "🌴"]) + def invalid_value(self, request: SubRequest) -> Any: + return request.param + + @pytest.fixture(params=[(None, [0]), ([], [0]), ([1], [1]), (["asd", 1, {}], [1])]) + def value_feature_pair(self, request: SubRequest) -> Tuple[Any, List[float]]: + return request.param + + @pytest.mark.parametrize("value", ["cat", ["cat"]]) + def test_apply_single_item_to_slot( + self, value: Any, mappings: List[Dict[Text, Any]] + ): + slot = self.create_slot(mappings=mappings, influence_conversation=False) + tracker = DialogueStateTracker.from_events("sender", evts=[], slots=[slot]) + + slot_event = SlotSet(slot.name, value) + tracker.update(slot_event) + + assert tracker.slots[slot.name].value == ["cat"] + + +class TestCategoricalSlot(SlotTestCollection): + def create_slot( + self, mappings: List[Dict[Text, Any]], influence_conversation: bool + ) -> Slot: + return CategoricalSlot( + "test", + mappings=mappings, + values=[1, "two", "小于", {"three": 3}, "nOnE", "None", "null"], + influence_conversation=influence_conversation, + ) + + # None is a special value reserved for unset slots. + @pytest.fixture(params=[{"a": "b"}, 2, True, "asd", "🌴", None]) + def invalid_value(self, request: SubRequest) -> Any: + return request.param + + @pytest.fixture( + params=[ + (None, [0, 0, 0, 0, 0, 0, 0]), # slot is unset + (1, [1, 0, 0, 0, 0, 0, 0]), + ("two", [0, 1, 0, 0, 0, 0, 0]), + ("小于", [0, 0, 1, 0, 0, 0, 0]), + ({"three": 3}, [0, 0, 0, 1, 0, 0, 0]), + ("nOnE", [0, 0, 0, 0, 1, 0, 0]), + ("None", [0, 0, 0, 0, 1, 0, 0]), # same as for 'nOnE' (case insensivity) + ("null", [0, 0, 0, 0, 0, 0, 1]), + ( + rasa.shared.core.constants.DEFAULT_CATEGORICAL_SLOT_VALUE, + [0, 0, 0, 0, 0, 0, 0], + ), + ] + ) + def value_feature_pair(self, request: SubRequest) -> Tuple[Any, List[float]]: + return request.param + + +class TestCategoricalSlotDefaultValue(SlotTestCollection): + def create_slot( + self, mappings: List[Dict[Text, Any]], influence_conversation: bool + ) -> Slot: + slot = CategoricalSlot( + "test", + mappings=mappings, + values=[1, "two", "小于", {"three": 3}, "nOnE", "None", "null"], + influence_conversation=influence_conversation, + ) + slot.add_default_value() + return slot + + # None is a special value reserved for unset slots. + @pytest.fixture(params=[{"a": "b"}, 2, True, "asd", "🌴", None]) + def invalid_value(self, request: SubRequest) -> Any: + return request.param + + @pytest.fixture( + params=[ + (None, [0, 0, 0, 0, 0, 0, 0, 0]), # slot is unset + (1, [1, 0, 0, 0, 0, 0, 0, 0]), + ("two", [0, 1, 0, 0, 0, 0, 0, 0]), + ("小于", [0, 0, 1, 0, 0, 0, 0, 0]), + ({"three": 3}, [0, 0, 0, 1, 0, 0, 0, 0]), + ("nOnE", [0, 0, 0, 0, 1, 0, 0, 0]), + ("None", [0, 0, 0, 0, 1, 0, 0, 0]), # same as for 'nOnE' (case insensivity) + ("null", [0, 0, 0, 0, 0, 0, 1, 0]), + ( + rasa.shared.core.constants.DEFAULT_CATEGORICAL_SLOT_VALUE, + [0, 0, 0, 0, 0, 0, 0, 1], + ), + ("unseen value", [0, 0, 0, 0, 0, 0, 0, 1]), + ] + ) + def value_feature_pair(self, request: SubRequest) -> Tuple[Any, List[float]]: + return request.param + + +class TestAnySlot(SlotTestCollection): + def create_slot( + self, mappings: List[Dict[Text, Any]], influence_conversation: bool + ) -> Slot: + return AnySlot("test", mappings=mappings, influence_conversation=False) + + @pytest.fixture(params=["there is nothing invalid, but we need to pass something"]) + def invalid_value(self, request: SubRequest) -> Any: + return request.param + + @pytest.fixture( + params=[ + (None, []), + ([], []), + ({"nested": {"dict": [1, 2, 3]}}, []), + (["asd", 1, {}], []), + ] + ) + def value_feature_pair(self, request: SubRequest) -> Tuple[Any, List[float]]: + return request.param + + def test_exception_if_featurized(self, mappings: List[Dict[Text, Any]]): + with pytest.raises(InvalidSlotConfigError): + AnySlot("⛔️", mappings=mappings, influence_conversation=True) + + +def test_raises_on_invalid_slot_type(): + with pytest.raises(InvalidSlotTypeException): + Slot.resolve_by_type("foobar") + + +def test_categorical_slot_ignores_none_value(): + """Checks that None can't be added as a possible value for categorical slots.""" + with pytest.warns(UserWarning) as records: + slot = CategoricalSlot( + name="branch", mappings=[{}], values=["Berlin", None, "San Francisco"] + ) + + assert "none" not in slot.values + + message_text = "Rasa will ignore `null` as a possible value for the 'branch' slot." + assert any(message_text in record.message.args[0] for record in records) diff --git a/tests/shared/core/test_trackers.py b/tests/shared/core/test_trackers.py new file mode 100644 index 0000000..b012626 --- /dev/null +++ b/tests/shared/core/test_trackers.py @@ -0,0 +1,1618 @@ +import datetime +import json +import logging +import os +import textwrap +import time +from pathlib import Path +import tempfile +from typing import List, Text, Dict, Any, Type + +import fakeredis +import freezegun +import pytest + +from rasa.core.actions.action import ActionExtractSlots +from rasa.core.channels import CollectingOutputChannel +from rasa.core.nlg import TemplatedNaturalLanguageGenerator +from rasa.core.training import load_data +import rasa.shared.utils.io +import rasa.utils.io +from rasa.core import training +from rasa.shared.core.constants import ( + ACTION_LISTEN_NAME, + ACTION_SESSION_START_NAME, + LOOP_NAME, + REQUESTED_SLOT, + LOOP_INTERRUPTED, +) +from rasa.shared.constants import ( + ASSISTANT_ID_KEY, + DEFAULT_SENDER_ID, + LATEST_TRAINING_DATA_FORMAT_VERSION, +) +from rasa.core.agent import Agent +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + AgentUttered, + AllSlotsReset, + ConversationPaused, + ConversationResumed, + FollowupAction, + ReminderScheduled, + SlotSet, + StoryExported, + UserUttered, + ActionExecuted, + Restarted, + ActionReverted, + UserUtteranceReverted, + SessionStarted, + Event, + ActiveLoop, + ActionExecutionRejected, + BotUttered, + LegacyForm, + LegacyFormValidation, + LoopInterrupted, + DefinePrevUserUtteredFeaturization, + EntitiesAdded, +) +from rasa.shared.core.slots import ( + FloatSlot, + BooleanSlot, + ListSlot, + TextSlot, + Slot, + AnySlot, +) +from rasa.core.tracker_store import ( + InMemoryTrackerStore, + RedisTrackerStore, + SQLTrackerStore, +) +from rasa.core.tracker_store import TrackerStore +from rasa.shared.core.trackers import DialogueStateTracker, EventVerbosity +from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, +) +from tests.core.conftest import MockedMongoTrackerStore +from tests.dialogues import ( + TEST_DIALOGUES, + TEST_MOODBOT_DIALOGUE, + TEST_DOMAINS_FOR_DIALOGUES, +) +from tests.core.utilities import tracker_from_dialogue, user_uttered, get_tracker + +from rasa.shared.nlu.constants import ( + ACTION_NAME, + METADATA_MODEL_ID, + PREDICTED_CONFIDENCE_KEY, +) + +test_domain = Domain.load("data/test_moodbot/domain.yml") + + +class MockRedisTrackerStore(RedisTrackerStore): + def __init__(self, _domain: Domain) -> None: + super().__init__(_domain) + + # Patch the Redis connection in RedisTrackerStore using fakeredis + self.red = fakeredis.FakeStrictRedis() + + # added in redis==3.3.0, but not yet in fakeredis + self.red.connection_pool.connection_class.health_check_interval = 0 + + +def stores_to_be_tested(): + temp = tempfile.mkdtemp() + return [ + MockRedisTrackerStore(test_domain), + InMemoryTrackerStore(test_domain), + SQLTrackerStore(test_domain, db=os.path.join(temp, "rasa.db")), + MockedMongoTrackerStore(test_domain), + ] + + +def stores_to_be_tested_ids(): + return ["redis-tracker", "in-memory-tracker", "SQL-tracker", "mongo-tracker"] + + +def test_tracker_duplicate(moodbot_domain: Domain): + tracker = tracker_from_dialogue(TEST_MOODBOT_DIALOGUE, moodbot_domain) + num_actions = len( + [ + event + for event in TEST_MOODBOT_DIALOGUE.events + if isinstance(event, ActionExecuted) + ] + ) + + # There is always one duplicated tracker more than we have actions, + # as the tracker also gets duplicated for the + # action that would be next (but isn't part of the operations) + assert len(list(tracker.generate_all_prior_trackers())) == num_actions + 1 + + +@pytest.mark.parametrize("store", stores_to_be_tested(), ids=stores_to_be_tested_ids()) +async def test_tracker_store_storage_and_retrieval(store: TrackerStore): + tracker = await store.get_or_create_tracker("some-id") + # the retrieved tracker should be empty + assert tracker.sender_id == "some-id" + + # Action listen should be in there + assert list(tracker.events) == [ActionExecuted(ACTION_LISTEN_NAME)] + + # lets log a test message + intent = {"name": "greet", "confidence": 1.0} + tracker.update(UserUttered("/greet", intent, [])) + assert tracker.latest_message.intent.get("name") == "greet" + await store.save(tracker) + + # retrieving the same tracker should result in the same tracker + retrieved_tracker = await store.get_or_create_tracker("some-id") + assert retrieved_tracker.sender_id == "some-id" + assert len(retrieved_tracker.events) == 2 + assert retrieved_tracker.latest_message.intent.get("name") == "greet" + + # getting another tracker should result in an empty tracker again + other_tracker = await store.get_or_create_tracker("some-other-id") + assert other_tracker.sender_id == "some-other-id" + assert len(other_tracker.events) == 1 + + +@pytest.mark.parametrize("store", stores_to_be_tested(), ids=stores_to_be_tested_ids()) +@pytest.mark.parametrize("pair", zip(TEST_DIALOGUES, TEST_DOMAINS_FOR_DIALOGUES)) +async def test_tracker_store(store, pair): + dialogue, domainpath = pair + domain = Domain.load(domainpath) + tracker = tracker_from_dialogue(dialogue, domain) + await store.save(tracker) + restored = await store.retrieve(tracker.sender_id) + assert restored == tracker + + +def test_tracker_write_to_story(tmp_path: Path, moodbot_domain: Domain): + tracker = tracker_from_dialogue(TEST_MOODBOT_DIALOGUE, moodbot_domain) + p = tmp_path / "export.yml" + tracker.export_stories_to_file(str(p)) + trackers = training.load_data( + str(p), + moodbot_domain, + use_story_concatenation=False, + tracker_limit=1000, + remove_duplicates=False, + ) + assert len(trackers) == 1 + recovered = trackers[0] + assert len(recovered.events) == 11 + assert recovered.events[4].type_name == "user" + assert recovered.events[4].intent == { + "confidence": 1.0, + "name": "mood_unhappy", + "full_retrieval_intent_name": None, + } + + +async def test_tracker_state_regression_without_bot_utterance(default_agent: Agent): + sender_id = "test_tracker_state_regression_without_bot_utterance" + for i in range(0, 2): + await default_agent.handle_text("/greet", sender_id=sender_id) + tracker = await default_agent.tracker_store.get_or_create_tracker(sender_id) + + # Ensures that the tracker has changed between the utterances + # (and wasn't reset in between them) + expected = ( + "action_session_start;action_listen;greet;utter_greet;action_listen;" + "greet;utter_greet;action_listen" + ) + assert ( + ";".join([e.as_story_string() for e in tracker.events if e.as_story_string()]) + == expected + ) + + +async def test_tracker_state_regression_with_bot_utterance(default_agent: Agent): + sender_id = "test_tracker_state_regression_with_bot_utterance" + for i in range(0, 2): + await default_agent.handle_text("/greet", sender_id=sender_id) + tracker = await default_agent.tracker_store.get_or_create_tracker(sender_id) + + expected = [ + "action_session_start", + None, + "action_listen", + "greet", + None, # DefinePrevUserUtteredFeaturization + "utter_greet", + None, + "action_listen", + "greet", + None, # DefinePrevUserUtteredFeaturization + "utter_greet", + None, + "action_listen", + ] + + assert [e.as_story_string() for e in tracker.events] == expected + + +async def test_bot_utterance_comes_after_action_event(default_agent: Agent): + sender_id = "test_bot_utterance_comes_after_action_event" + + await default_agent.handle_text("/greet", sender_id=sender_id) + + tracker = await default_agent.tracker_store.get_or_create_tracker(sender_id) + + # important is, that the 'bot' comes after the second 'action' and not + # before + expected = [ + "action", + "session_started", + "action", + "user", + "user_featurization", + "action", + "bot", + "action", + ] + + assert [e.type_name for e in tracker.events] == expected + + +@pytest.mark.parametrize( + "entities, expected_values", + [ + ([{"value": "greet", "entity": "entity_name"}], ["greet"]), + ( + [ + {"value": "greet", "entity": "entity_name"}, + {"value": "bye", "entity": "other"}, + ], + ["greet"], + ), + ( + [ + {"value": "greet", "entity": "entity_name"}, + {"value": "bye", "entity": "entity_name"}, + ], + ["greet", "bye"], + ), + ( + [ + {"value": "greet", "entity": "entity_name", "role": "role"}, + {"value": "bye", "entity": "entity_name"}, + ], + ["greet"], + ), + ( + [ + {"value": "greet", "entity": "entity_name", "group": "group"}, + {"value": "bye", "entity": "entity_name"}, + ], + ["greet"], + ), + ( + [ + {"value": "greet", "entity": "entity_name"}, + {"value": "bye", "entity": "entity_name", "group": "group"}, + ], + # bye has group set, so it should not be extracted + ["greet"], + ), + ], +) +def test_get_latest_entity_values( + entities: List[Dict[Text, Any]], expected_values: List[Text], domain: Domain +): + entity_type = entities[0].get("entity") + entity_role = entities[0].get("role") + entity_group = entities[0].get("group") + + tracker = DialogueStateTracker("default", domain.slots) + # the retrieved tracker should be empty + assert len(tracker.events) == 0 + assert list(tracker.get_latest_entity_values(entity_type)) == [] + + intent = {"name": "greet", PREDICTED_CONFIDENCE_KEY: 1.0} + tracker.update(UserUttered("/greet", intent, entities)) + + assert ( + list( + tracker.get_latest_entity_values( + entity_type, entity_role=entity_role, entity_group=entity_group + ) + ) + == expected_values + ) + assert list(tracker.get_latest_entity_values("unknown")) == [] + + +async def test_tracker_update_slots_with_entity(domain: Domain): + tracker = DialogueStateTracker("default", domain.slots) + + test_entity = domain.entities[0] + expected_slot_value = "test user" + + intent = {"name": "greet", PREDICTED_CONFIDENCE_KEY: 1.0} + tracker.update( + UserUttered( + "/greet", + intent, + [ + { + "start": 1, + "end": 5, + "value": expected_slot_value, + "entity": test_entity, + "extractor": "manual", + } + ], + ), + domain, + ) + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + tracker.update_with_events(events, domain) + + assert tracker.get_slot(test_entity) == expected_slot_value + + +def test_restart_event(domain: Domain): + tracker = DialogueStateTracker("default", domain.slots) + # the retrieved tracker should be empty + assert len(tracker.events) == 0 + + intent = {"name": "greet", PREDICTED_CONFIDENCE_KEY: 1.0} + tracker.update(ActionExecuted(ACTION_LISTEN_NAME)) + tracker.update(UserUttered("/greet", intent, [])) + tracker.update(ActionExecuted("my_action")) + tracker.update(ActionExecuted(ACTION_LISTEN_NAME)) + + assert len(tracker.events) == 4 + assert tracker.latest_message.text == "/greet" + assert len(list(tracker.generate_all_prior_trackers())) == 4 + + tracker.update(Restarted()) + + assert len(tracker.events) == 5 + assert tracker.followup_action == ACTION_SESSION_START_NAME + + tracker.update(SessionStarted()) + + assert tracker.followup_action == ACTION_LISTEN_NAME + assert tracker.latest_message.text is None + assert len(list(tracker.generate_all_prior_trackers())) == 1 + + dialogue = tracker.as_dialogue() + + recovered = DialogueStateTracker("default", domain.slots) + recovered.recreate_from_dialogue(dialogue) + + assert recovered.current_state() == tracker.current_state() + assert len(recovered.events) == 6 + assert recovered.latest_message.text is None + assert len(list(recovered.generate_all_prior_trackers())) == 1 + + +def test_session_start(domain: Domain): + tracker = DialogueStateTracker("default", domain.slots) + # the retrieved tracker should be empty + assert len(tracker.events) == 0 + + # add a SessionStarted event + tracker.update(SessionStarted()) + + # tracker has one event + assert len(tracker.events) == 1 + + +def test_revert_action_event(domain: Domain): + tracker = DialogueStateTracker("default", domain.slots) + # the retrieved tracker should be empty + assert len(tracker.events) == 0 + + intent = {"name": "greet", PREDICTED_CONFIDENCE_KEY: 1.0} + tracker.update(ActionExecuted(ACTION_LISTEN_NAME)) + tracker.update(UserUttered("/greet", intent, [])) + tracker.update(ActionExecuted("my_action")) + tracker.update(ActionExecuted(ACTION_LISTEN_NAME)) + + # Expecting count of 4: + # +3 executed actions + # +1 final state + assert tracker.latest_action.get(ACTION_NAME) == ACTION_LISTEN_NAME + assert len(list(tracker.generate_all_prior_trackers())) == 4 + + tracker.update(ActionReverted()) + + # Expecting count of 3: + # +3 executed actions + # +1 final state + # -1 reverted action + assert tracker.latest_action.get(ACTION_NAME) == "my_action" + assert len(list(tracker.generate_all_prior_trackers())) == 3 + + dialogue = tracker.as_dialogue() + + recovered = DialogueStateTracker("default", domain.slots) + recovered.recreate_from_dialogue(dialogue) + + assert recovered.current_state() == tracker.current_state() + assert tracker.latest_action.get(ACTION_NAME) == "my_action" + assert len(list(tracker.generate_all_prior_trackers())) == 3 + + +def test_revert_user_utterance_event(domain: Domain): + tracker = DialogueStateTracker("default", domain.slots) + # the retrieved tracker should be empty + assert len(tracker.events) == 0 + + intent1 = {"name": "greet", PREDICTED_CONFIDENCE_KEY: 1.0} + tracker.update(ActionExecuted(ACTION_LISTEN_NAME)) + tracker.update(UserUttered("/greet", intent1, [])) + tracker.update(ActionExecuted("my_action_1")) + tracker.update(ActionExecuted(ACTION_LISTEN_NAME)) + + intent2 = {"name": "goodbye", PREDICTED_CONFIDENCE_KEY: 1.0} + tracker.update(UserUttered("/goodbye", intent2, [])) + tracker.update(ActionExecuted("my_action_2")) + tracker.update(ActionExecuted(ACTION_LISTEN_NAME)) + + # Expecting count of 6: + # +5 executed actions + # +1 final state + assert tracker.latest_action.get(ACTION_NAME) == ACTION_LISTEN_NAME + assert len(list(tracker.generate_all_prior_trackers())) == 6 + + tracker.update(UserUtteranceReverted()) + + # Expecting count of 3: + # +5 executed actions + # +1 final state + # -2 rewound actions associated with the /goodbye + # -1 rewound action from the listen right before /goodbye + assert tracker.latest_action.get(ACTION_NAME) == "my_action_1" + assert len(list(tracker.generate_all_prior_trackers())) == 3 + + dialogue = tracker.as_dialogue() + + recovered = DialogueStateTracker("default", domain.slots) + recovered.recreate_from_dialogue(dialogue) + + assert recovered.current_state() == tracker.current_state() + assert tracker.latest_action.get(ACTION_NAME) == "my_action_1" + assert len(list(tracker.generate_all_prior_trackers())) == 3 + + +def test_traveling_back_in_time(domain: Domain): + tracker = DialogueStateTracker("default", domain.slots) + # the retrieved tracker should be empty + assert len(tracker.events) == 0 + + intent = {"name": "greet", PREDICTED_CONFIDENCE_KEY: 1.0} + tracker.update(ActionExecuted(ACTION_LISTEN_NAME)) + tracker.update(UserUttered("/greet", intent, [])) + + time.sleep(1) + time_for_timemachine = time.time() + time.sleep(1) + + tracker.update(ActionExecuted("my_action")) + tracker.update(ActionExecuted(ACTION_LISTEN_NAME)) + + # Expecting count of 4: + # +3 executed actions + # +1 final state + assert tracker.latest_action.get(ACTION_NAME) == ACTION_LISTEN_NAME + assert len(tracker.events) == 4 + assert len(list(tracker.generate_all_prior_trackers())) == 4 + + tracker = tracker.travel_back_in_time(time_for_timemachine) + + # Expecting count of 2: + # +1 executed actions + # +1 final state + assert tracker.latest_action.get(ACTION_NAME) == ACTION_LISTEN_NAME + assert len(tracker.events) == 2 + assert len(list(tracker.generate_all_prior_trackers())) == 2 + + +def test_tracker_init_copy(domain: Domain): + sender_id = "some-id" + tracker = DialogueStateTracker(sender_id, domain.slots) + tracker_copy = tracker.init_copy() + assert tracker.sender_id == tracker_copy.sender_id + + +def _load_tracker_from_json(tracker_dump: Text, domain: Domain) -> DialogueStateTracker: + """Read the json dump from the file and instantiate a tracker it.""" + + tracker_json = json.loads(rasa.shared.utils.io.read_file(tracker_dump)) + sender_id = tracker_json.get("sender_id", DEFAULT_SENDER_ID) + return DialogueStateTracker.from_dict( + sender_id, tracker_json.get("events", []), domain.slots + ) + + +def test_dump_and_restore_as_json( + default_agent: Agent, tmp_path: Path, stories_path: Text +): + trackers = load_data(stories_path, default_agent.domain) + + for tracker in trackers: + out_path = tmp_path / "dumped_tracker.json" + + dumped = tracker.current_state(EventVerbosity.AFTER_RESTART) + rasa.shared.utils.io.dump_obj_as_json_to_file(str(out_path), dumped) + + restored_tracker = _load_tracker_from_json(str(out_path), default_agent.domain) + + assert restored_tracker == tracker + + +def test_read_json_dump(default_agent: Agent): + tracker_dump = "data/test_trackers/tracker_moodbot.json" + tracker_json = json.loads(rasa.shared.utils.io.read_file(tracker_dump)) + + restored_tracker = _load_tracker_from_json(tracker_dump, default_agent.domain) + + assert len(restored_tracker.events) == 7 + assert restored_tracker.latest_action.get(ACTION_NAME) == "action_listen" + assert not restored_tracker.is_paused() + assert restored_tracker.sender_id == "mysender" + assert restored_tracker.events[-1].timestamp == 1517821726.211042 + + restored_state = restored_tracker.current_state(EventVerbosity.AFTER_RESTART) + assert restored_state == tracker_json + + +def test_current_state_after_restart(default_agent): + tracker_dump = "data/test_trackers/tracker_moodbot.json" + tracker_json = json.loads(rasa.shared.utils.io.read_file(tracker_dump)) + + tracker_json["events"].insert(3, {"event": "restart"}) + + tracker = DialogueStateTracker.from_dict( + tracker_json.get("sender_id"), + tracker_json.get("events", []), + default_agent.domain.slots, + ) + + events_after_restart = [e.as_dict() for e in list(tracker.events)[4:]] + + state = tracker.current_state(EventVerbosity.AFTER_RESTART) + assert state.get("events") == events_after_restart + + +def test_current_state_all_events(empty_agent: Agent): + tracker_dump = "data/test_trackers/tracker_moodbot.json" + tracker_json = json.loads(rasa.shared.utils.io.read_file(tracker_dump)) + + tracker_json["events"].insert(3, {"event": "restart"}) + + tracker = DialogueStateTracker.from_dict( + tracker_json.get("sender_id"), + tracker_json.get("events", []), + empty_agent.domain.slots, + ) + + evts = [e.as_dict() for e in tracker.events] + + state = tracker.current_state(EventVerbosity.ALL) + assert state.get("events") == evts + + +def test_current_state_no_events(empty_agent: Agent): + tracker_dump = "data/test_trackers/tracker_moodbot.json" + tracker_json = json.loads(rasa.shared.utils.io.read_file(tracker_dump)) + + tracker = DialogueStateTracker.from_dict( + tracker_json.get("sender_id"), + tracker_json.get("events", []), + empty_agent.domain.slots, + ) + + state = tracker.current_state(EventVerbosity.NONE) + assert state.get("events") is None + + +def test_current_state_applied_events(empty_agent: Agent): + tracker_dump = "data/test_trackers/tracker_moodbot.json" + tracker_json = json.loads(rasa.shared.utils.io.read_file(tracker_dump)) + + # add some events that result in other events not being applied anymore + tracker_json["events"].insert(1, {"event": "restart"}) + tracker_json["events"].insert(7, {"event": "rewind"}) + tracker_json["events"].insert(8, {"event": "undo"}) + + tracker = DialogueStateTracker.from_dict( + tracker_json.get("sender_id"), + tracker_json.get("events", []), + empty_agent.domain.slots, + ) + + evts = [e.as_dict() for e in tracker.events] + applied_events = [evts[2], evts[9]] + + state = tracker.current_state(EventVerbosity.APPLIED) + assert state.get("events") == applied_events + + +def test_session_started_not_part_of_applied_events(empty_agent: Agent): + # take tracker dump and insert a SessionStarted event sequence + tracker_dump = "data/test_trackers/tracker_moodbot.json" + tracker_json = json.loads(rasa.shared.utils.io.read_file(tracker_dump)) + tracker_json["events"].insert( + 4, {"event": ActionExecuted.type_name, "name": ACTION_SESSION_START_NAME} + ) + tracker_json["events"].insert(5, {"event": SessionStarted.type_name}) + + # initialise a tracker from this list of events + tracker = DialogueStateTracker.from_dict( + tracker_json.get("sender_id"), + tracker_json.get("events", []), + empty_agent.domain.slots, + ) + + # the SessionStart event was at index 5, the tracker's `applied_events()` should + # be the same as the list of events from index 6 onwards + assert tracker.applied_events() == list(tracker.events)[6:] + + +def test_get_last_event_for(): + events = [ActionExecuted("one"), user_uttered("two", 1)] + + tracker = get_tracker(events) + + assert tracker.get_last_event_for(ActionExecuted).action_name == "one" + + +def test_get_last_event_with_reverted(): + events = [ActionExecuted("one"), ActionReverted(), user_uttered("two", 1)] + + tracker = get_tracker(events) + + assert tracker.get_last_event_for(ActionExecuted) is None + + +def test_get_last_event_for_with_skip(): + events = [ActionExecuted("one"), user_uttered("two", 1), ActionExecuted("three")] + + tracker = get_tracker(events) + + assert tracker.get_last_event_for(ActionExecuted, skip=1).action_name == "one" + + +def test_get_last_event_for_with_exclude(): + events = [ActionExecuted("one"), user_uttered("two", 1), ActionExecuted("three")] + + tracker = get_tracker(events) + + assert ( + tracker.get_last_event_for( + ActionExecuted, action_names_to_exclude=["three"] + ).action_name + == "one" + ) + + +def test_last_executed_has(): + events = [ + ActionExecuted("one"), + user_uttered("two", 1), + ActionExecuted(ACTION_LISTEN_NAME), + ] + + tracker = get_tracker(events) + + assert tracker.last_executed_action_has("one") is True + + +def test_last_executed_has_not_name(): + events = [ + ActionExecuted("one"), + user_uttered("two", 1), + ActionExecuted(ACTION_LISTEN_NAME), + ] + + tracker = get_tracker(events) + + assert tracker.last_executed_action_has("another") is False + + +def test_events_metadata(): + # It should be possible to attach arbitrary metadata to any event and then + # retrieve it after getting the tracker dict representation. + events = [ + ActionExecuted("one", metadata={"one": 1}), + user_uttered("two", 1, metadata={"two": 2}), + ActionExecuted(ACTION_LISTEN_NAME, metadata={"three": 3}), + ] + + events = get_tracker(events).current_state(EventVerbosity.ALL)["events"] + assert events[0]["metadata"] == {"one": 1} + assert events[1]["metadata"] == {"two": 2} + assert events[2]["metadata"] == {"three": 3} + + +@pytest.mark.parametrize("key, value", [("asfa", 1), ("htb", None)]) +def test_tracker_without_slots(key, value, caplog): + event = SlotSet(key, value) + tracker = DialogueStateTracker.from_dict("any", []) + assert key in tracker.slots + with caplog.at_level(logging.INFO): + event.apply_to(tracker) + v = tracker.get_slot(key) + assert v == value + assert len(caplog.records) == 0 + + +@pytest.mark.parametrize( + "slot_type, initial_value, value_to_set", + [ + (FloatSlot, 4.234, 2.5), + (BooleanSlot, True, False), + (ListSlot, [1, 2, 3], [4, 5, 6]), + (TextSlot, "some string", "another string"), + (AnySlot, {"a": "nice dict"}, {"b": "better dict"}), + ], +) +def test_tracker_does_not_modify_slots( + slot_type: Type[Slot], initial_value: Any, value_to_set: Any +): + slot_name = "some-slot" + slot = slot_type(slot_name, mappings=[{}], initial_value=initial_value) + tracker = DialogueStateTracker("some-conversation-id", [slot]) + + # change the slot value in the tracker + tracker._set_slot(slot_name, value_to_set) + + # assert that the tracker contains the slot with the modified value + assert tracker.get_slot(slot_name) == value_to_set + + # assert that the initial slot has not been affected + assert slot.value == initial_value + + +@pytest.mark.parametrize( + "events, expected_applied_events", + [ + ( + [ + # Form gets triggered. + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("fill_whole_form"), + # Form executes and fills slots. + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet("slot1", "value"), + SlotSet("slot2", "value2"), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("fill_whole_form"), + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet("slot1", "value"), + SlotSet("slot2", "value2"), + ], + ), + ( + [ + # Form gets triggered. + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("fill_whole_form"), + # Form executes and fills all slots right away. Form finishes. + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet("slot1", "value"), + SlotSet("slot2", "value2"), + ActiveLoop(None), + # Form is done. Regular conversation continues. + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("intent outside form"), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("fill_whole_form"), + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet("slot1", "value"), + SlotSet("slot2", "value2"), + ActiveLoop(None), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("intent outside form"), + ], + ), + ( + [ + # Form gets triggered. + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + # Form executes and requests slot. + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet(REQUESTED_SLOT, "bla"), + # User fills slot. + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("bye"), + # Form deactivates after all slots are finished. + ActionExecuted("loop"), + SlotSet("slot", "value"), + ActiveLoop(None), + SlotSet(REQUESTED_SLOT, None), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet(REQUESTED_SLOT, "bla"), + SlotSet("slot", "value"), + ActiveLoop(None), + SlotSet(REQUESTED_SLOT, None), + ], + ), + ( + [ + # Form was executed before and finished. + ActionExecuted("loop"), + ActiveLoop(None), + # Form gets triggered again (for whatever reason).. + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + # Form executes and requests slot. + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet(REQUESTED_SLOT, "bla"), + # User fills slot. + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("bye"), + # Form deactivates after all slots are finished. + ActionExecuted("loop"), + SlotSet("slot", "value"), + ActiveLoop(None), + SlotSet(REQUESTED_SLOT, None), + ], + [ + ActionExecuted("loop"), + ActiveLoop(None), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet(REQUESTED_SLOT, "bla"), + SlotSet("slot", "value"), + ActiveLoop(None), + SlotSet(REQUESTED_SLOT, None), + ], + ), + ( + [ + user_uttered("trigger form"), + ActionExecuted("form"), + ActiveLoop("form"), + SlotSet(REQUESTED_SLOT, "some slot"), + BotUttered("ask slot"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("fill requested slots"), + SlotSet("some slot", "value"), + ActionExecuted("form"), + SlotSet("some slot", "value"), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ], + [ + user_uttered("trigger form"), + ActionExecuted("form"), + ActiveLoop("form"), + SlotSet(REQUESTED_SLOT, "some slot"), + BotUttered("ask slot"), + SlotSet("some slot", "value"), + SlotSet("some slot", "value"), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ], + ), + ( + [ + user_uttered("trigger form"), + DefinePrevUserUtteredFeaturization(False), + ActionExecuted("form"), + ActiveLoop("form"), + SlotSet(REQUESTED_SLOT, "some slot"), + BotUttered("ask slot"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("fill requested slots"), + SlotSet("some slot", "value"), + DefinePrevUserUtteredFeaturization(False), + ActionExecuted("form"), + SlotSet("some slot", "value"), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ], + [ + user_uttered("trigger form"), + DefinePrevUserUtteredFeaturization(False), + ActionExecuted("form"), + ActiveLoop("form"), + SlotSet(REQUESTED_SLOT, "some slot"), + BotUttered("ask slot"), + SlotSet("some slot", "value"), + SlotSet("some slot", "value"), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ], + ), + ], +) +def test_applied_events_with_loop_happy_path( + events: List[Event], expected_applied_events: List[Event] +): + tracker = DialogueStateTracker.from_events("👋", events) + applied = tracker.applied_events() + + assert applied == expected_applied_events + + +@pytest.mark.parametrize( + "events, expected_applied_events", + [ + ( + [ + # Form is triggered and requests slot. + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet(REQUESTED_SLOT, "bla"), + # User sends chitchat instead of answering form. + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("chitchat"), + # Form rejected execution. + ActionExecutionRejected("loop"), + # Action which deals with unhappy path. + ActionExecuted("handling chitchat"), + # We immediately return to form after executing an action to handle it. + ActionExecuted("loop"), + # Form happy path continues until all slots are filled. + SlotSet(REQUESTED_SLOT, "bla"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("fill slots"), + ActionExecuted("loop"), + SlotSet("slot", "value"), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet(REQUESTED_SLOT, "bla"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("chitchat"), + ActionExecutionRejected("loop"), + ActionExecuted("handling chitchat"), + ActionExecuted("loop"), + SlotSet(REQUESTED_SLOT, "bla"), + SlotSet("slot", "value"), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ], + ), + ( + [ + # Form gets triggered and requests slots. + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet(REQUESTED_SLOT, "bla"), + # User sends chitchat instead of answering form. + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("chitchat"), + # Form rejected execution. + ActionExecutionRejected("loop"), + # Unhappy path kicks in. + ActionExecuted("ask if continue"), + ActionExecuted(ACTION_LISTEN_NAME), + # User decides to fill form eventually. + user_uttered("I want to continue with form"), + ActionExecuted("loop"), + SlotSet(REQUESTED_SLOT, "bla"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("fill slots"), + ActionExecuted("loop"), + SlotSet("slot", "value"), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet(REQUESTED_SLOT, "bla"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("chitchat"), + ActionExecutionRejected("loop"), + ActionExecuted("ask if continue"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("I want to continue with form"), + ActionExecuted("loop"), + SlotSet(REQUESTED_SLOT, "bla"), + SlotSet("slot", "value"), + SlotSet(REQUESTED_SLOT, None), + ActiveLoop(None), + ], + ), + ( + [ + # Form gets triggered and requests slots. + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet(REQUESTED_SLOT, "bla"), + # User sends chitchat instead of answering form. + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("chitchat"), + # Form rejected execution. + ActionExecutionRejected("loop"), + # Unhappy path kicks in. + ActionExecuted("ask if continue"), + ActionExecuted(ACTION_LISTEN_NAME), + # User wants to quit form. + user_uttered("Stop the form"), + ActionExecuted("some action"), + ActiveLoop(None), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("outside the form"), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet(REQUESTED_SLOT, "bla"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("chitchat"), + ActionExecutionRejected("loop"), + ActionExecuted("ask if continue"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("Stop the form"), + ActionExecuted("some action"), + ActiveLoop(None), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("outside the form"), + ], + ), + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet(REQUESTED_SLOT, "bla"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("chitchat"), + # Different action than form action after chitchat. + # This indicates we are in an unhappy path. + ActionExecuted("handle_chitchat"), + ActionExecuted("loop"), + ActiveLoop("loop"), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("loop"), + ActiveLoop("loop"), + SlotSet(REQUESTED_SLOT, "bla"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("chitchat"), + # Different action than form action after chitchat. + # This indicates we are in an unhappy path. + ActionExecuted("handle_chitchat"), + ActionExecuted("loop"), + ActiveLoop("loop"), + ], + ), + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("loop"), + ActiveLoop("loop"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("chitchat"), + ActionExecuted("handle_chitchat"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("affirm"), + ActionExecuted("loop"), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + ActionExecuted("loop"), + ActiveLoop("loop"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("chitchat"), + # Different action than form action indicates unhappy path + ActionExecuted("handle_chitchat"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("affirm"), + ActionExecuted("loop"), + ], + ), + ( + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + DefinePrevUserUtteredFeaturization(False), + ActionExecuted("loop"), + ActiveLoop("loop"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("chitchat"), + DefinePrevUserUtteredFeaturization(False), + ActionExecuted("handle_chitchat"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("affirm"), + DefinePrevUserUtteredFeaturization(False), + ActionExecuted("loop"), + ], + [ + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("greet"), + DefinePrevUserUtteredFeaturization(False), + ActionExecuted("loop"), + ActiveLoop("loop"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("chitchat"), + DefinePrevUserUtteredFeaturization(False), + # Different action than form action indicates unhappy path + ActionExecuted("handle_chitchat"), + ActionExecuted(ACTION_LISTEN_NAME), + user_uttered("affirm"), + DefinePrevUserUtteredFeaturization(False), + ActionExecuted("loop"), + ], + ), + ], +) +def test_applied_events_with_loop_unhappy_path( + events: List[Event], expected_applied_events: List[Event] +): + tracker = DialogueStateTracker.from_events("👋", events) + applied = tracker.applied_events() + + assert applied == expected_applied_events + + +def test_reading_of_trackers_with_legacy_form_events(): + loop_name1 = "my loop" + loop_name2 = "my form" + tracker = DialogueStateTracker.from_dict( + "sender", + events_as_dict=[ + {"event": ActiveLoop.type_name, LOOP_NAME: loop_name1}, + {"event": LegacyForm.type_name, LOOP_NAME: None}, + {"event": LegacyForm.type_name, LOOP_NAME: loop_name2}, + ], + ) + + expected_events = [ActiveLoop(loop_name1), LegacyForm(None), LegacyForm(loop_name2)] + assert list(tracker.events) == expected_events + assert tracker.active_loop.name == loop_name2 + + +def test_writing_trackers_with_legacy_form_events(): + loop_name = "my loop" + tracker = DialogueStateTracker.from_events( + "sender", evts=[ActiveLoop(loop_name), LegacyForm(None), LegacyForm("some")] + ) + + events_as_dict = [event.as_dict() for event in tracker.events] + + for event in events_as_dict: + assert event["event"] == ActiveLoop.type_name + + +def test_reading_of_trackers_with_legacy_form_validation_events(): + loop_name = "form" + tracker = DialogueStateTracker.from_dict( + "sender", + events_as_dict=[ + {"event": ActiveLoop.type_name, LOOP_NAME: loop_name}, + {"event": LegacyFormValidation.type_name, "name": None, "validate": True}, + {"event": LegacyFormValidation.type_name, "name": None, "validate": False}, + ], + ) + + expected_events = [ + ActiveLoop(loop_name), + LegacyFormValidation(True), + LegacyFormValidation(False), + ] + actual_events = list(tracker.events) + assert list(tracker.events) == expected_events + assert not actual_events[1].is_interrupted + assert actual_events[2].is_interrupted + + assert tracker.active_loop.is_interrupted + + +def test_writing_trackers_with_legacy_for_validation_events(): + tracker = DialogueStateTracker.from_events( + "sender", evts=[LegacyFormValidation(True), LegacyFormValidation(False)] + ) + + events_as_dict = [event.as_dict() for event in tracker.events] + + for event in events_as_dict: + assert event["event"] == LoopInterrupted.type_name + + assert not events_as_dict[0][LOOP_INTERRUPTED] + assert events_as_dict[1][LOOP_INTERRUPTED] + + +@pytest.mark.parametrize( + "conversation_events,n_subtrackers", + [ + ( + # conversation contains multiple sessions + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + UserUttered("hi", {"name": "greet"}), + ActionExecuted("utter_greet"), + # second session begins here + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + UserUttered("goodbye", {"name": "goodbye"}), + ActionExecuted("utter_goodbye"), + ], + 2, + ), + ( + # conversation contains only one session + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + UserUttered("hi", {"name": "greet"}), + ActionExecuted("utter_greet"), + ], + 1, + ), + ( + # this conversation does not contain a session + [UserUttered("hi", {"name": "greet"}), ActionExecuted("utter_greet")], + 1, + ), + ], +) +def test_trackers_for_conversation_sessions( + conversation_events: List[Event], n_subtrackers: int +): + import rasa.shared.core.trackers as trackers_module + + tracker = DialogueStateTracker.from_events( + "some-conversation-ID", conversation_events + ) + + subtrackers = trackers_module.get_trackers_for_conversation_sessions(tracker) + + assert len(subtrackers) == n_subtrackers + + +def test_policy_predictions_dont_change_persistence(): + original_user_message = UserUttered("hi", intent={"name": "greet"}) + tracker = DialogueStateTracker.from_events( + "Vova", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("hi", intent={"name": "greet"}), + DefinePrevUserUtteredFeaturization(True), + EntitiesAdded(entities=[{"entity": "entity1", "value": "value1"}]), + ], + ) + + user_message: UserUttered = list(tracker.events)[1] + # The entities from the policy predictions are accessible + assert user_message.entities + + actual_serialized = user_message.as_dict() + + # Assert entities predicted by policies are not persisted + assert not actual_serialized["parse_data"]["entities"] + + expected_serialized = original_user_message.as_dict() + # don't compare timestamps + expected_serialized.pop("timestamp") + actual_serialized.pop("timestamp") + + assert actual_serialized == expected_serialized + + +@freezegun.freeze_time("2018-01-01") +def test_policy_prediction_reflected_in_tracker_state(): + entities_predicted_by_policy = [{"entity": "entity1", "value": "value1"}] + nlu_entities = [{"entity": "entityNLU", "value": "value100"}] + + tracker = DialogueStateTracker.from_events( + "Tester", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + "hi", + intent={"name": "greet"}, + entities=nlu_entities.copy(), + message_id="unique", + metadata={"some": "data"}, + ), + DefinePrevUserUtteredFeaturization(True), + EntitiesAdded(entities=entities_predicted_by_policy), + ], + ) + + tracker_state = tracker.current_state() + + expected_state = { + "sender_id": "Tester", + "slots": {}, + "latest_message": { + "intent": {"name": "greet"}, + "entities": nlu_entities + entities_predicted_by_policy, + "text": "hi", + "message_id": "unique", + "metadata": {"some": "data"}, + }, + "latest_event_time": 1514764800.0, + "followup_action": None, + "paused": False, + "events": None, + "latest_input_channel": None, + "active_loop": {}, + "latest_action": {"action_name": "action_listen"}, + "latest_action_name": "action_listen", + } + + assert tracker_state == expected_state + + # Make sure we didn't change the actual event + assert tracker.latest_message.parse_data["entities"] == nlu_entities + + +async def test_fill_slots_for_policy_entities(): + policy_entity, policy_entity_value = "policy_entity", "end-to-end" + nlu_entity, nlu_entity_value = "nlu_entity", "nlu rocks" + domain = Domain.from_yaml( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + entities: + - {nlu_entity} + - {policy_entity} + slots: + {nlu_entity}: + type: text + mappings: + - type: from_entity + entity: {nlu_entity} + {policy_entity}: + type: text + mappings: + - type: from_entity + entity: {policy_entity} + """ + ) + ) + + tracker = DialogueStateTracker.from_events( + "some sender", + evts=[ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + "hi", + intent={"name": "greet"}, + entities=[{"entity": nlu_entity, "value": nlu_entity_value}], + ), + DefinePrevUserUtteredFeaturization(True), + EntitiesAdded( + entities=[ + {"entity": policy_entity, "value": policy_entity_value}, + {"entity": nlu_entity, "value": nlu_entity_value}, + ] + ), + ], + domain=domain, + slots=domain.slots, + ) + + expected_events = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + "hi", + intent={"name": "greet"}, + entities=[ + {"entity": nlu_entity, "value": nlu_entity_value}, + # Added by `DefinePrevUserUtteredEntities` + {"entity": policy_entity, "value": policy_entity_value}, + ], + ), + DefinePrevUserUtteredFeaturization(True), + EntitiesAdded( + entities=[ + {"entity": policy_entity, "value": policy_entity_value}, + {"entity": nlu_entity, "value": nlu_entity_value}, + ] + ), + SlotSet(nlu_entity, nlu_entity_value), + SlotSet(policy_entity, policy_entity_value), + ] + + action_extract_slots = ActionExtractSlots(action_endpoint=None) + + events = await action_extract_slots.run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator(domain.responses), + tracker, + domain, + ) + tracker.update_with_events(events, domain) + + # Slots are correctly set + assert tracker.slots[nlu_entity].value == nlu_entity_value + assert tracker.slots[policy_entity].value == policy_entity_value + + for actual, expected in zip(tracker.events, expected_events): + assert actual == expected + + +def test_tracker_fingerprinting_consistency(): + slot = TextSlot(name="name", mappings=[{}], influence_conversation=True) + slot.value = "example" + tr1 = DialogueStateTracker("test_sender_id", slots=[slot]) + tr2 = DialogueStateTracker("test_sender_id", slots=[slot]) + f1 = tr1.fingerprint() + f2 = tr2.fingerprint() + assert f1 == f2 + + +def test_tracker_unique_fingerprint(domain: Domain): + slot = TextSlot(name="name", mappings=[{}], influence_conversation=True) + slot.value = "example" + tr = DialogueStateTracker("test_sender_id", slots=[slot]) + f1 = tr.fingerprint() + + event1 = UserUttered( + text="hello", + parse_data={ + "intent": {"name": "greet", "confidence": 0.9604260921478271}, + "entities": [ + {"entity": "city", "value": "London"}, + {"entity": "count", "value": 1}, + ], + "text": "hi", + "message_id": "3f4c04602a4947098c574b107d3ccc59", + "metadata": {}, + "intent_ranking": [ + {"name": "greet", "confidence": 0.9604260921478271}, + {"name": "goodbye", "confidence": 0.01835782080888748}, + {"name": "deny", "confidence": 0.011255578137934208}, + ], + }, + ) + tr.update(event1) + f2 = tr.fingerprint() + assert f1 != f2 + + event2 = ActionExecuted(action_name="action_listen") + tr.update(event2) + f3 = tr.fingerprint() + assert f2 != f3 + + +def test_tracker_fingerprint_story_reading(domain: Domain): + def build_tracker(domain: Domain) -> DialogueStateTracker: + story_yaml = """ + stories: + - story: test story + steps: + - intent: greet + - action: utter_greet + """ + reader = YAMLStoryReader(domain) + story_steps = reader.read_from_string(story_yaml) + events = [] + for step in story_steps: + evts = step.events + if isinstance(evts, list): + events += evts + else: + events.append(evts) + + slot = TextSlot(name="name", mappings=[{}], influence_conversation=True) + slot.value = "example" + + tracker = DialogueStateTracker.from_events("sender_id", events, [slot]) + return tracker + + tracker1 = build_tracker(domain) + f1 = tracker1.fingerprint() + + time.sleep(0.1) + + tracker2 = build_tracker(domain) + f2 = tracker2.fingerprint() + + assert f1 == f2 + + +def test_model_id_is_added_to_events(): + tracker = DialogueStateTracker("bloop", []) + tracker.model_id = "some_id" + tracker.update(ActionExecuted(action_name="test")) + tracker.update_with_events([UserUttered(), SessionStarted()], None) + assert all(e.metadata[METADATA_MODEL_ID] == "some_id" for e in tracker.events) + + +def test_model_id_is_not_added_to_events_with_id(): + tracker = DialogueStateTracker("bloop", []) + tracker.model_id = "some_id" + tracker.update( + ActionExecuted(action_name="test", metadata={METADATA_MODEL_ID: "old_id"}) + ) + assert tracker.events[-1].metadata[METADATA_MODEL_ID] == "old_id" + + +@pytest.mark.parametrize( + "event", + [ + ActionExecuted(action_name="action_listen"), + UserUttered(), + SessionStarted(), + SlotSet("my_slot", 1), + Restarted(), + AllSlotsReset(), + ConversationPaused(), + ConversationResumed(), + StoryExported(), + ActionReverted(), + UserUtteranceReverted(), + FollowupAction("my_action"), + BotUttered("my_text", {"my_data": 1}), + AgentUttered("my_text", "my_data"), + ReminderScheduled("my_intent", datetime.datetime.now()), + DefinePrevUserUtteredFeaturization(False), + EntitiesAdded([{"entity": "entity1", "value": "value1"}]), + ActionExecutionRejected("test_action"), + ActiveLoop("my_form"), + LoopInterrupted(True), + ], +) +def test_assistant_id_is_added_to_events(event): + assistant_id = "some_unique_assistant_name" + tracker = DialogueStateTracker("123abcd", []) + tracker.assistant_id = assistant_id + tracker.update(event) + assert all( + event.metadata[ASSISTANT_ID_KEY] == assistant_id for event in tracker.events + ) + + +def test_assistant_id_is_not_added_to_events_with_assistant_id(): + assistant_id = "some_unique_assistant_name" + tracker = DialogueStateTracker("123abcd", []) + tracker.assistant_id = assistant_id + tracker.update( + ActionExecuted(action_name="test", metadata={ASSISTANT_ID_KEY: "old_name"}) + ) + assert tracker.events[-1].metadata[ASSISTANT_ID_KEY] == "old_name" diff --git a/tests/shared/core/training_data/__init__.py b/tests/shared/core/training_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/shared/core/training_data/story_reader/__init__.py b/tests/shared/core/training_data/story_reader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/shared/core/training_data/story_reader/test_yaml_story_reader.py b/tests/shared/core/training_data/story_reader/test_yaml_story_reader.py new file mode 100644 index 0000000..ca9e700 --- /dev/null +++ b/tests/shared/core/training_data/story_reader/test_yaml_story_reader.py @@ -0,0 +1,1142 @@ +import json +import warnings + +import numpy as np +import os +import pytest +import sys + +from collections import Counter +from pathlib import Path +from typing import Any, Text, List, Dict, Optional +from _pytest.monkeypatch import MonkeyPatch +from unittest.mock import Mock + +from rasa.shared.nlu.training_data.features import Features +from rasa.shared.nlu.training_data.message import Message +import rasa.shared.utils.io +from rasa.shared.exceptions import ( + FileNotFoundException, + YamlSyntaxException, + YamlException, +) +from rasa.core.actions.action import ACTION_LISTEN_NAME +from rasa.core import training +from rasa.core.featurizers.tracker_featurizers import MaxHistoryTrackerFeaturizer +from rasa.core.featurizers.single_state_featurizer import SingleStateFeaturizer +from rasa.utils.tensorflow.model_data_utils import _surface_attributes + +from rasa.shared.constants import ( + INTENT_MESSAGE_PREFIX, + LATEST_TRAINING_DATA_FORMAT_VERSION, +) +from rasa.shared.core.constants import RULE_SNIPPET_ACTION_NAME +from rasa.shared.core.domain import Domain +from rasa.shared.core.training_data import loading +from rasa.shared.core.events import ActionExecuted, UserUttered, SlotSet, ActiveLoop +from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, + DEFAULT_VALUE_TEXT_SLOTS, +) +from rasa.shared.core.training_data.structures import StoryStep, RuleStep +from rasa.shared.nlu.constants import ( + ACTION_NAME, + ENTITIES, + ENTITY_ATTRIBUTE_END, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_VALUE, + FEATURE_TYPE_SENTENCE, + INTENT, + INTENT_NAME_KEY, + INTENT_RANKING_KEY, + PREDICTED_CONFIDENCE_KEY, + TEXT, + EXTRACTOR, +) +from tests.conftest import filter_expected_warnings + + +@pytest.fixture() +def rule_steps_without_stories(domain: Domain) -> List[StoryStep]: + yaml_file = "data/test_yaml_stories/rules_without_stories.yml" + + return loading.load_data_from_files([yaml_file], domain) + + +def test_can_read_test_story_with_slots(domain: Domain): + trackers = training.load_data( + "data/test_yaml_stories/simple_story_with_only_end.yml", + domain, + use_story_concatenation=False, + tracker_limit=1000, + remove_duplicates=False, + ) + assert len(trackers) == 1 + + assert trackers[0].events[-2] == SlotSet(key="name", value="peter") + assert trackers[0].events[-1] == ActionExecuted("action_listen") + + +@pytest.mark.parametrize( + "domain_dict", + [ + {"slots": {"my_slot": {"type": "text", "mappings": [{"type": "from_text"}]}}}, + {"slots": {"my_slot": {"type": "list", "mappings": [{"type": "from_text"}]}}}, + ], +) +async def test_default_slot_value_if_slots_referenced_by_name_only(domain_dict: Dict): + story = """ + stories: + - story: my story + steps: + - intent: greet + - slot_was_set: + - my_slot + """ + + reader = YAMLStoryReader(Domain.from_dict(domain_dict)) + events = reader.read_from_string(story)[0].events + + assert isinstance(events[-1], SlotSet) + assert events[-1].value + + +@pytest.mark.parametrize( + "domain_dict", + [ + { + "slots": { + "my_slot": {"type": "categorical", "mappings": [{"type": "from_text"}]} + } + }, + {"slots": {"my_slot": {"type": "float", "mappings": [{"type": "from_text"}]}}}, + ], +) +async def test_default_slot_value_if_incompatible_slots_referenced_by_name_only( + domain_dict: Dict, +): + story = """ + stories: + - story: my story + steps: + - intent: greet + - slot_was_set: + - my_slot + """ + + reader = YAMLStoryReader(Domain.from_dict(domain_dict)) + with pytest.warns(UserWarning): + events = reader.read_from_string(story)[0].events + + assert isinstance(events[-1], SlotSet) + assert events[-1].value is None + + +async def test_default_slot_value_if_no_domain(): + story = """ + stories: + - story: my story + steps: + - intent: greet + - slot_was_set: + - my_slot + """ + + reader = YAMLStoryReader() + with warnings.catch_warnings() as record: + events = reader.read_from_string(story)[0].events + + if record is not None: + record = filter_expected_warnings(record) + assert len(record) == 0 + + assert isinstance(events[-1], SlotSet) + assert events[-1].value is None + + +async def test_default_slot_value_if_unfeaturized_slot(): + story = """ + stories: + - story: my story + steps: + - intent: greet + - slot_was_set: + - my_slot + """ + domain = Domain.from_dict( + { + "intents": ["greet"], + "slots": {"my_slot": {"type": "any", "mappings": [{"type": "from_text"}]}}, + } + ) + reader = YAMLStoryReader(domain) + + with warnings.catch_warnings() as warning: + events = reader.read_from_string(story)[0].events + + if warning is not None: + warning = filter_expected_warnings(warning) + assert len(warning) == 0 + + assert isinstance(events[-1], SlotSet) + assert events[-1].value is None + + +def test_can_read_test_story_with_entities(domain: Domain): + trackers = training.load_data( + "data/test_yaml_stories/story_with_or_and_entities.yml", + domain, + use_story_concatenation=False, + tracker_limit=1000, + remove_duplicates=False, + ) + assert len(trackers) == 2 + + assert trackers[0].events[-3] == UserUttered( + intent={"name": "greet", "confidence": 1.0}, + parse_data={ + "text": "/greet", + "intent_ranking": [{"confidence": 1.0, "name": "greet"}], + "intent": {"confidence": 1.0, "name": "greet"}, + "entities": [], + }, + ) + assert trackers[0].events[-2] == ActionExecuted("utter_greet") + assert trackers[0].events[-1] == ActionExecuted("action_listen") + + assert trackers[1].events[-4] == UserUttered( + intent={"name": "greet", "confidence": 1.0}, + entities=[{"entity": "name", "value": "peter"}], + parse_data={ + "text": "/greet", + "intent_ranking": [{"confidence": 1.0, "name": "greet"}], + "intent": {"confidence": 1.0, "name": "greet"}, + "entities": [{"entity": "name", "value": "peter"}], + }, + ) + assert trackers[1].events[-3] == SlotSet(key="name", value="peter") + assert trackers[1].events[-2] == ActionExecuted("utter_greet") + assert trackers[1].events[-1] == ActionExecuted("action_listen") + + +def test_can_read_test_story_with_entities_without_value(domain: Domain): + trackers = training.load_data( + "data/test_yaml_stories/story_with_or_and_entities_with_no_value.yml", + domain, + use_story_concatenation=False, + tracker_limit=1000, + remove_duplicates=False, + ) + assert len(trackers) == 1 + + assert trackers[0].events[-4] == UserUttered( + intent={"name": "greet", "confidence": 1.0}, + entities=[{"entity": "name", "value": ""}], + parse_data={ + "text": "/greet", + "intent_ranking": [{"confidence": 1.0, "name": "greet"}], + "intent": {"confidence": 1.0, "name": "greet"}, + "entities": [{"entity": "name", "value": ""}], + }, + ) + assert trackers[0].events[-2] == ActionExecuted("utter_greet") + assert trackers[0].events[-1] == ActionExecuted("action_listen") + + +@pytest.mark.parametrize( + "file", + [ + "data/test_yaml_stories/stories.yml", + "data/test_yaml_stories/rules_without_stories.yml", + ], +) +async def test_is_yaml_file(file: Text): + assert YAMLStoryReader.is_stories_file(file) is True + + +def test_yaml_intent_with_leading_slash_warning(domain: Domain): + yaml_file = "data/test_wrong_yaml_stories/intent_with_leading_slash.yml" + + with pytest.warns() as record: + tracker = training.load_data( + yaml_file, + domain, + use_story_concatenation=False, + tracker_limit=1000, + remove_duplicates=False, + ) + record = filter_expected_warnings(record) + # one for leading slash + assert len(record) == 1 + assert type(record[0].message) == UserWarning + + assert tracker[0].latest_message == UserUttered(intent={"name": "simple"}) + + +def test_yaml_slot_without_value_is_parsed(domain: Domain): + yaml_file = "data/test_yaml_stories/story_with_slot_was_set.yml" + + tracker = training.load_data( + yaml_file, + domain, + use_story_concatenation=False, + tracker_limit=1000, + remove_duplicates=False, + ) + + assert tracker[0].events[-2] == SlotSet(key="name", value=DEFAULT_VALUE_TEXT_SLOTS) + + +def test_yaml_wrong_yaml_format_warning(domain: Domain): + yaml_file = "data/test_wrong_yaml_stories/wrong_yaml.yml" + + with pytest.raises(YamlSyntaxException): + _ = training.load_data( + yaml_file, + domain, + use_story_concatenation=False, + tracker_limit=1000, + remove_duplicates=False, + ) + + +def test_read_rules_with_stories(domain: Domain): + + yaml_file = "data/test_yaml_stories/stories_and_rules.yml" + + steps = loading.load_data_from_files([yaml_file], domain) + + ml_steps = [s for s in steps if not isinstance(s, RuleStep)] + rule_steps = [s for s in steps if isinstance(s, RuleStep)] + + # this file contains three rules and three ML stories + assert len(ml_steps) == 3 + assert len(rule_steps) == 3 + + assert rule_steps[0].block_name == "rule 1" + assert rule_steps[1].block_name == "rule 2" + assert rule_steps[2].block_name == "rule 3" + + assert ml_steps[0].block_name == "simple_story_without_checkpoint" + assert ml_steps[1].block_name == "simple_story_with_only_start" + assert ml_steps[2].block_name == "simple_story_with_only_end" + + +def test_read_rules_without_stories(rule_steps_without_stories: List[StoryStep]): + ml_steps = [s for s in rule_steps_without_stories if not isinstance(s, RuleStep)] + rule_steps = [s for s in rule_steps_without_stories if isinstance(s, RuleStep)] + + # this file contains five rules and no ML stories + assert len(ml_steps) == 0 + assert len(rule_steps) == 8 + + +def test_rule_with_condition(rule_steps_without_stories: List[StoryStep]): + rule = rule_steps_without_stories[0] + assert rule.block_name == "Rule with condition" + assert rule.events == [ + ActiveLoop("loop_q_form"), + SlotSet("requested_slot", "some_slot"), + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + UserUttered( + intent={"name": "inform", "confidence": 1.0}, + entities=[{"entity": "some_slot", "value": "bla"}], + ), + ActionExecuted("loop_q_form"), + ] + + +def test_rule_without_condition(rule_steps_without_stories: List[StoryStep]): + rule = rule_steps_without_stories[1] + assert rule.block_name == "Rule without condition" + assert rule.events == [ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + UserUttered(intent={"name": "explain", "confidence": 1.0}), + ActionExecuted("utter_explain_some_slot"), + ActionExecuted("loop_q_form"), + ActiveLoop("loop_q_form"), + ] + + +def test_rule_with_explicit_wait_for_user_message( + rule_steps_without_stories: List[StoryStep], +): + rule = rule_steps_without_stories[2] + assert rule.block_name == "Rule which explicitly waits for user input when finished" + assert rule.events == [ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + UserUttered(intent={"name": "explain", "confidence": 1.0}), + ActionExecuted("utter_explain_some_slot"), + ] + + +def test_rule_which_hands_over_at_end(rule_steps_without_stories: List[StoryStep]): + rule = rule_steps_without_stories[3] + assert rule.block_name == "Rule after which another action should be predicted" + assert rule.events == [ + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + UserUttered(intent={"name": "explain", "confidence": 1.0}), + ActionExecuted("utter_explain_some_slot"), + ActionExecuted(RULE_SNIPPET_ACTION_NAME), + ] + + +def test_conversation_start_rule(rule_steps_without_stories: List[StoryStep]): + rule = rule_steps_without_stories[4] + assert rule.block_name == "Rule which only applies to conversation start" + assert rule.events == [ + UserUttered(intent={"name": "explain", "confidence": 1.0}), + ActionExecuted("utter_explain_some_slot"), + ] + + +async def test_warning_if_intent_not_in_domain(domain: Domain): + stories = """ + stories: + - story: I am gonna make you explode 💥 + steps: + # Intent defined in user key. + - intent: definitely not in domain + """ + + reader = YAMLStoryReader(domain) + yaml_content = rasa.shared.utils.io.read_yaml(stories) + + with pytest.warns(UserWarning) as record: + reader.read_from_parsed_yaml(yaml_content) + + # one for missing intent + assert len(record) == 1 + + +async def test_no_warning_if_intent_in_domain(domain: Domain): + stories = ( + f'version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}"\n' + f"stories:\n" + f"- story: I am fine 💥\n" + f" steps:\n" + f" - intent: greet" + ) + + reader = YAMLStoryReader(domain) + yaml_content = rasa.shared.utils.io.read_yaml(stories) + + with pytest.warns(None) as record: + reader.read_from_parsed_yaml(yaml_content) + + assert not len(record) + + +def test_parsing_of_e2e_stories(domain: Domain): + yaml_file = "data/test_yaml_stories/stories_hybrid_e2e.yml" + tracker = training.load_data( + yaml_file, + domain, + use_story_concatenation=False, + tracker_limit=1000, + remove_duplicates=False, + ) + + assert len(tracker) == 1 + + actual = list(tracker[0].events) + + expected = [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": "simple"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + "I am looking for a Kenyan restaurant", + {"name": None}, + entities=[{"start": 19, "end": 25, "value": "Kenyan", "entity": "cuisine"}], + ), + ActionExecuted("", action_text="good for you"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": "goodbye"}), + ActionExecuted("utter_goodbye"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("One more thing", {"name": None}), + ActionExecuted("", action_text="What?"), + ActionExecuted(ACTION_LISTEN_NAME), + ] + + assert actual == expected + + +async def test_active_loop_is_parsed(domain: Domain): + stories = ( + f'version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}"\n' + f"stories:\n" + f"- story: name\n" + f" steps:\n" + f" - intent: greet\n" + f" - active_loop: null" + ) + + reader = YAMLStoryReader(domain) + yaml_content = rasa.shared.utils.io.read_yaml(stories) + + with pytest.warns(None) as record: + reader.read_from_parsed_yaml(yaml_content) + + assert not len(record) + + +def test_is_test_story_file(tmp_path: Path): + path = str(tmp_path / "test_stories.yml") + rasa.shared.utils.io.write_yaml({"stories": []}, path) + assert YAMLStoryReader.is_test_stories_file(path) + + +def test_is_not_test_story_file_if_it_doesnt_contain_stories(tmp_path: Path): + path = str(tmp_path / "test_stories.yml") + rasa.shared.utils.io.write_yaml({"nlu": []}, path) + assert not YAMLStoryReader.is_test_stories_file(path) + + +def test_is_not_test_story_file_raises_if_file_does_not_exist(tmp_path: Path): + path = str(tmp_path / "test_stories.yml") + with pytest.raises(FileNotFoundException): + YAMLStoryReader.is_test_stories_file(path) + + +def test_is_not_test_story_file_without_test_prefix(tmp_path: Path): + path = str(tmp_path / "stories.yml") + rasa.shared.utils.io.write_yaml({"stories": []}, path) + assert not YAMLStoryReader.is_test_stories_file(path) + + +def test_end_to_end_story_with_shortcut_intent(): + intent = "greet" + plain_text = f'/{intent}{{"name": "test"}}' + story = f""" +stories: +- story: my story + steps: + - user: | + {plain_text} + intent: {intent} + """ + + story_as_yaml = rasa.shared.utils.io.read_yaml(story) + steps = YAMLStoryReader().read_from_parsed_yaml(story_as_yaml) + user_uttered = steps[0].events[0] + + assert user_uttered == UserUttered( + plain_text, + intent={"name": intent}, + entities=[{"entity": "name", "start": 6, "end": 22, "value": "test"}], + ) + + +def test_end_to_end_story_with_entities(): + story = """ +stories: +- story: my story + steps: + - intent: greet + entities: + - city: Berlin + role: from + """ + + story_as_yaml = rasa.shared.utils.io.read_yaml(story) + + steps = YAMLStoryReader().read_from_parsed_yaml(story_as_yaml) + user_uttered = steps[0].events[0] + + assert user_uttered == UserUttered( + None, + intent={"name": "greet"}, + entities=[{"entity": "city", "value": "Berlin", "role": "from"}], + ) + + +def test_read_mixed_training_data_file(domain: Domain): + training_data_file = "data/test_mixed_yaml_training_data/training_data.yml" + + reader = YAMLStoryReader(domain) + yaml_content = rasa.shared.utils.io.read_yaml_file(training_data_file) + + with pytest.warns(None) as record: + reader.read_from_parsed_yaml(yaml_content) + assert not len(record) + + +def test_or_statement_with_slot_was_set(): + stories = """ + stories: + - story: tell name bob or joe + steps: + - intent: greet + - action: utter_greet + - intent: tell_name + - or: + - slot_was_set: + - name: joe + - slot_was_set: + - name: bob + - slot_was_set: + - name: null + """ + + reader = YAMLStoryReader() + yaml_content = rasa.shared.utils.io.read_yaml(stories) + + steps = reader.read_from_parsed_yaml(yaml_content) + + assert len(steps) == 3 + + slot = steps[0].events[3] + assert isinstance(slot, SlotSet) + assert slot.key == "name" + assert slot.value == "joe" + + slot = steps[1].events[3] + assert isinstance(slot, SlotSet) + assert slot.key == "name" + assert slot.value == "bob" + + slot = steps[2].events[3] + assert isinstance(slot, SlotSet) + assert slot.key == "name" + assert slot.value is None + + +@pytest.mark.parametrize( + "file,warning", + [ + ("data/test_yaml_stories/test_base_retrieval_intent_story.yml", None), + ( + "data/test_yaml_stories/non_test_full_retrieval_intent_story.yml", + UserWarning, + ), + ], +) +async def test_story_with_retrieval_intent_warns( + file: Text, warning: Optional["Warning"] +): + reader = YAMLStoryReader() + + with warnings.catch_warnings() as record: + reader.read_from_file(file) + + if record is not None: + record = filter_expected_warnings(record) + + if warning: + assert len(record) == 1 + assert type(record[0].message) == warning + else: + assert len(record) == 0 + + +def test_or_statement_story_with_or_slot_was_set(domain: Domain): + training_trackers = training.load_data( + "data/test_yaml_stories/story_with_or_slot_was_set.yml", + domain, + use_story_concatenation=False, + tracker_limit=1000, + remove_duplicates=False, + ) + assert len(training_trackers) == 2 + assert training_trackers[0].events[3] == SlotSet(key="name", value="peter") + assert training_trackers[1].events[3] == SlotSet(key="name", value="bob") + + +@pytest.mark.parametrize("is_conversation_test", [True, False]) +def test_handles_mixed_steps_for_test_and_e2e_stories(is_conversation_test): + stories = """ + stories: + - story: hello world + steps: + - user: Hi + - bot: Hello? + - user: Well... + intent: suspicion + """ + + reader = YAMLStoryReader() + yaml_content = rasa.shared.utils.io.read_yaml(stories) + + steps = reader.read_from_parsed_yaml(yaml_content) + + events = steps[0].events + assert len(events) == 3 + assert events[0].text == "Hi" + assert events[1].action_text == "Hello?" + assert events[2].text == "Well..." + + +def test_read_from_file_skip_validation(monkeypatch: MonkeyPatch): + yaml_file = "data/test_wrong_yaml_stories/wrong_yaml.yml" + reader = YAMLStoryReader() + + monkeypatch.setattr( + sys.modules["rasa.shared.utils.io"], + rasa.shared.utils.io.read_yaml.__name__, + Mock(return_value={}), + ) + + with pytest.raises(YamlException): + _ = reader.read_from_file(yaml_file, skip_validation=False) + + assert reader.read_from_file(yaml_file, skip_validation=True) == [] + + +@pytest.mark.parametrize( + "file", + [ + "data/test_yaml_stories/rules_missing_intent.yml", + "data/test_yaml_stories/stories_missing_intent.yml", + ], +) +def test_raises_exception_missing_intent_in_rules(file: Text, domain: Domain): + reader = YAMLStoryReader(domain) + + with pytest.warns() as warning: + reader.read_from_file(file) + + warning = filter_expected_warnings(warning) + + assert "Missing intent value" in warning[0].message.args[0] + + +def test_can_read_test_story(domain: Domain): + trackers = training.load_data( + "data/test_yaml_stories/stories.yml", + domain, + use_story_concatenation=False, + tracker_limit=1000, + remove_duplicates=False, + ) + assert len(trackers) == 7 + # this should be the story simple_story_with_only_end -> show_it_all + # the generated stories are in a non stable order - therefore we need to + # do some trickery to find the one we want to test + tracker = [t for t in trackers if len(t.events) == 5][0] + assert tracker.events[0] == ActionExecuted("action_listen") + assert tracker.events[1] == UserUttered( + intent={INTENT_NAME_KEY: "simple", "confidence": 1.0}, + parse_data={ + "text": "/simple", + "intent_ranking": [{"confidence": 1.0, INTENT_NAME_KEY: "simple"}], + "intent": {"confidence": 1.0, INTENT_NAME_KEY: "simple"}, + "entities": [], + }, + ) + assert tracker.events[2] == ActionExecuted("utter_default") + assert tracker.events[3] == ActionExecuted("utter_greet") + assert tracker.events[4] == ActionExecuted("action_listen") + + +def test_can_read_test_story_with_checkpoint_after_or(domain: Domain): + trackers = training.load_data( + "data/test_yaml_stories/stories_checkpoint_after_or.yml", + domain, + use_story_concatenation=False, + tracker_limit=1000, + remove_duplicates=False, + ) + assert len(trackers) == 2 + + +def test_read_story_file_with_cycles(domain: Domain): + graph = training.extract_story_graph( + "data/test_yaml_stories/stories_with_cycle.yml", domain + ) + + assert len(graph.story_steps) == 5 + + graph_without_cycles = graph.with_cycles_removed() + + assert graph.cyclic_edge_ids != set() + # sorting removed_edges converting set converting it to list + assert graph_without_cycles.cyclic_edge_ids == list() + + assert len(graph.story_steps) == len(graph_without_cycles.story_steps) == 5 + + assert len(graph_without_cycles.story_end_checkpoints) == 2 + + +def test_generate_training_data_with_cycles(domain: Domain): + featurizer = MaxHistoryTrackerFeaturizer(SingleStateFeaturizer(), max_history=4) + training_trackers = training.load_data( + "data/test_yaml_stories/stories_with_cycle.yml", domain, augmentation_factor=0 + ) + + _, label_ids, _ = featurizer.featurize_trackers( + training_trackers, domain, precomputations=None + ) + + # how many there are depends on the graph which is not created in a + # deterministic way but should always be 3 or 4 + assert len(training_trackers) == 3 or len(training_trackers) == 4 + + # if we have 4 trackers, there is going to be one example more for label 10 + num_tens = len(training_trackers) - 1 + # if new default actions are added the keys of the actions will be changed + + all_label_ids = [id for ids in label_ids for id in ids] + assert Counter(all_label_ids) == {0: 6, 15: 3, 14: num_tens, 1: 2, 16: 1} + + +def test_generate_training_data_with_unused_checkpoints(domain: Domain): + training_trackers = training.load_data( + "data/test_yaml_stories/stories_unused_checkpoints.yml", domain + ) + # there are 3 training stories: + # 2 with unused end checkpoints -> training_trackers + # 1 with unused start checkpoints -> ignored + assert len(training_trackers) == 2 + + +def test_generate_training_data_original_and_augmented_trackers(domain: Domain): + training_trackers = training.load_data( + "data/test_yaml_stories/stories_defaultdomain.yml", + domain, + augmentation_factor=3, + ) + # there are three original stories + # augmentation factor of 3 indicates max of 3*10 augmented stories generated + # maximum number of stories should be augmented+original = 33 + original_trackers = [ + t + for t in training_trackers + if not hasattr(t, "is_augmented") or not t.is_augmented + ] + assert len(original_trackers) == 4 + assert len(training_trackers) <= 34 + + +def test_visualize_training_data_graph(tmp_path: Path, domain: Domain): + graph = training.extract_story_graph( + "data/test_yaml_stories/stories_with_cycle.yml", domain + ) + + graph = graph.with_cycles_removed() + + out_path = str(tmp_path / "graph.html") + + # this will be the plotted networkx graph + G = graph.visualize(out_path) + + assert os.path.exists(out_path) + + # we can't check the exact topology - but this should be enough to ensure + # the visualisation created a sane graph + assert set(G.nodes()) == set(range(-1, 13)) or set(G.nodes()) == set(range(-1, 14)) + if set(G.nodes()) == set(range(-1, 13)): + assert len(G.edges()) == 14 + elif set(G.nodes()) == set(range(-1, 14)): + assert len(G.edges()) == 16 + + +def test_load_multi_file_training_data(domain: Domain): + featurizer = MaxHistoryTrackerFeaturizer(SingleStateFeaturizer(), max_history=2) + trackers = training.load_data( + "data/test_yaml_stories/stories.yml", domain, augmentation_factor=0 + ) + trackers = sorted(trackers, key=lambda t: t.sender_id) + + (tr_as_sts, tr_as_acts) = featurizer.training_states_and_labels(trackers, domain) + hashed = [] + for sts, acts in zip(tr_as_sts, tr_as_acts): + hashed.append(json.dumps(sts + acts, sort_keys=True)) + hashed = sorted(hashed, reverse=True) + + data, label_ids, _ = featurizer.featurize_trackers( + trackers, domain, precomputations=None + ) + + featurizer_mul = MaxHistoryTrackerFeaturizer(SingleStateFeaturizer(), max_history=2) + trackers_mul = training.load_data( + "data/test_multifile_yaml_stories", domain, augmentation_factor=0 + ) + trackers_mul = sorted(trackers_mul, key=lambda t: t.sender_id) + + (tr_as_sts_mul, tr_as_acts_mul) = featurizer.training_states_and_labels( + trackers_mul, domain + ) + hashed_mul = [] + for sts_mul, acts_mul in zip(tr_as_sts_mul, tr_as_acts_mul): + hashed_mul.append(json.dumps(sts_mul + acts_mul, sort_keys=True)) + hashed_mul = sorted(hashed_mul, reverse=True) + + data_mul, label_ids_mul, _ = featurizer_mul.featurize_trackers( + trackers_mul, domain, precomputations=None + ) + + assert hashed == hashed_mul + # we check for intents, action names and entities -- the features which + # are included in the story files + + data = _surface_attributes(data) + data_mul = _surface_attributes(data_mul) + + for attribute in [INTENT, ACTION_NAME, ENTITIES]: + if attribute not in data or attribute not in data_mul: + continue + assert len(data.get(attribute)) == len(data_mul.get(attribute)) + + for idx_tracker in range(len(data.get(attribute))): + for idx_dialogue in range(len(data.get(attribute)[idx_tracker])): + f1 = data.get(attribute)[idx_tracker][idx_dialogue] + f2 = data_mul.get(attribute)[idx_tracker][idx_dialogue] + if f1 is None or f2 is None: + assert f1 == f2 + continue + for idx_turn in range(len(f1)): + f1 = data.get(attribute)[idx_tracker][idx_dialogue][idx_turn] + f2 = data_mul.get(attribute)[idx_tracker][idx_dialogue][idx_turn] + assert np.all((f1 == f2).data) + + assert np.all(label_ids == label_ids_mul) + + +def test_yaml_slot_different_types(domain: Domain): + with pytest.warns(None): + tracker = training.load_data( + "data/test_yaml_stories/story_slot_different_types.yml", + domain, + use_story_concatenation=False, + tracker_limit=1000, + remove_duplicates=False, + ) + + assert tracker[0].events[3] == SlotSet(key="list_slot", value=["value1", "value2"]) + assert tracker[0].events[4] == SlotSet(key="bool_slot", value=True) + assert tracker[0].events[5] == SlotSet(key="text_slot", value="some_text") + + +@pytest.mark.parametrize( + "confidence,entities,expected_confidence,expected_entities,should_warn", + [ + # easy examples - where entities or intents might be missing + (None, None, 1.0, [], False), + ("0.2134345", None, 0.2134345, [], False), + ("0", None, 0, [], False), + ( + None, + json.dumps({"entity1": "entity_value1", "entity2": 2.0}), + 1.0, + [ + { + ENTITY_ATTRIBUTE_TYPE: "entity1", + ENTITY_ATTRIBUTE_VALUE: "entity_value1", + }, + {ENTITY_ATTRIBUTE_TYPE: "entity2", ENTITY_ATTRIBUTE_VALUE: 2.0}, + ], + False, + ), + # malformed confidences + ( + "-2", + None, + 1.0, + [], + True, + ), # no confidence string; some unidentified part left + ("abc0.2134345", None, 1.0, [], True), # same + ("123", None, 1.0, [], True), # value extracted by > 1 + ("123?", None, 1.0, [], True), # value extracted by > 1 + ("1.0.", None, 0.0, [], True), # confidence string extracted but not a float + # malformed entities + (None, json.dumps({"entity1": "entity2"}), 1.0, [], True), + (None, '{"entity1","entity2":2.0}', 1.0, [], True), + # ... note: if the confidence is None, the following will raise an error! + ( + "1.0", + json.dumps(["entity1"]), + 1.0, + [], + True, + ), # no entity string extracted; some unexpected string left + ], +) +def test_process_unpacks_attributes_from_single_message_and_fallsback_if_needed( + confidence: Optional[Text], + entities: Optional[Text], + expected_confidence: float, + expected_entities: Optional[List[Dict[Text, Any]]], + should_warn: bool, +): + # dummy intent + expected_intent = "my-intent" + + # construct text according to pattern + text = " \t " + INTENT_MESSAGE_PREFIX + expected_intent + if confidence is not None: + text += f"@{confidence}" + if entities is not None: + text += entities + text += " \t " + + # create a message with some dummy attributes and features + message = Message( + data={TEXT: text, INTENT: "extracted-from-the-pattern-text-via-nlu"}, + features=[ + Features( + features=np.zeros((1, 1)), + feature_type=FEATURE_TYPE_SENTENCE, + attribute=TEXT, + origin="nlu-pipeline", + ) + ], + ) + + # construct domain from expected intent/entities + domain_entities = [item[ENTITY_ATTRIBUTE_TYPE] for item in expected_entities] + domain_intents = [expected_intent] if expected_intent is not None else [] + domain = Domain( + intents=domain_intents, + entities=domain_entities, + slots=[], + responses={}, + action_names=[], + forms={}, + data={}, + ) + + # extract information + if should_warn: + with pytest.warns(UserWarning): + unpacked_message = YAMLStoryReader.unpack_regex_message(message, domain) + else: + unpacked_message = YAMLStoryReader.unpack_regex_message(message, domain) + + assert not unpacked_message.features + + assert set(unpacked_message.data.keys()) == { + TEXT, + INTENT, + INTENT_RANKING_KEY, + ENTITIES, + } + + assert unpacked_message.data[TEXT] == message.data[TEXT].strip() + + assert set(unpacked_message.data[INTENT].keys()) == { + INTENT_NAME_KEY, + PREDICTED_CONFIDENCE_KEY, + } + assert unpacked_message.data[INTENT][INTENT_NAME_KEY] == expected_intent + assert ( + unpacked_message.data[INTENT][PREDICTED_CONFIDENCE_KEY] == expected_confidence + ) + + intent_ranking = unpacked_message.data[INTENT_RANKING_KEY] + assert len(intent_ranking) == 1 + assert intent_ranking[0] == { + INTENT_NAME_KEY: expected_intent, + PREDICTED_CONFIDENCE_KEY: expected_confidence, + } + if expected_entities: + entity_data: List[Dict[Text, Any]] = unpacked_message.data[ENTITIES] + assert all( + set(item.keys()) + == { + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + } + for item in entity_data + ) + assert set( + (item[ENTITY_ATTRIBUTE_TYPE], item[ENTITY_ATTRIBUTE_VALUE]) + for item in expected_entities + ) == set( + (item[ENTITY_ATTRIBUTE_TYPE], item[ENTITY_ATTRIBUTE_VALUE]) + for item in entity_data + ) + else: + assert unpacked_message.data[ENTITIES] is not None + assert len(unpacked_message.data[ENTITIES]) == 0 + + +@pytest.mark.parametrize( + "intent,entities,expected_intent,domain_entities", + [ + ("wrong_intent", {"entity": 1.0}, "other_intent", ["entity"]), + ("my_intent", {"wrong_entity": 1.0}, "my_intent", ["other-entity"]), + ("wrong_intent", {"wrong_entity": 1.0}, "other_intent", ["other-entity"]), + # Special case: text "my_intent['entity1']" will be interpreted as the intent. + # This is not caught via the regex at the moment (intent names can include + # anything except "{" and "@".) + ("wrong_entity", ["wrong_entity"], "wrong_entity", ["wrong_entity"]), + ], +) +def test_process_warns_if_intent_or_entities_not_in_domain( + intent: Text, + entities: Optional[Text], + expected_intent: Text, + domain_entities: List[Text], +): + # construct text according to pattern + text = INTENT_MESSAGE_PREFIX + intent # do not add a confidence value + if entities is not None: + text += json.dumps(entities) + message = Message(data={TEXT: text}) + + # construct domain from expected intent/entities + domain = Domain( + intents=[expected_intent], + entities=domain_entities, + slots=[], + responses={}, + action_names=[], + forms={}, + data={}, + ) + + # expect a warning + with pytest.warns(UserWarning): + unpacked_message = YAMLStoryReader.unpack_regex_message(message, domain) + + if "wrong" not in intent: + assert unpacked_message.data[INTENT][INTENT_NAME_KEY] == intent + if "wrong" in entities: + assert unpacked_message.data[ENTITIES] is not None + assert len(unpacked_message.data[ENTITIES]) == 0 + else: + assert unpacked_message == message + + +async def test_unpack_regex_message_has_correct_entity_start_and_end(): + entity = "name" + slot_1 = {entity: "Core"} + text = f"/greet{json.dumps(slot_1)}" + + message = Message(data={TEXT: text}) + + domain = Domain( + intents=["greet"], + entities=[entity], + slots=[], + responses={}, + action_names=[], + forms={}, + data={}, + ) + + message = YAMLStoryReader.unpack_regex_message( + message, domain, entity_extractor_name="RegexMessageHandler" + ) + + assert message.data == { + "text": '/greet{"name": "Core"}', + "intent": {"name": "greet", "confidence": 1.0}, + "intent_ranking": [{"name": "greet", "confidence": 1.0}], + "entities": [ + { + "entity": "name", + "value": "Core", + "start": 6, + "end": 22, + EXTRACTOR: "RegexMessageHandler", + } + ], + } diff --git a/tests/shared/core/training_data/story_writer/__init__.py b/tests/shared/core/training_data/story_writer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/shared/core/training_data/story_writer/test_yaml_story_writer.py b/tests/shared/core/training_data/story_writer/test_yaml_story_writer.py new file mode 100644 index 0000000..9f5cda7 --- /dev/null +++ b/tests/shared/core/training_data/story_writer/test_yaml_story_writer.py @@ -0,0 +1,322 @@ +from pathlib import Path +import textwrap +from typing import Text +from collections import OrderedDict +import pytest +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION + +from rasa.shared.core.constants import ( + ACTION_SESSION_START_NAME, + ACTION_LISTEN_NAME, + ACTION_UNLIKELY_INTENT_NAME, +) +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + ActionExecuted, + UserUttered, + DefinePrevUserUtteredFeaturization, +) +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, +) +from rasa.shared.core.training_data.story_writer.yaml_story_writer import ( + YAMLStoryWriter, +) +from rasa.shared.core.training_data.structures import STORY_START + + +@pytest.mark.parametrize( + "input_yaml_file", + [ + "data/test_yaml_stories/stories.yml", + "data/test_yaml_stories/stories_defaultdomain.yml", + ], +) +async def test_simple_story(tmpdir: Path, domain: Domain, input_yaml_file: Text): + original_yaml_reader = YAMLStoryReader(domain, None) + original_yaml_story_steps = original_yaml_reader.read_from_file(input_yaml_file) + + target_story_filename = tmpdir / "test.yml" + writer = YAMLStoryWriter() + writer.dump(target_story_filename, original_yaml_story_steps) + + processed_yaml_reader = YAMLStoryReader(domain, None) + processed_yaml_story_steps = processed_yaml_reader.read_from_file( + target_story_filename + ) + + assert len(processed_yaml_story_steps) == len(original_yaml_story_steps) + for processed_step, original_step in zip( + processed_yaml_story_steps, original_yaml_story_steps + ): + assert len(processed_step.events) == len(original_step.events) + + +async def test_story_start_checkpoint_is_skipped(domain: Domain): + input_yaml_file = "data/test_yaml_stories/stories.yml" + + original_yaml_reader = YAMLStoryReader(domain, None) + original_yaml_story_steps = original_yaml_reader.read_from_file(input_yaml_file) + + yaml_text = YAMLStoryWriter().dumps(original_yaml_story_steps) + + assert STORY_START not in yaml_text + + +async def test_forms_are_converted(domain: Domain): + original_yaml_reader = YAMLStoryReader(domain, None) + original_yaml_story_steps = original_yaml_reader.read_from_file( + "data/test_yaml_stories/stories_form.yml" + ) + + assert YAMLStoryWriter.stories_contain_loops(original_yaml_story_steps) + + writer = YAMLStoryWriter() + + with pytest.warns(None) as record: + writer.dumps(original_yaml_story_steps) + + assert len(record) == 0 + + +def test_yaml_writer_dumps_user_messages(): + events = [UserUttered("Hello", {"name": "greet"}), ActionExecuted("utter_greet")] + tracker = DialogueStateTracker.from_events("default", events) + dump = YAMLStoryWriter().dumps(tracker.as_story().story_steps, is_test_story=True) + + assert ( + dump.strip() + == textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: default + steps: + - intent: greet + user: |- + Hello + - action: utter_greet + + """ + ).strip() + ) + + +def test_yaml_writer_doesnt_dump_action_unlikely_intent(): + events = [ + UserUttered("Hello", {"name": "greet"}), + ActionExecuted("utter_hello"), + ActionExecuted(ACTION_UNLIKELY_INTENT_NAME, metadata={"key1": "value1"}), + ActionExecuted("utter_bye"), + ] + tracker = DialogueStateTracker.from_events("default", events) + dump = YAMLStoryWriter().dumps(tracker.as_story().story_steps, is_test_story=True) + + assert ( + dump.strip() + == textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: default + steps: + - intent: greet + user: |- + Hello + - action: utter_hello + - action: utter_bye + +""" + ).strip() + ) + + +def test_yaml_writer_avoids_dumping_not_existing_user_messages(): + events = [UserUttered("greet", {"name": "greet"}), ActionExecuted("utter_greet")] + tracker = DialogueStateTracker.from_events("default", events) + dump = YAMLStoryWriter().dumps(tracker.as_story().story_steps) + + assert ( + dump.strip() + == textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: default + steps: + - intent: greet + - action: utter_greet + + """ + ).strip() + ) + + +@pytest.mark.parametrize( + "input_yaml_file", ["data/test_yaml_stories/rules_with_stories_sorted.yaml"] +) +def test_yaml_writer_dumps_rules(input_yaml_file: Text, tmpdir: Path, domain: Domain): + original_yaml_reader = YAMLStoryReader(domain, None) + original_yaml_story_steps = original_yaml_reader.read_from_file(input_yaml_file) + + dump = YAMLStoryWriter().dumps(original_yaml_story_steps) + # remove the version string + dump = "\n".join(dump.split("\n")[1:]) + + with open(input_yaml_file) as original_file: + assert dump == original_file.read() + + +async def test_action_start_action_listen_are_not_dumped(): + events = [ + ActionExecuted(ACTION_SESSION_START_NAME), + UserUttered("Hello", {"name": "greet"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + ] + tracker = DialogueStateTracker.from_events("default", events) + dump = YAMLStoryWriter().dumps(tracker.as_story().story_steps) + + assert ACTION_SESSION_START_NAME not in dump + assert ACTION_LISTEN_NAME not in dump + + +def test_yaml_writer_stories_to_yaml(domain: Domain): + reader = YAMLStoryReader(domain, None) + writer = YAMLStoryWriter() + steps = reader.read_from_file( + "data/test_yaml_stories/simple_story_with_only_end.yml" + ) + + result = writer.stories_to_yaml(steps) + assert isinstance(result, OrderedDict) + assert "stories" in result + assert len(result["stories"]) == 1 + + +def test_yaml_writer_stories_to_yaml_with_null_entities(domain: Domain): + writer = YAMLStoryWriter() + stories = textwrap.dedent( + """ + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: happy path + steps: + - intent: test_intent + entities: + - test_entity: null + - test_entity2: false + """ + ) + + stories_yaml = YAMLStoryReader().read_from_string(stories) + result = writer.stories_to_yaml(stories_yaml) + assert isinstance(result, OrderedDict) + assert "stories" in result + assert len(result["stories"]) == 1 + entities = result["stories"][0]["steps"][0]["entities"] + assert entities[0] == "test_entity" + assert entities[1] == OrderedDict({"test_entity2": False}) + + +def test_writing_end_to_end_stories(domain: Domain): + story_name = "test_writing_end_to_end_stories" + events = [ + # Training story story with intent and action labels + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(intent={"name": "greet"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + # Prediction story story with intent and action labels + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered(text="Hi", intent={"name": "greet"}), + DefinePrevUserUtteredFeaturization(use_text_for_featurization=False), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_LISTEN_NAME), + # End-To-End Training Story + UserUttered(text="Hi"), + ActionExecuted(action_text="Hi, I'm a bot."), + ActionExecuted(ACTION_LISTEN_NAME), + # End-To-End Prediction Story + UserUttered("Hi", intent={"name": "greet"}), + DefinePrevUserUtteredFeaturization(use_text_for_featurization=True), + ActionExecuted(action_text="Hi, I'm a bot."), + ActionExecuted(ACTION_LISTEN_NAME), + ] + + tracker = DialogueStateTracker.from_events(story_name, events) + dump = YAMLStoryWriter().dumps(tracker.as_story().story_steps) + + assert ( + dump.strip() + == textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: {story_name} + steps: + - intent: greet + - action: utter_greet + - intent: greet + - action: utter_greet + - user: |- + Hi + - bot: Hi, I'm a bot. + - user: |- + Hi + - bot: Hi, I'm a bot. + """ + ).strip() + ) + + +def test_reading_and_writing_end_to_end_stories_in_test_mode(domain: Domain): + story_name = "test_writing_end_to_end_stories_in_test_mode" + + conversation_tests = f""" +stories: +- story: {story_name} + steps: + - intent: greet + user: Hi + - action: utter_greet + - intent: greet + user: | + [Hi](test) + - action: utter_greet + - user: Hi + - bot: Hi, I'm a bot. + - user: | + [Hi](test) + - bot: Hi, I'm a bot. + """ + + end_to_end_tests = YAMLStoryReader().read_from_string(conversation_tests) + dump = YAMLStoryWriter().dumps(end_to_end_tests, is_test_story=True) + + assert ( + dump.strip() + == textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: {story_name} + steps: + - intent: greet + user: |- + Hi + - action: utter_greet + - intent: greet + user: |- + [Hi](test) + - action: utter_greet + - user: |- + Hi + - bot: Hi, I'm a bot. + - user: |- + [Hi](test) + - bot: Hi, I'm a bot. + """ + ).strip() + ) diff --git a/tests/shared/core/training_data/test_graph.py b/tests/shared/core/training_data/test_graph.py new file mode 100644 index 0000000..24b0d49 --- /dev/null +++ b/tests/shared/core/training_data/test_graph.py @@ -0,0 +1,89 @@ +from rasa.shared.core.training_data.structures import StoryGraph +import rasa.shared.core.training_data.loading +from rasa.shared.core.domain import Domain + + +def check_graph_is_sorted(g, sorted_nodes, removed_edges): + incoming_edges = {k: [s for s, vs in g.items() if k in vs] for k in g.keys()} + + visited = set() + for n in sorted_nodes: + deps = incoming_edges.get(n, []) + # checks that all incoming edges are from nodes we have already visited + assert all( + [d in visited or (d, n) in removed_edges for d in deps] + ), "Found an incoming edge from a node that wasn't visited yet!" + visited.add(n) + + +def test_node_ordering(): + example_graph = { + "a": ["b", "c", "d"], + "b": [], + "c": ["d"], + "d": [], + "e": ["f"], + "f": [], + } + sorted_nodes, removed_edges = StoryGraph.topological_sort(example_graph) + # sorting removed_edges converting set converting it to list + assert removed_edges == list() + check_graph_is_sorted(example_graph, sorted_nodes, removed_edges) + + +def test_node_ordering_with_cycle(): + example_graph = { + "a": ["b", "c", "d"], + "b": [], + "c": ["d"], + "d": ["a"], + "e": ["f"], + "f": ["e"], + } + sorted_nodes, removed_edges = StoryGraph.topological_sort(example_graph) + + check_graph_is_sorted(example_graph, sorted_nodes, removed_edges) + + +def test_is_empty(): + assert StoryGraph([]).is_empty() + + +def test_consistent_fingerprints(): + stories_path = "data/test_yaml_stories/stories.yml" + domain_path = "data/test_domains/default_with_slots.yml" + domain = Domain.load(domain_path) + story_steps = rasa.shared.core.training_data.loading.load_data_from_resource( + stories_path, domain + ) + story_graph = StoryGraph(story_steps) + + # read again + story_steps_2 = rasa.shared.core.training_data.loading.load_data_from_resource( + stories_path, domain + ) + story_graph_2 = StoryGraph(story_steps_2) + + fingerprint = story_graph.fingerprint() + fingerprint_2 = story_graph_2.fingerprint() + + assert fingerprint == fingerprint_2 + + +def test_unique_checkpoint_names(): + stories_path = "data/test_yaml_stories/story_with_two_equal_or_statements.yml" + domain_path = "data/test_domains/default_with_slots.yml" + domain = Domain.load(domain_path) + story_steps = rasa.shared.core.training_data.loading.load_data_from_resource( + stories_path, domain + ) + start_checkpoint_names = { + chk.name for s in story_steps for chk in s.start_checkpoints + } + + # first story: + # START_CHECKPOINT, GENR_OR_XXXXX for first OR, GENR_OR_YYYYY for second OR + + # additional in second story: + # GENR_OR_ZZZZZ as entities are different from first OR in first story + assert len(start_checkpoint_names) == 4 diff --git a/tests/shared/core/training_data/test_structures.py b/tests/shared/core/training_data/test_structures.py new file mode 100644 index 0000000..f42dcfe --- /dev/null +++ b/tests/shared/core/training_data/test_structures.py @@ -0,0 +1,99 @@ +import rasa.core +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION +from rasa.shared.core.constants import ACTION_SESSION_START_NAME +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + SessionStarted, + SlotSet, + UserUttered, + ActionExecuted, + DefinePrevUserUtteredFeaturization, +) +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, +) +from rasa.shared.core.training_data.story_writer.yaml_story_writer import ( + YAMLStoryWriter, +) +from rasa.shared.core.training_data.structures import Story +from rasa.shared.nlu.constants import INTENT_NAME_KEY + +domain = Domain.load("data/test_moodbot/domain.yml") + + +def test_session_start_is_not_serialised(domain: Domain): + tracker = DialogueStateTracker("default", domain.slots) + # the retrieved tracker should be empty + assert len(tracker.events) == 0 + + # add SlotSet event + tracker.update(SlotSet("slot", "value")) + + # add the two SessionStarted events and a user event + tracker.update(ActionExecuted(ACTION_SESSION_START_NAME)) + tracker.update(SessionStarted()) + tracker.update( + UserUttered("say something", intent={INTENT_NAME_KEY: "some_intent"}) + ) + tracker.update(DefinePrevUserUtteredFeaturization(False)) + + YAMLStoryWriter().dumps( + Story.from_events(tracker.events, "some-story01").story_steps + ) + + expected = f"""version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +stories: +- story: some-story01 + steps: + - slot_was_set: + - slot: value + - intent: some_intent +""" + + actual = YAMLStoryWriter().dumps( + Story.from_events(tracker.events, "some-story01").story_steps + ) + assert actual == expected + + +def test_as_story_string_or_statement_with_slot_was_set(): + import rasa.shared.utils.io + + stories = """ + stories: + - story: hello world + steps: + - or: + - slot_was_set: + - name: joe + - slot_was_set: + - name: bob + - action: some_action + """ + + reader = YAMLStoryReader() + yaml_content = rasa.shared.utils.io.read_yaml(stories) + + steps = reader.read_from_parsed_yaml(yaml_content) + + assert len(steps) == 3 + + +def test_cap_length(): + assert ( + rasa.shared.core.training_data.structures._cap_length("mystring", 6) == "mys..." + ) + + +def test_cap_length_without_ellipsis(): + assert ( + rasa.shared.core.training_data.structures._cap_length( + "mystring", 3, append_ellipsis=False + ) + == "mys" + ) + + +def test_cap_length_with_short_string(): + assert rasa.shared.core.training_data.structures._cap_length("my", 3) == "my" diff --git a/tests/shared/core/training_data/test_visualization.py b/tests/shared/core/training_data/test_visualization.py new file mode 100644 index 0000000..3bb4f4f --- /dev/null +++ b/tests/shared/core/training_data/test_visualization.py @@ -0,0 +1,190 @@ +from pathlib import Path +from typing import Text + +import rasa.shared.utils.io +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ActionExecuted, SlotSet, UserUttered +from rasa.shared.core.training_data import visualization +import rasa.utils.io +from rasa.shared.nlu.constants import TEXT, INTENT +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData + + +def test_style_transfer(): + r = visualization._transfer_style({"class": "dashed great"}, {"class": "myclass"}) + assert r["class"] == "myclass dashed" + + +def test_style_transfer_empty(): + r = visualization._transfer_style({"class": "dashed great"}, {"something": "else"}) + assert r["class"] == "dashed" + + +def test_common_action_prefix(): + this = [ + ActionExecuted("action_listen"), + ActionExecuted("greet"), + UserUttered("hey"), + ActionExecuted("amazing"), + # until this point they are the same + SlotSet("my_slot", "a"), + ActionExecuted("a"), + ActionExecuted("after_a"), + ] + other = [ + ActionExecuted("action_listen"), + ActionExecuted("greet"), + UserUttered("hey"), + ActionExecuted("amazing"), + # until this point they are the same + SlotSet("my_slot", "b"), + ActionExecuted("b"), + ActionExecuted("after_b"), + ] + num_common = visualization._length_of_common_action_prefix(this, other) + + assert num_common == 3 + + +def test_common_action_prefix_equal(): + this = [ + ActionExecuted("action_listen"), + ActionExecuted("greet"), + UserUttered("hey"), + ActionExecuted("amazing"), + ] + other = [ + ActionExecuted("action_listen"), + ActionExecuted("greet"), + UserUttered("hey"), + ActionExecuted("amazing"), + ] + num_common = visualization._length_of_common_action_prefix(this, other) + + assert num_common == 3 + + +def test_common_action_prefix_unequal(): + this = [ + ActionExecuted("action_listen"), + ActionExecuted("greet"), + UserUttered("hey"), + ] + other = [ + ActionExecuted("greet"), + ActionExecuted("action_listen"), + UserUttered("hey"), + ] + num_common = visualization._length_of_common_action_prefix(this, other) + + assert num_common == 0 + + +def test_graph_persistence(domain: Domain, tmp_path: Path): + from os.path import isfile + from networkx.drawing import nx_pydot + import rasa.shared.core.training_data.loading as core_loading + + story_steps = core_loading.load_data_from_resource( + "data/test_yaml_stories/stories.yml", domain + ) + out_file = str(tmp_path / "graph.html") + generated_graph = visualization.visualize_stories( + story_steps, + domain, + output_file=out_file, + max_history=3, + should_merge_nodes=False, + ) + + generated_graph = nx_pydot.to_pydot(generated_graph) + + assert isfile(out_file) + + content = rasa.shared.utils.io.read_file(out_file) + + assert "isClient = true" in content + assert "graph = `{}`".format(generated_graph.to_string()) in content + + +def test_merge_nodes(domain: Domain, tmp_path: Path): + from os.path import isfile + import rasa.shared.core.training_data.loading as core_loading + + story_steps = core_loading.load_data_from_resource( + "data/test_yaml_stories/stories.yml", domain + ) + out_file = str(tmp_path / "graph.html") + visualization.visualize_stories( + story_steps, + domain, + output_file=out_file, + max_history=3, + should_merge_nodes=True, + ) + assert isfile(out_file) + + +def test_story_visualization(domain: Domain, tmp_path: Path): + import rasa.shared.core.training_data.loading as core_loading + + story_steps = core_loading.load_data_from_resource( + "data/test_yaml_stories/stories.yml", domain + ) + out_file = tmp_path / "graph.html" + generated_graph = visualization.visualize_stories( + story_steps, + domain, + output_file=str(out_file), + max_history=3, + should_merge_nodes=False, + ) + + assert str(None) not in out_file.read_text() + assert "/affirm" in out_file.read_text() + assert len(generated_graph.nodes()) == 51 + assert len(generated_graph.edges()) == 56 + + +def test_story_visualization_with_training_data( + domain: Domain, tmp_path: Path, nlu_data_path: Text +): + import rasa.shared.core.training_data.loading as core_loading + + story_steps = core_loading.load_data_from_resource( + "data/test_yaml_stories/stories.yml", domain + ) + out_file = tmp_path / "graph.html" + test_text = "test text" + test_intent = "affirm" + generated_graph = visualization.visualize_stories( + story_steps, + domain, + output_file=str(out_file), + max_history=3, + should_merge_nodes=False, + nlu_training_data=TrainingData( + [Message({TEXT: test_text, INTENT: test_intent})] + ), + ) + + assert test_text in out_file.read_text() + assert test_intent not in out_file.read_text() + + assert len(generated_graph.nodes()) == 51 + assert len(generated_graph.edges()) == 56 + + +def test_story_visualization_with_merging(domain: Domain): + import rasa.shared.core.training_data.loading as core_loading + + story_steps = core_loading.load_data_from_resource( + "data/test_yaml_stories/stories.yml", domain + ) + generated_graph = visualization.visualize_stories( + story_steps, domain, output_file=None, max_history=3, should_merge_nodes=True + ) + assert 15 < len(generated_graph.nodes()) < 33 + + assert 20 < len(generated_graph.edges()) < 33 diff --git a/tests/shared/importers/__init__.py b/tests/shared/importers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/shared/importers/test_importer.py b/tests/shared/importers/test_importer.py new file mode 100644 index 0000000..66336dc --- /dev/null +++ b/tests/shared/importers/test_importer.py @@ -0,0 +1,391 @@ +import os +from pathlib import Path +from typing import Text, Dict, Type, List, Any + +import pytest + +from rasa.shared.constants import ( + DEFAULT_CONFIG_PATH, + DEFAULT_DOMAIN_PATH, + DEFAULT_DATA_PATH, + DEFAULT_CONVERSATION_TEST_PATH, +) +import rasa.shared.utils.io +import rasa.shared.core.constants +from rasa.shared.core.events import SlotSet, UserUttered, ActionExecuted +from rasa.shared.core.training_data.structures import StoryStep, StoryGraph +from rasa.shared.importers.importer import ( + TrainingDataImporter, + NluDataImporter, + E2EImporter, + ResponsesSyncImporter, +) +from rasa.shared.importers.multi_project import MultiProjectImporter +from rasa.shared.importers.rasa import RasaFileImporter +from rasa.shared.nlu.constants import ACTION_TEXT, ACTION_NAME, INTENT, TEXT +from rasa.shared.nlu.training_data.message import Message + + +@pytest.fixture() +def default_importer(project: Text) -> TrainingDataImporter: + config_path = os.path.join(project, DEFAULT_CONFIG_PATH) + domain_path = os.path.join(project, DEFAULT_DOMAIN_PATH) + default_data_path = os.path.join(project, DEFAULT_DATA_PATH) + + return TrainingDataImporter.load_from_dict( + {}, config_path, domain_path, [default_data_path] + ) + + +@pytest.mark.parametrize( + "config, expected", + [ + ({}, [RasaFileImporter]), + ({"importers": []}, [RasaFileImporter]), + ({"importers": [{"name": "RasaFileImporter"}]}, [RasaFileImporter]), + ({"importers": [{"name": "NotExistingModule"}]}, [RasaFileImporter]), + ( + { + "importers": [ + {"name": "rasa.shared.importers.multi_project.MultiProjectImporter"} + ] + }, + [MultiProjectImporter], + ), + ({"importers": [{"name": "MultiProjectImporter"}]}, [MultiProjectImporter]), + ( + { + "importers": [ + {"name": "RasaFileImporter"}, + {"name": "MultiProjectImporter"}, + ] + }, + [RasaFileImporter, MultiProjectImporter], + ), + ], +) +def test_load_from_dict( + config: Dict, expected: List[Type["TrainingDataImporter"]], project: Text +): + config_path = os.path.join(project, DEFAULT_CONFIG_PATH) + domain_path = os.path.join(project, DEFAULT_DOMAIN_PATH) + default_data_path = os.path.join(project, DEFAULT_DATA_PATH) + actual = TrainingDataImporter.load_from_dict( + config, config_path, domain_path, [default_data_path] + ) + + assert isinstance(actual, E2EImporter) + assert isinstance(actual.importer, ResponsesSyncImporter) + + actual_importers = [i.__class__ for i in actual.importer._importer._importers] + assert actual_importers == expected + + +def test_load_from_config(tmpdir: Path): + config_path = str(tmpdir / "config.yml") + + rasa.shared.utils.io.write_yaml( + {"importers": [{"name": "MultiProjectImporter"}]}, config_path + ) + + importer = TrainingDataImporter.load_from_config(config_path) + assert isinstance(importer, E2EImporter) + assert isinstance(importer.importer, ResponsesSyncImporter) + assert isinstance(importer.importer._importer._importers[0], MultiProjectImporter) + + +def test_nlu_only(project: Text): + config_path = os.path.join(project, DEFAULT_CONFIG_PATH) + default_data_path = os.path.join(project, DEFAULT_DATA_PATH) + actual = TrainingDataImporter.load_nlu_importer_from_config( + config_path, training_data_paths=[default_data_path] + ) + + assert isinstance(actual, NluDataImporter) + assert isinstance(actual._importer, ResponsesSyncImporter) + + stories = actual.get_stories() + assert stories.is_empty() + + conversation_tests = actual.get_stories() + assert conversation_tests.is_empty() + + domain = actual.get_domain() + assert domain.is_empty() + + config = actual.get_config() + assert config + + nlu_data = actual.get_nlu_data() + assert not nlu_data.is_empty() + + +def test_import_nlu_training_data_from_e2e_stories( + default_importer: TrainingDataImporter, +): + # The `E2EImporter` correctly wraps the underlying `CombinedDataImporter` + assert isinstance(default_importer, E2EImporter) + importer_without_e2e = default_importer.importer + + stories = StoryGraph( + [ + StoryStep( + "name", + events=[ + SlotSet("some slot", "doesn't matter"), + UserUttered(intent={"name": "greet_from_stories"}), + ActionExecuted("utter_greet_from_stories"), + ], + ), + StoryStep( + "name", + events=[ + UserUttered("how are you doing?"), + ActionExecuted(action_text="Hi Joey."), + ], + ), + ] + ) + + def mocked_stories(*_: Any, **__: Any) -> StoryGraph: + return stories + + # Patch to return our test stories + importer_without_e2e.get_stories = mocked_stories + + # The wrapping `E2EImporter` simply forwards these method calls + assert (importer_without_e2e.get_stories()).fingerprint() == ( + default_importer.get_stories() + ).fingerprint() + assert (importer_without_e2e.get_config()) == (default_importer.get_config()) + + # Check additional NLU training data from stories was added + nlu_data = default_importer.get_nlu_data() + + # The `E2EImporter` adds NLU training data based on our training stories + assert len(nlu_data.training_examples) > len( + importer_without_e2e.get_nlu_data().training_examples + ) + + # Check if the NLU training data was added correctly from the story training data + expected_additional_messages = [ + Message(data={INTENT: "greet_from_stories"}), + Message(data={ACTION_NAME: "utter_greet_from_stories"}), + Message(data={TEXT: "how are you doing?"}), + Message(data={ACTION_TEXT: "Hi Joey."}), + ] + + assert all(m in nlu_data.training_examples for m in expected_additional_messages) + + +def test_different_story_order_doesnt_change_nlu_training_data( + default_importer: E2EImporter, +): + stories = [ + StoryStep( + "name", + events=[ + UserUttered(intent={"name": "greet"}), + ActionExecuted("utter_greet_from_stories"), + ActionExecuted("hi", action_text="hi"), + ], + ), + StoryStep( + "name", + events=[ + UserUttered("bye", {"name": "bye"}), + ActionExecuted("utter_greet"), + ActionExecuted("hi", action_text="hi"), + ActionExecuted("bye", action_text="bye"), + ], + ), + ] + + def mocked_stories(*_: Any, **__: Any) -> StoryGraph: + return StoryGraph(stories) + + # Patch to return our test stories + default_importer.importer.get_stories = mocked_stories + + training_data = default_importer.get_nlu_data() + + # Pretend the order of the stories changed. This should have no + # effect on the NLU training data + stories = list(reversed(stories)) + + # Make sure importer doesn't cache stories + default_importer._cached_stories = None + + training_data2 = default_importer.get_nlu_data() + + assert hash(training_data) == hash(training_data2) + + +def test_import_nlu_training_data_with_default_actions( + default_importer: TrainingDataImporter, +): + assert isinstance(default_importer, E2EImporter) + importer_without_e2e = default_importer.importer + + # Check additional NLU training data from domain was added + nlu_data = default_importer.get_nlu_data() + + assert len(nlu_data.training_examples) > len( + importer_without_e2e.get_nlu_data().training_examples + ) + + extended_training_data = default_importer.get_nlu_data() + assert all( + Message(data={ACTION_NAME: action_name}) + in extended_training_data.training_examples + for action_name in rasa.shared.core.constants.DEFAULT_ACTION_NAMES + ) + + +def test_adding_e2e_actions_to_domain(default_importer: E2EImporter): + additional_actions = ["Hi Joey.", "it's sunny outside."] + stories = StoryGraph( + [ + StoryStep( + "name", + events=[ + UserUttered("greet_from_stories", {"name": "greet_from_stories"}), + ActionExecuted("utter_greet_from_stories"), + ], + ), + StoryStep( + "name", + events=[ + UserUttered("how are you doing?", {"name": "greet_from_stories"}), + ActionExecuted( + additional_actions[0], action_text=additional_actions[0] + ), + ActionExecuted( + additional_actions[1], action_text=additional_actions[1] + ), + ActionExecuted( + additional_actions[1], action_text=additional_actions[1] + ), + ], + ), + ] + ) + + def mocked_stories(*_: Any, **__: Any) -> StoryGraph: + return stories + + # Patch to return our test stories + default_importer.importer.get_stories = mocked_stories + + domain = default_importer.get_domain() + + assert all( + action_name in domain.action_names_or_texts + for action_name in additional_actions + ) + + +def test_nlu_data_domain_sync_with_retrieval_intents(project: Text): + config_path = os.path.join(project, DEFAULT_CONFIG_PATH) + domain_path = "data/test_domains/default_retrieval_intents.yml" + data_paths = [ + "data/test/stories_default_retrieval_intents.yml", + "data/test_responses/default.yml", + ] + importer = TrainingDataImporter.load_from_dict( + {}, config_path, domain_path, data_paths + ) + + domain = importer.get_domain() + nlu_data = importer.get_nlu_data() + + assert domain.retrieval_intents == ["chitchat"] + assert domain.intent_properties["chitchat"].get("is_retrieval_intent") + assert domain.retrieval_intent_responses == nlu_data.responses + assert domain.responses != nlu_data.responses + assert "utter_chitchat" in domain.action_names_or_texts + + +def test_subintent_response_matches_with_action(project: Text): + """Tests retrieval intent responses are matched correctly to actions.""" + config_path = os.path.join(project, DEFAULT_CONFIG_PATH) + domain_path = "data/test_domains/simple_retrieval_intent.yml" + data_path = "data/test/simple_retrieval_intent_nlu.yml" + importer = TrainingDataImporter.load_from_dict( + {}, config_path, domain_path, data_path + ) + + domain = importer.get_domain() + # Test retrieval intent response is matched correctly to actions + # ie. utter_chitchat/faq response compatible with action utter_chitchat + with pytest.warns(None) as record: + domain.check_missing_responses() + assert not record + + +def test_response_missing(project: Text): + """Tests warning when response is missing.""" + config_path = os.path.join(project, DEFAULT_CONFIG_PATH) + domain_path = "data/test_domains/missing_chitchat_response.yml" + data_path = "data/test/simple_retrieval_intent_nlu.yml" + importer = TrainingDataImporter.load_from_dict( + {}, config_path, domain_path, data_path + ) + + domain = importer.get_domain() + with pytest.warns(UserWarning) as record: + domain.check_missing_responses() + + assert ( + "Action 'utter_chitchat' is listed as a response action in the domain " + "file, but there is no matching response defined. Please check your " + "domain." + ) in record[0].message.args[0] + + +def test_nlu_data_domain_sync_responses(project: Text): + config_path = os.path.join(project, DEFAULT_CONFIG_PATH) + domain_path = "data/test_domains/default.yml" + data_paths = ["data/test_responses/responses_utter_rasa.yml"] + + importer = TrainingDataImporter.load_from_dict( + {}, config_path, domain_path, data_paths + ) + + with pytest.warns(None): + domain = importer.get_domain() + + # Responses were sync between "test_responses.yml" and the "domain.yml" + assert "utter_rasa" in domain.responses.keys() + + +def test_importer_with_unicode_files(): + importer = TrainingDataImporter.load_from_dict( + training_data_paths=["./data/test_nlu_no_responses/nlu_with_unicode.yml"] + ) + + # None of these should raise + nlu_data = importer.get_nlu_data() + assert not nlu_data.is_empty() + + importer.get_stories() + importer.get_domain() + + +def test_read_conversation_tests(project: Text): + importer = TrainingDataImporter.load_from_dict( + training_data_paths=[str(Path(project) / DEFAULT_CONVERSATION_TEST_PATH)] + ) + + test_stories = importer.get_conversation_tests() + assert len(test_stories.story_steps) == 7 + + +def test_importer_fingerprint(): + importer = TrainingDataImporter.load_from_dict( + training_data_paths=["./data/test_nlu_no_responses/nlu_with_unicode.yml"] + ) + + fp1 = importer.fingerprint() + fp2 = importer.fingerprint() + assert fp1 != fp2 diff --git a/tests/shared/importers/test_multi_project.py b/tests/shared/importers/test_multi_project.py new file mode 100644 index 0000000..08ebaf5 --- /dev/null +++ b/tests/shared/importers/test_multi_project.py @@ -0,0 +1,294 @@ +from pathlib import Path +from typing import Dict, Text + +from _pytest.tmpdir import TempPathFactory +import pytest +import os + +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.engine.storage.resource import Resource +import rasa.shared.utils.io +from rasa.shared.nlu.training_data.formats import RasaYAMLReader +import rasa.utils.io +from rasa.core import utils +from rasa.shared.importers.multi_project import MultiProjectImporter + + +def test_load_imports_from_directory_tree(tmp_path: Path): + root_imports = {"imports": ["Project A"]} + utils.dump_obj_as_yaml_to_file(tmp_path / "config.yml", root_imports) + + project_a_directory = tmp_path / "Project A" + project_a_directory.mkdir() + project_a_imports = {"imports": ["../Project B"]} + utils.dump_obj_as_yaml_to_file( + project_a_directory / "config.yml", project_a_imports + ) + + project_b_directory = tmp_path / "Project B" + project_b_directory.mkdir() + project_b_imports = {"some other": ["../Project C"]} + utils.dump_obj_as_yaml_to_file( + project_b_directory / "config.yml", project_b_imports + ) + + project_b_subproject_directory = project_b_directory / "Project B-1" + project_b_subproject_directory.mkdir() + project_b_1_imports = {"imports": ["../../Project A"]} + # Check if loading from `.yaml` also works + utils.dump_obj_as_yaml_to_file( + project_b_subproject_directory / "config.yaml", project_b_1_imports + ) + + # should not be imported + subdirectory_3 = tmp_path / "Project C" + subdirectory_3.mkdir() + + expected = [ + os.path.join(str(project_a_directory)), + os.path.join(str(project_b_directory)), + ] + + actual = MultiProjectImporter(str(tmp_path / "config.yml")) + + assert actual._imports == expected + + +def test_load_imports_without_imports(tmp_path: Path): + empty_config = {} + utils.dump_obj_as_yaml_to_file(tmp_path / "config.yml", empty_config) + + project_a_directory = tmp_path / "Project A" + project_a_directory.mkdir() + utils.dump_obj_as_yaml_to_file(project_a_directory / "config.yml", empty_config) + + project_b_directory = tmp_path / "Project B" + project_b_directory.mkdir() + utils.dump_obj_as_yaml_to_file(project_b_directory / "config.yml", empty_config) + + actual = MultiProjectImporter(str(tmp_path / "config.yml")) + + assert actual.is_imported(str(tmp_path / "Project C")) + + +@pytest.mark.parametrize("input_dict", [{}, {"imports": None}]) +def test_load_from_none(input_dict: Dict, tmp_path: Path): + config_path = tmp_path / "config.yml" + utils.dump_obj_as_yaml_to_file(tmp_path / "config.yml", input_dict) + + actual = MultiProjectImporter(str(config_path)) + + assert actual._imports == list() + + +def test_load_if_subproject_is_more_specific_than_parent(tmp_path: Path): + config_path = str(tmp_path / "config.yml") + utils.dump_obj_as_yaml_to_file(tmp_path / "config.yml", {}) + + project_a_directory = tmp_path / "Project A" + project_a_directory.mkdir() + project_a_imports = {"imports": ["Project B"]} + utils.dump_obj_as_yaml_to_file( + project_a_directory / "config.yml", project_a_imports + ) + + actual = MultiProjectImporter(config_path) + + assert actual.is_imported(str(project_a_directory)) + + +@pytest.mark.parametrize( + "input_path", ["A/A/A/B", "A/A/A", "A/B/A/A", "A/A/A/B/C/D/E.type"] +) +def test_in_imports(input_path: Text, tmp_path: Path): + config_path = str(tmp_path / "config.yml") + utils.dump_obj_as_yaml_to_file( + tmp_path / "config.yml", {"imports": ["A/A/A", "A/B/A"]} + ) + + importer = MultiProjectImporter(config_path, project_directory=os.getcwd()) + + assert importer.is_imported(input_path) + + +@pytest.mark.parametrize("input_path", ["A/C", "A/A/B", "A/B"]) +def test_not_in_imports(input_path: Text, tmp_path: Path): + config_path = str(tmp_path / "config.yml") + utils.dump_obj_as_yaml_to_file( + tmp_path / "config.yml", {"imports": ["A/A/A", "A/B/A"]} + ) + importer = MultiProjectImporter(config_path, project_directory=os.getcwd()) + + assert not importer.is_imported(input_path) + + +def test_cyclic_imports(tmp_path: Path): + project_imports = {"imports": ["Project A"]} + utils.dump_obj_as_yaml_to_file(tmp_path / "config.yml", project_imports) + + project_a_directory = tmp_path / "Project A" + project_a_directory.mkdir() + project_a_imports = {"imports": ["../Project B"]} + utils.dump_obj_as_yaml_to_file( + project_a_directory / "config.yml", project_a_imports + ) + + project_b_directory = tmp_path / "Project B" + project_b_directory.mkdir() + project_b_imports = {"imports": ["../Project A"]} + utils.dump_obj_as_yaml_to_file( + project_b_directory / "config.yml", project_b_imports + ) + + actual = MultiProjectImporter(str(tmp_path / "config.yml")) + + assert actual._imports == [str(project_a_directory), str(project_b_directory)] + + +def test_import_outside_project_directory(tmp_path: Path): + project_imports = {"imports": ["Project A"]} + utils.dump_obj_as_yaml_to_file(tmp_path / "config.yml", project_imports) + + project_a_directory = tmp_path / "Project A" + project_a_directory.mkdir() + project_a_imports = {"imports": ["../Project B"]} + utils.dump_obj_as_yaml_to_file( + project_a_directory / "config.yml", project_a_imports + ) + + project_b_directory = tmp_path / "Project B" + project_b_directory.mkdir() + project_b_imports = {"imports": ["../Project C"]} + utils.dump_obj_as_yaml_to_file( + project_b_directory / "config.yml", project_b_imports + ) + + actual = MultiProjectImporter(str(project_a_directory / "config.yml")) + + assert actual._imports == [str(project_b_directory), str(tmp_path / "Project C")] + + +def test_importing_additional_files(tmp_path: Path): + config = {"imports": ["bots/Bot A"]} + config_path = str(tmp_path / "config.yml") + utils.dump_obj_as_yaml_to_file(config_path, config) + + additional_file = tmp_path / "directory" / "file.yml" + additional_file.parent.mkdir() + + # create intermediate directories and fake files + rasa.shared.utils.io.write_text_file("stories:\n - story: simple", additional_file) + selector = MultiProjectImporter( + config_path, + training_data_paths=[str(tmp_path / "directory"), str(additional_file)], + ) + + assert selector.is_imported(str(additional_file)) + assert str(additional_file) in selector._story_paths + + +def test_not_importing_not_relevant_additional_files(tmp_path: Path): + config = {"imports": ["bots/Bot A"]} + config_path = str(tmp_path / "config.yml") + utils.dump_obj_as_yaml_to_file(config_path, config) + + additional_file = tmp_path / "directory" / "file.yml" + additional_file.parent.mkdir() + + selector = MultiProjectImporter( + config_path, training_data_paths=[str(tmp_path / "data"), str(additional_file)] + ) + + not_relevant_file1 = tmp_path / "data" / "another directory" / "file.yml" + not_relevant_file1.parent.mkdir(parents=True) + rasa.shared.utils.io.write_text_file("", not_relevant_file1) + not_relevant_file2 = tmp_path / "directory" / "another_file.yml" + rasa.shared.utils.io.write_text_file("", not_relevant_file2) + + assert not selector.is_imported(str(not_relevant_file1)) + assert not selector.is_imported(str(not_relevant_file2)) + + +def test_not_importing_e2e_conversation_tests_in_project(tmp_path: Path): + config = {"imports": ["bots/Bot A"]} + config_path = str(tmp_path / "config.yml") + utils.dump_obj_as_yaml_to_file(config_path, config) + + story_file = tmp_path / "bots" / "Bot A" / "data" / "stories.yml" + story_file.parent.mkdir(parents=True) + rasa.shared.utils.io.write_text_file("stories:\n - story: simple", story_file) + + story_test_file = tmp_path / "bots" / "Bot A" / "test_stories.yml" + rasa.shared.utils.io.write_text_file("""stories:""", story_test_file) + + selector = MultiProjectImporter(config_path) + + # Conversation tests should not be included in story paths + assert [str(story_file)] == selector._story_paths + assert [str(story_test_file)] == selector._e2e_story_paths + + +def test_single_additional_file(tmp_path: Path): + config_path = str(tmp_path / "config.yml") + empty_config = {} + utils.dump_obj_as_yaml_to_file(config_path, empty_config) + + additional_file = tmp_path / "directory" / "file.yml" + additional_file.parent.mkdir() + rasa.shared.utils.io.write_yaml({}, additional_file) + + selector = MultiProjectImporter( + config_path, training_data_paths=str(additional_file) + ) + + assert selector.is_imported(str(additional_file)) + + +async def test_multi_project_training(trained_async, tmp_path_factory: TempPathFactory): + example_directory = "data/test_multi_domain" + config_file = os.path.join(example_directory, "config.yml") + domain_file = os.path.join(example_directory, "domain.yml") + files_of_root_project = os.path.join(example_directory, "data") + + trained_stack_model_path = await trained_async( + config=config_file, + domain=domain_file, + training_files=files_of_root_project, + force_training=True, + persist_nlu_training_data=True, + ) + + storage_path = tmp_path_factory.mktemp("storage_path") + model_storage, model_metadata = LocalModelStorage.from_model_archive( + storage_path, trained_stack_model_path + ) + domain = model_metadata.domain + + expected_intents = { + "greet", + "goodbye", + "affirm", + "deny", + "mood_great", + "mood_unhappy", + } + + assert all([i in domain.intents for i in expected_intents]) + + with model_storage.read_from( + Resource("nlu_training_data_provider") + ) as resource_dir: + nlu_training_data_file = resource_dir / "training_data.yml" + nlu_training_data = RasaYAMLReader().read(nlu_training_data_file) + + assert expected_intents == nlu_training_data.intents + + expected_actions = [ + "utter_greet", + "utter_cheer_up", + "utter_did_that_help", + "utter_happy", + "utter_goodbye", + ] + + assert all([a in domain.action_names_or_texts for a in expected_actions]) diff --git a/tests/shared/importers/test_rasa.py b/tests/shared/importers/test_rasa.py new file mode 100644 index 0000000..e157168 --- /dev/null +++ b/tests/shared/importers/test_rasa.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import Text +import os + +from rasa.shared.constants import ( + DEFAULT_CONFIG_PATH, + DEFAULT_DOMAIN_PATH, + DEFAULT_DATA_PATH, + DEFAULT_CONVERSATION_TEST_PATH, +) +from rasa.shared.core.constants import DEFAULT_INTENTS, SESSION_START_METADATA_SLOT +from rasa.shared.core.domain import Domain +from rasa.shared.core.slots import AnySlot +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.importers.rasa import RasaFileImporter + + +def test_rasa_file_importer(project: Text): + config_path = os.path.join(project, DEFAULT_CONFIG_PATH) + domain_path = os.path.join(project, DEFAULT_DOMAIN_PATH) + default_data_path = os.path.join(project, DEFAULT_DATA_PATH) + + importer = RasaFileImporter(config_path, domain_path, [default_data_path]) + + domain = importer.get_domain() + assert len(domain.intents) == 7 + len(DEFAULT_INTENTS) + assert domain.slots == [AnySlot(SESSION_START_METADATA_SLOT, mappings=[{}])] + assert domain.entities == [] + assert len(domain.action_names_or_texts) == 19 + assert len(domain.responses) == 6 + + stories = importer.get_stories() + assert len(stories.story_steps) == 5 + + test_stories = importer.get_conversation_tests() + assert len(test_stories.story_steps) == 0 + + nlu_data = importer.get_nlu_data("en") + assert len(nlu_data.intents) == 7 + assert len(nlu_data.intent_examples) == 68 + + +def test_read_conversation_tests(project: Text): + importer = RasaFileImporter( + training_data_paths=[str(Path(project) / DEFAULT_CONVERSATION_TEST_PATH)] + ) + + test_stories = importer.get_conversation_tests() + assert len(test_stories.story_steps) == 7 + + +def test_rasa_file_importer_with_invalid_config(): + importer = RasaFileImporter(config_file="invalid path") + actual = importer.get_config() + + assert actual == {} + + +def test_rasa_file_importer_with_invalid_domain(tmp_path: Path): + config_file = tmp_path / "config.yml" + config_file.write_text("") + importer = TrainingDataImporter.load_from_dict({}, str(config_file), None, []) + + actual = importer.get_domain() + assert actual.as_dict() == Domain.empty().as_dict() diff --git a/tests/shared/nlu/__init__.py b/tests/shared/nlu/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/shared/nlu/training_data/__init__.py b/tests/shared/nlu/training_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/shared/nlu/training_data/formats/__init__.py b/tests/shared/nlu/training_data/formats/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/shared/nlu/training_data/formats/test_rasa_yaml.py b/tests/shared/nlu/training_data/formats/test_rasa_yaml.py new file mode 100644 index 0000000..458d743 --- /dev/null +++ b/tests/shared/nlu/training_data/formats/test_rasa_yaml.py @@ -0,0 +1,528 @@ +import textwrap +import warnings +from typing import Text + +import pytest +import pathlib + +from rasa.shared.exceptions import YamlException, YamlSyntaxException +import rasa.shared.utils.io +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION +from rasa.shared.nlu.constants import ( + INTENT, + METADATA, + METADATA_INTENT, + METADATA_EXAMPLE, +) +from rasa.shared.nlu.training_data.formats.rasa_yaml import ( + RasaYAMLReader, + RasaYAMLWriter, +) +from tests.conftest import filter_expected_warnings + +MULTILINE_INTENT_EXAMPLES = f"""version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +nlu: +- intent: intent_name + examples: | + - how much CO2 will that use? + - how much carbon will a one way flight from [new york]{{"entity": "city", "role": "from"}} to california produce? + - what's the carbon footprint of a flight from london to new york? + - how much co2 to new york? + - how much co2 is produced on a return flight from london to new york? + - what's the co2 usage of a return flight to new york? + - can you calculate the co2 footprint of a flight to london? +""" # noqa: E501 + +MULTILINE_INTENT_EXAMPLE_WITH_SYNONYM = """ +nlu: +- intent: intent_name + examples: | + - flight from [boston]{"entity": "city", "role": "from", "value": "bostn"}? +""" + +MULTILINE_INTENT_EXAMPLES_NO_LEADING_SYMBOL = """ +nlu: +- intent: intent_name + examples: | + how much CO2 will that use? + - how much carbon will a one way flight from [new york]{"entity": "city", "role": "from"} to california produce? +""" # noqa: E501 + +INTENT_EXAMPLES_WITH_METADATA = f"""version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +nlu: +- intent: intent_name + metadata: + - johnny + examples: + - text: | + how much CO2 will that use? + metadata: + sentiment: positive + - text: | + how much carbon will a one way flight from [new york]{{"entity": "city", "role": "from"}} to california produce? + metadata: co2-trip-calculation + - text: | + how much CO2 to [new york]{{"entity": "city", "role": "to"}}? +- intent: greet + metadata: initiate-conversation + examples: | + - Hi + - Hello +- intent: goodbye + examples: + - text: | + bye + metadata: positive-sentiment + - text: | + goodbye + metadata: positive-sentiment +""" # noqa: E501 + + +MINIMAL_VALID_EXAMPLE = """ +nlu:\n +stories: +""" + +WRONG_YAML_NLU_CONTENT_1 = """ +nlu: +- intent: name + non_key: value +""" + +WRONG_YAML_NLU_CONTENT_2 = """ +nlu: +- intent: greet + examples: | + - Hi + - Hey +""" + +SYNONYM_EXAMPLE = f""" +version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +nlu: +- synonym: savings + examples: | + - pink pig + - savings account +""" + +LOOKUP_ITEM_NAME = "additional_currencies" +LOOKUP_EXAMPLE = f""" +version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +nlu: +- lookup: {LOOKUP_ITEM_NAME} + examples: | + - Peso + - Euro + - Dollar +""" + +REGEX_NAME = "zipcode" +PATTERN_1 = "[0-9]{4}" +PATTERN_2 = "[0-9]{5}" +REGEX_EXAMPLE = f""" +version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +nlu: +- regex: {REGEX_NAME} + examples: | + - {PATTERN_1} + - {PATTERN_2} +""" + + +def test_wrong_format_raises(): + + wrong_yaml_nlu_content = """ + !! + """ + + parser = RasaYAMLReader() + with pytest.raises(YamlSyntaxException): + parser.reads(wrong_yaml_nlu_content) + + +@pytest.mark.parametrize( + "example", [WRONG_YAML_NLU_CONTENT_1, WRONG_YAML_NLU_CONTENT_2] +) +def test_wrong_schema_raises(example: Text): + + parser = RasaYAMLReader() + with pytest.raises(YamlException): + parser.reads(example) + + +@pytest.mark.parametrize( + "example", [MULTILINE_INTENT_EXAMPLES, INTENT_EXAMPLES_WITH_METADATA] +) +def test_multiline_intent_is_parsed(example: Text): + parser = RasaYAMLReader() + + with warnings.catch_warnings() as record: + training_data = parser.reads(example) + + if record is not None: + record = filter_expected_warnings(record) + assert len(record) == 0 + + assert len(training_data.training_examples) == 7 + assert training_data.training_examples[0].get( + INTENT + ) == training_data.training_examples[1].get(INTENT) + assert not len(training_data.entity_synonyms) + + +def test_intent_with_metadata_is_parsed(): + parser = RasaYAMLReader() + + with warnings.catch_warnings() as record: + training_data = parser.reads(INTENT_EXAMPLES_WITH_METADATA) + + if record is not None: + record = filter_expected_warnings(record) + assert len(record) == 0 + + assert len(training_data.training_examples) == 7 + example_1, example_2, *other_examples = training_data.training_examples + assert example_1.get(METADATA) == { + METADATA_INTENT: ["johnny"], + METADATA_EXAMPLE: {"sentiment": "positive"}, + } + assert example_2.get(METADATA) == { + METADATA_INTENT: ["johnny"], + METADATA_EXAMPLE: "co2-trip-calculation", + } + + +def test_metadata_roundtrip(): + reader = RasaYAMLReader() + result = reader.reads(INTENT_EXAMPLES_WITH_METADATA) + + dumped = RasaYAMLWriter().dumps(result) + assert dumped == INTENT_EXAMPLES_WITH_METADATA + + validation_reader = RasaYAMLReader() + dumped_result = validation_reader.reads(dumped) + + assert dumped_result.training_examples == result.training_examples + + +def test_write_metadata_stripped(): + reader = RasaYAMLReader() + result = reader.reads(INTENT_EXAMPLES_WITH_METADATA) + + # Add strippable characters to first example text + result.training_examples[0].data["text"] += " \r\n " + + dumped = RasaYAMLWriter().dumps(result) + assert dumped == INTENT_EXAMPLES_WITH_METADATA + + +# This test would work only with examples that have a `version` key specified +@pytest.mark.parametrize( + "example", + [MULTILINE_INTENT_EXAMPLES, SYNONYM_EXAMPLE, LOOKUP_EXAMPLE, REGEX_EXAMPLE], +) +def test_yaml_examples_are_written(example: Text): + parser = RasaYAMLReader() + writer = RasaYAMLWriter() + + training_data = parser.reads(example) + assert example.strip() == writer.dumps(training_data).strip() + + +def test_training_data_as_yaml_dict(): + from collections import OrderedDict + + parser = RasaYAMLReader() + writer = RasaYAMLWriter() + + training_data = parser.reads( + """ +nlu: +- intent: some_intent + examples: | + - an example +responses: + utter_something: + - text: hello world + """ + ) + structure = writer.training_data_to_dict(training_data) + + assert isinstance(structure, OrderedDict) + assert "nlu" in structure + assert "responses" in structure + + +def test_multiline_intent_example_is_skipped_when_no_leading_symbol(): + parser = RasaYAMLReader() + + with pytest.warns() as record: + training_data = parser.reads(MULTILINE_INTENT_EXAMPLES_NO_LEADING_SYMBOL) + + record = filter_expected_warnings(record) + + # warning for the missing leading symbol + assert len(record) == 1 + + assert len(training_data.training_examples) == 1 + assert not len(training_data.entity_synonyms) + + +@pytest.mark.parametrize( + "example, expected_num_entities", + [ + ( + "I need an [economy class](travel_flight_class:economy) ticket from " + '[boston]{"entity": "city", "role": "from"} to [new york]{"entity": "city",' + ' "role": "to"}, please.', + 3, + ), + ("i'm looking for a place to eat", 0), + ("i'm looking for a place in the [north](loc-direction) of town", 1), + ("show me [chines](cuisine:chinese) restaurants", 1), + ( + 'show me [italian]{"entity": "cuisine", "value": "22_ab-34*3.A:43er*+?df"} ' + "restaurants", + 1, + ), + ("Do you know {ABC} club?", 0), + ("show me [chines](22_ab-34*3.A:43er*+?df) restaurants", 1), + ( + 'I want to fly from [Berlin]{"entity": "city", "role": "to"} to [LA]{' + '"entity": "city", "role": "from", "value": "Los Angeles"}', + 2, + ), + ( + 'I want to fly from [Berlin](city) to [LA]{"entity": "city", "role": ' + '"from", "value": "Los Angeles"}', + 2, + ), + ], +) +def test_entity_is_extracted(example: Text, expected_num_entities: int): + reader = RasaYAMLReader() + + intent_name = "test-intent" + + yaml_string = f""" +nlu: +- intent: {intent_name} + examples: | + - {example} +""" + + result = reader.reads(yaml_string) + + assert len(result.training_examples) == 1 + actual_example = result.training_examples[0] + assert actual_example.data["intent"] == intent_name + assert len(actual_example.data.get("entities", [])) == expected_num_entities + + +def test_synonyms_are_parsed(): + parser = RasaYAMLReader() + training_data = parser.reads(SYNONYM_EXAMPLE) + + assert len(training_data.entity_synonyms) == 2 + assert training_data.entity_synonyms["pink pig"] == "savings" + assert training_data.entity_synonyms["savings account"] == "savings" + + +def test_synonyms_are_extracted_from_entities(): + parser = RasaYAMLReader() + training_data = parser.reads(MULTILINE_INTENT_EXAMPLE_WITH_SYNONYM) + + assert len(training_data.entity_synonyms) == 1 + + +def test_lookup_is_parsed(): + + parser = RasaYAMLReader() + training_data = parser.reads(LOOKUP_EXAMPLE) + + assert training_data.lookup_tables[0]["name"] == LOOKUP_ITEM_NAME + assert len(training_data.lookup_tables[0]["elements"]) == 3 + + +def test_regex_is_parsed(): + + parser = RasaYAMLReader() + training_data = parser.reads(REGEX_EXAMPLE) + + assert len(training_data.regex_features) == 2 + assert {"name": REGEX_NAME, "pattern": PATTERN_1} in training_data.regex_features + assert {"name": REGEX_NAME, "pattern": PATTERN_2} in training_data.regex_features + + +def test_minimal_valid_example(): + parser = RasaYAMLReader() + + with warnings.catch_warnings() as record: + parser.reads(MINIMAL_VALID_EXAMPLE) + + if record is not None: + record = filter_expected_warnings(record) + assert len(record) == 0 + + +def test_minimal_yaml_nlu_file(tmp_path: pathlib.Path): + target_file = tmp_path / "test_nlu_file.yaml" + rasa.shared.utils.io.write_text_file(MINIMAL_VALID_EXAMPLE, target_file) + assert RasaYAMLReader.is_yaml_nlu_file(target_file) + + +def test_nlg_reads_text(): + responses_yml = textwrap.dedent( + """ + responses: + utter_chitchat/ask_weather: + - text: Where do you want to check the weather? + """ + ) + + reader = RasaYAMLReader() + result = reader.reads(responses_yml) + + assert result.responses == { + "utter_chitchat/ask_weather": [ + {"text": "Where do you want to check the weather?"} + ] + } + + +def test_nlg_reads_any_multimedia(): + responses_yml = textwrap.dedent( + """ + responses: + utter_chitchat/ask_weather: + - text: Where do you want to check the weather? + image: https://example.com/weather.jpg + """ + ) + + reader = RasaYAMLReader() + result = reader.reads(responses_yml) + + assert result.responses == { + "utter_chitchat/ask_weather": [ + { + "text": "Where do you want to check the weather?", + "image": "https://example.com/weather.jpg", + } + ] + } + + +def test_nlg_fails_to_read_empty(): + responses_yml = textwrap.dedent( + """ + responses: + """ + ) + + reader = RasaYAMLReader() + + with pytest.raises(ValueError): + reader.reads(responses_yml) + + +def test_nlg_fails_on_empty_response(): + responses_yml = textwrap.dedent( + """ + responses: + utter_chitchat/ask_weather: + """ + ) + + reader = RasaYAMLReader() + + with pytest.raises(ValueError): + reader.reads(responses_yml) + + +def test_nlg_multimedia_load_dump_roundtrip(): + responses_yml = textwrap.dedent( + """ + responses: + utter_chitchat/ask_weather: + - text: Where do you want to check the weather? + image: https://example.com/weather.jpg + + utter_chitchat/ask_name: + - text: My name is Sara. + """ + ) + + reader = RasaYAMLReader() + result = reader.reads(responses_yml) + + dumped = RasaYAMLWriter().dumps(result) + + validation_reader = RasaYAMLReader() + dumped_result = validation_reader.reads(dumped) + + assert dumped_result.responses == result.responses + + # dumping again should also not change the format + assert dumped == RasaYAMLWriter().dumps(dumped_result) + + +def test_read_mixed_training_data_file(): + training_data_file = "data/test_mixed_yaml_training_data/training_data.yml" + + reader = RasaYAMLReader() + + with warnings.catch_warnings() as record: + reader.read(training_data_file) + + if record is not None: + record = filter_expected_warnings(record) + assert len(record) == 0 + + +def test_responses_text_multiline_is_preserved(): + responses_yml = textwrap.dedent( + """ + responses: + utter_confirm: + - text: |- + First line + Second line + Third line + - text: One more response + utter_cancel: + - text: First line + - text: Second line + """ + ) + + reader = RasaYAMLReader() + result = reader.reads(responses_yml) + + dumped = RasaYAMLWriter().dumps(result) + + validation_reader = RasaYAMLReader() + dumped_result = validation_reader.reads(dumped) + + assert dumped_result.responses == result.responses + + # dumping again should also not change the format + assert dumped == RasaYAMLWriter().dumps(dumped_result) + + +def test_intent_examples_multiline_consistency(tmp_path: pathlib.Path): + """Test that multiline examples are written back as multiline examples.""" + + training_data_file = ( + pathlib.Path("data") / "test_multiline_intent_examples_yaml" / "nlu.yml" + ) + training_data_from_disc = RasaYAMLReader().read(filename=training_data_file) + + tmp_file = tmp_path / "nlu.yml" + RasaYAMLWriter().dump(tmp_file, training_data_from_disc) + rewritten_file_content = tmp_file.read_text(encoding="utf-8") + original_file_content = training_data_file.read_text(encoding="utf-8") + + assert original_file_content == rewritten_file_content diff --git a/tests/shared/nlu/training_data/formats/test_readerwriter.py b/tests/shared/nlu/training_data/formats/test_readerwriter.py new file mode 100644 index 0000000..ab05ee4 --- /dev/null +++ b/tests/shared/nlu/training_data/formats/test_readerwriter.py @@ -0,0 +1,130 @@ +import pytest +from rasa.shared.nlu.training_data.formats.readerwriter import TrainingDataWriter +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.constants import INTENT_MESSAGE_PREFIX +from typing import Text, List, Dict, Any + + +@pytest.mark.parametrize( + "message_text, expected_text, entities", + [ + ( + "I like chocolate", + "I like chocolate", + [], + ), + ( + "I like chocolate", + "I like [chocolate](food)", + [ + {"entity": "food", "value": "chocolate", "start": 7, "end": 16}, + ], + ), + ( + "I like chocolate", + 'I like [chocolate]{"entity": "food", "value": "desert"}', + [ + {"entity": "food", "value": "desert", "start": 7, "end": 16}, + ], + ), + ( + f"{INTENT_MESSAGE_PREFIX}I like chocolate", + f"{INTENT_MESSAGE_PREFIX}I like chocolate", + [ + {"entity": "food", "value": "desert", "start": 7, "end": 16}, + ], + ), + ( + "I like chocolate", + 'I like [chocolate][{"entity": "food"}, {"entity": "desert"}]', + [ + {"entity": "food", "value": "chocolate", "start": 7, "end": 16}, + {"entity": "desert", "value": "chocolate", "start": 7, "end": 16}, + ], + ), + ( + "I like ice cream", + "I like [ice cream]" + '[{"entity": "food", "role": "liked"}, {"entity": "desert"}]', + [ + { + "entity": "food", + "value": "ice cream", + "start": 7, + "end": 16, + "role": "liked", + }, + {"entity": "desert", "value": "ice cream", "start": 7, "end": 16}, + ], + ), + ( + "I like ice cream with chocolate sprinkes", + "I like [ice cream]" + '[{"entity": "food", "role": "liked"}, {"entity": "desert"}] ' + "with chocolate sprinkes", + [ + { + "entity": "food", + "value": "ice cream", + "start": 7, + "end": 16, + "role": "liked", + }, + {"entity": "desert", "value": "ice cream", "start": 7, "end": 16}, + ], + ), + ( + "I like ice cream with chocolate sprinkes", + "I like [ice cream]" + '[{"entity": "food", "role": "liked"}, {"entity": "desert"}] ' + "with [chocolate sprinkes]" + "(food)", + [ + { + "entity": "food", + "value": "ice cream", + "start": 7, + "end": 16, + "role": "liked", + }, + {"entity": "desert", "value": "ice cream", "start": 7, "end": 16}, + { + "entity": "food", + "value": "chocolate sprinkes", + "start": 22, + "end": 40, + }, + ], + ), + ], +) +def test_generate_message( + message_text: Text, + expected_text: Text, + entities: List[Dict[Text, Any]], +): + message = Message.build(message_text, "dummy_intent", entities=entities) + message_text = TrainingDataWriter.generate_message(message) + + assert message_text == expected_text + + +@pytest.mark.parametrize( + "message_text, entities", + [ + ( + "I like chocolate cake", + [ + {"entity": "food", "value": "chocolate", "start": 7, "end": 16}, + {"entity": "desert", "value": "chocolate cake", "start": 7, "end": 21}, + ], + ), + ], +) +def test_generate_message_raises_on_overlapping_but_not_identical_spans( + message_text: Text, + entities: List[Dict[Text, Any]], +): + message = Message.build(message_text, "dummy_intent", entities=entities) + with pytest.raises(ValueError): + TrainingDataWriter.generate_message(message) diff --git a/tests/shared/nlu/training_data/test_entities_parser.py b/tests/shared/nlu/training_data/test_entities_parser.py new file mode 100644 index 0000000..cdfd6b7 --- /dev/null +++ b/tests/shared/nlu/training_data/test_entities_parser.py @@ -0,0 +1,178 @@ +from typing import Text, List, Dict, Any + +import pytest + +import rasa.shared.nlu.training_data.entities_parser as entities_parser +from rasa.shared.exceptions import InvalidEntityFormatException, SchemaValidationError +from rasa.shared.nlu.constants import TEXT + + +@pytest.mark.parametrize( + "example, expected_entities, expected_text", + [ + ( + "I need an [economy class](travel_flight_class:economy) ticket from " + '[boston]{"entity": "city", "role": "from"} to [new york]{"entity": "city",' + ' "role": "to"}, please.', + [ + { + "start": 10, + "end": 23, + "value": "economy", + "entity": "travel_flight_class", + }, + { + "start": 36, + "end": 42, + "value": "boston", + "entity": "city", + "role": "from", + }, + { + "start": 46, + "end": 54, + "value": "new york", + "entity": "city", + "role": "to", + }, + ], + "I need an economy class ticket from boston to new york, please.", + ), + ("i'm looking for a place to eat", [], "i'm looking for a place to eat"), + ( + "i'm looking for a place in the [north](loc-direction) of town", + [{"start": 31, "end": 36, "value": "north", "entity": "loc-direction"}], + "i'm looking for a place in the north of town", + ), + ( + "show me [chines](cuisine:chinese) restaurants", + [{"start": 8, "end": 14, "value": "chinese", "entity": "cuisine"}], + "show me chines restaurants", + ), + ( + 'show me [italian]{"entity": "cuisine", "value": "22_ab-34*3.A:43er*+?df"} ' + "restaurants", + [ + { + "start": 8, + "end": 15, + "value": "22_ab-34*3.A:43er*+?df", + "entity": "cuisine", + } + ], + "show me italian restaurants", + ), + ("Do you know {ABC} club?", [], "Do you know {ABC} club?"), + ( + "show me [chines](22_ab-34*3.A:43er*+?df) restaurants", + [{"start": 8, "end": 14, "value": "43er*+?df", "entity": "22_ab-34*3.A"}], + "show me chines restaurants", + ), + ( + 'I want to fly from [Berlin]{"entity": "city", "role": "to"} to [LA]{' + '"entity": "city", "role": "from", "value": "Los Angeles"}', + [ + { + "start": 19, + "end": 25, + "value": "Berlin", + "entity": "city", + "role": "to", + }, + { + "start": 29, + "end": 31, + "value": "Los Angeles", + "entity": "city", + "role": "from", + }, + ], + "I want to fly from Berlin to LA", + ), + ( + 'I want to fly from [Berlin](city) to [LA]{"entity": "city", "role": ' + '"from", "value": "Los Angeles"}', + [ + {"start": 19, "end": 25, "value": "Berlin", "entity": "city"}, + { + "start": 29, + "end": 31, + "value": "Los Angeles", + "entity": "city", + "role": "from", + }, + ], + "I want to fly from Berlin to LA", + ), + ( + 'I want to fly from [Berlin](city) to [LA][{"entity": "city", "role": ' + '"from", "value": "Los Angeles"}, {"entity": "location", "value": ' + '"Los Angeles"}]', + [ + {"start": 19, "end": 25, "value": "Berlin", "entity": "city"}, + { + "start": 29, + "end": 31, + "value": "Los Angeles", + "entity": "city", + "role": "from", + }, + { + "start": 29, + "end": 31, + "value": "Los Angeles", + "entity": "location", + }, + ], + "I want to fly from Berlin to LA", + ), + ], +) +def test_markdown_entity_regex( + example: Text, expected_entities: List[Dict[Text, Any]], expected_text: Text +): + + result = entities_parser.find_entities_in_training_example(example) + assert result == expected_entities + + replaced_text = entities_parser.replace_entities(example) + assert replaced_text == expected_text + + +def test_parse_training_example(): + message = entities_parser.parse_training_example("Hello!", intent="greet") + assert message.get("intent") == "greet" + assert message.get(TEXT) == "Hello!" + + +def test_parse_empty_example(): + message = entities_parser.parse_training_example("") + assert message.get("intent") is None + assert message.get(TEXT) == "" + + +def test_parse_training_example_with_entities(): + message = entities_parser.parse_training_example( + "I am from [Berlin](city).", intent="inform" + ) + assert message.get("intent") == "inform" + assert message.get(TEXT) == "I am from Berlin." + assert message.get("entities") == [ + {"start": 10, "end": 16, "value": "Berlin", "entity": "city"} + ] + + +def test_markdown_entity_regex_error_handling_not_json(): + with pytest.raises(InvalidEntityFormatException): + entities_parser.find_entities_in_training_example( + # JSON syntax error: missing closing " for `role` + 'I want to fly from [Berlin]{"entity": "city", "role: "from"}' + ) + + +def test_markdown_entity_regex_error_handling_wrong_schema(): + with pytest.raises(SchemaValidationError): + entities_parser.find_entities_in_training_example( + # Schema error: "entiti" instead of "entity" + 'I want to fly from [Berlin]{"entiti": "city", "role": "from"}' + ) diff --git a/tests/shared/nlu/training_data/test_features.py b/tests/shared/nlu/training_data/test_features.py new file mode 100644 index 0000000..457e964 --- /dev/null +++ b/tests/shared/nlu/training_data/test_features.py @@ -0,0 +1,684 @@ +import itertools +import os +import tempfile +from pathlib import Path +from typing import Optional, Text, List, Dict, Tuple, Any + +import numpy as np +import pytest +import scipy.sparse + +from rasa.shared.nlu.constants import ( + FEATURE_TYPE_SENTENCE, + FEATURE_TYPE_SEQUENCE, + TEXT, + INTENT, +) +from rasa.shared.nlu.training_data.features import ( + Features, + FeatureMetadata, + save_features, + load_features, +) + + +@pytest.fixture +def safe_tensors_tmp_file() -> str: + with tempfile.NamedTemporaryFile(delete=False, suffix=".safetensors") as f: + yield f.name + os.unlink(f.name) + + +@pytest.fixture +def dense_features() -> Features: + features_matrix = np.array([[1, 2, 3], [4, 5, 6]]) + return Features( + features=features_matrix, + feature_type="dense", + attribute="test", + origin="test_origin", + ) + + +@pytest.fixture +def sparse_features() -> Features: + features_matrix = scipy.sparse.csr_matrix( + ([1, 2, 3], ([0, 1, 1], [0, 1, 2])), shape=(2, 3) + ) + return Features( + features=features_matrix, + feature_type="sparse", + attribute="test", + origin="test_origin", + ) + + +@pytest.mark.parametrize( + "type,is_sparse,", + itertools.product([FEATURE_TYPE_SENTENCE, FEATURE_TYPE_SEQUENCE], [True, False]), +) +def test_print(type: Text, is_sparse: bool): + first_dim = 1 if type == FEATURE_TYPE_SEQUENCE else 3 + matrix = np.full(shape=(first_dim, 2), fill_value=1) + if is_sparse: + matrix = scipy.sparse.coo_matrix(matrix) + feat = Features( + features=matrix, + attribute="fixed-attribute", + feature_type=type, + origin="origin--doesn't-matter-here", + ) + assert repr(feat) + assert str(feat) + + +def test_combine_with_existing_dense_features(): + existing_features = Features( + np.array([[1, 0, 2, 3], [2, 0, 0, 1]]), FEATURE_TYPE_SEQUENCE, TEXT, "test" + ) + fingerprint = existing_features.fingerprint() + new_features = Features( + np.array([[1, 0], [0, 1]]), FEATURE_TYPE_SEQUENCE, TEXT, "origin" + ) + expected_features = np.array([[1, 0, 2, 3, 1, 0], [2, 0, 0, 1, 0, 1]]) + + existing_features.combine_with_features(new_features) + + assert np.all(expected_features == existing_features.features) + # check that combining features changes fingerprint + assert fingerprint != existing_features.fingerprint() + + +def test_combine_with_existing_dense_features_shape_mismatch(): + existing_features = Features( + np.array([[1, 0, 2, 3], [2, 0, 0, 1]]), FEATURE_TYPE_SEQUENCE, TEXT, "test" + ) + new_features = Features(np.array([[0, 1]]), FEATURE_TYPE_SEQUENCE, TEXT, "origin") + + with pytest.raises(ValueError): + existing_features.combine_with_features(new_features) + + +def test_combine_with_existing_sparse_features(): + existing_features = Features( + scipy.sparse.csr_matrix([[1, 0, 2, 3], [2, 0, 0, 1]]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "test", + ) + fingerprint = existing_features.fingerprint() + new_features = Features( + scipy.sparse.csr_matrix([[1, 0], [0, 1]]), FEATURE_TYPE_SEQUENCE, TEXT, "origin" + ) + expected_features = [[1, 0, 2, 3, 1, 0], [2, 0, 0, 1, 0, 1]] + + existing_features.combine_with_features(new_features) + actual_features = existing_features.features.toarray() + + assert np.all(expected_features == actual_features) + # check that combining features changes fingerprint + assert fingerprint != existing_features.fingerprint() + + +def test_combine_with_existing_sparse_features_shape_mismatch(): + existing_features = Features( + scipy.sparse.csr_matrix([[1, 0, 2, 3], [2, 0, 0, 1]]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "test", + ) + new_features = Features( + scipy.sparse.csr_matrix([[0, 1]]), FEATURE_TYPE_SEQUENCE, TEXT, "origin" + ) + + with pytest.raises(ValueError): + existing_features.combine_with_features(new_features) + + +def test_for_features_fingerprinting_collisions(): + """Tests that features fingerprints are unique.""" + m1 = np.asarray([[0.5, 3.1, 3.0], [1.1, 1.2, 1.3], [4.7, 0.3, 2.7]]) + + m2 = np.asarray([[0, 0, 0], [1, 2, 3], [0, 0, 1]]) + + dense_features = [ + Features(m1, FEATURE_TYPE_SENTENCE, TEXT, "CountVectorsFeaturizer"), + Features(m2, FEATURE_TYPE_SENTENCE, TEXT, "CountVectorsFeaturizer"), + Features(m1, FEATURE_TYPE_SEQUENCE, TEXT, "CountVectorsFeaturizer"), + Features(m1, FEATURE_TYPE_SEQUENCE, TEXT, "RegexFeaturizer"), + Features(m1, FEATURE_TYPE_SENTENCE, INTENT, "CountVectorsFeaturizer"), + ] + dense_fingerprints = {f.fingerprint() for f in dense_features} + assert len(dense_fingerprints) == len(dense_features) + + sparse_features = [ + Features( + scipy.sparse.coo_matrix(m1), + FEATURE_TYPE_SENTENCE, + TEXT, + "CountVectorsFeaturizer", + ), + Features( + scipy.sparse.coo_matrix(m2), + FEATURE_TYPE_SENTENCE, + TEXT, + "CountVectorsFeaturizer", + ), + Features( + scipy.sparse.coo_matrix(m1), + FEATURE_TYPE_SEQUENCE, + TEXT, + "CountVectorsFeaturizer", + ), + Features( + scipy.sparse.coo_matrix(m1), FEATURE_TYPE_SEQUENCE, TEXT, "RegexFeaturizer" + ), + Features( + scipy.sparse.coo_matrix(m1), + FEATURE_TYPE_SENTENCE, + INTENT, + "CountVectorsFeaturizer", + ), + ] + sparse_fingerprints = {f.fingerprint() for f in sparse_features} + assert len(sparse_fingerprints) == len(sparse_features) + + +def test_feature_fingerprints_take_into_account_full_array(): + """Tests that fingerprint isn't using summary/abbreviated array info.""" + big_array = np.random.random((128, 128)) + + f1 = Features(big_array, FEATURE_TYPE_SENTENCE, TEXT, "RegexFeaturizer") + big_array_with_zero = np.copy(big_array) + big_array_with_zero[64, 64] = 0.0 + f2 = Features(big_array_with_zero, FEATURE_TYPE_SENTENCE, TEXT, "RegexFeaturizer") + + assert f1.fingerprint() != f2.fingerprint() + + f1_sparse = Features( + scipy.sparse.coo_matrix(big_array), + FEATURE_TYPE_SENTENCE, + TEXT, + "RegexFeaturizer", + ) + + f2_sparse = Features( + scipy.sparse.coo_matrix(big_array_with_zero), + FEATURE_TYPE_SENTENCE, + TEXT, + "RegexFeaturizer", + ) + + assert f1_sparse.fingerprint() != f2_sparse.fingerprint() + + +def _generate_feature_list_and_modifications( + is_sparse: bool, type: Text, number: int +) -> Tuple[List[Features], List[Dict[Text, Any]]]: + """Creates a list of features with the required properties and some modifications. + The modifications are given by a list of kwargs dictionaries that can be used to + instantiate `Features` that differ from the aforementioned list of features in + exactly one property (i.e. type, sequence length (if the given `type` is + sequence type only), attribute, origin) + + Args: + is_sparse: whether all features should be sparse + type: the type to be used for all features + number: the number of features to generate + Returns: + a tuple containing a list of features with the requested attributes and + a list of kwargs dictionaries that can be used to instantiate `Features` that + differ from the aforementioned list of features in exactly one property + """ + seq_len = 3 + first_dim = 1 if type == FEATURE_TYPE_SENTENCE else 3 + + # create list of features whose properties match - except the shapes and + # feature values which are chosen in a specific way + features_list = [] + for idx in range(number): + matrix = np.full(shape=(first_dim, idx + 1), fill_value=idx + 1) + if is_sparse: + matrix = scipy.sparse.coo_matrix(matrix) + config = dict( + features=matrix, + attribute="fixed-attribute", + feature_type=type, + origin=f"origin-{idx}", + ) + feat = Features(**config) + features_list.append(feat) + + # prepare some Features that differ from the features above in certain ways + modifications = [] + # - if we modify one attribute + modifications.append({**config, **{"attribute": "OTHER"}}) + # - if we modify one attribute + other_type = ( + FEATURE_TYPE_SENTENCE + if type == FEATURE_TYPE_SEQUENCE + else FEATURE_TYPE_SEQUENCE + ) + other_seq_len = 1 if other_type == FEATURE_TYPE_SENTENCE else seq_len + other_matrix = np.full(shape=(other_seq_len, number - 1), fill_value=number) + if is_sparse: + other_matrix = scipy.sparse.coo_matrix(other_matrix) + modifications.append( + {**config, **{"feature_type": other_type, "features": other_matrix}} + ) + # - if we modify one origin + modifications.append({**config, **{"origin": "Other"}}) + # - if we modify one sequence length + if type == FEATURE_TYPE_SEQUENCE: + matrix = np.full(shape=(seq_len + 1, idx + 1), fill_value=idx) + if is_sparse: + matrix = scipy.sparse.coo_matrix(matrix) + modifications.append({**config, **{"features": matrix}}) + + return features_list, modifications + + +@pytest.mark.parametrize( + "is_sparse,type,number,use_expected_origin", + itertools.product( + [True, False], + [FEATURE_TYPE_SENTENCE, FEATURE_TYPE_SEQUENCE], + [1, 2, 5], + [True, False], + ), +) +def test_combine(is_sparse: bool, type: Text, number: int, use_expected_origin: bool): + + features_list, modifications = _generate_feature_list_and_modifications( + is_sparse=is_sparse, type=type, number=number + ) + modified_features = [Features(**config) for config in modifications] + first_dim = features_list[0].features.shape[0] + + origins = [f"origin-{idx}" for idx in range(len(features_list))] + if number == 1: + # in this case the origin will be same str as before, not a list + origins = origins[0] + expected_origin = origins if use_expected_origin else None + + # works as expected + combination = Features.combine(features_list, expected_origins=expected_origin) + assert combination.features.shape[1] == int(number * (number + 1) / 2) + assert combination.features.shape[0] == first_dim + assert combination.origin == origins + assert combination.is_sparse() == is_sparse + matrix = combination.features + if is_sparse: + matrix = combination.features.todense() + for idx in range(number): + offset = int(idx * (idx + 1) / 2) + assert np.all(matrix[:, offset : (offset + idx + 1)] == idx + 1) + + # fails as expected in these cases + if use_expected_origin and number > 1: + for modified_feature in modified_features: + features_list_copy = features_list.copy() + features_list_copy[-1] = modified_feature + with pytest.raises(ValueError): + Features.combine(features_list_copy, expected_origins=expected_origin) + + +@pytest.mark.parametrize( + "is_sparse,type,number", + itertools.product( + [True, False], [FEATURE_TYPE_SENTENCE, FEATURE_TYPE_SEQUENCE], [1, 2, 5] + ), +) +def test_filter(is_sparse: bool, type: Text, number: int): + + features_list, modifications = _generate_feature_list_and_modifications( + is_sparse=is_sparse, type=type, number=number + ) + + # fix the filter configuration first (note: we ignore origin on purpose for now) + filter_config = dict(attributes=["fixed-attribute"], type=type, is_sparse=is_sparse) + + # we get all features back if all features map... + result = Features.filter(features_list, **filter_config) + assert len(result) == number + + # ... and less matches if we change the (relevant) properties of some features + modified_features = [ + Features(**config) + for config in modifications + if set(config.keys()).intersection(filter_config.keys()) + ] + if number > 1: + for modified_feature in modified_features: + features_list_copy = features_list.copy() + features_list_copy[-1] = modified_feature + result = Features.filter(features_list_copy, **filter_config) + assert len(result) == number - 1 + if number > 2: + for feat_a, feat_b in itertools.combinations(modified_features, 2): + features_list_copy = features_list.copy() + features_list_copy[-1] = feat_a + features_list_copy[-2] = feat_b + result = Features.filter(features_list_copy, **filter_config) + assert len(result) == number - 2 + + # don't forget to check the origin + filter_config = dict( + attributes=["fixed-attribute"], + type=type, + origin=["origin-0"], + is_sparse=is_sparse, + ) + result = Features.filter(features_list, **filter_config) + assert len(result) == 1 + + +@pytest.mark.parametrize( + "num_features_per_attribute,specified_attributes", + itertools.product( + [{"a": 3, "b": 1, "c": 0}], + [None, ["a", "b", "c", "doesnt-appear"], ["doesnt-appear"]], + ), +) +def test_groupby( + num_features_per_attribute: Dict[Text, int], + specified_attributes: Optional[List[Text]], +): + + features_list = [] + for attribute, number in num_features_per_attribute.items(): + for idx in range(number): + matrix = np.full(shape=(1, idx + 1), fill_value=idx + 1) + config = dict( + features=matrix, + attribute=attribute, + feature_type=FEATURE_TYPE_SEQUENCE, # doesn't matter + origin=f"origin-{idx}", # doens't matter + ) + feat = Features(**config) + features_list.append(feat) + + result = Features.groupby_attribute(features_list, attributes=specified_attributes) + if specified_attributes is None: + for attribute, number in num_features_per_attribute.items(): + if number > 0: + assert attribute in result + assert len(result[attribute]) == number + else: + assert attribute not in result + else: + assert set(result.keys()) == set(specified_attributes) + for attribute in specified_attributes: + assert attribute in result + number = num_features_per_attribute.get(attribute, 0) + assert len(result[attribute]) == number + + +@pytest.mark.parametrize( + "shuffle_mode,num_features_per_combination", + itertools.product( + ["reversed", "random"], [[1, 0, 0, 0], [1, 1, 1, 1], [2, 3, 4, 5], [0, 1, 2, 2]] + ), +) +def test_reduce( + shuffle_mode: Text, num_features_per_combination: Tuple[int, int, int, int] +): + + # all combinations - in the expected order + # (i.e. all sparse before all dense and sequence before sentence) + all_combinations = [ + (FEATURE_TYPE_SEQUENCE, True), + (FEATURE_TYPE_SENTENCE, True), + (FEATURE_TYPE_SEQUENCE, False), + (FEATURE_TYPE_SENTENCE, False), + ] + + # multiply accordingly and mess up the order + chosen_combinations = [ + spec + for spec, num in zip(all_combinations, num_features_per_combination) + for _ in range(num) + ] + if shuffle_mode == "reversed": + messed_up_order = reversed(chosen_combinations) + else: + # Note: rng.permutation would mess up the types + rng = np.random.default_rng(23452345) + permutation = rng.permutation(len(chosen_combinations)) + messed_up_order = [chosen_combinations[idx] for idx in permutation] + + # create features accordingly + features_list = [] + for idx, (type, is_sparse) in enumerate(messed_up_order): + first_dim = 1 if type == FEATURE_TYPE_SEQUENCE else 3 + matrix = np.full(shape=(first_dim, 1), fill_value=1) + if is_sparse: + matrix = scipy.sparse.coo_matrix(matrix) + config = dict( + features=matrix, + attribute="fixed-attribute", # must be the same + feature_type=type, + origin="origin-does-matter-here", # must be the same + ) + feat = Features(**config) + features_list.append(feat) + + # reduce! + reduced_list = Features.reduce(features_list) + assert len(reduced_list) == sum(num > 0 for num in num_features_per_combination) + idx = 0 + for num, (type, is_sparse) in zip(num_features_per_combination, all_combinations): + if num == 0: + # nothing to check here - because we already checked the length above + # and check the types and shape of all existing features in this loop + pass + else: + feature = reduced_list[idx] + assert feature.is_sparse() == is_sparse + assert feature.type == type + assert feature.features.shape[-1] == num + idx += 1 + + +@pytest.mark.parametrize("differ", ["attribute", "origin"]) +def test_reduce_raises_if_combining_different_origins_or_attributes(differ: Text): + # create features accordingly + arbitrary_fixed_type = FEATURE_TYPE_SENTENCE + features_list = [] + for idx in range(2): + first_dim = 1 + arbitrary_matrix_matching_type = np.full(shape=(first_dim, 1), fill_value=1) + config = dict( + features=arbitrary_matrix_matching_type, + attribute="fixed-attribute" if differ != "attribute" else f"attr-{idx}", + feature_type=arbitrary_fixed_type, + origin="fixed-origin" if differ != "origin" else f"origin-{idx}", + ) + feat = Features(**config) + features_list.append(feat) + + # reduce! + if differ == "attribute": + message = "Expected all Features to describe the same attribute" + expected_origin = ["origin"] + else: + message = "Expected 'origin-1' to be the origin of the 0-th" + expected_origin = ["origin-1"] + with pytest.raises(ValueError, match=message): + Features.reduce(features_list, expected_origins=expected_origin) + + +def test_feature_metadata(): + metadata = FeatureMetadata( + data_type="dense", + attribute="text", + origin="test", + is_sparse=False, + shape=(10, 5), + safetensors_key="key_0", + ) + + assert metadata.data_type == "dense" + assert metadata.attribute == "text" + assert metadata.origin == "test" + assert not metadata.is_sparse + assert metadata.shape == (10, 5) + assert metadata.safetensors_key == "key_0" + + +def test_save_dense_features(safe_tensors_tmp_file: str, dense_features: Features): + features_dict = {"test_key": [dense_features]} + metadata = save_features(features_dict, safe_tensors_tmp_file) + + assert "test_key" in metadata + assert len(metadata["test_key"]) == 1 + assert metadata["test_key"][0]["data_type"] == "dense" + assert metadata["test_key"][0]["shape"] == (2, 3) + assert not metadata["test_key"][0]["is_sparse"] + assert Path(safe_tensors_tmp_file).exists() + + +def test_save_sparse_features(safe_tensors_tmp_file: str, sparse_features: Features): + features_dict = {"test_key": [sparse_features]} + metadata = save_features(features_dict, safe_tensors_tmp_file) + + assert "test_key" in metadata + assert len(metadata["test_key"]) == 1 + assert metadata["test_key"][0]["data_type"] == "sparse" + assert metadata["test_key"][0]["shape"] == (2, 3) + assert metadata["test_key"][0]["is_sparse"] + assert Path(safe_tensors_tmp_file).exists() + + +def test_save_mixed_features( + safe_tensors_tmp_file: str, dense_features: Features, sparse_features: Features +): + features_dict = {"test_key": [dense_features, sparse_features]} + metadata = save_features(features_dict, safe_tensors_tmp_file) + + assert "test_key" in metadata + assert len(metadata["test_key"]) == 2 + assert metadata["test_key"][0]["data_type"] == "dense" + assert metadata["test_key"][1]["data_type"] == "sparse" + assert Path(safe_tensors_tmp_file).exists() + + +def test_save_multiple_keys( + safe_tensors_tmp_file: str, dense_features: Features, sparse_features: Features +): + features_dict = {"dense_key": [dense_features], "sparse_key": [sparse_features]} + metadata = save_features(features_dict, safe_tensors_tmp_file) + + assert "dense_key" in metadata + assert "sparse_key" in metadata + assert metadata["dense_key"][0]["data_type"] == "dense" + assert metadata["sparse_key"][0]["data_type"] == "sparse" + assert Path(safe_tensors_tmp_file).exists() + + +@pytest.fixture +def setup_save_load( + safe_tensors_tmp_file: str, dense_features: Features, sparse_features: Features +) -> Tuple[str, Dict[str, Any], Dict[str, List[Features]]]: + features_dict = {"dense_key": [dense_features], "sparse_key": [sparse_features]} + metadata = save_features(features_dict, safe_tensors_tmp_file) + return safe_tensors_tmp_file, metadata, features_dict + + +def test_load_dense_features( + setup_save_load: Tuple[str, Dict[str, Any], Dict[str, List[Features]]], +): + temp_file, metadata, original_dict = setup_save_load + loaded_dict = load_features(temp_file, metadata) + + assert "dense_key" in loaded_dict + assert len(loaded_dict["dense_key"]) == 1 + assert not loaded_dict["dense_key"][0].is_sparse() + np.testing.assert_array_equal( + loaded_dict["dense_key"][0].features, original_dict["dense_key"][0].features + ) + + +def test_load_sparse_features( + setup_save_load: Tuple[str, Dict[str, Any], Dict[str, List[Features]]], +): + temp_file, metadata, original_dict = setup_save_load + loaded_dict = load_features(temp_file, metadata) + + assert "sparse_key" in loaded_dict + assert len(loaded_dict["sparse_key"]) == 1 + assert loaded_dict["sparse_key"][0].is_sparse() + assert ( + loaded_dict["sparse_key"][0].features != original_dict["sparse_key"][0].features + ).nnz == 0 + + +def test_load_preserves_metadata( + setup_save_load: Tuple[str, Dict[str, Any], Dict[str, List[Features]]], +): + temp_file, metadata, original_dict = setup_save_load + loaded_dict = load_features(temp_file, metadata) + + for key in original_dict: + for orig_feat, loaded_feat in zip(original_dict[key], loaded_dict[key]): + assert orig_feat.type == loaded_feat.type + assert orig_feat.attribute == loaded_feat.attribute + assert orig_feat.origin == loaded_feat.origin + + +def test_load_nonexistent_file(): + with pytest.raises(Exception): + load_features("nonexistent.safetensors", {}) + + +def test_load_invalid_metadata(safe_tensors_tmp_file: str, dense_features: Features): + features_dict = {"test_key": [dense_features]} + metadata = save_features(features_dict, safe_tensors_tmp_file) + + # Corrupt the metadata + metadata["test_key"][0]["safetensors_key"] = "invalid_key" + + with pytest.raises(Exception): + load_features(safe_tensors_tmp_file, metadata) + + +def test_end_to_end(safe_tensors_tmp_file: str): + # Create test data + dense_matrix = np.array([[1, 2], [3, 4]]) + sparse_matrix = scipy.sparse.csr_matrix(([1, 2], ([0, 1], [0, 1])), shape=(2, 2)) + + features_dict = { + "group1": [ + Features(dense_matrix, "dense", "test1", "origin1"), + Features(sparse_matrix, "sparse", "test2", "origin2"), + ], + "group2": [ + Features(dense_matrix * 2, "dense", "test3", ["origin3", "origin4"]) + ], + } + + # Save features + metadata = save_features(features_dict, safe_tensors_tmp_file) + + # Load features + loaded_dict = load_features(safe_tensors_tmp_file, metadata) + + # Verify structure + assert set(loaded_dict.keys()) == set(features_dict.keys()) + assert len(loaded_dict["group1"]) == 2 + assert len(loaded_dict["group2"]) == 1 + + # Verify dense features + np.testing.assert_array_equal( + loaded_dict["group1"][0].features, features_dict["group1"][0].features + ) + + # Verify sparse features + assert ( + loaded_dict["group1"][1].features != features_dict["group1"][1].features + ).nnz == 0 + + # Verify metadata + assert loaded_dict["group1"][0].type == "dense" + assert loaded_dict["group1"][1].type == "sparse" + assert loaded_dict["group2"][0].origin == ["origin3", "origin4"] diff --git a/tests/shared/nlu/training_data/test_lookup_tables_parser.py b/tests/shared/nlu/training_data/test_lookup_tables_parser.py new file mode 100644 index 0000000..5d8cb8d --- /dev/null +++ b/tests/shared/nlu/training_data/test_lookup_tables_parser.py @@ -0,0 +1,28 @@ +import pytest + +import rasa.shared.nlu.training_data.lookup_tables_parser as lookup_tables_parser + + +def test_add_item_to_lookup_tables(): + lookup_item_title = "additional_currencies" + lookup_examples = ["Peso", "Euro", "Dollar"] + + lookup_tables = [] + + for example in lookup_examples: + lookup_tables_parser.add_item_to_lookup_tables( + lookup_item_title, example, lookup_tables + ) + + assert lookup_tables == [{"name": lookup_item_title, "elements": lookup_examples}] + + +def test_add_item_to_lookup_tables_unloaded_file(): + lookup_item_title = "additional_currencies" + + lookup_tables = [{"name": lookup_item_title, "elements": "lookup.txt"}] + + with pytest.raises(TypeError): + lookup_tables_parser.add_item_to_lookup_tables( + lookup_item_title, "Pound", lookup_tables + ) diff --git a/tests/shared/nlu/training_data/test_message.py b/tests/shared/nlu/training_data/test_message.py new file mode 100644 index 0000000..ba85ee5 --- /dev/null +++ b/tests/shared/nlu/training_data/test_message.py @@ -0,0 +1,426 @@ +from typing import Optional, Text, List + +import pytest +import numpy as np +import scipy.sparse + +from rasa.shared.nlu.training_data.features import Features +from rasa.shared.nlu.constants import ( + TEXT, + FEATURE_TYPE_SENTENCE, + FEATURE_TYPE_SEQUENCE, + ACTION_TEXT, + ACTION_NAME, + INTENT, + RESPONSE, +) +from rasa.shared.nlu.training_data.message import Message +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer + + +@pytest.mark.parametrize( + "features, attribute, featurizers, expected_seq_features, expected_sen_features", + [ + (None, TEXT, [], None, None), + ( + [Features(np.array([1, 1, 0]), FEATURE_TYPE_SEQUENCE, TEXT, "test")], + TEXT, + [], + [1, 1, 0], + None, + ), + ( + [ + Features(np.array([1, 1, 0]), FEATURE_TYPE_SEQUENCE, TEXT, "c2"), + Features(np.array([1, 2, 2]), FEATURE_TYPE_SENTENCE, TEXT, "c1"), + Features(np.array([1, 2, 1]), FEATURE_TYPE_SEQUENCE, TEXT, "c1"), + ], + TEXT, + [], + [1, 1, 0, 1, 2, 1], + [1, 2, 2], + ), + ( + [ + Features(np.array([1, 1, 0]), FEATURE_TYPE_SEQUENCE, TEXT, "c1"), + Features(np.array([1, 2, 1]), FEATURE_TYPE_SENTENCE, TEXT, "test"), + Features(np.array([1, 1, 1]), FEATURE_TYPE_SEQUENCE, TEXT, "test"), + ], + TEXT, + ["c1"], + [1, 1, 0], + None, + ), + ], +) +def test_get_dense_features( + features: Optional[List[Features]], + attribute: Text, + featurizers: List[Text], + expected_seq_features: Optional[List[Features]], + expected_sen_features: Optional[List[Features]], +): + + message = Message(data={TEXT: "This is a test sentence."}, features=features) + + actual_seq_features, actual_sen_features = message.get_dense_features( + attribute, featurizers + ) + if actual_seq_features: + actual_seq_features = actual_seq_features.features + if actual_sen_features: + actual_sen_features = actual_sen_features.features + + assert np.all(actual_sen_features == expected_sen_features) + assert np.all(actual_seq_features == expected_seq_features) + + +@pytest.mark.parametrize( + "features, attribute, featurizers, expected_seq_features, expected_sen_features", + [ + (None, TEXT, [], None, None), + ( + [ + Features( + scipy.sparse.csr_matrix([1, 1, 0]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "test", + ) + ], + TEXT, + [], + [1, 1, 0], + None, + ), + ( + [ + Features( + scipy.sparse.csr_matrix([1, 1, 0]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "c2", + ), + Features( + scipy.sparse.csr_matrix([1, 2, 2]), + FEATURE_TYPE_SENTENCE, + TEXT, + "c1", + ), + Features( + scipy.sparse.csr_matrix([1, 2, 1]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "c1", + ), + ], + TEXT, + [], + [1, 1, 0, 1, 2, 1], + [1, 2, 2], + ), + ( + [ + Features( + scipy.sparse.csr_matrix([1, 1, 0]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "c1", + ), + Features( + scipy.sparse.csr_matrix([1, 2, 1]), + FEATURE_TYPE_SENTENCE, + TEXT, + "test", + ), + Features( + scipy.sparse.csr_matrix([1, 1, 1]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "test", + ), + ], + TEXT, + ["c1"], + [1, 1, 0], + None, + ), + ], +) +def test_get_sparse_features( + features: Optional[List[Features]], + attribute: Text, + featurizers: List[Text], + expected_seq_features: Optional[List[Features]], + expected_sen_features: Optional[List[Features]], +): + message = Message(data={TEXT: "This is a test sentence."}, features=features) + + actual_seq_features, actual_sen_features = message.get_sparse_features( + attribute, featurizers + ) + if actual_seq_features: + actual_seq_features = actual_seq_features.features + if actual_sen_features: + actual_sen_features = actual_sen_features.features + + if expected_seq_features is None: + assert actual_seq_features is None + else: + assert actual_seq_features is not None + assert np.all(actual_seq_features.toarray() == expected_seq_features) + + if expected_sen_features is None: + assert actual_sen_features is None + else: + assert actual_sen_features is not None + assert np.all(actual_sen_features.toarray() == expected_sen_features) + + +@pytest.mark.parametrize( + "features, attribute, featurizers, " + "expected_sequence_sizes, expected_sentence_sizes", + [ + (None, TEXT, [], [], []), + ( + [ + Features( + scipy.sparse.csr_matrix([1, 1, 0]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "test", + ) + ], + TEXT, + [], + [3], + [], + ), + ( + [ + Features( + scipy.sparse.csr_matrix([1, 1, 0]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "c2", + ), + Features( + scipy.sparse.csr_matrix([1, 2, 2]), + FEATURE_TYPE_SENTENCE, + TEXT, + "c1", + ), + Features( + scipy.sparse.csr_matrix([1, 2, 1]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "c1", + ), + ], + TEXT, + [], + [3, 3], + [3], + ), + ( + [ + Features( + scipy.sparse.csr_matrix([1, 1, 0]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "c1", + ), + Features( + scipy.sparse.csr_matrix([1, 2, 1]), + FEATURE_TYPE_SENTENCE, + TEXT, + "test", + ), + Features( + scipy.sparse.csr_matrix([1, 1, 1]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "test", + ), + ], + TEXT, + ["c1"], + [3], + [], + ), + ( + [ + Features( + scipy.sparse.csr_matrix([1, 1, 0, 0]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "c1", + ), + Features( + scipy.sparse.csr_matrix([1, 2, 1]), + FEATURE_TYPE_SENTENCE, + TEXT, + "test", + ), + Features(np.array([1, 1, 0]), FEATURE_TYPE_SEQUENCE, TEXT, "c1"), + Features(np.array([1, 2, 1]), FEATURE_TYPE_SENTENCE, TEXT, "test"), + ], + TEXT, + ["c1"], + [4], + [], + ), + ], +) +def test_get_sparse_feature_sizes( + features: Optional[List[Features]], + attribute: Text, + featurizers: List[Text], + expected_sequence_sizes: List[int], + expected_sentence_sizes: List[int], +): + message = Message(data={TEXT: "This is a test sentence."}, features=features) + feature_sizes = message.get_sparse_feature_sizes(attribute, featurizers) + + assert feature_sizes[FEATURE_TYPE_SEQUENCE] == expected_sequence_sizes + assert feature_sizes[FEATURE_TYPE_SENTENCE] == expected_sentence_sizes + + +@pytest.mark.parametrize( + "features, attribute, featurizers, expected", + [ + (None, TEXT, [], False), + ( + [ + Features( + scipy.sparse.csr_matrix([1, 1, 0]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "test", + ) + ], + TEXT, + [], + True, + ), + ( + [ + Features( + scipy.sparse.csr_matrix([1, 1, 0]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "c2", + ), + Features(np.ndarray([1, 2, 2]), FEATURE_TYPE_SEQUENCE, TEXT, "c1"), + ], + TEXT, + [], + True, + ), + ( + [ + Features( + scipy.sparse.csr_matrix([1, 1, 0]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "c2", + ), + Features(np.ndarray([1, 2, 2]), FEATURE_TYPE_SEQUENCE, TEXT, "c1"), + ], + TEXT, + ["c1"], + True, + ), + ( + [ + Features( + scipy.sparse.csr_matrix([1, 1, 0]), + FEATURE_TYPE_SEQUENCE, + TEXT, + "c2", + ), + Features(np.ndarray([1, 2, 2]), FEATURE_TYPE_SEQUENCE, TEXT, "c1"), + ], + TEXT, + ["other"], + False, + ), + ], +) +def test_features_present( + features: Optional[List[Features]], + attribute: Text, + featurizers: List[Text], + expected: bool, +): + message = Message(data={TEXT: "This is a test sentence."}, features=features) + + actual = message.features_present(attribute, featurizers) + + assert actual == expected + + +@pytest.mark.parametrize( + "message, result", + [ + (Message({INTENT: "intent", TEXT: "text"}), False), + (Message({RESPONSE: "response", TEXT: "text"}), False), + (Message({INTENT: "intent"}), True), + (Message({ACTION_TEXT: "action text"}), True), + (Message({ACTION_NAME: "action name"}), True), + (Message({TEXT: "text"}), True), + ], +) +def test_is_core_or_domain_message(message: Message, result: bool): + assert result == message.is_core_or_domain_message() + + +def test_add_diagnostic_data_with_repeated_component_raises_warning(): + message = Message() + message.add_diagnostic_data("a", {}) + with pytest.warns(UserWarning): + message.add_diagnostic_data("a", {}) + + +def test_message_fingerprint_includes_data_and_features( + whitespace_tokenizer: WhitespaceTokenizer, +): + message = Message(data={TEXT: "This is a test sentence."}) + fp1 = message.fingerprint() + whitespace_tokenizer.process([message]) + fp2 = message.fingerprint() + + assert fp1 != fp2 + + message.add_features( + Features(scipy.sparse.csr_matrix([1, 1, 0]), FEATURE_TYPE_SEQUENCE, TEXT, "c2") + ) + + fp3 = message.fingerprint() + assert fp2 != fp3 + + message.add_features( + Features(np.ndarray([1, 2, 2]), FEATURE_TYPE_SEQUENCE, TEXT, "c1") + ) + + fp4 = message.fingerprint() + + assert fp3 != fp4 + + assert len({fp1, fp2, fp3, fp4}) == 4 + + +def test_message_fingerprint_is_recalculated_after_setting_data(): + message = Message(data={TEXT: "This is a test sentence."}) + fp1 = message.fingerprint() + message.set(INTENT, "test") + fp2 = message.fingerprint() + assert fp1 != fp2 + + +def test_message_fingerprint_is_recalculated_after_adding_diagnostics_data(): + message = Message(data={TEXT: "This is a test sentence."}) + fp1 = message.fingerprint() + message.add_diagnostic_data("origin", "test") + fp2 = message.fingerprint() + assert fp1 != fp2 diff --git a/tests/shared/nlu/training_data/test_synonyms_parser.py b/tests/shared/nlu/training_data/test_synonyms_parser.py new file mode 100644 index 0000000..2ea7970 --- /dev/null +++ b/tests/shared/nlu/training_data/test_synonyms_parser.py @@ -0,0 +1,37 @@ +import rasa.shared.nlu.training_data.synonyms_parser as synonyms_parser + + +def test_add_synonym(): + + synonym_name = "savings" + synonym_examples = ["pink pig", "savings account"] + expected_result = {"pink pig": synonym_name, "savings account": synonym_name} + + result = {} + + for example in synonym_examples: + synonyms_parser.add_synonym(example, synonym_name, result) + + assert result == expected_result + + +def test_add_synonyms_from_entities(): + + training_example = "I want to fly from Berlin to LA" + + entities = [ + {"start": 19, "end": 25, "value": "Berlin", "entity": "city", "role": "to"}, + { + "start": 29, + "end": 31, + "value": "Los Angeles", + "entity": "city", + "role": "from", + }, + ] + + result = {} + + synonyms_parser.add_synonyms_from_entities(training_example, entities, result) + + assert result == {"LA": "Los Angeles"} diff --git a/tests/shared/nlu/training_data/test_training_data.py b/tests/shared/nlu/training_data/test_training_data.py new file mode 100644 index 0000000..6764d41 --- /dev/null +++ b/tests/shared/nlu/training_data/test_training_data.py @@ -0,0 +1,939 @@ +from pathlib import Path +from typing import Text, List, Dict, Any +from unittest.mock import Mock +from _pytest.monkeypatch import MonkeyPatch + +import pytest +import numpy as np + +import rasa.shared.utils.io +from rasa.shared.core.constants import USER_INTENT_OUT_OF_SCOPE +from rasa.shared.nlu.constants import ( + TEXT, + INTENT_RESPONSE_KEY, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_TYPE, + ENTITIES, + INTENT, + ACTION_NAME, + FEATURE_TYPE_SENTENCE, +) +from rasa.nlu.convert import convert_training_data +from rasa.nlu.extractors.mitie_entity_extractor import MitieEntityExtractor +from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizer +from rasa.shared.nlu.training_data.features import Features +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.shared.nlu.training_data.loading import guess_format, UNK, load_data +from rasa.shared.nlu.training_data.util import ( + get_file_format_extension, + template_key_to_intent_response_key, + intent_response_key_to_template_key, +) + +import rasa.shared.data +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import UserUttered, ActionExecuted +from rasa.shared.core.training_data.structures import StoryGraph, StoryStep +from rasa.shared.importers.importer import TrainingDataImporter, E2EImporter + + +def test_luis_data(): + td = load_data("data/examples/luis/demo-restaurants_v7.json") + + assert not td.is_empty() + assert len(td.entity_examples) == 8 + assert len(td.intent_examples) == 28 + assert len(td.regex_features) == 1 + assert len(td.training_examples) == 28 + assert td.entity_synonyms == {} + assert td.intents == {"affirm", "goodbye", "greet", "inform"} + assert td.entities == {"location", "cuisine"} + + +def test_wit_data(): + td = load_data("data/examples/wit/demo-flights.json") + assert not td.is_empty() + assert td.entity_examples == [ + Message( + { + "intent": "flight_booking", + "entities": [ + { + "entity": "location", + "start": 19, + "end": 25, + "entities": [], + "role": "from", + "value": "london", + } + ], + "text": "i want to fly from london", + } + ), + Message( + { + "intent": "flight_booking", + "entities": [ + { + "entity": "location", + "start": 17, + "end": 23, + "entities": [], + "role": "to", + "value": "berlin", + } + ], + "text": "i want to fly to berlin", + } + ), + Message( + { + "intent": "flight_booking", + "entities": [ + { + "entity": "location", + "start": 18, + "end": 24, + "entities": [], + "role": "from", + "value": "berlin", + }, + { + "entity": "location", + "start": 28, + "end": 33, + "entities": [], + "role": "to", + "value": "tokyo", + }, + ], + "text": "i want to go from berlin to tokyo tomorrow", + } + ), + Message( + { + "intent": "flight_booking", + "entities": [ + { + "entity": "location", + "start": 30, + "end": 36, + "entities": [], + "role": "from", + "value": "london", + }, + { + "entity": "wit$datetime", + "start": 50, + "end": 61, + "entities": [], + "role": "datetime", + "value": "next monday", + }, + { + "entity": "location", + "start": 40, + "end": 49, + "entities": [], + "role": "to", + "value": "amsterdam", + }, + ], + "text": "i'm looking for a flight from london to amsterdam next monday", + } + ), + ] + assert len(td.intent_examples) == 5 + assert len(td.training_examples) == 5 + assert td.entity_synonyms == {} + assert td.intents == {"flight_booking", USER_INTENT_OUT_OF_SCOPE} + assert td.entities == {"location", "wit$datetime"} + + +def test_dialogflow_data(): + td = load_data("data/examples/dialogflow/") + assert not td.is_empty() + assert len(td.entity_examples) == 5 + assert len(td.intent_examples) == 24 + assert len(td.training_examples) == 24 + assert len(td.regex_features) == 1 + assert len(td.lookup_tables) == 2 + assert td.intents == {"affirm", "goodbye", "hi", "inform"} + assert td.entities == {"cuisine", "location"} + non_trivial_synonyms = {k: v for k, v in td.entity_synonyms.items() if k != v} + assert non_trivial_synonyms == { + "mexico": "mexican", + "china": "chinese", + "india": "indian", + } + # The order changes based on different computers hence the grouping + assert {td.lookup_tables[0]["name"], td.lookup_tables[1]["name"]} == { + "location", + "cuisine", + } + assert { + len(td.lookup_tables[0]["elements"]), + len(td.lookup_tables[1]["elements"]), + } == {4, 6} + + +def test_lookup_table_json(): + lookup_fname = "data/test/lookup_tables/plates.txt" + td_lookup = load_data("data/test/lookup_tables/lookup_table.json") + assert not td_lookup.is_empty() + assert len(td_lookup.lookup_tables) == 1 + assert td_lookup.lookup_tables[0]["name"] == "plates" + assert td_lookup.lookup_tables[0]["elements"] == lookup_fname + + +def test_lookup_table_yaml(): + td_lookup = load_data("data/test/lookup_tables/lookup_table.yml") + assert not td_lookup.is_empty() + assert len(td_lookup.lookup_tables) == 1 + assert td_lookup.lookup_tables[0]["name"] == "plates" + assert len(td_lookup.lookup_tables[0]["elements"]) == 5 + + +def test_composite_entities_data(): + td = load_data("data/test/demo-rasa-composite-entities.yml") + assert not td.is_empty() + assert len(td.entity_examples) == 11 + assert len(td.intent_examples) == 29 + assert len(td.training_examples) == 29 + assert td.entity_synonyms == {"SF": "San Fransisco"} + assert td.intents == {"order_pizza", "book_flight", "chitchat", "affirm"} + assert td.entities == {"location", "topping", "size"} + assert td.entity_groups == {"1", "2"} + assert td.entity_roles == {"to", "from"} + assert td.number_of_examples_per_entity["entity 'location'"] == 8 + assert td.number_of_examples_per_entity["group '1'"] == 9 + assert td.number_of_examples_per_entity["role 'from'"] == 3 + + +def test_intent_response_key_to_template_key(): + intent_response_key = "chitchat/ask_name" + template_key = "utter_chitchat/ask_name" + assert intent_response_key_to_template_key(intent_response_key) == template_key + + +def test_template_key_to_intent_response_key(): + intent_response_key = "chitchat/ask_name" + template_key = "utter_chitchat/ask_name" + assert template_key_to_intent_response_key(template_key) == intent_response_key + + +@pytest.mark.parametrize( + "files", + [ + [ + "data/examples/rasa/demo-rasa.json", + "data/examples/rasa/demo-rasa-responses.yml", + ], + [ + "data/examples/rasa/demo-rasa.yml", + "data/examples/rasa/demo-rasa-responses.yml", + ], + ], +) +def test_demo_data(files: List[Text]): + from rasa.shared.importers.utils import training_data_from_paths + + trainingdata = training_data_from_paths(files, language="en") + assert trainingdata.intents == { + "affirm", + "greet", + "restaurant_search", + "goodbye", + "chitchat", + } + assert trainingdata.entities == {"location", "cuisine"} + assert set(trainingdata.responses.keys()) == { + "utter_chitchat/ask_name", + "utter_chitchat/ask_weather", + } + assert len(trainingdata.training_examples) == 46 + assert len(trainingdata.intent_examples) == 46 + assert len(trainingdata.response_examples) == 4 + assert len(trainingdata.entity_examples) == 11 + assert len(trainingdata.responses) == 2 + + assert trainingdata.entity_synonyms == { + "Chines": "chinese", + "Chinese": "chinese", + "chines": "chinese", + "vegg": "vegetarian", + "veggie": "vegetarian", + } + + assert trainingdata.regex_features == [ + {"name": "greet", "pattern": r"hey[^\s]*"}, + {"name": "zipcode", "pattern": r"[0-9]{5}"}, + ] + + +@pytest.mark.parametrize( + "files", + [ + [ + "data/examples/rasa/demo-rasa.json", + "data/examples/rasa/demo-rasa-responses.yml", + ], + [ + "data/examples/rasa/demo-rasa.yml", + "data/examples/rasa/demo-rasa-responses.yml", + ], + ], +) +def test_demo_data_filter_out_retrieval_intents(files): + from rasa.shared.importers.utils import training_data_from_paths + + training_data = training_data_from_paths(files, language="en") + assert len(training_data.training_examples) == 46 + + training_data_filtered = training_data.filter_training_examples( + lambda ex: ex.get(INTENT_RESPONSE_KEY) is None + ) + assert len(training_data_filtered.training_examples) == 42 + + training_data_filtered_2 = training_data.filter_training_examples( + lambda ex: ex.get(INTENT_RESPONSE_KEY) is not None + ) + assert len(training_data_filtered_2.training_examples) == 4 + + # make sure filtering operation doesn't mutate the source training data + assert len(training_data.training_examples) == 46 + + +@pytest.mark.parametrize( + "filepaths", + [ + [ + "data/examples/rasa/demo-rasa.yml", + "data/examples/rasa/demo-rasa-responses.yml", + ] + ], +) +def test_train_test_split(filepaths: List[Text]): + from rasa.shared.importers.utils import training_data_from_paths + + training_data = training_data_from_paths(filepaths, language="en") + + assert training_data.intents == { + "affirm", + "greet", + "restaurant_search", + "goodbye", + "chitchat", + } + assert training_data.entities == {"location", "cuisine"} + assert set(training_data.responses.keys()) == { + "utter_chitchat/ask_name", + "utter_chitchat/ask_weather", + } + + NUM_TRAIN_EXAMPLES = 46 + NUM_RESPONSE_EXAMPLES = 4 + + assert len(training_data.training_examples) == NUM_TRAIN_EXAMPLES + assert len(training_data.intent_examples) == NUM_TRAIN_EXAMPLES + assert len(training_data.response_examples) == NUM_RESPONSE_EXAMPLES + + for train_percent in range(50, 95, 5): + train_frac = train_percent / 100.0 + train_split, test_split = training_data.train_test_split(train_frac) + + assert ( + len(test_split.training_examples) + len(train_split.training_examples) + == NUM_TRAIN_EXAMPLES + ) + + num_classes = ( + len(training_data.number_of_examples_per_intent.keys()) + + -len(training_data.retrieval_intents) + + len(training_data.number_of_examples_per_response) + ) + + expected_num_train_examples_floor = int(train_frac * NUM_TRAIN_EXAMPLES) + if NUM_TRAIN_EXAMPLES - expected_num_train_examples_floor < num_classes: + expected_num_train_examples_floor = NUM_TRAIN_EXAMPLES - num_classes - 1 + + assert len(train_split.training_examples) >= expected_num_train_examples_floor + assert ( + len(train_split.training_examples) <= expected_num_train_examples_floor + 1 + ) + + assert len(training_data.number_of_examples_per_intent.keys()) == len( + test_split.number_of_examples_per_intent.keys() + ) + assert len(training_data.number_of_examples_per_intent.keys()) == len( + train_split.number_of_examples_per_intent.keys() + ) + assert len(training_data.number_of_examples_per_response.keys()) == len( + train_split.number_of_examples_per_response.keys() + ) + assert len(training_data.number_of_examples_per_response.keys()) == len( + train_split.number_of_examples_per_response.keys() + ) + + +def test_number_of_examples_per_intent(): + message_action = Message(data={"action_name": "utter_greet"}) + message_intent = Message( + data={"text": "I would like the newsletter", "intent": "subscribe"} + ) + message_non_nlu_intent = Message(data={"intent": "subscribe"}) + message_other_intent_one = Message( + data={"text": "What is the weather like today?", "intent": "ask_weather"} + ) + message_other_intent_two = Message( + data={"text": "Will it rain today?", "intent": "ask_weather"} + ) + message_non_nlu_other_intent_three = Message(data={"intent": "ask_weather"}) + + training_examples = [ + message_action, + message_intent, + message_non_nlu_intent, + message_other_intent_one, + message_other_intent_two, + message_non_nlu_other_intent_three, + ] + training_data = TrainingData(training_examples=training_examples) + + assert training_data.number_of_examples_per_intent["subscribe"] == 1 + assert training_data.number_of_examples_per_intent["ask_weather"] == 2 + + +def test_number_of_examples_per_intent_with_yaml(tmp_path: Path): + domain_path = tmp_path / "domain.yml" + domain_path.write_text(Domain.empty().as_yaml()) + + config_path = tmp_path / "config.yml" + config_path.touch() + + importer = TrainingDataImporter.load_from_dict( + {}, + str(config_path), + str(domain_path), + [ + "data/test_number_nlu_examples/nlu.yml", + "data/test_number_nlu_examples/stories.yml", + "data/test_number_nlu_examples/rules.yml", + ], + ) + + training_data = importer.get_nlu_data() + assert training_data.intents == {"greet", "ask_weather"} + assert training_data.number_of_examples_per_intent["greet"] == 2 + assert training_data.number_of_examples_per_intent["ask_weather"] == 3 + + +def test_validate_number_of_examples_per_intent(): + message_intent = Message( + data={"text": "I would like the newsletter", "intent": "subscribe"} + ) + message_non_nlu_intent = Message(data={"intent": "subscribe"}) + + training_examples = [message_intent, message_non_nlu_intent] + training_data = TrainingData(training_examples=training_examples) + + with pytest.warns(Warning) as w: + training_data.validate() + + assert len(w) == 1 + assert ( + w[0].message.args[0] == "Intent 'subscribe' has only 1 training examples! " + "Minimum is 2, training may fail." + ) + + +@pytest.mark.parametrize( + "filepaths", + [ + [ + "data/examples/rasa/demo-rasa.yml", + "data/examples/rasa/demo-rasa-responses.yml", + ] + ], +) +def test_train_test_split_with_random_seed(filepaths): + from rasa.shared.importers.utils import training_data_from_paths + + td = training_data_from_paths(filepaths, language="en") + + td_train_1, td_test_1 = td.train_test_split(train_frac=0.8, random_seed=1) + td_train_2, td_test_2 = td.train_test_split(train_frac=0.8, random_seed=1) + train_1_intent_examples = [e.get(TEXT) for e in td_train_1.intent_examples] + train_2_intent_examples = [e.get(TEXT) for e in td_train_2.intent_examples] + + test_1_intent_examples = [e.get(TEXT) for e in td_test_1.intent_examples] + test_2_intent_examples = [e.get(TEXT) for e in td_test_2.intent_examples] + + assert train_1_intent_examples == train_2_intent_examples + assert test_1_intent_examples == test_2_intent_examples + + +@pytest.mark.parametrize( + "files", + [ + ("data/examples/rasa/demo-rasa.json", "data/test/multiple_files_json"), + ("data/examples/rasa/demo-rasa.yml", "data/test/duplicate_intents_yaml"), + ], +) +def test_data_merging(files): + td_reference = load_data(files[0]) + td = load_data(files[1]) + assert len(td.entity_examples) == len(td_reference.entity_examples) + assert len(td.intent_examples) == len(td_reference.intent_examples) + assert len(td.training_examples) == len(td_reference.training_examples) + assert td.intents == td_reference.intents + assert td.entities == td_reference.entities + assert td.entity_synonyms == td_reference.entity_synonyms + assert td.regex_features == td_reference.regex_features + + +def test_repeated_entities(tmp_path: Path, whitespace_tokenizer: WhitespaceTokenizer): + data = """ +{ + "rasa_nlu_data": { + "common_examples" : [ + { + "text": "book a table today from 3 to 6 for 3 people", + "intent": "unk", + "entities": [ + { + "entity": "description", + "start": 35, + "end": 36, + "value": "3" + } + ] + } + ] + } +}""" + f = tmp_path / "tmp_training_data.json" + f.write_text(data, rasa.shared.utils.io.DEFAULT_ENCODING) + td = load_data(str(f)) + assert len(td.entity_examples) == 1 + example = td.entity_examples[0] + entities = example.get("entities") + assert len(entities) == 1 + tokens = whitespace_tokenizer.tokenize(example, attribute=TEXT) + start, end = MitieEntityExtractor.find_entity( + entities[0], example.get(TEXT), tokens + ) + assert start == 9 + assert end == 10 + + +def test_multiword_entities(tmp_path: Path, whitespace_tokenizer: WhitespaceTokenizer): + data = """ +{ + "rasa_nlu_data": { + "common_examples" : [ + { + "text": "show me flights to New York City", + "intent": "unk", + "entities": [ + { + "entity": "destination", + "start": 19, + "end": 32, + "value": "New York City" + } + ] + } + ] + } +}""" + f = tmp_path / "tmp_training_data.json" + f.write_text(data, rasa.shared.utils.io.DEFAULT_ENCODING) + td = load_data(str(f)) + assert len(td.entity_examples) == 1 + example = td.entity_examples[0] + entities = example.get("entities") + assert len(entities) == 1 + tokens = whitespace_tokenizer.tokenize(example, attribute=TEXT) + start, end = MitieEntityExtractor.find_entity( + entities[0], example.get(TEXT), tokens + ) + assert start == 4 + assert end == 7 + + +def test_nonascii_entities(tmp_path): + data = """ +{ + "luis_schema_version": "7.0", + "utterances" : [ + { + "text": "I am looking for a ßäæ ?€ö) item", + "intent": "unk", + "entities": [ + { + "entity": "description", + "startPos": 19, + "endPos": 26 + } + ] + } + ] +}""" + f = tmp_path / "tmp_training_data.json" + f.write_text(data, rasa.shared.utils.io.DEFAULT_ENCODING) + td = load_data(str(f)) + assert len(td.entity_examples) == 1 + example = td.entity_examples[0] + entities = example.get(ENTITIES) + assert len(entities) == 1 + entity = entities[0] + assert entity[ENTITY_ATTRIBUTE_VALUE] == "ßäæ ?€ö)" + assert entity[ENTITY_ATTRIBUTE_START] == 19 + assert entity[ENTITY_ATTRIBUTE_END] == 27 + assert entity[ENTITY_ATTRIBUTE_TYPE] == "description" + + +def test_entities_synonyms(tmp_path): + data = """ +{ + "rasa_nlu_data": { + "entity_synonyms": [ + { + "value": "nyc", + "synonyms": ["New York City", "nyc", "the big apple"] + } + ], + "common_examples" : [ + { + "text": "show me flights to New York City", + "intent": "unk", + "entities": [ + { + "entity": "destination", + "start": 19, + "end": 32, + "value": "NYC" + } + ] + }, + { + "text": "show me flights to nyc", + "intent": "unk", + "entities": [ + { + "entity": "destination", + "start": 19, + "end": 22, + "value": "nyc" + } + ] + } + ] + } +}""" + f = tmp_path / "tmp_training_data.json" + f.write_text(data, rasa.shared.utils.io.DEFAULT_ENCODING) + td = load_data(str(f)) + assert td.entity_synonyms["New York City"] == "nyc" + + +def cmp_message_list(firsts, seconds): + assert len(firsts) == len(seconds), "Message lists have unequal length" + + +def cmp_dict_list(firsts, seconds): + if len(firsts) != len(seconds): + return False + + for a in firsts: + for idx, b in enumerate(seconds): + if hash(a) == hash(b): + del seconds[idx] + break + else: + others = ", ".join(e.text for e in seconds) + assert False, f"Failed to find message {a.text} in {others}" + return not seconds + + +@pytest.mark.parametrize( + "data_file,gold_standard_file,output_format,language", + [ + ( + "data/examples/wit/demo-flights.json", + "data/test/wit_converted_to_rasa.json", + "json", + None, + ), + ( + "data/examples/luis/demo-restaurants_v7.json", + "data/test/luis_converted_to_rasa.json", + "json", + None, + ), + ( + "data/examples/dialogflow/", + "data/test/dialogflow_en_converted_to_rasa.json", + "json", + "en", + ), + ( + "data/examples/dialogflow/", + "data/test/dialogflow_es_converted_to_rasa.json", + "json", + "es", + ), + ( + "data/examples/rasa/demo-rasa.yml", + "data/test/md_converted_to_json.json", + "json", + None, + ), + ], +) +def test_training_data_conversion( + tmpdir, data_file, gold_standard_file, output_format, language +): + out_path = tmpdir.join("rasa_nlu_data.json") + convert_training_data(data_file, out_path.strpath, output_format, language) + td = load_data(out_path.strpath, language) + assert td.entity_examples != [] + assert td.intent_examples != [] + + gold_standard = load_data(gold_standard_file, language) + cmp_message_list(td.entity_examples, gold_standard.entity_examples) + cmp_message_list(td.intent_examples, gold_standard.intent_examples) + assert td.entity_synonyms == gold_standard.entity_synonyms + assert td.entity_roles == gold_standard.entity_roles + + # converting the converted file back to original + # file format and performing the same tests + rto_path = tmpdir.join("data_in_original_format.txt") + convert_training_data(out_path.strpath, rto_path.strpath, "json", language) + rto = load_data(rto_path.strpath, language) + cmp_message_list(gold_standard.entity_examples, rto.entity_examples) + cmp_message_list(gold_standard.intent_examples, rto.intent_examples) + assert gold_standard.entity_synonyms == rto.entity_synonyms + + # If the above assert fails - this can be used + # to dump to the file and diff using git + # with io.open(gold_standard_file) as f: + # f.write(td.as_json(indent=2)) + + +@pytest.mark.parametrize( + "data_file,expected_format", + [ + ( + "data/examples/luis/demo-restaurants_v7.json", + rasa.shared.data.yaml_file_extension(), + ), + ("data/examples", rasa.shared.data.yaml_file_extension()), + ("data/examples/rasa/demo-rasa.yml", rasa.shared.data.yaml_file_extension()), + ("data/rasa_yaml_examples", rasa.shared.data.yaml_file_extension()), + ], +) +def test_get_supported_file_format(data_file: Text, expected_format: Text): + fformat = get_file_format_extension(data_file) + assert fformat == expected_format + + +@pytest.mark.parametrize("data_file", ["path-does-not-exists", None]) +def test_get_non_existing_file_format_raises(data_file: Text): + with pytest.raises(AttributeError): + get_file_format_extension(data_file) + + +def test_guess_format_from_non_existing_file_path(): + assert guess_format("not existing path") == UNK + + +def test_is_empty(): + assert TrainingData().is_empty() + + +def test_custom_attributes(tmp_path): + data = """ +{ + "rasa_nlu_data": { + "common_examples" : [ + { + "intent": "happy", + "text": "I'm happy.", + "sentiment": 0.8 + } + ] + } +}""" + f = tmp_path / "tmp_training_data.json" + f.write_text(data, rasa.shared.utils.io.DEFAULT_ENCODING) + td = load_data(str(f)) + assert len(td.training_examples) == 1 + example = td.training_examples[0] + assert example.get("sentiment") == 0.8 + + +def test_without_additional_e2e_examples(tmp_path: Path): + domain_path = tmp_path / "domain.yml" + domain_path.write_text(Domain.empty().as_yaml()) + + config_path = tmp_path / "config.yml" + config_path.touch() + + existing = TrainingDataImporter.load_from_dict( + {}, str(config_path), str(domain_path), [] + ) + + stories = StoryGraph( + [ + StoryStep( + "name", + events=[ + UserUttered(None, {"name": "greet_from_stories"}), + ActionExecuted("utter_greet_from_stories"), + ], + ) + ] + ) + + # Patch to return our test stories + existing.get_stories = lambda *args: stories + + importer = E2EImporter(existing) + + training_data = importer.get_nlu_data() + + assert training_data.training_examples + assert not training_data.is_empty() + assert len(training_data.nlu_examples) == 0 + + +@pytest.mark.parametrize( + "source_lookup_table,expected_lookup_table", + [ + ( + {"name": "plates", "elements": "data/test/lookup_tables/plates.txt"}, + { + "name": "plates", + "elements": "tacos\nbeef\nmapo tofu\nburrito\nlettuce wrap", + }, + ), + ( + {"name": "plates", "elements": ["data/test/lookup_tables/plates.txt"]}, + { + "name": "plates", + "elements": "tacos\nbeef\nmapo tofu\nburrito\nlettuce wrap", + }, + ), + ( + { + "name": "plates", + "elements": "data/test/lookup_tables/not-existing-file.txt", + }, + { + "name": "plates", + "elements": "data/test/lookup_tables/not-existing-file.txt", + }, + ), + ( + {"name": "test", "some_key": "some_value", "elements": "everything else"}, + {"name": "test", "some_key": "some_value", "elements": "everything else"}, + ), + ], +) +def test_load_lookup_table( + source_lookup_table: Dict[Text, Any], expected_lookup_table: Dict[Text, Any] +): + assert TrainingData._load_lookup_table(source_lookup_table) == expected_lookup_table + + +def test_fingerprint_is_same_when_loading_data_again(): + from rasa.shared.importers.utils import training_data_from_paths + + files = [ + "data/examples/rasa/demo-rasa.yml", + "data/examples/rasa/demo-rasa-responses.yml", + ] + td1 = training_data_from_paths(files, language="en") + td2 = training_data_from_paths(files, language="en") + assert td1.fingerprint() == td2.fingerprint() + + +def test_fingerprint_is_different_when_lookup_table_has_changed( + monkeypatch: MonkeyPatch, +): + from rasa.shared.importers.utils import training_data_from_paths + + files = ["data/test/lookup_tables/lookup_table.json"] + + td1 = training_data_from_paths(files, language="en") + fingerprint1 = td1.fingerprint() + + monkeypatch.setattr( + TrainingData, + "_load_lookup_table", + Mock(return_value={"name": "plates", "elements": "tacos\nbeef"}), + ) + td2 = training_data_from_paths(files, language="en") + fingerprint2 = td2.fingerprint() + + assert fingerprint1 != fingerprint2 + + +@pytest.mark.parametrize( + "message", + [ + Message({INTENT: "intent2"}), + Message({ENTITIES: [{"entity": "entity2"}]}), + Message({ENTITIES: [{"entity": "entity1", "group": "new_group"}]}), + Message({ENTITIES: [{"entity": "entity1", "role": "new_role"}]}), + Message({ACTION_NAME: "action_name2"}), + ], +) +def test_label_fingerprints(message: Message): + training_data1 = TrainingData( + [ + Message({INTENT: "intent1"}), + Message({ENTITIES: [{"entity": "entity1"}]}), + Message({ACTION_NAME: "action_name1"}), + ] + ) + training_data2 = training_data1.merge(TrainingData([message])) + assert training_data1.label_fingerprint() != training_data2.label_fingerprint() + + +def test_training_data_fingerprint_incorporates_tokens( + whitespace_tokenizer: WhitespaceTokenizer, +): + from rasa.shared.importers.utils import training_data_from_paths + + files = [ + "data/examples/rasa/demo-rasa.yml", + "data/examples/rasa/demo-rasa-responses.yml", + ] + training_data = training_data_from_paths(files, language="en") + fp1 = training_data.fingerprint() + whitespace_tokenizer.process_training_data(training_data) + # training data fingerprint has changed + assert fp1 != training_data.fingerprint() + + +def test_training_data_fingerprint_incorporates_features(): + from rasa.shared.importers.utils import training_data_from_paths + + files = [ + "data/examples/rasa/demo-rasa.yml", + "data/examples/rasa/demo-rasa-responses.yml", + ] + training_data = training_data_from_paths(files, language="en") + fp1 = training_data.fingerprint() + big_array = np.random.random((128, 128)) + + f1 = Features(big_array, FEATURE_TYPE_SENTENCE, TEXT, "RegexFeaturizer") + training_data.training_examples[0].add_features(f1) + # training data fingerprint has changed + assert fp1 != training_data.fingerprint() diff --git a/tests/shared/nlu/training_data/test_util.py b/tests/shared/nlu/training_data/test_util.py new file mode 100644 index 0000000..d4b529b --- /dev/null +++ b/tests/shared/nlu/training_data/test_util.py @@ -0,0 +1,33 @@ +from typing import Text +import pytest +import numpy as np +import scipy.sparse +import rasa.shared.nlu.training_data.util + + +@pytest.mark.parametrize( + "s, has_escaped_char", + [ + ("Hey,\nmy name is Christof", True), + ("Howdy!", False), + ("A\tB", True), + ("Hey,\rmy name is Thomas", True), + ("Hey, my name is Thomas", False), + ("Hey,\nI\ncan\nwrite\nmany\nlines.", True), + ], +) +def test_has_string_escape_chars(s: Text, has_escaped_char: bool): + assert ( + rasa.shared.nlu.training_data.util.has_string_escape_chars(s) + == has_escaped_char + ) + + +def test_sparse_matrix_to_string(): + m = np.zeros((9, 9)) + m[0, 4] = 5.0 + m[3, 3] = 6.0 + m_sparse = scipy.sparse.csr_matrix(m) + expected_result = " (0, 4)\t5.0\n (3, 3)\t6.0" + result = rasa.shared.nlu.training_data.util.sparse_matrix_to_string(m_sparse) + assert result == expected_result diff --git a/tests/shared/test_data.py b/tests/shared/test_data.py new file mode 100644 index 0000000..805eb68 --- /dev/null +++ b/tests/shared/test_data.py @@ -0,0 +1,174 @@ +import os +import tempfile +from pathlib import Path + +import pytest +import rasa.shared + +import rasa.shared.data +from rasa.shared.nlu.training_data.loading import load_data +from rasa.shared.utils.io import write_text_file, json_to_string +from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( + YAMLStoryReader, +) + + +@pytest.mark.parametrize( + "path,is_yaml", + [ + ("my_file.yaml", True), + ("my_file.yml", True), + ("/a/b/c/my_file.yml", True), + ("/a/b/c/my_file.ml", False), + ("my_file.json", False), + ], +) +def test_is_yaml_file(path, is_yaml): + assert rasa.shared.data.is_likely_yaml_file(path) == is_yaml + + +@pytest.mark.parametrize( + "path,is_json", + [ + ("my_file.json", True), + ("/a/b/c/my_file.json", True), + ("/a/b/c/my_file.yml", False), + ], +) +def test_is_json_file(path, is_json): + assert rasa.shared.data.is_likely_json_file(path) == is_json + + +def test_get_core_directory(project): + data_dir = os.path.join(project, "data") + core_directory = rasa.shared.data.get_core_directory([data_dir]) + core_files = os.listdir(core_directory) + + assert len(core_files) == 2 + assert any(file.endswith("stories.yml") for file in core_files) + assert any(file.endswith("rules.yml") for file in core_files) + + +def test_get_nlu_directory(project): + data_dir = os.path.join(project, "data") + nlu_directory = rasa.shared.data.get_nlu_directory([data_dir]) + + nlu_files = os.listdir(nlu_directory) + + assert len(nlu_files) == 1 + assert nlu_files[0].endswith("nlu.yml") + + +def test_get_nlu_file(project): + data_file = os.path.join(project, "data/nlu.yml") + nlu_directory = rasa.shared.data.get_nlu_directory(data_file) + + nlu_files = os.listdir(nlu_directory) + + original = load_data(data_file) + copied = load_data(nlu_directory) + + assert nlu_files[0].endswith("nlu.yml") + assert original.intent_examples == copied.intent_examples + + +def test_get_core_nlu_files(project): + data_dir = os.path.join(project, "data") + nlu_files = rasa.shared.data.get_data_files( + [data_dir], rasa.shared.data.is_nlu_file + ) + core_files = rasa.shared.data.get_data_files( + [data_dir], YAMLStoryReader.is_stories_file + ) + assert len(nlu_files) == 1 + assert list(nlu_files)[0].endswith("nlu.yml") + + assert len(core_files) == 2 + assert any(file.endswith("stories.yml") for file in core_files) + assert any(file.endswith("rules.yml") for file in core_files) + + +@pytest.mark.parametrize( + "test_input,expected", + [ + ( + "dialogflow", + [ + "data/examples/dialogflow/agent.json", + "data/examples/dialogflow/entities/cuisine.json", + "data/examples/dialogflow/entities/cuisine_entries_en.json", + "data/examples/dialogflow/entities/cuisine_entries_es.json", + "data/examples/dialogflow/entities/flightNumber.json", + "data/examples/dialogflow/entities/flightNumber_entries_en.json", + "data/examples/dialogflow/entities/location.json", + "data/examples/dialogflow/entities/location_entries_en.json", + "data/examples/dialogflow/entities/location_entries_es.json", + "data/examples/dialogflow/intents/Default Fallback Intent.json", + "data/examples/dialogflow/intents/affirm.json", + "data/examples/dialogflow/intents/affirm_usersays_en.json", + "data/examples/dialogflow/intents/affirm_usersays_es.json", + "data/examples/dialogflow/intents/goodbye.json", + "data/examples/dialogflow/intents/goodbye_usersays_en.json", + "data/examples/dialogflow/intents/goodbye_usersays_es.json", + "data/examples/dialogflow/intents/hi.json", + "data/examples/dialogflow/intents/hi_usersays_en.json", + "data/examples/dialogflow/intents/hi_usersays_es.json", + "data/examples/dialogflow/intents/inform.json", + "data/examples/dialogflow/intents/inform_usersays_en.json", + "data/examples/dialogflow/intents/inform_usersays_es.json", + "data/examples/dialogflow/package.json", + ], + ), + ("luis", ["data/examples/luis/demo-restaurants_v7.json"]), + ( + "rasa", + [ + "data/examples/rasa/demo-rasa-multi-intent.yml", + "data/examples/rasa/demo-rasa-responses.yml", + "data/examples/rasa/demo-rasa.json", + "data/examples/rasa/demo-rasa.yml", + ], + ), + ("wit", ["data/examples/wit/demo-flights.json"]), + ], +) +def test_find_nlu_files_with_different_formats(test_input, expected): + examples_dir = "data/examples" + data_dir = os.path.join(examples_dir, test_input) + nlu_files = rasa.shared.data.get_data_files( + [data_dir], rasa.shared.data.is_nlu_file + ) + assert [Path(f) for f in nlu_files] == [Path(f) for f in expected] + + +def test_is_nlu_file_with_json(): + test = { + "rasa_nlu_data": { + "lookup_tables": [ + {"name": "plates", "elements": ["beans", "rice", "tacos", "cheese"]} + ] + } + } + + directory = tempfile.mkdtemp() + file = os.path.join(directory, "test.json") + + write_text_file(json_to_string(test), file) + + assert rasa.shared.data.is_nlu_file(file) + + +def test_is_not_nlu_file_with_json(): + directory = tempfile.mkdtemp() + file = os.path.join(directory, "test.json") + write_text_file('{"test": "a"}', file) + + assert not rasa.shared.data.is_nlu_file(file) + + +def test_get_story_file_with_yaml(): + examples_dir = "data/test_yaml_stories" + core_files = rasa.shared.data.get_data_files( + [examples_dir], YAMLStoryReader.is_stories_file + ) + assert core_files diff --git a/tests/shared/test_shared.py b/tests/shared/test_shared.py new file mode 100644 index 0000000..c112e95 --- /dev/null +++ b/tests/shared/test_shared.py @@ -0,0 +1,39 @@ +import os +from pathlib import Path + + +def test_shared_package_is_independent(): + shared_package = Path(".") / "rasa" / "shared" + + for root, dirs, files in os.walk(shared_package): + python_files = [f for f in files if f.endswith(".py")] + + for file in python_files: + full_path = Path(root) / file + lines = full_path.read_text().split("\n") + lines = [line.strip() for line in lines] + + imports = [ + line + for line in lines + if line.startswith("import ") or line.startswith("from ") + ] + rasa_imports = [line for line in imports if "rasa" in line] + + shared_imports = ["rasa.shared", "from rasa import shared"] + outside_rasa_imports = [ + import_line + for import_line in rasa_imports + if not any( + shared_import in import_line for shared_import in shared_imports + ) + ] + + excluded = [] + if file in excluded: + continue + # The shared package is required to be independent of the rest of Rasa + assert not outside_rasa_imports, ( + f"File `{file}` imports code from outside " + f"of `rasa.shared`: {','.join(outside_rasa_imports)}" + ) diff --git a/tests/shared/utils/__init__.py b/tests/shared/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/shared/utils/schemas/__init__.py b/tests/shared/utils/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/shared/utils/schemas/test_events.py b/tests/shared/utils/schemas/test_events.py new file mode 100644 index 0000000..25ee985 --- /dev/null +++ b/tests/shared/utils/schemas/test_events.py @@ -0,0 +1,104 @@ +from typing import Type + +import pytest +from jsonschema import ValidationError, validate + +from rasa.core.actions.action import RemoteAction +from rasa.shared.core.events import Event +from rasa.shared.utils.schemas.events import EVENT_SCHEMA +import rasa.shared.utils.common + +TEST_EVENT = { + "sender_id": "77bd4d3841294f1f9f82ef8cfe9321a7", + "event": "user", + "timestamp": 1640019570.8848355, + "text": "hey 👋🏻", + "parse_data": { + "intent": { + "id": 5103161883140543062, + "name": "greet", + "confidence": 0.9999818801879883, + }, + "entities": [], + "text": "hey", + "message_id": "52ec1cba47c840d798d9f612334a750d", + "metadata": {}, + "intent_ranking": [ + { + "id": 5103161883140543062, + "name": "greet", + "confidence": 0.9999818801879883, + } + ], + "response_selector": { + "all_retrieval_intents": ["chitchat"], + "faq": { + "response": { + "id": None, + "responses": None, + "response_templates": None, + "confidence": 0.0, + "intent_response_key": None, + "utter_action": "utter_None", + "template_name": "utter_None", + }, + "ranking": [], + }, + "chitchat": { + "response": { + "id": -2223917631873543698, + "responses": [{"text": "I am called Retrieval Bot!"}], + "response_templates": [{"text": "I am called Retrieval Bot!"}], + "confidence": 0.9660752415657043, + "intent_response_key": "chitchat/ask_name", + "utter_action": "utter_chitchat/ask_name", + "template_name": "utter_chitchat/ask_name", + }, + "ranking": [ + { + "id": -2223917631873543698, + "confidence": 0.9660752415657043, + "intent_response_key": "chitchat/ask_name", + } + ], + }, + }, + }, + "input_channel": "rasa", + "message_id": "52ec1cba47c840d798d9f612334a750d", + "metadata": {}, +} + + +@pytest.mark.parametrize("event_class", rasa.shared.utils.common.all_subclasses(Event)) +def test_remote_action_validate_all_event_subclasses(event_class: Type[Event]): + + if event_class.type_name == "slot": + response = { + "events": [{"event": "slot", "name": "test", "value": "example"}], + "responses": [], + } + elif event_class.type_name == "entities": + response = {"events": [{"event": "entities", "entities": []}], "responses": []} + else: + response = {"events": [{"event": event_class.type_name}], "responses": []} + + # ignore the below events since these are not sent or received outside Rasa + if event_class.type_name not in [ + "wrong_utterance", + "wrong_action", + "warning_predicted", + ]: + validate(response, RemoteAction.action_response_format_spec()) + + +def test_validate_single_event(): + validate(TEST_EVENT, EVENT_SCHEMA) + + +def test_validate_single_event_raises(): + test_wrong_schema = TEST_EVENT.copy() + test_wrong_schema["event"] = "non-existing event 🪲" + + with pytest.raises(ValidationError): + validate(test_wrong_schema, EVENT_SCHEMA) diff --git a/tests/shared/utils/test_cli.py b/tests/shared/utils/test_cli.py new file mode 100644 index 0000000..075270f --- /dev/null +++ b/tests/shared/utils/test_cli.py @@ -0,0 +1,82 @@ +import builtins +from unittest.mock import Mock + +import pytest +from _pytest.monkeypatch import MonkeyPatch + +import rasa.shared.utils.cli + + +def mock_print(*args, **kwargs): + raise BlockingIOError() + + +def test_print_error_and_exit(): + with pytest.raises(SystemExit): + rasa.shared.utils.cli.print_error_and_exit("") + + +def test_print_success(monkeypatch: MonkeyPatch): + mock = Mock() + monkeypatch.setattr(builtins, "print", mock_print) + monkeypatch.setattr(rasa.shared.utils.io, "handle_print_blocking", mock) + + text = "Test Success" + rasa.shared.utils.cli.print_success(text) + + assert mock.called + assert ( + mock.call_args_list[0][0][0] + == rasa.shared.utils.io.bcolors.OKGREEN + + text + + rasa.shared.utils.io.bcolors.ENDC + ) + + +def test_print_info(monkeypatch: MonkeyPatch): + mock = Mock() + monkeypatch.setattr(builtins, "print", mock_print) + monkeypatch.setattr(rasa.shared.utils.io, "handle_print_blocking", mock) + + text = "Test Info" + rasa.shared.utils.cli.print_info(text) + + assert mock.called + assert ( + mock.call_args_list[0][0][0] + == rasa.shared.utils.io.bcolors.OKBLUE + + text + + rasa.shared.utils.io.bcolors.ENDC + ) + + +def test_print_warning(monkeypatch: MonkeyPatch): + mock = Mock() + monkeypatch.setattr(builtins, "print", mock_print) + monkeypatch.setattr(rasa.shared.utils.io, "handle_print_blocking", mock) + + text = "Test Warning" + rasa.shared.utils.cli.print_warning(text) + + assert mock.called + assert ( + mock.call_args_list[0][0][0] + == rasa.shared.utils.io.bcolors.WARNING + + text + + rasa.shared.utils.io.bcolors.ENDC + ) + + +def test_print_error(monkeypatch: MonkeyPatch): + mock = Mock() + monkeypatch.setattr(builtins, "print", mock_print) + monkeypatch.setattr(rasa.shared.utils.io, "handle_print_blocking", mock) + + text = "Test Error" + rasa.shared.utils.cli.print_error(text) + + assert mock.called + assert ( + mock.call_args_list[0][0][0] + == rasa.shared.utils.io.bcolors.FAIL + text + rasa.shared.utils.io.bcolors.ENDC + ) diff --git a/tests/shared/utils/test_common.py b/tests/shared/utils/test_common.py new file mode 100644 index 0000000..4753300 --- /dev/null +++ b/tests/shared/utils/test_common.py @@ -0,0 +1,265 @@ +import asyncio +from typing import Collection, List, Optional, Text +from unittest.mock import Mock + +import pytest + +import rasa.shared.core.domain +import rasa.shared.utils.common +from rasa.shared.exceptions import RasaException + + +def test_all_subclasses(): + class TestClass: + pass + + subclasses = [type(f"Subclass{i}", (TestClass,), {}) for i in range(10)] + sub_subclasses = [ + type(f"Sub-subclass_{subclass.__name__}", (subclass,), {}) + for subclass in subclasses + ] + + expected = subclasses + sub_subclasses + assert rasa.shared.utils.common.all_subclasses(TestClass) == expected + + +def test_sort_dicts_by_keys(): + test_data = [{"Z": 1}, {"A": 10}] + + expected = [{"A": 10}, {"Z": 1}] + actual = rasa.shared.utils.common.sort_list_of_dicts_by_first_key(test_data) + + assert actual == expected + + +@pytest.mark.parametrize( + "collection, possible_outputs", + [ + (["a", "b", "c"], ["a, b and c"]), + (["a", "b"], ["a and b"]), + (["a"], ["a"]), + ( + {"a", "b", "c"}, + [ + "a, b and c", + "a, c and b", + "b, a and c", + "b, c and a", + "c, a and b", + "c, b and a", + ], + ), + ({"a", "b"}, ["a and b", "b and a"]), + ({"a"}, ["a"]), + ({}, [""]), + ], +) +def test_transform_collection_to_sentence( + collection: Collection, possible_outputs: List[Text] +): + actual = rasa.shared.utils.common.transform_collection_to_sentence(collection) + assert actual in possible_outputs + + +async def test_cached_method_with_sync_method(): + expected = 5 + mock = Mock(return_value=expected) + + class Test: + @rasa.shared.utils.common.cached_method + def f(self): + return mock() + + test_instance = Test() + assert test_instance.f() == expected + assert test_instance.f() == expected + + mock.assert_called_once() + + +async def test_cached_method_with_async_method(): + expected = 5 + mock = Mock(return_value=expected) + + class Test: + @rasa.shared.utils.common.cached_method + async def f(self): + await asyncio.sleep(0) + return mock() + + test_instance = Test() + assert await test_instance.f() == expected + assert await test_instance.f() == expected + + mock.assert_called_once() + + +async def test_cached_method_with_different_arguments(): + expected = 5 + mock = Mock(return_value=expected) + + class Test: + @rasa.shared.utils.common.cached_method + async def f(self, _: bool, arg2: bool): + return mock() + + test_instance = Test() + + # Caching works + assert await test_instance.f(True, arg2=True) == expected + assert await test_instance.f(True, arg2=True) == expected + + assert mock.call_count == 1 + + # Different arg results in cache miss + assert await test_instance.f(False, arg2=True) == expected + assert await test_instance.f(False, arg2=True) == expected + + assert mock.call_count == 2 + + # Different kwarg results in cache miss + assert await test_instance.f(True, arg2=False) == expected + assert await test_instance.f(True, arg2=False) == expected + + assert mock.call_count == 3 + + +def test_cached_method_with_function(): + with pytest.raises(AssertionError): + + @rasa.shared.utils.common.cached_method + def my_function(): + pass + + my_function() + + +async def test_cached_method_with_async_function(): + with pytest.raises(AssertionError): + + @rasa.shared.utils.common.cached_method + async def my_function(): + await asyncio.sleep(0) + + await my_function() + + +@pytest.mark.parametrize( + "module_path, lookup_path, outcome", + [ + ("rasa.shared.core.domain.Domain", None, "Domain"), + # lookup_path + ("Event", "rasa.shared.core.events", "Event"), + ], +) +def test_class_from_module_path( + module_path: Text, lookup_path: Optional[Text], outcome: Text +): + klass = rasa.shared.utils.common.class_from_module_path(module_path, lookup_path) + assert isinstance(klass, object) + assert klass.__name__ == outcome + + +@pytest.mark.parametrize( + "module_path, lookup_path", + [ + ("rasa.shared.core.domain.FunkyDomain", None), + ("FunkyDomain", None), + ("FunkyDomain", "rasa.shared.core.domain"), + ], +) +def test_class_from_module_path_not_found( + module_path: Text, lookup_path: Optional[Text] +): + with pytest.raises(ImportError): + rasa.shared.utils.common.class_from_module_path(module_path, lookup_path) + + +def test_class_from_module_path_fails(): + module_path = "rasa.shared.core.domain.logger" + with pytest.raises(RasaException): + rasa.shared.utils.common.class_from_module_path(module_path) + + +def test_extract_duplicates(): + list_one = ["greet", {"inform": {"use_entities": []}}, "start_form", "goodbye"] + list_two = ["goodbye", {"inform": {"use_entities": ["destination"]}}] + + expected = ["goodbye", "inform"] + result = rasa.shared.utils.common.extract_duplicates(list_one, list_two) + + assert result == expected + + +def test_extract_duplicates_with_unique_lists(): + list_one = ["greet", {"inform": {"use_entities": []}}, "start_form", "goodbye"] + list_two = ["bot_challenge", {"mood_sad": {"ignore_entities": []}}] + + result = rasa.shared.utils.common.extract_duplicates(list_one, list_two) + assert result == [] + + +def test_clean_duplicates(): + duplicates = {"intents": ["goodbye", "inform"], "entities": []} + expected = {"intents": ["goodbye", "inform"]} + result = rasa.shared.utils.common.clean_duplicates(duplicates) + assert result == expected + + +def test_merge_lists(): + list_one = ["greet", "start_form", "goodbye"] + list_two = ["goodbye", "bot_challenge", "greet"] + expected = ["bot_challenge", "goodbye", "greet", "start_form"] + result = rasa.shared.utils.common.merge_lists(list_one, list_two) + + assert result == expected + + +@pytest.mark.parametrize("override_existing_values", [False, True]) +def test_merge_dicts(override_existing_values): + dict_1 = {"intents": ["greet", "goodbye"], "entities": ["name"]} + dict_2 = { + "responses": {"utter_greet": [{"text": "Hi"}]}, + "intents": ["bot_challenge"], + } + + if override_existing_values: + expected = { + "entities": ["name"], + "intents": ["bot_challenge"], + "responses": {"utter_greet": [{"text": "Hi"}]}, + } + else: + expected = { + "entities": ["name"], + "intents": ["greet", "goodbye"], + "responses": {"utter_greet": [{"text": "Hi"}]}, + } + + result = rasa.shared.utils.common.merge_dicts( + dict_1, dict_2, override_existing_values + ) + + assert result == expected + + +@pytest.mark.parametrize("override_existing_values", [False, True]) +def test_merge_lists_of_dicts(override_existing_values): + list_one = ["greet", {"inform": {"use_entities": []}}, "start_form", "goodbye"] + list_two = ["goodbye", {"inform": {"use_entities": ["destination"]}}] + + if override_existing_values: + expected = [ + "greet", + {"inform": {"use_entities": ["destination"]}}, + "start_form", + "goodbye", + ] + else: + expected = ["goodbye", {"inform": {"use_entities": []}}, "greet", "start_form"] + + result = rasa.shared.utils.common.merge_lists_of_dicts( + list_one, list_two, override_existing_values + ) + + assert result == expected diff --git a/tests/shared/utils/test_io.py b/tests/shared/utils/test_io.py new file mode 100644 index 0000000..c739b8f --- /dev/null +++ b/tests/shared/utils/test_io.py @@ -0,0 +1,666 @@ +import builtins +import sys +import os +import string +import textwrap +import uuid +from collections import OrderedDict +from typing import Callable, Text, List, Set, Any, Dict +import copy + +from pathlib import Path +import numpy as np +import pytest +from _pytest.monkeypatch import MonkeyPatch +from unittest.mock import MagicMock + +import rasa.shared +from rasa.shared.nlu.training_data.features import Features +from rasa.shared.exceptions import FileIOException, FileNotFoundException, RasaException +import rasa.shared.utils.io +import rasa.shared.utils.validation +from rasa.shared.constants import NEXT_MAJOR_VERSION_FOR_DEPRECATIONS +from rasa.utils import io as io_utils + +os.environ["USER_NAME"] = "user" +os.environ["PASS"] = "pass" + + +@pytest.mark.parametrize("file, parents", [("A/test.yml", "A"), ("A", "A")]) +def test_file_in_path(file, parents): + assert rasa.shared.utils.io.is_subdirectory(file, parents) + + +@pytest.mark.parametrize( + "file, parents", [("A", "A/B"), ("B", "A"), ("A/test.yml", "A/B"), (None, "A")] +) +def test_file_not_in_path(file, parents): + assert not rasa.shared.utils.io.is_subdirectory(file, parents) + + +def test_raise_user_warning(): + with pytest.warns(UserWarning) as record: + rasa.shared.utils.io.raise_warning("My warning.") + + assert len(record) == 1 + assert record[0].message.args[0] == "My warning." + + +def test_raise_future_warning(): + with pytest.warns(FutureWarning) as record: + rasa.shared.utils.io.raise_warning("My future warning.", FutureWarning) + + assert len(record) == 1 + assert record[0].message.args[0] == "My future warning." + + +def test_raise_deprecation(): + with pytest.warns(DeprecationWarning) as record: + rasa.shared.utils.io.raise_warning("My warning.", DeprecationWarning) + + assert len(record) == 1 + assert record[0].message.args[0] == "My warning." + assert isinstance(record[0].message, DeprecationWarning) + + +def test_read_file_with_not_existing_path(): + with pytest.raises(FileNotFoundException): + rasa.shared.utils.io.read_file("some path") + + +@pytest.mark.parametrize( + "list_function, expected", + [ + ( + rasa.shared.utils.io.list_directory, + {"subdirectory", "subdirectory/sub_file.txt", "file.txt"}, + ), + (rasa.shared.utils.io.list_files, {"subdirectory/sub_file.txt", "file.txt"}), + (rasa.shared.utils.io.list_subdirectories, {"subdirectory"}), + ], +) +def test_list_directory( + tmpdir: Path, list_function: Callable[[Text], List[Text]], expected: Set[Text] +): + subdirectory = tmpdir / "subdirectory" + subdirectory.mkdir() + + sub_sub_directory = subdirectory / "subdirectory" + sub_sub_directory.mkdir() + + sub_sub_file = sub_sub_directory / "sub_file.txt" + sub_sub_file.write_text("", encoding=rasa.shared.utils.io.DEFAULT_ENCODING) + + file1 = subdirectory / "file.txt" + file1.write_text("", encoding="utf-8") + + hidden_directory = subdirectory / ".hidden" + hidden_directory.mkdir() + + hidden_file = subdirectory / ".test.text" + hidden_file.write_text("", encoding="utf-8") + + expected = {str(subdirectory / entry) for entry in expected} + + assert set(list_function(str(subdirectory))) == expected + + +def test_read_yaml_string(): + config_without_env_var = """ + user: user + password: pass + """ + content = rasa.shared.utils.io.read_yaml(config_without_env_var) + assert content["user"] == "user" and content["password"] == "pass" + + +def test_read_yaml_string_with_env_var(): + config_with_env_var = """ + user: ${USER_NAME} + password: ${PASS} + """ + content = rasa.shared.utils.io.read_yaml(config_with_env_var) + assert content["user"] == "user" and content["password"] == "pass" + + +def test_read_yaml_string_with_multiple_env_vars_per_line(): + config_with_env_var = """ + user: ${USER_NAME} ${PASS} + password: ${PASS} + """ + content = rasa.shared.utils.io.read_yaml(config_with_env_var) + assert content["user"] == "user pass" and content["password"] == "pass" + + +def test_read_yaml_string_with_env_var_prefix(): + config_with_env_var_prefix = """ + user: db_${USER_NAME} + password: db_${PASS} + """ + content = rasa.shared.utils.io.read_yaml(config_with_env_var_prefix) + assert content["user"] == "db_user" and content["password"] == "db_pass" + + +def test_read_yaml_string_with_env_var_postfix(): + config_with_env_var_postfix = """ + user: ${USER_NAME}_admin + password: ${PASS}_admin + """ + content = rasa.shared.utils.io.read_yaml(config_with_env_var_postfix) + assert content["user"] == "user_admin" and content["password"] == "pass_admin" + + +def test_read_yaml_string_with_env_var_infix(): + config_with_env_var_infix = """ + user: db_${USER_NAME}_admin + password: db_${PASS}_admin + """ + content = rasa.shared.utils.io.read_yaml(config_with_env_var_infix) + assert content["user"] == "db_user_admin" and content["password"] == "db_pass_admin" + + +def test_read_yaml_string_with_env_var_not_exist(): + config_with_env_var_not_exist = """ + user: ${USER_NAME} + password: ${PASSWORD} + """ + with pytest.raises(RasaException): + rasa.shared.utils.io.read_yaml(config_with_env_var_not_exist) + + +def test_environment_variable_not_existing(): + content = "model: \n test: ${variable}" + with pytest.raises(RasaException): + rasa.shared.utils.io.read_yaml(content) + + +def test_environment_variable_dict_without_prefix_and_postfix(): + os.environ["variable"] = "test" + content = "model: \n test: ${variable}" + + content = rasa.shared.utils.io.read_yaml(content) + + assert content["model"]["test"] == "test" + + +def test_environment_variable_in_list(): + os.environ["variable"] = "test" + content = "model: \n - value\n - ${variable}" + + content = rasa.shared.utils.io.read_yaml(content) + + assert content["model"][1] == "test" + + +def test_environment_variable_dict_with_prefix(): + os.environ["variable"] = "test" + content = "model: \n test: dir/${variable}" + + content = rasa.shared.utils.io.read_yaml(content) + + assert content["model"]["test"] == "dir/test" + + +def test_environment_variable_dict_with_postfix(): + os.environ["variable"] = "test" + content = "model: \n test: ${variable}/dir" + + content = rasa.shared.utils.io.read_yaml(content) + + assert content["model"]["test"] == "test/dir" + + +def test_environment_variable_dict_with_prefix_and_with_postfix(): + os.environ["variable"] = "test" + content = "model: \n test: dir/${variable}/dir" + + content = rasa.shared.utils.io.read_yaml(content) + + assert content["model"]["test"] == "dir/test/dir" + + +def test_environment_variable_with_dollar_char(): + os.environ["variable1"] = "$test1" + os.environ["variable2"] = "test2" + content = "model: \n test1: ${variable1}\n test2: ${variable2}" + + content = rasa.shared.utils.io.read_yaml(content) + + assert content["model"]["test1"] == "$test1" + assert content["model"]["test2"] == "test2" + + +def test_environment_variable_with_dollar_char_in_the_middle(): + os.environ["variable1"] = "test$123" + content = "model: \n test1: ${variable1}" + + content = rasa.shared.utils.io.read_yaml(content) + + assert content["model"]["test1"] == "test$123" + + +def test_emojis_in_yaml(): + test_data = """ + data: + - one 😁💯 👩🏿‍💻👨🏿‍💻 + - two £ (?u)\\b\\w+\\b f\u00fcr + """ + content = rasa.shared.utils.io.read_yaml(test_data) + + assert content["data"][0] == "one 😁💯 👩🏿‍💻👨🏿‍💻" + assert content["data"][1] == "two £ (?u)\\b\\w+\\b für" + + +def test_emojis_in_tmp_file(): + test_data = """ + data: + - one 😁💯 👩🏿‍💻👨🏿‍💻 + - two £ (?u)\\b\\w+\\b f\u00fcr + """ + test_file = io_utils.create_temporary_file(test_data) + content = rasa.shared.utils.io.read_yaml_file(test_file) + + assert content["data"][0] == "one 😁💯 👩🏿‍💻👨🏿‍💻" + assert content["data"][1] == "two £ (?u)\\b\\w+\\b für" + + +def test_read_emojis_from_json(): + import json + + d = {"text": "hey 😁💯 👩🏿‍💻👨🏿‍💻🧜‍♂️(?u)\\b\\w+\\b} f\u00fcr"} + json_string = json.dumps(d, indent=2) + + content = rasa.shared.utils.io.read_yaml(json_string) + + expected = "hey 😁💯 👩🏿‍💻👨🏿‍💻🧜‍♂️(?u)\\b\\w+\\b} für" + assert content.get("text") == expected + + +def test_bool_str(): + test_data = """ + one: "yes" + two: "true" + three: "True" + """ + + content = rasa.shared.utils.io.read_yaml(test_data) + + assert content["one"] == "yes" + assert content["two"] == "true" + assert content["three"] == "True" + + +@pytest.mark.parametrize( + "should_preserve_key_order, expected_keys", + [(True, list(reversed(string.ascii_lowercase)))], +) +def test_dump_yaml_key_order( + tmp_path: Path, should_preserve_key_order: bool, expected_keys: List[Text] +): + file = tmp_path / "test.yml" + + # create YAML file with keys in reverse-alphabetical order + content = "" + for i in reversed(string.ascii_lowercase): + content += f"{i}: {uuid.uuid4().hex}\n" + + file.write_text(content) + + # load this file and ensure keys are in correct reverse-alphabetical order + data = rasa.shared.utils.io.read_yaml_file(file) + assert list(data.keys()) == list(reversed(string.ascii_lowercase)) + + # dumping `data` will result in alphabetical or reverse-alphabetical list of keys, + # depending on the value of `should_preserve_key_order` + rasa.shared.utils.io.write_yaml( + data, file, should_preserve_key_order=should_preserve_key_order + ) + with file.open() as f: + keys = [line.split(":")[0] for line in f.readlines()] + + assert keys == expected_keys + + +@pytest.mark.parametrize( + "source, target", + [ + # ordinary dictionary + ({"b": "b", "a": "a"}, OrderedDict({"b": "b", "a": "a"})), + # dict with list + ({"b": [1, 2, 3]}, OrderedDict({"b": [1, 2, 3]})), + # nested dict + ({"b": {"c": "d"}}, OrderedDict({"b": OrderedDict({"c": "d"})})), + # doubly-nested dict + ( + {"b": {"c": {"d": "e"}}}, + OrderedDict({"b": OrderedDict({"c": OrderedDict({"d": "e"})})}), + ), + # a list is not affected + ([1, 2, 3], [1, 2, 3]), + # a string also isn't + ("hello", "hello"), + ], +) +def test_convert_to_ordered_dict(source: Any, target: Any): + assert rasa.shared.utils.io.convert_to_ordered_dict(source) == target + + def _recursively_check_dict_is_ordered_dict(d): + if isinstance(d, dict): + assert isinstance(d, OrderedDict) + for value in d.values(): + _recursively_check_dict_is_ordered_dict(value) + + # ensure nested dicts are converted correctly + _recursively_check_dict_is_ordered_dict(target) + + +def test_create_directory_for_file(tmp_path: Path): + file = str(tmp_path / "dir" / "test.txt") + + rasa.shared.utils.io.create_directory_for_file(str(file)) + assert not os.path.exists(file) + assert os.path.exists(os.path.dirname(file)) + + +def test_write_utf_8_yaml_file(tmp_path: Path): + """This test makes sure that dumping a yaml doesn't result in Uxxxx sequences + but rather directly dumps the unicode character.""" + + file_path = str(tmp_path / "test.yml") + data = {"data": "amazing 🌈"} + + rasa.shared.utils.io.write_yaml(data, file_path) + assert rasa.shared.utils.io.read_file(file_path) == "data: amazing 🌈\n" + + +def test_write_json_file(tmp_path: Path): + expected = {"abc": "dasds", "list": [1, 2, 3, 4], "nested": {"a": "b"}} + file_path = str(tmp_path / "abc.txt") + + rasa.shared.utils.io.dump_obj_as_json_to_file(file_path, expected) + assert rasa.shared.utils.io.read_json_file(file_path) == expected + + +def test_create_directory_if_new(tmp_path: Path): + directory = str(tmp_path / "a" / "b") + rasa.shared.utils.io.create_directory(directory) + + assert os.path.exists(directory) + + +def test_create_directory_if_already_exists(tmp_path: Path): + # This should not throw an exception + rasa.shared.utils.io.create_directory(str(tmp_path)) + assert True + + +def test_raise_deprecation_warning(): + with pytest.warns(FutureWarning) as record: + rasa.shared.utils.io.raise_deprecation_warning( + "This feature is deprecated.", warn_until_version="3.0.0" + ) + + assert len(record) == 1 + assert ( + record[0].message.args[0] + == "This feature is deprecated. (will be removed in 3.0.0)" + ) + + +def test_raise_deprecation_warning_version_already_in_message(): + with pytest.warns(FutureWarning) as record: + rasa.shared.utils.io.raise_deprecation_warning( + "This feature is deprecated and will be removed in 3.0.0!", + warn_until_version="3.0.0", + ) + + assert len(record) == 1 + assert ( + record[0].message.args[0] + == "This feature is deprecated and will be removed in 3.0.0!" + ) + + +def test_raise_deprecation_warning_default(): + with pytest.warns(FutureWarning) as record: + rasa.shared.utils.io.raise_deprecation_warning("This feature is deprecated.") + + assert len(record) == 1 + assert record[0].message.args[0] == ( + f"This feature is deprecated. " + f"(will be removed in {NEXT_MAJOR_VERSION_FOR_DEPRECATIONS})" + ) + + +def test_read_file_with_wrong_encoding(tmp_path: Path): + file = tmp_path / "myfile.txt" + file.write_text("ä", encoding="latin-1") + with pytest.raises(FileIOException): + rasa.shared.utils.io.read_file(file) + + +@pytest.mark.parametrize("config_file", Path("data", "configs_for_docs").glob("*.yml")) +def test_validate_config_file(config_file: Path): + # does not raise + rasa.shared.utils.io.read_model_configuration(config_file) + + +def test_validate_config_file_with_extra_keys(tmp_path: Path): + content = textwrap.dedent( + """ + recipe: default.v1 + language: en + pipeline: + policies: + + importers: + - RasaFileImporter + """ + ) + config_file = tmp_path / "config.yml" + config_file.write_text(content) + + rasa.shared.utils.io.read_model_configuration(config_file) + + +@pytest.mark.parametrize( + "config", + [ + # Pre 2.x pipeline templates are invalid + textwrap.dedent( + """ + pipeline: supervised_embeddings + """ + ), + # Each list item needs the `name` property + textwrap.dedent( + """ + pipeline: + - DIETClassier + policies: + """ + ), + # Name property is missing + textwrap.dedent( + """ + pipeline: + policies: + - some_attribute: "lala" + """ + ), + # Name property is not a string + textwrap.dedent( + """ + pipeline: + policies: + - name: 1234 + """ + ), + # Invalid training data version + textwrap.dedent( + """ + version: 2.0 + policies: + pipeline: + """ + ), + # Language has wrong type + textwrap.dedent( + """ + language: [] + policies: + pipeline: + """ + ), + ], +) +def test_invalid_config_files(config: Text, tmp_path: Path): + config_file = tmp_path / "config.yml" + config_file.write_text(config) + with pytest.raises(rasa.shared.utils.validation.YamlValidationException): + rasa.shared.utils.io.read_model_configuration(config_file) + + +@pytest.mark.parametrize( + "content, expected", + [ + ("rest:", {"rest": None}), + ( + textwrap.dedent( + """ + tracker_store: + password: test + """ + ), + {"tracker_store": {"password": "test"}}, + ), + ], +) +def test_read_config_file(tmp_path: Path, content: Text, expected: Dict): + config_file = tmp_path / "file.yml" + config_file.write_text(content) + + assert rasa.shared.utils.io.read_config_file(config_file) == expected + + +@pytest.mark.parametrize( + "content", + [ + "text", + textwrap.dedent( + """ + - item1 + - item2 + """ + ), + ], +) +def test_read_invalid_config_file(tmp_path: Path, content: Text): + config_file = tmp_path / "file.yml" + config_file.write_text(content) + + with pytest.raises(rasa.shared.utils.validation.YamlValidationException): + rasa.shared.utils.io.read_model_configuration(config_file) + + +@pytest.mark.parametrize( + "file,keys,expected_result", + [ + ("data/test_yaml_stories/stories.yml", ["stories"], True), + ("data/test_yaml_stories/stories.yml", ["something_else"], False), + ("data/test_yaml_stories/stories.yml", ["stories", "something_else"], True), + ( + "data/test_domains/default_retrieval_intents.yml", + ["intents", "responses"], + True, + ), + ("data/test_yaml_stories/rules_without_stories.yml", ["rules"], True), + ("data/test_yaml_stories/rules_without_stories.yml", ["stories"], False), + ], +) +async def test_is_key_in_yaml(file: Text, keys: List[Text], expected_result: bool): + assert rasa.shared.utils.io.is_key_in_yaml(file, *keys) == expected_result + + +async def test_is_key_in_yaml_with_unicode_files(): + # This shouldn't raise + assert rasa.shared.utils.io.is_key_in_yaml( + "./data/test_nlu_no_responses/nlu_with_unicode.yml", "nlu" + ) + + +@pytest.mark.parametrize("length", [4, 8, 16, 32]) +def test_random_string(length): + + s = rasa.shared.utils.io.random_string(length) + s2 = rasa.shared.utils.io.random_string(length) + + assert len(s) == length + assert len(s2) == length + assert s != s2 + + +@pytest.mark.parametrize( + "container", + [ + {}, + {"hello": "world"}, + {1: 2}, + {"foo": ["bar"]}, + {"a": []}, + [], + ["a"], + [{}], + [None], + ], +) +def test_fingerprint_containers(container): + assert rasa.shared.utils.io.deep_container_fingerprint( + container + ) == rasa.shared.utils.io.deep_container_fingerprint(copy.deepcopy(container)) + + +def test_deep_container_fingerprint_can_use_instance_fingerprint(): + m1 = np.asarray([[0.5, 3.1, 3.0], [1.1, 1.2, 1.3], [4.7, 0.3, 2.7]]) + f = Features(m1, "sentence", "text", "CountVectorsFeaturizer") + assert rasa.shared.utils.io.deep_container_fingerprint(f) == f.fingerprint() + + +@pytest.mark.skip_on_windows +def test_handle_print_blocking(monkeypatch: MonkeyPatch): + mock = MagicMock() + monkeypatch.setattr(rasa.shared.utils.io, "portalocker", mock) + + print_output = "Test block handling" + rasa.shared.utils.io.handle_print_blocking(print_output) + + assert mock.Lock.called + assert mock.Lock.call_args[0][0] == sys.stdout + + # print specific calls + # STDOUT write call + assert mock.mock_calls[2][1][0] == print_output + # STDOUT was flushed before __exit__ + assert "flush" in mock.mock_calls[-2][0] + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only test") +def test_handle_print_blocking_windows(monkeypatch: MonkeyPatch): + mock = MagicMock() + mock_print = MagicMock() + monkeypatch.setattr(rasa.shared.utils.io, "portalocker", mock) + monkeypatch.setattr(builtins, "print", mock_print) + + print_output = "Test block handling" + rasa.shared.utils.io.handle_print_blocking(print_output) + + assert mock.Lock.called + assert mock.Lock.call_args[0][0] == sys.stdout + + assert mock_print.called + assert mock_print.call_args[0][0] == print_output + + from colorama import ansitowin32 + + assert isinstance(mock_print.call_args[1]["file"], ansitowin32.StreamWrapper) + assert mock_print.call_args[1]["flush"] diff --git a/tests/shared/utils/test_validation.py b/tests/shared/utils/test_validation.py new file mode 100644 index 0000000..c38f823 --- /dev/null +++ b/tests/shared/utils/test_validation.py @@ -0,0 +1,382 @@ +from typing import Text +from threading import Thread + +import pytest + +from pep440_version_utils import Version + +from rasa.shared.exceptions import YamlException, SchemaValidationError +import rasa.shared.utils.io +import rasa.shared.utils.validation as validation_utils +import rasa.utils.io as io_utils +import rasa.shared.nlu.training_data.schemas.data_schema as schema +from rasa.shared.constants import ( + CONFIG_SCHEMA_FILE, + DOMAIN_SCHEMA_FILE, + LATEST_TRAINING_DATA_FORMAT_VERSION, +) +from rasa.shared.nlu.training_data.formats.rasa_yaml import NLU_SCHEMA_FILE +from rasa.shared.utils.validation import KEY_TRAINING_DATA_FORMAT_VERSION + + +@pytest.mark.parametrize( + "file, schema", + [ + ("data/test_moodbot/domain.yml", DOMAIN_SCHEMA_FILE), + ("data/test_config/config_defaults.yml", CONFIG_SCHEMA_FILE), + ("data/test_config/config_supervised_embeddings.yml", CONFIG_SCHEMA_FILE), + ("data/test_config/config_crf_custom_features.yml", CONFIG_SCHEMA_FILE), + ], +) +def test_validate_yaml_schema(file, schema): + # should raise no exception + validation_utils.validate_yaml_schema(rasa.shared.utils.io.read_file(file), schema) + + +def test_validate_yaml_schema_with_package_name(): + # should raise no exception + file = "data/test_moodbot/domain.yml" + schema = DOMAIN_SCHEMA_FILE + validation_utils.validate_yaml_schema( + rasa.shared.utils.io.read_file(file), schema, package_name="rasa" + ) + + +def test_validate_yaml_schema_with_random_package_name_fails(): + # should raise no exception + file = "data/test_moodbot/domain.yml" + schema = DOMAIN_SCHEMA_FILE + + with pytest.raises(ModuleNotFoundError): + validation_utils.validate_yaml_schema( + rasa.shared.utils.io.read_file(file), schema, package_name="rasa_foo_bar_42" + ) + + +@pytest.mark.parametrize( + "file, schema", + [ + ("data/test_domains/valid_actions.yml", DOMAIN_SCHEMA_FILE), + ], +) +def test_validate_yaml_schema_actions(file: Text, schema: Text): + # should raise no exception + validation_utils.validate_yaml_schema(rasa.shared.utils.io.read_file(file), schema) + + +@pytest.mark.parametrize( + "content, schema", + [ + ( + """ + intents: + - greet + + entities: + - name + + responses: + utter_greet: + - text: hey there! + + actions: + - utter_default: {send_domain: 1} + """, + DOMAIN_SCHEMA_FILE, + ), + ( + """ + intents: + - greet + + entities: + - name + + responses: + utter_greet: + - text: hey there! + + actions: + - utter_default: {send_domain: 0} + """, + DOMAIN_SCHEMA_FILE, + ), + ( + """ + intents: + - greet + + entities: + - name + + responses: + utter_greet: + - text: hey there! + + actions: + - utter_default: {send_domain: Ttrue} + """, + DOMAIN_SCHEMA_FILE, + ), + ( + """ + intents: + - greet + + entities: + - name + + responses: + utter_greet: + - text: hey there! + + actions: + - utter_default: {send_domain: ""} + """, + DOMAIN_SCHEMA_FILE, + ), + ], +) +def test_invalid_send_domain_value_in_actions(content: Text, schema: Text): + with pytest.raises(validation_utils.YamlValidationException): + validation_utils.validate_yaml_schema(content, schema) + + +@pytest.mark.parametrize( + "file, schema", + [ + ("data/test_domains/invalid_format.yml", DOMAIN_SCHEMA_FILE), + ("data/test_domains/wrong_response_format.yml", DOMAIN_SCHEMA_FILE), + ("data/test_domains/wrong_custom_response_format.yml", DOMAIN_SCHEMA_FILE), + ("data/test_domains/empty_response_format.yml", DOMAIN_SCHEMA_FILE), + ], +) +def test_validate_yaml_schema_raise_exception(file: Text, schema: Text): + with pytest.raises(YamlException): + validation_utils.validate_yaml_schema( + rasa.shared.utils.io.read_file(file), schema + ) + + +def test_validate_yaml_schema_raise_exception_null_text(): + domain = f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_ask_email: + - text: What is your email ID? + utter_ask_name: + - text: null + """ + with pytest.raises(validation_utils.YamlValidationException) as e: + validation_utils.validate_yaml_schema(domain, DOMAIN_SCHEMA_FILE) + + assert ( + "Missing 'text' or 'custom' key in response or null 'text' value in response." + in str(e.value) + ) + + +def test_validate_yaml_schema_raise_exception_extra_hyphen_for_image(): + domain = f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_cheer_up: + - image: https://i.imgur.com/nGF1K8f.jpg + - text: Here is something to cheer you up + """ + with pytest.raises(validation_utils.YamlValidationException) as e: + validation_utils.validate_yaml_schema(domain, DOMAIN_SCHEMA_FILE) + + assert ( + "Missing 'text' or 'custom' key in response or null 'text' value in response." + in str(e.value) + ) + + +def test_example_training_data_is_valid(): + demo_json = "data/examples/rasa/demo-rasa.json" + data = rasa.shared.utils.io.read_json_file(demo_json) + validation_utils.validate_training_data(data, schema.rasa_nlu_data_schema()) + + +@pytest.mark.parametrize( + "invalid_data", + [ + {"wrong_top_level": []}, + ["this is not a toplevel dict"], + { + "rasa_nlu_data": { + "common_examples": [ + { + "text": "mytext", + "entities": [{"start": "INVALID", "end": 0, "entity": "x"}], + } + ] + } + }, + ], +) +def test_validate_training_data_is_throwing_exceptions(invalid_data): + with pytest.raises(SchemaValidationError): + validation_utils.validate_training_data( + invalid_data, schema.rasa_nlu_data_schema() + ) + + +def test_url_data_format(): + data = """ + { + "rasa_nlu_data": { + "entity_synonyms": [ + { + "value": "nyc", + "synonyms": ["New York City", "nyc", "the big apple"] + } + ], + "common_examples" : [ + { + "text": "show me flights to New York City", + "intent": "unk", + "entities": [ + { + "entity": "destination", + "start": 19, + "end": 32, + "value": "NYC" + } + ] + } + ] + } + }""" + fname = io_utils.create_temporary_file( + data.encode(rasa.shared.utils.io.DEFAULT_ENCODING), + suffix="_tmp_training_data.json", + mode="w+b", + ) + data = rasa.shared.utils.io.read_json_file(fname) + assert data is not None + validation_utils.validate_training_data(data, schema.rasa_nlu_data_schema()) + + +@pytest.mark.parametrize( + "invalid_data", + [ + {"group": "a", "role": "c", "value": "text"}, + ["this is not a toplevel dict"], + {"entity": 1, "role": "c", "value": "text"}, + {"entity": "e", "role": None, "value": "text"}, + ], +) +def test_validate_entity_dict_is_throwing_exceptions(invalid_data): + with pytest.raises(SchemaValidationError): + validation_utils.validate_training_data( + invalid_data, schema.entity_dict_schema() + ) + + +@pytest.mark.parametrize( + "data", + [ + {"entity": "e", "group": "a", "role": "c", "value": "text"}, + {"entity": "e"}, + {"entity": "e", "value": "text"}, + {"entity": "e", "group": "a"}, + {"entity": "e", "role": "c"}, + {"entity": "e", "role": "c", "value": "text"}, + {"entity": "e", "group": "a", "value": "text"}, + {"entity": "e", "group": "a", "role": "c"}, + {"entity": "e", "value": 3}, + {"entity": "e", "value": "3"}, + ], +) +def test_entity_dict_is_valid(data): + validation_utils.validate_training_data(data, schema.entity_dict_schema()) + + +async def test_future_training_data_format_version_not_compatible(): + + next_minor = str(Version(LATEST_TRAINING_DATA_FORMAT_VERSION).next_minor()) + + incompatible_version = {KEY_TRAINING_DATA_FORMAT_VERSION: next_minor} + + with pytest.warns(UserWarning): + assert not validation_utils.validate_training_data_format_version( + incompatible_version, "" + ) + + +async def test_compatible_training_data_format_version(): + + prev_major = str(Version("1.0")) + + compatible_version_1 = {KEY_TRAINING_DATA_FORMAT_VERSION: prev_major} + compatible_version_2 = { + KEY_TRAINING_DATA_FORMAT_VERSION: LATEST_TRAINING_DATA_FORMAT_VERSION + } + + for version in [compatible_version_1, compatible_version_2]: + with pytest.warns(None): + assert validation_utils.validate_training_data_format_version(version, "") + + +async def test_invalid_training_data_format_version_warns(): + + invalid_version_1 = {KEY_TRAINING_DATA_FORMAT_VERSION: 2.0} + invalid_version_2 = {KEY_TRAINING_DATA_FORMAT_VERSION: "Rasa"} + + for version in [invalid_version_1, invalid_version_2]: + with pytest.warns(UserWarning): + assert validation_utils.validate_training_data_format_version(version, "") + + +def test_concurrent_schema_validation(): + successful_results = [] + + def validate() -> None: + payload = f""" +version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +nlu: +- intent: greet + examples: | + - hey + - hello + - hi + - hello there + - good morning + - good evening + - moin + - hey there + - let's go + - hey dude + - goodmorning + - goodevening + - good afternoon +- intent: goodbye + examples: | + - good afternoon + - cu + - good by + - cee you later + - good night + - bye + - goodbye + - have a nice day + - see you around + - bye bye + - see you later + """ + rasa.shared.utils.validation.validate_yaml_schema(payload, NLU_SCHEMA_FILE) + successful_results.append(True) + + threads = [] + for i in range(10): + threads.append(Thread(target=validate)) + + for thread in threads: + thread.start() + + for thread in threads: + thread.join() + + assert len(successful_results) == len(threads) diff --git a/tests/test_default_project.py b/tests/test_default_project.py new file mode 100644 index 0000000..aa7502d --- /dev/null +++ b/tests/test_default_project.py @@ -0,0 +1,57 @@ +from typing import Any, Dict, Text + +import pytest +import copy + +import re + +from pytest import Testdir + +from rasa.__main__ import create_argument_parser +import rasa.cli.data +import rasa.cli.utils +import rasa.cli.scaffold +import rasa.cli.train +import rasa.cli.shell +import rasa.shared.utils.io +from rasa.shared.constants import ASSISTANT_ID_KEY +from rasa.utils.common import EXPECTED_WARNINGS + + +@pytest.mark.timeout(300, func_only=True) +def test_default_project_has_no_warnings( + testdir: Testdir, default_config: Dict[Text, Any] +): + parser = create_argument_parser() + rasa.cli.scaffold.create_initial_project(".") + + config = copy.deepcopy(default_config) + # change default assistant id value to prevent config validation errors + config[ASSISTANT_ID_KEY] = "some_unique_assistant_name" + for _, items in config.items(): + for item in items: + if "epochs" in item: + item["epochs"] = 1 + item["evaluate_every_number_of_epochs"] = -1 + + rasa.shared.utils.io.write_yaml(config, "config.yml") + + with pytest.warns() as warning_recorder: + arg_namespace = parser.parse_args(["data", "validate"]) + rasa.cli.utils.validate_files( + arg_namespace.fail_on_warnings, + arg_namespace.max_history, + rasa.cli.data._build_training_data_importer(arg_namespace), + ) + rasa.cli.train.run_training(parser.parse_args(["train"])) + + # pytest.warns would override any warning filters that we could set + assert not [ + warning.message + for warning in warning_recorder.list + if not any( + type(warning.message) == warning_type + and re.search(warning_message, str(warning.message)) + for warning_type, warning_message in EXPECTED_WARNINGS + ) + ] diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py new file mode 100644 index 0000000..2475721 --- /dev/null +++ b/tests/test_dependencies.py @@ -0,0 +1,12 @@ +import sys +import pkg_resources + + +def test_tensorflow_text_install(): + installed_packages_list = [i.key for i in list(pkg_resources.working_set)] + tf_text_installed = "tensorflow-text" in installed_packages_list + + if sys.platform == "win32": + assert not tf_text_installed + else: + assert tf_text_installed diff --git a/tests/test_memory_leak.py b/tests/test_memory_leak.py new file mode 100644 index 0000000..50ab901 --- /dev/null +++ b/tests/test_memory_leak.py @@ -0,0 +1,228 @@ +import abc +import json +import subprocess +import sys +import time +from pathlib import Path +from typing import Text, List, Tuple, Optional, Union + +import memory_profiler +import psutil +import pytest + +import rasa +import rasa.shared.utils.io +from rasa.utils.common import TempDirectoryPath, get_temp_dir_name + +PROFILING_INTERVAL = 0.1 + +# Enable this to plot the results locally +WRITE_RESULTS_TO_DISK = False + + +def _custom_default_config( + tmp_path: Union[Path, Text], epochs: int, max_history: Optional[int] = -1 +) -> Text: + # Override default config to use custom amount of epochs + default_config = Path("rasa", "shared", "importers", "default_config.yml") + config = rasa.shared.utils.io.read_yaml_file(default_config) + + for model_part, items in config.items(): + for item in items: + if "epochs" in item: + item["epochs"] = epochs + if "max_history" in item and max_history != -1: + item["max_history"] = None + + config_for_test = Path(tmp_path) / "test_config.yml" + rasa.shared.utils.io.write_yaml(config, config_for_test) + + return str(config_for_test) + + +class MemoryLeakTest(abc.ABC): + """Generic template for memory leak tests.""" + + @property + def max_memory_threshold_mb(self) -> float: + return 1000 + + @pytest.fixture + @abc.abstractmethod + def name_for_dumped_files(self) -> Text: + raise NotImplementedError + + @abc.abstractmethod + def function_to_profile(self) -> None: + raise NotImplementedError + + @pytest.mark.timeout(720, func_only=True) + def test_for_memory_leak(self, name_for_dumped_files: Text, tmp_path: Path) -> None: + # Run as separate process to avoid other things affecting the memory usage. + # Unfortunately `memory-profiler` doesn't work properly with + # `multiprocessing.Process` as it can't handle the process exit + process = subprocess.Popen( + [ + sys.executable, + "-c", + ( + f"from {__name__} import {self.__class__.__name__}; " + f"t = {self.__class__.__name__}();" + f"t.function_to_profile()" + ), + ], + # Force TensorFlow to use CPU so we can track the memory usage + env={"CUDA_VISIBLE_DEVICES": "-1"}, + ) + + # Wait until process is running to avoid race conditions with the memory + # profiling + while not psutil.pid_exists(process.pid): + time.sleep(0.01) + + results = memory_profiler.memory_usage( + process, interval=PROFILING_INTERVAL, include_children=True, timestamps=True + ) + + # `memory-profiler` sometimes adds `None` values at the end which we don't need + results = [ + memory_timestamp + for memory_timestamp in results + if memory_timestamp is not None + ] + + if WRITE_RESULTS_TO_DISK: + self._write_results(name_for_dumped_files, results) + + max_memory_usage = max(results, key=lambda memory_time: memory_time[0])[0] + assert max_memory_usage < self.max_memory_threshold_mb + + @staticmethod + def _write_results(base_name: Text, results: List[Tuple[float, float]]) -> None: + mprof_plot = Path(f"{base_name}_plot.txt") + mprof_results = Path(f"{base_name}_raw.json") + + # plot this via `mprof plot mprof_result.txt` + with open(mprof_plot, "w") as f: + for memory, timestamp in results: + f.write(f"MEM {memory:.6f} {timestamp:.4f}\n") + + # dump result as json to be able analyze them without re-running the test + with open(mprof_results, "w") as f: + f.write(json.dumps(results)) + + +class TestNLULeakManyEpochs(MemoryLeakTest): + """Tests for memory leaks in NLU components when training with many epochs.""" + + @property + def epochs(self) -> int: + return 30 + + @property + def max_memory_threshold_mb(self) -> float: + return 2200 + + def function_to_profile(self) -> None: + import rasa.model_training + + with TempDirectoryPath(get_temp_dir_name()) as temp_dir: + rasa.model_training.train_nlu( + _custom_default_config(temp_dir, epochs=self.epochs), + Path("data", "test_nlu_no_responses", "sara_nlu_data.yml"), + output=temp_dir, + ) + + @pytest.fixture() + def name_for_dumped_files(self) -> Text: + return ( + f"memory_usage_rasa_nlu_{rasa.__version__}_" + f"epochs{self.epochs}_training_runs1" + ) + + +class TestCoreLeakManyEpochs(MemoryLeakTest): + """Tests for memory leaks in Core policies when training with many epochs.""" + + @property + def epochs(self) -> int: + return 200 + + @property + def max_memory_threshold_mb(self) -> float: + return 2000 + + def function_to_profile(self) -> None: + import rasa.model_training + + with TempDirectoryPath(get_temp_dir_name()) as temp_dir: + rasa.model_training.train_core( + "data/test_domains/default_with_slots.yml", + _custom_default_config(temp_dir, epochs=self.epochs, max_history=None), + "data/test_yaml_stories/stories_defaultdomain.yml", + output=temp_dir, + additional_arguments={"augmentation_factor": 20}, + ) + + @pytest.fixture() + def name_for_dumped_files(self) -> Text: + return ( + f"memory_usage_rasa_core_{rasa.__version__}_" + f"epochs{self.epochs}_training_runs1" + ) + + +class TestCRFDenseFeaturesLeak(MemoryLeakTest): + """Tests for memory leaks in NLU the CRF when using dense features.""" + + @property + def epochs(self) -> int: + return 1 + + @property + def max_memory_threshold_mb(self) -> float: + return 1600 + + def function_to_profile(self) -> None: + import rasa.model_training + + config = { + "pipeline": [ + {"name": "SpacyNLP"}, + {"name": "SpacyTokenizer"}, + {"name": "SpacyFeaturizer"}, + { + "name": "CRFEntityExtractor", + "features": [ + ["pos", "pos2"], + [ + "bias", + "prefix5", + "prefix2", + "suffix5", + "suffix3", + "suffix2", + "pos", + "pos2", + "digit", + "text_dense_features", + ], + ["pos", "pos2"], + ], + }, + ] + } + + with TempDirectoryPath(get_temp_dir_name()) as temp_dir: + config_for_test = Path(temp_dir) / "test_config.yml" + rasa.shared.utils.io.write_yaml(config, config_for_test) + + rasa.model_training.train_nlu( + str(config_for_test), + str(Path("data", "test_nlu_no_responses", "sara_nlu_data.yml")), + output=temp_dir, + ) + + @pytest.fixture() + def name_for_dumped_files(self) -> Text: + return f"memory_usage_rasa_nlu_crf_dense_{rasa.__version__}_" diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..ec77bbb --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,48 @@ +import os +import time +from pathlib import Path +from typing import Text, Optional + +import pytest +from _pytest.monkeypatch import MonkeyPatch + +import rasa +import rasa.constants +import rasa.shared.utils.io +import rasa.model +from rasa.exceptions import ModelNotFound + + +def test_get_latest_model(tmp_path: Path): + path = tmp_path / "test_get_latest_model" + path.mkdir() + Path(path / "model_one.tar.gz").touch() + + # create second model later to be registered as distinct in Windows + time.sleep(0.1) + Path(path / "model_two.tar.gz").touch() + + path_of_latest = os.path.join(path, "model_two.tar.gz") + assert rasa.model.get_latest_model(str(path)) == path_of_latest + + +def test_get_local_model(trained_rasa_model: str): + assert rasa.model.get_local_model(trained_rasa_model) == trained_rasa_model + + +@pytest.mark.parametrize("model_path", ["foobar", "rasa", "README.md", None]) +def test_get_local_model_exception(model_path: Optional[Text]): + with pytest.raises(ModelNotFound): + rasa.model.get_local_model(model_path) + + +def test_model_fingerprint(): + # As this tests in the root of the cloned `rasa` repo there is always a Git repo + # and hence a fingerprint. + assert rasa.model.project_fingerprint() + + +def test_model_fingerprint_with_no_git(monkeypatch: MonkeyPatch, tmp_path: Path): + monkeypatch.chdir(tmp_path) + + assert rasa.model.project_fingerprint() is None diff --git a/tests/test_model_testing.py b/tests/test_model_testing.py new file mode 100644 index 0000000..a0e42bb --- /dev/null +++ b/tests/test_model_testing.py @@ -0,0 +1,641 @@ +import asyncio +import sys +from pathlib import Path +import textwrap +from typing import List, Text + +import pytest +from _pytest.capture import CaptureFixture +from _pytest.monkeypatch import MonkeyPatch + +import rasa.shared.utils.io +import rasa.utils.io +from rasa.core.agent import Agent +from rasa.shared.core.events import UserUttered +from rasa.core.test import ( + EvaluationStore, + WronglyClassifiedUserUtterance, + WronglyPredictedAction, +) +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.training_data.story_writer.yaml_story_writer import ( + YAMLStoryWriter, +) +import rasa.model +import rasa.cli.utils +from rasa.nlu.test import NO_ENTITY +import rasa.core +from rasa.shared.nlu.constants import ( + ENTITY_ATTRIBUTE_VALUE, + ENTITY_ATTRIBUTE_START, + ENTITY_ATTRIBUTE_END, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_TEXT, +) +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION + + +def monkeypatch_get_latest_model(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + latest_model = tmp_path / "my_test_model.tar.gz" + monkeypatch.setattr(rasa.model, "get_latest_model", lambda: str(latest_model)) + + +def test_get_sanitized_model_directory_when_not_passing_model( + capsys: CaptureFixture, tmp_path: Path, monkeypatch: MonkeyPatch +): + from rasa.model_testing import _get_sanitized_model_directory + + monkeypatch_get_latest_model(tmp_path, monkeypatch) + + # Create a fake model on disk so that `is_file` returns `True` + latest_model = Path(rasa.model.get_latest_model()) + latest_model.touch() + + # Input: default model file + # => Should return containing directory + new_modeldir = _get_sanitized_model_directory(str(latest_model)) + captured = capsys.readouterr() + assert not captured.out + assert new_modeldir == str(latest_model.parent) + + +def test_get_sanitized_model_directory_when_passing_model_file_explicitly( + capsys: CaptureFixture, tmp_path: Path, monkeypatch: MonkeyPatch +): + from rasa.model_testing import _get_sanitized_model_directory + + monkeypatch_get_latest_model(tmp_path, monkeypatch) + + other_model = tmp_path / "my_test_model1.tar.gz" + assert str(other_model) != rasa.model.get_latest_model() + other_model.touch() + + # Input: some file + # => Should return containing directory and print a warning + new_modeldir = _get_sanitized_model_directory(str(other_model)) + captured = capsys.readouterr() + assert captured.out + assert new_modeldir == str(other_model.parent) + + +def test_get_sanitized_model_directory_when_passing_other_input( + capsys: CaptureFixture, tmp_path: Path, monkeypatch: MonkeyPatch +): + from rasa.model_testing import _get_sanitized_model_directory + + monkeypatch_get_latest_model(tmp_path, monkeypatch) + + # Input: anything that is not an existing file + # => Should return input + modeldir = "random_dir" + assert not Path(modeldir).is_file() + new_modeldir = _get_sanitized_model_directory(modeldir) + captured = capsys.readouterr() + assert not captured.out + assert new_modeldir == modeldir + + +@pytest.mark.parametrize( + "targets,predictions,expected_precision,expected_fscore,expected_accuracy", + [ + ( + ["no_entity", "location", "no_entity", "location", "no_entity"], + ["no_entity", "location", "no_entity", "no_entity", "person"], + 1.0, + 0.6666666666666666, + 3 / 5, + ), + ( + ["no_entity", "no_entity", "no_entity", "no_entity", "person"], + ["no_entity", "no_entity", "no_entity", "no_entity", "no_entity"], + 0.0, + 0.0, + 4 / 5, + ), + ], +) +def test_get_evaluation_metrics( + targets: List[Text], + predictions: List[Text], + expected_precision: float, + expected_fscore: float, + expected_accuracy: float, +): + from rasa.model_testing import get_evaluation_metrics + + report, precision, f1, accuracy = get_evaluation_metrics( + targets, predictions, True, exclude_label=NO_ENTITY + ) + + assert f1 == expected_fscore + assert precision == expected_precision + assert accuracy == expected_accuracy + assert NO_ENTITY not in report + + +@pytest.mark.parametrize( + "report_in,accuracy,report_out", + [ + ( + { + "location": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "micro avg": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "macro avg": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "weighted avg": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + }, + 0.8, + { + "location": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "micro avg": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "macro avg": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "weighted avg": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "accuracy": 0.8, + }, + ), + ( + { + "location": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "macro avg": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "weighted avg": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "accuracy": 0.8, + }, + 0.8, + { + "location": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "micro avg": { + "precision": 0.8, + "recall": 0.8, + "f1-score": 0.8, + "support": 2, + }, + "macro avg": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "weighted avg": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "accuracy": 0.8, + }, + ), + ], +) +def test_make_classification_report_complete( + report_in: dict, accuracy: float, report_out: dict +): + from rasa.model_testing import make_classification_report_complete + + report_out_actual = make_classification_report_complete(report_in, accuracy) + assert report_out == report_out_actual + + +@pytest.mark.parametrize( + "report_in", + [ + ( + { + "location": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "micro avg": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "macro avg": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "weighted avg": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "accuracy": 0.8, + }, + ), + ( + { + "location": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "macro avg": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + "weighted avg": { + "precision": 1.0, + "recall": 0.5, + "f1-score": 0.6666666666666666, + "support": 2, + }, + }, + ), + ], +) +def test_make_classification_report_complete_raises_clf_report_exception( + report_in: dict, +): + from rasa.model_testing import ( + ClassificationReportException, + make_classification_report_complete, + ) + + with pytest.raises(ClassificationReportException): + make_classification_report_complete(report_in, accuracy=0.8) + + +@pytest.mark.parametrize( + "targets,exclude_label,expected", + [ + ( + ["no_entity", "location", "location", "location", "person"], + NO_ENTITY, + ["location", "person"], + ), + ( + ["no_entity", "location", "location", "location", "person"], + None, + ["no_entity", "location", "person"], + ), + (["no_entity"], NO_ENTITY, []), + (["location", "location", "location"], NO_ENTITY, ["location"]), + ([], None, []), + ], +) +def test_get_label_set(targets: List[Text], exclude_label: Text, expected: List[Text]): + from rasa.model_testing import get_unique_labels + + actual = get_unique_labels(targets, exclude_label) + assert set(expected) == set(actual) + + +async def test_e2e_warning_if_no_nlu_model( + monkeypatch: MonkeyPatch, trained_core_model: Text, capsys: CaptureFixture +): + from rasa.model_testing import test_core + + # Patching is bit more complicated as we have a module `train` and function + # with the same name 😬 + monkeypatch.setattr( + sys.modules["rasa.core.test"], "test", asyncio.coroutine(lambda *_, **__: True) + ) + + await test_core(trained_core_model, use_conversation_test_files=True) + + assert "No NLU model found. Using default" in capsys.readouterr().out + + +def test_write_classification_errors(): + evaluation = EvaluationStore( + action_predictions=["utter_goodbye"], + action_targets=["utter_greet"], + intent_predictions=["goodbye"], + intent_targets=["greet"], + entity_predictions=None, + entity_targets=None, + ) + events = [ + WronglyClassifiedUserUtterance( + UserUttered("Hello", {"name": "goodbye"}), evaluation + ), + WronglyPredictedAction("utter_greet", "", "utter_goodbye"), + ] + tracker = DialogueStateTracker.from_events("default", events) + dump = YAMLStoryWriter().dumps(tracker.as_story().story_steps) + assert ( + dump.strip() + == textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: default + steps: + - intent: greet # predicted: goodbye: Hello + - action: utter_greet # predicted: utter_goodbye + + """ + ).strip() + ) + + +def test_log_failed_stories(tmp_path: Path): + path = str(tmp_path / "stories.yml") + rasa.core.test._log_stories([], path, "Some text") + + dump = rasa.shared.utils.io.read_file(path) + + assert dump.startswith("#") + assert len(dump.split("\n")) == 1 + + +@pytest.mark.parametrize( + "entity_predictions,entity_targets", + [ + ( + [{"text": "hi, how are you", "start": 4, "end": 7, "entity": "aa"}], + [ + {"text": "hi, how are you", "start": 0, "end": 2, "entity": "bb"}, + {"text": "hi, how are you", "start": 4, "end": 7, "entity": "aa"}, + ], + ), + ( + [ + {"text": "hi, how are you", "start": 0, "end": 2, "entity": "bb"}, + {"text": "hi, how are you", "start": 4, "end": 7, "entity": "aa"}, + ], + [ + {"text": "hi, how are you", "start": 0, "end": 2, "entity": "bb"}, + {"text": "hi, how are you", "start": 4, "end": 7, "entity": "aa"}, + ], + ), + ( + [ + {"text": "hi, how are you", "start": 0, "end": 2, "entity": "bb"}, + {"text": "hi, how are you", "start": 4, "end": 7, "entity": "aa"}, + ], + [{"text": "hi, how are you", "start": 4, "end": 7, "entity": "aa"}], + ), + ( + [ + { + "text": "Tanja is currently in Munich, but she lives in Berlin", + "start": 0, + "end": 5, + "entity": "person", + }, + { + "text": "Tanja is currently in Munich, but she lives in Berlin", + "start": 22, + "end": 28, + "entity": "city", + }, + { + "text": "Tanja is currently in Munich, but she lives in Berlin", + "start": 47, + "end": 53, + "entity": "city", + }, + ], + [ + { + "text": "Tanja is currently in Munich, but she lives in Berlin", + "start": 22, + "end": 28, + "entity": "city", + } + ], + ), + ( + [ + { + "text": "Tanja is currently in Munich, but she lives in Berlin", + "start": 0, + "end": 5, + "entity": "person", + }, + { + "text": "Tanja is currently in Munich, but she lives in Berlin", + "start": 47, + "end": 53, + "entity": "city", + }, + ], + [ + { + "text": "Tanja is currently in Munich, but she lives in Berlin", + "start": 22, + "end": 28, + "entity": "city", + }, + { + "text": "Tanja is currently in Munich, but she lives in Berlin", + "start": 47, + "end": 53, + "entity": "city", + }, + ], + ), + ( + [ + { + "text": "Tanja is currently in Munich, but she lives in Berlin", + "start": 47, + "end": 53, + "entity": "city", + } + ], + [ + { + "text": "Tanja is currently in Munich, but she lives in Berlin", + "start": 0, + "end": 5, + "entity": "person", + }, + { + "text": "Tanja is currently in Munich, but she lives in Berlin", + "start": 22, + "end": 28, + "entity": "city", + }, + { + "text": "Tanja is currently in Munich, but she lives in Berlin", + "start": 47, + "end": 53, + "entity": "city", + }, + ], + ), + ], +) +def test_evaluation_store_serialise( + entity_predictions: List[dict], entity_targets: List[dict] +): + from rasa.shared.nlu.training_data.formats.readerwriter import TrainingDataWriter + + store = EvaluationStore( + entity_predictions=entity_predictions, entity_targets=entity_targets + ) + + targets, predictions = store.serialise() + + assert len(targets) == len(predictions) + + i_pred = 0 + i_target = 0 + for i, prediction in enumerate(predictions): + target = targets[i] + if prediction != "None" and target != "None": + predicted = entity_predictions[i_pred] + assert prediction == TrainingDataWriter.generate_entity( + predicted.get("text"), predicted + ) + assert predicted.get("start") == entity_targets[i_target].get("start") + assert predicted.get("end") == entity_targets[i_target].get("end") + + if prediction != "None": + i_pred += 1 + if target != "None": + i_target += 1 + + +def test_test_does_not_use_rules(tmp_path: Path, default_agent: Agent): + from rasa.core.test import _create_data_generator + + test_file = tmp_path / "test.yml" + test_name = "my test story" + tests = f""" +stories: +- story: {test_name} + steps: + - intent: greet + - action: utter_greet + +rules: +- rule: rule which is ignored + steps: + - intent: greet + - action: utter_greet + """ + + test_file.write_text(tests) + + generator = _create_data_generator(str(test_file), default_agent) + test_trackers = generator.generate_story_trackers() + assert len(test_trackers) == 1 + assert test_trackers[0].sender_id == test_name + + +def test_duplicated_entity_predictions_tolerated(): + """Same entity extracted multiple times shouldn't be flagged as prediction error. + + This can happen when multiple entity extractors extract the same entity but a test + story only lists the entity once. For completeness, the other case (entity listed + twice in test story and extracted once) is also tested here because it should work + the same way. + """ + entity = { + ENTITY_ATTRIBUTE_TEXT: "Algeria", + ENTITY_ATTRIBUTE_START: 0, + ENTITY_ATTRIBUTE_END: 7, + ENTITY_ATTRIBUTE_VALUE: "Algeria", + ENTITY_ATTRIBUTE_TYPE: "country", + } + evaluation_with_duplicated_prediction = EvaluationStore( + entity_predictions=[entity, entity], entity_targets=[entity] + ) + assert not evaluation_with_duplicated_prediction.check_prediction_target_mismatch() + + evaluation_with_duplicated_target = EvaluationStore( + entity_predictions=[entity], entity_targets=[entity, entity] + ) + assert not evaluation_with_duplicated_target.check_prediction_target_mismatch() + + +def test_differently_ordered_entity_predictions_tolerated(): + """The order in which entities were extracted shouldn't matter. + + Let's have an utterance like this: "[Researcher](job_name) from [Germany](country)." + and imagine we use different entity extractors for the two entities. Then, the order + in which entities are extracted from the utterance depends on the order in which the + extractors are listed in the NLU pipeline. However, the expected order is given by + where the entities are found in the utterance, i.e. "Researcher" comes before + "Germany". Hence, it's reasonable for the expected and extracted order to not match + and it shouldn't be flagged as a prediction error. + + """ + entity1 = { + ENTITY_ATTRIBUTE_TEXT: "Algeria and Albania", + ENTITY_ATTRIBUTE_START: 0, + ENTITY_ATTRIBUTE_END: 7, + ENTITY_ATTRIBUTE_VALUE: "Algeria", + ENTITY_ATTRIBUTE_TYPE: "country", + } + entity2 = { + ENTITY_ATTRIBUTE_TEXT: "Algeria and Albania", + ENTITY_ATTRIBUTE_START: 12, + ENTITY_ATTRIBUTE_END: 19, + ENTITY_ATTRIBUTE_VALUE: "Albania", + ENTITY_ATTRIBUTE_TYPE: "country", + } + evaluation = EvaluationStore( + entity_predictions=[entity1, entity2], entity_targets=[entity2, entity1] + ) + assert not evaluation.check_prediction_target_mismatch() diff --git a/tests/test_model_training.py b/tests/test_model_training.py new file mode 100644 index 0000000..b4bc84e --- /dev/null +++ b/tests/test_model_training.py @@ -0,0 +1,1090 @@ +import inspect +import logging +import secrets +import shutil +import tempfile +import os +import textwrap +from pathlib import Path +from typing import Text +from unittest.mock import Mock + +import pytest +from _pytest.capture import CaptureFixture +from _pytest.logging import LogCaptureFixture +from _pytest.monkeypatch import MonkeyPatch +from _pytest.tmpdir import TempPathFactory + +import rasa +from rasa.core.policies.rule_policy import RulePolicy + +from rasa.core.policies.ted_policy import TEDPolicy +import rasa.model +import rasa.model_training +import rasa.core +import rasa.core.train +import rasa.nlu +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.engine.recipes.default_recipe import DefaultV1Recipe +from rasa.engine.graph import GraphModelConfiguration +from rasa.engine.training.graph_trainer import GraphTrainer +from rasa.shared.data import TrainingType +from rasa.shared.core.events import ActionExecuted, SlotSet +from rasa.shared.core.training_data.structures import RuleStep, StoryGraph, StoryStep +from rasa.nlu.classifiers.diet_classifier import DIETClassifier +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION +import rasa.shared.utils.io +from rasa.shared.core.domain import Domain +from rasa.shared.exceptions import InvalidConfigException +from rasa.utils.tensorflow.constants import EPOCHS + + +def count_temp_rasa_files(directory: Text) -> int: + return len( + [ + entry + for entry in os.listdir(directory) + if not any( + [ + # Ignore the following files/directories: + entry == "__pycache__", # Python bytecode + entry.endswith(".py") # Temp .py files created by TF + # Anything else is considered to be created by Rasa + ] + ) + ] + ) + + +def test_train_temp_files( + tmp_path: Path, + monkeypatch: MonkeyPatch, + domain_path: Text, + stories_path: Text, + stack_config_path: Text, + nlu_data_path: Text, +): + (tmp_path / "training").mkdir() + (tmp_path / "models").mkdir() + + monkeypatch.setattr(tempfile, "tempdir", tmp_path / "training") + output = str(tmp_path / "models") + + rasa.train( + domain_path, + stack_config_path, + [stories_path, nlu_data_path], + output=output, + force_training=True, + ) + + assert count_temp_rasa_files(tempfile.tempdir) == 0 + + # After training the model, try to do it again. This shouldn't try to train + # a new model because nothing has been changed. It also shouldn't create + # any temp files. + rasa.train( + domain_path, stack_config_path, [stories_path, nlu_data_path], output=output + ) + + assert count_temp_rasa_files(tempfile.tempdir) == 0 + + +def test_train_core_temp_files( + tmp_path: Path, + monkeypatch: MonkeyPatch, + domain_path: Text, + stories_path: Text, + stack_config_path: Text, +): + (tmp_path / "training").mkdir() + (tmp_path / "models").mkdir() + + monkeypatch.setattr(tempfile, "tempdir", tmp_path / "training") + + rasa.model_training.train_core( + domain_path, stack_config_path, stories_path, output=str(tmp_path / "models") + ) + + assert count_temp_rasa_files(tempfile.tempdir) == 0 + + +def test_train_nlu_temp_files( + tmp_path: Path, + monkeypatch: MonkeyPatch, + stack_config_path: Text, + nlu_data_path: Text, +): + (tmp_path / "training").mkdir() + (tmp_path / "models").mkdir() + + monkeypatch.setattr(tempfile, "tempdir", tmp_path / "training") + + rasa.model_training.train_nlu( + stack_config_path, nlu_data_path, output=str(tmp_path / "models") + ) + + assert count_temp_rasa_files(tempfile.tempdir) == 0 + + +def test_train_nlu_wrong_format_error_message( + capsys: CaptureFixture, + tmp_path: Path, + monkeypatch: MonkeyPatch, + stack_config_path: Text, + incorrect_nlu_data_path: Text, +): + (tmp_path / "training").mkdir() + (tmp_path / "models").mkdir() + + monkeypatch.setattr(tempfile, "tempdir", tmp_path / "training") + + rasa.model_training.train_nlu( + stack_config_path, incorrect_nlu_data_path, output=str(tmp_path / "models") + ) + + captured = capsys.readouterr() + assert "Please verify the data format" in captured.out + + +def test_train_nlu_with_responses_no_domain_warns(tmp_path: Path): + data_path = "data/test_nlu_no_responses/nlu_no_responses.yml" + + with pytest.warns(UserWarning) as records: + rasa.model_training.train_nlu( + "data/test_config/config_response_selector_minimal.yml", + data_path, + output=str(tmp_path / "models"), + ) + + assert any( + "You either need to add a response phrase or correct the intent" + in record.message.args[0] + for record in records + ) + + +def test_train_nlu_with_responses_and_domain_no_warns(tmp_path: Path): + data_path = "data/test_nlu_no_responses/nlu_no_responses.yml" + domain_path = "data/test_nlu_no_responses/domain_with_only_responses.yml" + + with pytest.warns(None) as records: + rasa.model_training.train_nlu( + "data/test_config/config_response_selector_minimal.yml", + data_path, + output=str(tmp_path / "models"), + domain=domain_path, + ) + + assert not any( + "You either need to add a response phrase or correct the intent" + in record.message.args[0] + for record in records + ) + + +def test_train_nlu_no_nlu_file_error_message( + capsys: CaptureFixture, + tmp_path: Path, + monkeypatch: MonkeyPatch, + stack_config_path: Text, +): + (tmp_path / "training").mkdir() + (tmp_path / "models").mkdir() + + monkeypatch.setattr(tempfile, "tempdir", tmp_path / "training") + + rasa.model_training.train_nlu( + stack_config_path, "", output=str(tmp_path / "models") + ) + + captured = capsys.readouterr() + assert "No NLU data given" in captured.out + + +def test_train_core_autoconfig( + tmp_path: Path, + monkeypatch: MonkeyPatch, + domain_path: Text, + stories_path: Text, + stack_config_path: Text, +): + monkeypatch.setattr(tempfile, "tempdir", tmp_path) + + # mock function that returns configuration + mocked_auto_configure = Mock(wraps=DefaultV1Recipe.auto_configure) + monkeypatch.setattr(DefaultV1Recipe, "auto_configure", mocked_auto_configure) + + # skip actual core training + monkeypatch.setattr(GraphTrainer, GraphTrainer.train.__name__, Mock()) + + # do training + rasa.model_training.train_core( + domain_path, + stack_config_path, + stories_path, + output="test_train_core_temp_files_models", + ) + + mocked_auto_configure.assert_called_once() + _, args, _ = mocked_auto_configure.mock_calls[0] + assert args[2] == TrainingType.CORE + + +def test_train_nlu_autoconfig( + tmp_path: Path, + monkeypatch: MonkeyPatch, + stack_config_path: Text, + nlu_data_path: Text, +): + monkeypatch.setattr(tempfile, "tempdir", tmp_path) + + # mock function that returns configuration + mocked_auto_configuration = Mock(wraps=DefaultV1Recipe.auto_configure) + monkeypatch.setattr(DefaultV1Recipe, "auto_configure", mocked_auto_configuration) + + monkeypatch.setattr(GraphTrainer, GraphTrainer.train.__name__, Mock()) + # do training + rasa.model_training.train_nlu( + stack_config_path, nlu_data_path, output="test_train_nlu_temp_files_models" + ) + + mocked_auto_configuration.assert_called_once() + _, args, _ = mocked_auto_configuration.mock_calls[0] + assert args[2] == TrainingType.NLU + + +def new_model_path_in_same_dir(old_model_path: Text) -> Text: + return str(Path(old_model_path).parent / (secrets.token_hex(8) + ".tar.gz")) + + +class TestE2e: + def test_e2e_gives_experimental_warning( + self, + moodbot_domain_path: Path, + e2e_bot_config_file: Path, + e2e_stories_path: Text, + nlu_data_path: Text, + caplog: LogCaptureFixture, + tmp_path: Path, + ): + with caplog.at_level(logging.WARNING): + rasa.train( + str(moodbot_domain_path), + str(e2e_bot_config_file), + [e2e_stories_path, nlu_data_path], + output=str(tmp_path), + dry_run=True, + ) + + assert any( + [ + "The end-to-end training is currently experimental" in record.message + for record in caplog.records + ] + ) + + def test_models_not_retrained_if_no_new_data( + self, + trained_e2e_model: Text, + moodbot_domain_path: Path, + e2e_bot_config_file: Path, + e2e_stories_path: Text, + nlu_data_path: Text, + trained_e2e_model_cache: Path, + ): + result = rasa.train( + str(moodbot_domain_path), + str(e2e_bot_config_file), + [e2e_stories_path, nlu_data_path], + output=new_model_path_in_same_dir(trained_e2e_model), + dry_run=True, + ) + + assert result.code == 0 + + def test_retrains_nlu_and_core_if_new_e2e_example( + self, + trained_e2e_model: Text, + moodbot_domain_path: Path, + e2e_bot_config_file: Path, + e2e_stories_path: Text, + nlu_data_path: Text, + tmp_path: Path, + trained_e2e_model_cache: Path, + ): + stories_yaml = rasa.shared.utils.io.read_yaml_file(e2e_stories_path) + stories_yaml["stories"][1]["steps"].append({"user": "new message!"}) + + new_stories_file = tmp_path / "new_stories.yml" + rasa.shared.utils.io.write_yaml(stories_yaml, new_stories_file) + + result = rasa.train( + str(moodbot_domain_path), + str(e2e_bot_config_file), + [new_stories_file, nlu_data_path], + output=new_model_path_in_same_dir(trained_e2e_model), + dry_run=True, + ) + + assert result.code == rasa.model_training.CODE_NEEDS_TO_BE_RETRAINED + + fingerprints = result.dry_run_results + assert not fingerprints["train_CountVectorsFeaturizer3"].is_hit + assert not fingerprints["train_DIETClassifier5"].is_hit + assert not fingerprints["end_to_end_features_provider"].is_hit + assert not fingerprints["train_TEDPolicy0"].is_hit + assert not fingerprints["train_RulePolicy1"].is_hit + + def test_retrains_only_core_if_new_e2e_example_seen_before( + self, + trained_e2e_model: Text, + moodbot_domain_path: Path, + e2e_bot_config_file: Path, + e2e_stories_path: Text, + nlu_data_path: Text, + tmp_path: Path, + trained_e2e_model_cache: Path, + ): + stories_yaml = rasa.shared.utils.io.read_yaml_file(e2e_stories_path) + stories_yaml["stories"][1]["steps"].append({"user": "Yes"}) + + new_stories_file = tmp_path / "new_stories.yml" + rasa.shared.utils.io.write_yaml(stories_yaml, new_stories_file) + + result = rasa.train( + str(moodbot_domain_path), + str(e2e_bot_config_file), + [new_stories_file, nlu_data_path], + output=new_model_path_in_same_dir(trained_e2e_model), + dry_run=True, + ) + + assert result.code == rasa.model_training.CODE_NEEDS_TO_BE_RETRAINED + + fingerprints = result.dry_run_results + + assert fingerprints["train_CountVectorsFeaturizer3"].is_hit + assert fingerprints["train_DIETClassifier5"].is_hit + assert fingerprints["end_to_end_features_provider"].is_hit + assert not fingerprints["train_TEDPolicy0"].is_hit + assert not fingerprints["train_RulePolicy1"].is_hit + + def test_nlu_and_core_trained_if_no_nlu_data_but_e2e_stories( + self, + moodbot_domain_path: Path, + e2e_bot_config_file: Path, + e2e_stories_path: Text, + tmp_path: Path, + monkeypatch: MonkeyPatch, + ): + train_mock = Mock() + monkeypatch.setattr(GraphTrainer, GraphTrainer.train.__name__, train_mock) + + rasa.train( + str(moodbot_domain_path), + str(e2e_bot_config_file), + [e2e_stories_path], + output=str(tmp_path), + ) + + args, _ = train_mock.call_args + model_configuration: GraphModelConfiguration = args[0] + for schema in [ + model_configuration.train_schema, + model_configuration.predict_schema, + ]: + assert any( + issubclass(node.uses, DIETClassifier) for node in schema.nodes.values() + ) + assert any( + issubclass(node.uses, TEDPolicy) for node in schema.nodes.values() + ) + + def test_new_nlu_data_retrains_core_if_there_are_e2e_stories( + self, + trained_e2e_model: Text, + moodbot_domain_path: Path, + e2e_bot_config_file: Path, + e2e_stories_path: Text, + nlu_data_path: Text, + tmp_path: Path, + trained_e2e_model_cache: Path, + ): + nlu_yaml = rasa.shared.utils.io.read_yaml_file(nlu_data_path) + nlu_yaml["nlu"][0]["examples"] += "- surprise!\n" + + new_nlu_file = tmp_path / "new_nlu.yml" + rasa.shared.utils.io.write_yaml(nlu_yaml, new_nlu_file) + + result = rasa.train( + str(moodbot_domain_path), + str(e2e_bot_config_file), + [e2e_stories_path, new_nlu_file], + output=new_model_path_in_same_dir(trained_e2e_model), + dry_run=True, + ) + + assert result.code == rasa.model_training.CODE_NEEDS_TO_BE_RETRAINED + + fingerprints = result.dry_run_results + assert not fingerprints["train_CountVectorsFeaturizer3"].is_hit + assert not fingerprints["train_DIETClassifier5"].is_hit + assert not fingerprints["end_to_end_features_provider"].is_hit + assert not fingerprints["train_TEDPolicy0"].is_hit + assert fingerprints["train_RulePolicy1"].is_hit + + def test_new_nlu_data_does_not_retrain_core_if_there_are_no_e2e_stories( + self, + moodbot_domain_path: Path, + e2e_bot_config_file: Path, + simple_stories_path: Text, + nlu_data_path: Text, + tmp_path: Path, + ): + rasa.train( + str(moodbot_domain_path), + str(e2e_bot_config_file), + [simple_stories_path, nlu_data_path], + output=str(tmp_path), + ) + + nlu_yaml = rasa.shared.utils.io.read_yaml_file(nlu_data_path) + nlu_yaml["nlu"][0]["examples"] += "- surprise!\n" + + new_nlu_file = tmp_path / "new_nlu.yml" + rasa.shared.utils.io.write_yaml(nlu_yaml, new_nlu_file) + + result = rasa.train( + str(moodbot_domain_path), + str(e2e_bot_config_file), + [simple_stories_path, new_nlu_file], + output=str(tmp_path), + dry_run=True, + ) + + assert result.code == rasa.model_training.CODE_NEEDS_TO_BE_RETRAINED + + fingerprints = result.dry_run_results + + assert not fingerprints["train_CountVectorsFeaturizer3"].is_hit + assert not fingerprints["train_DIETClassifier5"].is_hit + assert "end_to_end_features_provider" not in fingerprints + assert fingerprints["train_TEDPolicy0"].is_hit + assert fingerprints["train_RulePolicy1"].is_hit + + def test_training_core_with_e2e_fails_gracefully( + self, + capsys: CaptureFixture, + tmp_path: Path, + domain_path: Text, + stack_config_path: Text, + e2e_stories_path: Text, + ): + rasa.model_training.train_core( + domain_path, stack_config_path, e2e_stories_path, output=str(tmp_path) + ) + + assert not list(tmp_path.glob("*")) + + captured = capsys.readouterr() + assert ( + "Stories file contains e2e stories. " + "Please train using `rasa train` so that the NLU model is also trained." + ) in captured.out + + +@pytest.mark.timeout(300, func_only=True) +@pytest.mark.parametrize("use_latest_model", [True, False]) +def test_model_finetuning( + tmp_path: Path, + domain_path: Text, + stories_path: Text, + stack_config_path: Text, + nlu_data_path: Text, + trained_rasa_model: Text, + use_latest_model: bool, +): + (tmp_path / "models").mkdir() + output = str(tmp_path / "models") + + if use_latest_model: + trained_rasa_model = str(Path(trained_rasa_model).parent) + + result = rasa.train( + domain_path, + stack_config_path, + [stories_path, nlu_data_path], + output=output, + force_training=True, + model_to_finetune=trained_rasa_model, + finetuning_epoch_fraction=0.1, + ) + + assert Path(result.model).is_file() + + +@pytest.mark.timeout(300, func_only=True) +@pytest.mark.parametrize("use_latest_model", [True, False]) +def test_model_finetuning_core( + tmp_path: Path, + trained_moodbot_core_path: Text, + use_latest_model: bool, + tmp_path_factory: TempPathFactory, +): + (tmp_path / "models").mkdir() + output = tmp_path / "models" + + if use_latest_model: + trained_moodbot_core_path = str(Path(trained_moodbot_core_path).parent) + + # Typically models will be fine-tuned with a smaller number of epochs than training + # from scratch. + # Fine-tuning will use the number of epochs in the new config. + old_config = rasa.shared.utils.io.read_yaml_file("data/test_moodbot/config.yml") + old_config["policies"][0]["epochs"] = 10 + new_config_path = tmp_path / "new_config.yml" + rasa.shared.utils.io.write_yaml(old_config, new_config_path) + + old_stories = rasa.shared.utils.io.read_yaml_file( + "data/test_moodbot/data/stories.yml" + ) + old_stories["stories"].append( + {"story": "new story", "steps": [{"intent": "greet"}]} + ) + new_stories_path = tmp_path / "new_stories.yml" + rasa.shared.utils.io.write_yaml(old_stories, new_stories_path) + + result = rasa.model_training.train_core( + "data/test_moodbot/domain.yml", + str(new_config_path), + str(new_stories_path), + output=str(output), + model_to_finetune=trained_moodbot_core_path, + finetuning_epoch_fraction=0.2, + ) + + storage_dir = tmp_path_factory.mktemp("finetuned model") + _, metadata = LocalModelStorage.from_model_archive(storage_dir, Path(result)) + + assert metadata.train_schema.nodes["train_TEDPolicy0"].config[EPOCHS] == 2 + assert metadata.training_type == TrainingType.CORE + + +def test_model_finetuning_core_with_default_epochs( + tmp_path: Path, + monkeypatch: MonkeyPatch, + trained_moodbot_core_path: Text, + tmp_path_factory: TempPathFactory, +): + (tmp_path / "models").mkdir() + output = str(tmp_path / "models") + + # Providing a new config with no epochs will mean the default amount are used + # and then scaled by `finetuning_epoch_fraction`. + old_config = rasa.shared.utils.io.read_yaml_file("data/test_moodbot/config.yml") + del old_config["policies"][0]["epochs"] + new_config_path = tmp_path / "new_config.yml" + rasa.shared.utils.io.write_yaml(old_config, new_config_path) + + model_name = rasa.model_training.train_core( + "data/test_moodbot/domain.yml", + str(new_config_path), + "data/test_moodbot/data/stories.yml", + output=output, + model_to_finetune=trained_moodbot_core_path, + finetuning_epoch_fraction=2, + ) + + storage_dir = tmp_path_factory.mktemp("finetuned model") + _, metadata = LocalModelStorage.from_model_archive(storage_dir, Path(model_name)) + + assert metadata.train_schema.nodes["train_TEDPolicy0"].config[EPOCHS] == 2 + + +def test_model_finetuning_core_new_domain_label( + tmp_path: Path, + trained_default_agent_model: Text, + simple_config_path: Text, +): + (tmp_path / "models").mkdir() + output = str(tmp_path / "models") + + # Simulate addition to training data + old_domain = rasa.shared.utils.io.read_yaml_file( + "data/test_domains/default_with_slots.yml" + ) + old_domain["intents"].append("a_new_one") + new_domain_path = tmp_path / "new_domain.yml" + rasa.shared.utils.io.write_yaml(old_domain, new_domain_path) + + with pytest.raises(InvalidConfigException): + rasa.model_training.train_core( + domain=str(new_domain_path), + config=simple_config_path, + stories="data/test_yaml_stories/stories_defaultdomain.yml", + output=output, + model_to_finetune=trained_default_agent_model, + ) + + +def test_model_finetuning_new_domain_label_stops_all_training( + tmp_path: Path, trained_moodbot_path: Text +): + (tmp_path / "models").mkdir() + output = str(tmp_path / "models") + + old_domain = rasa.shared.utils.io.read_yaml_file("data/test_moodbot/domain.yml") + old_domain["intents"].append("a_new_one") + new_domain_path = tmp_path / "new_domain.yml" + rasa.shared.utils.io.write_yaml(old_domain, new_domain_path) + + with pytest.raises(InvalidConfigException): + rasa.train( + domain=str(new_domain_path), + config="data/test_moodbot/config.yml", + training_files=[ + "data/test_moodbot/data/stories.yml", + "data/test_moodbot/data/nlu.yml", + ], + output=output, + model_to_finetune=trained_moodbot_path, + ) + + +@pytest.mark.timeout(300, func_only=True) +@pytest.mark.parametrize("use_latest_model", [True, False]) +def test_model_finetuning_nlu( + tmp_path: Path, + trained_nlu_moodbot_path: Text, + use_latest_model: bool, + tmp_path_factory: TempPathFactory, +): + (tmp_path / "models").mkdir() + output = str(tmp_path / "models") + + if use_latest_model: + trained_nlu_moodbot_path = str(Path(trained_nlu_moodbot_path).parent) + + # Typically models will be fine-tuned with a smaller number of epochs than training + # from scratch. + # Fine-tuning will use the number of epochs in the new config. + old_config = rasa.shared.utils.io.read_yaml_file("data/test_moodbot/config.yml") + old_config["pipeline"][-1][EPOCHS] = 10 + new_config_path = tmp_path / "new_config.yml" + rasa.shared.utils.io.write_yaml(old_config, new_config_path) + + old_nlu = rasa.shared.utils.io.read_yaml_file("data/test_moodbot/data/nlu.yml") + old_nlu["nlu"][-1]["examples"] += "- perfect\n" + new_nlu_path = tmp_path / "new_nlu.yml" + rasa.shared.utils.io.write_yaml(old_nlu, new_nlu_path) + + model_name = rasa.model_training.train_nlu( + str(new_config_path), + str(new_nlu_path), + domain="data/test_moodbot/domain.yml", + output=output, + model_to_finetune=trained_nlu_moodbot_path, + finetuning_epoch_fraction=0.2, + ) + + storage_dir = tmp_path_factory.mktemp("finetuned model") + _, metadata = LocalModelStorage.from_model_archive(storage_dir, Path(model_name)) + + assert metadata.train_schema.nodes["train_DIETClassifier5"].config[EPOCHS] == 2 + assert metadata.training_type == TrainingType.NLU + + +def test_model_finetuning_nlu_new_label(tmp_path: Path, trained_nlu_moodbot_path: Text): + (tmp_path / "models").mkdir() + output = str(tmp_path / "models") + + old_nlu = rasa.shared.utils.io.read_yaml_file("data/test_moodbot/data/nlu.yml") + old_nlu["nlu"].append({"intent": "a_new_one", "examples": "-blah"}) + new_nlu_path = tmp_path / "new_nlu.yml" + rasa.shared.utils.io.write_yaml(old_nlu, new_nlu_path) + + with pytest.raises(InvalidConfigException): + rasa.model_training.train_nlu( + "data/test_moodbot/config.yml", + str(new_nlu_path), + domain="data/test_moodbot/domain.yml", + output=output, + model_to_finetune=trained_nlu_moodbot_path, + ) + + +def test_model_finetuning_nlu_new_entity( + tmp_path: Path, trained_nlu_moodbot_path: Text +): + (tmp_path / "models").mkdir() + output = str(tmp_path / "models") + + old_nlu = rasa.shared.utils.io.read_yaml_file("data/test_moodbot/data/nlu.yml") + old_nlu["nlu"][-1]["examples"] = "-[blah](something)" + new_nlu_path = tmp_path / "new_nlu.yml" + rasa.shared.utils.io.write_yaml(old_nlu, new_nlu_path) + + with pytest.raises(InvalidConfigException): + rasa.model_training.train_nlu( + "data/test_moodbot/config.yml", + str(new_nlu_path), + domain="data/test_moodbot/domain.yml", + output=output, + model_to_finetune=trained_nlu_moodbot_path, + ) + + +def test_model_finetuning_nlu_new_label_already_in_domain( + tmp_path: Path, + trained_rasa_model: Text, + nlu_data_path: Text, + config_path: Text, + domain_path: Text, +): + (tmp_path / "models").mkdir() + output = str(tmp_path / "models") + + old_nlu = rasa.shared.utils.io.read_yaml_file(nlu_data_path) + # This intent exists in `domain_path` but not yet in the nlu data + old_nlu["nlu"].append({"intent": "why", "examples": "whyy??"}) + new_nlu_path = tmp_path / "new_nlu.yml" + rasa.shared.utils.io.write_yaml(old_nlu, new_nlu_path) + + with pytest.raises(InvalidConfigException): + rasa.model_training.train_nlu( + config_path, + str(new_nlu_path), + domain=domain_path, + output=output, + model_to_finetune=trained_rasa_model, + ) + + +def test_model_finetuning_nlu_new_label_to_domain_only( + tmp_path: Path, trained_nlu_moodbot_path: Text +): + (tmp_path / "models").mkdir() + output = str(tmp_path / "models") + + old_domain = rasa.shared.utils.io.read_yaml_file("data/test_moodbot/domain.yml") + old_domain["intents"].append("a_new_one") + new_domain_path = tmp_path / "new_domain.yml" + rasa.shared.utils.io.write_yaml(old_domain, new_domain_path) + + result = rasa.model_training.train_nlu( + "data/test_moodbot/config.yml", + "data/test_moodbot/data/nlu.yml", + domain=str(new_domain_path), + output=output, + model_to_finetune=trained_nlu_moodbot_path, + ) + + assert Path(result).is_file() + + +@pytest.mark.timeout(200, func_only=True) +def test_model_finetuning_nlu_with_default_epochs( + tmp_path: Path, + monkeypatch: MonkeyPatch, + trained_nlu_moodbot_path: Text, + tmp_path_factory: TempPathFactory, +): + (tmp_path / "models").mkdir() + output = str(tmp_path / "models") + + # Providing a new config with no epochs will mean the default amount are used + # and then scaled by `finetuning_epoch_fraction`. + old_config = rasa.shared.utils.io.read_yaml_file("data/test_moodbot/config.yml") + del old_config["pipeline"][-1][EPOCHS] + new_config_path = tmp_path / "new_config.yml" + rasa.shared.utils.io.write_yaml(old_config, new_config_path) + + model_name = rasa.model_training.train_nlu( + str(new_config_path), + "data/test_moodbot/data/nlu.yml", + output=output, + model_to_finetune=trained_nlu_moodbot_path, + finetuning_epoch_fraction=0.01, + ) + + storage_dir = tmp_path_factory.mktemp("finetuned model") + _, metadata = LocalModelStorage.from_model_archive(storage_dir, Path(model_name)) + + assert metadata.train_schema.nodes["train_DIETClassifier5"].config[EPOCHS] == 3 + + +@pytest.mark.parametrize("model_to_fine_tune", ["invalid-path-to-model", "."]) +def test_model_finetuning_with_invalid_model( + tmp_path: Path, + monkeypatch: MonkeyPatch, + domain_path: Text, + stories_path: Text, + stack_config_path: Text, + nlu_data_path: Text, + model_to_fine_tune: Text, + capsys: CaptureFixture, +): + (tmp_path / "models").mkdir() + output = str(tmp_path / "models") + + with pytest.raises(SystemExit): + rasa.train( + domain_path, + stack_config_path, + [stories_path, nlu_data_path], + output=output, + force_training=True, + model_to_finetune=model_to_fine_tune, + finetuning_epoch_fraction=1, + ) + + output = capsys.readouterr().out + assert "No model for finetuning found" in output + + +@pytest.mark.parametrize("model_to_fine_tune", ["invalid-path-to-model", "."]) +def test_model_finetuning_with_invalid_model_core( + tmp_path: Path, + domain_path: Text, + stories_path: Text, + stack_config_path: Text, + model_to_fine_tune: Text, + capsys: CaptureFixture, +): + (tmp_path / "models").mkdir() + output = str(tmp_path / "models") + + with pytest.raises(SystemExit): + rasa.model_training.train_core( + domain_path, + stack_config_path, + stories_path, + output=output, + model_to_finetune=model_to_fine_tune, + finetuning_epoch_fraction=1, + ) + + assert "No model for finetuning found" in capsys.readouterr().out + + +@pytest.mark.parametrize("model_to_fine_tune", ["invalid-path-to-model", "."]) +def test_model_finetuning_with_invalid_model_nlu( + tmp_path: Path, + monkeypatch: MonkeyPatch, + domain_path: Text, + stack_config_path: Text, + nlu_data_path: Text, + model_to_fine_tune: Text, + capsys: CaptureFixture, +): + (tmp_path / "models").mkdir() + output = str(tmp_path / "models") + + with pytest.raises(SystemExit): + rasa.model_training.train_nlu( + stack_config_path, + nlu_data_path, + domain=domain_path, + output=output, + model_to_finetune=model_to_fine_tune, + finetuning_epoch_fraction=1, + ) + + assert "No model for finetuning found" in capsys.readouterr().out + + +def test_models_not_retrained_if_only_new_responses( + trained_e2e_model: Text, + moodbot_domain_path: Path, + e2e_bot_config_file: Path, + e2e_stories_path: Text, + nlu_data_path: Text, + trained_e2e_model_cache: Path, + tmp_path: Path, +): + domain = Domain.load(moodbot_domain_path) + domain_with_extra_response = """ + version: '2.0' + responses: + utter_greet: + - text: "Hi from Rasa" + """ + domain_with_extra_response = Domain.from_yaml(domain_with_extra_response) + + new_domain = domain.merge(domain_with_extra_response) + new_domain_path = tmp_path / "domain.yml" + rasa.shared.utils.io.write_yaml(new_domain.as_dict(), new_domain_path) + + result = rasa.train( + str(new_domain_path), + str(e2e_bot_config_file), + [e2e_stories_path, nlu_data_path], + output=str(tmp_path), + dry_run=True, + ) + + assert result.code == 0 + + +def test_models_not_retrained_if_only_new_action( + trained_e2e_model: Text, + moodbot_domain_path: Path, + e2e_bot_config_file: Path, + e2e_stories_path: Text, + nlu_data_path: Text, + trained_e2e_model_cache: Path, + tmp_path: Path, +): + domain = Domain.load(moodbot_domain_path) + domain_with_extra_response = """ + version: '2.0' + responses: + utter_greet_new: + - text: "Hi from Rasa" + """ + + new_domain = domain.merge(Domain.from_yaml(domain_with_extra_response)) + new_domain_path = tmp_path / "domain.yml" + rasa.shared.utils.io.write_yaml(new_domain.as_dict(), new_domain_path) + + result = rasa.train( + str(new_domain_path), + str(e2e_bot_config_file), + [e2e_stories_path, nlu_data_path], + output=str(tmp_path), + dry_run=True, + ) + + assert result.code == rasa.model_training.CODE_NEEDS_TO_BE_RETRAINED + + +def test_invalid_graph_schema( + tmp_path: Path, domain_path: Text, stories_path: Text, nlu_data_path: Text +): + config = textwrap.dedent( + """ + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + recipe: "default.v1" + assistant_id: placeholder_default + + pipeline: + - name: WhitespaceTokenizer + - name: TEDPolicy + """ + ) + + new_config_path = tmp_path / "config.yml" + rasa.shared.utils.io.write_yaml( + rasa.shared.utils.io.read_yaml(config), new_config_path + ) + + with pytest.raises(InvalidConfigException) as captured_exception: + rasa.train( + domain_path, + str(new_config_path), + [stories_path, nlu_data_path], + output=str(tmp_path), + ) + assert "Found policy 'TEDPolicy1' in NLU pipeline." in str(captured_exception) + + +def test_fingerprint_changes_if_module_changes( + tmp_path: Path, domain_path: Text, stories_path: Text, monkeypatch: MonkeyPatch +): + rule_policy_path = inspect.getfile(RulePolicy) + module_name = "custom_rule_policy" + new_class_name = "CustomRulePolicy" + + custom_module_path = Path(tmp_path, f"{module_name}.py") + shutil.copy2(rule_policy_path, custom_module_path) + + # Rename class as the class name has to be unique + source_code = custom_module_path.read_text() + source_code = source_code.replace("RulePolicy", new_class_name) + custom_module_path.write_text(source_code) + + config = textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + recipe: "default.v1" + assistant_id: placeholder_default + + policies: + - name: RulePolicy + - name: {module_name}.{new_class_name} + """ + ) + monkeypatch.syspath_prepend(tmp_path) + + new_config_path = tmp_path / "config.yml" + rasa.shared.utils.io.write_yaml( + rasa.shared.utils.io.read_yaml(config), new_config_path + ) + + # Train to initialize cache + rasa.train(domain_path, str(new_config_path), [stories_path], output=str(tmp_path)) + + # Make sure that the caching works as expected the code didn't change + result = rasa.train( + domain_path, + str(new_config_path), + [stories_path], + output=str(tmp_path), + dry_run=True, + ) + + assert result.code == 0 + + # Make a change to the code so a new training is necessary + source_code = custom_module_path.read_text() + source_code = source_code.replace("Dict[Text, Any]", "Dict") + custom_module_path.write_text(source_code) + + result = rasa.train( + domain_path, + str(new_config_path), + [stories_path], + output=str(tmp_path), + dry_run=True, + ) + + assert result.code == rasa.model_training.CODE_NEEDS_TO_BE_RETRAINED + assert not result.dry_run_results[f"train_{module_name}.{new_class_name}1"].is_hit + + +def test_check_unresolved_slots(capsys: CaptureFixture): + stories = StoryGraph( + [ + StoryStep( + "greet", + events=[SlotSet("temp1"), ActionExecuted("temp3"), SlotSet("cuisine")], + ), + RuleStep("bye", events=[SlotSet("temp4")]), + ] + ) + domain_path = "data/test_domains/default_with_mapping.yml" + domain = Domain.load(domain_path) + with pytest.raises(SystemExit): + rasa.model_training._check_unresolved_slots(domain, stories) + + error_output = capsys.readouterr().out + assert ( + "temp1" in error_output + and "temp4" in error_output + and "cuisine" not in error_output + ) + + stories = StoryGraph( + [ + StoryStep( + "greet", + events=[ + SlotSet("location"), + ActionExecuted("temp"), + SlotSet("cuisine"), + ], + ) + ] + ) + assert rasa.model_training._check_unresolved_slots(domain, stories) is None diff --git a/tests/test_plugin.py b/tests/test_plugin.py new file mode 100644 index 0000000..3357249 --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,71 @@ +import warnings +from typing import Optional, Text +from unittest.mock import MagicMock + +import pytest +from pluggy import PluginManager +from pytest import MonkeyPatch + +from rasa.core.brokers.broker import EventBroker +from rasa.core.tracker_store import TrackerStore +from rasa.plugin import plugin_manager +from rasa.shared.core.domain import Domain +from rasa.utils.endpoints import EndpointConfig + + +def test_plugin_manager() -> None: + manager = plugin_manager() + assert isinstance(manager, PluginManager) + + manager_2 = plugin_manager() + assert manager_2 == manager + + +@pytest.mark.parametrize("event_broker", [None, MagicMock()]) +def test_plugin_create_tracker_store( + event_broker: Optional[EventBroker], + monkeypatch: MonkeyPatch, +) -> None: + manager = plugin_manager() + monkeypatch.setattr( + manager.hook, "create_tracker_store", MagicMock(return_value=None) + ) + + endpoint_config = EndpointConfig() + domain = Domain.empty() + + with warnings.catch_warnings(): + warnings.simplefilter("error") + TrackerStore.create( + obj=endpoint_config, domain=domain, event_broker=event_broker + ) + + manager.hook.create_tracker_store.assert_called_once_with( + endpoint_config=endpoint_config, domain=domain, event_broker=event_broker + ) + + +@pytest.mark.parametrize("endpoints_file", [None, "test_endpoints.yml"]) +def test_init_anonymization_pipeline( + endpoints_file: Optional[Text], + monkeypatch: MonkeyPatch, +) -> None: + manager = plugin_manager() + monkeypatch.setattr( + manager.hook, "init_anonymization_pipeline", MagicMock(return_value=None) + ) + + manager.hook.init_anonymization_pipeline(endpoints_file=endpoints_file) + manager.hook.init_anonymization_pipeline.assert_called_once_with( + endpoints_file=endpoints_file + ) + + +def test_get_anonymization_pipeline(monkeypatch: MonkeyPatch) -> None: + manager = plugin_manager() + monkeypatch.setattr( + manager.hook, "get_anonymization_pipeline", MagicMock(return_value=None) + ) + + manager.hook.get_anonymization_pipeline() + manager.hook.get_anonymization_pipeline.assert_called_once() diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..3c1a824 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,2385 @@ +import asyncio +import json +import os +import textwrap +import time +import urllib.parse +import uuid +import sys +from argparse import Namespace +from http import HTTPStatus +from multiprocessing import Manager +from multiprocessing.managers import DictProxy +from pathlib import Path +from typing import Any, List, Text, Tuple, Type, Generator, NoReturn, Dict, Optional +from unittest.mock import Mock, ANY + +from _pytest.tmpdir import TempPathFactory +import pytest +from _pytest.monkeypatch import MonkeyPatch +from aioresponses import aioresponses +from freezegun import freeze_time +from unittest.mock import MagicMock +from ruamel.yaml import StringIO +from sanic import Sanic +from sanic_testing.testing import SanicASGITestClient + +import rasa +import rasa.constants +import rasa.core.jobs +from rasa.engine.storage.local_model_storage import LocalModelStorage +import rasa.nlu +import rasa.server +import rasa.shared.constants +import rasa.shared.utils.io +import rasa.utils.io +from rasa.core import utils +from rasa.core.agent import Agent, load_agent +from rasa.core.channels import ( + channel, + CollectingOutputChannel, + RestInput, + SlackInput, + CallbackInput, +) +from rasa.core.channels.slack import SlackBot +from rasa.core.tracker_store import InMemoryTrackerStore +import rasa.nlu.test +from rasa.nlu.test import CVEvaluationResult +from rasa.shared.core import events +from rasa.shared.core.constants import ( + ACTION_RESTART_NAME, + ACTION_SESSION_START_NAME, + ACTION_LISTEN_NAME, + REQUESTED_SLOT, + SESSION_START_METADATA_SLOT, +) +from rasa.shared.core.domain import Domain, SessionConfig +from rasa.shared.core.events import ( + Event, + Restarted, + UserUttered, + SlotSet, + BotUttered, + ActionExecuted, + SessionStarted, +) +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.nlu.constants import ( + INTENT_NAME_KEY, + ENTITY_ATTRIBUTE_TYPE, + ENTITY_ATTRIBUTE_VALUE, + PREDICTED_CONFIDENCE_KEY, +) +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION +from rasa.model_training import TrainingResult +from rasa.utils.endpoints import EndpointConfig +from tests.conftest import ( + AsyncMock, + with_assistant_id, + with_assistant_ids, + with_model_id, + with_model_ids, +) +from tests.nlu.utilities import ResponseTest +from tests.utilities import json_of_latest_request, latest_request + +# a couple of event instances that we can use for testing +test_events = [ + Event.from_parameters( + { + "event": UserUttered.type_name, + "text": "/goodbye", + "parse_data": { + "intent": {"confidence": 1.0, INTENT_NAME_KEY: "greet"}, + "entities": [], + }, + } + ), + BotUttered("Welcome!", {"test": True}), + SlotSet("cuisine", 34), + SlotSet("cuisine", "34"), + SlotSet("location", None), + SlotSet("location", [34, "34", None]), +] + +# sequence of events expected at the beginning of trackers +session_start_sequence: List[Event] = [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ActionExecuted(ACTION_LISTEN_NAME), +] + + +@pytest.fixture +def rasa_app_without_api(rasa_server_without_api: Sanic) -> SanicASGITestClient: + return rasa_server_without_api.asgi_client + + +@pytest.fixture +def rasa_app(rasa_server: Sanic) -> SanicASGITestClient: + return rasa_server.asgi_client + + +@pytest.fixture +def rasa_non_trained_app(rasa_non_trained_server: Sanic) -> SanicASGITestClient: + return rasa_non_trained_server.asgi_client + + +@pytest.fixture +def rasa_app_nlu(rasa_nlu_server: Sanic) -> SanicASGITestClient: + return rasa_nlu_server.asgi_client + + +@pytest.fixture +def rasa_app_core(rasa_core_server: Sanic) -> SanicASGITestClient: + return rasa_core_server.asgi_client + + +@pytest.fixture +def rasa_secured_app(rasa_server_secured: Sanic) -> SanicASGITestClient: + return rasa_server_secured.asgi_client + + +@pytest.fixture +def rasa_secured_app_asymmetric( + rasa_server_secured_asymmetric: Sanic, +) -> SanicASGITestClient: + return rasa_server_secured_asymmetric.asgi_client + + +@pytest.fixture +def rasa_non_trained_secured_app( + rasa_non_trained_server_secured: Sanic, +) -> SanicASGITestClient: + return rasa_non_trained_server_secured.asgi_client + + +@pytest.fixture() +async def tear_down_scheduler() -> Generator[None, None, None]: + yield None + rasa.core.jobs.__scheduler = None + + +async def test_root(rasa_non_trained_app: SanicASGITestClient): + _, response = await rasa_non_trained_app.get("/") + assert response.status == HTTPStatus.OK + assert response.text.startswith("Hello from Rasa:") + + +async def test_root_without_enable_api(rasa_app_without_api: SanicASGITestClient): + _, response = await rasa_app_without_api.get("/") + assert response.status == HTTPStatus.OK + assert response.text.startswith("Hello from Rasa:") + + +async def test_root_secured(rasa_non_trained_secured_app: SanicASGITestClient): + _, response = await rasa_non_trained_secured_app.get("/") + assert response.status == HTTPStatus.OK + assert response.text.startswith("Hello from Rasa:") + + +async def test_version(rasa_non_trained_app: SanicASGITestClient): + _, response = await rasa_non_trained_app.get("/version") + content = response.json + assert response.status == HTTPStatus.OK + assert content.get("version") == rasa.__version__ + assert ( + content.get("minimum_compatible_version") + == rasa.constants.MINIMUM_COMPATIBLE_VERSION + ) + + +async def test_status(rasa_app: SanicASGITestClient, trained_rasa_model: Text): + _, response = await rasa_app.get("/status") + model_file = response.json["model_file"] + assert response.status == HTTPStatus.OK + assert "model_id" in response.json + assert model_file == Path(trained_rasa_model).name + + +async def test_status_nlu_only( + rasa_app_nlu: SanicASGITestClient, trained_nlu_model: Text +): + _, response = await rasa_app_nlu.get("/status") + model_file = response.json["model_file"] + assert response.status == HTTPStatus.OK + assert "model_id" in response.json + assert "model_file" in response.json + assert model_file == Path(trained_nlu_model).name + + +async def test_status_secured(rasa_secured_app: SanicASGITestClient): + _, response = await rasa_secured_app.get("/status") + assert response.status == HTTPStatus.UNAUTHORIZED + + +async def test_status_not_ready_agent(rasa_app: SanicASGITestClient): + rasa_app.sanic_app.ctx.agent = None + _, response = await rasa_app.get("/status") + assert response.status == HTTPStatus.CONFLICT + + +@pytest.fixture +def shared_statuses() -> DictProxy: + return Manager().dict() + + +@pytest.mark.parametrize( + "response_test", + [ + ResponseTest( + "/model/parse", + { + "entities": [], + "intent": {"confidence": 1.0, INTENT_NAME_KEY: "greet"}, + "text": "hello", + }, + payload={"text": "hello"}, + ), + ResponseTest( + "/model/parse", + { + "entities": [], + "intent": {"confidence": 1.0, INTENT_NAME_KEY: "greet"}, + "text": "hello", + }, + payload={"text": "hello"}, + ), + ResponseTest( + "/model/parse", + { + "entities": [], + "intent": {"confidence": 1.0, INTENT_NAME_KEY: "greet"}, + "text": "hello ńöñàśçií", + }, + payload={"text": "hello ńöñàśçií"}, + ), + ], +) +async def test_parse(rasa_app: SanicASGITestClient, response_test: ResponseTest): + _, response = await rasa_app.post( + response_test.endpoint, json=response_test.payload + ) + rjs = response.json + assert response.status == HTTPStatus.OK + assert all(prop in rjs for prop in ["entities", "intent", "text"]) + assert rjs["entities"] == response_test.expected_response["entities"] + assert rjs["text"] == response_test.expected_response["text"] + assert rjs["intent"] == response_test.expected_response["intent"] + + +@pytest.mark.parametrize( + "response_test", + [ + ResponseTest( + "/model/parse?emulation_mode=wit", + { + "entities": [], + "intent": {"confidence": 1.0, INTENT_NAME_KEY: "greet"}, + "text": "hello", + }, + payload={"text": "hello"}, + ), + ResponseTest( + "/model/parse?emulation_mode=dialogflow", + { + "entities": [], + "intent": {"confidence": 1.0, INTENT_NAME_KEY: "greet"}, + "text": "hello", + }, + payload={"text": "hello"}, + ), + ResponseTest( + "/model/parse?emulation_mode=luis", + { + "entities": [], + "intent": {"confidence": 1.0, INTENT_NAME_KEY: "greet"}, + "text": "hello ńöñàśçií", + }, + payload={"text": "hello ńöñàśçií"}, + ), + ], +) +async def test_parse_with_different_emulation_mode( + rasa_app: SanicASGITestClient, response_test: ResponseTest +): + _, response = await rasa_app.post( + response_test.endpoint, json=response_test.payload + ) + assert response.status == HTTPStatus.OK + + +async def test_parse_without_nlu_model(rasa_app_core: SanicASGITestClient): + _, response = await rasa_app_core.post("/model/parse", json={"text": "hello"}) + assert response.status == HTTPStatus.OK + + rjs = response.json + assert all(prop in rjs for prop in ["entities", "intent", "text"]) + + +async def test_parse_on_invalid_emulation_mode(rasa_app: SanicASGITestClient): + _, response = await rasa_app.post( + "/model/parse?emulation_mode=ANYTHING", json={"text": "hello"} + ) + assert response.status == HTTPStatus.BAD_REQUEST + + +async def test_train_nlu_success( + rasa_app: SanicASGITestClient, + stack_config_path: Text, + nlu_data_path: Text, + domain_path: Text, + tmp_path_factory: TempPathFactory, +): + domain_data = rasa.shared.utils.io.read_yaml_file(domain_path) + config_data = rasa.shared.utils.io.read_yaml_file(stack_config_path) + nlu_data = rasa.shared.utils.io.read_yaml_file(nlu_data_path) + + # combine all data into our payload + payload = { + key: val for d in [domain_data, config_data, nlu_data] for key, val in d.items() + } + + data = StringIO() + rasa.shared.utils.io.write_yaml(payload, data) + + _, response = await rasa_app.post( + "/model/train", + data=data.getvalue(), + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + ) + assert response.status == HTTPStatus.OK + + # save model to temporary file + model_path = str(Path(tmp_path_factory.mktemp("model_dir")) / "model.tar.gz") + with open(model_path, "wb") as f: + f.write(response.body) + + storage_path = tmp_path_factory.mktemp("storage_path") + model_storage, model_metadata = LocalModelStorage.from_model_archive( + storage_path, model_path + ) + assert model_metadata.model_id + + +async def test_train_core_success_with( + rasa_app: SanicASGITestClient, + stack_config_path: Text, + stories_path: Text, + domain_path: Text, + tmp_path_factory: TempPathFactory, +): + payload = f""" +{Path(domain_path).read_text()} +{Path(stack_config_path).read_text()} +{Path(stories_path).read_text()} + """ + + _, response = await rasa_app.post( + "/model/train", + data=payload, + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + ) + assert response.status == HTTPStatus.OK + + # save model to temporary file + model_path = str(Path(tmp_path_factory.mktemp("model_dir")) / "model.tar.gz") + with open(model_path, "wb") as f: + f.write(response.body) + + storage_path = tmp_path_factory.mktemp("storage_path") + model_storage, model_metadata = LocalModelStorage.from_model_archive( + storage_path, model_path + ) + assert model_metadata.model_id + + +async def test_train_with_retrieval_events_success( + rasa_app: SanicASGITestClient, + stack_config_path: Text, + tmp_path_factory: TempPathFactory, +): + payload = {} + + tmp_path = tmp_path_factory.mktemp("tmp") + + for file in [ + "data/test_domains/default_retrieval_intents.yml", + stack_config_path, + "data/test_yaml_stories/stories_retrieval_intents.yml", + "data/test_responses/default.yml", + "data/test/stories_default_retrieval_intents.yml", + ]: + # Read in as dictionaries to avoid that keys, which are specified in + # multiple files (such as 'version'), clash. + content = rasa.shared.utils.io.read_yaml_file(file) + payload.update(content) + + concatenated_payload_file = tmp_path / "concatenated.yml" + rasa.shared.utils.io.write_yaml(payload, concatenated_payload_file) + + payload_as_yaml = concatenated_payload_file.read_text() + + # it usually takes a bit longer on windows so we're going to double the timeout + timeout = 60 * 10 if sys.platform == "win32" else 60 * 5 + + _, response = await rasa_app.post( + "/model/train", + data=payload_as_yaml, + timeout=timeout, + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + ) + assert response.status == HTTPStatus.OK + + assert_trained_model(response.body, tmp_path_factory) + + +def assert_trained_model( + response_body: bytes, tmp_path_factory: TempPathFactory +) -> None: + # save model to temporary file + + model_path = str(Path(tmp_path_factory.mktemp("model_dir")) / "model.tar.gz") + with open(model_path, "wb") as f: + f.write(response_body) + + storage_path = tmp_path_factory.mktemp("storage_path") + model_storage, model_metadata = LocalModelStorage.from_model_archive( + storage_path, model_path + ) + assert model_metadata.model_id + + +async def test_train_with_yaml( + rasa_app: SanicASGITestClient, tmp_path_factory: TempPathFactory +): + training_data = f""" +version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + +stories: +- story: My story + steps: + - intent: greet + - action: utter_greet + +rules: +- rule: My rule + steps: + - intent: greet + - action: utter_greet + +intents: +- greet + +nlu: +- intent: greet + examples: | + - hi + - hello + +responses: + utter_greet: + - text: Hi + +recipe: default.v1 +language: en +assistant_id: placeholder_default + +policies: +- name: RulePolicy + +pipeline: + - name: KeywordIntentClassifier +""" + _, response = await rasa_app.post( + "/model/train", + data=training_data, + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + ) + + assert response.status == HTTPStatus.OK + assert_trained_model(response.body, tmp_path_factory) + + +@pytest.mark.parametrize( + "params", [{}, {"augmentation": 20, "num_threads": 2, "force_training": True}] +) +async def test_train_with_yaml_with_params( + monkeypatch: MonkeyPatch, + rasa_non_trained_app: SanicASGITestClient, + tmp_path: Path, + params: Dict, +): + fake_model = Path(tmp_path) / "fake_model.tar.gz" + fake_model.touch() + fake_model_path = str(fake_model) + mock_train = Mock(return_value=TrainingResult(model=fake_model_path)) + monkeypatch.setattr(rasa.model_training, "train", mock_train) + + training_data = """ +stories: [] +rules: [] +intents: [] +nlu: [] +responses: {} +recipe: default.v1 +language: en +policies: [] +pipeline: [] +""" + _, response = await rasa_non_trained_app.post( + "/model/train", + data=training_data, + params=params, + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + ) + + assert response.status == HTTPStatus.OK + assert mock_train.call_count == 1 + args, kwargs = mock_train.call_args_list[0] + assert kwargs["core_additional_arguments"]["augmentation_factor"] == params.get( + "augmentation", 50 + ) + assert kwargs["nlu_additional_arguments"]["num_threads"] == params.get( + "num_threads", 1 + ) + assert kwargs["force_training"] == params.get("force_training", False) + + +async def test_train_with_invalid_yaml(rasa_non_trained_app: SanicASGITestClient): + invalid_yaml = """ +rules: +rule my rule +""" + + _, response = await rasa_non_trained_app.post( + "/model/train", + data=invalid_yaml, + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + ) + assert response.status == HTTPStatus.BAD_REQUEST + + +@pytest.mark.parametrize( + "headers, expected", + [({}, False), ({"force_training": False}, False), ({"force_training": True}, True)], +) +def test_training_payload_from_yaml_force_training( + headers: Dict, expected: bool, tmp_path: Path +): + request = Mock() + request.body = b"" + request.args = headers + + payload = rasa.server._training_payload_from_yaml(request, tmp_path) + assert payload.get("force_training") == expected + + +@pytest.mark.parametrize( + "headers, expected", + [ + ({}, rasa.shared.constants.DEFAULT_MODELS_PATH), + ({"save_to_default_model_directory": False}, ANY), + ( + {"save_to_default_model_directory": True}, + rasa.shared.constants.DEFAULT_MODELS_PATH, + ), + ], +) +def test_training_payload_from_yaml_save_to_default_model_directory( + headers: Dict, expected: Text, tmp_path: Path +): + request = Mock() + request.body = b"" + request.args = headers + + payload = rasa.server._training_payload_from_yaml(request, tmp_path) + assert payload.get("output") + assert payload.get("output") == expected + + +@pytest.mark.parametrize( + "headers, expected", + [ + ({}, rasa.shared.constants.DEFAULT_MODELS_PATH), + ({"save_to_default_model_directory": False}, ANY), + ( + {"save_to_default_model_directory": True}, + rasa.shared.constants.DEFAULT_MODELS_PATH, + ), + ], +) +def test_nlu_training_payload_from_json(headers: Dict, expected: Text, tmp_path: Path): + request = Mock() + request.json = {"rasa_nlu_data": {"common_examples": []}} + request.args = headers + + payload = rasa.server._nlu_training_payload_from_json(request, tmp_path) + assert payload.get("output") + assert payload.get("output") == expected + + +async def test_evaluate_stories(rasa_app: SanicASGITestClient, stories_path: Text): + stories = rasa.shared.utils.io.read_file(stories_path) + + _, response = await rasa_app.post( + "/model/test/stories", + data=stories, + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + ) + + assert response.status == HTTPStatus.OK + + js = response.json + assert set(js.keys()) == { + "report", + "precision", + "f1", + "accuracy", + "actions", + "in_training_data_fraction", + "is_end_to_end_evaluation", + } + assert not js["is_end_to_end_evaluation"] + assert set(js["actions"][0].keys()) == { + "action", + "predicted", + "confidence", + "policy", + } + + +async def test_evaluate_stories_not_ready_agent( + rasa_non_trained_app: SanicASGITestClient, stories_path: Text +): + stories = rasa.shared.utils.io.read_file(stories_path) + + _, response = await rasa_non_trained_app.post("/model/test/stories", data=stories) + + assert response.status == HTTPStatus.CONFLICT + + +async def test_evaluate_stories_end_to_end( + rasa_app: SanicASGITestClient, end_to_end_story_path: Text +): + stories = rasa.shared.utils.io.read_file(end_to_end_story_path) + + _, response = await rasa_app.post( + "/model/test/stories?e2e=true", + data=stories, + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + ) + + assert response.status == HTTPStatus.OK + js = response.json + assert set(js.keys()) == { + "report", + "precision", + "f1", + "accuracy", + "actions", + "in_training_data_fraction", + "is_end_to_end_evaluation", + } + assert js["is_end_to_end_evaluation"] + assert js["actions"] != [] + assert set(js["actions"][0].keys()) == { + "action", + "predicted", + "confidence", + "policy", + } + + +async def test_add_message(rasa_app: SanicASGITestClient): + + conversation_id = "test_add_message_test_id" + + _, response = await rasa_app.get(f"/conversations/{conversation_id}/tracker") + previous_num_events = len(response.json["events"]) + + unique_text = f"test_add_message_text_{time.time()}" + unique_slot_value = f"test_add_message_entity_{time.time()}" + data = { + "text": unique_text, + "sender": "user", # must be "user" + "parse_data": { + "text": unique_text, # this is what is used for "latest_message" + "intent": {PREDICTED_CONFIDENCE_KEY: 0.57, INTENT_NAME_KEY: "greet"}, + "entities": [ + { + ENTITY_ATTRIBUTE_TYPE: "name", + ENTITY_ATTRIBUTE_VALUE: unique_slot_value, + } + ], + }, + } + _, response = await rasa_app.post( + f"/conversations/{conversation_id}/messages", + headers={"Content-Type": rasa.server.JSON_CONTENT_TYPE}, + json=data, + ) + assert response.json["latest_message"]["text"] == unique_text + + _, response = await rasa_app.get(f"/conversations/{conversation_id}/tracker") + updated_events = response.json["events"] + assert len(updated_events) == previous_num_events + 2 + assert updated_events[-2]["text"] == unique_text + assert updated_events[-1]["event"] == "slot" + assert updated_events[-1]["value"] == unique_slot_value + + +async def test_evaluate_intent(rasa_app: SanicASGITestClient, nlu_data_path: Text): + nlu_data = rasa.shared.utils.io.read_file(nlu_data_path) + + _, response = await rasa_app.post( + "/model/test/intents", + data=nlu_data, + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + ) + + assert response.status == HTTPStatus.OK + assert set(response.json.keys()) == { + "intent_evaluation", + "entity_evaluation", + "response_selection_evaluation", + } + + +async def test_evaluate_invalid_intent_model_file(rasa_app: SanicASGITestClient): + _, response = await rasa_app.post( + "/model/test/intents?model=invalid.tar.gz", + json={}, + headers={"Content-type": rasa.server.JSON_CONTENT_TYPE}, + ) + + assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +async def test_evaluate_intent_without_body(rasa_app: SanicASGITestClient): + _, response = await rasa_app.post( + "/model/test/intents", headers={"Content-type": rasa.server.YAML_CONTENT_TYPE} + ) + + assert response.status == HTTPStatus.BAD_REQUEST + + +async def test_evaluate_intent_on_just_nlu_model( + rasa_app_nlu: SanicASGITestClient, nlu_data_path: Text +): + nlu_data = rasa.shared.utils.io.read_file(nlu_data_path) + + _, response = await rasa_app_nlu.post( + "/model/test/intents", + data=nlu_data, + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + ) + + assert response.status == HTTPStatus.OK + assert set(response.json.keys()) == { + "intent_evaluation", + "entity_evaluation", + "response_selection_evaluation", + } + + +async def test_evaluate_intent_with_model_param( + rasa_app: SanicASGITestClient, trained_nlu_model: Text, nlu_data_path: Text +): + _, response = await rasa_app.get("/status") + previous_model_file = response.json["model_file"] + + nlu_data = rasa.shared.utils.io.read_file(nlu_data_path) + + _, response = await rasa_app.post( + f"/model/test/intents?model={trained_nlu_model}", + data=nlu_data, + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + ) + + assert response.status == HTTPStatus.OK + assert set(response.json.keys()) == { + "intent_evaluation", + "entity_evaluation", + "response_selection_evaluation", + } + + _, response = await rasa_app.get("/status") + assert previous_model_file == response.json["model_file"] + + +async def test_evaluate_intent_with_model_server( + rasa_app: SanicASGITestClient, + trained_rasa_model: Text, + nlu_data_path: Text, + tear_down_scheduler: None, +): + production_model_server_url = ( + "https://example.com/webhooks/actions?model=production" + ) + test_model_server_url = "https://example.com/webhooks/actions?model=test" + + nlu_data = rasa.shared.utils.io.read_file(nlu_data_path) + + with aioresponses() as mocked: + # Mock retrieving the production model from the model server + mocked.get( + production_model_server_url, + body=Path(trained_rasa_model).read_bytes(), + headers={"ETag": "production", "filename": "prod_model.tar.gz"}, + ) + # Mock retrieving the test model from the model server + mocked.get( + test_model_server_url, + body=Path(trained_rasa_model).read_bytes(), + headers={"ETag": "test", "filename": "test_model.tar.gz"}, + ) + + agent_with_model_server = await load_agent( + model_server=EndpointConfig(production_model_server_url) + ) + rasa_app.sanic_app.ctx.agent = agent_with_model_server + + _, response = await rasa_app.post( + f"/model/test/intents?model={test_model_server_url}", + data=nlu_data, + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + ) + + assert response.status == HTTPStatus.OK + assert set(response.json.keys()) == { + "intent_evaluation", + "entity_evaluation", + "response_selection_evaluation", + } + + production_model_server = rasa_app.sanic_app.ctx.agent.model_server + # Assert that the model server URL for the test didn't override the production + # model server URL + assert production_model_server.url == production_model_server_url + # Assert the tests didn't break pulling the models + assert production_model_server.kwargs.get("wait_time_between_pulls") != 0 + + +async def test_cross_validation( + rasa_non_trained_app: SanicASGITestClient, + nlu_data_path: Text, + stack_config_path: Text, +): + nlu_data = Path(nlu_data_path).read_text() + config = Path(stack_config_path).read_text() + payload = f"{nlu_data}\n{config}" + + _, response = await rasa_non_trained_app.post( + "/model/test/intents", + data=payload, + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + params={"cross_validation_folds": 3}, + ) + + assert response.status == HTTPStatus.OK + response_body = response.json + for required_key in { + "intent_evaluation", + "entity_evaluation", + "response_selection_evaluation", + }: + assert required_key in response_body + + details = response_body[required_key] + assert all( + key in details for key in ["precision", "f1_score", "report", "errors"] + ) + + +async def test_cross_validation_with_callback_success( + rasa_non_trained_app: SanicASGITestClient, + nlu_data_path: Text, + monkeypatch: MonkeyPatch, + stack_config_path: Text, +): + nlu_data = Path(nlu_data_path).read_text() + config = Path(stack_config_path).read_text() + payload = f"{nlu_data}\n{config}" + + callback_url = "https://example.com/webhooks/actions" + with aioresponses() as mocked: + mocked.post(callback_url, payload={}) + + mocked_cross_validation = AsyncMock( + return_value=( + CVEvaluationResult({}, {}, {}), + CVEvaluationResult({}, {}, {}), + CVEvaluationResult({}, {}, {}), + ) + ) + monkeypatch.setattr( + rasa.nlu.test, + rasa.nlu.test.cross_validate.__name__, + mocked_cross_validation, + ) + + _, response = await rasa_non_trained_app.post( + "/model/test/intents", + data=payload, + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + params={"cross_validation_folds": 3, "callback_url": callback_url}, + ) + + assert response.status == HTTPStatus.NO_CONTENT + + # Sleep to give event loop time to process things in the background + await asyncio.sleep(1) + + mocked_cross_validation.assert_called_once() + + last_request = latest_request(mocked, "POST", callback_url) + assert last_request + + content = last_request[0].kwargs["data"] + response_body = json.loads(content) + for required_key in { + "intent_evaluation", + "entity_evaluation", + "response_selection_evaluation", + }: + assert required_key in response_body + + details = response_body[required_key] + assert all( + key in details for key in ["precision", "f1_score", "report", "errors"] + ) + + +@pytest.mark.flaky +async def test_cross_validation_with_callback_error( + rasa_non_trained_app: SanicASGITestClient, + nlu_data_path: Text, + monkeypatch: MonkeyPatch, + stack_config_path: Text, +): + nlu_data = Path(nlu_data_path).read_text() + config = Path(stack_config_path).read_text() + payload = f"{nlu_data}\n{config}" + + monkeypatch.setattr( + rasa.nlu.test, + rasa.nlu.test.cross_validate.__name__, + Mock(side_effect=ValueError()), + ) + + callback_url = "https://example.com/webhooks/actions" + with aioresponses() as mocked: + mocked.post(callback_url, payload={}) + + _, response = await rasa_non_trained_app.post( + "/model/test/intents", + data=payload, + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + params={"cross_validation_folds": 3, "callback_url": callback_url}, + ) + + assert response.status == HTTPStatus.NO_CONTENT + + await asyncio.sleep(1) + + last_request = latest_request(mocked, "POST", callback_url) + assert last_request + + content = last_request[0].kwargs["json"] + assert content["code"] == HTTPStatus.INTERNAL_SERVER_ERROR + + +async def test_callback_unexpected_error( + rasa_non_trained_app: SanicASGITestClient, + nlu_data_path: Text, + monkeypatch: MonkeyPatch, + stack_config_path: Text, +): + nlu_data = Path(nlu_data_path).read_text() + config = Path(stack_config_path).read_text() + payload = f"{nlu_data}\n{config}" + + async def raiseUnexpectedError() -> NoReturn: + raise ValueError() + + monkeypatch.setattr( + rasa.server, + rasa.server._training_payload_from_yaml.__name__, + Mock(side_effect=ValueError()), + ) + + callback_url = "https://example.com/webhooks/actions" + with aioresponses() as mocked: + mocked.post(callback_url, payload={}) + + _, response = await rasa_non_trained_app.post( + "/model/test/intents", + data=payload, + headers={"Content-type": rasa.server.YAML_CONTENT_TYPE}, + params={"cross_validation_folds": 3, "callback_url": callback_url}, + ) + + assert response.status == HTTPStatus.NO_CONTENT + + await asyncio.sleep(1) + + last_request = latest_request(mocked, "POST", callback_url) + assert last_request + + content = last_request[0].kwargs["json"] + assert content["code"] == HTTPStatus.INTERNAL_SERVER_ERROR + + +async def test_predict(rasa_app: SanicASGITestClient): + data = [ + {"event": "action", "name": "action_listen"}, + { + "event": "user", + "text": "hello", + "parse_data": { + "entities": [], + "intent": {"confidence": 0.57, INTENT_NAME_KEY: "greet"}, + "text": "hello", + }, + }, + ] + + _, response = await rasa_app.post( + "/model/predict", + json=data, + headers={"Content-Type": rasa.server.JSON_CONTENT_TYPE}, + ) + content = response.json + assert response.status == HTTPStatus.OK + assert "scores" in content + assert "tracker" in content + assert "policy" in content + + +async def test_predict_invalid_entities_format(rasa_app: SanicASGITestClient): + data = [ + {"event": "action", "name": "action_listen"}, + { + "event": "user", + "text": "hello", + "parse_data": { + "entities": {}, + "intent": {"confidence": 0.57, INTENT_NAME_KEY: "greet"}, + "text": "hello", + }, + }, + ] + + _, response = await rasa_app.post( + "/model/predict", + json=data, + headers={"Content-Type": rasa.server.JSON_CONTENT_TYPE}, + ) + assert response.status == HTTPStatus.BAD_REQUEST + + +async def test_predict_empty_request_body(rasa_app: SanicASGITestClient): + _, response = await rasa_app.post( + "/model/predict", headers={"Content-Type": rasa.server.JSON_CONTENT_TYPE} + ) + assert response.status == HTTPStatus.BAD_REQUEST + + +async def test_append_events_empty_request_body(rasa_app: SanicASGITestClient): + _, response = await rasa_app.post( + "/conversations/testid/tracker/events", + headers={"Content-Type": rasa.server.JSON_CONTENT_TYPE}, + ) + assert response.status == HTTPStatus.BAD_REQUEST + + +async def test_replace_events_empty_request_body(rasa_app: SanicASGITestClient): + _, response = await rasa_app.put( + "/conversations/testid/tracker/events", + headers={"Content-Type": rasa.server.JSON_CONTENT_TYPE}, + ) + assert response.status == HTTPStatus.BAD_REQUEST + + +@freeze_time("2018-01-01") +async def test_requesting_non_existent_tracker(rasa_app: SanicASGITestClient): + model_id = rasa_app.sanic_app.ctx.agent.model_id + assistant_id = rasa_app.sanic_app.ctx.agent.processor.model_metadata.assistant_id + _, response = await rasa_app.get("/conversations/madeupid/tracker") + content = response.json + assert response.status == HTTPStatus.OK + assert content["paused"] is False + assert content["slots"] == { + "name": None, + REQUESTED_SLOT: None, + SESSION_START_METADATA_SLOT: None, + } + assert content["sender_id"] == "madeupid" + assert content["events"] == [ + { + "event": "action", + "name": "action_session_start", + "policy": None, + "confidence": 1, + "timestamp": 1514764800, + "action_text": None, + "hide_rule_turn": False, + "metadata": {"assistant_id": assistant_id, "model_id": model_id}, + }, + { + "event": "session_started", + "timestamp": 1514764800, + "metadata": {"assistant_id": assistant_id, "model_id": model_id}, + }, + { + "event": "action", + INTENT_NAME_KEY: "action_listen", + "policy": None, + "confidence": None, + "timestamp": 1514764800, + "action_text": None, + "hide_rule_turn": False, + "metadata": {"assistant_id": assistant_id, "model_id": model_id}, + }, + ] + assert content["latest_message"] == { + "text": None, + "intent": {}, + "entities": [], + "message_id": None, + "metadata": {}, + } + + +@pytest.mark.parametrize("event", test_events) +async def test_pushing_event(rasa_app: SanicASGITestClient, event: Event): + model_id = rasa_app.sanic_app.ctx.agent.model_id + assistant_id = rasa_app.sanic_app.ctx.agent.processor.model_metadata.assistant_id + sender_id = str(uuid.uuid1()) + conversation = f"/conversations/{sender_id}" + + serialized_event = event.as_dict() + # Remove timestamp so that a new one is assigned on the server + serialized_event.pop("timestamp") + + time_before_adding_events = time.time() + # Wait a bit so that the server-generated timestamp is strictly greater + # than time_before_adding_events + time.sleep(0.01) + _, response = await rasa_app.post( + f"{conversation}/tracker/events", + json=serialized_event, + headers={"Content-Type": rasa.server.JSON_CONTENT_TYPE}, + ) + assert response.json is not None + assert response.status == HTTPStatus.OK + + _, tracker_response = await rasa_app.get(f"/conversations/{sender_id}/tracker") + tracker = tracker_response.json + assert tracker is not None + + assert len(tracker.get("events")) == 4 + + deserialized_events = [Event.from_parameters(event) for event in tracker["events"]] + + # there is an initial session start sequence at the beginning of the tracker + + assert deserialized_events[:3] == with_assistant_ids( + with_model_ids(session_start_sequence, model_id), assistant_id + ) + + assert deserialized_events[3] == with_assistant_id( + with_model_id(event, model_id), assistant_id + ) + assert deserialized_events[3].timestamp > time_before_adding_events + + +async def test_pushing_event_with_existing_model_id(rasa_app: SanicASGITestClient): + model_id = rasa_app.sanic_app.ctx.agent.model_id + assistant_id = rasa_app.sanic_app.ctx.agent.processor.model_metadata.assistant_id + sender_id = str(uuid.uuid1()) + conversation = f"/conversations/{sender_id}" + + existing_model_id = "some_old_id" + assert existing_model_id != model_id + event = with_assistant_id( + with_model_id(BotUttered("hello!"), existing_model_id), assistant_id + ) + serialized_event = event.as_dict() + + # Wait a bit so that the server-generated timestamp is strictly greater + # than time_before_adding_events + _, response = await rasa_app.post( + f"{conversation}/tracker/events", + json=serialized_event, + headers={"Content-Type": rasa.server.JSON_CONTENT_TYPE}, + ) + _, tracker_response = await rasa_app.get(f"/conversations/{sender_id}/tracker") + tracker = tracker_response.json + + deserialized_events = [Event.from_parameters(event) for event in tracker["events"]] + + # there is an initial session start sequence at the beginning of the tracker + received_event = deserialized_events[3] + assert received_event == with_assistant_id( + with_model_id(event, existing_model_id), assistant_id + ) + + +async def test_push_multiple_events(rasa_app: SanicASGITestClient): + model_id = rasa_app.sanic_app.ctx.agent.model_id + assistant_id = rasa_app.sanic_app.ctx.agent.processor.model_metadata.assistant_id + conversation_id = str(uuid.uuid1()) + conversation = f"/conversations/{conversation_id}" + + events = [e.as_dict() for e in test_events] + _, response = await rasa_app.post( + f"{conversation}/tracker/events", + json=events, + headers={"Content-Type": rasa.server.JSON_CONTENT_TYPE}, + ) + assert response.json is not None + assert response.status == HTTPStatus.OK + + _, tracker_response = await rasa_app.get( + f"/conversations/{conversation_id}/tracker" + ) + tracker = tracker_response.json + assert tracker is not None + + # there is an initial session start sequence at the beginning + assert [ + Event.from_parameters(event) for event in tracker.get("events") + ] == with_assistant_ids( + with_model_ids(session_start_sequence + test_events, model_id), assistant_id + ) + + +@pytest.mark.parametrize( + "params", ["?execute_side_effects=true&output_channel=callback", ""] +) +async def test_pushing_event_while_executing_side_effects( + rasa_server: Sanic, params: Text +): + input_channel = CallbackInput(EndpointConfig("https://example.com/callback")) + channel.register([input_channel], rasa_server, "/webhooks/") + rasa_app = rasa_server.asgi_client + sender_id = str(uuid.uuid1()) + conversation = f"/conversations/{sender_id}" + + serialized_event = test_events[1].as_dict() + + with aioresponses() as mocked: + mocked.post( + "https://example.com/callback", + repeat=True, + headers={"Content-Type": "application/json"}, + ) + await rasa_app.post( + f"{conversation}/tracker/events{params}", + json=serialized_event, + headers={"Content-Type": rasa.server.JSON_CONTENT_TYPE}, + ) + + r = latest_request(mocked, "post", "https://example.com/callback") + + if not params: + assert r is None + else: + message_received = json_of_latest_request(r) + assert message_received.get("recipient_id") == sender_id + assert message_received.get("text") == serialized_event.get("text") + + +async def test_post_conversation_id_with_slash(rasa_app: SanicASGITestClient): + model_id = rasa_app.sanic_app.ctx.agent.model_id + assistant_id = rasa_app.sanic_app.ctx.agent.processor.model_metadata.assistant_id + conversation_id = str(uuid.uuid1()) + id_len = len(conversation_id) // 2 + conversation_id = conversation_id[:id_len] + "/+-_\\=" + conversation_id[id_len:] + conversation = f"/conversations/{conversation_id}" + + events = [e.as_dict() for e in test_events] + _, response = await rasa_app.post( + f"{conversation}/tracker/events", + json=events, + headers={"Content-Type": "application/json"}, + ) + assert response.json is not None + assert response.status == HTTPStatus.OK + + _, tracker_response = await rasa_app.get( + f"/conversations/{conversation_id}/tracker" + ) + tracker = tracker_response.json + assert tracker is not None + + # there is a session start sequence at the start + assert [ + Event.from_parameters(event) for event in tracker.get("events") + ] == with_assistant_ids( + with_model_ids(session_start_sequence + test_events, model_id), assistant_id + ) + + +async def test_put_tracker(rasa_app: SanicASGITestClient): + data = [event.as_dict() for event in test_events] + _, response = await rasa_app.put( + "/conversations/pushtracker/tracker/events", + json=data, + headers={"Content-Type": rasa.server.JSON_CONTENT_TYPE}, + ) + content = response.json + assert response.status == HTTPStatus.OK + assert len(content["events"]) == len(test_events) + assert content["sender_id"] == "pushtracker" + + _, tracker_response = await rasa_app.get("/conversations/pushtracker/tracker") + tracker = tracker_response.json + assert tracker is not None + evts = tracker.get("events") + assert events.deserialise_events(evts) == test_events + + +async def test_predict_without_conversation_id(rasa_app: SanicASGITestClient): + _, response = await rasa_app.post("/conversations/non_existent_id/predict") + + assert response.status == HTTPStatus.NOT_FOUND + assert response.json["message"] == "Conversation ID not found." + + +async def test_sorted_predict(rasa_app: SanicASGITestClient): + await _create_tracker_for_sender(rasa_app, "sortedpredict") + + _, response = await rasa_app.post("/conversations/sortedpredict/predict") + scores = response.json["scores"] + sorted_scores = sorted(scores, key=lambda k: (-k["score"], k["action"])) + assert scores == sorted_scores + + +async def _create_tracker_for_sender(app: SanicASGITestClient, sender_id: Text) -> None: + data = [event.as_dict() for event in test_events[:3]] + _, response = await app.put( + f"/conversations/{sender_id}/tracker/events", + json=data, + headers={"Content-Type": rasa.server.JSON_CONTENT_TYPE}, + ) + + assert response.status == HTTPStatus.OK + + +async def test_get_tracker_with_jwt(rasa_secured_app: SanicASGITestClient): + # token generated with secret "core" and algorithm HS256 + # on https://jwt.io/ + + # {"user": {"username": "testadmin", "role": "admin"}} + jwt_header = { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJ1c2VyIjp7InVzZXJuYW1lIjoidGVzdGFkbWluIiwic" + "m9sZSI6ImFkbWluIn19.NAQr0kbtSrY7d28XTqRzawq2u" + "QRre7IWTuIDrCn5AIw" + } + _, response = await rasa_secured_app.get( + "/conversations/testadmin/tracker", headers=jwt_header + ) + assert response.status == HTTPStatus.OK + + _, response = await rasa_secured_app.get( + "/conversations/testuser/tracker", headers=jwt_header + ) + assert response.status == HTTPStatus.OK + + # {"user": {"username": "testuser", "role": "user"}} + jwt_header = { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJ1c2VyIjp7InVzZXJuYW1lIjoidGVzdHVzZXIiLCJyb" + "2xlIjoidXNlciJ9fQ.JnMTLYd56qut2w9h7hRQlDm1n3l" + "HJHOxxC_w7TtwCrs" + } + _, response = await rasa_secured_app.get( + "/conversations/testadmin/tracker", headers=jwt_header + ) + assert response.status == HTTPStatus.FORBIDDEN + + _, response = await rasa_secured_app.get( + "/conversations/testuser/tracker", headers=jwt_header + ) + assert response.status == HTTPStatus.OK + + +async def test_get_tracker_with_asymmetric_jwt( + rasa_secured_app_asymmetric: SanicASGITestClient, + encoded_jwt: Text, +) -> None: + jwt_header = {"Authorization": f"Bearer {encoded_jwt}"} + _, response = await rasa_secured_app_asymmetric.get( + "/conversations/myuser/tracker", headers=jwt_header + ) + assert response.status == HTTPStatus.OK + + _, response = await rasa_secured_app_asymmetric.get( + "/conversations/testuser/tracker", headers=jwt_header + ) + assert response.status == HTTPStatus.OK + + +def test_list_routes(empty_agent: Agent): + app = rasa.server.create_app(empty_agent, auth_token=None) + + routes = utils.list_routes(app) + assert set(routes.keys()) == { + "hello", + "version", + "status", + "retrieve_tracker", + "append_events", + "replace_events", + "retrieve_story", + "execute_action", + "trigger_intent", + "predict", + "add_message", + "train", + "evaluate_stories", + "evaluate_intents", + "tracker_predict", + "parse", + "load_model", + "unload_model", + "get_domain", + } + + +async def test_unload_model_error(rasa_app: SanicASGITestClient): + _, response = await rasa_app.get("/status") + assert response.status == HTTPStatus.OK + assert "model_file" in response.json and response.json["model_file"] is not None + + _, response = await rasa_app.delete("/model") + assert response.status == HTTPStatus.NO_CONTENT + + +async def test_get_domain(rasa_app: SanicASGITestClient, domain_path: Text): + _, response = await rasa_app.get( + "/domain", headers={"accept": rasa.server.JSON_CONTENT_TYPE} + ) + + content = response.json + + assert response.status == HTTPStatus.OK + # assert only keys in `domain_path` fixture + original_domain_dict = Domain.load(domain_path).as_dict() + for key in original_domain_dict.keys(): + assert key in content + + +async def test_get_domain_invalid_accept_header(rasa_app: SanicASGITestClient): + _, response = await rasa_app.get("/domain") + + assert response.status == HTTPStatus.NOT_ACCEPTABLE + + +async def test_load_model(rasa_app: SanicASGITestClient, trained_core_model: Text): + _, response = await rasa_app.get("/status") + + assert response.status == HTTPStatus.OK + assert "model_id" in response.json + + old_model_id = response.json["model_id"] + + data = {"model_file": trained_core_model} + _, response = await rasa_app.put("/model", json=data) + + assert response.status == HTTPStatus.NO_CONTENT + + _, response = await rasa_app.get("/status") + + assert response.status == HTTPStatus.OK + assert "model_id" in response.json + + assert old_model_id != response.json["model_id"] + + +async def test_load_model_from_model_server( + rasa_app: SanicASGITestClient, trained_core_model: Text, tear_down_scheduler: None +): + _, response = await rasa_app.get("/status") + + assert response.status == HTTPStatus.OK + assert "model_id" in response.json + + old_model_id = response.json["model_id"] + + endpoint = EndpointConfig("https://example.com/model/trained_core_model") + with open(trained_core_model, "rb") as f: + with aioresponses(passthrough=["http://127.0.0.1"]) as mocked: + headers = {} + fs = os.fstat(f.fileno()) + headers["Content-Length"] = str(fs[6]) + mocked.get( + "https://example.com/model/trained_core_model", + content_type="application/x-tar", + headers={ + "filename": "some_model_name.tar.gz", + "ETag": "new_fingerprint", + }, + body=f.read(), + ) + data = {"model_server": {"url": endpoint.url}} + _, response = await rasa_app.put("/model", json=data) + + assert response.status == HTTPStatus.NO_CONTENT + + _, response = await rasa_app.get("/status") + + assert response.status == HTTPStatus.OK + assert "model_id" in response.json + + assert old_model_id != response.json["model_id"] + + +async def test_load_model_invalid_request_body( + rasa_non_trained_app: SanicASGITestClient, +): + _, response = await rasa_non_trained_app.put("/model") + + assert response.status == HTTPStatus.BAD_REQUEST + + +async def test_load_model_invalid_configuration( + rasa_non_trained_app: SanicASGITestClient, +): + data = {"model_file": "some-random-path"} + _, response = await rasa_non_trained_app.put("/model", json=data) + + assert response.status == HTTPStatus.BAD_REQUEST + + +async def test_execute(rasa_app: SanicASGITestClient): + await _create_tracker_for_sender(rasa_app, "test_execute") + + data = {INTENT_NAME_KEY: "utter_greet"} + _, response = await rasa_app.post("/conversations/test_execute/execute", json=data) + + assert response.status == HTTPStatus.OK + + parsed_content = response.json + assert parsed_content["tracker"] + assert parsed_content["messages"] + + +async def test_execute_without_conversation_id(rasa_app: SanicASGITestClient): + data = {INTENT_NAME_KEY: "utter_greet"} + _, response = await rasa_app.post( + "/conversations/non_existent_id/execute", json=data + ) + + assert response.status == HTTPStatus.NOT_FOUND + assert response.json["message"] == "Conversation ID not found." + + +async def test_execute_with_missing_action_name(rasa_app: SanicASGITestClient): + test_sender = "test_execute_with_missing_action_name" + await _create_tracker_for_sender(rasa_app, test_sender) + + data = {"wrong-key": "utter_greet"} + _, response = await rasa_app.post( + f"/conversations/{test_sender}/execute", json=data + ) + + assert response.status == HTTPStatus.BAD_REQUEST + + +async def test_execute_with_not_existing_action(rasa_app: SanicASGITestClient): + test_sender = "test_execute_with_not_existing_action" + await _create_tracker_for_sender(rasa_app, test_sender) + + data = {"name": "ka[pa[opi[opj[oj[oija"} + _, response = await rasa_app.post( + f"/conversations/{test_sender}/execute", json=data + ) + + assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +async def test_trigger_intent(rasa_app: SanicASGITestClient): + data = {INTENT_NAME_KEY: "greet"} + _, response = await rasa_app.post( + "/conversations/test_trigger/trigger_intent", json=data + ) + + assert response.status == HTTPStatus.OK + + parsed_content = response.json + assert parsed_content["tracker"] + assert parsed_content["messages"] + + +async def test_trigger_intent_with_entity(rasa_app: SanicASGITestClient): + entity_name = "name" + entity_value = "Sara" + data = {INTENT_NAME_KEY: "greet", "entities": {entity_name: entity_value}} + _, response = await rasa_app.post( + "/conversations/test_trigger/trigger_intent", json=data + ) + + assert response.status == HTTPStatus.OK + + parsed_content = response.json + last_slot_set_event = [ + event + for event in parsed_content["tracker"]["events"] + if event["event"] == "slot" + ][-1] + + assert parsed_content["tracker"] + assert parsed_content["messages"] + assert last_slot_set_event["name"] == entity_name + assert last_slot_set_event["value"] == entity_value + + +async def test_trigger_intent_with_missing_intent_name(rasa_app: SanicASGITestClient): + test_sender = "test_trigger_intent_with_missing_action_name" + + data = {"wrong-key": "greet"} + _, response = await rasa_app.post( + f"/conversations/{test_sender}/trigger_intent", json=data + ) + + assert response.status == HTTPStatus.BAD_REQUEST + + +async def test_trigger_intent_with_not_existing_intent(rasa_app: SanicASGITestClient): + test_sender = "test_trigger_intent_with_not_existing_intent" + await _create_tracker_for_sender(rasa_app, test_sender) + + data = {INTENT_NAME_KEY: "ka[pa[opi[opj[oj[oija"} + _, response = await rasa_app.post( + f"/conversations/{test_sender}/trigger_intent", json=data + ) + + assert response.status == HTTPStatus.NOT_FOUND + + +@pytest.mark.parametrize( + "input_channels, output_channel_to_use, expected_channel", + [ + (None, "slack", CollectingOutputChannel), + ([], None, CollectingOutputChannel), + ([RestInput()], "slack", CollectingOutputChannel), + ([RestInput()], "rest", CollectingOutputChannel), + ( + [RestInput(), SlackInput("test", slack_signing_secret="foobar")], + "slack", + SlackBot, + ), + ], +) +def test_get_output_channel( + input_channels: List[Text], output_channel_to_use: Text, expected_channel: Type +): + request = MagicMock() + app = MagicMock(ctx=Namespace()) + app.ctx.input_channels = input_channels + request.app = app + request.args = {"output_channel": output_channel_to_use} + + actual = rasa.server._get_output_channel(request, None) + + assert isinstance(actual, expected_channel) + + +@pytest.mark.parametrize( + "input_channels, expected_channel", + [ + ([], CollectingOutputChannel), + ([RestInput()], CollectingOutputChannel), + ([RestInput(), SlackInput("test", slack_signing_secret="foobar")], SlackBot), + ], +) +def test_get_latest_output_channel(input_channels: List[Text], expected_channel: Type): + request = MagicMock() + app = MagicMock(ctx=Namespace()) + app.ctx.input_channels = input_channels + request.app = app + request.args = {"output_channel": "latest"} + + tracker = DialogueStateTracker.from_events( + "default", [UserUttered("text", input_channel="slack")] + ) + + actual = rasa.server._get_output_channel(request, tracker) + + assert isinstance(actual, expected_channel) + + +def test_app_when_app_has_no_input_channels(): + request = MagicMock() + + class NoInputChannels: + ctx = Namespace() + pass + + request.app = NoInputChannels() + + actual = rasa.server._get_output_channel( + request, DialogueStateTracker.from_events("default", []) + ) + assert isinstance(actual, CollectingOutputChannel) + + +@pytest.mark.parametrize( + "conversation_events,until_time,fetch_all_sessions,expected", + # conversation with one session + [ + ( + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + UserUttered("hi", {"name": "greet"}), + ActionExecuted("utter_greet"), + ], + None, + True, + f"""version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +stories: +- story: some-conversation-ID + steps: + - intent: greet + user: |- + hi + - action: utter_greet""", + ), + # conversation with multiple sessions + ( + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + UserUttered("hi", {"name": "greet"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + UserUttered("bye bye", {"name": "goodbye"}), + ActionExecuted("utter_goodbye"), + ], + None, + True, + f"""version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +stories: +- story: some-conversation-ID, story 1 + steps: + - intent: greet + user: |- + hi + - action: utter_greet +- story: some-conversation-ID, story 2 + steps: + - intent: goodbye + user: |- + bye bye + - action: utter_goodbye""", + ), + # conversation with multiple sessions, but setting `all_sessions=false` + # means only the last one is returned + ( + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + UserUttered("hi", {"name": "greet"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + UserUttered("bye bye", {"name": "goodbye"}), + ActionExecuted("utter_goodbye"), + ], + None, + False, + f"""version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +stories: +- story: some-conversation-ID + steps: + - intent: goodbye + user: |- + bye bye + - action: utter_goodbye""", + ), + # the default for `all_sessions` is `false` - this test checks that + # only the latest session is returned in that case + ( + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + UserUttered("hi", {"name": "greet"}), + ActionExecuted("utter_greet"), + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + UserUttered("bye bye", {"name": "goodbye"}), + ActionExecuted("utter_goodbye"), + ], + None, + None, + f"""version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +stories: +- story: some-conversation-ID + steps: + - intent: goodbye + user: |- + bye bye + - action: utter_goodbye""", + ), + # `until` parameter means only the first session is returned + ( + [ + ActionExecuted(ACTION_SESSION_START_NAME, timestamp=1), + SessionStarted(timestamp=2), + UserUttered("hi", {"name": "greet"}, timestamp=3), + ActionExecuted("utter_greet", timestamp=4), + ActionExecuted(ACTION_SESSION_START_NAME, timestamp=5), + SessionStarted(timestamp=6), + UserUttered("bye bye", {"name": "goodbye"}, timestamp=7), + ActionExecuted("utter_goodbye", timestamp=8), + ], + 4, + True, + f"""version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +stories: +- story: some-conversation-ID + steps: + - intent: greet + user: |- + hi + - action: utter_greet""", + ), + # empty conversation + ([], None, True, f'version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}"'), + # Conversation with slot + ( + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + UserUttered("hi", {"name": "greet"}), + ActionExecuted("utter_greet"), + SlotSet(REQUESTED_SLOT, "some value"), + ], + None, + True, + f"""version: "{rasa.shared.constants.LATEST_TRAINING_DATA_FORMAT_VERSION}" +stories: +- story: some-conversation-ID + steps: + - intent: greet + user: |- + hi + - action: utter_greet + - slot_was_set: + - requested_slot: some value""", + ), + ], +) +async def test_get_story( + rasa_app: SanicASGITestClient, + monkeypatch: MonkeyPatch, + conversation_events: List[Event], + until_time: Optional[float], + fetch_all_sessions: Optional[bool], + expected: Text, +): + conversation_id = "some-conversation-ID" + + tracker_store = InMemoryTrackerStore(Domain.empty()) + tracker = DialogueStateTracker.from_events(conversation_id, conversation_events) + + await tracker_store.save(tracker) + + monkeypatch.setattr(rasa_app.sanic_app.ctx.agent, "tracker_store", tracker_store) + monkeypatch.setattr( + rasa_app.sanic_app.ctx.agent.processor, "tracker_store", tracker_store + ) + + url = f"/conversations/{conversation_id}/story?" + + query = {} + + if fetch_all_sessions is not None: + query["all_sessions"] = fetch_all_sessions + + if until_time is not None: + query["until"] = until_time + + _, response = await rasa_app.get(url + urllib.parse.urlencode(query)) + + assert response.status == HTTPStatus.OK + assert response.content.decode().strip() == expected + + +async def test_get_story_without_conversation_id( + rasa_app: SanicASGITestClient, monkeypatch: MonkeyPatch +): + conversation_id = "some-conversation-ID" + url = f"/conversations/{conversation_id}/story" + + _, response = await rasa_app.get(url) + + assert response.status == HTTPStatus.NOT_FOUND + assert response.json["message"] == "Conversation ID not found." + + +async def test_get_story_does_not_update_conversation_session( + rasa_app: SanicASGITestClient, monkeypatch: MonkeyPatch +): + conversation_id = "some-conversation-ID" + + # domain with short session expiration time of one second + domain = Domain.empty() + domain.session_config = SessionConfig( + session_expiration_time=1 / 60, carry_over_slots=True + ) + + monkeypatch.setattr(rasa_app.sanic_app.ctx.agent.processor, "domain", domain) + + # conversation contains one session that has expired + now = time.time() + conversation_events = [ + ActionExecuted(ACTION_SESSION_START_NAME, timestamp=now - 10), + SessionStarted(timestamp=now - 9), + UserUttered("hi", {"name": "greet"}, timestamp=now - 8), + ActionExecuted("utter_greet", timestamp=now - 7), + ] + + tracker = DialogueStateTracker.from_events(conversation_id, conversation_events) + + # the conversation session has expired + assert rasa_app.sanic_app.ctx.agent.processor._has_session_expired(tracker) + + tracker_store = InMemoryTrackerStore(domain) + + await tracker_store.save(tracker) + + monkeypatch.setattr(rasa_app.sanic_app.ctx.agent, "tracker_store", tracker_store) + monkeypatch.setattr( + rasa_app.sanic_app.ctx.agent.processor, "tracker_store", tracker_store + ) + + _, response = await rasa_app.get(f"/conversations/{conversation_id}/story") + + assert response.status == HTTPStatus.OK + + # expected story is returned + assert ( + response.content.decode().strip() + == f"""version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +stories: +- story: some-conversation-ID + steps: + - intent: greet + user: |- + hi + - action: utter_greet""" + ) + + # the tracker has the same number of events as were initially added + assert len(tracker.events) == len(conversation_events) + + # the last event is still the same as before + assert tracker.events[-1].timestamp == conversation_events[-1].timestamp + + +@pytest.mark.parametrize( + "initial_tracker_events,events_to_append,expected_events", + [ + ( + # the tracker is initially empty, and no events are appended + # so we'll just expect the session start sequence with an `action_listen` + [], + [], + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ActionExecuted(ACTION_LISTEN_NAME), + ], + ), + ( + # the tracker is initially empty, and a user utterance is appended + # we expect a tracker with a session start sequence and a user utterance + [], + [UserUttered("/greet", {"name": "greet", "confidence": 1.0})], + [ + ActionExecuted(ACTION_SESSION_START_NAME), + SessionStarted(), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("/greet", {"name": "greet", "confidence": 1.0}), + ], + ), + ( + # the tracker is initially empty, and a session start sequence is appended + # we'll just expect the session start sequence + [], + [ActionExecuted(ACTION_SESSION_START_NAME), SessionStarted()], + [ActionExecuted(ACTION_SESSION_START_NAME), SessionStarted()], + ), + ( + # the tracker already contains some events - we can simply append events + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("/greet", {"name": "greet", "confidence": 1.0}), + ], + [ActionExecuted("utter_greet")], + [ + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered("/greet", {"name": "greet", "confidence": 1.0}), + ActionExecuted("utter_greet"), + ], + ), + ], +) +async def test_update_conversation_with_events( + rasa_app: SanicASGITestClient, + monkeypatch: MonkeyPatch, + initial_tracker_events: List[Event], + events_to_append: List[Event], + expected_events: List[Event], +): + conversation_id = "some-conversation-ID" + agent = rasa_app.sanic_app.ctx.agent + tracker_store = agent.tracker_store + domain = agent.domain + model_id = agent.model_id + assistant_id = agent.processor.model_metadata.assistant_id + + if initial_tracker_events: + tracker = await agent.processor.get_tracker(conversation_id) + tracker.update_with_events(initial_tracker_events, domain) + await tracker_store.save(tracker) + + fetched_tracker = await rasa.server.update_conversation_with_events( + conversation_id, agent.processor, domain, events_to_append + ) + assert list(fetched_tracker.events) == with_assistant_ids( + with_model_ids(expected_events, model_id), assistant_id + ) + + +async def test_append_events_does_not_repeat_session_start( + rasa_app: SanicASGITestClient, +): + session_start_events = [ + { + "event": "action", + "timestamp": 1644577572.9639301, + "metadata": { + "assistant_id": "unique_stack_assistant_test_name", + "model_id": "f90a69066e4a438aa6edfbed5b529919", + }, + "name": "action_session_start", + "policy": None, + "confidence": 1.0, + "action_text": None, + "hide_rule_turn": False, + }, + { + "event": "session_started", + "timestamp": 1644577572.963996, + "metadata": { + "assistant_id": "unique_stack_assistant_test_name", + "model_id": "f90a69066e4a438aa6edfbed5b529919", + }, + }, + { + "event": "action", + "timestamp": 1644577572.964009, + "metadata": { + "assistant_id": "unique_stack_assistant_test_name", + "model_id": "f90a69066e4a438aa6edfbed5b529919", + }, + "name": "action_listen", + "policy": None, + "confidence": None, + "action_text": None, + "hide_rule_turn": False, + }, + ] + _, response = await rasa_app.post( + "/conversations/testid/tracker/events", json=session_start_events + ) + + assert response.json["events"] == session_start_events + + +async def _create_tracker_for_query_params( + rasa_app: SanicASGITestClient, + model_id: Text, +) -> Tuple[Text, List[Event]]: + sender_id = uuid.uuid4().hex + + events_to_store: List[Event] = ( + session_start_sequence + + [ + UserUttered( + "hi", + parse_data={ + "intent": {"name": "greet"}, + "metadata": {"model_id": model_id}, + }, + ), + ActionExecuted("utter_greet"), + BotUttered("hey there"), + ActionExecuted(ACTION_LISTEN_NAME), + UserUttered( + "/restart", + parse_data={ + "intent": {"name": "restart"}, + "metadata": {"model_id": model_id}, + }, + ), + ActionExecuted(ACTION_RESTART_NAME), + Restarted(), + ] + + session_start_sequence + + [ + UserUttered( + "hi again", + parse_data={ + "intent": {"name": "greet"}, + "metadata": {"model_id": model_id}, + }, + ), + ] + ) + serialized_events_to_store = [event.as_dict() for event in events_to_store] + + _, response = await rasa_app.post( + f"/conversations/{sender_id}/tracker/events", json=serialized_events_to_store + ) + assert response.status == 200 + + return sender_id, events_to_store + + +async def test_get_tracker_with_query_param_include_events_all( + rasa_app: SanicASGITestClient, +) -> None: + model_id = rasa_app.sanic_app.ctx.agent.model_id + assistant_id = rasa_app.sanic_app.ctx.agent.processor.model_metadata.assistant_id + sender_id, events_to_store = await _create_tracker_for_query_params( + rasa_app, model_id + ) + + _, response = await rasa_app.get( + f"/conversations/{sender_id}/tracker?include_events=ALL" + ) + assert response.status == 200 + + tracker = response.json + assert tracker["sender_id"] == sender_id + + serialized_actual_events = tracker["events"] + + expected_events = with_assistant_ids( + with_model_ids(events_to_store, model_id), assistant_id + ) + serialized_expected_events = [event.as_dict() for event in expected_events] + + assert serialized_actual_events == serialized_expected_events + + +async def test_get_tracker_with_query_param_include_events_after_restart( + rasa_app: SanicASGITestClient, +) -> None: + model_id = rasa_app.sanic_app.ctx.agent.model_id + assistant_id = rasa_app.sanic_app.ctx.agent.processor.model_metadata.assistant_id + sender_id, events_to_store = await _create_tracker_for_query_params( + rasa_app, model_id + ) + + _, response = await rasa_app.get( + f"/conversations/{sender_id}/tracker?include_events=AFTER_RESTART" + ) + assert response.status == 200 + + tracker = response.json + assert tracker["sender_id"] == sender_id + + serialized_actual_events = tracker["events"] + + restarted_event = [ + event for event in events_to_store if isinstance(event, Restarted) + ][0] + truncated_events = events_to_store[events_to_store.index(restarted_event) + 1 :] + expected_events = with_assistant_ids( + with_model_ids(truncated_events, model_id), assistant_id + ) + serialized_expected_events = [e.as_dict() for e in expected_events] + + assert serialized_actual_events == serialized_expected_events + + +async def test_get_tracker_with_query_param_include_events_applied( + rasa_app: SanicASGITestClient, +) -> None: + model_id = rasa_app.sanic_app.ctx.agent.model_id + assistant_id = rasa_app.sanic_app.ctx.agent.processor.model_metadata.assistant_id + sender_id, events_to_store = await _create_tracker_for_query_params( + rasa_app, model_id + ) + + _, response = await rasa_app.get( + f"/conversations/{sender_id}/tracker?include_events=APPLIED" + ) + assert response.status == 200 + + tracker = response.json + assert tracker["sender_id"] == sender_id + + serialized_actual_events = tracker["events"] + + restarted_event = [ + event for event in events_to_store if isinstance(event, Restarted) + ][0] + truncated_events = events_to_store[events_to_store.index(restarted_event) + 1 :] + session_started = [ + event for event in truncated_events if isinstance(event, SessionStarted) + ][0] + truncated_events = truncated_events[truncated_events.index(session_started) + 1 :] + + expected_events = with_assistant_ids( + with_model_ids(truncated_events, model_id), assistant_id + ) + serialized_expected_events = [e.as_dict() for e in expected_events] + + assert serialized_actual_events == serialized_expected_events + + +async def test_get_tracker_with_query_param_include_events_none( + rasa_app: SanicASGITestClient, +) -> None: + model_id = rasa_app.sanic_app.ctx.agent.model_id + sender_id, events_to_store = await _create_tracker_for_query_params( + rasa_app, model_id + ) + + _, response = await rasa_app.get( + f"/conversations/{sender_id}/tracker?include_events=NONE" + ) + assert response.status == 200 + + tracker = response.json + assert tracker["sender_id"] == sender_id + + serialized_actual_events = tracker["events"] + assert serialized_actual_events is None + + +async def test_retrieve_story_with_query_param_all_sessions_true( + rasa_app: SanicASGITestClient, +) -> None: + model_id = rasa_app.sanic_app.ctx.agent.model_id + sender_id, events_to_store = await _create_tracker_for_query_params( + rasa_app, model_id + ) + + _, response = await rasa_app.get( + f"/conversations/{sender_id}/story?all_sessions=true" + ) + assert response.status == 200 + + story_content = response.body.decode("utf-8") + + expected_first_story = textwrap.dedent( + f""" + - story: {sender_id}, story 1 + steps: + - intent: greet + user: |- + hi + - action: utter_greet + - intent: restart + user: |- + /restart + - action: action_restart""" + ) + assert expected_first_story in story_content + + expected_second_story = textwrap.dedent( + f""" + - story: {sender_id}, story 2 + steps: + - intent: greet + user: |- + hi again""" + ) + assert expected_second_story in story_content + + +async def test_retrieve_story_with_query_param_all_sessions_false( + rasa_app: SanicASGITestClient, +) -> None: + model_id = rasa_app.sanic_app.ctx.agent.model_id + sender_id, events_to_store = await _create_tracker_for_query_params( + rasa_app, model_id + ) + + _, response = await rasa_app.get( + f"/conversations/{sender_id}/story?all_sessions=false" + ) + assert response.status == 200 + + story_content = response.body.decode("utf-8") + expected_story = textwrap.dedent( + f""" + - story: {sender_id} + steps: + - intent: greet + user: |- + hi again""" + ) + assert expected_story in story_content + + +async def test_retrieve_tracker_with_customized_action_session_start( + rasa_app: SanicASGITestClient, + monkeypatch: MonkeyPatch, +) -> None: + sender_id = str(uuid.uuid1()) + + async def mock_run_action_session_start( + self, + *args: Any, + **kwargs: Any, + ) -> List[Event]: + _events: List[Event] = [ + SessionStarted(), + BotUttered("Hey there!", {"name": "utter_greet"}), + ActionExecuted(ACTION_LISTEN_NAME), + ] + + return _events + + monkeypatch.setattr( + "rasa.core.actions.action.ActionSessionStart.run", mock_run_action_session_start + ) + + _, response = await rasa_app.get( + f"/conversations/{sender_id}/tracker", + headers={"Content-Type": rasa.server.JSON_CONTENT_TYPE}, + ) + + assert response.status == 200 + + tracker = response.json + assert tracker is not None + + tracker_events = tracker.get("events") + assert len(tracker_events) == 4 + + assert tracker_events[0].get("event") == "action" + assert tracker_events[0].get("name") == "action_session_start" + + assert tracker_events[1].get("event") == "session_started" + + assert tracker_events[2].get("event") == "bot" + assert tracker_events[2].get("text") == "Hey there!" + + assert tracker_events[3].get("event") == "action" + assert tracker_events[3].get("name") == "action_listen" diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 0000000..c189345 --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,539 @@ +import asyncio +import json +import os +from pathlib import Path +from typing import Any, Dict, Generator, Text + +from _pytest.monkeypatch import MonkeyPatch +import jsonschema +from unittest.mock import MagicMock, Mock +import pytest +import responses + +from rasa import telemetry +import rasa.constants +from rasa.core.agent import Agent +from rasa.core.brokers.broker import EventBroker +from rasa.core.channels import CmdlineInput +from rasa.core.tracker_store import TrackerStore +from rasa.shared.importers.importer import TrainingDataImporter +from rasa.shared.nlu.training_data.training_data import TrainingData + +TELEMETRY_TEST_USER = "083642a3e448423ca652134f00e7fc76" # just some random static id +TELEMETRY_TEST_KEY = "5640e893c1324090bff26f655456caf3" # just some random static id +TELEMETRY_EVENTS_JSON = "docs/docs/telemetry/events.json" + + +@pytest.fixture(autouse=True) +def patch_global_config_path(tmp_path: Path) -> Generator[None, None, None]: + """Ensure we use a unique config path for each test to avoid tests influencing + each other.""" + default_location = rasa.constants.GLOBAL_USER_CONFIG_PATH + rasa.constants.GLOBAL_USER_CONFIG_PATH = str(tmp_path / "global.yml") + yield + rasa.constants.GLOBAL_USER_CONFIG_PATH = default_location + + +@pytest.fixture(autouse=True) +def patch_telemetry_context() -> Generator[None, None, None]: + """Use a new telemetry context for each test to avoid tests influencing each other.""" + defaut_context = telemetry.TELEMETRY_CONTEXT + telemetry.TELEMETRY_CONTEXT = None + yield + telemetry.TELEMETRY_CONTEXT = defaut_context + + +async def test_events_schema( + monkeypatch: MonkeyPatch, default_agent: Agent, config_path: Text +): + # this allows us to patch the printing part used in debug mode to collect the + # reported events + monkeypatch.setenv("RASA_TELEMETRY_DEBUG", "true") + monkeypatch.setenv("RASA_TELEMETRY_ENABLED", "true") + + mock = Mock() + monkeypatch.setattr(telemetry, "print_telemetry_event", mock) + + with open(TELEMETRY_EVENTS_JSON) as f: + schemas = json.load(f)["events"] + initial = asyncio.all_tasks() + # Generate all known backend telemetry events, and then use events.json to + # validate their schema. + training_data = TrainingDataImporter.load_from_config(config_path) + + with telemetry.track_model_training(training_data, "rasa"): + pass + + telemetry.track_telemetry_disabled() + + telemetry.track_data_split(0.5, "nlu") + + telemetry.track_validate_files(True) + + telemetry.track_data_convert("yaml", "nlu") + + telemetry.track_tracker_export(5, TrackerStore(domain=None), EventBroker()) + + telemetry.track_interactive_learning_start(True, False) + + telemetry.track_server_start([CmdlineInput()], None, None, 42, True) + + telemetry.track_project_init("tests/") + + telemetry.track_shell_started("nlu") + + telemetry.track_visualization() + + telemetry.track_core_model_test(5, True, default_agent) + + telemetry.track_nlu_model_test(TrainingData()) + + telemetry.track_markers_extraction_initiated("all", False, False, None) + + telemetry.track_markers_extracted(1) + + telemetry.track_markers_stats_computed(1) + + telemetry.track_markers_parsed_count(1, 1, 1) + + # Also track train started for a graph config + training_data = TrainingDataImporter.load_from_config( + "data/test_config/graph_config.yml" + ) + with telemetry.track_model_training(training_data, "rasa"): + pass + + pending = asyncio.all_tasks() - initial + await asyncio.gather(*pending) + + assert mock.call_count == 20 + + for args, _ in mock.call_args_list: + event = args[0] + # `metrics_id` automatically gets added to all event but is + # not part of the schema so we need to remove it before validation + del event["properties"]["metrics_id"] + jsonschema.validate( + instance=event["properties"], schema=schemas[event["event"]] + ) + + +async def _mock_track_internal_exception(*args, **kwargs) -> None: + raise Exception("Tracking failed") + + +def test_config_path_empty(monkeypatch: MonkeyPatch): + # this tests the patch_global_config_path fixture -> makes sure the config + # is read from a temp file instead of the default location + assert "/.config/rasa" not in rasa.constants.GLOBAL_USER_CONFIG_PATH + + +def test_segment_request_header(): + assert telemetry.segment_request_header(TELEMETRY_TEST_KEY) == { + "Content-Type": "application/json", + "Authorization": "Basic NTY0MGU4OTNjMTMyNDA5MGJmZjI2ZjY1NTQ1NmNhZjM6", + } + + +def test_segment_payload(): + assert telemetry.segment_request_payload( + TELEMETRY_TEST_USER, "foobar", {"foo": "bar"}, {} + ) == { + "userId": TELEMETRY_TEST_USER, + "event": "foobar", + "properties": {"foo": "bar"}, + "context": {}, + } + + +def test_track_ignore_exception(monkeypatch: MonkeyPatch): + monkeypatch.setattr(telemetry, "_send_event", _mock_track_internal_exception) + + # If the test finishes without raising any exceptions, then it's successful + assert telemetry._track("Test") is None + + +def test_initialize_telemetry(): + telemetry.initialize_telemetry() + assert True + + +def test_initialize_telemetry_with_env_false(monkeypatch: MonkeyPatch): + monkeypatch.setenv("RASA_TELEMETRY_ENABLED", "false") + assert telemetry.initialize_telemetry() is False + + +def test_initialize_telemetry_with_env_true(monkeypatch: MonkeyPatch): + monkeypatch.setenv("RASA_TELEMETRY_ENABLED", "true") + assert telemetry.initialize_telemetry() is True + + +def test_initialize_telemetry_env_overwrites_config(monkeypatch: MonkeyPatch): + telemetry.toggle_telemetry_reporting(True) + assert telemetry.initialize_telemetry() is True + + monkeypatch.setenv("RASA_TELEMETRY_ENABLED", "false") + assert telemetry.initialize_telemetry() is False + + +def test_initialize_telemetry_prints_info(monkeypatch: MonkeyPatch): + # Mock actual training + mock = Mock() + monkeypatch.setattr(telemetry, "print_telemetry_reporting_info", mock) + + telemetry.initialize_telemetry() + + mock.assert_called_once() + + +def test_not_in_ci_if_not_in_ci(monkeypatch: MonkeyPatch): + for env in telemetry.CI_ENVIRONMENT_TELL: + monkeypatch.delenv(env, raising=False) + + assert not telemetry.in_continuous_integration() + + +def test_in_ci_if_in_ci(monkeypatch: MonkeyPatch): + monkeypatch.setenv("CI", True) + + assert telemetry.in_continuous_integration() + + +def test_with_default_context_fields_contains_package_versions(): + context = telemetry.with_default_context_fields() + assert "python" in context + assert context["rasa_open_source"] == rasa.__version__ + + +def test_default_context_fields_overwrite_by_context(): + context = telemetry.with_default_context_fields({"python": "foobar"}) + assert context["python"] == "foobar" + + +def test_track_sends_telemetry_id(monkeypatch: MonkeyPatch): + monkeypatch.setenv("RASA_TELEMETRY_ENABLED", "true") + telemetry.initialize_telemetry() + + mock = Mock() + monkeypatch.setattr(telemetry, "_send_event", mock) + telemetry._track("foobar", {"foo": "bar"}, {"baz": "foo"}) + + assert telemetry.get_telemetry_id() is not None + + mock.assert_called_once() + call_args = mock.call_args[0] + + assert call_args[0] == telemetry.get_telemetry_id() + assert call_args[1] == "foobar" + assert call_args[2]["foo"] == "bar" + assert call_args[2]["metrics_id"] == telemetry.get_telemetry_id() + assert call_args[3]["baz"] == "foo" + + +def test_toggle_telemetry_reporting(monkeypatch: MonkeyPatch): + # tests that toggling works if there is no config + telemetry.toggle_telemetry_reporting(True) + assert telemetry.initialize_telemetry() is True + + telemetry.toggle_telemetry_reporting(False) + assert telemetry.initialize_telemetry() is False + + # tests that toggling works if config is set to false + telemetry.toggle_telemetry_reporting(True) + assert telemetry.initialize_telemetry() is True + + +def test_segment_gets_called(monkeypatch: MonkeyPatch): + monkeypatch.setenv("RASA_TELEMETRY_WRITE_KEY", "foobar") + monkeypatch.setenv("RASA_TELEMETRY_ENABLED", "true") + telemetry.initialize_telemetry() + + with responses.RequestsMock() as rsps: + rsps.add(responses.POST, "https://api.segment.io/v1/track", json={}) + + telemetry._track( + "test event", {"foo": "bar"}, {"foobar": "baz", "license_hash": "foobar"} + ) + + assert len(rsps.calls) == 1 + r = rsps.calls[0] + + assert r + b = json.loads(r.request.body) + + assert "userId" in b + assert b["event"] == "test event" + assert b["properties"].get("foo") == "bar" + assert b["context"].get("foobar") == "baz" + + +def test_segment_does_not_raise_exception_on_failure(monkeypatch: MonkeyPatch): + monkeypatch.setenv("RASA_TELEMETRY_ENABLED", "true") + monkeypatch.setenv("RASA_TELEMETRY_WRITE_KEY", "foobar") + telemetry.initialize_telemetry() + + with responses.RequestsMock() as rsps: + rsps.add(responses.POST, "https://api.segment.io/v1/track", body="", status=505) + + # this call should complete without throwing an exception + telemetry._track( + "test event", {"foo": "bar"}, {"foobar": "baz", "license_hash": "foobar"} + ) + + assert rsps.assert_call_count("https://api.segment.io/v1/track", 1) + + +def test_segment_does_not_get_called_without_license(monkeypatch: MonkeyPatch): + monkeypatch.setenv("RASA_TELEMETRY_ENABLED", "true") + monkeypatch.setenv("RASA_TELEMETRY_WRITE_KEY", "foobar") + telemetry.initialize_telemetry() + + with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: + rsps.add(responses.POST, "https://api.segment.io/v1/track", body="", status=505) + + # this call should complete without throwing an exception + telemetry._track("test event", {"foo": "bar"}, {"foobar": "baz"}) + + assert rsps.assert_call_count("https://api.segment.io/v1/track", 0) + + +def test_environment_write_key_overwrites_key_file(monkeypatch: MonkeyPatch): + monkeypatch.setenv("RASA_TELEMETRY_WRITE_KEY", "foobar") + assert telemetry.telemetry_write_key() == "foobar" + + +def test_sentry_event_pii_removal(): + # this is an example event taken from sentry (generated by putting a print + # into `telemetry.strip_sensitive_data_from_sentry_event`) + event = { + "level": "error", + "exception": { + "values": [ + { + "module": None, + "type": "Exception", + "value": "Some unexpected exception.", + "mechanism": {"type": "excepthook", "handled": False}, + "stacktrace": { + "frames": [ + { + "filename": "rasa", + "abs_path": "/Users/tmbo/Library/Caches/pypoetry/virtualenvs/rasa-U5VQkfdm-py3.6/bin/rasa", + "function": "", + "module": "__main__", + "lineno": 33, + "pre_context": [ + "globals().setdefault('load_entry_point', importlib_load_entry_point)", + "", + "", + "if __name__ == '__main__':", + " sys.argv[0] = re.sub(r'(-script\\.pyw?|\\.exe)?$', '', sys.argv[0])", + ], + "context_line": " sys.exit(load_entry_point('rasa', 'console_scripts', 'rasa')())", + "post_context": [], + }, + { + "filename": "rasa/__main__.py", + "abs_path": "/Users/tmbo/lastmile/bot-ai/rasa/rasa/__main__.py", + "function": "main", + "module": "rasa.__main__", + "lineno": 113, + "pre_context": [ + "", + ' if hasattr(cmdline_arguments, "func"):', + " rasa.utils.io.configure_colored_logging(log_level)", + " set_log_and_warnings_filters()", + " rasa.telemetry.initialize_error_reporting()", + ], + "context_line": " cmdline_arguments.func(cmdline_arguments)", + "post_context": [ + ' elif hasattr(cmdline_arguments, "version"):', + " print_version()", + " else:", + " # user has not provided a subcommand, let's print the help", + ' logger.error("No command specified.")', + ], + "in_app": True, + }, + { + "filename": "rasa/cli/train.py", + "abs_path": "/Users/tmbo/lastmile/bot-ai/rasa/rasa/cli/train.py", + "function": "train", + "module": "rasa.cli.train", + "lineno": 69, + "pre_context": [ + " training_files = [", + ' get_validated_path(f, "data", DEFAULT_DATA_PATH, none_is_valid=True)', + " for f in args.data", + " ]", + "", + ], + "context_line": ' raise Exception("Some unexpected exception.")', + "post_context": [ + "", + " return rasa.train(", + " domain=domain,", + " config=config,", + " training_files=training_files,", + ], + "in_app": True, + }, + ] + }, + } + ] + }, + "event_id": "73dd4980a5fd498d96fec2ee3ee0cb86", + "timestamp": "2020-09-14T14:37:14.237740Z", + "breadcrumbs": {"values": []}, + "release": "rasa-2.0.0a4", + "environment": "production", + "server_name": "99ec342261934892aac1784d1ac061c1", + "sdk": { + "name": "sentry.python", + "version": "0.17.5", + "packages": [{"name": "pypi:sentry-sdk", "version": "0.17.5"}], + "integrations": ["atexit", "dedupe", "excepthook"], + }, + "platform": "python", + } + stripped = telemetry.strip_sensitive_data_from_sentry_event(event) + + for value in stripped.get("exception", {}).get("values", []): + for frame in value.get("stacktrace", {}).get("frames", []): + # make sure absolute path got removed from all stack entries + assert not frame.get("abs_path") + + +def _create_exception_event_in_file(filename: Text) -> Dict[Text, Any]: + """Create a sentry error event with the filename as the file the error occurred in. + + Args: + filename: name of the file the mock error supposedly happened in + Returns: + mock sentry error event + """ + # this is an example event taken from sentry (generated by putting a print + # into `telemetry.strip_sensitive_data_from_sentry_event`) + return { + "level": "error", + "exception": { + "values": [ + { + "module": None, + "type": "Exception", + "value": "Some unexpected exception.", + "mechanism": {"type": "excepthook", "handled": False}, + "stacktrace": { + "frames": [ + { + "filename": filename, + "abs_path": "/Users/tmbo/Library/Caches/pypoetry/virtualenvs/rasa-U5VQkfdm-py3.6/bin/rasa", + "function": "", + "module": "__main__", + "lineno": 33, + "pre_context": [ + "globals().setdefault('load_entry_point', importlib_load_entry_point)", + "", + "", + "if __name__ == '__main__':", + " sys.argv[0] = re.sub(r'(-script\\.pyw?|\\.exe)?$', '', sys.argv[0])", + ], + "context_line": " sys.exit(load_entry_point('rasa', 'console_scripts', 'rasa')())", + "post_context": [], + } + ] + }, + } + ] + }, + "event_id": "73dd4980a5fd498d96fec2ee3ee0cb86", + "timestamp": "2020-09-14T14:37:14.237740Z", + "breadcrumbs": {"values": []}, + "release": "rasa-2.0.0a4", + "environment": "production", + "server_name": "99ec342261934892aac1784d1ac061c1", + "sdk": { + "name": "sentry.python", + "version": "0.17.5", + "packages": [{"name": "pypi:sentry-sdk", "version": "0.17.5"}], + "integrations": ["atexit", "dedupe", "excepthook"], + }, + "platform": "python", + } + + +def test_sentry_drops_error_in_custom_path(): + event = _create_exception_event_in_file("/my_project/mymodule.py") + stripped = telemetry.strip_sensitive_data_from_sentry_event(event) + + assert stripped is None + + +def test_sentry_works_fine_with_relative_paths(): + event = _create_exception_event_in_file("rasa/train.py") + stripped = telemetry.strip_sensitive_data_from_sentry_event(event) + + assert stripped is not None + + stack_frames = stripped["exception"]["values"][0]["stacktrace"]["frames"] + assert stack_frames[0]["filename"] == "rasa/train.py" + + +def test_sentry_strips_absolute_path_from_site_packages(): + event = _create_exception_event_in_file( + "/Users/tmbo/Library/Caches/pypoetry/virtualenvs/rasa-U5VQkfdm-py3.7/lib/python3.7/site-packages/rasa/train.py" + ) + stripped = telemetry.strip_sensitive_data_from_sentry_event(event) + + assert stripped is not None + + stack_frames = stripped["exception"]["values"][0]["stacktrace"]["frames"] + assert stack_frames[0]["filename"] == f"site-packages{os.path.sep}rasa/train.py" + + +def test_sentry_strips_absolute_path_from_dist_packages(): + event = _create_exception_event_in_file( + "C:\\Users\\tmbo\\AppData\\Roaming\\Python\\Python35\\dist-packages\\rasa\\train.py" + ) + stripped = telemetry.strip_sensitive_data_from_sentry_event(event) + + assert stripped is not None + + stack_frames = stripped["exception"]["values"][0]["stacktrace"]["frames"] + assert stack_frames[0]["filename"] == f"dist-packages{os.path.sep}rasa\\train.py" + + +def test_context_contains_os(): + context = telemetry._default_context_fields() + + assert "os" in context + + context.pop("os") + + assert "os" in telemetry._default_context_fields() + + +def test_context_contains_license_hash(monkeypatch: MonkeyPatch) -> None: + mock = MagicMock() + mock.return_value.hook.get_license_hash.return_value = "1234567890" + monkeypatch.setattr("rasa.telemetry.plugin_manager", mock) + context = telemetry._default_context_fields() + + assert "license_hash" in context + assert mock.return_value.hook.get_license_hash.called + assert context["license_hash"] == "1234567890" + + # make sure it is still there after removing it + context.pop("license_hash") + assert "license_hash" in telemetry._default_context_fields() + + +def test_context_does_not_contain_license_hash(monkeypatch: MonkeyPatch) -> None: + mock = MagicMock() + mock.return_value.hook.get_license_hash.return_value = None + monkeypatch.setattr("rasa.telemetry.plugin_manager", mock) + context = telemetry._default_context_fields() + + assert "license_hash" not in context + assert mock.return_value.hook.get_license_hash.called diff --git a/tests/test_validator.py b/tests/test_validator.py new file mode 100644 index 0000000..90084e4 --- /dev/null +++ b/tests/test_validator.py @@ -0,0 +1,858 @@ +import textwrap +import warnings +from typing import Text + +import pytest +from _pytest.logging import LogCaptureFixture +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION + +from rasa.validator import Validator + +from rasa.shared.importers.rasa import RasaFileImporter +from pathlib import Path + + +@pytest.fixture(scope="class") +def validator_under_test() -> Validator: + importer = RasaFileImporter( + domain_path="data/test_validation/domain.yml", + training_data_paths=[ + "data/test_validation/data/nlu.yml", + "data/test_validation/data/stories.yml", + ], + ) + validator = Validator.from_importer(importer) + return validator + + +def test_verify_nlu_with_e2e_story(tmp_path: Path, nlu_data_path: Path): + story_file_name = tmp_path / "stories.yml" + with open(story_file_name, "w") as file: + file.write( + """ + stories: + - story: path 1 + steps: + - user: | + hello assistant! Can you help me today? + - intent: greet + - action: utter_greet + - intent: affirm + - action: utter_greet + - intent: bot_challenge + - action: utter_greet + - intent: deny + - action: goodbye + - intent: goodbye + - action: utter_goodbye + - intent: mood_great + - action: utter_happy + - intent: mood_unhappy + - action: utter_cheer_up + - action: utter_did_that_help + - action: utter_iamabot + """ + ) + importer = RasaFileImporter( + config_file="data/test_moodbot/config.yml", + domain_path="data/test_moodbot/domain.yml", + training_data_paths=[story_file_name, nlu_data_path], + ) + + validator = Validator.from_importer(importer) + # Since the nlu file actually fails validation, + # record warnings to make sure that the only raised warning + # is about the duplicate example 'good afternoon' + with pytest.warns(UserWarning) as record: + validator.verify_nlu(ignore_warnings=False) + assert len(record) == 1 + assert ( + "The example 'good afternoon' was found labeled with multiple different" + in record[0].message.args[0] + ) + + +def test_verify_intents_does_not_fail_on_valid_data(nlu_data_path: Text): + importer = RasaFileImporter( + domain_path="data/test_moodbot/domain.yml", training_data_paths=[nlu_data_path] + ) + validator = Validator.from_importer(importer) + # force validator to not ignore warnings (default is True) + assert validator.verify_intents(ignore_warnings=False) + + +def test_verify_intents_does_fail_on_invalid_data(nlu_data_path: Text): + # domain and nlu data are from different domain and should produce warnings + importer = RasaFileImporter( + domain_path="data/test_domains/default.yml", training_data_paths=[nlu_data_path] + ) + validator = Validator.from_importer(importer) + # force validator to not ignore warnings (default is True) + assert not validator.verify_intents(ignore_warnings=False) + + +def test_verify_valid_responses(): + importer = RasaFileImporter( + domain_path="data/test_domains/selectors.yml", + training_data_paths=[ + "data/test_selectors/nlu.yml", + "data/test_selectors/stories.yml", + ], + ) + validator = Validator.from_importer(importer) + assert validator.verify_utterances_in_stories() + + +def test_verify_valid_responses_in_rules(nlu_data_path: Text): + importer = RasaFileImporter( + domain_path="data/test_domains/default.yml", + training_data_paths=[ + nlu_data_path, + "data/test_yaml_stories/rules_without_stories_and_wrong_names.yml", + ], + ) + validator = Validator.from_importer(importer) + # force validator to not ignore warnings (default is True) + assert not validator.verify_utterances_in_stories(ignore_warnings=False) + + +def test_verify_story_structure(stories_path: Text): + importer = RasaFileImporter( + domain_path="data/test_domains/default.yml", training_data_paths=[stories_path] + ) + validator = Validator.from_importer(importer) + # force validator to not ignore warnings (default is True) + assert validator.verify_story_structure(ignore_warnings=False) + + +def test_verify_bad_story_structure(): + importer = RasaFileImporter( + domain_path="data/test_domains/default.yml", + training_data_paths=["data/test_yaml_stories/stories_conflicting_2.yml"], + ) + validator = Validator.from_importer(importer) + # force validator to not ignore warnings (default is True) + assert not validator.verify_story_structure(ignore_warnings=False) + + +def test_verify_bad_e2e_story_structure_when_text_identical(tmp_path: Path): + story_file_name = tmp_path / "stories.yml" + story_file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: path 1 + steps: + - user: | + amazing! + - action: utter_happy + - story: path 2 (should always conflict path 1) + steps: + - user: | + amazing! + - action: utter_cheer_up + """ + ) + # The two stories with identical user texts + importer = RasaFileImporter( + config_file="data/test_config/config_defaults.yml", + domain_path="data/test_domains/default.yml", + training_data_paths=[story_file_name], + ) + validator = Validator.from_importer(importer) + # force validator to not ignore warnings (default is True) + assert not validator.verify_story_structure(ignore_warnings=False) + + +def test_verify_correct_e2e_story_structure(tmp_path: Path): + story_file_name = tmp_path / "stories.yml" + with open(story_file_name, "w") as file: + file.write( + """ + stories: + - story: path 1 + steps: + - user: | + hello assistant! Can you help me today? + - action: utter_greet + - story: path 2 - state is similar but different from the one in path 1 + steps: + - user: | + hello assistant! you Can help me today? + - action: utter_goodbye + - story: path 3 + steps: + - user: | + That's it for today. Chat again tomorrow! + - action: utter_goodbye + """ + ) + importer = RasaFileImporter( + config_file="data/test_config/config_defaults.yml", + domain_path="data/test_domains/default.yml", + training_data_paths=[story_file_name], + ) + validator = Validator.from_importer(importer) + # force validator to not ignore warnings (default is True) + assert validator.verify_story_structure(ignore_warnings=False) + + +def test_verify_correct_e2e_story_structure_with_intents(tmp_path: Path): + story_file_name = tmp_path / "stories.yml" + with open(story_file_name, "w") as file: + file.write( + """ + stories: + - story: path 1 + steps: + - intent: greet + - action: utter_greet + - story: path 2 + steps: + - intent: goodbye + - action: utter_goodbye + """ + ) + importer = RasaFileImporter( + config_file="data/test_config/config_defaults.yml", + domain_path="data/test_domains/default.yml", + training_data_paths=[story_file_name], + ) + validator = Validator.from_importer(importer) + # force validator to not ignore warnings (default is True) + assert validator.verify_story_structure(ignore_warnings=False) + + +def test_verify_story_structure_ignores_rules(): + importer = RasaFileImporter( + domain_path="data/test_domains/default.yml", + training_data_paths=[ + "data/test_yaml_stories/stories_with_rules_conflicting.yml" + ], + ) + validator = Validator.from_importer(importer) + assert validator.verify_story_structure(ignore_warnings=False) + + +def test_verify_bad_story_structure_ignore_warnings(): + importer = RasaFileImporter( + domain_path="data/test_domains/default.yml", + training_data_paths=["data/test_yaml_stories/stories_conflicting_2.yml"], + ) + validator = Validator.from_importer(importer) + assert validator.verify_story_structure(ignore_warnings=True) + + +def test_verify_there_is_example_repetition_in_intents(nlu_data_path: Text): + # moodbot nlu data already has duplicated example 'good afternoon' + # for intents greet and goodbye + + importer = RasaFileImporter( + domain_path="data/test_moodbot/domain.yml", training_data_paths=[nlu_data_path] + ) + validator = Validator.from_importer(importer) + # force validator to not ignore warnings (default is True) + assert not validator.verify_example_repetition_in_intents(ignore_warnings=False) + + +def test_verify_logging_message_for_intent_not_used_in_nlu( + caplog: LogCaptureFixture, validator_under_test: Validator +): + caplog.clear() + with pytest.warns(UserWarning) as record: + # force validator to not ignore warnings (default is True) + validator_under_test.verify_intents(ignore_warnings=False) + + assert ( + "The intent 'goodbye' is listed in the domain file, " + "but is not found in the NLU training data." + in (m.message.args[0] for m in record) + ) + + +def test_verify_logging_message_for_intent_not_used_in_story( + caplog: LogCaptureFixture, validator_under_test: Validator +): + caplog.clear() + with pytest.warns(UserWarning) as record: + # force validator to not ignore warnings (default is True) + validator_under_test.verify_intents_in_stories(ignore_warnings=False) + + assert "The intent 'goodbye' is not used in any story or rule." in ( + m.message.args[0] for m in record + ) + + +def test_verify_logging_message_for_unused_utterance( + caplog: LogCaptureFixture, validator_under_test: Validator +): + caplog.clear() + with pytest.warns(UserWarning) as record: + # force validator to not ignore warnings (default is True) + validator_under_test.verify_utterances_in_stories(ignore_warnings=False) + + assert "The utterance 'utter_chatter' is not used in any story or rule." in ( + m.message.args[0] for m in record + ) + + +def test_verify_logging_message_for_repetition_in_intents(caplog, nlu_data_path: Text): + # moodbot nlu data already has duplicated example 'good afternoon' + # for intents greet and goodbye + importer = RasaFileImporter( + domain_path="data/test_moodbot/domain.yml", training_data_paths=[nlu_data_path] + ) + validator = Validator.from_importer(importer) + caplog.clear() # clear caplog to avoid counting earlier debug messages + with pytest.warns(UserWarning) as record: + # force validator to not ignore warnings (default is True) + validator.verify_example_repetition_in_intents(ignore_warnings=False) + assert len(record) == 1 + assert "You should fix that conflict " in record[0].message.args[0] + + +def test_early_exit_on_invalid_domain(): + domain_path = "data/test_domains/duplicate_intents.yml" + + importer = RasaFileImporter(domain_path=domain_path) + with pytest.warns(UserWarning) as record: + warnings.simplefilter("ignore", DeprecationWarning) + validator = Validator.from_importer(importer) + validator.verify_domain_validity() + + # two for non-unique domains, 2 for auto-fill removal + assert len(record) == 4 + + non_unique_warnings = list( + filter( + lambda warning: f"Loading domain from '{domain_path}' failed. " + f"Using empty domain. Error: 'Intents are not unique! " + f"Found multiple intents with name(s) ['default', 'goodbye']. " + f"Either rename or remove the duplicate ones.'" in warning.message.args[0], + record, + ) + ) + assert len(non_unique_warnings) == 2 + + auto_fill_warnings = list( + filter( + lambda warning: "Slot auto-fill has been removed in 3.0" + in warning.message.args[0], + record, + ) + ) + assert len(auto_fill_warnings) == 2 + + +def test_verify_there_is_not_example_repetition_in_intents(): + importer = RasaFileImporter( + domain_path="data/test_moodbot/domain.yml", + training_data_paths=["examples/knowledgebasebot/data/nlu.yml"], + ) + validator = Validator.from_importer(importer) + # force validator to not ignore warnings (default is True) + assert validator.verify_example_repetition_in_intents(ignore_warnings=False) + + +def test_verify_actions_in_stories_not_in_domain(tmp_path: Path, domain_path: Text): + story_file_name = tmp_path / "stories.yml" + story_file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: story path 1 + steps: + - intent: greet + - action: action_test_1 + """ + ) + + importer = RasaFileImporter( + domain_path=domain_path, training_data_paths=[story_file_name] + ) + validator = Validator.from_importer(importer) + with pytest.warns(UserWarning) as warning: + validity = validator.verify_actions_in_stories_rules() + assert validity is False + + assert ( + "The action 'action_test_1' is used in the 'story path 1' block, " + "but it is not listed in the domain file." in warning[0].message.args[0] + ) + + +def test_verify_actions_in_rules_not_in_domain(tmp_path: Path, domain_path: Text): + rules_file_name = tmp_path / "rules.yml" + rules_file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + rules: + - rule: rule path 1 + steps: + - intent: goodbye + - action: action_test_2 + """ + ) + importer = RasaFileImporter( + domain_path=domain_path, training_data_paths=[rules_file_name] + ) + validator = Validator.from_importer(importer) + with pytest.warns(UserWarning) as warning: + validity = validator.verify_actions_in_stories_rules() + assert validity is False + + assert ( + "The action 'action_test_2' is used in the 'rule path 1' block, " + "but it is not listed in the domain file." in warning[0].message.args[0] + ) + + +def test_verify_form_slots_invalid_domain(tmp_path: Path): + domain = tmp_path / "domain.yml" + domain.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + forms: + name_form: + required_slots: + - first_name + - last_nam + slots: + first_name: + type: text + mappings: + - type: from_text + last_name: + type: text + mappings: + - type: from_text + """ + ) + importer = RasaFileImporter(domain_path=domain) + validator = Validator.from_importer(importer) + with pytest.warns(UserWarning) as w: + validity = validator.verify_form_slots() + assert validity is False + + assert ( + w[0].message.args[0] == "The form slot 'last_nam' in form 'name_form' " + "is not present in the domain slots." + "Please add the correct slot or check for typos." + ) + + +def test_response_selector_responses_in_domain_no_errors(): + importer = RasaFileImporter( + config_file="data/test_config/config_defaults.yml", + domain_path="data/test_domains/response_selector_responses_in_domain.yml", + training_data_paths=[ + "data/test_yaml_stories/test_base_retrieval_intent_story.yml" + ], + ) + validator = Validator.from_importer(importer) + # force validator to not ignore warnings (default is True) + assert validator.verify_utterances_in_stories(ignore_warnings=False) + + +def test_invalid_domain_mapping_policy(): + importer = RasaFileImporter( + domain_path="data/test_domains/default_with_mapping.yml" + ) + validator = Validator.from_importer(importer) + assert validator.verify_domain_validity() is False + + +@pytest.mark.parametrize( + ("file_name", "data_type"), [("stories", "story"), ("rules", "rule")] +) +def test_valid_stories_rules_actions_in_domain( + file_name: Text, data_type: Text, tmp_path: Path +): + domain = tmp_path / "domain.yml" + domain.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + actions: + - action_greet + """ + ) + file_name = tmp_path / f"{file_name}.yml" + file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + {file_name}: + - {data_type}: test path + steps: + - intent: greet + - action: action_greet + """ + ) + importer = RasaFileImporter(domain_path=domain, training_data_paths=[file_name]) + validator = Validator.from_importer(importer) + assert validator.verify_actions_in_stories_rules() + + +@pytest.mark.parametrize( + ("file_name", "data_type"), [("stories", "story"), ("rules", "rule")] +) +def test_valid_stories_rules_default_actions( + file_name: Text, data_type: Text, tmp_path: Path +): + domain = tmp_path / "domain.yml" + domain.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + """ + ) + file_name = tmp_path / f"{file_name}.yml" + file_name.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + {file_name}: + - {data_type}: test path + steps: + - intent: greet + - action: action_restart + """ + ) + importer = RasaFileImporter(domain_path=domain, training_data_paths=[file_name]) + validator = Validator.from_importer(importer) + assert validator.verify_actions_in_stories_rules() + + +def test_valid_form_slots_in_domain(tmp_path: Path): + domain = tmp_path / "domain.yml" + domain.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + forms: + name_form: + required_slots: + - first_name + - last_name + slots: + first_name: + type: text + mappings: + - type: from_text + last_name: + type: text + mappings: + - type: from_text + """ + ) + importer = RasaFileImporter(domain_path=domain) + validator = Validator.from_importer(importer) + assert validator.verify_form_slots() + + +def test_verify_slot_mappings_mapping_active_loop_not_in_forms(tmp_path: Path): + domain = tmp_path / "domain.yml" + slot_name = "some_slot" + domain.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + entities: + - some_entity + slots: + {slot_name}: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: some_entity + conditions: + - active_loop: som_form + forms: + some_form: + required_slots: + - {slot_name} + """ + ) + importer = RasaFileImporter(domain_path=domain) + validator = Validator.from_importer(importer) + with pytest.warns( + UserWarning, + match=r"Slot 'some_slot' has a mapping condition " + r"for form 'som_form' which is not listed " + r"in domain forms.*", + ): + assert not validator.verify_slot_mappings() + + +def test_verify_slot_mappings_slot_with_mapping_conditions_not_in_form(tmp_path: Path): + domain = tmp_path / "domain.yml" + domain.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - activate_booking + entities: + - city + slots: + location: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: city + conditions: + - active_loop: booking_form + started_booking_form: + type: bool + influence_conversation: false + mappings: + - type: from_trigger_intent + intent: activate_booking + value: true + forms: + booking_form: + required_slots: + - started_booking_form + """ + ) + importer = RasaFileImporter(domain_path=domain) + validator = Validator.from_importer(importer) + with pytest.warns( + UserWarning, + match=r"Slot 'location' has a mapping condition for form 'booking_form', " + r"but it's not present in 'booking_form' form's 'required_slots'.*", + ): + assert not validator.verify_slot_mappings() + + +def test_verify_slot_mappings_valid(tmp_path: Path): + domain = tmp_path / "domain.yml" + domain.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - activate_booking + entities: + - city + slots: + location: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: city + conditions: + - active_loop: booking_form + started_booking_form: + type: bool + influence_conversation: false + mappings: + - type: from_trigger_intent + intent: activate_booking + value: true + forms: + booking_form: + required_slots: + - started_booking_form + - location + """ + ) + importer = RasaFileImporter(domain_path=domain) + validator = Validator.from_importer(importer) + assert validator.verify_slot_mappings() + + +@pytest.mark.parametrize( + ("file_name", "data_type"), [("stories", "story"), ("rules", "rule")] +) +def test_default_action_as_active_loop_in_rules( + tmp_path: Path, file_name: Text, data_type: Text +) -> None: + config = tmp_path / "config.yml" + + config.write_text( + textwrap.dedent( + """ + recipe: default.v1 + language: en + pipeline: + - name: WhitespaceTokenizer + - name: RegexFeaturizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + - name: CountVectorsFeaturizer + analyzer: char_wb + min_ngram: 1 + max_ngram: 4 + - name: DIETClassifier + epochs: 100 + - name: EntitySynonymMapper + - name: ResponseSelector + epochs: 100 + - name: FallbackClassifier + threshold: 0.3 + ambiguity_threshold: 0.1 + policies: + - name: MemoizationPolicy + - name: TEDPolicy + max_history: 5 + epochs: 100 + - name: RulePolicy + core_fallback_threshold: 0.3 + core_fallback_action_name: "action_default_fallback" + enable_fallback_prediction: true + """ + ) + ) + + domain = tmp_path / "domain.yml" + domain.write_text( + textwrap.dedent( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + - goodbye + - affirm + - deny + - mood_great + - mood_unhappy + - bot_challenge + responses: + utter_greet: + - text: "Hey! How are you?" + utter_cheer_up: + - text: "Here is something to cheer you up:" + image: "https://i.imgur.com/nGF1K8f.jpg" + utter_did_that_help: + - text: "Did that help you?" + utter_happy: + - text: "Great, carry on!" + utter_goodbye: + - text: "Bye" + utter_iamabot: + - text: "I am a bot, powered by Rasa." + session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: true + """ + ) + ) + file = tmp_path / f"{file_name}.yml" + file.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + {file_name}: + - {data_type}: test + steps: + - intent: nlu_fallback + - action: action_two_stage_fallback + - active_loop: action_two_stage_fallback + """ + ) + importer = RasaFileImporter( + config_file=str(config), domain_path=str(domain), training_data_paths=str(file) + ) + validator = Validator.from_importer(importer) + assert validator.verify_forms_in_stories_rules() + + +def test_verify_from_trigger_intent_slot_mapping_not_in_forms_does_not_warn( + tmp_path: Path, +): + domain = tmp_path / "domain.yml" + slot_name = "started_booking_form" + domain.write_text( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - activate_booking + entities: + - city + slots: + {slot_name}: + type: bool + influence_conversation: false + mappings: + - type: from_trigger_intent + intent: activate_booking + value: true + location: + type: text + mappings: + - type: from_entity + entity: city + forms: + booking_form: + required_slots: + - location + """ + ) + importer = RasaFileImporter(domain_path=domain) + validator = Validator.from_importer(importer) + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert validator.verify_slot_mappings() + + +def test_verify_utterances_does_not_error_when_no_utterance_template_provided( + tmp_path: Path, nlu_data_path: Path +): + story_file_name = tmp_path / "stories.yml" + with open(story_file_name, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + stories: + - story: path 1 + steps: + - intent: greet + - action: utter_greet + """ + ) + domain_file_name = tmp_path / "domain.yml" + with open(domain_file_name, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + actions: + - utter_greet + """ + ) + importer = RasaFileImporter( + config_file="data/test_moodbot/config.yml", + domain_path=domain_file_name, + training_data_paths=[story_file_name, nlu_data_path], + ) + + validator = Validator.from_importer(importer) + # force validator to not ignore warnings (default is True) + assert not validator.verify_utterances_in_stories(ignore_warnings=False) + # test whether ignoring warnings actually works + assert validator.verify_utterances_in_stories(ignore_warnings=True) + + +@pytest.mark.parametrize( + "config_file, message", + [ + ( + "data/test_config/config_defaults.yml", + "The config file is missing a unique value for " + "the 'assistant_id' mandatory key.", + ), + ( + "data/test_config/config_no_assistant_id.yml", + "The config file is missing the 'assistant_id' mandatory key.", + ), + ], +) +def test_warn_if_config_mandatory_keys_are_not_set_invalid_paths( + config_file: Text, message: Text +) -> None: + importer = RasaFileImporter(config_file=config_file) + validator = Validator.from_importer(importer) + + with pytest.warns(UserWarning, match=message): + validator.warn_if_config_mandatory_keys_are_not_set() diff --git a/tests/train.py b/tests/train.py new file mode 100644 index 0000000..1d499e9 --- /dev/null +++ b/tests/train.py @@ -0,0 +1,102 @@ +from rasa.utils.tensorflow.constants import EPOCHS +from typing import Any, Dict, List, Tuple, Text, Union + + +COMPONENTS_TEST_PARAMS = { + "DIETClassifier": {EPOCHS: 1}, + "ResponseSelector": {EPOCHS: 1}, + "LanguageModelFeaturizer": { + "model_name": "bert", + "model_weights": "bert-base-uncased", + }, +} + + +def get_test_params_for_component(component: Text) -> Dict[Text, Union[Text, int]]: + return ( + COMPONENTS_TEST_PARAMS[component] if component in COMPONENTS_TEST_PARAMS else {} + ) + + +def as_pipeline(*components): + return [{**{"name": c}, **get_test_params_for_component(c)} for c in components] + + +def pipelines_for_tests() -> List[Tuple[Text, List[Dict[Text, Any]]]]: + # these templates really are just for testing + # every component should be in here so train-persist-load-use cycle can be + # tested they still need to be in a useful order - hence we can not simply + # generate this automatically. + + # Create separate test pipelines for dense featurizers + # because they can't co-exist in the same pipeline together, + # as their tokenizers break the incoming message into different number of tokens. + + # first is language followed by list of components + return [ + ("en", as_pipeline("KeywordIntentClassifier")), + ( + "en", + as_pipeline( + "WhitespaceTokenizer", + "RegexFeaturizer", + "LexicalSyntacticFeaturizer", + "CountVectorsFeaturizer", + "CRFEntityExtractor", + "DucklingEntityExtractor", + "DIETClassifier", + "ResponseSelector", + "EntitySynonymMapper", + ), + ), + ( + "en", + as_pipeline( + "SpacyNLP", + "SpacyTokenizer", + "SpacyFeaturizer", + "SpacyEntityExtractor", + "SklearnIntentClassifier", + ), + ), + ( + "en", + as_pipeline( + "WhitespaceTokenizer", "LanguageModelFeaturizer", "DIETClassifier" + ), + ), + ("fallback", as_pipeline("KeywordIntentClassifier", "FallbackClassifier")), + ] + + +def pipelines_for_non_windows_tests() -> List[Tuple[Text, List[Dict[Text, Any]]]]: + # these templates really are just for testing + + # because some of the components are not available on Windows, we specify pipelines + # containing them separately + + # first is language followed by list of components + return [ + ( + "en", + as_pipeline( + "SpacyNLP", "SpacyTokenizer", "SpacyFeaturizer", "DIETClassifier" + ), + ), + ( + "en", + as_pipeline( + "MitieNLP", + "MitieTokenizer", + "MitieFeaturizer", + "MitieIntentClassifier", + "RegexEntityExtractor", + ), + ), + ( + "zh", + as_pipeline( + "MitieNLP", "JiebaTokenizer", "MitieFeaturizer", "MitieEntityExtractor" + ), + ), + ] diff --git a/tests/utilities.py b/tests/utilities.py new file mode 100644 index 0000000..6c334f7 --- /dev/null +++ b/tests/utilities.py @@ -0,0 +1,9 @@ +from yarl import URL + + +def latest_request(mocked, request_type, path): + return mocked.requests.get((request_type, URL(path))) + + +def json_of_latest_request(r): + return r[-1].kwargs["json"] diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/tensorflow/__init__.py b/tests/utils/tensorflow/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/tensorflow/conftest.py b/tests/utils/tensorflow/conftest.py new file mode 100644 index 0000000..846b297 --- /dev/null +++ b/tests/utils/tensorflow/conftest.py @@ -0,0 +1,181 @@ +import pytest +import scipy.sparse +import numpy as np + +from rasa.utils.tensorflow.model_data import ( + RasaModelData, + FeatureArray, + ragged_array_to_ndarray, +) + + +@pytest.fixture +async def model_data() -> RasaModelData: + return RasaModelData( + label_key="label", + label_sub_key="ids", + data={ + "text": { + "sentence": [ + FeatureArray( + ragged_array_to_ndarray( + [ + np.random.rand(5, 14), + np.random.rand(2, 14), + np.random.rand(3, 14), + np.random.rand(1, 14), + np.random.rand(3, 14), + ] + ), + number_of_dimensions=3, + ), + FeatureArray( + ragged_array_to_ndarray( + [ + scipy.sparse.csr_matrix( + np.random.randint(5, size=(5, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(5, size=(2, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(5, size=(3, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(5, size=(1, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(5, size=(3, 10)) + ), + ] + ), + number_of_dimensions=3, + ), + ] + }, + "action_text": { + "sequence": [ + FeatureArray( + ragged_array_to_ndarray( + [ + [ + scipy.sparse.csr_matrix( + np.random.randint(5, size=(5, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(5, size=(2, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(5, size=(3, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(5, size=(1, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(5, size=(3, 10)) + ), + ], + [ + scipy.sparse.csr_matrix( + np.random.randint(5, size=(5, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(5, size=(2, 10)) + ), + ], + [ + scipy.sparse.csr_matrix( + np.random.randint(5, size=(5, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(5, size=(1, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(5, size=(3, 10)) + ), + ], + [ + scipy.sparse.csr_matrix( + np.random.randint(5, size=(3, 10)) + ) + ], + [ + scipy.sparse.csr_matrix( + np.random.randint(5, size=(3, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(5, size=(1, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(5, size=(7, 10)) + ), + ], + ] + ), + number_of_dimensions=4, + ), + FeatureArray( + ragged_array_to_ndarray( + [ + [ + np.random.rand(5, 14), + np.random.rand(2, 14), + np.random.rand(3, 14), + np.random.rand(1, 14), + np.random.rand(3, 14), + ], + [np.random.rand(5, 14), np.random.rand(2, 14)], + [ + np.random.rand(5, 14), + np.random.rand(1, 14), + np.random.rand(3, 14), + ], + [np.random.rand(3, 14)], + [ + np.random.rand(3, 14), + np.random.rand(1, 14), + np.random.rand(7, 14), + ], + ] + ), + number_of_dimensions=4, + ), + ] + }, + "dialogue": { + "sentence": [ + FeatureArray( + ragged_array_to_ndarray( + [ + np.random.randint(2, size=(5, 10)), + np.random.randint(2, size=(2, 10)), + np.random.randint(2, size=(3, 10)), + np.random.randint(2, size=(1, 10)), + np.random.randint(2, size=(3, 10)), + ] + ), + number_of_dimensions=3, + ) + ] + }, + "label": { + "ids": [FeatureArray(np.array([0, 1, 0, 1, 1]), number_of_dimensions=1)] + }, + "entities": { + "tag_ids": [ + FeatureArray( + ragged_array_to_ndarray( + [ + np.array([[0], [1], [1], [0], [2]]), + np.array([[2], [0]]), + np.array([[0], [1], [1]]), + np.array([[0], [1]]), + np.array([[0], [0], [0]]), + ] + ), + number_of_dimensions=3, + ) + ] + }, + }, + ) diff --git a/tests/utils/tensorflow/test_callback.py b/tests/utils/tensorflow/test_callback.py new file mode 100644 index 0000000..c326b25 --- /dev/null +++ b/tests/utils/tensorflow/test_callback.py @@ -0,0 +1,102 @@ +from typing import Dict, Text +from pathlib import Path + +import pytest +from _pytest.tmpdir import TempPathFactory + +from rasa.core import training + +from rasa.core.policies.ted_policy import TEDPolicy +from rasa.engine.graph import ExecutionContext, GraphSchema +from rasa.engine.storage.local_model_storage import LocalModelStorage +from rasa.engine.storage.resource import Resource +from rasa.shared.core.domain import Domain +from rasa.utils.tensorflow.callback import RasaModelCheckpoint +from rasa.utils.tensorflow.constants import EPOCHS + + +@pytest.mark.parametrize( + "previous_best, current_values, improved", + [ + ( + {"val_i_acc": 0.5, "val_f1": 0.5}, + {"val_i_acc": 0.65, "val_f1": 0.7}, + True, + ), # both improved + ( + {"val_i_acc": 0.54, "val_f1": 0.5}, + {"val_i_acc": 0.54, "val_f1": 0.7}, + True, + ), # one equal, one improved + ( + {"val_i_acc": 0.8, "val_f1": 0.55}, + {"val_i_acc": 0.8, "val_f1": 0.55}, + False, + ), # both equal + ( + {"val_i_acc": 0.64, "val_f1": 0.5}, + {"val_i_acc": 0.41, "val_f1": 0.7}, + False, + ), # one improved, one worse + ( + {"val_i_acc": 0.71, "val_f1": 0.35}, + {"val_i_acc": 0.52, "val_f1": 0.35}, + False, + ), # one worse, one equal + ], +) +def test_does_model_improve( + previous_best: Dict[Text, float], + current_values: Dict[Text, float], + improved: bool, + tmpdir: Path, +): + checkpoint = RasaModelCheckpoint(tmpdir) + checkpoint.best_metrics_so_far = previous_best + # true iff all values are equal or better and at least one is better + assert checkpoint._does_model_improve(current_values) == improved + + +@pytest.fixture(scope="module") +def trained_ted( + tmp_path_factory: TempPathFactory, moodbot_domain_path: Path +) -> TEDPolicy: + training_files = "data/test_moodbot/data/stories.yml" + domain = Domain.load(moodbot_domain_path) + trackers = training.load_data(str(training_files), domain) + policy = TEDPolicy.create( + {**TEDPolicy.get_default_config(), EPOCHS: 1}, + LocalModelStorage.create(tmp_path_factory.mktemp("storage")), + Resource("ted"), + ExecutionContext(GraphSchema({})), + ) + policy.train(trackers, domain) + + return policy + + +@pytest.mark.parametrize( + "previous_best, current_values, improved", + [ + ({"val_i_acc": 0.5, "val_f1": 0.5}, {"val_i_acc": 0.5, "val_f1": 0.7}, True), + ({"val_i_acc": 0.5, "val_f1": 0.5}, {"val_i_acc": 0.4, "val_f1": 0.5}, False), + ], +) +def test_on_epoch_end_saves_checkpoints_file( + previous_best: Dict[Text, float], + current_values: Dict[Text, float], + improved: bool, + tmp_path: Path, + trained_ted: TEDPolicy, +): + model_name = "checkpoint" + best_model_file = tmp_path / model_name + assert not best_model_file.exists() + checkpoint = RasaModelCheckpoint(tmp_path) + checkpoint.best_metrics_so_far = previous_best + checkpoint.model = trained_ted.model + checkpoint.on_epoch_end(1, current_values) + if improved: + assert best_model_file.exists() + else: + assert not best_model_file.exists() diff --git a/tests/utils/tensorflow/test_crf.py b/tests/utils/tensorflow/test_crf.py new file mode 100644 index 0000000..593327f --- /dev/null +++ b/tests/utils/tensorflow/test_crf.py @@ -0,0 +1,233 @@ +"""Tests for CRF.""" + +# original code taken from +# https://github.com/tensorflow/addons/blob/master/tensorflow_addons/text/tests/crf_test.py +# (modified to our neeeds) + +import itertools + +import pytest +import numpy as np +import tensorflow as tf + +from rasa.utils.tensorflow.crf import ( + crf_sequence_score, + crf_unary_score, + crf_binary_score, + crf_log_norm, + crf_log_likelihood, +) + + +def calculate_sequence_score(inputs, transition_params, tag_indices, sequence_lengths): + expected_unary_score = sum( + inputs[i][tag_indices[i]] for i in range(sequence_lengths) + ) + expected_binary_score = sum( + transition_params[tag_indices[i], tag_indices[i + 1]] + for i in range(sequence_lengths - 1) + ) + return expected_unary_score + expected_binary_score + + +def brute_force_decode(sequence_lengths, inputs, transition_params): + num_words = inputs.shape[0] + num_tags = inputs.shape[1] + + all_sequence_scores = [] + all_sequences = [] + + tag_indices_iterator = itertools.product(range(num_tags), repeat=sequence_lengths) + inputs = tf.expand_dims(inputs, 0) + sequence_lengths = tf.expand_dims(sequence_lengths, 0) + transition_params = tf.constant(transition_params) + + # Compare the dynamic program with brute force computation. + for tag_indices in tag_indices_iterator: + tag_indices = list(tag_indices) + tag_indices.extend([0] * (num_words - sequence_lengths)) + all_sequences.append(tag_indices) + sequence_score = crf_sequence_score( + inputs=inputs, + tag_indices=tf.expand_dims(tag_indices, 0), + sequence_lengths=sequence_lengths, + transition_params=transition_params, + ) + sequence_score = tf.squeeze(sequence_score, [0]) + all_sequence_scores.append(sequence_score) + + expected_max_sequence_index = np.argmax(all_sequence_scores) + expected_max_sequence = all_sequences[expected_max_sequence_index] + expected_max_score = all_sequence_scores[expected_max_sequence_index] + return expected_max_sequence, expected_max_score + + +@pytest.mark.parametrize("dtype", [np.float16, np.float32]) +def test_crf_sequence_score(dtype): + transition_params = np.array([[-3, 5, -2], [3, 4, 1], [1, 2, 1]], dtype=dtype) + # Test both the length-1 and regular cases. + sequence_lengths_list = [ + np.array(3, dtype=np.int32), + np.array(1, dtype=np.int32), + ] + inputs_list = [ + np.array([[4, 5, -3], [3, -1, 3], [-1, 2, 1], [0, 0, 0]], dtype=dtype), + np.array([[4, 5, -3]], dtype=dtype), + ] + tag_indices_list = [ + np.array([1, 2, 1, 0], dtype=np.int32), + np.array([1], dtype=np.int32), + ] + for sequence_lengths, inputs, tag_indices in zip( + sequence_lengths_list, inputs_list, tag_indices_list + ): + sequence_score = crf_sequence_score( + inputs=tf.expand_dims(inputs, 0), + tag_indices=tf.expand_dims(tag_indices, 0), + sequence_lengths=tf.expand_dims(sequence_lengths, 0), + transition_params=tf.constant(transition_params), + ) + sequence_score = tf.squeeze(sequence_score, [0]) + + expected_sequence_score = calculate_sequence_score( + inputs, transition_params, tag_indices, sequence_lengths + ) + np.testing.assert_allclose(sequence_score, expected_sequence_score) + + +@pytest.mark.parametrize("dtype", [np.float16, np.float32]) +def test_crf_unary_score(dtype): + inputs = np.array([[4, 5, -3], [3, -1, 3], [-1, 2, 1], [0, 0, 0]], dtype=dtype) + for dtype in (np.int32, np.int64): + tag_indices = np.array([1, 2, 1, 0], dtype=dtype) + sequence_lengths = np.array(3, dtype=np.int32) + unary_score = crf_unary_score( + tag_indices=tf.expand_dims(tag_indices, 0), + sequence_lengths=tf.expand_dims(sequence_lengths, 0), + inputs=tf.expand_dims(inputs, 0), + ) + unary_score = tf.squeeze(unary_score, [0]) + expected_unary_score = sum( + inputs[i][tag_indices[i]] for i in range(sequence_lengths) + ) + np.testing.assert_allclose(unary_score, expected_unary_score) + + +@pytest.mark.parametrize("dtype", [np.float16, np.float32]) +def test_crf_binary_score(dtype): + tag_indices = np.array([1, 2, 1, 0], dtype=np.int32) + transition_params = np.array([[-3, 5, -2], [3, 4, 1], [1, 2, 1]], dtype=dtype) + sequence_lengths = np.array(3, dtype=np.int32) + binary_score = crf_binary_score( + tag_indices=tf.expand_dims(tag_indices, 0), + sequence_lengths=tf.expand_dims(sequence_lengths, 0), + transition_params=tf.constant(transition_params), + ) + binary_score = tf.squeeze(binary_score, [0]) + expected_binary_score = sum( + transition_params[tag_indices[i], tag_indices[i + 1]] + for i in range(sequence_lengths - 1) + ) + np.testing.assert_allclose(binary_score, expected_binary_score) + + +@pytest.mark.parametrize("dtype", [np.float16, np.float32]) +def test_crf_log_norm(dtype): + transition_params = np.array([[-3, 5, -2], [3, 4, 1], [1, 2, 1]], dtype=dtype) + # Test both the length-1 and regular cases. + sequence_lengths_list = [ + np.array(3, dtype=np.int32), + np.array(1, dtype=np.int64), + ] + inputs_list = [ + np.array([[4, 5, -3], [3, -1, 3], [-1, 2, 1], [0, 0, 0]], dtype=dtype), + np.array([[3, -1, 3]], dtype=dtype), + ] + tag_indices_list = [ + np.array([1, 2, 1, 0], dtype=np.int32), + np.array([2], dtype=np.int32), + ] + + for sequence_lengths, inputs, tag_indices in zip( + sequence_lengths_list, inputs_list, tag_indices_list + ): + num_words = inputs.shape[0] + num_tags = inputs.shape[1] + all_sequence_scores = [] + + # Compare the dynamic program with brute force computation. + for tag_indices in itertools.product(range(num_tags), repeat=sequence_lengths): + tag_indices = list(tag_indices) + tag_indices.extend([0] * (num_words - sequence_lengths)) + all_sequence_scores.append( + crf_sequence_score( + inputs=tf.expand_dims(inputs, 0), + tag_indices=tf.expand_dims(tag_indices, 0), + sequence_lengths=tf.expand_dims(sequence_lengths, 0), + transition_params=tf.constant(transition_params), + ) + ) + + brute_force_log_norm = tf.reduce_logsumexp(all_sequence_scores) + log_norm = crf_log_norm( + inputs=tf.expand_dims(inputs, 0), + sequence_lengths=tf.expand_dims(sequence_lengths, 0), + transition_params=tf.constant(transition_params), + ) + log_norm = tf.squeeze(log_norm, [0]) + + np.testing.assert_allclose(log_norm, brute_force_log_norm) + + +@pytest.mark.parametrize("dtype", [np.float16, np.float32]) +def test_crf_log_norm_zero_seq_length(dtype): + """Test `crf_log_norm` when `sequence_lengths` contains one or more + zeros.""" + inputs = tf.constant(np.ones([2, 10, 5], dtype=dtype)) + transition_params = tf.constant(np.ones([5, 5], dtype=dtype)) + sequence_lengths = tf.constant(np.zeros([2], dtype=np.int32)) + expected_log_norm = np.zeros([2], dtype=dtype) + log_norm = crf_log_norm(inputs, sequence_lengths, transition_params) + np.testing.assert_allclose(log_norm, expected_log_norm) + + +@pytest.mark.parametrize("dtype", [np.float32]) +def test_crf_log_likelihood(dtype): + inputs = np.array([[4, 5, -3], [3, -1, 3], [-1, 2, 1], [0, 0, 0]], dtype=dtype) + transition_params = np.array([[-3, 5, -2], [3, 4, 1], [1, 2, 1]], dtype=dtype) + sequence_lengths = np.array(3, dtype=np.int32) + + num_words = inputs.shape[0] + num_tags = inputs.shape[1] + all_sequence_log_likelihoods = [] + + # Make sure all probabilities sum to 1. + for tag_indices in itertools.product(range(num_tags), repeat=sequence_lengths): + tag_indices = list(tag_indices) + tag_indices.extend([0] * (num_words - sequence_lengths)) + sequence_log_likelihood, _ = crf_log_likelihood( + inputs=tf.expand_dims(inputs, 0), + tag_indices=tf.expand_dims(tag_indices, 0), + sequence_lengths=tf.expand_dims(sequence_lengths, 0), + transition_params=tf.constant(transition_params), + ) + all_sequence_log_likelihoods.append(sequence_log_likelihood) + total_log_likelihood = tf.reduce_logsumexp(all_sequence_log_likelihoods) + np.testing.assert_allclose(total_log_likelihood, 0.0, rtol=1e-6, atol=1e-6) + + # check if `transition_params = None` raises an error + crf_log_likelihood( + inputs=tf.expand_dims(inputs, 0), + tag_indices=tf.expand_dims(tag_indices, 0), + sequence_lengths=tf.expand_dims(sequence_lengths, 0), + ) + + +def test_different_dtype(): + inputs = np.ones([16, 20, 5], dtype=np.float32) + tags = tf.convert_to_tensor(np.ones([16, 20], dtype=np.int64)) + seq_lens = np.ones([16], dtype=np.int64) * 20 + + loss, _ = crf_log_likelihood( + inputs=inputs, tag_indices=tags, sequence_lengths=seq_lens + ) diff --git a/tests/utils/tensorflow/test_data_generator.py b/tests/utils/tensorflow/test_data_generator.py new file mode 100644 index 0000000..dfa2291 --- /dev/null +++ b/tests/utils/tensorflow/test_data_generator.py @@ -0,0 +1,204 @@ +import pytest + +import scipy.sparse +import numpy as np + +from rasa.utils.tensorflow.model_data import ( + ragged_array_to_ndarray, + FeatureArray, + RasaModelData, +) +from rasa.utils.tensorflow.data_generator import ( + RasaDataGenerator, + RasaBatchDataGenerator, +) + + +def test_data_generator_with_increasing_batch_size(model_data: RasaModelData): + epochs = 2 + + data_generator = RasaBatchDataGenerator( + model_data, + batch_size=[1, 2], + epochs=epochs, + batch_strategy="balanced", + shuffle=True, + ) + + expected_batch_sizes = [[1, 1, 1, 1, 1], [2, 2, 1]] + + for _epoch in range(epochs): + iterator = iter(data_generator) + + assert len(data_generator) == len(expected_batch_sizes[_epoch]) + + for i in range(len(data_generator)): + batch, _ = next(iterator) + + assert len(batch) == 11 + assert len(batch[0]) == expected_batch_sizes[_epoch][i] + + with pytest.raises(StopIteration): + next(iterator) + + data_generator.on_epoch_end() + + +def test_data_generator_with_fixed_batch_size(model_data: RasaModelData): + data_generator = RasaBatchDataGenerator( + model_data, batch_size=2, epochs=1, batch_strategy="balanced", shuffle=True + ) + + expected_batch_sizes = [2, 2, 1] + + iterator = iter(data_generator) + + assert len(data_generator) == len(expected_batch_sizes) + + for i in range(len(data_generator)): + batch, _ = next(iterator) + assert len(batch) == 11 + assert len(batch[0]) == expected_batch_sizes[i] + + with pytest.raises(StopIteration): + next(iterator) + + +@pytest.mark.parametrize( + "incoming_data, expected_shape", + [ + (FeatureArray(np.random.rand(7, 12), number_of_dimensions=2), (7, 12)), + (FeatureArray(np.random.rand(7), number_of_dimensions=1), (7,)), + ( + FeatureArray( + ragged_array_to_ndarray( + [ + np.random.rand(1, 10), + np.random.rand(3, 10), + np.random.rand(7, 10), + np.random.rand(1, 10), + ] + ), + number_of_dimensions=3, + ), + (4, 7, 10), + ), + ( + FeatureArray( + ragged_array_to_ndarray( + [ + ragged_array_to_ndarray( + [ + np.random.rand(1, 10), + np.random.rand(5, 10), + np.random.rand(7, 10), + ] + ), + ragged_array_to_ndarray( + [ + np.random.rand(1, 10), + np.random.rand(3, 10), + np.random.rand(3, 10), + np.random.rand(7, 10), + ] + ), + np.array([np.random.rand(2, 10)]), + ] + ), + number_of_dimensions=4, + ), + (8, 7, 10), + ), + ], +) +def test_pad_dense_data(incoming_data: FeatureArray, expected_shape: np.ndarray): + padded_data = RasaDataGenerator._pad_dense_data(incoming_data) + + assert padded_data.shape == expected_shape + + +@pytest.mark.parametrize( + "incoming_data, expected_shape", + [ + ( + FeatureArray( + np.array([scipy.sparse.csr_matrix(np.random.randint(5, size=(7, 12)))]), + number_of_dimensions=3, + ), + [1, 7, 12], + ), + ( + FeatureArray( + np.array([scipy.sparse.csr_matrix(np.random.randint(5, size=(7,)))]), + number_of_dimensions=2, + ), + [1, 1, 7], + ), + ( + FeatureArray( + ragged_array_to_ndarray( + [ + scipy.sparse.csr_matrix(np.random.randint(10, size=(1, 10))), + scipy.sparse.csr_matrix(np.random.randint(10, size=(3, 10))), + scipy.sparse.csr_matrix(np.random.randint(10, size=(7, 10))), + scipy.sparse.csr_matrix(np.random.randint(10, size=(1, 10))), + ] + ), + number_of_dimensions=3, + ), + (4, 7, 10), + ), + ( + FeatureArray( + ragged_array_to_ndarray( + [ + ragged_array_to_ndarray( + [ + scipy.sparse.csr_matrix( + np.random.randint(10, size=(1, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(10, size=(5, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(10, size=(7, 10)) + ), + ] + ), + ragged_array_to_ndarray( + [ + scipy.sparse.csr_matrix( + np.random.randint(10, size=(1, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(10, size=(3, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(10, size=(1, 10)) + ), + scipy.sparse.csr_matrix( + np.random.randint(10, size=(7, 10)) + ), + ] + ), + ragged_array_to_ndarray( + [ + scipy.sparse.csr_matrix( + np.random.randint(10, size=(2, 10)) + ) + ] + ), + ] + ), + number_of_dimensions=4, + ), + (8, 7, 10), + ), + ], +) +def test_scipy_matrix_to_values( + incoming_data: FeatureArray, expected_shape: np.ndarray +): + indices, data, shape = RasaDataGenerator._scipy_matrix_to_values(incoming_data) + + assert np.all(shape == expected_shape) diff --git a/tests/utils/tensorflow/test_feature_array.py b/tests/utils/tensorflow/test_feature_array.py new file mode 100644 index 0000000..95be7ba --- /dev/null +++ b/tests/utils/tensorflow/test_feature_array.py @@ -0,0 +1,197 @@ +import numpy as np +import scipy.sparse + +from rasa.utils.tensorflow.feature_array import ( + _recursive_serialize, + _serialize_nested_data, + _deserialize_nested_data, +) +from rasa.utils.tensorflow.model_data import RasaModelData + + +def test_recursive_serialize_numpy_array(): + data_dict = {} + metadata = [] + + _recursive_serialize(np.array([1, 2, 3]), "test_array", data_dict, metadata) + assert "test_array_array" in data_dict + assert metadata[0] == {"type": "dense", "key": "test_array_array", "shape": (3,)} + + +def test_recursive_serialize_floats(): + data_dict = {} + metadata = [] + + _recursive_serialize([1.0, 2.0, 3.0], "test_list", data_dict, metadata) + assert "test_list_list" in data_dict + assert metadata[0] == {"type": "list", "key": "test_list_list"} + + +def test_recursive_serialize_sparse_matrix(): + data_dict = {} + metadata = [] + + sparse_matrix = scipy.sparse.random(5, 10, density=0.1, format="coo") + _recursive_serialize(sparse_matrix, "test_sparse", data_dict, metadata) + assert "test_sparse_data" in data_dict + assert "test_sparse_row" in data_dict + assert "test_sparse_col" in data_dict + assert metadata[0] == { + "type": "sparse", + "key": "test_sparse", + "shape": sparse_matrix.shape, + } + + +def test_serialize_model_data(model_data: RasaModelData): + nested_data = model_data.data + + data_dict = {} + metadata = [] + _serialize_nested_data(nested_data, "component", data_dict, metadata) + + assert len(metadata) == 5 + + assert metadata[0]["key"] == "text" + assert len(metadata[0]["components"]) == 1 + assert metadata[0]["components"][0]["key"] == "sentence" + assert metadata[0]["components"][0]["number_of_dimensions"] == 3 + assert len(metadata[0]["components"][0]["features"]) == 2 + assert metadata[0]["components"][0]["features"][0]["type"] == "group" + assert len(metadata[0]["components"][0]["features"][0]["subcomponents"]) == 5 + assert ( + metadata[0]["components"][0]["features"][0]["subcomponents"][0]["type"] + == "dense" + ) + assert metadata[0]["components"][0]["features"][0]["subcomponents"][0]["shape"] == ( + 5, + 14, + ) + assert metadata[0]["components"][0]["features"][1]["type"] == "group" + assert len(metadata[0]["components"][0]["features"][1]["subcomponents"]) == 5 + assert ( + metadata[0]["components"][0]["features"][1]["subcomponents"][0]["type"] + == "sparse" + ) + assert metadata[0]["components"][0]["features"][1]["subcomponents"][0]["shape"] == ( + 5, + 10, + ) + + assert metadata[3]["key"] == "label" + assert len(metadata[3]["components"]) == 1 + assert metadata[3]["components"][0]["key"] == "ids" + assert metadata[3]["components"][0]["number_of_dimensions"] == 1 + assert metadata[3]["components"][0]["features"][0]["type"] == "list" + assert ( + metadata[3]["components"][0]["features"][0]["key"] + == "component_label_ids_0_list" + ) + + assert len(data_dict) == 87 + assert ( + data_dict["component_label_ids_0_list"] + == model_data.data["label"]["ids"][0].view(np.ndarray) + ).all() + + +def test_serialize_and_deserialize_model_data(model_data: RasaModelData): + actual_data = model_data.data + + data_dict = {} + metadata = [] + _serialize_nested_data(actual_data, "component", data_dict, metadata) + + loaded_data = _deserialize_nested_data(metadata, data_dict) + + assert len(actual_data) == len(loaded_data) + + assert len(actual_data["text"]["sentence"]) == len(loaded_data["text"]["sentence"]) + + # text.sentence has a dimension of 3 + assert len(actual_data["text"]["sentence"][0]) == len( + loaded_data["text"]["sentence"][0] + ) + # assert that the numpy arrays of the actual and loaded data in + # text.sentence are the same + for i in range(0, 5): + assert ( + actual_data["text"]["sentence"][0][i] + == loaded_data["text"]["sentence"][0][i] + ).all() + assert len(actual_data["text"]["sentence"][1]) == len( + loaded_data["text"]["sentence"][1] + ) + # assert that the sparse matrices of the actual and loaded data in + # text.sentence are the same + for i in range(0, 5): + assert ( + actual_data["text"]["sentence"][1][i] + == loaded_data["text"]["sentence"][1][i] + ).data.all() + + # action_text.sequence has a dimension of 4 + assert len(actual_data["action_text"]["sequence"]) == len( + loaded_data["action_text"]["sequence"] + ) + assert len(actual_data["action_text"]["sequence"][0]) == len( + loaded_data["action_text"]["sequence"][0] + ) + # assert that the sparse matrices of the actual and loaded data in + # action_text.sequence are the same + for i in range(0, 5): + for j in range(0, len(actual_data["action_text"]["sequence"][0][i])): + assert ( + actual_data["action_text"]["sequence"][0][i][j] + == loaded_data["action_text"]["sequence"][0][i][j] + ).data.all() + assert len(actual_data["action_text"]["sequence"][1]) == len( + loaded_data["action_text"]["sequence"][1] + ) + # assert that the numpy array of the actual and loaded data in + # action_text.sequence are the same + for i in range(0, 5): + for j in range(0, len(actual_data["action_text"]["sequence"][1][i])): + assert ( + actual_data["action_text"]["sequence"][1][i][j] + == loaded_data["action_text"]["sequence"][1][i][j] + ).all() + + # dialogue.sentence has a dimension of 3 + assert len(actual_data["dialogue"]["sentence"]) == len( + loaded_data["dialogue"]["sentence"] + ) + assert len(actual_data["dialogue"]["sentence"][0]) == len( + loaded_data["dialogue"]["sentence"][0] + ) + # assert that the numpy array of the actual and loaded data in + # dialogue.sentence are the same + for i in range(0, 5): + assert ( + actual_data["dialogue"]["sentence"][0][i] + == loaded_data["dialogue"]["sentence"][0][i] + ).all() + + # label.ids has a dimension of 4 + assert len(actual_data["label"]["ids"]) == len(loaded_data["label"]["ids"]) + # assert that the numpy array of the actual and loaded data in + # label.ids are the same + assert ( + actual_data["label"]["ids"][0].view(np.ndarray) + == loaded_data["label"]["ids"][0].view(np.ndarray) + ).all() + + # entities.tag_ids has a dimension of 3 + assert len(actual_data["entities"]["tag_ids"]) == len( + loaded_data["entities"]["tag_ids"] + ) + assert len(actual_data["entities"]["tag_ids"][0]) == len( + loaded_data["entities"]["tag_ids"][0] + ) + # assert that the numpy array of the actual and loaded data in + # entities.tag_ids are the same + for i in range(0, 5): + assert ( + actual_data["entities"]["tag_ids"][0][i] + == loaded_data["entities"]["tag_ids"][0][i] + ).all() diff --git a/tests/utils/tensorflow/test_layers.py b/tests/utils/tensorflow/test_layers.py new file mode 100644 index 0000000..b4908b7 --- /dev/null +++ b/tests/utils/tensorflow/test_layers.py @@ -0,0 +1,488 @@ +from typing import Text, List, Tuple, Union, Optional +import pytest +from _pytest.monkeypatch import MonkeyPatch +import numpy as np +import tensorflow as tf + +from rasa.utils.tensorflow.layers import ( + DotProductLoss, + MultiLabelDotProductLoss, + RandomlyConnectedDense, + DenseForSparse, +) +from rasa.utils.tensorflow.constants import INNER, SOFTMAX, LABEL, LABEL_PAD_ID +import rasa.utils.tensorflow.layers_utils as layers_utils +from rasa.shared.nlu.constants import ( + TEXT, + INTENT, + ACTION_NAME, + ACTION_TEXT, + FEATURE_TYPE_SENTENCE, + FEATURE_TYPE_SEQUENCE, +) +from rasa.core.constants import DIALOGUE + + +def test_dot_product_loss_inner_sim(): + layer = DotProductLoss(0, similarity_type=INNER) + a = tf.constant([[[1.0, 0.0, 2.0]], [[1.0, 0.0, 2.0]]]) + b = tf.constant([[[1.0, 0.0, -2.0]], [[1.0, 0.0, -2.0]]]) + mask = tf.constant([[1.0, 0.0]]) + similarity = layer.sim(a, b, mask=mask).numpy() + assert np.all(similarity == [[[-3.0], [0.0]]]) + + +def test_multi_label_dot_product_loss_call_shapes(): + num_neg = 1 + layer = MultiLabelDotProductLoss(num_neg) + batch_inputs_embed = tf.constant([[[0, 1, 2]], [[-2, 0, 2]]], dtype=tf.float32) + batch_labels_embed = tf.constant( + [[[0, 0, 1], [1, 0, 0]], [[0, 1, 0], [1, 0, 0]]], dtype=tf.float32 + ) + batch_labels_ids = tf.constant([[[2], [0]], [[1], [0]]], dtype=tf.float32) + all_labels_embed = tf.constant([[1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=tf.float32) + all_labels_ids = tf.constant([[0], [1], [2]], dtype=tf.float32) + mask = None + + loss, accuracy = layer( + batch_inputs_embed, + batch_labels_embed, + batch_labels_ids, + all_labels_embed, + all_labels_ids, + mask, + ) + + assert len(tf.shape(loss)) == 0 + assert len(tf.shape(accuracy)) == 0 + + +@pytest.mark.parametrize( + "label_ids, num_candidates, expected_pos_label_mask", + [ + ([[2, LABEL_PAD_ID], [3, 4]], 20, [[1, 0], [1, 1]]), + ([[2, 1], [3, 4]], 5, [[1, 1], [1, 1]]), + ], +) +def test_multi_label_dot_product_loss__construct_label_padding_mask( + label_ids: List[List[int]], + num_candidates: int, + expected_pos_label_mask: List[List[int]], +): + actual_label_mask = MultiLabelDotProductLoss._construct_mask_for_label_padding( + np.expand_dims(label_ids, -1), num_candidates + ).numpy() + + pos_label_columns = np.array(label_ids).shape[1] + + # First check if the mask corresponding to guaranteed positive label ids is correct. + assert np.all( + actual_label_mask[:, :pos_label_columns] + == np.array(expected_pos_label_mask).astype(np.float32) + ) + + # Next check if the mask corresponding to sampled candidates is correct. + assert np.all( + actual_label_mask[:, pos_label_columns:] + == np.ones((len(label_ids), num_candidates), dtype=np.float32) + ) + + +@pytest.mark.parametrize( + "sim_pos, sim_candidates_il, pos_neg_labels, mask, expected_loss", + [ + ( + np.array([[2.0, -0.1, -5], [4.2, 5.1, -4.5]]), + np.array([[-1.1, -3], [2.1, -3.5]]), + np.array([[1.0, 0.0], [1.0, 0.0]]), + None, + 1.1991243, + ), + ( + np.array([[2.0, -0.1, -5], [4.2, 5.1, -4.5]]), + np.array([[-1.1, -3], [2.1, -3.5]]), + np.array([[1.0, 0.0], [1.0, 0.0]]), + np.array( + [[1.0, 0.0, 1.0, 1.0, 1.0], [0.0, 0.0, 1.0, 1.0, 1.0]], dtype=np.float32 + ), + 1.5972487, + ), + ], +) +def test_multi_label_dot_product_loss__compute_loss_with_and_without_mask( + sim_pos: np.ndarray, + sim_candidates_il: np.ndarray, + pos_neg_labels: np.ndarray, + mask: Optional[np.ndarray], + expected_loss: float, +): + layer = MultiLabelDotProductLoss(num_candidates=3) + + loss = layer._loss_sigmoid( + np.expand_dims(sim_pos, 1).astype(np.float32), + np.expand_dims(sim_candidates_il, 1).astype(np.float32), + pos_neg_labels.astype(np.float32), + mask, + ).numpy() + + assert np.isclose([loss], [expected_loss]) + + +@pytest.mark.parametrize( + "sim_pos, sim_candidates_il, pos_neg_labels, mask, expected_accuracy", + [ + ( + np.array([[2.0, -0.1, -5], [4.2, 5.1, -4.5]]), + np.array([[-1.1, -3], [2.1, -3.5]]), + np.array([[1.0, 0.0], [1.0, 0.0]]), + None, + 0.6, + ), + ( + np.array([[2.0, -0.1, -5], [4.2, 5.1, -4.5]]), + np.array([[-1.1, -3], [2.1, -3.5]]), + np.array([[1.0, 0.0], [1.0, 0.0]]), + np.array( + [[1.0, 0.0, 1.0, 1.0, 1.0], [0.0, 0.0, 1.0, 1.0, 1.0]], dtype=np.float32 + ), + 0.5833334, + ), + ], +) +def test_multi_label_dot_product_loss__compute_accuracy_with_and_without_mask( + sim_pos: np.ndarray, + sim_candidates_il: np.ndarray, + pos_neg_labels: np.ndarray, + mask: Optional[np.ndarray], + expected_accuracy: float, +): + layer = MultiLabelDotProductLoss(num_candidates=3) + + accuracy = layer._accuracy( + np.expand_dims(sim_pos, 1).astype(np.float32), + np.expand_dims(sim_candidates_il, 1).astype(np.float32), + pos_neg_labels.astype(np.float32), + mask, + ).numpy() + + assert np.isclose([accuracy], [expected_accuracy]) + + +def test_multi_label_dot_product_loss__sample_candidates_with_constant_number_of_labels( + monkeypatch: MonkeyPatch, +): + num_candidates = 2 + num_features = 4 + batch_size = 3 + layer = MultiLabelDotProductLoss( + num_candidates, scale_loss=False, similarity_type=INNER + ) + + # Seven random vectors for inputs and labels + i0, i1, i2, l0, l1, l2, l3 = np.round( + np.random.uniform(-100, 100, size=[7, num_features]) + ).tolist() + + # Each example in the batch has one input + batch_inputs_embed = tf.constant([[i0], [i1], [i2]], dtype=tf.float32) + # Each input can have multiple labels (here its always the same number of labels, + # but it doesn't have to be) + batch_labels_embed = tf.constant([[l0, l1], [l2, l3], [l3, l0]], dtype=tf.float32) + # We assign the corresponding indices + batch_labels_ids = tf.constant( + [[[0], [1]], [[2], [3]], [[3], [0]]], dtype=tf.float32 + ) + # List all the labels and ids in play + all_labels_embed = tf.constant([l0, l1, l2, l3], dtype=tf.float32) + all_labels_ids = tf.constant([[0], [1], [2], [3]], dtype=tf.float32) + + # Inside `layer._sample_candidates` random indices will be generated for the + # candidates. We mock them to have a deterministic output. + mock_indices = [0, 2, 0, 1, 0, 3] + + def mock_random_indices(*args, **kwargs) -> tf.Tensor: + return tf.reshape(tf.constant(mock_indices), [batch_size, num_candidates]) + + monkeypatch.setattr(layers_utils, "random_indices", mock_random_indices) + + # Now run the function we want to test + ( + pos_inputs_embed, + pos_labels_embed, + candidate_labels_embed, + pos_neg_labels, + ) = layer._sample_candidates( + batch_inputs_embed, + batch_labels_embed, + batch_labels_ids, + all_labels_embed, + all_labels_ids, + ) + # The inputs just stay the inputs, up to an extra dimension + assert np.all( + pos_inputs_embed.numpy() == tf.expand_dims(batch_inputs_embed, axis=-2).numpy() + ) + # All positive labels of each batch are in `pos_labels_embed` + assert np.all( + pos_labels_embed.numpy() == np.array([[[l0, l1]], [[l2, l3]], [[l3, l0]]]) + ) + # The candidate label embeddings are picked according to the `mock_indices` above. + # E.g. a 2 coming from `mock_indices` means that `all_labels_embed[2]` is picked, + # i.e. `l2`. + assert np.all( + candidate_labels_embed.numpy() == np.array([[[l0, l2]], [[l0, l1]], [[l0, l3]]]) + ) + # The `pos_neg_labels` contains `1`s wherever the vector in `candidate_labels_embed` + # of example `i` is actually in the possible lables of example `i` + assert np.all( + pos_neg_labels.numpy() + == np.array( + [ + [1, 0], # l0 is an actual positive example in `batch_labels_embed[0]`, + # whereas l2 is not + [ + 0, + 0, + ], # Neither l0 nor l3 are positive examples in `batch_labels_embed[1]` + [ + 1, + 1, + ], # l0 and l3 are both positive examples in `batch_labels_embed[2]` + ] + ) + ) + + +def test_multi_label_dot_product_loss__sample_candidates_with_variable_number_of_labels( + monkeypatch: MonkeyPatch, +): + num_candidates = 2 + num_features = 4 + batch_size = 3 + layer = MultiLabelDotProductLoss(num_candidates) + + # Seven random vectors for inputs and labels + i0, i1, i2, l0, l1, l2, l3 = np.round( + np.random.uniform(-100, 100, size=[7, num_features]) + ).tolist() + # Label used for padding + lp = [-1] * num_features + + # Each example in the batch has one input + batch_inputs_embed = tf.constant([[i0], [i1], [i2]], dtype=tf.float32) + # Each input can have multiple labels (lp serves as a placeholder) + batch_labels_embed = tf.constant( + [[l0, l1, l3], [l2, lp, lp], [l3, l0, lp]], dtype=tf.float32 + ) + # We assign the corresponding indices + batch_labels_ids = tf.constant( + [[[0], [1], [3]], [[2], [-1], [-1]], [[3], [0], [-1]]], dtype=tf.float32 + ) + # List all the labels and ids in play + all_labels_embed = tf.constant([l0, l1, l2, l3], dtype=tf.float32) + all_labels_ids = tf.constant([[0], [1], [2], [3]], dtype=tf.float32) + + # Inside `layer._sample_candidates` random indices will be generated for the + # candidates. We mock them to have a deterministic output. + mock_indices = [0, 2, 0, 1, 3, 1] + + def mock_random_indices(*args, **kwargs) -> tf.Tensor: + return tf.reshape(tf.constant(mock_indices), [batch_size, num_candidates]) + + monkeypatch.setattr(layers_utils, "random_indices", mock_random_indices) + + # Now run the function we want to test + ( + pos_inputs_embed, + pos_labels_embed, + candidate_labels_embed, + pos_neg_labels, + ) = layer._sample_candidates( + batch_inputs_embed, + batch_labels_embed, + batch_labels_ids, + all_labels_embed, + all_labels_ids, + ) + # The inputs just stay the inputs, up to an extra dimension + assert np.all( + pos_inputs_embed.numpy() == tf.expand_dims(batch_inputs_embed, axis=-2).numpy() + ) + # All example labels of each batch are in `pos_labels_embed` + assert np.all( + pos_labels_embed.numpy() + == np.array([[[l0, l1, l3]], [[l2, lp, lp]], [[l3, l0, lp]]]) + ) + # The candidate label embeddings are picked according to the `mock_indices` above. + # E.g. a 2 coming from `mock_indices` means that `all_labels_embed[2]` is picked, + # i.e. `l2`. + assert np.all( + candidate_labels_embed.numpy() == np.array([[[l0, l2]], [[l0, l1]], [[l3, l1]]]) + ) + # The `pos_neg_labels` contains `1`s wherever the vector in `candidate_labels_embed` + # of example `i` is actually in the possible lables of example `i` + assert np.all( + pos_neg_labels.numpy() + == np.array( + [ + [1, 0], # l0 is an actual positive example in `batch_labels_embed[0]`, + # whereas l2 is not + [ + 0, + 0, + ], # Neither l0 nor l1 are positive examples in `batch_labels_embed[1]` + [1, 0], # l3 is an actual positive example in `batch_labels_embed[2]`, + # whereas l1 is not + ] + ) + ) + + +def test_multi_label_dot_product_loss__loss_sigmoid_is_ln2_when_all_similarities_zero(): + batch_size = 2 + num_candidates = 2 + sim_pos = tf.zeros([batch_size, 1, 1], dtype=tf.float32) + sim_candidates_il = tf.zeros([batch_size, 1, num_candidates], dtype=tf.float32) + pos_neg_labels = tf.cast( + tf.random.uniform([batch_size, num_candidates]) < 0.5, tf.float32 + ) + + layer = MultiLabelDotProductLoss( + num_candidates, scale_loss=False, similarity_type=INNER + ) + loss = layer._loss_sigmoid(sim_pos, sim_candidates_il, pos_neg_labels) + assert abs(loss.numpy() - np.math.log(2.0)) < 1e-6 + + +@pytest.mark.parametrize( + "model_confidence, mock_similarities, expected_confidences", + [ + # Confidence is always `1.0` since only one option exists and we use softmax + (SOFTMAX, [[[-3.0], [0.0]]], [[[1.0], [1.0]]]) + ], +) +def test_dot_product_loss_get_similarities_and_confidences_from_embeddings( + model_confidence: Text, + mock_similarities: List, + expected_confidences: List, + monkeypatch: MonkeyPatch, +): + def mock_sim(*args, **kwargs) -> tf.Tensor: + return tf.constant(mock_similarities) + + monkeypatch.setattr(DotProductLoss, "sim", mock_sim) + + similarities, confidences = DotProductLoss( + 1, model_confidence=model_confidence + ).get_similarities_and_confidences_from_embeddings( + # Inputs are not used due to mocking of `sim` + tf.zeros([1]), + tf.zeros([1]), + tf.zeros([1]), + ) + assert np.all(similarities == mock_similarities) + assert np.all(confidences == expected_confidences) + + +@pytest.mark.parametrize( + "inputs, units, expected_output_shape", + [ + (np.array([[1, 2], [4, 5], [7, 8]]), 4, (3, 4)), + (np.array([[1, 2], [4, 5], [7, 8]]), 2, (3, 2)), + (np.array([[1, 2], [4, 5], [7, 8]]), 5, (3, 5)), + (np.array([[1, 2], [4, 5], [7, 8], [7, 8]]), 5, (4, 5)), + (np.array([[[1, 2], [4, 5], [7, 8]]]), 4, (1, 3, 4)), + ], +) +def test_randomly_connected_dense_shape( + inputs: np.array, units: int, expected_output_shape: Tuple[int, ...] +): + layer = RandomlyConnectedDense(units=units) + y = layer(inputs) + assert y.shape == expected_output_shape + + +@pytest.mark.parametrize( + "inputs, units, expected_num_non_zero_outputs", + [ + (np.array([[1, 2], [4, 5], [7, 8]]), 4, 12), + (np.array([[1, 2], [4, 5], [7, 8]]), 2, 6), + (np.array([[1, 2], [4, 5], [7, 8]]), 5, 15), + (np.array([[1, 2], [4, 5], [7, 8], [7, 8]]), 5, 20), + (np.array([[[1, 2], [4, 5], [7, 8]]]), 4, 12), + ], +) +def test_randomly_connected_dense_output_always_dense( + inputs: np.array, units: int, expected_num_non_zero_outputs: int +): + layer = RandomlyConnectedDense(density=0.0, units=units, use_bias=False) + y = layer(inputs) + num_non_zero_outputs = tf.math.count_nonzero(y).numpy() + assert num_non_zero_outputs == expected_num_non_zero_outputs + + +def test_randomly_connected_dense_all_inputs_connected(): + layer = RandomlyConnectedDense(density=0.0, units=2, use_bias=False) + # Create a unit vector [1, 0, 0, 0, ...] + x = np.zeros(10) + x[0] = 1.0 + # For every standard basis vector + for _ in range(10): + x = np.roll(x, 1) + y = layer(np.expand_dims(x, 0)) + assert tf.reduce_sum(y).numpy() != 0.0 + + +@pytest.mark.parametrize( + "layer_name, expected_feature_type", + [ + (f"sparse_to_dense.{TEXT}_{FEATURE_TYPE_SENTENCE}", FEATURE_TYPE_SENTENCE), + ( + f"sparse_to_dense.{LABEL}_{ACTION_TEXT}_{FEATURE_TYPE_SENTENCE}", + FEATURE_TYPE_SENTENCE, + ), + ( + f"sparse_to_dense.{LABEL}_{ACTION_NAME}_{FEATURE_TYPE_SEQUENCE}", + FEATURE_TYPE_SEQUENCE, + ), + (f"some_name.{DIALOGUE}_{FEATURE_TYPE_SEQUENCE}", FEATURE_TYPE_SEQUENCE), + (f"some_name.{TEXT}_sentenc", None), + (f"sparse_to_dense.{TEXT}_squence", None), + ("some_name", None), + ], +) +def test_dense_for_sparse_get_feature_type( + layer_name: Text, expected_feature_type: Union[Text, None] +): + layer = DenseForSparse(name=layer_name, units=10) + assert layer.get_feature_type() == expected_feature_type + + +@pytest.mark.parametrize( + "layer_name, expected_attribute", + [ + (f"sparse_to_dense.{TEXT}_{FEATURE_TYPE_SEQUENCE}", TEXT), + (f"sparse_to_dense.{INTENT}_{FEATURE_TYPE_SENTENCE}", INTENT), + (f"other_name.{LABEL}_{FEATURE_TYPE_SENTENCE}", LABEL), + (f"other_name.{DIALOGUE}_{FEATURE_TYPE_SENTENCE}", DIALOGUE), + (f"sparse_to_dense.{ACTION_NAME}_{FEATURE_TYPE_SEQUENCE}", ACTION_NAME), + (f"other_name.{ACTION_TEXT}_{FEATURE_TYPE_SENTENCE}", ACTION_TEXT), + ( + f"other_name.{LABEL}_{ACTION_NAME}_{FEATURE_TYPE_SENTENCE}", + f"{LABEL}_{ACTION_NAME}", + ), + ( + f"sparse_to_dense.{LABEL}_{ACTION_TEXT}_{FEATURE_TYPE_SEQUENCE}", + f"{LABEL}_{ACTION_TEXT}", + ), + ("some_name", None), + ("sparse_to_dense", None), + (f"sparse_to_dense.{TEXT}", None), + (f"sparse_to_dense.labl_{FEATURE_TYPE_SEQUENCE}", None), + ], +) +def test_dense_for_sparse_get_attribute( + layer_name: Text, expected_attribute: Union[Text, None] +): + layer = DenseForSparse(name=layer_name, units=10) + assert layer.get_attribute() == expected_attribute diff --git a/tests/utils/tensorflow/test_layers_utils.py b/tests/utils/tensorflow/test_layers_utils.py new file mode 100644 index 0000000..6a4a745 --- /dev/null +++ b/tests/utils/tensorflow/test_layers_utils.py @@ -0,0 +1,71 @@ +import pytest +import tensorflow as tf +import numpy as np +from typing import List, Optional, Union +import rasa.utils.tensorflow.layers_utils as layers_utils + + +@pytest.mark.parametrize( + "batch_size, n, n_max", + [ + (10, 4, 100), + (10, 4, 1), + ( + tf.constant(10, dtype=tf.int32), + tf.constant(4, dtype=tf.int32), + tf.constant(100, dtype=tf.int32), + ), + ( + tf.constant(10, dtype=tf.int32), + tf.constant(4, dtype=tf.int32), + tf.constant(1, dtype=tf.int32), + ), + ], +) +def test_random_indices(batch_size: int, n: int, n_max: int): + indices = layers_utils.random_indices(batch_size, n, n_max) + assert np.all(tf.shape(indices).numpy() == [batch_size, n]) + assert np.max(indices.numpy()) < n_max + assert np.max(indices.numpy()) >= 0 + + +def test_random_indices_raises_invalid_argument_error(): + with pytest.raises(tf.errors.InvalidArgumentError): + layers_utils.random_indices(2, 2, 0) + + +def test_batch_flatten(): + x = tf.ones([5, 6, 7, 8, 9]) + x_flat = layers_utils.batch_flatten(x) + assert np.all(tf.shape(x_flat).numpy() == [5 * 6 * 7 * 8, 9]) + + +def test_get_candidate_values(): + x = tf.constant([[0, 1, 2], [3, 4, 5], [6, 7, 8]], dtype=tf.float32) + candidate_ids = tf.constant([[0, 1], [0, 0], [2, 0]]) + expected_result = [ + [[0, 1, 2], [3, 4, 5]], + [[0, 1, 2], [0, 1, 2]], + [[6, 7, 8], [0, 1, 2]], + ] + actual_result = layers_utils.get_candidate_values(x, candidate_ids) + assert np.all(expected_result == actual_result) + + +@pytest.mark.parametrize( + "x, y, mask, expected_output", + [ + ([1, 2, 3], [2, 1, 3], None, 1 / 3), + ([1, 2, 3], [2, 1, 3], [1.0, 1.0, 0.0], 0.0), + ([[1, 2], [1, 2]], [[0, 0], [1, 2]], None, 0.5), + ([[1, 2], [1, 2]], [[0, 0], [1, 2]], [[1.0, 1.0], [1.0, 0.0]], 0.5), + ([[1, 2], [1, 2]], [[0, 0], [1, 3]], [[1.0, 1.0], [1.0, 1.0]], 0.25), + ], +) +def test_reduce_mean_equal( + x: Union[List[List[int]], List[int]], + y: List[int], + mask: Optional[List[int]], + expected_output: float, +): + assert expected_output == layers_utils.reduce_mean_equal(x, y, mask) diff --git a/tests/utils/tensorflow/test_metrics.py b/tests/utils/tensorflow/test_metrics.py new file mode 100644 index 0000000..9d6ffb5 --- /dev/null +++ b/tests/utils/tensorflow/test_metrics.py @@ -0,0 +1,205 @@ +"""Tests F beta metrics.""" + +# original code taken from +# https://github.com/tensorflow/addons/blob/master/tensorflow_addons/metrics/tests/f_scores_test.py +# (modified to our neeeds) + +import numpy as np +import pytest +import tensorflow as tf +from rasa.utils.tensorflow.metrics import FBetaScore, F1Score + + +def test_config_fbeta(): + fbeta_obj = FBetaScore(num_classes=3, beta=0.5, threshold=0.3, average=None) + assert fbeta_obj.beta == 0.5 + assert fbeta_obj.average is None + assert fbeta_obj.threshold == 0.3 + assert fbeta_obj.num_classes == 3 + assert fbeta_obj.dtype == tf.float32 + + # Check save and restore config + fbeta_obj2 = FBetaScore.from_config(fbeta_obj.get_config()) + assert fbeta_obj2.beta == 0.5 + assert fbeta_obj2.average is None + assert fbeta_obj2.threshold == 0.3 + assert fbeta_obj2.num_classes == 3 + assert fbeta_obj2.dtype == tf.float32 + + +def _test_tf(avg, beta, act, pred, sample_weights, threshold): + act = tf.constant(act, tf.float32) + pred = tf.constant(pred, tf.float32) + + fbeta = FBetaScore(3, avg, beta, threshold) + fbeta.update_state(act, pred, sample_weights) + return fbeta.result().numpy() + + +def _test_fbeta_score(actuals, preds, sample_weights, avg, beta_val, result, threshold): + tf_score = _test_tf(avg, beta_val, actuals, preds, sample_weights, threshold) + np.testing.assert_allclose(tf_score, result, atol=1e-7, rtol=1e-6) + + +def test_fbeta_perfect_score(): + preds = [[0.7, 0.7, 0.7], [1, 0, 0], [0.9, 0.8, 0]] + actuals = [[1, 1, 1], [1, 0, 0], [1, 1, 0]] + + for avg_val in ["micro", "macro", "weighted"]: + for beta in [0.5, 1.0, 2.0]: + _test_fbeta_score(actuals, preds, None, avg_val, beta, 1.0, 0.66) + + +def test_fbeta_worst_score(): + preds = [[0.7, 0.7, 0.7], [1, 0, 0], [0.9, 0.8, 0]] + actuals = [[0, 0, 0], [0, 1, 0], [0, 0, 1]] + + for avg_val in ["micro", "macro", "weighted"]: + for beta in [0.5, 1.0, 2.0]: + _test_fbeta_score(actuals, preds, None, avg_val, beta, 0.0, 0.66) + + +@pytest.mark.parametrize( + "avg_val, beta, result", + [ + (None, 0.5, [0.71428573, 0.5, 0.833334]), + (None, 1.0, [0.8, 0.5, 0.6666667]), + (None, 2.0, [0.9090904, 0.5, 0.555556]), + ("micro", 0.5, 0.6666667), + ("micro", 1.0, 0.6666667), + ("micro", 2.0, 0.6666667), + ("macro", 0.5, 0.6825397), + ("macro", 1.0, 0.6555555), + ("macro", 2.0, 0.6548822), + ("weighted", 0.5, 0.6825397), + ("weighted", 1.0, 0.6555555), + ("weighted", 2.0, 0.6548822), + ], +) +def test_fbeta_random_score(avg_val, beta, result): + preds = [[0.7, 0.7, 0.7], [1, 0, 0], [0.9, 0.8, 0]] + actuals = [[0, 0, 1], [1, 1, 0], [1, 1, 1]] + _test_fbeta_score(actuals, preds, None, avg_val, beta, result, 0.66) + + +@pytest.mark.parametrize( + "avg_val, beta, result", + [ + (None, 0.5, [0.9090904, 0.555556, 1.0]), + (None, 1.0, [0.8, 0.6666667, 1.0]), + (None, 2.0, [0.71428573, 0.833334, 1.0]), + ("micro", 0.5, 0.833334), + ("micro", 1.0, 0.833334), + ("micro", 2.0, 0.833334), + ("macro", 0.5, 0.821549), + ("macro", 1.0, 0.822222), + ("macro", 2.0, 0.849206), + ("weighted", 0.5, 0.880471), + ("weighted", 1.0, 0.844445), + ("weighted", 2.0, 0.829365), + ], +) +def test_fbeta_random_score_none(avg_val, beta, result): + preds = [ + [0.9, 0.1, 0], + [0.2, 0.6, 0.2], + [0, 0, 1], + [0.4, 0.3, 0.3], + [0, 0.9, 0.1], + [0, 0, 1], + ] + actuals = [[1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 0, 0], [1, 0, 0], [0, 0, 1]] + _test_fbeta_score(actuals, preds, None, avg_val, beta, result, None) + + +@pytest.mark.parametrize( + "avg_val, beta, sample_weights, result", + [ + (None, 0.5, [1.0, 1.0, 1.0, 1.0, 1.0, 1.0], [0.909091, 0.555556, 1.0]), + (None, 0.5, [1.0, 0.0, 1.0, 1.0, 0.0, 1.0], [1.0, 0.0, 1.0]), + (None, 0.5, [0.5, 1.0, 1.0, 1.0, 0.5, 1.0], [0.9375, 0.714286, 1.0]), + (None, 1.0, [1.0, 1.0, 1.0, 1.0, 1.0, 1.0], [0.8, 0.666667, 1.0]), + (None, 1.0, [1.0, 0.0, 1.0, 1.0, 0.0, 1.0], [1.0, 0.0, 1.0]), + (None, 1.0, [0.5, 1.0, 1.0, 1.0, 0.5, 1.0], [0.857143, 0.8, 1.0]), + (None, 2.0, [1.0, 1.0, 1.0, 1.0, 1.0, 1.0], [0.714286, 0.833333, 1.0]), + (None, 2.0, [1.0, 0.0, 1.0, 1.0, 0.0, 1.0], [1.0, 0.0, 1.0]), + (None, 2.0, [0.5, 1.0, 1.0, 1.0, 0.5, 1.0], [0.789474, 0.909091, 1.0]), + ("micro", 0.5, [1.0, 1.0, 1.0, 1.0, 1.0, 1.0], 0.833333), + ("micro", 0.5, [1.0, 0.0, 1.0, 1.0, 0.0, 1.0], 1.0), + ("micro", 0.5, [0.5, 1.0, 1.0, 1.0, 0.5, 1.0], 0.9), + ("micro", 1.0, [1.0, 1.0, 1.0, 1.0, 1.0, 1.0], 0.833333), + ("micro", 1.0, [1.0, 0.0, 1.0, 1.0, 0.0, 1.0], 1.0), + ("micro", 1.0, [0.5, 1.0, 1.0, 1.0, 0.5, 1.0], 0.9), + ("micro", 2.0, [1.0, 1.0, 1.0, 1.0, 1.0, 1.0], 0.833333), + ("micro", 2.0, [1.0, 0.0, 1.0, 1.0, 0.0, 1.0], 1.0), + ("micro", 2.0, [0.5, 1.0, 1.0, 1.0, 0.5, 1.0], 0.9), + ("macro", 0.5, [1.0, 1.0, 1.0, 1.0, 1.0, 1.0], 0.821549), + ("macro", 0.5, [1.0, 0.0, 1.0, 1.0, 0.0, 1.0], 0.666667), + ("macro", 0.5, [0.5, 1.0, 1.0, 1.0, 0.5, 1.0], 0.883929), + ("macro", 1.0, [1.0, 1.0, 1.0, 1.0, 1.0, 1.0], 0.822222), + ("macro", 1.0, [1.0, 0.0, 1.0, 1.0, 0.0, 1.0], 0.666667), + ("macro", 1.0, [0.5, 1.0, 1.0, 1.0, 0.5, 1.0], 0.885714), + ("macro", 2.0, [1.0, 1.0, 1.0, 1.0, 1.0, 1.0], 0.849206), + ("macro", 2.0, [1.0, 0.0, 1.0, 1.0, 0.0, 1.0], 0.666667), + ("macro", 2.0, [0.5, 1.0, 1.0, 1.0, 0.5, 1.0], 0.899522), + ("weighted", 0.5, [1.0, 1.0, 1.0, 1.0, 1.0, 1.0], 0.880471), + ("weighted", 0.5, [1.0, 0.0, 1.0, 1.0, 0.0, 1.0], 1.0), + ("weighted", 0.5, [0.5, 1.0, 1.0, 1.0, 0.5, 1.0], 0.917857), + ("weighted", 1.0, [1.0, 1.0, 1.0, 1.0, 1.0, 1.0], 0.844444), + ("weighted", 1.0, [1.0, 0.0, 1.0, 1.0, 0.0, 1.0], 1.0), + ("weighted", 1.0, [0.5, 1.0, 1.0, 1.0, 0.5, 1.0], 0.902857), + ("weighted", 2.0, [1.0, 1.0, 1.0, 1.0, 1.0, 1.0], 0.829365), + ("weighted", 2.0, [1.0, 0.0, 1.0, 1.0, 0.0, 1.0], 1.0), + ("weighted", 2.0, [0.5, 1.0, 1.0, 1.0, 0.5, 1.0], 0.897608), + ], +) +def test_fbeta_weighted_random_score_none(avg_val, beta, sample_weights, result): + preds = [ + [0.9, 0.1, 0], + [0.2, 0.6, 0.2], + [0, 0, 1], + [0.4, 0.3, 0.3], + [0, 0.9, 0.1], + [0, 0, 1], + ] + actuals = [[1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 0, 0], [1, 0, 0], [0, 0, 1]] + _test_fbeta_score(actuals, preds, sample_weights, avg_val, beta, result, None) + + +def test_eq(): + f1 = F1Score(3) + fbeta = FBetaScore(3, beta=1.0) + + preds = [ + [0.9, 0.1, 0], + [0.2, 0.6, 0.2], + [0, 0, 1], + [0.4, 0.3, 0.3], + [0, 0.9, 0.1], + [0, 0, 1], + ] + actuals = [[1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 0, 0], [1, 0, 0], [0, 0, 1]] + + fbeta.update_state(actuals, preds) + f1.update_state(actuals, preds) + np.testing.assert_allclose(fbeta.result().numpy(), f1.result().numpy()) + + +def test_sample_eq(): + f1 = F1Score(3) + f1_weighted = F1Score(3) + + preds = [ + [0.9, 0.1, 0], + [0.2, 0.6, 0.2], + [0, 0, 1], + [0.4, 0.3, 0.3], + [0, 0.9, 0.1], + [0, 0, 1], + ] + actuals = [[1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 0, 0], [1, 0, 0], [0, 0, 1]] + sample_weights = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0] + + f1.update_state(actuals, preds) + f1_weighted(actuals, preds, sample_weights) + np.testing.assert_allclose(f1.result().numpy(), f1_weighted.result().numpy()) diff --git a/tests/utils/tensorflow/test_model_data.py b/tests/utils/tensorflow/test_model_data.py new file mode 100644 index 0000000..3be6cb1 --- /dev/null +++ b/tests/utils/tensorflow/test_model_data.py @@ -0,0 +1,182 @@ +import copy + +import pytest +import numpy as np + +from rasa.utils.tensorflow.model_data import RasaModelData + + +def test_shuffle_session_data(model_data: RasaModelData): + before = copy.copy(model_data) + + # precondition + assert np.all( + np.array(list(before.values())) == np.array(list(model_data.values())) + ) + + data = model_data.shuffled_data(model_data.data) + + # check that original data didn't change + assert np.all( + np.array(list(before.values())) == np.array(list(model_data.values())) + ) + # check that new data is different + assert np.all(np.array(model_data.values()) != np.array(data.values())) + + +def test_split_data_by_label(model_data: RasaModelData): + split_model_data = model_data._split_by_label_ids( + model_data.data, model_data.get("label", "ids")[0], np.array([0, 1]) + ) + + assert len(split_model_data) == 2 + for s in split_model_data: + assert len(set(s.get("label", "ids")[0])) == 1 + + for key, attribute_data in split_model_data[0].items(): + for sub_key, features in attribute_data.items(): + assert len(features) == len(model_data.data[key][sub_key]) + assert len(features[0]) == 2 + + +def test_split_data_by_none_label(model_data: RasaModelData): + model_data.label_key = None + model_data.label_sub_key = None + + split_model_data = model_data.split(2, 42) + + assert len(split_model_data) == 2 + + train_data = split_model_data[0] + test_data = split_model_data[1] + + # train data should have 3 examples + assert len(train_data.get("label", "ids")[0]) == 3 + # test data should have 2 examples + assert len(test_data.get("label", "ids")[0]) == 2 + + +def test_train_val_split(model_data: RasaModelData): + train_model_data, test_model_data = model_data.split(2, 42) + + for key, values in model_data.items(): + assert len(values) == len(train_model_data.get(key)) + assert len(values) == len(test_model_data.get(key)) + for sub_key, data in values.items(): + assert len(data) == len(train_model_data.get(key, sub_key)) + assert len(data) == len(test_model_data.get(key, sub_key)) + for i, v in enumerate(data): + if isinstance(v[0], list): + assert ( + v[0][0].dtype + == train_model_data.get(key, sub_key)[i][0][0].dtype + ) + else: + assert v[0].dtype == train_model_data.get(key, sub_key)[i][0].dtype + + for values in train_model_data.values(): + for data in values.values(): + for v in data: + assert np.array(v).shape[0] == 3 + + for values in test_model_data.values(): + for data in values.values(): + for v in data: + assert np.array(v).shape[0] == 2 + + +@pytest.mark.parametrize("size", [0, 1, 5]) +def test_train_val_split_incorrect_size(model_data: RasaModelData, size: int): + with pytest.raises(ValueError): + model_data.split(size, 42) + + +def test_session_data_for_ids(model_data: RasaModelData): + filtered_data = model_data._data_for_ids(model_data.data, np.array([0, 1])) + + for values in filtered_data.values(): + for data in values.values(): + for v in data: + assert np.array(v).shape[0] == 2 + + key = model_data.keys()[0] + sub_key = model_data.keys(key)[0] + + assert np.all( + np.array(filtered_data[key][sub_key][0][0]) + == np.array(model_data.get(key, sub_key)[0][0]) + ) + assert np.all( + np.array(filtered_data[key][sub_key][0][1]) + == np.array(model_data.get(key, sub_key)[0][1]) + ) + + +def test_get_number_of_examples(model_data: RasaModelData): + assert model_data.number_of_examples() == 5 + + +def test_get_number_of_examples_raises_value_error(model_data: RasaModelData): + model_data.data["dense"] = {} + model_data.data["dense"]["data"] = [np.random.randint(5, size=(2, 10))] + with pytest.raises(ValueError): + model_data.number_of_examples() + + +def test_is_in_4d_format(model_data: RasaModelData): + assert model_data.data["action_text"]["sequence"][0].number_of_dimensions == 4 + assert model_data.data["text"]["sentence"][0].number_of_dimensions == 3 + + +def test_balance_model_data(model_data: RasaModelData): + data = model_data.balanced_data(model_data.data, 2, False) + + assert np.all(np.array(data["label"]["ids"][0]) == np.array([0, 1, 1, 0, 1])) + + +def test_not_balance_model_data(model_data: RasaModelData): + test_model_data = RasaModelData( + label_key="entities", label_sub_key="tag_ids", data=model_data.data + ) + + data = test_model_data.balanced_data(test_model_data.data, 2, False) + + assert np.all( + data["entities"]["tag_ids"] == test_model_data.get("entities", "tag_ids") + ) + + +def test_get_num_of_features(model_data: RasaModelData): + num_features = model_data.number_of_units("text", "sentence") + + assert num_features == 24 + + +def test_sort(model_data: RasaModelData): + assert list(model_data.data.keys()) == [ + "text", + "action_text", + "dialogue", + "label", + "entities", + ] + + model_data.sort() + + assert list(model_data.data.keys()) == [ + "action_text", + "dialogue", + "entities", + "label", + "text", + ] + + +def test_update_key(model_data: RasaModelData): + assert model_data.does_feature_exist("label", "ids") + + model_data.update_key("label", "ids", "intent", "ids") + + assert not model_data.does_feature_exist("label", "ids") + assert model_data.does_feature_exist("intent", "ids") + assert "label" not in model_data.data diff --git a/tests/utils/tensorflow/test_model_data_utils.py b/tests/utils/tensorflow/test_model_data_utils.py new file mode 100644 index 0000000..6536829 --- /dev/null +++ b/tests/utils/tensorflow/test_model_data_utils.py @@ -0,0 +1,367 @@ +from typing import Any, Text, Optional, Dict, List + +import pytest +import scipy.sparse +import numpy as np +import copy + +from spacy import Language + +from rasa.engine.graph import ExecutionContext +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.nlu.extractors.extractor import EntityTagSpec +from rasa.nlu.constants import SPACY_DOCS +from rasa.nlu.featurizers.dense_featurizer.spacy_featurizer import SpacyFeaturizer +from rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer import ( + CountVectorsFeaturizer, +) +from rasa.nlu.tokenizers.spacy_tokenizer import SpacyTokenizer +from rasa.utils.tensorflow import model_data_utils +from rasa.shared.nlu.training_data.features import Features +from rasa.shared.nlu.constants import ( + ACTION_NAME, + TEXT, + INTENT, + ENTITIES, + FEATURE_TYPE_SENTENCE, + FEATURE_TYPE_SEQUENCE, +) +from rasa.utils.tensorflow.constants import SENTENCE +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.training_data.training_data import TrainingData +from rasa.utils.tensorflow.model_data_utils import TAG_ID_ORIGIN + +shape = 100 + + +def test_create_fake_features(): + # DENSE FEATURES + dense_feature_sentence_features = Features( + features=np.random.rand(shape), + attribute=INTENT, + feature_type=SENTENCE, + origin=[], + ) + features = [[None, None, [dense_feature_sentence_features]]] + + fake_features = model_data_utils._create_fake_features(features) + assert len(fake_features) == 1 + assert fake_features[0].is_dense() + assert fake_features[0].features.shape == (0, shape) + + # SPARSE FEATURES + sparse_feature_sentence_features = Features( + features=scipy.sparse.coo_matrix(np.random.rand(shape)), + attribute=INTENT, + feature_type=SENTENCE, + origin=[], + ) + features = [[None, None, [sparse_feature_sentence_features]]] + fake_features = model_data_utils._create_fake_features(features) + assert len(fake_features) == 1 + assert fake_features[0].is_sparse() + assert fake_features[0].features.shape == (0, shape) + assert fake_features[0].features.nnz == 0 + + +def test_surface_attributes(): + intent_features = { + INTENT: [ + Features( + features=np.random.rand(shape), + attribute=INTENT, + feature_type=SENTENCE, + origin="featurizer-a", + ), + Features( + features=np.random.rand(shape), + attribute=INTENT, + feature_type=SENTENCE, + origin="featurizer-b", + ), + ] + } + + action_name_features = scipy.sparse.coo_matrix(np.random.rand(shape)) + action_name_features = { + ACTION_NAME: [ + Features( + features=action_name_features, + attribute=ACTION_NAME, + feature_type=SENTENCE, + origin="featurizer-c", + ) + ] + } + state_features = copy.deepcopy(intent_features) + state_features.update(copy.deepcopy(action_name_features)) + # test on 2 dialogs -- one with dialog length 3 the other one with dialog length 2 + dialogs = [[state_features, intent_features, {}], [{}, action_name_features]] + surfaced_features = model_data_utils._surface_attributes( + dialogs, featurizers=["featurizer-a", "featurizer-c"] + ) + assert INTENT in surfaced_features and ACTION_NAME in surfaced_features + # check that number of lists corresponds to number of dialogs + assert ( + len(surfaced_features.get(INTENT)) == 2 + and len(surfaced_features.get(ACTION_NAME)) == 2 + ) + # length of each list corresponds to length of the dialog + assert ( + len(surfaced_features.get(INTENT)[0]) == 3 + and len(surfaced_features.get(INTENT)[1]) == 2 + ) + assert ( + len(surfaced_features.get(ACTION_NAME)[0]) == 3 + and len(surfaced_features.get(ACTION_NAME)[1]) == 2 + ) + # check that features are correctly populated with `None`s + assert ( + surfaced_features.get(INTENT)[0][2] is None + and surfaced_features.get(INTENT)[1][0] is None + and surfaced_features.get(INTENT)[1][1] is None + ) + assert ( + surfaced_features.get(ACTION_NAME)[0][1] is None + and surfaced_features.get(ACTION_NAME)[0][2] is None + and surfaced_features.get(ACTION_NAME)[1][0] is None + ) + # check that all features are the same as before + assert all( + [ + (turn[0].features == intent_features[INTENT][0].features).all() + for dialogue in surfaced_features.get(INTENT) + for turn in dialogue + if turn is not None + ] + ) + assert all( + [ + (turn[0].features != action_name_features[ACTION_NAME][0].features).nnz == 0 + for dialogue in surfaced_features.get(ACTION_NAME) + for turn in dialogue + if turn is not None + ] + ) + + +def test_extract_features(): + fake_features = np.zeros(shape) + fake_features_as_features = Features( + features=fake_features, attribute=INTENT, feature_type=SENTENCE, origin=[] + ) + # create zero features + fake_features_list = [fake_features_as_features] + + # create tracker state features by setting a random index in the array to 1 + random_inds = np.random.randint(shape, size=6) + list_of_features = [] + for idx in random_inds: + current_features = copy.deepcopy(fake_features_as_features) + current_features.features[idx] = 1 + list_of_features.append([current_features]) + + # organize the created features into lists ~ dialog history + tracker_features = [ + [list_of_features[0], None, list_of_features[1]], + [None, None, list_of_features[2]], + [list_of_features[3], list_of_features[4], list_of_features[5]], + ] + + ( + attribute_masks, + dense_features, + sparse_features, + ) = model_data_utils._extract_features(tracker_features, fake_features_list, INTENT) + expected_mask = np.array([[1, 0, 1], [0, 0, 1], [1, 1, 1]]) + + assert np.all(np.squeeze(np.array(attribute_masks), 2) == expected_mask) + assert np.array(dense_features[SENTENCE]).shape[-1] == fake_features.shape[-1] + assert sparse_features == {} + + +@pytest.mark.parametrize( + "text, intent, entities, attributes, real_sparse_feature_sizes", + [ + ("Hello!", "greet", None, [TEXT], {"text": {"sequence": [1], "sentence": [1]}}), + ( + "Hello!", + "greet", + None, + [TEXT, INTENT], + { + "intent": {"sentence": [], "sequence": [1]}, + "text": {"sequence": [1], "sentence": [1]}, + }, + ), + ( + "Hello Max!", + "greet", + [{"entity": "name", "value": "Max", "start": 6, "end": 9}], + [TEXT, ENTITIES], + {"text": {"sequence": [2], "sentence": [2]}}, + ), + ], +) +def test_convert_training_examples( + spacy_nlp: Language, + text: Text, + intent: Optional[Text], + entities: Optional[List[Dict[Text, Any]]], + attributes: List[Text], + real_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, +): + message = Message(data={TEXT: text, INTENT: intent, ENTITIES: entities}) + + tokenizer = SpacyTokenizer.create( + SpacyTokenizer.get_default_config(), + default_model_storage, + Resource("tokenizer"), + default_execution_context, + ) + count_vectors_featurizer = CountVectorsFeaturizer.create( + CountVectorsFeaturizer.get_default_config(), + default_model_storage, + Resource("count_featurizer"), + default_execution_context, + ) + spacy_featurizer = SpacyFeaturizer.create( + SpacyFeaturizer.get_default_config(), + default_model_storage, + Resource("spacy_featurizer"), + default_execution_context, + ) + + message.set(SPACY_DOCS[TEXT], spacy_nlp(text)) + + training_data = TrainingData([message]) + tokenizer.process_training_data(training_data) + count_vectors_featurizer.train(training_data) + count_vectors_featurizer.process_training_data(training_data) + spacy_featurizer.process_training_data(training_data) + + entity_tag_spec = [ + EntityTagSpec( + "entity", + {0: "O", 1: "name", 2: "location"}, + {"O": 0, "name": 1, "location": 2}, + 3, + ) + ] + output, sparse_feature_sizes = model_data_utils.featurize_training_examples( + [message], attributes=attributes, entity_tag_specs=entity_tag_spec + ) + + assert len(output) == 1 + for attribute in attributes: + assert attribute in output[0] + for attribute in {INTENT, TEXT, ENTITIES} - set(attributes): + assert attribute not in output[0] + # we have sparse sentence, sparse sequence, dense sentence, and dense sequence + # features in the list + assert len(output[0][TEXT]) == 4 + if INTENT in attributes: + # we will just have space sentence features + assert len(output[0][INTENT]) == 1 + if ENTITIES in attributes: + # we will just have space sentence features + assert len(output[0][ENTITIES]) == len(entity_tag_spec) + # check that it calculates sparse_feature_sizes correctly + assert sparse_feature_sizes == real_sparse_feature_sizes + + +@pytest.mark.parametrize( + "features, featurizers, expected_features", + [ + ([], None, []), + (None, ["featurizer-a"], None), + ( + [ + Features( + np.random.rand(5, 14), FEATURE_TYPE_SENTENCE, TEXT, "featurizer-a" + ) + ], + None, + [ + Features( + np.random.rand(5, 14), FEATURE_TYPE_SENTENCE, TEXT, "featurizer-a" + ) + ], + ), + ( + [ + Features( + np.random.rand(5, 14), FEATURE_TYPE_SENTENCE, TEXT, "featurizer-a" + ) + ], + ["featurizer-b"], + [], + ), + ( + [ + Features( + np.random.rand(5, 14), FEATURE_TYPE_SENTENCE, TEXT, "featurizer-a" + ), + Features( + np.random.rand(5, 14), + FEATURE_TYPE_SEQUENCE, + ACTION_NAME, + "featurizer-b", + ), + ], + ["featurizer-b"], + [ + Features( + np.random.rand(5, 14), + FEATURE_TYPE_SEQUENCE, + ACTION_NAME, + "featurizer-b", + ) + ], + ), + ( + [ + Features( + np.random.rand(5, 14), FEATURE_TYPE_SEQUENCE, "role", TAG_ID_ORIGIN + ), + Features( + np.random.rand(5, 14), + FEATURE_TYPE_SEQUENCE, + ACTION_NAME, + "featurizer-b", + ), + ], + ["featurizer-b"], + [ + Features( + np.random.rand(5, 14), FEATURE_TYPE_SEQUENCE, "role", TAG_ID_ORIGIN + ), + Features( + np.random.rand(5, 14), + FEATURE_TYPE_SEQUENCE, + ACTION_NAME, + "featurizer-b", + ), + ], + ), + ], +) +def test_filter_features( + features: Optional[List["Features"]], + featurizers: Optional[List[Text]], + expected_features: Optional[List["Features"]], +): + actual_features = model_data_utils._filter_features(features, featurizers) + + if expected_features is None: + assert actual_features is None + return + + assert len(actual_features) == len(expected_features) + for actual_feature, expected_feature in zip(actual_features, expected_features): + assert expected_feature.origin == actual_feature.origin + assert expected_feature.type == actual_feature.type + assert expected_feature.attribute == actual_feature.attribute diff --git a/tests/utils/tensorflow/test_models.py b/tests/utils/tensorflow/test_models.py new file mode 100644 index 0000000..caa131a --- /dev/null +++ b/tests/utils/tensorflow/test_models.py @@ -0,0 +1,279 @@ +import pytest +from typing import Dict, Text, Union, Tuple, List +import numpy as np +import tensorflow as tf + +from rasa.utils.tensorflow.models import RasaModel, TransformerRasaModel +from rasa.utils.tensorflow.model_data import RasaModelData +from rasa.utils.tensorflow.model_data import FeatureArray +from rasa.utils.tensorflow.constants import LABEL, IDS, SENTENCE +from rasa.shared.nlu.constants import TEXT, FEATURE_TYPE_SENTENCE, FEATURE_TYPE_SEQUENCE + + +@pytest.mark.parametrize( + "existing_outputs, new_batch_outputs, expected_output", + [ + ( + {"a": np.array([1, 2]), "b": np.array([3, 1])}, + {"a": np.array([5, 6]), "b": np.array([2, 4])}, + {"a": np.array([1, 2, 5, 6]), "b": np.array([3, 1, 2, 4])}, + ), + ( + {}, + {"a": np.array([5, 6]), "b": np.array([2, 4])}, + {"a": np.array([5, 6]), "b": np.array([2, 4])}, + ), + ( + {"a": np.array([1, 2]), "b": {"c": np.array([3, 1])}}, + {"a": np.array([5, 6]), "b": {"c": np.array([2, 4])}}, + {"a": np.array([1, 2, 5, 6]), "b": {"c": np.array([3, 1, 2, 4])}}, + ), + ], +) +def test_merging_batch_outputs( + existing_outputs: Dict[Text, Union[np.ndarray, Dict[Text, np.ndarray]]], + new_batch_outputs: Dict[Text, Union[np.ndarray, Dict[Text, np.ndarray]]], + expected_output: Dict[Text, Union[np.ndarray, Dict[Text, np.ndarray]]], +): + + predicted_output = RasaModel._merge_batch_outputs( + existing_outputs, new_batch_outputs + ) + + def test_equal_dicts( + dict1: Dict[Text, Union[np.ndarray, Dict[Text, np.ndarray]]], + dict2: Dict[Text, Union[np.ndarray, Dict[Text, np.ndarray]]], + ) -> None: + assert dict2.keys() == dict1.keys() + for key in dict1: + val_1 = dict1[key] + val_2 = dict2[key] + assert type(val_1) == type(val_2) + + if isinstance(val_2, np.ndarray): + assert np.array_equal(val_1, val_2) + + elif isinstance(val_2, dict): + test_equal_dicts(val_1, val_2) + + test_equal_dicts(predicted_output, expected_output) + + +@pytest.mark.parametrize( + "batch_size, number_of_data_points, expected_number_of_batch_iterations", + [(2, 3, 2), (1, 3, 3), (5, 3, 1)], +) +def test_batch_inference( + batch_size: int, + number_of_data_points: int, + expected_number_of_batch_iterations: int, +): + model = RasaModel() + + def _batch_predict( + batch_in: Tuple[np.ndarray], + ) -> Dict[Text, Union[np.ndarray, Dict[Text, np.ndarray]]]: + + dummy_output = batch_in[0] + output = { + "dummy_output": dummy_output, + "non_input_affected_output": tf.constant( + np.array([[1, 2]]), dtype=tf.int32 + ), + } + return output + + # Monkeypatch batch predict so that run_inference interface can be tested + model.batch_predict = _batch_predict + + # Create dummy model data to pass to model + model_data = RasaModelData( + label_key=LABEL, + label_sub_key=IDS, + data={ + TEXT: { + SENTENCE: [ + FeatureArray( + np.random.rand(number_of_data_points, 2), number_of_dimensions=2 + ) + ] + } + }, + ) + output = model.run_inference(model_data, batch_size=batch_size) + + # Firstly, the number of data points in dummy_output should be equal + # to the number of data points sent as input. + assert output["dummy_output"].shape[0] == number_of_data_points + + # Secondly, the number of data points inside diagnostic_data should be + # equal to the number of batches passed to the model because for every + # batch passed as input, it would have created a + # corresponding diagnostic data entry. + assert output["non_input_affected_output"].shape == ( + expected_number_of_batch_iterations, + 2, + ) + + +@pytest.mark.parametrize( + "new_sparse_feature_sizes, old_sparse_feature_sizes, raise_exception", + [ + ( + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 2], + FEATURE_TYPE_SEQUENCE: [3, 5, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [], FEATURE_TYPE_SENTENCE: [1]}, + }, + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 2], + FEATURE_TYPE_SEQUENCE: [3, 5, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [], FEATURE_TYPE_SENTENCE: [2]}, + }, + True, + ), + ( + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 2], + FEATURE_TYPE_SEQUENCE: [3, 1, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [2], FEATURE_TYPE_SENTENCE: []}, + }, + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 2], + FEATURE_TYPE_SEQUENCE: [3, 5, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [2], FEATURE_TYPE_SENTENCE: []}, + }, + True, + ), + ( + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 2], + FEATURE_TYPE_SEQUENCE: [3, 5, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [2], FEATURE_TYPE_SENTENCE: []}, + }, + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 2], + FEATURE_TYPE_SEQUENCE: [3, 5, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [2], FEATURE_TYPE_SENTENCE: []}, + }, + False, + ), + ( + { + TEXT: { + FEATURE_TYPE_SENTENCE: [10, 2], + FEATURE_TYPE_SEQUENCE: [18, 5, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [3], FEATURE_TYPE_SENTENCE: []}, + }, + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 2], + FEATURE_TYPE_SEQUENCE: [3, 5, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [2], FEATURE_TYPE_SENTENCE: []}, + }, + False, + ), + ], +) +def test_raise_exception_decreased_sparse_feature_sizes( + new_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + old_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + raise_exception: bool, +): + """Tests if exception is raised when sparse feature sizes decrease + during incremental training.""" + if raise_exception: + with pytest.raises(Exception) as exec_info: + TransformerRasaModel._check_if_sparse_feature_sizes_decreased( + new_sparse_feature_sizes=new_sparse_feature_sizes, + old_sparse_feature_sizes=old_sparse_feature_sizes, + ) + assert "Sparse feature sizes have decreased" in str(exec_info.value) + else: + TransformerRasaModel._check_if_sparse_feature_sizes_decreased( + new_sparse_feature_sizes=new_sparse_feature_sizes, + old_sparse_feature_sizes=old_sparse_feature_sizes, + ) + + +@pytest.mark.parametrize( + "new_sparse_feature_sizes, old_sparse_feature_sizes, expected_output", + [ + ( + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 2], + FEATURE_TYPE_SEQUENCE: [3, 5, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [], FEATURE_TYPE_SENTENCE: [5]}, + }, + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 2], + FEATURE_TYPE_SEQUENCE: [3, 5, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [], FEATURE_TYPE_SENTENCE: [2]}, + }, + True, + ), + ( + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 10], + FEATURE_TYPE_SEQUENCE: [3, 10, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [2], FEATURE_TYPE_SENTENCE: []}, + }, + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 2], + FEATURE_TYPE_SEQUENCE: [3, 5, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [2], FEATURE_TYPE_SENTENCE: []}, + }, + True, + ), + ( + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 2], + FEATURE_TYPE_SEQUENCE: [3, 5, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [2], FEATURE_TYPE_SENTENCE: []}, + }, + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 2], + FEATURE_TYPE_SEQUENCE: [3, 5, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [2], FEATURE_TYPE_SENTENCE: []}, + }, + False, + ), + ], +) +def test_if_sparse_feature_sizes_have_increased( + new_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + old_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + expected_output: bool, +): + """Tests if any of the sparse feature sizes has increased.""" + output = TransformerRasaModel._sparse_feature_sizes_have_increased( + new_sparse_feature_sizes=new_sparse_feature_sizes, + old_sparse_feature_sizes=old_sparse_feature_sizes, + ) + assert output == expected_output diff --git a/tests/utils/tensorflow/test_rasa_layers.py b/tests/utils/tensorflow/test_rasa_layers.py new file mode 100644 index 0000000..4055b9d --- /dev/null +++ b/tests/utils/tensorflow/test_rasa_layers.py @@ -0,0 +1,1029 @@ +import pytest +import tensorflow as tf +import numpy as np + +from typing import Text, Union, Any, Dict, List, Type + +from rasa.shared.nlu.constants import TEXT, FEATURE_TYPE_SENTENCE, FEATURE_TYPE_SEQUENCE +from rasa.utils.tensorflow import layers +from rasa.utils.tensorflow.rasa_layers import ( + ConcatenateSparseDenseFeatures, + RasaFeatureCombiningLayer, + RasaSequenceLayer, + RasaCustomLayer, +) +from rasa.utils.tensorflow.constants import ( + DENSE_INPUT_DROPOUT, + SPARSE_INPUT_DROPOUT, + DROP_RATE, + DENSE_DIMENSION, + REGULARIZATION_CONSTANT, + CONCAT_DIMENSION, + CONNECTION_DENSITY, + DROP_RATE_ATTENTION, + KEY_RELATIVE_ATTENTION, + VALUE_RELATIVE_ATTENTION, + MAX_RELATIVE_POSITION, + UNIDIRECTIONAL_ENCODER, + HIDDEN_LAYERS_SIZES, + NUM_TRANSFORMER_LAYERS, + TRANSFORMER_SIZE, + NUM_HEADS, + SENTENCE, + SEQUENCE, + MASKED_LM, + LABEL, +) +from rasa.utils.tensorflow.exceptions import TFLayerConfigException +from rasa.utils.tensorflow.model_data import FeatureSignature + + +attribute_name = TEXT +units_1 = 2 +units_2 = 3 +units_sparse_to_dense = 10 +units_concat = 7 +units_hidden_layer = 11 +units_transformer = 14 +num_transformer_heads = 2 +num_transformer_layers = 2 +batch_size = 5 +max_seq_length = 3 + + +model_config_basic = { + DENSE_INPUT_DROPOUT: False, + SPARSE_INPUT_DROPOUT: False, + DROP_RATE: 0.5, + DENSE_DIMENSION: {attribute_name: units_sparse_to_dense}, + REGULARIZATION_CONSTANT: 0.001, + CONCAT_DIMENSION: {attribute_name: units_concat}, + CONNECTION_DENSITY: 0.5, + HIDDEN_LAYERS_SIZES: {attribute_name: [units_hidden_layer]}, + NUM_TRANSFORMER_LAYERS: 0, + TRANSFORMER_SIZE: None, + UNIDIRECTIONAL_ENCODER: None, + MASKED_LM: False, +} + +model_config_basic_no_hidden_layers = dict( + model_config_basic, **{HIDDEN_LAYERS_SIZES: {attribute_name: []}} +) + +model_config_transformer = dict( + model_config_basic, + **{ + DROP_RATE_ATTENTION: 0.5, + KEY_RELATIVE_ATTENTION: True, + VALUE_RELATIVE_ATTENTION: True, + MAX_RELATIVE_POSITION: 10, + UNIDIRECTIONAL_ENCODER: False, + NUM_TRANSFORMER_LAYERS: {attribute_name: num_transformer_layers}, + TRANSFORMER_SIZE: {attribute_name: units_transformer}, + NUM_HEADS: num_transformer_heads, + }, +) + +model_config_transformer_mlm = dict(model_config_transformer, **{MASKED_LM: True}) + + +# Dummy feature signatures and features (full of 1s) for tests that don't check exact +# numerical outputs, only shapes + +feature_signature_sparse_1 = FeatureSignature( + is_sparse=True, units=units_1, number_of_dimensions=3 +) +feature_sparse_seq_1 = tf.sparse.from_dense( + tf.ones((batch_size, max_seq_length, units_1)) +) +feature_sparse_sent_1 = tf.sparse.from_dense(tf.ones((batch_size, 1, units_1))) + +feature_signature_dense_1 = FeatureSignature( + is_sparse=False, units=units_1, number_of_dimensions=3 +) +feature_dense_seq_1 = tf.ones((batch_size, max_seq_length, units_1)) +feature_dense_sent_1 = tf.ones((batch_size, 1, units_1)) + +feature_signature_dense_2 = FeatureSignature( + is_sparse=False, units=units_2, number_of_dimensions=3 +) +feature_dense_seq_2 = tf.ones((batch_size, max_seq_length, units_2)) +feature_dense_sent_2 = tf.ones((batch_size, 1, units_2)) + +sequence_lengths = tf.ones((batch_size,)) * max_seq_length +sequence_lengths_empty = tf.ones((batch_size,)) * 0 + +attribute_signature_basic = { + SEQUENCE: [feature_signature_dense_1, feature_signature_sparse_1], + SENTENCE: [feature_signature_dense_1], +} +attribute_features_basic = ( + [feature_dense_seq_1, feature_sparse_seq_1], + [feature_dense_sent_1], + sequence_lengths, +) + + +@pytest.mark.parametrize( + "layer_class, model_config, layer_args, expected_output_units", + [ + # ConcatenateSparseDense layer with mixed features + ( + ConcatenateSparseDenseFeatures, + model_config_basic, + { + "feature_type": "arbitrary", + "feature_type_signature": [ + feature_signature_sparse_1, + feature_signature_sparse_1, + feature_signature_dense_1, + feature_signature_dense_2, + ], + }, + 2 * units_sparse_to_dense + units_1 + units_2, + ), + # ConcatenateSparseDense layer with only sparse features + ( + ConcatenateSparseDenseFeatures, + model_config_basic, + { + "feature_type": "arbitrary", + "feature_type_signature": [feature_signature_sparse_1], + }, + units_sparse_to_dense, + ), + # ConcatenateSparseDense layer with only dense features + ( + ConcatenateSparseDenseFeatures, + model_config_basic, + { + "feature_type": "arbitrary", + "feature_type_signature": [feature_signature_dense_1], + }, + units_1, + ), + # FeatureCombining layer with sequence- and sentence-level features, doing + # dimension unifying + ( + RasaFeatureCombiningLayer, + model_config_basic, + {"attribute_signature": attribute_signature_basic}, + units_concat, + ), + # FeatureCombining layer with sequence- and sentence-level features, no + # dimension unifying + ( + RasaFeatureCombiningLayer, + model_config_basic, + { + "attribute_signature": { + SEQUENCE: [feature_signature_dense_1], + SENTENCE: [feature_signature_dense_1], + } + }, + units_1, + ), + # FeatureCombining layer with sentence-level features only + ( + RasaFeatureCombiningLayer, + model_config_basic, + { + "attribute_signature": { + "sequence": [], + "sentence": [feature_signature_dense_1], + } + }, + units_1, + ), + # FeatureCombining layer with sequence-level features only + ( + RasaFeatureCombiningLayer, + model_config_basic, + { + "attribute_signature": { + "sequence": [feature_signature_dense_1], + "sentence": [], + } + }, + units_1, + ), + # Sequence layer with mixed features, hidden layers and transformer + ( + RasaSequenceLayer, + model_config_transformer, + {"attribute_signature": attribute_signature_basic}, + units_transformer, + ), + # Sequence layer with mixed features, hidden layers, no transformer + ( + RasaSequenceLayer, + model_config_basic, + {"attribute_signature": attribute_signature_basic}, + units_hidden_layer, + ), + # Sequence layer with mixed features, no hidden layers, no transformer + ( + RasaSequenceLayer, + model_config_basic_no_hidden_layers, + {"attribute_signature": attribute_signature_basic}, + units_concat, + ), + ], +) +def test_layer_gives_correct_output_units( + layer_class: Type[tf.keras.layers.Layer], + model_config: Dict[Text, Any], + layer_args: Dict[Text, Any], + expected_output_units: int, +) -> None: + layer = layer_class(**layer_args, config=model_config, attribute=attribute_name) + assert layer.output_units == expected_output_units + + +@pytest.mark.parametrize( + "layer_class, model_config, layer_args, layer_inputs, expected_output_shapes_train," + "expected_output_shapes_test", + [ + # ConcatenateSparseDense layer with mixed features + ( + ConcatenateSparseDenseFeatures, + model_config_basic, + { + "feature_type": "arbitrary", + "feature_type_signature": [ + feature_signature_sparse_1, + feature_signature_sparse_1, + feature_signature_dense_1, + feature_signature_dense_2, + ], + }, + ( + [ + feature_sparse_seq_1, + feature_sparse_seq_1, + feature_dense_seq_1, + feature_dense_seq_2, + ], + ), + [ + [ + batch_size, + max_seq_length, + 2 * units_sparse_to_dense + units_1 + units_2, + ] + ], + "same_as_train", # means that test-time shapes are same as train-time ones + ), + # ConcatenateSparseDense layer with only sparse features + ( + ConcatenateSparseDenseFeatures, + model_config_basic, + { + "feature_type": "arbitrary", + "feature_type_signature": [feature_signature_sparse_1], + }, + ([feature_sparse_sent_1],), + [[batch_size, 1, units_sparse_to_dense]], + "same_as_train", + ), + # ConcatenateSparseDense layer with only dense features + ( + ConcatenateSparseDenseFeatures, + model_config_basic, + { + "feature_type": "arbitrary", + "feature_type_signature": [feature_signature_dense_1], + }, + ([feature_dense_sent_1],), + [[batch_size, 1, units_1]], + "same_as_train", + ), + # FeatureCombining layer with sequence- and sentence-level features, dimension + # unifying + ( + RasaFeatureCombiningLayer, + model_config_basic, + {"attribute_signature": attribute_signature_basic}, + attribute_features_basic, + [ + [batch_size, max_seq_length + 1, units_concat], + [batch_size, max_seq_length + 1, 1], + ], + "same_as_train", + ), + # FeatureCombining layer with sequence- and sentence-level features, no + # dimension unifying + ( + RasaFeatureCombiningLayer, + model_config_basic, + { + "attribute_signature": { + SEQUENCE: [feature_signature_dense_1], + SENTENCE: [feature_signature_dense_1], + } + }, + ([feature_dense_seq_1], [feature_dense_sent_1], sequence_lengths), + [ + [batch_size, max_seq_length + 1, units_1], + [batch_size, max_seq_length + 1, 1], + ], + "same_as_train", + ), + # FeatureCombining layer with sentence-level features only + ( + RasaFeatureCombiningLayer, + model_config_basic, + { + "attribute_signature": { + "sequence": [], + "sentence": [feature_signature_dense_1], + } + }, + ([], [feature_dense_sent_1], sequence_lengths_empty), + [[batch_size, 1, units_1], [batch_size, 1, 1]], + "same_as_train", + ), + # FeatureCombining layer with sequence-level features only + ( + RasaFeatureCombiningLayer, + model_config_basic, + { + "attribute_signature": { + "sequence": [feature_signature_dense_1], + "sentence": [], + } + }, + ([feature_dense_seq_1], [], sequence_lengths), + [[batch_size, max_seq_length, units_1], [batch_size, max_seq_length, 1]], + "same_as_train", + ), + # Sequence layer with mixed features, hidden layers and transformer, doing MLM + ( + RasaSequenceLayer, + model_config_transformer_mlm, + {"attribute_signature": attribute_signature_basic}, + attribute_features_basic, + [ + [batch_size, max_seq_length + 1, units_transformer], + [batch_size, max_seq_length + 1, units_hidden_layer], + [batch_size, max_seq_length + 1, 1], + [batch_size, max_seq_length + 1, units_1], + [batch_size, max_seq_length + 1, 1], + [ + batch_size, + num_transformer_layers, + num_transformer_heads, + max_seq_length + 1, + max_seq_length + 1, + ], + ], + [ + [batch_size, max_seq_length + 1, units_transformer], + [batch_size, max_seq_length + 1, units_hidden_layer], + [batch_size, max_seq_length + 1, 1], + [0], + [0], + [ + batch_size, + num_transformer_layers, + num_transformer_heads, + max_seq_length + 1, + max_seq_length + 1, + ], + ], + ), + # Sequence layer with mixed features, hidden layers, no transformer, no MLM + ( + RasaSequenceLayer, + model_config_basic, + {"attribute_signature": attribute_signature_basic}, + attribute_features_basic, + [ + [batch_size, max_seq_length + 1, units_hidden_layer], + [batch_size, max_seq_length + 1, units_hidden_layer], + [batch_size, max_seq_length + 1, 1], + [0], + [0], + [0], + ], + "same_as_train", + ), + # Sequence layer with mixed features, no hidden layers, no transformer, no MLM + ( + RasaSequenceLayer, + model_config_basic_no_hidden_layers, + {"attribute_signature": attribute_signature_basic}, + attribute_features_basic, + [ + [batch_size, max_seq_length + 1, units_concat], + [batch_size, max_seq_length + 1, units_concat], + [batch_size, max_seq_length + 1, 1], + [0], + [0], + [0], + ], + "same_as_train", + ), + # Sequence layer with only sequence-level sparse features & MLM (to check the + # shape of token_ids) + ( + RasaSequenceLayer, + model_config_transformer_mlm, + { + "attribute_signature": { + SEQUENCE: [feature_signature_sparse_1], + SENTENCE: [], + } + }, + ([feature_sparse_seq_1], [], sequence_lengths), + [ + [batch_size, max_seq_length, units_transformer], + [batch_size, max_seq_length, units_hidden_layer], + [batch_size, max_seq_length, 1], + [batch_size, max_seq_length, 2], + [batch_size, max_seq_length, 1], + [ + batch_size, + num_transformer_layers, + num_transformer_heads, + max_seq_length, + max_seq_length, + ], + ], + [ + [batch_size, max_seq_length, units_transformer], + [batch_size, max_seq_length, units_hidden_layer], + [batch_size, max_seq_length, 1], + [0], + [0], + [ + batch_size, + num_transformer_layers, + num_transformer_heads, + max_seq_length, + max_seq_length, + ], + ], + ), + ], +) +def test_correct_output_shape( + layer_class: Type[tf.keras.layers.Layer], + model_config: Dict[Text, Any], + layer_args: Dict[Text, Any], + layer_inputs: List[List[Union[tf.SparseTensor, tf.Tensor]]], + expected_output_shapes_train: List[List[int]], + expected_output_shapes_test: Union[Text, List[List[int]]], +) -> None: + layer = layer_class(**layer_args, attribute=attribute_name, config=model_config) + + train_outputs = layer(layer_inputs, training=True) + if not isinstance(train_outputs, tuple): + train_outputs = [train_outputs] + for i, expected_shape in enumerate(expected_output_shapes_train): + assert train_outputs[i].shape == expected_shape + + if expected_output_shapes_test == "same_as_train": + expected_output_shapes_test = expected_output_shapes_train + test_outputs = layer(layer_inputs, training=False) + if not isinstance(test_outputs, tuple): + test_outputs = [test_outputs] + for i, expected_shape in enumerate(expected_output_shapes_test): + assert test_outputs[i].shape == expected_shape + + +@pytest.mark.parametrize( + "layer_class, layer_args", + [ + # ConcatenateSparseDense layer breaks on empty feature type signature + ( + ConcatenateSparseDenseFeatures, + {"feature_type": "arbitrary", "feature_type_signature": []}, + ), + # FeatureCombining layer breaks on empty attribute signature + ( + RasaFeatureCombiningLayer, + {"attribute_signature": {"sequence": [], "sentence": []}}, + ), + # Sequence layer breaks on no sequence-level features + ( + RasaSequenceLayer, + { + "attribute_signature": { + "sequence": [], + "sentence": [feature_dense_sent_1], + } + }, + ), + ], +) +def test_raises_exception_when_missing_features( + layer_class: Type[tf.keras.layers.Layer], layer_args: Dict[Text, Any] +) -> None: + with pytest.raises(TFLayerConfigException): + layer_class(**layer_args, attribute=attribute_name, config=model_config_basic) + + +def test_concat_sparse_dense_raises_exception_when_inconsistent_sparse_features() -> None: # noqa: E501 + with pytest.raises(TFLayerConfigException): + ConcatenateSparseDenseFeatures( + attribute=attribute_name, + feature_type=SEQUENCE, + feature_type_signature=[ + FeatureSignature(is_sparse=True, units=2, number_of_dimensions=3), + FeatureSignature(is_sparse=True, units=1, number_of_dimensions=3), + ], + config=model_config_basic, + ) + + +# Realistic feature signatures and features for checking exact outputs + +realistic_feature_signature_dense_1 = FeatureSignature( + is_sparse=False, units=1, number_of_dimensions=3 +) +realistic_feature_dense_seq_1 = tf.convert_to_tensor( + [[[10.0], [20.0], [30.0]], [[40.0], [50.0], [0.0]]], dtype=tf.float32 +) + +realistic_feature_signature_dense_2 = FeatureSignature( + is_sparse=False, units=2, number_of_dimensions=3 +) +realistic_feature_dense_seq_2 = tf.convert_to_tensor( + [[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], [[1.5, 2.5], [3.5, 4.5], [0.0, 0.0]]], + dtype=tf.float32, +) + +realistic_feature_signature_dense_3 = FeatureSignature( + is_sparse=False, units=3, number_of_dimensions=3 +) +realistic_feature_dense_sent_3 = tf.convert_to_tensor( + [[[0.1, 0.2, 0.3]], [[0.4, 0.5, 0.6]]], dtype=tf.float32 +) + +realistic_sequence_lengths = tf.convert_to_tensor([3, 2], dtype=tf.int32) +realistic_sequence_lengths_empty = tf.convert_to_tensor([0, 0], dtype=tf.int32) + + +def test_concat_sparse_dense_correct_output_for_dense_input() -> None: + layer = ConcatenateSparseDenseFeatures( + attribute=attribute_name, + feature_type=SEQUENCE, + feature_type_signature=[ + realistic_feature_signature_dense_1, + realistic_feature_signature_dense_2, + ], + config=dict( + model_config_basic, + # also activate all dropout to check that it has no effect on dense features + **{SPARSE_INPUT_DROPOUT: True, DENSE_INPUT_DROPOUT: True}, + ), + ) + outputs_expected = [ + [[10.0, 1.0, 2.0], [20.0, 3.0, 4.0], [30.0, 5.0, 6.0]], + [[40.0, 1.5, 2.5], [50.0, 3.5, 4.5], [0.0, 0.0, 0.0]], + ] + inputs = ([realistic_feature_dense_seq_1, realistic_feature_dense_seq_2],) + train_outputs = layer(inputs, training=True) + assert (train_outputs.numpy() == outputs_expected).all() + test_outputs = layer(inputs, training=False) + assert (test_outputs.numpy() == outputs_expected).all() + + +def test_concat_sparse_dense_applies_dropout_to_sparse_input() -> None: + layer_dropout_for_sparse = ConcatenateSparseDenseFeatures( + attribute=attribute_name, + feature_type=SEQUENCE, + feature_type_signature=[feature_signature_sparse_1, feature_signature_sparse_1], + config=dict(model_config_basic, **{SPARSE_INPUT_DROPOUT: True, DROP_RATE: 1.0}), + ) + + inputs = ([feature_sparse_seq_1, feature_sparse_seq_1],) + expected_outputs_train = tf.zeros( + (batch_size, max_seq_length, units_sparse_to_dense * 2) + ) + + train_outputs = layer_dropout_for_sparse(inputs, training=True) + assert np.allclose(train_outputs.numpy(), expected_outputs_train.numpy()) + + # We can't check exact output contents for sparse inputs but during test-time no + # dropout should be applied, hence the outputs should not be all zeros in this case + # (unlike at training time). + test_outputs = layer_dropout_for_sparse(inputs, training=False) + assert not np.allclose(test_outputs.numpy(), expected_outputs_train.numpy()) + + +def test_concat_sparse_dense_applies_dropout_to_sparse_densified_input() -> None: + layer_dropout_for_sparse_densified = ConcatenateSparseDenseFeatures( + attribute=attribute_name, + feature_type=SEQUENCE, + feature_type_signature=[feature_signature_sparse_1, feature_signature_sparse_1], + config=dict( + model_config_basic, **{DENSE_INPUT_DROPOUT: True, DROP_RATE: 0.99999999} + ), # keras dropout doesn't accept velues >= 1.0 + ) + + inputs = ([feature_sparse_seq_1, feature_sparse_seq_1],) + expected_outputs_train = tf.zeros( + (batch_size, max_seq_length, units_sparse_to_dense * 2) + ) + + train_outputs = layer_dropout_for_sparse_densified(inputs, training=True) + assert np.allclose(train_outputs.numpy(), expected_outputs_train.numpy()) + + # We can't check exact output contents for sparse inputs but during test-time no + # dropout should be applied, hence the outputs should not be all zeros in this case + # (unlike at training time). + test_outputs = layer_dropout_for_sparse_densified(inputs, training=False) + assert not np.allclose(test_outputs.numpy(), expected_outputs_train.numpy()) + + +@pytest.mark.parametrize( + "attribute_signature, inputs, expected_outputs_train, expected_outputs_test", + [ + # Both sequence- and sentence-level features, not unifying dimensions before + # concatenation + ( + { + SEQUENCE: [ + realistic_feature_signature_dense_1, + realistic_feature_signature_dense_2, + ], + SENTENCE: [realistic_feature_signature_dense_3], + }, + ( + [realistic_feature_dense_seq_1, realistic_feature_dense_seq_2], + [realistic_feature_dense_sent_3], + realistic_sequence_lengths, + ), + ( + np.array( + [ + [ + [10.0, 1.0, 2.0], + [20.0, 3.0, 4.0], + [30.0, 5.0, 6.0], + [0.1, 0.2, 0.3], + ], + [ + [40.0, 1.5, 2.5], + [50.0, 3.5, 4.5], + [0.4, 0.5, 0.6], + [0.0, 0.0, 0.0], + ], + ], + dtype=np.float32, + ), + np.array([[[1.0], [1.0], [1.0], [1.0]], [[1.0], [1.0], [1.0], [0.0]]]), + ), + "same_as_train", + ), + # Sequence-level features only + ( + { + SEQUENCE: [ + realistic_feature_signature_dense_1, + realistic_feature_signature_dense_2, + ], + SENTENCE: [], + }, + ( + [realistic_feature_dense_seq_1, realistic_feature_dense_seq_2], + [], + realistic_sequence_lengths, + ), + ( + np.array( + [ + [[10.0, 1.0, 2.0], [20.0, 3.0, 4.0], [30.0, 5.0, 6.0]], + [[40.0, 1.5, 2.5], [50.0, 3.5, 4.5], [0.0, 0.0, 0.0]], + ], + dtype=np.float32, + ), + np.array([[[1.0], [1.0], [1.0]], [[1.0], [1.0], [0.0]]]), + ), + "same_as_train", + ), + # Sentence-level features only + ( + {SEQUENCE: [], SENTENCE: [realistic_feature_signature_dense_3]}, + ([], [realistic_feature_dense_sent_3], realistic_sequence_lengths_empty), + (realistic_feature_dense_sent_3.numpy(), [[[1.0]], [[1.0]]]), + "same_as_train", + ), + ], +) +def test_feature_combining_correct_output( + attribute_signature: Dict[Text, List[FeatureSignature]], + inputs: List[List[Union[tf.SparseTensor, tf.Tensor]]], + expected_outputs_train: List[np.ndarray], + expected_outputs_test: Union[Text, List[np.ndarray]], +) -> None: + layer = RasaFeatureCombiningLayer( + attribute=attribute_name, + config=model_config_basic, + attribute_signature=attribute_signature, + ) + if expected_outputs_test == "same_as_train": + expected_outputs_test = expected_outputs_train + + train_outputs, train_mask_seq_sent = layer(inputs, training=True) + assert (train_outputs.numpy() == expected_outputs_train[0]).all() + assert (train_mask_seq_sent.numpy() == expected_outputs_train[1]).all() + + test_outputs, test_mask_seq_sent = layer(inputs, training=False) + assert (test_outputs.numpy() == expected_outputs_test[0]).all() + assert (test_mask_seq_sent.numpy() == expected_outputs_test[1]).all() + + +@pytest.mark.parametrize( + "attribute_signature, inputs, expected_outputs_train", + [ + # Both sequence- and sentence-level features + ( + { + SEQUENCE: [ + realistic_feature_signature_dense_1, + realistic_feature_signature_dense_2, + ], + SENTENCE: [realistic_feature_signature_dense_3], + }, + ( + [realistic_feature_dense_seq_1, realistic_feature_dense_seq_2], + [realistic_feature_dense_sent_3], + realistic_sequence_lengths, + ), + ( + np.array( + [ + [ + [10.0, 1.0, 2.0], + [20.0, 3.0, 4.0], + [30.0, 5.0, 6.0], + [0.1, 0.2, 0.3], + ], + [ + [40.0, 1.5, 2.5], + [50.0, 3.5, 4.5], + [0.4, 0.5, 0.6], + [0.0, 0.0, 0.0], + ], + ], + dtype=np.float32, + ), + np.array([[[1.0], [1.0], [1.0], [1.0]], [[1.0], [1.0], [1.0], [0.0]]]), + np.concatenate( + (realistic_feature_dense_seq_1, [[[0.0]], [[0.0]]]), axis=1 + ), + ), + ), + # Only sequence-level features + ( + { + SEQUENCE: [ + realistic_feature_signature_dense_1, + realistic_feature_signature_dense_2, + ], + SENTENCE: [], + }, + ( + [realistic_feature_dense_seq_1, realistic_feature_dense_seq_2], + [], + realistic_sequence_lengths, + ), + ( + np.array( + [ + [[10.0, 1.0, 2.0], [20.0, 3.0, 4.0], [30.0, 5.0, 6.0]], + [[40.0, 1.5, 2.5], [50.0, 3.5, 4.5], [0.0, 0.0, 0.0]], + ], + dtype=np.float32, + ), + np.array([[[1.0], [1.0], [1.0]], [[1.0], [1.0], [0.0]]]), + realistic_feature_dense_seq_1.numpy(), + ), + ), + ], +) +def test_sequence_layer_correct_output( + attribute_signature: Dict[Text, List[FeatureSignature]], + inputs: List[Union[tf.Tensor, List[Union[tf.SparseTensor, tf.Tensor]]]], + expected_outputs_train: List[np.ndarray], +) -> None: + layer = RasaSequenceLayer( + attribute=attribute_name, + # Use MLM but no transformer and no hidden layers. + config=dict(model_config_basic_no_hidden_layers, **{MASKED_LM: True}), + attribute_signature=attribute_signature, + ) + + # Training-time check + ( + seq_sent_features_expected, + mask_seq_sent_expected, + token_ids_expected, + ) = expected_outputs_train + (_, seq_sent_features, mask_seq_sent, token_ids, mlm_boolean_mask, _) = layer( + inputs, training=True + ) + assert (seq_sent_features.numpy() == seq_sent_features_expected).all() + assert (mask_seq_sent.numpy() == mask_seq_sent_expected).all() + assert (token_ids.numpy() == token_ids_expected).all() + assert mlm_boolean_mask.dtype == bool + # no masking at the padded position found in the shorter sequence + assert not mlm_boolean_mask[-1][-1][0] + # when sentence-level features are present, also ensure that no masking is done at + # sentence-level feature positions (determined by sequence lengths) + if len(attribute_signature[SENTENCE]) > 0: + assert not mlm_boolean_mask.numpy()[0][realistic_sequence_lengths.numpy()][0] + + # Test-time check + (seq_sent_features_expected, mask_seq_sent_expected, _) = expected_outputs_train + ( + transformer_outputs, + seq_sent_features, + mask_seq_sent, + token_ids, + mlm_boolean_mask, + _, + ) = layer(inputs, training=False) + # Check that transformer outputs match the combined features, i.e. that MLM wasn't + # applied + assert (transformer_outputs.numpy() == seq_sent_features_expected).all() + assert (seq_sent_features.numpy() == seq_sent_features_expected).all() + assert (mask_seq_sent.numpy() == mask_seq_sent_expected).all() + assert token_ids.numpy().size == 0 + assert mlm_boolean_mask.numpy().size == 0 + + +@pytest.mark.parametrize( + "new_sparse_feature_sizes, old_sparse_feature_sizes, feature_type, use_bias", + [ + ([10, 10, 10], [3, 2, 3], FEATURE_TYPE_SENTENCE, True), + ([10, 10, 10], [1, 5, 2], FEATURE_TYPE_SEQUENCE, False), + ([3, 3, 3], [3, 3, 3], FEATURE_TYPE_SEQUENCE, True), + ([8], [3], FEATURE_TYPE_SENTENCE, False), + ], +) +def test_replace_dense_for_sparse_layers( + new_sparse_feature_sizes: List[int], + old_sparse_feature_sizes: List[int], + feature_type: Text, + use_bias: bool, +): + """Tests if `DenseForSparse` layers are adjusted correctly.""" + output_units = 10 + kernel_initializer = tf.constant_initializer( + np.random.random((sum(old_sparse_feature_sizes), output_units)) + ) + layer = layers.DenseForSparse( + units=output_units, kernel_initializer=kernel_initializer, use_bias=use_bias + ) + layer.build(input_shape=sum(old_sparse_feature_sizes)) + + new_layer = RasaCustomLayer._replace_dense_for_sparse_layer( + layer_to_replace=layer, + new_sparse_feature_sizes=new_sparse_feature_sizes, + old_sparse_feature_sizes=old_sparse_feature_sizes, + attribute=TEXT, + feature_type=feature_type, + reg_lambda=0.02, + ) + new_layer.build(input_shape=sum(new_sparse_feature_sizes)) + + # check dimensions + assert new_layer.get_kernel().shape[0] == sum(new_sparse_feature_sizes) + + # check if bias tensor was preserved correctly + if use_bias: + assert np.array_equal(layer.get_bias().numpy(), new_layer.get_bias().numpy()) + else: + assert new_layer.get_bias() is None + + # check if the existing weights were preserved + chunk_index, new_chunk_index = 0, 0 + kernel, new_kernel = layer.get_kernel().numpy(), new_layer.get_kernel().numpy() + for old_size, new_size in zip(old_sparse_feature_sizes, new_sparse_feature_sizes): + chunk = kernel[chunk_index : chunk_index + old_size, :] + new_chunk = new_kernel[new_chunk_index : new_chunk_index + old_size, :] + assert np.array_equal(chunk, new_chunk) + chunk_index += old_size + new_chunk_index += new_size + + +@pytest.mark.parametrize( + "new_sparse_feature_sizes, old_sparse_feature_sizes", + [ + ( + { + TEXT: { + FEATURE_TYPE_SENTENCE: [10, 5], + FEATURE_TYPE_SEQUENCE: [5, 10, 15], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [5], FEATURE_TYPE_SENTENCE: []}, + }, + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 2], + FEATURE_TYPE_SEQUENCE: [3, 5, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [2], FEATURE_TYPE_SENTENCE: []}, + }, + ), + ( + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 2], + FEATURE_TYPE_SEQUENCE: [3, 5, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [2], FEATURE_TYPE_SENTENCE: []}, + }, + { + TEXT: { + FEATURE_TYPE_SENTENCE: [5, 2], + FEATURE_TYPE_SEQUENCE: [3, 5, 10], + }, + LABEL: {FEATURE_TYPE_SEQUENCE: [2], FEATURE_TYPE_SENTENCE: []}, + }, + ), + ], +) +def test_adjust_sparse_layers_for_incremental_training( + new_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], + old_sparse_feature_sizes: Dict[Text, Dict[Text, List[int]]], +): + """Tests if `adjust_sparse_layers_for_incremental_training` finds and updates + every `DenseForSparse` layer that has its sparse feature sizes increased.""" + + def init_sparse_to_dense_layer( + attribute, feature_type, input_size, output_size, reg_lambda + ): + kernel_initializer = tf.constant_initializer( + np.random.random((input_size, output_size)) + ) + layer = layers.DenseForSparse( + name=f"sparse_to_dense.{attribute}_{feature_type}", + kernel_initializer=kernel_initializer, + reg_lambda=reg_lambda, + units=output_size, + ) + layer.build(input_shape=input_size) + return layer + + units, reg_lambda = 10, 0.02 + bottom_custom_layer = RasaCustomLayer() + layer_text_sequence = init_sparse_to_dense_layer( + attribute=TEXT, + feature_type=FEATURE_TYPE_SEQUENCE, + input_size=sum(old_sparse_feature_sizes[TEXT][FEATURE_TYPE_SEQUENCE]), + output_size=units, + reg_lambda=reg_lambda, + ) + bottom_tf_layers = {"other_layer": object, "sparse_to_dense": layer_text_sequence} + bottom_custom_layer._tf_layers = bottom_tf_layers + middle_custom_layer = RasaCustomLayer() + layer_label_sequence = init_sparse_to_dense_layer( + attribute=LABEL, + feature_type=FEATURE_TYPE_SEQUENCE, + input_size=sum(old_sparse_feature_sizes[LABEL][FEATURE_TYPE_SEQUENCE]), + output_size=units, + reg_lambda=reg_lambda, + ) + middle_tf_layers = { + "other_layer": object, + "sparse_to_dense": layer_label_sequence, + "another_custom_layer": bottom_custom_layer, + } + middle_custom_layer._tf_layers = middle_tf_layers + top_custom_layer = RasaCustomLayer() + layer_text_sentence = init_sparse_to_dense_layer( + attribute=TEXT, + feature_type=FEATURE_TYPE_SENTENCE, + input_size=sum(old_sparse_feature_sizes[TEXT][FEATURE_TYPE_SENTENCE]), + output_size=units, + reg_lambda=reg_lambda, + ) + top_tf_layers = { + "other_layer": object, + "sparse_to_dense": layer_text_sentence, + "another_custom_layer": middle_custom_layer, + } + top_custom_layer._tf_layers = top_tf_layers + + top_custom_layer.adjust_sparse_layers_for_incremental_training( + new_sparse_feature_sizes=new_sparse_feature_sizes, + old_sparse_feature_sizes=old_sparse_feature_sizes, + reg_lambda=reg_lambda, + ) + custom_layers = [bottom_custom_layer, middle_custom_layer, top_custom_layer] + for custom_layer in custom_layers: + dense_layer = custom_layer._tf_layers["sparse_to_dense"] + layer_attribute = dense_layer.get_attribute() + layer_feature_type = dense_layer.get_feature_type() + layer_expected_size = sum( + new_sparse_feature_sizes[layer_attribute][layer_feature_type] + ) + dense_layer.build(input_shape=layer_expected_size) + layer_final_size = dense_layer.get_kernel().shape[0] + assert layer_attribute and layer_feature_type + assert layer_final_size == layer_expected_size diff --git a/tests/utils/tensorflow/test_tf_environment.py b/tests/utils/tensorflow/test_tf_environment.py new file mode 100644 index 0000000..3f30570 --- /dev/null +++ b/tests/utils/tensorflow/test_tf_environment.py @@ -0,0 +1,11 @@ +import pytest +from typing import Text, Dict +from rasa.utils.tensorflow.environment import _parse_gpu_config + + +@pytest.mark.parametrize( + "gpu_config_string, parsed_gpu_config", + [("0: 1024", {0: 1024}), ("0:1024, 1:2048", {0: 1024, 1: 2048})], +) +def test_gpu_config_parser(gpu_config_string: Text, parsed_gpu_config: Dict[int, int]): + assert _parse_gpu_config(gpu_config_string) == parsed_gpu_config diff --git a/tests/utils/tensorflow/test_transformer.py b/tests/utils/tensorflow/test_transformer.py new file mode 100644 index 0000000..b52570f --- /dev/null +++ b/tests/utils/tensorflow/test_transformer.py @@ -0,0 +1,10 @@ +import pytest + +from rasa.utils.tensorflow.transformer import MultiHeadAttention + + +def test_valid_transformer_size(): + mha = MultiHeadAttention(units=256, num_heads=4) + assert mha.units == 256 + with pytest.raises(SystemExit): + MultiHeadAttention(units=50, num_heads=4) diff --git a/tests/utils/test_common.py b/tests/utils/test_common.py new file mode 100644 index 0000000..10591c2 --- /dev/null +++ b/tests/utils/test_common.py @@ -0,0 +1,365 @@ +import json +import os +import logging +import logging.config +import sys +from pathlib import Path +from pytest import MonkeyPatch +from typing import Any, Text, Type +from unittest import mock + +import pytest +from pytest import LogCaptureFixture + +from rasa.core.agent import Agent + +from rasa.nlu.classifiers.diet_classifier import DIETClassifier +from rasa.shared.exceptions import RasaException +import rasa.utils.common +from rasa.utils.common import ( + RepeatedLogFilter, + find_unavailable_packages, + configure_logging_and_warnings, + configure_logging_from_file, + get_bool_env_variable, +) +import tests.conftest + + +@pytest.fixture(autouse=True) +def reset_logging() -> None: + manager = logging.root.manager + manager.disabled = logging.NOTSET + for logger in manager.loggerDict.values(): + if isinstance(logger, logging.Logger): + logger.setLevel(logging.NOTSET) + logger.propagate = True + logger.disabled = False + logger.filters.clear() + handlers = logger.handlers.copy() + for handler in handlers: + logger.removeHandler(handler) + + +def test_repeated_log_filter(): + log_filter = RepeatedLogFilter() + record1 = logging.LogRecord( + "rasa", logging.INFO, "/some/path.py", 42, "Super msg: %s", ("yes",), None + ) + record1_same = logging.LogRecord( + "rasa", logging.INFO, "/some/path.py", 42, "Super msg: %s", ("yes",), None + ) + record2_other_args = logging.LogRecord( + "rasa", logging.INFO, "/some/path.py", 42, "Super msg: %s", ("no",), None + ) + record3_other = logging.LogRecord( + "rasa", logging.INFO, "/some/path.py", 42, "Other msg", (), None + ) + assert log_filter.filter(record1) is True + assert log_filter.filter(record1_same) is False # same log + assert log_filter.filter(record2_other_args) is True + assert log_filter.filter(record3_other) is True + assert log_filter.filter(record1) is True # same as before, but not repeated + + +async def test_call_maybe_coroutine_with_async() -> Any: + expected = 5 + + async def my_function(): + return expected + + actual = await rasa.utils.common.call_potential_coroutine(my_function()) + + assert actual == expected + + +async def test_call_maybe_coroutine_with_sync() -> Any: + expected = 5 + + def my_function(): + return expected + + actual = await rasa.utils.common.call_potential_coroutine(my_function()) + + assert actual == expected + + +def test_dir_size_empty(tmp_path: Path): + assert rasa.utils.common.directory_size_in_mb(tmp_path) == 0 + + +def test_dir_size_with_single_file(tmp_path: Path): + tests.conftest.create_test_file_with_size(tmp_path, 5) + assert rasa.utils.common.directory_size_in_mb(tmp_path) == pytest.approx(5) + + +def test_dir_size_with_multiple_files(tmp_path: Path): + tests.conftest.create_test_file_with_size(tmp_path, 2) + tests.conftest.create_test_file_with_size(tmp_path, 3) + assert rasa.utils.common.directory_size_in_mb(tmp_path) == pytest.approx(5) + + +def test_dir_size_with_sub_directory(tmp_path: Path): + subdir = tmp_path / "sub" + subdir.mkdir() + + tests.conftest.create_test_file_with_size(tmp_path, 2) + tests.conftest.create_test_file_with_size(subdir, 3) + + assert rasa.utils.common.directory_size_in_mb(tmp_path) == pytest.approx(5) + + +@pytest.mark.parametrize("create_destination", [True, False]) +def test_copy_directory_with_created_destination( + tmp_path: Path, create_destination: bool +): + source = tmp_path / "source" + source.mkdir() + + sub_dir_name = "sub" + sub_dir = source / sub_dir_name + sub_dir.mkdir() + + file_in_sub_dir_name = "file.txt" + file_in_sub_dir = sub_dir / file_in_sub_dir_name + file_in_sub_dir.touch() + + test_file_name = "some other file.txt" + test_file = source / test_file_name + test_file.touch() + + destination = tmp_path / "destination" + if create_destination: + destination.mkdir() + + rasa.utils.common.copy_directory(source, destination) + + assert destination.is_dir() + assert (destination / test_file_name).is_file() + assert (destination / sub_dir_name).is_dir() + assert (destination / sub_dir_name / file_in_sub_dir_name).is_file() + + +def test_copy_directory_with_non_empty_destination(tmp_path: Path): + destination = tmp_path / "destination" + destination.mkdir() + (destination / "some_file.json").touch() + + with pytest.raises(ValueError): + rasa.utils.common.copy_directory(tmp_path, destination) + + +def test_find_unavailable_packages(): + unavailable = find_unavailable_packages( + ["my_made_up_package_name", "io", "foo_bar", "foo_bar"] + ) + assert unavailable == {"my_made_up_package_name", "foo_bar"} + + +@pytest.mark.parametrize( + "clazz,module_path", + [ + (Path, "pathlib.Path"), + (Agent, "rasa.core.agent.Agent"), + (DIETClassifier, "rasa.nlu.classifiers.diet_classifier.DIETClassifier"), + ], +) +def test_module_path_from_class(clazz: Type, module_path: Text): + assert rasa.utils.common.module_path_from_class(clazz) == module_path + + +def test_override_defaults(): + defaults = {"nested-dict": {"key1": "value1", "key2": "value2"}} + custom = {"nested-dict": {"key2": "override-value2"}} + + updated_config = rasa.utils.common.override_defaults(defaults, custom) + + expected_config = {"nested-dict": {"key1": "value1", "key2": "override-value2"}} + assert updated_config == expected_config + + +def test_cli_missing_log_level_default_used(): + """Test CLI without log level parameter or env var uses default.""" + configure_logging_and_warnings() + rasa_logger = logging.getLogger("rasa") + # Default log level is currently INFO + assert rasa_logger.level == logging.INFO + matplotlib_logger = logging.getLogger("matplotlib") + # Default log level for libraries is currently ERROR + assert matplotlib_logger.level == logging.ERROR + + +def test_cli_log_level_debug_used(): + """Test CLI with log level uses for rasa logger whereas libraries stay default.""" + configure_logging_and_warnings(logging.DEBUG) + rasa_logger = logging.getLogger("rasa") + assert rasa_logger.level == logging.DEBUG + matplotlib_logger = logging.getLogger("matplotlib") + # Default log level for libraries is currently ERROR + assert matplotlib_logger.level == logging.ERROR + + +@mock.patch.dict(os.environ, {"LOG_LEVEL": "WARNING"}) +def test_cli_log_level_overrides_env_var_used(): + """Test CLI log level has precedence over env var.""" + configure_logging_and_warnings(logging.DEBUG) + rasa_logger = logging.getLogger("rasa") + assert rasa_logger.level == logging.DEBUG + matplotlib_logger = logging.getLogger("matplotlib") + # Default log level for libraries is currently ERROR + assert matplotlib_logger.level == logging.ERROR + + +@mock.patch.dict(os.environ, {"LOG_LEVEL": "WARNING", "LOG_LEVEL_MATPLOTLIB": "INFO"}) +def test_cli_missing_log_level_env_var_used(): + """Test CLI without log level uses env var for both rasa and libraries.""" + configure_logging_and_warnings() + rasa_logger = logging.getLogger("rasa") + assert rasa_logger.level == logging.WARNING + matplotlib_logger = logging.getLogger("matplotlib") + + assert matplotlib_logger.level == logging.INFO + + +def test_cli_valid_logging_configuration() -> None: + logging_config_file = "data/test_logging_config_files/test_logging_config.yml" + configure_logging_from_file(logging_config_file=logging_config_file) + rasa_logger = logging.getLogger("rasa") + + handlers = rasa_logger.handlers + assert len(handlers) == 1 + assert isinstance(handlers[0], logging.FileHandler) + assert "test_handler" == rasa_logger.handlers[0].name + + logging_message = "This is a test info log." + rasa_logger.info(logging_message) + + handler_filename = handlers[0].baseFilename + assert Path(handler_filename).exists() + + with open(handler_filename, "r") as logs: + data = logs.readlines() + logs_dict = json.loads(data[-1]) + assert logs_dict.get("message") == logging_message + + for key in ["time", "name", "levelname"]: + assert key in logs_dict.keys() + + +@pytest.mark.parametrize( + "logging_config_file", + [ + "data/test_logging_config_files/test_missing_required_key_invalid_config.yml", + "data/test_logging_config_files/test_invalid_value_for_level_in_config.yml", + "data/test_logging_config_files/test_invalid_handler_key_in_config.yml", + ], +) +def test_cli_invalid_logging_configuration( + logging_config_file: Text, caplog: LogCaptureFixture +) -> None: + with caplog.at_level(logging.DEBUG): + configure_logging_from_file(logging_config_file=logging_config_file) + + assert ( + f"The logging config file {logging_config_file} could not be applied " + f"because it failed validation against the built-in Python " + f"logging schema." in caplog.text + ) + + +@pytest.mark.skipif( + sys.version_info.minor == 7, reason="no error is raised with python 3.7" +) +def test_cli_invalid_format_value_in_config(caplog: LogCaptureFixture) -> None: + logging_config_file = ( + "data/test_logging_config_files/test_invalid_format_value_in_config.yml" + ) + + with caplog.at_level(logging.DEBUG): + configure_logging_from_file(logging_config_file=logging_config_file) + + assert ( + f"The logging config file {logging_config_file} could not be applied " + f"because it failed validation against the built-in Python " + f"logging schema." in caplog.text + ) + + +@pytest.mark.skipif( + sys.version_info.minor in [9, 10], reason="no error is raised with python 3.9" +) +def test_cli_non_existent_handler_id_in_config(caplog: LogCaptureFixture) -> None: + logging_config_file = ( + "data/test_logging_config_files/test_non_existent_handler_id.yml" + ) + + with caplog.at_level(logging.DEBUG): + configure_logging_from_file(logging_config_file=logging_config_file) + + assert ( + f"The logging config file {logging_config_file} could not be applied " + f"because it failed validation against the built-in Python " + f"logging schema." in caplog.text + ) + + +@pytest.mark.parametrize( + "env_name, env_value, default_value, expected", + [ + ("SOME_VAR", "False", False, False), + ("SOME_VAR", "false", False, False), + ("SOME_VAR", "False", True, False), + ("SOME_VAR", "false", True, False), + ("SOME_VAR", "0", False, False), + ("SOME_VAR", "0", True, False), + ("SOME_VAR", "True", False, True), + ("SOME_VAR", "true", False, True), + ("SOME_VAR", "true", True, True), + ("SOME_VAR", "True", True, True), + ("SOME_VAR", "1", False, True), + ("SOME_VAR", "1", True, True), + ], +) +def test_get_bool_env_variable( + env_name, env_value, default_value, expected, monkeypatch: MonkeyPatch +): + monkeypatch.setenv(env_name, env_value) + result = get_bool_env_variable(env_name, default_value) + + assert result is expected + + +@pytest.mark.parametrize( + "env_name, default_value, expected", + [ + ("SOME_VAR", True, True), + ("SOME_VAR", False, False), + ], +) +def test_get_bool_env_variable_not_set( + env_name, default_value, expected, monkeypatch: MonkeyPatch +): + result = get_bool_env_variable(env_name, default_value) + + assert result is expected + + +@pytest.mark.parametrize( + "env_name, env_value, default_value", + [ + ("SOME_VAR", "ffalse", False), + ("SOME_VAR", "ffalse", True), + ("SOME_VAR", "ttrue", False), + ("SOME_VAR", "ttrue", True), + ("SOME_VAR", "11", True), + ("SOME_VAR", "00", True), + ("SOME_VAR", "01", True), + ], +) +def test_get_bool_env_variable_with_invalid_value( + env_name, env_value, default_value, monkeypatch: MonkeyPatch +): + monkeypatch.setenv(env_name, env_value) + + with pytest.raises(RasaException): + get_bool_env_variable(env_name, default_value) diff --git a/tests/utils/test_endpoints.py b/tests/utils/test_endpoints.py new file mode 100644 index 0000000..711f2fd --- /dev/null +++ b/tests/utils/test_endpoints.py @@ -0,0 +1,234 @@ +import structlog +from pathlib import Path +from typing import Text, Optional, Union +from unittest.mock import Mock + +import pytest +from aioresponses import aioresponses + +from rasa.shared.exceptions import FileNotFoundException +from tests.utilities import latest_request, json_of_latest_request +import rasa.utils.endpoints as endpoint_utils + + +@pytest.mark.parametrize( + "base, subpath, expected_result", + [ + ("https://example.com", None, "https://example.com"), + ("https://example.com/test", None, "https://example.com/test"), + ("https://example.com/", None, "https://example.com/"), + ("https://example.com/", "test", "https://example.com/test"), + ("https://example.com/", "test/", "https://example.com/test/"), + ( + "http://duckling.rasa.com:8000", + "/parse", + "http://duckling.rasa.com:8000/parse", + ), + ( + "http://duckling.rasa.com:8000/", + "/parse", + "http://duckling.rasa.com:8000/parse", + ), + ], +) +def test_concat_url(base, subpath, expected_result): + assert endpoint_utils.concat_url(base, subpath) == expected_result + + +def test_warning_for_base_paths_with_trailing_slash(): + test_path = "base/" + with structlog.testing.capture_logs() as caplog: + assert endpoint_utils.concat_url(test_path, None) == test_path + + assert len(caplog) == 1 + assert caplog[0]["event"] == "endpoint.concat_url.trailing_slash" + assert caplog[0]["log_level"] == "debug" + + +async def test_endpoint_config(): + with aioresponses() as mocked: + endpoint = endpoint_utils.EndpointConfig( + "https://example.com/", + params={"A": "B"}, + headers={"X-Powered-By": "Rasa"}, + basic_auth={"username": "user", "password": "pass"}, + token="mytoken", + token_name="letoken", + type="redis", + port=6379, + db=0, + password="password", + timeout=30000, + ) + + mocked.post( + "https://example.com/test?A=B&P=1&letoken=mytoken", + payload={"ok": True}, + repeat=True, + status=200, + ) + + await endpoint.request( + "post", + subpath="test", + content_type="application/text", + json={"c": "d"}, + params={"P": "1"}, + ) + + r = latest_request( + mocked, "post", "https://example.com/test?A=B&P=1&letoken=mytoken" + ) + + assert r + + assert json_of_latest_request(r) == {"c": "d"} + assert r[-1].kwargs.get("params", {}).get("A") == "B" + assert r[-1].kwargs.get("params", {}).get("P") == "1" + assert r[-1].kwargs.get("params", {}).get("letoken") == "mytoken" + + # unfortunately, the mock library won't report any headers stored on + # the session object, so we need to verify them separately + async with endpoint.session() as s: + assert s._default_headers.get("X-Powered-By") == "Rasa" + assert s._default_auth.login == "user" + assert s._default_auth.password == "pass" + + +async def test_endpoint_config_with_cafile(tmp_path: Path): + cafile = "data/test_endpoints/cert.pem" + + with aioresponses() as mocked: + endpoint = endpoint_utils.EndpointConfig( + "https://example.com/", cafile=str(cafile) + ) + + mocked.post("https://example.com/", status=200) + + await endpoint.request("post") + + request = latest_request(mocked, "post", "https://example.com/")[-1] + + ssl_context = request.kwargs["ssl"] + certs = ssl_context.get_ca_certs() + assert certs[0]["subject"][4][0] == ("organizationalUnitName", "rasa") + + +async def test_endpoint_config_with_non_existent_cafile(tmp_path: Path): + cafile = "data/test_endpoints/no_file.pem" + + endpoint = endpoint_utils.EndpointConfig("https://example.com/", cafile=str(cafile)) + + with pytest.raises(FileNotFoundException): + await endpoint.request("post") + + +def test_endpoint_config_default_token_name(): + test_data = {"url": "http://test", "token": "token"} + + actual = endpoint_utils.EndpointConfig.from_dict(test_data) + + assert actual.token_name == "token" + + +def test_endpoint_config_custom_token_name(): + test_data = {"url": "http://test", "token": "token", "token_name": "test_token"} + + actual = endpoint_utils.EndpointConfig.from_dict(test_data) + + assert actual.token_name == "test_token" + + +async def test_request_non_json_response(): + with aioresponses() as mocked: + endpoint = endpoint_utils.EndpointConfig("https://example.com/") + + mocked.post( + "https://example.com/test", + payload="ok", + content_type="application/text", + status=200, + ) + + response = await endpoint.request("post", subpath="test") + + assert not response + + +@pytest.mark.parametrize( + "filename, endpoint_type", + [("data/test_endpoints/example_endpoints.yml", "tracker_store")], +) +def test_read_endpoint_config(filename: Text, endpoint_type: Text): + conf = endpoint_utils.read_endpoint_config(filename, endpoint_type) + assert isinstance(conf, endpoint_utils.EndpointConfig) + + +@pytest.mark.parametrize( + "endpoint_type, cafile", + [("action_endpoint", "./some_test_file"), ("tracker_store", None)], +) +def test_read_endpoint_config_with_cafile(endpoint_type: Text, cafile: Optional[Text]): + conf = endpoint_utils.read_endpoint_config( + "data/test_endpoints/example_endpoints.yml", endpoint_type + ) + assert conf.cafile == cafile + + +@pytest.mark.parametrize( + "filename, endpoint_type", + [ + ("", "tracker_store"), + ("data/test_endpoints/example_endpoints.yml", "stuff"), + ("data/test_endpoints/example_endpoints.yml", "empty"), + ("/unknown/path.yml", "tracker_store"), + ], +) +def test_read_endpoint_config_not_found(filename: Text, endpoint_type: Text): + conf = endpoint_utils.read_endpoint_config(filename, endpoint_type) + assert conf is None + + +@pytest.mark.parametrize( + "value, default, expected_result", + [ + (None, True, True), + (False, True, False), + ("false", True, False), + ("true", False, True), + ], +) +def test_bool_arg( + value: Optional[Union[bool, str]], default: bool, expected_result: bool +): + request = Mock() + request.args = {} + if value is not None: + request.args = {"key": value} + assert endpoint_utils.bool_arg(request, "key", default) == expected_result + + +@pytest.mark.parametrize( + "value, default, expected_result", + [(None, 0.5, 0.5), (0.5, None, 0.5), ("0.5", 0, 0.5), ("a", 0.5, 0.5)], +) +def test_float_arg( + value: Optional[Union[float, str]], default: float, expected_result: float +): + request = Mock() + request.args = {} + if value is not None: + request.args = {"key": value} + assert endpoint_utils.float_arg(request, "key", default) == expected_result + + +@pytest.mark.parametrize( + "value, default, expected_result", + [(None, 0, 0), (1, 0, 1), ("1", 0, 1), ("a", 0, 0)], +) +def test_int_arg(value: Optional[Union[int, str]], default: int, expected_result: int): + request = Mock() + request.args = {} + if value is not None: + request.args = {"key": value} + assert endpoint_utils.int_arg(request, "key", default) == expected_result diff --git a/tests/utils/test_io.py b/tests/utils/test_io.py new file mode 100644 index 0000000..3042b11 --- /dev/null +++ b/tests/utils/test_io.py @@ -0,0 +1,135 @@ +import pytest +from _pytest.tmpdir import TempPathFactory +from prompt_toolkit.document import Document +from prompt_toolkit.validation import ValidationError + +import rasa.shared.utils.io +import rasa.utils.io as io_utils + + +@pytest.mark.parametrize("actual_path", ["", "file.json", "file"]) +def test_file_path_validator_with_invalid_paths(actual_path): + test_error_message = actual_path + + validator = io_utils.file_type_validator([".yml"], test_error_message) + + document = Document(actual_path) + with pytest.raises(ValidationError) as e: + validator.validate(document) + + assert e.value.message == test_error_message + + +@pytest.mark.parametrize("actual_path", ["domain.yml", "lala.yaml"]) +def test_file_path_validator_with_valid_paths(actual_path): + validator = io_utils.file_type_validator([".yml", ".yaml"], "error message") + + document = Document(actual_path) + # If the path is valid there shouldn't be an exception + assert validator.validate(document) is None + + +@pytest.mark.parametrize("user_input", ["", " ", "\t", "\n"]) +def test_non_empty_text_validator_with_empty_input(user_input): + test_error_message = "enter something" + + validator = io_utils.not_empty_validator(test_error_message) + + document = Document(user_input) + with pytest.raises(ValidationError) as e: + validator.validate(document) + + assert e.value.message == test_error_message + + +@pytest.mark.parametrize("user_input", ["utter_greet", "greet", "Hi there!"]) +def test_non_empty_text_validator_with_valid_input(user_input): + validator = io_utils.not_empty_validator("error message") + + document = Document(user_input) + # If there is input there shouldn't be an exception + assert validator.validate(document) is None + + +def test_create_validator_from_callable(): + def is_valid(user_input) -> None: + return user_input == "this passes" + + error_message = "try again" + + validator = io_utils.create_validator(is_valid, error_message) + + document = Document("this passes") + assert validator.validate(document) is None + + document = Document("this doesn't") + with pytest.raises(ValidationError) as e: + validator.validate(document) + + assert e.value.message == error_message + + +def test_empty_directories_are_equal(tmp_path_factory: TempPathFactory): + dir1 = tmp_path_factory.mktemp("dir1") + dir2 = tmp_path_factory.mktemp("dir2") + + assert rasa.utils.io.are_directories_equal(dir1, dir2) + + +def test_directories_are_equal(tmp_path_factory: TempPathFactory): + dir1 = tmp_path_factory.mktemp("dir1") + (dir1 / "file.txt").write_text("Hello!") + + dir2 = tmp_path_factory.mktemp("dir2") + (dir2 / "file.txt").write_text("Hello!") + + assert rasa.utils.io.are_directories_equal(dir1, dir2) + + +def test_directories_are_equal_sub_dir(tmp_path_factory: TempPathFactory): + dir1 = tmp_path_factory.mktemp("dir1") + (dir1 / "dir").mkdir() + (dir1 / "dir" / "file.txt").write_text("Hello!") + + dir2 = tmp_path_factory.mktemp("dir2") + (dir2 / "dir").mkdir() + (dir2 / "dir" / "file.txt").write_text("Hello!") + + assert rasa.utils.io.are_directories_equal(dir1, dir2) + + +def test_directories_are_equal_different_file_content( + tmp_path_factory: TempPathFactory, +): + dir1 = tmp_path_factory.mktemp("dir1") + (dir1 / "file.txt").write_text("Hello!") + + dir2 = tmp_path_factory.mktemp("dir2") + (dir2 / "file.txt").write_text("Bye!") + + assert not rasa.utils.io.are_directories_equal(dir1, dir2) + + +def test_directories_are_equal_extra_file(tmp_path_factory: TempPathFactory): + dir1 = tmp_path_factory.mktemp("dir1") + (dir1 / "file.txt").write_text("Hello!") + + dir2 = tmp_path_factory.mktemp("dir2") + (dir2 / "file.txt").write_text("Hello!") + (dir2 / "file2.txt").touch() + + assert not rasa.utils.io.are_directories_equal(dir1, dir2) + + +def test_directories_are_equal_different_file_content_sub_dir( + tmp_path_factory: TempPathFactory, +): + dir1 = tmp_path_factory.mktemp("dir1") + (dir1 / "dir").mkdir() + (dir1 / "dir" / "file.txt").write_text("Hello!") + + dir2 = tmp_path_factory.mktemp("dir2") + (dir2 / "dir").mkdir() + (dir2 / "dir" / "file.txt").write_text("Bye!") + + assert not rasa.utils.io.are_directories_equal(dir1, dir2) diff --git a/tests/utils/test_plotting.py b/tests/utils/test_plotting.py new file mode 100644 index 0000000..a0428b7 --- /dev/null +++ b/tests/utils/test_plotting.py @@ -0,0 +1,151 @@ +from typing import List +import rasa.utils.plotting +import numpy as np +import pytest + + +@pytest.mark.parametrize( + "data, num_bins, expected_bins", + [ + # We write `n + 1` to highlight that we include `n` + ([[1, 3, 8], [2, 3, 3]], 7, list(range(1, 9 + 1))), + ([[3, 8], [2, 3, 3]], 6, list(range(2, 9 + 1))), + ([[3, 7], [2, 3, 3]], 5, list(range(2, 8 + 1))), + ([[3.0, 7.0], [3.0, 7.0]], 2, [3.0, 5.0, 7.0, 9.0]), + ], +) +def test_paired_histogram_specification_bins( + data: List[List[float]], num_bins: int, expected_bins: List[float] +): + """Bin list should run from the lowest data value to the highest + bin_width""" + for density in [False, True]: + bins, _, _, _ = rasa.utils.plotting._extract_paired_histogram_specification( + data, num_bins=num_bins, density=density, x_pad_fraction=0, y_pad_fraction=0 + ) + assert np.all(bins == expected_bins) + + +@pytest.mark.parametrize("bad_data", [([[]]), ([[], []])]) +def test_paired_histogram_specification_bins_raises(bad_data: List): + """`_extract_paired_histogram_specification` raises a ValueError on empty data""" + for density in [False, True]: + with pytest.raises(ValueError): + rasa.utils.plotting._extract_paired_histogram_specification( + bad_data, + num_bins=2, + density=density, + x_pad_fraction=0, + y_pad_fraction=0, + ) + + +@pytest.mark.parametrize("bad_data", [([[]]), ([[], []])]) +def test_plot_paired_histogram_warns_on_bad_data(bad_data: List): + """Empty data shouldn't raise an error.""" + for density in [False, True]: + with pytest.warns( + UserWarning, match=r"Unable to plot paired histogram 'TITLE': .*" + ): + rasa.utils.plotting.plot_paired_histogram( + bad_data, title="TITLE", density=density + ) + + +@pytest.mark.parametrize( + "data, num_bins, density, expected_histograms", + [ + ( + [[1, 3, 8], [2, 3, 3]], + 7, + False, + [[1, 0, 1, 0, 0, 0, 0, 1], [0, 1, 2, 0, 0, 0, 0, 0]], + ), + ( + [[1, 3, 8], [2, 3, 3]], + 7, + True, + [[1 / 3, 0, 1 / 3, 0, 0, 0, 0, 1 / 3], [0, 1 / 3, 2 / 3, 0, 0, 0, 0, 0]], + ), + ([[3.0, 7.0], [3.0, 7.0]], 2, False, [[1, 0, 1], [1, 0, 1]]), + ([[3.0, 7.0], [3.0, 7.0]], 2, True, [[1 / 4, 0, 1 / 4], [1 / 4, 0, 1 / 4]]), + ([[3.0, 8.0], [3.0, 8.0]], 2, True, [[1 / 5, 0, 1 / 5], [1 / 5, 0, 1 / 5]]), + ([[3.0, -1.0], [3.0, 7.0]], 4, False, [[1, 0, 1, 0, 0], [0, 0, 1, 0, 1]]), + ( + [[3.0, -1.0], [3.0, 7.0]], + 4, + True, + [[1 / 4, 0, 1 / 4, 0, 0], [0, 0, 1 / 4, 0, 1 / 4]], + ), + ([[3.0, 7.0], [3.0, 7.0, 8.5]], 2, False, [[1, 1, 0], [1, 1, 1]]), + ], +) +def test_paired_histogram_specification_histograms( + data: List[List[float]], + num_bins: int, + density: bool, + expected_histograms: List[List[float]], +): + _, histograms, _, _ = rasa.utils.plotting._extract_paired_histogram_specification( + data, num_bins=num_bins, density=density, x_pad_fraction=0, y_pad_fraction=0 + ) + assert np.all(histograms[0] == expected_histograms[0]) + assert np.all(histograms[1] == expected_histograms[1]) + + +@pytest.mark.parametrize( + "data, num_bins, density, x_pad_fraction, expected_ranges", + [ + ([[1, 3, 8], [2, 3, 3]], 100, False, 0.0, [1.0, 2.0]), + ([[1, 3, 8], [2, 3, 3, 3, 3]], 100, False, 0.0, [1.0, 4.0]), + ([[1, 3, 8], [2, 3, 3]], 7, True, 0.0, [2 / 3, 2 / 3]), + ([[1, 3, 8], [2, 3, 3]], 100, False, 1.0, [2.0, 4.0]), + ([[1, 3, 8], [2, 3, 3, 3, 3]], 100, False, 1.0, [2.0, 8.0]), + ([[1, 3, 8], [2, 3, 3]], 7, True, 1.0, [4 / 3, 4 / 3]), + ], +) +def test_paired_histogram_specification_x_ranges( + data: List[List[float]], + num_bins: int, + density: bool, + x_pad_fraction: float, + expected_ranges: List[float], +): + _, _, x_ranges, _ = rasa.utils.plotting._extract_paired_histogram_specification( + data, + num_bins=num_bins, + density=density, + x_pad_fraction=x_pad_fraction, + y_pad_fraction=0, + ) + assert np.all(x_ranges == expected_ranges) + + +@pytest.mark.parametrize( + "data, num_bins, y_pad_fraction, expected_range", + [ + ([[1, 3, 8], [2, 3, 3]], 7, 0.0, [0.5, 8.5]), + ([[1, 3, 8], [2, 3, 3, 3, 3]], 7, 0.0, [0.5, 8.5]), + ([[1, 3, 8], [2, 3, 3]], 7, 1.0, [-0.5, 9.5]), + ([[1, 3, 8], [2, 3, 3, 3, 3]], 7, 1.0, [-0.5, 9.5]), + ], +) +def test_paired_histogram_specification_y_range( + data: List[List[float]], + num_bins: int, + y_pad_fraction: float, + expected_range: List[float], +): + for density in [False, True]: + ( + _, + histograms, + _, + y_range, + ) = rasa.utils.plotting._extract_paired_histogram_specification( + data, + num_bins=num_bins, + density=density, + x_pad_fraction=0, + y_pad_fraction=y_pad_fraction, + ) + assert np.all(list(y_range) == expected_range) diff --git a/tests/utils/test_train_utils.py b/tests/utils/test_train_utils.py new file mode 100644 index 0000000..1e9f45c --- /dev/null +++ b/tests/utils/test_train_utils.py @@ -0,0 +1,357 @@ +from typing import Any, Dict, List + +import numpy as np +import pytest +from typing import Text + +import rasa.utils.train_utils as train_utils +from rasa.nlu.constants import NUMBER_OF_SUB_TOKENS +from rasa.nlu.tokenizers.tokenizer import Token +from rasa.shared.nlu.constants import ( + SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE, + SPLIT_ENTITIES_BY_COMMA, +) +from rasa.utils.tensorflow.constants import ( + MODEL_CONFIDENCE, + RANKING_LENGTH, + RENORMALIZE_CONFIDENCES, + SIMILARITY_TYPE, + LOSS_TYPE, + COSINE, + SOFTMAX, + INNER, + CROSS_ENTROPY, + MARGIN, + AUTO, + TOLERANCE, + CHECKPOINT_MODEL, + EVAL_NUM_EPOCHS, + EVAL_NUM_EXAMPLES, + EPOCHS, +) +from rasa.shared.exceptions import InvalidConfigException + + +def test_align_token_features(): + tokens = [ + Token("This", 0, data={NUMBER_OF_SUB_TOKENS: 1}), + Token("is", 5, data={NUMBER_OF_SUB_TOKENS: 1}), + Token("a", 8, data={NUMBER_OF_SUB_TOKENS: 1}), + Token("sentence", 10, data={NUMBER_OF_SUB_TOKENS: 2}), + Token("embedding", 19, data={NUMBER_OF_SUB_TOKENS: 4}), + ] + + seq_dim = sum(t.get(NUMBER_OF_SUB_TOKENS) for t in tokens) + token_features = np.random.rand(1, seq_dim, 64) + + actual_features = train_utils.align_token_features([tokens], token_features) + + assert np.all(actual_features[0][0] == token_features[0][0]) + assert np.all(actual_features[0][1] == token_features[0][1]) + assert np.all(actual_features[0][2] == token_features[0][2]) + # sentence is split into 2 sub-tokens + assert np.all(actual_features[0][3] == np.mean(token_features[0][3:5], axis=0)) + # embedding is split into 4 sub-tokens + assert np.all(actual_features[0][4] == np.mean(token_features[0][5:10], axis=0)) + + +@pytest.mark.parametrize( + ( + "input_values, ranking_length, renormalize, possible_output_values, " + " resulting_ranking_length" + ), + [ + # keep the top 2 + ([0.1, 0.4, 0.01], 2, False, [[0.1, 0.4, 0.0]], 2), + # normalize top 2 + ([0.1, 0.4, 0.01], 2, True, [[0.2, 0.8, 0.0]], 2), + # 2 possible values that could be excluded + ([0.1, 0.4, 0.1], 2, True, [[0.0, 0.8, 0.2], [0.2, 0.8, 0.0]], 2), + # ranking_length > num_confidences => ranking_length := num_confidences + ([0.1, 0.3, 0.2], 5, False, [[0.1, 0.3, 0.2]], 3), + # ranking_length > num_confidences => ranking_length := num_confidences + ([0.1, 0.3, 0.1], 5, True, [[0.1, 0.3, 0.1]], 3), + # ranking_length == 0 => ranking_length := num_confidences + ([0.1, 0.3, 0.1], 0, True, [[0.1, 0.3, 0.1]], 3), + ], +) +def test_rank_and_mask( + input_values: List[float], + ranking_length: int, + possible_output_values: List[List[float]], + renormalize: bool, + resulting_ranking_length: int, +): + confidences = np.array(input_values) + indices, modified_confidences = train_utils.rank_and_mask( + confidences=confidences, ranking_length=ranking_length, renormalize=renormalize + ) + assert any( + np.allclose(modified_confidences, np.array(possible_output)) + for possible_output in possible_output_values + ) + assert np.allclose( + sorted(input_values, reverse=True)[:resulting_ranking_length], + confidences[indices], + ) + + +@pytest.mark.parametrize( + "split_entities_config, expected_initialized_config", + [ + ( + SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE, + {SPLIT_ENTITIES_BY_COMMA: SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE}, + ), + ( + {"address": False, "ingredients": True}, + { + "address": False, + "ingredients": True, + SPLIT_ENTITIES_BY_COMMA: SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE, + }, + ), + ], +) +def test_init_split_entities_config( + split_entities_config: Any, expected_initialized_config: Dict[(str, bool)] +): + assert ( + train_utils.init_split_entities( + split_entities_config, SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE + ) + == expected_initialized_config + ) + + +@pytest.mark.parametrize( + "component_config, raises_exception", + [ + ({MODEL_CONFIDENCE: SOFTMAX, LOSS_TYPE: MARGIN}, True), + ({MODEL_CONFIDENCE: SOFTMAX, LOSS_TYPE: CROSS_ENTROPY}, False), + ({MODEL_CONFIDENCE: INNER, LOSS_TYPE: MARGIN}, True), + ({MODEL_CONFIDENCE: INNER, LOSS_TYPE: CROSS_ENTROPY}, True), + ({MODEL_CONFIDENCE: COSINE, LOSS_TYPE: MARGIN}, True), + ({MODEL_CONFIDENCE: COSINE, LOSS_TYPE: CROSS_ENTROPY}, True), + ], +) +def test_confidence_loss_settings( + component_config: Dict[Text, Any], raises_exception: bool +): + component_config[SIMILARITY_TYPE] = INNER + if raises_exception: + with pytest.raises(InvalidConfigException): + train_utils._check_confidence_setting(component_config) + else: + train_utils._check_confidence_setting(component_config) + + +@pytest.mark.parametrize( + "component_config, raises_exception", + [ + ({MODEL_CONFIDENCE: SOFTMAX, SIMILARITY_TYPE: INNER}, False), + ({MODEL_CONFIDENCE: SOFTMAX, SIMILARITY_TYPE: COSINE}, True), + ], +) +def test_confidence_similarity_settings( + component_config: Dict[Text, Any], raises_exception: bool +): + component_config[LOSS_TYPE] = CROSS_ENTROPY + if raises_exception: + with pytest.raises(InvalidConfigException): + train_utils._check_confidence_setting(component_config) + else: + train_utils._check_confidence_setting(component_config) + + +@pytest.mark.parametrize( + "component_config, raises_exception", + [ + ( + { + MODEL_CONFIDENCE: SOFTMAX, + SIMILARITY_TYPE: INNER, + RENORMALIZE_CONFIDENCES: True, + RANKING_LENGTH: 10, + }, + False, + ), + ( + { + MODEL_CONFIDENCE: SOFTMAX, + SIMILARITY_TYPE: INNER, + RENORMALIZE_CONFIDENCES: False, + RANKING_LENGTH: 10, + }, + False, + ), + ( + { + MODEL_CONFIDENCE: AUTO, + SIMILARITY_TYPE: INNER, + RENORMALIZE_CONFIDENCES: True, + RANKING_LENGTH: 10, + }, + True, + ), + ( + { + MODEL_CONFIDENCE: AUTO, + SIMILARITY_TYPE: INNER, + RENORMALIZE_CONFIDENCES: False, + RANKING_LENGTH: 10, + }, + False, + ), + ], +) +def test_confidence_renormalization_settings( + component_config: Dict[Text, Any], raises_exception: bool +): + component_config[LOSS_TYPE] = CROSS_ENTROPY + if raises_exception: + with pytest.raises(InvalidConfigException): + train_utils._check_confidence_setting(component_config) + else: + train_utils._check_confidence_setting(component_config) + + +@pytest.mark.parametrize( + "component_config, model_confidence", + [ + ({MODEL_CONFIDENCE: SOFTMAX, LOSS_TYPE: MARGIN}, AUTO), + ({MODEL_CONFIDENCE: SOFTMAX, LOSS_TYPE: CROSS_ENTROPY}, SOFTMAX), + ], +) +def test_update_confidence_type( + component_config: Dict[Text, Text], model_confidence: Text +): + component_config = train_utils.update_confidence_type(component_config) + assert component_config[MODEL_CONFIDENCE] == model_confidence + + +@pytest.mark.parametrize( + "component_config, raises_exception", + [ + ({TOLERANCE: 0.5}, False), + ({TOLERANCE: 0.0}, False), + ({TOLERANCE: 1.0}, False), + ({TOLERANCE: -1.0}, True), + ({TOLERANCE: 2.0}, True), + ({}, False), + ], +) +def test_tolerance_setting(component_config: Dict[Text, float], raises_exception: bool): + if raises_exception: + with pytest.raises(InvalidConfigException): + train_utils._check_tolerance_setting(component_config) + else: + train_utils._check_tolerance_setting(component_config) + + +@pytest.mark.parametrize( + "component_config", + [ + ( + { + CHECKPOINT_MODEL: True, + EVAL_NUM_EPOCHS: -2, + EVAL_NUM_EXAMPLES: 10, + EPOCHS: 5, + } + ), + ( + { + CHECKPOINT_MODEL: True, + EVAL_NUM_EPOCHS: 0, + EVAL_NUM_EXAMPLES: 10, + EPOCHS: 5, + } + ), + ], +) +def test_warning_incorrect_eval_num_epochs(component_config: Dict[Text, Text]): + with pytest.warns(UserWarning) as record: + train_utils._check_evaluation_setting(component_config) + assert len(record) == 1 + assert ( + f"'{EVAL_NUM_EPOCHS}' is not -1 or greater than 0. Training will fail" + in record[0].message.args[0] + ) + + +@pytest.mark.parametrize( + "component_config", + [ + ({CHECKPOINT_MODEL: True, EVAL_NUM_EPOCHS: 10, EPOCHS: 5}), + ({CHECKPOINT_MODEL: False, EVAL_NUM_EPOCHS: 10, EPOCHS: 5}), + ], +) +def test_warning_eval_num_epochs_greater_than_epochs( + component_config: Dict[Text, Text] +): + warning = ( + f"'{EVAL_NUM_EPOCHS}={component_config[EVAL_NUM_EPOCHS]}' is " + f"greater than '{EPOCHS}={component_config[EPOCHS]}'." + f" No evaluation will occur." + ) + with pytest.warns(UserWarning) as record: + train_utils._check_evaluation_setting(component_config) + assert len(record) == 1 + if component_config[CHECKPOINT_MODEL]: + warning = ( + f"You have opted to save the best model, but {warning} " + "No checkpoint model will be saved." + ) + assert warning in record[0].message.args[0] + + +@pytest.mark.parametrize( + "component_config", + [ + ({CHECKPOINT_MODEL: True, EVAL_NUM_EPOCHS: 1, EVAL_NUM_EXAMPLES: 0, EPOCHS: 5}), + ( + { + CHECKPOINT_MODEL: True, + EVAL_NUM_EPOCHS: 1, + EVAL_NUM_EXAMPLES: -1, + EPOCHS: 5, + } + ), + ], +) +def test_warning_incorrect_eval_num_examples(component_config: Dict[Text, Text]): + with pytest.warns(UserWarning) as record: + train_utils._check_evaluation_setting(component_config) + assert len(record) == 1 + assert ( + f"'{EVAL_NUM_EXAMPLES}' is not greater than 0. No checkpoint model " + f"will be saved" + ) in record[0].message.args[0] + + +@pytest.mark.parametrize( + "component_config", + [ + ( + { + CHECKPOINT_MODEL: False, + EVAL_NUM_EPOCHS: 0, + EVAL_NUM_EXAMPLES: 0, + EPOCHS: 5, + } + ), + ( + { + CHECKPOINT_MODEL: True, + EVAL_NUM_EPOCHS: 1, + EVAL_NUM_EXAMPLES: 10, + EPOCHS: 5, + } + ), + ], +) +def test_no_warning_correct_checkpoint_setting(component_config: Dict[Text, Text]): + with pytest.warns(None) as record: + train_utils._check_evaluation_setting(component_config) + assert len(record) == 0 diff --git a/tests_deployment/.env.example b/tests_deployment/.env.example new file mode 100644 index 0000000..4b306bd --- /dev/null +++ b/tests_deployment/.env.example @@ -0,0 +1,7 @@ +# credentials for the Postgres database used in integration tests +PGUSER=postgres +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +# credentials for RabbitMQ used in integration tests +RABBITMQ_USER=guest +RABBITMQ_PASSWORD=guest diff --git a/tests_deployment/README.md b/tests_deployment/README.md new file mode 100644 index 0000000..fffdf69 --- /dev/null +++ b/tests_deployment/README.md @@ -0,0 +1,2 @@ +This directory contains setup for external resources like message brokers and databases which +are required in order to be able to run integration tests on our CI. diff --git a/tests_deployment/docker-compose.integration.yml b/tests_deployment/docker-compose.integration.yml new file mode 100644 index 0000000..a8d2418 --- /dev/null +++ b/tests_deployment/docker-compose.integration.yml @@ -0,0 +1,77 @@ +version: "3.8" + +services: + redis: + image: redis:6 + # Set health checks to wait until redis has started + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + ports: + - 6379:6379 + + postgres: + image: postgres:13 + # Set health checks to wait until postgres has started + healthcheck: + test: ["CMD", "pg_isready"] + interval: 10s + timeout: 5s + retries: 5 + environment: + # postgres image requires password to be set + PGUSER: ${PGUSER} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - 5432:5432 + + rabbitmq: + image: healthcheck/rabbitmq + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD} + ports: + - 5672:5672 + + zookeeper: + image: confluentinc/cp-zookeeper:7.0.5 + hostname: zookeeper + container_name: zookeeper + ports: + - "2181:2181" + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + + kafka: + image: confluentinc/cp-kafka:7.0.5 + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181' + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: SASL_PLAINTEXT:SASL_PLAINTEXT,SASL_PLAINTEXT_HOST:SASL_PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: SASL_PLAINTEXT://kafka:29092,SASL_PLAINTEXT_HOST://localhost:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + ZOOKEEPER_SASL_ENABLED: "false" + KAFKA_OPTS: "-Djava.security.auth.login.config=/etc/kafka/kafka_server_jaas.conf" + KAFKA_INTER_BROKER_LISTENER_NAME: SASL_PLAINTEXT + KAFKA_SASL_ENABLED_MECHANISMS: PLAIN + KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL: PLAIN + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_TOOLS_LOG4J_LOGLEVEL: ERROR + volumes: + - ${PWD}/kafka_server_jaas.conf:/etc/kafka/kafka_server_jaas.conf + + mongodb: + image: mongodb/mongodb-community-server:6.0.4-ubuntu2204 + ports: + - "27017:27017" diff --git a/tests_deployment/docker-compose.kafka.yml b/tests_deployment/docker-compose.kafka.yml new file mode 100644 index 0000000..84ab2a6 --- /dev/null +++ b/tests_deployment/docker-compose.kafka.yml @@ -0,0 +1,50 @@ +version: "3.8" + +services: + zookeeper: + image: confluentinc/cp-zookeeper:7.0.5 + hostname: zookeeper + container_name: zookeeper + ports: + - "2181:2181" + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + healthcheck: + test: nc -z localhost 2181 || exit 1 + interval: 10s + retries: 10 + start_period: 15s + timeout: 10s + + kafka: + image: confluentinc/cp-kafka:7.0.5 + restart: on-failure + depends_on: + zookeeper: + condition: service_healthy + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181' + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: SASL_PLAINTEXT:SASL_PLAINTEXT,SASL_PLAINTEXT_HOST:SASL_PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: SASL_PLAINTEXT://kafka:29092,SASL_PLAINTEXT_HOST://localhost:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + ZOOKEEPER_SASL_ENABLED: "false" + KAFKA_OPTS: "-Djava.security.auth.login.config=/etc/kafka/kafka_server_jaas.conf" + KAFKA_INTER_BROKER_LISTENER_NAME: SASL_PLAINTEXT + KAFKA_SASL_ENABLED_MECHANISMS: PLAIN + KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL: PLAIN + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_TOOLS_LOG4J_LOGLEVEL: ERROR + volumes: + - ${PWD}/tests_deployment/kafka_server_jaas.conf:/etc/kafka/kafka_server_jaas.conf + healthcheck: + test: nc -z localhost 9092 || exit 1 + interval: 30s + retries: 10 + start_period: 15s + timeout: 10s diff --git a/tests_deployment/kafka_server_jaas.conf b/tests_deployment/kafka_server_jaas.conf new file mode 100644 index 0000000..0c53e7a --- /dev/null +++ b/tests_deployment/kafka_server_jaas.conf @@ -0,0 +1,8 @@ +KafkaServer { + org.apache.kafka.common.security.plain.PlainLoginModule required + username="admin" + password="password" + user_admin="password"; +}; + +Client {}; diff --git a/trivy-secret.yaml b/trivy-secret.yaml new file mode 100644 index 0000000..3b37d4a --- /dev/null +++ b/trivy-secret.yaml @@ -0,0 +1,37 @@ +allow-rules: + - id: docs/docs/deploy/deploy-rasa.mdx + description: Example service account in docs + path: docs/docs/deploy/deploy-rasa.mdx + - id: tests/conftest.py + description: JWT private key used in unit testing + path: tests/conftest.py + - id: test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/ca-cert + description: CA certificate for test environments + path: test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/ca-cert + - id: test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/signed-server-cert + description: Signed broker certificate for test environments + path: test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/signed-server-cert + - id: test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/server.keystore.jks + description: Broker keystore for test environments + path: test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_all_conections/server.keystore.jks + - id: test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/ca-cert + description: CA certificate for test environments + path: test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/ca-cert + - id: test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/signed-server-cert + description: Signed broker certificate for test environments + path: test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/signed-server-cert + - id: test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/server.keystore.jks + description: Broker keystore for test environments + path: test_environments/message_and_event_brokers/kafka/sasl_plain/with_tls/ssl_localhost/server.keystore.jks + - id: test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/ca-cert + description: CA certificate for test environments + path: test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/ca-cert + - id: test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/server.keystore.jks + description: CA certificate for test environments + path: test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_256/ssl/server.keystore.jks + - id: test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/ca-cert + description: CA certificate for test environments + path: test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/ca-cert + - id: test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/server.keystore.jks + description: CA certificate for test environments + path: test_environments/message_and_event_brokers/kafka/sasl_scram/with_tls/scram_sha_512/ssl/server.keystore.jks